把 Qwen3.8-Flash-Next 塞进 128 GB 的 Jetson AGX Thor:从 9.5 到 33 tok/s 的完整复盘
一台 128 GB 统一内存的 Jetson AGX Thor T5000,一个 98.57 GiB 的 NVFP4 大模型, 一条没人走通过的路(sm_110 + 内联 NVFP4 的 PLE 表)。
结果是单流 9.53 → 33.23 tok/s(3.5 倍),KV 池从 36k token 扩到 446k token, 同时把多模态开了回来。下面把这台机器上真正发生的事、每条数字背后的算术、 以及踩过的每一个坑写清楚。所有数字都是本机实测,公开数据会单独标注。
2026-09-15 补记:原文定稿后又做完了三件事 —— ① 把 §14.3 那个"未量化的 bf16 输出头" 真的换成了本地造的 NVFP4 头(c=1 +15% / c=2 +25%,质量判分持平);② 上线了一个带 多 key、按 key 计量、按 key 排队限流的转发网关(8000 网关 → 8001 vLLM),并把 uncensored 权重挂上同一台服务;③ 把 agent 侧每轮的真实 token 开销量了出来 (ZeroClaw 16,009 token vs pi 1,073 token,差一个数量级)。 这三部分在文末新增的第十五 ~ 十七节,原文一字未改。
TL;DR
| 阶段 | 改动 | 单流 | 4 并发 | KV 池 | 权重+非 torch |
|---|---|---|---|---|---|
| 1 | eager 模式(起点) | 9.53 | 16.63(2 并发) | 5.61 GiB / 51,541 tok | 99.9 GiB |
| 2 | 关 eager → PIECEWISE CUDA 图 + 启动前 drop_caches | 24.61 | 36.60 | 6.87 GiB / 63,146 tok | 99.87 GiB |
| 3 | MTP1 投机解码 | 32.46 | 36.60 | 5.72 GiB / 36,096 tok | 101.18 GiB |
| 4 | PLE 表 26.82 GiB offload 出显存 | 33.23 | 43.09 | 22.91 GiB / 144,640 tok | 74.18 GiB |
| 5 | 上下文 4k→32k + 恢复多模态 | 32.87 | 41.64 | 18.78 GiB / 446,223 tok | 77.03 GiB |
| 6 | 并发上限 2→8、批预算 2048→8192 | 32.83 | 84.18(改写,c=4) | 17.5 GiB / 452k tok | 77.0 GiB |
| 7 | humming 原生 FP4/MXFP8 内核 + MTP3 | 32.18 | 129.08(改写,c=8) | 20.6 GiB / 392k tok | 76.1 GiB |
一句话结论:第 2 步是最大单项收益(2.58x);第 4 步不提升速度,它是解锁项 —— 不做它,KV 只剩 5.7 GiB,MTP 之后连多模态的 3 GiB 都塞不下。第 7 步是本篇唯一 "先验证机制、再被实测证实"的一条:把草稿层的 MoE 从 Marlin 权重解压换到原生内核后, K=3 从全面落后变成 c=1/c=8 的最好成绩。
一、硬件:Thor T5000 的真实底牌
先把宣传数字拆开。
2070 这个数字是怎么来的
T5000 datasheet(DS11945001)里 2070 TFLOPS = FP4(E2M1) + 2:4 稀疏,@MAXN 1.575 GHz。 注意是 TFLOPS 不是 TOPS,而且带稀疏。同门的 dense 数字:
| 精度 | 稀疏 | Dense |
|---|---|---|
| FP4 (E2M1) | 2070 TFLOPS | 1035 |
| FP8 | 1035 | 517 |
几何核算对得上:20 SM × 2560 CUDA 核 × 2 (FMA) × 1.575 GHz = 8.064 TFLOPS FP32。 实测运行时钟 1386 MHz(落在 120 W 功耗档)—— 所以日常跑不到 1.575 GHz。
真正决定 decode 速度的是带宽,不是算力
实测(torch 微基准,128 GB 统一 LPDDR5X):
| 指标 | 实测 | 官方理论 |
|---|---|---|
| 纯读带宽 | 193.6 GB/s | 273 GB/s |
| D2D 拷贝(读+写合计) | 253.9 GB/s | 273 GB/s |
官方 273 GB/s 是理论峰值,实测纯读只有 193.6 —— 差 29%。 这一条决定了后面所有的 估算:判断一个模型在 Thor 上能跑多快,用「每步活跃权重字节 ÷ 实测带宽」估上界, 不要用 TOPS。
tcgen05 确实能用(这是 sm_110 的关键疑点)
sm_110 是不是真 Blackwell 张量核?业界一直有争议(因为它是 sm_110 而非 sm_120)。 我们直接编内核验证:
- 编译期:PTX
.version ≥ 9.0下,sm_110a/sm_110f接受tcgen05.*指令; 基础sm_110、sm_120a、sm_121a全部拒收。 - 运行期:写了一个
tcgen05.alloc / relinquish / dealloc的裸内核,在真机上 launch + sync 全部返回cudaSuccess。 - 指令映射(反汇编自己的 cubin 得到):
| PTX | SASS |
|---|---|
tcgen05.mma.kind::f16 / kind::tf32 |
UTCHMMA |
tcgen05.mma.kind::f8f6f4 |
UTCQMMA |
tcgen05.mma.kind::i8 |
UTCIMMA(仅 sm_110a) |
tcgen05.alloc |
UTCATOMSWS |
tcgen05.commit |
UTCBAR |
tcgen05.cp |
UTCCP |
- 生态里的实际情况(扫已编译的二进制):
| 二进制 | 结果 |
|---|---|
| cuBLASLt 13.4 (aarch64) | 2 个 sm_110a cubin,其中一个含 32 条 UTCIMMA |
| FlashInfer JIT cache | 297 个 110a .so,其中 6 个含 tcgen05(fp4_gemm、fused_moe_100 等) |
torch libtorch_cuda.so |
59 个 sm_110a CUTLASS block-scaled FP4 GEMM |
vLLM 自己的 _C_stable_libtorch.abi3.so |
67 个 sm_110 cubin,0 个 sm_110a,无任何 UTC* 指令 |
最后一行值得记住:vLLM 官方 aarch64 二进制里没有任何 sm_110a 特化的内核, 所以它跑在这台机器上时走的是通用路径。这也解释了后面 MoE 的窘境。
但 tcgen05 对 dense 单流没用 —— 因为 dense decode 是带宽瓶颈(搬权重),不是算力瓶颈。 算力闲置是真,但把它用起来不会让单流变快。
二、模型:98.57 GiB 装不进 128 GB 的算术
Qwen3.8-Flash-Next-NVFP4:98.57 GiB,301,730 个张量,34 个分片。
结构:48 层(12 层 full attention + 36 层 linear attention/GDN)、512 专家 top-10、 hidden 2560、2 个 KV 头 × head_dim 256、MTP 头 1.49 GiB、vision 0.38 GiB。
每步到底要读多少字节
| 组成 | 常驻 | 每步活跃读取 |
|---|---|---|
| 路由专家(512 选 10) | 63.28 GiB | 1.24 GiB |
| lm_head + embed | 2.37 GiB | 2.37 GiB |
| GDN(线性注意力) | 2.01 GiB | 2.01 GiB |
| MTP 草稿层 | 1.49 GiB | 1.49 GiB |
| 其他主干 | 1.31 GiB | 1.31 GiB |
| full attention | 0.59 GiB | 0.59 GiB |
| 共享专家 | 0.23 GiB | 0.23 GiB |
| PLE 表 | 26.82 GiB | 每 token 抓 16 行 × 90 B = 1.4 KB |
| vision | 0.38 GiB | — |
每步活跃权重 ≈ 9.23 GiB(开 MTP)/ 7.74 GiB(不开)。
按纯读 193.6 GB/s 算:9.23 GiB ÷ 193.6 GB/s ≈ 51 ms/步。太慢?
不 —— MTP 每步能吐多个 token,所以真正的上界要看「每步产出 token 数 ÷ 步长」。
单流带宽上界大约 21 tok/s(MTP0 时)。实测 eager 9.53 → 利用率 45%,
而 Spark 上的公开实现约 70%。
内存墙(这一节是全篇的核心)
总内存 122.86 GiB (MemTotal)
启动时实际可分配 113.85 GiB
权重 96.8 - 97.4 GiB ← 98.57 GiB 的 checkpoint,加载后
KV ~71 KB/token
─────────────────────────
权重 + 最小可用 KV(9 GiB) = 106 GiB > 可用 113.85 减去宿主/驱动所需
实测结论:gmu(--gpu-memory-utilization)不存在可行区间。 0.92(KV 12.69 GiB)→ 引擎加载期死;0.89(KV 8.99 GiB)→ 死; 只有 0.86(KV 5.61 GiB)能起来。死亡方式是:
NVRM: GPU0 nvCheckOkFailedNoLog: Check failed: Out of memory
[NV_ERR_NO_MEMORY] (0x00000051) returned from _memdescAllocInternal(pMemDesc)
而且 EngineCore 是无 traceback 静默死亡的,日志里只有 API server 那句
RuntimeError: Engine core initialization failed。要看真因必须从特权容器里读 dmesg:
docker run --rm --privileged --entrypoint sh <IMAGE> -c 'dmesg | tail -20'
所以 PLE 表 offload 不是优化项,是前置条件 —— 26.82 GiB 的 N-gram 表常驻显存, 就是那堵墙本身。
三、四级提升,一级一级拆
第 1→2 级:关 eager、上 PIECEWISE CUDA 图(最大单项,2.58x)
起始配置为了稳妥开了 --enforce-eager(每层 ops 都不进图)。改成:
--compilation-config '{"cudagraph_mode":"PIECEWISE"}'
外加一条容易被忽略的前置动作:
sync; echo 1 > /proc/sys/vm/drop_caches
为什么需要 drop_caches:vLLM 启动时的内存检查把可回收的 page cache 算作已用,
于是报 Free memory on device (115.17/122.86 GiB) on startup is less than desired,
看起来像 gmu 设太高。清一下缓存(Cached 35 GB → 246 MB)就过了。
我一开始误判成"gmu 上限 0.937",白折腾了几轮。
结果:9.53 → 24.61 tok/s(三次 24.36 / 24.61 / 24.72,很稳),prefill 1.06k → 1.52k tok/s。 —— 顺手追平了公开 Thor 最好水平(Leibniz-HBI 的 23-24,而且他们那 24 是开了 MTP1 才拿到的)。
第 2→3 级:MTP1(+32%)
--speculative-config '{"method":"mtp","num_speculative_tokens":1}'
24.61 → 32.46 tok/s。但 K 不能随便加,见第五节。
第 3→4 级:PLE 表 offload —— 速度不变,容量翻 12 倍
这是本篇技术含量最高、也踩坑最多的一段。
背景:NVIDIA 官方的 /opt/qwen38-thor.patch 里塞了
Qwen4ExpPLENVFP4EmbeddingMethod —— 运行时把 PLE 表当成一个
合并的 320,001,536 行参数,在显存里两次 F.embedding 再反量化。
我们的 checkpoint 与公开配方不同:公开实现(blazux / MiaAI-Lab)假设 PLE 表是 独立的 FP8 分片文件、每行 160 字节;而我们这份是内联在分片 1~19 里的 NVFP4:
128 个头 × 2,500,012 行/头 × 90 字节/行
90 B = 80 B packed U8 权重 + 10 B F8_E4M3 尺度
总计 320,001,536 行 = 26.82 GiB
行空间进一步验证:checkpoint 里的 ngram_heads_offsets = [0, 20,000,003, 40,000,026, ...]
(16 个头,每头约 2000 万行),最大合法 id 320,001,446 < 总行数 320,001,536。
做法:自己做 mmap 按行抓取适配器(这也是比公开配方更优的一点:我们不下载新 checkpoint, 且 90 B/行 比 160 B/行 省带宽),三处 hook:
Qwen4ExpPLENVFP4EmbeddingMethod.create_weights→ 只分配 1 行占位(省 25.6 GiB + 3.2 GiB 尺度)Qwen4ExpNGramEmbedding.load_weights→ 跳过 256 个ngram_embedding.shard_*张量,记录布局Qwen4ExpPLENVFP4EmbeddingMethod.embedding→ 自定义 opvllm::ple_mmap_lookup_ids
PLE offload 的四个坑(每条都是 vLLM 自己 docstring 里写明的约束)
坑 1:光把 op 名写进 splitting_ops 不够。
第一次尝试(offload 生效了,Model loading took 71.73 GiB,确实省下 26 GiB)但崩在 CUDA 图捕获:
RuntimeError: Cannot copy between CPU and CUDA tensors during CUDA graph capture
unless the CPU tensor is pinned.
因为 op 里有一次 H2D 拷贝,被捕获进图了。正确做法是给 op 包上 vLLM 的装饰器:
from vllm.compilation.breakable_cudagraph import eager_break_during_capture
@eager_break_during_capture
def _impl(row_ids, out): ...
它做三件事:结束当前图段 → 在这个流上 eager 执行这个 op → 再开一个新段。
(另外注意:is_breakable_cudagraph_enabled() 依赖环境变量 VLLM_USE_BREAKABLE_CUDAGRAPH,
好在 NVIDIA 这个镜像默认是开的。)
坑 2:输出张量必须地址稳定 + 原地写 —— 否则引擎会静默死亡。
我一开始在每次调用里 torch.empty() 一个新输出张量。捕获用的地址和重放时的地址不一致,
图重放会写进已释放的内存,结果是:
Application startup complete. ← 起来 3 秒后
Process manager: send sigterm to process EngineCore
EngineDeadError: EngineCore encountered an issue.
没有任何 Python traceback(原生层崩溃),docker inspect 也看不出所以然。
修法是按尺寸缓存持久缓冲:
_BUFFERS: dict[tuple[int, int], torch.Tensor] = {}
def _out_buffer(device, n_rows):
key = (device.index or -1, n_rows)
if key not in _BUFFERS:
_BUFFERS[key] = torch.empty((n_rows, 90), dtype=torch.uint8, device=device)
return _BUFFERS[key]
坑 3:捕获期的假输入会产生越界行号,而官方 CUDA 路径根本不校验索引。
我们第一次成功捕获后报 KeyError: 3689274810368 —— 我算出的 shard 索引是 3.69e12。
反推发现 3689274810368 × 2,500,012 = 2^63,也就是 id 是个垃圾值(约 INT64_MAX)。
原因是图捕获用的 dummy batch 里,N-gram 行号是垃圾;而官方路径用 F.embedding(CUDA)
不检查越界,只是读到垃圾行、对 dummy 输出毫无影响,所以它从不报错 —— 只有我这边
Python 侧严格查表才发现。
修法干净:ids %= total_rows。合法 id 取模后不变,垃圾 id 被折进合法范围。
坑 4:op 名必须用这个镜像的真实注册名。
公开配方里写的是 vllm::qwen3_8_flash_next_ple_short_conv 之类,
但 NVIDIA 这个 Jetson 镜像里注册的是 qwen4_exp 命名:
vllm::qwen4_exp_ple_short_conv
vllm::qwen4_exp_qsa_with_output
照抄公开配方的话,splitting_ops 里这些名字一个都匹配不上,你会以为"写了没用"。
offload 的效果
权重 + 非 torch 97.0 GiB → 71.73 GiB
KV 池 5.72 GiB → 22.91 GiB(144,640 token,4k 上下文并发 35.3 路)
抓行开销 冷缓存 1.5~4.2 µs/行;解码每步 32 行 ≈ 可忽略
单流 32.46 → 33.23 tok/s(没有付出速度代价)
四、投机解码:接受率决定一切
MTP 的 K 值扫描(同配置,只改 K):
| K | 单流 tok/s | 4 并发 |
|---|---|---|
| 0 | 24.61 | — |
| 1 | 33.23 | 43.09 |
| 2 | 32.62 | 43.30 |
| 3 | 30.69 | 39.25 |
K=1 最优,K 越大越慢。 为什么?从 /metrics 拿到逐位接受率(K=3 时):
草稿次数 730 / 草稿 token 2190(= 730×3)/ 接受 1028
整体接受率 47.0%
逐位:位置 0 = 494/730 = 67.7%
位置 1 = 314/730 = 43.0%
位置 2 = 220/730 = 30.1%
算一下就明白了:
K=1 每步产出 = 1 + 0.677 = 1.68 个 token
K=3 每步产出 = 1 + 1.409 = 2.41 个 token ← 只多 1.43 倍
但 K=3 的成本 = 3 次草稿前向(读 1.49 GiB 草稿层权重 + 各自 KV)≈ 2 倍
→ 1.43x 收益 < 2x 成本,必亏
根因是逐位接受率衰减太快(68% → 43% → 30%)。
所以调投机解码别拍脑袋改 K,先读
vllm:spec_decode_num_accepted_tokens_per_pos_total,看衰减曲线。
后续在同一高可预测负载下的复核(K=1 vs K=3)
上面的 K 扫描是在混合负载上做的,于是又补了一轮代码改写负载(对投机最有利的场景) 和并发维度的测量,结论更硬:
| 负载 | 并发 | MTP1 | MTP3 |
|---|---|---|---|
| 代码改写 | c=1 | 30.48 | 25.10 |
| 代码改写 | c=4 | 84.18 | 79.55 |
| 代码改写 | c=8 | 117.01 | 108.30 |
| 散文 | c=4 | 70.30 | 71.00 |
| 散文 | c=8 | 100.26 | 98.68 |
即使代码改写负载的第一位接受率高达 85%,K=3 依然全面落后:
MTP1: 30.48 tok/s, 1.90 tok/步 → 16.0 步/s → 62.4 ms/步
MTP3: 25.10 tok/s, 3.19 tok/步 → 7.9 步/s → 127 ms/步
→ 每步 token 只涨 1.68 倍,步长却翻了一倍 → 净亏 18%
还顺便量到两件有意思的事:
-
接受率随并发下降(批内序列越多,越难预测):
代码改写 K=3 c=1 [0.852, 0.704, 0.630] → c=8 [0.764, 0.599, 0.511] 散文 K=3 c=1 [0.760, 0.442, 0.250] → c=8 [0.675, 0.399, 0.215]
-
为什么 Spark 上 MTP3 能赚、在我们这里不能:每步的 token 产出双方差不多量级, 但我们每步的草稿成本更高 —— 日志显示草稿层的 MoE 走的是 MARLIN 权重解压 (见第十节 P1-5),而 Spark 的 12.x 家族有可用的快速内核。也就是说这不是负载问题, 是草稿前向的相对开销问题。
换成原生内核之后,K 的最优点跟着并发布移动(最终版扫描)
把草稿层的 MoE 从 Marlin 换到 humming 之后重扫一遍,并且这次用步长而不是 tok/s 来比较:
| K | c=1(多次) | token/步 | 步长 | c=8(多次) | KV 池 |
|---|---|---|---|---|---|
| MTP1 | 33.99 | 1.90 | 55.9 ms | 116.4 / 117.0 | 450k |
| MTP2 | 33.93 / 34.25 | 2.31-2.41 | 67.5-71.1 ms | 118.6 / 130.5 | 493,244 |
| MTP3 | 36.89 [28.7-37.1] | 3.07 | 83.2 ms | 124.5 / 129.1 / 129.3 | 450k |
拿三点做一个线性模型,误差只有 3%:
步长 = 42.3 ms(基础:读 7.74 GiB 主干) + K × 13.7 ms(每次草稿前向)
基础段 42.3 ms vs 物理下界 7.74 GiB ÷ 194 GB/s = 39.9 ms → 利用率 94%
草稿段 13.7 ms vs 物理下界 1.49 GiB ÷ 194 GB/s = 7.9 ms → 每次白花 5.8 ms
验算:MTP2 预测 42.3 + 2×13.7 = 69.7 ms,实测 67.5 ms
这就把"K 的最优点为什么会移动"讲清楚了:单流时草稿前向永远是 M=1 的小 GEMM(读 1.49 GiB 却只算一个 token,带宽吃不满,13.7 ms 里 5.8 ms 是浪费),所以 K 越大越亏; 多流时批内序列多、草稿前向的 M 变大、效率上来,K=2/3 才开始赚。
选型规则:
多路 agent / 并发服务 → MTP2 或 MTP3 都行(c=8 两者噪声内相当:118-130 对 124-129),
但 MTP2 的 c=1 更稳(33.9-34.3)且 KV 池最大(493k)
单流交互(一个人写代码)→ MTP3 可以再搏 8%(36.89),代价是抖动到 [28.7-37.1]
能确立的只有:MTP1 在 c=8 明显低 8-10%
顺带一个测量陷阱:别用 SSE chunk 数当 token 数。vLLM 会把一步内接受的所有 token
打包进同一个 delta,chunk 数其实是步数,这样测会得出"接受率 97% 但速度只有 1 tok/s"的荒谬结论。
永远用 usage.completion_tokens。
五、内存墙的正确解法:显式留出宿主余量
offload 成功后我犯了一个新错误:腾出来的 26 GiB 被 vLLM 的 KV 自动分配一口吃光了。
gmu 0.88 的预算 108.12 GiB
权重 + 非 torch 74.22
激活峰值 1.17
CUDA 图 0.59
KV(自动分配的!) +32.73
──────────────────────────
合计 108.71 GiB(顶到预算)
启动时可用 113.85 GiB → 宿主只剩 ~5 GiB
→ 又一次 NV_ERR_NO_MEMORY,引擎静默死亡
这和 MiaAI-Lab 仓库里警告的失败模式完全一样("KV 给太多、宿主只剩 6.9-8.8 GiB,驱动开始拒绝分配")。
正确做法:显式留宿主余量。 gmu 降到 0.80:
预算 98.29 GiB
权重+非torch 74.18 / 加多模态后 77.03
激活峰值 1.20 / 2.48
CUDA 图 0.61 / 0.38
KV 22.91 / 18.78
→ 宿主保留 ~15-17 GiB
注意一点:mmap 的 PLE 表需要 page cache 才便宜,所以饿死宿主不只是稳定性问题, 也是性能问题。
一个反直觉的副产品
把 --max-model-len 从 4096 提到 32768,KV 的 GiB 没涨(22.91 → 18.78,因为改用大 block),
但 token 容量翻了 3 倍(144,640 → 446,223)。原因是 vLLM 为对齐 mamba page 会调整
block_size(4k 时 1568,32k 时 1600),padding 开销变小。
同时"恢复多模态"的真实代价不是 4 GiB 而是 ~3 GiB(编码器缓存),却换来了更大的 token 容量和可用的视觉输入。这笔交易很划算。
六、其他坑(速查)
| 坑 | 症状 | 解法 |
|---|---|---|
--kv-cache-memory 单位是字节 |
传 2 → KV 池变 0,报 No available memory for the cache blocks,看起来像模型装不下 |
不设它,或用字节数;它还会让 vLLM 跳过 memory profiling 并忽略 gmu |
model_type 不匹配 |
The checkpoint ... has model type 'qwen3_8_flash_next' but Transformers does not recognize this architecture |
--hf-overrides 只改 architectures 不够(model_type 先被解析);要单文件挂载修正后的 config:-v /tmp/fixed.json:/model/config.json:ro |
| 代理污染 localhost | 健康检查全变 502/000,脚本白等 20 分钟 | 会话里 export 过 http_proxy 会持久化;测本地端口前必须 unset http_proxy https_proxy + export no_proxy=127.0.0.1,localhost |
| llama.cpp + CUDA 13.2 | 高 -ngl 下输出乱码、无报错 |
Thor 上用 CUDA 13.0.x 工具链构建(13.2 在 sm_110 上有 issue);官方无 aarch64 CUDA 预编译 |
| 静默死亡的引擎 | EngineDeadError 但没有 traceback |
从 --privileged 容器读 dmesg,找 NV_ERR_NO_MEMORY / Xid |
| Docker 镜像拉取 | 无国际出口 | 走 ghcr.nju.edu.cn 镜像(digest 一致);HuggingFace 走 hf-mirror.com;ModelScope 直连 |
七、评测的坑(如果有做模型对比,这些会各花掉你一小时)
用 lm-eval 对两个同规模模型(27B dense / 35B-A3B MoE)做能力对比时踩的坑:
- 思考模型返回空
content。 带 reasoning parser 时答案落在reasoning字段, 而 lm-eval 只读content→ 每条都是 null(API returned null content)、全 0 分假象。 评测时必须--default-chat-template-kwargs '{"enable_thinking": false}', 并且先看一条响应的 content 非空再信分数。 - lm-eval 接本地端点用的是
auth_token=而不是api_key=。 实际 header 从OPENAI_API_KEY环境变量拼 —— 401 且 header 显示'Bearer '就是环境变量没设。 还有个坑:脚本里export OPENAI_API_KEY=$KEY写在KEY=$(cat ...)之前,会静默拿到空 key。 - HumanEval 对聊天模型基本不可用(as shipped)。 任务自带
until=['\ndef','\nclass',...],模型一写出def就被切断,得到 40-70 字符残片和 ~0 分。 要么用humaneval_instruct并且覆盖停止串 (--gen_kwargs "until=[] max_gen_toks=1400"),要么自己做执行判分。 --limit N是按子任务算的。mmlu_pro有 14 个子科目,--limit 200= 2800 次请求(几小时)。--limit 15→ 210 题,够看趋势。- GPQA 是 gated 数据集,没 HF 授权会直接
DatasetNotFoundError。 - 重跑前一定清掉/改名旧的
--output_path日志 —— 上一次失败留下的samples_*.jsonl和一次成功的结果长得一模一样。 - 还有一条不算 lm-eval 的锅:GSM8K 的 samples 每个问题存两遍 (strict-match / flexible-extract 两个 filter 各写一次),统计错题时会虚高一倍 (我看到 20/200 实际是 10/100)。
八、别人的工作:链接、数字,以及我们用了他们什么
这条路不是没人走过 —— 恰恰相反,前后有十几个项目。先把原文和数字列清楚, 再说我们站在谁的肩膀上。
| 项目 | 平台 | 关键数字 | 我们采用了什么 |
|---|---|---|---|
| Leibniz-HBI/Qwen3.8-Flash-Next-Jetson-Thor-vLLM | Thor | eager MTP0 9.5 → PIECEWISE MTP0 19.9 → PIECEWISE MTP1 23-24 | 最完整的一份 Thor 配方;我们逐档复现并超过(9.53 → 24.61 → 32.46);其 docs/LIMITATIONS.md 与我们的独立发现高度一致(见下文"对得上的部分") |
| rittim/qwen38-flash-next-thor-vllm | Thor | 单流 ~20 / 2 路 ~31 / 4 路 ~51 聚合;llama.cpp 15-17 | 把 PLE 抓行注册成自定义 op 并放进 splitting_ops 的机制(dockerfile-sm110.patch);这是我们从"只改 mmap"到"能进图"的关键线索 |
| blazux/qwen3.8-Flash-DGX | Spark | 单流 17.1 / 8 路 87.5 / 16 路 131.6 / 32 路 212.0 | PLE 表 mmap 的原始实现(vllm_ple_mmap.py);但它的布局假设是独立 FP8 分片、160 字节/行,我们的 checkpoint 是内联 NVFP4、90 字节/行,所以适配器必须重写 |
| MiaAI-Lab/Qwen3.8-Flash-Next-Single-DGX-Spark | Spark | 单流 48.7 / 2 路 74.6 / 4 路 113.7 / 8 路 162.9;8k prefill 2,200 tok/s;512k YaRN + fp8 KV | 内存预算的思路(PG 显式留宿主余量、KV_TARGET_GIB 而非 gmu 自动分配)—— 我们正是在它警告的那一步(KV 吃满 → NV_ERR_NO_MEMORY)上摔了一次 |
| thomas-hiddenpeak/qwen4-thor | Thor | 原生 C++/CUDA 引擎 + PLE SSD Stream(io_uring 从 NVMe 流式读,释放 47.6 GiB) | 佐证"把 PLE 表放到盘上"是共识路线 |
| UsamaKenway/Qwen3.8-Flash-Next-AGX-Thor-sm_110 | Thor | — | 仓库名本身就是"Thor = sm_110"这条线索 |
| maneeshdisodia/thor-hacks · VitalyAnkh/thor-sm110-microbench | Thor | sm_110 张量核能力矩阵、2:4 稀疏事实 | 帮助我们确认"硬件在、软件不在"这一判断 |
| NVIDIA 开发者论坛 381500 (另有 381497) | Spark/Thor | 离线 PLE 表的讨论 + QSA top-k 非确定性的根因与修复;prefill 2360→1760 | 非确定性那条对应 vllm#51782(atomicAdd 槽位) |
| kubesimplify 博客 | Spark | llama.cpp + Unsloth GGUF(唯一量化 N-gram 表的构建)34.5 tok/s,67.55 GiB | 另一条技术路线(GGUF,不走 vLLM) |
NVIDIA 官方镜像内的 /opt/qwen38-thor.patch |
Thor | Qwen4ExpPLENVFP4EmbeddingMethod —— 运行时把 PLE 表当合并的 3.2 亿行参数,两次 F.embedding + 反量化 |
我们 offload 要绕开的正是这个实现 |
和我们的实测完全对得上的部分
这种"独立复现出同一组数字"的吻合,比任何单方面的宣称都可信:
- Leibniz 的 eager MTP0 = 9.5 tok/s,我们独立实测 9.53 tok/s(同配置口径)。
- 他们的
LIMITATIONS.md第一条就是"原生 SM110 NVFP4 MoE 是最大的性能缺口", 与我们扫二进制得出的结论一致(CUTLASS grouped-MoE 在 sm_110 上status=7)。 - 第二条"融合版 MTP GDN 内核在 SM110 上无法执行,只能
VLLM_GDN_DECODE_KERNEL=triton" —— 我们遇到的是同一个内核缺口。 - 第三条"QSA cooperative Top-K 在 SM110 上会以非法 cluster 几何启动,配方把它改成
persistent Top-K" —— 这正是我们镜像里
persistent_topk的由来(也是非确定性风险的来源)。 - 第五条"PLE mmap 仍有 host/device 同步开销" —— 我们在每步都能量到那次同步。
一个必须先纠正的常见误解:Spark 和 Thor 不是同一档芯片
网上(包括我最初的理解)常把 DGX Spark 的 GB10 和 Jetson AGX Thor 当成"同一种 sm_110"。 这是错的,而这个错误会让所有结论都跑偏:
| Jetson AGX Thor | DGX Spark (GB10) | |
|---|---|---|
| Compute capability | 11.0 / sm_110(CUDA 13 前叫 sm_101) | 12.1 / sm_121 |
| 张量核档次 | 数据中心级,有 tensor memory(tcgen05) | 消费级,没有 tcgen05 |
| 矩阵吞吐 / 向量吞吐 | 矩阵约 2x Spark,向量约 1/3 Spark | 反之 |
| 内存 | 128 GB LPDDR5X 统一内存,~273 GB/s | 128 GB 同规格 |
来源:arnon.dk 的 arch 对照表、 conda-forge 的 GPU 归类讨论 ("Jetson Thor is sbsa+sm110/11.0 … DGX Spark (GB10) is sbsa+sm121/12.1")、 Level1Techs 论坛的对比。
为什么这件事很关键:整个 CUDA 生态(flashinfer 的 b12x 系列、大量 CUTLASS GEMM)
是为 12.x 消费级家族编译的,因为那才是有量的设备;而 Thor 的 sm_110 是一条
只有它自己的架构线(CUDA 13 才把 sm_101 改名 sm_110)。
结果就是本文反复出现的那句话:Thor 的硬件更强(有 tcgen05、矩阵吞吐 2 倍),
但生态里没有它的内核。Spark 的 48.7 tok/s 是在没有 tcgen05 的芯片上、
靠一套能跑的内核 + 更深的投机解码拿到的。
九、我们走到了哪一步,以及为什么没到人家的速度
走到了哪一步
| 参照 | 他们 | 我们 | 结论 |
|---|---|---|---|
| Leibniz(Thor)逐档 | 9.5 → 19.9 → 23-24 | 9.53 → 24.61 → 32.46/33.23 | 三档全部超过 |
| rittim(Thor)单流 | ~20 | 33.23 | 超过 |
| Leibniz(Thor)PIECEWISE+MTP0 | 19.9 | 24.61 | 超过 23.6% |
| blazux(Spark)单流 | 17.1 | 33.23 | 跨平台比较,但高出近一倍 |
| MiaAI-Lab(Spark)单流 | 48.7 | 33.23 | 还差 1.47 倍 |
| MiaAI-Lab(Spark)8 路聚合 | 162.9 | 43.09(4 路,且被限流) | 差距最大的一项 |
| 容量 | — | KV 446k token / 权重 72-77 GiB / 宿主余 15-17 GiB | 比公开配方更宽裕 |
一句话:在 Thor 这个平台上,逐档我们都在最前面;没达到的只有 Spark 那两个数字。
为什么没到 —— 三条归因,按证据强度排序
① 我们把自己限在了 2 路并发(这是我引入的瓶颈,不是硬件限制)。
启动脚本里写的是 --max-num-seqs 2 —— 这是早期权重 97 GiB、KV 只剩 5.7 GiB 时为省内存设的上限。
后来 PLE offload 腾出 26 GiB、KV 涨到 446k token 之后,我忘了把这个上限提上去。
所以本文所有"4 并发"测量实际上是被压成 2 路跑的:
4 请求 / 43.09 tok/s 聚合 → 实际是 2 路并行,每路 ~21.5 tok/s
对照 MiaAI 的 8 路 162.9(每路 20.4)—— 我们的"每路"效率其实和它同一水平, 差的只是并行路数被自己锁死了。
② 单流已经贴在内存带宽屋顶上,所以内核优化在单流上根本不会有效果。
这是本文最该被记住的一段算术:
MTP1 每步产出 = 1 + 接受率(位置0) = 1 + 0.677 = 1.68 token
33.23 tok/s ÷ 1.68 tok/步 = 19.78 步/秒 → 50.6 ms/步
每步活跃权重 9.23 GiB ÷ 50.6 ms = 196 GB/s
实测纯读带宽 = 193.6 GB/s
→ 利用率 ≈ 100%
也就是说:每步要读的字节已经被读满了。 单流唯一的杠杆不是"算得更快", 而是每步多产出 token(更深的投机解码)或每步少读字节(更激进的量化)。 这解释了为什么把 K 从 1 加到 3 没用(接受率衰减使每步产出只从 1.68 涨到 2.41,成本却翻倍), 也解释了为什么"补上 FP4 MoE 内核"不会让单流变快 —— 单流根本不在算力限制区。
对照:MiaAI 单流 48.7 tok/s
若按接受长度 2.8-3.0(MTP3)算 → 每步产出 ~3.8-4.0 token → 约 12.2 步/秒 → 82 ms/步
他们每步比我们慢(82 vs 50.6 ms),但每步产出是我们的 2.3 倍
→ 快的是"每步产出 token 数",不是"每步算得快"
但这里有个必须说明的口径问题:他们那个 2.8-3.0 的接受长度很可能来自高可预测负载 (代码改写/重复文本),而我们报的 47% 整体接受率来自混合散文基准。 同一份模型上我们早前在 EDIT-heavy 负载测到过 97.7% 的接受率 —— 投机解码的收益完全由负载决定,两边的数字不构成同口径对比。 想公平比较, 必须用同一组提示词重测。
③ prefill / 高并发是真正的差距所在,而那里确实是缺内核。
我们 prefill ~1,516 tok/s(2.5k 输入);长上下文衰减到 ~800 tok/s(20k 输入)
MiaAI prefill ~2,200 tok/s(8k 输入)
prefill 是算力限制的(不像 decode 是带宽限制),而这里正好是内核短板所在。 我们的启动日志把内核分配说得非常清楚(原文摘录):
[nvfp4.py:304] Using 'VLLM_CUTLASS' NvFp4 MoE backend ... ← 目标 MoE:原生 FP4 ✓
[__init__.py] Using MarlinNvFp4LinearKernel for NVFP4 GEMM ← 线性层:Marlin 回退
[__init__.py] Using MarlinMxfp8LinearKernel for MXFP8 GEMM ← 线性层:Marlin 回退
[marlin.py:34] Your GPU does not have native support for FP4 computation but FP4
quantization is being used. Weight-only FP4 compression will be used
leveraging the Marlin kernel. This may degrade performance for
compute-heavy workloads.
[nvfp4.py:304] Using 'MARLIN' NvFp4 MoE backend ... ← 草稿层 MoE:Marlin 回退
也就是说:目标 MoE 的专家层其实拿到了原生 FP4,真正被降级的是 线性层(NVFP4 / MXFP8 GEMM)与草稿层(MTP)的 MoE —— 它们跑的是 Marlin 的 "权重解压后再算"(日志原话 weight-only FP4 compression,并明确警告会拖慢 compute-heavy 负载)。prefill 和高并发正是 compute-heavy 区间,所以这条差距 是真实且可归因的。
那"别人说的 41 tok/s"到底怎么来的
同类硬件(GB10/Spark,同为 273 GB/s 规格、无 tcgen05)上的独立报告给的就是这个区间 —— NVIDIA 开发者论坛的 Flash-Next 长帖里:
"34 to 42 tok/s decode with the MTP head",262K 原生上下文 + vision + 前缀缓存(单台 GB10)
"35 t/s with no degradation over context length"(FP8 + 750k KV,但 agentic 负载只有 15-17)
双 Spark FP8 MTP=3:tg1024 @ d4096 = 35.02 t/s
所以在 Thor 上我们实测的步长拆解,可以把这个 41 讲得很清楚。 关键实测:步长与负载无关。
| 负载 | 单流 | token/步 | 步长 | 每步带宽 |
|---|---|---|---|---|
| 高可预测(重复改写代码) | 36.15 | 3.08 | 85.1 ms | 121 GB/s |
| 低可预测(自由写作) | 27.49 | 2.37 | 86.2 ms | 120 GB/s |
| 对照:MTP1 + Marlin(贴顶那条) | 33.23 | 1.68 | 50.6 ms | 196 GB/s ≈ 实测纯读的 100% |
两种负载的步长几乎一样(85.1 vs 86.2 ms),所以单流速度 = token/步 ÷ 85 ms:
41 tok/s 需要 41 × 0.0851 = **3.49 token/步**
我们最好的负载拿到 3.08 → 36.15 tok/s,是 41 的 88%
MTP3 时每步只吃掉 121 GB/s,而实测纯读是 193.6 GB/s → **步长还有约 40% 余量**
差距分两块,而且都有明确的下一步:
- 命中率(负载相关):3.08 → 3.49 之间的部分靠更强的 drafter,或更适合投机的负载。
- 步长本身:把每步的字节拆开算,就能看清 85 ms 是怎么来的。
步长拆解(这里纠正了一个流传很广的说法)
先纠正一处:网上(包括 Leibniz 的 LIMITATIONS)说"融合版 GDN MTP 内核在 SM110 上不可用、 只能退回 Triton"。在我们这套 build 上不成立 —— 启动日志明确写着:
INFO [qwen_gdn_linear_attn.py:528] GDN decode kernel: cuda
这段门禁(_fused_gdn_decode_unsupported_reason)只要求 BF16 + K=V=128 + compute capability ≥ 8.0,
sm_110 轻松通过。所以"单流慢是因为 GDN 回退"这个解释是错的。
真正的账是草稿层被读了 K 遍:
MTP1 每步读 = 主干 7.74 GiB + 草稿层 1.49 GiB × 1 = 9.23 GiB
理论 9.91 GB ÷ 193.6 GB/s = 51.2 ms,实测 50.6 ms → **100% 带宽饱和**
MTP3 每步读 = 7.74 GiB + 1.49 GiB × 3 = 12.21 GiB
理论 13.11 GB ÷ 193.6 GB/s = 67.7 ms,实测 85.1 ms → **多出 17.4 ms**
多出来的 17 ms 是草稿前向的小批量低效:草稿是自回归的(第 n 个 token 依赖第 n-1 个), 单流时永远是 M=1 的 GEMM —— 要读 1.49 GiB 权重却只算一个 token,带宽吃不满。
这解释了 MTP3 为什么在多流下才显出优势:
c=1(草稿 M=1,低效):MTP3 32.18 vs MTP1 33.23 → 基本打平
c=8(草稿 M=8,高效):MTP3 129.08 vs MTP1 117.01 → +10%
所以选型的规则很简单:单流自用 → MTP1/MTP3 差别不大;要多路并发 → 必须 MTP3 + 原生内核。
结论:单流不是"机器不行",而是每步的字节数(草稿层读 K 遍)与那份文本的命中率决定的。 想再往上走,两个方向:让草稿前向更省字节(更激进的量化/共享),或者提高命中率。 另外别把 RTX PRO 6000 的 180-226 tok/s 拿来比 —— 那是 1.8 TB/s 的显存,完全不同档。
想够到 41 的最后两条路(都试了,都堵死)
① 换更强的 drafter —— 没有可用的。 吞吐瓶颈既然在"每步 token 数",最直接的办法就是换接受率更高的草稿器。
查下来:RadixArk/Qwen3.8-27B-DSpark(1.86B draft,配 SGLang 的 DSPARK)确实存在,但那是 27B 的;
Flash-Next 没有公开的 DSpark/EAGLE speculator(社区也在问同样的问题,答案都是"先等官方 EAGLE 头")。
而且 vLLM 只对 config.json 里带 dspark_* 块的 checkpoint 支持 dspark —— 现有 Flash-Next 权重没有这个块。
→ 除非自己训一个 speculator,这条路走不通。
② ngram(prompt-lookup)投机 —— 被 QSA 的 ring capacity 断言挡死。 理论上它最合适:不需要任何草稿权重、不需要草稿前向,每步字节从 12.21 GiB 回到 7.74 GiB (步长 ~50 ms 而不是 85 ms),而"改代码 / 重写文件"这类负载本来就是大量复制上下文, 正是 prompt-lookup 的甜点区。实测直接崩在启动:
AssertionError: QSA ring capacity 12 must divide the attention block size 1616
根因(读 models/qwen4_exp/common/qsa_cache.py 得到):
span = compress_ratio + num_speculative_tokens # 6 + 5 = 11
capacity = compress_ratio * ceil(span / compress_ratio) # 6 * 2 = 12
assert block_size % capacity == 0 # 1616 % 12 != 0 → 挂
ngram 会强制切到 V1 model runner(日志原话 "Model Runner V2 does not yet support ngram/ngram_gpu speculative decoding"),而 V1 算出的 block_size=1616 不能被 12 整除;MTP 路径下是 1584,能整除,所以没事。 → 这是 V1 runner 与 QSA 的 block-size 协商不兼容,不是配置写错。
③ 但还有第三条路,而且是做完 kernel 级归因才发现的(见第十四节):
输出投影 lm_head 是 bf16、1.27 GB,且每步被读 4 次(1 次目标验证 + 3 次草稿,
草稿复用同一份权重)。仅这一项就是 20.3 ms/步 = 单步的 24%。
实测把草稿那 3 次换成 FP8 副本后,步长稳定降 7.5-10 ms(83 → 74.8 ms)——
按此推算,若量化粒度做得够细(按 K 分块,MXFP8 风格)使接受率不受影响,
同负载单流就是 约 40 tok/s。这是目前唯一一条既不改内核、也不训模型的路。
小结
单流 → 带宽与步长层面已到顶(MTP1 达实测纯读带宽的 100%);提速只能靠"每步多出 token"。
MTP3 每步多出的 ~17 ms 是草稿前向的小批量低效(M=1 的 GEMM 吃不满带宽)
并发 → 曾被我们自己的 max-num-seqs=2 锁死(人为差距,已解除:43 → 129 tok/s)
预填充/高并发 → 真实的算力缺口,只能靠补内核(humming 原生内核是第一个进展,但只拿到持平)
投机深度 → 一旦草稿层离开 Marlin,K=3 从"全面落后"变成最好(+28% @c=1、+19% @c=8)
跨平台比较 → Thor(sm_110, 有 tcgen05) vs Spark(sm_121, 无 tcgen05),同带宽级但不是同一档芯片
十、接下来做什么
按"性价比"排序,不是按"听起来高级"排序。
P0 — 配置层(今天就能做,预期收益最大)
- 解除并发上限 —— 已实测完成:
--max-num-seqs 2 → 8、--max-num-batched-tokens 2048 → 8192。结果(同机同模型):
| 单流 | c=4 聚合 | c=8 聚合 | |
|---|---|---|---|
| seqs=2 / batched=2048 | 32.87 | 43.09 | 测不了(被限流) |
| seqs=8 / batched=8192 | 32.83(不变) | 70.30 / 84.18(散文/代码改写) | 100.26 / 117.01 |
单流零代价(符合"已贴带宽屋顶"的结论),聚合吞吐 2.3-2.7 倍。
KV 池有 415k token 容量(32k 上下文 12.7 路并发),完全支撑得起。
这一项同时校正了我此前所有"并发"数字 —— 它们其实都是 2 路的结果。
2. 用同口径负载重测接受率:准备两组提示词(高可预测的代码改写 / 混合散文),
分别测 K=1/2/3。现在的 47% 只代表混合负载,不能用来判定 K 的最优值 ——
在 EDIT-heavy 负载上 K=16 曾在别的模型上从 12.3 干到 66 tok/s。
3. 显式控制 KV 目标(--kv-cache-memory 或用 KV_TARGET_GIB 的思路),
把"自动分配吃满"变成"留出指定余量";省下的显存换更长上下文或更深的图。
P1 — 内核层(按实测日志重排,工作量最大)
- 把线性层从 Marlin 换到原生 FP4 CUTLASS。证据:
MarlinNvFp4LinearKernel for NVFP4 GEMM与MarlinMxfp8LinearKernel for MXFP8 GEMM,而 Marlin 自己警告 "weight-only FP4 会拖慢 compute-heavy 负载";同时 torch 自带 59 个 sm_110a 的 CUTLASS block-scaled FP4 GEMM。 影响每一层每一步,收益主要在 prefill 与高并发 —— 单流基本无感,别指望它提升 33。 - 让草稿层的 MoE 脱离 Marlin。日志里
NvFp4 MoE backend被选了两次:目标 MoE =VLLM_CUTLASS,草稿层 =MARLIN。草稿层的 Marlin 开销正是让 K>1 亏本的那笔成本 (见第四节),把它修好可能让更深的投机重新变得划算。
先记下一个已排除的捷径:直接强制 --moe-backend cutlass 行不通,实测启动即失败:
ValueError: NvFp4 MoE backend 'VLLM_CUTLASS' does not support the deployment
configuration since kernel does not support quantization scheme
QuantKey(u8, scale(f8e4m3fn, static, GroupShape(row=1, col=16)))
注意这个报错的含义:降级不是"忘了打开一个旗标",而是那个量化方案在这套 build 里
根本没有对应内核 —— auto 的选择已经是候选列表里能拿到的最优组合
(候选池:FLASHINFER_TRTLLM / FLASHINFER_CUTEDSL / FLASHINFER_CUTEDSL_BATCHED /
FLASHINFER_CUTLASS / VLLM_CUTLASS / MARLIN / HUMMING / EMULATION)。
要改善只有两条路:补内核,或者换量化方案(例如把 MXFP8 那部分requantize成
有内核的方案)。这解释了为什么这个坑在社区里挂了很久 —— 它不是配置问题。
6. 试 Marlin 自己提示的开关:VLLM_MARLIN_USE_ATOMIC_ADD=1 —— 已实测,不采纳。
日志原话是 "can achieve better performance for small size_n",听起来正对草稿层和小批量
解码的形状,但在这台机器上(代码改写负载,同一提示词):
| c=4 聚合 | c=8 聚合 | |
|---|---|---|
| 不开(基线) | 84.18 | 117.01 |
VLLM_MARLIN_USE_ATOMIC_ADD=1 |
74.44 | 103.05 |
两个档位都慢约 12%,而接受率还略高一点(0.85/0.79 vs 0.83/0.75),说明是内核本身
变慢了,不是统计抖动。散文负载上结果混合(c=4 慢、c=8 快),不足以翻盘。
教训:框架自己打印的性能建议也要实测,它是在另一套形状/另一代架构上得出的。
7. 解开 FlashInfer cuTe-DSL MoE 在 sm_110 上的门禁:flashinfer_cutedsl_moe.py:81
只放行 is_device_capability_family(100),而候选 backend 列表里 FLASHINFER_CUTEDSL
是存在的 —— 也就是说可能只差一个能力判断就能多拿一档。
8. (已修正)不是"编译 GDN 内核":实测日志显示 GDN decode kernel: cuda,融合内核在
sm_110 上本来就启用(Leibniz 的 LIMITATIONS 第 2 条只适用于他们那份 pinned build)。
真正的杠杆是减少草稿层的每步字节:MTP3 时草稿层权重 1.49 GiB 被读 3 遍,
且单流下是 M=1 的小 GEMM(实测每步多出 ~17 ms)。方向:草稿层更激进量化、
或让草稿前向在多流下天然批量更大(这已经是 c=8 时 MTP3 领先的原因)。
9. 修 QSA cooperative Top-K 在 SM110 上的 cluster 几何(第 3 条)。
顺带能去掉 persistent_topk 这条非确定性风险源。
10. 排查 Thor 上 PLE GPU gather(ATS)的非法访问。修好后抓行可留在 GPU 侧,
省掉每步 host/device 往返(第 5 条指向同一处)。
P2 — 容量层(如果目标是超长上下文)
- fp8 KV:KV 显存减半 → 同显存下 token 容量约 2 倍(MiaAI 用它做到 512k 上下文 + 8 路)。
- 更激进的 PLE 常驻/量化:把表的一部分按热度常驻显存,兼顾延迟与容量。
一句话
单流已经到底了(100% 带宽利用率),接下来的收益全在"每步多产出 token"和
"并发/预填充的算力侧"—— 前者靠投机解码的负载匹配,后者靠补上 sm_110 的内核。
而且第一件该做的事,是把我自己设的 max-num-seqs 2 拿掉。
十一、动手记录:把 humming 的原生 NVFP4/MXFP8 内核救活
第十节 P1 的第 4 条(把线性层从 Marlin 换到原生内核)在写这份笔记的当天就往前推了一大步, 过程比结论有意思,值得单独记一节。
发现:镜像里其实装着一条没人用的原生路径
镜像里装了一个叫 humming 的库(v0.1.12),vLLM 里有对应的线性层内核
(HummingNvFp4LinearKernel / HummingMxfp8LinearKernel),而且它的
is_supported() 只要求 has_device_capability(75) —— 没有排除 sm_110。
更关键的是它带 nvrtc_compile:这是运行时按设备 JIT 编译的库,
不是只支持特定几代的预编译包。
所以就有了一个很朴素的尝试:
--linear-backend humming # 合法取值之一,文档说"没实现的层自动回退"
结果内核选择确实变了:
Using HummingMxfp8LinearKernel for MXFP8 GEMM ← 原来是 MarlinMxfp8LinearKernel
Using HummingNvFp4LinearKernel for NVFP4 GEMM ← 原来是 MarlinNvFp4LinearKernel
但选中之后启动即崩,然后是一连串"只差一行"的缺口 —— 每一处都指向同一件事: 库作者认真处理过 DGX Spark(GB10/sm_121)的统一内存情形,只是漏了 Thor(sm_110)。
① humming/tune/__init__.py
heuristics_map = {75,80,86,87,89,90,100,103,120,121} ← 没有 110
→ KeyError: 110
(注意 103 已经直接复用 Sm100Heuristics,说明 sm_10x 这条数据中心线共享同一档启发式)
② humming/utils/device.py calculate_gpu_bandwidth
if (major, minor) == (12, 1):
# NVIDIA GB10 (DGX Spark, sm_121): unified LPDDR5X memory with no
# discrete memory-clock domain, so NVML_CLOCK_MEM raises
# NVML_ERROR_NOT_SUPPORTED ... Use the platform's known memory bandwidth instead.
return 273.0
← 这段特例写得很清楚。只是 (11,0) 也是统一内存、也在 NVML 上查不到显存时钟
③ humming/utils/device.py estimate_tensorcore_max_tops
nvmlDeviceGetMaxClockInfo(handle, NVML_CLOCK_SM) 在 Jetson 上同样 NOT_SUPPORTED,
而且 ops_map 里也没有 110
修法:运行时补丁,不改源码
用 .pth 在解释器启动时打补丁(API server 与 EngineCore 进程都会生效):
heuristics_map[110] = Sm100Heuristics # 与 sm_103 同款处理
带宽查询失败 → 回落 273 GB/s # 与库内 GB10 特例同值(Thor 规格同为 273)
张量核峰值失败 → 20 SM × 8192 ops/clk × 1.575 GHz = 232 TFLOPS FP16 dense
(与 datasheet 的 FP8 dense 517 TFLOPS 自洽:FP16 是 FP8 的一半)
补完这三处,服务起来了:模型的完整前向(含多模态编码器)在 humming 内核上跑通,
KV 池 452,005 token,--linear-backend humming 稳态可用。
正确性先于性能
换内核必须验输出(新路径可能算错,而且错得很安静):
日本的首都是哪里? → 东京 ✓
写 fib(n) 函数 → 语法正确的 Python ✓
三句话解释带宽瓶颈 → 恰好三句、连贯、内容正确 ✓
无退化、无重复、无乱码 ✓
性能:说实话,没测出确定的收益(用重复取中位数复测后仍然如此)
第一轮用单次测量,结果自相矛盾;改成每个档位重复 3 次取中位数之后(Marlin 基线用的是 同一套脚本、同一组提示词):
| 负载 | 并发 | Marlin(中位) | humming 线性层 + MoE(中位) | 差异 |
|---|---|---|---|---|
| 代码改写 | c=1 | 30.48 | 31.60 [28.3–34.3] | 噪声内 |
| 代码改写 | c=4 | 84.18 | 77.05 [74.5–78.8] | 慢 8% |
| 代码改写 | c=8 | 117.01 | 116.39 [115.4–121.7] | 持平 |
| 散文 | c=4 | 70.30 | 76.58 [71.6–77.5] | 快 9% |
结论:基本持平,没有可以写在标题上的收益。 但它的意义不在速度:
- 这是一条真正在 sm_110 上跑起来的原生 FP4/MXFP8 路径,不再是"权重解压后再算";
- 它运行在 JIT 编译路径上,意味着调优/自动调参是有空间的(目前的配置来自启发式估算, 而不是针对这块芯片实测搜索出来的);
- 它把草稿层的 MoE 也一起从 Marlin 换了出来(
--moe-backend humming), 而草稿层的开销正是决定"更深投机是否划算"的那笔账(见第四节)。
关于噪声来源,还是那句话:换了内核 → 浮点累加顺序变了 → 同一提示词、温度 0 下生成的文本 本身就不一样(实测接受率 0.90 → 0.76),于是 tok/s 里混进了"这次写的是什么"的影响。 所以换后端类实验必须看与生成内容无关的步长,并重复取中位数。这轮之后我把基准脚本固定成 了三重复制 + 中位数 + 区间。
还没试完的部分 → 已经试完:把草稿层拉出 Marlin 让 K=3 回本
日志显示 NvFp4 MoE backend 有两次选择(目标 MoE = VLLM_CUTLASS,草稿层 = MARLIN),
而 vLLM 里有 humming 的 MoE 实现(fused_humming_moe、NvFp4MoeBackend.HUMMING)。
于是把 --moe-backend humming 也打开,让草稿层的 MoE 离开 Marlin 权重解压,
再测 K=1 / K=3(同一个代码改写负载,重复取中位数):
| 配置 | c=1 | c=4 | c=8 |
|---|---|---|---|
| MTP1 + Marlin(原最优) | 30.48 | 84.18 | 117.01 |
| MTP3 + Marlin | 25.10 | 79.55 | 108.30 |
| MTP1 + humming(线性层+MoE) | 31.60 | 77.05 | 116.39 |
| MTP3 + humming(线性层+MoE) | 32.18 | 76.63 | 129.08 |
关键对比(同一 K,只换草稿层后端):
K=3, c=1: 25.10 → 32.18 (+28%)
K=3, c=8: 108.30 → 129.08 (+19%)
也就是说:第四节里"K>1 不划算"的结论,根因并不在草稿质量,而在草稿层的 Marlin 开销。 草稿层一旦跑上原生内核,K=3 从"全面落后"变成 c=1 和 c=8 的最好成绩。 这条把两个看似独立的发现(投机解码的接受率曲线、Marlin 回退的内核缺口)接上了, 也是这份笔记里唯一一个"先有机制预测、后被实测证实"的结果。
(诚实标注:c=1 的区间是 [24.9–33.0]、c=8 是 [118.6–141.1],抖动仍然不小, c=4 还略低于 MTP1+Marlin。所以"最好"是中位数意义上的最好,需要更多重复才能下最终结论。)
顺带一笔必须记住的运维账:启动慢的真凶既不是 humming,也不是编译缓存,而是视觉(多模态)的 Triton warmup。 时间轴来自一次真实启动:
05:34:58 KV 池建好
… 14.7 分钟完全静默(1-2 个 CPU 核钉在 100%、GPU 功耗仅 6W、全盘零新文件、无 IO)…
05:49:39 Warmed position embedding and vision rotary kernels on grids=[...]
05:49:39 Warmed M-RoPE Triton kernels / Warmed Mamba batch_memcpy_kernel
05:49:50 init engine took 938.96 s
KV 池之后到 READY 之间,日志里唯一的一行就是视觉 warmup 的完成行 —— 那 14.7 分钟全花在里面, 而它只在结束时打印,所以整段过程看起来像卡死(其实 GPU 在空载等 CPU 编译内核)。
对照(引擎初始化时间):
Marlin + 关多模态 394-412s
humming + 开多模态 881-939s
下面几个猜测都被实测证伪了,别再重复:
挂 humming 编译缓存 无缓存 26 min → 挂上 19.2 min,但后续两次 21.9 / 20.0 min,落在抖动内
挂 Triton 编译缓存 无收益(98 MB / 1879 个文件,白挂)
关 enable_jit_warmup 日志确认生效,但启动仍 20 分钟
关 enable_cutedsl_warmup 同上
关 enable_flashinfer_autotune
日志明确 "Skipping FlashInfer autotune because it is disabled",启动仍 20 分钟
py-spy 抓 CPU 栈 容器缺 CAP_SYS_PTRACE → Permission denied(要 --cap-add SYS_PTRACE)
实用取舍:
纯文本(--limit-mm-per-prompt '{"image":0,"video":0}')→ 启动回到个位数分钟,KV 池还更大
要视觉 → 接受约 15 分钟的一次性 warmup
十二、还没解决的根源问题(诚实清单)
这些不是调旗标能解决的,是真正的空白:
- 线性层与草稿层没有原生 FP4 内核,只能跑 Marlin 权重解压 —— 这是最大的内核缺口。
注意别把这句说成"MoE 没有原生 FP4 内核":实测启动日志显示目标 MoE 的专家层走的是
VLLM_CUTLASS原生 NVFP4,被降级的是 NVFP4/MXFP8 的线性层 (MarlinNvFp4LinearKernel/MarlinMxfp8LinearKernel)和草稿层(MTP)的 MoE (第二次 backend 选择结果 =MARLIN)。而 torch 自带的libtorch_cuda.so里就有 59 个 sm_110a 的 CUTLASS block-scaled FP4 GEMM —— 说明"这个内核在这块芯片上编得出来", 缺的是把它接进 vLLM 的线性层与草稿层。 - Thor 的 ATS GPU gather 非法访问,PLE 抓行只能走 CPU(约 93% 的加载路径会踩到)。
- 草稿层的每步字节数:MTP3 时草稿层权重(1.49 GiB)被读 3 遍,占总字节的 1/3;
而草稿前向在单流下是 M=1 的小 GEMM,实测每步因此多出 ~17 ms(理论 67.7 ms vs 实测 85.1 ms)。
注意:这不是"缺内核" —— 启动日志显示
GDN decode kernel: cuda,融合内核在 sm_110 上是 启用的(网上流传的"SM110 只能用 Triton 回退"在这套 build 上不成立)。 真正的方向是减少草稿层的读取字节(更激进的量化)或提高其批量效率。 persistent_topk非确定性:GB10 上有人实测 temp=0 时 13/50 次发散 (atomicAdd 槽位,对应 vllm#51782)。我们这条 Jetson 镜像路径下 3 提示 × 6 次 = 18/18 字节完全一致,没复现 —— 但样本量小,长期当 agent 用还得再验。
十三、复现清单
# 0. 前置:清 page cache(否则启动内存检查假失败)
docker run --rm --privileged --entrypoint sh <IMAGE> -c 'sync; echo 1 > /proc/sys/vm/drop_caches'
# 1. 起服务
docker run -d --name vllm-flashnext --runtime nvidia --network host \
--ulimit memlock=-1 --ulimit stack=67108864 --shm-size=32g \
-e VLLM_API_KEY="$KEY" \
-e VLLM_PLE_MMAP=1 -e VLLM_PLE_MMAP_DIR=/model \
-v /path/flash-next:/model:ro \
-v /tmp/fixed_config.json:/model/config.json:ro \ # model_type=qwen4_exp
-v ~/.cache/vllm:/root/.cache/vllm \
-v ~/.cache/flashinfer:/root/.cache/flashinfer \
<IMAGE>:plemmap /model \
--served-model-name qwen3.8-flash-next --host 0.0.0.0 --port 8000 \
--gpu-memory-utilization 0.80 \ # 显式留 ~15 GiB 给宿主
--max-model-len 32768 --max-num-seqs 2 \
--max-num-batched-tokens 2048 \
--safetensors-load-strategy lazy --trust-remote-code \
--speculative-config '{"method":"mtp","num_speculative_tokens":1}' \
--compilation-config '{"cudagraph_mode":"PIECEWISE","splitting_ops":[
"vllm::unified_attention_with_output",
"vllm::mamba_mixer2","vllm::short_conv","vllm::linear_attention",
"vllm::qwen_gdn_attention_core","vllm::sparse_attn_indexer",
"vllm::qwen4_exp_ple_short_conv","vllm::qwen4_exp_qsa_with_output",
"vllm::ple_mmap_lookup_ids"]}'
关键检查点(照着看日志,每一步都对得上再往下走):
权重 + 非 torch 应约 71.7-77.0 GiB(不做 offload 是 ~97-101 GiB)
CUDA 图 4-6 个 PIECEWISE 图捕获成功
KV 池 18.8-22.9 GiB / 144k-446k token
宿主可用内存 ≥ 15 GiB(低于这个数就会撞 NV_ERR_NO_MEMORY)
单流 33-37 tok/s(MTP1/2/3,视负载);8 并发 124-154 tok/s
启动耗时 首次 ~6 分钟;之后引擎初始化 30-48 s(缓存在 ~/.cache/humming)
三处已知待调/可调:
① --max-num-seqs(本篇早期用的是省内存时的 2,实际支撑 8)与 --max-num-batched-tokens
(2048 → 8192)—— 见第十节 P0-1。
② 关多模态 --limit-mm-per-prompt '{"image":0,"video":0}' —— 能把启动从 20 分钟压到 4.5 分钟
(多模态视觉 warmup 占约 750 s),代价是失去视觉能力。
③ 可选实验开关:-e VLLM_MTP_LOGITS_FP8=1 —— 让草稿的输出投影走 FP8 副本
(见第十四节;目前步长省 7.5-10 ms,但量化粒度待改进才能真正转化为吞吐)。
十四、拆开一个 decode 步:kernel 级归因,以及一个未量化的 lm_head
前面十节全在算"每步搬多少字节",但草稿那 13.7 ms 到底花在哪个 kernel 上, 只有 profiler 能回答。这一节是实测记录,包含两次自我更正和一个意外发现。
14.1 怎么在这套栈上抓 kernel 级 trace
vLLM 自带 torch profiler,不用 nsys/ncu(镜像里也没有):
--profiler-config '{"profiler":"torch","torch_profiler_dir":"/root/prof","torch_profiler_with_stack":false}'
# 然后 POST /start_profile → 跑几次生成 → POST /stop_profile
# (路由在 vllm/entrypoints/serve/profile/api_router.py;VLLM_TORCH_PROFILER_DIR 已废弃,只会 warning)
三个坑,每个都值一次完整的重启周期:
① trace 是 pretty-print 的 JSON,字段分行、冒号后有空格。我第一版流式解析器写的是
紧凑正则 "cat":"kernel",于是一个 kernel 事件都没匹配到 —— 而那一份 1.48 GB 的 trace
里其实有 273,941 个 kernel 事件。我据此得出了"CUDA 图把 kernel 藏起来了"的错误结论,
又花一次启动去用 --enforce-eager 重抓。
② 图模式(PIECEWISE)里 kernel 是可见的,不需要 eager。而且 eager 会歪曲你要测的东西:
图模式(生产配置): 单步 86.0 ms,其中 kernel 时间 81.6 ms(95% 忙)
--enforce-eager : 单步 166.7 ms,其中 kernel 时间 83.5 ms(49% 忙)
eager 把一半时间变成了 CPU 发射空隙(2153 个 kernel/步要一个个发),恰好放大了你正在 研究的"小算子低效"。要看真实占比,必须测图模式。
③ 解析要流式:380 token 的 decode 解压后 1.48 GB,而 gpu_memory_utilization 0.8
下宿主只剩约 3 GB 可用,整体 json.load 会 OOM。
14.2 单步解剖(MTP3,80 步平均)
单步墙钟 86.0 ms
├─ kernel 81.6 ms(95%)
└─ 空隙 4.4 ms
kernel 数 2137 个/步
前 60%(≈52 ms) 目标模型 48 层堆栈:humming 内核,每个 0.02-0.44 ms,合计约 40 ms
后 40%(≈34 ms) 草稿阶段:结构完全不同,被**一个** cuBLASLt kernel 家族主导
每步 kernel 时间 Top:
21.28 ms × 15.9 nvjet_sm110_tst_128x8_64x12_2x1_v_bz_TNT ← 全步的 25%
17.02 ms × 50.2 humming <Shape<0,1280,2560>, <16,256,64>>
6.93 ms × 50.2 humming <Shape<0,2560,640>, <16,256,64>>
6.21 ms × 35.4 humming <Shape<0,16384,2560>,<16,256,64>>
14.3 真凶:未量化的 bf16 lm_head,每步被读 4 次
把那个 kernel 的时长和 grid 配对之后,它自己分成了两群:
grid[1940,1,1] 520 次 中位 4958 µs ← 慢的全部在这里
grid[20,1,1] 1421 次 中位 53 µs ← 便宜(每 SM 一个 block)
grid[100,1,1] 127 次 中位 249 µs
1940 = 248320 ÷ 128 —— 而 checkpoint 里
lm_head.weight : BF16 [248320, 2560] = 1,271,398,400 字节(1.27 GB)
即:没有量化的输出投影,每步被读 4 次(1 次目标验证 + 3 次草稿),每次约 5 ms:
4 × 1.27 GB = 5.09 GB/步 ≈ 20.3 ms/步 = 单步的 24%
而且草稿没有自己的头:models/qwen4_exp/nvidia/mtp.py 把 mtp.shared_head.head.
重映射到 lm_head.(第 107-116 行),Qwen4ExpMTP.compute_logits 直接
self.logits_processor(self.lm_head, hidden_states) —— 草稿和目标共用同一份 1.27 GB。
顺带解释了两个此前没想明白的现象: - 为什么 c=8 能到 130-154 tok/s:8 路序列共享一次 lm_head 读取,这块固定成本被摊薄了; - 为什么单流那么难上 40:单流时这 20 ms/步里每次只产出 1 个 token 的 logits。
14.4 试修:给草稿的那个 head 换 FP8 副本
思路:目标验证仍走 bf16(保输出分布),只把 3 次草稿的读取换成 FP8 副本(1.27 → 0.64 GB)。 先做微基准验证收益存在:
bf16 F.linear : 4.82 ms
fp8 _scaled_mm: 2.49 ms → 1.94×,误差 mean 0.0155 / max 0.09
补丁做法(mtp_logits_fp8.py,运行时 .pth 加载,VLLM_MTP_LOGITS_FP8=1 才启用):
包装 Qwen4ExpMTP.compute_logits,在调用期间把 self.lm_head.quant_method
换成 FP8 替身、调用完再换回来 —— 这样即使草稿和目标的 head 是同一个对象也不会串味
(目标验证在时序上不会与草稿并发)。权重按 8192 行分块量化,避免 float32 中间量爆显存。
实测(同负载,代码改写):
步长 bf16 82.3-84.8 ms → FP8 74.8 ms 省 7.5-10 ms ✓ 与预测的 3×2.33 ms 吻合
接受率 bf16 2.60 / 2.80 / 2.80 / 2.91(四次采样) vs FP8 2.58 → 在噪声带内,无证据变差
吞吐 bf16 33.0-35.4 tok/s vs FP8 34.5 tok/s(单次)→ 分不出胜负
两处必须自己更正的地方(写在这里因为它们都是"看起来对"的测量陷阱):
- 我打印的"FP8 每次 0.11 ms"是假的:
torch._scaled_mm是异步的,我只量到了 CPU 入队时间。跨配置比较必须看端到端的步长,不能信单次调用的计时。 - "只改草稿、输出逐字节不变"是错的。vLLM 的
rejection_sample_method默认"standard"(概率式拒绝采样,见vllm/config/speculative.py),被拒绝位置的替代 token 从max(0, p_target − p_draft)残差里重采样 —— 输出确实依赖草稿分布,实测同一组 temp=0 提示词 3 条里 2 条分叉。任何"改草稿侧不影响输出"的说法都必须实测验证。
结论:带宽收益是真的(步长稳定降 7.5-10 ms),但我的量化太粗(per-row absmax → 一个 2560 长的点积只有 1 个 scale,logit 误差 ~0.09,而 top 候选之间常常只差 0.1), 接受率刚好把收益抵消到测不出。要吃到这 7.5 ms,得改成按 K 分块量化(MXFP8 风格, 每 32-128 元素一个 scale),把误差降一个量级。
14.5 另一个测量陷阱:冷启动
同一配置、同一负载,启动后第一次测量总是显著偏慢:
第一次(刚启动完) 25.53 tok/s,步长 101.9 ms
之后同配置 33.01 / 34.60 / 35.36 tok/s,步长 80.9-84.8 ms
原因是 PLE 表的 mmap 冷页:第一次抓行要从磁盘读。这解释了本文档里几处"同配置数字对不上" 的现象 —— 永远不要拿冷的第一跑和热的第二跑对比。
结语
这台机器的故事其实是软件适配的价值远大于硬件规格的标准案例: 2070 TFLOPS 的宣传数字和 273 GB/s 的理论带宽,在日常 decode 里一个都没用上 —— 真正起作用的是 193.6 GB/s 的实测读带宽、CUDA 图捕获、投机解码的接受率曲线, 以及"把 26.82 GiB 的表从显存里搬出去、但记得给宿主留 15 GiB"这种琐碎的内存算术。
而这个故事还有一个更讽刺的维度:Thor 的硬件比 DGX Spark 更强 (sm_110 有 tcgen05、矩阵吞吐约 2 倍),但整个 CUDA 生态是为 12.x 消费级家族 (含 sm_121 的 Spark)编译的 —— 于是"硬件更强的那台"反而拿不到能跑满它的内核, 只能退回 Marlin 和 Triton 回退路径。硬件在,软件不在,这就是全部差距。
一路上最能省时间的经验只有一条:vLLM(以及所有这类框架)的 docstring 里写的约束 是真的约束。我踩的四个坑 —— 装饰器、地址稳定、原地写、op 名字 —— 全都白纸黑字写在它自己的注释里,只是没人会先去读。
如果只留一个数字作结,我会留这个:每步 86 ms 里,有 20 ms 花在把一个 1.27 GB 的 bf16 输出投影读 4 遍(第十四节)。它不是算力瓶颈、不是内核缺失,只是"这个张量没人量化"。 在统一内存的机器上,这类"没人做过的小事"往往比 2070 TFLOPS 的宣传数字更能决定体验 —— 这和本文开头的结论是同一件事:软件在不在,比硬件强不强重要。
2026-09-15 补记
十五、上线:把模型变成一个可计量、可限流、可多租的服务
为什么不能用 vLLM 自带的 key
--api-key 只接受一把静态 key,给多把也只是"等价的多把"(鉴权处做的是全等比较),
于是不能按 key 限速、限并发、分开计量;更麻烦的是它是启动参数 ——
在这台机器上加一个 key 要重启,而重启一个 98 GiB 的权重是 5~12 分钟。
结论:多租控制必须自己加一层。模型服务负责算,网关负责管。
拓扑(端口对调后)
客户端 / harness / 各个 agent 实例
│ Authorization: Bearer <各自的 key>
▼
0.0.0.0:8000 ← 转发网关(零依赖 Node,挂在 tmux 里)
│ 换成上游那把 key 再转发
▼
0.0.0.0:8001 ← vLLM(qwen3.8-flash-next / -uncensored)
对调只做了一次:8000 从模型改成网关卡,模型挪到 8001。客户端侧零改动 —— 把原来打 8000 的那把 key 注册成网关的一个身份,客户端仍然打 8000、仍然用那把 key, 只是从"直连模型"变成"过网关",配额与账本立刻生效。
网关做了什么
| 能力 | 实现 | 实测 |
|---|---|---|
| 多 key 身份 | keys.json 里一个身份一把 key |
每个 agent 实例一把,账本按实例分 |
| 每身份并发上限 | 超了排队,不是拒绝 | 见下表 |
| 每身份速率上限 | 每分钟请求数(rpm) | 429 时带 rate_limit 类型 |
| 排队 | 队列上限 50,排队超时 5 分钟,超时才 429 | 见下表 |
| 计量 | 每次请求一行 usage.jsonl + /admin/usage |
非流式/流式都能记 |
| 收口 | 只放行 /v1/*;/metrics 等一律 404;/health 开放 |
key 持有者看不到内部指标 |
| 热加载 | 改 keys.json 2 秒内生效 |
加人不用重启服务 |
同一身份(max_concurrent = 1)连发 3 条请求,排队是真在起作用,而且可计量:
| 请求 | 耗时 | 日志里的 waited_ms |
|---|---|---|
| 第 1 条 | 0.78 s | 0 |
| 第 2 条 | 1.74 s | 778 |
| 第 3 条 | 2.44 s | 1740 |
(端到端一次真实对话 1.09 s,那一次携带了 16K 的固定前缀。)
计量口径的一个坑
非流式响应里有 usage,好办。流式响应默认没有 —— vLLM 只在客户端带
stream_options: {include_usage: true} 时才在最后一个 chunk 里回 usage。
所以网关的对策是:有 usage 就按 token 记;没有就只计次,token 写 null 并标
usage_absent,绝不猜数字。要么让客户端带上这个参数,要么承认这条请求只有次数账。
十六、收尾两件悬案:本地 NVFP4 输出头,以及最终配置下的并发
§14.3 那个"每步被读 4 遍的 bf16 输出头",换掉了
第十四节把单步 86 ms 里的 20 ms 归因给一个未量化的 bf16 lm_head(1.27 GB × 4 次)。 本地把输出头做成 NVFP4 之后(不动镜像,运行时替换),同一负载下:
| 配置 | c=1 | c=2 聚合 |
|---|---|---|
| bf16 输出头 | 41.48 | 64.38 |
| NVFP4 输出头 | 47.71 (+15%) | 80.19 (+25%) |
质量判分持平(不是"看起来还行",是同一套判分脚本)。这一步的价值不在平均值, 而在它证明了第十四节的归因是对的:那 20 ms 确实是"没人量化",不是算力不够。
uncensored 权重挂上同一台服务
同一个 server 暴露两个模型名(qwen3.8-flash-next / qwen3.8-flash-next-uncensored),
同一套启动参数:SEQS=4、--gpu-memory-utilization 0.73、MTP2、text-only、
humming 原生内核、PIECEWISE CUDA 图、async-scheduling,外加
--reasoning-parser qwen3 与 --tool-call-parser qwen3_xml --enable-auto-tool-choice。
uncensored 权重单流 51.99 tok/s;同一台机器上的并发表(KV 池 1,013,816 token 那一次):
| 并发 | 聚合 tok/s | 备注 |
|---|---|---|
| c=1 | 34.7 ~ 42.5 | 取决于负载可预测性 |
| c=2 | 66.6 ~ 68.3 | |
| c=4 | 89.4 | 4 路都被服务(running 峰值 4) |
| c=8 | 100.9 | 8 个都在被服务,每人 26~27 tok/s |
一个必须诚实记录的现象:KV 池会"抽签"
同一套参数连续三次启动,KV 池拿到的是:1,013,816 / 557,599 / 557,599 token,
而 vLLM 自报的 Actual usage 是 59.5 GiB 和 72.4 GiB —— 差 13 GiB,
不属于启动参数能解释的部分(当时我一度以为是漏了环境变量,实测推翻:带全量 env 重启后
池子依旧是 557,599)。
所以两句话:别指望靠重启"抽到"一个大池子;要稳就调 --gpu-memory-utilization。
以及:报容量时必须报"你要的那个配置下这一次启动"的数字,不要报历史最好值。
顺带一条容量账(以 557,599 token 的池子为例):
| 需求 | 占用 |
|---|---|
| 2 × 256K 满上下文 | 524,288 → 94.0%(装得下,但没有余量给第三个人) |
| 10 × 32K | 327,680 → 58.8% |
这一版 vLLM 的前缀缓存是开着的(日志里 Mamba cache mode is set to 'align' ...
when prefix caching is enabled)。这一条对多租服务很重要:十几个 agent 共用的那段
固定前缀(工具定义 + 系统提示,见下节)在 KV 里只存一份,不会按用户数翻倍。
十七、两条花掉我最多时间的教训
教训一:复刻一个跑着的容器,不要用白名单过滤环境变量
为了做 A/B,我用"看起来相关"的几个环境变量重建了启动脚本。漏掉了 4 个,
其中一个叫 VLLM_API_KEY —— 后果是新容器完全没有鉴权:
/v1/models 不带 key 返回 200。这台机器对局域网可达,等于开了个后门,
而我在自己的测试里一直只验证"带 key 能通",所以第一轮检测没发现。
同批漏掉的还有三个调优开关(VLLM_MARLIN_USE_ATOMIC_ADD、VLLM_MTP_LOGITS_FP8、
VLLM_PLE_MMAP_STATS),它们会让内存/性能悄悄偏离现场,而不报任何错。
正确的做法只有一种:
docker inspect <name> --format '{{json .}}' > restore/<name>.inspect.json
# 全量导出 env(只排除由镜像自带、改错会坏启动的 PATH / LD_LIBRARY_PATH)
python3 restore/extract_env.py # 写出 restore/vllm.env(600)
# 启动脚本里引用它,不要再手写 -e
docker run ... --env-file restore/vllm.env ...
以及一条检查清单:起来之后第一件事是验鉴权 —— 不带 key 必须 401。 "带 key 能通"证明不了任何事。
教训二:agent 的提示词固定开销,是多租服务的隐形天花板
把 agent 接上模型之后,第一件让我意外的事是每轮的真实输入 token:
| agent | 一轮(系统+用户两条消息)的 input_tokens | 构成 |
|---|---|---|
| ZeroClaw v0.8.4 | 16,009 | 32 个工具定义 + 系统提示;其中 personality 模板只有 1,713 token |
| pi(极简 harness) | 系统提示词正文 1,073 | 官方自述"4 工具 + bash" |
差一个数量级。原因是 ZeroClaw 把 SOP 工作流、定时任务、子代理、跨机消息、技能包、 甚至"硬件访问(Arduino)"这些都作为工具和段落塞进了提示词 —— 对一个 QQ 聊天机器人, 大部分是纯税。而且这份税每一轮都要重新付一次 prefill(前缀缓存能把它在 KV 里 只存一份,但算力和带宽仍要为它做一次前向)。
试着裁剪过:schema 里确实有 excluded_tools(36 个通道级配置类型都带这个键,
描述是"不暴露给模型"),但我把它写进 [channels.webhook] 之后,
daemon 直接报 config section ... is malformed and was reset to defaults,
重启后 input_tokens 一点没变(16,009 → 16,009)。所以这条开关的实际效果未知,
我没有验成 —— 记在这里,而不是写成一个结论。
给做多租服务的同行的结论只有一句:在选 agent 内核之前,先把它的固定提示词开销量出来。 模型侧省下来的那 15% 单流性能,很容易被 agent 侧每轮多发的 15K token 吃掉。
以上为 2026-09-15 补记;原文(第一 ~ 十四节 + 结语)保持定稿状态未改动。