RTX PRO 6000 Blackwell 跑 Qwen3.8-27B-FP8:从 46 到 251 tok/s 的 SGLang 部署

· SGLang · Blackwell SM120 · 投机解码

这篇记录一次完整的「工作站级 Blackwell 上部署大模型推理服务」的实战:在一台 8× NVIDIA RTX PRO 6000 Blackwell(单卡 96GB GDDR7,计算能力 SM120)的节点上,用 SGLang 部署 Qwen3.8-27B-FP8(混合 GDN 线性注意力 + 全注意力的视觉语言模型),并叠加投机解码,最终把单流 decode 从基线 46 tok/s 推到 251 tok/s(5.4×),高并发 C16 聚合 3524 tok/s

它有意思的地方不在「调参」,而在三个容易被忽略的结构性事实:① 你买到的 Blackwell 不一定是数据中心那块(SM120 和 SM100 的 FP8 栈不通用,这是第一道墙);② 对带宽受限的 decode,投机解码才是 GDDR7 上的正确杠杆(而不是继续抠 FP8 GEMM);③ 两种 draft 架构(DSpark 的独立 Markov 头 vs DFlash2 的候选选择器)差距完全由一个指标决定 —— accept length。下面按「约束 → 方案 → 实测 → 坑」的顺序走。

TL;DR · 核心数字

1. 第一道墙:你的 Blackwell 是 SM120,不是 SM100

部署前最该先确认的一件事,是这块 Blackwell 的计算能力。RTX PRO 6000 / RTX 50 系属于 SM120(工作站 / 专业级 Blackwell),而 B100 / B200 / GB200 数据中心卡是 SM100。两者虽然都叫 Blackwell、都原生支持 FP8,但预编译的 kernel 栈不通用

这个区分的直接后果:一套在 B200 上「开箱即用」的 FP8 推理栈,搬到 SM120 上会连环报错(DeepGEMM "Unknown recipe"、flashinfer "trtllm not supported on capability 120"、cutlass/triton 吐垃圾 token)。所以第一性结论是:先按「SM120 需要自己 JIT、且工具链门槛更高」来选版本——SGLang 要用 0.5.17+(Day-0 支持 Qwen3.8 + SM120),而不是社区跑 B200 的旧版本。

2. 部署方案

2.1 硬件与拓扑

项目规格
GPUNVIDIA RTX PRO 6000 Blackwell Server Edition × 8(96GB GDDR7 / 卡,SM120,600W)
GPU 互联PCIe Gen5 x16;无 NVLink;PIX P2P(TP 首选):(1,2)(1,3)(2,3)@NUMA0、(4,5)(6,7)@NUMA1
CPU2× Intel Xeon Platinum 8558P(96 核,L3 520MB)
内存2TB DDR5
OS / 驱动 / CUDARHEL 9.6 · 驱动 595.45.04 · 系统 nvcc 12.8 + 另装 /usr/local/cuda-13.2

GPU6+7 做 TP2 是因为它们在拓扑表里是 PIX(同一 PCIe switch 下的 P2P 直连,同 NUMA1)——TP 的 allreduce 走这条最便宜的路。跨 NUMA(SYS)的卡对会多一层 UPI,TP 通信变慢,不选。

NUMA 0(CPU 0-47) NUMA 1(CPU 48-95) GPU0 GPU1 GPU2 GPU3 GPU4 GPU5 GPU6 GPU7 TP2 主服务(PIX P2P) 实线 = PIX(同 PCIe switch P2P 直连,TP 首选)· 虚线 = 同 NUMA 无直连 · 蓝色 = 本部署 TP2 卡对(GPU6+7)

2.2 软件栈:为什么要两套 venv

venvSGLangPyTorchFlashInfer用途
.venv0.5.17(官方)2.11.0+cu1300.6.15.post1DSpark 部署(0.5.17 支持 DSPARK)
.venv-sglang-devmaster(含 DFlash2)2.13.00.6.17当前主服务:DFlash2 只在 master 有

关键约束:DFlash2 的候选选择器(CandidateSelector / selector_rank / conv_group)在官方 0.5.17 里不存在,是 8/19 才合进 master 的(#35371)。所以要么把生产 venv 升到 master(会破坏正在跑的 DSpark 依赖),要么另起一个隔离 venv 装 master。后者风险最低——两套 venv 并存,各自 CUDA_VISIBLE_DEVICES 不重叠即可。这是「不为了新特性去动生产环境」的朴素工程判断。

2.3 模型与架构

Qwen3.8-27B-FP8 是一个混合架构的视觉语言模型,理解它的层布局是理解整套部署参数的前提:

这个「48 线性 + 16 全注意力」的混合结构,直接决定了后面两个参数为什么那样取(KV cache 只给 16 层全注意力用、GDN 另有一套 state 池)。

2.4 为什么是投机解码:decode 是带宽受限的

先讲清楚「为什么投机解码在这台机器上是正确的杠杆」,否则后面的数字没有解释力。27B 模型单流 decode 时,每生成一个 token 都要把 ~29GB 权重从显存读一遍——这是典型的内存带宽受限(memory-bound):算得再快,也快不过搬数据。FP8 已经把权重体积砍半,但单流 decode 的瓶颈仍是「每个 token 一次全量权重读」。

投机解码的本质是把多次权重读摊薄:用一个很小的 draft 模型先「猜」出 γ 个候选 token,再用目标模型一次 forward 并行验证这 γ+1 个位置(一次权重读换最多 γ+1 个 token)。只要接受率足够高,等效于把「每 token 一次权重读」变成「每 ~5 个 token 一次权重读」。所以收益的天花板完全由 accept length(平均每次验证接受几个)决定——这也是后面对比 DSpark vs DFlash2 时唯一的根指标。

一个反直觉点

把 FP8 GEMM 再抠快,对单流 decode 几乎没用(实测方案 A 单流仍是 46.6 tok/s,和基线一样)——因为瓶颈不在算。投机解码才是打带宽瓶颈的那一棒。FP8 的真正价值体现在高并发(更多请求共享权重读)和显存(KV cache 减半)。分清楚「单流靠投机、并发靠 FP8+ batching」,就不会把力气用错地方。

2.5 启动参数与「为什么」

参数为什么这么取
--attention-backendflashinferSM120 上唯一能正常跑 GQA 全注意力 + sampling 且性能好的后端;trtllm_mha 仅 SM100
--kv-cache-dtypefp8_e4m3KV cache 显存减半(32.8 vs 65.5 KB/token),SM120 原生 FP8,把显存让给并发
--chunked-prefill-size2048混合 GDN 模型推荐值,避免 prefill/decode 抢占造成延迟抖动
--mem-fraction-static0.88(TP2)静态显存分配比例,TP2 双卡各放一半权重,余量更紧
--mamba-ssm-dtypebfloat16GDN state 槽位减半(153.9→78.4 MB),官方标为「精度闸门」需验证,实测质量正常
--mamba-full-memory-ratio6.08GDN state 池与 KV 池的配比,官方 cookbook 对「spec + bf16 state + fp8 KV」的平衡值
--mamba-radix-cache-strategyextra_buffer每请求 5 个 state slot(spec 验证需要额外状态副本)
--speculative-algorithmDFLASH用 DFlash2(见 §2.6);早期为 DSPARK
--speculative-dflash-block-size8DFlash2 的 verify 窗口,与 draft 训练配置一致
--reasoning-parser / --tool-call-parserqwen3 / qwen3_coderQwen3.8 是思维链模型,解析 reasoning_content 与工具调用

服务监听 0.0.0.0:8000,同时提供 OpenAI/v1/chat/completions)与 Anthropic/v1/messages)双协议,共享同一模型实例和投机加速,未设 --api-key(内网可信,任意 key 即可)。并发上限 max_running_requests=48(受 GDN state 槽位约束)。

2.6 两种 draft 策略:DSpark vs DFlash2

这次部署先后试了两套 draft 架构,它们的差异是整篇性能故事的核心:

DSpark(早期)DFlash2(当前主服务)
来源RadixArk/Qwen3.8-27B-DSpark(1.36B,2.6GB)z-lab/Qwen3.8-27B-DFlash2(3.85GB)
draft 结构5 层全注意力 + 独立 Markov 置信度头(rank 256)5 层 sliding-attention(window 2048)+ 候选选择器top_k=16,复用目标模型自己的 lm_head)
verify 窗口block_size=7(γ=7,verify 8)block_size=8(verify 更长)
候选怎么来小 Markov 头自己算目标模型 lm_head 算 top-k 候选(自洽、更准),大 vocab 靠 flashinfer top_k「免费」
是否进 CUDA graphselector 折叠进 CUDA graph,无额外延迟
accept len(根指标)~3.3~5.1(+55%)

一句话:DFlash2 用「目标模型自己的 lm_head 出候选」比 DSpark「独立小 Markov 头出候选」更自洽,加上更长的 verify 窗口,accept len 从 3.3 拉到 5.1——这就是它全面更快的根本原因,后面用数字坐实。

3. 实测性能

3.1 测试方法(口径先说清)

3.2 单流 decode:5.4× 的台阶

下面是单流 C1 从基线到主服务的完整台阶(代码 prompt,256 tok)。横轴是按优化顺序排的:

0 100 200 tok/s 46.6 1.0× 110 2.4× 163.7 3.5× 171.5 3.7× 251.6 5.4× 基线无投机 DSparkTP1 DFlash2TP1 DSparkTP2 DFlash2TP2

台阶拆开看,三个正交的贡献各自独立可归因:

注意 DSpark TP2(171.5)其实略低于 DFlash2 TP1(163.7)之上——也就是说「TP2 + 弱 draft」不如「TP1 + 强 draft」:单流场景下,先把 accept len 做高(换 DFlash2)比先上 TP 更划算。TP2 是在强 draft 之上再叠一层。

3.3 并发吞吐:C1 / C4 / C16 全矩阵

把「无投机基线」也放进并发矩阵,能看到投机解码在高并发下依旧大幅领先(因为它和 batching 是正交的两个杠杆):

配置(均 FP8 · flashinfer)C1C4C16
无投机(基线)46176632
DSpark TP11107351832
DFlash2 TP1163.711282826
DSpark TP2171.510822402
DFlash2 TP2(主服务)251.616263524
C1 110 164 172 252 tok/s C4 735 1128 1082 1626 tok/s C16 1832 2826 2402 3524 tok/s DSpark TP1 DFlash2 TP1 DSpark TP2 DFlash2 TP2(主)

两个观察:

3.4 为什么 DFlash2 快:accept len 是唯一的根指标

把 DSpark 和 DFlash2 的差异压到一个数上,就是 accept length(平均每次 verify 接受几个 token)。TP2 下 DFlash2 ~5.1 vs DSpark ~3.3(+55%)。accept len 越高,「一次权重读换到的 token」越多,decode 吞吐近似线性受益。它高的原因拆成两块:更长的 verify 窗口(block 8 vs 7)+ 更准的候选(目标 lm_head top_k=16 vs 独立 Markov 头)。两者叠加,就是 §3.2 台阶里那 46% 的全部来源。没有玄学,就是一个指标。

3.5 TTFT 与长上下文

场景DSpark TP1DFlash2 TP2(主)说明
TTFT(2k prompt)~62 ms≈ 60 msGDN 线性注意力 O(n) prefill 的红利,首 token 极快
8K 上下文 C128.633.7TP2 +18%
57K 上下文 C13.8(输出退化,仅 29 tok)9.4(正常)TP1 长上下文质量退化(FP8+TP1 已知问题),TP2 稳定

TTFT 低是混合 GDN 架构送的红利:48 层线性注意力的 prefill 是 O(n)(状态固定大小),只有 16 层全注意力是 O(n²)。所以 2k prompt 首 token 才 60ms 量级——这是「模型架构」给的,不是「部署」给的,但值得在选型时记住。

3.6 与社区数据交叉验证 + 实测复核

来源配置C1 (tok/s)
社区 wiki(Max-Q 350W 卡)TP2 SGLang / EAGLE / FP8141
社区 wiki(Max-Q 350W 卡)TP2 vLLM / MTP3 / FP8120.8
本次实测(Server Ed 600W)TP2 SGLang / DFlash2 / FP8251.6

本次 251.6 tok/s 高于社区记录的 141(EAGLE),除 DFlash2 更强外,本机是 Server Edition(600W)而非社区的 Max-Q(350W)卡,供电/持续性能更高。两个因素都朝同一个方向,不矛盾。社区 DFlash2 论文在 B200 上报 4.24–4.76×(相对 MTP3 基线),与本机「相对无投机基线 5.4×」量级一致(注意基线不同,不直接比)。

复核(2026-08-20 在线服务实跑)

报告写成后又在生产服务(DFlash2 TP2 @ 8000)上现跑了一遍 benchmark.pyC1 234.4 / C4 1653 / C16 3479 tok/s。C1 相对文档值 251.6 略低,落在同配置多次运行的波动带(234–251)内;C4/C16 与文档值高度一致。结论:数字可复现,无异常

4. 九个部署坑(这篇最值钱的部分)

坑都踩过并定位到根因,按「启动会死 → 跑得慢 → 装不上」分类。

4.1 启动直接失败

坑 1 · SM120 被判「sm75 or higher」
flashinfer 的 get_cuda_version() 优先查 CUDA_HOME/bin/nvcc,没设时回落到系统 /usr/local/cuda(本机 12.8)。而 SM12.x 要求 CUDA ≥ 12.9,于是编译目标为空、报 "requires sm75 or higher"。修复:export CUDA_HOME=/usr/local/cuda-13.2(指到 ≥12.9 的 toolkit)。TP1 脚本恰好设了所以能跑,TP2 新脚本漏了才暴露——同一个 venv,TP1 能跑不代表 TP2 能跑,因为触发路径不同。
坑 2 · ninja not found
直接调 .venv/bin/python 而没 source .venv/bin/activate,venv 里 pip 装的 ninja 不在 PATH。TP2 的 prefill CUDA graph 捕获要触发 flashinfer JIT 编译,需要 ninja。修复:脚本里 source .venv/bin/activate(或把 venv bin 加进 PATH)。
坑 3 · custom_all_reduce.cuh:508 invalid argument(最隐蔽)
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True 分配的显存来自 CUDA VMM(cuMemCreate),无法被 cudaIpcGetMemHandle 导出。SGLang 的 custom allreduce 在 CUDA graph capture 时注册 IPC buffer → 返回 invalid argument。单卡 TP1 不触发(没有跨卡 allreduce),TP2 才炸。修复:TP 场景不要设 expandable_segments。这条最反直觉——一个「省显存碎片」的常用设置,在 TP 下是定时炸弹。

4.2 版本 / 依赖

坑 4 · DFlash2 官方 0.5.17 里没有
候选选择器(CandidateSelector/selector_rank/conv_group)是 8/19 才合进 master 的。0.5.17 启动 DFlash2 会因缺 EntryClass 失败。修复:隔离 venv 装 git+https://github.com/sgl-project/sglang(master),不动生产 0.5.17。
坑 5 · flashinfer-cubin 版本不匹配
flashinfer 0.6.15.post1 与 flashinfer-cubin 0.6.13 不一致(PyPI 没有 .post1 的 cubin 包)。export FLASHINFER_DISABLE_VERSION_CHECK=1 绕过;真正升级时应同步 cubin。
坑 6 · cuDNN 版本检查不认 9.16/9.19
SGLang 的 cuDNN 版本白名单不识别较新版本。export SGLANG_DISABLE_CUDNN_CHECK=1

4.3 环境 / 运维

坑 7 · 代理下 huggingface_hubFileMetadataError
公司出口代理会剥掉 HEAD 响应的 Content-Length,而 huggingface_hub 解析文件 metadata 时三项(etag / X-Repo-Commit / Content-Length)缺一即抛错;hf-mirror 又会 308 回源再跳 XET CDN,多跳组合极脆弱。HF_HUB_DISABLE_XET=1 也救不了(问题发生在更早的 HEAD 解析)。修复:改用 hfd.sh(curl 拉文件清单 + aria2c 多线程断点续传,完全不经过 hub 库)+ HF_ENDPOINT=https://hf-mirror.com。2.7GB DSpark 权重 ~15 分钟下完。校验别只比文件大小——用 safetensors.safe_open 读 tensor 数(DSpark 62 个)更能确认没损坏。
坑 8 · SELinux 拒绝 system systemd 跑 /data 下的 venv python
最初想做 system 级 service,SELinux Enforcing 拒绝从 /data 挂载点执行 uv venv 的 python(Permission denied,203/EXEC)。修复:改用用户级 systemd(以普通用户身份跑,无 SELinux 限制)+ loginctl enable-linger(登录后不随登出杀掉)。
坑 9 · 后台进程被 shell 退出杀掉
直接 & 后台起 SGLang,shell/工具边界一退出进程组就没了。修复:早期用 at now(atd daemon 持有进程);最终统一收敛到用户级 systemd(带自动重启),一劳永逸。

5. 运维体系:用户级 systemd

最终主服务收敛成一个用户级 systemd unitsglang-dflash2),这是「能长期跑」的关键,而不是一个 nohup 脚本:

能力配置
自动重启Restart=on-failure + RestartSec=5 + StartLimitBurst=10/50s(防崩溃风暴)
开机自启systemctl --user enable + loginctl enable-linger <user>(Linger=yes 已验证)
优雅停止TimeoutStopSec=30(先 SIGTERM 30s 再 SIGKILL)
双路日志文件(stdout/stderr 落 logs/)+ journald(Storage=persistent

实测:SIGKILL 主进程 → 5s 内自动拉起 → ~45s 恢复监听 → API 200,C1 重启后仍 276 tok/s 一致。运维一键:./ops/sglang_ops.sh status|restart|logs|tail|enable

关键环境变量(已写进 unit,避免每次手设):

Environment=CUDA_VISIBLE_DEVICES=6,7
Environment=CUDA_HOME=/usr/local/cuda-13.2        # 坑1:SM120 必需
Environment=SGLANG_DISABLE_CUDNN_CHECK=1           # 坑6
Environment=FLASHINFER_DISABLE_VERSION_CHECK=1     # 坑5
Environment=HF_HUB_DISABLE_XET=1
Environment=HF_ENDPOINT=https://hf-mirror.com
# 注意:不要设 PYTORCH_CUDA_ALLOC_CONF=expandable_segments   # 坑3

6. 结论与选型建议

把整件事收成几条可迁移的判断:

  1. 先认卡再选栈:Blackwell 分 SM120(工作站)/ SM100(数据中心),FP8 栈不通用。看到 SM120 就按「要自己 JIT + 工具链 ≥ CUDA 12.9 + attention 只能 flashinfer」来选版本,别抄 B200 的配方。
  2. 杠杆要打在带宽上:27B 单流 decode 是带宽受限,继续抠 FP8 GEMM 对单流无效;投机解码(摊薄权重读)才是单流的杠杆,FP8 + batching 才是高并发的杠杆,两者正交、都要上。
  3. accept len 是投机解码的北极星:换 draft 架构时只看这一个指标。DFlash2(目标 lm_head 出候选 + 更长窗口)把 accept len 从 3.3 拉到 5.1,就是全部 46% 增益的来源。
  4. TP2 给单流,TP1 给批量:交互 / Agent 单流选 TP2(+54%);纯高吞吐批量选 TP1(并发上限和 KV 容量更从容,TP 通信开销更小)。按 PIX 卡对选 TP 卡。
  5. 长期跑就上 systemd:nohup 不是部署。用户级 systemd(绕 SELinux)+ linger + 自动重启 + 双路日志,才是「服务」而不是「进程」。

7. 附录

7.1 主服务启动命令(去敏,路径为示意)

CUDA_VISIBLE_DEVICES=6,7 \
CUDA_HOME=/usr/local/cuda-13.2 \
SGLANG_DISABLE_CUDNN_CHECK=1 FLASHINFER_DISABLE_VERSION_CHECK=1 \
python -m sglang.launch_server \
  --model-path ~/sglang/models/Qwen3.8-27B-FP8 \
  --host 0.0.0.0 --port 8000 --tp-size 2 \
  --attention-backend flashinfer --sampling-backend flashinfer \
  --kv-cache-dtype fp8_e4m3 --chunked-prefill-size 2048 \
  --mem-fraction-static 0.88 \
  --mamba-full-memory-ratio 6.08 --mamba-ssm-dtype bfloat16 \
  --mamba-radix-cache-strategy extra_buffer \
  --speculative-algorithm DFLASH \
  --speculative-draft-model-path ~/sglang/models/Qwen3.8-27B-DFlash2 \
  --speculative-draft-attention-backend flashinfer \
  --speculative-draft-model-quantization unquant \
  --speculative-dflash-block-size 8 \
  --served-model-name Qwen3.8-27B-FP8 \
  --reasoning-parser qwen3 --tool-call-parser qwen3_coder \
  --trust-remote-code

7.2 客户端接入(OpenAI 兼容)

# base_url 换成实际节点地址(内网示例 192.0.2.1,占位)
export OPENAI_API_KEY="not-needed"
export OPENAI_BASE_URL="http://192.0.2.1:8000/v1"

from openai import OpenAI
c = OpenAI()  # 自动读上面两个环境变量
r = c.chat.completions.create(
    model="Qwen3.8-27B-FP8",
    messages=[{"role": "user", "content": "解释什么是 GPU Roofline 模型"}],
    temperature=0.6, max_tokens=1024,
)
print(r.choices[0].message.content)          # 正文
print(r.choices[0].message.reasoning_content)  # 思维链(Qwen3 系)

同样支持 Anthropic /v1/messages(Claude Code 直连)。鉴权未开(内网可信),需要时加 --api-key。推理参数建议:temperature=0.6(对话/代码)、0(抽取/分类确定性),交互场景开 stream=true

7.3 数据口径与来源