KV Cache 内存布局:推理吞吐不是只靠显存容量堆出来
KV Cache 内存布局推理吞吐不是只靠显存容量堆出来大模型推理里KV Cache 是吞吐和并发的底层约束。很多优化讨论停在“显存够不够”但真正上线后会发现显存容量只是第一层问题内存布局、块大小、碎片率、访问局部性和调度策略都会影响吞吐。显存够布局烂照样跑不稳。我更愿意把 KV Cache 当成一个系统内存分配器来设计而不是模型旁边的附属数组。它要面对长短请求混部、动态释放、批处理重排和多 GPU 拆分不能只按理想 batch 计算。一、KV Cache 是动态资源prefill 后每个序列都会持续增长 KV。短请求很快结束长请求一直占着块。若采用简单连续分配碎片会越来越明显最终出现“总显存还有但放不下新序列”的尴尬。flowchart TD A[新请求进入] -- B[申请 KV blocks] B -- C[Prefill 写入] C -- D[Decode 逐步追加] D -- E{请求结束?} E --|否| D E --|是| F[释放 blocks] F -- G[空闲块回收]这里的关键是 blocks而不是一整段连续内存。分页式 KV 管理能提升利用率也让请求生命周期更容易管理。二、块大小是吞吐和碎片的折中块太小block table 管理开销上升访问索引更复杂块太大内部碎片增加。块大小要结合模型层数、head 维度、batch 形态和 kernel 访问模式测。struct KvBlock { block_id: u32, token_capacity: u16, used_tokens: u16, device_ptr: u64, } struct SequenceKv { seq_id: u64, blocks: Vecu32, logical_len: usize, }Rust 层可以只维护元数据真正 device pointer 交给底层 runtime。关键是元数据结构要清楚表达逻辑长度和物理块之间的映射。三、调度器要感知 KV 压力推理调度不能只看请求数。一个 32K 上下文请求和一个 512 token 请求对 KV 的压力完全不同。队列入场前就要估算 KV 预算。admission_control: max_active_tokens: 200000 max_kv_blocks: 12000 reject_when_fragmentation_above: 0.25如果碎片率已经高继续接长请求会让系统更抖。此时可以延迟低优先级请求或触发整理策略。推理系统也需要背压不能把所有请求都吞进来。背压机制的实现有几种策略。最简单的令牌桶直接计数 active tokens但忽略了长短序列对碎片的不同影响。更精细的方案会在准入时估算请求的预期 KV 增长率短请求按 max_new_tokens 计算长请求按历史 decode 速率外推。如果当前碎片率超过阈值即使总 token 数未达上限也会拒绝长请求入场防止碎片进一步恶化。多 GPU tensor parallel 场景下碎片率需要按每张卡独立统计——一张卡碎片高可能拖慢整个 TP 组因为 KV cache 在张量并行维度上是分片存储的某张卡被碎片堵死后同组其他卡的可用空间也随之被锁死。推荐在调度器里维护一个 GPU 级别的健康分数综合碎片率、block 分配延迟和空闲块数动态调整准入策略。这套机制在混合长短请求的线上流量下能把碎片率控制在 20% 以内避免 P99 尾延迟因 block 分配失败而飙升。四、观测要到块级别只看显存使用率不够。要看 active blocks、free blocks、fragmentation、平均 blocks per sequence、释放延迟、分配失败次数。出现尾延迟时这些指标能告诉你是算力瓶颈还是内存管理瓶颈。生产里我还会记录每次 OOM 前的 KV 状态快照。没有快照事后只能看一条 OOM 日志定位不了根因。还要区分 prefill 和 decode 的 KV 压力。prefill 阶段一次性写入大量 tokendecode 阶段逐步追加两者对分配器的冲击不同。压测时可以把长上下文请求单独拉出来观察 block 分配耗时和碎片变化。kv_profile: prefill_alloc_p95: 1.8ms decode_append_p95: 0.12ms fragmentation: 18% allocation_failures: 0五、总结KV Cache 内存布局决定推理系统能不能稳定承载并发。分页式管理、合理块大小、调度器感知 KV 压力、块级观测都是底层优化的基本功。推理吞吐不是只靠显存容量堆出来。内存布局不稳再大的显存也会被碎片和长尾拖住。