Source snapshot of github.com/Scottcjn/elya-nes, also on GitLab with full history. Code only: nothing here runs on Hugging Face infrastructure.

elya-nes

Part of Transformers on Retro Game Consoles on Hackaday.io — build logs, corrections, and the sibling NES / SNES / Genesis / N64 / Game Boy Color ports.

A cycle-exact measurement instrument for the NES/Famicom 2A03, and a ternary transformer forward pass built on top of it, verified bit-exact against a host reference over 19 generated tokens - now with trained weights, so it says something.

seed token 'b'      -> 'because he was so happy and sa'
seed token 'd'      -> 'day, they had a big boy na'
seed token 'j'      -> 'jack for a long friend. '
seed token 'l'      -> 'lily was very happy and saw a b'

446,464 ternary weights on the cartridge, 102,400 of them streamed per token, 4-bit activations, a 64-symbol vocabulary, trained on TinyStories with quantisation-aware training so the forward pass the trainer sees is the forward pass the 6502 executes. 0.634 seconds per token on a 1.79 MHz 2A03.

Every number in FINDINGS.md is measured. Nothing here is estimated.

The two wins compose: 8 experts AND an exact softmax normaliser

Two lines of work landed on this tree from separate branches - an exact softmax normaliser (below) and a mixture of experts (below that) - and the obvious question is whether their gains add or get in each other's way. All four cells of the 2 x 2 were retrained here, 60,000 steps, two seeds each, so that the comparison is one tree and one estimator:

val nats/char power-of-two normaliser exact normaliser
dense 1.4020 / 1.4096 = 1.4058 1.3754 / 1.3779 = 1.3766
8 experts 1.2202 / 1.2221 = 1.2211 1.1932 / 1.1898 = 1.1915
A, exact normalisation:  -0.0292 at dense capacity,  -0.0296 at 8 experts
B, 8 experts:            -0.1847 under pow2,         -0.1851 under exact
additive prediction   1.4058 -0.0292 -0.1847 = 1.1920
measured                                        **1.1915**
interaction                                       -0.0005   (seed spread 0.0076)

They add. The interaction is inside the seed spread under both estimators this repo uses, and it has the sign of very slightly more than additive. The cycles add too: 1,117,248 -> 1,129,375 (A) -> 1,134,432 (both), against an additive prediction of 1,135,265.

dense + pow2 merged cartridge
val nats/char, seed 1 / seed 2 1.4020 / 1.4096 1.1932 / 1.1898
ternary weights on the cartridge 102,400 446,464
ternary weights streamed per token 102,400 102,400
mean cycles/token 1,117,248 1,134,432 (+1.54%)
seconds/token 0.6243 0.6338
image 106,512 B, 12 banks 548,880 B, 66 banks
ROM vs host, 64-seed survey 1,216/1,216 1,216/1,216, both seeds: 2,432/2,432

One correction falls out of doing this on one tree. The softmax result below was published as -0.0375 nats/char against a dense baseline of 1.4141 that came from two older arms; against a dense control trained here the same effect is -0.0292, and -0.0296 at 8 experts. The effect is real, non-overlapping and reproduces at both capacities - it is 0.008 smaller than published, and the reason is the baseline, not the mechanism. Full arithmetic in FINDINGS.md.

The integer softmax was throwing away a quarter of its budget

The quantised softmax normalised with a power-of-two shift: kk is the smallest shift with S >> kk <= 8, so the realised sum landed anywhere in (4, 8] and a quarter of the time the softmax was running on half its budget. Replacing that with an exact normalisation - p = min(e*8/S, 7), the same three-bit nibble, the same budget - and re-laddering AV_SHIFT, which moves from 2 to 3 once the accumulator is bigger:

shipped exact exact + AV_SHIFT 3
val nats/char, seed 1 / seed 2 1.4133 / 1.4149 1.3957 / 1.3848 1.3754 / 1.3779
mean 1.4141 1.3902 1.3766 (-0.0375, 2.65%)

(The 1.4141 baseline is runs/final_av2_bpe64_tau0.75 and runs/t20_final_s2, older arms from a different implementation of the same forward pass. Against a dense + pow2 control trained on the merged tree the effect is -0.0292; see the 2 x 2 above.) | mean cycles/token | 1,116,979 | 1,121,121 | 1,129,291 (+1.10%) | | ROM vs host | 1,216/1,216 (64 seeds) | 1,216/1,216 | 1,216/1,216 (64 seeds) |

60,000 steps, two seeds each, ranges nowhere near overlapping. That is two thirds of what ternarising the weights costs, recovered from a normaliser and a shift. (Of the +1.10% cycles, +4,223 at the last position is the kernel; the rest is that this model trains to 557 more nonzero weights.)

Widening the probability nibble - which is what the previous experiment's diagnosis called for - is worth nothing. Budgets of 8, 16 and 32 measure 1.5279, 1.5335 and 1.5267 nats/char at matched steps: not monotone, and every gap smaller than the noisier arms' own seed spread. The leak was the shared exponent, not the bit width. See FINDINGS.md.

And fixing it did not make longer context help. The T = 85 minus T = 20 penalty is +0.0472 nats/char under the old normaliser and +0.0487 under the new one - the gap moved by 0.0015, inside the seed spread. The softmax was not what stood between the long-range head and paying for itself. The ceiling is still capacity.

Is 29 characters of context the reason it cannot form a sentence? No.

T is now a build parameter and the cartridge was rebuilt at the largest context its 32 KB of PRG-RAM can hold - 85 tokens, ~124 characters, the KV cache spread across all four banks. Retrained on the identical recipe:

T = 20 T = 85
val nats/char, seed 1 / seed 2 1.4133 / 1.4149 1.4347 / 1.4318 (worse)
ROM vs host 1,216/1,216 tokens exact (64 seeds) 252/252 tokens exact (3 seeds)
mean cycles/token 1,116,979 1,697,916 (+52%)
attention share, last position 7.3% 52.9%
seconds/token, last position 0.641 1.283

(T = 20 runs the attention kernels; T = 85 is past their addressing ceiling and runs the legacy attention path, which is why the cycle gap is wider than the context ratio. Both columns are measured on this tree.)

Two seeds each, and the two groups do not overlap: the worst T = 20 run beats the best T = 85 run by 0.0169 nats/char, six times the within-context seed spread. The attention did learn to reach - layer 2's mean attention distance went from 0.91 to 7.82 and 13.7% of its mass now lands beyond what T = 20 could see - and the loss got worse anyway. Held-out loss is flat from about position 10 (~15 characters) in every model that has room to show it, and at matched positions the short-context model wins everywhere. The ceiling is capacity, not context. Full argument in FINDINGS.md.

So capacity was bought instead - and it worked

A flash cartridge holds far more than an original NES cart, so the mixture build keeps attention, the embedding and the head shared and gives the feed-forward block of every layer N copies, routed by a 64-byte table on the current token id. One expert streams per token, so the weights the 6502 walks stay at 102,400 while the weights on the cartridge do not.

dense 8-expert mixture
ternary weights on the cartridge 102,400 446,464
ternary weights streamed per token 102,400 102,400
val nats/char, seed 1 / seed 2 1.4020 / 1.4096 1.2202 / 1.2221
mean cycles/token 1,117,063 1,123,138 (+0.54%)
seconds/token 0.6241 0.6275
image 106,512 B, 12 banks 548,880 B, 66 banks
ROM vs host, 64-seed survey 1,216/1,216 EXACT 1,216/1,216 EXACT

Two seeds each and the groups are nowhere near overlapping: the worst mixture seed beats the best dense seed by 0.180 nats/char, twenty times the 0.009 seed noise. Quadrupling the context cost 52% more cycles and made the model 0.019 nats/char worse; quadrupling the parameters cost 0.55% more cycles and made it 0.182 nats/char better.

The routing table's construction turned out not to matter - balanced, bigram-clustered and outright random assignments land within 0.0032 nats/char of each other - so the router is one lda routebank,x, measured at 11 cycles.

And the ceiling was built. rom/bankprobe.s maps a 1 MB image and confirms 127/127 switchable MMC5 banks answer, which is room for 16 experts:

N weights on cart image val nats/char cycles/token ROM vs host
1 102,400 106,512 B 1.4020 / 1.4096 1,117,063 1,216/1,216
8 446,464 548,880 B 1.2202 / 1.2221 1,123,138 1,216/1,216
16 839,680 1,007,632 B 1.1671 (1 seed) 1,125,463 1,216/1,216

A one-megabyte NES cartridge running an 8.2x model for 0.76% more time.

Quick start

./build.sh                                     # model binaries + every ROM
python3 tools/check_branches.py out/calib.nes out/calib.lbl
python3 tools/run_calib.py   out/calib.nes     # instrument calibration, 28/28
python3 tools/run_prim.py    out/prim.nes      # MMC5 primitives, 19/19
python3 tools/run_nn.py      out/nn.nes out/model/expected.json 200
python3 tools/run_profile.py out/nnprof.nes    # per-stage cycle profile
python3 host/blocksize.py                      # block-size saturation

Reproduce the dense benchmark in Mesen 2

Mesen 2.1.1's headless Lua runner provides an independent measurement of the same $0300 BEGIN/END writes used above. The public 96 KiB MMC5 image needs a container-only compatibility transform: Mesen leaves its reset vector unmapped because 12 PRG banks are not a power of two. mesen_pad_mmc5.py expands the image to 128 KiB, leaves banks 0--11 byte-for-byte unchanged, and mirrors the fixed code bank at bank 15. No measured code or data changes.

NES_AV_SHIFT=2 NES_SM_NORM=pow2 \
  train/build_trained.sh runs/fac/dense_pow2_s1.npz 1
python3 tools/mesen_pad_mmc5.py out/nn.nes out/nn-mesen.nes
MESEN_BIN=/path/to/Mesen python3 tools/mesen_nes_bench.py \
  out/nn-mesen.nes out/model/expected.json --source-rom out/nn.nes \
  --output results/nes_mesen_2.1.1_dense_pow2.json

The runner creates an empty portable settings.json only when one does not already exist (to bypass Mesen's first-run GUI), enables local Lua file output for that process, and does not enable network access. The committed result contains all raw callback timestamps: 19/19 host-reference tokens match, 21,227,718 total CPU cycles, mean 1,117,248 cycles/token, and 254,732,616 NES master clocks. Every master-clock delta is exactly 12 times its CPU-cycle delta, and the mean differs from the published MAME 0.277 value by 0 cycles (0.0%).

Train a model and put it on the cartridge

python3 train/prep_corpus.py                   # charset + 64-slot BPE, coverage
python3 train/test_equiv.py                    # trainer forward == ref.py, exactly
sh      train/sweep.sh                         # the arm table
python3 train/train_nes.py --steps 60000 --tau 0.75 --name mymodel
sh      train/verify_trained.sh runs/mymodel.npz "1 26 40"
python3 train/sidebyside.py out/FINAL_VERIFICATION.txt
python3 train/sample.py runs/mymodel.npz --survey

verify_trained.sh packs, proves max|dW| = 0 by decoding the ROM's own weight stream back into matrices, assembles, and only then runs MAME. That order is deliberate: assembling first would turn a silent exporter bug into a ROM that runs perfectly and says the wrong thing.

Headline results

instrument MAME 0.277 nes + Lua write tap on $0300, 1 cycle = 558.73 ns
CPU clock derived, not assumed: 1,789,772 Hz (MAME truncates 21477272/12)
datasheet calibration 28/28, 0 mismatches, bit-identical over 3 runs
primitives vs prior run 19/19 match
bank crossings per token 6 dense, 13 for the 8-expert mixture - and a switch costs 73 cycles measured in the loop, not the 6-cycle datasheet store. 949.8 cycles/token, 0.084%
ternary kernel 10.688 cycles/MAC asymptotic vs the 8-cycle primitive
ROM vs host reference, T = 20 19/19 tokens EXACT at every one of 64 seed tokens: 1,216/1,216 per cartridge
ROM vs host reference, T = 85 84/84 tokens EXACT, at three independent seed tokens: 252/252
trained model (T = 20) val 2.0546 nats/token = 1.4133 nats/char (uniform 4.1589)
trained model (T = 85) val 2.0856 nats/token = 1.4347 nats/char - longer context, worse
8-expert mixture, power-of-two normaliser val 1.7738 / 1.7766 = 1.2202 / 1.2221 nats/char, two seeds - 13% better than dense, non-overlapping
SHIPPING: 8 experts + exact normaliser val 1.7346 / 1.7296 = 1.1932 / 1.1898 nats/char, two seeds
shipping cost +1.54% cycles/token against dense + pow2, 4.36x the parameters on the cartridge
shipping ROM vs host 2,432/2,432 tokens EXACT - 64 seed tokens on each of the two seeds' cartridges, every expert routed in both
do the two wins add? yes: interaction -0.0005 nats/char against a seed spread of 0.0076, and -833 cycles against an additive prediction of 1,135,265
nonzero weights (dense, 2026-08-08 arm) 52,207 of 102,400 (density 0.5098)
nonzero weights (shipping mixture) 233,408 of 446,464 on the cartridge, 53,266 streamed per token
cycles per token (T = 20, shipping mixture) 1,101,416 (pos 0) .. 1,165,451 (pos 18), mean 1,134,432
cycles per token (T = 20, dense + pow2) mean 1,117,248
attention at full context (T = 20) 86,142 cycles, 7.3% of a token (was 302,624, 21.6%)
attention kernels 8.00 cycles/MAC measured, self-modified operands in PRG-RAM
wall clock at 1,789,772 Hz 0.6338 s/token at T = 20, shipping mixture (0.6243 dense + pow2)
cycles per token (T = 85, legacy attention) 1,103,387 (pos 0) .. 2,295,963 (pos 83), mean 1,697,916
attention share at full context (T = 85) 52.9%, on the legacy attention path - see below
independent emulator ares 147 gives the identical 19 tokens (random-init build)

Layout

rom/     common.inc  marker protocol + deterministic machine init
         calib.s     28-payload datasheet calibration ROM (NROM)
         prim.s      MMC5 primitives: bank switch, $6000, accumulators, ternary
         mmc1.s      MMC1 bank switch cost, on a real MMC1 cart
         mmc3.s      MMC3 bank switch cost, on a real MMC3 cart
         nn.s        the transformer: 32 gather chains + forward pass
         bankprobe.s how many of the MMC5's 128 PRG banks really answer
         *.cfg       one ld65 config per mapper
host/    ref.py      the exact-integer SPECIFICATION and the weight packer
         blocksize.py  block-size saturation measurement
train/   prep_corpus.py   the 64-symbol charset, the BPE, the story-disjoint split
         model_nes.py     differentiable twin of ref.py (STE on every hard op)
         test_equiv.py    proves that twin IS ref.py, at every layer
         train_nes.py     the QAT trainer
         sweep.sh         the arm table
         verify_pack.py   decodes the ROM's stream back out: max|dW| = 0
         build_trained.sh pack -> prove -> assemble
         verify_trained.sh the same, then MAME at several seed tokens
         sidebyside.py    ROM vs host as decoded text
         sample.py        generate with ref.py and detokenise
         table.py         the results table, normalised per character
         perpos.py        held-out loss POSITION BY POSITION
         attnspan.py      how far back the attention actually reaches
         route.py         the four routing tables, all 11 cycles on the 6502
         factorial.sh     the 2 x 2: {dense, 8 experts} x {pow2, exact}
         factorial_table.py  that 2 x 2, and whether the two effects add
         link_variants.sh every build variant, assembled and linked
         eval_npz.py      one held-out estimator for every arm
         replicate_experts.py  N IDENTICAL experts: the mixture's control arm
         expert_coverage.py    proves the survey routes to every expert
         moe_gate.sh      pack, prove, run, cover, survey, profile
         moe_table.py     the mixture comparison table, per character
tools/   nes_bench.py      the instrument (write tap, no polling, GC-safe)
         run_calib.py      datasheet calibration report
         run_prim.py       primitive report
         run_nn.py         ROM vs host, token by token, plus cycles/token
         run_profile.py    per-stage cycle profile
         run_bank_profile.py  bank-switch and router cost, bracketed in situ
         gen_bankstamp.py  stamps every PRG bank for the bank-budget probe
         check_branches.py branch placement verified from RAW ROM BYTES
DESIGN.md   ROM layout, weight stream format, cost model - written FIRST
FINDINGS.md the journal, appended after every discrete result

Build variants of nn.s

define ROM purpose
(none) nn.nes the clean build; this is what the timing numbers come from
-DPROFILE nnprof.nes X-preserving 12-cycle stage markers
-DBENCH nnbench.nes drives the real gather over synthetic list lengths
-DATTNPROF nnattn.nes nested markers splitting attention into QK / softmax / AV (NCTX <= 21)
-DATTNBENCH nnabench.nes isolated slope of the attention kernels, self-modified vs pointer (NCTX <= 21)
-DRAMEXEC ramexec.nes probes whether MMC5 PRG-RAM at $8000 is writable and executable (NCTX <= 21)
-DBANKPROF nnbank.nes brackets the weight-stream bank switch and the router in situ - this is where 73 and 11 cycles come from
-DMOE + out/model/nnmoe.cfg nn.nes mixture-of-experts build. Both the define and the linker config are decided by train/build_trained.sh from whether the packer emitted out/model/moe.inc, which it does whenever the npz carries _moe
-DDEBUG -DDBGPOS=n nndbg.nes dumps intermediate state at position n (NCTX <= 21 only)
-DSEEDTOK=n the seed token the ROM free-runs from (default 1)
-DNCTX=n context length; must match NES_T for host/ref.py. 21 is the ceiling for the attention kernels (64 / L, the key cache row); above it the ROM builds on the legacy attention path, up to 85 (32768 / (L*2*D))
-DNSTREAM=9 + rom/nn9.cfg 9 weight-stream banks for a model denser than 0.559

NES_T is passed to build.sh / train/build_trained.sh and reaches the packer, the trainer and the assembler from that one place. The trainer stamps T into the npz and ref.Model.from_npz refuses to load a mismatch, for the same reason it refuses a requantise-shift mismatch.

The requantise shifts (KSHIFT, W2SHIFT, AVSHIFT, SMSHIFT) are generated by host/ref.py into out/model/shifts.inc and included by rom/nn.s, so the kernel and the specification cannot hold different values.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support