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
两个关键推论:
- KV cache 只覆盖 16 个 attention 层。recurrent 层是固定大小状态(不随上下文增长)。所以 decode 每 token 的带宽开销 = 全部权重 + 全部 attention KV(10 万 ctx 约 13GB q8_0),KV 读取是长上下文的瓶颈。
- 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,只 prefillT。 - 注意匹配是严格的从头 LCP:
ABCDEvsXBCDE只命中 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,纯文本从不复现。
排查过程(两层):
- 第一层(假象):以为是 draft 模型 n_ctx 太小 → 改 4 倍,无效。
- 第二层(真凶):DFlash2 draft 用 ISWA(带滑窗的注意力)。draft 的 SWA KV 池大小 =
n_swa × n_seq + n_ubatch= 512×4+512 = 2560 cells,与主模型 n_ctx 无关。大图的视觉 token 几千个 → 超出窗口 → 旧 KV 滑出 →non-consecutive token position→failed 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)
- 8×4090 + 大内存(≥128GB,256GB+ 更佳)。
- 构建 llama.cpp(含 DFlash2 的分支),下载 Q6_K 主模型 + DFlash2 draft + mmproj。
- 单实例
--parallel 4 -c (每槽目标×4),-sm layer,KV q8_0。 - 投机:
--spec-type draft-dflash --spec-draft-n-max 5 --spec-draft-p-min 0.2。 - 缓存:
--cache-reuse 512(显存前缀)+--cache-ram <大值>(RAM 二级,按内存余量开)。 - 大图用户多 → ISWA SWA 池要够(见第 7 节)。
--verbosity 4便于监控cache state;生产可降回 3。- 前面挂一层网关(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)