Kestrel_VLLM - 19 · KV 复用迁移到 GPU(上):per-state 镜像与同类静默分叉

19 · KV 复用迁移到 GPU(上):per-state 镜像与同类静默分叉

一句话 :GPU 侧的 KV 缓存一直没有 CPU 那套调度能力(分页、前缀复用、disk-KV、L3 驻留),只有一份扁平镜像;迁移的第一步是把它改成 per-state 槽位池 ,并堵掉「多 state 共用镜像」「内存前缀复用不写镜像」两处同类静默分叉。 前置 :建议先读 第 18 篇(把 attention/decode 数值做到位级的前提)。 环境 :x86-64 + NVIDIA RTX 5090(CUDA 后端)· Qwen3-30B-A3B q4local · 开关 VLLM_CUDA_ATTN=1 / VLLM_CUDA_DECODE=1。

状态:✅ 已落地并复验(opt-in 档不回归,=0 档与迁移前逐字节一致)。

一、问题与结论

  1. 原设计在多 state 下必错 :device KV 镜像原为全后端单份 ,且 vcuda_dev_kv_init 会 free-all 后重建 ------ 第二个 state 一 init 就把第一个 state 的镜像指针释放掉;多个 state 的行还会互相覆盖。
  2. 前缀复用是第二处 :st_qwen_copy_kv_prefix 只复制 CPU 侧 KV 数组、不碰镜像 ,却把 dst->cache_len[l] 直接前移到 prefix_len。于是镜像的前缀行是未初始化垃圾,后续 decode/attention 会静默读它。
  3. 地雷已复现(硬数据) :VLLM_STREAM_COPYTEST=1 VLLM_CUDA_ATTN=1 --cuda → A_NLL=6.631492386 B_NLL=18.756641629 diff=-1.213e+01 FAIL(纯 CPU 基线 diff=0.000e+00 PASS)。B 侧 NLL 飞到 18.75,是典型的「读到垃圾」。
  4. 修法不是加开关,是改归属 :镜像从「单份」改成 per-state 槽位池 (vc_kv_slot_t kv[8]),kv_init 只分配/释放本槽位 ;引擎侧 STQwenInferenceState 持槽位号,惰性 acquire、free 时归还。
  5. 把「拒绝」升级为「同步」 :新增 vcuda_dev_kv_copy_prefix(device-to-device 逐层拷 [0,prefix) 的 K/V/ks/vs),在 st_qwen_copy_kv_prefix 末尾补齐镜像。判别量 diff 从 -4.205e-03 变为 0.000e+00,且 [CUDA-KV-GUARD] 告警消失 ------ 证明被复用侧的 state 确实从「CPU 回退」切到了「同步后的镜像」,且与全量路径逐位一致。
  6. 无回归(逐字节硬证) :L2 两档 dump(纯 CPU / DECODE+INFER+FORCE_CPU+L2EXACT,2120 key)改造前后 SHA256 完全相同 (A15A5FB9... / 24B104A6...)。

图 1 :本篇两处「同类静默分叉」的成因与修法。上支:device KV 镜像原为全后端单份、kv_init free-all 后重建,多 state 共用时会互相释放/覆盖行(复现 A_NLL=6.63 / B_NLL=18.76 / diff=-1.213e+01 FAIL);修法是把归属改成 per-state 槽位池 (VC_KV_SLOTS=8),并用 vc_kv_bind 的指针视图把改动挡在 API 层------三个 attention 内核与 vc_decode_body 一行未改。下支:st_qwen_copy_kv_prefix 只复制 CPU KV、不碰镜像,导致镜像前缀读垃圾;修法是补 vcuda_dev_kv_copy_prefix(逐层 D2D 拷 [0,prefix) 的 K/V/ks/vs),diff 由 -4.205e-03 变为 0.000e+00。两处都坚持「不允许静默读垃圾」。

二、背景:CPU 有、GPU 没有的调度能力

CPU 侧 KV 缓存早已具备(设计见 docs/KV缓存v2-惰性分配与分层驻留方案.md):

能力 CPU 侧实现 GPU 侧(迁移前)
分页 kv_bs 块 + 块指针数组 ❌ 扁平 [nl][max_seq][kv_dim]
内存前缀复用 st_qwen_copy_kv_prefix(逐块 memcpy + scale) ❌ 镜像不跟随
disk-KV 快照 st_kv_disk_save/load(q8+scale) ❌ 镜像不跟随
L3 分层驻留 Q4 驱逐 + 按需回填 ❌ 无等价物

GPU 侧的镜像只有一个用途:让 attention 内核不必逐块跳转 地流式读 KV(vc_dev_t 注释:mirrored as flat [nl][max_seq][kv_dim] int8 + [nl][max_seq][nkv] f32 scales)。它是「派生物」,必须以 CPU 的 q8+scale 行为唯一真值。迁移的全部难点都在这个不变式上。

三、核心机制

3.1 地雷一:单份镜像 + free-all

原实现里 vc_dev_t 只有一个扁平镜像字段,vcuda_dev_kv_init 进来先 vc_kv_free(s) 无条件释放 那份镜像再 cudaMalloc。引擎侧靠 st->cuda_kv_ready 做「每 state 只 init 一次」,但对共享的同一份 device 缓冲 来说这个 per-state 标志是错位的:state A 先 append → init 出镜像 P;state B 首次 append → 也走 !cuda_kv_ready 分支 → kv_init 把 P 释放、另建 P'。A 之后读的仍是旧指针语义下的 P',内容还是 B 写的。

复现(现成 harness 正好是三 state 结构:COPYTEST 建 istA/istT/istB,A 走「全量 prefill」,B 走「st_qwen_copy_kv_prefix(T→B) + 续 prefill」,最后比较两条路的 forced-NLL):

ini 复制代码
# 基线(纯 CPU,CUDA 全关)
[STREAM] COPYTEST A_NLL=6.635696930 (n=20)  B_NLL=6.635696930 (n=20)  diff=0.000e+00 PASS

# 地雷(VLLM_CUDA_ATTN=1 --cuda)
[STREAM] COPYTEST A_NLL=6.631492386 (n=20)  B_NLL=18.756641629 (n=20)  diff=-1.213e+01 FAIL

B_NLL 从 6.64 飞到 18.76 ------ 语言模型 NLL 的正常区间是 5--7,18.76 只可能来自「注意力读到了语义无关的向量」。地雷确凿。

3.2 落地一:镜像改成 per-state 槽位池(device 侧)

仓库里的槽位定义与 vc_dev_t 挂载(src/npu/cuda/vllm_cuda_kernels.cu):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu */
/* Per-state KV mirror slot(镜像按 state 归属,不再单份 free-all)。 */
#define VC_KV_SLOTS 8
typedef struct {
    int      used;      /* 1 = 该槽位已被 acquire */
    int      nl, max_seq, kv_dim, nkv;
    int8_t  *d_k;       /* [nl][max_seq][kv_dim] int8 K */
    int8_t  *d_v;       /* [nl][max_seq][kv_dim] int8 V */
    float   *d_ks;      /* [nl][max_seq][nkv]  f32 K scale */
    float   *d_vs;      /* [nl][max_seq][nkv]  f32 V scale */
} vc_kv_slot_t;

/* vc_dev_t 内 */
vc_kv_slot_t kv[VC_KV_SLOTS];
int     kv_act;   /* bound slot index, -1 = none */
int8_t *d_k, *d_v; float *d_ks, *d_vs;   /* 指向当前绑定槽位的「视图」 */

kv_init 只动本槽位(彻底去掉 free-all):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu,vcuda_dev_kv_init() */
/* Allocate the GPU K/V mirror for ONE slot. Other slots are untouched:
 * this is the per-state ownership that replaces the old free-all. */
VCUDA_API int vcuda_dev_kv_init(void *dev, int slot, int nl, int max_seq,
                                int kv_dim, int nkv) {
    vc_dev_t *s = (vc_dev_t *)dev;
    if (!s || !s->ok) return -1;
    if (slot < 0 || slot >= VC_KV_SLOTS) return -1;
    vc_kv_slot_t *k = &s->kv[slot];
    if (!k->used) return -1;   /* must be acquired first (kv_slot_alloc) */
    const size_t kv_elems = (size_t)nl * max_seq * kv_dim;
    const size_t sc_elems = (size_t)nl * max_seq * nkv;
    if (cudaMalloc((void **)&k->d_k,  kv_elems) != cudaSuccess || ... ) { /* 释放半成品 */ }
    k->nl = nl; k->max_seq = max_seq; k->kv_dim = kv_dim; k->nkv = nkv;
    return vc_kv_bind(s, slot);
}

三个关键设计:

  1. kv_init(dev, slot, ...) 只动本槽位,彻底去掉 free-all。
  2. vc_kv_bind(slot) 把槽位「绑定」成扁平指针视图 (s->d_k = k->d_k; ...)。这样三个 attention 内核与 vc_decode_body 的 s->d_k + base*kv_dim 这类行寻址一行都不用改------改动被限制在 API 边界,热点路径零风险。
c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu,vc_kv_bind() */
static int vc_kv_bind(vc_dev_t *s, int slot) {
    if (slot < 0 || slot >= VC_KV_SLOTS) return -1;
    vc_kv_slot_t *k = &s->kv[slot];
    if (!k->used) return -1;
    if (s->kv_act != slot && s->graph_ready) {   /* 切槽位使已捕获的 CUDA Graph 失效 */
        cudaGraphExecDestroy(s->gexec); s->gexec = NULL;
        cudaGraphDestroy(s->graph);     s->graph = NULL;
        s->graph_ready = 0;
    }
    s->kv_act = slot;
    s->kv_nl = k->nl; s->kv_max_seq = k->max_seq;
    s->kv_dim = k->kv_dim; s->kv_nkv = k->nkv;
    s->d_k = k->d_k; s->d_v = k->d_v; s->d_ks = k->d_ks; s->d_vs = k->d_vs;
    return 0;
}
  1. 切槽位会让已捕获的 CUDA Graph 失效 (图里固化的是镜像基址),vc_kv_bind 检测到 kv_act 变化就销毁 gexec/graph,下次重捕获。

槽位生命周期由两个 API 管:vcuda_dev_kv_slot_alloc(dev)(→ slot 或 -1 池满)、vcuda_dev_kv_slot_free(dev, slot)(释放本槽位,含图失效)。append / attn_q8 / decode_run / spec_probe / spec_verify 全部加 slot 参数并在入口 vc_kv_bind。

3.3 引擎侧:槽位惰性 acquire

STQwenInferenceState 用槽号持有,st_cuda_kv_slot(st) 惰性 acquire,st_qwen_inference_free 归还。仓库里的惰性分配(src/model/vllm_safetensors.c):

c 复制代码
/* 文件名:src/model/vllm_safetensors.c */
/* Resolve (lazily acquiring) this state's device KV-mirror slot. Returns a slot
 * index >= 0, or -1 when no mirror is available (pool full / no backend). */
static int st_cuda_kv_slot(STQwenInferenceState *st) {
    if (st->cuda_kv_slot >= 0) return st->cuda_kv_slot;
    if (st->cuda_kv_slot == -2) return -1;   /* 已判定池满:不再重试、不再刷屏 */
    vllm_cuda_t *c = st_cuda_of(st);
    if (!c || !vllm_cuda_available(c)) return -1;
    st->cuda_kv_slot = vllm_cuda_kv_slot_alloc(c);
    if (st->cuda_kv_slot < 0) {   /* pool exhausted: say so once, don't degrade silently */
        fprintf(stderr,
                "[CUDA-KV-SLOT] device KV mirror pool exhausted (VC_KV_SLOTS) -> "
                "this state falls back to CPU attention/decode\n");
        st->cuda_kv_slot = -2;
        return -1;
    }
    return st->cuda_kv_slot;
}

草稿模型走独立设备 g_draft_cuda,因此天然各自成池(不受主模型槽位占用影响)。

槽位容量 :VC_KV_SLOTS 初值 4,实测 COPYTEST 自身就要 4 个(stream state + istA/istT/istB),正好顶格 → 抬到 8 留余量。池满时打印一次告警 并让该 state 回退 CPU(槽位记 -2,避免每层每 token 重试刷屏)。

内存代价:镜像单槽 = nl·max_seq·(2·kv_dim + 8·nkv)。对本机 30B-A3B(48 层 / kv_dim=512 / max_seq=8192)= 415,236,096 B ≈ 396 MiB (测算见 第 21 篇)。只有真正 append 的 state 才分配,故实际开销正比于并发。

四、地雷二:前缀复用不碰镜像

4.1 问题

st_qwen_copy_kv_prefix 逐块把 CPU 数组 memcpy 好、把 dst->cache_len[l] 设成 prefix_len,但 device 镜像一行都没写 。此后 dst 一 append 就从 pos = prefix_len 开始写,镜像的 [0, prefix_len) 永远是未初始化内容。

4.2 第一步:拒绝,而不是静默

同步不成时,让该 state 整体回退 CPU,并打印一次性 [CUDA-KV-GUARD] 告警。复验(ATTN=1 ATTN_BITEXACT=1):

ini 复制代码
[CUDA-KV-GUARD] prefix copy of 74 tokens leaves the device KV mirror unsynced
                -> CUDA mirror disabled for this state (CPU fallback)
[STREAM] COPYTEST A_NLL=6.635696930  B_NLL=6.635696930  diff=0.000e+00 PASS

-1.213e+01 → 0。但这是靠「把 B 踢回 CPU」换来的 :diff 虽然为 0,B 实际没在用镜像。

4.3 第二步:真正的 device 侧同步

新增逐层 D2D 拷贝 API(src/npu/cuda/vllm_cuda_kernels.cu):

c 复制代码
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu */
/* Copy the mirror rows [0, prefix_len) of EVERY layer from `src_slot` to
 * `dst_slot` (device-to-device). This is the device half of a host KV-prefix
 * reuse (st_qwen_copy_kv_prefix): the host rows were memcpy'd block by block,
 * so the device mirror has to follow or the subsequent decode/attention reads
 * stale/never-written rows. */
VCUDA_API int vcuda_dev_kv_copy_prefix(void *dev, int src_slot, int dst_slot,
                                       int prefix_len) {
    ...
    if (sk->nl != dk->nl || sk->max_seq != dk->max_seq ||
        sk->kv_dim != dk->kv_dim || sk->nkv != dk->nkv) return -1;
    for (int l = 0; l < sk->nl; l++) {
        const size_t koff = (size_t)l * (size_t)sk->max_seq * (size_t)sk->kv_dim;
        cudaMemcpyAsync(dk->d_k + koff, sk->d_k + koff, kbytes, cudaMemcpyDeviceToDevice);
        cudaMemcpyAsync(dk->d_v + koff, sk->d_v + koff, kbytes, cudaMemcpyDeviceToDevice);
        cudaMemcpyAsync(dk->d_ks + soff, sk->d_ks + soff, sbytes, cudaMemcpyDeviceToDevice);
        cudaMemcpyAsync(dk->d_vs + soff, sk->d_vs + soff, sbytes, cudaMemcpyDeviceToDevice);
    }
    ...
}

接线点在 st_qwen_copy_kv_prefix 末尾(src/model/vllm_safetensors.c):

c 复制代码
/* 文件名:src/model/vllm_safetensors.c,st_qwen_copy_kv_prefix() 末尾 */
/* 同步成功 → 放开镜像(dst 继续走 CUDA,与全量 prefill 位级一致);
 * 同步失败 → 标记该 state「镜像不可用」整体回退 CPU。
 * 两种情况都不允许静默读到垃圾。 */
if (st_cuda_of(dst) && vllm_cuda_available(st_cuda_of(dst))) {
    if (st_cuda_kv_sync_prefix(dst, src, prefix_len) == 0) {
        dst->cuda_kv_nosync = 0;
        fprintf(stderr, "[CUDA-KV-SYNC] device KV mirror prefix synced "
                        "(%d tokens, slot %d <- %d)\n",
                prefix_len, dst->cuda_kv_slot, src->cuda_kv_slot);
    } else {
        fprintf(stderr, "[CUDA-KV-GUARD] prefix copy of %d tokens cannot sync the "
                        "device KV mirror -> CUDA mirror disabled for this state "
                        "(CPU fallback)\n", prefix_len);
        dst->cuda_kv_nosync = 1;
    }
}

成功则清 cuda_kv_nosync;失败(跨设备 / 任一侧无镜像 / 几何不符 / 拷贝失败)则保留上一步的 guard 回退。两种情况都不允许静默读垃圾。

一个反直觉的结论 :serve 的 ist_reset 复用分支不需要接线 。它复用同一个 state 的同一份镜像 ,cache_len 回绕到 keep_lcp 时镜像的 [0,keep_lcp) 行本就有效,续 prefill 从 keep_lcp 接着写即可。需要同步的是「跨 state / 跨设备」的复用 ,而 serve 不走 st_qwen_copy_kv_prefix。(此点由读码 + 路径核对确认,见 第 20 篇 的同类教训。)

4.4 判别量:为什么 diff 能证明「B 真的在用镜像」

同一条命令(VLLM_STREAM_COPYTEST=1 VLLM_CUDA_ATTN=1 --cuda,实测):

P0(guard 回退) P1(真同步)
日志 [CUDA-KV-GUARD] [CUDA-KV-SYNC] synced (74 tokens, slot 3 <- 2)
A_NLL 6.631492386(CUDA 快内核) 6.631492386(CUDA 快内核)
B_NLL 6.635696930(CPU) 6.631492386(CUDA 快内核)
diff -4.205e-03 0.000e+00

A 走的是 CUDA 的默认(非位级)attention 内核,与 CPU 有 greedy 级口径差;B 在 P0 走 CPU 所以拿的是「CPU 精确值」,两者相差 -4.205e-03。P1 后 B 也走同一个 CUDA 内核读同一份数据 ,于是 A == B 逐位相同。diff 从 -4.205e-03 变 0 本身就是「B 已切到镜像」的证据 ------ 若同步写错偏移或槽位,B 会像迁移前那样大幅偏离(≈-1.2e+01)。

4.5 L2 复用对拍(判据的逐字节版)

把探针从「GPU vs CPU」换成「复用 vs 全量 」(VLLM_L2_DUMP,key = t<pos>_l<L>_{nrm,kraw,knorm,qrope,k,v,ks,vs,attn,ores,x} + t<pos>_{final_normed,logits},实测):

量 值
A 段 / B 段 key 数 10070 / 10070(11 类逐层量 × 912 + logits/normed × 19)
A 段 vs B 段同 key 哈希 0 不等
整份 dump vs P0 基线(B 在 CPU 时) 逐字节相同

最后一行是最强的一句:「复用 + 同步后的镜像」的输出,与「B 被迫走 CPU」的输出逐字节相同 ------ 即镜像同步路径 == CPU 路径 == 全量 prefill。

五、边界与已知限制

项 结果
=0(CUDA 全关)COPYTEST diff=0.000e+00 PASS,值同迁移前(6.635696930)
L2 纯 CPU dump l2_ref SHA256 A15A5FB9... 逐字节不变
L2 全 GPU decode dump l2_gpu SHA256 24B104A6... 逐字节不变
L2 不一致集合 仍 2054 个,首个仍 t32_l0_x;层 0 注意力路径十探针保持一致
  • 本篇只覆盖两处同类分叉(多 state 共用镜像、内存前缀复用);disk-KV 恢复与 L3 见 第 20 篇。
  • 镜像单槽 ≈ 396 MiB、池上限 8 槽均为本机 30B-A3B 口径,换模型/换卡需重算。
  • 复用同步是 D2D 拷贝,成本随 prefix_len × nl 增长;长前缀场景未单独量收益。

CPU 对照(迁移前基线)

  • CPU 参考 :kestrel-llm/src/model/vllm_safetensors.c:st_qwen_copy_kv_prefix ------ CPU 内存 KV 前缀复用(迁移前的前缀复用语义)。
  • 迁移要点 :单份 device KV 镜像 → per-state 槽位池(VC_KV_SLOTS);vc_kv_bind 用指针视图把槽位机制挡在 API 层(内核零改);切槽位使已捕获 graph 失效;前缀复用补 D2D 镜像同步([CUDA-KV-SYNC])。
  • 真机验证 :未独立复现:需 30B-A3B + VLLM_STREAM_COPYTEST 三 state(istA/istT/istB)与 L2 复用对拍。

六、小结

  1. 「派生物」必须显式跟随真值。镜像相对 CPU KV 是派生数据;任何修改 CPU KV 的调度动作(前缀复用、disk-KV 恢复、L3 回填)都必须显式同步它,否则就是静默分叉。
  2. per-state 的 ready 标志救不了共享缓冲 。st->cuda_kv_ready 看似「每 state 一次初始化」,但当缓冲是全局单份时,这个标志只保证「每个 state 各 init 了一次」,结果是把别人的数据砸了。要么 per-state 归属,要么根本别用 ready 标志自我安慰。
  3. 改造要限制在 API 边界 。vc_kv_bind 用「指针视图」把槽位机制挡在 API 层,三个 attention 内核与 vc_decode_body 一行未改 ------ 这是本篇能快速、低风险落地的主因。
  4. diff 也能当证据,只要口径差异是已知的 。P1 的判别量不是「结果对不对」而是「diff 是否归零」:因为 A/B 走同一内核读同一数据时必须逐位相同。先用已知口径差造出一把尺子,再拿尺子量新路径 ------ 比单纯看「输出合理」强得多。
  5. 先复现、再动手 。三处同类隐患都是先跑出 FAIL(-1.2e+01 / -4.2e-03 / 见 第 20 篇 的 DKVTEST FAIL),再写代码。没有可证伪的复现,就无法判断修法是否真的生效。

相关篇目:第 18 篇、第 20 篇、第 21 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
我的div丢了肿么办21 小时前
go语言对路由中的业务逻辑进行抽离
后端·go
孔令飞1 天前
1024程序员节,OneX平台送3套免费Go实战项目
云原生·go·ai编程
我不会起名字3221 天前
gRPC 拦截器写错:metadata 透传与超时注入的 3 个坑
微服务·go·grpc·拦截器
用户4015823324622 天前
对话一长,AI 就忘了你说过的话?Claude Code、Codex、Aider、WES Code 怎么压缩上下文
人工智能·go·ai编程
我的div丢了肿么办2 天前
go语言抽离路由文件以及对路由进行分组
后端·go
孔令飞2 天前
【OneX】OneX AI 工具安装、配置及功能介绍
云原生·go·ai编程
小满zs2 天前
Go语言第十四章(文件操作)
后端·go
vipxieliang2 天前
Go 1.24 工具链改进实战:工具依赖管理与 go vet 新分析器
后端·go
程序员爱钓鱼2 天前
Go 编程实战:Benchmark——基准测试、testing.B、benchmem、性能对比与代码优化实战
go·排序算法