tinyworldformer:在 Raspberry Pi Pico 2 W 上用世界模型运行游戏
A tiny world model running games on a Raspberry Pi Pico 2 W
tinyworldformer 使用两个小型神经网络,在 Raspberry Pi Pico 2 W 上同时生成并游玩 Breakout、Doom-lite、Flappy Bird、Pong 和 Pac-Man lite。
项目公开了世界模型、智能体、int8 量化与片上学习的实现和复现流程,读者可结合逐帧准确率与资源占用评估端侧可行性。
使用 2 个小型神经网络,在 Raspberry Pi Pico 2 W 上同时生成并游玩这款游戏。第一个网络创建游戏:根据当前帧和动作,预测下一帧和奖励。第二个网络游玩该游戏:利用生成的帧并选择动作。
![]() |
![]() |
![]() |
![]() |
上面的每一帧都由世界模型网络采样,动作则由行动者网络执行。
为什么
我开始对循环式 transformer 感兴趣:它不是堆叠不同的层,而是将同一个 transformer 块使用相同权重应用多次。复用权重很节省内存,所以我想看看循环式世界模型能否小到足以在微控制器上运行。我选择 Pico 2 W,因为它价格低廉且带有 FPU;选择小游戏,是因为我可以直接用 numpy 编写它们的真实版本,并将生成的每一帧与之核对。后来我加入了一个流式 RL 智能体,这样游戏板也能学会在自己的梦境中游玩。
目录:这两个网络 · 这些游戏 · 世界模型 · 智能体 · 量化 · 芯片上运行 · 烧录 · 浏览器 · 复现 · 代码布局
世界模型与行动者
| 网络 1:世界模型 | 网络 2:行动者 | |
|---|---|---|
| 职责 | 帧 + 动作 → 下一帧 + 奖励 | 帧 → 动作 |
| 架构 | 循环 transformer,q int8 | 含两个隐藏层的 MLP,float32 |
| 大小 | 26k–131k 个参数 | 9k–16k 个参数 |
| 每帧成本 | 2–8M 次乘加运算 | 9k–16k 次乘加运算 |
| 训练 | 使用录制的真实游戏(方法) | 使用 stream-x,在真实游戏或梦境中(方法) |
大小取决于游戏,有些游戏需要更多参数才能被学会并变得可玩,我们尽量让参数数量保持足够小。
在芯片上,两个网络都不会使用真实游戏:行动者只能看到世界模型提供的帧,世界模型也只能从行动者那里获得动作。二者都运行在 RP2350 的核心 1 上,核心 0 则负责 USB 和显示屏。
预训练的行动者可以替换为一个全新的 stream-x 智能体,让它在板上从零开始学习。按下任意按钮都会将控制权交给你,因此你也可以亲自游玩这个梦境。
这些游戏
每个游戏都是 src/tinyworldformer/games/ 中数百行 numpy 代码。它会渲染成由 token id 组成的小网格,而每一帧始终包含完整的游戏状态,因此隐藏状态必须被绘制到帧中:球的朝向是其 token 的一部分,Doom-lite 还有一张小地图。
| 游戏 | 帧 | 规则 | 随机事件 | 世界模型 | 精确帧,2 次循环 |
|---|---|---|---|---|---|
| Breakout | 20×16 | 挡板、球、56 块砖、5 条命 | 失误后的发球 | 26k 个参数,4.3M MACs | 99.99% |
| Doom-lite | 32×32 | 8×8 迷宫中的 raycast 第一人称视角;敌人沿最短路径走向你;你进行射击 | 敌人重生 | 55k 个参数,4.2M MACs | 83.6%(见下文) |
| Flappy Bird | 24×24 | 重力、振翅、滚动管道 | 下一个缺口的高度 | 46k 个参数,2.0M MACs | 100% |
| Pong | 20×32 | 你对抗一个反应迟缓的 CPU 挡板,先得 5 分者获胜 | 得分后的发球 | 46k 个参数,2.3M MACs | 99.45% |
| Pac-Man lite | 48×48 | 23×21 迷宫,249 颗豆子,两个沿最短路径移动的幽灵,3 条命 | 幽灵重生 | 131k 个参数,8.2M MACs | 86.4% |
“完全正确的帧”是指在留出的转换中,预测帧的每个单元格都与真实游戏相匹配的比例。随机事件之后紧接着的帧会被排除在外,因为这些帧只能被采样,无法被预测。Doom-lite 的 3D 视图包含 768 个单元格,只要有一个阴影墙像素出现偏差,整个帧就算作错误,因此它的数字看起来比实际表现更差;但游戏状态几乎总是正确的(见 检查梦境)。
你可以在终端中游玩这些真实游戏:uv run python -m tinyworldformer.games.play --game pacman(或 breakout、doomlite、flappy、pong)。
世界模型如何运作
帧被拆分为词元
帧被切成方形图块:Breakout 中为 2×2 个单元格,Doom-lite、Flappy Bird 和 Pong 中为 4×4,Pac-Man 中为 6×6。因此每个游戏会产生 36 到 80 个词元。图块的嵌入向量是其中每个单元格对应的一个学习向量之和;同时为图块内的每个位置设置单独的表,以免丢失单元格的顺序。每个词元还会加入一个位置向量和一个动作向量。
循环式 Transformer
模型是一个单一的 Transformer 块(注意力加 ReLU MLP),共用一组权重,重复应用 1 到 4 次,每次循环前都会将输入重新加回去。每次循环后的输出都以下一帧为训练目标,因此在运行时,循环次数用于在速度和准确率之间取舍。在 Pico 上,为了保持不错的帧率,我们使用两次循环。
动画展示了一个留出的 Doom-lite 步骤。经过一次循环后,模型会画出一个并不存在的敌人;每多循环一次,就会移除其中一部分,错误单元格数依次为 92、35、12,最后为 0。右侧的柱状条表示每种循环次数能在留出帧中取得完全正确的比例。
下面是一个由梦境生成的 Doom-lite 步骤,在 1、2、3 和 4 次循环后解码,红框标出了与真实规则不同的单元格:
按循环次数统计的完全正确留出帧:
| 循环次数 | Breakout | Doom-lite | Flappy Bird | Pong | Pac-Man |
|---|---|---|---|---|---|
| 1 | 99.68% | 59.8% | 99.99% | 98.47% | 79.6% |
| 2 | 99.99% | 83.6% | 100% | 99.45% | 86.4% |
| 3 | 100% | 85.9% | 100% | 99.60% | 85.7% |
| 4 | 99.96% | 84.7% | 100% | 99.53% | 84.4% |
解码
模型会为每个单元格和每个词元输出一个分数。事实证明,将这些分数转换成帧的重要性几乎不亚于模型本身,而这一过程最终采用三条规则(如上文 Pong 中所示):
- 每个背景单元格(墙、砖块、颗粒、地面)都独立选取最有可能的词元。
- 单例(球、玩家和每个幽灵)通过在所有(单元格,标记)组合中进行一次选择来放置,因此每种始终恰好有一个。随机事件发生后,会在 logits 与最佳值相差不超过 2.2 的候选中进行采样,因此世界模型会采样新的发球或重生,而不会将它们平均处理。
- Pac-Man 和幽灵都是 2×2 的精灵图,但每个精灵图中只有一个单元格承载状态(嘴巴记录朝向;幽灵的左下角单元格记录下方是否有小丸)。另外三个单元格由该单元格复制而来,而不是预测得到。
我们实现了规则 2,因为第一个 Breakout 模型同时画出了两个球;采用一次全局选择后,完全正确的帧占比从 91.6% 提高到了 99.7%。
检查世界模型
仅凭记录数据上的准确率还不够。智能体会将梦境推进到数据中从未出现过的状态,而那里的任何错误都可能成为它可以利用的漏洞。因此,主要检查会在智能体游玩时进行:从想象帧(games/<game>.py: read)中读回游戏状态,将真实规则应用于该状态和智能体的动作,再将结果与下一幅想象帧及其奖励进行比较。
每局游戏 5,000 个想象步,进行 2 轮循环,使用随附的智能体(training/evalwm.py --agent):
| 游戏 | 错误的游戏状态数 | 错误的奖励数 | 每步错误的单元格数 |
|---|---|---|---|
| Breakout | 未读回;含不可能标记的帧为 0 | 6(未击碎砖块却获得了奖励) | – |
| Doom-lite | 1 | 0 | 1.50 |
| Flappy Bird | 0 | 0 | 0.00 |
| Pong | 1 | 0 | 0.00 |
| Pac-Man | 742 | 38 | 0.39 |
训练
首先,智能体游玩真实游戏,每一步都记录为一个样本(帧、动作、下一帧、奖励),每个游戏有 1–3 million 个样本。有些回合从异常状态开始,例如带有缺口的砖块或随机位置,因此数据覆盖了游戏中更多的情况。
接着,世界模型学习以逐单元交叉熵损失预测下一帧:共 30k 步,前 3–4k 步以浮点数运行,随后启用 int8 舍入(QAT)。
int8 权重被写入 Rust 源代码,其中每个浮点数都以其精确比特形式存储。我们使用 cargo test 对其进行检查,以逐比特复现 Python 整数规范,随后固件在 Pico 上运行。
在世界模型中训练的智能体会到达记录数据中从未出现过的状态,因此用该智能体游玩并记录真实游戏,再使用所有数据微调世界模型。
智能体在不同游戏中开始利用这些漏洞,因此以下是针对它的一些修复:
- 按“这个单元格发生了变化”对损失加权,使模型偏向于预测变化,因此它画出了两个球并打碎了两块砖。改为按输入帧中事物可能发生的位置加权,再加上全局单例选择,就解决了这个问题。
- 一个 Breakout 智能体在梦境中拿到 154 分,却还剩 26 块砖,因为砖块被击中时,世界模型没有移除角落中的砖块。Doom-lite 智能体则学会从相距 5 个单元格的位置进行狙击。
- 仅在梦境中训练的智能体在梦境中的得分为 210(真实游戏的最高分为 56),在真实游戏中的得分为 28,这正如 World Models(Ha & Schmidhuber)所预测的。每一轮收集与微调都会堵住该智能体找到的可利用漏洞。
- Doom-lite 的梦境有时会丢失敌人精灵,并且之后再也不会绘制它:模型会复制前一视图,而不是根据小地图重新绘制它。噪声增强解决了这个问题(
train --noise 0.3,与 GameNGen 中一样):输入视图会被加入随机像素,有时还会丢失该精灵。 d=32无法容纳规模更大的 Breakout 数据,而d=48可以。Pac-Man 的 48×48 帧需要d=64。
智能体:stream-x
智能体使用 Stream AC(λ)(Elsayed, Vasan & Mahmood, 2024),每次只从一个转移中学习:没有经验回放缓冲区、没有批处理、没有目标网络。资格迹将贡献度回传到过去,而 ObGD 会缩小任何会超出 TD 目标的步幅。策略网络和价值网络各有两个隐藏层,每层有 64 个单元(在 Pico 上为 32 个),并使用 LayerNorm。
输入是从帧中读取的特征向量(training/streamx.py: observe_*,在 tinyworldformer-rs/src/agent.rs 中以 Rust 镜像实现)。例如,在 Breakout 中是球的行、列和朝向;在 Pac-Man 中则是各个方向到最近豆子的最短路径距离,以及每个鬼魂距离当前位置有多近。
下面展示每个游戏的一次训练运行(真实游戏,500k 步)在六个阶段的情况:未训练、训练结束,以及得分达到全程的 25、50、75 和 95% 时的快照。每个面板都从相同起点按贪心策略游玩,并在每帧跳过若干步。"try" 统计迄今已进行的回合数,因此可以看到崩溃情况。
同一次运行的各快照在真实游戏中的贪心回报,取 10 个回合的平均值:
| 游戏 | 0 | 1k | 2k | 5k | 10k | 20k | 50k | 100k | 150k | 250k | 500k | 最大值 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Breakout | 8.2 | 17.0 | 15.9 | 21.9 | 29.9 | 29.9 | 29.9 | 31.1 | 35.0 | 39.9 | 45.6 | 56 |
| Doom-lite | −5.0 | −5.0 | −5.0 | −5.0 | −5.0 | 59.5 | 69.9 | 78.1 | 81.8 | 82.1 | 82.9 | – |
| Flappy Bird | −1.0 | −1.0 | −1.0 | 1.9 | 24.6 | 24.9 | 96.2 | 123.7 | 309.2 | 332 | 332 | 332 |
| Pac-Man | 7.0 | 3.8 | 17.0 | 17.7 | 8.2 | 46.8 | 72.9 | 138.9 | 146.6 | 190.3 | 217.4 | 249 |
| Pong | −4.3 | −3.9 | −3.9 | −3.9 | −1.7 | −2.0 | −0.3 | 1.1 | 1.7 | 1.1 | 1.1 | 5 |
没有奖励塑形时,Doom-lite 智能体会蹲守在起始角落并开火,因为敌人总会送上门来。对于最近 20 步中被访问超过 6 次的格子,智能体每在其中停留一步,--idle-penalty 0.1 就会扣除 0.1。位置是从小地图读取的,因此该惩罚在梦境内也有效。有了它,智能体会巡逻,并获得两倍的击杀数。
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,每个输出行都有一个缩放系数(scale = max|w| / 127)。激活值也都是 int8,每个张量和每个循环索引各有一个缩放系数,因为不同循环看到的数值范围不同。矩阵乘法将 int8 × int8 的乘积累加到 int32 中(在 RP2350 上,每条 SMLAD 指令执行两次乘加运算),然后用两个缩放系数做一次浮点乘法。Softmax 使用一个包含 1024 个条目的 exp 表,概率以 uint8 存储。范数、残差和嵌入保持为 float32,因为 RP2350 带有 FPU。
训练采用相同的舍入方式(使用直通估计器的 QAT),因此这个 int8 模型就是实际训练得到的模型。rustexport/intref.py 是用 numpy 实现的 int8 模型,所有浮点运算均按固定顺序执行;Rust 引擎逐位复现它的结果,而 cargo test 会为每个导出的环境检查黄金向量。
| 游戏 | int8 权重 | float32(嵌入、范数、缩放系数) | 游戏数据 | 智能体(float32) | 固件闪存 | 固件 RAM |
|---|---|---|---|---|---|---|
| Breakout | 19.7 KB | 31.6 KB | 1.1 KB | 54.1 KB | 361 KB | 299 KB |
| Doom-lite | 33.9 KB | 91.9 KB | 12.0 KB | 61.3 KB | 471 KB | 357 KB |
| Flappy Bird | 30.1 KB | 69.5 KB | 10.1 KB | 50.8 KB | 409 KB | 227 KB |
| Pong | 30.1 KB | 70.4 KB | 10.9 KB | 52.6 KB | 414 KB | 244 KB |
闪存和 RAM 占用根据固件 ELF(size -A)测得。该固件使用 Display Pack 驱动程序构建,因此这些占用包含代码、USB、显示驱动程序和设备端学习器。Pico 2 W 配备 4 MB 闪存和 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。运行时不分配任何内存:权重是闪存中的 static 数组,每个缓冲区都是 RAM 中固定大小的 static。
核心 1 运行游戏。其循环的一次迭代(dream_loop,位于 firmware/src/main.rs 中)就是一帧:
- 行动者从当前帧(
agent::observe)中读取特征,并运行其 MLP 来选择按键。如果启用设备端学习,则改由学习智能体选择;如果你正在按键,则使用你的按键操作。 - int8 transformer 对帧和按键执行 2 次循环(
Engine::forward),解码则将其分数转换为下一帧:背景逐格取 argmax,每个单例只放置一次(随机事件使用 xorshift RNG 采样),然后再贴上精灵图案。奖励为奖励头的 argmax;生命值和回合结束次数则是在此基础上维护的计数器。 - 如果启用学习,学习器会根据这一次状态转换进行更新,随后将其遗忘。
- 该帧通过双槽通道发送到核心 0。如果核心 0 仍在处理上一帧,这一帧就会被丢弃,因此核心 1 绝不会等待屏幕。
游玩时,核心 1 会降速至可游玩的速度(每帧 66–150 ms,具体取决于游戏)。智能体则以芯片所允许的最快速度运行。
Core 0 运行 embassy 异步任务来处理其余所有工作。通过 USB 串口,每帧都会以带颜色的 ANSI 文本输出,并附带一行状态信息(模型耗时、行动器耗时、fps、环境名称和智能体名称),按键输入也会回传。可选的 Display Pack(--features display)配备一块通过 SPI 连接、最高频率达 62.5 MHz 的 320×240 ST7789 显示屏,并且仅重绘自上一帧以来发生变化的单元格。其按键每 20 ms 轮询一次。两个内核仅共享帧通道和几个原子变量:当前由谁行动、按下的按键、学习开关以及循环次数。
内存占用去向:
| 在闪存中 | 在 RAM 中 | |
|---|---|---|
| 世界模型 | int8 矩阵、float32 嵌入、归一化参数和缩放因子、游戏的初始帧和配色(52–138 KB) | 单次前向传播的临时缓冲区:36–80 个 token 的激活值、矩阵乘法读取的 int8 副本,以及下一帧各单元格的分数(Flappy Bird 为 114 KB,Pac-Man 最高 368 KB,其中仅各单元格的分数就占 184 KB) |
| 行动器 | float32 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 闪存和 512 KiB SRAM。Breakout 使用 361 KB 闪存和 299 KB SRAM;体积最大的 Pac-Man 使用 600 KB 和 460 KB。
大部分时间花在 int8 点积上(dot_i8 在 src/lib.rs 中)。一次 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 秒,即可切换到预训练的行动器;再次执行即可切回。
烧录游戏和智能体
每次导出都并列放在 crate 中:世界模型位于 tinyworldformer-rs/models/envs/<env>/,智能体位于 models/agents/<game>/<agent>/。构建时需要选择两者。
cd tinyworldformer-rs ./flash.sh --list # what is exported ./flash.sh --env doomlite --agent doom_agent_roam_p0.3 # build and flash (hold BOOTSEL while plugging in) ./flash.sh --env breakout --display # with the Display Pack ./flash.sh --env pong --host # play it in this terminal ./flash.sh --env flappy --default # make it the default for plain cargo TWF_ENV=doomlite TWF_AGENT=doom_agent_roam_p0.1 cargo test --release # golden tests for that pair
如果没有 --agent,且 models/default 中列有该环境所对应游戏的智能体,就使用该智能体;否则使用名称排在最前的智能体。为其他游戏准备的智能体会被拒绝,并列出有效选项。屏幕上的状态行会显示当前运行的环境和智能体。
USB 按键:Breakout 为 a/d/s,Doom-lite 为 w/a/d/space/s,Flappy 为 space,Pac-Man 为 w/a/s/d,Pong 为 w/s。随后,g 将控制权交还智能体,1–4 设置循环次数,l 切换设备端学习,r 重新启动学习。
在浏览器中
同一个 no_std int8 引擎也可编译为 WebAssembly(tinyworldformer-rs/web/)。该模块没有导入,也不使用 wasm-bindgen,并会运行固件的循环:推演、智能体,以及即时进行 stream-x 学习。一个静态页面会在画布上绘制这些帧。
rustup target add wasm32-unknown-unknown # once cd tinyworldformer-rs/web ./build.sh # one module per env into www/ (or ./build.sh pong flappy) python3 -m http.server -d www # open http://localhost:8000, ?env=pacman picks a game
使用相同种子时,wasm 构建会与原生构建逐帧推演出相同的回合,具体速度取决于游戏,每帧为 1–5 ms。每个模块都自带其游戏的默认智能体;TWF_AGENT=... ./build.sh <env> 可选择另一个。按键与 Pico 上相同,另外还有方向键和屏幕按钮;f 会以最快速度运行推演,这在学习过程中很有帮助(l)。
复现
uv sync G=pong # breakout, doomlite, flappy, pacman, pong uv run python -m tinyworldformer.training.data --game $G --transitions 2000000 --varied 0.3 # -> data/$G uv run python -m tinyworldformer.training.train --data data/$G --d 48 --heads 3 --ff 96 --out models/${G}_wm.pt uv run python -m tinyworldformer.training.streamx --env real --game $G --out models/${G}_agent.pt uv run python -m tinyworldformer.training.evalwm --ckpt models/${G}_wm.pt --agent models/${G}_agent.pt # check the dream uv run python -m tinyworldformer.training.streamx --env dream --wm models/${G}_wm.pt --out models/${G}_dream_agent.pt uv run python -m tinyworldformer.training.export --ckpt models/${G}_wm.pt uv run python -m tinyworldformer.training.streamx --out models/${G}_agent.pt --export tinyworldformer-rs cd tinyworldformer-rs && TWF_ENV=$G cargo test --release && TWF_ENV=$G cargo run --release
各游戏不同的设置:
| 游戏 | 设置 |
|---|---|
| Breakout | 1M 次状态转移,--miss-penalty 5,然后使用智能体收集(data --learned <agent> --holes 0.5 --out data/breakout_learned),并在全部数据上进行微调(train --init ... --data data/breakout data/breakout_learned --steps 15000 --warmup-float 0 --lr 1e-3),共四轮 |
| 轻量版 Doom | --varied 0.5, train --noise 0.3, streamx --max-ep-steps 500 --idle-penalty 0.1 |
| Flappy Bird | streamx --max-ep-steps 3000 |
| Pac-Man | 1.2M 次状态转移,--varied 0.4,train --d 64 --heads 4 --ff 128 --steps 40000 --warmup-float 4000,streamx --miss-penalty 5 --max-ep-steps 1500 |
| Pong | streamx --max-ep-steps 5000 |
代码布局
| 路径 | 内容 |
|---|---|
src/tinyworldformer/games/ |
五款游戏(规则、渲染、read)、Game 注册表、脚本化代理、终端游玩 |
src/tinyworldformer/training/ |
数据、世界模型、训练、梦境检查、stream-x、导出到 Rust crate |
src/tinyworldformer/rustexport/ |
可复用的 PyTorch → no_std Rust 导出:QAT 层、int8 规范、转换、代码生成 |
tinyworldformer-rs/ |
no_std int8 引擎、梦境循环、代理与学习器;主机演示(src/main.rs) |
tinyworldformer-rs/firmware/ |
Pico 2 W 固件 |
tinyworldformer-rs/web/ |
WebAssembly 构建和浏览器页面(www/) |
来源:Hacker News · github.com





















