At the end of the previous entry I learned that speeding up only the matrix multiply is pointless. So I turned attention into a circuit. It became 15.6× faster, and softmax didn't cost a single byte of bandwidth. All four circuits are pinned to the 2.0 GB/s wall.
And at the very end, I found out that the way I had been measuring was wrong. The direction of the conclusions didn't change, but I redid every number (section 6).
After publishing, I found that the CPU implementation I compared against had been compiled with -O2 (NEON disabled).
Adding -mfpu=neon makes the same code 1.78× faster.
So every "N× the CPU" in the text was overstated by about 1.8×.
The numbers are corrected throughout this page (attention: 28× → 15.6×).
Right after writing "suspect the measurement formula" in section 6, I made the same bias again, this time in how much effort went into the baseline. I measured what I built carefully, and what I compared it against carelessly. That happened twice in a row.
the 78% that was still on the CPU
A measurement I added at the end of The Wall Doesn't Move broke the premise of the plan. Even with the matrix multiply turned into a circuit and cut to 14.2 ms, attention left on the CPU takes 46 ms at context 512. The CPU takes 78% of the whole system, and the circuit I worked so hard on barely helps.
So Stage 5 had one job. Put attention in a circuit too.
two phases, one memory in between
The matmul circuit was simple. Each incoming weight gets multiplied and added, one after another, and it never has to look back. Attention doesn't work that way.
First you stream all of K to build the scores. Then you use those scores as weights and stream V. V can't start until K has been read to the end, so you need a buffer for the scores in between. That is the biggest structural difference.
In Stage 5a I spent a whole day on the board chasing a single one-cycle slip in this circuit. Three times I confidently named a different cause, and twice I was wrong. The real cause: "the data was 2 stages deep, but I took the valid flag from 3 stages." A counting mistake.
In Stage 5b I changed the order. Pass the Vivado simulator first. I swept context length over 1, 2, 3, 5, 8, 12, 16 and 64, dropped upstream data with a 10% probability, and stalled downstream one time in four, then checked 1,560 words against numpy. It passed on the first try, and matched on the board on the first try too. Logic errors in the simulator; only timing and bandwidth on the board. That is the split.
why attention is denser than matmul
This was the surprise. Attention is a "denser" computation than matmul.
In a matmul, each weight byte you read is used only once. Then it is thrown away. So one multiply per byte. The height of the wall is directly the ceiling on compute.
Attention is different. This model (SmolLM2-135M) uses a structure called GQA, where one K/V head is shared by 3 q heads. So each byte of K you read can be handed to 3 separate computations at once.
the exponent never leaves the chip
On the CPU, softmax is expensive. Measured: 6.9 ms of the 46 ms of attention. It computes an exponential for every position in the context.
In the circuit, it becomes one table lookup. The score is rounded to 8 bits,
so it can take only 256 values. Just bake in the 256 answers ahead of time.
The exp operation itself disappears from the circuit.
And the key point: this table lives inside the chip. Not a single extra byte moves between the chip and DDR.
Softmax needs a division, but the circuit doesn't do it. It outputs 192 numerators and 3 denominators and leaves the division to downstream. Building hardware for a division that happens only 3 times per group isn't worth it.
But growing the output from 192 to 195 words was surprisingly tricky. The output stage has a huge selector that picks one of 192 values, and it has only 0.436 ns of slack per clock. Adding even one more 2-way choice could break timing. So I put the denominators at free addresses that the selector's address formula already produces (192, 200, 208). That added 3 words without changing the formula, and the circuit is not one level deeper.
when the number refuses to make sense
Up to here, things went smoothly. Use 2 HP ports to double the bandwidth. It should have been the same thing I did in Stages 3 and 4.
But I only got 1.46 GB/s. One port gives 1.00, so that's 1.46×. Matmul had scaled 1.72×. Every result checked against numpy matched, so the circuit was working correctly. Only the speed didn't add up.
A reason came to mind right away. The attention circuit stalls upstream while it emits the 195 output words. The matmul circuit never stalls. That had to be it, I thought.
But the numbers didn't fit. It stalls for 216 of the 8,217 beats in a group, only 2.6%. That can't explain 26%.
So before touching the circuit, I measured at context lengths 128, 512 and 2048 and solved simultaneous equations with two unknowns: "the stall time per group, S" and "the fixed overhead per measurement, P." Here is the answer.
measure the slope, not the ratio
P is the measurement program itself. Starting a transfer and waiting for it to finish took 0.7–0.8 ms per channel pair. In an 8 ms measurement, that alone eats 10%.
And it had been in every measurement since Stage 1.
I remeasured everything. Not one conclusion changed direction, but the numbers now look like this.
| Measurement | Old (1 point) | New (slope) | Meaning |
|---|---|---|---|
| Stage 2 · no compute · 1 HP port | 1.13 | 1.143 | exactly 100.0% of datapath width |
| Stage 2 · no compute · 2 HP ports | 1.97 | 2.009 | 95.7% of the DDR theoretical 2.1 |
| Stage 3 · 16 INT8 MACs | 1.91 | 1.982 | 98.7% of the wall |
| Stage 4 · ternary | 1.83 | 1.894 | 9.47 G weights/s |
| Stage 5b · attention + softmax | 1.46 | 1.99 | 99.3% of the wall |
The first row surprised me most. The 1-port circuit had been running at exactly 100.0% of datapath width. For two weeks I had thought "98.7%, so 1.3% is being lost somewhere," but the only loss was inside the measurement program.
When the numbers don't fit, suspect the measurement formula first. I got lucky this time only because I swept context length and solved the simultaneous equations before rebuilding the circuit. If I had gone ahead with "adding shadow accumulators should remove the stall," I would have spent two weeks building something that improved things by only 2.6%, and been at a loss when the numbers still didn't fit.
four different circuits, one number
With everything remeasured, here are all the circuits I have built so far, side by side.
| Stage 5b circuit | Value |
|---|---|
| Read (2 HP ports) | 1.99 GB/s |
| Against the DDR wall | 99.3% |
| Multiplies | 5.95 G MAC/s |
| Per byte | 2.99 MAC |
| LUT | 12,820 / 53,200 (24%) |
| DSP | 108 / 220 (49%) |
| Timing slack | +0.201 ns |
| Attention at context 512 (1 token) | 2.97 ms ── 15.6× the CPU |
the optimisation that is not worth it
Stage 5b had a third planned item: removing the stall (2.6%) while the 195 output words are emitted. With a second set of accumulators, the circuit could take in the next group while emitting, and the stall would go away.
I dropped it. The measurements told me why. The 1-port circuit runs at 97.4%, exactly the theoretical value computed from the stall. But with 2 ports it is already at 99.3% of the wall. Even with the stall gone, DDR won't give any more.
"The bottleneck is bandwidth, not area." The reading this plan started from became, here, the reason not to build. Adding another set of 192 32-bit accumulators (6,144 registers × 2 sets) is pointless when the wall won't allow it.
The chip still has 76% of its LUTs and 51% of its DSPs free. Plenty left over, and nothing to use it on. That's where this board stands.
what is measured and what is not
What I can claim. All of it is measured on the board.
What I can't claim. I'll be honest about these.