05 · 卸载集合:小投影为何是负收益
一句话 :决定「哪些投影该上 GPU」时,直觉说「全都上」,实测说不是------QKV/O 这类小投影单独卸载反而更慢,因为每次调用的固定往返盖过了它们的算力;解法是 FLOP 门槛 + 投影融合。 前置 :建议先读 第 04 篇 · 分组量化 GEMM 内核。 环境:x86-64 + NVIDIA RTX 5090(sm_120)· 阶段一 2B 稠密(Qwen3-VL-2B q8)。
一、问题与结论
| 问题 | 做法 | 判定 |
|---|---|---|
| 卸载集合怎么定? | 不是「全上」,由分桶实测选出来 | 实测 |
| 小投影单独 offload 值不值? | 不值(固定往返 > 其算力) | 实测:变慢 |
| 那它们就永远在 CPU? | 不------用 FLOP 门槛自动筛,用融合摊薄往返 | 实测:融合后转正 |
lm_head 为什么之前没快? |
它走非批式路径,压根没被 offload | 实测:补挂钩后 6.31 → 0.39 |
二、背景:一个反直觉的现象
最初的想法很自然:既然 GPU 快,就把 QKV / O / gate / up / down / lm_head 全卸载。实测(2B q8,decode,ms/token):
| 投影 | 卸载前 | 卸载后 | 判定 |
|---|---|---|---|
| QKV | 6.39 | 7.24 | 变慢 |
| O | 3.86 | 4.39 | 变慢 |
| gate/up | 17.10 | 8.66 | 大幅变快 |
| down | 10.71 | 3.77 | 大幅变快 |
| lm_head | 6.31 | 0.39 | 巨幅变快 |
另一轮记录(
README的分桶表)方向相同、绝对值略有差异(QKV 7.64→11.17、gate/up 16.96→8.66)------测量轮次不同,不可混比。
小投影卸载反而更慢。
三、根因:固定往返 > 小投影算力
每次设备调用的固定成本是一整条往返:
H2D(激活上传) → kernel launch → D2H(结果下载) → cudaDeviceSynchronize
实测这一条约 ~100 µs 。而 decode 时 M=1,小投影本身的算力只有:
- Q/O:8.4 MFLOP
- K/V:4.2 MFLOP
在 ~100 µs 里做 8.4 MFLOP,等于要求 84 GFLOP/s------远低于 GPU 能力,说明时间全花在往返上,不在算。
根因一句话:瓶颈是固定开销,不是算力。 所以「让每次调用更值钱」才是解法方向。
四、核心机制
4.1 解决一:FLOP 门槛
用 FLOP 数把「太小不值得」的投影自动留在 CPU。宿主侧在每次尝试 offload 前先过一道门槛:
c
/* 文件:src/model/vllm_safetensors.c(st_cuda_try_gemm_batched_impl,节选) */
// MEASURED (2B q8, decode buckets, ms/token): offloading the small
// projections LOSES - QKV 7.64 -> 11.17, O 3.70 -> 3.73 - because the
// ~100 us per-call round trip exceeds their compute; gate/up (16.96 ->
// 7.56) and down (10.79 -> 3.98) win big. 1e7 FLOP cleanly separates ...
double minflop = 1e7;
const char *mf = getenv("VLLM_CUDA_MINFLOP");
if (mf && mf[0]) { double v = atof(mf); if (v >= 0) minflop = v; }
if (2.0 * (double)n_batch * (double)n_rows * (double)cols < minflop)
ST_CUDA_REJ("below VLLM_CUDA_MINFLOP");
1e7 这条线在 M=1 时干净地切开两组:
| 投影 | M=1 FLOPs | 是否放行 |
|---|---|---|
| K / V | 4.2 M | ✗ |
| Q / O | 8.4 M | ✗ |
| gate / up / down | 25 M | ✓ |
| lm_head | 622 M | ✓ |
而 prefill 是 M=32,所有投影的 FLOPs ×32 → 全部放行。所以这个门槛只影响 decode,prefill 的收益不受影响。
4.2 解决二:投影融合
有些投影本身不值得(QKV/O),但它们共享同一次激活量化 ------那就把同层的几个投影拼进一次设备调用,一次往返摊给多个投影:
| 融合 | 范围 | 原因 |
|---|---|---|
| QKV 三合一 | 仅 prefill(n_batch > 8) |
decode 下 QKV 仍被 FLOPs 排除 |
| gate+up 二合一 | 全局 | 本身盈利,融合进一步摊薄 |
QKV 的 prefill 门就是这句判断(src/model/vllm_safetensors.c):
c
/* 文件:src/model/vllm_safetensors.c(dyn_matvec_q8_fused_qkv_batched,节选) */
if (st_cuda_enabled() && n_batch > 8) {
...
if (st_cuda_try_gemm_fused(outs, ow, 3, x_batch, ws, nr, cols, n_batch,
g_cuda_layer, pj, ST_CUDA_FUSED_QKV, 0))
return; /* 已在 GPU 上完成融合的 Q/K/V */
}
设备侧实现是 col_off 转置进一个 [K][ΣN] 缓冲(见 第 03 篇),一次 launch、一次同步。融合路径自己也有一道同样的 FLOP 门槛(st_cuda_try_gemm_fused),且要求每个成员 N 是 4 的倍数(无空隙拼接)。
关键性质 :融合只消掉往返,不改变每个输出元素的算术 。
VLLM_CUDA_VERIFY=1全量对拍 0 失败------这是它能安全落地的依据。
4.3 解决三:lm_head 此前根本没被卸载
一个容易漏掉的点:lm_head 走的是非批式 路径(dyn_matvec_q8 / dyn_matvec_q4_q8),不在批式勾子路径上,所以最初的 offload 根本没碰它。而它是单个最大的矩阵(2B 里 ~310 MB)且每 token 只调用 1 次------正是最该卸载的那个。
源码注释把这段经过记了下来:
c
/* 文件:src/model/vllm_safetensors.c(st_cuda_try_gemm_lmhead 之前,节选) */
* ... the entire weight read leaves the host. Measured on the 2B q8 model:
* lm_head cost 6.87 ms/token on the CPU and was UNCHANGED with the CUDA
* backend on, because it is dispatched through the non-batched
* dyn_matvec_q8/_q4_q8 wrappers, which were not on the offload path.
*
* Identity: those two wrappers are called with q8_lm_weight / q4_lm_weight
* ONLY (verified at every call site), so a fixed sentinel layer id gives a
* stable, collision-free wkey.
*/
#define ST_CUDA_LMHEAD_LAYER 0x7FFFFFF0
补上挂钩后 6.31 → 0.39 ms/token。
教训:「所有 GEMM 都挂了 offload」是个需要验证的假设,不能假设。拿分段耗时表逐项对,才发现有个大项压根没进来。
4.4 分段耗时表:定位优化点的第一站
VLLM_CUDA_PROF=1 在退出时打印每次调用的分段分解(cache_probe / upload / h2d / gemm / reduce / d2h+sync):
c
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(vcuda_dev_destroy 里的 prof 打印,节选) */
fprintf(stderr,
"[CUDA-PROF] calls=%lld (hits=%lld uploads=%lld) per-call avg us:\n"
" total=%.1f cache_probe=%.1f upload=%.1f h2d_act=%.1f "
"gemm=%.1f reduce=%.1f d2h+sync=%.1f\n"
" share: gemm=%.1f%% d2h+sync=%.1f%% h2d=%.1f%% "
"reduce=%.1f%% cache=%.1f%%\n", ...);
2B Q8 实测的占比:
| 分段 | 占比 | 说明 |
|---|---|---|
| GEMM | 仅 18% | 内核本身 |
| 传输 + 同步 | ~40% | H2D / D2H / sync |
| 上装 | 33% | 只在首轮(预热后应为 0) |
GEMM 只占 18% 这个数字直接说明:优化内核是次要的,先优化「调用形态」(融合、预热、减少同步)。这张表是后面所有优化的依据------定位优化点先看它,别猜。
4.5 预热:把上装成本移出计时窗口
不预热时,196 个权重的 strip-cache 收集 + 上传 + 转置(实测 ~0.82 ms × 196 ≈ 160 ms)落在第一次 prefill 的计时窗口里,会把 GPU 收益整个吃掉,使 prefill 净慢于 CPU。所以 VLLM_CUDA_INFER=1 在模型加载后自动预热全部权重,并打印两个数字:
c
/* 文件:src/main.c(模型加载期预热,节选) */
fprintf(stderr, "[CUDA] preload: %d/%d weights warmed in %.2fs; "
"device cache %.1f MB / %d entries\n",
warmed, want_all, st_now_sec() - tp, dbytes / 1048576.0, dent);
if (dent < want_wc)
fprintf(stderr, "[CUDA] WARNING: only %d/%d weights stay resident -> "
"raise VLLM_CUDA_WCACHE_MB (device) AND "
"VLLM_GW_BUDGET_MB / VLLM_GW_SLOTS (host), or the "
"lazy rebuild dominates again\n", dent, want_wc);
一个实际运行的样例:
css
[CUDA] preload: 253/253 weights warmed in 0.36s; device cache 2854 MB / 253 entries
预算是硬前提:
ini
VLLM_GW_BUDGET_MB=4096 VLLM_GW_SLOTS=256 VLLM_CUDA_WCACHE_MB=8192 # 2B Q8 参考值
宿主侧 ≥ 模型 int8 权重大小(2B≈1.4 GB / 8B≈7 GB),设备侧同理。预算不足会静默退化 成「边跑边重建」。实测:宿主 strip-cache 抖动时,decode 会从 20 ms/tok 退化到 ~190 ms/tok。这个 WARNING 必须看,否则会拿一个退化的配置去下结论。
五、实测数据
| 口径 | 数值 | 判定 | 标注 |
|---|---|---|---|
| QKV 单投影 offload(2B q8 decode) | 6.39 → 7.24 ms/tok | 变慢 | 实测 |
| O | 3.86 → 4.39 ms/tok | 变慢 | 实测 |
| gate/up | 17.10 → 8.66 ms/tok | 大幅变快 | 实测 |
| down | 10.71 → 3.77 ms/tok | 大幅变快 | 实测 |
| lm_head | 6.31 → 0.39 ms/tok | 巨幅变快 | 实测 |
| 每次调用固定往返 | ~100 µs | 小投影的主导开销 | 实测 |
| PROF 分段占比 | GEMM 18% / 传输+同步 ~40% / 上装 33% | 先优化调用形态 | 实测 |
| 预热成本 | ~0.82 ms × 196 ≈ 160 ms(2B q8 口径) | 必须移出 prefill 窗口 | 实测 |
| 预算不足退化 | 20 → ~190 ms/tok | 必须看 WARNING | 实测 |
六、边界与已知限制
- 这里的实测是 2B q8 decode 的口径;
~100 µs固定往返与1e7门槛都绑定到当时那台设备,换设备要重测。 - 「小投影负收益」是单投影结论;一旦融合(QKV 三合一、gate+up 二合一)往返被摊薄,结论就变了------两者不能混用。
- QKV 融合只在 prefill(
n_batch > 8)生效;decode 下 QKV 仍走 CPU。 - 「预算不足会静默退化」意味着:不看 WARNING 就可能拿退化配置去下错误结论。
lm_head走的是非批式路径,它的挂钩是后来补的;新加投影时要逐项确认它到底在不在 offload 路径上。
CPU 对照(迁移前基线)
- CPU 参考 :
kestrel-llm/src/model/vllm_safetensors.c(函数dyn_matvec_q8/dyn_matvec_q4_q8)------ 非批式路径,lm_head在 CPU 侧就走它。 - 迁移要点 :CPU 侧全部在本机算 → CUDA 侧把「小投影单独 offload 是负收益」交给 FLOP 门槛
1e7自动留在 CPU;QKV/gate+up 融合摊薄固定往返;补lm_head挂钩;预热把上装成本移出计时窗。 - 真机验证 :未独立复现:需 2B Q8 decode 分桶耗时表 +
VLLM_CUDA_PROF分段占比与预热日志,均需模型运行。
七、小结(可复用结论)
- 卸载集合由实测选定,不是全上 :小投影的
~100 µs固定往返超过其算力,单独上 GPU 是负收益。 - FLOP 门槛
1e7是最省事的筛子 :M=1时干净切开「不值得」与「值得」;prefill 因M=32全部放行,不受影响。 - 融合只消往返、不改算术:QKV / gate+up 拼成一次调用,每个输出元素的算术不变------这才是它能安全落地的前提。
- 别假设「所有 GEMM 都挂了 offload」 :
lm_head走非批式路径,最初压根没被卸载;要靠分段耗时表逐项对。 - 先看 PROF 分段表:GEMM 只占 18%,先优化调用形态(融合、预热、减少同步),再谈内核。
相关篇目:第 04 篇 · 分组量化 GEMM 内核、第 06 篇 · VRAM 驻留窗口的两个坑 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...