stdlib: Add beta simulate module to the gem5 stdlib

This module is used to semi-automate the running of gem5 simulation,
mostly by handling exit events automatically and removing instantiation
boilerplate code.

NOTE: This module is still in beta.

Change-Id: I4706119478464efcf4d92e3a1da05bddd0953b6a
Reviewed-on: https://gem5-review.googlesource.com/c/public/gem5/+/50753
Maintainer: Bobby Bruce <bbruce@ucdavis.edu>
Reviewed-by: Jason Lowe-Power <power.jg@gmail.com>
Tested-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Bobby Bruce <bbruce@ucdavis.edu>
diff --git a/configs/example/gem5_library/arm-hello.py b/configs/example/gem5_library/arm-hello.py
index 5a2f46c..b1f6f38 100644
--- a/configs/example/gem5_library/arm-hello.py
+++ b/configs/example/gem5_library/arm-hello.py
@@ -41,9 +41,6 @@
 ```
 """
 
-import m5
-from m5.objects import Root
-
 from gem5.isas import ISA
 from gem5.utils.requires import requires
 from gem5.resources.resource import Resource
@@ -52,6 +49,7 @@
 from gem5.components.boards.simple_board import SimpleBoard
 from gem5.components.cachehierarchies.classic.no_cache import NoCache
 from gem5.components.processors.simple_processor import SimpleProcessor
+from gem5.simulate.simulator import Simulator
 
 # This check ensures the gem5 binary is compiled to the ARM ISA target. If not,
 # an exception will be thrown.
@@ -89,12 +87,13 @@
     Resource("arm-hello64-static")
 )
 
-# Lastly we setup the root, instantiate the design, and run the simulation.
-root = Root(full_system=False, system=board)
+# Lastly we run the simulation.
+simulator = Simulator(board=board, full_system=False)
+simulator.run()
 
-m5.instantiate()
-
-exit_event = m5.simulate()
 print(
-    "Exiting @ tick {} because {}.".format(m5.curTick(), exit_event.getCause())
+    "Exiting @ tick {} because {}.".format(
+        simulator.get_current_tick(),
+        simulator.get_last_exit_event_cause(),
+    )
 )
diff --git a/configs/example/gem5_library/riscv-fs.py b/configs/example/gem5_library/riscv-fs.py
index 4d0a2c8..4c1f117 100644
--- a/configs/example/gem5_library/riscv-fs.py
+++ b/configs/example/gem5_library/riscv-fs.py
@@ -39,9 +39,6 @@
   password: `root`)
 """
 
-import m5
-from m5.objects import Root
-
 from gem5.components.boards.riscv_board import RiscvBoard
 from gem5.components.memory import SingleChannelDDR3_1600
 from gem5.components.processors.simple_processor import SimpleProcessor
@@ -53,6 +50,7 @@
 from gem5.isas import ISA
 from gem5.utils.requires import requires
 from gem5.resources.resource import Resource
+from gem5.simulate.simulator import Simulator
 
 # Run a check to ensure the right version of gem5 is being used.
 requires(isa_required=ISA.RISCV)
@@ -84,13 +82,10 @@
                    disk_image=Resource("riscv-disk-img"),
 )
 
-root = Root(full_system=True, system=board)
-
-m5.instantiate()
-
+simulator = Simulator(board=board)
 print("Beginning simulation!")
 # Note: This simulation will never stop. You can access the terminal upon boot
 # using m5term (`./util/term`): `./m5term localhost <port>`. Note the `<port>`
 # value is obtained from the gem5 terminal stdout. Look out for
 # "system.platform.terminal: Listening for connections on port <port>".
-exit_event = m5.simulate()
\ No newline at end of file
+simulator.run()
\ No newline at end of file
diff --git a/configs/example/gem5_library/x86-ubuntu-run-with-kvm.py b/configs/example/gem5_library/x86-ubuntu-run-with-kvm.py
index 630cb09..fa84960 100644
--- a/configs/example/gem5_library/x86-ubuntu-run-with-kvm.py
+++ b/configs/example/gem5_library/x86-ubuntu-run-with-kvm.py
@@ -35,14 +35,11 @@
 -----
 
 ```
-scons build/X86_MESI_Two_Level/gem5.opt
+scons build/X86/gem5.opt
 ./build/X86/gem5.opt configs/example/gem5_library/x86-ubuntu-run-with-kvm.py
 ```
 """
 
-import m5
-from m5.objects import Root
-
 from gem5.utils.requires import requires
 from gem5.components.boards.x86_board import X86Board
 from gem5.components.memory.single_channel import SingleChannelDDR3_1600
@@ -53,6 +50,8 @@
 from gem5.isas import ISA
 from gem5.coherence_protocol import CoherenceProtocol
 from gem5.resources.resource import Resource
+from gem5.simulate.simulator import Simulator
+from gem5.simulate.exit_event import ExitEvent
 
 # This runs a check to ensure the gem5 binary is compiled to X86 and to the
 # MESI Two Level coherence protocol.
@@ -126,19 +125,14 @@
     readfile_contents=command,
 )
 
-
-root = Root(full_system=True, system=board)
-root.sim_quantum = int(1e9)  # sim_quantum must be st if KVM cores are used.
-
-m5.instantiate()
-
-# This first stretch of the simulation runs using the KVM cores. In this setup
-# this will terminate until Ubuntu boot is complete.
-m5.simulate()
-
-# This will switch from the KVM cores to the Timing cores.
-processor.switch()
-
-# This final stretch of the simulation will be run using the Timing cores. In
-# this setup an echo statement will be executed prior to exiting.
-m5.simulate()
+simulator = Simulator(
+    board=board,
+    on_exit_event={
+        # Here we want override the default behavior for the first m5 exit
+        # exit event. Instead of exiting the simulator, we just want to
+        # switch the processor. The 2nd m5 exit after will revert to using
+        # default behavior where the simulator run will exit.
+        ExitEvent.EXIT : (func() for func in [processor.switch]),
+    },
+)
+simulator.run()
diff --git a/configs/example/gem5_library/x86-ubuntu-run.py b/configs/example/gem5_library/x86-ubuntu-run.py
index 622f4f3..c6f6f83 100644
--- a/configs/example/gem5_library/x86-ubuntu-run.py
+++ b/configs/example/gem5_library/x86-ubuntu-run.py
@@ -44,11 +44,10 @@
 ```
 """
 
-import m5
-from m5.objects import Root
-
-from gem5.resources.resource import Resource
 from gem5.prebuilt.demo.x86_demo_board import X86DemoBoard
+from gem5.resources.resource import Resource
+from gem5.simulate.simulator import Simulator
+
 
 # Here we setup the board. The prebuilt X86DemoBoard allows for Full-System X86
 # simulation.
@@ -62,6 +61,5 @@
     disk_image=Resource("x86-ubuntu-img"),
 )
 
-root = Root(full_system=True, system=board)
-m5.instantiate()
-m5.simulate()
+simulator = Simulator(board=board)
+simulator.run()
diff --git a/src/python/SConscript b/src/python/SConscript
index 1939100..984ae82 100644
--- a/src/python/SConscript
+++ b/src/python/SConscript
@@ -32,6 +32,10 @@
 PySource('gem5', 'gem5/coherence_protocol.py')
 PySource('gem5', 'gem5/isas.py')
 PySource('gem5', 'gem5/runtime.py')
+PySource('gem5.simulate', 'gem5/simulate/__init__.py')
+PySource('gem5.simulate', 'gem5/simulate/simulator.py')
+PySource('gem5.simulate', 'gem5/simulate/exit_event.py')
+PySource('gem5.simulate', 'gem5/simulate/exit_event_generators.py')
 PySource('gem5.components', 'gem5/components/__init__.py')
 PySource('gem5.components.boards', 'gem5/components/boards/__init__.py')
 PySource('gem5.components.boards', 'gem5/components/boards/abstract_board.py')
diff --git a/src/python/gem5/simulate/__init__.py b/src/python/gem5/simulate/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/python/gem5/simulate/__init__.py
diff --git a/src/python/gem5/simulate/exit_event.py b/src/python/gem5/simulate/exit_event.py
new file mode 100644
index 0000000..6dafc75
--- /dev/null
+++ b/src/python/gem5/simulate/exit_event.py
@@ -0,0 +1,86 @@
+# Copyright (c) 2021 The Regents of the University of California
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met: redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer;
+# redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution;
+# neither the name of the copyright holders nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from enum import Enum
+
+
+class ExitEvent(Enum):
+    """
+    An enum class holding all the supported simulator exit events.
+
+    The simulator will exit in certain conditions. The simulate package has
+    been designed to categorize these into sensible states of exit, listed
+    below.
+    """
+
+    EXIT = "exit"  # A standard vanilla exit.
+    WORKBEGIN = "workbegin"  # An exit because a ROI has been reached.
+    WORKEND = "workend"  # An exit because a ROI has ended.
+    SWITCHCPU = "switchcpu"  # An exit needed to switch CPU cores.
+    FAIL = "fail"  # An exit because the simulation has failed.
+    CHECKPOINT = "checkpoint"  # An exit to load a checkpoint.
+    MAX_TICK = "max tick" # An exit due to a maximum tick value being met.
+    USER_INTERRUPT = ( # An exit due to a user interrupt (e.g., cntr + c)
+        "user interupt"
+    )
+
+    @classmethod
+    def translate_exit_status(cls, exit_string: str) -> "ExitEvent":
+        """
+        This function will translate common exit strings to their correct
+        ExitEvent categorization.
+
+
+        **Note:** At present, we do not guarantee this list is complete, as
+        there are no bounds on what string may be returned by the simulator
+        given an exit event.
+        """
+
+        if exit_string == "m5_workbegin instruction encountered":
+            return ExitEvent.WORKBEGIN
+        elif exit_string == "workbegin":
+            return ExitEvent.WORKBEGIN
+        elif exit_string == "m5_workend instruction encountered":
+            return ExitEvent.WORKEND
+        elif exit_string == "workend":
+            return ExitEvent.WORKEND
+        elif exit_string == "m5_exit instruction encountered":
+            return ExitEvent.EXIT
+        elif exit_string == "exiting with last active thread context":
+            return ExitEvent.EXIT
+        elif exit_string == "simulate() limit reached":
+            return ExitEvent.MAX_TICK
+        elif exit_string == "switchcpu":
+            return ExitEvent.SWITCHCPU
+        elif exit_string == "m5_fail instruction encountered":
+            return ExitEvent.FAIL
+        elif exit_string == "checkpoint":
+            return ExitEvent.CHECKPOINT
+        elif exit_string == "user interrupt received":
+            return ExitEvent.USER_INTERRUPT
+        raise NotImplementedError(
+            "Exit event '{}' not implemented".format(exit_string)
+        )
diff --git a/src/python/gem5/simulate/exit_event_generators.py b/src/python/gem5/simulate/exit_event_generators.py
new file mode 100644
index 0000000..011bca6
--- /dev/null
+++ b/src/python/gem5/simulate/exit_event_generators.py
@@ -0,0 +1,76 @@
+# Copyright (c) 2021 The Regents of the University of California
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met: redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer;
+# redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution;
+# neither the name of the copyright holders nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import m5.stats
+from ..components.processors.abstract_processor import AbstractProcessor
+from ..components.processors.switchable_processor import SwitchableProcessor
+
+"""
+In this package we store generators for simulation exit events.
+"""
+
+
+def default_exit_generator():
+    """
+    A default generator for an exit event. It will return True, indicating that
+    the Simulator run loop should exit.
+    """
+    while True:
+        yield True
+
+
+def default_switch_generator(processor: AbstractProcessor):
+    """
+    A default generator for a switch exit event. If the processor is a
+    SwitchableProcessor, this generator will switch it. Otherwise nothing will
+    happen.
+    """
+    is_switchable = isinstance(processor, SwitchableProcessor)
+    while True:
+        if is_switchable:
+            yield processor.switch()
+        else:
+            yield False
+
+
+def default_workbegin_generator():
+    """
+    A default generator for a workbegin exit event. It will reset the
+    simulation statistics.
+    """
+    while True:
+        m5.stats.reset()
+        yield False
+
+
+def default_workend_generator():
+    """
+    A default generator for a workend exit event. It will dump the simulation
+    statistics.
+    """
+    while True:
+        m5.stats.dump()
+        yield False
diff --git a/src/python/gem5/simulate/simulator.py b/src/python/gem5/simulate/simulator.py
new file mode 100644
index 0000000..1645dfc
--- /dev/null
+++ b/src/python/gem5/simulate/simulator.py
@@ -0,0 +1,349 @@
+# Copyright (c) 2021 The Regents of the University of California
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met: redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer;
+# redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution;
+# neither the name of the copyright holders nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import m5
+import m5.ticks
+from m5.stats import addStatVisitor
+from m5.stats.gem5stats import get_simstat
+from m5.objects import Root
+from m5.util import warn
+
+import os
+from typing import Optional, List, Tuple, Dict, Generator, Union
+
+from .exit_event_generators import (
+    default_exit_generator,
+    default_switch_generator,
+    default_workbegin_generator,
+    default_workend_generator,
+)
+from .exit_event import ExitEvent
+from ..components.boards.abstract_board import AbstractBoard
+from ..components.processors.cpu_types import CPUTypes
+
+
+class Simulator:
+    """
+    This Simulator class is used to manage the execution of a gem5 simulation.
+
+    **Warning:** The simulate package is still in a beta state. The gem5
+    project does not guarantee the APIs within this package will remain
+    consistent in future across upcoming releases.
+
+    Example
+    -------
+    Examples using the Simulator class can be found under
+    `configs/example/gem5_library`.
+
+    The most basic run would be as follows:
+
+    ```
+    simulator = Simulator(board=board)
+    simulator.run()
+    ```
+
+    This will run a simulation and execute default behavior for exit events.
+    """
+
+    def __init__(
+        self,
+        board: AbstractBoard,
+        full_system: bool = True,
+        on_exit_event: Optional[
+            Dict[Union[str, ExitEvent], Generator[Optional[bool], None, None]]
+        ] = None,
+        expected_execution_order: Optional[List[ExitEvent]] = None,
+    ) -> None:
+        """
+        :param board: The board to be simulated.
+        :param full_system: Whether to run in full-system simulation or not. If
+        False, the simulation will run in Syscall-Execution mode. True by
+        default.
+        :param on_exit_event: An optional map to specify the generator to
+        execute on each exit event. The generator may yield a boolean which,
+        if True, will have the Simulator exit the run loop.
+        :param expected_execution_order: May be specified to check the exit
+        events come in a specified order. If the order specified is not
+        encountered (e.g., 'Workbegin', 'Workend', then 'Exit'), an Exception
+        is thrown. If this parameter is not specified, any ordering of exit
+        events is valid.
+
+        `on_exit_event` usage notes
+        ---------------------------
+
+        The `on_exit_event` parameter specifies a Python generator for each
+        exit event. `next(<generator>)` is run each time an exit event. The
+        generator may yield a boolean. If this value of this boolean is True
+        the Simulator run loop will exit, otherwise
+        the Simulator run loop will continue execution. If the generator has
+        finished (i.e. a `StopIteration` exception is thrown when
+        `next(<generator>)` is executed), then the default behavior for that
+        exit event is run.
+
+        As an example, a user may specify their own exit event setup like so:
+
+        ```
+        def unique_exit_event():
+            processor.switch()
+            yield False
+            m5.stats.dump()
+            yield False
+            yield True
+
+        simulator = Simulator(
+            board=board
+            on_exit_event = {
+                ExitEvent.Exit : unique_exit_event(),
+            },
+        )
+        ```
+
+        This will execute `processor.switch()` the first time an exit event is
+        encountered, will dump gem5 statistics the second time an exit event is
+        encountered, and will terminate the Simulator run loop the third time.
+
+        Each exit event has a default behavior if none is specified by the
+        user. These are as follows:
+
+            * ExitEvent.EXIT:  default_exit_list
+            * ExitEvent.CHECKPOINT: default_exit_list
+            * ExitEvent.FAIL : default_exit_list
+            * ExitEvent.SWITCHCPU: default_switch_list
+            * ExitEvent.WORKBEGIN: default_workbegin_list
+            * ExitEvent.WORKEND: default_workend_list
+            * ExitEvent.USER_INTERRUPT: default_exit_generator
+            * ExitEvent.MAX_TICK: default_exit_generator()
+
+        These generators can be found in the `exit_event_generator.py` module.
+
+        """
+
+        warn(
+            "The simulate package is still in a beta state. The gem5 "
+            "project does not guarantee the APIs within this package will "
+            "remain consistent across upcoming releases."
+        )
+
+        # We specify a dictionary here outlining the default behavior for each
+        # exit event. Each exit event is mapped to a generator.
+        self._default_on_exit_dict = {
+            ExitEvent.EXIT: default_exit_generator(),
+            # TODO: Something else should be done here for CHECKPOINT
+            ExitEvent.CHECKPOINT: default_exit_generator(),
+            ExitEvent.FAIL: default_exit_generator(),
+            ExitEvent.SWITCHCPU: default_switch_generator(
+                processor=board.get_processor()
+            ),
+            ExitEvent.WORKBEGIN: default_workbegin_generator(),
+            ExitEvent.WORKEND: default_workend_generator(),
+            ExitEvent.USER_INTERRUPT: default_exit_generator(),
+            ExitEvent.MAX_TICK: default_exit_generator(),
+        }
+
+        if on_exit_event:
+            self._on_exit_event = on_exit_event
+        else:
+            self._on_exit_event = self._default_on_exit_dict
+
+        self._instantiated = False
+        self._board = board
+        self._full_system = full_system
+        self._expected_execution_order = expected_execution_order
+        self._tick_stopwatch = []
+
+        self._last_exit_event = None
+        self._exit_event_count = 0
+
+    def get_stats(self) -> Dict:
+        """
+        Obtain the current simulation statistics as a Dictionary, conforming
+        to a JSON-style schema.
+
+        **Warning:** Will throw an Exception if called before `run()`. The
+        board must be initialized before obtaining statistics
+        """
+
+        if not self._instantiated:
+            raise Exception(
+                "Cannot obtain simulation statistics prior to inialization."
+            )
+
+        return get_simstat(self._root).to_json()
+
+    def add_text_stats_output(self, path: str) -> None:
+        """
+        This function is used to set an output location for text stats. If
+        specified, when stats are dumped they will be output to this location
+        as a text file file, in addition to any other stats' output locations
+        specified.
+
+        :param path: That path in which the file should be output to.
+        """
+        if not os.is_path_exists_or_creatable(path):
+            raise Exception(
+                f"Path '{path}' is is not a valid text stats output location."
+            )
+        addStatVisitor(path)
+
+    def add_json_stats_output(self, path: str) -> None:
+        """
+        This function is used to set an output location for JSON. If specified,
+        when stats are dumped they will be output to this location as a JSON
+        file, in addition to any other stats' output locations specified.
+
+        :param path: That path in which the JSON should be output to.
+        """
+        if not os.is_path_exists_or_creatable(path):
+            raise Exception(
+                f"Path '{path}' is is not a valid JSON output location."
+            )
+        addStatVisitor(f"json://{path}")
+
+    def get_last_exit_event_cause(self) -> str:
+        """
+        Returns the last exit event cause.
+        """
+        return self._last_exit_event.getCause()
+
+    def get_current_tick(self) -> int:
+        """
+        Returns the current tick.
+        """
+        return m5.curTick()
+
+    def get_tick_stopwatch(self) -> List[Tuple[ExitEvent, int]]:
+        """
+        Returns a list of tuples, which each tuple specifying an exit event
+        and the ticks at that event.
+        """
+        return self._tick_stopwatch
+
+    def get_roi_ticks(self) -> List[int]:
+        """
+        Returns a list of the tick counts for every ROI encountered (specified
+        as a region of code between a Workbegin and Workend exit event).
+        """
+        start = 0
+        to_return = []
+        for (exit_event, tick) in self._tick_stopwatch:
+            if exit_event == ExitEvent.WORKBEGIN:
+                start = tick
+            elif exit_event == ExitEvent.WORKEND:
+                to_return.append(tick - start)
+
+        return to_return
+
+    def _instantiate(self) -> None:
+        """
+        This method will instantiate the board and carry out necessary
+        boilerplate code before the instantiation such as setting up root and
+        setting the sim_quantum (if running in KVM mode).
+        """
+
+        if not self._instantiated:
+            root = Root(full_system=self._full_system, board=self._board)
+
+            # We take a copy of the Root in case it's required elsewhere
+            # (for example, in `get_stats()`).
+            self._root = root
+
+            if CPUTypes.KVM in [
+                core.get_type()
+                for core in self._board.get_processor().get_cores()
+            ]:
+                m5.ticks.fixGlobalFrequency()
+                root.sim_quantum = m5.ticks.fromSeconds(0.001)
+
+            m5.instantiate()
+            self._instantiated = True
+
+    def run(self, max_ticks: int = m5.MaxTick) -> None:
+        """
+        This function will start or continue the simulator run and handle exit
+        events accordingly.
+
+        :param max_ticks: The maximum number of ticks to execute per simulation
+        run. If this max_ticks value is met, a MAX_TICK exit event is
+        received, if another simulation exit event is met the tick count is
+        reset. This is the **maximum number of ticks per simululation run**.
+        """
+
+        # We instantiate the board if it has not already been instantiated.
+        self._instantiate()
+
+        # This while loop will continue until an a generator yields True.
+        while True:
+
+            self._last_exit_event = m5.simulate(max_ticks)
+
+            # Translate the exit event cause to the exit event enum.
+            exit_enum = ExitEvent.translate_exit_status(
+                self.get_last_exit_event_cause()
+            )
+
+            # Check to see the run is corresponding to the expected execution
+            # order (assuming this check is demanded by the user).
+            if self._expected_execution_order:
+                expected_enum = self._expected_execution_order[
+                    self._exit_event_count
+                ]
+                if exit_enum.value != expected_enum.value:
+                    raise Exception(
+                        f"Expected a '{expected_enum.value}' exit event but a "
+                        f"'{exit_enum.value}' exit event was encountered."
+                    )
+
+            # Record the current tick and exit event enum.
+            self._tick_stopwatch.append((exit_enum, self.get_current_tick()))
+
+            try:
+                # If the user has specified their own generator for this exit
+                # event, use it.
+                exit_on_completion = next(self._on_exit_event[exit_enum])
+            except StopIteration:
+                # If the user's generator has ended, throw a warning and use
+                # the default generator for this exit event.
+                warn(
+                    "User-specified generator for the exit event "
+                    f"'{exit_enum.value}' has ended. Using the default "
+                    "generator."
+                )
+                exit_on_completion = next(
+                    self._default_on_exit_dict[exit_enum]
+                )
+            except KeyError:
+                # If the user has not specified their own generator for this
+                # exit event, use the default.
+                exit_on_completion = next(
+                    self._default_on_exit_dict[exit_enum]
+                )
+
+            self._exit_event_count += 1
+
+            # If the generator returned True we will return from the Simulator
+            # run loop.
+            if exit_on_completion:
+                return
diff --git a/tests/gem5/configs/boot_kvm_switch_exit.py b/tests/gem5/configs/boot_kvm_switch_exit.py
index 5aa19f5..9f5f7ee 100644
--- a/tests/gem5/configs/boot_kvm_switch_exit.py
+++ b/tests/gem5/configs/boot_kvm_switch_exit.py
@@ -45,6 +45,8 @@
 from gem5.runtime import (
     get_runtime_coherence_protocol, get_runtime_isa
 )
+from gem5.simulate.simulator import Simulator
+from gem5.simulate.exit_event import ExitEvent
 from gem5.utils.requires import requires
 
 parser = argparse.ArgumentParser(
@@ -201,25 +203,24 @@
 print("Running with protocol: " + get_runtime_coherence_protocol().name)
 print()
 
-root = Root(full_system=True, system=motherboard)
+simulator = Simulator(
+    board=motherboard,
+    on_exit_event={
+        # When we reach the first exit, we switch cores. For the second exit we
+        # simply exit the simulation (default behavior).
+        ExitEvent.EXIT : (i() for i in [processor.switch]),
+    },
+    # This parameter allows us to state the expected order-of-execution.
+    # That is, we expect two exit events. If anyother event is triggered, an
+    # exeception will be thrown.
+    expected_execution_order=[ExitEvent.EXIT, ExitEvent.EXIT],
+)
 
-root.sim_quantum = int(1e9)
+simulator.run()
 
-m5.instantiate()
-
-print("Booting!")
-exit_event = m5.simulate()
-if exit_event.getCause() != "m5_exit instruction encountered":
-    raise Exception("Expected exit instruction after boot!")
-
-print(f"Switching processors to {args.cpu}!")
-processor.switch()
-
-exit_event = m5.simulate()
-exit_cause = exit_event.getCause()
-
-if exit_cause != "m5_exit instruction encountered":
-    raise Exception(
-        f"Expected exit after switching processors, received: {exit_cause}"
+print(
+    "Exiting @ tick {} because {}.".format(
+        simulator.get_current_tick(),
+        simulator.get_last_exit_event_cause(),
     )
-print("Exiting @ tick {} because {}.".format(m5.curTick(), exit_cause))
+)
diff --git a/tests/gem5/configs/parsec_disk_run.py b/tests/gem5/configs/parsec_disk_run.py
index 1315c58..a5cf41d 100644
--- a/tests/gem5/configs/parsec_disk_run.py
+++ b/tests/gem5/configs/parsec_disk_run.py
@@ -35,10 +35,7 @@
 * This will only function for the X86 ISA.
 """
 
-import m5
-import m5.ticks
-from m5.objects import Root
-
+import m5.stats
 
 from gem5.resources.resource import Resource
 from gem5.components.boards.x86_board import X86Board
@@ -48,10 +45,9 @@
 )
 from gem5.components.processors.cpu_types import CPUTypes
 from gem5.isas import ISA
-from gem5.runtime import (
-    get_runtime_isa,
-    get_runtime_coherence_protocol,
-)
+from gem5.runtime import get_runtime_isa, get_runtime_coherence_protocol
+from gem5.simulate.simulator import Simulator
+from gem5.simulate.exit_event import ExitEvent
 from gem5.utils.requires import requires
 
 import time
@@ -220,8 +216,6 @@
     + "parsecmgmt -a run -p {} ".format(args.benchmark)
     + "-c gcc-hooks -i {} ".format(args.size)
     + "-n {}\n".format(str(args.num_cpus))
-    + "sleep 5 \n"
-    + "m5 exit \n"
 )
 
 board.set_kernel_disk_workload(
@@ -240,103 +234,44 @@
 print("Running with protocol: " + get_runtime_coherence_protocol().name)
 print()
 
-root = Root(full_system=True, system=board)
 
-if args.cpu == "kvm" or args.boot_cpu == "kvm":
-    # TODO: This of annoying. Is there a way to fix this to happen
-    # automatically when running KVM?
-    root.sim_quantum = int(1e9)
+# Here we define some custom workbegin/workend exit event generators. Here we
+# want to switch to detailed CPUs at the beginning of the ROI, then continue to
+# the end of of the ROI. Then we exit the simulation.
+def workbegin():
+    processor.switch()
+    yield False
 
-m5.instantiate()
+def workend():
+    yield True
 
-globalStart = time.time()
-print("Beginning the simulation")
+simulator = Simulator(
+    board=board,
+    on_exit_event={
+        ExitEvent.WORKBEGIN : workbegin(),
+        ExitEvent.WORKEND: workend(),
+    },
+)
 
-start_tick = m5.curTick()
-end_tick = m5.curTick()
+global_start = time.time()
+simulator.run()
+global_end = time.time()
+global_time = global_end - global_start
 
-m5.stats.reset()
+roi_ticks = simulator.get_roi_ticks()
+assert len(roi_ticks) == 1
 
-exit_event = m5.simulate()
-
-if exit_event.getCause() == "workbegin":
-    print("Done booting Linux")
-    # Reached the start of ROI.
-    # The start of the ROI is marked by an m5_work_begin() call.
-    print("Resetting stats at the start of ROI!")
-    m5.stats.reset()
-    start_tick = m5.curTick()
-
-    # Switch to the Timing Processor.
-    board.get_processor().switch()
-else:
-    print("Unexpected termination of simulation!")
-    print("Cause: {}".format(exit_event.getCause()))
-    print()
-
-    m5.stats.dump()
-    end_tick = m5.curTick()
-
-    m5.stats.reset()
-    print("Performance statistics:")
-    print("Simulated time: {}s".format((end_tick - start_tick) / 1e12))
-    print("Ran a total of", m5.curTick() / 1e12, "simulated seconds")
-    print(
-        "Total wallclock time: {}s, {} min".format(
-            (
-                time.time() - globalStart,
-                (time.time() - globalStart) / 60,
-            )
-        )
-    )
-    exit(1)
-
-# Simulate the ROI.
-exit_event = m5.simulate()
-
-if exit_event.getCause() == "workend":
-    # Reached the end of ROI
-    # The end of the ROI is marked by an m5_work_end() call.
-    print("Dumping stats at the end of the ROI!")
-    m5.stats.dump()
-    end_tick = m5.curTick()
-
-    m5.stats.reset()
-
-    # Switch back to the Atomic Processor
-    board.get_processor().switch()
-else:
-    print("Unexpected termination of simulation!")
-    print("Cause: {}".format(exit_event.getCause()))
-    print()
-    m5.stats.dump()
-    end_tick = m5.curTick()
-
-    m5.stats.reset()
-    print("Performance statistics:")
-    print("Simulated time: {}s".format((end_tick - start_tick) / 1e12))
-    print("Ran a total of", m5.curTick() / 1e12, "simulated seconds")
-    print(
-        "Total wallclock time: {}s, {} min".format(
-            time.time() - globalStart,
-            (time.time() - globalStart) / 60,
-        )
-    )
-    exit(1)
-
-# Simulate the remaning part of the benchmark
-# Run the rest of the workload until m5 exit
-
-exit_event = m5.simulate()
 
 print("Done running the simulation")
 print()
 print("Performance statistics:")
 
-print("Simulated time in ROI: {}s".format((end_tick - start_tick) / 1e12))
-print("Ran a total of {} simulated seconds".format(m5.curTick() / 1e12))
+print("Simulated time in ROI: {}s".format((roi_ticks[0]) / 1e12))
 print(
-    "Total wallclock time: {}s, {} min".format(
-        time.time() - globalStart, (time.time() - globalStart) / 60
+    "Ran a total of {} simulated seconds".format(
+        simulator.get_current_tick() / 1e12
     )
 )
+print(
+    "Total wallclock time: {}s, {} min".format(global_time, (global_time) / 60)
+)
diff --git a/tests/gem5/configs/riscv_boot_exit_run.py b/tests/gem5/configs/riscv_boot_exit_run.py
index cf6d0f7..6420542 100644
--- a/tests/gem5/configs/riscv_boot_exit_run.py
+++ b/tests/gem5/configs/riscv_boot_exit_run.py
@@ -33,15 +33,13 @@
 * Runs exclusively on the RISC-V ISA with the classic caches
 """
 
-import m5
-from m5.objects import Root
-
 from gem5.isas import ISA
 from gem5.utils.requires import requires
 from gem5.resources.resource import Resource
 from gem5.components.processors.cpu_types import CPUTypes
 from gem5.components.boards.riscv_board import RiscvBoard
 from gem5.components.processors.simple_processor import SimpleProcessor
+from gem5.simulate.simulator import Simulator
 
 import argparse
 import importlib
@@ -168,14 +166,16 @@
     ),
 )
 
-root = Root(full_system=True, system=board)
-
-m5.instantiate()
+simulator = Simulator(board=board)
 
 if args.tick_exit:
-    exit_event = m5.simulate(args.tick_exit)
+    simulator.run(max_ticks = args.tick_exit)
 else:
-    exit_event = m5.simulate()
+    simulator.run()
+
 print(
-    "Exiting @ tick {} because {}.".format(m5.curTick(), exit_event.getCause())
-)
+    "Exiting @ tick {} because {}.".format(
+        simulator.get_current_tick(),
+        simulator.get_last_exit_event_cause(),
+    )
+)
\ No newline at end of file
diff --git a/tests/gem5/configs/simple_binary_run.py b/tests/gem5/configs/simple_binary_run.py
index fa4faa0..3a44602 100644
--- a/tests/gem5/configs/simple_binary_run.py
+++ b/tests/gem5/configs/simple_binary_run.py
@@ -30,15 +30,13 @@
 gem5 while still being functinal.
 """
 
-import m5
-from m5.objects import Root
-
 from gem5.resources.resource import Resource
 from gem5.components.processors.cpu_types import CPUTypes
 from gem5.components.memory import SingleChannelDDR3_1600
 from gem5.components.boards.simple_board import SimpleBoard
 from gem5.components.cachehierarchies.classic.no_cache import NoCache
 from gem5.components.processors.simple_processor import SimpleProcessor
+from gem5.simulate.simulator import Simulator
 
 import argparse
 
@@ -98,16 +96,13 @@
         resource_directory=args.resource_directory)
 motherboard.set_se_binary_workload(binary)
 
-root = Root(full_system=False, system=motherboard)
+# Run the simulation
+simulator = Simulator(board=motherboard, full_system=False)
+simulator.run()
 
-if args.cpu == "kvm":
-    # TODO: This of annoying. Is there a way to fix this to happen
-    # automatically when running KVM?
-    root.sim_quantum = int(1e9)
-
-m5.instantiate()
-
-exit_event = m5.simulate()
 print(
-    "Exiting @ tick {} because {}.".format(m5.curTick(), exit_event.getCause())
+    "Exiting @ tick {} because {}.".format(
+        simulator.get_current_tick(),
+        simulator.get_last_exit_event_cause(),
+    )
 )
diff --git a/tests/gem5/configs/x86_boot_exit_run.py b/tests/gem5/configs/x86_boot_exit_run.py
index 217a823..5c8b025 100644
--- a/tests/gem5/configs/x86_boot_exit_run.py
+++ b/tests/gem5/configs/x86_boot_exit_run.py
@@ -29,7 +29,6 @@
 """
 
 import m5
-from m5.objects import Root
 
 from gem5.runtime import (
     get_runtime_coherence_protocol,
@@ -42,6 +41,7 @@
 from gem5.components.boards.x86_board import X86Board
 from gem5.components.processors.cpu_types import CPUTypes
 from gem5.components.processors.simple_processor import SimpleProcessor
+from gem5.simulate.simulator import Simulator
 
 import argparse
 import importlib
@@ -220,20 +220,17 @@
 print("Running with protocol: " + get_runtime_coherence_protocol().name)
 print()
 
-root = Root(full_system=True, system=motherboard)
-
-if args.cpu == "kvm":
-    # TODO: This of annoying. Is there a way to fix this to happen
-    # automatically when running KVM?
-    root.sim_quantum = int(1e9)
-
-m5.instantiate()
-
 print("Beginning simulation!")
-if args.tick_exit != None:
-    exit_event = m5.simulate(args.tick_exit)
+simulator = Simulator(board=motherboard)
+
+if args.tick_exit:
+    simulator.run(max_ticks = args.tick_exit)
 else:
-    exit_event = m5.simulate()
+    simulator.run()
+
 print(
-    "Exiting @ tick {} because {}.".format(m5.curTick(), exit_event.getCause())
-)
+    "Exiting @ tick {} because {}.".format(
+        simulator.get_current_tick(),
+        simulator.get_last_exit_event_cause(),
+    )
+)
\ No newline at end of file