DGX Spark 跑 Qwen3.8-Flash-Next 吞吐调优:前缀缓存为什么一个 token 都没命中

jerryliang 发布于 7 小时前 9 次阅读


在 DGX Spark(GB10,单卡 128G 统一内存)上用 vLLM 跑 Qwen3.8-Flash-Next(125B MoE / 6B 激活 / NVFP4 量化)给审核服务做推理,是可以靠调显存和调度参数把吞吐拉起来的。然而在这台机器上,遇到过一个很别扭的现象:明明每个请求的提示词都一样,缓存命中率却接近零;后来把能调的调度参数都试了一遍,吞吐不升反降。

本文记录一下排查过程,以及最后真正有效的两处配置。

主要参考: DGX Spark 上 Qwen3.8-Flash-Next 的实测记录、GB10 上 GDN kernel 选型与换引擎的分析

一、起点

服务端的实际配置是 vLLM 加社区的 b12x 镜像(针对 GB10 做了定制 kernel 的那个 fork)。审核服务的请求结构很简单:


messages[0]  role=system   纯文本 666 字符(所有请求完全一致)
messages[1]  role=user     [ image_url, text「请审核这张视频关键帧截图。」]

折算成 token 就是 416 token 共享提示词 + 660 token 图片 = 1,076 token。业务侧要的是每分钟 45 个请求以上。

初始状态(2026-11-10 上午):

指标 起点
GPU 显存利用率 0.72
KV cache 显存 7.81 GiB
KV cache 可容纳 token 318,246
131k 上下文最大并发 2.43x
平均 TTFT 1.69 s
端到端请求耗时 12.93 s
生成吞吐 65–80 tok/s

二、第一步:把显存要回来

这台机器是统一内存,0.72 的利用率相当于白扔了 27 GiB。但直接调大会起不来 —— PyTorch 的分配器上限还卡在 0.72,两处必须一起改:


# .env
GPU_MEM_UTIL=0.72                                  -> 0.822
PYTORCH_ALLOC_CONF=per_process_memory_fraction:0.72 -> 0.822

改完重启,KV cache 从 7.81 GiB 涨到 23.83 GiB,可容纳 token 从 318,246 涨到 1,205,134,131k 上下文的最大并发从 2.43x 涨到 9.19x。这个数字比 NVIDIA 模型卡上标的「1M tokens of FP8 KV Cache」还多一点。

注意:只改 GPU_MEM_UTIL 不改分配器上限,KV cache 申请时会直接 OOM,容器起不来。

三、第二步:关掉 MTP 投机解码

这一条和直觉相反。MTP 是模型自带的 4B 投机头,本来是加速用的,但单卡上带宽已经吃满,draft + verify 等于多跑一趟,只添延迟。


# docker-compose.yml,去掉这两行
- --speculative-config
- '{"method":"mtp","num_speculative_tokens":4}'

实测(接受率 39.9%,每步验证 4 个位置只接受 1.6 个):


生成吞吐     65–80  ->  100–104 tok/s
平均 TTFT    1.27 s  ->  0.54 s

同一个模型,同一块卡,少一个投机头反而更快。

四、查缓存:为什么一个 token 都没命中

1. 先把逐请求的命中数打开

默认情况下 vLLM 的响应里是看不到缓存命中数的,只有一个聚合的 Prefix cache hit rate。加上这个开关就能逐请求看:


--enable-prompt-tokens-details

usage.prompt_tokens_details = {"cached_tokens": 0, "created_cache_tokens": 0,
                            "multimodal_tokens": {"image": 660}}

11 个真实请求采样,cached_tokens 全是 0,而提示词是逐字相同的。

2. 根因是块粒度

混合模型(线性注意力 + full attention)解析出的 KV 块粒度是 2,960 token。整个请求只有 1,076 token,只占一个不完整块,而块哈希覆盖的是块内全部 token —— 图一换,块的哈希就换,连前面那 416 token 的共享提示词也一起废掉。

所谓「前缀」,在这里从来没有被单独看见过。

反证很好做:重复发同一个 30 token 的纯文本请求,cached_tokens 是 0,然后 30,再 30。整段完全相同才命中,差一点都不行。

3. 两个开关都改不动


--mamba-block-size 64
  -> 被平台按 kernel 对齐重算回 2960

--mamba-cache-mode none
  -> 被 compose 命令行里原有的一条同名参数覆盖(同名参数取最后一个)

4. 一个容易看错的指标

Engine 日志里的 Prefix cache hit rate 是容器启动以来的累计值,不是当前值。我用重复请求的探针把它拉到过 77.8%,之后被真实流量稀释下来,一度让我以为缓存是好的。

判断缓存有没有生效,看逐请求的 cached_tokens 或者 vllm:prefix_cache_hits_total 的增量:


$ curl -s localhost:8000/metrics | grep -E '^vllm:prefix_cache_(queries|hits)_total'
vllm:prefix_cache_queries_total{...} 95764
vllm:prefix_cache_hits_total{...} 0

60 秒内查询 20,444 token,命中 0。

五、算一笔账:瓶颈在 prefill,不在 decode

每个请求 1,072 token 的 prefill,对 95 token 的输出,比例 11:1。

之前一直盯着生成吞吐和 decode 的每步耗时优化,方向是反的 —— 真正的大头是把整段输入铺进显存、算出第一份 KV。

六、调度旋钮实测三轮,全是负收益

这个 fork 自己写了一套 prefill/decode 配平的东西(上游 vLLM 里连 vllm/v1/core/sched/prefill_interleave.py 这个文件都没有):


--prefill-compute-share           争用时给 prefill 的算力占比,auto 会自动配平
--prefill-compute-half-life       auto 的自适应半衰期
--max-num-prefill-tokens-per-step 争用步内 prefill 的 token 上限
--max-parallel-prefills           同时 prefill 的车道数
--prefill-policy                  round-robin / decode-aware
--decode-refill-target            decode 感知的 refill 阈值

三轮实测(每轮都是重启后 12 分钟的同负载窗口):

配置 请求速率 TTFT 生成吞吐
不调(基线) 46.7 /分 0.468 s 72.9–86.1 tok/s
share 0.2 + step 256 24.3 /分 5.93 s 36.3–59.0 tok/s
auto + responsive 34.7 /分 1.449 s 49.3–63.5 tok/s
decode-aware + np2 30.7 /分 0.792 s ~65 tok/s

不是这轮压得更狠 —— auto 和 decode-aware 那两轮的请求速率更低(34.7 和 30.7,负载比基线还轻),吞吐反而更低。

原因也很直接。这套旋钮默认是 decode 优先、TTFT 不敏感,首 token 晚一点无所谓。我们的负载前提正好掉过来:输入长、输出短、首 token 就是全部体验。前提反了,调得越勤亏得越多。

七、最终配置


GPU_MEM_UTIL=0.822
PYTORCH_ALLOC_CONF=per_process_memory_fraction:0.822
max-num-seqs 8 / max-num-batched-tokens 4096 / max-model-len 131072
无 --speculative-config
无任何 prefill 调参

效果:

指标 起点 终点
KV cache 显存 7.81 GiB 23.83 GiB
KV cache 可容纳 token 318,246 1,205,134
131k 上下文最大并发 2.43x 9.19x
平均 TTFT 1.69 s 0.468 s
端到端请求耗时 12.93 s 8.56 s
生成吞吐 65–80 tok/s 72.9–86.1 tok/s

八、踩过的坑

重启脚本会把服务停掉。 直接 docker compose up -d --force-recreate 时,旧容器里有在途请求,FastAPI 会一直等连接关闭,而推理引擎已经停了,请求永远等不到,容器就退不出来;compose 因为名字冲突把新容器改名并卡在 Created,最后 8000 端口不再监听,服务实际是停的。改成先强杀再起就好:


docker compose down --timeout 10 --remove-orphans
docker compose up -d

改 compose 的时候:

  • 数字值要加引号。- 64 会被 YAML 解析成整数,compose 报 command.N must be a string
  • 同名 CLI 参数后面覆盖前面,插新参数之前先 grep 有没有同名项
  • 改完跑一次 docker compose config -q 校验,不然重启脚本会在校验阶段就中止
  • 判断缓存命中率别看日志那一行,看逐请求的 cached_tokens 或 hits_total 增量。

    九、还没动的

    每请求输出 95 个 token,其中大部分是模型的思考链。关掉思考能把输出压到个位数,延迟还有很大空间,但审核质量会掉,需要先拿真实样本比过一致率再决定。

    另外还有一条没走通的:换 llama.cpp。llama.cpp 在同一个 batch 里混算 prefill 和 decode,不存在这种争抢,而且前缀缓存是 token 级的连续匹配,没有块粒度问题。但它要换 GGUF 权重(91G,和 vLLM 不能并存)、丢掉 MTP 和定制 kernel,审核质量也要重测。

    最后留一条判据给自己:以后再碰上「自动配平」这类参数,先问一句它是默认 decode 优先还是 prefill 优先,答不上来就别拧。

    此作者没有提供个人介绍。
    最后更新于 2026-11-10