The memory wall sits at 1.97 GB/s, and no design moves it. So I stopped widening the road and made the cargo smaller instead. With ternary weights, the same wall delivers 4.8× as many weights. Not a single multiplier is used.
Update (2026-09-13): I later measured "everything except the matrix multiply" and found that attention takes 83 ms on the CPU. I added this to section 7.
what the three stages measured
In Stage 2 I measured where the wall is. In Stage 3 I built a circuit that can compute right up to the wall. In Stage 4 I pulled 5× out of the same wall. Everything was measured on this one board.
where the wall actually is
The first job was to pin down "how fast can I read at all." I wrote this up in detail in a separate entry (Two Ceilings), so here are just the key points.
There are two ceilings. The speed of the DDR itself (2.1 GB/s, fixed when you buy the board), and the datapath width on the PL side (8 bytes × clock, which design can change). With one HP port, the datapath width runs out first and only half the DDR gets used. Only with two ports did the DDR side saturate.
does computing cost bandwidth?
In Stage 2 I used a circuit that just accepts data and throws it away. I swapped that for real multipliers. There was only one question: can it hold 1.97 GB/s while actually computing?
It could easily have failed. If the arithmetic can't keep up, the circuit stalls the upstream, and bandwidth drops the moment that happens. So I set one absolute design rule: Keep the accept signal at 1, always. Never stall the upstream.
the first build did not meet timing
The first build failed timing. It was short by almost exactly 0.5 ns. On an FPGA, "does the signal arrive within one clock" is an absolute requirement. If it doesn't, there is no error. Wrong values just flow through, silently.
use_dsp.
That took it from −0.502 ns → +0.062 ns.
The real oversight: the chip has 220 DSPs, and 0 of them were being used.
I could have run it anyway with timing unmet. It does run. But when data gets corrupted, it just returns wrong answers without saying anything. Then I could no longer tell whether a problem was in the circuit's logic or in timing. So I fixed it first, before moving on to measurement.
three values per weight, five weights per byte
The wall doesn't move. So the only option is to get more weights out of the same single byte. That is ternary quantization (BitNet b1.58). Each weight is rounded to one of three values: −1 / 0 / +1.
With three values, each weight carries log₂3 = 1.585 bits. In practice I use 3⁵ = 243 ≤ 256 and pack exactly 5 per byte. That is 5× the density of INT8.
what it cost in silicon
Here is how Stage 3 and Stage 4 used the chip's resources, side by side. This comparison is the thing I most wanted to show in this whole project.
| Stage | Compute units | Read | Weight throughput | DSP | LUT | Checked vs numpy |
|---|---|---|---|---|---|---|
| 2 · no compute | — | 1.97 | — | — | — | — |
| 3 · INT8 | 16 | 1.91 | 1.91 | 20 | 8,175 | 131,070 rows |
| 4 · ternary | 80 | 1.83 | 9.16 | 0 | 9,557 | 524,278 rows |
The last column is the result of checking every single row the PL produced against numpy. Fast but broken is worthless, so I did this at every stage. In fact, a bug where the generator script's pipeline depth was off by one would not have been found without this check—it shows up as "runs fine, but every answer is wrong."
what this is not, yet
At this speed, a ternary-quantized 135M-parameter model (27 MB of weights) works out to 14.7 ms per token = about 68 tok/s. But that is an upper bound for the matrix multiply alone. Attention, layernorm, softmax and the KV cache haven't been built at all yet.
So I measured how heavy the rest actually is (update, 2026-09-13). I wrote everything except the matrix multiply in C at the real dimensions of SmolLM2-135M and ran it on the board's CPU. It was far heavier than expected, and it changed the premise of the plan.
At context 512, attention alone takes 83 ms. That is 5.7× the matrix multiply's 14.68 ms. The whole system only reaches 9.8 tok/s, one seventh of 68 tok/s.
It isn't a bad implementation. A naive version that converts to float took 158 ms. Computing directly in INT8 still takes 83 ms, an effective 0.21 G MAC/s. That is close to the measured upper limit of the CPU's INT8 dot product, 0.28 G MAC/s. It is the raw performance of the Cortex-A9. Rewriting it won't make it shorter.
The next step is clear. Attention becomes a circuit too. Luckily, what needs building is almost the same as the existing engine—the KV cache is just another INT8 stream, and QKT and AV are nothing but dot products.
Only the sizes differ. With GQA, 3 K/V heads are shared by 9 heads,
so each byte is reused 3.15 times (the matrix multiply used it once).
That means I want 48 MACs—still only a little over 20% of the 220 DSPs.
Softmax's exp can also go into the circuit as a BRAM lookup table.
Attention becomes bandwidth-bound: 2.94 ms at context 512. The budget is 14.68 + 2.94 + 4.2 ≈ 21.8 ms, or about 46 tok/s. On top of that, it holds up much better as the context grows— the PL's bandwidth advantage applies directly to reading the KV cache as well.
After that, this is what remains.