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 档与迁移前逐字节一致)。
一、问题与结论
- 原设计在多 state 下必错:device KV 镜像原为全后端单份,且 vcuda_dev_kv_init 会 free-all 后重建 —— 第二个 state 一 init 就把第一个 state 的镜像指针释放掉;多个 state 的行还会互相覆盖。
- 前缀复用是第二处:st_qwen_copy_kv_prefix 只复制 CPU 侧 KV 数组、不碰镜像,却把 dst->cache_len[l] 直接前移到 prefix_len。于是镜像的前缀行是未初始化垃圾,后续 decode/attention 会静默读它。
- 地雷已复现(硬数据):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,是典型的「读到垃圾」。
- 修法不是加开关,是改归属:镜像从「单份」改成 per-state 槽位池(vc_kv_slot_t kv[8]),kv_init 只分配/释放本槽位;引擎侧 STQwenInferenceState 持槽位号,惰性 acquire、free 时归还。
- 把「拒绝」升级为「同步」:新增 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 回退」切到了「同步后的镜像」,且与全量路径逐位一致。
- 无回归(逐字节硬证):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):
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):
# 基线(纯 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):
/* 文件名: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):
/* 文件名: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);
}
三个关键设计:
- kv_init(dev, slot, …) 只动本槽位,彻底去掉 free-all。
- vc_kv_bind(slot) 把槽位「绑定」成扁平指针视图(s->d_k = k->d_k; …)。这样三个 attention 内核与 vc_decode_body 的 s->d_k + base*kv_dim 这类行寻址一行都不用改——改动被限制在 API 边界,热点路径零风险。
/* 文件名: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;
}
- 切槽位会让已捕获的 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):
/* 文件名: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):
[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):
/* 文件名: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):
/* 文件名: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,实测):
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},实测):
最后一行是最强的一句:「复用 + 同步后的镜像」的输出,与「B 被迫走 CPU」的输出逐字节相同 —— 即镜像同步路径 == CPU 路径 == 全量 prefill。
五、边界与已知限制
- 本篇只覆盖两处同类分叉(多 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 复用对拍。
六、小结
- 「派生物」必须显式跟随真值。镜像相对 CPU KV 是派生数据;任何修改 CPU KV 的调度动作(前缀复用、disk-KV 恢复、L3 回填)都必须显式同步它,否则就是静默分叉。
- per-state 的 ready 标志救不了共享缓冲。st->cuda_kv_ready 看似「每 state 一次初始化」,但当缓冲是全局单份时,这个标志只保证「每个 state 各 init 了一次」,结果是把别人的数据砸了。要么 per-state 归属,要么根本别用 ready 标志自我安慰。
- 改造要限制在 API 边界。vc_kv_bind 用「指针视图」把槽位机制挡在 API 层,三个 attention 内核与 vc_decode_body 一行未改 —— 这是本篇能快速、低风险落地的主因。
- diff 也能当证据,只要口径差异是已知的。P1 的判别量不是「结果对不对」而是「diff 是否归零」:因为 A/B 走同一内核读同一数据时必须逐位相同。先用已知口径差造出一把尺子,再拿尺子量新路径 —— 比单纯看「输出合理」强得多。
- 先复现、再动手。三处同类隐患都是先跑出 FAIL(-1.2e+01 / -4.2e-03 / 见 第 20 篇 的 DKVTEST FAIL),再写代码。没有可证伪的复现,就无法判断修法是否真的生效。
相关篇目:第 18 篇、第 20 篇、第 21 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua…; CPU 推理源码 gitee.com/pei-xiaogua…
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
• KV reuse migration to GPU (on): per-state mirrors separated from the same silent fork
One sentence: KV cache on the GPU side has never had CPU's dispatch capability (segregation, prefix reuse, disk-KV, L3 presence), with only one flat mirror; the first step of the migration was to transform it into a per-state slot and to block the "Multi Common Mirror" (Multistate Common Image) (Multiple Memory Prefixing Unwritten Mirror) forewords.
Prefix: It is recommended to start with Part 18 (preconditions for attaining the value attitudinal/decode).
Environment: x86-64 + NVIDIA RTX 5090 (CUDA backend) Qwen3-30B-A3B q4local q switch VLLM CUDA ATTN=1 / VLM CUDA DECADE=1.
Status: ✅ landed and rechecked (opt-in file does not return, = 0 match byte prior to migration).
I. ISSUES AND CONCLUSIONS
- The original design is wrong under multiple dates: the device KV mirror was a single copy of the whole backend, and vcuda dev kv init was recreated after free-all -- the second state one init releases the first mirror pointer of the state; multiple status lines will cover each other.
- Prefix reuse is the second: st qwen copy kv prefix only replicates the CPU side KV array without touching the mirror, moving dst->cache len[l] directly forward to prefix len. So the front line of the mirror is uninvolved trash, followed by decode/attaction will read it silently.
- Mines reproduced (hard data): VLM STREAM COPYTEST=1 VLMM CUDA ATTN=1-cuda →A NLL=6.631492386 B NLL=18.756641629 diff=-1.213e+01 FAIL (pure CPU baseline diff=0.000e+00 PASS). On the B side, NLL flew to 18.75, a typical "read the garbage".
- The modification is not a switch, but a change in attribution: the mirror is changed from a "single" to a per-state tank (vc kv slot tkv[8]), kv init is allocated/released only; STQwenInferenceSate on the side of the engine is returned when inert acquire, free).
- Upgrade "Rejection" to "Sync": add new K/V/ks/vs to prefix (device-to-device) to complete the image at the end of st qwen copy kv prefix. The difference diff from -4.205e-03 to 0.000e+00, and [CUDA-KV-GUARD] alarm disappears - proves that the used side state did access the "Sync Mirror" from the "CPU rollback" and corresponds to the full mass path.
- No return (certificate by byte): L2 2 sets of dump (pure CPU / DECODE+INFER+FORCE CPU+L2EXACT, 2120 key) are identical to SHA256 (A15A5FB9.../ 24B104A6...).
Figure 1: Causes and modifications of the two "silent silos of the same kind" in this article. Previous: Device KV mirrors were all back-end singles, kv init free-all reconstruction, multiple state releases/cover lines when shared (repeated A NLL=6.63 / B NLL=18.76 / Diff=-1.213e+01 FAIL); amended to change attribution to per-state tank (VC KV SLOTS=8) and to block changes with vc kv bind's pointer view on the API layer - three attestation & vc decode body line. Next: st qwen copy kv prefix only replicates CPU KV, does not touch mirror images, leading to pre-soft-reading garbage; the modification is K/V/ks/vs which supplements vcuda dev kv copy prefix (layer by layer D2D [0, prefix)], diff from -4.205e-03 to 0.000e+00. In both places, they insist that "no silent reading of garbage is allowed".
Background: CPU has and GPU does not have movement control capacity
KV cache on the CPU side is already in place (see docs/KVcachev2 - Inert Allocation and Layer Presence Program.md):
The mirror image on the GPU side has only one purpose: to make KV (vc dev t Note: Mirrorred as flat [nl][max seq][kv dim] int8 + [nl] [max seq] [nkv] f32 scales) unnecessary to jump through the field. It's a "specified creature" that must be the only true value of CPU's q8+scale behavior. All the challenges of migration are in this constant pattern.
Core mechanisms
3.1 Mines I: Single image + free-all
The original vc dev t has only one flat image of field, vcuda dev kv init come in first vc kv free(s) and then cudamalloc. Engine side by st->cuda kv ready does "only once" for each state, but this per-state sign is wrong for the same share of device buffer: state A first applied init image P; state B went for the first time. cuda kv ready branch → kv init releases P and builds P'. "A" is followed by "P" or "B".
Recurrence (now known as harness is exactly three state structures: COPYTEST builds statA/istT/istB, A goes "full prefill", B goes "st qwen copy kv prefix (T→B)+ continues prefill" and lastly compares two paths:
# Baseline (pure CPU, CUDA All)
[STREAM] COPYTEST A_NLL=6.635696930 (n=20) B_NLL=6.635696930 (n=20) diff=0.000e+00 PASS
# Mines (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 flew from 6.64 to 18.76 - The normal range for the language model NLL is 5-7,18.76, which may only come from "the attention given to the unconnected vector". The mines are solid.
3.2 Landfall I: Change mirror to per-state slot pool (device side)
The definition of slots in the repository with vc dev t mounted (src/npu/cuda/vllm cuda kernels.cu):
* File name: src/npu/cuda/vllm cuda kernels.cu*/
/ * Per-state KV Mirror slot (image belongs to state, no longer single free-all). */
#define VC_KV_SLOTS 8
typedef struct {
Int used; / * 1 = the slot has been 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; /* refers to the 'view' of the current slot binding */
kv init only moves this slot (remove free-all):
* File name: 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 (cuda Malloc((void**)&k->d k, kv elems)! = cudaSuccess ||) {/* Release semi-finished */}
k->nl = nl; k->max_seq = max_seq; k->kv_dim = kv_dim; k->nkv = nkv;
return vc_kv_bind(s, slot);
}
Three key designs:
- kv init (dev, slot,...) just move the slot and completely remove the free-all.
- vc kv bind(slot) converts the slot to a flat pointer view (s->d k=k->d k; ). No line of this type of site will be changed for three kernels and vc decode body s->d k + base*kv dim -- changes are restricted to the API border and no risk for hotspot paths.
* File name: 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){/ *cut slots to invalidate captured CUDA Grab*/
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;
}
- Cutting the slot will render the captured CUDA Graph invalid (the image base is solidified in the diagram), vc kv bind has detected a change in kv act to destroy gexec/graph, next recapturing.
The slot life cycle is composed of two API tubes: vcuda dev kv slot alloc(dev) (→slot or -1 pool full), vcuda dev kv slot free(dev, slot) (release this slot, including graph invalid). Append / attn q8 / decode run / spec probe / spec verify all add slot parameters and enter vc kv bind.
3.3 Engine side: slot inertity acquire
STQwenInferencestate held in slot number, st cuda kv slot(st) inertity acquire, st qwen inference free returned. Inert distribution in warehouse (src/model/vllm safetensors.c):
* File name: 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; / *is determined to be full: no more retry, no more screenwashing */
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;
}
The draft model follows the stand-alone device g draft cuda and is therefore naturally separate from the pool (without the main model slot occupation).
Slot capacity: VC KV SLOTS initial value 4, it is measured that COPYTEST itself requires 4 (stream state + statA/istt/istB), which is the top of the grid to the 8 leftover. Prints the state rollback CPU when the pool is full and allows the state rollback CPU (train - 2, avoids token retry screens per layer).
Memory costs: Mirror = nl max seq (2 kv dim + 8 nkv). On-board 30B-A3B (48th floor / kv dim=512 / max seq=8192) = 415,236,096 B ≈396 MiB (measured in Part 21). Only real append state is allocated, so the actual cost is exactly the same.
Mines II: Prefix not to touch mirrors
4.1 Issues
St qwen copy kv prefix sets the CPU array memcpy on a block-by-block basis and dst->cache len[l] as prefix len, but device mirrors are not written at all. Then dst I append started from pos = prefix len, and the mirror [0, prefix len) was never initialized.
4.2 Step 1: Reject, not silence
When sync is not possible, let the state as a whole rollback CPU and print a one-time [CUDA-KV-GUARD] alarm. Check (ATTN=1 ATTN BITEXACT=1):
[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. But it was replaced by "Kick B back CPU": Diff, though 0, B is not actually mirroring.
4.3 Step 2: Real Divice SideSync
Add layer by layer D2D copy API (src/npu/cuda/vllm cuda kernels.cu):
* File name: 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);
}
...
}
At the end of st qwen copy kv prefix (src/model/vllm safetensors.c):
* File name: src/model/vllm safetensors.c, st qwen copy kv prefix() */
/ * Synchronization success → Release mirror image (dst continues to run CUDA, same as full prefill level);
* Synchronization failed → Mark the entire rollback CPU of the state "image not available".
* Neither situation allows silent reading of garbage. */
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;
}
}
Success clears cuda kv nosync; failure (no mirrors on either side/ geometry/ copying failed) retains the last guard rollback. In both cases, silent reading of garbage is not allowed.
A counterintuitive conclusion: the stat reset reuse branch does not need a connection. It repeats the same mirror of the state, and Cache len returns to the [0, keep lcp] mirror of the keep lcp time, which is valid and continues from the keep lcp to write. Synchronizes the reuse of "cross state/ cross device" and serve without st qwen copy kv prefix. (This point is confirmed by the read code + path check, see the same lessons as in Title 20.
4.4 Distinction: Why diff can prove that "B is really mirroring"
The same command (VLLM STREAM COPYTEST=1 VLM CUDA ATTN=1-cuda, known):
A takes CUDA's default (non-grade) attestation kernel, and CPU has a greeny caliber difference; B takes CPU at P0 and so takes the "CPU precision value" and the difference is -4.205e-03. After P1 B also follows the same CUDA kernel reading of the same data, so A = B is the same. Diff from -4.205e-03 to 0 is in itself evidence of "B has cut to mirror" - if synchronised miswritten or sloted, B deviates as much as it did before the migration (≈-1.2e+01).
4.5 L2 Re-use matching (judgement Z113Z version)
Switching the probe from "GPU vs CPU" to "repeated vs full" (VLLM L2 DUMP, key =t<pos> l<L>nrm, knorm, qrope, k, v,ks, vs, attn, ores, x}+t<pos> final normed, logits},
The last line is the strongest sentence: "Repeat + Synchronized Mirror" with the same output of "B was forced to walk CPU" on byte - i.e. mirror sync path = = CPU path = = full mass prefill.
V. Boundary and known constraints
- This article covers only two of the same forks (multistate shared mirrors, memory prefix reuses); disk-KV restores with L3 see 20.
- Mirror single slots ≈ 396 MiB, pool ceilings ≈8 槽 30B-A3B caliber, recalculated to change models/cards.
- Re-use sync is a D2D copy, with costs increasing with prefix len x nl; long prefixes do not yield a separate gain.
CPU comparison (pre-removal baseline)
CPU Reference: Kestrel-llm/src/mode/vllm safetensors.c: st qwen copy kv prefix - CPU memory KV prefix duplicate (prefix synonym for migration).
- Points for migration: single device KV mirrors → per-state tank (VC KV SLOTS); vc kv bind to block the slot mechanism in the API layer with a pointer view (kernel zeroing); cut slots to disable captured graph; prefix refill D2D mirror sync ([CUDA-KV-SYNC]).
- Realism: Not independently reproduced: 30B-A3B + VLM STREAM COPYTEST III state (ista/istT/istB) and L2 reuse.
Summary
- "Specific creatures" must follow the true values. Mirrors relative to CPU KV are derived data; any movement to modify CPU KV (prefix reuse, disk-KV recovery, L3 completion) must be clearly synchronized, otherwise it is a silent fork.
- per-state ready sign cannot save shared buffer. t->cuda kv ready appears to be "initiating every state" but when the buffer is a single part of the global picture, this sign only guarantees "each state has an init" and as a result, someone else's data is smashed. Either per-state belongs, or don't use ready as a sign of consolation.
- Alteration is limited to the API border. vc kv bind uses the Pointer View to block the slot mechanism at the API level, with three attention kernels and vc decode body line unchanged -- this is the main reason why this article is quick and low-risk.
- Diff can also be evidence as long as the caliber difference is known. The P1 scale is not "results right" but "diff is zero" because A/B has to read the same data from the same kernel. It's better to build a ruler with a known caliber difference, then take a ruler to a new path -- it's much better than simply looking at the "output is reasonable."
- Re-emerge, then do it. All three of the same hazards are first running out of FAIL (-1.2e+01 /-4.2e-03/ See 20 DKVTEST FAIL) and then writing codes. Without the resurgence of perjury, it would be impossible to judge whether the law was actually in force.
Related Articles: 18, 20, 21 Source code and supporting resources: this warehouse gitee.com/pei-xiaogua...; CPU reasoning source gitee.com/pei-xiaogua...
Auto-fetched and translated by AI Atlas. All rights belong to the original author.