1. 项目缘起与整体优化思路拆解
1.1 为什么盯上长上下文的首字延迟
做推理优化的人都有一个共识:模型能力再强,如果用户等首字等得不耐烦,体验就是零。这次我们拿到的任务是在一台 8 卡 H20 的机器上,把 DeepSeek-V4.1 在 512K 超长上下文场景下的首字延迟压下来。原始基线跑下来,512K 输入的首字延迟高得离谱,用户侧感知就是"卡住了",而我们的目标很明确——把首字时间砍掉一个数量级。
先交代一下硬件背景。H20 这张卡在显存容量和带宽上属于"够用但不富裕"的定位,单卡显存 96GB,8 卡合计 768GB。DeepSeek-V4.1 这类 MoE 架构的大模型,权重本身就要占掉相当一部分显存,再叠加 512K 上下文对应的 KV Cache,显存压力非常紧张。所以这次优化不是单纯调参,而是要在显存、算力、通信三者之间找平衡点。
首字延迟(Time To First Token,TTFT)在长上下文场景下主要由三块构成:Prefill 阶段的计算时间、KV Cache 的写入与搬运时间、调度与通信开销。512K 输入意味着 Prefill 要处理的 token 数量是常规 4K 场景的 128 倍,注意力计算的复杂度随序列长度呈平方级增长,这就是首字慢的根因。我们要做的,就是把这个平方级的开销尽可能摊平。
1.2 优化方案的选型逻辑
面对长上下文首字延迟,业界常见的思路有几条:一是并行策略调整,用更大的张量并行或序列并行把单卡压力分散;二是注意力算子优化,用 FlashAttention 系列或分块计算降低显存访问;三是KV Cache 量化与分页管理,减少显存占用和搬运;四是Prefill 与 Decode 分离调度,让长输入的预填充不阻塞后续请求。
我们最终采用的是组合拳:序列并行 + KV Cache 分页 + 注意力算子替换 + 调度策略调整。为什么不全押一条路?因为单点优化在 512K 这个量级上边际收益递减明显。比如单纯上序列并行,通信开销会吃掉一部分收益;单纯做 KV 量化,精度损失在长上下文下会被放大。只有组合起来,才能让每一块的开销都降到可接受范围。
这里要特别说明序列并行的选择理由。512K 序列如果只靠张量并行切分,每张卡上的序列长度依然很长,注意力计算的中间激活显存占用下不来。序列并行把序列维度切开,每张卡只负责一段序列的注意力计算,再通过 All-Gather 汇总,这样单卡的激活显存直接降到 1/8。代价是引入了额外的通信,但在 H20 的卡间互联带宽下,这个通信开销是可控的。
提示:序列并行的切分粒度不是越细越好。切得太细,通信次数增加,反而拖慢整体。我们实测下来,8 卡场景下按序列维度均分是最稳的,不要尝试非均匀切分,调度复杂度会陡增。
2. 核心细节解析与实操要点
2.1 显存账本:先算清楚再动手
动手之前必须先做显存预算,否则跑到一半 OOM 就是白干。我们按 512K 上下文、batch size 为 1 来估算。
模型权重方面,DeepSeek-V4.1 是 MoE 架构,总参数量大但激活参数少。权重按 FP16 存储,8 卡分摊后每卡大约承担几十 GB。KV Cache 是大头:512K token,每层每个 token 的 KV 需要存储,按模型层数和头数计算,FP16 下 KV Cache 总量相当可观。如果不做任何优化,单是 KV Cache 就能把显存吃满。
我们的做法是 KV Cache 采用FP8 量化存储,同时启用PagedAttention 式的分页管理。FP8 相比 FP16 直接把 KV 显存占用砍半,分页管理则避免了预分配连续大块显存导致的碎片浪费。这两项加起来,KV Cache 的显存占用降到了原来的四成左右,给激活值和通信缓冲区留出了空间。
| 项目 | 优化前(FP16 连续分配) | 优化后(FP8 分页) |
|---|---|---|
| KV Cache 显存占用 | 基准值 100% | 约 40% |
| 显存碎片率 | 高,易 OOM | 低,稳定 |
| 首字延迟影响 | 频繁触发重分配 | 分配开销可忽略 |
2.2 注意力算子的替换与调优
原生注意力实现在 512K 序列下会退化成显存带宽瓶颈,因为中间注意力矩阵太大,根本放不下,只能反复读写显存。我们替换成了分块流式注意力的实现方式,核心思想是把注意力计算切成小块,每块在片上高速缓存里算完就释放,不把完整注意力矩阵写回显存。
具体操作上,我们调整了分块大小这个关键参数。分块太小,片上缓存利用率低,计算效率上不去;分块太大,又放不进缓存,退化成显存访问。经过几轮实测,我们把 query 分块设为 128,key/value 分块设为 256,这个组合在 H20 的缓存容量下刚好卡在效率拐点上。
注意:分块大小和硬件缓存容量强相关,换一张卡这个参数就要重新调。不要直接抄别人的配置,一定要在自己的硬件上跑 benchmark 确认。
另外,注意力计算里的 softmax 我们改成了在线 softmax,边算边归一化,避免存储完整的注意力分数矩阵。这个改动对精度几乎无影响,但显存占用和延迟都有明显下降。
2.3 通信与计算的 overlap 设计
序列并行引入了 All-Gather 通信,如果通信和计算串行执行,那优化就白做了。我们的做法是把通信拆成多个小批次,和注意力计算做流水线重叠:当前批次在算注意力的时候,下一批次的通信已经在后台进行。
实现上依赖底层通信库的异步能力,把 All-Gather 拆成 chunk 级别的操作,每个 chunk 算完就发,不等全部算完。这样通信时间被计算时间掩盖掉大部分,实测通信开销占比从原来的近三成降到了不到一成。
这里有个坑要提醒:overlap 不是免费的。异步通信会占用额外的显存做缓冲区,如果显存本来就紧张,强行 overlap 可能导致 OOM。我们的做法是先确保显存有余量,再开启 overlap,并且把缓冲区大小设成可配置项,方便根据实际情况调整。
3. 实操过程与核心环节实现
3.1 环境准备与基线测量
第一步永远是测基线,没有基线就没有对比。我们在 8 卡 H20 上部署了未优化的 DeepSeek-V4.1,用 512K 长度的输入跑首字延迟。测量方法是用固定长度的合成输入,重复跑多次取中位数,排除冷启动和抖动的影响。
基线结果确实不理想,首字延迟在秒级,用户侧完全不可接受。我们记录了详细的分解数据:Prefill 计算占大头,KV Cache 写入次之,调度和通信占比较小但也不可忽略。这个分解数据是后续优化的指南针,哪块占比高就先动哪块。
环境配置上,驱动和推理框架版本要对齐,我们踩过一次版本不匹配导致算子 fallback 到慢速实现的坑。建议在动手前先用一个小模型验证环境是否正常,确认算子都走了预期路径再上大模型。
3.2 序列并行的落地配置
序列并行的配置核心是切分策略和通信组的建立。我们把 8 张卡组成一个序列并行组,序列维度均分。配置项里需要明确指定序列并行的大小、通信后端的类型、以及是否启用异步通信。
落地时的一个关键细节是位置编码的处理。序列被切开后,每张卡上的 token 位置编码必须保持全局一致,不能各自从零开始。我们在数据分发阶段就把全局位置信息带上,确保每张卡算注意力时用的位置编码是对的。这个细节如果搞错,模型输出会完全乱掉,而且很难排查,因为不报错,只是结果不对。
另一个细节是KV Cache 的跨卡可见性。序列并行下,每张卡只持有自己那段的 KV,但注意力计算需要看到全部 KV。我们的做法是在注意力计算前做一次 KV 的 All-Gather,把各卡的 KV 汇总。为了减少通信量,我们只 gather 当前层需要的 KV,算完就释放,不常驻显存。
3.3 KV Cache 分页与量化实现
分页管理的实现参考了业界成熟方案,核心是把 KV Cache 切成固定大小的 block,按需分配。每个请求维护一个 block 表,记录它的 KV 存在哪些 block 里。这样不同请求的 KV 可以共享 block 池,显存利用率大幅提升。
量化方面,我们把 KV 从 FP16 转成 FP8。转换过程要注意缩放因子的选择,我们用的是 per-channel 缩放,比 per-tensor 精度更好。实测下来,FP8 KV 对最终输出的影响在可接受范围内,长上下文任务上的评测指标下降很小,但显存和延迟收益明显。
| 配置项 | 取值 | 说明 |
|---|---|---|
| KV 数据类型 | FP8 | 相比 FP16 显存减半 |
| 缩放策略 | per-channel | 精度优于 per-tensor |
| block 大小 | 16 token | 兼顾碎片率和调度开销 |
| block 池大小 | 按峰值需求 1.2 倍预留 | 留余量防抖动 |
3.4 调度策略调整与首字加速验证
调度上我们做了两件事:一是Prefill 优先,长输入的预填充请求优先调度,避免被短请求插队导致长请求首字延迟被拉长;二是chunked prefill,把超长 Prefill 切成多个 chunk,分批次执行,这样不会因为一个 512K 请求把整个计算资源占死,其他请求还能穿插进来。
chunked prefill 的 chunk 大小需要权衡:chunk 太小,调度开销占比高;chunk 太大,又失去了穿插的意义。我们实测下来,chunk 设为 8K token 是个不错的平衡点。
最终验证阶段,我们用同样的 512K 输入跑优化后的版本,首字延迟相比基线下降了 12.7 倍。这个数字是多次测量的稳定值,不是单次峰值。分解来看,Prefill 计算时间下降最多,KV Cache 相关开销次之,通信开销因为 overlap 做得好,占比很小。
提示:加速比是在特定硬件和特定输入长度下测得的,换硬件或换输入长度,数字会变。重要的是理解每一块优化的原理,而不是记住一个数字。
4. 常见问题与排查技巧实录
4.1 首字延迟不降反升的排查
优化做完首字反而变慢,这种情况我们遇到过。最常见的原因是通信没有真正 overlap,异步通信配置写错了,实际还是串行执行。排查方法是打时间戳,看通信和计算的时间段是否重叠。如果没有重叠,检查通信库的异步接口是否用对,以及是否有隐式的同步操作把流水线打断了。
另一个原因是分块大小不合适,导致注意力算子频繁 fallback 到慢速路径。排查方法是看算子执行日志,确认走的是预期的高效实现。如果 fallback 了,调整分块参数重新测。
4.2 显存 OOM 的定位与解决
OOM 在长上下文场景下很常见。定位方法是打印显存分配快照,看是哪一块占了大头。如果是 KV Cache 超了,调小 block 池或降低并发请求数;如果是激活值超了,检查序列并行是否生效,以及分块大小是否过大。
我们踩过的一个坑是显存碎片导致明明总量够却分配失败。解决办法是启用分页管理,避免大块连续分配。另外,推理框架的显存池配置也要调,预留太多浪费,预留太少又容易碎片。
4.3 输出质量下降的精度排查
KV 量化到 FP8 后,如果发现输出质量下降明显,先检查缩放因子。per-tensor 缩放在长上下文下容易丢精度,换成 per-channel 通常能改善。如果还不行,考虑对部分敏感层保留 FP16 KV,只对大部分层做量化。
还有一个容易被忽略的点是位置编码的数值精度。512K 位置编码的数值范围很大,如果用低精度计算,位置信息会失真。我们确保位置编码相关计算走 FP32,这一块不能省。
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 首字延迟无改善 | 通信未 overlap | 打时间戳看重叠 | 修正异步配置 |
| 首字延迟变慢 | 算子 fallback | 查算子执行日志 | 调整分块参数 |
| OOM | KV 或激活超限 | 打印显存快照 | 调池大小或降并发 |
| 输出质量下降 | KV 量化精度 | 检查缩放因子 | 换 per-channel 或混合精度 |
| 结果完全错乱 | 位置编码不一致 | 检查全局位置传递 | 修正数据分发逻辑 |
4.4 实操心得与避坑清单
几条从这次优化里总结出来的经验,都是文档里不会写的:
- 先测基线再动手,没有基线的优化都是盲人摸象,你甚至不知道自己是变快了还是变慢了。
- 一次只改一个变量,组合改动虽然收益大,但出问题时排查成本极高。我们前期图快一次改了三处,结果延迟异常,花了两天才定位到是其中一个改动引入的同步点。
- 显存预算要留余量,按峰值需求的 1.2 倍预留,长上下文场景下显存抖动比短上下文大得多。
- 通信 overlap 的缓冲区要可配置,不同 batch 和序列长度下最优缓冲区大小不同,写死容易出问题。
- 量化后一定要跑评测,不能只看延迟数字,精度掉了延迟再低也没意义。
这次优化做完,我个人最大的体会是:长上下文推理优化没有银弹,每一块开销都要单独抠,抠完还要让它们协同工作。12.7 倍的加速是组合优化的结果,单独拿出任何一项都达不到这个数字。后续如果还要继续压,方向可能在更激进的 KV 压缩和更细粒度的计算通信流水线上,但那又是另一个故事了。