AI AI infra · 推理侧
第 1 章
AI infra · 推理侧

第 9 章 长上下文:两笔账同时变坏

上下文从 8K 涨到 32K,很多人以为只是"输入变长了"。实际上有两笔账同时变化,而且变坏的速度不一样:缓存是线性的,四倍长度就是四倍显存;注意力计算是平方的,四倍长度是十六倍算力。前八章的所有结论,到了长上下文都要重新算一遍。

这一章先把两笔账算清楚,再看工程上有哪些手段,最后说清楚哪些问题能解决、哪些只能绕开。

一笔线性,一笔平方

缓存这笔账在第 3 章算过:每 token 的字节数等于二乘以层数、KV 头数、头维度和字节数。8B 的 GQA 模型是每 token 128 KiB,8K 请求 1 GiB,32K 请求 4 GiB,128K 请求 16 GiB。严格线性,没有拐点。

计算这笔账不一样。注意力的核心是每个位置和此前每个位置算一次相关性,位置数是 T,两两比较就是 T 的平方。T 翻四倍,比较次数翻十六倍。

两笔账的差别在工程上很致命。线性那笔可以靠加显存、靠量化、靠淘汰来扛;平方那笔只能靠减少比较次数,或者靠更快的算力。前者是容量问题,后者是时间问题,两个问题的解法不通用。

平方到底有多大

用 MLSysBook 里的一个估算感受一下量级。

序列长度十万,12 个注意力头,如果把注意力分数矩阵完整地物化出来,每个头是十万乘十万,十二个头就是一点二乘十的十一次方个元素。FP16 每元素两字节,合计 240 GB。这还只是一层;32 层全部保留就是 7.7 TB。

这个数字说明两件事。第一,真实的注意力实现绝不可能把整个分数矩阵写进显存,必须边算边扔。第二,即便如此,计算量本身没有减少——FlashAttention 这类方法省的是显存往返,不是那 S 平方次比较。

所以"用了 FlashAttention 就不怕长上下文"是错的。它把平方的内存占用变成了线性,把平方的计算留了下来。

切块预填充

长上下文带来的第一个现实问题是调度。

一条 32K 的请求进来,预填充要算几十毫秒甚至几秒。在这段时间里,GPU 被这一条请求占满,所有正在逐字生成的请求都得停。用户看到的是:另一条对话突然卡住不吐字了。

解决办法是把长预填充切成固定大小的块,比如每块 512 个 token,每一步只放一块进去,和正在解码的请求一起调度。这样解码的节奏不被打断,长请求晚一点完成,但没有人的输出会突然停顿。

切块大小是一个取舍。块小,解码的节奏稳,但调度次数多、开销大;块大,调度简单,但解码会被拖慢。Inference Engineering 和 MLSysBook 都提到过这个机制,后者引的是 Sarathi-Serve 的做法,块大小取 512 个 token 左右。

这个手段解决的是干扰,不解决总量。总计算量没变,只是把一段长任务摊平了。

缓存怎么省

缓存那笔账是线性的,所以省法也直接。

前缀缓存最有效:同一段系统提示词、同一份文件、同一轮对话,只存一份。长上下文场景里前缀往往很长,命中一次省下的就是几 GiB 和几秒的预填充。

KV 量化次之:每元素从两字节压到一个,缓存直接减半。代价是误差会沿着生成过程累积,上下文越长越明显,所以长上下文下量化缓存要格外谨慎,第 7 章说过这一点。

再往下是淘汰和下沉:把不活跃的请求搬到 CPU 内存甚至别的机器,需要时再取回。这解决的是容量,代价是延迟。

最后是共享注意力:如果多条请求共享同一段长文档,可以让它们指向同一份缓存,只在各自新增的部分上分开。这和前缀缓存是同一个思路,只是共享的粒度更粗。

注意力本身能不能少算

能,但那是模型结构的改动,不是推理侧的开关。

常见的方向有三种。滑窗注意力只让每个位置看前面固定窗口内的内容,计算量从平方变成线性乘以窗口大小,代价是超出窗口的信息看不到。稀疏注意力按固定模式跳过一部分比较,比如只看局部加几个全局位置。线性注意力换掉 softmax,用可分解的形式把平方项拆开。

这三种都是训练时就要定下来的结构,推理工程师改不了。能做的只是确认手上的模型用的是哪种,然后按它的复杂度算账。如果一个模型号称支持 128K,先看它的注意力是不是全稠密——全稠密的 128K,计算量是 8K 的二百五十六倍,绝大多数场景根本跑不起。

名义长度和有效长度

还有一个容易被忽略的区别:模型声称支持的上下文长度,和它实际能用好的长度,不是一回事。

位置编码决定了模型见过的最长序列。超过训练长度之后,位置编码要外推,常见做法是调整频率或者插值。外推能跑,但效果会退化:模型可能仍然能处理开头的指令,却漏掉中间段落的信息,或者在第 100K 个 token 之后开始重复。

这件事没法靠推理参数解决,只能靠评测。判断方法是拿真实任务测:把关键信息放在不同位置,看模型能不能找到;把长文档的摘要和人工摘要比,看差多少。第 11 章会讲怎么设计这类评测。

所以选型时,"支持 128K"只是一个上限声明,不是质量承诺。

三类负载,三种账

同样叫长上下文,不同的用法撞的是不同的墙。

第一类是文档问答:一份几万字的文档,加上一个问题,输出几百字。输入长、输出短,预填充占绝对大头。它的成本几乎全在算力上,优化的重点是前缀缓存和切块,因为同一份文档会被反复问到。

第二类是代码库理解:把整个仓库的上下文塞进去,让模型改一个函数。上下文极长,但重复度极高——同一个文件在不同请求里反复出现。这类负载的账不在算力,在缓存命中率:命中率高,成本降一个数量级;命中率低,每次都在重新预填充几十万 token。

第三类是多轮对话:上下文随轮数增长,每一轮都要把前面的历史带上。它的特点是缓存持续存在,一轮接一轮地长。这类负载要防的是缓存泄漏和淘汰:聊到第五十轮时,前面四十九轮的缓存还在不在,直接决定这一轮要算多久。

三类负载的优化手段排序完全不同。文档问答先做前缀复用,代码库先做缓存共享,多轮对话先做淘汰策略和容量规划。用错顺序,做的是无用功。

一次手算:32K 请求

用 8B 的 GQA 模型算一遍,A100,FP16。

缓存:每 token 128 KiB,32K 是 4 GiB。一张 80GB 卡,权重 15 GiB,剩 65 GiB,理论上能放十六条,实际按余量算十二条左右。

预填充计算分两部分。线性部分是两倍参数量乘以长度,2 乘以 80 亿乘以 32768,约 524 TFLOP。注意力部分是四乘以层数、长度平方和隐藏维度,4 乘以 32 乘以 32768 的平方乘以 4096,约 563 TFLOP。两部分相当,合计约 1.1 PFLOP。

按 A100 的 312 TFLOPS 算,理想情况 3.5 秒。实际打对折,六七秒。这就是一条 32K 请求的首字延迟下限。

对比 8K:线性部分 131 TFLOP,注意力部分 35 TFLOP,合计 166 TFLOP,理想 0.53 秒。长度翻四倍,预填充时间翻了将近七倍,因为平方项开始占主导。

这组数字解释了为什么长上下文服务通常不让用户等首字,而是先返回一个"正在处理"的状态,或者把长文档的处理放到后台。

长上下文的边界

三件事这一章解决不了。

第一,平方的计算量。切块、融合、稀疏化都只能缓解,全稠密注意力的复杂度摆在那里,除非换模型结构。

第二,缓存的容量。一条 128K 请求 16 GiB,这是硬账,只能靠多卡或者量化。

第三,质量。名义长度不等于有效长度,评测才能回答。

能做的三件事是:前缀缓存省重复计算,切块预填充保解码节奏,量化省容量。这三件事加起来,通常能把长上下文的成本压到可接受,但压不到和短上下文一样。

下一章讲并行。前面所有的"装不下""算不完",到了多卡就变成另一个问题:怎么切、切完怎么通信、通信的代价有多大。