Skip to content

Latest commit

 

History

History
736 lines (579 loc) · 31.6 KB

File metadata and controls

736 lines (579 loc) · 31.6 KB

Calling Python from VHDL

vunit-python-bridge is a VUnit package that embeds a Python interpreter in the simulator so that VHDL testbenches can execute Python code and call Python functions, for example reference models written with NumPy. The VHDL API, python_pkg/python_context, is compiled into the python_bridge library and is added to a project with add_package():

from vunit import VUnit

vu = VUnit.from_argv()
vu.add_vhdl_builtins()
vu.add_package("vunit-python-bridge", allow_setup=True)

lib = vu.add_library("lib")
lib.add_source_files("*.vhd")

vu.main()

allow_setup=True is required because, when added, the package builds its native library and registers simulator options as part of its setup function, which VUnit only runs when the project allows it.

python_pkg and its foreign language interface, python_ffi_pkg, then become available through python_context:

library vunit_lib;
context vunit_lib.vunit_context;

library python_bridge;
context python_bridge.python_context;

...

exec("import numpy as np");
result := eval("int(np.sum([1, 2, 3]))");

Requirements

  • A VUnit with support for packages and simulator hooks, see the README.
  • NVC, GHDL or Questa/ModelSim, through the VUnit Python bridge described below, or Riviera-PRO/Active-HDL (VHPI) through the VHPI application, which the package builds in the same way, see :ref:`python_bridge:other_simulators`. Any other simulator raises a RuntimeError.
  • VHDL-2008 or later.
  • CPython 3.10 or later with the standard (GIL) build. Free-threaded builds are rejected with an error.
  • Linux: a C compiler (cc, gcc or clang, or CC) and the Python development headers (for example the python3-dev package) since the bridge library is compiled on first use, see :ref:`python_bridge:native`. Python must provide a shared libpython (--enable-shared), which is the case for distribution Pythons, actions/setup-python, uv and pyenv builds with default settings.
  • Windows: a 64-bit CPython from python.org (or compatible, such as actions/setup-python and uv). No compiler is needed for NVC and GHDL when the package includes the prebuilt DLLs. Otherwise, and always for Questa and Riviera-PRO/Active-HDL, a MinGW-w64 gcc is needed: CC, the one bundled with the simulator if there is one, or gcc on PATH. MSYS2/MinGW Pythons are not supported. The simulator must be a 64-bit (x64) build too, since the Python DLL is loaded into its process; the package rejects 32-bit and ARM64 simulators and Pythons.

The simulator runs Python in the same environment as VUnit itself, including an active virtual environment and its installed packages.

A missing prerequisite, for example a missing C compiler or missing Python headers, is reported when the package is added: add_package raises an error explaining what to install.

python_setup and python_cleanup

Both are optional. python_setup starts the interpreter eagerly, otherwise it starts on first use. python_cleanup releases the values staged for integer_array_t arguments early; they are released anyway when the simulator process ends. It does not finalize the interpreter, so it is safe to call even if more Python code runs afterwards (for example in another test case of the same simulation). Output is flushed by every operation and does not depend on python_cleanup.

test_runner_setup(runner, runner_cfg);
python_setup;  -- Optional, the interpreter starts on first use

...

python_cleanup;  -- Optional, releases the staged values early
test_runner_cleanup(runner);

Sessions

Every operation that runs Python code or evaluates a Python expression -- exec, eval/eval_<type>, call, exec_file, import_run_script and import_module_from_file -- takes an optional trailing parameter, session : python_session_t := default_session, that selects the namespace the operation runs in. A session is made with new_session and its Python namespace is created the first time it is used; the default_session constant used when the parameter is omitted runs in __main__.

constant golden : python_session_t := new_session("golden");
constant fixed_point : python_session_t := new_session("fixed_point");

...

exec("x = 1", session => golden);
exec_file("fixed_point.py", session => fixed_point);

expected := eval("model(x)", session => golden);
got := call("model", arg(x), session => fixed_point);

Since session comes after up to 10 positional arguments in call, it is normally given by name, as above. Both sessions in the example can define model without interfering with each other.

A session is a VUnit object with an identity of its own, returned by get_id(session). new_session(name) creates the identity name under python_bridge:python, the identity of python_logger, while new_session(id) takes an identity that already exists and can therefore place the session anywhere in the identity tree, for example under the identity of what it serves:

constant model_id : id_t := get_id("model", parent => get_id("my_testbench"));
constant model : python_session_t := new_session(model_id);

Two sessions with the same identity are the same session, so a session does not have to be passed around to be used in several places. name(session) is the name of its identity without hierarchy, while the full name of the identity is the key of its Python namespace. Two sessions with the same name but different identities are therefore namespaces of their own.

Errors of an operation are reported on the logger of the identity of the session it was performed in, get_logger(get_id(session)). The loggers of the sessions created from a name are children of python_logger, so log levels and log handler settings made on python_logger apply to them, but mocking a logger does not capture what its children log. A test that mocks the errors of a session mocks the logger of that session, the default session included:

constant default_logger : logger_t := get_logger(get_id(default_session));
constant golden_logger : logger_t := get_logger(get_id(golden));

...

mock(golden_logger, failure);

On Riviera-PRO/Active-HDL (VHPI), only the default session is supported: passing any other session fails with a clear error.

Caveats

Sessions are separate namespaces in one Python interpreter, not separate interpreters (which would not work with NumPy and many other extension modules). Consequently, only the names defined by the executed code, such as functions, classes and variables, are separate. Everything else is shared:

  • Imported modules are loaded once and shared by all sessions. A module imported in one session is the same module object in another session, so changes to module state, such as np.random.seed(...) or attributes set on a module, are visible in all sessions. The same applies to sibling modules imported by a Python file executed in several sessions.
  • sys.path, sys.modules, environment variables, the current directory and open files are process wide.
  • Only the default session runs in __main__. Classes defined in other sessions report __main__ as their module but cannot be found there, which matters for example when pickling their instances.
  • All sessions end with the simulation. Test cases run in the same simulation (run_all_in_same_sim) share the sessions.

exec

exec executes a string of Python source code in a persistent namespace (__main__ by default, or another :ref:`session <python_bridge:sessions>`) that lives until the simulation ends. Names defined by one exec are therefore visible to later calls to exec and eval on the same session.

exec("a = -17");
check_equal(eval("abs(a)"), 17);

Multiline code can be written with an explicit LF, or with the + operator that python_pkg defines for strings as a shorthand for & LF &:

exec(
  "def scale(x, gain):" & LF &
  "    return x * gain"
);

exec(
  "import numpy as np" +
  "" +
  "def fibonacci(n):" +
  "    a, b = 0, 1" +
  "    for _ in range(n):" +
  "        a, b = b, a + b" +
  "    return a"
);

Indentation and blank lines ("") are preserved exactly. + only applies where a string is expected; numeric_std arithmetic such as unsigned'("0011") + "0001" is unaffected.

eval

eval evaluates a string containing a Python expression, in the default session or another :ref:`session <python_bridge:sessions>`, and converts the result to the VHDL type expected by the context, through one of these functions (all aliased eval):

  • eval_integer, returning integer
  • eval_real, returning real
  • eval_string, returning string
  • eval_integer_vector, returning integer_vector
  • eval_real_vector, returning real_vector
  • eval_integer_vector_ptr, returning integer_vector_ptr_t
  variable answer : integer;
  variable ratio : real;
  variable list : integer_vector_ptr_t;
begin
  exec("from math import pi");
  answer := eval("6 * 7");
  ratio := eval("pi");
  list := eval("[1, 1, 2, 3, 5, 8]");

When the result type is not clear from the context (for example when it is passed as an argument, or logged with info), the explicit eval_<type> name selects the overload:

info("pi = " & to_string(eval_real("pi")));

On NVC, GHDL and Questa, where the API is implemented by the VUnit Python bridge, eval has more result types:

  • eval_boolean, returning boolean. Only bool/numpy.bool_ is accepted. It also makes the result of eval usable as a condition: if eval("model.is_done()") then.
  • eval_std_ulogic, returning std_ulogic.
  • eval_std_ulogic_vector, returning an unconstrained std_ulogic_vector.
  • eval_integer_array, returning integer_array_t, see :ref:`python_bridge:integer_array`.

eval_boolean and eval_std_ulogic are aliased eval like the other result types. eval_std_ulogic_vector and eval_integer_array are not, since that would make check_equal(eval("17"), 17) and length(eval("[1, 2]")) ambiguous: use their explicit names.

std_ulogic_vector, signed and unsigned results are also available as the procedures eval_std_ulogic_vector, eval_signed and eval_unsigned, that take the result as an out parameter. The width is given by the actual, and a Python value that does not fit that width is an error. signed and unsigned results are only available in the procedure form, since a function cannot know the width of the result.

  variable byte : std_ulogic_vector(7 downto 0);
  variable level : signed(15 downto 0);
begin
  eval_std_ulogic_vector("format(value, '08b')", byte);
  eval_signed("model.level()", level);

call, arg, kwarg and to_call_str

call calls a Python function (or a callable expression such as a dotted name, "np.sum", or a method, "model.run") with up to 10 arguments, built with arg (positional) and kwarg (keyword), and converts the returned value like eval. Like every other operation, it accepts an optional trailing :ref:`session <python_bridge:sessions>` parameter, normally given by name since it follows the positional arguments:

  variable gcd, count : integer;
  variable ratio : real;
begin
  exec("from math import gcd as py_gcd");
  gcd := call("py_gcd", arg(35), arg(77), arg(119));
  ratio := call("round", arg(3.14159), kwarg("ndigits", 3));

  -- No return value: the procedure form of call
  call("print", arg(35), arg(77), arg(119));

arg and kwarg accept these value types:

VHDL Python
integer int
real float
boolean bool
string str
integer_vector list of int
real_vector list of float
integer_vector_ptr_t list of int
std_ulogic bool, 1 and H give True, 0 and L give False
unsigned int, passed as arg_unsigned/kwarg_unsigned
signed int, passed as arg_signed/kwarg_signed
integer_array_t NumPy array, not on Riviera-PRO/Active-HDL

An unsigned or signed value of any width becomes an exact Python integer, so it is not limited to the range of a VHDL integer. The two have names of their own rather than arg overloads, since a string literal belongs to every character array type and an overload would therefore make arg("hello") ambiguous. H and L are read as 1 and 0 in a std_ulogic, unsigned or signed value; any other metavalue is an error.

A value that cannot be converted has no session and is reported as a failure on python_logger itself. The argument becomes an expression raising the same message in Python. The call it is used in therefore fails with that message as well instead of being made without the argument, which is what would otherwise happen when the logger is mocked and the simulation continues:

FAILURE - python_bridge:python - arg cannot convert 'X'; expected '0', '1', 'L' or 'H'
FAILURE - python_bridge:python:default - eval("model(__vunit__.error("arg cannot convert 'X'; ..."))") failed:
Traceback (most recent call last):
  ...
RuntimeError: arg cannot convert 'X'; expected '0', '1', 'L' or 'H'

An aggregate or a literal does not select an overload by itself and needs a qualified expression: arg(real_vector'(1.0, 2.0)), arg(integer_vector'(1, 2, 3)). There is no std_ulogic_vector value, which would be ambiguous for the same reason as unsigned. Such a value is passed as a number or as the string of its characters:

call("model.scale", arg_unsigned(unsigned(gain)));
call("model.push", arg(to_string(slv)));

On NVC, GHDL and Questa, call returns the same types as eval: call_boolean, call_std_ulogic, call_std_ulogic_vector, call_integer_array, call_string, call_real_vector and call_integer_vector_ptr, plus the procedures call_std_ulogic_vector, call_signed and call_unsigned taking the result as an out parameter. All of the functions but call_std_ulogic_vector are aliased call.

to_call_str builds the Python call expression itself, as a string, which is useful to embed a call inside a larger exec/eval string:

exec("gcd = " & to_call_str("py_gcd", arg(35), arg(77)));

Every unused trailing argument of call/to_call_str defaults to an ignored placeholder, so calls with fewer than 10 arguments need no padding.

Argument groups

Arguments combined with & become a single argument, which makes it possible to pass more than 10 arguments and to build the arguments of a call in steps:

-- Calls plot(data, **dict(title="Step response", grid=True))
call("plot", arg(data), kwarg("title", string'("Step response")) & kwarg("grid", true));

-- Calls scale(*(1, 2,), **dict(gain=3))
call("scale", arg(1) & arg(2) & kwarg("gain", 3));

A group of positional arguments is passed to Python as *(1, 2,), a group of keyword arguments as **dict(a=1, b=2) and a group of both as *(1,), **dict(a=1). It uses one of the 10 argument slots, no matter how many arguments it holds, and keeps Python's own rules: a group with keyword arguments must come after the positional arguments of the call and a keyword must not be repeated within it. Appending a positional argument to a group that already has keyword arguments is an error for the same reason, reported as positional argument after keyword arguments.

null_arg is the identity of the operation. That is what makes a group possible to build one argument at a time, ending with null_arg, and what lets a helper that contributes an optional argument return null_arg when it has none. An empty group is a call with no arguments rather than an error. Since arg_t holds unconstrained strings, a group is grown by a function rather than by assigning to a variable in a loop:

-- The registers from idx and up, an empty group when there are none
impure function registers(idx : natural) return arg_t is
begin
  if idx = num_registers then
    return null_arg;
  end if;
  return kwarg(name(idx), value(idx)) & registers(idx + 1);
end;

...

call("model.configure", registers(0));

import_run_script

import_run_script imports the VUnit run script as a Python module, so that functions and classes defined in it (for example a plot helper using matplotlib) can be called from VHDL without duplicating them in a separate file:

import_run_script;
exec("run.hello_world()");

import_run_script("my_run_script");
exec("my_run_script.hello_world()");

Without an explicit name, the module is named after the run script's file name (without extension), so the run script is normally named run.py. Because the run script is imported as a module, it must be import-safe: code that is only meant to run when the script is invoked directly (typically the call to vu.main()) must be behind if __name__ == "__main__":; a run script without such a guard is rejected before it is imported. Like the other operations, it takes an optional trailing :ref:`session <python_bridge:sessions>` parameter.

import_module_from_file and to_py_list_str

import_module_from_file imports any Python file as a module by path (also taking an optional :ref:`session <python_bridge:sessions>` parameter), and is what import_run_script uses internally:

import_module_from_file(join(tb_path(runner_cfg), "reference_model.py"), "reference_model");
exec("reference_model.configure(gain=4)");

to_py_list_str converts an integer_vector, integer_vector_ptr_t or real_vector to the string representation of the equivalent Python list, for use in exec/eval strings:

exec("a_list = " & to_py_list_str(integer_vector'(1, 2, 3, 4)));
check_equal(eval("sum(a_list)"), 10);

Type mapping

  • integer ↔ int. Results outside the VHDL integer range fail.
  • real ↔ float. Strict: an int result is not accepted.
  • string ↔ str. UTF-8.
  • boolean ↔ bool/numpy.bool_ (Python bridge only).
  • integer_vector ↔ list of int.
  • real_vector ↔ list of float (call_real_vector needs the bridge).
  • integer_vector_ptr_t ↔ list of int.
  • std_ulogic/std_ulogic_vector ↔ str, one character per element out of U X 0 1 Z W L H -, left to right (results only, Python bridge).
  • signed/unsigned ↔ int (procedure results only, Python bridge).
  • integer_array_t ↔ numpy.ndarray (Python bridge), see :ref:`python_bridge:integer_array`.

The types marked as needing the Python bridge are available on NVC, GHDL and Questa, but not on Riviera-PRO/Active-HDL.

Results are strict: a value that does not fit the VHDL type, or is of the wrong Python type, is an error. Values are never silently truncated or wrapped.

Errors

Python exceptions, syntax errors, type errors and undefined names are reported as failures on the logger of the :ref:`session <python_bridge:sessions>` the operation was performed in, get_logger(get_id(session)), including the Python traceback. For the default session that logger is named python_bridge:python:default and python_logger (named python_bridge:python) is its parent. The test fails and stops like for any other failure. The logger of the session can be mocked to test error handling; when mocked, eval/call return a default value (0, 0.0, "" or an empty vector, depending on the type) after the failure.

FAILURE - python_bridge:python:default - eval("1 / 0") failed:
Traceback (most recent call last):
  File "<eval #3>", line 1, in <module>
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero

Output of print is written to the simulator output and flushed after every operation.

integer_array_t and NumPy

Note

integer_array_t values are transferred by the VUnit Python bridge and are therefore available on NVC, GHDL and Questa, but not on Riviera-PRO/Active-HDL.

An integer_array_t argument is transferred to Python and referred to by the expression as a NumPy array of dtype int32, so it can be reused in several calls. The shape follows VUnit's indexing so that elements correspond directly:

Array VHDL Python
1D get(a, i) a[i], shape (length,)
2D get(a, x, y) a[y, x], shape (height, width)
3D get(a, x, y, z) a[y, x, z], shape (height, width, depth)

integer_array_t does not record its number of dimensions. An argument is treated as 3D if its depth is larger than one, else as 2D if its height is larger than one, else as 1D.

A returned array (or nested list of integers) becomes a new integer_array_t with the corresponding width, height and depth. Its bit_width and is_signed are those of the argument, if the function returns one of its integer_array_t arguments (possibly modified in place), else given by the dtype: bool is 1 bit unsigned, int8/uint8 8 bit, int16/uint16 16 bit and all other integer dtypes 32 bit signed. All values must fit that word size, otherwise the call fails. Floating point arrays are rejected; convert them explicitly with astype. As usual, the returned integer_array_t is owned by the caller and can be freed with deallocate.

exec_file

exec_file executes a Python file (where the Python bridge implements the API: NVC, GHDL and Questa), with the equivalent of

with open(path, encoding="utf-8") as file:
    source = file.read()
exec(compile(source, str(path), "exec"), namespace, namespace)

which means that tracebacks show the real file name and line numbers, __file__ is set to the absolute path of the file while it executes (and restored afterwards), and the directory of the file is added to sys.path while it executes so that it can import sibling modules without setting PYTHONPATH. Imports are therefore expected at the top level of the file. Executing the same file twice executes it twice.

A relative file name is relative to the directory of the testbench file (tb_path); an absolute path is used as given. Like the other operations, it takes an optional trailing :ref:`session <python_bridge:sessions>` parameter.

Semantics to be aware of

Important

  • eval/eval_<type> are declared impure since they always read state from the Python interpreter.
  • Errors are reported as failures on the logger of the :ref:`session <python_bridge:sessions>`, including the Python traceback, rather than aborting the simulation.
  • python_setup and python_cleanup are optional, see :ref:`python_bridge:setup_and_cleanup`. python_cleanup does not finalize the interpreter.
  • real results have no float32 range limit: VHDL real is double precision where the Python bridge implements the API, so any finite Python float (also double precision) is in range. A value that is not finite, such as float("inf") or float("nan"), has no VHDL real representation and is reported as a failure.
  • string arguments are passed to Python double-quoted verbatim: a quote or backslash inside the string is not escaped, so it must be avoided or already be valid inside a Python double-quoted string.

Other simulators

Riviera-PRO/Active-HDL (VHPI) implement python_ffi_pkg with a VHPI application, built from the C sources in src/vunit_python_bridge/native/vhpi with their ccomp driver. On Windows, ccomp is given the directory of the gcc bundled with the simulator (<installation>/mingw) or, when there is none, of gcc on PATH. The package builds the application under the output path (<output path>/<simulator>/libraries) the first time it is added and rebuilds it when the sources, the Python running VUnit or the simulator installation change, so a run script needs nothing beyond add_package("vunit-python-bridge", allow_setup=True).

This application differs from the Python bridge in a few ways: only the default session exists, the operations implemented by the bridge (integer_array_t values, the boolean, std_ulogic, vector and integer_array_t results, exec_file) report that they require NVC, GHDL or Questa, a Python error stops the simulation with the message printed by the application rather than through the logger of the session, and real values outside the single precision float range are rejected.

The embedded_python example of this repository, examples/embedded_python, is a complete example covering all three simulator families.

Its tb_example.vhd has a test case for each part of the API: exec and eval with the types they convert, calls with positional, keyword and group arguments, a 20 register status dump, wide arg_unsigned/arg_signed and std_ulogic argument values, a 2-D integer_array_t image transposed by NumPy, the result types of eval and call, Python files executed with exec_file or imported with import_module_from_file, two models loaded into a session each, and a Python model failing with the logger of the default session mocked. Its last test case drives python_model, a verification component whose behaviour is the Python function in python_model.py rather than VHDL.

How it works (NVC, GHDL and Questa)

For NVC, GHDL and Questa, the interpreter is embedded in the simulator process by a small C library, the VUnit Python bridge (src/vunit_python_bridge/native). NVC and GHDL call it through VHPIDIRECT; Questa/ModelSim calls it through the FLI, using the front end in native/fli.c that converts the FLI parameters of one foreign subprogram per entry point. The generated VHDL and everything above it is the same for all three. The interpreter is started on first use, is never restarted within a simulation, and uses no signal handlers of its own.

Linux
The bridge is compiled from source against the Python running VUnit the first time the package is added, and cached in <output path>/python_bridge. It is rebuilt automatically when the source, the Python version or the Python installation changes. For Questa the FLI front end is compiled in too, against the mti.h of the simulator, and the simulator installation is part of the cache key. The package ships no prebuilt Linux library.
Windows
For NVC and GHDL, the package ships DLLs built with MSVC for each supported Python minor version (src/vunit_python_bridge/bin). The matching DLL is copied to the output path and nothing is compiled. A development checkout of the package does not contain the DLLs; they can be built with tools/build_python_bridge.py from an MSVC developer prompt, and without them the library is built with gcc like for Questa. For Questa the library must be linked against the simulator's mtipli, so it is always built on first use with gcc, against the headers and the import library of the Python running VUnit. The compiler is CC if set, else the MinGW gcc bundled with the simulator (gcc-*-mingw64* next to the Questa executables' directory), else gcc on PATH. Many Questa installations on Windows bundle none. A gcc build imports the Python DLL directly, so the package adds its directory to PATH for the simulator processes. These builds are untested.

The bridge uses the full (version specific) CPython ABI rather than the Stable ABI since embedding the interpreter in the environment VUnit runs in requires the PyConfig initialization API, which is not part of the limited API.

The package registers simulator hooks that make the simulator find the library automatically. NVC is given a --load option. GHDL gets the library directory in its dynamic library search path, plus a linker search path for the ahead-of-time compiled llvm and gcc backends, which the hook recognizes from the backend of the GHDL interface it is called with. The FLI attributes generated for Questa name the library by absolute path, and the vsim process VUnit starts is given -noautoldlibpath on Linux so that the C++ runtime Questa bundles, which is often older than the one the Python extension modules of the environment (NumPy) were built against, does not take precedence over the one of the system. That option is set up when the process starts, so it has to go on the command line of the process rather than on the vsim command of the do-file VUnit generates, and it is only passed to a vsim listing it in vsim -help all. No simulator options need to be set by the user.

Tested configurations

Simulator Linux
NVC 1.22 (and 1.23-devel)
GHDL mcode 6.0.0, 7.0.0-dev
GHDL llvm-jit 6.0.0, 7.0.0-dev
GHDL llvm 6.0.0, 7.0.0-dev
GHDL gcc 6.0.0, 7.0.0-dev
Questa (FLI) Altera Starter FPGA Edition 2025.3

On Windows, CI tests NVC 1.22 and GHDL mcode (nightly) with Python 3.10, 3.12 and 3.14. The released wheel is also installed and tested on Linux, macOS and on Windows machines without a compiler, there with every supported Python, so each of its prebuilt DLLs is run.

Questa is tested manually on Linux and is not covered by CI; the Windows build of its bridge library is untested. Riviera-PRO/Active-HDL (VHPI) is not tested at all.