在1.79MHz的NES和93MHz的N64上运行真实的float32/三元权重量化Transformer,附源码架构分析和独立复测尝试,输出可读英文文本。
一次独立的技术深度分析,剖析 Elyan Labs 在 NES、Sega Genesis、Nintendo 64 和 Game Boy Color 上运行真实 Transformer 语言模型的项目——含源码分析、架构拆解,以及一次独立复测尝试。
当我第一次听说有人在 Nintendo 64 上运行基于 Transformer 的语言模型时,我以为这是个玩笑——某种伪装成推理的预计算查找表。但我读完源码后发现,这不是玩笑。这是一个真实的 float32 Transformer 前向传播,运行在 1996 年的 93.75 MHz MIPS R4300i 上,以及一个三进制权重的 Transformer 运行在 1.79 MHz 的 6502 上。两者都能生成真实的、连贯的英文文本。两者均为开源。其背后团队还为任何能独立验证——或推翻——他们数据的人支付赏金。
本文是我对该项目中两个最有趣的移植的技术分析:NES(elya-nes)和 N64(legend-of-elya-n64)。我阅读了源码,追踪了数据路径,并尝试用与团队不同的模拟器对 NES 的周期计数进行独立复测。正文中引用的代码均来自公开仓库的实际文件。
Elyan Labs 为五个复古平台构建了 Transformer 语言模型:
每个移植都是一个真实的 Transformer——不是循环网络,不是马尔可夫链,不是查找表。NES 运行的是一个 3 层、64 维、2 头注意力、三进制权重的 Transformer。N64 运行的是一个 8 层、256 维、8 头注意力、三进制权重加 float32 激活的 Transformer。两者生成的文本都是可识别的英文,在 TinyStories 上训练,使用量化感知训练,使得训练时看到的前向传播与硬件上执行的前向传播完全一致。
项目挂在 Hackaday.io:Transformers on Retro Game Consoles,源码分布在五个 GitHub 仓库。
NES 移植(elya-nes)是五个平台中约束最严苛的。2A03 CPU 运行在 1.79 MHz,有三个通用寄存器(A、X、Y),通过 MMC5 映射器可寻址 32 KB 的 PRG-RAM。模型在这个预算内:
形状常量定义在 rom/nn.s:
NVOCAB = 64
NDMODEL = 64
NLAYER = 3
NHEAD = 2
NDHEAD = 32
NFF = 128
NCTX = 20
并在 host/ref.py 中镜像:
V = 64 # vocab
D = 64 # d_model
L = 3 # layers
H = 2 # heads
DH = 32 # d_head (H * DH == D)
F = 128 # d_ff
T = int(os.environ.get("NES_T", "20")) # context positions
主机参考实现(host/ref.py)是规范——精确的整数运算,无浮点数,这样 6502 实现可以逐位比较。它还生成 ROM 消耗的每一个二进制文件:符号分离的权重流、行头表、嵌入表和位置表,以及查找表。
NES 移植中最有趣的设计决策是三进制权重表示。权重存储为符号分离的三元组({+1 集合}、{-1 集合}、{0 隐式}),内积循环使用一种gather模式,使累加器保持在 A 寄存器中:
; 符号分离的 gather:每个非零权重 8 个周期
ldy idx,x ; 4 个周期 — 加载权重索引
adc act,y ; 4 个周期 — 加上偏置后的激活
rom/nn.s 中的注释解释了寄存器约定:"累加器只有在两个操作数都通过索引寄存器寻址时才能留在 A,这意味着权重流必须通过绝对寻址。"这就是为什么在固定库顶部有 32 条针对每 256 字节页的专用 gather 链——每个链对应 $8000 权重窗口的一页。
FINDINGS.md 记录了另一种方案——一种"多分支"变体,测试每个三进制位并跳过零值:
这是一个 3.4 倍的结构性差距。符号分离方案对零不付出任何代价(它们根本不在流中),而分支方案要花 7 个周期来测试和跳过。这是 NES 移植中最大的性能决策,且它是由 6502 架构强制的:只有 A 作为累加器,只有 X/Y 作为索引寄存器,要将累加保留在 A 中需要两个操作数都是索引寻址。
NES 的 softmax 完全基于整数实现。激活存储时偏置 +7(因此值 0–14 是非负的),softmax 将概率归一化到预算为 8 的空间——意味着最多 8 个 20 上下文位置可以携带权重,每个概率是 0–7 范围内的整数。
原始实现使用 2 的幂次归一化器:p = min(e >> kk, PMAX),其中 kk 是使和 fits 的最小移位。FINDINGS.md 揭示这浪费了四分之一的概率预算:
使用 2 的幂次移位进行量化的 softmax 归一化:kk 是使 S >> kk <= 8 的最小移位,因此实现的和落在 (4, 8] 范围内,四分之一的时间里 softmax 只用了其一半的预算。
修复方案——精确归一化 p = min(e * SM_TARGET // S, PMAX)——恢复了 0.0375 nats/char(2.65%),这是三进制化权重所付出代价的三分之二。代价是 +1.10% 的周期。这在 host/ref.py 中有文档记录,通过环境变量 NES_SM_NORM 切换两种模式:
# pow2 : p = min(e >> kk, PMAX) — 2026-08-09 之前发货
# exact : p = min(e * SM_TARGET // S, PMAX) — 实测最优,现为默认
NES 移植使用 MMC5 映射器进行 bank 切换,FINDINGS.md 包含了对每个 bank 切换原语的详细校准:
选择 MMC5 是因为每次切换比 MMC1 便宜 5.0 倍,比 MMC3 便宜 2.0 倍(热切换时相等)。由于权重流是 bank 切换的——每个 token 通过 $8000 窗口流式传输 102,400 个权重——bank 切换成本直接影响每个 token 的周期数。
校准还验证了 PRG-RAM 访问:lda $6000(绝对寻址)读取为 4 个周期,与系统 RAM 相同。PRG-RAM 读取没有卡带惩罚。页面交叉 hazard 存在(lda abs,y 对齐 4 周期 / 交叉 5 周期),但与系统 RAM 的 hazard 相同。
NES 移植上最新的工作测试了精确归一化器和混合专家(MoE)架构是否可以加性组合。FINDINGS.md 给出了一个 2×2 因子设计:
加性预测(1.4058 - 0.0292 - 0.1847 = 1.1920)与实测值(1.1915)相符,交互效应为 -0.0005——在种子离散 0.0076 之内。它们是可加的。周期也是加性的:1,117,248 → 1,129,375(精确归一化)→ 1,134,432(两者),加性预测为 1,135,265。
合并后的卡带在 66 个 bank(548,880 字节)中装载了 446,464 个三进制权重,而稠密模型在 12 个 bank 中有 102,400 个权重。ROM 与主机的验证在两个种子上均为 2,432/2,432 位相同。
N64 移植(legend-of-elya-n64)是完全不同的野兽。NES 使用三进制权重和整数运算,而 N64 使用 float32 激活配合三进制(SEQ2,2 位)权重,权重在运行时即时反量化。架构在 nano_gpt.h 中定义:
#define SGAI_N_LAYERS 8
#define SGAI_N_EMBED 256
#define SGAI_N_HEADS 8
#define SGAI_HEAD_DIM (SGAI_N_EMBED / SGAI_N_HEADS) // 32
#define SGAI_VOCAB 256
#define SGAI_CTX 128
#define SGAI_Q_BLOCK 32 // weight quantization block size
这给出 6,356,992 个参数(头文件中有勘误:之前发表的数字是 8.4M,高出了 32%——实际数量是 8 层 × (4×256×256 注意力 + 2×256×1024 FFN) + 256×256 绑定嵌入 = 6,356,992,根据权重 blob 大小 6,750,220 字节验证)。
权重文件格式有精确的头部布局文档记录:
N64 的 MIPS R4300i FPU 缺少 trunc.w.s 指令(浮点转整数截断)。这意味着标准的 <math.h> 函数——使用硬浮点 FPU 指令——在用 -msoft-float 编译的代码中调用时会崩溃。nano_gpt.c 中的源码明确警告了这一点:
/* 注意:不要包含 <math.h>——libm 函数使用硬浮点 FPU 指令,
取而代之,代码从头实现了两个关键数学函数:
通过范围缩减 + 泰勒级数实现的 exp()(softmax_f 函数,第 298 行):指数运算为 exp(x) = exp(x/128)^128,其中 exp(x/128) 使用 4 阶泰勒级数(当 |x/128| < 0.156 时准确),随后通过 7 次平方恢复完整范围。整个过程不使用任何 float 到 int 的转换,完全避开了缺失的指令。误差小于 0.1%。
通过 Quake III 位运算技巧实现的快速逆平方根(第 277 行):对于 RMS 归一化,代码使用了著名的 0x5f3759df 位技巧,并进行 2 轮牛顿-拉夫森迭代:
u.i = 0x5f3759df - (u.i >> 1); /* 初始近似 ≈ 1/sqrt(mean_sq) */
这与 Quake III Arena 中使用的技巧相同(巧合的是,这也是另一款 1990 年代末期的游戏),针对 R4300i 的软浮点 ABI 做了适配。
Float16 权重缩放:一次位移动,而非除法
权重以 2 位三进制值存储,每个 32 权重块配有一个 float16(IEEE 754 半精度)块缩放因子。nano_gpt.c 中的 f16_to_float() 函数(第 42 行)值得仔细研究,因为它包含一个真实的优化故事:
naive 实现使用 mantissa / (float)(1u << -e),这是热点路径上一个 div.s 指令,在 VR4300 上每个前向传播需消耗约 29 个周期,共执行 196,608 次。优化后的版本认识到,对于正常 float16 值,扩展到 float32 实际上是一次纯位移动:
u.i = sign | ((exp + 112u) << 23) | (frac << 13);
对于所有正常值(blob 中的每个权重缩放因子都是正常值),这在位级别是完全等价的,且将 196,608 次除法替换为 196,608 次整数 OR 操作。源码中的注释解释了数学原理:(1 + frac/1024) * 2^(exp-15) 完全等同于 float32 的指数字段 exp-15+127 和尾数字段 frac<<13。
Big-Endian 权重加载:swap16/swap32 辅助函数
N64 是 big-endian;权重文件是 little-endian(由 x86 上的 Python 生成)。f16_to_float 函数在解码前进行字节交换:
#ifndef HOST_BUILD
f16 = (uint16_t)((f16 >> 8) | (f16 << 8));
#endif
HOST_BUILD 标志将同一文件编译为 x86 原生版本(reference_cli.c),作为位精确的参考实现,此时文件中的 LE 半字已经是正确顺序。这种双目标编译策略意味着同一个源文件既作为 N64 推理引擎,又作为主机端的参考实现,用于一致性测试——这是一种确保两者不会产生偏差的巧妙方法。
矩阵乘法内核:Q8、三进制(SEQ2)和 RSP 加速版本
代码有三个矩阵乘法内核,由权重 blob 的幻数选择:
matmul_q8(第 84 行)—— Q8 int8 权重配合 float16 缩放因子,反量化方式为 int8_val * float16_scale。内层循环在 float32 中累加 blk_acc,然后乘以块缩放因子——将缩放因子提出内层循环节省了每个 32 权重块 31 次 mul.s 操作(OPT_HOIST_SCALE 路径)。
matmul_t2(第 133 行)—— 三进制(2 位)权重,每字节打包 4 个值。每个字节通过位提取解包为四个符号-幅度值,在 float32 中累加,然后由 float16 块缩放因子进行缩放。这是交付的 6.36M 参数模型使用的内核。
matmul_qn(第 165 行)—— 通用 N 位权重格式("SEQn" blob),支持 2–8 位宽度。
RSP 加速路径(matmul_e,第 229 行)在定义了 USE_RSP_MATMUL 时分派到 rsp_matmul_pk,通过 DMA 分块将矩阵乘法工作发送到 RSP 协处理器。RSP 运行 8 通道 int16 矩阵乘法微码(rsp_matmul.S),每次分派处理 8 个输出行。这带来了相对于标量 VR4300 执行 1.78 倍的加速(2.19 vs 1.23 tok/s)。
tok/s 计数器曾经是错的:48 倍的夸大
README 中有一个值得注意的勘误部分。该项目最初公布 N64 的速度约为 60 tok/s,实际数字是 1.23 tok/s。产生 60 这个数字的计数器实际上没有在测量任何东西。
legend_of_elya.c 中 commit bf97959 的有 bug 代码:
int elapsed = G.frame - G.gen_start_frame;
if (elapsed > 0)
G.gen_toks_sec = (float)G.gen_out_count * 60.0f / (float)elapsed;
G.frame 计数游戏循环迭代次数,循环每次生成一个 token。因此 gen_out_count 和 elapsed 每次运行该行代码时都增加 1。它们的比值在构造上固定在 ~1,而读数固定在字面量 60.0f——这是假设一次循环迭代耗时 1/60 秒。实际上它耗时 0.81 秒。
等式右边没有任何东西依赖于任何操作实际花费的时间。它在任何机器上以任何速度都报告约 60——这是一个伪装成测量值的常数。
这就是那种完全可以复现、完全自洽、但测量的却是错误东西的仪表 bug。团队在 README 中显著地公开了勘误,并使其保持可见。这种诚实是罕见的,值得注意。
独立复现尝试:NES 每 Token 周期数
Bounty #16517 要求使用团队未使用的模拟器对公布数字进行独立复现。NES 数字——每 token 平均 1,117,248 个周期——是用 MAME 0.277 的 nes 驱动测量的。赏金任务特别指出 FCEUX 和 Mesen 是未测试的模拟器。
我尝试使用 FCEUX 2.6.5 复现 NES 周期数,它提供了一个内置调试器并支持周期计数。elya-nes 仓库包含:
tools/run_nn.py——启动 MAME、加载 ROM 并通过 Lua 写tap在 $0300 上收集标记时间戳的测试工具
out/——ROM 镜像和校准报告
rom/calib.s——包含 28 个经数据手册验证的测试用例的校准 ROM
标记协议使用 MARKX v = ldx #v(2 周期)+ stx $0300(4 周期)= 6 周期,主机减去 6 周期标记开销。空负载读数为 0,确认校准完成。
FCEUX 调试器确认 2A03 时钟为 1789772 Hz(与 MAME 的截断值一致),28 个校准原语与其预期的周期计数匹配。然而,FCEUX 的 Lua API 不提供像 MAME 的 manager.machine.time 那样的阿秒级时间戳——它只提供帧级时间精度,这对于测试工具所需的每指令周期计数是不够的。
Mesen(它有 Lua API 和周期精确调试器)会是更好的候选,但我无法在本文范围内完成完整的重新测量。tools/run_nn.py 中的测试工具是 MAME 特定的,需要适配到 Mesen 的 API 表面。
我可以报告的是,校准原语——rom/calib.s 中的 28 个指令时序测试——在 FCEUX 调试器下全部产生预期的周期计数:
lda #imm (2) + sta abs (4) = 12 周期含标记 ✓
页交叉索引加载:lda abs,x 4 → 交叉时 5 ✓
索引存储:sta abs,x 5 不受页交叉影响 ✓(虚拟读是无条件的)
RMW:inc abs = 6, inc abs,x = 7, inc zp = 5 ✓
分支:2/3/4(未 taken / taken / 跨页 taken)✓
这些正是"看似合理但错误的模拟器会产生分歧"的陷阱,FCEUX 全部通过。这不是完整的数字复现——赏金任务要求完整的 token 生成周期计数,而不只是校准——但它确认了测量仪器的基础在第二个模拟器上是可靠的。
修正后的 N64 数字及其意义
N64 修正后的 1.23 tok/s 确实很慢。25 token 的提示在第一个字符出现前需要约 20 秒,而生成完整句子需要近一分钟。RSP 覆盖使其翻倍至 2.19 tok/s,但仍然是一分钟一个回复。
但速度不是重点。重点是一颗 1996 年的 CPU 正在执行一个真正的 Transformer 前向传播——嵌入查找、带 QK/AV 矩阵乘法的多头注意力、前馈网络、softmax、贪心采样——完全在卡带上运行,无需互联网、无需服务器、无需云 API。现代游戏中的每一个"AI NPC"都是对 GPU 集群的网络调用。而这个运行在卡带上。
nano_gpt.h 中的架构表说明了这一点:
8 层、256 嵌入维度、8 个头——与 GPT 相同的 Transformer 架构,只是参数从 1750 亿减少到 636 万
128 token 的上下文窗口——足以支持短对话
三进制权重(2 位)配合 float16 块缩放——卡带上仅需 1,984 KB
Float32 激活值通过软件 FPU 模拟实现——因为 R4300i 的硬浮点指令在 -msoft-float 编译选项下会崩溃
KV 缓存放在 RDRAM 中:128 token × 8 层 × 256 维 = 256 KB
README 中的路线图同样值得一看:第三阶段包含 Q4 量化(权重体积减半至约 230KB)、分块矩阵乘法以提高缓存友好性,以及在空闲帧期间进行推测性生成。第五阶段提到"RSP 独立推理——整个前向传播在 RSP 上执行,释放 VR4300 运行游戏逻辑"。这些都是来自真正理解硬件的人的实实在在的优化目标。
硬件熵的细节
我发现一个特别巧妙的设计是 RNG 种子生成。N64 版本使用 MIPS CP0 Count 寄存器与帧计数器进行异或运算:
// 来自 nano_gpt.c — 利用 CPU 振荡器抖动产生硬件熵
u32 seed = CP0_COUNT() ^ G.frame;
Count 寄存器每个周期递增,而由于 token 生成的时序并非完全确定性(缓存状态、RSP DMA 完成、中断时序),Count 的低位提供了真正的熵源。这意味着即使对同一提示,每次响应也都不相同——由硬件种子驱动,而非使用固定种子的伪随机数生成器。代码注释指出这"使得 token 流和周期计数都不可复现",这是一个诚实的权衡:获得真正的熵源,但牺牲了完美可复现性。
RustChain 挖矿模块:一台能赚取加密货币的 N64
N64 仓库的 mining/ 目录下有一个可选模块,让真实的 N64 通过"古董证明"挖矿赚取 RTC(RustChain Token)奖励。N64 执行 5 项硬件指纹检查:
CPU PRId(处理器标识寄存器)
COUNT 时序(周期计数器漂移)
VI 扫描(视频接口时序)
内存比率(RDRAM 配置)
反模拟检测(用于区分真实芯片与模拟器的检查项)
结果通过 joybus 写入控制器卡,经由 Raspberry Pi Pico 通过 USB 中继,提交至 RustChain 节点。N64 获得 3.0x 古董硬件倍数(1996 年硅片)。钱包地址由 RDRAM 配置寄存器 + CP0 PRId 硬件衍生——每台主机独一无二。
这正是运行赏金系统的同一个 RustChain 区块链,赏金系统为本文提供了资金支持。其循环设计颇为精妙:一台复古主机通过证明自身古老来赚取 token,而这些 token 又资助了关于这台主机如何赚取 token 的文章。
源代码揭示的工程质量
阅读两个仓库的源代码,有几个特质尤为突出:
代码对 bug 毫不遮掩。nano_gpt.h 文件中包含两个附带完整解释的 bug 修复——一个关于硬编码的注意力缩放因子(若 head 维度改变会静默错误缩放),另一个关于硬编码的权重缓冲区大小(当模型增大时会静默停止工作)。两个修复都附带了推理过程和原始有 bug 的代码。
仪表化校准依据数据手册。NES 校准 ROM(rom/calib.s)针对 6502 数据手册测试了 28 个指令时序案例,0 不匹配。最坏情况下与整数周期计数的偏差为 2.6e-11。校准特意探测了"看似合理但错误的模拟器会出错的地方"——带无条件虚拟读取的索引存储、页跨越冒险、RMW 指令时序。
宿主参考实现就是规格说明书。host/ref.py 不是近似——它是精确的整数运算,不使用浮点数,因此可以与 6502 实现逐位比对。验证门槛是 16 个贪心 token 与宿主参考实现字节级一致,而非 3 个(团队指出"三 token 门槛在这个项目上已经让三个真正有问题的改动通过了")。
发现日志是追加的,不会被重写。FINDINGS.md 是一个每次结果后都会增长的日志。当某个数字出错时(softmax 基线、tok/s 计数器),修正内容与原始内容并列展示,而非替换。
为什么这个项目重要
在复古硬件上运行 Transformer 的实际应用场景有限——没有人会在生产环境中往 N64 上部署 6.36M 参数的模型。但这个项目展示了一些真正重要的事情:
Transformer 推理在架构上并不与 GPU 绑定。同样的数学运算可以在 6502、MIPS R4300i、68000 和类似 Z80 的 Game Boy CPU 上运行。瓶颈始终相同:内存带宽和乘累加吞吐量。
量化感知训练是有效的。NES 模型使用量化感知训练,因此"训练器看到的前向传播就是 6502 执行的前向传播"。这与现代边缘 AI 部署背后的原理相同,只是应用在了比"边缘 AI"这一术语早二十年的硬件上。
仪表化才是难点。团队六次被"完全可复现、完全自洽、却在测量错误对象"的仪表蒙蔽。N64 上 48 倍的 tok/s 虚高是最戏剧性的例子,但 NES 也有自己的问题:一个 V 计数器发生环绕,报告了 4.8 倍的偏差;一个 Top-K 注意力循环按扫描顺序保留前 K 个而非强度最高的 K 个。
独立验证不可或缺。两位工程师独立验证了基线,然后才发表了报告。模型推理本身也需要验证——比如用 TinyStories 作为独立数据集,在 6502 和 R4300i 两个平台上都用相同的精确整数基准来检验。