news 2026/10/17 11:34:03

大模型推理显存优化:用共享内存KV Cache降低显存占用与延迟

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型推理显存优化:用共享内存KV Cache降低显存占用与延迟

先说一个反直觉的结论:部署大模型推理服务时,真正把显存吃干的往往不是模型权重,而是KV Cache。我在某台双路服务器上跑一个7B级别的模型,权重占14GB,看似还有不少余量;但并发一拉高、上下文一加长,KV Cache直接暴涨到近20GB,请求还在排队,OOM先把服务干崩了。更憋屈的是,我给服务开了8个worker进程做并发隔离,每个worker都各自在显存里为同一段系统提示词反复计算、反复缓存同一份K和V。后来我换了个思路——把KV Cache放进共享内存,让所有worker共享同一份前缀缓存。结果显存占用降了接近一半,首token延迟也从几百毫秒掉到了百毫秒级别。这篇文章就完整记录一下当时的方案设计、落地实现和踩过的那些坑。

1. KV Cache为什么是推理的隐形吞显存怪兽

1.1 KV Cache的显存账单:先算清楚钱花在哪

先简单说说KV Cache到底是个什么东西。做自回归生成时,每次输出一个token,都需要拿当前token的Query去和之前所有token的Key做注意力计算,然后再用Value加权求和。如果每走一步都把前面所有token的K、V重新算一遍,计算量会随序列长度平方级增长,解码速度慢到没法用。

所以现在的方案都是把已经算好的Key和Value缓存下来,每步只计算新token的K、V,然后追加进缓存。这个缓存在显存里的组织方式,就是KV Cache。

那它到底吃多少显存?计算公式可以拆得很细:

单个token占用的KV字节数 = 层数 × 2(Key和Value两份) × KV头数 × 每个头的维度 × 数据类型字节数

我拿当时那个7B级模型举例,28层,分组查询下KV头数是2,每个head的维度是128,FP16存储是2字节:

单个token的KV大小 = 28 × 2 × 2 × 128 × 2 = 28672字节,约28KB。

看起来还好,但后面全是乘法:

上下文长度单请求KV Cache8路并发32路并发
2048约57MB约458MB约1.8GB
4096约114MB约917MB约3.6GB
8192约229MB约1.8GB约7.3GB

权重确实是14GB没错,但KV Cache在长上下文、高并发场景下直接追平、甚至超过权重。更关键的是,权重只加载一次,KV Cache却是在每个请求生命周期内动态增长的,二者的压力曲线完全不一样。

1.2 多worker部署里的重复计算与重复缓存

单进程单请求算完一遍KV也就算了,但大多数推理服务为了并发隔离和稳定性,会同时开好几个worker进程。每个worker都有自己独立的显存KV管理,互不可见。

问题就出在这里:如果我所有请求前面都带同一段系统提示词或知识库前缀,比如一段4k token的固定设定,那么8个worker、每worker再开4个并发请求,就是在重复做8 × 4 = 32份前缀KV的计算和缓存。每份4k token,按上面的公式算,一份就约112MB,32份合计约3.6GB,里面承载的有效信息其实只有1份。

这些请求如果被调度到不同机器,机器之间也一样在重复劳动。但起码在同一台机器内部,我可以通过进程间共享的方式把这32份合并成一份。这就是我想把KV Cache放到共享内存里的直接动机。

顺着这个思路,当时也认真对比过几种跨进程复用KV的方案,下面单独说。

2. 共享内存不是银弹:先算清楚能省什么

2.1 跨进程复用KV的几种候选方案对比

做技术选型最怕凭感觉,我先把能想到的方案列了一张表,逐个排除。

方案时延复制/序列化开销自己管理的成本结论
进程内缓存最快无最低但worker之间互相看不到,解决不了重复缓存问题
文件/SSD持久化慢高,需要序列化与反序列化中适合恢复和冷启动,不适合热路径
外部缓存服务中等非常高,两倍网络拷贝高KV Cache数据量太大,塞外部缓存服务很不现实
共享内存微秒级接近零拷贝高,要自己设计并发和生命周期最终选它

外部缓存服务很早被排除,因为KV Cache不是热点数据那种几百KB的小对象,而是动辄几百MB到数GB的连续张量块。走网络和序列化,哪怕延迟只有几毫秒,也会让首token延迟彻底失控。

2.2 从数据形态看可行性:KV Cache适合做成共享块池

KV Cache虽然总数据量大,但它的结构其实非常适合放进共享内存管理。它本质上是一组按层、按head排列的连续张量,写入时是顺序追加的,读的时候需要随机访问某一段连续区间。这和共享内存段天然契合——我可以把一整块共享内存切成固定大小的slot,每个slot存"某一层、某一段序列范围"的K/V数据。

这就好比超市寄存柜,柜子分成一排排小格子,每个格子只负责存固定的一段东西。新来的顾客并不需要把所有东西都存一遍,他只需要打开缺的那几个格子,把里面东西取走就行。

另外有一个细节让方案成立:KV Cache是纯数据,不包含指向显存内部的指针或句柄,它是可以被序列化成普通字节流的。只要在共享内存里用偏移量管理位置,任何进程映射同一段共享内存之后,都能用同样的偏移找到同一份数据。

从这两点看,共享内存KV Cache这件事不是硬凑,而是数据形态天然匹配。

3. 从零搭一个共享内存KV缓存池的完整流程

3.1 共享内存段布局与头部设计

先说明一下,这里我用的是系统级共享内存API,创建一段共享内存后映射到进程虚拟地址空间。不同进程虽然看到的内存内容一致,但映射的基地址通常不同,所以内存布局里绝对不能出现绝对指针,全部要用相对偏移。

我设计的布局分三块:头部元信息区、空闲slot链、KV数据区。头部结构大致长这样:

#define KV_SHM_MAGIC 0x5EADBEEF #define KV_SHM_VERSION 1 #define MAX_SLOTS 8192 typedef struct { uint64_t magic; // 共享内存魔数,用于校验 uint64_t version; // 版本号,换了布局就换版本 uint64_t total_size; // 共享内存段总大小 uint64_t used_size; // 已用字节数 uint32_t slot_count; // slot总数 process_shared_lock_t lock; // 进程共享锁,后面细说 uint64_t free_offsets[MAX_SLOTS]; // 空闲slot偏移表 } kv_shm_header_t;

每个slot存一段KV数据,结构如下:

typedef struct { uint64_t hash; // 这段token序列的哈希,用于查找 uint32_t layer_id; // 对应模型的哪一层 uint64_t seq_start; // 起始token位置 uint64_t seq_end; // 结束token位置(开区间) uint64_t offset; // 在共享内存数据区的偏移 uint64_t length; // 数据字节长度 uint32_t refcount; // 引用计数 uint32_t state; // 空闲/填充/释放状态 } kv_shm_slot_t;

slot里用hash去匹配前缀,不需要遍历整个共享内存。这里有一个当时容易犯的错误:一开始想直接在共享内存里存两级链表,省点空间。后来发现,链表的指针重定位特别麻烦,多个进程同时读写时还容易死锁。后来直接改成固定数组的静态slot表,查找时用hash做线性探测,简单且可控。

3.2 写入流程:从显存搬回主存再落进共享内存

写入共享内存的时机不是每个token都写,那样开销太大。我是按固定checkpoint间隔来写的,比如每64个token作为一个保存点,把这一段token对应的K/V从显存拷回主存,再切分成按层组织的块存进共享内存。

写入流程大概是这样:

  1. prefill阶段的KV计算完成后,把这64个token区间、某个具体layer的K/V张量从显存拷到主存临时buffer。
  2. 计算这段token序列整体的哈希值(用这段的token id作为输入,再把layer编号叠进去,作为slot查找的key)。
  3. 拿到共享内存的进程共享锁,在线性探测表里找到空slot。
  4. 写入offset和length,更新refcount和state。
  5. 释放锁。

第二步计算hash有个细节:为了让不同请求相同token id的片段尽量命中间一个哈希桶,我只取token id序列的哈希,而不掺入模型实例、prompt其余部分等随机因素。否则同一段公共前缀在不同请求里会算出完全不同hash,共享缓存就失效了。

static uint64_t CalcHash(const int* token_ids, size_t len, uint32_t layer_id) { uint64_t h = 1469598103934665603ull; // FNV-1a的初始值 for (size_t i = 0; i < len; i++) { h ^= (uint64_t)(uint32_t)token_ids[i]; h *= 1099511628211ull; } h ^= (uint64_t)layer_id; h *= 1099511628211ull; return h; }

3.3 读取流程:先命中前缀,再补齐尾部

新请求进来时做的事其实很简单:先用token id序列逐段匹配共享内存里已有的KV块,找到能匹配的最长公共前缀;命中的部分直接从共享内存拷回显存,未命中的尾部再走一遍prefill去计算。

size_t FindMatchLength(const int* input_ids, size_t total_len, int64_t* out_slot_ids, int max_slots) { size_t matched = 0; size_t checkpoint = 0; // 按保存点逐段匹配 for (checkpoint = 0; checkpoint + CHECKPOINT_LEN <= total_len; checkpoint += CHECKPOINT_LEN) { uint64_t h = CalcHash(input_ids + checkpoint, CHECKPOINT_LEN, /*layer=*/0); if (!FindSlotByHash(h)) { break; } matched += CHECKPOINT_LEN; } return matched; }

命中后,把共享内存里对应slot的K/V数据用普通的H2D拷贝搬进显存,填入当前请求的KV buffer。这里必须强调一个关键点:命中的KV块在共享内存里是按层、按段分开存的,搬进显存时要严格保持原来的排列顺序,不能乱序重组。否则注意力计算里的位置信息会全乱。

3.4 为什么"从主存搬显存"比"重算prefill"快

有人可能会问:KV Cache本来在显存里访问是最快的,现在绕一道共享内存(主存),再把数据搬回显存,这不是脱裤子放屁吗?

关键区别在于:prefill是计算,主存拷显存是搬运。一段4k token的prefill,在7B模型上要跑完整量的矩阵乘法和注意力,实测通常要几十到上百毫秒;而把这4k token对应的KV块从主存搬到显存,数据量大概就是4k × 28KB约112MB,在正常的PCIe带宽下也就几毫秒级别的传输。更不用说prefill还会白白占用一整块计算资源,烤机期间GPU没法干别的。

你要理解的是:共享内存方案省掉的不是"读取KV的延迟",而是"从零开始重新计算并缓存相同KV的整段时间和显存空间"。只要命中率够高,这笔买卖就是赚的。

4. 实测数据:省了一半显存,也踩了一堆坑

4.1 我在模拟项目X上的基准对比

这套方案搭完之后,我在模拟项目X上做了对照组测试。测试条件说明一下:7B级模型,上下文4096,并发32路,所有请求共享同一段4k token系统提示词前缀,服务端开8个worker进程。

指标改造前改造后(共享内存KV Cache)变化
峰值显存占用18.6GB9.8GB约 -47%
首token延迟TTFT780ms110ms约 -86%
稳态吞吐量512 token/s1120 token/s约 +119%
前缀命中率0%78%-

数据取的是中位数,多次压测后我发现浮动主要在首token延迟上,因为命中不同长度前缀时帧间差异比较大。整体看,显存下降符合预期——32路请求共享前缀后,重复缓存被抹掉一大截;吞吐提升也合理,因为原本大量做重复prefill的计算资源被释放出来给解码用。

不过必须说明一点:这组数据的参考价值在于前缀命中率够高。如果你的请求之间根本没有任何共享前缀,那这个方案收益就会非常有限,后面我会专门讲适用边界。

4.2 崩溃记录一:绝对指针引发的幽灵段错误

第一个让我印象深刻的坑就是指针问题。最初实现时,我在内存布局里图省事,直接在slot里塞了一个指向另一块地址的指针,想着反正在一个进程里写入后,其他进程也能读。

结果是灾难性的:进程A写出一个指针值,进程B映射同一段共享内存后,把它当成有效地址去解引用,立刻段错误。原因不复杂——不同进程映射共享内存的虚拟地址基址不同,同样的绝对地址在不同进程里指向的完全不是同一个东西。

教训很简单:共享内存里任何"指向共享内存内部其他位置"的引用,必须用相对偏移量,访问时再用自己的基址加回去。比如我上面的offset字段就是相对于共享内存段首地址的偏移。这颗雷埋了一下午才排掉。

4.3 崩溃记录二:进程共享锁和引用计数的双重释放

第二个坑在进程锁。普通pthread_mutex默认是进程私有的,不同进程之间完全无效。必须初始化时显式设置进程共享属性:

pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); pthread_mutex_init(&shared_lock, &attr);

这还不算完。如果进程在持有锁的中间崩溃了,这把锁就永远锁死,其他所有worker全部卡死。我当时排查半天,最后改成robust mutex(在锁属性上设置robust标志),并及时检测EOWNERDEAD状态,主动接管并恢复锁状态,才把这个问题根治。

引用计数的坑更隐蔽。我把refcount设计成释放和加锁是两个原子操作,结果在高并发下出现两个进程同时释放同一个slot的情况,共享内存里的数据被写烂,后续请求全走miss,性能打回原形。后来改成把refcount的增减和slot状态变更放进同一个临界区,才彻底稳定。这类问题有个判断技巧:如果共享内存里的slot状态偶尔对不上,优先怀疑并发控制粒度,而不是怀疑数据本身。

4.4 一个性能意外:NUMA节点带来的延迟波动

功能稳定之后,我一开始以为吞吐能稳稳提升,结果压测时发现数据忽高忽低。后来定位到是在多路服务器上,不同worker进程被调度到不同NUMA节点,而共享内存段只挂在其中一个节点上。远端节点访问共享内存要走片间总线,延迟比本节点高一大截,命中反而比prefill还慢。

解决方式有两种,我最终选了便宜的那种:进程启动时绑定CPU亲和性,尽量让访问共享内存的进程跟共享内存所在节点对齐。如果你的部署环境有更严苛的性能需求,可以考虑per-node各建一段共享缓存,每个节点管自己的一段,不做跨节点共享。

5. 这套方案的适用边界与后续玩法

5.1 什么时候才值得做这么大的改造

坦白讲,共享内存KV Cache不是所有场景都适合上。我给自己的团队定了四条判断标准,缺了基本就不值得折腾:

  • 多进程部署是前提。单进程单worker的场景,共享内存没有任何收益。
  • 公共前缀要够长、命中率要够高。如果每条请求都是随机prompt,做共享缓存就是白忙活。
  • 显存KV确实是瓶颈。如果权重加载后显存余量很大,真不急这一下。
  • 团队能接受额外的并发和生命周期复杂度。这个方案会引入进程锁、引用计数、内存布局等一系列维护成本,不是读完这篇就能零成本上产的。

另外可以做一个快速收益估算:公共前缀token数 × 并发请求数 × 每token KV字节数,得到的就是可以抹掉的重复显存上限;再乘以prefill单位耗时,就是你能省下来的计算时间。拿这个估算值跟你改造要投入的人力做对比,决策会清晰很多。

5.2 别忘了更省事的替代方案

如果你现在还不想动共享内存,还有两个成本更低的中间方案可以先试。

一是把相同前缀的请求在路由层做调度归并,让它们尽量落到同一个worker进程。多进程之间的重复因为进程隔离没法避免,但路由层至少能把重复次数从32路降到8路。二是采用显存内的缓存块复用,本质是把"同一段公共前缀的KV"在同一个进程的多个请求之间共享,收益虽然没有跨进程那么彻底,但改动量小很多。

把KV卸载到SSD做持久化是另一个思路,适合超长上下文和冷启动恢复,但需要接受更高的传输延迟,通常不在热路径上用。

再往后,如果真要做得更深,可以考虑把共享缓存做成多层结构:共享内存做热层,文件映射做温层,请求前缀在某个范围内命中热层、范围更大时命中温层,再配合显存内的block复用。这个方案我再往后慢慢沉淀。

最后分享一个小技巧。共享内存的命名空间记得按"模型名+量化精度+上下文窗口长度"来区分,别统一用一个名字。不然你换了模型之后,旧缓存段还残留在系统共享内存目录里无人清理,白白占着主存。这个细节当时让我多花了一个小时查内存去哪儿了。如果你也在做多worker推理优化,先把公共前缀的命中率统计出来,这是决定整个方案值不值得投入的第一张牌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 11:30:48

LabVIEW调用TensorRT加速YOLOv5:手写C++ DLL部署全流程

做机器视觉集成的人&#xff0c;多半会被一个问题缠住&#xff1a;模型训练得挺好&#xff0c;一到部署就头疼。LabVIEW上位机、YOLOv5模型、TensorRT加速、DLL封装&#xff0c;这四个词凑在一起&#xff0c;基本就是一条从“模型能用”到“现场能跑”的完整链路。标题里的“手…

作者头像 李华
网站建设 2026/10/17 11:29:55

AI论文写作软件怎么选?8款实用工具实测与自考论文全流程指南

1. 论文写不下去的真正卡点&#xff0c;不在“写”而在“组织”我见过太多自考学生&#xff0c;白天上班晚上赶论文&#xff0c;熬到凌晨两点对着空白文档发呆。明明看了几十篇资料&#xff0c;脑子里也有想法&#xff0c;可一落到文档里就变成一团浆糊。说实话&#xff0c;这个…

作者头像 李华
网站建设 2026/10/17 11:28:53

本地AI视频修复实战:去马赛克、超分与批量处理全流程

你有没有遇到过这种情况&#xff1a;手里有一段非常重要的视频&#xff0c;清晰度低到人脸糊成一团&#xff0c;背景里全是格子状马赛克&#xff0c;想修一下&#xff0c;又不敢随便传到在线 AI 工具上。我最近就把一套本地运行的视频增强环境 Lada v0.10.1 完整跑了一遍&#…

作者头像 李华
网站建设 2026/10/17 11:28:39

自建artcraft:用OpenCV为数字插画注入手工质感

1. 从"越画越没劲"到动手自建 artcraft&#xff1a;这件事的起点先说一下背景。过去两年我一直在一家小型工作室做数字插画和动态视觉设计&#xff0c;日常产出以扁平风格插画、产品宣传图和简单的逐帧动画为主。工作本身不算枯燥&#xff0c;但时间一长&#xff0c;…

作者头像 李华
网站建设 2026/10/17 11:27:09

artcraft:面向创意工作者的轻量化可嵌入式工具设计方法论

1. 项目概述&#xff1a;从“artcraft”这个词开始&#xff0c;我们到底在做什么&#xff1f;“artcraft”这个词乍一看像一个合成词&#xff0c;拆开是 art&#xff08;艺术&#xff09;和 craft&#xff08;工艺&#xff09;&#xff0c;合起来直译就是“艺术工艺”。但放在当…

作者头像 李华
网站建设 2026/10/17 11:23:33

iverilog+gtkwave组合:轻量级Verilog仿真与波形调试实操指南

做FPGA和数字IC相关的开发时&#xff0c;仿真几乎是绕不过去的一步。前段时间我接了一个快速模块验证需求&#xff0c;没有直接开大型图形化仿真工程&#xff0c;而是用 iverilog gtkwave 搭了一个最小命令行验证环境&#xff1a;iverilog 负责把 Verilog 源码编译成可执行的仿…

作者头像 李华