Reorganize how tb infrstructure selects toolchains

This commit is contained in:
Alex Mykyta
2023-10-22 11:04:43 -07:00
parent 683fc4d0ac
commit d689bb7077
24 changed files with 323 additions and 179 deletions

View File

@@ -52,9 +52,7 @@ jobs:
- name: Test
run: |
cd tests
export SKIP_SYNTH_TESTS=1
export STUB_SIMULATOR=1
pytest --cov=peakrdl_regblock
pytest --cov=peakrdl_regblock --synth-tool skip --sim-tool stub
- name: Coveralls
env:

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
**/_build
**/*.out
**/transcript
**/htmlcov
**/*.log
**/*.pb
**/.Xil

View File

@@ -30,11 +30,6 @@ To run synthesis tests, Vivado needs to be installed and visible via the PATH en
Vivado can be downloaded for free from: https://www.xilinx.com/support/download.html
To skip synthesis tests, export the following environment variable:
```bash
export SKIP_SYNTH_TESTS=1
```
## Python Packages
@@ -62,6 +57,13 @@ You can also run a specific testcase. For example:
pytest tests/test_hw_access
```
Command-line arguments can be used to explicitly select which simulator/synthesis tools are used
If unspecified, the tool will be selected automatically based on what you have installed.
```bash
pytest --sim-tool questa --synth-tool vivado
```
Alternatively, launch tests using the helper script. This handles installing
dependencies into a virtual environment automatically.
```bash

25
tests/conftest.py Normal file
View File

@@ -0,0 +1,25 @@
def pytest_addoption(parser):
parser.addoption(
"--sim-tool",
choices=["questa", "xilinx", "stub", "skip", "auto"],
default="auto",
help="""
Select the simulator to use.
stub: run the testcase using a no-op simulator stub
skip: skip all the simulation tests
auto: choose the best simulator based on what is installed
"""
)
parser.addoption(
"--synth-tool",
choices=["vivado", "skip", "auto"],
default="auto",
help="""
Select the synthesis tool to use.
skip: skip all the simulation tests
auto: choose the best tool based on what is installed
"""
)

View File

@@ -1,4 +1,4 @@
from typing import Optional, List
from typing import Optional
import unittest
import os
import glob
@@ -49,41 +49,37 @@ class BaseTestCase(unittest.TestCase):
def _load_request(self, request):
self.request = request
@classmethod
def get_testcase_dir(cls) -> str:
class_dir = os.path.dirname(inspect.getfile(cls))
def get_testcase_dir(self) -> str:
class_dir = os.path.dirname(inspect.getfile(self.__class__))
return class_dir
@classmethod
def get_run_dir(cls) -> str:
this_dir = cls.get_testcase_dir()
run_dir = os.path.join(this_dir, "run.out", cls.__name__)
def get_run_dir(self) -> str:
this_dir = self.get_testcase_dir()
run_dir = os.path.join(this_dir, "run.out", self.__class__.__name__)
return run_dir
@classmethod
def _write_params(cls) -> None:
def _write_params(self) -> None:
"""
Write out the class parameters to a file so that it is easier to debug
how a testcase was parameterized
"""
path = os.path.join(cls.get_run_dir(), "params.txt")
path = os.path.join(self.get_run_dir(), "params.txt")
with open(path, 'w') as f:
for k, v in cls.__dict__.items():
for k, v in self.__class__.__dict__.items():
if k.startswith("_") or callable(v):
continue
f.write(f"{k}: {repr(v)}\n")
@classmethod
def _export_regblock(cls):
def _export_regblock(self):
"""
Call the peakrdl_regblock exporter to generate the DUT
"""
this_dir = cls.get_testcase_dir()
this_dir = self.get_testcase_dir()
if cls.rdl_file:
rdl_file = cls.rdl_file
if self.rdl_file:
rdl_file = self.rdl_file
else:
# Find any *.rdl file in testcase dir
rdl_file = glob.glob(os.path.join(this_dir, "*.rdl"))[0]
@@ -98,45 +94,33 @@ class BaseTestCase(unittest.TestCase):
rdlc.compile_file(udp_file)
rdlc.compile_file(rdl_file)
root = rdlc.elaborate(cls.rdl_elab_target, "regblock", cls.rdl_elab_params)
root = rdlc.elaborate(self.rdl_elab_target, "regblock", self.rdl_elab_params)
cls.exporter.export(
self.exporter.export(
root,
cls.get_run_dir(),
self.get_run_dir(),
module_name="regblock",
package_name="regblock_pkg",
cpuif_cls=cls.cpuif.cpuif_cls,
retime_read_fanin=cls.retime_read_fanin,
retime_read_response=cls.retime_read_response,
reuse_hwif_typedefs=cls.reuse_hwif_typedefs,
retime_external_reg=cls.retime_external,
retime_external_regfile=cls.retime_external,
retime_external_mem=cls.retime_external,
retime_external_addrmap=cls.retime_external,
default_reset_activelow=cls.default_reset_activelow,
default_reset_async=cls.default_reset_async,
cpuif_cls=self.cpuif.cpuif_cls,
retime_read_fanin=self.retime_read_fanin,
retime_read_response=self.retime_read_response,
reuse_hwif_typedefs=self.reuse_hwif_typedefs,
retime_external_reg=self.retime_external,
retime_external_regfile=self.retime_external,
retime_external_mem=self.retime_external,
retime_external_addrmap=self.retime_external,
default_reset_activelow=self.default_reset_activelow,
default_reset_async=self.default_reset_async,
)
@classmethod
def setUpClass(cls):
def setUp(self) -> None:
# Create fresh build dir
run_dir = cls.get_run_dir()
run_dir = self.get_run_dir()
if os.path.exists(run_dir):
shutil.rmtree(run_dir)
pathlib.Path(run_dir).mkdir(parents=True, exist_ok=True)
cls._write_params()
self._write_params()
# Convert testcase RDL file --> SV
cls._export_regblock()
def setUp(self) -> None:
# cd into the run directory
self.original_cwd = os.getcwd()
os.chdir(self.get_run_dir())
def run_test(self, plusargs:List[str] = None) -> None:
simulator = self.simulator_cls(testcase_cls_inst=self)
simulator.run(plusargs)
self._export_regblock()

View File

@@ -66,7 +66,7 @@ class CpuifTestMode:
return self._get_file_paths("rtl_files")
def get_tb_inst(self, tb_cls: 'SimTestCase', exporter: 'RegblockExporter') -> str:
def get_tb_inst(self, testcase: 'SimTestCase', exporter: 'RegblockExporter') -> str:
class_dir = self._get_class_dir_of_variable("tb_template")
loader = jj.FileSystemLoader(class_dir)
jj_env = jj.Environment(
@@ -77,7 +77,7 @@ class CpuifTestMode:
context = {
"cpuif": self,
"cls": tb_cls,
"testcase": testcase,
"exporter": exporter,
"type": type,
}

View File

@@ -1,24 +1,21 @@
from typing import List
import os
import jinja2 as jj
import pytest
from .sv_line_anchor import SVLineAnchor
from .simulators.questa import Questa
from .simulators import StubSimulator
from .simulators import get_simulator_cls
from .base_testcase import BaseTestCase
SIM_CLS = Questa
if os.environ.get("STUB_SIMULATOR", False):
SIM_CLS = StubSimulator
class SimTestCase(BaseTestCase):
#: Abort test if it exceeds this number of clock cycles
timeout_clk_cycles = 5000
simulator_cls = SIM_CLS
incompatible_sim_tools = set()
tb_template_file = "tb_template.sv"
@@ -32,17 +29,14 @@ class SimTestCase(BaseTestCase):
clocking_hwif_in = True
clocking_hwif_out = True
@classmethod
def get_extra_tb_files(cls) -> List[str]:
def get_extra_tb_files(self) -> List[str]:
paths = []
for path in cls.extra_tb_files:
path = os.path.join(cls.get_testcase_dir(), path)
for path in self.extra_tb_files:
path = os.path.join(self.get_testcase_dir(), path)
paths.append(path)
return paths
@classmethod
def _generate_tb(cls):
def _generate_tb(self):
"""
Render the testbench template into actual tb.sv
"""
@@ -57,32 +51,38 @@ class SimTestCase(BaseTestCase):
)
context = {
"cls": cls,
"exporter": cls.exporter,
"testcase": self,
"exporter": self.exporter,
}
# template path needs to be relative to the Jinja loader root
template_path = os.path.join(cls.get_testcase_dir(), cls.tb_template_file)
template_path = os.path.join(self.get_testcase_dir(), self.tb_template_file)
template_path = os.path.relpath(template_path, template_root_path)
template = jj_env.get_template(template_path)
output_path = os.path.join(cls.get_run_dir(), "tb.sv")
output_path = os.path.join(self.get_run_dir(), "tb.sv")
stream = template.stream(context)
stream.dump(output_path)
@classmethod
def setUpClass(cls):
super().setUpClass()
def setUp(self):
name = self.request.config.getoption("--sim-tool")
if name in self.incompatible_sim_tools:
pytest.skip()
simulator_cls = get_simulator_cls(name)
if simulator_cls is None:
pytest.skip()
super().setUp()
# Create testbench from template
cls._generate_tb()
self._generate_tb()
simulator = cls.simulator_cls(testcase_cls=cls)
simulator = simulator_cls(self)
# cd into the build directory
cwd = os.getcwd()
os.chdir(cls.get_run_dir())
os.chdir(self.get_run_dir())
try:
simulator.compile()
finally:
@@ -91,5 +91,16 @@ class SimTestCase(BaseTestCase):
def run_test(self, plusargs:List[str] = None) -> None:
simulator = self.simulator_cls(testcase_cls_inst=self)
name = self.request.config.getoption("--sim-tool")
simulator_cls = get_simulator_cls(name)
simulator = simulator_cls(self)
# cd into the build directory
cwd = os.getcwd()
os.chdir(self.get_run_dir())
try:
simulator.run(plusargs)
finally:
# cd back
os.chdir(cwd)

View File

@@ -1,34 +1,37 @@
from typing import Type, TYPE_CHECKING, List
from typing import Type, Optional, List
import functools
if TYPE_CHECKING:
from ..sim_testcase import SimTestCase
from .base import Simulator
from .questa import Questa
from .xilinx import Xilinx
from .stub import StubSimulator
class Simulator:
ALL_SIMULATORS: List[Simulator]
ALL_SIMULATORS = [
Questa,
Xilinx,
StubSimulator,
]
def __init__(self, testcase_cls: 'Type[SimTestCase]' = None, testcase_cls_inst: 'SimTestCase' = None) -> None:
self.testcase_cls = testcase_cls
self.testcase_cls_inst = testcase_cls_inst
@functools.lru_cache()
def get_simulator_cls(name: str) -> Optional[Type[Simulator]]:
if name == "skip":
return None
@property
def tb_files(self) -> List[str]:
files = []
files.extend(self.testcase_cls.cpuif.get_sim_files())
files.extend(self.testcase_cls.get_extra_tb_files())
files.append("regblock_pkg.sv")
files.append("regblock.sv")
files.append("tb.sv")
if name == "auto":
# Find the first simulator that is installed
for sim_cls in ALL_SIMULATORS:
if sim_cls is StubSimulator:
# Never offer the stub as an automatic option
continue
if sim_cls.is_installed():
return sim_cls
raise ValueError("Could not find any installed simulators")
return files
def compile(self) -> None:
raise NotImplementedError
def run(self, plusargs:List[str] = None) -> None:
raise NotImplementedError
class StubSimulator(Simulator):
def compile(self) -> None:
pass
def run(self, plusargs:List[str] = None) -> None:
pass
# Look up which explicit simulator name was specified
for sim_cls in ALL_SIMULATORS:
if sim_cls.name == name:
if not sim_cls.is_installed():
raise ValueError("Simulator '%s' is not installed" % sim_cls.name)
return sim_cls
raise RuntimeError

View File

@@ -0,0 +1,31 @@
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from ..sim_testcase import SimTestCase
class Simulator:
name = ""
@classmethod
def is_installed(cls) -> bool:
raise NotImplementedError
def __init__(self, testcase: 'SimTestCase' = None) -> None:
self.testcase = testcase
@property
def tb_files(self) -> List[str]:
files = []
files.extend(self.testcase.cpuif.get_sim_files())
files.extend(self.testcase.get_extra_tb_files())
files.append("regblock_pkg.sv")
files.append("regblock.sv")
files.append("tb.sv")
return files
def compile(self) -> None:
raise NotImplementedError
def run(self, plusargs:List[str] = None) -> None:
raise NotImplementedError

View File

@@ -1,10 +1,20 @@
from typing import List
import subprocess
import os
import shutil
from . import Simulator
from .base import Simulator
class Questa(Simulator):
name = "questa"
@classmethod
def is_installed(cls) -> bool:
return (
shutil.which("vlog") is not None
and shutil.which("vsim") is not None
)
def compile(self) -> None:
cmd = [
"vlog", "-sv", "-quiet", "-l", "build.log",
@@ -31,7 +41,7 @@ class Questa(Simulator):
def run(self, plusargs:List[str] = None) -> None:
plusargs = plusargs or []
test_name = self.testcase_cls_inst.request.node.name
test_name = self.testcase.request.node.name
# call vsim
cmd = [
@@ -54,11 +64,11 @@ class Questa(Simulator):
def assertSimLogPass(self, path: str):
self.testcase_cls_inst.assertTrue(os.path.isfile(path))
self.testcase.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
for line in f:
if line.startswith("# ** Error"):
self.testcase_cls_inst.fail(line)
self.testcase.fail(line)
elif line.startswith("# ** Fatal"):
self.testcase_cls_inst.fail(line)
self.testcase.fail(line)

View File

@@ -0,0 +1,17 @@
from typing import List
from .base import Simulator
class StubSimulator(Simulator):
name = "stub"
@classmethod
def is_installed(cls) -> bool:
# Always available!
return True
def compile(self) -> None:
pass
def run(self, plusargs: List[str] = None) -> None:
pass

View File

@@ -1,22 +1,34 @@
from typing import List
import subprocess
import os
import shutil
from . import Simulator
from .base import Simulator
class Xilinx(Simulator):
"""
Don't bother using the Xilinx simulator... Its buggy and extraordinarily slow.
As observed in v2021.1:
As observed in v2023.2:
- clocking block assignments do not seem to actually simulate correctly.
assignment statements get ignored or the values get mangled.
- Streaming operators have all sorts of limitations.
Keeping this here in case someday it works better...
"""
name = "xilinx"
@classmethod
def is_installed(cls) -> bool:
return (
shutil.which("xvlog") is not None
and shutil.which("xelab") is not None
and shutil.which("xsim") is not None
)
def compile(self) -> None:
cmd = [
"xvlog", "--sv",
"--log", "compile.log",
"--include", os.path.join(os.path.dirname(__file__), ".."),
"--define", "XSIM",
]
@@ -25,6 +37,7 @@ class Xilinx(Simulator):
cmd = [
"xelab",
"--log", "elaborate.log",
"--timescale", "1ns/1ps",
"--debug", "all",
"tb",
@@ -35,7 +48,7 @@ class Xilinx(Simulator):
def run(self, plusargs:List[str] = None) -> None:
plusargs = plusargs or []
test_name = self.testcase_cls_inst.request.node.name
test_name = self.testcase.request.node.name
# call vsim
cmd = [
@@ -54,13 +67,13 @@ class Xilinx(Simulator):
def assertSimLogPass(self, path: str):
self.testcase_cls_inst.assertTrue(os.path.isfile(path))
self.testcase.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
for line in f:
if line.startswith("Error:"):
self.testcase_cls_inst.fail(line)
self.testcase.fail(line)
elif line.startswith("Fatal:"):
self.testcase_cls_inst.fail(line)
self.testcase.fail(line)
elif line.startswith("FATAL_ERROR:"):
self.testcase_cls_inst.fail(line)
self.testcase.fail(line)

View File

@@ -1,12 +1,11 @@
from typing import List
import subprocess
import os
import pytest
from .base_testcase import BaseTestCase
from .synthesizers import get_synthesizer_cls
@pytest.mark.skipif(os.environ.get("SKIP_SYNTH_TESTS", False), reason="user skipped")
class SynthTestCase(BaseTestCase):
def _get_synth_files(self) -> List[str]:
@@ -17,20 +16,24 @@ class SynthTestCase(BaseTestCase):
return files
def setUp(self) -> None:
name = self.request.config.getoption("--synth-tool")
synth_cls = get_synthesizer_cls(name)
if synth_cls is None:
pytest.skip()
super().setUp()
def run_synth(self) -> None:
script = os.path.join(
os.path.dirname(__file__),
"synthesis/vivado/run.tcl"
)
name = self.request.config.getoption("--synth-tool")
synth_cls = get_synthesizer_cls(name)
synth = synth_cls(self)
cmd = [
"vivado", "-nojournal", "-notrace",
"-mode", "batch",
"-log", "out.log",
"-source", script,
"-tclargs"
]
cmd.extend(self._get_synth_files())
# cd into the build directory
cwd = os.getcwd()
os.chdir(self.get_run_dir())
subprocess.run(cmd, check=True)
try:
synth.run()
finally:
# cd back
os.chdir(cwd)

View File

@@ -0,0 +1,30 @@
from typing import List, Optional, Type
import functools
from .base import Synthesizer
from .vivado import Vivado
ALL_SYNTHESIZERS: List[Synthesizer]
ALL_SYNTHESIZERS = [
Vivado,
]
@functools.lru_cache()
def get_synthesizer_cls(name: str) -> Optional[Type[Synthesizer]]:
if name == "skip":
return None
if name == "auto":
# Find the first tool that is installed
for synth_cls in ALL_SYNTHESIZERS:
if synth_cls.is_installed():
return synth_cls
raise ValueError("Could not find any installed synthesis tools")
# Look up which explicit synth tool name was specified
for synth_cls in ALL_SYNTHESIZERS:
if synth_cls.name == name:
if not synth_cls.is_installed():
raise ValueError("Synthesis tool '%s' is not installed" % synth_cls.name)
return synth_cls
raise RuntimeError

View File

@@ -0,0 +1,17 @@
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from ..synth_testcase import SynthTestCase
class Synthesizer:
name = ""
@classmethod
def is_installed(cls) -> bool:
raise NotImplementedError
def __init__(self, testcase: 'SynthTestCase' = None) -> None:
self.testcase = testcase
def run(self) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,29 @@
import os
import subprocess
import shutil
from .base import Synthesizer
class Vivado(Synthesizer):
name = "vivado"
@classmethod
def is_installed(cls) -> bool:
return shutil.which("vivado") is not None
def run(self) -> None:
script = os.path.join(
os.path.dirname(__file__),
"vivado_scripts/run.tcl"
)
cmd = [
"vivado", "-nojournal", "-notrace",
"-mode", "batch",
"-log", "out.log",
"-source", script,
"-tclargs"
]
cmd.extend(self.testcase._get_synth_files())
subprocess.run(cmd, check=True)

View File

@@ -40,11 +40,11 @@ module tb;
default clocking cb @(posedge clk);
default input #1step output #1;
output rst;
{%- if exporter.hwif.has_input_struct and cls.clocking_hwif_in %}
{%- if exporter.hwif.has_input_struct and testcase.clocking_hwif_in %}
output hwif_in;
{%- endif %}
{%- if exporter.hwif.has_output_struct and cls.clocking_hwif_out %}
{%- if exporter.hwif.has_output_struct and testcase.clocking_hwif_out %}
input hwif_out;
{%- endif %}
@@ -61,7 +61,7 @@ module tb;
//--------------------------------------------------------------------------
// CPUIF
//--------------------------------------------------------------------------
{{cls.cpuif.get_tb_inst(cls, exporter)|indent}}
{{testcase.cpuif.get_tb_inst(testcase, exporter)|indent}}
//--------------------------------------------------------------------------
// DUT
@@ -93,7 +93,7 @@ module tb;
//--------------------------------------------------------------------------
initial begin
cb.rst <= '1;
{%- if exporter.hwif.has_input_struct and cls.init_hwif_in %}
{%- if exporter.hwif.has_input_struct and testcase.init_hwif_in %}
cb.hwif_in <= '{default: '0};
{%- endif %}
@@ -112,8 +112,8 @@ module tb;
// Monitor for timeout
//--------------------------------------------------------------------------
initial begin
##{{cls.timeout_clk_cycles}};
$fatal(1, "Test timed out after {{cls.timeout_clk_cycles}} clock cycles");
##{{testcase.timeout_clk_cycles}};
$fatal(1, "Test timed out after {{testcase.timeout_clk_cycles}} clock cycles");
end
endmodule

View File

@@ -19,10 +19,7 @@ pip install -U .
cd $this_dir
# Run unit tests
export SKIP_SYNTH_TESTS=1
#export STUB_SIMULATOR=1
export NO_XSIM=1
pytest --workers auto --cov=peakrdl_regblock
pytest --workers auto --cov=peakrdl_regblock --synth-tool skip
# Generate coverage report
coverage html -i -d $this_dir/htmlcov

View File

@@ -1,5 +1,6 @@
from ..lib.sim_testcase import SimTestCase
class Test(SimTestCase):
incompatible_sim_tools = {"xilinx"}
def test_dut(self):
self.run_test()

View File

@@ -1,5 +1,6 @@
from ..lib.sim_testcase import SimTestCase
class Test(SimTestCase):
incompatible_sim_tools = {"xilinx"}
def test_dut(self):
self.run_test()

View File

@@ -2,9 +2,9 @@
{%- block declarations %}
{% sv_line_anchor %}
localparam REGWIDTH = {{cls.regwidth}};
localparam REGWIDTH = {{testcase.regwidth}};
localparam STRIDE = REGWIDTH/8;
localparam N_REGS = {{cls.n_regs}};
localparam N_REGS = {{testcase.n_regs}};
{%- endblock %}

View File

@@ -1,13 +1,11 @@
import os
from parameterized import parameterized_class
import pytest
from ..lib.sim_testcase import SimTestCase
from ..lib.synth_testcase import SynthTestCase
from ..lib.test_params import get_permutations
from ..lib.cpuifs import ALL_CPUIF
from ..lib.simulators.xilinx import Xilinx
@@ -51,30 +49,3 @@ class TestDefaultResets(SimTestCase):
class TestSynth(SynthTestCase):
def test_dut(self):
self.run_synth()
@pytest.mark.skipif(os.environ.get("STUB_SIMULATOR", False) or os.environ.get("NO_XSIM", False), reason="user skipped")
@parameterized_class(get_permutations({
"cpuif": ALL_CPUIF,
"retime_read_fanin": [True, False],
"retime_read_response": [True, False],
"reuse_hwif_typedefs": [True, False],
}))
class TestVivado(SimTestCase):
"""
Vivado XSIM's implementation of clocking blocks is broken, which is heavily used
by the testbench infrastructure in most testcases.
Since this group of tests does not rely on writing HWIF values, the bugs in
xsim are avoided.
Run this testcase using xsim to get some cross-simulator coverage.
Goal is to validate the generated RTL doesn't use constructs that offend xsim.
This is skipped in CI stub tests as it doesn't add value
"""
simulator_cls = Xilinx
def test_dut(self):
self.run_test()