Simulation Engineering Toolkit — Presentation 05

Verification Bridge: cocotb, Verilator and Golden Models

A SystemVerilog NTT core verified in both directions: the FHE simulator's NTT as the golden model, a bit-accurate scoreboard, transactors, constrained-random stimulus, functional coverage and the crosses that make a bug observable, a seeded bug that uniform stimulus misses, Verilator and Icarus in CI, and RTL cycle counts calibrating the simulator.

cocotb Verilator Golden models Constrained random Functional coverage Cycle calibration
Golden model → Stimulus → Scoreboard → Coverage → Cycles → Calibrate
00

Topics We'll Cover

Concepts used here, and where they are explained. Each links to a glossary entry: this series glossary, or the glossaries of LLM Inference Simulators and FHE Accelerator Simulators for concepts those series already explain.

01

A Bridge in Both Directions

An architecture simulator and the RTL describe the same machine at different levels. Each can check the other. The simulator's functional model is the golden model that says what the RTL must compute. The RTL's measured cycle counts say how fast the simulator may assume each unit runs. The LLM Inference Simulators glossary introduces co-simulation. This deck builds both directions for one unit: the NTT that dominates FHE workloads (FHE Accelerator Simulators 01, slide 05).

FHE_Accelerator_Simntt_reference (golden NTT)cost model: ntt_bfly_per_cycle cocotb testbench (Python)driver, monitor, scoreboardfunctional coverage RTL on VerilatorBarrett multiplier, butterfly,P-lane NTT core expected stimulus results amber: measured cycles per transform -> butterfly efficiency -> derated ntt_bfly_per_cycle green: the simulator checks the RTL. amber: the RTL calibrates the simulator.

The code is RTL_CoSim_NTT. Every number on these slides comes from its examples/results.md.

02

The Design Under Test: an NTT Core

mod_mul_barrett

a*b mod q with a run-time modulus, one per RNS limb, and a precomputed mu = floor(2^2W / q) (modular reduction). Four pipeline stages, one result per cycle, W = 50 bits.

ntt_butterfly

Cooley-Tukey: (a + w*b, a - w*b) mod q. Five cycles of latency. A tag travels with the data and carries the write-back addresses.

ntt_core

An iterative radix-2 NTT of any n up to 2LOGN with P butterfly lanes: bit-reversed load, log2n stages, natural-order unload. Ready/valid streams.

Barrett's quotient estimate is never too large and at most two too small, so the remainder needs up to two correction steps. The second is the subject of most of this deck. A compile-time define seeds a bug there by dropping it:

rtl/mod_mul_barrett.sv: the final correction, and the seeded bug source
`ifdef INJECT_BUG
            if (r0 >= q_ext) begin
                r    <= W'(r0 - q_ext);
                corr <= (r0 >= q2_ext) ? 2'd2 : 2'd1;
            end else begin
                r    <= W'(r0);
                corr <= 2'd0;
            end
`else
            if (r0 >= q2_ext) begin
                r    <= W'(r0 - q2_ext);
                corr <= 2'd2;
            end else if (r0 >= q_ext) begin
                r    <= W'(r0 - q_ext);
                corr <= 2'd1;
            end else begin
                r    <= W'(r0);
                corr <= 2'd0;
            end
`endif

All three modules are -Wall lint-clean in Verilator and parse in Icarus. The data memory is a register array with 2P read ports, which is fine for simulation; a chip would bank SRAMs instead, and the cycle counts would not change.

03

cocotb in One Page

cocotb runs Python coroutines alongside an HDL simulator. A test is an async function that drives signals, and awaits triggers (RisingEdge, ClockCycles, ReadOnly, Timer), letting simulated time pass between them. Background coroutines started with cocotb.start_soon act as clocks and monitors. Deck 06, slide 12 introduces it on a modular adder; here it carries a whole verification environment.

tb/test_butterfly.py: a monitor coroutine, started in the background source
async def monitor():
    nonlocal received
    while True:
        await RisingEdge(dut.clk)
        await ReadOnly()
        if dut.out_valid.value == 1:
            got = (int(dut.x.value), int(dut.y.value), int(dut.out_tag.value), int(dut.corr.value))
            want = expected[received]
            if got != want and len(errors) < 10:
                errors.append(f"txn {received}: got {got}, want {want}")
            received += 1

cocotb.start_soon(monitor())
04

Two Golden Models and a Scoreboard

ModelSaysUsed for
Functional: fhe_sim.precision.ntt_referenceWhat the transform must be: X_k = sum_j x_j w^(jk) mod q, by definition, O(n2)Every word of every transform the core computes, at every size from 2P to 1024
Bit-accurate: ntt_cosim.model.barrettWhat each pipeline stage computes, including how many correction steps a product neededThe butterfly scoreboard, and the coverage sample of every transaction

The functional model is the FHE simulator's own code, so the RTL is checked against the same definition the simulator reasons with. The bit-accurate model mirrors the RTL step by step:

src/ntt_cosim/model.py: Barrett as the RTL does it, W + 2 bits and all source
def barrett(a: int, b: int, q: int, w_bits: int, mu: int | None = None) -> tuple[int, int]:
    """(a * b mod q, correction steps), computed exactly as rtl/mod_mul_barrett.sv does."""
    if mu is None:
        mu = barrett_mu(q, w_bits)
    x = a * b
    q3 = ((x >> (w_bits - 1)) * mu) >> (w_bits + 1)
    r0 = (x - q3 * q) & ((1 << (w_bits + 2)) - 1)       # the RTL keeps W + 2 bits
    if r0 >= 2 * q:
        return r0 - 2 * q, 2
    if r0 >= q:
        return r0 - q, 1
    return r0, 0

The scoreboard compares each output in order with the model's prediction. The model also reports the correction count the RTL brings out on a corr port, so a hidden mismatch inside the pipeline shows up even when the final value happens to be right.

05

Transactors: Driving and Sampling Ready/Valid

A transactor turns transactions (a vector to transform) into pin wiggles and back. The NTT core's streams use ready/valid handshakes: a beat moves on the clock edge when both are high. The transactor drives each cycle's inputs at the falling edge, waits for the read-only phase, and records the handshakes the next rising edge will commit:

tb/test_ntt_core.py: one loop iteration per clock cycle source
while len(out) < n:
    await FallingEdge(d.clk)
    cycle += 1
    send = k < beats and not (rng is not None and rng.random() < p_in_gap)
    if send:
        d.in_data.value = sum(x[k * P + l] << (l * self.W) for l in range(P))
    d.in_valid.value = int(send)
    ready = not (rng is not None and rng.random() < p_out_stall)
    d.out_ready.value = int(ready)
    await ReadOnly()
    if send and d.in_ready.value == 1:
        first_in = cycle if first_in is None else first_in
        last_in = cycle
        k += 1
    if ready and d.out_valid.value == 1:
        v = int(d.out_data.value)
        out.extend((v >> (l * self.W)) & self.mask for l in range(P))
        first_out = cycle if first_out is None else first_out
        last_out = cycle
The bug this replaced

The first version sampled in_ready after the rising edge. On the last input beat the core had already left its load state, so the beat looked rejected, was sent again, and the test hung. Signals sampled after an edge show the next cycle's state. Sample a handshake in the cycle it happens, before the edge that commits it.

06

Constrained-Random Stimulus

Uniform random operands spread evenly over [0, q), but the corners live in tiny regions: 0, 1, q-1, and products large enough to stress the reduction. A constrained-random generator still covers the whole range, but biases the distribution towards the corners:

src/ntt_cosim/stimulus.py: corner-biased operands source
def _corner(rng: random.Random, q: int) -> int:
    r = rng.random()
    if r < 0.10:
        return rng.choice((0, 1, q - 1, q - 2))
    if r < 0.55:
        return q - 1 - rng.randrange(max(1, q >> 8))      # the top 1/256 of the range
    return rng.randrange(q)


def constrained(rng: random.Random, q: int) -> tuple[int, int, int]:
    """(a, b, w): corner-biased operands and twiddles."""
    return _corner(rng, q), _corner(rng, q), _corner(rng, q)

How often each generator reaches Barrett's second correction, measured over a million draws of the bit-accurate model:

primestimuluscorr_2 per millionobservable corr_2 per million
nearuniform00
nearconstrained00
miduniform1028
midconstrained176527307

Source: examples/results.md in RTL_CoSim_NTT

For the near prime, just under 250, mu is almost exact and no draw ever needed a second correction. That bin is excluded for that prime, with the reason written down. That is part of coverage closure, not a way around it.

07

Functional Coverage, and the Crosses That Matter

Functional coverage counts the situations the stimulus created, in named bins, whatever the code coverage says (deck 06). Each butterfly transaction is sampled from the model's view of it:

src/ntt_cosim/coverage.py: bins and crosses source
def sample_butterfly(cov: Coverage, a: int, b: int, w: int, q: int, t: int, corr: int, gap: int) -> None:
    """Sample one butterfly transaction; t = w*b mod q, corr = Barrett correction steps."""
    cov.hit("a_zero", a == 0)
    cov.hit("a_max", a == q - 1)
    cov.hit("b_zero", b == 0)
    cov.hit("b_max", b == q - 1)
    cov.hit("w_one", w == 1)
    cov.hit("w_max", w == q - 1)
    cov.hit("sum_wraps", a + t >= q)
    cov.hit("sum_no_wrap", a + t < q)
    cov.hit("diff_borrows", a < t)
    cov.hit("diff_no_borrow", a >= t)
    cov.hit(f"corr_{corr}")
    # Crosses. A wrong second correction leaves t off by q, which the add/subtract stage
    # hides unless the sum wraps or the difference borrows: only these crosses make a
    # bug in that step observable at the outputs.
    cov.hit("corr_2_x_sum_wraps", corr == 2 and a + t >= q)
    cov.hit("corr_2_x_diff_borrows", corr == 2 and a < t)
    cov.hit("back_to_back", gap == 0)
    cov.hit("after_gap", gap > 0)

Why the crosses. If the second correction is dropped, the product t = w*b mod q comes out as t + q. The add stage computes a + t + q - q, which is right whenever a + t < q, and the subtract stage gives a - t, right whenever a ≥ t. So the error is masked unless the sum wraps or the difference borrows. Hitting corr_2 is not enough. Only the crosses show the stimulus made the bug observable. That was found the hard way, as slide 09 shows.

08

Interactive: Coverage Closure

Runs the bit-accurate Barrett butterfly in your browser (BigInt arithmetic) on uniform or corner-biased stimulus, and fills the bins as it goes. Compare the rates with the recorded million-draw study below. The browser's random numbers are not seeded, so counts vary from run to run.

09

A Seeded Bug: Caught or Escaped

Five campaigns on the RTL in Verilator, each with the bit-accurate scoreboard. Two run the INJECT_BUG build, which drops the second correction:

primeRTLstimulustransactionsresultfunctional coveragecorr_2 hitsobservable corr_2 hitsholes
nearcleanconstrained20,000pass82%00corr_2, corr_2_x_sum_wraps, corr_2_x_diff_borrows
midcleanuniform20,000pass59%41a_zero, a_max, b_zero, b_max, w_one, w_max, corr_2_x_sum_wraps
midcleanconstrained20,000pass100%332129none
midseeded buguniform4,000bug escaped53%10a_zero, a_max, b_zero, b_max, w_one, w_max, corr_2_x_sum_wraps, corr_2_x_diff_borrows
midseeded bugconstrained4,000bug caught100%6729none

Source: examples/results.md in RTL_CoSim_NTT

10

Verilator, Icarus and CI for RTL

Verilator

Compiles the RTL to C++ (two-state, cycle-based), so it is fast. --lint-only -Wall is a free static check. Without root, the Ubuntu package can be unpacked with dpkg -x and pointed to with VERILATOR_ROOT.

Icarus Verilog

An event-driven four-state interpreter: slower, but a second opinion. The same cocotb tests run on both (SIM=icarus). A design that only passes on one simulator has a bug or a dialect problem.

The pipeline

GitHub Actions: model tests, then a {verilator, icarus} matrix. Jenkins adds JUnit reports, coverage, an exact cycle-count gate and a nightly seed sweep.

Jenkinsfile: lint, model tests, RTL on two simulators source
stage('Lint') {
    steps {
        sh(env.VPATH + 'verilator --lint-only -Wall rtl/*.sv --top-module ntt_core')
        sh(env.VPATH + 'verilator --lint-only -Wall -GP=4 rtl/*.sv --top-module ntt_core')
        sh 'iverilog -g2012 -o /dev/null rtl/*.sv'
    }
}

stage('Model tests') {
    steps {
        sh '.venv/bin/pytest -m "not rtl" --junitxml=pytest-model.xml --cov=ntt_cosim --cov-report=xml:coverage.xml'
        recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'coverage.xml']], sourceCodeRetention: 'LAST_BUILD')
    }
}

stage('RTL tests (Verilator)') {
    steps {
        sh(env.VPATH + "SIM=verilator NIGHTLY=${params.NIGHTLY ? 1 : 0} .venv/bin/pytest -m rtl --junitxml=pytest-verilator.xml")
    }
}

stage('RTL tests (Icarus)') {
    steps {
        sh(env.VPATH + 'SIM=icarus .venv/bin/pytest -m rtl --junitxml=pytest-icarus.xml')
    }
}

stage('Cycle-count gate') {

Cycle counts are deterministic, so the performance gate is exact: any transform that takes one more cycle than ci/cycle_baseline.json fails the build. That catches a change that adds a pipeline stage before it silently costs throughput.

11

RTL Cycle Counts and a Cycle Model

Each stage issues n/2P butterfly groups, then waits for its last results to be written back before the next stage may read them. The wait is the five-cycle butterfly pipeline plus one cycle to see it is empty:

load n/P . . . unload n/P issue n/2Pdrain 6 stage 0stage 1stage log2(n) - 1 compute = log2(n) (n/2P + 6)total = 2n/P + computeefficiency = (n/2) log2(n) / (P total)
lanes Pntestsloadcompute (RTL)compute (model)unloadtotal (RTL)total (model)butterfly efficiency
1164/4 pass1656561688880.364
11284/4 pass1284904901287467460.601
110244/4 pass1024518051801024722872280.708
2164/4 pass84040856560.286
21284/4 pass64266266643943940.569
210244/4 pass51226202620512364436440.703
4164/4 pass43232440400.200
41284/4 pass32154154322182180.514
410244/4 pass25613401340256185218520.691
8164/4 pass22828232320.125
81284/4 pass169898161301300.431
810244/4 pass1287007001289569560.669

Source: examples/results.md in RTL_CoSim_NTT

The model matches every measurement exactly, at every size and lane count built, so it can be trusted to extrapolate to n = 216, too large to simulate quickly with a register-array memory. More lanes finish sooner but waste a larger share of each stage on the drain: efficiency falls as P grows.

12

Feeding the RTL Back Into the Simulator

FHE_Accelerator_Sim costs an NTT at ntt_bfly_per_cycle butterflies per cycle: efficiency 1. Derating it by the efficiency this RTL achieves at N = 216 needs one more fact: how the 4096 lanes are grouped into cores. The simulator does not say, so several groupings are compared:

NTT organisationefficiencybaseline algorithmverdictMin-KS + seeded keys + OTF ptverdict
ideal (simulator default)1.00013.94 msmemory-bound7.19 msMAC-bound
16 cores x 256 lanes, this RTL0.77114.14 msmemory-bound7.70 msMAC-bound
16 cores x 256 lanes, ping-pong I/O0.95513.97 msmemory-bound7.27 msMAC-bound
4 cores x 1024 lanes, this RTL0.69614.26 msmemory-bound7.95 msMAC-bound
1 core x 4096 lanes, this RTL0.50014.63 msmemory-bound9.06 msNTT-bound
1 core x 4096 lanes, ping-pong I/O0.57114.46 msmemory-bound8.54 msMAC-bound

Source: examples/results.md in RTL_CoSim_NTT

On the NTT-starved small design, every microsecond of NTT time is on the critical path, and the same derating costs up to 26%:

NTT organisationefficiencybaseline algorithmverdictMin-KS + seeded keys + OTF ptverdict
ideal (simulator default)1.00017.46 msNTT-bound26.65 msNTT-bound
2 cores x 256 lanes, this RTL0.77121.33 msNTT-bound33.51 msNTT-bound
2 cores x 256 lanes, ping-pong I/O0.95518.07 msNTT-bound27.73 msNTT-bound
1 core x 512 lanes, this RTL0.74422.00 msNTT-bound34.59 msNTT-bound

Source: examples/results.md in RTL_CoSim_NTT

13

Switching Activity as a Power Proxy

Dynamic power is roughly activity × capacitance × V2 × f. Before a gate-level netlist exists, toggle counts on the RTL's registers give the activity term and show how strongly power depends on the data (RTL power analysis). The testbench samples the multiplier's pipeline registers and the outputs every cycle:

operandstoggles per butterflyrelative to uniform random
uniform random a, b, w223.01.00
random data, one twiddle224.21.01
small data (< 2^10), one twiddle164.00.74
constant operands0.10.00

Source: examples/results.md in RTL_CoSim_NTT

14

What to Take Away