谢谢分享,回头去做
-
strata极致优化指南(参数篇)64GB RAM + RTX pro 5000 48GB, IQ3_XXS量化,prefill 4500t/s,decode 180t/s 同时缓存5个262k会话不重新prefill -
单卡7900XTX+llama.cpp+Vulkan+DFlash2+Qwen3.8-27B 速度终于上百了谢谢分享,回头去测试下
-
抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机 -
抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机 -
抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机 -
抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机 -
抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机 -
抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机看到论坛里 5090 那几篇 NInfer 的帖子(1228 / 1870 / 1803)之后,我拿手上这张 4080 SUPER 32G 也抄了一遍。全程没编译——直接找的社区预编译镜像。
结论先行:
- 速度:同机同法实测,NInfer 单路 88.2 t/s vs SGLang 98.3 t/s,SGLang 快 11.5%;但 NInfer 双路聚合能到 122 t/s。
- 智力:同一张卡、同一批题、同一评分脚本,AGIEval 1200 题打平(NInfer 1084 vs SGLang 1082),CEval+CMMLU 420 题 NInfer 领先 1.67pp。所谓"SGLang 智力更高"在数据上不成立。
- 稳定性:这是最大的坑。NInfer 这个 build 在 4080S 上并发必崩,崩了整卡进 full-chip reset,只能重启机器。我崩了三次,最后定位到 CUDA Graph 重放 × 批量(≥2 路)投机解码,加
--no-cuda-graph解决,性能只掉 2.6%。 - 最终配置:单路 85.9 t/s、双路聚合 122 t/s、双路 × 256K 上下文全部跑通、显存余量 4.2GB。
之前关于SGLang的帖子:
https://lcz.me/topic/1654
https://lcz.me/topic/1631
https://lcz.me/topic/1629一、测试机配置
项目 配置 CPU AMD Ryzen 7 9700X(8C/16T) 主板 ROG CROSSHAIR X670E HERO 显卡 NVIDIA RTX 4080 SUPER 32GB(sm_89,80 SM,32760 MiB) 驱动 595.84 内存 60 GB 系统 Ubuntu 24.04.4 LTS / 内核 7.0.0-31-generic 磁盘 NVMe 937G(可用 528G)+ SATA 991G(可用 59G) Docker 29.8.1 + nvidia-container-toolkit 1.20.1 引擎 NInfer( ninfer-serve,社区预编译镜像)镜像 nahsilabs/ninfer-4090:1bd56c9a(5.15GB)= 源码sergiuszm/ninfer-4090 @ 1bd56c9a,基于 CUDA 13.1.2模型 qwen3_8_27b.ninfer,18,210,531,328 字节(16.96 GiB),groupwise-int模型校验 md5 b7e293b072113b351c8e79eb9726a5d6,魔数NINFER\0\002用途 DeepSeek Harness 的本地 provider(OpenAI 兼容) 注意:下载 artifact 必须用旧的那个 commit。HF
neroued/Qwen3.8-27B-NInfer现在的新版是 19.03 GiB(多了 DFlash2 companion weights),manifest 里写着cuda_architecture: sm_120a+minimum_revision: 385b30ce,sm_89 的移植线用不了。旧版路径:
https://huggingface.co/neroued/Qwen3.8-27B-NInfer/resolve/3526913004b1cf552cb57b88d6a5c6f5e4a89a70/qwen3_8_27b.ninfer
二、速度对比(同一张卡、同一测法)
测法完全一致:同一段 prompt(Paris 历史)、
max_tokens=256、temperature=0.7、reasoning_effort=medium、各跑 5 轮取平均,读服务端 per-request 的timings。引擎 模型 权重占用 投机 单路 decode 备注 SGLang RedHatAI/Qwen3.8-27B-INT4 + 独立 DFlash2 草稿 17.33 + 0.79 + 1.44 = 19.56 GiB DFLASH,draft 8(NVFP4 草稿) 98.34 t/s 172K 上下文,2 路 NInfer(CUDA Graph 开) 官方 groupwise-int(单 artifact) 16.96 GiB MTP,draft 3 88.21 t/s 262K 上下文 NInfer(最终, --no-cuda-graph)同上 16.96 GiB MTP,draft 3 85.9 t/s 262K,双路稳定 同法差值 −11.5%。注意 NInfer 这条线用的是更小的 artifact(16.96 GiB 单文件,MTP + 视觉都内嵌),SGLang 那条线是目标模型 17.33 GiB + MTP 头 0.79 GiB + 独立 DFlash2 草稿 1.44 GiB,合计 19.56 GiB。
不过速度差异不能简单归因于引擎:两条线的量化方案不同(compressed-tensors INT4 vs groupwise-int)、投机机制不同(独立 1.92B 草稿模型 vs 内嵌单层草稿头)、上下文也不同(172K vs 262K)。要下"谁更快"的结论得先把这些变量对齐。
双路并发(NInfer 最终配置):
模式 单路 双路各 聚合 倍数 等长 2×256tok 85.9 ~75 142.4 t/s 1.66× 长短混跑 85.9 100 / 67~84 104~107 t/s 1.2× 3 请求(C=2 + 排队) 85.9 ~75×2 132~138 t/s 1.5× 有意思的是批处理下 MTP 接受率反而升高:单路 57% → 双路 71~74%,短请求甚至 92%。
三、智力对比(同一张卡、同一批题、同一评分脚本)
数据集两边各存一份并校验 md5 一致(
agieval_eval.json1200 题、combined_eval.json420 题)。评分逻辑完全沿用原脚本,0-shot、temperature=0。测试 SGLang(RedHat INT4) NInfer(groupwise-int) 差值 AGIEval 1200 题 1082/1200 = 90.17% 1084/1200 = 90.33% +0.17pp CEval+CMMLU 420 题 368/420 = 87.62% 375/420 = 89.29% +1.67pp ├ CEval 220 题 193/220 = 87.73% 202/220 = 91.82% +4.09pp └ CMMLU 200 题 175/200 = 87.50% 173/200 = 86.50% −1.00pp 认知 21 题 无记录 19/21 = 90.48% ※ — ※ 两题是判分脚本的假阴性:一题答
C = πd(脚本期望的写法是pi*d,语义其实完全相同)被判错;另一题答反渗透,比脚本里的标准答案蒸馏更符合现代工程实际。人工看实际是 21/21。AGIEval 分子任务
子任务 SGLang NInfer 差 gaokao-history 143 (95.3%) 147 (98.0%) +4 gaokao-biology 145 (96.7%) 146 (97.3%) +1 gaokao-chinese 125 (83.3%) 126 (84.0%) +1 gaokao-english 142 (94.7%) 142 (94.7%) 0 gaokao-physics 135 (90.0%) 135 (90.0%) 0 gaokao-geography 140 (93.3%) 139 (92.7%) −1 logiqa-zh 129 (86.0%) 128 (85.3%) −1 gaokao-chemistry 123 (82.0%) 121 (80.7%) −2 分歧 66 题:SGLang 对/NInfer 错 = 32,SGLang 错/NInfer 对 = 34 —— 完全对称。
结论:质量打平(NInfer 微幅领先),速度 SGLang 快 11.5%,稳定性 SGLang 完胜。 但 NInfer 换来的是 262K 上下文 + 双路并发能力。
四、三次崩卡(本篇最有价值的部分)
NInfer 这个 build 在 4080S 上有个致命特性:kernel 一挂就是整卡进
NV_ERR_GPU_IN_FULLCHIP_RESET死锁,rmmod卸不掉、nvidia-smi --gpu-reset报Not Supported(Xorg 占用),只能重启机器。我崩了三次。# 触发条件 崩溃耗时 现象 1 --prefill-chunk 2048立即 cudaErrorCooperativeLaunchTooLarge2 3 路并发 + 长短混跑 ~4 分钟 Xid 120 GSP task exception: load access page fault3 2 路 + MTP + CUDA Graph 30.4 分钟 / 648 请求 cudaErrorLaunchFailure崩溃 1 的源码级根因(确定性,可复现)
报错点在
bf16_gdn_gating_proj_kernels.cu:310的协作启动。翻sergiuszm/ninfer-4090 @ 1bd56c9a的源码找到了:// bf16_gdn_gating_proj_plan.cpp —— 预算是按 128 SM 的 RTX 4090 写死的 constexpr std::int32_t resident_ctas_27(Bf16GdnGatingScheduleId schedule) noexcept { return schedule == Bf16GdnGatingScheduleId::MmaCooperativeSplit8 ? 256 : 128; }而运行时算占用率的函数没按 SplitK 区分:
// bf16_gdn_gating_proj_kernels.cu if constexpr (std::is_same_v<Geometry, Bf16Gdn27Geometry>) { // Qualified on the sm_120a build ... return 2; // ← split8/split4/split2 一律返回 2 CTA/SM }split2 的真实占用率是 1 CTA/SM(512 线程 × 74 寄存器 = 37,888 寄存器/CTA,一个 SM 只有 65,536,装不下两个)——代码注释自己都写了 "split4/2 (512 threads, 74 regs) admit 1 CTA/SM"。
在 80 SM 的 4080S 上:
单次 prefill token 路由 网格 CTA 设备上限 结果 1024 split8 192 → 被切片成 144+48 160
安全1664 split2 78 80
侥幸1665 split2 84 80
崩2048 split2 96 80
崩(实测踩中)在 128 SM 的 4090 上 96 ≤ 128 恰好塞得下,所以作者测不出来;换到 80 SM 的卡就复现。 fork README 里"chunk ≤2688 的硬崩已修"这句话不跨 SM 数迁移。
结论:
--prefill-chunk必须 ≤1024。崩溃 3 的判定实验(本篇的重点)
崩溃 1 有明确源码根因,但崩溃 3 是"跑得好好的突然死"。已知事实是:
- 单路串行跑了 4 小时零事故(AGIEval 1200 题 145 分钟 + CEval 420 题 + 全部调参基准)
- 只要并发就崩,但崩的时间从 4 分钟到 30 分钟不等
于是做对照实验,全部 2 路并发、负载与崩溃基线完全一致、各跑 40 分钟:
实验 配置 时长 请求数 失败 聚合吞吐 结果 基线 MTP + CUDA Graph 30.4 min 648 2 123.6
崩Arm 1 关闭 MTP 40.2 min 399 0 59.7
存活Arm 2 MTP + --no-cuda-graph40.0 min 842 0 122.5
存活判定:崩溃根因 = CUDA Graph 重放 × 批量(≥2 路)投机解码。
- Arm 1 证明单纯"批量解码"不崩(关掉投机就能活)
- Arm 2 是关键:保留 MTP、只关 CUDA Graph,冲过了基线 30.4 分钟的崩溃点,且速度几乎无损
- 单路即使带 CUDA Graph 也稳 → 需要 Graph × batch 的组合才触发
长浸泡验证
90.1 分钟 / 854 轮 / 1895 请求 / 678,408 token → 0 失败 聚合吞吐 均 123.1 t/s(最低 79.1,最高 150.4) 0 条 cudaErrorLaunchFailure/FATAL,0 个 full-chip reset,0 个 Xid,37°C累计 130 分钟 / 2,737 请求 / 97.8 万 token 零失败,是崩溃点的 4.3 倍。
代价只有 2.6%:单路 88.21 → 85.9 t/s,双路聚合 123.6 → 121.9 t/s。
五、调参实测
原帖 1803 的参数是给 4090D 48G 的,我这台是 32G,而且 1803 用的是
--kv-dtype fp8。四轮实测:轮次 配置 KV 池(tok) 显存 速度 MTP 接受率 A1 基线 fp8/ C=3345,024 31,043 MiB 88.48 58.1% A2 rk4v4-e8/ C=3654,528 31,043 MiB 85.79 55.3% B1 采用 rk4v4-e8/ C=2 + 裁剪524,288 28,579 MiB 88.21 57.9% B2 rk2v4-e8/ C=2 + 裁剪524,288 26,405 MiB 85.66 55.7% 1)
--kv-dtype fp8 → rk4v4-e8:同显存 KV 池 +90%(345K → 654K),代价仅 −3.0% 速度、−2.8pp 接受率。README 预告 5.7% 的税,实测更省。fp8 不要用,同样显存只能放一半 KV。2)裁剪 + C=2 更优:
--vision-max-tokens 8192 --media-cache-mib 256 --media-live-mib 512 --max-concurrency 2
→ 显存 −2.46 GB,速度回到基线。关键:KV 池 524,288 正好 = 2 × 262,144,引擎按"并发数 × max-context"精确配池,不是缩水。3)
rk2v4-e8不采用:再省 2.2GB,但速度掉到 85.66;而 262144 已是模型上下文上限,2-bit 换不来更长上下文。4)思考预算不是速度杠杆(固定 20 题探针):
预算 正确率 均题耗时 输出 tok/题 无 18/20 = 90.0% 8.40s 827 8000 18/20 = 90.0% 8.41s 827 4000 18/20 = 90.0% 8.39s 827 2000 18/20 = 90.0% 7.58s 746 1000 17/20 = 85.0% 6.89s 668 模型自然思考只有 ~800 token,budget ≥4000 完全是死参数。2000 省 10% 且不掉分。
六、长上下文 × 双路并发验证
KV 池零余量下两路都跑满上下文,是唯一没底的边界。分级加压:
级别 双路各 prompt 合计 token 占 KV 池 TTFT decode 墙钟 结果 L1 63,362 126,724 24% 53 / 55s 3.7 / 72.3 109s 
L2 126,695 253,390 48% 125 / 127s 3.6 / 70.7 253s 
L3 200,584 401,168 77% 235 / 233s 61.1 / 3.3 470s 
L4(单路) 211,140 — 40% 250s 58.5 252s 
L5(极限) 258,640 517,280 98.7% 335 / 346s 3.5 / 52.8 682s 
双路 × 256K 可用,即使池子占满 98.7% 也不崩。
三个必须知道的行为特征:
- prefill 是串行的,会饿死另一条通道:一条解码期撞上另一条 prefill 时被压到 3.3~3.7 t/s(日志实证
queue 5m35.8s、prefill 770.8 tok/s)。双路收益只在"两条都在解码"时兑现。 - 长 prompt 的 TTFT 是硬成本:63K→53s,127K→126s,200K→235s,258K→335s(prefill 770~860 tok/s)。
- MTP 接受率不随深度衰减:200K 处 58.8~60.4%,258K 处 54.2%。
七、最终启动参数
docker run -d --name ninfer-qwen38-27b \ --restart unless-stopped --gpus all --ipc=host \ -p 8080:8080 -e TZ=Asia/Shanghai \ -v "$PWD/models:/app/models:ro" -v "$PWD/logs:/app/logs" \ nahsilabs/ninfer-4090:1bd56c9a \ models/qwen3_8_27b.ninfer \ --host 0.0.0.0 --port 8080 \ --max-context 262144 --kv-capacity auto \ --max-concurrency 2 \ --no-cuda-graph \ --prefill-chunk 1024 \ --kv-dtype rk4v4-e8 \ --vision --vision-max-tokens 8192 \ --media-cache-mib 256 --media-live-mib 512 \ --max-pending-requests 16 --pending-timeout-ms 600000 \ --host-kv-mib 24576 --host-state-slots 72 \ --max-private-continuations 12 --max-shared-prefixes 8 \ --auto-long-anchors 4 --max-long-anchors-per-continuation 4 \ --device-state-slots 6 \ --spec mtp --draft-tokens 3 --lm-head-draft \ --model-id Qwen3.8-27B实测结果:显存 28.6G / 32.8G(余量 4.2G)、单路 85.9 t/s、双路聚合 122 t/s、KV 池 524,288、上下文 262,144。
八、踩坑速查
现象 原因 解决 GPU 进 full-chip reset,只能重启机器 prefill-chunk ≥ 1665触发协作启动越界--prefill-chunk 1024并发跑 4~30 分钟后整卡死 CUDA Graph × 批量投机解码 --no-cuda-graph启动直接 OOM 用了 bf16/int8级 KV,262K 放不下用 rk4v4-e8或rk2v4-e8同显存 KV 池只有别人一半 用了 fp8换 rk4v4-e8(+90% 池子)vision 开起来吃 3GB 显存 media 缓冲用了默认 1024+2048 --media-cache-mib 256 --media-live-mib 512下载新 artifact 加载失败 新版含 DFlash2,声明 sm_120a用 commit 3526913004b1的 16.96GiB 版6 路并发 decode 掉到 9.6 t/s 超过引擎并发能力 --max-concurrency 2
九、结论
- 4080S 32G 跑 NInfer + Qwen3.8-27B 是可行的,不用编译,社区有现成的 sm_89 镜像。
- 质量和 SGLang 打平:AGIEval 1200 题 90.33% vs 90.17%,CEval+CMMLU 89.29% vs 87.62%。换引擎不会掉智力,反而在 CEval 上更高。
- 速度 SGLang 领先 11.5%(98.3 vs 88.2 t/s)。但 NInfer 的权重占用更小(16.96 GiB vs 19.56 GiB),并换来 262K 上下文和可用的双路并发(聚合 122 t/s)。两者量化方案与投机机制都不同,速度差不能纯归因于引擎。
- 这个 build 的并发是带雷的:必须
--no-cuda-graph,代价 2.6%。 - 两条硬红线:
--prefill-chunk ≤ 1024、--max-concurrency ≤ 2。碰了就要重启机器。
一句给同好的话:这张卡上用 NInfer,"稳"比"快"重要得多——先把那两个 flag 钉死,再谈调优。
技术来源
技术/模型 来源 NInfer 引擎 Neroued/ninfer sm_89 移植(本机镜像来源) sergiuszm/ninfer-4090 预编译镜像 nahsilabs/ninfer-4090:1bd56c9a(Docker Hub)模型 artifact(16.96 GiB,兼容版) neroued/Qwen3.8-27B-NInfer @ 3526913004b1 SGLang 侧对照模型 RedHatAI/Qwen3.8-27B-INT4 DFlash2 草稿(SGLang 对照用) z-lab/Qwen3.8-27B-DFlash2 causal_small_t共享内存修正(本 build 缺失)Doelfke/ninfer-yarn @ 7a876cf E8 4-bit KV 来源 UDPSendToFailed/ninfer-4090 全部数据取自
ninfer-serve服务端 per-request 日志与容器日志,不是客户端 SSE 计数(客户端计数会明显低估)。三次崩溃的dmesg/journalctl -b -1证据、源码片段、以及 1895 请求的浸泡日志均可复现。 -
SGLang 跑通:9700X+4080S 32G 跑 Qwen3.8-27B AWQ-INT4 双路128K+三级HiCache 从满屏乱码到跑通 -
小白到能玩# 4080 SUPER 32G 跑 Qwen3.8-27B 无审查版(Q6_K) 实测61.7t/s 完整部署与实测指南,自己从27t/s一路调上来的,分享给大家@imbiplaza-ASUS 新出了个
DavidAU
/
Qwen3.8-27B-TURBO-Fable-Cold-Fusion-735-882-Heretic-Uncensored-NEO-CODER-MAX-MTP-GGUF , 据说是qwen3.8 智力天花板了, 你去试试呗, 看看怎么样Mark.
-
# SGLang 投机解码在 32GB 卡上的选型指南:DFlash2 / MTP / 无投机实测之前发的关于SGLang调试经历的帖子,大家翻阅参考:
https://lcz.me/topic/1621
https://lcz.me/topic/1620
上一篇把 Qwen3.8-27B 在 RTX 4080 SUPER 32G 上用 SGLang 跑通并修好了 MTP 的 KV 池崩塌问题。这一篇回答一个更实际的问题:同样是投机解码,MTP 和 DFlash2 到底选哪个?整机配置
CPU:AMD Ryzen 7 9700X(8核16线程)
内存:64GB DDR5
显卡:NVIDIA RTX 4080 SUPER 32GB(32760 MiB)
系统:Ubuntu(SGLang 这套)/Windows 11(上一篇 llama.cpp 那套)结论先行:短上下文和长上下文,DFlash2 全面胜出(短上下文快 1.5~1.8 倍,128K 长上下文快 1.67 倍);代价是显存——DFlash2 的草稿模型要占 3.69GB 显存,KV 池从 33 万 token 掉到 11~14 万,双路 128K 从此不可能。
三条路全部实测,同一台机器、同一个目标模型、同一批提示词。
一、结论表(同版本 0.5.19,RedHatAI INT4 目标模型)
场景 无投机 NEXTN (MTP) DFlash2 DFlash2 vs MTP 短上下文 代码 解码 41.8 t/s 89.3 t/s 130.5 t/s +46% 短上下文 抽取原文 解码 40.6 t/s 86.5 t/s 143.0 t/s +65% 短上下文 中文创作 解码 42.0 t/s 66.2 t/s 75.2 t/s +14% 双流并发 聚合解码 76.9 t/s 166.7 t/s 293.4 t/s +76% 128K 长上下文 解码 33.8 t/s 39.0 t/s 65.3 t/s +67% 预填充 @119K 1207 tok/s 1166 tok/s 1203 tok/s 持平 接受长度(短/128K) — 3.76 / 2.13 4.74 / 2.91 — KV 池 token 331173 280537 137709(单路)<br>113997(双路) 0.4× 草稿显存 — 0.79 GB 3.69 GB — 一句话:DFlash2 用 2.9GB 额外显存 + KV 池缩到 40%,换来 1.5~1.8 倍速度。 值不值,取决于你要的是吞吐还是长上下文。
二、为什么 128K 场景差距反而更大
这是这次实测最有价值的发现。投机解码的收益 = 接受长度。而 MTP 的接受长度会随上下文长度剧烈衰减,DFlash2 基本不衰减:
上下文 MTP 接受长度 DFlash2 接受长度 短(1.6K~13K) 3.76 4.74~5.33 中(32K) — 4.92 128K 1.68 ~ 2.13 2.91 短上下文 MTP 还能靠 3.76 的接受长度拿到 89 t/s;到了 128K 接受长度掉到 2 附近,投机验证的开销就快把收益吃光了,只剩 39 t/s(比无投机的 33.8 只快 15%)。DFlash2 在 128K 仍有 2.91 的接受长度,于是拿到 65.3 t/s。
原理上说得通:MTP 是"目标模型自带的单层草稿头",它只见过目标模型的最后一层隐状态,长上下文里隐状态的分布漂移会直接削弱它的预测;DFlash2 是独立的 1.92B 草稿模型,用块扩散(block diffusion)一次并行预测一整块 8 个位置的 token 并保留每位的候选,再用一个轻量 selector 串成一条连贯路径,还从目标模型的第 5/19/33/47/61 层抽取隐状态做条件——信息量大得多,所以长上下文下更稳。
三、环境:0.5.17 → 0.5.19 的隔离升级
DFlash2 在 SGLang 0.5.17 里根本不存在(
grep -i dflash2在整个srt/零命中,只有 DFLASH v1 的 worker)。0.5.19(2026-09-04 发布)包含了官方两个关键 PR:- #35371
[Spec] DFlash2: local convolution + candidate selector(2026-08-19 合并) - #35496
Support quantized target lm_head in the DFlash2 selector(2026-08-20 合并)
第二个 PR 值得单独说一句:DFlash2 的 selector 要拿草稿隐状态直接和目标模型的
lm_head.weight做 matmul,量化目标模型会报DFlash2 selector requires a dense FP16/BF16/FP32 target lm_head。32G 卡上跑 27B 只能用量化模型,所以这个 PR 是刚需。不过实测发现我们的目标模型不需要它:RedHatAI INT4 的
lm_head.weight是稠密 BF16(只有 Linear 层走 compressed-tensors pack-quantized,lm_head在 ignore 列表里)。也就是说 0.5.18/0.5.19 都能用。升级做法是克隆 venv,不动原来那套,0.5.17 的双路 128K 配置完整保留当退路:
rsync -a /home/shenzq/sglang-env/ /home/shenzq/sglang019-env/ sed -i 's#/home/shenzq/sglang-env#/home/shenzq/sglang019-env#g' /home/shenzq/sglang019-env/bin/* /home/shenzq/sglang019-env/bin/python -m pip install -U "sglang==0.5.19" -i https://pypi.tuna.tsinghua.edu.cn/simple升级幅度不小:torch 2.11.0 → 2.13.0+cu130、flashinfer 0.6.15 → 0.6.18、sglang-kernel 0.4.5 → 0.4.6.post1、triton 3.7.1。
启动(草稿模型 3.85GB,走 hf-mirror 下载):
export HF_ENDPOINT=https://hf-mirror.com export HF_HUB_DISABLE_XET=1 python -m sglang.launch_server \ --model-path /mnt/sda6/download/qwen38-redhat-int4 \ --speculative-algorithm DFLASH \ --speculative-draft-model-path incoai/Qwen3.8-27B-DFlash2 \ --speculative-num-draft-tokens 8 \ --context-length 131072 --mem-fraction-static 0.92 \ --max-mamba-cache-size 5 --mamba-ssm-dtype bfloat16 \ --cuda-graph-backend-prefill=disabled \ --kv-cache-dtype fp8_e4m3 --page-size 1 --language-only四、显存账:为什么 DFlash2 吃掉了长上下文
32G 卡上的固定开销(实测,
--language-only):项目 大小 目标模型 INT4 权重 17.65 GB DFlash2 草稿模型(1.92B bf16) 3.69 GB mamba 状态(8 槽 fp32) 3.54 GB 融合 KV cell 42 KiB/token(目标 32 + 草稿 10) 关键在那个 42 KiB/token:DFlash2 的草稿 KV 是"融合"进同一个池子的(日志
DFLASH fused KV materialization enabled),所以每 token 的池子开销比无投机多 31%。草稿 KV 本身已经是 fp8(10 KiB/token,改不了),省不下来。于是 128K 单路要 131072 × 42 KiB = 5.5GB KV,只能靠三件事挤出来:
--mem-fraction-static 0.88 → 0.92(+1.3GB)--mamba-ssm-dtype bfloat16(ssm 状态减半,省 1.75GB)--max-mamba-cache-size 8 → 5(再省 0.67GB)
挤完的结果:池 137709 token(单路)/ 113997(双路)。对比无投机的 331173、MTP 的 280537。
所以双路 128K 在 DFlash2 下是不可能的:那需要 262144 × 42 KiB = 11GB KV,而 32G 卡上扣掉权重和 mamba 只剩 6GB 左右。要双路 128K,只能用 0.5.17 + 无投机(池 279214)那条路。
五、无损性验证:7/8 逐字一致
DFlash2 官方声称"解码无损,greedy 输出与目标模型完全一致"。实测 8 个提示词(贪心解码,
temperature=0),与同版本无投机输出逐字比对:提示词 结果 常识 / 数学 / 中文创作 / 列表 / 逻辑 / 摘要 / 中文知识问答
7 项逐字相同代码生成
️ 语义相同,单点 token 不同(280 vs 282 字符)唯一差异出现在代码提示的这里:
DFlash2:This will output: 55 无投机:This will output `55`.两句话语义等价,后续文本重新汇合——这是 fp8 KV cache + 投机验证改变了浮点运算顺序导致的近等分 token 翻转,不是模型能力差异。所以准确说法是**"近似无损"**:达不到位级完全一致,但语义一致。
六、踩过的四个坑
坑 1:0.5.17 没有 DFlash2。 别指望加个参数就能用,必须升级(且 0.5.18 起才是 torch 2.13 那套依赖)。
坑 2:
--mem-fraction-static太激进会在图捕获时崩,而且报错极具误导性。 mf 0.93 时 KV 池能给到 145490 token,但捕获 prefill 图前只剩 1.74GB,捕获中途 OOM,报的是:RuntimeError: num_active_captures_ > 0 INTERNAL ASSERT FAILED at CUDACachingAllocator.cpp:3211 markCaptureEnd called with no captures in progress看起来像 PyTorch bug,其实是显存不够。解法是
--cuda-graph-backend-prefill=disabled把显存全让给 KV 池——副作用很小:prefill 用 eager 跑,实测预填充速度和开图时几乎一致(1203 vs 无投机的 1207 tok/s),而且启动时间从 354s 降到 22s(省掉了 170s 的 prefill 图捕获 + 96s 的草稿 verify 图捕获)。坑 3:0.5.19 里 mamba 槽位耗尽的断言还在,而且代码重构了。 0.5.17 那个补丁(槽位耗尽时优雅跳过前缀捐赠而不是崩服务)不能直接搬:0.5.19 里
self.cache.evict()改名成evict_for_alloc(),prepare_for_caching_req()新增了 int8 checkpoint 分支,捐赠调用点从 2 处变成 3 处,每处都要挡None。改完确认 0.5.19 里effective_cache_len <= 0的清理分支仍在,语义与 0.5.17 一致。坑 4:mamba 槽位会把并发数压回 1。 日志直说了:
max_running_requests is capped to 1 by the mamba state cache (max_mamba_cache_size=8, 5 state slots per request)投机下每个请求要 5 个 mamba 状态槽,8 槽只能跑 1 个请求。要双路就必须
--max-mamba-cache-size 10(但又要多吃 0.7GB 显存,池子进一步缩水)——投机、并发、上下文长度,在 32G 卡上三者只能取其二。七、五方总表(含 llama.cpp)
把之前 llama.cpp 两档和 SGLang 三档放在一起(SGLang 数据为本次 0.5.19 同版本实测):
项目 llama.cpp<br>Q6_K+MTP llama.cpp<br>unsloth Q4_K_M+MTP SGLang INT4<br>无投机 SGLang INT4<br>+NEXTN(MTP) SGLang INT4<br>+DFlash2 权重 20.89 GB 15.33 GB 17.7 GB 17.7 GB 17.7 GB + 3.7 GB 草稿 代码 解码 80.9 t/s 101.2 t/s 41.8 t/s 89.3 t/s 130.5 t/s 抽取 解码 81.8 t/s 91.1 t/s 40.6 t/s 86.5 t/s 143.0 t/s 创作 解码 52.9 t/s 59.7 t/s 42.0 t/s 66.2 t/s 75.2 t/s 双路聚合 95-160 t/s 161.4 t/s 76.9 t/s 166.7 t/s 293.4 t/s 128K 长上下文解码 未测 未测 33.8 t/s 39.0 t/s 65.3 t/s 可用上下文 128K × 2 256K × 2 128K × 2 128K 单路 128K 单路 KV 池 token — — 331173 280537 137709 显存 28.4 GB 含视觉 27.6 GB 30.1 GB 30.2 GB 30.2 GB 预填充 未测 未测 1207 tok/s 1166 tok/s 1203 tok/s 八、怎么选
- 要长上下文 + 并发(双路 128K、Agent 长文档)→ SGLang 无投机,池 279214/331173,唯一能做到双路 128K 的方案。
- 要单路极致速度(编码、批量生成、短上下文对话)→ DFlash2,比 MTP 快 46~76%,128K 长上下文快 67%。
- 要长上下文 + 一点加速(单路 128K 但想省点时间)→ MTP,池 280537 是 DFlash2 的 2 倍,128K 解码 39 t/s。
- 要超长上下文 × 并发(256K × 2)→ 只有 llama.cpp unsloth Q4_K_M 那条路,代价是速度从 130 t/s 掉到 101 t/s。
- 显存最省 → llama.cpp Q4_K_M(15.33GB 权重)。
最后提醒一句:DFlash2 的价值集中在长上下文。如果你的用法全是短提示(<8K),MTP 的 89 t/s 和 DFlash2 的 130 t/s 是一倍多的差距,但两者的显存代价差别(0.79GB vs 3.69GB)会决定你能不能同时留下双路和 128K。
九、复现清单
# 1) 隔离升级到 0.5.19(保留 0.5.17 环境) rsync -a /home/shenzq/sglang-env/ /home/shenzq/sglang019-env/ /home/shenzq/sglang019-env/bin/python -m pip install -U "sglang==0.5.19" -i https://pypi.tuna.tsinghua.edu.cn/simple # 2) 打 mamba 槽位优雅降级补丁(0.5.19 代码重构过,需专用版) /home/shenzq/sglang019-env/bin/python fix_mamba_graceful_019.py # 3) 下载草稿模型(镜像站) export HF_ENDPOINT=https://hf-mirror.com HF_HUB_DISABLE_XET=1 huggingface-cli download incoai/Qwen3.8-27B-DFlash2 --local-dir /mnt/sda6/download/qwen38-dflash2 # 4) 启动(见第二节命令) bash start_dflash2.sh dflash-128k # ctx 131072 单路,池 137709 bash start_dflash2.sh dflash-128k-dual # ctx 131072 双路,池 113997 bash start_dflash2.sh nospec-128k # 无投机对照,池 331173测试脚本:
bench_sglang.py(短上下文/双流)、bench_dflash_long.py(预填充曲线 + 128K 解码 + 双路长上下文)、lossless_check.py+compare_lossless.py(无损性比对)。硬件:RTX 4080 SUPER 32G(32760 MiB)、驱动 595.84、CUDA 13.0、Ubuntu。目标模型 RedHatAI Qwen3.8-27B INT4(compressed-tensors W4A16 group128,Marlin 核),KV cache fp8_e4m3,
--page-size 1。 - #35371
-
SGLang 跑通:9700X+4080S 32G 跑 Qwen3.8-27B AWQ-INT4 双路128K+三级HiCache 从满屏乱码到跑通非常好的帖子,你把上个帖子链接发进来,方便大家查找。我帮你贴下,以后自己的帖子注意维护。
https://lcz.me/topic/1614
两个帖子质量都很好。好的,谢谢
-
# SGLang 上 MTP 投机的完整调优记录:从"draft 白占 4.7GB"到单路 89 t/s,以及一个会崩服务的 mamba 坑 -
SGLang :Qwen3.8-27B INT4 开 MTP 投机 从 41 跑到 89 t/s 踩坑记录(draft 白占 4.7G + 一个会崩服务的坑) -
SGLang :Qwen3.8-27B INT4 开 MTP 投机 从 41 跑到 89 t/s 踩坑记录(draft 白占 4.7G + 一个会崩服务的坑) -
# SGLang 上 MTP 投机的完整调优记录:从"draft 白占 4.7GB"到单路 89 t/s,以及一个会崩服务的 mamba 坑 -
小白抄作业成功 # 9700X+4080S 跑 Qwen3.8-27B Q6_K 128K双路+视觉 单路95t/s 踩坑记录 -
# SGLang 开 MTP 后 KV 池被砍到 1/5:一个上游 bug 的完整定位与修复,附同机四方实测总表上一篇我还在说"SGLang 开 MTP 只能跑 32K 上下文,因为它在显存分配上留了 8GB 结构性预留"。
那个结论是错的。 后来插了几行日志一查,真相是 SGLang 把 MTP 的层数识别成了 64(实际只有 1 层),导致每 token 的 KV 成本被高估 5 倍,KV 池被砍到 1/5 —— 不是"显存不够",是"算错了"。
修好之后,单路 128K + MTP 直接跑通(池子 44913 → 211357 token)。这篇把定位过程、修复、前后实测和更新后的四方总表一次写完。
1. 先给三张结论表
1.1 修复前后(单变量 A/B:只切换补丁)
指标 修复前 修复后 变化 cell_size163840 B/token 32768 B/token ÷5 KV 池 token 44913 211357 ×4.71 单条输入上限 45417 token ~211000 token ×4.65 127K 单路请求
被拒(HTTP 400)
成功— 短上下文 code 86.1 t/s 86.1 t/s 0 短上下文 抽取 85.9 t/s 85.9 t/s 0 短上下文 创作 59.8 t/s 59.8 t/s 0 双流聚合 170.1 t/s 171.8 t/s +1% 运行显存 26182 MiB(余 6016) 30626 MiB(余 1572) 预算用满 1.2 MTP 到底值多少(同权重同参数,只切投机)
场景 无 MTP MTP 增益 短上下文 code 41.5 t/s 86.1 t/s +107% 短上下文 抽取原文 40.5 t/s 85.9 t/s +112% 短上下文 中文创作 41.5 t/s 59.8 t/s +44% 双流聚合 82.9 t/s 171.8 t/s +107% 128K 单路 decode 34.4 t/s 31.5 t/s(另一次 37.8) −8% ~ +10%,基本持平 接受长度 — 短上下文 2.5-3.7 / 128K 仅 1.68 — MTP 的价值全在短/中上下文;128K 下接受长度塌到 1.6-2.0,验证开销把收益吃光 —— 长上下文别指望 MTP。
1.3 更新后的四方总表
项目 ① llama.cpp<br>Q6_K+MTP ② llama.cpp<br>unsloth Q4_K_M+MTP ③ SGLang<br>INT4 无投机 ④ SGLang<br>INT4+NEXTN 权重 20.89 GB 15.33 GB 17.7 GB 17.7 GB code 解码 80.9 t/s 101.2(nmax4) 41.5 t/s 86.1 t/s tool 解码 81.8 t/s 91.1(nmax4) 40.5 t/s 85.9 t/s prose 解码 52.9 t/s 59.7(nmax3) 41.5 t/s 59.8 t/s 双路总吞吐 95-160 161.4 82.9 t/s 171.8 t/s 可用上下文 128K × 2 256K × 2 128K 128K(单路,修复前只有 45K) 显存 28.4 GB(含视觉) 27.6 GB 30.4 GB 26.2-30.6 GB MTP 接受率 未记录 tool 87-100% / code 87-89% — 0.87-0.94 / 0.90 / 0.50(128K 时 0.42) 口径提醒:①② 是 Windows 11 +
-c 262144/131072、temp 0.7;③④ 是 Ubuntu + fp8 KV、贪婪解码。④ 的双路吞吐 171.8 是在 32K 上下文下测的,② 的 161.4 是在 256K 下测的,论"每单位上下文的速度"② 更强。2. 现象:MTP 一开,KV 池从 28 万掉到 4.5 万
模型是
RedHatAI/Qwen3.8-27B-INT4(17.7 GB,自带model_mtp.safetensors),同一个启动命令只差投机开关:运行 KV 池 128K 请求 不开投机 279214
能跑开 NEXTN 44913
Input length (125837 tokens) exceeds the maximum allowed length (60967 tokens)3. 排查:先以为显存不够,后来发现是算错
第一反应当然是"显存被 MTP 吃掉了",于是把所有能想到的旋钮都试了一遍:
尝试 KV 池 结论 基线 mf 0.92 44913 — mf 0.97 55228 涨得很少 mf 0.99 59150 还是很少 mf 0.97 + 并发降到 1 + prefill 压到 2048 + hicache 缩到 4 60973(上限) 到顶了 --max-total-tokens 262144强制指定55228 参数被忽略 关 CUDA graph 不变 无关 投机步数从 3 降到 1 +2.9K 无关 参数调到天亮都没用。直到我注意到一个矛盾:
Memory pool end. avail mem=7.90 GB ← 分配完还剩 7.9GB 空闲 KV Cache is allocated. #tokens: 44913 ← 却只给了 1.37GB显存明明够,它却不用。 那就不是"不够",是"算错"。
于是往 sizing 代码里插了几行日志(
_profile_available_bytes和_compute_cell_size),一次就露馅了:[KV_SIZING] avail=11.359GB slack=2.387GB rest_after_mamba=6.853GB pre_load=29.837GB reserve_mb=3712 mf=0.92 post_capture=False → 交给池子的预算是 6.85GB,却只分配了 1.37GB [KV_CELL_DEBUG] cell_size=32768B (32.0KiB/token) num_layers_arg=16 mambaish=True full_attn=16 spec=SpeculativeAlgorithm.EAGLE eagle_draft_layers=64 ← 这里! [KV_CELL_DEBUG2] EAGLE 缩放生效: eagle_draft_layers=64 num_layers=16 -> cell_size=163840B ← 每 token 成本被放大 5 倍顺带还排除了一个我原本以为的元凶:日志里
post_capture=False,说明那个"混合 mamba 模型的 pre_capture 激活预留"(3712 MB)根本没生效,跟这事无关。4. 根因:MTP 层数识别错误,×5 的缩放
两处代码组合出来的。
第一处,
spec_aux_hidden_state.py里推导 draft 层数:num_nextn_predict_layers = draft_model_config.num_nextn_predict_layers if num_nextn_predict_layers is not None: config.eagle_draft_num_layers = int(num_nextn_predict_layers) else: config.eagle_draft_num_layers = int(max( draft_model_config.num_hidden_layers, # ← 拿到 64 draft_model_config.num_attention_layers, ))Qwen3.5/3.8 的 MTP 层数写在
text_config.mtp_num_hidden_layers里(=1),不是 DeepSeek/GLM 用的num_nextn_predict_layers。字段认不出来就走了 fallback,而--speculative-draft-model-path指向的正是模型自己的目录 —— 于是这个"draft 模型配置"读到的就是整模型的 64 层。(证据:
model_mtp.safetensors里只有mtp.layers.0.*,就一层。)第二处,
pool_configurator.py拿这个数字去缩放每 token 成本:# EAGLE/STANDALONE: scale cell_size to account for draft model KV cache self._cell_size = int( self._cell_size * (1 + int(eagle_draft_num_layers) / int(num_layers)) )而混合注意力模型(48 层 GDN + 16 层全注意力)里
num_layers取的是真正带 KV 的层数 = 16,于是缩放系数变成:1 + 64 / 16 = 5每 token 成本高估 5 倍,KV 池就被砍到 1/5。
5. 修法:一处改动
最地道的改法不是去改那个 fallback(那只修了一处),而是在
ModelConfig里就把字段补齐 —— 所有依赖num_nextn_predict_layers的地方都自动受益:# sglang/srt/configs/model_config.py self.num_nextn_predict_layers = getattr( self.hf_text_config, "num_nextn_predict_layers", None ) + if self.num_nextn_predict_layers is None: + # Qwen3.5/3.8 name the MTP depth `mtp_num_hidden_layers` + self.num_nextn_predict_layers = getattr( + self.hf_text_config, "mtp_num_hidden_layers", None + )验证(只改这一处,把之前打在 fallback 上的补丁撤销):
mem-fraction 修复前 只改 ModelConfig 后 0.92 44913 211355 0.97 59150 257367 mf 0.97时池子 257367 token,双路 128K(需要 262144)已经接近可行。6. 修复前后实测:单变量 A/B
方法:只切换这一个补丁,vocab 占位补丁两边都开,其他启动参数逐字符相同。
公共参数:
--model-path <Qwen3.8-27B-INT4> --language-only \ --context-length 131072 --max-running-requests 2 --mem-fraction-static 0.92 \ --kv-cache-dtype fp8_e4m3 --page-size 1 \ --max-mamba-cache-size 16 --mamba-ssm-dtype bfloat16 \ --enable-hierarchical-cache --hicache-size 12 --hicache-write-policy write_through \ --speculative-algorithm NEXTN --speculative-draft-model-path <同目录> \ --speculative-eagle-topk 1 --speculative-num-steps 3 --speculative-num-draft-tokens 4127K 单路请求(本轮唯一真实差异)
修复前 修复后 结果 HTTP 400: exceeds the maximum allowed length (45417 tokens)
成功prompt — 128180 token prefill — 112.9 s(1136 tok/s) 解码 @128K — 31.5 t/s(接受长度 1.68) 短上下文 decode:两边逐位相同(对照组)
修复前: code 86.1 t/s accept 3.56 | echo 85.9 accept 3.66 | prose 59.8 accept 2.51 双流聚合 90.9 + 79.2 = 170.1 t/s 修复后: code 86.1 t/s accept 3.56 | echo 85.9 accept 3.66 | prose 59.8 accept 2.51 双流聚合 90.9 + 80.9 = 171.8 t/s逐位相同是预期结果:那个字段只被 KV 池尺寸估算消费,不参与任何 kernel、attention 或采样逻辑。这也正好反证了补丁是纯内存修复 —— 如果有人报告"打了补丁速度变了",那一定是别的原因。
顺带一修的另一个浪费:draft 的 vocab 层(2.4 GiB)
MTP draft 在
__init__里会建自己的embed_tokens和lm_head(各 248320×5120×2 byte ≈ 2.5 GB),但运行时set_embed_and_head()会把它们del掉、换成目标模型的张量引用 —— 而 KV 池是在这之前就按"draft 还占着 5.53GB"算好尺寸的,之后释放的显存就白空着。用"0 尺寸占位"规避(上游对应 PR #37155 把这步释放提前了):
draft 加载显存: 5.53 GB → 0.79 GB KV 池: 14161 → 45423 token两个修复加起来:池子 14161 → 45423(vocab 层)→ 211357(层数),总共 ×14.9。
7. MTP 参数怎么扫(同 llama.cpp 扫 n-max 的规律)
steps/draft code 抽取 创作 接受率(code/抽取/创作) 1/2 62.0 60.5 54.6 0.97 / 0.95 / 0.74 2/3 73.5 77.8 60.9 0.87 / 0.98 / 0.64 3/4 86-89 86 59.8 0.87-0.94 / 0.90 / 0.50 5/6 108.7 —(显存不足) 59.6 0.91 / — / 0.42 规律和 llama.cpp 完全一致:steps 越大,可预测任务(代码/工具)越快,创作类接受率反而下降。而且上下文越长接受长度越低:短上下文 3.76,128K 时只有 1.68。
8. 三方/四方横评(更新后)
项目 ① llama.cpp Q6_K+MTP ② llama.cpp Q4_K_M+MTP ③ SGLang INT4 无投机 ④ SGLang INT4+NEXTN 系统 Windows 11 Windows 11 Ubuntu Ubuntu 权重 20.89 GB 15.33 GB 17.7 GB 17.7 GB code 80.9 101.2 41.5 86.1 tool 81.8 91.1 40.5 85.9 prose 52.9 59.7 41.5 59.8 双路聚合 95-160 161.4 82.9 171.8 上下文 128K×2 256K×2 128K 128K 单路(修复前 45K) 显存 28.4 GB 含视觉 27.6 GB 30.4 GB 26.2-30.6 GB prefill 未测 报告未列 1680-1915 tok/s ~1775 tok/s 结论:
- llama.cpp + Q4_K_M + MTP 仍是"最快 + 最长上下文"的综合最优(256K×2 还能 161 t/s),它的 KV 池是按实际使用分配;
- SGLang + MTP 修好之后,解码反超 llama.cpp(+6%~13%),双流聚合最高,而且单路 128K 保住了;
- SGLang 无投机的唯一优势是 prefill(1680-1915 tok/s,123K 时还有 1199)和 HiCache 前缀复用(17.9K 前缀 10.2s → 1.25s);
- 两者取舍的核心差异从"显存分配策略"变成了"MTP 在长上下文下收益趋零"这个共同的物理限制。
9. 给同是小白的提醒
- 池子异常小的时候,先怀疑"算错"而不是"不够"。判据很简单:分配完成后还剩多少显存。像这次分完还剩 7.9GB,就说明预算没被用满,参数调到天亮都没用。
- 定位靠日志,别靠猜:在 sizing 路径(
_profile_available_bytes、_compute_cell_size)插几行print,把中间值打出来,一次就能看到cell_size被放大了 5 倍。改完记得把调试补丁撤掉(env 变量门控更安全)。 - 换带 MTP 的权重先确认两件事:它带
model_mtp.safetensors;model.safetensors.index.json里引用了它。 - Xet 存储的仓库必须
HF_HUB_DISABLE_XET=1,否则 hf-mirror 等于没用,还会 401。 max_running_requests is capped to 1这条日志一定要看:混合 mamba 模型 + 投机下每请求要 5 个 mamba 槽,槽不够就变排队(我实测 2×64K 墙钟 84.4s ≈ 2×42s,是串行不是并行)。--max-mamba-cache-size别乱调大:bf16 + 16 槽下跑 2×60K 长并发,两个请求都能完成但随后AssertionError: Can not alloc mamba cache(mamba_component.py:479)→ SIGQUIT → 整个服务退出。长上下文档建议用--max-mamba-cache-size 6(默认 float32)。- 投机参数要自己扫,而且别忘了它只在短上下文值钱:短上下文 +107%,128K 下 −8%~+10%(基本白干)。
10. 还没解决的
- 双路长并发的 mamba 槽位断言崩溃(见提醒 6),要么等上游修,要么避开 bf16 + 大槽位组合。
- 128K 下 MTP 收益趋零:接受长度塌到 1.68,属于投机解码在长上下文下的通用问题,不是这个 bug 带来的。
- SGLang 的 L3 磁盘层只写不读(混合模型上从不触发预取),三级缓存实际只能用两级。
上游已按第 5 节的方案提交了 issue 和最小复现脚本(
repro_mtp_draft_layers.py:Part 1 不需要 GPU,只读 config.json 和权重就能证明误判;Part 2 起两次服务对比池子)。这个 bug 对所有用mtp_num_hidden_layers的 Qwen3.5/3.8 混合模型都成立,池子会被砍1 + 总层数/带KV层数倍;就算是全注意力模型(每层都带 KV)也会被砍 2 倍。感谢论坛里讨论投机解码和显存分配的各位,这次少走了很多弯路。
-
# SGLang 上 MTP 投机的完整调优记录:从"draft 白占 4.7GB"到单路 89 t/s,以及一个会崩服务的 mamba 坑上一篇写了 llama.cpp + Q6_K + MTP 的成绩(工具 81.8 t/s / 代码 80.9 / 创作 52.9)。这篇是 SGLang 侧的续集:换成带 MTP 权重的 INT4 模型、把 NEXTN 投机调起来,最后不仅追平还略微超过了 llama.cpp(代码 89.3 t/s)。
但过程比想象中曲折:MTP 打开后 KV 池从 28 万 token 掉到 1.4 万 token,连 32K 上下文都放不下。这篇把每个坑的定位过程和修法都写下来。
1. 先给结论
项目 结果 权重 RedHatAI/Qwen3.8-27B-INT4(17.7 GB,自带model_mtp.safetensors)引擎 SGLang 0.5.17 + --speculative-algorithm NEXTN代码生成 decode 41.5 t/s → 89.3 t/s(+115%,接受率 0.94) 抽取原文 decode 40.5 t/s → 86.6 t/s(+114%,接受率 0.93) 中文创作 decode 41.5 t/s → 59.7 t/s(+44%,接受率 0.51) 双流聚合 82.9 t/s → 170.5 t/s prefill(13.7K 提示) 1846 → 1775 tok/s(−4%,基本无损) 128K 上下文下的解码 34.4 → 37.8 t/s(接受长度掉到 2.0,增益变小但仍为正) 代价 无(见第 7 节:原本以为要牺牲上下文,最后发现是 SGLang 的一个 bug,修完池子反而 ×4.7) 一句话:MTP 能让解码翻倍(短上下文)到 +10%(128K 长上下文),而且第 7 节那个 bug 修掉之后,连单路 128K 都能带着 MTP 一起跑。
2. 换权重:先验证包是不是自洽的
上一篇(llama.cpp + Q6_K 那篇)我在 hotdogs 的 AWQ 包上栽过:它的
config.json用model.language_model.前缀声明"忽略 linear_attn",但文件里真实键名是model.layers.,而且 linear_attn 其实被量化了 —— 结果加载器按"未量化"去找 bf16 权重,找不到,输出满屏乱码。所以这次拿到 RedHatAI 的包先做了自洽性检查:
# 1) ignore 列表里的名字和文件里的真实键名对得上吗 # 2) 声明 bf16 的层,文件里真的是 bf16 吗 model.language_model.layers.0.linear_attn.in_proj_qkv.weight_packed I32 ← 量化,符合预期 model.language_model.layers.0.linear_attn.in_proj_a.weight BF16 ← 真 bf16,符合预期 linear_attn.in_proj_a.weight (bf16) 层数: 48 ← 48 层全对 linear_attn.in_proj_a.weight_packed 层数: 0 ← 没有残留的 packed 版本这个包是干净的:前缀和架构(
Qwen3_5ForConditionalGeneration)一致,in_proj_a/b(输出维度只有 48,过不了 Marlin 的 64 整除要求)确实保留 bf16,re:^mtp.*让 MTP 层也保持 bf16。直接就能跑,不用改权重。3. 下载坑:HF Xet 存储走不了镜像
用 hf-mirror 下载时直接报错:
RuntimeError: Task error: File reconstruction error: CAS Client Error: HTTP status client error (401 Unauthorized), domain: https://cas-server.xethub.hf.co/v2/reconstructions/...原因:这个仓库用的是 HF 的 Xet 存储,客户端会绕过
HF_ENDPOINT直接去cas-server.xethub.hf.co取数据,所以镜像是失效的、而且直连会 401。解决:关掉 Xet,走经典 HTTP 路径(这样才会真正走 hf-mirror):
export HF_ENDPOINT=https://hf-mirror.com export HF_HUB_DISABLE_XET=1 # ← 关键 hf download RedHatAI/Qwen3.8-27B-INT4 --local-dir /mnt/sda6/download/qwen38-redhat-int4关掉之后速度稳定在 32 MB/s,19.5 GB 十分钟左右下完。
4. 打开 NEXTN 投机
--speculative-algorithm NEXTN \ --speculative-draft-model-path <模型目录> \ --speculative-eagle-topk 1 \ --speculative-num-steps 3 \ --speculative-num-draft-tokens 4--speculative-draft-model-path指模型自己的目录就行:model.safetensors.index.json里同时引用了model.safetensors和model_mtp.safetensors,draft 加载器会把mtp.*的键挑出来用。效果(同一个模型,只差投机开关):
负载 MTP 关 MTP 开 提升 接受率 接受长度 代码生成 41.5 t/s 89.3 t/s +115% 0.941 3.76 抽取原文 40.5 t/s 86.6 t/s +114% 0.931 3.66 中文创作 41.5 t/s 59.7 t/s +44% 0.513 2.56 双流聚合 82.9 t/s 170.5 t/s +109% — — 对比之前在没有 MTP 权重时用 NGRAM 投机的成绩(接受率只有 0.06-0.23):训练过的 draft head 和 n-gram 匹配完全不是一个量级,接受率差了 4-15 倍。
5. 最大的坑:MTP draft 白占 4.7 GB,KV 池被压到 1.4 万
打开 MTP 后服务起来了,但一查 KV 池:
Load weight end. type=Qwen3_5ForConditionalGeneration, mem usage=17.67 GB ← 目标模型 Load weight end. type=Qwen3_5ForCausalLMMTP, mem usage=5.53 GB ← MTP draft?! Mamba Cache is allocated. ssm_state size: 0.98GB, intermediate_ssm_state_cache size: 1.12GB KV Cache is allocated. #tokens: 14161 ← 只有 0.44 GB! Memory pool end. avail mem=4.17 GB ← 还剩 4GB 没被用draft 的权重文件只有 0.85 GB,加载却占 5.53 GB。翻代码发现
Qwen3_5ForCausalLMMTP.__init__会给 draft 建自己的embed_tokens和lm_head(各 248320×5120×2 byte = 2.5 GB),但运行时eagle_worker_v2.init_lm_head()会调用set_embed_and_head():def set_embed_and_head(self, embed, head): del self.model.embed_tokens.weight # 删掉自己的 if not self.config.tie_word_embeddings: del self.lm_head.weight self.model.embed_tokens.weight = embed # 换成目标模型的张量引用 self.lm_head.weight = head torch.cuda.empty_cache()问题是:KV 池是在这之前就按"draft 还占着 5.53GB"算好尺寸的,之后
empty_cache()释放出来的 5GB 就白空着了(所以才有avail mem=4.17 GB却只给 1.4 万 token 的怪现象)。修法:构造时直接把这两个权重换成 0 尺寸占位(
del仍能成功,之后会被目标张量替换):# Qwen3_5ForCausalLMMTP.__init__ 末尾插入 self.model.embed_tokens.weight = torch.nn.Parameter(torch.empty(0, device=dev)) if not config.tie_word_embeddings: self.lm_head.weight = torch.nn.Parameter(torch.empty(0, device=dev))效果立竿见影:
指标 打补丁前 打补丁后 draft 加载显存 5.53 GB 0.79 GB KV 池 14161 token 45423 token(3.2 倍) 提醒:
weight必须是nn.Parameter,直接赋torch.empty(0)会报TypeError: cannot assign ... as parameter 'weight'。6. 并发被 mamba 槽位卡住
补丁之后最大上下文到 45K,但日志还有一条:
max_running_requests is capped to 1 by the mamba state cache (max_mamba_cache_size=6, 5 state slots per request)Qwen3.8 是混合架构(48 层 GDN 线性注意力 + 16 层全注意力),投机解码下每个请求要占用 5 个 mamba 状态槽(要在验证失败时回滚)。默认/我们之前用的 6 槽只够 1 个请求,双路并发直接变成排队。
解决:
--max-mamba-cache-size 16+--mamba-ssm-dtype bfloat16(SSM 状态用 bf16 存储,槽位成本减半)→ 并发恢复成 2,而且 mamba 显存从 0.98 GB 变成 1.20 GB(16 槽 bf16),中间态缓存反而更省。7. 真正的元凶:MTP 层数识别错误,把 KV 池砍到了 1/5
修完上面的坑,KV 池到 45423,但 128K 需要 ≥131072,于是开始找显存。我先试了各种参数(mem-fraction 0.92→0.99、并发降到 1、prefill 压到 2048、hicache 缩到 4、关 CUDA graph、减投机步数),池子最多只到 60973,而且每次分配完都还空着 5-6 GB 显存 —— 说明不是显存不够,是算错了。
于是往 sizing 代码里插了调试日志,一次就露馅了:
无投机: cell_size=32768B (32.0KiB/token) num_layers=16 eagle_draft_layers=None → KV 池 279214 有投机: cell_size=32768B → EAGLE 缩放生效: eagle_draft_layers=64 num_layers=16 → cell_size=163840B (×5) → KV 池 44913pool_configurator.py里有一段为投机解码准备的缩放:# EAGLE/STANDALONE: scale cell_size to account for draft model KV cache self._cell_size = int( self._cell_size * (1 + int(eagle_draft_num_layers) / int(num_layers)) )而
eagle_draft_num_layers的来源在spec_aux_hidden_state.py:num_nextn_predict_layers = draft_model_config.num_nextn_predict_layers if num_nextn_predict_layers is not None: config.eagle_draft_num_layers = int(num_nextn_predict_layers) else: config.eagle_draft_num_layers = int(max( draft_model_config.num_hidden_layers, # ← 因为 draft 路径就是模型自己,这里拿到 64 draft_model_config.num_attention_layers, ))Qwen3.5/3.8 的 MTP 层数写在
text_config.mtp_num_hidden_layers里(=1),不是 DeepSeek/GLM 用的num_nextn_predict_layers。字段认不出来 → 走了 fallback →max(num_hidden_layers, ...)= 64(因为--speculative-draft-model-path指向模型自己的目录,读到的是整模型的 64 层)。而真正带 KV 的层只有 16 层(48 层是 GDN 线性注意力,不占 KV),于是缩放系数变成
1 + 64/16 = 5,每 token 成本被高估 5 倍,KV 池被砍到 1/5。修法(一行级的补丁)
fix_mtp_draft_layers.py:在 fallback 之前先认mtp_num_hidden_layers:elif _mtp_layers is not None and int(_mtp_layers) > 0: # mtp_num_hidden_layers config.eagle_draft_num_layers = int(_mtp_layers)效果
mem-fraction 修复前 KV 池 修复后 KV 池 0.92 44913 211357(4.7 倍) 0.97 59150 257367 修好之后,单路 128K + MTP 直接就跑通了:
配置 prompt prefill 解码 @128K 接受长度 MTP 126863 tok 111.4 s(1139 tok/s) 37.8 t/s 2.00 无 MTP(同 mamba 配置) 126863 tok 106.9 s(1187 tok/s) 34.4 t/s — 也就是说 128K 上下文下 MTP 仍有 +10%(接受长度从短上下文的 3.76 掉到 2.0,所以增益变小,但没变负)。
另外补充一句:社区那个 PR #37155 是真的,它把 draft 的 vocab 层(embed/lm_head)释放时机提前到 KV 池划分之前 —— 但那解决的是另一个问题(2.4 GiB 的 vocab 层白占),和这里的 5 倍 cell_size 是两码事。两处都值得修:vocab 层那个值 2.4 GB,层数这个值池子的 4.7 倍。
一个还没解决的并发崩溃
修好池子后,双路 2×60K 并发(两个请求都成功完成,51.4s)之后服务会崩在同一个 mamba 槽位断言上:
AssertionError: Can not alloc mamba cache mem_cache/unified_cache/components/mamba_component.py:479 _alloc_mamba_slot ← cache_unfinished_req ← stash_chunked_request而且这个崩溃
--mamba-ssm-dtype bfloat16+ 16 槽 才会出现;换回--max-mamba-cache-size 6+ 默认 float32,同样 2×64K 并发是稳的(只是投机下并发会被压成 1)。所以:- MTP + 单路 128K:
--max-mamba-cache-size 6(默认 dtype),稳
- MTP + 双路(短/中上下文):bf16 + 16 槽,但别跑 2×60K 这种长并发
8. 顺带抓到一个会崩服务的 bug(强烈建议避开)
为了让长上下文档多留并发槽位,我把 mamba 调到 16 槽 + bf16 之后,跑 2×64K 并发,服务整个崩了:
AssertionError: Can not alloc mamba cache sglang/srt/mem_cache/unified_cache/components/mamba_component.py:479 _alloc_mamba_slot ← cache_unfinished_req ← stash_chunked_request (chunked prefill 每分一块都要暂存 mamba 状态) → SIGQUIT → scheduler 异常 → 整个服务退出根因:chunked prefill 是分块的,每分一块都要暂存一次当前 mamba 状态;长提示分块多、并发两个就更容易把槽位耗光,而这里只有一句 assert,没有任何保护或降级,直接崩进程。
换成验证过的
--max-mamba-cache-size 6之后单路 128K 和 2×64K 都稳定(服务存活),但并发会被压成 1:2×64K 墙钟 84.4s ≈ 2×42s,是排队串行而不是并行。9. 顺便把不带 MTP 的单路 128K 验了
测试 prompt prefill 解码 @128K 单路 ~127K 123462 tok 103.0 s(1199 tok/s) 34.5 t/s 同上重放 123286 tok 102.5 s(1202 tok/s) 37.1 t/s KV 池 289577 token,单路 128K 只占 43%。注意 fp8 KV 下长上下文的解码会从 41.5 掉到 34-37 t/s。
10. 最终启动脚本(三档切换)
跑 MTP 之前请先打两个补丁:
patch_mtp_lmhead.py(draft vocab 层占位,省 4.7 GB)和
fix_mtp_draft_layers.py(修 MTP 层数识别,KV 池 ×4.7)。两个都是幂等的。# ===== 档 A:MTP + 单路 128K(修复后新增,decode 37.8 t/s @128K)===== python -m sglang.launch_server \ --model-path /mnt/sda6/download/qwen38-redhat-int4 \ --language-only \ --context-length 131072 --max-running-requests 1 \ --mem-fraction-static 0.92 \ --kv-cache-dtype fp8_e4m3 --page-size 1 \ --max-mamba-cache-size 6 \ --enable-hierarchical-cache --hicache-size 12 \ --hicache-write-policy write_through \ --speculative-algorithm NEXTN \ --speculative-draft-model-path /mnt/sda6/download/qwen38-redhat-int4 \ --speculative-eagle-topk 1 \ --speculative-num-steps 3 --speculative-num-draft-tokens 4 \ --trust-remote-code # ===== 档 B:MTP 速度优先(短/中上下文,code 89 t/s、双流聚合 170 t/s)===== python -m sglang.launch_server \ --model-path /mnt/sda6/download/qwen38-redhat-int4 \ --language-only \ --context-length 32768 --max-running-requests 2 \ --mem-fraction-static 0.92 \ --kv-cache-dtype fp8_e4m3 --page-size 1 \ --max-mamba-cache-size 16 --mamba-ssm-dtype bfloat16 \ --enable-hierarchical-cache --hicache-size 12 \ --hicache-write-policy write_through \ --speculative-algorithm NEXTN \ --speculative-draft-model-path /mnt/sda6/download/qwen38-redhat-int4 \ --speculative-eagle-topk 1 \ --speculative-num-steps 3 --speculative-num-draft-tokens 4 \ --trust-remote-code # ===== 档 C:长上下文/双路(无 MTP,最稳,41.5 t/s)===== python -m sglang.launch_server \ --model-path /mnt/sda6/download/qwen38-redhat-int4 \ --language-only \ --context-length 131072 --max-running-requests 2 \ --mem-fraction-static 0.92 \ --kv-cache-dtype fp8_e4m3 --page-size 1 \ --max-mamba-cache-size 6 \ --enable-hierarchical-cache --hicache-size 12 \ --hicache-write-policy write_through \ --trust-remote-code11. 给同是小白的提醒
- 换带 MTP 的权重前,先确认它带
model_mtp.safetensors,并检查model.safetensors.index.json里有没有引用它。 - Xet 存储的仓库必须
HF_HUB_DISABLE_XET=1,否则镜像站等于没用,还会 401。 - MTP 打开后第一件事是看 KV 池(日志里的
max_total_num_tokens),别以为服务起来了就没事 —— 池子被挤到 1.4 万 token 时,长提示会被 400 拒掉。 - 池子异常小的时候,别急着调参数,先去读 sizing 代码。我这次就是靠往
_profile_available_bytes/_compute_cell_size里插几行日志,一眼看出cell_size被放大了 5 倍 —— 参数调到天亮也没用,因为那是算错而不是不够。 max_running_requests is capped to 1这条日志一定要看,混合 mamba 模型 + 投机解码下每请求要 5 个 mamba 槽,槽不够就变排队。--max-mamba-cache-size别乱调大:它和 chunked prefill 的交互有个会崩整个服务的断言(Can not alloc mamba cache),调完一定要跑 2×64K 压测。- 投机参数要自己扫(和扫 n-max 一样),steps 越大代码/回声越快、但创作类接受率反而下降:1/2 → 接受率 0.97/0.95/0.74;3/4 → 0.94/0.93/0.51;5/6 → 0.91/—/0.42。而且上下文越长接受长度越低:短上下文 3.76,128K 时只有 2.0(增益从 +115% 降到 +10%)。
- 贪婪解码下投机不改变结果:3 个测试题里 2 个输出和关闭 MTP 时逐字节一致,1 个措辞不同(bf16 下 batch-verify 与单 token 路径的数值抖动),不是投机算错。
感谢论坛里关于 MTP/投机解码的讨论,让我这次少走了不少弯路。
- MTP + 单路 128K:
-
同机四方实测总表:llama.cpp Q6_K / llama.cpp Q4_K_M / SGLang INT4 / SGLang INT4+MTP,谁最强@Enigma 很奇怪,好像跑的还不如3090 24G在sglang下面
可能参数没调好,有时间再调调


