Qwen3.8-Flash-Next on Jetson AGX Thor:FP8 KV Cache 移植记录

日期:2026-09-22(跨 9/23 凌晨) 机器:懒猫 AIPod X5-T5000 = Jetson AGX Thor T5000(sm_110,122.86 GiB 统一内存,L4T R39.2 / JetPack 7.2,CUDA 13.2) 引擎:NVIDIA Jetson 专用 vLLM 构建 0.28.1rc1.dev580+g385dce36b(包路径 vllm/models/qwen4_exp/) 模型:Qwen3.8-Flash-Next 180B MoE(NVFP4 · 消审查版)· 36 层 GDN 线性注意力 + 12 层 QSA 稀疏注意力 · 内嵌 MTP 镜像:vllm-thor-plemmap:latest(未重建镜像;所有改动以 只读挂载挂入)


1. 做了什么

目标:让这台机器上的 vLLM 支持 --kv-cache-dtype fp8_e4m3,把 KV 池做大。

镜像里 fp8 KV 本来被五道门拒绝,且拒绝理由各不相同。逐道拆:

补丁 1 · vllm/models/qwen4_exp/nvidia/qsa.py(6 处)

# 原代码 改动
1 supported_kv_cache_dtypes = ["auto","bfloat16"] 加入 "fp8", "fp8_e4m3"
2 if self.kv_cache_dtype not in ("auto","bfloat16"): raise NotImplementedError("Qwen4Exp QSA requires a BF16 main KV cache") 放行 fp8
3 if cache_config.cache_dtype not in ("auto","bfloat16"): raise NotImplementedError(同上) 放行 fp8
4 if self.kv_cache_torch_dtype != torch.bfloat16: raise NotImplementedError("requires BF16 cache storage") 放行 torch.float8_e4m3fn 与 torch.uint8(vLLM 的 fp8 缓存以 uint8 字节分配;拒 uint8 等于拒掉内核唯一会拿到的存储)
5 主 KV cache 送进内核前 key_cache.view(torch.float8_e4m3fn) / value_cache.view(...):位重解释(不是数值转换)
6 内核调用 传入 k_scale / v_scale / kv_quant_mode

补丁 2 · vllm/models/qwen4_exp/nvidia/ops/qsa.py(9 处)

  • Triton 内核签名加 k_scale / v_scale / KV_QUANT_MODE: tl.constexpr(不开 fp8 时编译期惰性,零性能影响)
  • fp8 tile 提升为 bf16 再做点积(不整块反量化 → 这是消掉早期 −6% 的那版做法)
  • scores *= k_scale(乘在 score 上)、normalized_output *= v_scale(乘在累加器上)
  • 算子入口断言放行 fp8;封装 / launch / warmup 三处签名同步
  • 量化时 block_n = max(16, block_n // 2):避免 _cast_kv_tile 物化 fp32 tile 后 shared memory 翻倍超限

补丁 3 · vllm/v1/attention/backends/fa_utils.py(1 处)

原: return (fa_version == 3 and family(90)) or (fa_version == 4 and family(100))
改: 追加 or (fa_version in (3,4) and family(110))

依据:vLLM 官方文档的注意力后端表写着 FA4 的 fp8 KV 支持 compute capability ≥ 10.0; 镜像里的判定却只认 major 9 / major 10,注释自承 “Blackwell (SM100+, restrict to SM100 for now)”。 即:这是上游的暂时性限制,sm_110 本应在内。

补丁 4 · vllm/v1/attention/backends/flash_attn.py(1 处)

_fp8kv_thor = current_platform.is_device_capability_family(110)
if is_quantized_kv_cache(self.kv_cache_dtype) and not _fp8kv_thor and not flash_attn_supports_kv_cache_dtype(...):
    raise NotImplementedError("FlashAttention does not support ... kv-cache on this device.")

原因:本模型的 QSA 后端类名是 Qwen4ExpQSAFlashAttentionBackend —— 它继承自 FlashAttention 后端, 于是白继承了 FA 的设备门。但真正执行 KV 读写的是 QSA 自己的 Triton 内核,不经过 FA,这道门属于误伤。 旁证:参考实现(DGX Spark / GB10)的镜像里根本没有 flash_attn 模块,它的 QSA 是独立 Triton 后端,因此不存在这道门。

启动参数

--kv-cache-dtype fp8_e4m3
--attention-config.flash_attn_version=4
--long-prefill-token-threshold 1024
(`--enable-prefix-caching` 亦于同日打开)

挂载(只读、可秒级回退):

fp8kv/patch/qsa.nvidia.py   → .../vllm/models/qwen4_exp/nvidia/qsa.py
fp8kv/patch/qsa.ops.py      → .../vllm/models/qwen4_exp/nvidia/ops/qsa.py
fp8kv/patch/fa_utils.py     → .../vllm/v1/attention/backends/fa_utils.py
fp8kv/patch/flash_attn.py   → .../vllm/v1/attention/backends/flash_attn.py

一键回退:FP8KV=0 KV_DTYPE=auto bash start_seqs.sh


2. 跑分(全部实测 · 同机同镜像)

KV 池(--gpu-memory-utilization 0.70,同模型同参数)

KV dtype GPU KV cache size 可用 KV 显存
bf16 496,770 token 14.04 GiB
fp8_e4m3 833,617 ~ 872,939 token(多次启动有摆动,profiler headroom 受页缓存影响) 13.25 ~ 13.69 GiB
增益 ×1.68 ~ 1.76

prefill(sparkDash 协议复刻 · 同 sizes · 每发新随机长度)

ctx bf16 fp8_e4m3 差
16.4k 1833 1690 −7.8%
32.8k 1863 1708 −8.3%
65.6k 1867 1713 −8.3%
131k 1863 1703 −8.6%
250k 1836 1674 −8.8%

decode · 短上下文(thorbench · 与 bf16 基线同 flags)

prompt 并发 bf16 fp8 差
prose c8 174.6 169.0 −3.2%
code c8 235.2 210.3 −10.6%
json c8 242.3 251.5 +3.8%

decode · 长上下文(单发 · ITL 增量口径)

ctx bf16 ms/step fp8 ms/step 差
82k 43.77 44.73 −2.2%
156k 44.29 45.50 −2.7%

8 路并发 × ~16k 上下文(20 秒纯解码窗口 · 等全部 prefill 完成后计时)

配置 聚合 每流中位 快慢比 最长 token 间隔
fp8 KV · 8 路 39.2 tok/s 4.8 2.2× 0.11~0.21 s
bf16 KV · 8 路 36.0 tok/s 4.7 1.9× 0.12~0.22 s
bf16 KV · 4 路 19.6 tok/s 5.0 1.3× 0.08 s

每流速度与流数几乎无关(4 路 5.0 / 8 路 4.7 tok/s)⇒ 每步成本 ≈ 0.20 s,只随上下文变长而变贵。

数值(8 道 GPQA Diamond 固定题 · temp0 seed42 · 关思考)

  • 答案字母:fp8 与 bf16 8/8 完全一致
  • 自由推理文本逐字一致:2/8(其余为措辞差异;「empirical parity, not bitwise identity」)
  • 批量(3~6 并发)下的旧记录与单发记录答案有偏移 ⇒ fp8 在批量下有质量漂移,需要按场景取舍

3. 同日顺带完成的四项改动

项目 改动 实测
PLE 抓行 单线程 → 线程池(24 workers · 2048 行/任务 · 小批量也进池 · 抓前去重+排序) 每行 277 → 10 µs(社区同口径最优约 32 µs/页,我们更好)
写回 out.copy_(from_numpy(blob)) → 固定内存 staging + 非阻塞 H2D H2D 段降至 ~0.2 ms/次
计时 拆成 D2H / gather / H2D 三段 定位依据
前缀缓存 打开 --enable-prefix-caching(本修订已含上游 block-size 修复:_block_size_resolved=True, block_size=3184) 12k 前缀重复请求 TTFT 8.18 → 3.87 s
长 prefill --long-prefill-token-threshold 1024 解码流被 prefill 饿死的现象显著缓解(失败题 169 → 127)

盘层基准(4 KB 随机读 · O_DIRECT · 铺满 71 GiB 模型目录)

队列深度 平均延迟 聚合吞吐 IOPS
QD1 143 µs 26.9 MB/s 6,558
QD16 307 µs 94 MB/s 23,026
QD64 600 µs 89 MB/s 21,709

同口径复刻参考实现探针(100 次随机 64 页 · 14 线程):我们 2.78 ms/64 行(43.5 µs/页) vs 参考机 2.06 ms(32.2 µs/页) ⇒ 仅 1.4×,盘不是瓶颈。


4. 结论

  1. fp8 KV 在这台机器上可用:池子 ×1.7,数值在单发场景与 bf16 一致,批量下有漂移。
  2. 它买的是容量,不是速度:prefill 稳定 −8%,decode −2~11%。原因是 QSA 是稀疏注意力——每步只读被选中的一小块 KV,KV 压成 fp8 省不到带宽,只多付反量化。
  3. 真正限制多 agent 并发的是"长上下文下的每步注意力/索引开销",不是 PLE、不是磁盘、不是 KV 精度、不是调度:每步 ~0.20 s 且与流数无关(每流恒定 ~5 tok/s),而短上下文 c8 可达 169~192 tok/s。
  4. 五道门里,四道是过期的保守检查,一道(FA 设备矩阵)是上游的暂时性架构限制;没有一处需要改内核实现本身。

短上下文聚合的"200+ 成立条件"(c2/c4/c8 × 三种 prompt · 384 token 生成)

prompt 每步 token(MTP 接受) c2 c4 c8
json 2.79 95.5 141.3 230.2
code 2.50 87.4 141.5 231.7
prose 2.14 77.8 114.7 170.6

聚合 ≈ 并发 × 每步 token ÷ 每步耗时。每步 token 由 prompt 类型决定(json/code 的结构化输出接受率高), 每步耗时由上下文长度决定(短 ctx ≈ 85 ms;16k ctx ≈ 200 ms,约 40 倍)。 ⇒ "200+ tok/s 聚合"成立的条件是:短上下文 + code/json + c8,三者缺一不可。

★ MTP 在"多路 + 长上下文"下是净亏(当晚最大发现)

配置(8 路 × 16k · 20 秒纯解码窗口 · 同旋钮 bf16) 聚合 每流中位 快慢比 无饿死
MTP=2 36.0 / 35.9 tok/s 4.7 1.9~2.2× ✓
MTP 关 68.3 tok/s 8.3 1.3× ✓

关掉投机解码后吞吐 +90%。机制:16k 上下文 + 8 路时草稿接受率只有 39.9% (短上下文基线 json 2.79 / code 2.50 / prose 2.14 tok/step ⇒ 约 57%), 每步多一轮 draft+verify 的固定延迟却只换来 +0.8 个 token ⇒ 净亏,且这笔固定成本 与上下文长度、与流数都无关——这正是前面"16k→32k 每流只掉 16%""4 路≈8 路"两条异常的来源。

操作结论: - 多路 / 长上下文(≥16k)⇒ 关 MTP(MTP_OFF=1,收益 +90%,KV 池还多 24%:497k → 617,593) - 单流 / 短上下文 ⇒ 保留 MTP(c8 短 ctx 169~192 tok/s 是开着 MTP 测的) - MTP=0 会被 pydantic 校验拒绝(要求 >0)⇒ 必须整段去掉 --speculative-config

★ MTP 的 K × 上下文长度完整对照(当晚补齐三档)

场景(同机同镜像 · 20 秒纯解码窗口/thorbench) K=2 K=1 K=0
短 ctx c1 json(384 tok 生成) — 49.4 29.2
短 ctx c2 json 95.5 80.9 52.3
短 ctx c4 json 141.3 127.0 90.5
短 ctx c4 prose 114.7 113.2 87.5
短 ctx c4 code 141.5 130.0 96.3
4 × 16k 聚合 19.6 24.6 37.2
4 × 16k 每流中位 5.0 6.1 9.5
4 × 16k 快慢比 / 饿死 1.3× / 无 1.2× / 无 1.2× / 无
8 × 16k 聚合 36.0 — 68.3
KV 池(GMU 0.70) ~497k ~483k 642,771

机制(spec-decode 计数器实测,不是推测) - K=1 时单个草稿的命中率:prose 0.70 / code 0.86 / json 0.93(每步接受数) - K=2 相对 K=1 只多 0.44 / 0.64 / 0.86 个 token/步 ⇒ 第二个草稿命中率腰斩 - 上下文越长 ⇒ 每步 draft+verify 的固定成本越高,而收益(多 0.4 token)不变 ⇒ 在 16k 处彻底翻转

可落地分档 - 长 ctx(≥8k 提示,agent/coding 场景)⇒ MTP=0:4 路 37.2(= K=2 的 1.9×)· 8 路 68.3(同 1.9×) - 短 ctx(几百 token 的短问)⇒ MTP=2:c4 json 141.3(K=0 只有 90.5,关掉反而掉 36%) - 引擎侧限制:num_speculative_tokens_per_batch_size 的定义是 (range_start, range_end, K) 闭区间、 按当前 batch size(= 并发度)每步查表(speculative.py:479-484 + scheduler.py:275), 看不到上下文长度 ⇒ 想按 ctx 分档只能做两套 profile,不能靠一张调度表 - 教训:早期试动态 K 时手写的那版 [[1,2,3],[3,5,2],[6,8,1]] 是凭直觉编的 ✗, 实测形状完全不是那样(长 ctx 下连 4 路都该 K=0)⇒ 调度表必须用实测填

★ 用户客户端实测(Windows bench_gui.py 手动测试 · 2026-09-23 当天档位)

场景 聚合 prefill 聚合 decode
单流 · 200k ctx — 50 tok/s
双流 · 100k ctx — 70 tok/s
3 流 · 100k ctx — 60 tok/s
4 流 · 16k ctx — 73 tok/s
6 流 · 32k ctx — 90 tok/s
8 流 · 32k ctx — 100 tok/s
各档聚合 prefill ~1600 tok/s —

口径说明(重要,别和上面的表混着比) - 本表用 bench_gui.py(tkinter 并发流式观察窗)手动逐档跑:并排 N 路流式窗口,肉眼确认是否同时出首 token、哪个流在卡;聚合值取整轮墙钟口径。 - 本表是客户端整轮聚合口径:一轮请求从发起到结束的全部生成 token ÷ 墙钟时间,含爬坡段。 - 上面那些「20 秒纯解码窗口」是稳态 + 每流口径(例:4×16k K=0 = 聚合 37.2 / 每流 9.5)。 - 两套数字不能直接相减或换算;但方向性结论互相印证: - 聚合 prefill ~1600 tok/s 与本文单发 sparkDash 测得的 1674~1713 tok/s 同量级 ✓ - 「8 路难用」说的是单流交互延迟✗,不是聚合吞吐 ✗ —— 8 路 32k 聚合 100 tok/s ⇒ 每流约 12.5 tok/s,是能用的。 - 单流 200k ctx 仍有 50 tok/s ⇒ 长上下文单路体验明显好于 20 秒窗口口径给人的印象。 - 多路(6~8 流)能跑到 90~100 tok/s ⇒ MTP 关掉的长 ctx 档在多路下是对的(投机在这种负载下净亏,见上一节)。

5. 复现

# 起服务(FP8 默认档)
SEQS=8 GPU_MEM=0.70 MTP=2 PORT=8001 BATCHED=4096 LPT=1024 bash ~/mtpvocab/start_seqs.sh
# 回退 bf16
FP8KV=0 KV_DTYPE=auto SEQS=8 GPU_MEM=0.70 MTP=2 PORT=8001 BATCHED=4096 bash ~/mtpvocab/start_seqs.sh

补丁生成器:~/mtpvocab/fp8kv/patch_fp8kv.py(锚点唯一命中校验 + AST 校验,任一不命中即整体不写) 备份与回滚:~/mtpvocab/fp8kv/backup/(镜像 ID、两文件原版、sha256)


附件

「用户客户端实测」一节用的工具:bench_gui.py —— tkinter 并发流式观察窗,无额外依赖(tkinter 标准库 + requests)。

⬇️ 下载 bench_gui.py(26 KB)

python bench_gui.py --url http://<host>:8001/v1 --model qwen3.8-flash-next \
                    --ctx 32768 --concurrency 8
参数 默认 说明
--url http://localhost:1234/v1 OpenAI 兼容端点
--model local-model 模型名
--api-key 空 需要认证时填
--ctx 4096 每路 prompt 目标 token 数(点「随机」按此生成,每路内容不同 → 避开 prefix cache)
--max-tokens 256 每路生成上限
--concurrency 2 并发路数

测量特点:

  • 精确优先:响应带 usage 就用服务端真值算 TTFT / prefill / decode;没有才走估算
  • 自动校准:用服务端真值对估算系数(字符/token、每块 token)做滑动平均,越跑越准
  • 卡顿检测:任意路超过 0.5 s 没有新 token 就标记,并统计最大间隔
  • 看门狗兜底:停止后 2 s 强制恢复按钮,防线程未收尾卡死

← 返回文章列表