RDNA3 · MoE · torch.compile · 实测于 2× RX 7900 XT

在 vLLM 的 --enforce-eager 下,全机最快的模型把 107.8 tok/s 记成 15,
顺手还造出两个结论

在 --enforce-eager 下测得 15 tok/s;对照双卡上的 torch.compile,两卡功耗对称,解码速率随上下文变化

早先的一次基准测试中,torch.compile 等了二十分钟后超时。加上 --enforce-eager 后测得约 15 tok/s,本仓库据此得出"MoE 在 ROCm 上不实用"的结论。 实际上,该模型完成引擎初始化需要二十六分钟。而在编译后,它在这台机器实测的五种架构中位居首位。

2× RX 7900 XT · gfx1100 · TP=2 gemma-4-26B-A4B · 128 专家 Zen 1 宿主 · 8 核 实测 2026-07-25

摘要

经编译后,一个 128 专家的 MoE 在 500 token 上下文下解码速率达到 107.82 tok/s, 在 32000 上为 72.80,领先这里测过的每一个稠密模型,包括参数量更大的 checkpoint。但在 --enforce-eager 下,同一模型测得约 15,而此前公开发表的判决正是以此为据。

避开 eager 模式需要付出代价:引擎启动耗时 1569 秒,其中加载权重占 54 秒。 在八核宿主机上,编译全程以单线程运行,因为 vllm/env_override.py 在 import 时 直接赋值 TORCHINDUCTOR_COMPILE_THREADS = "1"。由于这是硬编码赋值而非 setdefault, 在环境变量中 export 该变量完全不起作用。

7.2× 的吞吐损失还不是核心问题。eager 执行还制造了两个定性假象,且均被误判为架构特性: 两张卡之间 131 W / 65 W 的功耗落差,被解读为专家负载不均;而随上下文保持平坦的解码速率, 则被当成类似混合架构的扩展特性。编译之后,实测的 22 行数据在两卡间对称度处于 2.26 % 以内,两卡显存完全一致,且吞吐量在整条上下文阶梯上衰减了三分之一。

这两项错误结论此前均已在本仓库发布,随后被更正。提交给上游的修复仅有两行(见 vllm#53891 与 vllm#53892): 既保留单线程默认值,又允许通过环境变量明确覆盖。

1测的是什么

2026-07-25 的这轮 campaign 涵盖七组配置与八次引擎启动,在五个模型及两种并行度设置下累计完成 146 次计时生成。 预热完成后,服务端在数百 token 的生成区间内统计解码吞吐量;矩阵中的每格数据均取两轮测试的均值。 测试同时按卡采样板级功耗,记录该生成窗口内的峰值,并同步记录每张卡的显存占用。

init_engine_s 是引擎初始化,而非编译时间。它涵盖权重加载、profiling、 KV cache 分配、torch.compile 以及计算图捕获。权重加载耗时单独统计, 因此图 2 能够证明两次慢启动的耗时并非花在此处。本测试未能单独剥离出 Inductor 在剩余时间中的具体占比。

eager 的原始输出不在这个仓库里 后文列出的各项编译侧数据均由 benchmarks/results.jsonl 重算得出。eager 相关的测量则缺少已入库的原始记录: 它们出自更早的一次试探性运行,原始数据从未提交入库,仅作为引述文字保存在 docs/benchmarks.md 中。 本文直接从该文档提取这些数据而非重新抄录,以防二者产生分歧;但直接提取并不等同于由仓库重算, 文中的各处图表均已明确标注出处。决定了整个架构最终判决的关键数字,反而是本文中来源依据最薄弱的一个。

2被发表出去的那个判决

最初的探索性探测将该模型记录为"torch.compile 二十分钟没跑完 → 不实用 → 强制 --enforce-eager → 约 15 tok/s",并补充道"功耗严重失衡(131 W / 65 W), MoE 无法均匀吃满两张卡"。该模型随即被归入"受制于 ROCm 的架构"之列, 仓库架构表中"🟡 一般,约 15 tok/s"这一行挂了好几个月。

这套初始推理本身并无不合理之处。编译耗时二十分钟仍未结束,在工程直觉上确实等同于陷入停滞, 而启用 eager 模式正是官方文档推荐的规避手段。真正站不住脚的是后续的推断:误将 eager 模式报出的运行表现, 当作对该架构固有性能的衡量。

图 1 · 500 token 上下文下各架构的排名 可由仓库重算 eager 那条未入库
图 1. 阶梯最短一档上的解码速率,两轮均值,取自 benchmarks/results.jsonl。斜纹那条是同一个模型在 --enforce-eager 下的结果;它画得不一样,因为它是这张图里唯一一个无法从仓库重算的值。 排序之后,这个 MoE 在同一张榜上既在最前,也靠近最后。最下面那条混合 SSM 是另一个有两个成因的故事, 放在这里只是为了标出整条阶梯的位置。

12B 稠密模型也独立复现了同样的误判。早先的一次上下文测试同样借由 --enforce-eager 绕开编译耗时障碍,测出"平坦的 15.8 tok/s,与上下文无关,功耗在两卡间剧烈晃动"。 而经编译运行后,其速率达到 59.91,人为造成的异常平坦现象彻底消失。

3二十六分钟花在哪

图 2 · 这次 campaign 的每一次引擎启动 可由仓库重算
图 2. 全部八次启动,按运行顺序排列。其中两次超过二十分钟,四次不到两分钟; 剩下两次是 31B 和 27B,落在中间。而在那两次慢的上面,加载权重只占总时间的 3.4 % 和 0.4 %。 贵的那部分,不是模型从磁盘上读进来。 唯一被重复过的配置是 12B 在 TP=1 下,相隔八分钟启动了两次:59.67 s 然后 33.36 s。 这是整次 campaign 里唯一一组冷启动对热启动,而它不是那两次慢的之一。

编译之所以只占用单核,症结在于每次 import vllm 都会执行的一行代码:

vllm/env_override.py:105 · main 上今天仍是这样,2026-08-29 核过
os.environ["TORCHINDUCTOR_COMPILE_THREADS"] = "1"

代码采用了直接赋值而非 setdefault,导致外部环境变量中 export 的设定在 Inductor 读取前即被强行覆写。 无论调用方如何配置,只要 import vLLM,torch._inductor.config.compile_threads 都会被强制设为 1。 而 Inductor 原生的默认行为本应为每个核心分配一个 worker。

默认值本身不是问题 上游采用单线程编译是有意设计的,旨在规避 vllm#10480 与 vllm#10619 中记录的边界问题:编译子进程容易与调试器及特定启动路径发生冲突。探讨是否修改该默认值是另一层面的问题,并不是这里要做的。 本文反馈的问题要聚焦得多:该机制未向运维人员提供任何主动退出的途径。这种缺失并非刻意设计, 在尚未产生覆盖需求前,普通直接赋值与 setdefault 的写法差异极易被忽略。

4eager 编造的两件事

7.2× 的吞吐损失属于明显的测量缺陷,读者很容易识别并予以扣除。更深层次的错误在于,eager 模式制造了两个定性层面的伪结果,每一个都被误读为模型本身的架构属性,而非开关参数带来的副作用。

图 3 · 两个被编造出来的现象,对照编译之后的实测 可由仓库重算 eager 那行未入库
图 3. 编译之后每一档上下文的每卡板级功耗,数据与图 1 那条 MoE 出自同样 22 行; 两个刻度是两张卡,落在共同的 0–300 W 轴上。它们在每一档上都彼此重叠,最差相差 2.26 %, 而且每一行两卡显存完全相同。红色那行是当初推出"负载不均"结论所依据的 eager 报告,相差 50.4 %。 第二个视图是同一次运行的解码速率,它掉到短上下文值的 67.5 % —— eager 那条平线是开关,不是架构。

当时测得的功耗读数看似很容易用直觉解释,但该解释从根本上就是错的。在纯张量并行模式下,vLLM 会在每一张 GPU 上完整保留所有专家,并沿中间维度切分各专家的权重,因此无论路由逻辑如何分流,计算始终是对称的。 路由不均是专家并行的性质,而当时跑的不是专家并行。编译运行后功耗不对称现象彻底消失,正是对它的确认: 专家的实际执行方式并未发生任何改变。

5提给上游的是什么

改动仅有两行,且完全不影响默认配置:

vllm#53892 · 2 处新增,1 处删除,1 个文件
-os.environ["TORCHINDUCTOR_COMPILE_THREADS"] = "1"
+os.environ.setdefault("TORCHINDUCTOR_COMPILE_THREADS", "1")
上报
vllm#53891,2026-08-26, 标签 rocm:export 的 TORCHINDUCTOR_COMPILE_THREADS 被静默覆写。
提交
vllm#53892,同一天。 保留默认值 1,只有明确 export 才能穿过去。
验证
在 ROCm 7.14 / vLLM 0.23 镜像上,把变量 export 成 8 再 import vllm.env_override,得到 compile_threads == 8;不设时仍是 1。 若未打补丁,自定义 export 会被强行重置为 "1"。

6没有被确立的部分

编译产物真的被有效缓存

常有建议称 26 分钟的编译耗时可通过缓存均摊,但这次 campaign 无法证实这一点。 唯一被重复启动的仅有 TP=1 下的 12B 模型,冷启动 59.67 s、热启动 33.36 s,在一个本来就不是问题的启动上省了 1.79×。 那两个超过二十分钟的配置在这里都没有再启动第二次,所以贵的那一档热启动要多少,缺一次实测。 编译缓存在上游是真实存在的;缺的是它在这台宿主机上的一次测量。

1569 s 里有多少是 Inductor

指标 init_engine_s 综合了加载、profiling、KV 缓存分配、编译与计算图捕获。加载权重被单独分了出来,耗时极少。 剩下的未做细分,因此"编译要 26 分钟"是"引擎初始化要 26 分钟,而且不是权重"的简写。

每一组对比里 eager 的那一侧

eager 的速率和 131 W / 65 W 那次读数都没有写进任何入库文件。它们直接引自早期文档记录; 若要使其与其他实测数据具备同等置信度,需重新跑一次 --enforce-eager。 方向没有疑问;确切的倍数最多只有一位有效数字。

一台宿主,而且是慢的那种

基准测试宿主机为 Zen 1 Threadripper,为容器分配了八个核心。编译线程参数的收益随核心数量扩展,核心更多的系统可获得成比例的更大收益。由于补丁尚待上游采纳,打补丁后的编译耗时在此处未作直接测量。

7此后发生的变化

2026-07-25 · 判决被就地撤销 调大启动超时后编译顺利完成,架构表中的评价从"🟡 一般,约 15 tok/s"修正为"🟢 这里测过最快的"。 该修订在 docs/architecture-notes.md 中按日期记录,而非直接覆写原始条目, 因为此前的记录本身即具对照价值。
2026-08-26 · 已向上游报告并提交修复 见 vllm#53891 与 vllm#53892。 截至 2026-08-29 二者均处于开启状态;PR 具备合并条件且正等待 review,main 分支第 105 行依旧维持原有的直接赋值。
2026-08-29 · 本仓库曾有三个数字与自身数据不符 在撰写本文过程中,各项编译数据首次与 results.jsonl 进行了严格核对。 12B 模型的编译耗时在某份文档中被记为 23 分钟,在另一份文档中记作"TP=1 下 24 分钟"; 入库的值是 TP=2 下 1537.92 s,而 TP=1 只用了 59.67 s。 此前所谓"1538 s 冷、33 s 热"的数据对,实则是将 TP=2 的冷启动与 TP=1 的热启动放在了一起。 三处误差均已就地更正并标注日期,verify_doc_figures.py 现在把这次 campaign 的每一次引擎启动时间都钉住,让它们不会漂回去。
2026-08-29 · 本文曾记错一处统计数量 上面那一段的英文版写的是七次引擎启动。这次 campaign 实际包含八次启动,涵盖七个配置: 其中 12B 模型在 TP=1 下启动了两次,热启动那个数字正是从这次重复里来的。中文版原本是对的。 此处已就地更正;verify_doc_figures.py 始终在对照 model_meta 校验该计数, 散文是唯一一处把它重新抄了一遍的地方。
2026-08-29 · 图 2 图注对柱状图统计有误 原图注曾称有两次启动超过二十分钟,"另外六次均不到两分钟"。不到两分钟的是四次; 另外两次是 31B 和 27B,就是图上印着 227 s 和 504 s 的那两根,而否认它们的那句话就压在图的正下方。 已就地更正,verify_doc_figures.py 现在把这三个计数钉住,图注不会再和柱子对不上。

数据来源。测试原始数据记录于 benchmarks/results.jsonl, 每次计时生成记录一行解码速率、每卡功耗与每卡显存,另有每次引擎启动一行 model_meta。本页的图由 verify_doc_figures.py 从它重算;未入库的 eager 数据从 docs/benchmarks.md §1 提取。

上游议题:vllm#53891 与 vllm#53892。 默认值取 1 的理由见 vllm#10480 与 vllm#10619。