At the end of the previous entry I listed three things I couldn't claim yet. Not a single token had been generated end to end, the data streamed through was random numbers, and the temperature of the exponent table was a placeholder. This entry records how I cleared all three.
Now, when you give it text, the board computes one token at a time and returns a continuation. Matmul and attention run on circuits I built; the rest runs on the CPU. When the Mac does the same computation, the logits match down to the last bit.
stage 6 · the circuit was right, the model was not
The first thing I did was take the real weights of SmolLM2-135M, the model I had used so far, round them to ternary (−1 / 0 / +1), and stream them through the circuit. The circuit's answers matched numpy on all 239,232 words. The circuit works correctly.
As a model, though, it was completely broken.
| SmolLM2-135M | perplexity | The capital of France is → |
|---|---|---|
| As is (fp32) | 18.31 | decent English |
| Rounded to ternary | 9,176,689 | makers emergmakermakermaker… |
| Reference: random pick from a 49,152-word vocabulary | 49,152 | — |
187 times worse than picking at random. Changing the rounding per row barely helps. Each weight is off by 50–60%, and that error stacks up over 30 layers.
BitNet b1.58 is the name of a model trained in ternary from the start. It is not the same thing as normally trained weights rounded to ternary afterward. This has nothing to do with whether the circuit is correct, but it means I had to pick a different model to run.
This stage turned up one more thing. The circuit ran at 100.0% of the datapath width, yet 83% of the time per token was fixed overhead from DMA calls. The format back then allowed "one x (input vector) per transfer," so every time the matrix changed I had to end the transfer, and one token took 181 calls. That became the homework for section 3.
stage 6.5 · the only one that fits in 512 MB
I looked for a model already trained in ternary that fits in this board's 512 MB. The only candidate was
1bitLLM/bitnet_b1_58-large (0.7B).
I wrote the same quantization steps as the circuit in numpy and checked it:
perplexity 17.90. Still ternary, and better than SmolLM2 in fp32 (18.31).
Its shape is different, though, so both the circuits and the way the data is packed had to be rebuilt.
| SmolLM2-135M | bitnet_b1_58-large | Impact on the circuit | |
|---|---|---|---|
| hidden / FFN / layers | 576 / 1536 / 30 | 1536 / 4096 / 24 | 6× the weights (136 MB in ternary) |
| head_dim | 64 | 96 | Attention circuit rebuilt |
| q : kv heads | 9 : 3 | 16 : 16 | No GQA. Each byte is reused once instead of 3 times |
| lm_head | — | fp16 (not ternary) | As is, 98 MB read every token |
To save memory, I dropped lm_head (the final matrix that scores all 32,002 vocabulary entries) to ternary. In INT8 the quality stays the same (17.90 → 17.89), but it doesn't fit in the region DMA can read (CMA). In ternary, perplexity doubles, 17.90 → 35.59, but the text doesn't fall apart. This is the quality price this setup pays. I come back to it in the last section.
stages 7 & 8 · a header, then a variable row length
How do you cut 181 calls? The circuit is already fast, so I changed how data is handed to it. Put a small header at the front of the stream saying "R rows follow," and the circuit can move on to the next group without ending the transfer when x changes. Next, I let the header also carry the length of one row.
Variable row length paid off in three ways at once. Less padding cut the bytes read by 17%. Group boundaries dropped to a quarter. And the cleanup step of adding partial sums on the CPU went away. One more thing: because the weights shrank to 141.3 MiB, attention could later share the same chip (section 5).
stages 7c & 9 · head_dim 96, no GQA, and a multiplier
I measured bitnet-large's attention on the CPU: 96.22 ms (context 512).
The converted estimate I had been using was 180 ms.
The old bench had been built with NEON disabled (-O2). I corrected the earlier entries too.
With that, matmul (96.51 ms) and attention (96.22 ms) turned out to be roughly half and half.
The Stage 5b circuit only handles head_dim 64 and GQA 3:1, so it can't be used.
Before rebuilding, I checked one more thing in numpy.
Stage 5b squeezed the score into 8 bits with a fixed shift, s >> 8.
That ignores the quantization scales of q and K. On the real model,
the temperature of the exponent drifts from layer to layer and head to head.
| How attention is built (lm_head in ternary) | perplexity |
|---|---|
| Proper float softmax | 35.56 |
| Stage 5b as is (fixed shift) | 58.25 |
| Multiplier m in the header (one multiplier) | 35.90 |
I write m = TAU · sq · sk / √96, scaled by 224 into an integer, into the header.
The circuit multiplies the score by it, then looks up the exponent table. Real values range 10,818–79,505.
That doesn't fit in 16 bits, but fits exactly in the DSP's 18-bit input.
That settles the previous entry's "the exponent table's temperature is a placeholder."
| Stage 9 attention circuit (2 HP ports) | Value |
|---|---|
| Verification | all 37,248 words match |
| Context 512, one token (24 layers × 16 heads) | 20.22 ms |
| Read | 1.872 GB/s (93.2% of the DDR wall) |
| Per byte | 1.00 MAC (no GQA) |
| Multipliers | 17 (Stage 5b: 48) |
| vs. CPU (NEON on) | 4.8× |
At 142.86 MHz, timing failed both times. The violation was not in my circuit but in the transfer length counter inside AXI DMA. I dropped the clock to 125 MHz. Two ports' datapath width is 2.000 GB/s, almost the same as the DDR wall of 2.009. The circuit is bandwidth-bound, so the lower clock costs almost nothing.
stage 10 · four DMAs, no switch
I put the matmul circuit and the attention circuit in the same bitstream. Within a token, the two always run in alternation. So I built no switching logic. I placed four DMAs and just kick whichever one is needed. They never run at the same time, so they don't fight over DDR.
Sharing one bitstream cost 1.4% (projected 107.8 ms with separate bitstreams, 109.3 ms shared). Nearly all of that difference came from extra round trips, because DMA transfers were cut to 8 MiB or less. Resources: LUT 34%. Two-thirds of the chip is still free.
stage 11 · scatter-gather instead of copying
To run end to end, K and V must grow by one row per token (the KV cache). This is where the attention circuit's input format became a problem.
The circuit takes a stream of [header][q][K, T rows][V, T rows], joined head after head.
When T grows by 1, the start of V and the positions of every later head shift.
A simple DMA can only read "one contiguous region," so the choices are to repack the whole KV every token (36 MB at T=512)
or to end the transfer at every head (384 per token).
Up to Stage 9, the scale for turning K and V into INT8 came from "the maximum over all positions." That looks at future K that hasn't arrived yet. And the maximum changes every time T grows, so every past INT8 value would have to be redone. You can't append that way.
So I fixed the scale per layer and per head. I ran 144 tokens of text, separate from the evaluation text, measured max|K| and max|V|, and keep using those. Perplexity went 35.90 → 35.61. Fixing the scale actually made it better.
bit-identical logits on two machines
Running end to end means more to verify. Not just whether the circuit's answers are right, but whether the whole token is right, including the CPU's floating-point math. So I ran the same computation on the Mac too, and compared both sets of logits (the scores for all 32,002 vocabulary entries).
The Mac (Apple M4) and the board (Cortex-A9) are both ARM, so the same arm_neon.h instructions work on both.
Even so, the results don't match unless you make them. These are the rules I set to make them match.
-ffp-contract=off). Clang on the Mac fuses them into FMA by defaultexpf and rintf can differ in the last bit from one libm to anothervrecpsq_f32 to refine reciprocals. Same name, but on AArch64 it is a fused operation and gives different results from ARMv7the hardware was fine all along
When it first ran end to end, one token took 225.6 ms. That's twice the Stage 10 projection (109 ms). This table records knocking out the causes one at a time.
| What changed | Per token | Why it helped |
|---|---|---|
| (ref.) CPU small ops in numpy | 305 ms for small ops alone | The board's numpy doesn't use NEON; 20–100 ns per element |
| (ref.) Plain C | 55 ms for small ops alone | rintf is a function call; float compares stall the pipeline |
| (ref.) C + NEON | 25 ms for small ops alone | Rounding by a trick, exp as a polynomial, division via a reciprocal estimate |
| First end-to-end version | 225.6 ms | — |
| PYNQ arrays → plain numpy arrays | 164–172 ms | Every slice of a PYNQ array cost about 100 µs |
| DMA control moved to C | 125.9 ms | Register access from Python is 6–14 µs each; that added up to hundreds of µs per transfer |
A measurement showed each DMA transfer taking about 150 µs longer than theory. I guessed that polling the status register was slowing the transfer itself, and built a version that "just watches the clock until the transfer should be done." Nothing changed.
Once I moved DMA control to C, transfers finished in the theoretical time. The hardware was fine all along; the delay was on the Python side. Still, the extra 250 µs from the "wait on the clock, then read once" experiment remains unexplained.
Stage 6 had the same story: "the circuit runs at 100% of the datapath width, yet call overhead is 83%." The same thing happened here. Handing data to the circuit takes as much work as making the circuit fast. That's where this stage spent most of its time.
only attention grows with T
I kept generating up to context 512 (the size of the board's KV buffer) and logged each component's time for every token.
| Context length T | matmul | attention | CPU | total | tok/s |
|---|---|---|---|---|---|
| 64 | 87.3 | 5.3 | 26.6 | 119.2 | 8.39 |
| 128 | 87.3 | 7.7 | 26.6 | 121.6 | 8.22 |
| 256 | 87.2 | 12.7 | 26.7 | 126.7 | 7.89 |
| 384 | 87.3 | 17.4 | 26.7 | 131.5 | 7.60 |
| 500–512 | 87.4 | 22.0 | 26.7 | 136.0 | 7.35 |
Units are ms. Each row is the median of the surrounding 8 tokens. In a separate run under the same conditions, context 512 took 138.0 ms (7.25 tok/s).
where the budget went
At Stage 10 I measured the parts separately and added them up, projecting 109.2 ms at context 512. The real thing took 136.0 ms. Here is where the 26.8 ms difference came from.
what is measured and what is not
What I can claim. All of it measured on the real board.
What I can't claim.