世界模型塞进 Pico 2 W:两个小网络在单片机上做梦打游戏

一个 26k 到 131k 参数的循环 Transformer 预测下一帧,一个 9k 到 16k 参数的 MLP 挑动作,两个网络一起跑在 Pico 2 W 的同一个核上。五个游戏里,83.6% 到 100% 的留出帧能完全画对,训练、量化和解码规则全部公开。

中文
复制
竖幅照片:Pico Explorer 基板上插着 Pico 2 W,小屏幕里是一帧模型生成的游戏画面,上面叠着 This game doesn't exist. 一行字

每一帧都由一个微型循环 transformer 生成:它接收当前帧和一个动作,预测下一帧和奖励。另一个更小的网络则查看每一帧并挑选动作。两者都能装进一块 Raspberry Pi Pico 2 W。

这个智能体是预训练好的,但它也能边玩边学:一个全新的 stream-x 智能体从零开始,在梦境中游玩的过程中不断改进。

在板子上

同样的两个网络跑在带 Display Pack 的 Raspberry Pi Pico 2 W 上:世界模型和 actor 运行在同一个核心上。

2 个微型网络

世界模型和 actor 并排运行的动画

网络 1:世界模型网络 2:actor
任务帧 + 动作 → 下一帧 + 奖励帧 → 动作
架构循环 transformer,q int8两层隐藏层的 MLP,float32
规模26k–131k 参数9k–16k 参数
每帧开销2–8M 次乘加9k–16k 次乘加
训练方式在录制的真实游戏上训练(见下文)用 stream-x 训练,在真实游戏中或梦境里(见下文)

规模取决于游戏。有些游戏需要更多参数才能学会,我们尽量把参数数量压小。

在芯片上,两个网络都不接触真实游戏:actor 只看到世界模型生成的帧,世界模型只从 actor 那里拿到动作。两者都跑在 RP2350 的 core 1 上,core 0 负责 USB 和显示。

预训练的 actor 可以换成一个全新的 stream-x 智能体,在板子上从零开始学习。按下任意按键就能接管控制,你可以亲自玩这个梦境。

游戏

每个游戏都是 src/tinyworldformer/games/ 里几百行 numpy。它渲染成一个小型的 token id 网格,而且一帧始终包含完整的游戏状态,所以隐藏状态必须画进帧里:球的朝向是它 token 的一部分,Doom-lite 则带一张小地图。

游戏帧规则随机事件世界模型精确帧(2 次循环)
Breakout20×16挡板、球、56 块砖、5 条命漏接后的发球26k 参数,4.3M MACs99.99%
Doom-lite32×32光线投射渲染 8×8 迷宫的第一人称视角;敌人沿最短路径走向你;你开枪射击敌人重生55k 参数,4.2M MACs83.6%(见下文)
Flappy Bird24×24重力、扇翅、滚动的管道下一个缺口的高度46k 参数,2.0M MACs100%
Pong20×32你对抗一个反应滞后的 CPU 挡板,先得 5 分者胜得分后的发球46k 参数,2.3M MACs99.45%
Pac-Man lite48×4823×21 迷宫、249 颗豆子、两只沿最短路径移动的幽灵、3 条命幽灵重生131k 参数,8.2M MACs86.4%

“精确帧”指在留出的转移样本中,预测帧的每一个格子都与真实游戏完全一致的比例。随机事件发生后的帧不计入,因为这些帧只能采样,无法预测。Doom-lite 的 3D 视角有 768 个格子,只要有一个带明暗的墙面像素不对,整帧就算错,所以它的数字比实际表现要差;游戏状态几乎总是对的(见“检查世界模型”一节)。

你可以在终端里玩这些真实游戏:uv run python -m tinyworldformer.games.play --game pacman(或 breakout、doomlite、flappy、pong)。

世界模型如何工作

帧被切成 token

一帧被切成若干方形 patch、每个 patch 对应 token id 的动画

一帧被切成方形 patch:Breakout 是 2×2 个格子,Doom-lite、Flappy Bird 和 Pong 是 4×4,Pac-Man 是 6×6。这样每个游戏得到 36 到 80 个 token。一个 patch 的嵌入,是每个格子各取一个学习到的向量再求和;patch 内每个位置各有一张独立的表,格子顺序因此不会丢失。每个 token 还会加上一个位置向量和动作向量。

循环 Transformer

同一个 transformer 块被反复应用、每次都在输入端加回输入的动画

模型只有一个 transformer 块(注意力加 ReLU MLP),只有一套权重,应用 1 到 4 次,每次应用前都把输入加回去。每次应用后的输出都对着下一帧训练,所以运行时循环次数可以在速度和精度之间取舍。在 Pico 上,为了保持像样的帧率,我们用两次循环。

动画展示的是一个留出的 Doom-lite 步骤。一遍之后,模型画出了一个并不存在的敌人,之后每多跑一遍就抹掉它的一部分:92、35、12,最后是 0 个错误格子。右侧的柱状图是各循环次数下完全正确的留出帧占比。

下面是一个“梦到”的 Doom-lite 步骤在 1、2、3、4 次循环后解码的结果,红框标出的是与真实规则不符的格子:

1、2、3、4 次循环后解码出的 Doom-lite 梦境步骤,红框标出与真实规则不符的格子

按循环次数统计的完全正确留出帧:

循环次数BreakoutDoom-liteFlappy BirdPongPac-Man
199.68%59.8%99.99%98.47%79.6%
299.99%83.6%100%99.45%86.4%
3100%85.9%100%99.60%85.7%
499.96%84.7%100%99.53%84.4%

解码

把分数解码成 Pong 帧的动画:背景格子、只出现一个的精灵和精灵盖章

模型为每个格子和每个 token 输出一个分数。把这些分数变成一帧,其重要性几乎不亚于模型本身,最终归结为三条规则(上图以 Pong 为例):

  1. 背景格子(墙、砖块、豆子、地板)各自独立地取最可能的 token。

  2. 只出现一个的精灵(球、玩家、每只幽灵)在所有 (格子, token) 组合上做一次统一选择,保证每种恰好只有一个。随机事件发生后,从与最优分数相差 2.2 logits 以内的候选中采样,这样世界模型会采样出新的发球或重生,而不是把它们平均掉。

  3. Pac-Man 和幽灵是 2×2 的精灵,但每个精灵只有一个格子携带状态(嘴部格子记录朝向;幽灵左下角的格子记录其下方是否有豆子)。其余三个格子由它盖章生成,而不是预测出来的。

我们实现规则 2,是因为第一个 Breakout 模型一次画出了两个球;改成一次全局选择后,完全正确的帧从 91.6% 提升到 99.7%。

检查世界模型

仅凭录制的数据做到准确还不够。智能体会把梦境推进到数据中从未出现过的状态,而在那里犯下的任何错误都可能被它利用。因此,主要检查放在智能体游玩的过程中进行:从梦境帧里读回游戏状态(games/<game>.py: read),把真实规则套用到该状态和智能体的动作上,再将结果与下一帧梦境和奖励作对比。

从梦境中的 Doom-lite 帧读回游戏状态并与真实规则对比的动画

每个游戏 5,000 个梦境步,2 次循环,使用随附的智能体(training/evalwm.py --agent):

游戏错误的游戏状态错误的奖励每步错误格子数
Breakout未读回;0 帧含不可能出现的 token6(砖块未破却付了奖励)–
Doom-lite101.50
Flappy Bird000.00
Pong100.00
Pac-Man742380.39

训练

首先,智能体玩真实游戏,每一步都记录为一个样本(帧、动作、下一帧、奖励),每个游戏 1–3 百万个。部分回合从非常规状态开始,比如带洞的砖块或随机位置,这样数据能覆盖游戏的更多部分。

真实游戏被记录为帧、动作、下一帧、奖励样本的动画

然后世界模型学习预测下一帧,损失为逐格交叉熵:训练 30k 步,前 3–4k 步用浮点运行,之后开启 int8 舍入(QAT)。

世界模型训练完成后再用其自身智能体录制的游戏进行微调的动画

int8 权重被写入 Rust 源码,每个浮点数都以其精确的位存储。我们用 cargo test 校验,确保 Rust 逐位复现 Python 的整数规范,然后固件在 Pico 上运行。

int8 权重导出到 Rust、固件在 Pico 上运行的动画

在世界模型中训练出的智能体会到达录制数据中从未有过的状态,所以用这个智能体录制真实游戏,再在全部数据上微调世界模型。

不同游戏里的智能体都开始钻这些空子,下面是其中一些修复:

  • 按“这个格子变了”给 loss 加权,会让模型偏向预测变化,于是它画出两个球、打碎两块砖。改成按输入帧里哪些位置可能发生变化来加权,再加上全局的单例选择,问题就解决了。

  • 世界模型里的 Breakout 智能体拿到 154 分、还剩 26 块砖,因为世界模型在砖块被击中时没有把角落的砖去掉。Doom-lite 的智能体学会了从 5 格开外狙击。

  • 只在梦境里训练的智能体在梦境里拿到 210 分(真实上限是 56),在真实游戏里拿到 28 分,和 World Models(Ha & Schmidhuber)的预测一致。每一轮收集加微调,都堵上了智能体当时找到的那个漏洞。

  • Doom-lite 的梦境有时会丢掉敌人 sprite,之后再也没画出来:模型直接复制了上一帧的画面,而不是根据小地图重新绘制。加噪声增强解决了这个问题(train --noise 0.3,和 GameNGen 的做法一样):输入画面被加上随机像素,有时还会丢掉 sprite。

  • d=32 吸收不了更宽的 Breakout 数据,d=48 可以。Pac-Man 的 48×48 画面需要 d=64。

智能体:stream-x

stream-x 每次用一个 transition 更新的动画

智能体用的是 Stream AC(λ)(Elsayed, Vasan & Mahmood, 2024),每次只从一个 transition 学习:没有 replay buffer,没有 batch,也没有 target network。资格迹把信用回溯到过去,ObGD 会缩小任何会冲过 TD target 的步长。策略网络和价值网络各有两层 64 单元的隐藏层(Pico 上是 32),并带 LayerNorm。

输入是从画面里读出的特征向量(training/streamx.py: observe_*,在 tinyworldformer-rs/src/agent.rs 里有对应的 Rust 实现)。比如 Breakout 里球的行、列和方向;Pac-Man 里则是每个方向上到最近豆子的最短路径距离,以及每只鬼有多近。

下面是每个游戏的一次训练(真实游戏,50 万步),取六个阶段:未训练、结束,以及分数走到 25%、50%、75% 和 95% 时的快照。每个面板都从同一个起点贪心游玩,每帧跳过几步。“try” 是到目前为止玩过的回合数,所以你能看到崩溃。

Breakout

Breakout 训练进展:从初始状态到训练结束的六个阶段

Doom-lite

Doom-lite 训练进展:从初始状态到训练结束的六个阶段

Flappy Bird

Flappy Bird 训练进展:从初始状态到训练结束的六个阶段

Pac-Man lite

Pac-Man lite 训练进展:从初始状态到训练结束的六个阶段

Pong

Pong 训练进展:从初始状态到训练结束的六个阶段

真实游戏中的贪心回报(取 10 局平均值),数据来自同一次训练的若干快照:

游戏01k2k5k10k20k50k100k150k250k500kmax
Breakout8.217.015.921.929.929.929.931.135.039.945.656
Doom-lite−5.0−5.0−5.0−5.0−5.059.569.978.181.882.182.9–
Flappy Bird−1.0−1.0−1.01.924.624.996.2123.7309.2332332332
Pac-Man7.03.817.017.78.246.872.9138.9146.6190.3217.4249
Pong−4.3−3.9−3.9−3.9−1.7−2.0−0.31.11.71.11.15

不加 shaping 时,Doom-lite 的智能体会蹲在起始角落开火,因为敌人总会自己送上门来。--idle-penalty 0.1 对每一步收取 0.1 的代价,条件是所在格子在过去 20 步内被访问超过 6 次。位置从小地图上读取,所以这个惩罚在梦境里同样生效。加上它之后,智能体会四处巡逻,击杀数翻了一倍。

tinyworldformer-rs/src/learner.rs 是同一套算法用 no_std Rust 写的版本,反向传播是手写的。与 200 次 PyTorch 更新相比,误差在 3.5e-6 以内。在主机上,一个全新的智能体在 Rust int8 的 Flappy Bird 梦境里训练 25k 步后平均每局通过 6 根水管,100k 步后 121 根,250k 步后 300 根以上。

量化与 Pico

模型内部 int8 矩阵、缩放因子与 float32 嵌入的动画

世界模型中的每个矩阵都采用 int8,每个输出行对应一个 scale(scale = max|w| / 127)。激活值同样为 int8,每个张量一个 scale,每个循环索引一个 scale,因为不同循环所见的数值范围各不相同。Matmul 将 int8 × int8 累加为 int32(在 RP2350 上每条 SMLAD 指令完成两次乘加),再用两个 scale 做一次浮点乘法。Softmax 使用一张 1024 项的 exp 表,概率以 uint8 存储。归一化、残差和嵌入保持 float32,因为 RP2350 配有 FPU。

训练时采用相同的舍入方式(QAT,直通估计器),因此 int8 模型就是训练所得的那个模型。rustexport/intref.py 是用 numpy 编写的 int8 模型,每个浮点运算的顺序都是固定的;Rust 引擎逐位复现它,cargo test 对每个导出的 env 校验黄金向量。

每个游戏的 flash 占用柱状图,按 int8 权重、float32 嵌入、游戏数据和智能体堆叠

游戏int8 权重float32(嵌入、归一化、scale)游戏数据agent(float32)固件 flash固件 RAM
Breakout19.7 KB31.6 KB1.1 KB54.1 KB361 KB299 KB
Doom-lite33.9 KB91.9 KB12.0 KB61.3 KB471 KB357 KB
Flappy Bird30.1 KB69.5 KB10.1 KB50.8 KB409 KB227 KB
Pong30.1 KB70.4 KB10.9 KB52.6 KB414 KB244 KB

Flash 和 RAM 的数据取自固件 ELF(size -A),构建时带上了 Display Pack 驱动,因此其中包含代码、USB、显示驱动和端上学习器。Pico 2 W 有 4 MB flash 和 520 KB SRAM,其中 512 KiB 位于主内存映射中。float32 嵌入表是占比最大的部分,也可以改为 int8。

src/tinyworldformer/rustexport/ 只依赖 numpy 和 torch(有一项测试保证这一点),因此其他项目可以直接复用这套导出流程:用 rustexport.qat(QLinear、LoopedBlock)搭建模型,先做浮点预热再用 ActQuant.enabled = True 训练,用 rustexport.convert.looped_model 转换,然后用 int_model_arrays、mlp_arrays 和 codegen.write_module 写出 Rust 源码。rustexport.Golden 写出供 Rust 测试校验的向量。

在芯片上

固件(tinyworldformer-rs/firmware)是基于 embassy 的 no_std Rust。运行时不分配任何内存:权重是 flash 中的 static 数组,每个缓冲区都是 RAM 中固定大小的 static。

Core 1 负责运行游戏。它的循环每跑一遍(firmware/src/main.rs 中的 dream_loop)就是一帧:

  1. actor 从当前帧读取特征(agent::observe),跑一遍 MLP 选出一个按键。如果开启了端上学习,就由学习智能体来选;如果你正在按键,就用你按的键。

  2. int8 transformer 对帧和按键跑 2 轮循环(Engine::forward),解码把它的分数变成下一帧:每个格子取背景的 argmax,每个 singleton 只放置一次(随机事件用 xorshift RNG 采样),然后盖上 sprite。奖励是奖励头的 argmax;命数和回合结束是额外叠加的计数器。

  3. 如果开启了学习,学习器就用这一个 transition 更新,然后把它忘掉。

  4. 帧通过一个双槽通道送到 core 0。如果 core 0 还在处理上一帧,这一帧就被丢弃,所以 core 1 从不等屏幕。

你亲自玩的时候,core 1 会降到可玩的速度(每帧 66–150 ms,视游戏而定)。智能体则以芯片允许的最快速度运行。

Core 0 用 embassy 异步任务跑其余所有事情。通过 USB 串口,每一帧都以带颜色的 ANSI 文本发出,附一行状态(模型耗时、actor 耗时、fps、env 与智能体名称),按键则从串口回传。可选的 Display Pack(--features display)是一块 320×240 的 ST7789,走 SPI,最高 62.5 MHz,只重绘自上一帧以来发生变化的格子。它的按键每 20 ms 轮询一次。两个核之间只共享帧通道和几个原子量:谁在玩、按下的键、学习开关、循环计数。

内存的去向:

占用 flash占用 RAM
世界模型int8 矩阵、float32 嵌入、归一化参数与缩放系数、游戏的开场帧和颜色(52–138 KB)一次前向传播的临时缓冲区:36–80 个 token 的激活值、矩阵乘法读取的 int8 副本,以及下一帧的逐格分数(Flappy Bird 为 114 KB,Pac-Man 高达 368 KB,其中仅逐格分数就占 184 KB)
actorfloat32 MLP 权重和特征归一化参数(51–61 KB)其特征和隐藏层(约 1 KB)
端上学习器仅代码两个 32 单元网络的权重、资格迹和归一化参数(Pac-Man 为 49 KB,Doom-lite 高达 106 KB;随输入特征数量增长)
固件代码、USB、显示驱动(约 150 KB)栈(core 1 为 16 KB)、USB 缓冲区、帧通道

每个游戏都能塞进 4 MB flash 和 512 KiB SRAM。Breakout 用 361 KB flash 和 299 KB SRAM;最大的 Pac-Man 用 600 KB 和 460 KB。

大部分时间花在 int8 点积上(src/lib.rs 里的 dot_i8)。一次 32 位加载取回 4 个权重,SXTB16 把字节 0/2 和 1/3 解包成 16 位两半,SMLAD 在一条指令里做两次乘加。注意力把 uint8 概率乘以 int8 值,所以改用 UXTB16。归一化、exp 查表和最后按输出做的缩放仍留在 FPU 上用 float32 算。2 个循环的一帧是 2–4M 次乘加(Pac-Man 是 8.2M);在桌面 CPU 上,同样的 Rust 代码做一帧梦境耗时 0.7 ms(Flappy Bird)到 1.5 ms(Breakout)。Pico 本身的帧率我还没测。

板子上电后是一个在设备上从头学的智能体。通过 USB 按 l,或在 Display Pack 上按住 B 键 1 秒,切换到预训练的 actor,再按一次切回来。

代码、权重和完整的复现步骤:github.com/SheepSeb/tinyworldformer

来源: stealth← 返回首页