跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • 用户
  • 群组
皮肤
  • 浅色
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • 深色
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • 默认(LCZ-Blue)
  • 不使用皮肤
  • LCZ-Green
  • LCZ-Blue
  • LCZ-Black
折叠
品牌标识

抡锤者

首页 版块 标签
硬件
装备秀
AI 广场
E

Enigma

@Enigma
德高望重
取消关注 关注
关于
帖子
37
主题
8
分享
0
群组
1
粉丝
0
关注
0

帖子

最新 最佳 有争议的

  • SGLang :Qwen3.8-27B INT4 开 MTP 投机 从 41 跑到 89 t/s 踩坑记录(draft 白占 4.7G + 一个会崩服务的坑)
    E Enigma
    LLM讨论区 sg-lang mtp qwen-27b

    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%,基本无损)
    代价 KV 池 289577 → 45423 token,可用上下文从 128K 掉到 ~45K

    一句话:MTP 能让解码翻倍,但会把长上下文挤没了;想两者兼得,得先解决下面第 5 节那个 5GB 的浪费。

    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 池到 45423。但 128K 上下文需要 ≥131072,于是开始找显存:

    配置 KV 池
    mf 0.92, mrr 2 45423
    mf 0.97, mrr 2, mamba 16+bf16 55228
    mf 0.99, mrr 2 59150
    mf 0.97, mrr 1, prefill 2048, hicache 4(全压上去) 60973 ← 上限
    --max-total-tokens 262144 强制指定 55228(被忽略)

    也就是说:把并发降到 1、mem-fraction 拉到 0.97、prefill 预留压到 2048、hicache 缩到 4,池子也只能到 6 万 token,而且分配完还空着 5-6 GB 显存。SGLang 给 MTP 留了约 8GB 的结构性预留,并且:关 CUDA graph 没用、把投机步数从 3 降到 1 只多 2.9K token、--max-total-tokens 直接被忽略。

    结论:这张 32GB 卡上,MTP 档的可用上下文 ≈ 45K(保守跑 32K),换 2.1 倍解码速度。要 128K 就得关掉 MTP。

    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. 最终启动脚本(两档切换)

    # ===== 档 A:速度优先(MTP,32K 上下文,86-91 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
    
    # ===== 档 B:长上下文优先(无 MTP,128K 单路 / 2×64K 串行,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-code
    

    11. 给同是小白的提醒

    1. 换带 MTP 的权重前,先确认它带 model_mtp.safetensors,并检查 model.safetensors.index.json 里有没有引用它。
    2. Xet 存储的仓库必须 HF_HUB_DISABLE_XET=1,否则镜像站等于没用,还会 401。
    3. MTP 打开后第一件事是看 KV 池(日志里的 max_total_num_tokens),别以为服务起来了就没事 —— 池子被挤到 1.4 万 token 时,长提示会被 400 拒掉。
    4. max_running_requests is capped to 1 这条日志一定要看,混合 mamba 模型 + 投机解码下每请求要 5 个 mamba 槽,槽不够就变排队。
    5. --max-mamba-cache-size 别乱调大:它和 chunked prefill 的交互有个会崩整个服务的断言,调完一定要跑 2×64K 压测。
    6. 投机参数要自己扫(和扫 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。
    7. 贪婪解码下投机不改变结果:3 个测试题里 2 个输出和关闭 MTP 时逐字节一致,1 个措辞不同(bf16 下 batch-verify 与单 token 路径的数值抖动),不是投机算错。

    感谢论坛里关于 MTP/投机解码的讨论,让我这次少走了不少弯路。


  • 抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机
    E Enigma
    LLM讨论区 rtx4080s qwen-27b ninfer

    看到论坛里 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.json 1200 题、combined_eval.json 420 题)。评分逻辑完全沿用原脚本,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 立即 cudaErrorCooperativeLaunchTooLarge
    2 3 路并发 + 长短混跑 ~4 分钟 Xid 120 GSP task exception: load access page fault
    3 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-graph 40.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=3 345,024 31,043 MiB 88.48 58.1%
    A2 rk4v4-e8 / C=3 654,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% 也不崩。

    三个必须知道的行为特征:

    1. prefill 是串行的,会饿死另一条通道:一条解码期撞上另一条 prefill 时被压到 3.3~3.7 t/s(日志实证 queue 5m35.8s、prefill 770.8 tok/s)。双路收益只在"两条都在解码"时兑现。
    2. 长 prompt 的 TTFT 是硬成本:63K→53s,127K→126s,200K→235s,258K→335s(prefill 770~860 tok/s)。
    3. 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

    九、结论

    1. 4080S 32G 跑 NInfer + Qwen3.8-27B 是可行的,不用编译,社区有现成的 sm_89 镜像。
    2. 质量和 SGLang 打平:AGIEval 1200 题 90.33% vs 90.17%,CEval+CMMLU 89.29% vs 87.62%。换引擎不会掉智力,反而在 CEval 上更高。
    3. 速度 SGLang 领先 11.5%(98.3 vs 88.2 t/s)。但 NInfer 的权重占用更小(16.96 GiB vs 19.56 GiB),并换来 262K 上下文和可用的双路并发(聚合 122 t/s)。两者量化方案与投机机制都不同,速度差不能纯归因于引擎。
    4. 这个 build 的并发是带雷的:必须 --no-cuda-graph,代价 2.6%。
    5. 两条硬红线:--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 请求的浸泡日志均可复现。


  • 小白抄作业成功 # 9700X+4080S 跑 Qwen3.8-27B Q6_K 128K双路+视觉 单路95t/s 踩坑记录
    E Enigma
    LLM讨论区 rtx4080s llama.cpp qwen-27b

    9700X + 4080S 32G 跑 Qwen3.8-27B 无审查版(Q6_K) 128K双路+视觉 完整部署与调优记录

    看到论坛大神的《4080S 跑 Qwen3.8-27B 实测 61.7t/s》帖子,照着调了一晚上,最终单路比大神还快一点(主要是 n-max 扫出来的甜点位不同),分享下我的踩坑过程给后面的小白参考。

    1. 整机配置

    • CPU:AMD Ryzen 7 9700X(8核16线程)
    • 主板:华硕 X670E HERO
    • 内存:64GB DDR5
    • 显卡:NVIDIA RTX 4080 SUPER 32GB(驱动 616.56)
    • 系统:Windows 11

    2. 先给结论

    项目 结果
    引擎 llama.cpp b10549(CUDA 后端)
    模型 Qwen3.8-27B-abliterated-Q6_K(20.9 GB)
    上下文 128K × 2 路并行(模型原生 262K)
    视觉 mmproj bf16(+0.84GB,支持图片输入)
    工具调用 decode 95.7 t/s(n-max 4)/ 81.8 t/s(n-max 3)
    代码生成 decode 86.9 t/s(n-max 4)/ 80.9 t/s(n-max 3)
    中文创作 decode 52.9 t/s(n-max 3)
    双路并发 45-78 t/s ×2,总吞吐 95-160 t/s
    显存占用 28.4 GB / 32 GB(含视觉)
    温度 峰值 50-55°C

    一句话:这台机器跑 Qwen3.8-27B Q6_K,单路 80-95 t/s、双路并发 45-78 t/s 是常态,128K 全 GPU 加载无 OOM,还能带视觉。

    3. 最大的坑:CUDA 没装上(小白必看)

    一开始模型加载了但 GPU 显存 0 MiB,纯 CPU 跑 5 t/s,以为显卡不行,查了半天:

    • nvidia-smi 显示正常(驱动 616.56,CUDA UMD 13.4)
    • llama-server --list-devices 返回 (none)
    • 系统里只有 NVIDIA 驱动,没有装 CUDA Toolkit(找不到 cudart64_13.dll)

    根因:驱动 ≠ CUDA Toolkit。驱动只让 nvidia-smi 能跑,但 ggml-cuda.dll 需要 cudart64_13.dll 才能枚举设备。论坛大神的机器装了 CUDA Toolkit 所以没这个问题。

    解决:装 CUDA Toolkit 13.3.1 网络安装器(9.9MB,静默安装 2 分钟):

    cuda_13.3.1_windows_network.exe -s -noaccepteula
    

    装完注意 PATH 要加 bin\x64(不是 bin,我一开始加错了还是 (none)):

    C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.3\bin\x64
    

    加完 --list-devices 立刻识别到 CUDA0: NVIDIA GeForce RTX 4080 SUPER (32759 MiB),速度直接从 5 t/s 跳到 80+。

    4. 调优过程(照着大神帖子改的)

    4.1 n-max 扫描(最大提速来源)

    大神帖子用的 n-max=2,我好奇扫了 2-5,发现 3 才是我的甜点位:

    n-max tool code prose 结论
    2 65.9 66.2 49.6 大神用的,偏保守
    3 81.8 80.9 52.9 我的甜点位(推荐)
    4 95.7 86.9 49.0 工具最快,创作开始掉
    5 89.7 90.7 40.8 创作崩了(接受率 0.23)

    n-max 必须自己扫,别人的 2 不代表你的甜点位也是 2。

    4.2 上下文 64K → 128K

    大神用的 64K,我试了 128K:显存从 24GB 涨到 27GB,速度只掉 5-11%(KV cache 翻倍,Flash Attention 开销增加),完全值得。Qwen3.8 原生 262K 不需要 YaRN。

    4.3 双路并行

    --parallel 2 + 128K:两个请求同时处理不排队,每个 45-78 t/s,总吞吐 95-160 t/s。也试了三路(--parallel 3),能跑但总吞吐反而降到 67 t/s,不划算。

    4.4 视觉

    Qwen3.8 是 VL 模型,加 --mmproj 加载视觉编码器(bf16,只占 0.84GB),128K 双路 + 视觉显存 28.4GB,余量 4.4GB,稳。

    4.5 稳定性

    双路 128K 跑了 15 分钟持续并发(690+ 请求):

    • 显存全程 27.5GB,零增长(无泄漏)
    • 温度峰值 50-55°C,离热降频(83°C)很远
    • 零崩溃零 OOM

    5. 最终启动脚本

    cd /d C:\llama-b10549-bin-win-cuda-13.3-x64
    set PATH=C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.3\bin\x64;%PATH%
    
    taskkill /f /im llama-server.exe >nul 2>&1
    timeout /t 2 >nul
    nvidia-smi --gpu-reset -i 0 >nul 2>&1
    timeout /t 1 >nul
    
    start "llama-server" llama-server.exe ^
        -m "C:\AI-MODEL\Huihui-Qwen3.8-27B-abliterated\Huihui-Qwen3.8-27B-abliterated-Q6_K.gguf" ^
        --mmproj "C:\AI-MODEL\Huihui-Qwen3.8-27B-abliterated\mmproj-model-bf16.gguf" ^
        --port 8080 --host 127.0.0.1 ^
        -t 8 --parallel 2 ^
        --jinja --reasoning off ^
        -fa on ^
        --cache-type-k q8_0 --cache-type-v q8_0 ^
        --fit-target 2048 ^
        -n 8192 ^
        -c 131072 ^
        --spec-type draft-mtp --spec-draft-n-max 3 ^
        --temp 0.7 --top-p 0.8 --top-k 20
    

    6. 给同是小白的提醒

    1. 驱动 ≠ CUDA Toolkit。只装驱动跑不了 CUDA offload,--list-devices 返回 (none) 就是这个原因
    2. CUDA 13.3 的 DLL 在 bin\x64 不是 bin,PATH 加错照样 (none)
    3. n-max 必须自己扫,抄别人的没用
    4. --reasoning off 不是 --reasoning-budget 0,前者关思考链,后者只是截断
    5. 双路是甜点位,三路总吞吐反而降

    感谢论坛大神们的帖子,让我这个小白少走了一晚上弯路。


  • # SGLang 投机解码在 32GB 卡上的选型指南:DFlash2 / MTP / 无投机实测
    E Enigma
    LLM讨论区 sg-lang dflash 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,只能靠三件事挤出来:

    1. --mem-fraction-static 0.88 → 0.92(+1.3GB)
    2. --mamba-ssm-dtype bfloat16(ssm 状态减半,省 1.75GB)
    3. --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。


  • 同机四方实测总表:llama.cpp Q6_K / llama.cpp Q4_K_M / SGLang INT4 / SGLang INT4+MTP,谁最强
    E Enigma
    LLM讨论区 sg-lang llama.cpp qwen-27b

    对比四方(llama.cpp Q6_K+MTP、SGLang INT4 无投机、SGLang INT4+MTP、unsloth UD-Q4_K_M + MTP)测试报告,凑成四方总表。

    结论先说:综合最强的是 llama.cpp + unsloth UD-Q4_K_M + MTP —— 它在 256K 上下文双路并发下还能跑 161 t/s,单路代码破 100 t/s,这是 SGLang 这套做不到的(我们的 MTP 只能跑 32K)。

    1. 四方总表

    项目 ① llama.cpp<br>Q6_K + MTP ② llama.cpp<br>unsloth Q4_K_M + MTP ③ SGLang<br>INT4 无投机 ④ SGLang<br>INT4 + NEXTN
    系统 Windows 11 Windows 11 Ubuntu Ubuntu
    引擎 llama.cpp b10549 llama.cpp b10549 SGLang 0.5.17 SGLang 0.5.17
    权重 Q6_K 20.89 GB UD-Q4_K_M 15.33 GB RedHatAI INT4 17.7 GB 同 ③
    投机 draft-mtp n-max 3 draft-mtp n-max 2~5 无 NEXTN steps3/draft4
    code 解码 80.9 t/s 101.2(nmax4)/ 94.7(nmax3) 41.5 t/s 89.3 t/s
    tool 解码 81.8 t/s 91.1(nmax4)/ 80.2(nmax3) 40.5 t/s 86.6 t/s
    prose 解码 52.9 t/s 59.7(nmax3) 41.5 t/s 59.7 t/s
    双路总吞吐 95-160(波动大) 161.4(±0.3%,3 轮) 82.9 t/s 170.5 t/s
    可用上下文 128K × 2 256K × 2 128K(单路实测 123K) ~45K(建议 32K)
    显存占用 28.4 GB(含视觉) 27.6 GB(256K KV,无视觉) 30.4 GB 26.2 GB
    MTP 接受率 未记录 tool 87-100% / code 87-89% / prose 42-45% — 0.94 / 0.93 / 0.51
    接受长度 未记录 n-max4:4.22-4.50 — steps3:3.66-3.76
    视觉 ✅ mmproj(+0.84 GB) 未加载(可加,+870 MiB) 该权重有视觉塔 可 --language-only 跳过
    prefill 未测 报告未列出 1680-1915 tok/s(123K 时 1199) ~1775 tok/s
    无审查 ✅(去审版) 未标注 ❌ 原始模型 ❌ 原始模型

    2. 口径说明(不看这段会误判)

    四组数据的"上下文"完全不同,这是最容易误读的地方:

    组 上下文 意味着
    ① Q6_K 128K × 2 中等偏长
    ② UD-Q4_K_M 256K × 2 最长,且是并发
    ③ SGLang 无投机 128K(单路) 长
    ④ SGLang + MTP 32K 最短(受显存限制被迫压缩)

    其余差异:

    • ① ② 用 temp 0.7 / top-p 0.8 / top-k 20,③ ④ 用贪婪解码(temp 0)
    • ① ② 输出长度 tool 36 / code 300 / prose 338-363 token;③ ④ 取 128 token
    • ① ② 的 tool 负载是 JSON 工具调用;③ ④ 的对应项是"抽取原文(回声型)"
    • ①② 的 KV 是 q8_0 + -fa on;③④ 是 fp8_e4m3

    所以 ④ 的 170.5 t/s 双路吞吐虽然最高,但它是在 32K 上下文下测的;② 的 161.4 t/s 是在 256K 上下文下测的。论"每单位上下文的速度",② 明显更强。

    3. 分项解读

    3.1 解码速度:Q4_K_M + MTP 单路最快

    负载 ① Q6_K+MTP ② Q4_K_M+MTP ④ SGLang+MTP ③ SGLang 无投机
    code 80.9 101.2 89.3 41.5
    tool 81.8 91.1 86.6 40.5
    prose 52.9 59.7 59.7 41.5

    解码是显存带宽游戏,权重越小越快,四组的排名和权重体积排名几乎一致:

    15.33 GB (②)  >  17.7 GB (③④)  >  20.89 GB (①)
    

    ② 比 ④ 快 13%(code),主要就是 15.33 GB vs 17.7 GB 的差距。③ 慢是因为它完全没有投机 —— 也就是说,④ 相比 ③ 的 +115% 全部来自投机,和权重无关。

    3.2 双路扩展性:② 最稳,④ 峰值最高

    组 单路 双路 降幅
    ② Q4_K_M nmax3 tool 80.2 / code 94.7 tool 69.2 / code 92.2(总 161.4) tool −13.7% / code −2.6%
    ④ SGLang+MTP 89.3 / 86.6 90.9 / 79.6(总 170.5) code −5% / prose −11%
    ① Q6_K 80.9-81.8 总 95-160(波动大) 波动大

    ② 的双路三轮数据波动只有 ±0.3%(161.0 / 161.6 / 161.5),稳定性最好。报告里的解释也合理:tool 请求输出短(36 token),并发调度开销占比大所以掉 13.7%;code 输出长(300 token),几乎不掉速。

    3.3 上下文与显存:这才是 ② 的杀手锏

    组 上下文 显存 余量
    ② Q4_K_M 256K × 2 27636 MiB 5124 MiB
    ① Q6_K 128K × 2(含视觉) 28.4 GB 4.4 GB
    ③ SGLang 无投机 128K 30.4 GB 1.8 GB
    ④ SGLang + MTP 32K 26.2 GB 6.0 GB

    ② 能做到 256K 双路 + MTP 只占 27.6 GB,秘籍在报告里那句话:"KV 池按实际使用分配,不是预分配全上下文" —— 双路只比单路多 342 MiB,n-max 每 +1 只多 150 MiB(草稿模型 KV)。

    而 SGLang 是预分配:打开 MTP 后它会留约 8 GB 结构性预留,KV 池被硬顶在 45423 token(把并发降到 1、mem-fraction 拉到 0.97、prefill 压到 2048 也只到 60973),--max-total-tokens 还被忽略。

    这就是"llama.cpp 能 256K×2 + MTP、SGLang 只能 32K + MTP"的根本原因 —— 不是引擎算得慢,是显存分配策略不同。

    3.4 MTP 参数扫描对照

    两组都印证了同一个规律:n-max / steps 越大,可预测任务越快,创作类接受率反而下降。

    tool code prose
    ② n-max 2 81.0 80.3 55.4
    ② n-max 3 80.2 94.7 59.7
    ② n-max 4 91.1 101.2 52.4
    ② n-max 5 82.9 100.9 50.1
    ④ steps 1/2 60.5 62.0 54.6
    ④ steps 3/4 86.6 89.3 59.7
    ④ steps 5/6 —(显存不足) 108.7 59.6

    ② 的接受率:n-max 2 → tool 100% / code 89.3% / prose 45.2%;n-max 4 → 81.2% / 80.9% / 29.6%。
    ④ 的接受率:steps 1/2 → 0.95 / 0.97 / 0.74;steps 3/4 → 0.93 / 0.94 / 0.51。

    ② 的接受长度在 n-max 4 时到 4.22-4.50,已经和帖子 1356 里 SGLang NEXTN 的 3.8-4.2 持平甚至更高;④ 的 steps3 是 3.66-3.76。④ 理论上也能上 steps 5(code 108.7 t/s、接受长度 5.57),但 KV 池会被压到 10403 token,13.7K 的提示直接 400 —— 又是那个显存分配问题。

    3.5 prefill

    只有 ③④ 有干净的 prefill 数据(SGLang):

    提示长度 SGLang INT4
    512 1680 tok/s
    2.1K 1915 tok/s
    8.5K 1888 tok/s
    34K 1682 tok/s
    68K 1461 tok/s
    123K 1199 tok/s

    ①② 的 llama.cpp 响应体里其实有 prompt_per_second 字段(报告第八节提到取数方式),建议补一组 llama-bench -p 512,8192,65536 -n 128,四方就能在同一口径下比 prefill。SGLang 的 chunked prefill 在 512→123K 只掉 30%,这是它目前唯一稳赢的项。

    3.6 功能面

    能力 ① ② ③④
    视觉 ✅ mmproj 可加(+870 MiB) 该权重有视觉塔
    前缀复用 prompt cache per-slot 同 ✅ HiCache 两级,17.9K 前缀 10.2s → 1.25s
    并发模型 固定 --parallel N 同 continuous batching(但受 mamba 态限制)
    部署复杂度 单 exe,最简 同 Python 环境 + 一堆参数
    无审查 ✅ 未标注 ❌(原始模型)

    4. 结论与选型

    你的场景 推荐 理由
    要最快 + 长上下文(综合最强) ② llama.cpp + UD-Q4_K_M + MTP 256K×2 还能 161 t/s,单路 code 101.2
    只要单路最快(agent / 代码) ② 用 --parallel 1 --spec-draft-n-max 4 code 101.2 / tool 91.1
    中文创作为主 ② 或 ④ 用 n-max/steps 3 都是 59.7 t/s
    高并发批量服务 + 前缀高度重叠 ④/③ SGLang + HiCache prefix 复用秒级,continuous batching
    要 128K 长文档但不想折腾 ③ SGLang 无投机 / ① Q6_K 128K 稳
    要视觉输入 + 长上下文 ① Q6_K + mmproj ② 也支持但要另加 mmproj
    追求量化精度 ① Q6_K 6.6 bpw 最保险

    如果只记一句话:llama.cpp 的"KV 按需分配"让它在 MTP + 长上下文这个组合上完胜;SGLang 的"预分配 + MTP 预留"把上下文锁死在 45K,但它换来了 HiCache 前缀复用和更高的 prefill。

    5. 附:② 的推荐参数(来自 unsloth 测试报告)

    :: 单任务优先(最快)
    -c 262144 --parallel 1 --spec-draft-n-max 4
    
    :: 同时跑两个任务(吞吐优先)
    -c 262144 --parallel 2 --spec-draft-n-max 3
    
    :: 中文创作为主(prose 峰值)
    -c 262144 --parallel 1 --spec-draft-n-max 3
    

  • SGLang 跑通:9700X+4080S 32G 跑 Qwen3.8-27B AWQ-INT4 双路128K+三级HiCache 从满屏乱码到跑通
    E Enigma
    AI Agent sg-lang rtx4080s qwen-27b

    4080S 32G 用 SGLang 跑 Qwen3.8-27B AWQ-INT4:双路 128K + 三级 HiCache,从"满屏乱码"到跑通的完整踩坑记录

    上一篇写的是 llama.cpp + Q6_K 的成绩(单路 80-95 t/s、双路 128K + 视觉)。这次换 SGLang 引擎,目标是论坛大神帖子里那套 HiCache 三级 KV 缓存 + 双路 128K。

    结果一晚上下来,坑比上次多得多,而且最大的坑不在 SGLang,在网上下载的那个 AWQ 权重包本身——它的 config 和权重文件自相矛盾,SGLang 和 HuggingFace transformers 加载出来都是满屏乱码。这篇把根因、修法、实测数据全部放出来,照着抄能省你一整晚。

    1. 整机配置

    • CPU:AMD Ryzen 7 9700X(8核16线程)
    • 内存:64GB DDR5
    • 显卡:NVIDIA RTX 4080 SUPER 32GB(32760 MiB)
    • 系统:Ubuntu(SGLang 这套)/Windows 11(上一篇 llama.cpp 那套)
    • 引擎:SGLang 0.5.17 + torch 2.11.0 + sglang-kernel 0.4.5
    • 模型:hotdogs/Qwen3.8-27B-abliterated-AWQ-INT4(16.5 GB,每 token KV 32 KB)

    2. 先给结论

    项目 结果
    原始 AWQ 权重包能直接用吗 ❌ 不能,config 与权重文件自相矛盾,SGLang 和 HF 都输出乱码
    修好之后能不能用 ✅ 能,输出正常(The capital of France is Paris.)
    双路 128K 并发 ✅ 跑通,2×123K token 并发,峰值显存 31114 / 32760 MiB
    HiCache L1(GPU KV) ✅ 生效
    HiCache L2(主机内存 12GB) ✅ 生效,17.9K 前缀 10.2s → 1.25s
    HiCache L3(磁盘) ⚠️ 只写不读,能落盘但 0.5.17 在混合模型上从不触发预取
    MTP 投机解码 ❌ 这个 checkpoint 没有 MTP 权重,方案里的 MTP 项做不了
    pp512(prefill) 1737 tok/s
    单路 decode 41.4 tok/s(不投机)/47-75 tok/s(开 NGRAM)
    双路 decode 聚合 74.8 tok/s

    一句话:SGLang 这套 prefill 强、前缀复用强、并发强,但 decode 打不过 llama.cpp 的 MTP;想追平要么开 NGRAM,要么换带 MTP 的权重。

    3. 最大的坑:AWQ 权重包的 config 和权重文件对不上

    3.1 现象

    服务器起来了,健康检查 200,但不管问什么都输出乱码:

    The capital of France is  →  " is is is is is is is is is is is is is is"
    中文提问                  →  "!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
    

    换温度、换 KV 精度、换层数截断、--load-format dummy(随机权重)都试了:随机权重反而能输出正常 token,真权重反而是乱码 —— 说明模型结构没问题,问题出在"权重没加载对"。

    3.2 怎么定位到根因

    先看 config.json 里声明哪些层不量化:

    "ignore": [
      "model.language_model.layers.0.linear_attn",
      "model.language_model.layers.0.linear_attn.norm",
      ...
    ]
    

    再去 safetensors 里数真实张量名:

    model.layers.0.linear_attn.in_proj_qkv.weight_packed   # ← 真实名字,没有 language_model
    model.layers.0.linear_attn.in_proj_qkv.weight_scale
    

    对不上。 这个包的架构声明是 Qwen3_5ForCausalLM(纯文本),根本没有 model.language_model. 这一层(那是多模态 Qwen3_5ForConditionalGeneration 的命名)。所以那 97 条 ignore 一条都没生效。

    更坑的是第二点:文件里 linear_attn 其实被量化了。逐层验证:

    48 个 GDN 层全部是 weight_packed(int32) + weight_scale
    linear_attn 层里有 bf16 .weight 的:0 个
    

    也就是 README 里写的"linear_attn 保持 BF16"是假的,实际全被量化了(文件 16.44 GiB 也能印证,真要是 bf16 会多出 8GB)。

    3.3 于是两条加载路径都是死路

    走法 结果
    ignore 生效(把 GDN 当未量化) 文件里没有 bf16 权重 → 加载器找不到 → 随机/空权重 → 乱码
    ignore 不生效(把 GDN 当量化) 撞上 Marlin 的 size_n % 64 == 0:GDN 融合后的 in_proj_ba 是 [48,48]=96 → 直接崩

    第二种就是上游 issue #19406 那个报错。用 HF transformers 加载也是乱码(报 in_proj_qkv.weight MISSING / weight_packed UNEXPECTED),同一个根因,所以这真不是引擎的锅。

    顺便排除掉的怀疑对象:

    • int4 的码字和 scale 数据本身是自洽的(每组 code 饱和在 ±7,max|code-8|/scale ≈ 7)→ 权重没坏,坏的只是 config
    • A_log 的 float32 补丁、GDN stride 修复(commit 8ba9646)在 0.5.17 里已经存在
    • KV dtype fp8/auto、--page-size、fused/legacy 的 GDN 拆分路径,全都试过,都不是原因

    3.4 修法(关键:只反量化两个小矩阵)

    对照官方 RedHatAI/Qwen3.8-27B-INT4 的 config 就能看出差别:它把 in_proj_a / in_proj_b 也放进了 ignore。因为这两个投影输出维度只有 48,过不了 Marlin 的 64 整除要求。

    所以修法是:把 48 层的 in_proj_a/in_proj_b 离线反量化成 bf16,其余量化权重按字节原样搬运。

    # 每个 GDN 层只有两个 [48, 5120] 的小矩阵,96 个张量一共才 47 MB
    def dequant8(pk, sc):          # pk: int32,每个 int32 装 8 个 int4
        x = pk.to(torch.int64)
        codes = torch.stack([(x >> (4 * i)) & 0xF for i in range(8)], dim=2).flatten(1)
        g = codes.shape[1] // sc.shape[1]
        return ((codes - 8).float() * sc.float().repeat_interleave(g, dim=1)).to(torch.bfloat16)
    

    然后把 ignore 改成和真实张量名一致:

    lm_head
    model.layers.{N}.linear_attn.in_proj_a
    model.layers.{N}.linear_attn.in_proj_b
    

    SGLang 内部有一张 packed_modules_mapping:in_proj_ba → [in_proj_b, in_proj_a],所以只要把两个分片都标成忽略,融合后的 in_proj_ba 会自动走未量化路径;而 in_proj_qkvz(2048/2048/6144/6144,每个都能被 64 整除)继续走 int4 Marlin。

    显存只多 47 MB,其他什么都不用改。 改完立刻正常:

    greedy:  " Paris.\nThe capital of Germany is Berlin.\nThe capital of Italy is Rome..."
    chat  :  "法国的首都是巴黎。"
    

    额外提醒:网上有个 PR(#22618)给 Qwen3.5 加了个"ignore 里有 linear_attn 就把整个 GDN 设成未量化"的 guard。如果你的权重是这种"声明忽略但实际量化"的包,千万别打那个补丁 —— 它会把整个 GDN 变未量化,反而让你连权重都加载不上。

    4. HiCache 三级缓存实测

    启动基线:--enable-hierarchical-cache --hicache-size 12 --hicache-write-policy write_through --page-size 1

    L1(GPU KV 池)✅

    重复前缀直接命中显存:cached_tokens_details {'device': 43008, 'host': 0, 'storage': 0},缓存命中率 0.96。

    L2(主机内存)✅ 读取已验证

    把 L1 压小(--max-total-tokens 32768),发 3 个互不重复的 17.9K 前缀,再重放第 1 个:

    请求 prompt cached 命中来源 耗时
    ALPHA 冷 17935 0 — 10.208s
    BETA 冷 17936 0 — 10.220s
    GAMMA 冷 17936 0 — 10.224s
    ALPHA 重放 17935 16384 host 1.252s
    sglang:cached_tokens_total{cache_source="host"} 16384
    sglang:evicted_tokens_total{cache_type="UnifiedRadixCache"} 39531
    

    冷 10.2s → 暖 1.25s,8 倍。 L2 主机内存层是真的在工作。

    L3(磁盘)⚠️ 只写不读

    落盘完全正常:

    sglang:backuped_tokens_total{storage_backend="file"} 104056
    /mnt/sda6/hicache_l3   24G / 211010 个 .bin
    

    但读取从来没被触发。用 6 个互不重复的前缀(共 107K token)把 L1(32768)和 L2(60855)全部撑爆,L3 里确实有 ALPHA 的数据,再请求 ALPHA:

    [ALPHA-again] prompt=17935 cached=0 device=None host=None storage=None e2e=10.233s   ← 全量重算
    

    metrics 里连 prefetched_tokens_total 的 series 都不存在 —— 根本没有发起过预取。重启进程后再试,一样。

    结论:0.5.17 在 UnifiedRadixCache(KV+MAMBA 混合池)上不会为 L3 触发预取。 想要真三级缓存得等上游修,相关的 PR 是 #20457(mamba 状态卸载)、#28185(linear-attn 前缀缓存的 int8 checkpoint pool),issue 是 #24121。

    顺带踩到的三个坑(很重要)

    坑 现象 解决
    --file-storage-path 是空参数 全树只有参数声明、没有任何地方使用它,文件偷偷写到 /tmp/hicache 必须用环境变量 SGLANG_HICACHE_FILE_BACKEND_STORAGE_DIR=/mnt/sda6/hicache_l3
    文件后端淘汰器默认惰性 不配置就无限增长(实测涨到 24 GB) 设 SGLANG_HICACHE_FILE_BACKEND_MAX_SIZE=20G、..._MIN_FREE_SPACE=10G
    --hicache-size 是 KV + mamba 总量 给 12 时 KV host 只有 2-6 GB,剩下 5.7-10 GB 全给 mamba 状态了 看清楚分配日志再调
    /flush_cache 返回 400 没法用它做隔离测试 用重启进程代替

    5. 双路 128K 跑通:关键参数是 max-mamba-cache-size

    这是本篇最值钱的一条。

    GDN(Gated DeltaNet)的状态缓存默认要 34 个槽位 = ssm_state 4.92 GB。但双路并发只需要 2 路:

    配置 max_mamba_cache_size ssm_state KV 池 tokens
    默认 34 4.92 GB 183838(mf 0.88)
    优化 6 0.98 GB 356237(mf 0.92)

    白捡 4GB 显存,KV 池直接从 18 万 token 干到 35 万 token。实测双路 128K:

    max_total_num_tokens=356237   max_running_requests=2   context_len=131072
    [dual-A] prompt=123215  e2e=206.0s
    [dual-B] prompt=123216  e2e=206.1s     ← 真并发,KV 使用率 0.69 / mamba 0.67
    峰值显存 31114 / 32760 MiB,无 OOM、无 retraction、无 abort
    

    6. 速度基准(和上一篇的 llama.cpp 对照)

    6.1 SGLang 本机实测

    指标 数值
    pp512 1736.8 tok/s
    pp8.5k 1884.0 tok/s
    pp34k 1684.3 tok/s
    pp68k 1459.8 tok/s
    单路 decode(短上下文 128 token) 41.4 tok/s(TTFT 285 ms)
    单路 decode @64K 上下文 37.0 tok/s
    双路同时 decode 36.7 + 38.1 = 74.8 tok/s 聚合
    双路 2×120K prompt 并发 聚合 prefill 2252 tok/s,墙钟 107.0 s
    峰值显存 31.1-31.3 GB / 32 GB

    HiCache 开关对照(其余参数完全一致):

    指标 HiCache 开 HiCache 关 差异
    pp512 1736.8 1849.1 −6.1%
    pp68k 1459.8 1466.7 −0.5%
    单路 decode 41.4 41.4 0%
    双路 2×120K 墙钟 107.0 s 160.6 s 快 33%

    write_through 的 GPU→主机 DMA 开销基本可以忽略(短上下文 −6%,长上下文 −0.5%,decode 无影响),而双路 12 万 token 时反而因为 mamba 状态能卸到主机内存、不用重算,快了三分之一。

    6.2 NGRAM 投机解码(不需要 MTP 权重)

    这个 checkpoint 没有 MTP,但 SGLang 的 NGRAM 投机不需要任何额外权重,白嫖:

    负载 无投机 NGRAM 提升 接受率
    代码生成 41.4 tok/s 56.1 tok/s +35% 0.110
    抽取原文(回声型) 40.5 tok/s 75.1 tok/s +85% 0.231
    中文散文创作 41.5 tok/s 47.2 tok/s +14% 0.062

    参数:--speculative-algorithm NGRAM --speculative-num-steps 5 --speculative-num-draft-tokens 6
    代价:KV 池 356237 → 271673 token,mixed chunked prefill 被自动禁用,启动慢 50s。

    6.3 和上一篇 llama.cpp(Q6_K + draft-mtp)的对照

    负载 llama.cpp Q6_K +MTP SGLang INT4 不投机 SGLang INT4 +NGRAM
    工具调用 decode 81.8 t/s(n-max3) 41.4 —
    代码生成 80.9 41.4 56.1
    中文创作 52.9 41.5 47.2
    双路聚合 95-160 74.8 预估 100-135
    prefill(pp) 上次没测 1737-1910 同
    显存 28.4 GB(含视觉) 31.1 GB(无视觉) 同

    结论:

    1. decode 的差距几乎全是 MTP 造成的,不是引擎差距。 llama.cpp 的 80-95 t/s 是"Q6_K + 训练过的 MTP draft head",我们这边是"INT4 + 完全不投机"。而且注意 INT4 权重只有 17.7 GB(Q6_K 是 20.9 GB),带宽上 SGLang 这套是占便宜的 —— llama.cpp 如果关掉 MTP,单路大概率只有 30-35 t/s,还不如我们的 41.4。
    2. prefill 是 SGLang 的大优势:1.7-1.9k tok/s,68K 长上下文只掉 15%。长文档首答和 HiCache 冷启动都靠这个。
    3. 前缀复用 SGLang 强:HiCache L2 跨请求共享,命中秒级;llama.cpp 的 prompt cache 是 per-slot 的,--parallel 2 两个槽各存一份。
    4. llama.cpp 赢在显存余量和视觉:28.4 GB 还能带视觉双路 128K;我们 31.1 GB 无视觉、只剩 1 GB。
    5. 精度取舍:Q6_K 比 AWQ INT4 更保险(我们这里连 GDN 的 in_proj_qkvz/out_proj 都是 int4)。

    7. 最终启动脚本

    export SGLANG_HICACHE_FILE_BACKEND_STORAGE_DIR=/mnt/sda6/hicache_l3   # 只有开 L3 才需要
    export SGLANG_HICACHE_FILE_BACKEND_MAX_SIZE=20G
    export SGLANG_HICACHE_FILE_BACKEND_MIN_FREE_SPACE=10G
    
    python -m sglang.launch_server \
      --model-path /mnt/sda6/download/qwen38-awq-fixed \
      --served-model-name qwen38 \
      --host 0.0.0.0 --port 8001 \
      --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 \
      --reasoning-parser qwen3 \
      --tool-call-parser qwen3_coder \
      --trust-remote-code
    

    想要再快就加投机(二选一):

    # 方案A:不用换权重,白嫖 NGRAM
      --speculative-algorithm NGRAM \
      --speculative-num-steps 5 \
      --speculative-num-draft-tokens 6
    
    # 方案B:换成带 MTP 的权重(如 RedHatAI/Qwen3.8-27B-INT4)
      --speculative-algorithm NEXTN \
      --speculative-eagle-topk 1 \
      --speculative-num-steps 5 \
      --speculative-num-draft-tokens 6
    

    8. 给同是小白的提醒

    1. 先验证权重包本身。加载出来乱码,第一步就去比对 config.json 的 ignore 和 safetensors 里的真实键名,别急着怀疑引擎。kv 相关的报错(Marlin size_n)和乱码往往是同一个根因的两副面孔。
    2. --page-size 必须是 1,混合 mamba 模型 + HiCache 用别的值会 segfault,这条大神帖子里就写了,是真的。
    3. --max-mamba-cache-size 是双路 128K 的钥匙,默认 34 白扔 4GB。
    4. HiCache 的 L3 目前对混合模型只写不读,别指望它;L2(主机内存)才是真正省钱的那层,--hicache-size 给 12 就够。
    5. --file-storage-path 在 0.5.17 里不生效,必须用环境变量,否则你会像我一样盯着空目录怀疑人生。
    6. A_log 要用 float32、GDN stride 修复这些在 0.5.17 里已经内置了,不用自己打补丁;但别乱打那个 #22618 的 linear_attn guard。
    7. 投机参数要自己扫(和上次扫 n-max 一样),num-steps / num-draft-tokens 的甜点位因任务而异。

    9. 还没解决的

    • L3 磁盘层只写不读:0.5.17 的混合模型限制,等上游修。
    • 这个 checkpoint 没有 MTP:想要 MTP 得换权重(代价是不再是 abliterated 版)。
    • 显存只剩 1 GB:双路 128K 是这卡的极限,正式长跑建议用 96K(mem-fraction 0.90)。
    • 量化精度:AWQ INT4 + GDN 部分层量化,质量比 Q6_K 略逊,追求质量还是建议 Q6_K。

    感谢论坛大神的 HiCache 帖子,也感谢上一篇里各位指路的回复。这次把一个坏权重包拆开修好、把三层缓存一层层验明白,算是把 SGLang 这条路走通了 —— 剩下的就是等上游把 L3 补上。

    2be8a5f0-c64c-4174-a815-d4212ca69e4b-image.jpeg


  • 抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机
    E Enigma
    LLM讨论区 rtx4080s qwen-27b ninfer

    Screenshot_20260922194737.png


  • SGLang 跑通:9700X+4080S 32G 跑 Qwen3.8-27B AWQ-INT4 双路128K+三级HiCache 从满屏乱码到跑通
    E Enigma
    AI Agent sg-lang rtx4080s qwen-27b

    @terry 说:

    @Enigma HiCahce跑通,FP8KV久没有那么急迫了,还是可以研究下,我看你其他帖子似乎是有BUG?Decode其实不重要,我之前都是20多tokens,不妨碍工作,重要的就是对会话prefill,显存要够。还有DFlash如果还不饿Hicache能同时跑通,最好,或者和FP8KV。

    DFlash2已经跑通能用了,效果不错的,现在的满速跑,功率260W/320W,核心温度45左右,凉快又安静


  • SGLang 跑通:9700X+4080S 32G 跑 Qwen3.8-27B AWQ-INT4 双路128K+三级HiCache 从满屏乱码到跑通
    E Enigma
    AI Agent sg-lang rtx4080s qwen-27b

    @terry 说:

    非常好的帖子,你把上个帖子链接发进来,方便大家查找。我帮你贴下,以后自己的帖子注意维护。
    https://lcz.me/topic/1614
    两个帖子质量都很好。

    好的,谢谢


  • # SGLang 上 MTP 投机的完整调优记录:从"draft 白占 4.7GB"到单路 89 t/s,以及一个会崩服务的 mamba 坑
    E Enigma
    LLM讨论区 sg-lang mtp qwen-27b

    上一篇写了 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 池 44913
    

    pool_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-code
    

    11. 给同是小白的提醒

    1. 换带 MTP 的权重前,先确认它带 model_mtp.safetensors,并检查 model.safetensors.index.json 里有没有引用它。
    2. Xet 存储的仓库必须 HF_HUB_DISABLE_XET=1,否则镜像站等于没用,还会 401。
    3. MTP 打开后第一件事是看 KV 池(日志里的 max_total_num_tokens),别以为服务起来了就没事 —— 池子被挤到 1.4 万 token 时,长提示会被 400 拒掉。
    4. 池子异常小的时候,别急着调参数,先去读 sizing 代码。我这次就是靠往 _profile_available_bytes / _compute_cell_size 里插几行日志,一眼看出 cell_size 被放大了 5 倍 —— 参数调到天亮也没用,因为那是算错而不是不够。
    5. max_running_requests is capped to 1 这条日志一定要看,混合 mamba 模型 + 投机解码下每请求要 5 个 mamba 槽,槽不够就变排队。
    6. --max-mamba-cache-size 别乱调大:它和 chunked prefill 的交互有个会崩整个服务的断言(Can not alloc mamba cache),调完一定要跑 2×64K 压测。
    7. 投机参数要自己扫(和扫 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%)。
    8. 贪婪解码下投机不改变结果:3 个测试题里 2 个输出和关闭 MTP 时逐字节一致,1 个措辞不同(bf16 下 batch-verify 与单 token 路径的数值抖动),不是投机算错。

    感谢论坛里关于 MTP/投机解码的讨论,让我这次少走了不少弯路。


  • # SGLang 开 MTP 后 KV 池被砍到 1/5:一个上游 bug 的完整定位与修复,附同机四方实测总表
    E Enigma
    LLM讨论区 sg-lang mtp qwen-27b

    上一篇我还在说"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_size 163840 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 4
    

    127K 单路请求(本轮唯一真实差异)

    修复前 修复后
    结果 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

    结论:

    1. llama.cpp + Q4_K_M + MTP 仍是"最快 + 最长上下文"的综合最优(256K×2 还能 161 t/s),它的 KV 池是按实际使用分配;
    2. SGLang + MTP 修好之后,解码反超 llama.cpp(+6%~13%),双流聚合最高,而且单路 128K 保住了;
    3. SGLang 无投机的唯一优势是 prefill(1680-1915 tok/s,123K 时还有 1199)和 HiCache 前缀复用(17.9K 前缀 10.2s → 1.25s);
    4. 两者取舍的核心差异从"显存分配策略"变成了"MTP 在长上下文下收益趋零"这个共同的物理限制。

    9. 给同是小白的提醒

    1. 池子异常小的时候,先怀疑"算错"而不是"不够"。判据很简单:分配完成后还剩多少显存。像这次分完还剩 7.9GB,就说明预算没被用满,参数调到天亮都没用。
    2. 定位靠日志,别靠猜:在 sizing 路径(_profile_available_bytes、_compute_cell_size)插几行 print,把中间值打出来,一次就能看到 cell_size 被放大了 5 倍。改完记得把调试补丁撤掉(env 变量门控更安全)。
    3. 换带 MTP 的权重先确认两件事:它带 model_mtp.safetensors;model.safetensors.index.json 里引用了它。
    4. Xet 存储的仓库必须 HF_HUB_DISABLE_XET=1,否则 hf-mirror 等于没用,还会 401。
    5. max_running_requests is capped to 1 这条日志一定要看:混合 mamba 模型 + 投机下每请求要 5 个 mamba 槽,槽不够就变排队(我实测 2×64K 墙钟 84.4s ≈ 2×42s,是串行不是并行)。
    6. --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)。
    7. 投机参数要自己扫,而且别忘了它只在短上下文值钱:短上下文 +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 倍。

    感谢论坛里讨论投机解码和显存分配的各位,这次少走了很多弯路。


  • 抄作业翻车三次:4080S 32G 上 NInfer 跑 Qwen3.8-27B,双路 256K 全部跑通,崩卡根因定位到 CUDA Graph × 批量投机
    E Enigma
    LLM讨论区 rtx4080s qwen-27b ninfer

    image.jpeg

  • 登录

  • 登录或注册以进行搜索。
  • 第一个帖子
    最后一个帖子
0
  • 版块
  • 最新
  • 标签
  • 热门
  • 用户
  • 群组