Where the plan to run a small language model fast on an FPGA stands now. Why it is slow on a CPU, what changes when it moves to an FPGA, and how I measured that for the first time yesterday.
What I want to do is run a small quantized inference model on compute circuits I build myself in an FPGA. I know a GPU would be faster. The point is to build it myself.
But the research and measurements so far have overturned my initial picture twice. This document uses figures to share what I know now, and what I will do next.
The important point is that the PL starts out empty. It has 220 DSPs on it, but they are not wired to anything, so they do nothing. You design what the circuit should do, build a bitstream (a file with the circuit design baked in), and load it.
And the PL cannot reach DDR on its own. There is a gate called the HP port, and only after you build a circuit that goes through it can the PL touch memory. That gate being blocked is exactly where I got stuck on the ADRFT1.
As a real example, here is how much the circuit I built yesterday actually used.
| Resource | Used | Total | Share | Role |
|---|---|---|---|---|
| LUT | 4,360 | 53,200 | 8.2% | Anything: control, decisions, address calculation |
| Flip-flop | 6,199 | 106,400 | 5.8% | Holds a value for one clock |
| Block RAM | 11.5 | 140 | 8.2% | Temporary buffer for chunks of data |
| DSP | 0 | 220 | 0.0% | Multiply only. Unused, since this circuit computes nothing |
The DSP count of exactly 0 makes the point nicely. That circuit only moves data and does no computation, so it uses not a single multiplier. The division of labor is: LUTs do anything, DSPs only multiply.
You can build a multiplier out of LUTs too, but a multiplier of any reasonable width eats hundreds of them and runs slow. That is why dedicated circuits come baked into the chip from the start. The DSP on this chip is a 25-bit × 18-bit signed multiplier with a 48-bit accumulator, which is far too big for INT8.
When weights are ternary (+1 / 0 / −1), a multiply becomes a three-way choice: add, do nothing, or subtract. That is not a multiplication, so it is built from LUTs, not DSPs. So on the ternary path, the budget that matters is not the 220 DSPs but the 53,200 LUTs. A rough estimate says about 1,000–1,500 are needed, so LUTs will be left over too. In the end, the conclusion stays the same: the only thing in short supply is bandwidth.
This was the first of my assumptions in this project to be overturned. At first I thought, "Hundreds of DSPs are sitting idle, so this should get fast." In reality, there were more than enough DSPs. What was short was the bandwidth from memory.
To go faster, the most effective thing is to cut the number of bytes per weight. Going from INT8 (1 byte/weight) to ternary (about 0.2 bytes/weight) lets the same bandwidth carry 5 times as many weights. And with ternary weights, multiplies become additions and subtractions, so you don't even need DSPs. That is why models like BitNet b1.58 suit this board.
That is what I meant by "the PL does the same job at 74%". From the same memory, the CPU can pull only 30%, while the PL pulls a little over 70%. A circuit that drives memory directly in hardware uses memory far more efficiently than a general-purpose CPU.
The measured value of 1.54 GB/s itself is correct. The interpretation was wrong. It is not 74% of the DDR's 2.1 GB/s. It is 96% of the PL-side datapath width of 0.80 GB/s. The AXI path is 64 bits wide, so it carries only 8 bytes per clock. At 100 MHz the limit is 8 × 100,000,000 = 0.80 GB/s. Both the 0.77 read and the 0.77 write were hitting this ceiling, not the DDR.
I rebuilt the circuit as read-only and measured again. The true limit for the PL reading from DDR was 1.97 GB/s (93.6% of DDR). Against the CPU's 33%, that is 2.8×, so the conclusion that "the PL can pull more out of DDR" actually got stronger. The condition, though, is using two HP ports. With one, it only reaches 53.7%. Details in "Two Ceilings".
And on the ADRFT1, the CPU was pulling only 13%. What felt "slow" on that board was not the DDR's performance. It was the CPU failing to draw that performance out. When I say the hypothesis that "moving to the fabric is worth it" was backed by measurement, this gap is what I mean.
There was a reason to go out of my way to build a circuit that does nothing. If I build the compute units first and the result is slow, I can't tell whether the circuit is bad or the road is narrow. If I pin down the width of the road first, the next compute circuit only has to be sized to consume that road fully. It will be neither too big nor too small.
After each transfer I compared the source data and the result byte by byte and confirmed an exact match. Fast but broken would be pointless.
It looks mundane, but it may be the biggest result of this project so far. On the ADRFT1, I never managed to build this loop. Reprogramming its FPGA needed dedicated hardware (over ¥30,000), and a failed attempt could not be recovered. On the PYNQ-Z1 that came out of a drawer, the loop ran on the first day.
By the way, I had been considering a move to the KV260 ($199, roughly 10× the PYNQ-Z1's performance), but the PYNQ-Z1 turned out to be more capable than I expected, so that decision can probably wait. A ternary 135M-class model fits in 512MB of memory, so there is a chance I can run it end to end on this board at least once.
| Stage | Task | What it tells us |
|---|---|---|
| Done | Measure read-only bandwidth | Done on 2026-09-08. 1.97 GB/s (93.6% of DDR). As expected, read-only got more, but for a different reason than I thought — the headroom was not in the write-back but in the datapath width. → "Two Ceilings" |
| Next | Line up INT8 multipliers | Size it to the width of the road. 220 are not needed. The settled design value is 14 at 142.86 MHz (6% of the total) |
| Main goal | Go ternary (BitNet b1.58) | 1.0 → 0.2 bytes per weight. 5× as much through the same road. At 1.97 GB/s, that is the equivalent of 9.8 G weights per second |
My expectation is that once ternary is done, a 135M-class model should run 10–20× faster than on the CPU, and around 30 tokens per second should be within reach. But this is an estimate from the bandwidth I measured this time. I won't know until it actually runs.