amaranth/nmigen/sim/core.py
whitequark b65e11f38f sim: split into base, core, and engines.
Before this commit, each simulation engine (which is only pysim at
the moment, but also cxxsim soon) was a subclass of SimulatorCore,
and every simulation engine module would essentially duplicate
the complete structure of a simulator, with code partially shared.

This was a really bad idea: it was inconvenient to use, with
downstream code having to branch between e.g. PySettle and CxxSettle;
it had no well-defined external interface; it had multiple virtually
identical entry points; and it had no separation between simulation
algorithms and glue code.

This commit completely rearranges simulation code.
  1. sim._base defines internal simulation interfaces. The clarity of
     these internal interfaces is important because simulation
     engines mix and match components to provide a consistent API
     regardless of the chosen engine.
  2. sim.core defines the external simulation interface: the commands
     and the simulator facade. The facade provides a single entry
     point and, when possible, validates or lowers user input.
     It also imports built-in simulation engines by their symbolic
     name, avoiding eager imports of pyvcd or ctypes.
  3. sim.xxxsim (currently, only sim.pysim) defines the simulator
     implementation: time and state management, process scheduling,
     and waveform dumping.

The new simulator structure has none of the downsides of the old one.

See #324.
2020-08-27 11:52:31 +00:00

207 lines
7.4 KiB
Python

import inspect
from .._utils import deprecated
from ..hdl.cd import *
from ..hdl.ir import *
from ._base import BaseEngine
__all__ = ["Settle", "Delay", "Tick", "Passive", "Active", "Simulator"]
class Command:
pass
class Settle(Command):
def __repr__(self):
return "(settle)"
class Delay(Command):
def __init__(self, interval=None):
self.interval = None if interval is None else float(interval)
def __repr__(self):
if self.interval is None:
return "(delay ε)"
else:
return "(delay {:.3}us)".format(self.interval * 1e6)
class Tick(Command):
def __init__(self, domain="sync"):
if not isinstance(domain, (str, ClockDomain)):
raise TypeError("Domain must be a string or a ClockDomain instance, not {!r}"
.format(domain))
assert domain != "comb"
self.domain = domain
def __repr__(self):
return "(tick {})".format(self.domain)
class Passive(Command):
def __repr__(self):
return "(passive)"
class Active(Command):
def __repr__(self):
return "(active)"
class Simulator:
def __init__(self, fragment, *, engine="pysim"):
if isinstance(engine, type) and issubclass(engine, BaseEngine):
pass
elif engine == "pysim":
from .pysim import PySimEngine
engine = PySimEngine
else:
raise TypeError("Value '{!r}' is not a simulation engine class or "
"a simulation engine name"
.format(engine))
self._fragment = Fragment.get(fragment, platform=None).prepare()
self._engine = engine(self._fragment)
self._clocked = set()
def _check_process(self, process):
if not (inspect.isgeneratorfunction(process) or inspect.iscoroutinefunction(process)):
raise TypeError("Cannot add a process {!r} because it is not a generator function"
.format(process))
return process
def add_process(self, process):
process = self._check_process(process)
def wrapper():
# Only start a bench process after comb settling, so that the reset values are correct.
yield Settle()
yield from process()
self._engine.add_coroutine_process(wrapper, default_cmd=None)
def add_sync_process(self, process, *, domain="sync"):
process = self._check_process(process)
def wrapper():
# Only start a sync process after the first clock edge (or reset edge, if the domain
# uses an asynchronous reset). This matches the behavior of synchronous FFs.
yield Tick(domain)
yield from process()
self._engine.add_coroutine_process(wrapper, default_cmd=Tick(domain))
def add_clock(self, period, *, phase=None, domain="sync", if_exists=False):
"""Add a clock process.
Adds a process that drives the clock signal of ``domain`` at a 50% duty cycle.
Arguments
---------
period : float
Clock period. The process will toggle the ``domain`` clock signal every ``period / 2``
seconds.
phase : None or float
Clock phase. The process will wait ``phase`` seconds before the first clock transition.
If not specified, defaults to ``period / 2``.
domain : str or ClockDomain
Driven clock domain. If specified as a string, the domain with that name is looked up
in the root fragment of the simulation.
if_exists : bool
If ``False`` (the default), raise an error if the driven domain is specified as
a string and the root fragment does not have such a domain. If ``True``, do nothing
in this case.
"""
if isinstance(domain, ClockDomain):
pass
elif domain in self._fragment.domains:
domain = self._fragment.domains[domain]
elif if_exists:
return
else:
raise ValueError("Domain {!r} is not present in simulation"
.format(domain))
if domain in self._clocked:
raise ValueError("Domain {!r} already has a clock driving it"
.format(domain.name))
if phase is None:
# By default, delay the first edge by half period. This causes any synchronous activity
# to happen at a non-zero time, distinguishing it from the reset values in the waveform
# viewer.
phase = period / 2
self._engine.add_clock_process(domain.clk, phase=phase, period=period)
self._clocked.add(domain)
def reset(self):
"""Reset the simulation.
Assign the reset value to every signal in the simulation, and restart every user process.
"""
self._engine.reset()
# TODO(nmigen-0.4): replace with _real_step
@deprecated("instead of `sim.step()`, use `sim.advance()`")
def step(self):
return self.advance()
def advance(self):
"""Advance the simulation.
Run every process and commit changes until a fixed point is reached, then advance time
to the closest deadline (if any). If there is an unstable combinatorial loop,
this function will never return.
Returns ``True`` if there are any active processes, ``False`` otherwise.
"""
return self._engine.advance()
def run(self):
"""Run the simulation while any processes are active.
Processes added with :meth:`add_process` and :meth:`add_sync_process` are initially active,
and may change their status using the ``yield Passive()`` and ``yield Active()`` commands.
Processes compiled from HDL and added with :meth:`add_clock` are always passive.
"""
while self.advance():
pass
def run_until(self, deadline, *, run_passive=False):
"""Run the simulation until it advances to ``deadline``.
If ``run_passive`` is ``False``, the simulation also stops when there are no active
processes, similar to :meth:`run`. Otherwise, the simulation will stop only after it
advances to or past ``deadline``.
If the simulation stops advancing, this function will never return.
"""
assert self._engine.now <= deadline
while (self.advance() or run_passive) and self._engine.now < deadline:
pass
def write_vcd(self, vcd_file, gtkw_file=None, *, traces=()):
"""Write waveforms to a Value Change Dump file, optionally populating a GTKWave save file.
This method returns a context manager. It can be used as: ::
sim = Simulator(frag)
sim.add_clock(1e-6)
with sim.write_vcd("dump.vcd", "dump.gtkw"):
sim.run_until(1e-3)
Arguments
---------
vcd_file : str or file-like object
Verilog Value Change Dump file or filename.
gtkw_file : str or file-like object
GTKWave save file or filename.
traces : iterable of Signal
Signals to display traces for.
"""
if self._engine.now != 0.0:
for file in (vcd_file, gtkw_file):
if hasattr(file, "close"):
file.close()
raise ValueError("Cannot start writing waveforms after advancing simulation time")
return self._engine.write_vcd(vcd_file=vcd_file, gtkw_file=gtkw_file, traces=traces)