RTL simulation — does the state actually survive?

The generated BFM harness drives the synthesized RTL through real handshakes, one clock at a time, in Vivado xsim. For this design it answers a question nothing earlier in the flow can.

C-synthesis can say the emitted static compiles and infers a memory. It cannot say the value survives between firings: a static swept by ap_rst would csynth identically and then quietly zero itself every job, and the design would look right and be wrong. Only a real RTL run can tell.

And that is not the only thing this rung is for. A free-running ap_ctrl_none composite has no scheduler, so a token-count bug does not produce a wrong answer — it hangs, or silently drops a job. The pysim can miss that class entirely; the RTL cannot.

Running it

Toolchain-gated: it needs Vitis HLS to C-synthesize the top, plus Vivado xsim and a MinGW g++ to elaborate and drive it.

python -m examples.fir_block.fir_block_build --through csynth   # produces the RTL
pytest -m xsi tests/examples/test_fir_block_xsi.py              # elaborates, runs, checks

The gate skips loudly when the RTL or the XSI workspace is absent, rather than passing on nothing:

if not (ROOT / "fir_block_proj").is_dir():
    pytest.skip(f"no csynth RTL at {ROOT / 'fir_block_proj'} — run fir_block.tcl first")

To check the other realization, rebuild and rerun:

python -m examples.fir_block.fir_block_build --through csynth --unroll-lane
pytest -m xsi tests/examples/test_fir_block_xsi.py

The harness comes from the testbench graph

The FirBlockTB composite is walkable, so the same structure that runs in pysim also generates the C++ harness:

tb = make_xsi_tb(...)                       # the FirBlockTB graph
spec = tb_top_spec(tb)
(xsi / "fir_block_tb_harness.h").write_text(render_tb_harness(spec))
(xsi / "fir_block_bfm_tb.cpp").write_text(render_tb_main(spec, tb.n_cycles))
write_xsi_bundles(xsi, ...)                 # the scenario, from the same writer pysim uses

That is the point of building the testbench as a graph rather than a script: one statement, two backends. pysim builds the graph and runs it; the XSI generator builds the same graph and emits a BFM harness from it. They cannot end up describing different tests, because there is only one description.

Three pieces are emitted:

  • fir_block_vectors.h — the scenario constants read off the graph: MEM_DW, the arena size, NUM_CMDS, and DONE_WORDS. The per-job offsets are not baked here; they ride the s_cmd bundle, so the RTL stays scenario-independent.
  • fir_block_tb_harness.h — the BFM harness, which #includes the DUT’s fir_block_ports.h (hence codegen order: DUT first).
  • fir_block_bfm_tb.cpp — a two-line main.

The BFM drives the boundary: the s_cmd command stream in, the two m_axi bundles against a flat memory model, and s_done out. Every value crossing the boundary is a burst bundle written before the run (vectors/s_cmd, vectors/mem_in, vectors/golden) and read back after (vectors/out, vectors/s_done).

Under the hood: run.bat

Four steps — the standard XSI recipe:

  1. xvlog compiles the C-synthesized Verilog named by rtl_fir_block.f;
  2. xelab -dll elaborates the top into xsim.dir/fir_block/xsimk.dll;
  3. g++ builds the BFM harness main against that DLL;
  4. xsim runs the executable, stepping the clock for a fixed bound.

The file list is regenerated by the csynth step from the RTL actually on disk. That is not housekeeping: a stale .f plus a cached xsimk.dll is precisely how an XSI run goes green while proving nothing — xvlog compiles a file set that no longer matches the design, and xelab reuses what it already built.

The test clears the previous run’s dumps for the same reason:

generate_tb(ROOT)
for name in ("out", "s_done"):
    d = XSI / "vectors" / name
    if d.exists():
        for f in d.iterdir():
            f.unlink()

Without that, a broken build passes on last run’s output.

What the gate asserts

Exit code zero proves nothing here, so check_xsi_outputs reads the dumped bundles and checks two things:

for j, step in enumerate(s for s in tb._steps if s["op"] == FirOp.FILTER):
    # WORDS, not samples: at LW samples per word an n-sample block occupies ceil(n/LW) words.
    dst, nw = step["dst"], step["nw"]
    got, want = out[dst:dst + nw], golden[dst:dst + nw]
    if not np.array_equal(got, want):
        raise AssertionError(...)

s_done = read_burst_bundle(vdir / "s_done")[0]
assert len(s_done) == len(tb._steps) * dw, (
    f"... expected {len(tb._steps)}*{dw} (one FirDesc echo per command, LOAD_TAPS included)")

Bit-exact against the golden, block by block — and the golden is the stateless one, so agreement is the statement that both flavours of state survived in real hardware, across a mid-stream tap reload.

One completion per command, LOAD_TAPS included. Without this a dropped no-output job would look like a pass: the blocks that did run would still match.

The test also refuses to proceed if the run produced no dump at all:

assert (XSI / "vectors" / "out").exists(), (
    f"the XSI run produced no memory dump — it did not complete\n{proc.stdout[-2000:]}")

What it found

This gate earned its place on its first real run, and the story is worth keeping because it is the argument for having it.

csynth was clean. The first output block matched. And the RTL was still wrong — the delay line had been seeded with the MAC-time invariant rather than the pre-shift one, which drops the newest carry sample. It was invisible to csynth, and invisible in block 1 because zero_state starts that block from zeros so it never reads the carry. Only block 2’s first samples were wrong.

Two things made it findable rather than merely detectable:

  • the golden is bit-exact, not a tolerance. The wrong output was entirely plausible — a filter with a slightly-off history still looks like a filter. A tolerance would have swallowed it.
  • the program runs three filter firings, not one. A single-block scenario never touches the carry.

Diagnosis did not proceed by guessing. Given the RTL’s block-2 output, the candidate wrong-carry hypotheses were enumerated and scored against it in numpy — a one-sample shift matched 64/64 — which named the bug instead of suggesting one. It was then confirmed on a hand-checkable T = 4, blk = 8 build before the fix went in.

The generalizable move: when RTL disagrees with a golden, enumerate candidate corrupt states and score each against the observed output. It converges far faster than re-reading the kernel, and it distinguishes “the state is stale” from “the state is shifted” from “the state is ignored”, which look identical in a diff.

Both realizations, one golden

Both kernels are verified through this same gate, against the same golden:

realization csynth XSI
serial (default) clean bit-exact, 5/5 completions
unroll (--unroll-lane) clean bit-exact, 5/5 completions

That is the property that makes the pair a QoR probe rather than two designs: they are numerically indistinguishable and differ only in resources and rate. If they ever disagreed here, comparing their DSP counts would be comparing two different filters.

Where to next

The remaining rung is the parameter sweep — rebuilding across sample widths and both realizations and collecting resources and throughput per point. That is where the two kernels stop being an either/or and become a curve.