← projects

Mirage: A Transformer Accelerator Designed by an AI Agent

Mirage top-level block diagram, four quadrants: a 12 by 5 DSP matrix multiplication array, multi-head attention with Q K V weight BRAMs and a finite state machine, the softmax path with max finder, Taylor approximation and quantizers, and the feed-forward network with projection, hidden and output layers behind BRAM caches.
Fig. 1 · The architecture as it was drawn at the start. Every audit and every draft lands in one of these four cores. The rebuild replaced the 12×5 array with 32 lanes on 34 DSP48E1, and the tensor dimensions here are the toy sizes v1 was built against. Cropped from Mirage/images/mirage-top-level-bd.png. Select any image for its full-resolution file.

Mirage is an open-source transformer accelerator designed by an AI agent. It runs Transformer encoder inference for English to Arabic and Arabic to English machine translation. It targets the Digilent Cora Z7-07S (Zynq-7007S), a board that carries 14,400 LUTs, 66 DSP48E1 slices, 50 RAMB36 blocks and one 667 MHz ARM Cortex-A9. This page documents the agentic workflow that built it against a 19-task plan, a week-by-week schedule of module-specific features in which each core was verified before the next, from the matrix multiply array through multi-head attention to the fully integrated encoder.

Demo 1 The whole bring-up in one take

All eight stages in one take, 1 minute 34 seconds, from plugging the cable in to the negative control. Nothing is cut inside a command. Recorded 2026-08-13. Stage by stage, with the commands typed on camera, on the board bring-up page.
The board bring-up → The same design on real silicon: 2,352 jobs replayed on the fabric, 61,632 values matched byte-for-byte, zero mismatched, with a negative control proving the check could fail.

01 The problem

Not enough silicon, and no claim allowed through without evidence.

In the silicon budget, a 6-layer encoder with 256-dimensional activations has to run on a part that offers 66 DSP48E1 multipliers and about 225 KB of BRAM. Since the full stack cannot fit, one physical encoder layer stands in for all six and is walked six times per sentence. Pre-synthesis, the multiplier budget can then be expressed like this:

DSP48E1 floorplan of the XC7Z007S: 65 of 66 slices allocated A grid of 66 cells, one per DSP48E1 slice. 60 cells are allocated to the gemm_array matrix multiply tile, 4 to the taylor_exp exponential unit, 1 to the drafted softmax modules, and exactly 1 cell is free. Pre-synthesis estimate from reports/w11-resource-map.md. XC7Z007S · DSP48E1 BUDGET · PRE-SYNTHESIS ESTIMATE (W11)

◂ swipe to pan the diagram ▸

Fig. 2 · 65 of 66 DSP48E1 slices allocated. One free. Pre-synthesis, reports/w11-resource-map.md.

Demo 2 JTAG chain identification

The part in the map above answers for itself. 0x13723093 is the XC7Z007S fabric, 0x4ba00477 the ARM core beside it. Both ID codes are burned into the die, so this machine cannot print them. Stage 02 of the recorded run, 2026-08-13.

The other constraint is trust, since a chip is a long chain of decisions where a week-3 arithmetic error can quietly poison week 9. Every task closes on evidence for that reason, and every claim on this page carries the tier of evidence behind it.

02 Architecture

Agent, model, transformer, and a human who stays outside the loop.

There is no footage in this section. None of it ran on hardware: the architecture was drawn, modelled in Python and simulated in GHDL. The recordings pick up again in 04, at the next claim a camera can settle.

The four roles

The structure of this project is a chain of four parts, and each part has exactly one job. The agent is the harness loop in workflow/ and harness/, which picks one task at a time from the plan and refuses to close it until the files it promised exist on disk. The model is whichever one the harness is pointed at, and it takes each brief and does the engineering work of writing the reference models, drafting and repairing the VHDL, and leaving a report behind. Which model is a setting, not a premise. The 19-task run below was Kimi K2; the v2 rebuild, the board bring-up and the later write-ups were Claude; a little of the early scaffolding was Codex. Across the whole project that is roughly 60 / 30 / 10 Claude / Kimi / Codex. That is my estimate, not a measured split. The harness never asks: it gates on the artifacts, so the model is a swappable part. The Mirage transformer is the accelerator itself, the design that all of that work lands in. Together the agent, the model and the transformer constitute the workflow, while I steer the design process by writing the spec, building the gates, and reviewing what comes out of each phase:

The four roles: agent, model, and Mirage transformer form the workflow, with the human steering A box labelled the workflow contains three nodes in a chain: the agent, which briefs and gates, the model, which drafts and repairs, and the Mirage transformer, the accelerator design the work lands in. Above the box, a node labelled human, Fahad, sends a dashed steering edge into the workflow, carrying the spec, the gates and review. human steers the design spec · gates · review THE WORKFLOW the agent briefs · gates · retries the model drafts · audits · repairs Mirage transformer the accelerator design brief drafts

◂ swipe to pan the diagram ▸

Fig. 3 · agent → model → Mirage transformer. The human steers from outside.

The starting point was my own earlier work on Mirage. I wrote roughly 40% of the code by hand, before any of this was agentic, and designed about half the accelerator that way: the matrix multiplication, attention and softmax cores, the block diagram in Fig. 1 and the model scripts. That half is the upstream Mirage repository. Then I started working full time and ran out of hours for it. The agentic run is how the rest got built, and it treated every hand-written file as read-only from start to finish.

The harness

The harness is the half of the workflow that never trusts a transcript, and each block below can be selected for the note recorded against it:

Harness architecture: plan, gate CLI, model, and evidence tools Four zones left to right. PLAN: the spec, the 19-task plan, and the state file. GATE: the workflow CLI whose complete command exits 2 unless declared artifacts exist on disk. MODEL: the model, with pre-tool scope guards beneath it. EVIDENCE: ghdl, Python golden models, report artifacts, and the trace log. Task briefs of at most 500 tokens flow from the state file through the CLI to the model. The gate re-reads the disk before closing a task and writes results back to the state file. PLAN GATE · workflow.cli MODEL EDA · EVIDENCE mvp spec 19-task plan state file workflow.cli replies ≤500 tokens artifact gate the model scope guards ghdl goldens artifacts trace parse brief ≤500 tok one task gate re-reads the disk veto log verdict

◂ swipe to pan the diagram ▸

harness Select any block. The artifact gate is the one that matters.

data · artifacts gate checks indigo = the model

Fig. 4 · Briefs come from the state file, evidence from ghdl and spec-first goldens. A gate that reads the disk decides done. workflow/, harness/hooks.py.

The harness is four parts and one rule. spec/mirage.state.json holds 59 nodes. It is the only thing that knows what is done, never the model’s memory. The file picks the next task and briefs it in under 500 tokens, which is what lets a 19-task program run inside one context window. Each of the 19 carries a numeric acceptance criterion, a declared artifact list and a risk note before any work starts, and complete re-reads the disk and exits 2 when a declared artifact is not there. A task cannot be closed by claiming it. All 19 closed and none was forced; --force exists and only a human may use it.

Scope guards refuse writes into the read-only upstream submodule and block rm -rf, git push and curl | bash outright. The model is allowed to be wrong. It is not allowed to be destructive. And the order of work is fixed: the bit-accurate Python golden model is written from the spec, before the RTL is read, so the reference cannot inherit the mistakes it exists to catch. Every defect in this section was found that way, by a model with no knowledge of how the VHDL cheated. 03 runs the loop and the ledger it keeps.

The datapath

In the datapath, int8 weights stream in from DDR while int16 activations stay inside the fabric, and four of the blocks below carry real defects that the loop found:

Mirage datapath: processing system, AXI stream, and the encoder layer instantiated once Top zone: the Zynq processing system with the ARM Cortex-A9, AXI DMA and DDR3, feeding an AXI4-Stream rail. Bottom zone: the programmable logic holding token embedding, weight stream controller, score BRAM, and the encoder layer blocks: qkv score calculation, taylor exponential, softmax core, two add-and-norm stages, feed-forward network, and output projection. A return edge marks the same physical layer being reused six times. Dashed outlines mark model-drafted modules. PROCESSING SYSTEM · ZYNQ XC7Z007S ARM Cortex-A9 PS · driver + tiling AXI DMA · HP0 weights + activations DDR3 all 6 layers of weights UART console demo host · never run AXI4-STREAM · 100 MB/s planning assumption PROGRAMMABLE LOGIC · 14 400 LUT · 66 DSP48E1 · 50 RAMB36 ENCODER LAYER · INSTANTIATED ONCE token embedding int8 · 256-dim weight stream prefetch layer n+1 score BRAM 128 × 21 bit qkv_score_calc Q·Kᵀ · 8 heads taylor_exp_approx Q9 · |x| ≤ 0.5352 smx_core softmax · V add & norm ① int16 inter-stage ffn_core 256 → 1024 → 256 add & norm ② int16 inter-stage output proj → logits logits ×6 · time-multiplexed layer reuse

◂ swipe to pan the diagram ▸

datapath Select any block for the audit note recorded against it.

data flow control · prefetch heavy edge = layer reuse ×6 dashed outline = model-drafted

Fig. 5 · Reusing one layer six times is what makes it fit. spec/mirage.state.json and the W6 to W11 reports.

03 The loop & the ledger

Nineteen tasks, and nothing closes on a claim the disk cannot confirm.

No footage here either. What this section claims is that a task closed only when its declared artifacts were on disk, and the evidence for that is the state file and the exit codes, not a recording.

A task the loop can act on has to be falsifiable, so "verify the softmax" cannot be a task while "smx_core matches the Python golden for 8 heads and 4 tokens" can be one. The loop below runs five verbs against 19 such tasks, and the timeline under it records when each closure landed:

The agent loop as a cycle, with the decision that terminates it Five nodes in a cycle: next, start, execute, verify, then a decision diamond asking whether the declared artifacts exist on disk. Yes leads to task closed, 19 of 19 with none forced, and back to next. No leads back to execute for at most two retries, and a third failure leads to blocked, which only a human force flag can clear. Each node can be selected for its note. next state file picks start locks one open execute golden → RTL verify run the check artifacts on disk? task closed 19 / 19 · none forced blocked human --force only yes next task · ×19 no · retry ≤2 3rd failure

◂ swipe to pan the diagram ▸

Fig. 6 · The only path from “I think I’m done” to closed runs through the gate. Only a human clears a block. Contract: harness/loop.py, workflow/query.py.
The 19-task timeline in closure order, grouped by phase Nineteen circles on a horizontal axis in closure order from W1 to W19, grouped into six phase bands: P1 attention core, P2 FFN and encoder, P3 platform, P4 translation, P5 optimization, P6 hardening. The run opened on July 24 at 17:37, paused overnight for 11.7 hours between W13 and W14, and froze the MVP on July 25 at 13:28. Each circle can be selected for that task's outcome, closing time and artifact. P1 · ATTENTION P2 · FFN P3 P4 P5 P6 · FREEZE W1 W2 W3 W4 W5 W6 W7 W8 W9 W10 W11 W12 W13 W14 W15 W16 W17 W18 W19 run opened Jul 24 · 17:37 overnight pause · +11.7 h between W13 and W14 MVP frozen Jul 25 · 13:28

◂ swipe to pan the timeline ▸

ledger Select any task, or step through all 19 in closure order.
Fig. 7 · All 19 closures in order, with what each one found. spec/mirage.state.json.

The gate itself is one command, and a refusal is recorded verbatim as follows:

the gate, refusing
# close a task whose declared report does not exist yet
$ python -m workflow.cli complete p1/w3 --artifacts reports/w3-exp-error-histogram.md
REFUSED p1/w3: acceptance not verified
  artifact missing: reports/w3-exp-error-histogram.md
  fix, or re-run with --force to override
$ echo $?
2

Format as implemented in workflow/cli.py, where --force was used zero times.

What the run spent

Both sittings were logged end to end, so the model's spend is measured from the session logs rather than estimated, and the numbers below are rounded from those logs:

live session time

≈9.2 h5.6 h on Jul 24 and 3.6 h on Jul 25, around the overnight pause

model calls

308Kimi K2 requests across the 19 tasks

fresh tokens

≈583k≈416k read in as prompts and ≈167k written out

cached reads

≈30Mprompt tokens re-read from cache to keep context warm

Provider-measured counts from the Kimi wire logs, summed over the two sittings that closed all 19 tasks. A 6.2 h session after the freeze is not counted.

Where the phases landed

The plan's phases land inside the four cores of the block diagram, which are shown here as direct crops from Fig. 1. That drawing is v1, so the dimensions printed in it are the toy sizes the first design was built against:

Multi-head attention quadrant of the Mirage block diagram: Q K V weight BRAMs, token caches and a finite state machine.
Fig. 8 · Multi-head attention, where P1 opened. W1 found the score_sum overflow. W6 integrated 8 heads at worst 3.98 LSB.
Softmax quadrant of the Mirage block diagram: max finder, Taylor approximation and quantizers behind BRAM caches.
Fig. 9 · The softmax path. W3 exposed the clamp defect; W4 and W5 drafted smx_cache and smx_core.
Feed-forward network quadrant of the Mirage block diagram: projection, hidden and output layers behind BRAM caches with a finite state machine.
Fig. 10 · The feed-forward network core, where P2 ran. W7 found it failing 10 of 10 trials and closed with a redesign spec.
Matrix multiplication quadrant of the Mirage block diagram: a 12 by 5 array of multiply-accumulate DSP cells.
Fig. 11 · v1’s 12×5 DSP array, which every phase leaned on. W14 measured 36 of 36 dot products, start to done in 1 clock. The shipped array is 32 lanes on 34 DSP48E1.

04 Results

What came out of the run, and which of the numbers behind it are measured rather than modelled.

How the Arabic is produced

This is the part that is easiest to overclaim, so follow which box the tokens come out of. The host embeds, the fabric multiplies, the host normalizes between layers, and when the encoder state is finished the host decodes it. Six decoder layers and an LM head, all in numpy. The fabric has no embedding table, no decoder, and not enough block RAM for a single decoder weight matrix.

Where the Arabic tokens come from Hello is tokenized on the host to 3213 and 0, embedded on the host into two by 512 float32, then six encoder layers alternate 392 fabric matmul jobs per layer with host LayerNorm and residual, producing a two by 512 encoder state. That state is handed to a decoder that runs entirely on the host, six decoder layers and an LM head in numpy, emitting 1466, 1466, 0. The Arabic output is identical to the float32 reference, with cosine similarity 0.9835 and 0.9875 and token ids matching exactly. "Hello"→ [3213, 0] embed · host2 × 512 fp32 × 6 ENCODER LAYERS matmuls · PL392 jobs / layer LayerNorm · host+ residual, float next layer encoder state2 × 512 encoder state → decoder DECODER · ENTIRELY ON THE HOST 6 decoder layers + LM head · numpy the fabric has no embedding table, no decoder, no beam search, and not enough BRAM for one weight matrix → [1466, 1466, 0] OUTPUT مرحبا مرحبا identical to the float32 reference cosine 0.9835 / 0.9875 · token ids match exactly

◂ swipe to pan the diagram ▸

Fig. 27 · The tokens come out of the host, not the board. The fabric produced every matmul feeding this result. The decode ran on the model's encoder state.
What the board demonstrated. The PL is programmed with Mirage and answers with 0x4d495247. Its GEMM passes a known-answer test. It ran 2,352 jobs and returned 61,632 values over MMIO matching simulation exactly, and changing one input byte breaks that match in 112 places. Section 05 is how that run was done.
What is still an inference. enc_hw is the host's own computation, so the Arabic above was decoded from the model's encoder state, not from the board's. The board's 61,632 values match that state bit-exactly, so decoding the board's numbers must give the same Arabic. "Must" is an inference. Closing it means running the decoder on the tensors the fabric returned.

Composition

DSP multipliers: 65 of 66 allocated Pie chart of all 66 DSP48E1 slices. The gemm array takes 60, the taylor exponential takes 4, the drafted softmax modules take 1, and 1 is free. Each slice is named by a label traced to it with a leader line. DSP MULTIPLIERS · 65 OF 66 ALLOCATED all 66 DSP48E1 slices · pre-synthesis gemm_array · 60 taylor_exp · 4 smx drafts · 1 free · 1
Fig. 12 · Where the 66 DSP slices go: 60 to the matrix tile, 4 to the exponential, 1 to the drafted softmax, 1 free. reports/w11-resource-map.md.
Block RAM: 42 of 50 allocated Pie chart of all 50 RAMB36 blocks. Q K V and Wo weight buffers take 32, the FFN takes 5, projection and score buffers take 4, the softmax cache takes 1, and 8 are free. Each slice is named by a label traced to it with a leader line. BLOCK RAM · 42 OF 50 ALLOCATED all 50 RAMB36 blocks · pre-synthesis QKV/Wo · 32 FFN · 5 score/proj · 4 smx cache · 1 free · 8
Fig. 13 · Where the 50 block RAMs go: 42 allocated, 8 free. The split holds only because the FFN's 262 KB of hidden and output weights stream from DDR.

Golden model first and RTL second kept finding real upstream defects, which the table records module by module:

Defects found in upstream RTL, reports W1 to W9
ModuleAs foundRoot causeAfter the loop
taylor_exp64/64 sampled points ≥100% errorimplicit clamp swallowed the input (Q-format)0.9683% max error (Q9, |x| ≤ 0.5352)
smx_core e2eattention 87 to 107 int8 LSB from goldenexp input scaling off by ≈2¹⁸worst 2.99 LSB, RMS 0.52
ffn_core10/10 trials fail and it won’t compileno declaration for "mac_array", 278 KB vs 225 KB BRAMredesign spec: 16-bit activations, streamed weights
6-layer stackdrift >1 LN unit by layer 1softmax chaotic under int8 inter-stage quantizationint16 inter-stage: 0.0062 LN worst over 3 layers

Comparison

Time to translate one sentence: golden software model versus GHDL verification Two horizontal bars. The golden software model in Python prices the 6-token sentence at 25.98 milliseconds. The GHDL-verified VHDL figure is 16.74 milliseconds, a 36 percent reduction. Cycle-model figures at 200 megahertz. TIME TO TRANSLATE ONE SENTENCE ms · cycle model at 200 MHz · lower is better golden software model (Python) 25.98 GHDL verification (VHDL) 16.74 · −36%
Fig. 14 · The baseline is the software planning model. W15 re-priced it with the gemm rate verified in GHDL, overlapping streaming with compute. reports/optimization-report-v1.md.
Tokens translated per second: golden software model versus GHDL verification Two horizontal bars. The golden software model in Python projects about 231 tokens per second. The GHDL-verified VHDL figure is about 360 tokens per second, up 56 percent, against a real-time need near 15 tokens per second. TOKENS TRANSLATED PER SECOND cycle model · 6-layer encoder · higher is better golden software model (Python) ≈231 GHDL verification (VHDL) ≈360 · +56%
Fig. 15 · Real-time NMT needs about 15 tok/s, so the optimized figure carries roughly 24× headroom. The pipeline is DDR-bound, crossover near 192 MB/s.
Resource utilization against the device, pre-synthesis Three horizontal bars. DSP48E1 at 65 of 66 is 98 percent. RAMB36 at 42 of 50 is 84 percent. LUT at about 3,600 of 14,400 is 25 percent. Pre-synthesis estimates, since no Vivado run exists. HOW MUCH OF THE CHIP MIRAGE USES device budget share · pre-synthesis DSP48E1 65/66 · 98% RAMB36 42/50 · 84% LUT ≈3.6k/14.4k · 25%
Fig. 16 · DSP at 98% is expected to fail acceptance. The documented mitigation time-multiplexes the gemm as 2×30. W15 · GHDL, reports/w11-resource-map.md.

Across layers and across the run

Per-layer drift for each inter-stage activation format, log scale Line chart over layers 0, 1 and 2 on a logarithmic drift scale in LayerNorm units. The three int8 formats climb from 0.039 to 0.163 at layer 0 up past 2 LN units by layer 2, all far above the 0.01 budget line. The int16 format stays flat at 0.0043 to 0.0062, inside budget. Model-level results from reports/w9-multilayer.md. ROUNDING ERROR AS LAYERS STACK drift from the software reference · log scale 0.001 0.01 0.1 1 budget 0.01 L0 L1 L2 int8 ×8 / ×16 / ×32 int16 ×512
Fig. 17 · Drift is how far the hardware’s numbers wander from the software reference as rounding compounds across layers, in LayerNorm units. Every int8 format is over the 0.01 budget by layer 1. int16 stays flat, so the datapath keeps int16 between stages. reports/w9-multilayer.md.
Cumulative task closures over the elapsed hours of the run Step line from 1 to 19 closures over 19.85 elapsed hours. Six closures land in the first half hour, seven more land in a burst near hour 5, the line pauses overnight, and the last six land between hours 17 and 19.85. Two shaded bands mark Kimi K2's live sessions, and the unshaded gap between them is the human pause and review. TASKS CLOSED OVER THE RUN each step up is one task closing · 19.85 h total 0h 5h 10h 15h 20h 0 19 W6 W13 W19 overnight pause

indigo band = Kimi K2 live session unshaded gap = my pause · review

Fig. 18 · Each step is one task closing. The shaded bands are Kimi K2’s two live sessions. The gap is where I paused overnight and reviewed. Timestamps from spec/mirage.state.json.

Comparison against golden reference, FPGA model and hardware

Golden reference versus FPGA cycle model, log scale Two bars on a logarithmic scale for one identical toy-scale sentence. The measured pure Python golden reference takes about 5.43 seconds on the CPU, while the FPGA cycle model at 200 megahertz estimates 3.18 milliseconds, a ratio near 1,700. GOLDEN REFERENCE VS FPGA CYCLE MODEL same sentence, same math · log scale software · Python golden, measured on CPU ≈5.43 s FPGA · cycle model, GHDL-measured gemm rate ≈3.18 ms · ≈1,700×
Fig. 19 · The software side is the pure Python golden reference, timed on the host CPU. The FPGA side prices the cycle count at 200 MHz with the gemm rate measured in GHDL. So 1,700× is against interpreted Python. The report puts an optimized int8 CPU baseline at 10 to 20×. reports/evaluation-report-draft.md.
FPGA cycle model versus silicon, where the board run measured values and not time Two horizontal bars. The FPGA cycle model prices the toy sentence at about 3.18 milliseconds. The silicon bar carries the same projection in gray and is marked not measured: the board run in section 05 is a vector replay that checks values, so no wall-clock latency was recorded. FPGA CYCLE MODEL VS SILICON same sentence · FPGA image on the Cora Z7 FPGA cycle model · 200 MHz ≈3.18 ms silicon · FPGA image on the board ≈3.18 ms ⚠ not measured · values only
Fig. 20 · v1's bitstream was deliberately not built, so v1 has no silicon number. v2 ran on the board as a vector replay that compares values rather than timing a translation, so the gray bar still carries the cycle model’s projection. See 05.

Demo 3 Bitstream load and smoke test

make load sends the bitstream and a bare-metal smoke test down the same cable. The serial line answers: ID register 0x4d495247, which is MIRG over AXI4-Lite, a loopback with 0 of 64 bytes wrong, and a gemm returning 64 in all 32 lanes. That came out of the chip. Stage 04 of the recorded run, 2026-08-13.

Demo 4 Weight load over JTAG

Why nothing above is a measured speed. openocd loads the bitstream, breaks at main() so ps7_init finishes and DDR comes up, then pushes 25,165,824 bytes of weights to 0x01000000 and the command stream to 0x00400000, at about 132 KiB/s. That wall clock describes the cable, not the array. Stage 06 of the recorded run, 2026-08-13.

Fault injection

Fault injection flips one stored bit on purpose, in a weight or in an activation, and then measures how far the final output moves. A quantized datapath can absorb a wrong value or it can amplify it, and nothing in the design says which one without a measurement. The spec set the ceiling at 0.5 LayerNorm units, so this is the test that decides whether the design sits inside it.

Worst output drift under single-bit fault injection, against the limit Two horizontal bars against a dashed limit line at 0.5 LayerNorm units. A single bit flip in a weight word drifts the output 0.0507 LN units at worst over 20 trials, and a flip in an activation word drifts it 0.0407. Both sit far inside the limit. FAULT INJECTION · FLIP ONE BIT worst output drift over 20 trials weight bit flip · 20 trials 0.0507 activation bit flip · 20 trials 0.0407 limit 0.5
Fig. 21 · Fault injection flips one stored bit, in a weight or an activation, and measures how far the output moves. Both cases absorb the hit at least 10× inside the 0.5 limit. The one real finding is an FFN accumulator that can overflow int32 at all-max input. reports/robustness-matrix.md.

Demo 5 Negative control, the checker failing on purpose

The same idea as the chart above, run on the board instead of in a model. One activation byte in the tape goes from 05 to 06 and the comparison prints VECTOR REPLAY FAIL, 112 of 61,632 values wrong. A checker that cannot fail proves nothing. Stage 08 of the recorded run, 2026-08-13.

The release check is one command, and the recorded v1.0 re-run reads as follows:

reports/final-regression.log
$ bash outputs/run_regression.sh
== model-level golden checks ==
PASS  taylor_exp_model.py
PASS  smx_e2e_model.py (2.99 < 4.0)
PASS  ffn_model.py
PASS  encoder_e2e_model.py (0.0296 < 0.05)
PASS  multilayer_e2e_model.py (0.0047 < 0.01)
PASS  greedy_decode_model.py
PASS  bidirectional_demo.py
PASS  beam_search_model.py (13 >= 13)
PASS  robustness_model.py
== RTL (ghdl) checks ==
PASS  ghdl:gemm_latency_tb
==
REGRESSION: ALL GREEN
exit=0

Evidence tiers & the exit bar

ghdl-simulated

  • gemm tile: 36 of 36 dot products
  • start to done: 1 clock
  • every model-drafted VHDL file analyses and elaborates clean
  • the ledger and the gate that closed each task. See 03.

Model-level golden

  • every accuracy claim, from LSB drift to token-identical decode
  • bit-accurate Python against float references
  • not full-pipeline RTL simulation
  • the int8 and int16 drift curves. See 04.

Estimate / projection

  • latency, throughput, the 1,700× comparison, v1’s resource map
  • cycle-model or pre-synthesis figures
  • nothing here was measured on silicon
  • every bar that carries a projection. See 04.

Measured on silicon

  • 2,352 jobs replayed on the fabric
  • 61,632 values compared, 0 mismatched
  • negative control: one activation byte flipped, 112 mismatches
  • resources and timing from implementation run 16 (PROVENANCE.md §D.3, §D.6). See 05.
Spec exit criteria and where each stands
Exit criterionStandingStatus
EN→AR & AR→EN encoder translationv1: 10/10 token-identical vs golden on toy random weights. v2 runs the real opus-mt-en-ar weights and decodes token-identically to the float32 reference, with the encoder's matmuls on silicon and the decode on the hostencoder on silicon
≥3× latency vs CPU baselinethe cycle model clears it with margin, and no int8-CPU comparison exists in the repomodeled
<90% LUT & <95% BRAM, timing metv1: LUT 25% and BRAM 84% pre-synth, timing unmeasured, DSP 98% against the task's 90% bar. v2 measured in context: LUT 18.9%, BRAM 73.0%, DSP 51.5%, WNS +0.214 ns with 0 of 8,803 endpoints failingmet on v2
Open-source repo + build/sim docsthe regression script and per-week reports exist, and no LICENSE file existsblocked
Demo script + pitch deckboth existdone

Three numbers still do not exist in the repository and are not charted on this page: a BLEU score, since no trained checkpoint exists, and a board power figure and a measured silicon latency, since the board run checks values rather than timing anything. Post-synthesis utilization and timing did not exist when v1 froze; they exist for v2 and are charted in 05.

05 On silicon

Synthesis rejected v1. What the rebuild changed, and what the board proved.

Everything above froze at v1, before synthesis. Put through Vivado, v1 declared 212% of the device's block RAM and 18,100 LUTs, or 126%, and did not fit. The re-architecture that followed moved every array into a BRAM primitive and every multiply into a DSP, and widened the encoder to the 512 dimensions the real MarianMT checkpoint uses. That design fits, meets timing, and has since run on the board. This section is what the machine turned out to be.

The three domains

An FPGA that translates is the wrong picture. The fabric performs a transformer encoder's matrix multiplies and nothing else; an x86 host stitches the layers together and runs the entire decoder, and the ARM between them is a courier that moves bytes and starts jobs.

The three domains: host, ARM courier, and programmable logic The x86 host tokenizes the sentence, runs the elementwise glue and the whole decoder, and generates a job tape of 858,037 records. openocd carries it over JTAG as 0.91 megabytes of commands plus a 25.17 megabyte weight blob loaded once. The ARM Cortex-A9 holds both in DDR and drives the fabric over AXI4-Lite at 0x43C00000. The programmable logic runs the matmul chain, Q K V projections, Q K transpose scores, softmax, attention output, output projection and the feed-forward network, and returns tensors to the host over MMIO. The fabric never receives text. HOST · x86 translate_hw.py MarianMT en-ar · 298 MB SentencePiece → [3213, 0] elementwise glue · float LayerNorm · residual · embed decoder · 6 layers + LM head tape generator 858,037 records TRANSPORT · openocd/JTAG tape_split.py commands 0.91 MB · 7 s weight blob 25.17 MB · once ≈130 KiB/s measured DDR up via main() breakpoint PROCESSING SYSTEM · ARM A9 vectors.c · courier crt0.S → ps7_init() level shifters up · caches OFF ACT · CFG · WBLK · JOB · DUMP gemm · stage · smax · scale DDR 512 MB 0x01000000 blob 25.17 MB 0x00400000 commands 0.91 MB 0x00600000 expected results AXI4-Lite @ 0x43C00000 ID = 0x4d495247 "MIRG" PROGRAMMABLE LOGIC · 99.8% OF THE ARITHMETIC Q · K · Vprojections147k MAC QKᵀ scoresstage_engine9.2k MAC softmaxexp + recipno divider A · Vattention out9.2k MAC W_oout projection49k MAC FFN512→2048→512 2,163k · 91% 2.38 M MACs / decode step · 6 layers · 2,352 jobs / sentence tensors read back over MMIO · DUMP / DUMPS int8 tiles out, int8/int32 tiles in · never text ↑ back to the host for LayerNorm, then the next layer

◂ swipe to pan the diagram ▸

Fig. 22 · The fabric never sees a sentence. It takes quantized tiles and returns tensors, so tokenization, embedding, LayerNorm and the decoder stay on the host. Indigo is the fabric, teal the ARM, amber the host. That coding is local to this section.

The split, to scale

Where the arithmetic runs, drawn to scale A single bar for the 2.38 million multiply-accumulates in one decode step. The programmable logic takes 99.8 percent of it. The host's share, LayerNorm, residual and embedding, is the hairline at the right-hand end, traced to its label by a leader line. WHERE THE ARITHMETIC RUNS 2.38 M multiply-accumulates per decode step PL · matmul chain + softmax · 99.8% host · 0.2% · LayerNorm, residual, embed
Fig. 23 · The host's share is the hairline, not a missing bar. It is cheap in FLOPs and it runs between every pass.

On the fabric · 99.8%

Every multiply-accumulate in all six encoder layers, including the 512→2048→512 feed-forward network, which is 91% of a layer by itself.

On the host · ≈0.2%

FP.layer_norm(x1 + yf, …), the residual add and the embedding lookup. The whole decoder sits here too, and the encoder's MAC budget does not count it.

Making it fit

v1’s architecture did not fit this device and was never going to. Synthesis put it at 18,182 LUT, 126% of the part, with 2 DSP slices and zero block RAM: every array became flip-flops and a mux tree, and every int8 multiply became fabric. Both causes were measured rather than guessed. First, the design read 60 values out of an array in one cycle, and no memory primitive on any device serves that. Block RAM has 2 read ports and LUTRAM 16. So qk_score_bram alone spent 11,587 LUT, 80% of the whole device, to hold 2,688 bits. Second, int8×int8 sits below Vivado’s threshold for inferring a DSP, which the same run proved by contrast: smx_v_calc’s 32×8 multiply is what inferred the design’s only 2 slices.

Sixteen implementation runs followed:

Implementation trajectory, measured from PROVENANCE.md
v1run 6run 8run 9run 10run 11run 16
LUT18,182 (126%)1,9692,3032,2032,3372,1502,724 (18.9%)
DSP48E1266 (100%)3434343434 (51.5%)
RAMB36035.535.535.536.536.536.5 (73.0%)
WNSnot run−9.485−6.226+0.058n/a+0.252+0.214 ns

◂ swipe to pan the table ▸

The run worth a sentence is run 10, because it failed on a flow defect and not a design one. synth_design had never been given -mode out_of_context, so Vivado treated encoder_layer as a whole chip and demanded a package pin for every port bit. 171 bits against 100 sites on clg400, reported as [Place 30-58] IO placement is infeasible. Run 11 is the same netlist with the flag set. Nothing about the design was wrong. Telling those two kinds of failure apart is the skill this page is about.

Inside the fabric

The memory budget is the architecture. v1 kept its arrays in logic and inferred its multipliers, which is how a 6-layer encoder came to ask for 212% of the block RAM on a part that has 50 blocks. v2 forces every array into a BRAM primitive and every multiply into a DSP48E1, and buys the latency back with a double-buffered weight window and a softmax that multiplies by a reciprocal rather than dividing.

Inside the fabric, block by block layer_ctrl dispatches jobs to gemm_sequencer, which drives a 32-lane output-stationary MAC array. The array is fed by a double-buffered weight window in 16 block RAMs, an activation memory of 8 bits by 65,536, and a score memory of 21 bits by 4,096. A divider-free softmax unit uses an exponential ROM and a reciprocal ROM. Measured in context on Vivado 2022.2 implementation run 16: 34 of 66 DSP48E1 at 51.5 percent, 36.5 of 50 block RAM tiles at 73 percent, 2,724 of 14,400 slice LUTs at 18.9 percent, and worst negative slack of plus 0.214 nanoseconds with zero of 8,803 endpoints failing. v1, for contrast, asked for 18,100 LUTs, or 126 percent, and did not fit. layer_ctrl job dispatcher, not a fixed op walker JOB gemm_sequencer reduction chunking programmable requant weight_window 16 RAMB36 · double-buffered buf A buf B writes go to !rd_buf, so load overlaps compute 16 banks × 32 b × 1024 mac_array 32 × mac_lane, output-stationary 32 DSP48E1 · the product only accumulators kept in fabric 1,024 LUT = 32 × 32-bit act_mem 8 b × 65536 · 15 BRAM 0 LUT score_mem 21 b × 4096 · 3 BRAM 0 LUT softmax_unit exp_rom · 1 BRAM recip_rom · 1 BRAM no divider reciprocal multiply; a divider would not have fit MEASURED IN CONTEXT · VIVADO 2022.2 · IMPLEMENTATION RUN 16 DSP48E1 34 / 66 · 51.5% BRAM tile 36.5 / 50 · 73% Slice LUT 2,724 / 14,400 · 18.9% WNS+0.214 ns · 0 of 8,803 failing v118,100 LUT · 126% · did not fit

◂ swipe to pan the diagram ▸

Fig. 24 · The same encoder, re-expressed against the primitives the device actually has. Resources and timing are post-implementation in context, not estimates. PROVENANCE.md §D.3.

Why the tape was taken apart

The board replays a job tape: a flat list of records that write weights, write activations, run a job and dump the result. One sentence came to 64.3 MB against an on-chip ceiling near 64 KB. Counting the records first changed the problem. 98.6% of the tape was weight rows, and weight rows do not change between sentences.

The 64.3 MB tape is 98.6 percent weight rows Before: one 64.3 megabyte tape of 858,037 records, of which 786,432 weight rows are 63.4 megabytes, or 98.6 percent, with activations at 0.7 megabytes and configuration at 0.2. After tape_split.py turns 1,152 runs of weight rows into 1,152 WBLK records: 0.91 megabytes of commands, 71 times smaller and about 7 seconds over JTAG, plus a 25.17 megabyte binary weight blob loaded into DDR once. The round trip was proven byte-identical both in Python and through the board's own C interpreter. BEFORE · ONE TAPE, RE-SENT EVERY SENTENCE WROW · 786,432 weight rows · 63.4 MB · 98.6% ACT 0.7 MB CFG 0.2 · rest 0.03 64.3 MB · 858,037 records · OCM allows ≈64 KB per tape tape_split.py · 1,152 runs become 1,152 WBLK records AFTER · WEIGHTS ONCE, COMMANDS PER SENTENCE commands 0.91 MB 71× smaller ≈7 s over JTAG weight blob 25.17 MB binary, resident in DDR loaded once · ≈3 min round-trip proven byte-identical expand(split(tape)) == tape · 786,432 rows and again through the board's own C interpreter

◂ swipe to pan the diagram ▸

Fig. 25 · The tape was not too big; it was re-shipping the model. Moving the weight rows into a blob loaded once leaves 0.91 MB of commands per sentence, 71× smaller.

Demo 6 Recording and splitting the job tape

The split above being made. A 6-layer MarianMT encoder pass is recorded off the RTL simulator as a job tape, then cut into 0.91 MB of commands and 25.17 MB of weights: 1,152 window loads over 786,432 rows, with a DDR map for both halves. Stage 05 of the recorded run, 2026-08-13.

Onto the board without the vendor stack

The documented path from a bitstream to a bare-metal application is Vitis: 60 to 100 GB, and the 2022.2 release does not support this machine’s OS. None of it turned out to be necessary. The hardware handoff file already contains ps7_init_gpl.c. That is the processing system’s whole configuration, 12,500 lines of C that define their own mask_write and mask_poll as plain volatile pointer writes and depend on nothing else. So the vendor platform reduced to a linker script, a crt0 and a UART driver, built with gcc-arm-none-eabi and loaded with openocd: about 200 MB of toolchain instead of 60 to 100 GB. The three generated driver files are used unmodified, because editing verified code to suit a new build would mean the thing on the board is no longer the thing that was checked.

There is no operating system on this board, and no SSH. The XC7Z007S here has no boot flash and no SD card; the application is bare metal, loaded over JTAG. Both channels arrive on one micro-USB through an FT2232H. openocd claims JTAG on interface 0, and the console is /dev/ttyUSB1 at 115200 8N1. A PetaLinux image was scoped and deliberately never built.

Demo 7 One cable, two channels

One micro-USB carries both channels into the board: interface 0 is JTAG, interface 1 is the console. Only /dev/ttyUSB1 gets a tty node, because openocd claims interface 0 over libusb and the kernel leaves it alone. Stage 01 of the recorded run, 2026-08-13.

Demo 8 Serial console on a board with no OS

The console opened on a board that is not running anything yet. The baud rate comes out of PCW_UART0_BAUD_RATE in the XSA and the host has to match it. picocom attaches and the board says nothing, because there is no boot flash and no SD card. Code arrives over JTAG. Stage 03 of the recorded run, 2026-08-13.

ps7_post_config() raises the level shifters between the processing system and the fabric, and until it runs AXI never reaches the programmable logic at all. And the caches stay off: the ACT_DATA register auto-increments when it is accessed, so a dirty cache line that never reached the fabric would come back looking exactly like a datapath bug.

Three defects sat between “the bitstream loads” and “the board prints”, and all three were silent:

Three failures that reported nothing
What was wrongWhat it looked like
The driver addressed UART1 at 0xE0001000. The block design enables UART0 at 0xE0000000 on MIO 14-15 and leaves UART1 disabled. Writing to an unclocked peripheral produces no output and reports no error. Identical to a board that never booted.
The capture script set stty min 0 time 10, which makes read() return zero bytes on timeout. cat reads that as end-of-file and exits about a second in. Two runs captured an empty file, which looks exactly like a dead board.
target smp makes the last-created target the default. halt and resume silently addressed cpu1 while every expectation was about cpu0.

There was no output to go on. What localised it was the halt state: it had moved from System at pc 0xffffff28 to Supervisor. crt0 had plainly run, so the CPU was not the suspect and the search narrowed to the peripheral. The full session, every stage of it, is recorded on the board bring-up page.

Chain of custody

Four representations of the same computation, each required to agree with the one before it at zero tolerance: a float32 reference in numpy, a bit-exact int8 model of the datapath, the RTL in GHDL, and the board. The last link is a vector replay. The fabric computes, and the expected values sit in DDR only to be compared against.

The test was not written for the board. sim/ops_cmd.txt is the register sequence for every job type the design implements, generated from the bit-exact int8 model and already run against the RTL in GHDL before any hardware existed. sw/board/vectors.c replays that same sequence against the real fabric over AXI4-Lite and compares every value returned. The comparison then runs twice: once by the ARM core against a copy of the expected tape in DDR, once on the host against the reference on disk. A bug in one comparator cannot pass both. The two clips below are that run and its negative control.

Float32 reference, int8 model, GHDL simulation and silicon all agree bit-exactly A chain of four boxes: a float32 MarianMT reference in numpy, quantized into a bit-exact int8 datapath model, matched bit-exactly by the encoder_layer testbench in GHDL over 729 seconds of wall time, matched bit-exactly again on silicon across 2,352 jobs and 61,632 values with none wrong. Below, a negative control: changing one byte of the 64.3 megabyte tape, an activation from 05 to 06, produced 112 mismatches out of 61,632 and a REPLAY FAIL, so the comparison is capable of failing. float32 refMarianMT in numpyno torch int8 modelmirage_int8.Fabricbit-exact datapath GHDL simencoder_layer_tb729 s wall silicon2,352 jobs 61,632 · 0 wrong quantize bit-exact bit-exact NEGATIVE CONTROL · A TEST THAT CANNOT FAIL PROVES NOTHING one byte of 64.3 MB changed · ACT 0000 05 → 06 112 of 61,632 mismatched · REPLAY FAIL divergence propagates through six layers, damped by requantization · mostly ±1, the expected signature

◂ swipe to pan the diagram ▸

Fig. 26 · The board is computing, not echoing. Values come back over MMIO from the AXI slave, and one changed input byte breaks the match in 112 places.

Demo 9 Vector replay on the fabric

The last link in the chain above, closing. 2,352 jobs run on the fabric and every value is compared against the simulator’s answer on the chip. The host reads only the tally: 61,632 values checked, 0 mismatched, VECTOR REPLAY PASS. Stage 07 of the recorded run, 2026-08-13.

06 Conclusion

The project’s numbers in one place, with the open claims beside them.

v1.0 froze in July with a design that did not fit and a bitstream nobody built. Every number in it came from a model. The table is what a month of rebuilding moved: correctness changed evidence tier and speed did not.

What changed between v1.0 and the silicon run
Claimv1.0, July 2026Now, after the board run
BitstreamNever built. A Vivado run away.mirage_cora_wrapper.bit, timing closed 2026-08-01
Board bring-upListed as future workRun on camera, reproducible from demo.sh
LUT18,182, 126% of the device. Did not fit.2,724, 18.9%, measured in context
DSP48E165 of 66, 98%. Over the 90% bar.34 of 66, 51.5%, measured
RAMB36About 84%, projected36.5 of 50, 73.0%, measured
TimingAssumed. No Vivado run existed.WNS +0.214 ns, 0 of 8,803 endpoints failing
Datapath correctnessBit-accurate Python models only61,632 values off the chip, 0 wrong, and a negative control that caught a planted byte
Latency, 16.74 msCycle model at 200 MHzUnchanged. Still a cycle model.
360 tok/s, 1,700× CPUEstimate against unoptimised PythonUnchanged. The project’s own report narrows it to 10× to 20× against a competent int8 baseline.
Translation qualityToy-scale random weights, no BLEUReal MarianMT weights drive the tape. Still no BLEU.

Six claims on this page are not settled by the board run. The table says what is missing for each one, because leaving one out would be worse than saying nothing.

What the board run does not prove
Open claimWhat is missing
SpeedWeights cross JTAG at about 132 KiB/s, so the session is bandwidth-bound by a wide margin. The wall clock times the cable, not the design.
Translation qualityThe decode is token-identical to the float32 reference on one sentence. No BLEU evaluation exists.
The last decode hopenc_hw is the host’s own computation, so the check decodes the model’s encoder state. The board’s 61,632 values match that state bit-exactly, so the same Arabic follows by inference, not by demonstration.
PowerNo rail was ever measured. The 5 to 10 mJ per sentence is a projection.
Sustained operationOne sentence, replayed twice. Nothing here covers thermals, or a second sentence without a reload.
The FFN overflowThe all-max input that drives the accumulator to 4.0× int32 overflow is modelled, not observed. rc2_hardening.vhd is drafted and GHDL-analysed, not integrated.

Each item closes something in the two tables above. The first one is what makes a timing number worth reporting.

Open work, in rough order of impact
NextWhat it buys
Load weights over SD or EthernetJTAG makes the load three minutes. SD or Ethernet makes it a fraction of a second, and only then is a measured speed worth reporting.
Decode from the board’s own stateCloses the one inference above. “Must give the same Arabic” becomes a demonstration.
DDR bandwidth tuningAfter the optimization pass the pipeline is memory-bound, not compute-bound, so the next real speedup lives here.
int4 FFN weightsRelieves BRAM pressure and stream time at once.
Integrate rc2_hardening.vhdDrafted and GHDL-analysed, never integrated. It covers the modelled overflow above. Single-bit upsets already absorb at the noise floor, at most 0.051 LN drift.
A Marian decoder with cross-attentionThe MVP is encoder-only, so the board never sees the decode.
Run a real BLEUQuantize the trained checkpoints through Mirage/model/scripts/, then re-run token match beside BLEU. The one open claim the project can close on its own.

Resources and timing are post-implementation in context (Vivado 2022.2, xc7z007sclg400-1, run 16, 100 MHz clk_fpga_0). Silicon results are a vector replay over JTAG, which measures values and not wall-clock time, so the latency figures in 04 remain cycle-model projections.