CIRCT 24.0.0git
Loading...
Searching...
No Matches
simulator.py
Go to the documentation of this file.
1# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
2# See https://llvm.org/LICENSE.txt for license information.
3# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4
5import json
6import os
7import re
8import shutil
9import signal
10import socket
11import subprocess
12import time
13from pathlib import Path
14from typing import Dict, List, Optional, Callable, IO, Union
15import threading
16
17_thisdir = Path(__file__).parent
18CosimCollateralDir = _thisdir
19_SUPPORTED_SIMULATORS = ("verilator", "questa")
20
21
22def is_port_open(port) -> bool:
23 """Check if a TCP port is open locally."""
24 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
25 result = sock.connect_ex(('127.0.0.1', port))
26 sock.close()
27 return True if result == 0 else False
28
29
30def supported_simulators() -> List[str]:
31 """Return the simulator backends known to the ESI cosim runtime."""
32 return list(_SUPPORTED_SIMULATORS)
33
34
35def is_simulator_available(name: str) -> bool:
36 """Return True if the requested simulator backend is usable.
37
38 This checks the executable and environment conventions used by the Python
39 cosim backends, so pytest callers can skip simulator-backed tests before
40 generating or compiling hardware collateral.
41 """
42 name = name.lower()
43 if name == "verilator":
44 from .verilator import Verilator
45 if Verilator._find_verilator_bin() is None:
46 return False
47 return Verilator._find_verilator_root() is not None
48 if name == "questa":
49 return shutil.which("vsim") is not None
50 raise ValueError(f"Unknown simulator: {name}")
51
52
53def available_simulators() -> List[str]:
54 """Return the known simulator backends available in this environment."""
55 return [
56 name for name in _SUPPORTED_SIMULATORS if is_simulator_available(name)
57 ]
58
59
61
62 def __init__(self, top: str) -> None:
63 # User source files.
64 self.user: List[Path] = []
65 # DPI shared objects.
66 self.dpi_so: List[str] = ["EsiCosimDpiServer"]
67 # DPI SV files.
68 self.dpi_sv: List[Path] = [
69 CosimCollateralDir / "Cosim_DpiPkg.sv",
70 CosimCollateralDir / "Cosim_Endpoint.sv",
71 CosimCollateralDir / "Cosim_CycleCount.sv",
72 CosimCollateralDir / "Cosim_Manifest.sv",
73 ]
74 # Name of the top module.
75 self.top = top
76
77 def add_file(self, file: Path):
78 """Add a single RTL file to the source list."""
79 if file.is_file():
80 self.user.append(file)
81 else:
82 raise FileNotFoundError(f"File {file} does not exist")
83
84 def add_dir(self, dir: Path):
85 """Add all the RTL files in a directory to the source list. Files named in
86 the directory's `filelist.f` (as written by ExportVerilog) are added first,
87 in the listed order, since some simulators require dependencies such as
88 packages to be compiled before their users."""
89 listed: List[Path] = []
90 filelist = dir / "filelist.f"
91 if filelist.is_file():
92 for line in filelist.read_text().splitlines():
93 line = line.strip()
94 if line and (dir / line).is_file():
95 listed.append(dir / line)
96 for file in listed + sorted(dir.iterdir()):
97 if file in self.user:
98 continue
99 if file.is_file() and (file.suffix == ".sv" or file.suffix == ".v"):
100 self.user.append(file)
101 elif file.is_dir():
102 self.add_dir(file)
103
104 def dpi_so_paths(self) -> List[Path]:
105 """Return a list of all the DPI shared object files (the loadable
106 artifact: ``.so`` on POSIX, ``.dll`` on Windows)."""
107 return [self._find_dpi(name, link=False) for name in self.dpi_so]
108
109 def dpi_link_paths(self) -> List[Path]:
110 """Return a list of files to pass to the linker for the DPI libraries.
111 On POSIX this is the same as ``dpi_so_paths()``; on Windows it is the
112 import library (``.lib``) sitting next to the DLL."""
113 return [self._find_dpi(name, link=True) for name in self.dpi_so]
114
115 def _find_dpi(self, name: str, link: bool) -> Path:
116 is_windows = os.name == "nt"
117
118 def check_path(p: Path) -> Optional[Path]:
119 if is_windows:
120 suffix = ".lib" if link else ".dll"
121 cand = p / f"{name}{suffix}"
122 else:
123 cand = p / f"lib{name}.so"
124 return cand if cand.exists() else None
125
126 env = Simulator.get_env()
127 if is_windows:
128 search_env = env.get("PATH", "")
129 env_sep = os.pathsep
130 else:
131 search_env = env.get("LD_LIBRARY_PATH", "")
132 env_sep = ":"
133 for path in search_env.split(env_sep):
134 if not path:
135 continue
136 p = check_path(Path(path))
137 if p is not None:
138 return p
139
140 # Check a few directories relative to this file. The build tree puts
141 # libraries under ``<package>/../lib`` and ``<package>/../../lib``; wheel
142 # installs put them directly in the esiaccel package dir.
143 for candidate in (
144 _thisdir.parent,
145 _thisdir.parent / "lib",
146 _thisdir.parent.parent / "lib",
147 ):
148 p = check_path(candidate)
149 if p is not None:
150 return p
151
152 suffix = (".lib" if link else ".dll") if is_windows else ".so"
153 raise FileNotFoundError(f"Could not find {name}{suffix}")
154
155 @property
156 def rtl_sources(self) -> List[Path]:
157 """Return a list of all the RTL source files."""
158 return self.dpi_sv + self.user
159
160
162
163 def __init__(self,
164 proc: subprocess.Popen,
165 port: int,
166 threads: Optional[List[threading.Thread]] = None,
167 gui: bool = False):
168 self.proc = proc
169 self.port = port
170 self.threads: List[threading.Thread] = threads or []
171 self.gui = gui
172
173 def force_stop(self):
174 """Make sure to stop the simulation no matter what."""
175 if self.proc:
176 if os.name == "nt":
177 # The child was started with CREATE_NEW_PROCESS_GROUP, so CTRL_BREAK
178 # is delivered to that group only.
179 try:
180 self.proc.send_signal(signal.CTRL_BREAK_EVENT)
181 except (OSError, ValueError):
182 pass
183 else:
184 os.killpg(os.getpgid(self.proc.pid), signal.SIGINT)
185 # Allow the simulation time to flush its outputs.
186 try:
187 self.proc.wait(timeout=1.0)
188 except subprocess.TimeoutExpired:
189 # If the simulation doesn't exit of its own free will, kill it.
190 self.proc.kill()
191
192 # Join reader threads (they should exit once pipes are closed).
193 for t in self.threads:
194 t.join()
195
196
198
199 CompileCommand = List[str]
200 CompileFunction = Callable[[], Optional[int]]
201 CompileStep = Union[CompileCommand, CompileFunction]
202
203 # Some RTL simulators don't use stderr for error messages. Everything goes to
204 # stdout. Boo! They should feel bad about this. Also, they can specify that
205 # broken behavior by overriding this.
206 UsesStderr = True
207
208 def __init__(self,
209 sources: SourceFiles,
210 run_dir: Path,
211 debug: bool,
212 save_waveform: bool = False,
213 run_stdout_callback: Optional[Callable[[str], None]] = None,
214 run_stderr_callback: Optional[Callable[[str], None]] = None,
215 compile_stdout_callback: Optional[Callable[[str], None]] = None,
216 compile_stderr_callback: Optional[Callable[[str], None]] = None,
217 make_default_logs: bool = True,
218 macro_definitions: Optional[Dict[str, Optional[str]]] = None):
219 """Simulator base class.
220
221 Optional sinks can be provided for capturing output. If not provided,
222 the simulator will write to log files in `run_dir`.
223
224 Args:
225 sources: SourceFiles describing RTL/DPI inputs.
226 run_dir: Directory where build/run artifacts are placed.
227 debug: Enable cosim debug mode.
228 save_waveform: When True and debug=True, dump simulator waveforms to a
229 waveform file. The exact format depends on the backend (e.g. FST for
230 Verilator, VCD for Questa). Requires debug to be enabled.
231 run_stdout_callback: Line-based callback for runtime stdout.
232 run_stderr_callback: Line-based callback for runtime stderr.
233 compile_stdout_callback: Line-based callback for compile stdout.
234 compile_stderr_callback: Line-based callback for compile stderr.
235 make_default_logs: If True and corresponding callback is not supplied,
236 create log file and emit via internally-created callback.
237 macro_definitions: Optional dictionary of macro definitions to be defined
238 during compilation.
239 """
240 self.sources = sources
241 self.run_dir = run_dir
242 self.debug = debug
243 self.save_waveform = save_waveform
244 self.macro_definitions = macro_definitions
245
246 # Unified list of any log file handles we opened.
247 self._default_files: List[IO[str]] = []
248
249 def _ensure_default(cb: Optional[Callable[[str], None]], filename: str):
250 """Return (callback, file_handle_or_None) with optional file creation.
251
252 Behavior:
253 * If a callback is provided, return it unchanged with no file.
254 * If no callback and make_default_logs is False, return (None, None).
255 * If no callback and make_default_logs is True, create a log file and
256 return a writer callback plus the opened file handle.
257 """
258 if cb is not None:
259 return cb, None
260 if not make_default_logs:
261 return None, None
262 p = self.run_dir / filename
263 p.parent.mkdir(parents=True, exist_ok=True)
264 logf = p.open("w+")
265 self._default_files.append(logf)
266
267 def _writer(line: str, _lf=logf):
268 _lf.write(line + "\n")
269 _lf.flush()
270
271 return _writer, logf
272
273 # Initialize all four (compile/run stdout/stderr) uniformly.
274 self._compile_stdout_cb, self._compile_stdout_log = _ensure_default(
275 compile_stdout_callback, 'compile_stdout.log')
276 self._compile_stderr_cb, self._compile_stderr_log = _ensure_default(
277 compile_stderr_callback, 'compile_stderr.log')
278 self._run_stdout_cb, self._run_stdout_log = _ensure_default(
279 run_stdout_callback, 'sim_stdout.log')
280 self._run_stderr_cb, self._run_stderr_log = _ensure_default(
281 run_stderr_callback, 'sim_stderr.log')
282
283 @staticmethod
284 def get_env() -> Dict[str, str]:
285 """Get the environment variables to locate shared objects."""
286
287 env = os.environ.copy()
288 # Directories that may contain the ESI runtime / cosim DPI shared
289 # libraries (build tree and wheel install layouts).
290 lib_dirs = [
291 str(_thisdir.parent),
292 str(_thisdir.parent / "lib"),
293 str(_thisdir.parent.parent / "lib"),
294 ]
295 sep = os.pathsep
296 if os.name == "nt":
297 # Windows resolves DLL loads via PATH (and the loader's own search
298 # order). Make sure both the package dir (wheel layout) and any
299 # build-tree ``lib`` dirs are visible.
300 env["PATH"] = sep.join(lib_dirs) + sep + env.get("PATH", "")
301 else:
302 env["LIBRARY_PATH"] = env.get("LIBRARY_PATH",
303 "") + ":" + ":".join(lib_dirs)
304 env["LD_LIBRARY_PATH"] = env.get("LD_LIBRARY_PATH",
305 "") + ":" + ":".join(lib_dirs)
306 return env
307
308 def compile_commands(self) -> List[CompileStep]:
309 """Return the compile steps for the simulator.
310
311 Each step may be either a shell command (`List[str]`) or a Python callback
312 (`Callable[[], Optional[int]]`). Python callbacks should return `0` or
313 `None` on success and a non-zero integer on failure.
314 """
315 assert False, "Must be implemented by subclass"
316
317 def _run_compile_command(self, cmd: CompileCommand) -> int:
318 ret = self._start_process_with_callbacks(cmd,
319 env=Simulator.get_env(),
320 cwd=None,
321 stdout_cb=self._compile_stdout_cb,
322 stderr_cb=self._compile_stderr_cb,
323 wait=True)
324 if isinstance(ret, int) and ret != 0:
325 print("====== Compilation failure")
326
327 # Always print both stdout and stderr so that linker errors (which go
328 # to stdout for cmake/ninja) are not silently hidden.
329 if self._compile_stdout_log is not None:
330 self._compile_stdout_log.seek(0)
331 stdout_content = self._compile_stdout_log.read()
332 if stdout_content:
333 print(stdout_content)
334 if self._compile_stderr_log is not None:
335 self._compile_stderr_log.seek(0)
336 stderr_content = self._compile_stderr_log.read()
337 if stderr_content:
338 print(stderr_content)
339
340 return ret if isinstance(ret, int) else 1
341
342 def _run_compile_step(self, step: CompileStep) -> int:
343 if callable(step):
344 ret = step()
345 if ret is None:
346 return 0
347 if not isinstance(ret, int):
348 raise TypeError("compile step callback must return int or None")
349 return ret
350 return self._run_compile_command(step)
351
352 def compile(self) -> int:
353 cmds = self.compile_commands()
354 self.run_dir.mkdir(parents=True, exist_ok=True)
355 for step in cmds:
356 ret = self._run_compile_step(step)
357 if ret != 0:
358 return ret
359 return 0
360
361 def run_command(self, gui: bool) -> List[str]:
362 """Return the command to run the simulation."""
363 assert False, "Must be implemented by subclass"
364
365 @property
366 def waveform_extension(self) -> str:
367 """File extension for waveform dumps.
368
369 Subclasses should override if their format differs. The Verilator C++
370 driver writes FST (via ``VerilatedFstC``); the generic SV driver uses
371 ``$dumpfile/$dumpvars`` which produces VCD.
372 """
373 return ".vcd"
374
375 def run_proc(self, gui: bool = False) -> SimProcess:
376 """Run the simulation process. Returns the Popen object and the port which
377 the simulation is listening on.
378
379 If user-provided stdout/stderr sinks were supplied in the constructor,
380 they are used. Otherwise, log files are created in `run_dir`.
381 """
382 self.run_dir.mkdir(parents=True, exist_ok=True)
383
384 env_gui = os.environ.get("COSIM_GUI", "0")
385 if env_gui != "0":
386 gui = True
387
388 # Erase the config file if it exists. We don't want to read
389 # an old config.
390 portFileName = self.run_dir / "cosim.cfg"
391 if os.path.exists(portFileName):
392 os.remove(portFileName)
393
394 # Run the simulation.
395 simEnv = Simulator.get_env()
396 if self.debug:
397 debug_file = (self.run_dir / "cosim_debug.log").resolve()
398 simEnv["COSIM_DEBUG_FILE"] = str(debug_file)
399 if "DEBUG_PERIOD" not in simEnv:
400 # Slow the simulation down to one tick per millisecond.
401 simEnv["DEBUG_PERIOD"] = "1"
402 if self.save_waveform:
403 waveform_file = (self.run_dir /
404 f"cosim_waveform{self.waveform_extension}").resolve()
405 simEnv["SAVE_WAVE"] = str(waveform_file)
406 rcmd = self.run_command(gui)
407 # Start process with asynchronous output capture.
408 proc, threads = self._start_process_with_callbacks(
409 rcmd,
410 env=simEnv,
411 cwd=self.run_dir,
412 stdout_cb=self._run_stdout_cb,
413 stderr_cb=self._run_stderr_cb,
414 wait=False)
415
416 # Get the port which the simulation RPC selected.
417 checkCount = 0
418 while (not os.path.exists(portFileName)) and \
419 proc.poll() is None:
420 time.sleep(0.1)
421 checkCount += 1
422 if checkCount > 500 and not gui:
423 raise Exception(f"Cosim never wrote cfg file: {portFileName}")
424 port = -1
425 while port < 0:
426 portFile = open(portFileName, "r")
427 for line in portFile.readlines():
428 m = re.match("port: (\\d+)", line)
429 if m is not None:
430 port = int(m.group(1))
431 portFile.close()
432
433 # The cosim server writes ``cosim.cfg`` after its TCP listen socket is
434 # bound and the accept thread has been spawned. So we don't need to wait for
435 # the port to be opened.
436 return SimProcess(proc=proc, port=port, threads=threads, gui=gui)
437
439 self, cmd: List[str], env: Optional[Dict[str, str]], cwd: Optional[Path],
440 stdout_cb: Optional[Callable[[str],
441 None]], stderr_cb: Optional[Callable[[str],
442 None]],
443 wait: bool) -> int | tuple[subprocess.Popen, List[threading.Thread]]:
444 """Start a subprocess and stream its stdout/stderr to callbacks.
445
446 If wait is True, blocks until process completes and returns its exit code.
447 If wait is False, returns the Popen object (threads keep streaming).
448 """
449 if os.name == "posix":
450 proc = subprocess.Popen(cmd,
451 stdout=subprocess.PIPE,
452 stderr=subprocess.PIPE,
453 env=env,
454 cwd=cwd,
455 text=True,
456 preexec_fn=os.setsid)
457 else: # windows
458 proc = subprocess.Popen(cmd,
459 stdout=subprocess.PIPE,
460 stderr=subprocess.PIPE,
461 env=env,
462 cwd=cwd,
463 text=True,
464 creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
465
466 def _reader(pipe, cb):
467 if pipe is None:
468 return
469 for raw in pipe:
470 if raw.endswith('\n'):
471 raw = raw[:-1]
472 if cb:
473 try:
474 cb(raw)
475 except Exception as e:
476 print(f"Exception in simulator output callback: {e}")
477
478 threads: List[threading.Thread] = [
479 threading.Thread(target=_reader,
480 args=(proc.stdout, stdout_cb),
481 daemon=True),
482 threading.Thread(target=_reader,
483 args=(proc.stderr, stderr_cb),
484 daemon=True),
485 ]
486 for t in threads:
487 t.start()
488 if wait:
489 for t in threads:
490 t.join()
491 return proc.wait()
492 return proc, threads
493
494 def run(self,
495 inner_command: str,
496 gui: bool = False,
497 server_only: bool = False) -> int:
498 """Start the simulation then run the command specified. Kill the simulation
499 when the command exits."""
500
501 # 'simProc' is accessed in the finally block. Declare it here to avoid
502 # syntax errors in that block.
503 simProc = None
504 try:
505 simProc = self.run_proc(gui=gui)
506 if server_only:
507 # wait for user input to kill the server
508 input(
509 f"Running in server-only mode on port {simProc.port} - Press anything to kill the server..."
510 )
511 return 0
512 else:
513 # Run the inner command, passing the connection info via environment vars.
514 testEnv = os.environ.copy()
515 testEnv["ESI_COSIM_PORT"] = str(simProc.port)
516 testEnv["ESI_COSIM_HOST"] = "localhost"
517 ret = subprocess.run(inner_command, cwd=os.getcwd(),
518 env=testEnv).returncode
519 if simProc.gui:
520 print("GUI mode - waiting for simulator to exit...")
521 simProc.proc.wait()
522 return ret
523 finally:
524 if simProc and simProc.proc.poll() is None:
525 simProc.force_stop()
526
527
528def load_macro_definitions(path: Path) -> Dict[str, Optional[str]]:
529 """Read RTL macro definitions from a JSON file.
530
531 The file must hold an object mapping macro name to value, where null defines
532 the macro without assigning one::
533
534 {"SPB_256BIT_DATA": "1", "SYNTHESIS": null}
535
536 This lets a source generator that only discovers its macros while running --
537 a build system reading its own project description, for instance -- hand them
538 to the simulator without the caller having to know them up front.
539
540 Args:
541 path: The JSON file to read.
542
543 Returns:
544 The macro mapping, suitable for `get_simulator`'s `macro_definitions`.
545
546 Raises:
547 FileNotFoundError: If *path* does not exist.
548 ValueError: If *path* is not a JSON object of name/value pairs.
549 """
550 if not path.is_file():
551 raise FileNotFoundError(f"Macro definitions file not found: {path}")
552
553 try:
554 definitions = json.loads(path.read_text())
555 except ValueError as e:
556 raise ValueError(f"{path} is not valid JSON: {e}") from e
557
558 if not isinstance(definitions, dict):
559 raise ValueError(f"{path} must contain a JSON object mapping macro name "
560 f"to value, got {type(definitions).__name__}")
561
562 macros: Dict[str, Optional[str]] = {}
563 for name, value in definitions.items():
564 if value is not None and not isinstance(value, (str, int, float, bool)):
565 raise ValueError(f"{path}: macro '{name}' has a "
566 f"{type(value).__name__} value; expected a scalar or "
567 f"null")
568 macros[str(name)] = None if value is None else str(value)
569 return macros
570
571
572def get_simulator(
573 name: str,
574 sources: SourceFiles,
575 rundir: Path,
576 debug: bool,
577 save_waveform: bool = False,
578 macro_definitions: Optional[Dict[str, Optional[str]]] = None) -> Simulator:
579 """Create a simulator backend.
580
581 Args:
582 name: Simulator backend name.
583 sources: SourceFiles describing RTL/DPI inputs.
584 rundir: Directory where build/run artifacts are placed.
585 debug: Enable cosim debug mode.
586 save_waveform: Save simulator waveforms when debug mode is enabled.
587 macro_definitions: Optional mapping of macro names to values. A value of
588 None defines the macro without assigning a value.
589 """
590 name = name.lower()
591 if name == "verilator":
592 from .verilator import Verilator
593 return Verilator(sources,
594 rundir,
595 debug,
596 save_waveform=save_waveform,
597 macro_definitions=macro_definitions)
598 elif name == "questa":
599 from .questa import Questa
600 return Questa(sources,
601 rundir,
602 debug,
603 save_waveform=save_waveform,
604 macro_definitions=macro_definitions)
605 else:
606 raise ValueError(f"Unknown simulator: {name}")
static void print(TypedAttr val, llvm::raw_ostream &os)
static mlir::Operation * resolve(Context &context, mlir::SymbolRefAttr sym)
static StringAttr append(StringAttr base, const Twine &suffix)
Return a attribute with the specified suffix appended.
__init__(self, subprocess.Popen proc, int port, Optional[List[threading.Thread]] threads=None, bool gui=False)
Definition simulator.py:167
int|tuple[subprocess.Popen, List[threading.Thread]] _start_process_with_callbacks(self, List[str] cmd, Optional[Dict[str, str]] env, Optional[Path] cwd, Optional[Callable[[str], None]] stdout_cb, Optional[Callable[[str], None]] stderr_cb, bool wait)
Definition simulator.py:443
__init__(self, SourceFiles sources, Path run_dir, bool debug, bool save_waveform=False, Optional[Callable[[str], None]] run_stdout_callback=None, Optional[Callable[[str], None]] run_stderr_callback=None, Optional[Callable[[str], None]] compile_stdout_callback=None, Optional[Callable[[str], None]] compile_stderr_callback=None, bool make_default_logs=True, Optional[Dict[str, Optional[str]]] macro_definitions=None)
Definition simulator.py:218
List[CompileStep] compile_commands(self)
Definition simulator.py:308
int _run_compile_command(self, CompileCommand cmd)
Definition simulator.py:317
str waveform_extension(self)
Definition simulator.py:366
int _run_compile_step(self, CompileStep step)
Definition simulator.py:342
Dict[str, str] get_env()
Definition simulator.py:284
List[str] run_command(self, bool gui)
Definition simulator.py:361
SimProcess run_proc(self, bool gui=False)
Definition simulator.py:375
List[Path] rtl_sources(self)
Definition simulator.py:156
List[Path] dpi_link_paths(self)
Definition simulator.py:109
add_file(self, Path file)
Definition simulator.py:77
Path _find_dpi(self, str name, bool link)
Definition simulator.py:115
None __init__(self, str top)
Definition simulator.py:62
List[Path] dpi_so_paths(self)
Definition simulator.py:104
add_dir(self, Path dir)
Definition simulator.py:84
List[str] supported_simulators()
Definition simulator.py:30
List[str] available_simulators()
Definition simulator.py:53
bool is_port_open(port)
Definition simulator.py:22