05 · 卸载集合:小投影为何是负收益

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 分段占比与预热日志,均需模型运行。

七、小结(可复用结论)

  1. 卸载集合由实测选定,不是全上 :小投影的 ~100 µs 固定往返超过其算力,单独上 GPU 是负收益。
  2. FLOP 门槛 1e7 是最省事的筛子 :M=1 时干净切开「不值得」与「值得」;prefill 因 M=32 全部放行,不受影响。
  3. 融合只消往返、不改算术:QKV / gate+up 拼成一次调用,每个输出元素的算术不变------这才是它能安全落地的前提。
  4. 别假设「所有 GEMM 都挂了 offload」 :lm_head 走非批式路径,最初压根没被卸载;要靠分段耗时表逐项对。
  5. 先看 PROF 分段表:GEMM 只占 18%,先优化调用形态(融合、预热、减少同步),再谈内核。

相关篇目:第 04 篇 · 分组量化 GEMM 内核、第 06 篇 · VRAM 驻留窗口的两个坑 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
每天一道题1 小时前
Agent 评测的关键,不是给它出难题,而是让它做选择
人工智能·ai
风花一世月1 小时前
⚡ 我把一座含氢能源园区搬进了浏览器(五类能流实时算守恒,在线直接玩)
前端·人工智能
付威20231 小时前
rpi 这些扩展到底怎么选?一张工具地图看懂 16 个 Rust 插件
人工智能
浪子明X1 小时前
LangChain 接入 Embedding 之前:先画清文本、向量与检索器的契约
人工智能·langchain·embedding
?? Daisy1 小时前
ZCode 接入第三方 API:添加供应商与配置模型的流程(2026-10)
人工智能
FL16238631291 小时前
城市道路叶子叶堆检测数据集VOC+YOLO格式351张2类别
人工智能·yolo·机器学习
luiyarch1 小时前
汽车电子ISO 21448 SOTIF系列(第26期):未知危险场景的确认方法(下)——AI驱动的场景发现
网络·人工智能·安全·车载系统·汽车
AI你一生一世1 小时前
GPT-6 Astra 深度拆解:当推理模型开始为“长任务”重新设计
人工智能·gpt·大语言模型·astra·推理模型·长任务·gpt-6
柯南46681 小时前
【AI工程师精讲】06:MoE:为什么"万亿参数"的模型,实际只用了很小一部分
人工智能·ai编程