Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions devlog/2026-08-08-m3-m4-sprint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# M3 software stack + M4 RTL, in one sprint

DRAFT - voice pass pending.

Two milestones' worth of machinery landed in a day and a half. Not the
milestones themselves - the training run and its gate are still ahead - but
everything they need to exist.

## Measuring the bar first

Before training anything, I measured what the denoiser is up against. The
val split (30 views, 1 spp vs 1024 spp reference): 7.06 dB PSNR, 0.034 SSIM.
The 1-spp frames are basically speckle - pixel correlation with the reference
is about 0.2, because at one sample per pixel most paths simply never find
the light. The gate I committed to: +6 dB from the ternary net, so the
denoised output has to reach about 13.1 dB on average. No RTL was supposed to
be written until that gate passes. More on that below.

Two independent implementations of the metrics (a quick audit script and the
real model/metrics.py) landed on the same 7.06 to the second decimal, which
is the kind of accidental cross-check I will happily take.

## M3: the software stack

The pieces: a dataset loader (int8 dumps to q/128 tensors), PSNR/SSIM in
plain numpy, calibration that turns the M1 stubs into real per-layer
power-of-two shifts (chosen from accumulator percentiles) and integer biases
(bias_int = round(128 * b * 2^s)), and a QAT training loop whose FP forward
now mirrors the integer path exactly - the conv output is scaled by 2^-shift,
which is where BitNet's gamma actually went.

The export path was the satisfying part. A trained (or random - bit-exactness
does not care) net exports through the M1 packer into a golden-vector case
directory whose expected output comes from the network's own integer forward.
The unmodified C++ golden model then either reproduces it bit for bit or the
build fails. It reproduces it.

## M4: the hardware, earlier than planned

The gate says no RTL until the math earns it. I bent that rule knowingly: the
RTL got written against the same golden vectors, so its correctness is
independent of whether training succeeds - and a random-weight net through
the calibration and export path gives the full chain a real oracle at any
frame size. The trained weights only change the .mem files.

Architecture, decided before any SystemVerilog: each 3x3 conv layer computes
one output channel per cycle - 144 add/sub taps into an adder tree for a
16-to-16 layer - and the five layers run as a streaming chain with a 2-line
buffer per boundary. The noisy RGB rides a FIFO to a residual add at the end.

Verification is the same religion as ever: every Verilator testbench links
the C++ golden model as its oracle, drives randomized valid/ready stalls, and
diffs bit-exactly. The requant module alone gets the 11 normative spec rows
plus an exhaustive sweep of 3.2 million (acc, shift) pairs against the C++
implementation. The full chain is bit-exact on every network case including
packed weights, at 17 cycles/pixel steady state - about 90 fps at 100 MHz on
256px frames, if it ever closes timing there.

## The numbers

Yosys, whole design: zero $mul, $div, $mod, $pow cells. Zero DSP48s after
synth_xilinx. The claim in this repo's name is now machine-checked on every
CI run.

Area: 63,697 LUTs, 11,624 FFs, 24 BRAM36s. The LUT number is roughly 3x what
I estimated, and the diagnosis is instructive: reading 144 weight codes per
cycle through dynamic part-selects synthesizes into parallel byte-mux trees -
45k LUT6s of them. The fix for M5 is to reorganize weight storage as wide
BRAM words indexed by output channel, which should collapse most of that.
Estimates are estimates; netlists do not negotiate.

## What the process caught

Worth recording because each of these would have been an ugly debug session
later: a testbench that sampled handshakes after the clock edge (would have
failed any correct DUT), a yosys -q flag that silenced the very stat output
the DSP check grepped (making it vacuously pass), a shell construct that
swallowed the check's exit code, a hierarchy command that silently blackboxes
missing modules unless you pass -check, and a golden case whose expected
output saturates uniformly to 127 - meaning it could not distinguish relu
from no-relu at all. Every one was found by adversarial verification of the
verification, not by luck.

## The gate run

Same day, as it turned out. CUDA wheel in (the uv venv has no pip - use
uv pip), 150 epochs on the 5070, FP-proxy val PSNR settling around 14.4 dB.
Then the real evaluation, on the integer path, the one that counts:

mean noisy 7.06 denoised 12.87 delta +5.81 dB (gate: >= +6.00)

Failed. By 0.19 dB. The FP proxy said 14.4; the integer path said 12.87 -
a 1.5 dB gap, far too big to be quantization.

The culprit was calibration freezing too much. Shifts are supposed to freeze
at epoch 3 - the FP forward trains against them, that is the design. But
calibrate() also computes bias_int from the FP biases as they are at epoch 2,
and the biases keep training for another 147 epochs. The integer path was
running final weights against epoch-2 biases; drift measured up to 569 LSBs
on layer 1. Recomputing bias_int from the final biases - pure arithmetic,
no retraining:

mean noisy 7.06 denoised 13.83 delta +6.76 dB (gate: >= +6.00)

Passed, with 0.76 dB of margin. The fix is now in train.py permanently:
checkpoints refresh bias_int at save time, with a test that would have
caught this from day one.

Then the part this whole repo exists for. Export the trained net through the
2-bit packer, hand it to the unmodified C++ golden model: PASS, bit-exact.
Hand the same case to the Verilator testbench:

PASS tb_denoiser trained_v0270_256px cycles=1128571 pixels=65536 cyc_per_px=17.22

The SystemVerilog design - the one with zero multiplier cells, asserted by
yosys on every CI run - denoised a real path-traced frame with trained
ternary weights, bit-identical to PyTorch's integer path. 1.13M cycles for
256x256; about 88 fps at 100 MHz if timing closes there. The triptych at the
top of the README is that frame.

The output is visibly imperfect - residual speckle survives where the input
saturates, and v1's five layers leave the contrast muddy. +6.76 dB is a
transformation, not a solved problem. Both statements are true and I intend
to keep publishing both.

## Next

M5: hardware-in-the-loop (the Verilated model linked into the renderer),
the LUT diet (63.7k today, mostly weight-ROM mux forests that belong in
BRAM), real synthesis numbers, and only then a board.
Loading