Devlog #2: Building the NTT Core
Last time I ended on “okay, I’ll build a tiny NTT accelerator for Kyber on the Shrike Lite.” So that’s what I’ve been doing. No LEDs blinking yet since this whole devlog is me in a simulator, fighting my own garbage code.
But it’s progress, and it’s the kind of progress where I learned something every single time it broke.
Quick recap of the target: Kyber’s math runs mod q = 3329, and the whole NTT is 7 layers of 128 butterflies each (256 coefficients, standard Cooley-Tukey). On a chip with 1120 LUT5s and 32kb of BRAM, getting this to fit is a real damn sisyphean task. This sh*t is like tech from the 2000’s.
Started at the bottom: a 16-bit Han-Carlson adder, because I refuse to put ripple-carry anywhere near a critical path (We’re civilized men, writing civilized code, not animals).
Could I have done just a simple carry-look ahead? Yes, yes I could have. No, I don’t think I will. Fan out is getting crazy already, may as well use a PPA atp.
Built it as a proper parallel-prefix tree using 2D wire arrays in Verilog instead of copy-pasting carry logic by hand.
Found one non-bug worth noting for later: the top half of the adder (bits 8–15) isn’t a true prefix tree, it just ripples off the bit-7 carry. Works fine functionally, just means the top half is roughly double the gate depth of the bottom half. But, really, who cares. Not me. CBA.
The Montgomery reducer (a.k.a. the finite state machine from hell):
This is where it got ugly. Montgomery reduction is how you do modular multiplication without a hardware divider:
- multiply,
- then reduce using a precomputed constant instead of dividing by q every time.
I wrote it, simulated it, ran it cycle by cycle in EDA Playground because Icarus was being uncooperative (yeah, when does Icarus help anyways??? may as well switch to Quartus at this point), and found: add_a/add_b were registers, so they only update after the clock edge, but result <= add_sum was reading the sum from the previous cycle’s stale operands.
Every state that tried to compute-and-latch in one cycle was quietly latching garbage. Bit 0 of t_low was getting dropped somewhere in there too. Phenomenal. Simply beautiful.
Fixed it by making the adder operands purely combinational (mux on state), so add_sum always reflects the current state’s actual intent.
I thought that fixed it.
No, why would it fully fix it? Of course it would not. There was still a state-transition bug lurking. Very fun to debug.
The butterfly
This is the unit that actually does the NTT math: read two coefficients + a twiddle factor, multiply, reduce, add/subtract, write back. Spent a good chunk of time chasing what looked like a hang here, that turned out to be nothing but a testbench race condition - sampling signals in the same cycle they change, not an actual DUT problem. Frick me.
Bonus: figured out how BRAM actually gets wired
Dug through some existing Renesas example projects (shoutout to whoever wrote the FIFO-on-BRAM one, you saved my life)
Where it stands
Working (in simulation, against reference vectors):
Han-Carlson adder
Montgomery reducer
Modular ALU
Serial multiplier (shoutout to whoever made the Kyber spec sheet; you saved my ass because most of the time all I have to do is bit shift to “multiply”)
NTT butterfly
Address generator
Full 256-point forward NTT, top to bottom
Still to build:
- the inverse NTT (GS-DIF, reusing everything above),
- the pointwise multiplication step for after both polynomials are transformed,
- and the actual RP2040-FPGA SPI link tying it all to real hardware instead of a testbench.
atleast man the sim works, thats pretty damn good. im happy this works. next is getting it on the hardware and doing some actual cryptography with it.