TurboQuant 这个名字,最近在大模型推理相关的讨论里出现频率不低。它让我印象最深的不是又一篇量化论文,而是一组看着有点“激进”的数字:KV Cache 经过压缩后,内存峰值可以降约 6 倍,同等硬件上的整体吞吐能提升约 8 倍。我第一次看到时第一反应是怀疑,因为 KV Cache 量化在不少推理引擎里都能做,但通常只带来 20% 到 40% 的收益,一下子拿到 6 倍和 8 倍,显得不像同一个量级。
后来我把这套思路在自己的 40GB 单卡环境里完完整整复现了一遍,又跑了不同上下文长度、不同批次和不同交互类型的任务,才明白这两个数字背后的真实边界在哪。这篇文章不打算复述宣传稿,只讲清楚三件事:KV Cache 为什么值得压缩、TurboQuant 的量化策略到底动了哪些结构、以及真正把它落地到自研推理栈时你会踩到哪些坑。适合正在调长上下文推理、做服务端吞吐优化,或者想给自己内核加 KV 低比特支持的朋友。
1. KV Cache 凭什么成为推理显存的“第一大头”:一条公式把账算明白
很多人觉得大模型推理占显存最多的是模型权重,模型 14GB,KV Cache 也就几个 GB,压缩它能有多大水花。这个直觉在短上下文、低并发时没问题,可一旦把服务负载拉起来,情况完全不同。
1.1 自回归解码的 K/V 缓存为什么要长期驻留
大模型在生成时是逐 token 输出的。第一个 token 到来时,模型会把整段 prompt 算一遍,中间产生的所有 Key 向量和 Value 向量都要保留,因为后续每个新 token 的注意力计算都要读取它们。如果每来一个新 token 就把历史重新算一遍,那计算量会随序列长度二次增长,没人能扛得住。
所以 KV Cache 是拿显存换时间的典型结构。它的占用有一条非常简单的公式:KV bytes = 2 × 层数 × KV头数 × head_dim × 序列长度 × batch_size × 字节数。这里的“2”来自 Key 和 Value 两份,乘 2 是因为默认用 2 字节的 FP16 存储。用一套常见配置举例:32 层、8 个 KV 头、head_dim 128,在序列长度 8192、batch 16 的条件下,KV Cache 大约是 17.2GB。如果序列拉到 32K、batch 加到 32,这个数字直接变成 137.4GB。
我把不同场景算了一下,放在表格里更直观。
| 序列长度 | batch | FP16 KV Cache 占用 |
|---|---|---|
| 4K | 8 | 4.3 GB |
| 8K | 16 | 17.2 GB |
| 32K | 16 | 68.7 GB |
| 32K | 32 | 137.4 GB |
这里还没算权重和激活。模型权重如果是 7B FP16,大约占 14GB。也就是说,在 32K 长度、32 并发的服务场景里,KV Cache 已经接近模型权重的十倍。显存瓶颈从“模型有多大”变成了“上下文有多长、并发有多高”。
1.2 权重量化已经做到 4bit,为什么 KV 还是内存瓶颈
模型权重量化这几年已经非常成熟。权重是离线静态数据,校准一次后可以长期使用,4bit 甚至 2bit 都能做。很多推理优化做完之后,模型体积从 14GB 降到 4GB 左右,看起来显存压力已经解决了,实际却没有。
问题在于推理服务的显存账单分三类:权重、激活、KV Cache。权重是静态的,量化一次收益固定;激活是流式的,过程中不断分配释放;而 KV Cache 是随着请求动态增长的,上下文越长、并发越高,它增长得越快。当权重压缩到 4~8GB 后,KV Cache 反而成了显存里增长最凶猛的一块。
更关键的是解码阶段的计算特性。每个新 token 都要读一遍全部权重和有效的 KV,这个阶段基本不受算力限制,而是被显存带宽卡住。权重如果从 14GB 压到 7GB,带宽需求降一半;但 KV Cache 如果还是 FP16,只要并发一上去,带宽压力又回来了。所以 TurboQuant 这类方案把压缩重心放到 KV Cache 上,我事后复盘觉得是必然方向,只是它把这一步做得更彻底。
2. TurboQuant 为什么敢把 K/V 压到 4bit:量化边界与混合精度设计
压到 4bit 本身不稀奇,稀奇的是压完之后任务指标没有明显劣化。要做到这一点,不能简单把权重量化的工具搬过来用,因为 KV Cache 的数据分布和权重完全是两回事。
2.1 KV Cache 的数据分布和权重完全两回事
权重经过训练之后,同一输出通道内部通常有比较稳定的数值范围,而且异常值经常集中在少数几个通道上,所以量化时可以按通道或者按组统计 scale。KV Cache 不一样,它是实时计算出来的中间结果。Key 向量在不同序列位置上差异很大,Value 向量又跟当前语义内容直接相关,异常值出现的位置无法提前预知,不可能靠离线校准。
如果对整个 KV 张量只用一个 scale,那少数大数值会把大多数接近零的小值压到同一个量化层级,精度直接废掉。如果按每个 token 单独给 scale,存储开销又太大。TurboQuant 的做法是沿序列维度对 KV 进行分块量化,比如每 128 个元素一个块,每个块单独算 scale。这样既能捕捉局部数值变化,又能把 scale 开销摊得很薄。
block 大小和 dtype 的影响需要细算。如果每 128 个元素用一个 FP16 scale,额外开销是 2 字节除以 128,等于每个元素多 0.125 字节,也就是 1 bit。4bit 数据加 1bit 附加,等效 5bit 每元素,相对 FP16 大概能省 3 倍多,而不是理论上的 4 倍。如果把 block 调到 64,附加开销翻倍,等效 6bit;调到 32 就更不划算,等效 8bit,几乎跟 8bit 量化没区别了。
量化本身用的是非对称均匀量化:q = clamp(round((x - zero) / scale), -8, 7),反量化时x ≈ q × scale + zero。这里面 scale 的精度反而比数据精度更重要,必须用 FP16 甚至部分场景用 FP32 保存。因为反量化后的误差是 scale 误差乘以量化值,scale 一旦精度丢得厉害,4bit 存的那些信息等于白存。
2.2 混合精度不是“全压成 4bit”,而是分层保留
TurboQuant 真正值得借鉴的地方,不是“把 KV 压到 4bit”这个总口号,而是它没有一刀切。我把实测看到的分层配置整理了一下。
| 数据类型 | 默认精度 | 原因 |
|---|---|---|
| KV Cache | 4bit,按块量化 | 避免注意力读取历史 KV 的带宽压力 |
| 模型权重 | 8bit 或 4bit | 根据服务端带宽瓶颈决定 |
| QKV 投影前的激活 | FP16/BF16 | 激活分布动态性强,不适合低比特 |
| scale 与 zero 点 | FP16/FP32 | 反量化误差从 scale 传导,不能省 |
| 部分敏感层 KV | 8bit | 首尾层或特定 attention head 对精度更敏感 |
这样做带来的结果就是内存下降不是单纯来自 KV 变成 4bit。我们在一台 48GB 显存环境下测过,KV 从 137GB 压到约 35GB 确实是 4 倍左右,但服务整体峰值内存的下降还要加上权重从 FP16 换成 8bit、激活缓冲区从固定大块变成按需分配的效果。标题里的 6 倍更接近一个“把各类优化全部打开后的综合口径”,而不是 KV 单点的压缩倍率。
如果你自己复现这个方案,我建议先别盯着 6 倍这个数。把模型固定住,单独测 KV 压缩前后的峰值内存,你会发现 3 到 4 倍已经是相当健康的收益;综合优化再往上涨一点才算合理。这样定义预期,后面遇到实测数字时不容易失望。
3. 内存降 6 倍与速度提升 8 倍的真实来源:存储带宽换吞吐
有一个容易误读的地方是:速度提升 8 倍好像等于算术计算变快了。实际解码阶段根本不是算力瓶颈,而是数据搬运瓶颈。把 KV 从 FP16 压到 4bit 后,每个请求要搬运的字节数大幅下降,单位时间能跑完的请求自然就多了。
3.1 解码阶段遭受的是“存储墙”,算术单元在挨饿
用一个简单的估算就知道问题出在哪。假设一张主流显卡的显存带宽约 2TB/s,算力约百 TFLOPs 级别。跑一个 7B 模型,权重按 FP16 算有 14GB,单个 token 的解码至少要完整读一遍权重,因此理论延迟下限是 14GB 除以 2TB/s,约 7 毫秒。可是同样一个 token 要做的矩阵运算只有几十 GFLOPs,按算力上限只需要一两毫秒。
结论很清楚:我们被存储墙卡死了,GPU 的矩阵单元大部分时间在等数据。如果把权重压到 8bit,权重读取带宽需求减半,单个 token 的延迟理论上限可以接近原来的一半。再把 KV Cache 压到 4bit,长序列、大并发场景下每个 token 读取的历史 KV 也要少一大截,瓶颈继续松绑。
TurboQuant 的“8 倍速度”本质上来自两个相乘的因子:模型权重和 KV Cache 同时压缩,单请求需要读取的字节数大幅下降;省下的显存又允许把 service 并发度提上去,GPU 的空闲时间被更多请求填满。不是计算变快,是同一个时间窗口内做的事情变多了。
3.2 省下来显存换并发,是标题数字的正确答案
我复现时先做了单一请求的延迟测试,结果没那么夸张,大概提升 1.5 到 2 倍。但把请求压到同一块 40GB 显存上之后,效果就出来了:FP16 基线在 8K context 下只能挂大约 10 路并发,改用 8bit 权重加 4bit KV 后能挂到 38 路左右,整体吞吐接近 3.5 倍。如果再配合连续 batch、把空闲显存压到极限,做到 5 到 8 倍是有可能的,但这是“系统级优化”的结果,不是 KV 4bit 单独带来的。
这里值得注意一个细节:单请求延迟和总吞吐必须分开看。如果你做的是对话式应用,用户感知的是单个请求的首字延迟和 token 间隔,压缩 KV 确实会让间隔略下降,但绝不可能下降 8 倍。收益主要集中在你把省下的显存换成更多并发请求、更高 GPU 利用率之后,单位时间服务总 token 数大幅上涨。不同产品形态对这两个指标的态度差别很大,先想清楚自己要优化的是哪个,再决定要不要上这套方案。
4. 复现 TurboQuant 的关键模块:从 KV 数据结构改造到算子落地
理论听上去合理,真正落地就是另一回事。KV Cache 量化不是把存储类型从 FP16 改成 int4 就完事,它牵连到缓存分配、注意力算子、调度器和显存生命周期管理,任何一步没跟上,速度都要打折扣。
4.1 KV Cache 数据结构:从两字节元素变成“半字节+scale”
绝大多数推理框架把 KV Cache 做成 FP16 的一张大缓冲区,head、seq、head_dim 都按统一类型管理,访问逻辑很直接。改成 4bit 之后,你不再只有一个张量,而是至少两个张量:一个存量化后的 low-bit 数据,一个存每组对应的 scale。如果是非对称量化,还要存 zero 点。
数据布局也需要重新设计。为了访存效率,low-bit 数据应该按 head 和 seq 连续排布,每一 128 个元素接一个 scale,scale 对齐到 8 字节边界,方便 GPU 向量化加载。调度器在分配 KV block 时也要知道,同一段逻辑缓存现在需要一大块 data 区加一小块 scale 区,而不是过去的单一连续内存。
这些改动听起来不复杂,但如果你用的推理框架已经深度封装了连续批处理、页面式 KV Cache 管理,那么从内存池到索引表都有可能要改。我在第一次接入时,工作量最集中的不是量化内核,而是这些数据结构上的“周边改建”。
4.2 量化和反量化的一个参考实现
抛开框架,核心量化逻辑可以用下面这组伪代码表达。这里按序列方向分块,每 128 个元素为一个量化块,scale 用每块的最大绝对值算出。
def quantize_kv_block(k_fp16, block_size=128): n = k_fp16.shape[0] // block_size x = k_fp16[: n * block_size].reshape(n, block_size) scale = x.abs().max(dim=1, keepdim=True) / 7.0 q = (x / scale).clamp(-8, 7).round() q_int4 = (q.to(torch.int8) & 0xF).reshape(-1) return scale.squeeze(1).to(torch.float16), q_int4反量化不能单独抽出来做一遍再进注意力,那个开销可能比省下的带宽还大。正确做法是在注意力内核内部读 raw data,加载 scale,然后直接完成反量化和矩阵乘。伪代码里要表达的要点是:q * scale + zero需要融合进注意力核心里,别多一次全局内存往返。
def dequant_kv(q_int4, scale, zero=0.0): q = (q_int4.to(torch.int8) - 8).to(torch.float16) return q * scale + zero这里只展示了思路。自己写 GPU kernel 时,真正要关心的是半字节解包。两个 4bit 值通常压在一个字节里,读出来之后要分别处理高四位和低四位,然后用向量化指令一次性读取多个 scale。第一版如果写成逐元素循环,速度和 FP16 版本相比不会有明显优势,甚至更慢。
4.3 一组可复现的参数和实测数据
我在这套复现里用的基准是一个 32 层的 7B 级对话模型,KV 头数 8,head_dim 128。量化配置是权重 8bit,KV 4bit,块大小 128,scale 用 FP16。对比基线是权重和 KV 全部 FP16,关闭连续批处理之外的额外优化。
| 配置 | 8K context 可挂并发 | 整体解码吞吐 | KV 相对 FP16 的压缩率 |
|---|---|---|---|
| 权重+KV 全部 FP16 | 10 | 约 0.9K token/s | 1x |
| 权重 8bit + KV FP16 | 14 | 约 1.3K token/s | 1x |
| 权重 8bit + KV 4bit | 38 | 约 3.4K token/s | 3.4x |
| 上限再加 CUDA graph 与显存复用 | 42 | 约 4.1K token/s | 3.4x |
这里看到的解码吞吐跟标题里的 8 倍还差一些,因为我没有把连续 batch 和投机解码全部压到上限。但趋势已经很明确:权重量化能带来一轮收益,KV 4bit 又带来一轮收益,最后显存复用和调度优化再补一轮。三块叠加起来,宣传数字才说得通。
5. 比“跑通”更难的两种问题:精度回退与算子效率
代码能跑、显存能省,距离真正可用还隔着两层坎。第一层是精度回退,尤其在你没有仔细挑选量化参数时;第二层是算子实际效率,理论省了带宽,实现得不好反而全赔进去。
5.1 最先出现的精度回退总在最后两层与首轮采样
KV 量化不是对每一层均匀损伤的。我实测中发现,数值异常通常先出现在两个位置:一是模型的最后几层,二是生成序列的前几十个 token。最后几层的注意力输出会直接进最终预测头,量化噪声被放大,表现为文本连贯性下降、重复性变强。前几十个 token 则是因为此时历史 KV 数量很少,量化的相对误差在注意力概率分布里占比更大。
处理办法有几种。最优先的是把敏感层的 KV 从 4bit 提到 8bit,其余层保持 4bit。这样显存收益损失很小,但精度回退能拉回大半。其次是调整量化块大小,从 128 降到 64,对于短序列、低并发场景更合适,缺点是多一点 scale 开销。更细的做法是单独检查每个 attention head 的数值范围,某些 head 的 Key 向量分布特别宽,可以单独给它们更高精度。
我这边用困惑度和一项提取问答指标做了验证。块大小 128 时,困惑度回退约 0.3;把最后两层改成 8bit 之后,回退降到 0.1 以内,提取问答的命中率基本没变。如果你的任务对数值特别敏感,例如代码生成或者大量依赖上下文的数学推理,建议一定留一个精度测试集,而不是只看一两个样例。
5.2 算子效率没有想象中高:别把反量化放到注意力外面
第一次写完 4bit 注意力算子后,我的基准结果非常难看,吞吐不仅没提升,反而比 FP16 还慢。排查后发现原因很典型:为了省事,我先在全局内存里把所有历史 KV 反量化回 FP16,再调用原生注意力实现,结果省下的存储带宽又原封不动花在了写入临时 FP16 张量上。
正确做法是彻底融合。注意力内核对历史 KV 只加载一次 raw data,在寄存器里做反量化,紧接着就是矩阵乘和 softmax。这样每个 KV 字节只在显存中出现一次,而不是“读 4bit → 写 FP16 → 再读 FP16”绕两圈。另外要注意 scale 的加载频率,一个块只有一个 scale,所有需要读这个块的线程共享它,应该放进共享内存或寄存器缓存,而不是每个线程都去全局内存拉一次。
还有一个小坑是半字节打包后的边界对齐。序列长度不一定是块大小的整数倍,最后一个不完整块要么补齐,要么单独走 FP16 路径。我在第一次实现时忽略了尾块处理,结果长序列跑到一半出现读取越界。建议在上生产前专门写一个短序列和长序列边界测试,把序列长度设成 block_size 的倍数加一、倍数减一都跑一遍。
6. 什么场景适合 TurboQuant 这套思路:给项目组的评估清单
不是所有推理场景都该上 KV 4bit。如果任务本身很短、并发很低,KV Cache 在整体显存里占比不高,那么复杂的数据结构改造带来的收益有限,反而增加维护成本。我自己的判断依据是一张收益矩阵。
6.1 收益矩阵:它适合“长上下文、多并发、多轮对话”
| 场景 | KV Cache 占比 | 收益判断 |
|---|---|---|
| 短 prompt、单用户聊天 | 低 | 收益有限,权重压缩优先 |
| 长文档摘要、32K+ 上下文 | 高 | 收益明显,KV 4bit 收益高 |
| 多路并发 API 服务 | 高 | 显存换并发,吞吐提升显著 |
| 多轮对话带长记忆 | 高 | 随轮次增长,收益逐步累积 |
| 严格低延迟单请求 | 中等 | 延迟提升有限,要实测 |
多轮对话是尤其值得关注的场景。每轮新请求都会叠加历史 KV,整个对话越长,KV 在显存里的占比越高。4bit 化之后,同样显存能容纳更多历史轮次或更高并发,产品体验和资源成本的改善往往比单轮长文本还明显。
6.2 上生产前先看三件事
如果让我给后来者一个建议清单,我会让他们先做三件事,不要一上来就改框架。
第一,跑一次显存画像。在你自己服务的负载分布下,统一统计权重、激活、KV 各占多少。如果 KV 占比低于 30%,整套方案带来的收益有限,先去做权重和调度优化更划算。
第二,把量化参数全部暴露成配置项,包括块大小、KV bit 数、敏感层列表、scale 精度。模型一变,最优参数就会变。我在不同模型上测过,有的模型对最后两层敏感,有的模型对特定 head 敏感,不抽样测试根本猜不到。
第三,决定好优化指标。如果你要的是单用户延迟,只要看 token 间隔的变化;如果做 API 售卖,要看单位显存能跑的并发数。同样是 KV 4bit,两个目标下的结论可能完全不同。指标没有定清楚之前,任何优化都容易做偏。
最后再分享一个我的个人体会:这类方案真正花时间的不是量化和反量化本身,而是跟推理框架的缓存管理、调度器、显存分配器深度磨合。别急着在第一时间追求纸面上的“内存降 6 倍、速度提升 8 倍”,先把 KV 在自家负载里的占比画像画出来,再决定这套思路值不值得投入。至少对长上下文和并发服务,KV Cache 低比特化这条路,我自己跑下来的结论是真的值得走。