A synthesisable pre-norm decoder block — Q8.8 fixed-point, 4×4 systolic MAC array, KV-cache, 84-test verification suite, Vivado synthesis to Artix-7. Walk through every module. Compare to H100, Trillium, Cerebras, Groq, and Sohu. Trace the architecture back to "Attention Is All You Need".
Twenty-six slides. Half walk through every RTL module in the repo at the level of a code review; the other half put the design in context — the algorithm, the historical hardware, and the real shipping silicon that runs the same workload on a million-times bigger budget.
transformer_pkg.sv — the whole arithmetic library in one fileprocessing_element.sv — the MAC tilesystolic_array.sv — 4×4 array with fill / drain accountingsoftmax_unit.sv — PWL exp + reciprocal-by-LUT FSMlayer_norm.sv — division-free norm via RSqrt + Newton–Raphsonfeed_forward.sv — two GEMMs and a ReLUmulti_head_attention.sv — heads, masking, softmax, KV-cachetransformer_decoder.sv — the top-level FSMkv_cache_bram.sv and friends"Attention Is All You Need" (Vaswani et al., NeurIPS 2017, arXiv:1706.03762, posted 12 June 2017) replaced the recurrence and convolution stack with self-attention plus position-wise MLPs. It exposed a compute pattern that has dominated silicon roadmaps ever since:
By 2020, attention had become a measurable fraction of inference time, and the academic-hardware community responded with attention-as-a-primitive papers (A³ HPCA 2020, SpAtten HPCA 2021, ELSA ISCA 2021, FACT ISCA 2023). Industry caught up in 2022 when NVIDIA shipped the Hopper Transformer Engine — the first time a GPU had per-layer FP8 statistics built in. Blackwell Ultra (2025) doubled the throughput of the SFU's MUFU.EX2 instruction, the one softmax actually sits on — silicon admitting that softmax is finally a first-class concern.
This is the educational analogue of all of the above, scaled to fit in 16 GB of host memory and on a $200 Artix-7 FPGA. Every block — GEMM, softmax, LayerNorm, KV-cache, top-level FSM — is small enough to read in one screen of code. None of the moves above are skipped: the softmax is a real PWL approximation, the LayerNorm is a real division-free RSqrt path, the KV-cache really avoids re-projection of past tokens.
D_MODEL = 64, N_HEADS = 4, D_FF = 256, MAX_SEQ_LEN = 128, Q8.8 16-bit signed fixed-point, 4×4 PE systolic array. Vivado 2025.2 synthesises the streaming variant on Artix-7 (xc7a35tcpg236-1). The point isn't the throughput — it's that all eight RTL files together are smaller than a single CUTLASS kernel.
One transformer_decoder instance corresponds to one decoder layer. Stacking N of them gives an N-layer decoder; weight memories can be shared across layers (area-optimal, time-multiplexed) or replicated (throughput-optimal). The dataflow per token is:
token_emb ──► [LayerNorm 1] ──► [Multi-Head Attention] ──►(+)──► [LayerNorm 2] ──► [Feed-Forward] ──►(+)──► out_emb
▲ Residual 1 ▲ Residual 2
└────────────────────────────────────────────┘ └─────────────────────────────┘
This is a pre-norm decoder: LayerNorm sits before each sub-layer, with a residual that bypasses the sub-layer. Pre-norm is the architecture used by GPT-2, GPT-3, GPT-4, LLaMA-1/2/3, Mistral, Qwen, and most modern decoder-only LLMs. The original "Attention Is All You Need" Transformer was post-norm; the switch happened around 2019 because pre-norm trains far more stably at large scale (Xiong et al., "On Layer Normalization in the Transformer Architecture", ICML 2020).
seq_pos.transformer_decoder ← Top-level FSM (12 states) ├── layer_norm × 2 ← Pre-attention & pre-FFN normalisation ├── multi_head_attention ← Causal MHA with KV-cache + time-multiplexed softmax │ └── softmax_unit ← Shared, MAX_SEQ_LEN-wide ├── feed_forward ← Two-layer FFN with ReLU └── transformer_pkg ← Parameters · types · FP utilities
Every value in the datapath — activations, weights, biases, partial sums, softmax outputs, normalised activations — is 16-bit two's-complement Q8.8. That means 8 integer bits, 8 fractional bits, signed. Range −128.000 to +127.996, resolution 1/256 ≈ 0.0039.
| Property | Value |
|---|---|
| Format | Q8.8 signed (1 sign · 7 integer · 8 fraction); width 16 |
| Range | [−128.0, +127.996] |
| Resolution | 2−8 = 0.00390625 |
| Multiplier | 16 × 16 → 32-bit signed; one Artix-7 DSP48E1 slice |
| Accumulator | 32-bit, full precision; truncated to 16-bit Q8.8 at module boundaries |
| Saturating add | fp_sat_add in transformer_pkg.sv — clamps to ±0x7FFF / 0x8000 |
| Multiply | fp_mul: (a·b) >>> FRAC_BITS — arithmetic right-shift of the 32-bit product |
Q8.8 has the same 65 536-level dynamic range as INT16 — about 1/40 of FP16's range. For D_MODEL ≥ 1024 and unscaled activations (the GPT-2-large era and beyond), per-tensor Q8.8 starts to fail because individual attention activations hit the rails. That's the regime the industry left fixed-point behind for: SmoothQuant (per-channel), AWQ (per-group), GPTQ, FP8-with-per-tile-scale (the Hopper Transformer Engine), and now NVFP4 (per-block FP4 with E4M3 scale).
This design lives in the small-D regime where Q8.8 works, on purpose. The package-level types make it the single point of change: edit DATA_WIDTH, FRAC_BITS, and the FP utility functions, rebuild, re-run the 84-test suite.
transformer_pkg.sv233 lines. The whole arithmetic library in one place — every other module imports it. Edit this file to scale the design.
| Parameter | Default | Comment |
|---|---|---|
D_MODEL | 64 | Embedding dimension |
N_HEADS | 4 | Heads (D_HEAD = D_MODEL/N_HEADS = 16) |
D_FF | 256 | FFN inner dimension (4× expansion) |
MAX_SEQ_LEN | 128 | Context window |
VOCAB_SIZE | 512 | Used in the bigger top-level only |
DATA_WIDTH / FRAC_BITS | 16 / 8 | Q8.8 |
ACC_WIDTH | 32 | Full-precision MAC accumulator |
PE_ROWS / PE_COLS | 4 / 4 | Systolic array shape |
// Multiply two Q8.8 values, return Q8.8 (truncated)
function automatic signed [15:0] fp_mul(input signed [15:0] a, input signed [15:0] b);
logic signed [2*DATA_WIDTH-1:0] product;
product = a * b;
return to_data(product >>> FRAC_BITS);
endfunction
// Saturating add for Q8.8
function automatic signed [15:0] fp_sat_add(input signed [15:0] a, input signed [15:0] b);
logic signed [DATA_WIDTH:0] sum;
sum = {a[DATA_WIDTH-1], a} + {b[DATA_WIDTH-1], b};
if (sum > $signed({1'b0, {(DATA_WIDTH-1){1'b1}}})) return {1'b0, {(DATA_WIDTH-1){1'b1}}};
else if (sum < $signed({1'b1, {(DATA_WIDTH-1){1'b0}}})) return {1'b1, {(DATA_WIDTH-1){1'b0}}};
else return to_data(sum[DATA_WIDTH-1:0]);
endfunction
fp_inv_sqrtA division-free reciprocal-square-root, ~12 bits of accuracy in Q8.8. The same shape of code (CLZ → normalise → 32-entry LUT → one Newton–Raphson step → denormalise) appears three times in the design: here for LayerNorm's 1/√σ², in softmax_unit for 1/Σexp, and as a pattern that students can transplant to any Q-format reciprocal they need. The key trick is CLZ-normalise once, denormalise at the end — it lets a 32-entry LUT cover the full 16-bit input range to identical accuracy across decades.
For odd leading-zero counts the code multiplies by a Q2.14 constant for √2 (= 23170) so the binary-only shift can resume after the LUT step. This is verbatim what x86's RSQRTSS and ARM's VRSQRTE.F do internally — minus the IEEE rounding modes.
processing_element.sv52 lines. The smallest possible MAC tile — three flops, one multiplier, one adder. Same shape as Kung & Leiserson 1978; same shape as a single TPU MXU lane in 2025. The pattern is forty-eight years old and still right.
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
a_out <= 0; w_out <= 0; accumulator <= 0;
end else if (clear) begin
a_out <= 0; w_out <= 0; accumulator <= 0;
end else if (enable) begin
a_out <= a_in; // skew east
w_out <= w_in; // skew south
accumulator <= accumulator + (to_acc(a_in) * to_acc(w_in));
end
end
assign acc_out = accumulator;
mmul_pe.sv in the companion AI_MMUL_Unit repo — line-for-line equivalent.posedge clk or negedge rst_n; this is the Xilinx 7-series convention so it will stay in BRAM-aware retiming and not block CDC checks.to_acc. The 16→32-bit sign-extension is wrapped in a package-level helper because iverilog 10.1 mishandled inline {{16{a[15]}}, a}. A workaround that became a clarification.| Metric | Value |
|---|---|
| LUTs | 34 |
| FFs | 64 |
| DSP48E1 | 1 |
| Fmax | 229.9 MHz |
One DSP, one PE. The 4×4 array uses 16 DSPs — under 30% of the xc7a35t's 90 DSP48E1 slices. Headroom for an 8×8 array is real on this part.
systolic_array.sv92 lines. Generates ROWS × COLS PEs, wires their A-east and B-south chains, and tracks a completion counter so the host can tell when the last accumulator has settled.
A streams left→right (blue), B streams top→bottom (purple), accumulators stay put (green).
done assertion: a long-standing one-cycle bug here was fixed in commit bdc0614. The cycle counter pre-increments, so the comparison threshold is ROWS + COLS − 2, not − 1. The corrected RTL asserts done on exactly the cycle where PE(R−1, C−1) registers its final partial sum. The TB tb_systolic_array.sv was updated to verify this.This design loads a fresh weight tile for every projection (Q, K, V, O, FFN1, FFN2), so weights aren't reused enough to amortise weight-stationary's load cost. Output-stationary is also the natural shape for the QKᵀ score computation, which dominates per-head cycles. NVDLA, Apple ANE, and most edge NPUs go weight-stationary only because they hold a whole layer's weights in SRAM for batched inference — a regime this small reference design doesn't aim at.
softmax_unit.sv332 lines — the largest module in the repo by line count. Implements the standard max-subtract-exp-normalise recipe, every step of which is approximated:
S_IDLE ─► S_FIND_MAX ─► S_SUBTRACT_EXP ─► S_SUM ─► S_RECIP ─► S_NORMALIZE ─► S_DONE
One pass over the input vector, holding the running maximum. Standard textbook trick: subtracting max(x) before exp prevents overflow without changing the result, because softmax is shift-invariant.
A 4-segment piecewise-linear approximation of exp(x) over the negative range — that's all softmax ever sees after subtracting max:
function automatic signed [15:0] approx_exp(input signed [15:0] x);
if (x >= 0) return 16'sh0100; // exp(0) = 1.0
else if (x > -16'sh0100) return 16'sh0100 + x; // (-1, 0): linear
else if (x > -16'sh0200) begin // (-2, -1)
result = 16'sh0060 + (x + 16'sh0100) >>> 1; // ~0.375 + 0.5*(x+1)
return (result > 0) ? result : 16'sh0004;
end else if (x > -16'sh0400) begin // (-4, -2)
result = 16'sh0020 + (x + 16'sh0200) >>> 2; // ~0.125 + 0.25*(x+2)
return (result > 0) ? result : 16'sh0004;
end else return 16'sh0004; // floor for very negative
endfunction
This is good enough for token-selection ordering (which is all that matters for greedy/argmax decoding) and produces probabilities that sum to ≈ 1.0 within a few LSBs in Q8.8. The TB confirms Σprobs ≈ 0xFE / 0x100 for an 8-element uniform input.
Computing p_i = exp(x_i) / Σexp would need a divider. Instead the unit computes recip = 65536 / exp_sum in one shared cycle, then multiplies each exp_i by recip with the same fp_mul the rest of the design uses. Because fp_mul implicitly does (a·b) >> 8, the formula works out to (exp_i << 8) / exp_sum — exactly the original division.
The reciprocal itself uses the same CLZ-normalise → 32-entry Q2.14 LUT → one Newton–Raphson step → denormalise pattern as fp_inv_sqrt. Newton–Raphson here is r ← r·(2 − x·r), which converges quadratically; ~12 bits of accuracy after one step is enough for Q8.8 outputs.
| Metric | Value | Comment |
|---|---|---|
| LUTs | 537 | Mostly the 32-entry case-LUT and the Newton step |
| FFs | 584 | FSM, exp_vals[], shifted[], idx |
| DSP48E1 | 3 | Reciprocal mul + Newton mul + per-element normalise |
| Fmax | 47.9 MHz | Critical path: combinational compute_reciprocal |
The 47.9 MHz figure is one of the two reasons the full design doesn't meet the 100 MHz target. Pipelining the reciprocal across two FSM states would more-than-double Fmax — the deferred work is documented in README.md.
layer_norm.sv151 lines. Computes yi = γi · (xi − μ) / √(σ² + ε) + βi across a VEC_LEN-element vector. The interesting part is what's not here: any divider.
S_IDLE ─► S_MEAN ─► S_VARIANCE ─► S_NORMALIZE ─► S_DONE
μ = (Σxi) >>> log₂(VEC_LEN). The runtime $fatal at instantiation enforces VEC_LEN = power of two — added in commit fed82e7 after a code review caught that >>> would silently round for non-power-of-2 lengths.fp_inv_sqrt from the package — a CLZ-normalised, LUT-seeded, Newton–Raphson-refined reciprocal square root. Same shape as compute_reciprocal in softmax_unit.The +1 epsilon (literal 16'sh0001 = 1/256 in Q8.8) is enough to keep the LUT index away from zero on the constant-input case (variance = 0). The behavioural test Constant→zero exercises this: feed a vector of identical values, expect zero output.
A hardware integer divider for 32-bit values is ~600 LUTs and 12+ pipeline stages on Artix-7. Replacing it with a 32-entry LUT + two 16×16 multiplies (Newton–Raphson) keeps the path entirely inside the DSP fabric. The cost is ~12 bits of accuracy — more than enough for Q8.8.
| Metric | Value | Comment |
|---|---|---|
| LUTs | 1,585 | VEC_LEN = 64 is the dominant cost — centered[] array |
| FFs | 2,157 | Per-element registers across the FSM |
| DSP48E1 | 7 | γ·(x−μ)·invstd is two cascaded muls per element |
| Fmax | 29.2 MHz | Critical path: fp_inv_sqrt Newton–Raphson chain |
29.2 MHz is the slowest module in the design and therefore the system-level limiter. The path is the combinational chain inside the Newton–Raphson step: 16-bit multiply → subtract from constant → 16-bit multiply → variable shift. All inside a single FSM state. Pipelining across two FSM states is the documented path to ≥ 100 MHz.
Modern LLMs (LLaMA, Qwen, Mistral) use RMSNorm instead of LayerNorm — same RSqrt path, but no mean-subtraction. RMSNorm is roughly 30% cheaper and slightly faster to train. Migrating this module to RMSNorm is mostly deletion: drop S_MEAN, drop the centered[] array, keep fp_inv_sqrt.
feed_forward.sv125 lines. Two linear layers and a ReLU. The wide-then-narrow shape every transformer FFN uses — D_MODEL=64 expands to D_FF=256, then collapses back to D_MODEL=64.
S_IDLE ─► S_LINEAR1 ─► S_RELU ─► S_LINEAR2 ─► S_DONE
(D_FF iters) (parallel) (D_MODEL iters)
For one decoder layer at sequence length S, the FLOP budget breaks down (per "Attention Is All You Need" §3.3 and the standard expansion):
| Stage | FLOPs | Fraction |
|---|---|---|
| Q, K, V projections (3×) | 6 · S · D² | ~17% |
| Output projection WO | 2 · S · D² | ~6% |
| QKᵀ scores | 2 · S² · D | ≪ for small S |
| scores · V | 2 · S² · D | ≪ for small S |
| FFN expand (4D × D) | 8 · S · D² | ~22% |
| FFN contract (D × 4D) | 8 · S · D² | ~22% |
FFN is roughly 44% of the layer for typical S, and at single-token inference (S = 1) it's the dominant compute. This is why GPT-3, GPT-4, and LLaMA all keep the 4× FFN ratio — and why FlashAttention, which optimises the attention block, has so little to say about FFN.
Modern LLMs use SwiGLU (Shazeer 2020): three weight matrices, two for the gate, one for the projection, sigmoid(W₁x) ⊙ (W₂x) → W₃ projection. SwiGLU outperforms ReLU on training perplexity but costs 3 GEMMs and a sigmoid per token instead of 2 GEMMs. The reference design uses plain ReLU because:
for j loop accumulator — they could use the systolic array. Doing so would cut FFN cycles from O(D_MODEL · D_FF) to ~O(max(D_MODEL, D_FF)) but at the cost of weight-tile bookkeeping. Straightforward as future work; left out to keep the FSM readable.multi_head_attention.sv362 lines. The single biggest module in the repo. Implements the canonical scaled-dot-product attention from "Attention Is All You Need" §3.2 — minus the divide-by-√d_k, which is folded into pre-scaling of the weights.
Attention(Q,K,V) = softmax(Q·Kᵀ / √d_k) · V over each of N_HEADS in parallel
| State | Cycles | What happens |
|---|---|---|
| S_IDLE | 1 | Wait for start |
| S_PROJ_QKV | 3 · D_MODEL | Compute fresh Q, K, V vectors for current token |
| S_WRITE_CACHE | 1 | Append K, V at seq_pos in the cache |
| S_SCORE | N_HEADS · seq_pos · D_HEAD | Compute Q · Kᵀ scores over all past positions |
| S_SOFTMAX_PREP | 1 | Pad scores beyond seq_pos with −128.0 (causal mask) |
| S_SOFTMAX_RUN | ~5·MAX_SEQ_LEN | Run shared softmax_unit for this head |
| S_SOFTMAX_STORE | 1 | Capture probabilities |
| S_WEIGHTED_SUM | N_HEADS · D_HEAD · seq_pos | Compute Σ pi · Vi per head |
| S_OUTPUT_PROJ | D_MODEL² | Concat heads, project with WO |
| S_DONE | 1 | Pulse valid |
One softmax_unit instance is time-multiplexed across heads. The instance is sized for VEC_LEN = MAX_SEQ_LEN, and positions beyond seq_pos are padded with the smallest representable Q8.8 value (−128.0) so the PWL exp approximation forces them to ~0. This implements the causal mask as a number, not as a control bit — saves a separate masking pipeline.
K and V for all prior positions are held in dual-port memory. On every attention step:
K[0 .. seq_pos] for QKᵀ (parallel with V reads).V[0 .. seq_pos] for the weighted sum.K[seq_pos] and V[seq_pos] for next token.In the streaming variant the cache is kv_cache_bram — a real dual-port BRAM that does element-by-element vector writes and single-element reads. In the non-streaming variant it's a flat unpacked array, intended only for simulation.
seq_pos). LLaMA-class models add Rotary Positional Embedding to Q and K before the dot product. RoPE is two element-wise multiply-adds per element — easy to insert as an extra FSM state.transformer_decoder.sv249 lines. The conductor — instantiates LayerNorm-1, MHA, LayerNorm-2, FFN, threads them with two residual additions, and runs a 12-state top-level FSM.
S_IDLE ─► S_LN1_START ─► S_LN1_WAIT ─►
S_ATTN_START ─► S_ATTN_WAIT ─► S_RESIDUAL1 ─►
S_LN2_START ─► S_LN2_WAIT ─►
S_FFN_START ─► S_FFN_WAIT ─► S_RESIDUAL2 ─► S_DONE
start pulses. The default in the always-block is to drive every *_start low; the case statement raises one for exactly the cycle of its S_xx_START state. Sub-modules launch on the rising edge.fp_sat_add rather than wrap-around. Residual + LayerNorm guarantees mid-band activations, so saturation is rare in practice; checking it is one of the behavioural-tier tests.Approximate, for D_MODEL=64, N_HEADS=4, seq_pos = 64 (mid-context):
| Stage | Cycles |
|---|---|
| LayerNorm 1 | ~200 |
| MHA Q/K/V proj | ~200 |
| MHA score + softmax + weighted sum | ~2,500 |
| MHA output proj | ~4,100 |
| LayerNorm 2 | ~200 |
| FFN linear 1 + ReLU + linear 2 | ~16,400 |
| Total per token | ~24,000 cycles |
At the achievable 29.2 MHz that's ~1.2 ms per token, ~830 tok/s — for a one-layer 64-dim toy model on a $200 FPGA. Stack 12 layers at this clock and you get ~70 tok/s, a reasonable rate for a small embedded model. The streaming variant trades throughput for area and is what synthesises within the 16 GB Vivado memory budget.
The repo ships two top-levels:
transformer_decoder_top.svtransformer_decoder_top_stream.svweight_bram, kv_cache_bram)For an educational repo, having both makes the trade-off concrete: the streaming variant is the bridge variant with the boundary registers stripped and the weights moved to BRAM. Diff one against the other and you can see, line by line, what "minimum-area" means in practice. The streaming variant's regression test (tb_transformer_decoder_stream.sv) produces bit-identical outputs to the bridge variant for the same weight load.
From stream_results.log:
--- Test 1: Single token at position 0 --- out_emb[0] = 0097 (0.5898) ← matches bridge variant exactly out_emb[1] = ff7f (-0.5039) ... [PASS] KV cache active: token-1 output differs from token-0 ============================================ Results: 4 PASSED, 0 FAILED ============================================
Replace combinational weight arrays with single-port BRAM and an address generator; replace pipeline registers between sub-modules with a single bus + handshake; let the FSM serialise everything that used to overlap. Trade ~10× fewer registers for ~3–5× more cycles per token. On Artix-7 that's the difference between "synthesises" and "exceeds 16 GB host memory".
kv_cache_bram.svWithout a KV-cache, autoregressive generation re-projects every prior token's K and V on every step — O(n²·d) per token. With a KV-cache, only the fresh Q is computed; past K, V are read from memory — O(n·d) per token. The streaming variant uses a real dual-port BRAM (bram_dp) sized for MAX_SEQ_LEN × D_MODEL.
One BRAM each for K and V. Linear address: mem[pos · D_MODEL + dim] — row-major, position-major.
| Port | Use | Pattern |
|---|---|---|
| A (write) | Append K or V at current seq_pos | Pulse wr_start, the module auto-walks D_MODEL elements from the input vector |
| B (read) | Per-element fetch by (pos, dim) | One-cycle latency, used by the score / weighted-sum loops |
For MAX_SEQ_LEN = 128, D_MODEL = 64, Q8.8: 128 × 64 × 16 bits × 2 (K and V) = 32 768 bits = 4 KB. Fits in two RAMB18E1 BRAMs comfortably (the xc7a35t has 50 of them).
By contrast, a real LLM:
| Model | KV cache @ FP16 | KV cache @ NVFP4 (Blackwell) |
|---|---|---|
| LLaMA-2-7B, 4K context | ~2 GB | ~0.25 GB |
| LLaMA-2-70B, 4K context | ~40 GB (~size of weights) | ~5 GB |
| LLaMA-3.1-405B, 128K context | ~640 GB | ~80 GB |
That's the entire reason long-context LLM inference is HBM-bandwidth-bound rather than compute-bound. PagedAttention (vLLM, SOSP 2023) borrows OS virtual memory to cut KV waste from 60–80% to under 4%. NVIDIA has been pushing FP8 and NVFP4 KV quantisation to claw the size back. Same idea here, fifteen orders of magnitude smaller.
54 tests, ~2 s on a laptop. scripts/verify_behavioral.py implements a bit-accurate Python model of the RTL: same Q8.8 representation, same truncation rules, same RSqrt LUT entries. No simulator required. The fastest path to confidence in algorithmic correctness.
30 tests in tb/sv/, run by scripts/run_sim.sh under iverilog 12.0. PE, systolic array, softmax, BRAM, full decoder, streaming decoder. The integration tests reproduce known-good outputs from a Python golden model.
Python-driven RTL co-simulation in tb/cocotb/. Currently exercises PE and softmax — the modules where Python's expressiveness pays for itself in stimulus generation and golden-model comparison.
| Module | Tests | Key checks |
|---|---|---|
| Fixed-point utilities | 16 | Q8.8 roundtrip · multiply with ±1 LSB tolerance · saturating add at the rails |
| Processing element | 9 | Single MAC · accumulate · forwarding · clear · 20-op random vs Python golden |
| Systolic array | 8 | 2×2 matmul producing [[19,22],[43,50]] exactly · single-element · clear |
| Softmax unit | 8 | Uniform · dominant · ordering · sum ≈ 1 · back-to-back |
| Layer norm | 5 | Constant→zero · symmetry · γ/β scaling · centering |
| Feed-forward | 4 | ReLU zeroing · bias propagation |
| Decoder integration | 4 | Full pipeline · KV-cache · 2-token sequential |
bdc0614 — Fix systolic_array done signal: asserted one cycle too late.b4cb919 — Fix clz16 return type and add default cases to LUTs. CLZ for 16-bit needs to return a 5-bit result so the all-zero input maps to 16; default cases protect synthesis against X/Z propagation.0d99b46 — tb_transformer_decoder: replace fake Test 2 with real KV-cache check.a7ce1a1 — tb_softmax: fix Tests 1 and 4 to actually detect failures. A test that always passes is worse than no test. Two were rewritten to compare against expected probabilities, not just "non-zero".6bda9a7 — run_sim.sh: detect and propagate [FAIL] assertion failures.fed82e7 — layer_norm: add runtime assertion for power-of-2 VEC_LEN.That's six bugs caught after the initial release. Each one became a TB; each TB is in the regression suite.
scripts/lint_check.pyWalks all eight RTL files, checks module/endmodule balance, package imports, reset patterns (posedge clk or negedge rst_n), and cross-file instantiation resolution. Cheap to run, catches typos that iverilog's parser elides.
Vivado 2025.2, target xc7a35tcpg236-1, 100 MHz clock constraint. The full top-level exceeded the host's 16 GB memory budget, so sub-modules were synthesised individually. The numbers below are per-sub-module and aren't additive (the array shares logic with the PEs it contains).
| Module | LUTs | FFs | BRAM | DSP48E1 | Fmax (MHz) | Comment |
|---|---|---|---|---|---|---|
| processing_element | 34 | 64 | 0 | 1 | 229.9 | Headroom for 2× clock |
| systolic_array (4×4) | 455 | 901 | 0 | 16 | 156.8 | Skew-flop chain limits Fmax |
| softmax_unit | 537 | 584 | 0 | 3 | 47.9 | Critical: combinational reciprocal |
| layer_norm | 1,585 | 2,157 | 0 | 7 | 29.2 | Critical: Newton–Raphson chain |
| transformer_decoder_top (estimated) | ~2,611 | ~3,706 | 0 | ~27 | ≤ 29.2 | System-level Fmax bound by layer_norm |
Two modules contain combinational logic inside a single FSM state that's too long for a 10 ns period:
fp_inv_sqrt is fully combinational. Its critical path is mul → subtract → mul → variable shift, all in 1 cycle. Pipelining across two FSM states (a register between the LUT-and-mul and the second-mul-and-shift) restores ≥ 100 MHz.compute_reciprocal has the same shape — same fix.This isn't a bug; it's a deliberate trade. The combinational implementation is one screen of code; the pipelined version is two — and an educational design is allowed to leave the pipeline as homework. The README documents the gap.
Total budget: 20 800 LUTs, 41 600 FFs, 50 BRAM, 90 DSP48E1.
| Year | Event |
|---|---|
| 1978 | Kung & Leiserson, Systolic Arrays for VLSI. The PE/array shape that this repo's processing_element/systolic_array modules still implement. |
| 2015 | Google TPU v1 — 256×256 INT8 systolic, deployed for inference SLA. |
| 2017 | Vaswani et al., "Attention Is All You Need" (NeurIPS, arXiv:1706.03762). Transformer is born. |
| 2017 | Microsoft Project Brainwave — FPGA "soft NPU" on Stratix 10, 39.5 TFLOPS at sub-ms latency in MS-FP8. |
| 2017 | NVIDIA V100 ships first Tensor Core (4×4×4, FP16 mul / FP32 acc) — pre-transformer in target, but the GEMM accelerator that won the transformer era. |
| 2018 | Habana Goya HL-1000 — first commercial deep-learning inference accelerator. CNN-and-BERT-class. |
| 2018 | Google Edge TPU — 4 TOPS INT8 at 2 W. Pure CNN; no useful transformer support. |
| 2020 | A³ (HPCA) — Ham et al., the first major paper to architect attention as a hardware primitive. ELSA (ISCA 2021), SpAtten (HPCA 2021), FACT (ISCA 2023) follow. |
| 2020 | NVIDIA A100 — TF32, BF16, FP64 TC, 2:4 sparsity, 312 TFLOPS BF16. The first GPU on which 100B-parameter models trained in production. |
| 2020 | FTRANS (Li et al., ISLPED) — block-circulant BERT on FPGA, 8.8× energy improvement vs GPU. |
| 2021 | EdgeBERT (Tambe et al., MICRO) — entropy-based early exit + adaptive attention span on a 12 nm ASIC. |
| 2021 | FQ-BERT (Liu et al., DATE) — fully integer/fixed-point BERT including softmax and LayerNorm in the integer domain. Direct ancestor of this repo's algorithmic moves. |
| 2022 | FlashAttention (Dao et al., NeurIPS, arXiv:2205.14135) — reduces attention HBM traffic to O(N²d²/M); 2–4× speedup with no approximation. Hardware-software co-design lesson of the decade. |
| 2022 | NVIDIA H100 + Transformer Engine — per-layer FP8 statistics, 1979 TFLOPS FP8 dense. |
| 2024 | FlashAttention-3 (Shah et al., arXiv:2407.08608) — Hopper-aware, 740 TFLOPS FP16, ~75% peak utilisation, ~1.2 PFLOPS FP8. |
| 2024 | NVIDIA Blackwell B200 — FP4, NVFP4 (16-element block, FP8 E4M3 scale); 20 PFLOPS FP4. |
| 2024 | Google TPU v6e Trillium — MXU enlarged from 128×128 to 256×256, first resize since 2017. |
| 2024 | Etched Sohu announced — first transformer-only ASIC. |
| 2025 | NVIDIA Blackwell Ultra doubles SFU MUFU.EX2 throughput. The first GPU generation where softmax is treated as a first-class architectural concern. |
The throughline: every generation, the silicon learns one more thing about transformers. Pre-2020 it learned "you need a lot of MACs". 2022 it learned "you need 8-bit numbers and per-layer scales". 2024 it learned "you need an explicit memory primitive (TMA, TMEM)". 2025 it learned "softmax is on the critical path; the SFU has to be wider". Forty-eight years from Kung's PE to the Blackwell Ultra SFU — the primitive doesn't change, the surroundings do.
Hopper (2022) was the first GPU architecture marketed by reference to a non-GEMM workload. The Transformer Engine isn't a separate IP block — it's a software runtime + hardware features that turn each forward/backward pass into a per-layer scaled FP8 matmul.
| Feature | H100 Transformer Engine | This repo |
|---|---|---|
| Datatype | FP8 E4M3 / E5M2 + BF16 acc | Q8.8 mul + 32-bit acc |
| Per-layer scale | Yes, runtime-managed | No — fixed-point only |
| GEMM topology | 4×8×16 reduction-tree TC × 132 SMs | 4×4 output-stationary systolic × 1 |
| Async DMA | TMA | None |
| Softmax | SFU MUFU.EX2 + warp shuffle reduction | FSM, PWL exp, reciprocal LUT |
| Norm | Vector lane, FP16/BF16 div | FSM, RSqrt LUT + Newton–Raphson, no divider |
| Peak FP8 dense | 1 979 TFLOPS | ~3 GMAC/s @ 29 MHz |
The ratio is roughly 10⁹. The architectural moves are the same — block GEMM, max-subtract softmax, normalise, residual. Implementing them in 1 500 lines of SystemVerilog instead of 50 million transistors is the entire point.
"H100 does 989 TFLOPS" cites BF16. The FP8 figure is 1 979 TFLOPS dense or 3 958 sparse. Tech press articles routinely compare H100 BF16 to AMD/TPU FP8 — apples vs oranges.
Notice what no shipping system has: a hardwired fixed-point datapath of the kind in this repo. Production silicon picks per-layer scaled FP8/FP4 instead because (a) range adapts, and (b) the area cost of FP arithmetic is no longer dominant at 4 nm. Q8.8 lives on in educational and edge-FPGA niches — this design is squarely in the first.
| Device | Topology | Datatype | Peak / SRAM | Transformer-specific feature |
|---|---|---|---|---|
| Cerebras WSE-3 (CS-3) | 900 K cores, full 300 mm wafer, 5 nm | FP16, BF16, FP8 | 125 PF · 44 GB SRAM, 21 PB/s | Weight Streaming + MemoryX (4 TB–2.4 PB) + SwarmX gradient reduction |
| Groq LPU (TSP) | Functional slices, 14 nm, 900 MHz | FP16, INT8 | 188 TF FP16 / 750 TOPS INT8 · 230 MB SRAM, no DRAM | Compiler-scheduled deterministic everything — autoregressive decoding has constant-shape steps; static schedule wins |
| Tenstorrent Wormhole / Blackhole | 80 / 140 Tensix cores, 12 nm / 6 nm | FP8/16, BFP2/4/8 | 292 / 745 TF FP8 · 210 MB SRAM (Blackhole) | Per-core matrix + vector + RISC-V, NoC mesh — dataflow that maps each transformer layer to N cores |
| Tesla Dojo D1 | 354 cores/chip × 25 chips/tile, 7 nm | FP32, BF16, CFloat8 | 9 PF / training tile · 11 GB SRAM/tile | CFloat8 — user picks the exp/mantissa split per layer (a per-tensor analogue of Hopper's per-layer FP8 scales) |
| Etched Sohu (announced 2024) | Hardwired transformer dataflow, 4 nm | FP8 (vendor claim) | vendor: 500 K tok/s Llama-70B / 8-Sohu server · 144 GB HBM3e | Whole transformer block hardwired — no flexibility for non-transformer architectures |
| SambaNova SN40L | RDU, 1040 PCU + 1040 PMU CGRA | BF16 / FP8 | 638 TFLOPS · 520 MiB SRAM + 64 GiB HBM + 1.5 TiB DDR | Streaming dataflow — compiler fuses hundreds of operators across one pipeline |
| Apple Neural Engine (M4) | 16-core, weight-stationary | FP16 | 38 TOPS · shared SoC SRAM | Native scaled-dot-product-attention layer; ml-ane-transformers reference reports 10× speedup, 14× lower peak memory |
| Graphcore IPU (Mk2 / Bow) | 1472 tiles, 14 nm / wafer-on-wafer 7 nm | FP16, FP32 | 250 TFLOPS FP16 · 900 MB SRAM | Hardware stochastic rounding at full clock — no FP32 master-weight copy needed |
The reference design in this repo lives at the educational end of the third axis: it does only a transformer decoder and nothing else, and the hardwiring is its strongest feature for teaching purposes.
The KV-cache is the single biggest difference between transformer training and transformer inference. Training is GEMM-bound; inference, especially at long context, is HBM-bandwidth-bound — and the bandwidth is consumed by KV reads.
Per token, per layer, the decoder reads 2 · seq · D_MODEL bytes of KV and computes 2 · seq · D_MODEL MACs. The arithmetic intensity is O(1) FLOPs per byte — the canonical memory-bound case. No matmul accelerator helps. On an H100 at single-batch inference, MMUL utilisation drops to 1–3%; the rest of the chip is idle waiting on HBM.
Borrow OS virtual memory: split the KV cache into fixed blocks, give each request a block table mapping logical to physical. Cuts wasted KV memory from 60–80% to under 4%, gives 2–4× throughput over FasterTransformer/Orca at the same latency. Hardware implication: attention kernels must do gather-scatter from a block table.
FP8 (E4M3 / E5M2) KV halves memory at near-FP16 accuracy. NVFP4 KV halves it again — <1% accuracy loss on MMLU-PRO, MBPP, LiveCodeBench, Ruler 64K (NVIDIA blog, 2025). LMDeploy ships per-head per-token asymmetric INT4/INT8.
For MAX_SEQ_LEN = 128, D_MODEL = 64, Q8.8: the entire KV cache is 4 KB. It fits in two BRAMs. There is no HBM, no PagedAttention, no quantisation pressure — the problem doesn't exist at this scale. What survives is the interface: append-on-token, broadcast-on-attention. That interface is the same on this Artix-7 design and on a 1 000 000 000 000-parameter model running on 16 H100s.
| Format | Bits | Range | LSB / Granularity | Mul cost | Range/Precision split |
|---|---|---|---|---|---|
| Q8.8 (this repo) | 16 | ±128 | 2⁻⁸ ≈ 4·10⁻³ | 1 DSP48 | Static — fixed point |
| INT8 + per-tensor scale | 8 + 16-bit scale | scale-dependent | scale·2⁻⁷ | ~½ DSP | Static per tensor |
| INT8 + per-channel scale | 8 + 16-bit/ch | per-channel | per-channel | ~½ DSP + scale mul | Per-output-channel — used by SmoothQuant, AWQ |
| FP8 E4M3 (Hopper fwd) | 8 | ±448 | 2⁻⁹ near 1.0 | ~⅓ DSP eq. | Per-element float |
| FP8 E5M2 (Hopper grad) | 8 | ±57 344 | 2⁻⁵ near 1.0 | ~⅓ DSP eq. | Bigger range, less precision |
| NVFP4 / MXFP4 (Blackwell) | 4 + shared E4M3 scale per 16 | ±6 per element | ~½ near 1.0 | tiny mul + shared scale fan-out | Block-scaled — best of both |
Multiplier area is roughly quadratic in mantissa width. So:
The curve eventually meets a wall: at FP4, multiplier energy is so low (~0.03 pJ at 4 nm) that operand movement dominates the budget. That's the regime block scaling exists for — share one E4M3 scale across 16 elements, broadcast it once, save 15× on scale-fetch energy.
| Year | Project | What it did | Headline number |
|---|---|---|---|
| 2017 | MS Project Brainwave | FPGA "soft NPU" on Stratix 10, MS-FP8 | 39.5 TFLOPS, sub-ms latency |
| 2020 | FTRANS (Li et al., ISLPED) | Block-circulant weight compression for BERT/RoBERTa | 16× model-size reduction · 27× perf vs CPU · 8.8× energy improvement vs GPU |
| 2021 | EdgeBERT (Tambe et al., MICRO) | Entropy-based early exit, sentence-granularity DVFS, FP quantisation; 12 nm ASIC | 53× energy savings vs Jetson TX2 |
| 2021 | FQ-BERT (Liu et al., DATE) | Fully integer/fixed-point BERT — softmax and LayerNorm in the integer domain, all intermediates fixed-point | 28.91× perf-per-watt vs CPU/GPU, < 1% accuracy loss on SST-2 |
| 2022 | ViTA (Nag, Datta et al.) | Vision-transformer accelerator on Zynq ZC7020 | ~90% utilisation @ 0.88 W, 150 MHz |
| 2022 | FlashAttention (algorithm) | Tile-and-fuse Q·Kᵀ + softmax + scores·V into a single SRAM-sized kernel | 2–4× speedup, no approximation |
| 2023–2024 | ALSAP, DOTA, FAMOUS, FNM-Trans | Sparse attention, N:M sparsity, length-adaptive pipelining; UltraScale+ targets | — |
This repo's core algorithmic moves all trace back to the FQ-BERT line: integer softmax (PWL exp), integer LayerNorm (RSqrt LUT + Newton–Raphson), integer attention scoring, integer FFN. The FQ-BERT paper showed that you can run BERT entirely in the integer domain with < 1% accuracy loss — this design implements the same idea at smaller scale and with simpler approximations.
| Property | This repo | FTRANS (2020) | FQ-BERT (2021) | ViTA (2022) | H100 (2022) |
|---|---|---|---|---|---|
| Target | Artix-7 xc7a35t | VCU118 | VCU118 | Zynq ZC7020 | 4 nm GPU |
| Clock | 29.2–229.9 MHz (mod-dep.) | ~200 MHz | ~200 MHz | 150 MHz | 1.78 GHz |
| Numeric | Q8.8 fixed-point | Block-circulant 16-bit | Mixed integer/FXP | INT8 | FP8 + per-layer scale |
| D_MODEL | 64 | 768 (BERT-base) | 768 | 768 | any (compiler tiles) |
| Compute | 4×4 systolic + FSM softmax/LN | Multiple SAs + dedicated softmax/LN | Reconfigurable, INT softmax/LN | Single dense engine | 132 SMs × Tensor Cores + SFUs |
| KV-cache | Dual-port BRAM, append-on-token | — | — | — | HBM3, PagedAttention via vLLM |
| Verification | Python golden + iverilog + CocoTB, 84 tests | Per-paper | Per-paper | Per-paper | Internal QA + CUDA test stack |
| Lines of RTL | ~1 500 | large | large | large | (unpublished) |
This is not a competitive transformer accelerator. It will not run LLaMA-7B; the systolic array is two orders of magnitude too small, the KV-cache is six orders of magnitude too small, the clock is one order of magnitude too slow. It is an educational reference implementation with three stated goals:
If you're reading the RTL because you're learning hardware design, that's the audience. If you're reading it because you want to deploy a transformer to an FPGA, look at FQ-BERT or ViTA instead and bring the patterns from this repo to your own scale.
Choose a configuration; see the per-token cycle budget for one decoder layer at the chosen sequence position. Numbers come from the FSM cycle counts in the actual modules.
Notice how FFN's share of the budget climbs as D_MODEL grows (because FFN is O(D²) while the rest is O(D · seq)) and how seq_pos has almost zero effect on per-token cost at small D — the KV-cache is doing exactly the work it's there to do.
Type a value; see how Q8.8 stores it, the rounding error, and the worst-case error across the format's range. Compare with FP8 E4M3 (Hopper forward), FP8 E5M2 (Hopper backward), and FP4 E2M1 (Blackwell).
Try 0.5898 in Q8.8 — that's the actual out_emb[0] from the integration test (0x0097). Try 1e-4 in FP4 (rounds to zero) versus Q8.8 (also zero — Q8.8 is no better at small numbers than FP4 is). Try 200 in Q8.8 (saturates) versus FP8 E4M3 (representable). The format choices are full of these quiet trade-offs.
seq_pos; LLaMA-class models rotate Q and K. Easy to add: one new FSM state, two element-wise multiplies per head per element.layer_norm.sv (drop S_MEAN, drop the centered array, keep fp_inv_sqrt).None of those changes are mysterious. They're all in the literature, in shipping silicon, or in the companion AI_MMUL_Unit repo. The reference design is small enough to make each change a self-contained patch, with the regression suite catching anything that drifts. That's the role of educational RTL — not to replace H100, but to make H100's architectural choices legible.
From Kung's 1978 PE to the Blackwell Ultra SFU, the primitive doesn't change — the surroundings do. This repo is the smallest place where you can see the whole loop close: a 16-bit MAC, a piecewise-linear exp, a Newton–Raphson reciprocal square root, a KV-cache, and an FSM that schedules them so a token comes out the other end.