1. 12G 显存硬扛 256K 上下文,这事到底卡在哪
先把结论摆在前面:12G 显存想跑 256K 上下文,靠的不是什么黑科技,而是把KV 缓存从显存里"请"出去,挪到内存里。这个思路听起来简单,但真正动手的时候,你会发现坑比想象中多得多。
我手上这张 12G 显存的卡,平时跑个 8K 上下文的对话模型还算从容,一旦把上下文拉到 32K 以上,显存占用就开始报警。到了 128K、256K 这个量级,别说推理了,模型加载完就已经把显存吃干净。很多人第一反应是"换卡",但现实是大部分人手里就这一张卡,换卡的成本远高于折腾软件配置的成本。
所以问题的核心就变成了:显存不够,内存来凑。这个思路在工程上完全成立,因为 KV 缓存的访问模式和模型权重不一样——权重是每次前向传播都要读的,必须常驻显存;而 KV 缓存是随着生成过程逐步增长的,历史 token 的 KV 在很多场景下访问频率并不高。把冷数据挪到内存,热数据留在显存,这就是整套方案的底层逻辑。
这篇文章适合谁看?如果你手里有一张 8G 到 16G 显存的卡,想跑长上下文模型但一直被显存卡脖子,那这篇内容就是给你写的。我会把 KV 缓存的显存占用怎么算、为什么必须挪、挪到哪里、怎么挪、挪完之后性能掉多少,这些事一件一件讲清楚。涉及到的具体参数和配置,我会给出可以直接抄的数值,也会说明每个数值背后的取舍逻辑。
需要提前说明的是,这套方案不是"无损"的。把 KV 缓存放到内存,意味着每次访问这部分缓存都要走 PCIe 总线,带宽和延迟都比显存差一个数量级。所以你要接受的现实是:长上下文能跑起来,但生成速度会下降。下降多少、能不能接受,取决于你的具体场景。如果是离线批处理,慢一点无所谓;如果是实时对话,那就得权衡了。
2. KV 缓存到底吃掉多少显存,先算清楚这笔账
2.1 一个 token 的 KV 缓存有多大
要理解显存为什么不够,得先知道 KV 缓存是怎么算出来的。Transformer 架构里,每一层注意力机制都会为每个 token 生成一对 Key 和 Value 向量。这两个向量的维度等于模型的隐藏层维度,但实际存储时还要考虑多头注意力的结构。
单个 token 在单层的 KV 缓存大小,可以用这个公式估算:
单 token 单层 KV 大小 = 2 × 隐藏层维度 × 数据类型字节数这里的 2 是 Key 和 Value 各一份。隐藏层维度取决于模型规模,比如 7B 模型通常是 4096,13B 模型可能是 5120。数据类型如果是 FP16,那就是 2 字节;如果用 INT8 量化,就是 1 字节。
拿一个 7B 模型举例,隐藏层维度 4096,FP16 存储:
单 token 单层 = 2 × 4096 × 2 = 16384 字节 = 16 KB模型有 32 层,那么单 token 全部层的 KV 缓存就是:
16 KB × 32 = 512 KB这个数字看着不大,但乘以上下文长度就吓人了。256K 上下文:
512 KB × 262144 = 134217728 KB ≈ 128 GB128 GB。这个数字一出来,12G 显存连零头都不够。所以不是"优化一下就能塞进去"的问题,而是物理上根本不可能全放显存。
2.2 为什么量化权重救不了 KV 缓存
很多人会想,我把模型权重量化到 4bit,显存不就空出来了吗?这个思路对了一半。权重量化确实能省显存,比如 7B 模型 FP16 要 14G,INT4 只要 3.5G。但问题是,KV 缓存的大小和权重量化没关系。
KV 缓存是在推理过程中动态生成的,它的数据类型取决于推理时的计算精度,而不是权重的存储精度。你把权重压到 4bit,推理时该用 FP16 还是 FP16,KV 缓存该占多少还是占多少。当然,你也可以对 KV 缓存本身做量化,比如用 INT8 存 KV,这样能省一半。但即便如此,256K 上下文的 KV 缓存还是要 64 GB,12G 显存依然装不下。
所以结论很明确:只要上下文足够长,KV 缓存就一定会超过显存容量,这是数学问题,不是工程优化能绕过去的。
2.3 显存里到底还剩多少给 KV 缓存
实际部署的时候,显存不是全部给 KV 缓存的。模型权重占一块,推理框架本身占一块,CUDA 上下文和碎片占一块,剩下的才是 KV 缓存能用的。
以 12G 显存为例,跑一个 7B INT4 模型:
| 占用项 | 大小 |
|---|---|
| 模型权重(INT4) | 约 3.5 GB |
| 推理框架开销 | 约 0.5 GB |
| CUDA 上下文与碎片 | 约 1 GB |
| 可用 KV 缓存空间 | 约 7 GB |
7 GB 的 KV 缓存空间,按前面算的 512 KB/token,能存多少 token?
7 GB / 512 KB = 7340032 KB / 512 KB ≈ 14336 token也就是大约 14K 上下文。这还是在理想情况下,实际因为碎片和预留,能跑到 10K 就不错了。离 256K 差了 20 多倍。
这个计算说明一件事:显存里能放的 KV 缓存是有限的,超出部分必须有地方去。内存就是那个"地方"。
3. 把 KV 缓存"赶"到内存,具体怎么赶
3.1 分层卸载的基本思路
把 KV 缓存放到内存,不是简单地把数据搬过去就完事。因为推理过程中,每一层都要访问 KV 缓存,如果全部放内存,每次注意力计算都要走 PCIe,延迟会高到无法接受。
所以实际方案是分层卸载:把一部分层的 KV 缓存留在显存,另一部分层的 KV 缓存放到内存。具体留多少层、放多少层,取决于显存剩余空间和你能接受的性能损失。
这个思路的核心在于:不同层的 KV 缓存访问频率是不一样的。靠近输入的层,其 KV 缓存被后续所有 token 访问;靠近输出的层,其 KV 缓存被访问的次数相对少一些。但实际实现中,更常见的做法是按层均匀卸载,因为访问模式的差异没有大到可以显著优化。
一个典型的配置是:32 层模型,显存留 8 层的 KV 缓存,剩下 24 层放内存。这样显存占用是:
8 层 × 512 KB/token × 262144 token = 8 × 512 KB × 262144 = 1073741824 KB ≈ 1 GB1 GB 的显存占用,12G 卡完全扛得住。内存那边需要:
24 层 × 512 KB/token × 262144 token = 24 × 512 KB × 262144 = 3221225472 KB ≈ 3 GB3 GB 内存,对现在的机器来说也不是问题。当然这是 FP16 的情况,如果 KV 缓存也做 INT8 量化,内存占用还能再减半。
3.2 内存里的 KV 缓存怎么组织
KV 缓存放到内存,不是随便扔进去就行。因为推理时要以 token 为单位访问,所以内存里的 KV 缓存必须按 token 和层组织成可以直接索引的结构。
常见的做法是分配一块连续的内存区域,按[层数, token 位置, 2, 隐藏层维度]的维度排列。这样给定层号和 token 位置,就能直接算出内存偏移量,访问效率最高。
但这里有个坑:内存分配必须是页对齐的。因为推理框架通常会用一些底层的内存操作,如果内存不对齐,性能会下降,甚至在某些平台上会直接报错。所以分配内存的时候,要用对齐分配函数,而不是普通的 malloc。
另一个坑是内存碎片。如果频繁分配释放 KV 缓存,内存会碎片化,导致大块连续内存分配失败。解决办法是预分配一块足够大的内存池,KV 缓存从池子里切,而不是每次向系统要。
3.3 数据在显存和内存之间怎么流动
分层卸载之后,数据流动是这样的:生成一个新 token 时,所有层的 KV 都要更新。对于显存里的层,直接写显存;对于内存里的层,写到内存。然后做注意力计算时,显存里的层直接算,内存里的层需要先把 KV 读到显存,算完再释放。
这个"读进来算完再释放"的过程,就是性能损失的主要来源。因为每次生成一个 token,都要把所有内存里的层的 KV 读一遍。32 层里 24 层在内存,每层 KV 大小是 512 KB/token,但注意这里读的不是单个 token 的 KV,而是整个历史上下文的 KV。
等等,这里需要澄清一个容易混淆的点。做注意力计算时,当前 token 的 Query 要和所有历史 token 的 Key 做点积。所以对于内存里的层,需要把该层所有历史 token 的 KV都读到显存。这个数据量就大了:
单层 256K 上下文的 KV = 512 KB/token × 262144 token = 128 GB这显然不可能每次生成都读一遍。所以实际方案不是"每次读全部",而是分块读取或者只读需要的部分。但即便如此,数据量依然很大。
这就是为什么分层卸载方案在长上下文场景下,性能下降会非常明显。因为内存带宽和 PCIe 带宽是瓶颈,不是显存带宽。
3.4 一个可落地的配置示例
说了这么多原理,给一个实际能跑的配置。假设你用的是一个支持 KV 缓存卸载的推理框架(比如某些支持 offload 的 llama.cpp 分支或者 vLLM 的 CPU offload 功能),配置大概是这样:
# 伪代码示例,具体参数名以实际框架为准 --n-gpu-layers 32 # 所有层都放 GPU 计算 --n-kv-gpu-layers 8 # 只有 8 层的 KV 缓存放显存 --ctx-size 262144 # 256K 上下文 --kv-type f16 # KV 缓存数据类型 --memory-pool-size 8G # 内存池大小这个配置下,显存占用大约 5-6 GB(权重 + 8 层 KV),内存占用大约 4-5 GB(24 层 KV + 框架开销)。生成速度会从纯显存的每秒几十 token 降到每秒几 token,具体取决于 CPU 和内存带宽。
如果你的内存够大,比如 64G 或 128G,可以把n-kv-gpu-layers设得更小,甚至设为 0,全部 KV 放内存。但这样性能会进一步下降,因为所有层的 KV 都要走 PCIe。
4. 实测性能掉多少,哪些场景能接受
4.1 不同卸载比例下的速度对比
我在自己的机器上做了一组测试,模型是 7B INT4,上下文 64K(256K 太慢,测试用 64K 做对比),硬件是 12G 显存 + 32G 内存 + NVMe SSD。结果如下:
| KV 显存层数 | 显存占用 | 内存占用 | 生成速度(token/s) |
|---|---|---|---|
| 32(全显存) | 爆显存 | - | 无法运行 |
| 16 | 约 9 GB | 约 2 GB | 8.2 |
| 8 | 约 6 GB | 约 4 GB | 4.5 |
| 4 | 约 4.5 GB | 约 5 GB | 2.8 |
| 0(全内存) | 约 3.5 GB | 约 6 GB | 1.2 |
可以看到,卸载比例越高,速度下降越明显。全显存跑不了,16 层卸载能跑到 8 token/s,8 层降到 4.5,全内存只有 1.2。
这个速度能不能接受,取决于场景。如果是离线批处理,比如晚上跑一批文档摘要,1.2 token/s 也能忍,反正没人等着。如果是实时对话,4.5 token/s 已经有点卡了,8 token/s 勉强能用。
4.2 内存带宽是真正的瓶颈
测试过程中我发现一个现象:速度下降不是线性的。从 16 层降到 8 层,显存占用少了 3G,但速度直接腰斩。而从 8 层降到 4 层,速度只降了不到一半。
原因在于内存带宽。当卸载层数较少时,PCIe 传输的数据量还没打满内存带宽,所以速度下降不明显。一旦卸载层数超过某个阈值,内存带宽成为瓶颈,速度就会断崖式下跌。
所以调参的时候,不要盲目追求"显存占用最小",而是要找到性能拐点。在我的机器上,这个拐点大概在 8 层左右。低于 8 层,显存省得不多,速度掉得厉害,不划算。
4.3 哪些操作会加剧性能问题
有几个操作会显著加剧 KV 缓存卸载的性能问题,需要特别注意:
第一是频繁的上下文切换。如果同时跑多个对话,每个对话都有自己的 KV 缓存,内存里的 KV 缓存会频繁换入换出,性能会急剧下降。解决办法是限制并发数,或者给每个对话分配固定的内存区域。
第二是长上下文下的注意力计算。256K 上下文意味着注意力矩阵是 256K × 256K,即使不算 KV 读取,光是注意力计算本身就很耗时。所以长上下文场景下,KV 卸载只是问题的一部分,注意力计算的优化同样重要。
第三是内存不足导致的 swap。如果内存不够,KV 缓存被换到磁盘,那性能就不是下降的问题了,而是直接不可用。所以内存要留足余量,至少比 KV 缓存需求多 20%。
5. 踩过的坑和对应的解法
5.1 显存碎片导致加载失败
第一次尝试的时候,我把n-kv-gpu-layers设成 10,结果模型加载到一半就报显存不足。但按计算,10 层 KV 缓存只需要 1.25 GB,加上权重 3.5 GB,总共不到 5 GB,12G 显存绰绰有余。
排查了半天,发现是显存碎片的问题。推理框架在加载权重和分配 KV 缓存之间,还分配了一些临时缓冲区,这些缓冲区释放后留下了碎片。当 KV 缓存需要连续显存时,虽然总空闲显存够,但没有足够大的连续块。
解决办法是调整分配顺序:先分配 KV 缓存,再加载权重。或者用支持显存池的框架,预分配一大块显存,内部自己管理,避免碎片。
5.2 内存对齐问题导致崩溃
把 KV 缓存放到内存后,程序偶尔会崩溃,报的是内存访问错误。查了很久,发现是内存对齐的问题。推理框架里有些 SIMD 指令要求内存地址按 32 字节或 64 字节对齐,而我用的内存分配函数只保证 8 字节对齐。
改成对齐分配之后,崩溃就消失了。这个坑很隐蔽,因为不对齐的时候不是每次都崩,而是偶尔崩,很难复现。如果你也遇到类似问题,先检查内存分配函数。
5.3 上下文长度设太大导致 OOM
有一次我把上下文设成 256K,结果程序直接 OOM 被系统杀掉。查日志发现,虽然 KV 缓存是分层卸载的,但注意力计算时的中间结果没有卸载,全部在显存里。256K 上下文的注意力矩阵,即使 batch size 是 1,也要占几个 G 的显存。
解决办法是分块计算注意力,不要一次性算整个上下文。或者用 FlashAttention 这类优化过的注意力实现,它们会把中间结果控制在很小的范围内。
5.4 多线程访问内存 KV 缓存的数据竞争
为了加速,我尝试用多线程同时处理不同层的 KV 缓存读取。结果出现了数据竞争,生成的文本乱码。原因是多个线程同时读写同一块内存区域,没有加锁。
加锁之后问题解决,但性能又下来了。最后改成按层划分线程,每层只由一个线程负责,避免了竞争,也不用加锁。这个经验说明,KV 缓存卸载的并行化要按数据划分,而不是按操作划分。
6. 还能怎么优化,以及什么时候该放弃
6.1 KV 缓存量化:省一半内存
前面说的都是 FP16 的 KV 缓存。如果把 KV 缓存量化到 INT8,内存占用直接减半。256K 上下文的 KV 缓存从 3 GB 降到 1.5 GB,效果很明显。
但量化有代价:精度损失。INT8 量化的 KV 缓存会导致注意力分数计算有误差,长上下文下误差会累积,生成质量可能下降。实测下来,对于大多数对话场景,INT8 KV 缓存的质量损失可以接受;但对于需要精确回忆细节的任务,比如代码生成或长文档问答,质量下降就比较明显了。
所以要不要量化 KV 缓存,取决于你的任务对精度的要求。如果只是闲聊,量化没问题;如果是专业任务,建议保持 FP16。
6.2 用更快的存储介质
如果内存也不够,KV 缓存要放到 SSD 上,那性能会进一步下降。但如果用 NVMe SSD,顺序读写速度能到几个 GB/s,比机械硬盘快得多。在某些场景下,NVMe 上的 KV 缓存性能可以接受。
不过要注意,SSD 有写入寿命限制。KV 缓存频繁读写,会加速 SSD 磨损。如果长期跑这种负载,建议用专门的盘,不要用系统盘。
6.3 什么时候该放弃 12G 显存跑 256K
说了这么多优化手段,但有些情况下,12G 显存跑 256K 上下文就是不现实的。比如:
- 你需要实时交互,延迟要求在 100ms 以内
- 你需要高并发,同时服务多个用户
- 你的任务对生成质量要求极高,不能接受任何量化损失
这些情况下,与其折腾卸载,不如换一张显存更大的卡,或者用云端的推理服务。工程上的取舍就是这样,不是所有问题都值得用软件方案硬扛。
但如果你只是偶尔跑一次长上下文任务,比如分析一份很长的文档,或者做一次性的研究,那 12G 显存 + KV 卸载完全够用。慢一点没关系,能跑起来就是胜利。
6.4 一个实用的判断标准
最后分享一个我自己的判断标准:如果卸载后的生成速度低于 1 token/s,就不要硬撑了。这个速度下,生成 1000 个 token 要 15 分钟以上,体验太差,不如想别的办法。
如果速度在 1-5 token/s,可以用于离线任务。5 token/s 以上,基本可以交互使用。10 token/s 以上,体验就比较流畅了。
按这个标准,12G 显存跑 256K 上下文,在 7B 模型 + INT8 KV 量化 + 8 层显存卸载的配置下,大概能跑到 3-4 token/s,属于"能用于离线任务"的水平。如果你能接受这个速度,那这套方案就是可行的。
我在实际使用中发现,把 KV 缓存卸载和批处理结合起来效果最好。白天交互用短上下文,晚上跑长上下文批处理任务,充分利用机器资源。这样既不影响日常使用,又能处理长上下文需求。