06 · VRAM 驻留窗口的两个坑
一句话:显存装不下整模型时,要让设备侧只保留一层有界工作集——但「驱逐」这件事有两个反直觉的坑:驱逐未来层会让代价对 keep 完全平坦,用
cudaFree做驱逐的同步抖动比省下的重传还贵。
前置:建议先读 第 03 篇 · 权重条带缓存与融合权重、第 05 篇 · 卸载集合。
环境:x86-64 + NVIDIA RTX 5090(sm_120)· 阶段一 2B 稠密(Qwen3-VL-2B q8,1.85 GB)。
一、问题与结论
这不是加速开关,是「跑得起」的开关。目标是让显存装不下的模型能以有界显存工作集跑起来。
| 问题 | 做法 | 判定 |
|---|---|---|
| 该驱逐哪些层? | 只驱逐过去层,保留当前与未来 | 实测:不对称才按 keep 缩放 |
| 驱逐时怎么释放? | 走设备缓冲池,不用cudaFree | 实测:cudaFree同步更贵 |
| 驻留会影响输出吗? | 不会——只改缓存占用 | 实测:7 档 TOKIDS 8/8 相同 |
| 开窗时还要预热吗? | 跳过(会被立刻驱逐) | 实测:纯浪费 |
二、背景与规则
引擎宿主侧已有「逐层驻留」(VLLM_VQF_STREAM)。GPU 侧在同一个层边界钩子上推进,两边锁步:
驱逐 layer < cur_layer - keep + 1 的所有权重; cur_layer 本身与所有「未到来」的层保持常驻; lm_head 永远常驻。规则落在vcuda_dev_wcache_window()里,挂载点是vqf_stream_layer_advance的同一处(4 处层循环):
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(vcuda_dev_wcache_window,节选) */constintlo=cur_layer-keep+1;intevicted=0;for(inti=0;i<VCUDA_MAX_ENTRIES;++i){vc_wentry_t*e=&s->ent[i];if(!e->used)continue;if(e->layer>=VCUDA_LMHEAD_LAYER)continue;/* lm_head: always keep */if(e->layer>=lo)continue;/* current + all upcoming: keep */s->bytes-=e->bytes;vc_ent_release(s,e);e->used=0;e->key=0;s->n_ent--;s->n_evict++;evicted++;}为什么不对称?因为一层循环只向前扫:
cur之上的层马上要用——驱逐它们等于下一层立刻重传;cur-keep+1之下的层本轮已消费完——它们才是可以丢的。
三、核心机制
3.1 坑 1:驱逐未来层 → 代价对 keep 完全平坦
第一版实现「同时驱逐> cur+1的未来层」,结果:
| keep | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| ms/tok | 184 | 178 | 170 | 163 | 159 |
代价与 keep 几乎无关——不管留多少,都是 ~160–184 ms/tok。
原因就是第二节说的:未来层马上要用,驱逐它们 → 每 token 把所有层权重重传一遍。于是「省显存」变成了「每 token 全量重传」,keep 这个参数失去了意义。
改法:只驱逐过去层。改完后代价才按预期随 keep 缩放。
3.2 坑 2:用cudaFree驱逐 → 抖动比省下的重传还贵
第二版改对了方向,但驱逐动作是cudaFree一对:
cudaFree是设备同步调用。每层 6 次 free 的抖动,比它省下的重传还贵。
改法:加设备缓冲池(同几何复用,VCUDA_POOL_MAX=128槽),驱逐退化成 O(1) 指针移动:
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(vc_ent_release,节选) */if(e->dWT&&e->dbsT&&s->n_pool<VCUDA_POOL_MAX&&e->Npad>0&&e->ngroups>0){s->pool[s->n_pool].dWT=e->dWT;s->pool[s->n_pool].wt_bytes=(size_t)e->K*(size_t)e->Npad;s->pool[s->n_pool].dbsT=e->dbsT;s->pool[s->n_pool].bs_bytes=(size_t)e->ngroups*(size_t)e->Npad*sizeof(float);s->pool[s->n_pool].Npad=e->Npad;s->pool[s->n_pool].ngroups=e->ngroups;s->n_pool++;e->dWT=NULL;e->dbsT=NULL;e->bytes=0;return;}vc_ent_free(e);/* 池满时的退化路径:真 free */池复用要求几何完全一致(Npad与ngroups都相同),这样布局逐字节匹配、无需重排:
/* 文件:src/npu/cuda/vllm_cuda_kernels.cu(vc_ent_alloc,节选) */if(s->pool[i].Npad==e->Npad&&s->pool[i].ngroups==e->ngroups&&s->pool[i].wt_bytes>=wt_bytes&&s->pool[i].bs_bytes>=bs_bytes){…}resident 窗口下同一批形状循环出现,所以复用几乎总命中,预热后不再cudaMalloc。
效果:keep=1从 159 →94–106 ms/tok。
3.3 修正后的代价曲线
2B Q8,8 token 均值:
| keep | off | 27 | 26 | 20 | 12 | 4 | 1 |
|---|---|---|---|---|---|---|---|
| ms/tok | 32 | 33 | 37 | 61 | 78 | 115 | 106 |
| 设备常驻条数 | 197 | 190 | 183 | 141 | 85 | 29 | 8 |
读法:
- 常驻43%权重(keep=12)→ decode 慢2.4×
- 常驻14%权重(keep=4)→ decode 慢3.6×
模型装得下就别开它。它是「跑得起大模型」的开关,不是加速开关。(附带的一个反直觉点:keep=1 反而比 keep=4 略快——常驻越少,池复用率越高。)
3.4 不变式:驻留不影响输出
这是它能安全落地的前提。实测 keep ∈ {1,4,12,20,26,27} 与关闭共7 档:
greedy TOKIDS8/8 完全相同。
原因:驻留只改缓存占用,不改任何 GEMM 的输入、顺序或结果——下次用就重新上传。这与宿主侧「跨驻留档 TOKIDS 逐位相同」的红线同构(但只在本后端内成立)。同理,预热与懒加载两条路径的输出也一致(TOKIDS 前缀与翻转点逐位相同)。
3.5 开窗时自动跳过预热
窗口会在第一个层边界驱逐窗口外的权重,所以全量预热等于「传完即丢」——纯浪费。因此VLLM_CUDA_STREAM > 0时跳过预热,让窗口的按需路径自己传:
/* 文件:src/model/vllm_safetensors.c(st_cuda_preload_all,节选) */constchar*e=getenv("VLLM_CUDA_STREAM");if(e&&e[0]&&atoi(e)>0)return0;/* 窗口模式:跳过全量预热 */四、实测数据
| 口径 | 数值 | 判定 | 标注 |
|---|---|---|---|
| 驱逐未来层(第一版)keep=1…16 | 184 / 178 / 170 / 163 / 159 ms/tok | 代价对 keep 平坦 | 实测 |
| 仅驱逐过去 + 缓冲池 keep=1 | 159 → 94–106 ms/tok | 缓冲池有效 | 实测 |
| 2B Q8 8-token 均值代价曲线 | 见上表(off 32 → keep=4 115 / keep=1 106 ms/tok) | 装得下就别开 | 实测 |
| 驻留不变式 | 7 档 keep 与关闭 TOKIDS 8/8 相同 | 输出不变 | 实测 |
| 窗口重传吞吐 | ~7.7 GB/s | 离 PCIe 上限还远 | 实测 |
五、边界与已知限制
- 只有 2B(1.85 GB)在本机可跑,窗口的省显存收益需要 8B/30B 才能实测(现有 8B VQF 的布局标志
0x1与引擎修订构建的0x3不匹配,无法加载——见 第 07 篇)。 - 窗口的重传成本实测 ~7.7 GB/s,离 PCIe 上限还很远,瓶颈在 memset + 转置 + 同步的固定开销。「按层预取上装」(算第
l层时并行上装l+1)是明确的下一步。 - 「驻留不变式」(7 档 TOKIDS 8/8)只在本后端内成立,不能外推到与 CPU 的位级一致。
- 池复用的 O(1) 驱逐依赖「同几何形状循环出现」;形状频繁变化的负载下命中率会下降。
CPU 对照(迁移前基线)
- CPU 参考:
kestrel-llm/src/model/vqf.c(函数vqf_stream_layer_advance,VLLM_VQF_STREAM逐层驻留)—— 每层权重用时建文件页、算完立即MADV_DONTNEED释放,只留 keep 层常驻。 - 迁移要点:CPU 的逐层驻留 → CUDA 侧的不对称驱逐窗口(只丢过去层、保当前与未来,
lm_head永不驱逐);cudaFree换成缓冲池 O(1) 复用;窗口档输出不变(TOKIDS 8/8)。 - 真机验证:未独立复现:需 1.85GB 2B Q8 + keep∈{1…27} 七档实测与重传吞吐(~7.7 GB/s),无对应模型与探针。
六、小结(可复用结论)
- 只驱逐过去层,不碰未来:层循环只向前扫;驱逐未来 → 每 token 全量重传,代价对 keep 平坦、参数失去意义。
- 驱逐别用
cudaFree:它是设备同步调用,每层 6 次 free 的抖动比它省下的重传还贵;缓冲池把驱逐退化成 O(1)。 - 池复用要求几何一致:
Npad与ngroups都相同,布局才逐字节匹配、无需重排。 - 驻留必须输出不变:7 档 TOKIDS 8/8 相同,这条不变式是它能安全落地的前提;它只改缓存占用。
- 开窗跳过预热:预热会被窗口立刻驱逐,等于纯浪费;让窗口的按需路径自己传。
相关篇目:第 03 篇 · 权重条带缓存与融合权重、第 05 篇 · 卸载集合、第 07 篇 · 8B 阶段
源码与配套资源:本仓库 https://gitee.com/pei-xiaoguang/kestrel-llm-cuda.git;
CPU 推理源码 https://gitee.com/pei-xiaoguang/kestrel-llm