Mirage: A Transformer Accelerator Designed by an AI Agent
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
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:
◂ swipe to pan the diagram ▸
reports/w11-resource-map.md.Demo 2 JTAG chain identification
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:
◂ swipe to pan the diagram ▸
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:
◂ swipe to pan the diagram ▸
data · artifacts gate checks indigo = the model
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:
◂ swipe to pan the diagram ▸
data flow control · prefetch heavy edge = layer reuse ×6 dashed outline = model-drafted
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:
◂ swipe to pan the diagram ▸
harness/loop.py, workflow/query.py.◂ swipe to pan the timeline ▸
spec/mirage.state.json.The gate itself is one command, and a refusal is recorded verbatim as follows:
# 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:
score_sum overflow. W6 integrated 8 heads at worst 3.98 LSB.
smx_cache and smx_core.
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.
◂ swipe to pan the diagram ▸
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.
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
reports/w11-resource-map.md.Golden model first and RTL second kept finding real upstream defects, which the table records module by module:
| Module | As found | Root cause | After the loop |
|---|---|---|---|
taylor_exp | 64/64 sampled points ≥100% error | implicit clamp swallowed the input (Q-format) | 0.9683% max error (Q9, |x| ≤ 0.5352) |
smx_core e2e | attention 87 to 107 int8 LSB from golden | exp input scaling off by ≈2¹⁸ | worst 2.99 LSB, RMS 0.52 |
ffn_core | 10/10 trials fail and it won’t compile | no declaration for "mac_array", 278 KB vs 225 KB BRAM | redesign spec: 16-bit activations, streamed weights |
| 6-layer stack | drift >1 LN unit by layer 1 | softmax chaotic under int8 inter-stage quantization | int16 inter-stage: 0.0062 LN worst over 3 layers |
Comparison
reports/optimization-report-v1.md.reports/w11-resource-map.md.Across layers and across the run
reports/w9-multilayer.md.indigo band = Kimi K2 live session unshaded gap = my pause · review
spec/mirage.state.json.Comparison against golden reference, FPGA model and hardware
reports/evaluation-report-draft.md.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
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.
reports/robustness-matrix.md.Demo 5 Negative control, the checker failing on purpose
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:
$ 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.
| Exit criterion | Standing | Status |
|---|---|---|
| EN→AR & AR→EN encoder translation | v1: 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 host | encoder on silicon |
| ≥3× latency vs CPU baseline | the cycle model clears it with margin, and no int8-CPU comparison exists in the repo | modeled |
| <90% LUT & <95% BRAM, timing met | v1: 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 failing | met on v2 |
| Open-source repo + build/sim docs | the regression script and per-week reports exist, and no LICENSE file exists | blocked |
| Demo script + pitch deck | both exist | done |
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.
◂ swipe to pan the diagram ▸
The split, to scale
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:
| v1 | run 6 | run 8 | run 9 | run 10 | run 11 | run 16 | |
|---|---|---|---|---|---|---|---|
| LUT | 18,182 (126%) | 1,969 | 2,303 | 2,203 | 2,337 | 2,150 | 2,724 (18.9%) |
| DSP48E1 | 2 | 66 (100%) | 34 | 34 | 34 | 34 | 34 (51.5%) |
| RAMB36 | 0 | 35.5 | 35.5 | 35.5 | 36.5 | 36.5 | 36.5 (73.0%) |
| WNS | not run | −9.485 | −6.226 | +0.058 | n/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.
◂ swipe to pan the diagram ▸
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.
◂ swipe to pan the diagram ▸
Demo 6 Recording and splitting the job tape
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.
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
/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
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:
| What was wrong | What 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.
◂ swipe to pan the diagram ▸
Demo 9 Vector replay on the fabric
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.
| Claim | v1.0, July 2026 | Now, after the board run |
|---|---|---|
| Bitstream | Never built. A Vivado run away. | mirage_cora_wrapper.bit, timing closed 2026-08-01 |
| Board bring-up | Listed as future work | Run on camera, reproducible from demo.sh |
| LUT | 18,182, 126% of the device. Did not fit. | 2,724, 18.9%, measured in context |
| DSP48E1 | 65 of 66, 98%. Over the 90% bar. | 34 of 66, 51.5%, measured |
| RAMB36 | About 84%, projected | 36.5 of 50, 73.0%, measured |
| Timing | Assumed. No Vivado run existed. | WNS +0.214 ns, 0 of 8,803 endpoints failing |
| Datapath correctness | Bit-accurate Python models only | 61,632 values off the chip, 0 wrong, and a negative control that caught a planted byte |
| Latency, 16.74 ms | Cycle model at 200 MHz | Unchanged. Still a cycle model. |
| 360 tok/s, 1,700× CPU | Estimate against unoptimised Python | Unchanged. The project’s own report narrows it to 10× to 20× against a competent int8 baseline. |
| Translation quality | Toy-scale random weights, no BLEU | Real 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.
| Open claim | What is missing |
|---|---|
| Speed | Weights 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 quality | The decode is token-identical to the float32 reference on one sentence. No BLEU evaluation exists. |
| The last decode hop | enc_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. |
| Power | No rail was ever measured. The 5 to 10 mJ per sentence is a projection. |
| Sustained operation | One sentence, replayed twice. Nothing here covers thermals, or a second sentence without a reload. |
| The FFN overflow | The 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.
| Next | What it buys |
|---|---|
| Load weights over SD or Ethernet | JTAG 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 state | Closes the one inference above. “Must give the same Arabic” becomes a demonstration. |
| DDR bandwidth tuning | After the optimization pass the pipeline is memory-bound, not compute-bound, so the next real speedup lives here. |
| int4 FFN weights | Relieves BRAM pressure and stream time at once. |
Integrate rc2_hardening.vhd | Drafted 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-attention | The MVP is encoder-only, so the board never sees the decode. |
| Run a real BLEU | Quantize 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.