PYNQ-Z1 · Stage 6 → 11 · 2026-09-28

It Runs End to End

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.

PYNQ-Z1 · bitnet_b1_58-large (0.7B, ternary)actual board output, unedited
$ python3 bitnet_run.py gen "The capital of France is" 24
The capital of France is the city of Paris. The city has been the home of many people, from the ancient Greeks and the Romans
Per token 125.9 ms → 7.94 tok/s (context around 30) · at context 512, 136–138 ms → 7.3 tok/s

01Rounding the real weights broke the model

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-135MperplexityThe capital of France is →
As is (fp32)18.31decent English
Rounded to ternary9,176,689makers emergmakermakermaker…
Reference: random pick from a 49,152-word vocabulary49,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.

What I learned

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.

02Switching to a model trained in ternary

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-135Mbitnet_b1_58-largeImpact on the circuit
hidden / FFN / layers576 / 1536 / 301536 / 4096 / 246× the weights (136 MB in ternary)
head_dim6496Attention circuit rebuilt
q : kv heads9 : 316 : 16No 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.

03Fixing the handoff

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.

The stream in one DMA transfer Stage 6 one x only x 640 B rows × R (640 weights/row) ■ TLAST 181 transfers 83% fixed overhead Stage 7 header R R x rows × R R x rows × R … ■ 12 transfers 6 round trips on 2 HP Stage 8 header R, BPR R,BPR x 1560 B rows × R (1560 wts) R,BPR x 4120 B rows × R … ■ 5 round trips 76.87 ms Stage 7: rows fixed at 640. Input 1536 padded to 640 × 3 = 1920; the CPU added the partial sums Stage 8: rows of 1560 / 4120. groups 387 → 97, padding 20% → 1.3%, weights 169.7 → 141.3 MiB
Figure 1 / I changed the outside of the circuit. The compute part (the ternary adder tree) is the same as in Stage 4. Only the input state machine changed. LUTs went 9,557 → 9,720, barely more. Even so, the matmul for one token dropped 96.51 ms → 76.87 ms on two HP ports, reaching 95.9% of the DDR wall.

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).

04Rebuilding attention

stages 7c & 9 · head_dim 96, no GQA, and a multiplier

Before building, I re-measured the baseline

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 softmax35.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
Verificationall 37,248 words match
Context 512, one token (24 layers × 16 heads)20.22 ms
Read1.872 GB/s (93.2% of the DDR wall)
Per byte1.00 MAC (no GQA)
Multipliers17 (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.

05Taking turns on one chip

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.

One layer (× 24 layers, then lm_head) normalize to INT8 matmul q · k · v RoPE q, K, V to INT8 attention 16 heads ÷ denom, norm to INT8 matmul o residual, norm to INT8 matmul gate · up silu × up, norm to INT8 matmul down residual next-layer norm next layer matmul ── DMA 0, 1 (simple transfer) → ternary circuit × 2 attention ── DMA 2, 3 (SG transfer, sec. 6) → attention circuit × 2 CPU ── floating-point small ops (C + NEON, sec. 7) 5 transfers / layer 121 transfers / token
Figure 2 / PL and CPU hand off 10 times per layer. Colored boxes are circuits; dashed boxes are the CPU. Each box waits for the result of the one before it, so transfers can't be batched. One token makes 121 round trips between the circuits and the CPU. At Stage 10 this flow wasn't running yet. I replayed one recorded token and measured the PL alone: 98.57 ms (450,178 words match).

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.

06Streaming a growing KV without moving it

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).

Simple transfer as is (one contiguous region) H+q K 0 … T−1 V 0 … T−1 H+q K V … new K row goes here everything after this shifts (every token moves half the KV) SG transfer (bound by descriptors) ── fixed slots per head H+q K 0 … T−1 free (up to 511) V 0 … T−1 free (up to 511) just append new rows same: just append descriptor A 104 + 96T bytes descriptor B 96T bytes 1 layer: A₀ B₀ A₁ B₁ … A₇ B₇ → the circuit sees one stream Each token, only the length fields change. One TAILDESC write per layer starts it
Figure 3 / Don't move the data; rewrite the reading order. SG (scatter-gather) is a DMA feature that walks a table (of descriptors) saying "where to read, and how many bytes." K and V stay in separate places, yet reach the circuit as one stream. The only change was one line of DMA configuration; the attention circuit didn't change by a single bit. The cost was LUT +3,471 (40% overall).
One more decision ── fix the KV scale

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.

07The same answer on the Mac and the board

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).

Mac (M4) PYNQ-Z1 Same files on both e2e_core.py skeleton of one token e2e_cpu.c norm, RoPE, silu, quantize (NEON) NumpyBackend multiplies ternary matrices as integers rebuilt from the board's .bin (also checks the packing) FpgaBackend drives my circuits through 4 DMAs KV lives in CMA as INT8 179.1 MiB every circuit call logged and sent → Mac recomputes in integers Results logits (perplexity 130 tokens + 2 generations) 191 tokens, all bit-identical circuit calls (1 generation; KV rebuilt from the log) 3,509 calls · 13,055,162 words match perplexity 35.690103 = 35.690103
Figure 4 / The only difference is the "circuit stand-in." The Mac and the board run the same code, with only the circuit part swapped out. The circuit's answers are integers, so of course they match. But because even the floating-point parts agreed to the last bit, a whole token can now be compared as one unit. If anything drifted anywhere, it would show up somewhere in the logits.

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.

08Python was the slow part

the 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 changedPer tokenWhy it helped
(ref.) CPU small ops in numpy305 ms for small ops aloneThe board's numpy doesn't use NEON; 20–100 ns per element
(ref.) Plain C55 ms for small ops alonerintf is a function call; float compares stall the pipeline
(ref.) C + NEON25 ms for small ops aloneRounding by a trick, exp as a polynomial, division via a reciprocal estimate
First end-to-end version225.6 ms—
PYNQ arrays → plain numpy arrays164–172 msEvery slice of a PYNQ array cost about 100 µs
DMA control moved to C125.9 msRegister access from Python is 6–14 µs each; that added up to hundreds of µs per transfer
At one point I blamed the hardware, and was wrong

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.

09What grows as the context grows

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.

0 50 100 150 ms 64 128 256 384 512 context length T matmul 87.3 ms (independent of context) CPU small ops 26.7 ms attention 5.3 → 21.9 ms (linear in T) 119.2 135.9
Figure 5 / Only the purple grows. Matmul and CPU small ops stay flat regardless of context length; only attention grows in proportion to T. Even at context 512, attention is 16% of the total. Back in Stage 5 the story was "leave attention on the CPU and at context 2048 it takes 96% of the total." As a circuit, it became the smallest block. (Around context 16 the first few tokens are slow, so I left them off the chart.)
Context length TmatmulattentionCPUtotaltok/s
6487.35.326.6119.28.39
12887.37.726.6121.68.22
25687.212.726.7126.77.89
38487.317.426.7131.57.60
500–51287.422.026.7136.07.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).

10Projection vs. the real thing

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.

matmul (PL) attention (PL) CPU small ops Stage 10 projection sum of separate measurements 78.4 20.2 109.2 ms Real, end to end median, context 500–512 87.4 22.0 26.7 136.0 ms +9.0 +1.9 +16.0 Matmul +9.0: 10 → 97 transfers (about 6.6 ms of it is copying results out of uncached memory) CPU +16.0 is work the bench lacked (INT8 for activations and K/V, argmax over 32,002 words)
Figure 6 / The CPU missed by the most. The projected "small ops 10.68 ms" was measured in C for normalization, RoPE, silu and residuals only. In reality there is more: rounding activations to INT8 before every handoff to the circuit, converting K and V to INT8, and finding the max over the vocabulary at the end. When you measure parts separately and add them up, the work between the parts falls out. It's the kind of error you only see by running end to end.

11What I can and can't claim so far

what is measured and what is not

What I can claim. All of it measured on the real board.

What I can't claim.

The next biggest block is CPU small ops at 26.7 ms (20% of the total). Move matmul to a circuit and attention stands out; move attention and the CPU stands out. Start with the longest bar, same as always.