开篇先说实话:512K 的上下文窗口,纸面上看着很唬人,可真要落到部署上,能在这个长度下还能正常出字的系统,十个里面未必有一个。我这次在 8 卡 H20 上部署一个 671B 参数的 MoE 大模型(内部代号 DeepSeek-V4.1,MLA 注意力架构),第一次跑 512K 长输入时,首字延迟稳定在 82 秒以上。用户发一条超长消息,一分钟都看不到第一个字,这种体验根本没法上线。经过两轮系统性优化,我把首字延迟压到了 6.5 秒,相对基线整整提升了 12.7 倍。这中间没有单一某个“神级优化”,而是把并行策略、稀疏注意力、精度量化、通信调度重新组合了一遍,每一步踩的坑也都值得记下来。这篇优化实录,适合所有在做长上下文推理部署的朋友参考,尤其是手头只有 H20 这种“显存大、带宽高、算力适中”的卡的人群。
1. 先算清楚:512K Prefill 的延迟到底卡在哪
1.1 算力账:512K 输入的计算量有多大
首字延迟说白了就是一次 prefill 的耗时,外加排队和通信。prefill 要把用户输入的所有 token 都过一遍模型,输入越长,计算量线性增长。对大模型来说,prefill 的计算量有个粗略的估算公式:
计算量 FLOPs ≈ 2 × token 数 × 每个 token 激活的参数量以 671B 总参数、37B 每 token 激活参数的 MoE 模型为例,512K 输入的 prefill 计算量大概是:
2 × 512000 × 37e9 ≈ 3.79e16 FLOPs8 卡 H20 单卡 FP8 算力约 296 TFLOPS,8 卡合计约 2.37 PFLOPS。理论上纯算力极限就是 16 秒左右;如果用 BF16,算力减半,极限要 32 秒。
这个数看起来“也还行”,但现实跑出 82.5 秒,差的 50 多秒全在别的地方。标准全量 Attention 在 512K 下是恐怖的 2620 亿个注意力分数对,这个量级的 O(n²) 计算是第一个大坑;再加上权重反复从 HBM 搬到计算单元、MoE 路由的跨卡通信、kernel 启动和调度开销,最后落在墙上的时间远超纸面。
1.2 访存账:权重和 KV Cache 都是真金白银
H20 单卡 96GB,8 卡总共 768GB,听起来很大。先算两笔必须花的钱:
- 模型权重:671B 参数,BF16 要 1342GB,FP8 也要 671GB。这意味着 8 张卡只有用 FP8 才可能把权重全塞进显存。
- KV Cache:模型用了 MLA 压缩,每个 token 每层约 1.1KB(FP16),512K token 全量下来约 35GB。如果不是 MLA 而是普通 GQA 结构,这个数字会膨胀到 120GB 以上,直接爆显存。
带宽也要考虑。H20 每卡 HBM 带宽约 4TB/s,8 卡理论 32TB/s,但 prefill 过程中每一层都要把权重从 HBM 搬到 SM,搬一次就是 671GB。理想状态读一遍权重也要 20 秒左右,这还没算 KV 的读写和通信。访存不是最尖锐的瓶颈,但它决定了下限,任何低效的数据搬运都会直接吃掉宝贵的算力时间。
1.3 三座大山:计算、通信、调度
把 512K prefill 的延迟拆开看,压力集中在三块:
- 计算:Attention 在 512K 下是 O(n²) 的暴力计算,必须稀疏化。
- 通信:MoE 模型每层都要做 all-to-all 路由,token 越多通信量越大。512K token 全部参与路由时,通信成本是 128K 的四倍。
- 调度:一条超长请求进来后,常规实现会长时间独占所有计算资源,其他并发请求全部排队。单看这条请求延迟很高,从系统整体看,平均首字延迟会更恶化。
我得出的结论是:单一手段救不了超长 prefill,必须把并行切分、计算削减、精度压缩、通信重叠、调度打散全部叠加在一起。
2. 整体设计:从并行到稀疏的四层优化叠加
2.1 并行策略调整:512K 输入为什么选 CP=8
最先要定的就是 8 张卡怎么划分。一开始直觉方案是 DP=8,也就是每张卡各算各的。但 DP=8 在长序列上有个致命问题:每个卡都要处理完整的 512K 序列,KV 也是全量,算力并行没享受到,显存和通信先爆了。
正确做法是上下文并行 CP=8:把 512K token 切成 8 段,每卡只持有 64K token,Attention 阶段靠 ring-like 的方式把 KV 在卡间流动,每卡只需要计算自己那 64K query 与全量 KV 的局部注意力,最后归一化聚合。这样单卡的 attention 计算量直接除以 8。
至于 TP,在长序列场景我没有选它。TP 在矩阵维度切分,每层都要做同步通信,通信量随模型宽度增长;而长序列的主要矛盾是序列长度,TP 对削减 attention 压力毫无帮助,反而引入新的同步点。H20 的 NVLink 带宽再高也经不住每层都全量同步,所以全局并行维度选 CP,TP 只保留在单卡内部的 MLP 计算上,不做卡间扩展。
2.2 稀疏注意力:4:1 稀疏率下长程依赖怎么保持
512K 全量 Attention 是算不动也存不下的,稀疏化是必须的。但无脑稀疏又会损失长程召回能力。我最终采用了三层混合稀疏设计:
- 局部窗口:每个 token 只看前后 4096 个 token,覆盖近程语义。
- 全局锚点:每隔 1024 个 token 设置一个全局 token,所有 token 都可以直接attend这些锚点,用来传递长程骨架信息。
- 按需检索:对于窗口和锚点都覆盖不到的远程关键信息,用一个轻量的检索模块把相关历史块拉进当前窗口。
实测下来 attention 部分的 FLOPs 削减到原来的四分之一左右,端到端在长文问答、摘要类任务上的掉点控制在 1% 上下。这个代价是完全可以接受的。
为什么这种稀疏还能保住效果?因为长文本里的注意力矩阵本来就是高度稀疏的,绝大多数远距离 token 对的注意力分数趋近于零。局部窗口负责即时语境,全局锚点相当于“摘要指针”,按需检索则模拟人的“回头翻资料”行为。比起无脑保留全部注意力,这种结构反而更接近真实的长文本语义分布。
2.3 精度档位选择:FP8 权重与 INT8 KV Cache 的取舍
H20 的 FP8 算力是 BF16 的两倍,这一档必须吃满。我最终的精度方案是:
- 权重:per-channel 的 FP8,按输出通道做 scale。这里提一句,不要用 per-tensor 的 scale,长序列上误差会迅速累积,我踩过这个坑,后面细说。
- 激活:动态量化到 FP8,per-token 和 per-channel 组合 scale。
- KV Cache:INT8,利用 MLA 的 latent 向量维度小、量化误差可控的特点,把 35GB 的 KV 压到约 18GB。
- 注意力分数内部:保留 BF16 计算,避免 low-bit 下的 softmax 精度崩塌。
收益有两块:一是权重减半,8 卡总算力从 BF16 的 1.18 PFLOPS 提到 2.37 PFLOPS;二是显存大幅松绑。代价是 FFN 第一个线性层对精度比较敏感,我专门给这一层保留 BF16,其余全走 FP8,质量几乎无损。
2.4 Chunked Prefill:把超大请求拆成微块再调度
单条 512K 请求如果一次性算完,显存峰值非常难看,而且计算期间其他请求全被堵死。我的做法是把每卡上的 64K chunk 再切成 8 个 8K 微块,每个微块算完立即释放临时激活和通信 buffer。
这一步单独看不会降低单请求的总计算量,但对系统整体收益巨大:prefill 微块可以和其他请求的 decode token 交错执行,把算力空隙填满;长请求和短请求之间也不再是“先来后到”的排队,而是能互相穿插。最终配合调度器,单请求的排队时间明显下降,端到端 TTFT 的稳定性也好很多。
这里有一个容易被忽略的细节:chunk 大小不能拍脑袋定。8K 是我实测的甜点值,4K 虽然调度更灵活但 kernel 数量翻倍,16K 则显存峰值和 CUDA Graph 内存占用都上去了,具体数据放在第 4 章。
3. 关键实现:显存账本、通信重叠与 Kernel 层优化
3.1 768GB 显存怎么分配:权重、KV 与激活的精细规划
很多人拿到 8×96GB 觉得“反正放得下”,实际排账的时候会发现处处是坑。我最终的单卡显存分配大致是这样:
| 占用项 | 单卡占用 | 说明 |
|---|---|---|
| 模型权重 FP8 | 约 84GB | 671B / 8,含分片后的专家与共享层 |
| KV Cache INT8 | 约 2.2GB | 18GB 总量按序列切分到各卡 |
| 激活 / 临时 buffer | 约 3GB | 8K 微块算完即释放 |
| 通信 buffer | 约 6GB | 环型 KV 传递 + all-to-all 的 staging |
| 预留 / 碎片 | 约 1GB | 留给 CUDA Graph 捕获和动态分配 |
加起来接近 96GB,所以要把通信 buffer 设计成可复用的大块内存池,不能每层通信都临时 malloc。另一个重点是把权重里的 MTP 模块和极少访问的冷专家做成按需加载或常驻压缩,否则 OOM 会在整卡长跑后突然出现,防不胜防。
3.2 All-to-All 通信优化:把通信时间从 45% 压到 21%
MoE 的 all-to-all 通信是长序列 prefill 的另一头猛兽。没优化前,通信占了 prefill 总耗时的 45% 左右,主要原因是:每层一次 all-to-all,计算和通信串行;消息小而多,NVLink 利用率上不去;数据发送前还要做一次拷贝,白白多走一遍显存。
我做了四件事:
- 把 chunked prefill 的微块做成流水线,计算当前微块的 Attention 时,提前发起下一个微块的 token dispatch 通信,让通信和计算重叠。
- 合并通信通道,CP 的 KV 传递和 EP 的路由用同一个 NVLink 通道组,减少通道竞争。
- 去掉 staging buffer 的额外拷贝,直接拿设备内存指针做零拷贝传输。
- 调高 NVLink 的通信通道数和消息粒度,小消息拼成大包再发。
这一套下来,通信占比稳定降到 21%,并且不再随着序列长度线性恶化。
3.3 Chunked Prefill 与 CUDA Graph:减少调度和启动开销
长 prefill 里有很多小算子:RMSNorm、RoPE、量化、门控、残差,每一个在常规框架里都是一次 kernel 启动。512K 输入切成 64 个 8K 微块后,这类小 kernel 累计几千次,启动开销加起来是一笔惊人的隐形成本。
解决办法是把固定 shape 的路径 capture 成 CUDA Graph。我的做法是:针对 8K 微块,把“QKV 投影 + Attention + O 投影 + 非专家部分的前向”整体捕获成一个 graph;因为 chunk 固定为 8K,Attention 的输入 padding 到固定长度,Graph 可以完整复用。MoE 的路由是动态的,这部分没法全程固定,只 capture 非 MoE 部分,路由和专家计算仍在 graph 外执行。
实测效果很直接:kernel 启动和调度开销从约 4.6 秒压到约 1.2 秒。要特别提醒,CUDA Graph 捕获时会占一块常驻显存,且不能和动态 shape 混用,必须把捕获逻辑和运行时分配的内存池分开,否则多次捕获后显存碎片会越来越严重。
3.4 12.7 倍到底怎么拆出来的:逐层减账
为了让这份记录更可复现,我把每一步优化后的 512K TTFT 单独跑了一轮,完整拆解如下:
| 优化阶段 | 512K 首字延迟 | 单步加速 | 累计加速 |
|---|---|---|---|
| 基线(BF16、全量 Attention、DP8、无 Graph) | 82.5s | 1× | 1× |
| + FlashAttention / 算子融合 / 显存复用 | 65.2s | 1.26× | 1.26× |
| + CP=8 + 通信重叠 | 18.9s | 3.45× | 4.36× |
| + FP8 权重与激活 + INT8 KV Cache | 13.4s | 1.41× | 6.16× |
| + 稀疏 Attention(4:1) | 7.8s | 1.72× | 10.58× |
| + Chunked Prefill + CUDA Graph + 调度 | 6.5s | 1.2× | 12.69× |
可以看到收益最大的是 CP=8 加通信重叠,直接把 4 倍多的提速拿了下来;其次是稀疏 Attention,接近 1.7 倍。这两项的收益都随序列长度增大而增大,512K 下尤其明显。
4. 实测数据与调参记录
4.1 测试环境与口径说明
先说测试环境,方便大家在类似配置上对照。硬件是 8 卡 H20 96GB,NVLink 全互联拓扑,CUDA 12.x,NCCL 通信库,推理框架是基于主流动态 batch 框架二次开发的自研版本,支持 FP8 和 CUDA Graph。
模型就是开头说的 671B MoE,MLA 注意力,约 60 层,每 token 激活 37B 参数。测试输入是拼接长文档形成的 512K token prompt,在业务场景里模拟“用户一次性扔一整本书进来提问”。每个长度档位跑 20 次,取中位数,排除 GC 和网络抖动的影响。首字延迟的口径是:从请求到达系统开始,到第一个输出 token 完整生成出来的时间,包含排队、prefill、首个 decode。
4.2 不同长度下的首字延迟对比
除了 512K,我把 128K 和 256K 也一起测了,方便看加速趋势:
| 输入长度 | 基线 TTFT | 优化后 TTFT | 加速比 |
|---|---|---|---|
| 128K | 18.2s | 2.1s | 8.7× |
| 256K | 41.6s | 3.9s | 10.7× |
| 512K | 82.5s | 6.5s | 12.7× |
加速比随长度增大而增大,这一点非常关键。原因有两层:固定开销(权重加载、CUDA Graph 捕获、调度初始化)在更长输入中占比被摊薄;稀疏 Attention 的收益是非线性的,序列越长,剪掉的无效计算比例越高。这从侧面说明,这套优化对超长上下文的价值比中短长度更大。
4.3 几个关键参数的“甜点值”
在调参过程中记录下来的几个甜点值,未必对所有模型通用,但可以作为起点:
| 参数 | 甜点值 | 说明 |
|---|---|---|
| 稀疏窗口 W | 4096 | 2048 时质量掉点明显,8192 收益变缓且显存压力大 |
| 全局锚点间隔 G | 1024 | 512 时质量略高但 attention 计算增加,256 划不来 |
| Prefill 微块 chunk | 8K | 4K 调度灵活但 kernel 数量翻倍,16K 显存峰值过高 |
| KV 量化 scale | per-token | 比 per-channel 更稳,MLA latent 维度下的误差控制更好 |
| FFN 第一层精度 | BF16 | 全 FP8 时端到端质量下降,只留这层 BF16 代价最小 |
这些参数不是独立的,它们互相影响。例如稀疏窗口从 4096 调到 8192,chunk 的显存占用就明显上升,此时 chunk 可能要降到 4K 才能压住显存。调参时一定要结合第 3.1 节的显存账本一起看,不能只盯单项指标。
5. 避坑实录:这些坑我替你们踩过了
5.1 稀疏注意力踩出的“注意力黑洞”现象
第一次把稀疏 Attention 接进去后,短文本效果正常,长文档场景却出现了明显的“上下文里面有,但模型答不出来”的情况。排查后发现,问题出在全局锚点的分布上:当时锚点按固定间隔均匀放置,某些关键内容恰好落在两个锚点之间,既不在局部窗口内,也没有锚点覆盖,等于对于那些 token 来说形成了注意力盲区,像黑洞一样把信息吞掉了。
解决办法是把全局锚点的间隔从 1024 收紧到 512,并且尽量按段落边界放锚点,而不是均匀撒点。此外加了一个轻量的“高频块检索”,把最近被频繁注意的 KV 块拉回窗口。这个改动对长文问答的召回率提升非常明显。教训是:稀疏注意力的有效性不仅取决于窗口大小,更取决于锚点的密度和位置分布,均匀布局在长文本里并不总是最优。
5.2 KV Cache 量化后中长段精度突然掉点
另一个让我折腾很久的问题是 KV Cache 量化。单独看 perplexity 没发现异常,但端到端长文本任务在 250K 以后开始出现重复回答、漏细节,越往后越严重。排查发现是 INT8 的 scale 设置方式导致的:MLA 的 latent 向量量化误差在远端 token 上会逐层累积,per-channel scale 对这种低维向量的适应性不好。
解决方案是把 KV 量化改成 per-token 动态 scale,并用校准集统计 max 值;同时给前 4 层 KV 保留 BF16,成本只增加约 1.5GB 每卡,端到端质量立刻恢复到接近全精度的水平。这里特别想强调:不要只盯 perplexity,一定要跑端到端的长文任务才能暴露 KV 量化问题,因为长距离依赖对微小误差极其敏感。
5.3 All-to-All 与 CP 通信的死锁问题
优化过程中还遇到过偶发卡死:多卡任务跑到一半整批 hang 住,GPU 利用率不高但进程不再继续,SMI 里看不到报错,只能靠日志定位。最后确认是通信顺序问题:CP 的 KV 环型传递和 EP 的 all-to-all 原本用了两个独立 NCCL 通信组,各自发起,依赖顺序不一致时形成了等待环。
解决方式是把 CP 和 EP 的通信合并进同一个通信组,用一个全局调度顺序统一管理,保证“先 EP 路由,再 CP 交换”的顺序不被打破。另外,CUDA Graph 捕获到含通信的路径时也会和异步 all-to-all 产生竞争,所以含通信的路径要使用支持 Graph capture 的通信实现,或者重放前手动管理事件同步。死锁这类问题最难排查,建议一开始就把通信依赖图打印出来,别等卡死了再分析。
5.4 H20 满载降频与功耗控制
H20 的算力本来就不高,连续跑 512K 这种超长 prefill 时,还会撞上功耗墙和温度墙,core clock 出现明显下降,实测 FP8 算力掉 10% 到 15%。这让“纸面算力”和“稳定算力”之间出现很大差距。
对策分两步:一是调整服务器散热和风扇策略,把 NVLink/NVSwitch 的功耗预留出来,避免通信高负载时抢掉计算功耗;二是把 prefill 微块的调度做得更均匀,避免某一卡瞬时功耗飚高。更重要的是,做性能预估时不要用 H20 的峰值算力,要先用 30 分钟满载测试测出稳定算力,再按这个数评估优化收益。
写在最后
这次优化做下来,我最大的感受是:长上下文 prefill 优化不是某一个奇技淫巧的胜利,而是算力、通信、显存、调度四个方向的博弈和平衡。稀疏 Attention、FP8 量化、上下文并行这些名词单独拿出来都是老生常谈,但只有把它们组合在一起,并针对 H20 的特性做取舍,才能形成 12.7 倍这种量级的收益。如果一开始不把算力账和显存账算明白,上来就盲目叠加优化手段,我估计最后最多也就提升个 2 到 3 倍。另外,所有的优化都要用端到端 TTFT 来评判,不能只看单个 kernel 的加速比。尤其对 512K 这种超长输入,调度和排队对体验的影响经常比 kernel 本身还大。个人经验是:动手优化之前,先把模型架构的 KV 压缩方式、通信拓扑、稳定算力这三个基本盘摸清楚,后边的路会顺很多。