跳到正文
Hacker News· airinei·· 5 小时前AI 评分58

tinyworldformer:在 Raspberry Pi Pico 2 W 上运行小型世界模型玩游戏

A tiny world model running games on a Raspberry Pi Pico 2 W

AI 导读

作者 SheepSeb 开源 tinyworldformer,用两个小型神经网络在 Raspberry Pi Pico 2 W 上同时生成并游玩游戏:世界模型根据当前帧和动作预测下一帧与奖励,actor 网络再据此选动作。

正文 · AI 翻译

tinyworldformer: games created by a tiny looped transformer on a Raspberry Pi Pico 2 W

在 Raspberry Pi Pico 2 W 上使用两个小型神经网络同时生成并玩游戏。第一个网络创建游戏:给定当前帧和动作,它预测下一帧和奖励。第二个网络玩这个游戏:它使用生成的帧并选择一个动作。

上方的每一帧都由世界模型网络采样生成,动作则由演员网络给出。

为什么

我对循环式 transformer 产生了兴趣,即同一个 transformer 块以相同权重被多次应用,而不是堆叠不同的层。复用权重在内存上开销很低,所以我想看看循环式世界模型能否小到在微控制器上运行。我选择 Pico 2 W 是因为它便宜且带有 FPU,选择小游戏是因为我可以直接把真实版本写成 numpy 代码,并逐帧对照检查每个生成的帧。后来我加入了一个流式 RL 智能体,让开发板也能在自己的梦境中学习游玩。

目录:两个网络 · 游戏 · 世界模型 · 智能体 · 量化 · 在芯片上 · 烧录 · 浏览器 · 复现 · 代码结构

世界模型与演员

the world model and the actor

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

大小取决于游戏,有些游戏需要更多参数才能学会可玩,我们尽量让参数数量保持得足够小。

在芯片上,两个网络都从不使用真实游戏:演员只看到世界模型生成的帧,而世界模型只从演员获取动作。两者都在 RP2350 的 core 1 上运行,core 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 迷宫的射线投射第一人称视角;敌人沿最短路径走向你;你射击 敌人的重生 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)。

世界模型如何工作

帧被拆分为 token

patches

帧被切成方形图块:Breakout 中为 2×2 单元格,Doom-lite、Flappy Bird 和 Pong 中为 4×4,Pac-Man 中为 6×6。这样每款游戏得到 36 到 80 个 token。一个图块的嵌入是每个单元格一个学习向量的和,图块内每个位置都有单独的表,因此单元格顺序不会丢失。每个 token 还会得到一个位置向量和动作向量。

循环 Transformer

loop

该模型是单个 transformer 块(注意力加 ReLU MLP),只有一组权重,应用 1 到 4 次,每次传递前都会把输入加回去。每次传递后的输出都针对下一帧进行训练,因此在运行时,循环次数在速度和准确率之间权衡。在 Pico 上为了保持可接受的帧率,我们使用两次循环。

动画展示了一个留出的 Doom-lite 步骤。一次传递后,模型画出了一个并不存在的敌人,之后每次传递都会消除它的一部分:92、35、12,然后是 0 个错误单元格。右侧的条形图是每个循环次数完全正确的留出帧所占比例。

这是一个在 1、2、3 和 4 次循环后解码的梦境 Doom-lite 步骤,红框标出与真实规则不同的单元格:

loops

按循环次数统计的精确留出帧:

循环次数 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%

解码

decode

模型为每个单元格和每个 token 输出一个分数。将这些分数转换为帧的重要性几乎与模型本身相当,最终归结为三条规则(上方以 Pong 展示):

  1. 背景单元格(墙壁、砖块、豆子、地板)各自独立地取最可能的 token。
  2. 单例(球、玩家、每个幽灵)在所有(单元格,token)对上进行一次选择来放置,因此每种总是恰好有一个。随机事件之后,选择会在与最佳分数相差 2.2 logits 以内的候选中采样,因此世界模型会采样新的发球或重生,而不是对它们取平均。
  3. Pac-Man 和幽灵是 2×2 精灵,但每个只有一个单元格携带状态(嘴巴持有朝向;幽灵的左下单元格持有其下方是否有豆子)。其他三个单元格是从它盖章而来,而不是预测的。

我们实现规则 2 是因为第一个 Breakout 模型同时画出了两个球;一次全局选择将精确帧从 91.6% 提升到了 99.7%。

检查世界模型

在记录数据上的准确率还不够。智能体会将梦境驱动到数据从未有过的状态,那里的任何错误都可能被它利用。因此,主要检查在智能体游玩时运行:从梦境帧中读回游戏状态(games/<game>.py: read),将真实规则应用于该状态和智能体的动作,并将结果与下一个梦境帧和奖励进行比较。

check

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

游戏 错误游戏状态 错误奖励 每步错误单元格
Breakout 未读回;0 帧包含不可能 token 6(未打破砖块却支付了) –
Doom-lite 1 0 1.50
Flappy Bird 0 0 0.00
Pong 1 0 0.00
Pac-Man 742 38 0.39

训练

首先,智能体玩真实游戏,每一步都被记录为一个样本(帧、动作、下一帧、奖励),每局 100 万到 300 万个。有些回合从异常状态开始,比如带洞的砖块或随机位置,这样数据能覆盖游戏的更多情况。

data

然后世界模型学习用逐单元交叉熵损失预测下一帧:3 万步,其中前 3–4 千步用浮点运行,之后开启 int8 舍入(QAT)。

training

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

deploy

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

智能体在不同游戏中学会了利用它们,所以以下是对它的一些修复:

  • 按“该单元发生了变化”加权损失会使模型偏向预测变化,于是它画出两个球并打掉两块砖。按输入帧中可能发生事情的位置加权,再加上全局单例选择,修复了这个问题。
  • 梦中的 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-x

智能体使用 Stream AC(λ)(Elsayed, Vasan & Mahmood, 2024),它一次从一个转移中学习:没有回放缓冲区,没有批次,没有目标网络。资格迹将信用回溯到过去,ObGD 会缩小任何会超过 TD 目标的步长。策略网络和价值网络各有两个 64 单元的隐藏层(Pico 上为 32),并带有 LayerNorm。

输入是从帧中读取的特征向量(training/streamx.py: observe_*,在 Rust 中镜像为 tinyworldformer-rs/src/agent.rs)。例如,Breakout 中球的行、列和方向,或者在 Pac-Man 中,对每个方向,到最近豆子的最短路径距离以及每个幽灵有多近。

下面是每个游戏的一次训练运行(真实游戏,50 万步),分为六个阶段:未训练、结束,以及分数覆盖 25%、50%、75% 和 95% 时的快照。每个面板从相同起点贪婪地玩,并且每帧跳过几步。“try”统计到目前为止玩过的回合数,所以你可以看到崩溃。

Breakout

Doom-lite

Flappy Bird

Pac-Man lite

Pong

真实游戏中的贪婪回报,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 智能体会蹲在起始角落开火,因为敌人总会自己送上门来。--idle-penalty 0.1 对在过去 20 步内被访问超过 6 次的格子,每停留一步就付出 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

quantization

世界模型中的每个矩阵都是 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 为每个导出的环境检查黄金向量。

sizes

游戏 int8 权重 float32(嵌入、归一化、缩放因子) 游戏数据 智能体(float32) 固件 flash 固件 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

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。

核心 1 运行游戏。其循环的一轮(firmware/src/main.rs 中的 dream_loop)就是一帧:

  1. actor 从当前帧读取特征(agent::observe)并运行其 MLP 来选择按钮。如果开启端上学习,则由学习智能体来选择;如果你在按键,则使用你的按键。
  2. int8 transformer 对帧和按钮运行 2 个循环(Engine::forward),解码将其分数转换为下一帧:每个格子取背景 argmax,每个单例只放置一次(随机事件用 xorshift RNG 采样),然后盖上精灵图章。奖励是奖励头的 argmax;生命和回合结束是额外的计数器。
  3. 如果开启学习,学习器从这一个转移中更新,然后将其遗忘。
  4. 帧通过双槽通道发送到核心 0。如果核心 0 仍在处理上一帧,这一帧就被丢弃,因此核心 1 从不等待屏幕。

当你游玩时,核心 1 会放慢到可玩的速度(每帧 66–150 ms,取决于游戏)。智能体则以芯片允许的最快速度运行。

Core 0 运行 embassy 异步任务来处理其他所有事务。通过 USB 串口,每一帧都以彩色 ANSI 文本形式输出,并附带一行状态信息(模型时间、actor 时间、fps、环境与 agent 名称),同时接收按键输入。可选的 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)
actor 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 的 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 本身的帧率。

开发板上电时会启动一个在设备上学习的新 agent。通过 USB 按 l,或在 Display Pack 上按住 B 键 1 秒,可切换到预训练的 actor,再次操作则切换回来。

烧录游戏与 agent

每个导出都并排放在 crate 中:世界模型在 tinyworldformer-rs/models/envs/<env>/,agent 在 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 中列出的该环境游戏的 agent(如果存在),否则按名称取第一个。为不同游戏提供的 agent 会被拒绝,并列出有效的 agent。屏幕上的状态行显示正在运行的环境和 agent。

USB 按键:Breakout a/d/s,Doom-lite w/a/d/space/s,Flappy space,Pac-Man w/a/s/d,Pong w/s。然后 g 交还给 agent,1–4 设置循环计数,l 切换设备端学习,r 重新启动它。

在浏览器中

同样的 no_std int8 引擎也可编译为 WebAssembly(tinyworldformer-rs/web/)。该模块没有导入,也没有 wasm-bindgen,它运行固件的循环:做梦、agent 以及即时流式学习。一个静态页面在 canvas 上绘制帧。

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,具体取决于游戏。每个模块携带其游戏的默认 agent;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 100 万次转移,--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-lite --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 120 万次转移,--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

代码布局

path contents
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