# RV32 inside Qwen3.5 `chipformer/rv/` compiles a RISC-V core into stock Qwen3.5 Gated DeltaNet + SwiGLU layers with the same q35 compiler as the CHIP-8 machine (`chipformer/q35/ir.py`, `prims.py`, `emit.py`). P3 (branch `rv-p3b`) is first, then P2 (branch `rv-p2`), P1 (branch `rv-p1`) and the P0 feasibility study (branch `rv-p0`). ## P3: the RV32 computer inside Qwen3.5-4B (a `calc` tool) ### What it is - **rv-q4p2:** a stock text-only `Qwen3_5ForCausalLM`. - Layers: the 11 chip layers of the P2 core plus the bridge first (`core.build(text=...)`), then Qwen3.5-4B's 32 layers. - Hidden 3968; 478,656 tokens (Qwen's plus 230,324 chip tokens appended as special tokens); 18.7 GB in bf16. - Built by `python -m chipformer.rv.merge BASE --out DIR` (needs about 31 GB of RAM). - **The bridge** (in the circuit): - Qwen's tool-call tokens after `` are captured into a text buffer that the core reads as memory at 0x8000: 16 bytes per token, up to 80 tokens per call. - `` starts a run (GO). Each byte the program prints is echoed as the matching Qwen token, so Qwen reads the response as ordinary text; HALT is echoed as a newline. - The circuit owns exactly those positions (owner, tau and mu). Everywhere else Qwen predicts. - **The firmware:** `env/calc/calc.c`, built with `-DBRIDGE` as `tests/data/rv/calc-bridge.elf`. - It parks between calls. It takes the call's first parameter, evaluates it on P2's digit file and prints `\n\nVALUE\n\n\n\n\n`. - Values have up to 62 digits. - Integers: `+ - * // % ** gcd()` and parentheses. - Decimals: `+ - *`, printed with a point and no trailing zeros. - calc v2 (`2db7827`): `/` is exact rational arithmetic throughout (reduced fractions `p/q`). - Error messages: - `error (too complex: split it)` when the 7 bignum registers run out; - `error (too long: split it)` for a call longer than the 80-token capture window. - **Decoding** (`rv/chat.py` `RvChat`): - Chip tokens run only the chip layers and are invisible to Qwen's layers. - The snapshot (firmware, RAM, registers) goes into the chip layers' states after the prompt. - Every circuit token is checked in lockstep against the reference machine (`bridge.Session`). ### The chip layers on the Triton executor (`908fdd4`) - `RvChat(executor=True)` runs the chip layers as the circuit itself (`tritonexec.Executor2`: the IR's arithmetic in fp32, one CUDA graph per token) instead of their HF modules. - After the chip layers, a text token's residual is Qwen's embedding row plus the 23 kept dims (output code, tau, mu); the exit clears every other chip dim and the ballast. So `hc` is rebuilt from those and handed to Qwen's layers unchanged. - **Checked** (`rv/execcheck.py`, 6 records including two mul_div ones): - "verify" mode runs both paths. The HF chip view's residual equals the executor's bit for bit at every position. - Executor-only decoding gives the records' tokens exactly. - **Speed (L4):** about 250 chip tokens/s on the HF path, 3,200 on the executor (13x). - A calc call (1.3k to 3k chip tokens) now takes about 0.5 to 1 s. - Text tokens (about 68 ms each at batch 1) are most of the time. - mul_div problems: median 585 s per problem on the HF path, 242 s on the executor. ### Batching (`rvbatch.py`, `evalarith --batch N`) - Each conversation keeps its own executor (batch 1, its own CUDA graph) for its chip bursts. - The text tokens of all live rows go through Qwen's layers in one batched step, on q35's per-row-position cache (`batch._Rows`). Finished rows are refilled from the queue. - **Invariance** (`rv/batchcheck.py --alone`, A10G, B = 8): - Each of 6 records (two of them mul_div) decoded alone in the 8-row decoder gives the same tokens as all 6 decoded together, so the results do not depend on the batch's other rows. - Not bit-identical to batch-1 `generate`: Qwen's GEMMs round differently at M = 1 and M = B, and the static KV cache with a mask differs from the dynamic one. Five of 6 records still matched the L4 batch-1 records exactly; the sixth differs from token 8,390. - The circuit is exact either way: 0 lock errors, 0 head mismatches. - **Throughput:** - On the same 6 mixed problems: 128 problems/h at batch 1 vs 199/h at batch 8. Six problems are limited by the longest. - Per text step: 62 ms for 1 row, about 90 ms for 8. - Full sets at batch 8 are in the calc v2 section below. - The rows' chip bursts run one after another, about a third of a batched run's wall time; a batched executor (Executor2 takes B rows) is the next speedup. - **Base Qwen** (pytool, base_nudge): `--batch` uses q35's `BatchDecoder` (compiled steps). It is not token-identical to the batch-1 HF `generate` path: 1 of 6 pytool texts matched, with the same accuracy, 4/6 both ways. ### Checks - **Text fidelity** (`rv/textcheck.py`, 16 prompts): - Mean KL of the merge vs base: 6.1e-4. The noise floor between base's own sdpa and eager kernels: 5.1e-4. - Greedy text identical on 10/16 prompts; the tokenizer is identical (4/4). - **IR replay** (`rv/replay.py`): every circuit-owned position of an eval record re-run in the IR evaluator. All clean (0 wrong predictions, 0 owner mismatches): - arith: 40/40 records (89,453 owned positions); - DM: 6 records from each of 4 sets; - mul_div: 5 records, 4 of them from the executor path (up to 24 calls, 78k owned positions); - calc v2: 6 batched records. - **Lockstep:** 0 lock errors and 0 head mismatches in every rv1 run (head: the stock lm_head's argmax equals the circuit's token at every owned text position). ### Gate results (n = 40, Qwen budget 4096 tokens; exact rational match) calc v1 (fractions only as the final value), L4, batch 1. rv1 = the merge with the machine inside; pytool = base Qwen3.5-4B with the same tool, answered by the host (`calc.expected`); base_nudge = base Qwen with no tool. | Set | rv1 | pytool | base_nudge | |---|---|---|---| | arith (evals' seeded set) | 39/40 | 39/40 | 30/40 | | dmx_mul_big | 39/40 | 40/40 | 22/40 | | dmx_add_or_sub_big | 39/40 | 39/40 | 35/40 | | dmx_div_big | 38/40 | 39/40 | 29/40 | | dmx_add_sub_multiple_longer | 40/40 | 40/40 | 39/40 | | dmx_mul_div_multiple_longer | 20/40 | 19/40 | 36/40 | - The DM base_nudge runs used q35's `BatchDecoder` (A10G, batch 16). The arith base_nudge run used batch-1 HF `generate`. - **rv1's misses are Qwen's, not the machine's.** - Every result the machine returned was exact (lockstep and replay clean). - Misses: one re-subtraction after a correct result (arith); a dropped sign (add_or_sub, which pytool misses too); one 44-call loop (mul_big); two in div_big. In both div_big misses the machine had returned the right fraction; Qwen then answered a different number once and left no final answer the other time. - **mul_div: the tool was the problem.** - 610 of the 1,040 calc calls returned `error`, because v1 allowed a fraction only as the final value (`1/2*1/2`, `(-27/4)/(-27)`). Qwen then retried in loops: 15 of the 20 misses made 20+ calls. - pytool used the same semantics and loops the same way. - calc v2 fixes this. Re-run on v2, Qwen's same 1,083 calls (mul_div and div_big) give 1,020 values, 0 of them wrong; 41 errors (decimals mixed with `/`); 22 too complex. ### calc v2 (rationals) re-run (A10G, rv1 at batch 8, pytool at batch 16) - pytool's host here is the firmware itself on a reference bridge Session (`--host bridge`). Its responses are byte for byte what rv1's machine answers: the same capture window, error messages and echo tokens. | Set | rv1 | pytool | |---|---|---| | dmx_mul_div_multiple_longer | **40/40** (204 calls) | 39/40 (209 calls) | | dmx_div_big | 39/40 | 38/40 | - rv1's one div_big miss is a 44-call loop on a result the machine returned correctly. - IR replay of 6 v2 batched records: clean. - Throughput (A10G, batch 8): 109 problems/h on mul_div and 270/h on div_big. The L4 at batch 1 managed 14.5/h on mul_div (v1, executor) and 80/h on div_big (v1, HF chip path). - Base Qwen's batched runs (`BatchDecoder`, fixed batch 16) don't depend on the batch's other rows either: 12/12 prompts decoded alone matched the full batch. ### Not done in P3 - The 80-token capture window was not raised. Its thermometer weights 8·2^m overflow bf16 past 80 after the emitter's 2^36 fold, so a wider window needs a two-level counter. - Text decoding is still the HF modules at batch 1 or B, about 68 ms per token on an L4. Two fallbacks are installed instead of the optimized kernels: torch's linear attention, as `flash-linear-attention` is missing, and the `causal_conv1d` reference. - 0.8B was skipped (user decision). It has 1 value head per key head, and the core needs 2. - MicroPython has run on the standalone core and executor (P2), not yet inside the merged model; it needs a bridge build (P4). ## P2: RV32IM + Zba, a software stack (MicroPython, calc) ### What changed (ISA-visible parts first, commit `77fa2ae`) - **Memory map v2:** - RAM: 32 KB at 0. It holds .data, TLS, .bss, the heap, and the stack below 0x8000. - DROM: 128 KB of read-only data at 0x20000 (.rodata; loads only). - ROM: 256 KB of code at 0x40000. - The linker scripts are `env/rom.ld` and `env/qemu.ld`. - **ROM word:** one pre-decoded 4-byte word per instruction (`ref.rom_word`): - Layout: uop | S0, RD slot | S1-3, RS1 slot | S4-6, RS2 slot | S7-9. - Every format fits, with static facts folded in: branch targets are word offsets with a bad-target bit, JAL targets are absolute, AUIPC's upper bits are precomputed, and the CSR stubs are uops. - It decodes in one MLP stage. The two value heads of a ROM key head now hold different words, so 256 KB fits in the same 2 sublayers that held 128 KB. - Stores take their data register through a third regfile read port (the RD slot), so I-type and S-type share one immediate layout. - **DIV / DIVU / REM / REMU** in hardware. - The record is `QS(k0)`, then `QD(k << 4 | q_k)` for k = k0..7 (the hex digits of |A| / |B|, most significant first), then the result's value record. - k0 = 7 - dA + dB (from the operands' nibble counts) skips digits that must be 0. - Random operands average 4.1 tokens per DIV. - DIV by 0 and the overflow case follow RISC-V. - C builds use `-march=rv32im_zba` with picolibc's rv32im libc. - **Zba:** sh1add / sh2add / sh3add. - MicroPython measured -4% with Zba+Zbb, and Zba was almost all of it. - Zbb is not added. - **The digit file:** 8 registers of 128 decimal digits, outside the address space and part of the snapshot. The custom-0 / custom-1 ops (`ref.py` has the exact semantics): - `BMAC`: `D[i+off] = (ovw ? 0 : D[i+off]) + S[i]*m + c`. This one op covers add, mul-row, copy, scale and zero. - `BMSB`: `D[i+off] -= S[i]*m + b`. This covers sub and the div-digit step. - `BLD`: loads decimal digits from a RAM byte buffer. - `BSCAN`: the length of the ASCII digit run at an address. - `BGET`: reads one digit into a register. - `BPRD`: prints one digit as a BYTE token. Printing uses a sled of BPRD instructions, one token per digit. - All operands come in one register: `d | s<<3 | ovw<<6 | n<<7 | off<<13 | m<<20`. The common words fit a one-token value. - A row op is a `BD(digit | carry << 4)` token per digit, then rd's value (the final carry). - **Tokens:** - New families: QS (16), QD (128) and BD (256), plus the control SC. - The vocabulary is now 230,068 rows plus PAD. ### The circuit (commit `3c33d0f`) - **Size:** - **11 layers**, the same as P1. - 2,679 dims and 99 heads (at most 16 per sublayer). - 42,012 MLP units, at most 8,149 per sublayer out of 9,216. - **1,040,595 nonzero weights**; the emitted standalone hidden size is 2816. - With unlimited capacity it is still 11 layers. The depth is the dependency chain (DIV's compare signs at L10, the exit at L11), so list scheduling (`ir.Graph.reschedule`) cannot remove a layer here. | Layer | Attention (key heads) | MLP (units) | |---|---|---| | L1 | pc, count, j, v (4) | thermometers (306) | | L2 | — | PC word, link, digits-so-far nd (1,376) | | L3-L4 | ROM 16 + 16 (256 KB) | decode (4,138) | | L5 | registers x3, quotient heads x4 (7) | adder s1, MUL / REM products, DIV signs, digit-file rows (7,876) | | L6 | digit file: 2 read ports x 4 (8) | adder s2, shifter, logic, \|A\|>>4s, Q x \|B\| and m x \|B\| products (6,621) | | L7-L8 | RAM 16 + 16 (32 KB) | multiplier s3-s4, DIV product splits and compare columns, loads, Zba, branch target (3,751 / 7,923) | | L9 | DROM 16 (128 KB) | multiplier s5, DROM bytes to bits, DIV lookahead, digit / carry (8,149) | | L10 | — | the 15 DIV compare signs, rd value select (1,074) | | L11 | — | the output code (798) | - **DIV**, at the position computing digit k (s = 7 - k): - q_k = #{m in 1..15 : D >= m|B|}, where D = (|A| >> 4s) - 16 Q |B| and Q is the digits so far. - Each of the 15 tests is the sign (bit 39) of D - m|B| mod 2^40. It is built from un-normalized nibble-product columns in 3 MLP stages: column split, lookahead, sign. - The column offsets are solved so that every column stays in [0, 255]. - |B| = (B xor sb) + sb, with the +1 folded into nibble 0 (range 0..16). - |A| >> 4s is a mux with the ~A + 1 correction. - The quotient heads (L5: 4 key heads x 2 value heads, one per digit, one row each) store each digit at its QD token in one copy per alignment. One-hot reads gated by nd return the digits so far, right-aligned. - A first version read 8 rows of one head at once. rv-tiny's fla chunk kernel then left fp32 residues (2e-8) on c-crc32p: the multi-hot query's 1/sqrt(8) is not exact, and the rows are rewritten often. The IR's read emulation reads rows directly, so it cannot see this. Every read is one-hot or 2-hot (the ROM's) again. - REM is A -+ |Q| B mod 2^32 through the multiplier: Q replaces A as the product input, and the column forms change. - **DROM:** byte tables read at L9, like the code ROM. Bits come out of the L9 MLP; the lane and sign select happen in the value select. - **Digit file:** port 1 reads row j (the record index); port 2 reads row j + off or k. Writes go to both ports' copies at port 2's row from t-1. ### Gate results - **Compliance:** riscv-tests **rv32ui (38), rv32um (all 8, including div / divu / rem / remu) and rv32uzba (3) are green.** - Teacher-forced in the IR, with the HF-kernel read emulation on traces under 1,500 tokens. - Also free-running on the GPU. - DIV fuzz (edge values, all operand sizes, signs, rd = x0) is exact in the IR. The reference's DIV decomposition is checked against the ISA on 80k random cases. - The local suite (test_rv_core, test_rv_ref, test_rv_fuzz) passes: 228 tests, 14 skipped. - **Programs:** all 61 ELFs give the same exit code and stdout under the reference model as under qemu-riscv32. Free-running on the L4 (the executor generates every token, which must equal the reference trace): | Program | Instructions | Tokens | tok/instr | P1 instructions | |---|---|---|---|---| | MicroPython s5 (18-line snippet) | 492,526 | 610,544 | 1.24 | — | | MicroPython s1 (`print(2**100)`) | 39,888 | 50,150 | 1.26 | — | | fib(25) | 2,114,629 | 2,174,558 | 1.03 | 2,117,133 | | qsort 1000 | 199,157 | 240,176 | 1.21 | 256,975 | | sieve below 10^4 | 123,535 | 176,225 | 1.43 | 126,897 | | mini-gmp 20x20 | 14,767 | 22,671 | 1.54 | 15,967 | | crc32 printf | 4,109 | 6,283 | 1.53 | 9,998 | | crc32, sort, strops, sieve(2000) + 7 riscv-tests (div, rem, Zba) | 44,722 | | | | - The two free-running batches total **3.03M instructions**. - Fuzz (now with DIV, Zba and DROM loads): 64 programs, 152,848 instructions. - A bigger fuzz run: 128 programs at 300 loop iterations, **2,986,619 instructions, exact**. - **Altogether P2 free-ran 15.3M instructions exactly**: the programs above, the fuzz runs, the 10k calc cases and the v2 runs. - **MicroPython** (P2.4a, `env/build_mpy.sh`, `env/mpy/`) is rv_tool's best build under 256 KB, for this machine: - The 3 source patches, 5 hot files at -O3, fastlib, computed goto, the map cache, no GC clear, parse chunk 512. - rv32im_zba, with the script compiled into the DROM and output through putchar ecalls. - Sizes: text 205 KB (ROM), rodata 26 KB (DROM), bss 2 KB, and a GC heap of about 23 KB. - s1 and s5 are free-running exact. rv_tool measured 38,964 / 490,092 instructions with Zba+Zbb; this build takes 39,888 / 492,526. - **calc** (P2.4b, `env/calc/`, `chipformer/rv/calc.py`): C firmware on the digit file. - It handles + - * / // % ** ^ gcd() and parentheses with Python's semantics, on values up to 62 digits. - It has a fast path for "A op B" and a shunting-yard loop otherwise. - Long division estimates each quotient digit from the window's top 3 digits and the divisor's top 2 (at most 2 too big), then runs BMSB and an add-back if needed. - **10,000 random cases equal Python on the reference machine.** - **All 10,000 are also free-running exact on the circuit** (the executor on the L4): 8,951,798 instructions and 12,298,128 tokens in 19 min. - Six expressions are teacher-forced in the IR with the kernel check (test_rv_core.test_calc). - Tokens per case (from the text in RAM to the end of the print), against CHIP-8 v15 (the median of 20; CHIP-8 counts from after its CALL record, so its CALL record, about 2 tokens per digit plus 10, is added): | Call | RV32 calc tokens (instructions) | of which digit-file / print tokens | CHIP-8 v15 after CALL | CHIP-8 incl. CALL | |---|---|---|---|---| | add 4+3 | 254 (202) | 25 | 30 | ~54 | | add 12+12 | 303 (210) | 76 | 55 | ~113 | | sub 8-5 | 289 (218) | 44 | 42 | ~78 | | mul 2x2 | 340 (283) | 20 | 44 | ~62 | | mul 4x3 | 377 (296) | 38 | 68 | ~92 | | mul 8x8 | 520 (352) | 113 | 206 | ~248 | | mul 12x12 | 684 (402) | 217 | 394 | ~452 | | div 4/4 | 435 (352) | 23 | 526 | ~552 | | div 6/2 | 660 (510) | 52 | 570 | ~596 | | div 12/4 | 882 (618) | 114 | 751 | ~793 | - The digit work itself (BD / SC / BYTE / QS / QD tokens) costs about 1 token per digit per row, about the same as the CHIP-8 macros. - What costs more is the C around it: about 200-250 tokens per call for the dispatch, register bookkeeping and the call / return of the bignum routines, against CHIP-8's 22-byte BIOS. - On random 1-24-digit operands the means are: add 358, sub 432, mul 680, div 1,117, mod 1,056, gcd 3,394, pow 1,731, and mixed 2-4-operand expressions 991. - Closing the gap would mean writing the dispatcher in assembly, or giving more of the control to the digit-file ops. - **Executor v2** (P2.5; NVIDIA L4, crc32 printf at 1.53 tokens per instruction): | Path | Batch | Per step | tok/s | instr/s | |---|---|---|---|---| | P1 executor (dense weight blocks), P2 circuit | 1 | 1.066 ms | 938 | 614 | | sparse weights (gather, product, scatter-add), torch.compile + CUDA graph | 1 | 0.261 ms | 3,836 | 2,509 | | sparse | 8 | 0.427 ms | 18,738 | 12,254 | | sparse | 32 | 1.132 ms | 28,261 | 18,483 | | **executor v2: Triton, one fused kernel per sublayer, CUDA graph** | **1** | **0.177 ms** | **5,657** | **3,699** | | executor v2 | 8 | 0.441 ms | 18,150 | 11,870 | | executor v2 | 32 | 1.502 ms | 21,309 | 13,936 | - **Batch 1 is now 3.7k instructions/s, 1.8x P1's 2.0k**, on a circuit twice the size (and 6x the P1 executor on the P2 circuit). - At batch 8 the two paths are level; at batch 32 the sparse torch path is ahead (27k tok/s). - v2's value-head programs (one per vhead and row) are latency-bound and scale badly with batch. The next levers are several rows per program, and one persistent kernel for the whole step. - v2 is checked free-running: the fuzz set (64 programs, 152,848 instructions) and MicroPython s1, gmpmul, crc32p, rv32um-div and rv32um-rem (58,880 instructions), all exact. - The P2 circuit's dense weight blocks are 165 MB per step, against 0.78M nonzeros, so batch 1 is bandwidth-bound. Sparse rows cut that about 25x. - `tritonexec.Executor2` fuses each sublayer into one Triton kernel under one CUDA graph: - MLP: a block of units per program. - Heads: one program per value head: projections over the lagged residual, decay, the one-row write, the k-hot read with the ballast norm, and the output columns. - Plus an apply kernel. - It equals the IR on every dim. - **rv-tiny on the GPU:** a stock `Qwen3_5ForCausalLM`, 2,112M parameters, hidden 2816, 12 layers, saved on q35-eval in `~/wrv2/dist/rv-tiny-p2`. - bf16 with the fla kernels, for rv32um-div, rv32um-rem, rv32uzba-sh2add and c-crc32p: the residual before the exit equals the IR on every dim, and free-running `generate` reproduces the reference trace. crc32p's 6,282 generated tokens print `crc32=414fa339`. - tinycheck now warms up fla's autotuner before each check. On a new shape the autotuner reruns kernels that update the recurrent state in place, which corrupted the first result. - Longer traces (gmpmul, 22k tokens) run out of memory in tinycheck's one-shot prefill: the logits alone are 22k x 230k. This is a limit of the check, not of the model. - **Re-checked after the one-hot quotient heads** (free-running on the final circuit): - MicroPython s5 and s1, qsort, crc32p, gmpmul and rv32um div / divu / rem / remu: 750,680 instructions with executor v2. - 2,000 calc cases: 1,773,438 instructions. - The fuzz set: 64 programs, 152,848 instructions. - All exact. ### Not in P2 - **Zbb:** it measured under 0.5% for MicroPython. - **RAM:** it stays at 32 KB. That is in the asked 32-64 KB range, and MicroPython's rodata now sits in the DROM. - **The text bridge to Qwen:** that is P3. The calc input sits in RAM (`calc_in`, 0x100) for now; P3's capture will write it. - **calc's C overhead** (above). - **A persistent single-kernel step:** v2 uses one kernel per sublayer instead. ## P1: the RV32IM core (minus DIV), standalone ### The core - **ISA:** all of RV32I, plus MUL / MULH / MULHSU / MULHU through a 5-stage multiplier. DIV and REM are an illegal-instruction fault; C code uses gcc `-march=rv32i_zmmul`, which sends DIV/REM to libgcc. FENCE commits as NT. - **System:** - `ecall` a7 = 1 is putchar: the record is one `BYTE(a0 & 255)` token. - `ecall` a7 = 93 is exit (`HALT`). - The CSR stubs are pure reads, folded into the ROM as constants: cycle / time / instret (+h) read 0, mhartid 0, misa 0x40001100. Any CSR write is illegal. - **Faults halt and print.** The record is `FLT(cause)`, the PC's 8 hex digits (`HEX`), then `HALT`. The causes are illegal, ebreak, misaligned load / store, load / store out of range, bad jump (misaligned or outside ROM; static targets are flagged in the ROM image) and unknown ecall. - **Sizes:** - ROM 128 KB in 2 sublayers (32 key heads). - RAM 32 KB in 2 sublayers (32 key heads × 2 value heads × 128 rows, words). - The address map: RAM at 0 (data, rodata, TLS, bss and heap from 0x100, the stack below 0x8000), ROM at 0x10000. - **Tokens:** - V / F0 / F1 have 15-bit payloads; J / R have 16 bits; BYTE 8; FLT 3; HEX 4; there are 7 controls. - The vocabulary is **229,667 rows**. - The uop byte keeps 64..127 free for the custom-0/1 bignum ops of P2. - **Circuit:** - **11 layers** (the exit is folded into the output sublayer), 1,253 IR dims, 69 heads, 19,576 MLP units (at most 6,198 per sublayer), **501,396 nonzero weights**. - A j counter head (2^-j, tokens of the current record) replaces P0's previous-token head. | Layer | Attention | MLP | |---|---|---| | L1 | pc, count, j | thermometers | | L2 | — | PC word, link | | L3-L4 | ROM | field decode, immediate, link | | L5 | registers | adder s1, logic, shift s1, multiplier s1 (products), store data | | L6 | — | adder s2, shift s2, multiplier s2 (product nibbles) | | L7-L8 | RAM | multiplier s3-s4, loads, merge, compares, faults | | L9 | — | multiplier s5 | | L10 | — | rd value and size | | L11 | — | output code + exit | ### Gate results - **Compliance in the IR:** riscv-tests **rv32ui (38 tests) and rv32um mul / mulh / mulhsu / mulhu, all green**, teacher-forced, with HF-kernel read emulation on traces under 1,500 tokens. fence_i and ma_data are excluded, and so is rv32um div / rem (no DIV). - **Other IR tests:** - 10 fault programs, one per cause plus variants. - The CSR stubs and putchar. - ALU / branch / MUL fuzz. - The n = 63 refresh. - Snapshots at n > 0. - C programs: crc32 printing through picolibc printf (13.4k tokens), strops, and the **mini-gmp 20×20-digit multiply (16k instructions)**. The last two take 12-44k tokens, so they run under `RV_SLOW=1`. - **Reference vs qemu-riscv32:** - 54 ELFs (gcc 13.2; C with picolibc for rv32i, libgcc): exit codes and stdout equal qemu's. - The traced machine ends in the ISA emulator's exact register state. - **C programs verified free-running** (the executor generates every token itself; each must equal the reference trace up to HALT): | Program | Instructions | Output | |---|---|---| | fib(25), recursive | 2,117,133 | `fib(25)=75025` | | qsort 1000 ints (picolibc) | 256,975 | sorted, hash 72e4eb11 | | sieve below 10^4 | 126,897 | `primes below 10000: 1229` | | mini-gmp 20×20 multiply | 15,967 | `mul=1219326311370217952249809480012498094790` | | crc32, sort, strops, sieve(2000) | 51,459 | | | **total** | **2,568,431** | all equal | sudoku-easy (8.4M instructions) passes the qemu / reference check but was not free-run. - **Fuzz** (`chipformer/rv/fuzz.py`): random fault-free RV32IM programs covering every ALU, shift and MUL op, all load / store widths through masked addresses, branches, calls, putchar, LUI / AUIPC. **64 programs, 1,481,677 instructions free-running, all equal.** - **In total, 4.05M instructions** are verified free-running. - **`rv-tiny`** (`python -m chipformer.rv.tinycheck --save dist/rv-tiny`; saved on q35-eval in `~/wrv/dist/rv-tiny`): - **The model:** a stock `Qwen3_5ForCausalLM`, 960M parameters, hidden 1280, 12 layers (11 chip + 1 zero full-attention), Qwen3.5-4B's DeltaNet head shapes, vocabulary 229,667 + PAD, with tied embeddings. The code dims of each token row score bit agreement. - **`firmware.py`:** ELF → `ref.Machine` → `core.snapshot` → the HF cache, after a zero PAD prefix, then plain greedy `generate`. - **Checks:** the bf16 residual before the exit layer equals the IR on every circuit dim at every position, and **free-running `generate` reproduces the reference trace exactly**. - On GPU with the fla kernels: 7 programs, including crc32, whose 13.4k generated tokens print `crc32=414fa339`. - On CPU with the torch kernels: 3 programs. - **Speed** (NVIDIA L4, crc32 trace at 1.34 tokens per instruction): | Path | Per step | tok/s | instr/s | |---|---|---|---| | executor, batch 1 (torch.compile + CUDA graph) | 0.37 ms | 2,706 | 2,017 | | executor, batch 8 | 0.60 ms | 13,367 | 9,963 | | executor, batch 32 | 0.85 ms | 37,743 | 28,131 | | rv-tiny HF modules, StaticCache + torch.compile (reduce-overhead) | 8.9 ms | 112 | 83 | | the same after `fast.compact` (nonzero blocks only) | 1.64 ms | 609 | 454 | - **The executor** (`fastexec.py`) now has batch rows and free-running generation (decode plus embedding on the GPU, one graph per step). - **The stock HF path is not within 2× of the executor.** Compacted and compiled it is 4.4× slower at batch 1, and 24× without compaction. The dense layers read 0.96B parameters per token, including the tied 229k-row output head. After compaction it is launch-bound: each layer runs conv, l2norm, the fla GDN kernel, gated RMSNorm and the MLP. - Both rows of the HF path generate the exact reference trace. - **Tokens per instruction:** 1.0-1.55 on the C programs (1.40 averaged over crc32, sieve, sort and strops); 1.19 on the P0.6 small-C mix. ### Not in P1 - DIV/REM in hardware (the P2 micro-code). - The text I/O bridge to Qwen (P3). The BYTE family stands in for Qwen's byte tokens. - causal-conv1d (it doesn't build on q35-eval). # P0: feasibility (branch `rv-p0`) Everything below is measured on `rv-p0` unless marked *estimate*. ## Verdict | Criterion (plan P0.8) | Result | | |---|---|---| | IR exact | 38 riscv-tests rv32ui + 4 gcc -O2 C programs (up to 31.7k instructions / 44k tokens) + 480 fuzzed ALU ops + branch fuzz, teacher-forced: 0 wrong tokens. Every head read also replayed as the HF torch kernels compute it (2^-3.5 q scale, bf16 cast of o, gated RMSNorm): exact | GO | | Real HF modules | stock `Qwen3_5ForCausalLM` (bf16, torch reference kernels, CPU), snapshot injected into the cache: residual == IR on every IR dim at every position, chunked prefill and token-by-token decode | GO | | Depth ≤ 12 (P1), ≤ 32 (P2) | the P0 skeleton is **8 layers**; P1 extrapolates to ~11-13, P2 to ~16-20 (below) | GO | | Width / text-visible dims ≤ 3072 | 884 IR dims, all chip-only (overlayable); text-visible = output code (19) + mu/tau/ballast until the P3 bridge adds capture state | GO | | Byte columns exact on fla kernels | **bf16: bit-exact** on an NVIDIA L4 with fla 0.5.2: `chunk_gated_delta_rule` (prefill) and `fused_recurrent_gated_delta_rule` (decode) on 9 programs, every IR dim at every position, including crc32's 4,075 positions (`gpucheck --decode`). **fp32: 0 wrong tokens** on the same 18 runs, but the residual is not bit-exact; see "fp32" below | GO (bf16) | | Compact executor ≥ 3k instr/s | **3.6k instr/s on an NVIDIA L4** (5,562 tok/s, 0.18 ms per token at crc32's 1.55 tokens per instruction; about 4.7k at the small-C mix's 1.19): the circuit's own executor (`fastexec.py`), torch.compile'd and captured as one CUDA graph, its replays checked exact against the IR | GO | ## Design as built ### Trace format (`spec.py`) One record per instruction; state changes only at a record's last token, and the PC is implied: PC = ROM_BASE + 4·(anchor + n), with the anchor the payload of the latest J/R token and n the commits since (an R refresh is emitted before the instruction when n reaches 63). | Record | Tokens | |---|---| | rd-writing (ALU, LUI, AUIPC, loads) | `F0` · `V F1` · `V V F2` by the value's sign-extended size (15-bit chunks) | | rd = x0, not-taken branch, FENCE | `NT` | | taken branch, JAL, JALR | `J(word)`; the link rd = PC+4 is computed in-circuit | | SW | `ST0`/`ST1` (the RAM value head = address bit 6); the data is rs2 from t-1, in-circuit | | SB, SH | `V V V SS0/SS1`: the merged word (old bytes merged at the top of the circuit) | | ECALL, EBREAK, illegal | `HALT` | Vocabulary: V, F0, F1, J, R × 32,768 + F2 × 4 + 7 controls = **163,851 rows**. ### The commit rule Every position computes the instruction its *next* token belongs to: at a record's last token the circuit already works on the next instruction; inside a record it recomputes the current one. A write takes its **key from t-1** (a conv tap), its **write gate beta from the token at t** (beta = sigmoid(256 g − 128) with g ∈ {≤0, 1}, exactly 0 or 1), and its **value from token payloads at lags 0..3 or from dims computed at t-1 below the writing layer**. The payload-to-bit wiring needs no constants (which would break at the sequence start): for "V bit at t-1 when t is F1" the channel is P = −Vb + isF1 + isV with taps (1, −1), which equals the bit exactly when t is F1 and is ≤ 0 otherwise. Information only flows down through tokens, so nothing writes a value computed above its own layer at the same position; that is why the PC is implied rather than written. ### Memory | Table | Layout | Read | Write | |---|---|---|---| | ROM (code) | 16 key heads × 2 value heads; row pairs (p, p+64); 8 bytes per instruction **pre-decoded** at load: (uop, rdw, rs1, rs2) in value head 0, imm in value head 1; byte-valued columns | 2-hot query {p, p+64} (effective q scale 2^-4, exact for bytes); the word is selected by z per column block, so no select MLP | snapshot only | | registers | 2 key heads (rs1, rs2 ports), 32 rows, 109 columns per row (payload sources per bit + the link) | one-hot query; a bit = sum of its columns | key rdw(t-1), beta = F0+F1+F2+J | | RAM | 16 key heads × 2 value heads × 128 rows × 1 word = 16 KB per sublayer; key head = EA[5:2], value head = EA[6], row = EA[13:7] | one-hot query, z picks the value head | key = address(t-1) AND store(t-1); beta = ST_x/SS_x | | pc, count, prev | 1 head each | | J/R payload; 2^-n decay counter; token kinds at lags 1-3 | The ROM is a compiled image, so static facts are folded in (`ref.rom_fields`): rdw = rd only if the instruction writes rd, AUIPC/LUI are constants, branch and JAL targets are absolute words. The uop byte has room for the custom-0/custom-1 bignum ops planned for P2. Address map: **RAM at 0** (.data from 0x100, the stack below 0x4000), **ROM at 0x10000**. RAM at the bottom makes data and stack pointers 1-token values: 1.42 → 1.19 tokens per instruction on the small-C mix. ### Datapath (8 layers) | Layer | Attention | MLP (units) | |---|---|---| | L1 | pc, count, prev (3 heads) | n thermometer, record position j (129) | | L2 | — | PC word w = anchor + n, link w+1, thresholds + carry into bit 6 (1,072) | | L3 | ROM (16) | field one-hots, immediate bits, gated link (4,660) | | L4 | registers (2) | adder stage 1 for 4 operand modes (generate / propagate / digit bits per nibble), logic ops, shifter stage 1, store data (5,216) | | L5 | — | adder stage 2 (carries and result bits in one stage), shifter stage 2 (1,008) | | L6 | RAM (16) | load lanes / sign extension, SB/SH merge, SLT/SLTU/zero (492) | | L7 | — | rd value select, its size class, branch decision (190) | | L8 | — | output code: family id + 15-bit payload (175) | The 32-bit add is 2 MLP sublayers: nibble sums are free linear forms, then stage 1 thresholds them per operand mode (with the mode as an AND literal, so no operand mux layer), then stage 2 applies the carry sources as AND literals on the digit bits. ## Measurements (`python -m chipformer.rv.measure --nonzeros`) - **Size:** 884 IR dims, 37 heads, 12,942 MLP units; **298,676 nonzero weights** in the 8 chip layers. At 4B width the dense chip layers hold 1.08B parameters, almost all zeros. The ROM, RAM and registers live in the recurrent state, not in the weights. - **Capacity per sublayer:** ROM 64 KB (pre-decoded, 8 bytes per instruction), RAM 16 KB (words). - **Tokens per instruction:** - Measured on gcc -O2 C programs (crc32, sieve, sort, strops): **1.40**. Byte stores are 4 tokens and sieve is store-heavy. - The small-C mix from P0.6 gives **1.19**, against 3-5 for CHIP-8. - **Payload width:** | Chunk bits | Value records (1/2/3 tokens) | Tokens per instruction, mix | Vocabulary rows | Embedding at 3072 wide | |---|---|---|---|---| | 12 | 20.4k / 4.2k / 2.6k | 1.44 (2-token jumps) | 20.7k | 0.13 GB | | **15** (built) | 23.6k / 2.2k / 1.4k | **1.19** | 164k | 1.01 GB | | 16 | 23.7k / 3.5k / — | 1.11 (SB/SH as V+SS) | 459k | 2.82 GB | Recommendation: keep 15-bit V/F and make **J/R 16-bit** (+65k rows), since 256 KB ROMs need 16-bit word indices (MicroPython with float is 229 KB). - **HF cache snapshot (R11):** injecting the initial states into `DynamicCache` recurrent states after a zero (text-token) prefix works for prefill and decode (`hf.py`). Static cache not tried. - **Speed** (`python -m chipformer.rv.timing --hidden 3072`, batch 1, crc32 trace at 1.55 tokens per instruction): | Path (NVIDIA L4) | ms/token | tok/s | instr/s | |---|---|---|---| | HF modules, dense, eager, DynamicCache (4B width: hidden 3072, 16/32 heads, 9216 units) | 11.0 | 91 | 59 | | the same after `fast.compact` (nonzero blocks only) | 12.5 | 80 | 52 | | circuit executor (`fastexec.py`), eager | 5.1 | 194 | 126 | | executor, one CUDA graph per step | 0.53 | 1,898 | 1,226 | | **executor, torch.compile + CUDA graph** | **0.18** | **5,562** | **3,594** | - **What the executor is.** It runs the IR's arithmetic in fp32 over the circuit's live structure: - each sublayer's projections as one matmul over the input dims they read; - one-hot key writes as row scatters, ≤2-hot reads as top-2 row gathers; - decay only on the counter. - **How it is checked.** It equals `ir.evaluate` on every dim at every position, eager on CPU and on GPU, and its CUDA-graph replays are checked against the IR on 300 positions. - **It replaces the HF modules for chip-only steps.** Same arithmetic, not the same kernels. The HF rows stay eager and launch-bound here; the speed fork's static-cache CUDA-graph decode path (`decode_rows`, about 1.3 ms for a 16-layer chip step on an L40S) was not applied to this model. - **L40S *estimate*.** By bandwidth alone (864 vs 300 GB/s) the executor row would be about 16k tok/s on an L40S. These steps are launch- and latency-bound, so the real gain should be smaller; it is an upper estimate. - **Next.** A persistent single-kernel step (Triton/CUDA) over the 0.3M nonzeros is the next step if more is needed. ## GPU numerics (P0.2, NVIDIA L4, fla 0.5.2, `gpucheck --decode`) Programs: signext, call, memcpy, rv32ui-lb / -sh / -jalr / -sra, c-crc32 (4,075 positions) and c-strops. Each runs as one chunked prefill (`chunk_gated_delta_rule`) and token by token (`fused_recurrent_gated_delta_rule`), with the snapshot injected into the cache. - **bf16** (the merged model's dtype): **bit-exact.** Every IR dim matches at every position in all 18 runs. That covers the byte-column 2-hot ROM reads, the sibling value heads' write gates, the lag-1 key commits through prefill-then-decode, and the counter's decays through the chunk kernel's exp(cumsum). - **fp32: 0 wrong tokens** in all 18 runs, but 70-400 dims per run are not bit-exact (1-ulp noise). - **Why.** Without the final bf16 cast, the kernel's q scale 1/sqrt(128) and the chunk kernel's exp(cumsum) decays leave about 2^-23 relative error, e.g. a counter read of 0.2500611 for 0.25. - **Why the tokens are still right.** The circuit's AND/threshold margins absorb it. - **Status.** fp32 decoding is correct, not bit-exact. Making it bit-exact would need power-of-2 q scales the kernels don't have; bf16 is the target dtype. ## Options measured or costed - **8-byte granule RAM rows** (the spike fork's alternative): 2× RAM per sublayer, but SW becomes 4 tokens (+0.27 tokens per instruction on the mix). RAM needs are tiny (P0.6: at most 16 KB touched), so word rows stay. - **ROM density:** pre-decoded 64 KB per sublayer. 256 KB = 4 sublayers, which adds about 3 layers of fetch depth. The alternative is raw 4-byte words at 128 KB per sublayer plus one decode MLP stage. Pick for P1 against the depth budget. - **SB/SH cost** is 4 tokens with 15-bit payloads; 2 with 16-bit (V + SS16). ## P1 / P2 extrapolation (*estimates*) - **P1** (all RV32I, MUL via 5 parallel MLP stages, putchar/exit, 128 KB ROM, 32-64 KB RAM): MUL runs beside the adder/RAM path (L4-L8), so it adds no depth. The ROM grows to 2 sublayers (+1), RAM to 2-4 sublayers (+1..3) and output select (+1), giving **11-13 layers**. - **P2** (256 KB ROM, micro-coded DIV, CARRY/instret, custom bignum ops): **16-20 layers**. ## Gaps - **causal-conv1d is untested.** It doesn't build on q35-eval: its CUDA toolkit is 12.8, while torch is built for cu13.0. The conv runs through the torch fallback, as in every run above. - **Not in the P0 circuit:** MUL*, DIV*/REM*, putchar, faults (misaligned, out of range), text I/O. - `fence_i` (self-modifying code) and `ma_data` (misaligned access) are skipped, as planned. ## Files - `chipformer/rv/`: - `spec.py`: tokens and records. - `ref.py`: the ISA emulator, `rom_fields`, the traced `Machine`. - `asm.py`: assembler. - `elf.py`: ELF loader. - `programs.py`: assembly test programs. - `core.py`: the circuit and snapshot. - `hf.py`: HF modules plus cache injection. - `measure.py`, `gpucheck.py` (GPU numerics), `timing.py`. - `fastexec.py`: the compact circuit executor (the P0.5 prototype). - `env/`: riscv_test.h, link scripts, crt0, C programs, `build_tests.sh` (WSL). - `tests/`: - `test_rv_ref.py`: ref == qemu-riscv32 on 44 ELFs; Machine == ISA. - `test_rv_core.py`: the circuit, teacher-forced. - `test_rv_hf.py`. - `data/rv/`: the built ELFs and qemu results. - **IR additions** (`q35/ir.py`): - from store-v1: sibling value heads, per-value-head beta, `into`. - new here: head `init` (snapshot) states, byte-valued and decay levels (snapshots at n > 0, tested mid-run), `kernel_check` (HF kernel emulation), beta g ≤ 0. - `q35/prims.py`: `thead`.