Qwen3.8-27B 八卡 RTX 4090 本地部署与调优全记录

Qwen3.8-27B 八卡 RTX 4090 本地部署与调优全记录

目标:让 27B 模型在 8×RTX 4090 上,以生产级质量服务多个真实用户——单用户 76+ tok/s,长上下文 262k,图片理解可用,多用户缓存秒恢复。 本文是完整踩坑 + 调优过程的沉淀,照着走可以直接复现。

1. 硬件与最终性能

配置
GPU 8× RTX 4090(24GB)
内存 503GB
主模型 Qwen3.8-27B-UD-Q6_K(21GB)+ mmproj-F16(视觉投影,885MB)
Draft 模型 Qwen3.8-27B-DFlash2-Q4_K_M(1.1GB)
每卡显存 13~15GB / 24GB

最终性能(实测)

场景 指标
单用户短上下文 decode 76~86 tok/s(未开投机时 38 tok/s,2.1× 提速
10 万 token 长上下文 decode 40~67 tok/s(KV 带宽瓶颈,随 draft 接受率波动)
Prefill 3748 tok/s(20k 全量,8 卡)
4 用户并发 每路 33~35 tok/s,总吞吐 ~130 tok/s,压测 12/12 成功 0 错误
Prefix cache 命中 同文档二问 76.3s → 1.5s(50× 加速)
用户隔久回来(RAM 二级缓存) 20k 文档挤占 slot 后回来 2.7s,98% 命中
23 万 token 超长文档 正常完成
大图并发 修复后 6/6 并发 0 失败(修复前 6/6 全挂)

2. 模型结构(理解优化的前提)

Qwen3.8-27B 是混合架构qwen35,类 Qwen3.5 / 混合线性注意力):

64 层 = 16 层 full attention(每 4 层 1 个) + 48 层 recurrent/SSM
n_embd 5120,GQA:n_head 24 / n_head_kv 4

两个关键推论:

  1. KV cache 只覆盖 16 个 attention 层。recurrent 层是固定大小状态(不随上下文增长)。所以 decode 每 token 的带宽开销 = 全部权重 + 全部 attention KV(10 万 ctx 约 13GB q8_0),KV 读取是长上下文的瓶颈。
  2. attention 层天然均匀分布full_attention_interval=4 → 按连续块切 8 卡,每卡恰好 2 个 attention 层、KV 分片等量。-ts 负载均衡无需调整(强行调反而破坏均衡)。

3. 部署架构

用户 → 网关(new-api) → 路由脚本(:15290) → llama-server(:15202, 单实例 4 槽)

为什么是"单实例 + 4 槽"而不是多实例

  • 早期是三实例(15202/15203/15204)各占 2~3 卡。问题:实例间缓存物理隔离(llama.cpp 的 prompt cache 是进程内数据结构,无共享内存/IPC,无法跨进程共享)。
  • 合并为单实例 --parallel 4:4 个 slot 共享同一个 KV 池。同一用户的连续对话(或不同用户共享的系统提示词前缀)才能真正命中缓存。
  • 坑:-c 是总上下文不是每槽--parallel 4 -c 1048576 = 每槽 262k。

路由层

一个极简 Python 路由脚本(:15290)挂在 llama-server 前: - enable_thinking=false 的请求 → 直接打到同一实例(Qwen 模板支持请求级控制思考开关,无需单独"无思考"实例)。 - 思考请求 → 同一实例。 - 好处:网关侧(new-api)只配一个渠道,模型名统一;将来要拆"思考/非思考"实例只改路由表。

网关用 new-api:渠道指向路由端口;模型名与 llama-server 的 --alias 一致。

4. 核心:DFlash2 投机解码(2.1× 提速)

背景

普通 27B 在 8×4090 上 decode 只有 ~38 tok/s(带宽受限)。投机解码用一个 1.1GB 的小 draft 模型猜 N 个 token,主模型一次验证:

  • 试过 ngram:提速有限。
  • DFlash2(llama.cpp PR #27342,未合并分支):专用 draft 模型(dflash 架构,block_count=5),每步一次 draft forward 产出最多 5 个候选 token。

构建

git clone https://github.com/ggml-org/llama.cpp
git fetch origin pull/27342/head && git checkout FETCH_HEAD
cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release -j

(构建过程中有若干编译坑,主要是 PR 未合并、与 main 分支有少量冲突,按报错逐个处理即可。)

参数(关键调优结论)

--spec-type draft-dflash \
--spec-draft-n-max 5 \
--spec-draft-p-min 0.2
  • n_max 5 而不是 7/9:干净环境基准 5 vs 7 vs 9 = 76.6 / 76.7 / ~80 tok/s,无实质差异(接受率曲线位置 ≥6 只剩 ~0.3,多猜不中)。选 5:省 draft 算力、多 slot 并发争抢最小。
  • p_min 0.2 早停:draft 置信度 <0.2 时提前停止该序列。实测无损(接受长度不变),低接受率场景(图片、代码)draft 生成数省 ~36%,把算力让给其他用户。

效果

38 → 79 tok/s(2.07×),接受率 0.24~0.48 随内容类型波动,全程无损(投机解码本身不改变输出分布)。

5. 三级缓存体系(长上下文秒开的核心)

llama.cpp server 有两级内置缓存,配合使用覆盖"短间隔"和"长间隔"两种复用场景:

L1:显存池前缀复用 --cache-reuse 512

同一个 KV 池里,新请求与已有前缀的公共前缀直接复用(不重新 prefill)。

  • 公共前缀匹配无长度下限:缓存 ABCDE、请求 ABCT → 命中 ABC,只 prefill T
  • 注意匹配是严格的从头 LCPABCDE vs XBCDE 只命中 0;中间改一个字,从改动处之后全部 miss。
  • 512 不是"前缀 ≥512 才复用",而是公共前缀之后的不连续 chunk 做 KV shift 搬移的最小粒度。

实测:同文档二问 76.3s → 1.5s

L2:RAM 二级缓存 --cache-ram 256GB

llama.cpp 内置 server_prompt_cache(PR #16391,默认仅 8GB,太小):

  • slot 被 LRU 替换时,把该 slot 的 KV 状态序列化进 CPU 内存;新请求命中公共前缀时从 RAM 恢复,剩余部分才 prefill。
  • 跨 slot、全局 LRU,不占显存池,与 slot 数解耦。
  • 容量 = 字节上限(256GB)÷ 每份状态大小。实测密度 ~40KB/token(100k 状态 ≈ 4GB,draft 状态另算 ~40MB)。256GB ≈ 60 份 100k 状态
  • 服务器 503GB 内存,256GB 上限 + 系统/模型 mmap 仍余 200GB+。按需增长,不是启动占满。

实测:6 个短请求挤占 4 个 slot 后,原 20k 文档用户回来 2.7s、cached 98%

这就是"用户聊着聊着走了,几小时后回来秒开"的实现——不依赖 slot 数,不占显存。

监控缓存

--verbosity 4(TRC 日志)后可随时查看:

grep "cache state" server-*.log | tail -1
# cache state: 8 prompts, 7266 MiB (limits: 262144 MiB, 1048576 tokens)

prompts = 当前缓存份数;另有 saving prompt with length N, X MiB 保存明细。注意:只有 slot 被替换时才触发保存,不是每次请求。

6. 生产启动参数(完整)

llama-server \
  --model Qwen3.8-27B-UD-Q6_K.gguf \
  --mmproj mmproj-F16.gguf \
  --alias "qwen3.8-27b" \
  --port 15202 \
  --n-gpu-layers 99 \
  --parallel 4 -c 1048576 \
  -sm layer \
  -b 4096 --ubatch-size 512 \
  --flash-attn auto \
  --cache-type-k q8_0 --cache-type-v q8_0 \
  --cache-reuse 512 \
  --cache-ram 262144 \
  --spec-type draft-dflash \
  --spec-model Qwen3.8-27B-DFlash2-Q4_K_M.gguf \
  --spec-draft-n-max 5 --spec-draft-p-min 0.2 \
  --verbosity 4

要点: - KV q8_0:显存减半、decode 带宽减半(KV 读取是长上下文瓶颈),实测质量无感知差异。 - -sm layer:混合架构按层切 8 卡,attention/KV/权重天然均衡(见第 2 节)。 - 包装成 while true 自恢复脚本 + ulimit -s 65536(CUDA 建图栈深),日志按天落盘。

7. 踩坑:大图请求偶发 500(最隐蔽的一个)

现象:带大图的请求偶发 500 failed to process mtmd chunk,纯文本从不复现。

排查过程(两层)

  1. 第一层(假象):以为是 draft 模型 n_ctx 太小 → 改 4 倍,无效。
  2. 第二层(真凶):DFlash2 draft 用 ISWA(带滑窗的注意力)。draft 的 SWA KV 池大小 = n_swa × n_seq + n_ubatch = 512×4+512 = 2560 cells,与主模型 n_ctx 无关。大图的视觉 token 几千个 → 超出窗口 → 旧 KV 滑出 → non-consecutive token positionfailed to find a memory slot → 500。

修复:把 ISWA SWA 池公式 ×8(源码 src/llama-kv-cache-iswa.cpp),2560 → 20480 cells,draft KV 200MB → 1.6GB(仅 draft 受影响)。

验证:单发大图 OK;6 并发大图 0/6 失败(修复前 6/6 全挂);纯文本 77.6 tok/s 无退化。

教训:报错误导性很强(mtmd chunk 像多模态处理问题,实际是 draft 的 KV 池上限);并发 + 大图是最可靠的复现手段。

7.5 踩坑:Claude Code 报 500(system 位置异常)

现象:Claude Code 调 /v1/messages 报 500 System message must be at the beginning

根因:Claude Code 会把上下文(如 "Available agent types...")作为 role=system 消息放在 messages[] 里 user 消息之后。网关转 OpenAI 格式时原样保留位置 → 模板只收开头的 system → 抛异常。

修复:宽松版 chat 模板——把"非开头 system 抛异常"改为"按 user 渲染",--chat-template-file 挂载。注意:llama.cpp 部分 fork 改过 GGUF 元数据的值类型表(STRING/ARRAY 编码与上游不同),提取内嵌模板时别用上游解析器。

8. 测过但没用/不值得做的(省你时间)

结论
-ts 自定义负载分配 无需调:混合架构 attention 层每 4 层 1 个,连续切 8 卡每卡恰好 2 个 attention 层,天然均衡
n_max 7/9 无提升(76.6/76.7/~80,噪声级),反而多耗 draft 算力
--perf server 模式无效(CLI 专用)
--slot-prompt-similarity 默认 0.10 已生效(缓存亲和选 slot),不用调
KV q4_0 最大杠杆(~25-30% 带宽节省)但长上下文精确回忆质量风险未验证,暂缓;建议先起对照实例只测质量再决定是否上生产
拆成 2 卡×4 实例 + 缓存感知路由 缓存物理隔离(无跨进程共享),牺牲共享换隔离,现阶段不划算
draft KV q8_0(--spec-draft-type-k/v q8_0 免费小优化(draft KV 减半),预期 2~5%,可做

9. 并发特性(多用户必读)

4 路并发压测(8.9k prompt + 500 token 输出 × 4 路 × 3 轮):

  • 每路 33~35 tok/s(单用户 76),总吞吐 ~130 tok/s
  • 一人慢拖累所有人:同批 decode 的 KV 读取被最长上下文主导,且长生成占住 slot 让后来者排队。
  • 缓解手段:p_min 早停(低接受率序列少占 draft 算力)、cache-ram(长会话被挤占也不丢缓存)、控制单槽上下文上限(-c / parallel)。

10. 快速上手清单(TL;DR)

  1. 8×4090 + 大内存(≥128GB,256GB+ 更佳)。
  2. 构建 llama.cpp(含 DFlash2 的分支),下载 Q6_K 主模型 + DFlash2 draft + mmproj。
  3. 单实例 --parallel 4 -c (每槽目标×4)-sm layer,KV q8_0。
  4. 投机:--spec-type draft-dflash --spec-draft-n-max 5 --spec-draft-p-min 0.2
  5. 缓存:--cache-reuse 512(显存前缀)+ --cache-ram <大值>(RAM 二级,按内存余量开)。
  6. 大图用户多 → ISWA SWA 池要够(见第 7 节)。
  7. --verbosity 4 便于监控 cache state;生产可降回 3。
  8. 前面挂一层网关(new-api 等),模型名与 --alias 对齐。

附录:环境指纹

  • llama-server:llama.cpp 0.1.2-dev(含 PR #27342 DFlash2 + ISWA 补丁)
  • 主模型:qwen35 架构,64 层(16 attention + 48 recurrent),GQA 24/4
  • draft:dflash 架构,block_count 5,context 262144,Q4_K_M
  • 显存:13~15GB/卡(模型权重 ~2.6GB/卡 + KV 分片 + draft)
  • 内存:RSS ≈ 模型 mmap 8GB + 缓存(实测 8 份 ≈ 7GB)