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