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.
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.
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).
The code is RTL_CoSim_NTT. Every number on these slides comes from its examples/results.md.
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.
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.
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:
`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.
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.
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())
runner.build() compiles the RTL once per parameter set. runner.test() runs a test module and writes JUnit XML. pytest wraps both (tests/test_rtl.py), so RTL tests sit in the same suite as the model tests.await ReadOnly() every signal has settled for this time step, and writes are illegal until the next edge.| Model | Says | Used for |
|---|---|---|
Functional: fhe_sim.precision.ntt_reference | What 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.barrett | What each pipeline stage computes, including how many correction steps a product needed | The 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:
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.
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:
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 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.
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:
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:
| prime | stimulus | corr_2 per million | observable corr_2 per million |
|---|---|---|---|
| near | uniform | 0 | 0 |
| near | constrained | 0 | 0 |
| mid | uniform | 102 | 8 |
| mid | constrained | 17652 | 7307 |
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.
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:
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.
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.
Five campaigns on the RTL in Verilator, each with the bit-accurate scoreboard. Two run the INJECT_BUG build, which drops the second correction:
| prime | RTL | stimulus | transactions | result | functional coverage | corr_2 hits | observable corr_2 hits | holes |
|---|---|---|---|---|---|---|---|---|
| near | clean | constrained | 20,000 | pass | 82% | 0 | 0 | corr_2, corr_2_x_sum_wraps, corr_2_x_diff_borrows |
| mid | clean | uniform | 20,000 | pass | 59% | 4 | 1 | a_zero, a_max, b_zero, b_max, w_one, w_max, corr_2_x_sum_wraps |
| mid | clean | constrained | 20,000 | pass | 100% | 332 | 129 | none |
| mid | seeded bug | uniform | 4,000 | bug escaped | 53% | 1 | 0 | a_zero, a_max, b_zero, b_max, w_one, w_max, corr_2_x_sum_wraps, corr_2_x_diff_borrows |
| mid | seeded bug | constrained | 4,000 | bug caught | 100% | 67 | 29 | none |
Source: examples/results.md in RTL_CoSim_NTT
tests/test_rtl.py), so a change that weakens the generator fails the build.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.
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.
GitHub Actions: model tests, then a {verilator, icarus} matrix. Jenkins adds JUnit reports, coverage, an exact cycle-count gate and a nightly seed sweep.
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.
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:
| lanes P | n | tests | load | compute (RTL) | compute (model) | unload | total (RTL) | total (model) | butterfly efficiency |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 16 | 4/4 pass | 16 | 56 | 56 | 16 | 88 | 88 | 0.364 |
| 1 | 128 | 4/4 pass | 128 | 490 | 490 | 128 | 746 | 746 | 0.601 |
| 1 | 1024 | 4/4 pass | 1024 | 5180 | 5180 | 1024 | 7228 | 7228 | 0.708 |
| 2 | 16 | 4/4 pass | 8 | 40 | 40 | 8 | 56 | 56 | 0.286 |
| 2 | 128 | 4/4 pass | 64 | 266 | 266 | 64 | 394 | 394 | 0.569 |
| 2 | 1024 | 4/4 pass | 512 | 2620 | 2620 | 512 | 3644 | 3644 | 0.703 |
| 4 | 16 | 4/4 pass | 4 | 32 | 32 | 4 | 40 | 40 | 0.200 |
| 4 | 128 | 4/4 pass | 32 | 154 | 154 | 32 | 218 | 218 | 0.514 |
| 4 | 1024 | 4/4 pass | 256 | 1340 | 1340 | 256 | 1852 | 1852 | 0.691 |
| 8 | 16 | 4/4 pass | 2 | 28 | 28 | 2 | 32 | 32 | 0.125 |
| 8 | 128 | 4/4 pass | 16 | 98 | 98 | 16 | 130 | 130 | 0.431 |
| 8 | 1024 | 4/4 pass | 128 | 700 | 700 | 128 | 956 | 956 | 0.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.
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 organisation | efficiency | baseline algorithm | verdict | Min-KS + seeded keys + OTF pt | verdict |
|---|---|---|---|---|---|
| ideal (simulator default) | 1.000 | 13.94 ms | memory-bound | 7.19 ms | MAC-bound |
| 16 cores x 256 lanes, this RTL | 0.771 | 14.14 ms | memory-bound | 7.70 ms | MAC-bound |
| 16 cores x 256 lanes, ping-pong I/O | 0.955 | 13.97 ms | memory-bound | 7.27 ms | MAC-bound |
| 4 cores x 1024 lanes, this RTL | 0.696 | 14.26 ms | memory-bound | 7.95 ms | MAC-bound |
| 1 core x 4096 lanes, this RTL | 0.500 | 14.63 ms | memory-bound | 9.06 ms | NTT-bound |
| 1 core x 4096 lanes, ping-pong I/O | 0.571 | 14.46 ms | memory-bound | 8.54 ms | MAC-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 organisation | efficiency | baseline algorithm | verdict | Min-KS + seeded keys + OTF pt | verdict |
|---|---|---|---|---|---|
| ideal (simulator default) | 1.000 | 17.46 ms | NTT-bound | 26.65 ms | NTT-bound |
| 2 cores x 256 lanes, this RTL | 0.771 | 21.33 ms | NTT-bound | 33.51 ms | NTT-bound |
| 2 cores x 256 lanes, ping-pong I/O | 0.955 | 18.07 ms | NTT-bound | 27.73 ms | NTT-bound |
| 1 core x 512 lanes, this RTL | 0.744 | 22.00 ms | NTT-bound | 34.59 ms | NTT-bound |
Source: examples/results.md in RTL_CoSim_NTT
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:
| operands | toggles per butterfly | relative to uniform random |
|---|---|---|
| uniform random a, b, w | 223.0 | 1.00 |
| random data, one twiddle | 224.2 | 1.01 |
| small data (< 2^10), one twiddle | 164.0 | 0.74 |
| constant operands | 0.1 | 0.00 |
Source: examples/results.md in RTL_CoSim_NTT
w*b still sees random b.-Wall, and gate on exact cycle counts in CI.examples/results.md.