← 返回
IT技术

把整数神经网络编译进 DOOM 地图:用巫毒娃娃做运算,光照和平面高度存数据

✍️ zhirenhun 📅 2026/10/30 👁 15 阅读 ⏱ 67 分钟
把整数神经网络编译进 DOOM 地图:用巫毒娃娃做运算,光照和平面高度存数据

Doomformer 把整数神经网络编译成 ZDoom 地图,并在 ViZDoom 里运行。算术由 Voodoo doll(DOOM 为额外的玩家 1 出生点生成的玩家副本)完成,结果则存放在扇区光照等级和平面高度里。Python 从运行中的游戏里读回每一个暴露值,与 NumPy 整数参考结果比对,容差为 0。只要有一个值差一,本次运行就判定失败。它很慢:在一个小型 MLP 上,比 NumPy 慢约 23,000 倍(按引擎时间计;若把整个调用都套上计时器,则是 65,000 倍);在默认调度下,一张手写数字要花 163 秒实时 DOOM 时间(单张地图),而在 --opt 3 下只需 8 秒。

安装 · 验证 · 工作原理 · 影片 · 文档

安装与运行

pip install git+https://github.com/mottopanikeiku/doomformer   # Python 3.11+, Linux or macOS
doomformer hello             # 2 s: a small MLP, every value checked
doomformer draw --digit 7    # 15 s: the marine walks a 7 and the level reads it

doomformer doctor 会检查 ViZDoom、Freedoom 和显示环境,缺少 ACC 时自动构建,并编译一个 ACS 探针,全部通过后才报告成功。首次构建需要 git、cmake、C 编译器和访问 github.com 的网络。Linux 使用动态运行时,不需要 glibc-static。Fedora 上执行:sudo dnf install git cmake gcc gcc-c++ make。 Colab notebook 无需任何配置,但你要在浏览器画布上作画,再由 doomformer mnist --image 把图像喂给 DOOM,全程没有陆战队员出场。

实证:走一个数字,DOOM 读出它

按住 FIRE,陆战队员会在脚下涂出 3×3 的地板格。按下 READ 后,关卡把 784 个像素写入网络,同一张地图里的 1,355 个巫毒娃娃共同完成计算。加上 --digit 参数,Python 就替你操作:它按下陆战队员的按键并触发一次 USE,之后只推进时钟并读取结果。

          ▄▄▄▄▄▄▄▄▄▄▄▄▄▄
       ▄████████████████
       ████▀▀     ▀████▀
       ▀▀▀        ▄███▀
                  ████
                  ███
                  ███
                  ███
                 ████
                 ███▀
                 ████
                 ▀███

DOOM reads: 7  (right)   -- the winner's doll lit it on the 7-segment display
EXACT: all 248 values DOOM computed [...] equal the NumPy integer reference (tolerance 0)
READ pass: 273 game tics = 8 s of real-time DOOM at 35 tics/s, 3 s wall [...]

在 [...] 处有删节;完整输出见 docs/commands.md。draw 用 --opt 3 构建地图,因此一次 READ 要花 273 个游戏 tic;在 --opt 0(原始调度)下要花 5,705 个 tic(相当于 163 秒实时 DOOM)。不加 --digit 时,命令会打开一个窗口,由你自己走动并绘制;此时 DOOM 按实时运行。

这些玩偶完成的计算是:784 个像素 → 64 个隐藏单元 → 45 次两两分数比较 → 10 个胜出计数。只有九场比较全部胜出的数字才能让一个玩偶通过,而该玩偶穿过的那条线会启动关卡脚本,在显示屏上点亮这个数字。Python 会读回 784 个像素、隐藏层、每一次比较和最终答案,并校验全部 248 个值(119 个值,每个都连同其取反的孪生值一起读取,外加 10 个输出)。

这张图片显示了DOOM游戏中的一些数字,包括0-9,数字是用黄色字体写在黑色背景上

EXACT 表示 DOOM 的结果与整数网络一致,并不说明数字识别得对不对。在 MNIST 测试集的前 100 个数字上,DOOM 读对了 99 个;从全部 10,000 个中抽样的 1,000 个里读对了 966 个,且每个结果都是 exact。整数网络在全部 10,000 个上的得分为 97.16%(浮点模型为 97.58%)。走动的 30 个数字(0 到 9,每个有三种脚本化笔画变体)在 --opt 3 下全部读取正确且 exact,而网络也在这个笔画分布上训练过:本演示的第一版是一个仅在 MNIST 上训练的 784-32-10 网络,对这类笔画的读取率只有 41.08%。通过真实的 X 按键事件绘制的 7 在默认 --opt 3 下读取为 exact;在 --opt 0 下用更早的网络试过一个 7 和一个 1。详情 · 影片

博物馆地图:点亮一个数字,改接一条连线

doomformer museum build 会生成 museum.wad,一张无需 Python 就能玩的地图。你踩上七块地板垫点亮一个数字,再按 READ。136 个 voodoo doll 共同计算一个小型手写网络:每个数字的得分统计与它相符的段数,得分最高的数字对应的 doll 点亮显示屏。MUTATE 会在关卡运行过程中改接该网络的一条连线:把段 a 到数字 7 的权重从 +1 改成 -1,做法是修改两条权重线的 specials。同一个 7 于是被读成 1,RESTORE 则把它恢复原状。HUD 和记分板会在每次 READ 后显示十个得分,摄像机位则展示数字 7 的第六个得分 doll:MUTATE 之前它能通过自己的闸门,之后则停在闸门前。

$ doomformer museum verify
7                        pads abc     w_7a +1  DOOM reads 7  scores [4, 6, 3, 5, 4, 3, 2, 7, 3, 4]  EXACT (148 values; 744 tics; [...])
7 after MUTATE           pads abc     w_7a -1  DOOM reads 1  scores [4, 6, 3, 5, 4, 3, 2, 5, 3, 4]  EXACT (148 values; 744 tics; [...])
7 after RESTORE          pads abc     w_7a +1  DOOM reads 7  scores [4, 6, 3, 5, 4, 3, 2, 7, 3, 4]  EXACT (148 values; 744 tics; [...])

这里由 Python 来操作:它按下 marine 的按键和 USE,每次 READ 时只读取,并带有受保护的读数。它把全部 148 个值与当时接线状态下网络的 NumPy 参考值逐一比较。不运行 DOOM,静态提取器也能证明,重新接线后的地图网表就是只改动了那一个权重的网络。我只在 ViZDoom 里跑过整张地图。它的自测地图会读取数字 0 到 9,然后在 MUTATE 之后和 RESTORE 之后读取 7,全程无需玩家。在 GitHub runner 上,它记录了 GZDoom 4.14.2、LZDoom 3.88b、UZDoom 5.0.3 和 ZDoom 2.8.1 中的精确值;在 Zandronum 3.2.1 中,只有当所有扇区标签都低于 32,768 时才会记录。详情

检查内容

这张技术文章配图描述了一个名为ViZDoom的游戏,用于训练深度学习模型

Python 从运行中的游戏里读取扇区光照等级以及地板和天花板高度,再与同一网络的独立 NumPy 整数实现逐项比对。检查只针对实际运行过的输入做整数相等判断,未运行过的输入不在覆盖范围内。测试会主动攻击这一点:在一张小编译地图里把某条权重线从 5 提到 6,结果必须在恰好跨越该线的那些输入上恰好偏差 +1;如果读回值只是回显 Python 写入的数值,也必须判定失败(pytest tests/test_tamper.py,三个测试,约 15 秒)。

读回默认受保护:在每一页之前,Python 先污染并检查自己的 USER 槽位,然后要求该页替换这些槽位。这样丢弃一页时,就不会悄悄复用重复输入的答案或上一页的数值。guard=False 是显式针对单张地图的历史退出选项。受保护的构建会产生新哈希(ACS 和元数据,不涉及几何或算术);哈希记录说明了这一变化。

doomformer fuzz --nets 40 会比对 40 个随机网络在 DOOM 中运行时的每一个值(306 次通过,0 次不匹配,使用 --workers 2 时约 10 秒;植入的编译器 bug 能被发现)。

fuzz --edge 会把权重、偏置和输入都推到取值范围边界:在十个全新随机种子上跑 15,000 个网络,分别在 --opt 0、--opt 3 以及瓦片映射模式下,共执行 113,605 次运行,比对 1,987,632 个值,无一不匹配(范围检查拒绝了抽到的 47,686 个输入,约占 30%);再把生成器放宽到从下界附近作为平面起点,又跑了 3,000 个(22,682 次运行);另外 2,000 个还从 15,000 到 16,000 之间抽取起点(14,782 次运行),全部没有不匹配。更早的几轮测试在调度修复之后跑了 2,000 个随机 MLP(15,374 次运行,276,594 个值)、300 个脉冲网络和 100 个随机 transformer,覆盖全部 8 种 attention、norm 和 MLP 变体(实际运行 99 个;有一个全零矩阵不受支持);200 个脉冲 --edge 网络执行了 12,794 次运行,没有不匹配。fuzz --edge 不抽取 transformer。CI 在每次 push 时用新生成并打印出来的种子对 60 个网络做模糊测试,每周一跑一轮更长的测试。模糊测试器和独立运行在本仓库自己的映射中发现了四个故障:两个调度 bug 和两个平面限制。一次平面移动如果起点距零超过 14,018 个单位,就会让移动器忙上三个 tic,并丢掉该平面上的下一个请求;而起点高于 16,000 的平面在第一遍精确,后续遍数出错,于是 DOOM 在 net.check 已接受的网络上与参考实现不一致。check 和 compile 现在会拒绝这类情况:平面起点必须在 [-15,000, 16,000] 内,移动范围在 ±14,018 以内(doomformer verify --plane-sweep,docs/verification.md)。

doomformer verify 会运行一个小型 MLP 的每个输入类别(在同一页面所述的一条假设下,20 个网络中有 20 个网络的各类别结果与所有输入一致)。df.coverage 统计实际比对的内容:在 MNIST 上,全部 248 个暴露值,即参考实现定义的 129 个值加上 119 个孪生值;一旦去掉某个读出名称,计数就会下降。该映射并不暴露同一网络的 903 个阶梯计数和 1,351 个门比较;它们通过所馈入的和来检查。doomformer verify --extract net.wad --net net.npz 无需启动 DOOM,就能从编译好的地图中把网络读回来,并将每个权重、偏置和阈值与源网络逐一比对:随附的 MNIST 网络(784-64-45-10-10)仅凭其地图在 --opt 0 到 3 下都能完整还原,490 个随机网络中有 484 个在 --opt 0 下同样如此(其余 6 个虽然相等,但违反了平面契约),而在 --opt 1 到 3 下,199 个网络全部还原。EQUAL 覆盖算术网表、平面契约、触发与重复标志、移动器速度、复位脚本和读出页面;时序与几何信息则标记为 NOT CHECKED。改动过的权重行会精确显示为那个权重。在 --opt 0 下对小地图做 23,622 次单点编辑,提取器检测出 17,906 次;未检测出的 98 次编辑在 DOOM 中用 12 组输入运行,结果全部精确。它展示的是地图所声明的内容;至于 DOOM 是否按读取到的地图运行,则由精确性检查来验证。变异测试框架会改动编译后地图的一行:在运行的 1,004 个位点(共 5,578 个)中,有 297 个被杀死,涵盖所有权重、符号和目标类别。707 个存活位点中,590 个符合某条规则(冗余滚动器、前向传播永远不需要的冻结行),这些规则有聚合对照支持;117 个是输入缺口(只有部分输入才会暴露的行走地板);没有一个被证明等价。doomformer lint --fuzz 在 5,397 张地图上将静态调度检查器与 DOOM 进行对比:0 个假阴性。该方法属于翻译验证(Pnueli、Siegel 和 Singerman,1998):对每次编译做测试,而非证明编译器正确。

分工如下:

  • 所有乘加、激活和比较都由玩偶完成。ACS 级脚本负责寻址单元格、移动输入门、回卷玩偶、感知世界并复制结果。网络中的算术运算没有一项在 ACS 中运行。工具包的 df.Agent 是唯一例外:它在 ACS 中完成 argmax(docs/agents.md)。
  • mnist:Python 写入 784 个像素端口,并判断玩偶何时停止;玩偶负责选出数字。
  • demo 和 ask:Python 写入 token id,判断玩偶何时停止,从 DOOM 计算出的 logits 中采样每个字母;当模型被切分到多张地图时,在相邻游戏之间复制整数。demo --one-map 是单张地图,层与层之间没有 Python;Python 写入 token,并在每轮传播后采样字母。
  • draw 及其大脑就是关卡本身:Python 只推进时钟并运行读出脚本(在 draw 中,从 READ 开始)。关卡提供输入(784 个像素、感知到的 token),玩偶负责选出数字或动作。
  • demo --one-map --loop:Python 只推进时钟并运行读出脚本。玩偶选出每个字母,并将其写入下一个位置。
  • 飞行循环:Python 只负责推进时钟并运行读出脚本,步长周期则先在另一个由它编写的游戏里校准好。关卡绘制输入脉冲,输出脉冲则触发推力线。
  • --opt 3(draw 的默认值;其他场景用 --opt 0,即带保护性回读的原始调度):ACS 脚本把每一层的光照强度复制到下一层的门高度。它不做任何加减和比较,求和、阈值、比较和选择仍由玩偶完成,但“层与层之间没有 ACS”只在 --opt 0 到 2 时成立。
  • doomformer machine:玩偶负责程序计数器和寄存器。默认情况下,循环、回退和停机检查由一个 ACS 脚本完成,存储的程序在第一个 tic 之前由宿主加载。使用 --clock dolls 时,由循环玩偶和行特殊功能重启、回退和停止关卡,ACS 只读取数值并检查玩偶是否已停止(docs/machine.md)。
  • learn digits:Python 写入每个样本的像素和标签,推进时钟并读取;玩偶负责计算得分、argmax 和权重更新(docs/learning.md)。
  • python -m demos.brainlevel --lesion-at N 或 --lesion-when near|ahead:关卡自身的时钟会在第 N 次决策之前关闭受损单元的门,或在它感知到的 token 首次满足条件时关闭;Python 只负责读取。

在 draw、他的大脑、循环、飞行和机器这几类运行中,一个包装器(ReadoutOnly)会让任何其他调用抛出异常,并且会统计命令数:无头 draw 运行 58 次,他的大脑影片运行 710 次,损伤运行 154 次,其他为 0。

这些地图使用 ZDoom 的 UDMF 格式和 ACS,因此需要 ZDoom(这里用的是 ViZDoom 1.3.2 加 Freedoom 素材),而不是 1993 年的可执行文件。net.quantization_report(x) 衡量整数模型与其来源浮点模型之间的差距。EXACT 则不做这种衡量。

工作原理

docs/HOW_IT_WORKS.md 把网络的每个部分对应到地图的某个元素:一个数值对应一组玩偶,一个权重对应一条线,一个激活对应一段楼梯,注意力对应一条天花板闸门走廊,消融对应一扇门。


矩阵乘法。每个玩偶是一个激活单元,它穿过的每条熔岩线都会累加一个权重。
该图显示了一个Doom游戏截图,背景是蓝色球体的墙壁,前景是绿色和灰色色调的战士

注意力。玩偶从天花板闸门下方挤过去,而闸门已被另一个活跃数值压低。数清谁通过了,就是一次乘法。

引擎有不少尖锐的边角,编译器则绕开它们。平面移动器在同一平面上还有请求正在执行时,会拒绝第二个请求,后一个请求就此丢失。传送线会取消玩偶在该 tic 内先前穿过的线。距离 BSP 节点超过 32,767 单位的东西可能被归到错误的扇区,所以每张地图都控制在 32,000 单位以内。这些是 ViZDoom 1.3.2 中实测到的 ZDoom 2.8.1 行为。32,000 单位规则在 Linux 上必须遵守;在 macOS arm64 上,探测地图中没有出现回绕(原因未查),但该规则在所有平台都保留。

hello 大脑也在原版 ZDoom 2.8.1(53 行全部匹配)、Zandronum 3.2.1(53 行中 50 行匹配:它找不到超过 32,767 的扇区标签)以及 GZDoom 4.14.2、LZDoom 和 UZDoom(53 行中 44 行匹配:这些引擎里平面是双精度,UDMF 解析器拒绝超出 ±32,768 的坐标;坐标回绕仍然存在)中运行。hello 大脑记录的值在 ZDoom、GZDoom、LZDoom 和 UZDoom 中与 NumPy 参考一致,在 Zandronum 中把标签移到 32,768 以下后也一致。玩偶时钟计数器机器(multiply 7 9,464 步,235,567 tics)和一个 SUBLEQ 加法也在 GZDoom、LZDoom 和 UZDoom 中运行,每一步的 pc 和寄存器都与解释器日志相同;ZDoom 2.8.1 和 Zandronum(标签低于 32,768)精确运行了 50 步乘法和 SUBLEQ 加法,而 Zandronum 使用默认标签时在第 1 步卡住(其他端口)。doomformer facts 重新运行 28 个探针(55 个用例),逐行给出 PASS 或 FAIL(约 10 秒),引擎事实列出了每项发现及其背后的探针或测试。

DOOM 地图里的计算机早已存在。counter.wad(2006)是用巫毒玩偶和传送带搭建的 4 位计数器,conway.wad 在 Boom 地图里运行生命游戏,Danny Spencer 的 DOOM 计算器则是一台加法机。Doomformer 新增了从整数网络到 ZDoom 地图的编译器、一份实测的引擎行为清单(这些行为会破坏精确性),以及针对 NumPy 参考的容差为 0 的校验。docs/related_work.md 列出了查阅过的资料,以及每条资料与本项目共享什么、不共享什么。

命令

命令 DOOM 会做什么 耗时 难度等级
doomformer hello 一个小型 MLP,5 个随机输入 2 秒 我还太年轻,不该死
doomformer draw --digit 7 陆战队员走出一段 7 字轨迹,关卡读取它 15 秒
doomformer mnist 读取 4 个测试数字,或你自己的图片 1.5 到 3 分钟 嘿,不算太粗暴
doomformer demo 一个 transformer 写出关卡名称 15 到 60 秒 尽情伤害我
doomformer demo --nightmare 2 层模型,106 张瓦片地图 3.5 到 7 分钟 噩梦!
doomformer ask "what does iddqd do" 根据 189 条 DOOM 事实作答 约 11 分钟 极度暴力
doomformer machine subleq all 一台 SUBLEQ 机器在同一张地图上运行三个程序,其中一个会自我改写 约 5 分钟
doomformer learn digits --classes 4 --steps 300 一个空白感知机在同一张地图内学会 8×8 数字 约 5 分钟

hello 运行一个 4-6-5-3 整数 MLP,demo 是一个 1 层 transformer,用 136 个官方关卡名称训练,这些名称被切成 38 张瓦片地图,--nightmare 是 2 层模型(56,390 个娃娃),ask 是一个 2 层 transformer,用 189 条 DOOM 事实训练而成。

docs/commands.md 列出了所有命令及其变体,并附有示例输出:mnist --image 和 --tiles、demo --softmax、--model doomnames_modern、--one-map、--loop、--opt、fuzz、verify、lint、tune、facts、machine、learn。

以下耗时来自一台空闲的 16 核 arm64 Linux 机器,最多同时运行 8 个游戏;hello 和 draw 只运行一个游戏。在繁忙的机器上同时运行 2 个游戏时,demo 约需 1 分钟,demo --softmax 约需 3.5 分钟;同时运行 3 个游戏时,ask 耗时 20 分钟。

  • “不在训练名称中”这个标准很低:2 层模型在温度 200 下随机抽取 30 个样本,其中 29 个是新名称;而默认的 demo 在 10 个种子中有 6 个会写出它 136 个训练名称之一(输出中会注明是哪一个)。默认种子(20)是特意挑选的。
  • DEMON CANYON 是 --nightmare 写出的名称,属于手工挑选的样本(种子 2,温度 150;在它之前尝试了多少个种子没有记录)。2 层模型在 12,934 个清洗后的关卡名称中的 11,341 个上训练。
  • ask 对训练中释义的答对率为 94.7%,对同一事实的新表述答对率为 35.4%。“what does iddqd do” 是训练问题,回答是 GOD MODE。对于 189 条事实之外的问题,得到的答案虽然精确,但毫无意义。

更大规模的实验

他的大脑就是关卡本身。关卡脚本感知最近的恶魔,1,345 个陆战队员巫毒娃娃运行他的策略 transformer 并选出动作,关卡再让他转身、扣下扳机。Python 推进时钟并读取结果。50 次决策全部精确命中(种子 4)。一次决策耗时 4,338 个游戏 tic(相当于 124 秒实时 DOOM);加上 --opt 3 后,同样的 50 次决策选出同样的 50 个动作,全部精确,每次 774 tic(22 秒)。在那次运行中,他的娃娃们在第一个恶魔还很远时转身了 21 次决策,第 22 次决策首次选择 FIRE,总共选了五次;五个恶魔死亡。该策略模仿一个能看到恶魔精确位置的脚本自瞄。他思考时恶魔被冻结;若让恶魔保持清醒(--live,见下文),他 5.5 秒后就会死亡,击杀数为 0——这几乎是竞技场强制的结局;影片对思考过程做了延时处理。 影片 · 代码

从克隆的仓库运行实验(pip install -e .,然后 python -m demos.<name>)。demos/README.md 列出了每条命令、耗时以及输出内容:

  • python -m demos.brainlevel:约 4 分钟完成 5 次决策;--decisions 50 --seed 4 即 50 次全中的那次运行,约 31 分钟; 加上 --opt 3 后,在负载较高的机器上 50 次约需 5 分钟。
  • python -m demos.brainlevel --seed 2 --decisions 12 --opt 3 --lesion 'x0:0;L0.ao:7;L0.x1:7;L0.m:0;L0.x2:0' --lesion-at 3: 从第 3 次决策起,关卡时钟关闭 5 个单位的门,陆战队员转向健康大脑开火的位置;12 次决策全部精确,约 2 分钟。
  • python -m demos.brainlevel --live --lives 3:恶魔保持清醒,他的玩偶在计算;关卡每次决策采样一次传感器,动作在 773 tics 后才生效。跑 90 条命(种子 4、5、6,每次决策都精确)他 0 击杀,中位 191 tics(5.5 秒)后就死了,还没等第一个动作生效;同一竞技场冻结后每条命能拿 22 击杀。这个竞技场里他动不了,第一次恶魔伤害在中位 128 tics 后到来,所以任何等待 773 tics 的 actor 都会先死:同一策略的 Python 副本、同样延迟,也是 0 击杀;延迟改成 3 tics 则是 3.3。真正有信息量的是扫描(python -m demos.brainlive sweep):每 4 tics 给一个新动作,感知延迟 3 tics 时能保住冻结击杀数的 79%,6 tics 时 36%,12 tics 时 19%。
  • python -m demos.aimlearn:约 3 分钟跑 2 条命;--lives 5 --seed 4 约需 11 分钟。
  • python -m demos.flyloop:约 3 分钟跑 40 步;--steps 900 会一直跑到第 169 步结束,约 13 分钟。
  • python -m demos.lobotomy:在 DOOM 中进行损伤扫描并做出 3 个决策,约需 1.5 分钟。

  • Transformer 为关卡命名。2 层命名模型用 1,690 次 tile 运行写出 DEMON CANYON;再由 Python 生成器构建该关卡。
    手工挑选的样本(seed 2),并非语料库中的名称。

    陆战队员游玩它命名的关卡。1 层策略在 55 局游戏上走完该关卡:开门、两次击杀、抵达出口。102 个决策全部精确。
    Python 提供罗盘与敌人位置。从 120 个 seed 中挑选第 118 个。

    感知机在一局游戏内学习。38 个天花板高度权重,由 dolls 根据 aimbot 的标签更新。当前代码在 5 条命内取得 4、22、15、20 和 16 次击杀,815/815 个决策精确。
    视频是更早一次运行,使用旧地图布局:4、24、26、22、24 次击杀,931/931 精确,seed 4。Python 对特征分箱并给出标签。

    损伤。一次 7 弹丸霰弹枪射击关闭 7 个神经元作为门,陆战队员停止开火:74 个决策中 0 次击杀,全部精确。
    从 3,000 次随机射击中挑选,其中 4 次为和平主义者:16 个 `x0` 单元的 11,440 个七元子集中有 64 个如此,而 5 个神经元是最小值。

    果蝇回路。源自 FlyWire 连接组的 255 个神经元驱动陆战队员的扑击、梳理抽动和起飞;拒绝即为沉默。169/169 步精确。
    简化的整数动力学,合成输入脉冲,放电频率为浮点模型的 0.6 至 2.7 倍,单一 seed。
    这张技术文章配图展示了一个使用Python编写的单层转换器的示例

    单张地图中的 Transformer。层与层之间没有 Python:3,548 个 dolls 写出 SHIL,2,680/2,680 个暴露值精确。
    1 层,d=8 模型。

    不靠 Python 生成。 获胜方的玩偶决定下一个 token,关卡自行回卷;Python 只负责推进时钟和读取。它用 5 轮写出了 DAN(5,875 个玩偶)。
    单一模型、默认提示词;影片以延时方式呈现思考过程。ViZDoom 最多列出 4,096 个对象,因此有 1,779 个玩偶未被绘制([引擎限制](docs/engine_limits.md#object-list-vizdoom-lists-at-most-4096-objects))。

    一个 4 层 transformer。 它在 10,166 个 tile 实例、190 万个游戏 tic 中写出了 UNHOLY STRONGHOLD。
    这些实例运行在一个游戏池上,而非同时运行 10,166 个游戏。

    演示集锦涵盖了以上全部内容。docs/DEMOS.md 列出了各项数字、每项检查的范围以及所选种子清单,docs/FLY.md 则介绍了飞行部分。

    自己动手构建

    把一个网络编译成地图,让它的输出去开门、闪灯,或者推开那个陆战队员。下面这段代码粘贴即可运行:

    import numpy as np
    import doomformer as df
    
    # 2 inputs -> [a AND b, a OR b] -> 2 outputs, all integers
    net = df.MLP(
        weights=[np.array([[1, 1], [1, 1]]), np.array([[1, 0], [0, 1]])],
        biases=[np.array([-1, 0]), np.array([0, 0])],
        thresholds=[np.array([1]), np.array([1])],
        signed_input=False,
    )
    with df.compile(net) as brain:
        for x in ([0, 0], [1, 0], [1, 1]):
            # computed by voodoo dolls; raises df.MismatchError on any difference from net.reference
            print(x, "->", brain(np.array(x)).tolist())
        brain.record([np.array([0, 1]), np.array([1, 1])], "dolls.gif")   # film the dolls doing it

    df.MLP.from_torch 负责量化 PyTorch 模型(见示例 02),world 和 actions 两个钩子则把房间放进地图,并给输出接上门、灯光和推力(见示例 03)。同一套 API 也支持脉冲网络、transformer、Conv1d 和 Conv2d,以及在地图内部更新权重的学习器;mode="tiles" 会把模型铺到一组游戏上(doomformer plan 会告诉你属于哪种情况)。详细指南见 docs/BUILD_YOUR_OWN.md,另有十二个单文件示例。做出了什么新奇玩意儿?欢迎来晒一晒、聊一聊。

    同时运行多个游戏

    一张地图就是一个游戏,一个游戏占用一个核心。df.run_many 把每个输入交给下一个空闲游戏,检查每一轮结果,再按顺序返回输出,既可以在本机运行,也可以在你自己启动的 Ray 集群上运行。全程无需托管服务。在一台 16 核机器上,用 14 个游戏跑 1,000 个 MNIST 测试数字耗时 2 小时:966 个读取正确,1,000 个全部精确,共 5,696,000 个游戏 tick,相当于 45 小时实时 DOOM(models/mnist/doom_1000.json)。

    瓦片(tile)是多地图方案:四张瓦片地图,每层一张,放在常驻游戏里,由 Python 把层值从一个游戏复制到下一个游戏。doomformer mnist --tiles --all --workers 6 用 1,390 秒读完全部 10,000 个测试数字:10,000 个全部精确(每个 248 个值),9,716 个正确(97.16%),每个数字上 DOOM 的 one-hot 都与 NumPy 一致(models/mnist/doom_tiles_10000.json)。在那里,一个数字需要 216 个游戏 tick、0.83 秒,而在单地图中需要 5,696 个 tick、约 104 秒。doomformer plan 会预测耗时(在 11 个留出的 K3 和 K8 网络上,单地图的 rms 误差为 13%,瓦片为 22%;在其他 K 的网络上,实测秒数比预测高 0 到 40%,MNIST 在 --opt 0 下高 21%),打印带误差范围的游戏 tick 数(MNIST 在 --opt 3 下为 276 ± 7.2%,实测 272),并给出推荐方案。

    doomformer bench tiles 在单 worker 下每分钟读取 81 个 MNIST 数字,16 个 worker 下达到 880 个(稳态;含游戏启动阶段分别为 79 和 552),每一轮结果都精确无误;每个数字消耗的引擎 CPU 时间在单 worker 下为 0.72 秒,16 worker 下为 0.92 秒(共输出 100 个数字,重复两遍;16 核)。

    doomformer mnist --tiles --all --workers 6         # all 10,000 digits as tile maps, about 23 min
    pip install "doomformer[ray]"
    ray start --head                  # other machines: ray start --address=HEAD:6379
    doomformer mnist --all --ray --record mnist.json   # all 10,000 digits, one map each, on a cluster

    每局游戏预留一个核心和约1.3 GB内存(实测峰值为1.24 GB)。具体测量数据见docs/scaling.md。

    其他功能

    局限

    内容一览

    路径 内容
    doomformer/ 工具链:地图编译器、DOOM 运行时、精确校验、CLI
    demos/ 研究代码:以他的大脑为关卡、walker 与关卡生成器、飞行回路、学习竞技场
    training/ 所有模型的量化感知训练,附语料库及其来源
    models/ 所有训练好的模型,附训练日志与评估结果
    examples/ 十二个单文件示例,多数运行时间不到 15 秒
    tests/ 安装 torch 后共 479 项测试(不装则更少),其中 175 项跑在真实无头 DOOM 上(4 项仅在 DOOMFORMER_HEAVY=1 时运行,或需数分钟);CI 在 Linux 和 macOS 上运行整套测试
    docs/ 工作原理、每个 demo 及其数据、引擎事实、工具链指南、每条命令及示例输出
    CHANGELOG.md 每个版本新增的内容

    许可协议

    代码与文档:MIT。数据则沿用各自的许可协议(见THIRD_PARTY.md):关卡名称列表来自 Doom Wiki(CC BY-SA 4.0)和 id Software 的源码(GPL-2.0),100 个 MNIST 样本数字为 CC BY-SA 3.0,models/fly 中的果蝇神经回路衍生自 FlyWire 连接组(CC BY-NC 4.0,非商业用途)。Freedoom 图形素材采用 BSD 许可。DOOM 是 id Software / ZeniMax 的商标;本项目与二者无隶属关系。

    ——

    广告 广告
    🧑‍💻

    zhirenhun

    一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

    compiler doom esoteric-computing game-mechanics