今年有一大半时间,我都泡在“把大模型推理延迟再压下来一点”这件事上。Model-Optimizer 这个项目,就是在这个背景下一点点攒出来的。它不是什么颠覆性的新算法,而是一套把权重量化、KV Cache 优化、算子融合、动态批处理、投机采样这些已知手段,拧在一起落到具体工程里的组合拳。如果你正在跑 LLM 推理服务,对 TTFT、TPOT、吞吐量这几个指标有执念,又不满足于“能用就行”的状态,那这篇里记录的配置、参数和踩坑记录,应该能帮你省下不少排查时间。
我先把项目放在一个具体的语境里讲:线上有部署在私有化环境里的对话模型,7B 到 13B 不等,卡是 A10/A30 这一档。业务方给的硬指标是首 token 延迟低于 2 秒、每秒吞吐不低于 30 个请求、长上下文场景不能 OOM。最开始用原版 Transformers 直接起服务,14B 模型在 A30 上显存就差一点不够用,延迟更是惨不忍睹。打磨 Model-Optimizer 的过程,本质上就是回答“在算力不变的情况下,怎么让模型跑得更快更省”这个问题。
1. 项目背景与整体设计思路
1.1 为什么需要 Model-Optimizer:推理优化的现实痛点
很多人对“推理优化”有一种误解,以为就是调个 batch size、换一下精度,大不了上 vLLM 就完事了。真到生产环境就会发现,问题比想象中复杂得多。模型在训练阶段是“重前向、轻部署”,但推理阶段要面对的是有限的显存、严格的延迟上限、波动的请求流量,还有“显存越少、KV Cache 越小、有效吞吐越低”这个硬约束。显存不光是给模型权重用的,还要留给激活值、KV Cache 和临时计算图,任何一个环节爆掉,整个服务就全垮。
另一个现实痛点是,单点优化往往按下葫芦浮起瓢。只做量化,模型小了但吞吐没有本质提升;只开动态批处理,显存又撑不住长上下文;只做算子融合,在低并发下收益又聊胜于无。Model-Optimizer 的设计初衷,就是把这些手段按生产链路组织起来,像流水线一样逐级优化:先让模型塞进显存,再把访存压下来,最后把算力用满。它不是替代推理框架,而是沉淀在框架之上的一层优化编排逻辑——测基线、找准瓶颈、套相应手段、验证收益,整个过程固化成可复用的流水线。
1.2 方案选型逻辑:从单点优化走向组合优化
项目启动时我列过一张技术选型表,对比了三种路线:一是基于原始 Transformers 加 TorchScript/图优化;二是直接迁移到 vLLM/TensorRT-LLM 这类推理框架;三是自研轻量优化层,把精度压缩和调度策略掌握在自己手里。
第一条路最省事,但 TorchScript 对动态 shape 和复杂控制流的支持并不友好,GPTQ 量化后的模型在导出时经常因为算子不兼容而失败。第二条路收益最直接,但业务系统里有几个自定义算子,框架适配成本不低,而且线上想单独调整某种量化策略,改源码难度大。最终选了第三条:以 PyTorch 为基础,配合 llama.cpp 的量化工具链估算方案,再按需接入 vLLM 的 PagedAttention 思路做显存管理。这样做的代价是自己要写不少兼容层,换来的自由度是任何优化手段都能独立开关,方便做对照实验。
组合优化的核心在于“顺序”。我最终固定下来的流程是:权重量化在前,KV Cache 压缩紧随其后,然后是算子融合和批处理策略,最后才是投机采样这种带概率性质的加速手段。顺序错了会很别扭,比如先做 KV Cache 量化却没有先压缩权重,显存腾出来的空间不够支撑更大的批,优化效果会被严重打折。
1.3 项目目标与衡量指标
优化项目如果没有清晰的衡量指标,很容易变成自我感动。这个项目的量化指标只有三个:TTFT(Time To First Token,首 token 延迟)、TPOT(Time Per Output Token,每输出 token 耗时)和整体吞吐量(tokens/s)。TTFT 反映“响应快不快”,TPOT 反映“生成流畅不流畅”,吞吐量反映“服务能扛多少并发”。
但这三个指标是彼此打架的。想降低 TTFT,就要减少 prefill 阶段的计算量,最好把 batch 拆小;想提高吞吐,又要把 batch 尽量填满,让 GPU 算力不闲置。Model-Optimizer 里所有优化决策,最后都要在这三个指标之间找平衡点。我在项目里给每个实验都记录了一张类似下面的对照表,没有数据支撑的优化建议,一律不接受。
| 实验项 | TTFT (ms) | TPOT (ms) | 吞吐量 (tokens/s) | 显存峰值 (GB) |
|---|---|---|---|---|
| 原生 FP16 | 852 | 89 | 218 | 13.8 |
| 加 INT8 量化 | 633 | 71 | 285 | 7.2 |
| 加 KV Cache INT8 | 592 | 66 | 312 | 6.5 |
| 加算子融合 | 548 | 58 | 348 | 6.0 |
| 加动态批处理 | 601 | 55 | 426 | 7.8 |
2. 核心优化手段的原理与实操要点
2.1 权重量化:INT8 / INT4 / FP8 怎么选
量化是 Model-Optimizer 里收益最直接的一步。原理不复杂:把 FP16 的权重矩阵从 16 bit 压到 8 bit 或 4 bit,模型体积直接减半甚至减到四分之一,访存压力也同步下降。但量化不是一个“无脑降精度”的事,不同方案的底层逻辑差别很大。
INT8 量化适合想快速扩容显存的场景。我用过两种主流做法,一种是基于校准集统计激活值分布的 PTQ(训练后量化),另一种是 LLM.int8() 那种混合分解方案——把绝大部分权重量化到 INT8,而对那些激活值特别大、像“离群点”一样的列保持 FP16。实测下来,7B 模型用 INT8 的困惑度损失通常可以控制在 0.1 以内,基本无感。如果你用的显卡支持 FP8(比如 H 系列),可以优先考虑 FP8,它在数值表示上比 INT8 更平滑,量化误差更小。
真正要小心的是 INT4。GPTQ 和 AWQ 是两种主流做法。GPTQ 基于二阶 Hessian 信息做逐层重建,理论上误差最小;AWQ 则根据激活值的分布来保护重要权重通道。我在实际操作里的体感是:在同等压缩率下,AWQ 的稳定性更好,尤其当模型规模在 13B 以下时,GPTQ 容易出现个别层误差异常放大。而 AWQ 校准速度快,对校准数据集的质量要求也低一些。
实操层面,参考配置如下:AWQ 校准用 128 条指令数据,group_size 设为 128,用 per-channel 的缩放因子。不要用 256 的 group size,虽然能再省一点显存,但在长上下文场景下生成的流畅度下降明显。混合量化是我后来加上去的策略:先量化全部层,再计算每一层输出的余弦相似度,把相似度低于 0.99 的层挑出来恢复成 FP16,通常这样做的代价是只多占 5% 显存,但困惑度能挽回一大截。
2.2 KV Cache 压缩与注意力优化
如果权重是“静态显存”,KV Cache 就是“动态显存杀手”。它的大小公式很简单:2(K 和 V)乘以 batch size、序列长度、层数、头维度,再乘以精度字节数。一个 13B 模型,batch size 为 8、上下文 4096 时,光 KV Cache 就可能吃掉 4GB 以上,而且它随并发线性增长。所以 KV Cache 优化是决定服务稳定性的一步棋。
KV Cache 量化的思路和权重量化类似,维度上要注意“按通道量化优于按张量量化”。K 和 V 的数值分布完全不同,K 的分布通常更尖锐,V 更平缓,分开处理能降低量化误差。int8 KV Cache 量化配合按通道缩放因子,实测显存能省 40% 左右,性能几乎不跌。如果再激进一点用 int4,就需要小心某些层在长序列下确实会出现失真,建议对前几层保留 int8。
另一个有效的策略是用 GQA(Grouped Query Attention)替换 MHA(Multi-Head Attention)。GQA 让多个 query 头共享一组 key/value 头,KV Cache 的大小能压缩到原来的 1/n 甚至更小。如果你是重新训练或微调模型,这个改造非常值得;如果只是做推理优化,那更多是通过 KV Cache 的显存上限来控制服务稳定性。我在项目里推行一个经验法则:KV Cache 的显存占用不要超过总显存的一半,超过就说明批大小或上下文长度设得太激进。
2.3 算子融合与前向图优化
算子融合是那种“不做不知道,做了离不开”的优化。在 PyTorch 默认执行方式里,一个简单的Q @ K^T注意力计算会分解成多个内核调用,每一次调用都要把中间结果写回 HBM(显存)再读出来。HBM 的带宽比计算单元慢得多,所以很多时候 GPU 都在“等数据”,而不是“算数据”。
FlashAttention 的思路就是抓这一点:把整个 attention 计算融合进一个 kernel,分块计算,把中间状态留在寄存器或共享内存里,不落回 HBM。我的项目里,这个融合改造贡献了约 15% 的 TPOT 改善。另外一类常见的融合是激活函数和线性层融合,比如x @ W + b之后立刻接 GELU,可以直接熔成一个 kernel,省掉一次读写。
实操要点有两个。第一,用 PyTorch 自带的torch.compile时,要留意动态 shape 对图捕获的影响;如果序列长度频繁变化,编译后的图可能会反复回退到 eager 模式,收益大打折扣。我的做法是对 prefill 和 decode 分别编译,因为这两个阶段的 shape 模式完全不同。第二,在自定义 CUDA kernel 或使用 FlashAttention 周边库时,必须检查它是否支持你当前模型的头数和 head_dim——常见的不兼容就发生在 MQA/GQA 和某些 flash 实现之间,不兼容时通常会自动回退到普通 attention,速度不升反降。
2.4 动态批处理与投机采样:吞吐量提升的两板斧
传统批处理是静态的:一个 batch 开始前定好哪些请求一起跑,要等 batch 里最慢的那个解码完才整体结束。这在请求到达时间不确定的生产场景下非常浪费算力。动态批处理(也叫 continuous batching)把“请求”和“生成”解耦:一个请求完成解码就立刻出队,新请求马上占位进来,GPU 在每一轮都能保持高利用率。
vLLM 的 PagedAttention 是这套思路的典型实现,它把 KV Cache 分页管理,像操作系统管理内存一样分配和回收。Model-Optimizer 也借鉴了这个思路,不过我为了轻量化了,只实现了 chunked prefill:把长请求的 prefill 阶段切成小块,和正在 decode 的短请求交错执行,避免一个长请求独占 GPU。这个改造让线上 TTFT 的 P99 从 2100ms 降到了 1400ms。
投机采样则是另一维度的技巧。它用一个小号草稿模型先生成 K 个候选 token,再让大模型一次性地验证这些 token。如果草稿模型的预测和真值匹配,大模型一次前向就“免费”生成了 K 个 token。实测下来,7B 大模型配 0.5B 草稿模型,对代码生成任务有约 1.8 倍加速;但对一些专有领域文本,草稿模型猜不准,收益接近于零,甚至因为额外开销而变慢。所以投机采样应该是“最后打开”的优化开关,不能当作默认配置。
3. 项目实操过程与关键节点记录
3.1 第一步:建立基线,先跑通再谈优化
很多优化项目失败,不是优化手段不行,而是没有一条可信的基线。我在 Model-Optimizer 里第一件事就是把原生 FP16 模型跑通,记录下三个指标和显存峰值。这一步的价值不是“看它有多慢”,而是给后面每次改动提供一个对照坐标系。
基线环境是 Python 3.10、PyTorch 2.1、CUDA 12.1,模型用 transformers 的默认 pipeline 加载。测试集包含三组:128 条代码生成请求(短上下文 512)、64 条文档总结请求(中等上下文 2048)、32 条长文档对话请求(上下文 8192)。每组都记录 TTFT、TPOT、总吞吐量、显存峰值和 P99 延迟。跑完基线的数据很不好看——长上下文请求几乎把显存打满,P99 的 TTFT 直接到 3 秒以上。但正因为基线足够“差”,后面的每一步优化都能量化出真实收益。
这里有个容易被忽略的细节:做基线测试时,一定要固定随机种子和输入长度分布,否则两次测试的波动会掩盖真实的优化收益。我用固定种子生成测试数据,并且把 warmup 轮数设为 5、正式测试轮数设为 20,取后 15 轮的平均值作为基线。踩过的坑是,GPU 在低温状态下跑第一次推理,耗时会比稳定后高一倍,不 warmup 直接测,基线数据会虚高,后面优化收益会显得很小。
3.2 第二步:性能画像,用数据定位瓶颈
基线建立后,下一步不是急着优化,而是先搞清楚时间都花在哪了。我给项目里接了一个简单的 profiling 工具,给 prefill 阶段和 decode 阶段分别埋点,统计各阶段耗时占比。为什么要区分这两个阶段?因为它们的瓶颈完全不同:prefill 是计算密集型,主要受算力限制;decode 是访存密集型,受显存带宽限制。用不同的手段去优化不同阶段,效率才会高。
实际跑下来的 profiling 结果很典型:短请求场景下,decode 阶段占总时长的 62%,说明瓶颈在显存带宽,这时候最优解是权重量化和 KV Cache 量化——它们能直接减少每轮 decode 读取的数据量。长请求场景下,prefill 占比飙到 70% 以上,瓶颈在计算,这时优先考虑算子融合和 chunked prefill。这些结论听起来像是常识,但没有 profiling 数据支撑时,很容易把优化顺序搞反。
我还做了一件事:监测 GPU 显存分配曲线。用torch.cuda.memory_reserved()和torch.cuda.memory_allocated()定期打点,能看到 KV Cache 的增长是不是平稳。如果曲线出现陡峭台阶,多半是缓存分配策略有问题;如果曲线持续线性上涨,就要怀疑是否某个请求的 KV Cache 没有及时释放,形成了隐式泄漏。
3.3 第三步:逐项优化落地的具体参数与步骤
整个优化流程按固定顺序逐项落地,每次只改一个变量。第一步做权重量化。我用 AutoAWQ 对 7B 模型做 4bit 量化,校准集用的是代码生成下游任务的 128 条样本,group_size=128, zero_point=True。量化的过程大概需要 20 分钟,产出是一个quantized_model/目录。加载量化模型时要注意:不要直接from_pretrained加载整个目录,而是把量化权重转成 safetensors 后再加载,前者的加载速度会慢一倍。
第二步做 KV Cache 量化。因为在 PyTorch 侧现成的 KV Cache 量化工具不多,我参照 vLLM 的实现思路自己写了 K 和 V 的量化/反量化 kernel,用 int8 存储,按通道缩放。注意 K 和 V 的量化和反量化必须在设备上完成,不能搬运到 CPU,否则通信开销会吃掉全部收益。这一步完成后,长上下文场景的显存峰值下降了约 35%。
第三步是算子融合。模型里的 attention 子图替换成 FlashAttention 实现。注意这里不能用简单粗暴的全局替换:当use_flash_attention=True时,要检查模型配置里的 attention head 类型是否兼容。我遇到的最大坑是 GQA 模型在某些 flash 实现下没有被正确处理,需要手动验证 attention 输出和原实现的数值误差,误差超过 1e-3 就说明回退或实现有问题。
第四步是调整批处理策略。引入动态批处理调度器,设置max_num_batched_tokens=4096,max_num_seqs=64。这两个参数一个限制总 token 数,一个限制请求数,相当于给显存用量画了一条安全线。经验值是,它们应该根据量化后的模型显存重新计算,而不是沿用 FP16 时的旧值。最后才是投机采样,草稿模型选择 0.5B 的 Qwen 系列蒸馏模型,num_speculative_tokens=3。这个参数不是越大越好,草稿模型质量不高时,3 和 5 的差距几乎看不到,反而会增加验证失败的机率。
3.4 第四步:联合调优与效果对照
单项优化的风险在于,手段之间存在互相牵制的效应。INT8 权重虽然省了显存,但如果同时又把 KV Cache 压成 INT4,在 8192 长上下文的压力测试下,输出质量可能出现肉眼可见的下降。所以我在每一轮改动后都会跑一遍 3.1 里固定的基线测试,用同一套测试集做对照。
最终的效果对照如下:在这个 7B 模型上,TTFT 从 852ms 降到 548ms,TPOT 从 89ms 降到 55ms,吞吐量从 218 tokens/s 提升到 348 tokens/s(此数据不含投机采样)。等开机投机采样之后,代码生成类任务的吞吐量直接冲到了 586 tokens/s,但文档总结类任务只到 377 tokens/s——这说明“同一套优化组合在不同任务类型下,收益差距非常大”。
这里要特别强调“灰度”意识。不要在一次变更里同时上量化和批处理改造,最好分开上、分开测。我在项目里制定了一个原则:一次变更只碰一个变量,提交记录里写清楚改了什么配置、预期收益是多少、实际收益是多少。这个习惯后来救了我很多次——当线上指标异常时,回滚一个变更比排查五个叠加变更要容易得多。
4. 优化过程中踩过的坑与排查实录
4.1 量化后效果骤降,问题可能不在校准集
我最初做 INT4 量化时,困惑度从 8.2 直接涨到 12.5,对话质量肉眼可见地变傻。第一反应是校准集不够,于是换成了更通用的 512 条指令数据,重新量化后效果依然很差。后来逐层排查才发现,问题出在模型的前几层 embedding 层——量化时没有排除这些层,导致输入向量被严重压缩。
解决方案是混合精度:对所有 attention 层做 INT4 量化,但把 embedding 和 lm_head(语言模型头)保留 FP16。原因在于,embedding 层的权重虽然只占总参数量的一小部分,但它直接影响所有 token 的初始表示,压缩误差会被后续层级放大。这个改动单独就挽回了一半的困惑度损失,代价只是显存增加了约 0.3GB。
另一个启发是,量化后一定要做“层敏感度分析”。具体做法是:逐层把某个量化层的权重换成 FP16,其他层保持量化,看困惑度是否明显下降。如果某一层单独恢复 FP16 就能让困惑度大幅改善,说明该层是敏感层,需要在量化时特别保护。我在 13B 模型上跑过一遍,发现敏感层通常集中在中间位置的少数几层,而非均匀分布。
4.2 显存估算总是失准,OOM 到底是谁的锅
动态批处理上线后的第一次压测,显存直接爆了。按公式算,max_num_batched_tokens=4096时 KV Cache 占用大约 1.5GB,加上权重和激活值,远应该不到 14GB 的上限。但实际运行时,torch.cuda.OutOfMemoryError还是出现了。
排查后发现,罪魁祸首是 PyTorch 的显存缓存分配器。它为了减少分配调用,会预留一部分显存作为缓存碎片,碎片量取决于请求 shape 的变化频率。当请求长度跨度很大时,缓存碎片可能达到几百 MB 甚至 1GB。解决方法是调整PYTORCH_CUDA_ALLOC_CONF环境变量:设置expandable_segments:True,让显存按可扩展段分配,能显著减少碎片。
这个坑值得单独记录一笔:不要完全相信公式计算出来的显存值,一定要留 10% 到 15% 的余量。KV Cache 的显存统计也只是理论值,实际分配可能因为分页对齐、张量形状 padding 等原因多出几倍。
4.3 TPOT 抖动严重,批处理调度被忽视
动态批处理上线后,平均 TPOT 很漂亮,但 P95 和 P99 抖动得厉害。最夸张的一次,P99 是平均值的 3 倍。初步怀疑是某个大请求的 prefill 阻塞了 decode 请求,后来用 profiling 数据确认,确实是 chunked prefill 的切块策略有问题——我把 prefill 切成 512 token 一块,但混入 decode 时没有考虑当前 batch 里 decode 请求的剩余长度,导致某些轮次的计算量超了。
修复的策略叫“水位线控制”:每一轮调度之前先估算本轮新增计算量,如果加上 decode 请求的剩余 token 数超过预算,就推迟 prefill 块到下一轮。调优后的效果是,P99 抖动从 3 倍降到了 1.6 倍,虽然牺牲了一点平均吞吐,但线上体验的重心从来都是“别让部分用户卡太久”。
在这个问题上,我深刻体会到:平均指标是给管理层看的,P99 才是给用户看的。优化过程中不要只盯着平均值,将 P99 纳入每次实验的记录表,能早期发现很多隐蔽的调度问题。
4.4 投机采样在某些场景下反而变慢
投机采样的原理决定了它的收益有很强的前置条件:草稿模型的预测必须和大模型足够一致。如果草稿模型是通用领域的,面对领域专用术语时,它生成的候选 token 可能一个都匹配不上,大模型的验证会变成纯粹多出来的负担,更慢且更耗显存。
我在政务文档处理场景就遇到这个问题。草稿模型是通用聊天模型,对法律文书的用词习惯完全没概念,候选匹配率不到 20%。关掉后,TPOT 反而快了约 30ms。所以项目里给投机采样加了个动态开关:监控最近 50 个请求的候选 token 接受率,低于 40% 就自动关闭,高于 55% 才重新开启。这个自适应的经验法则是从实践中打磨出来的,比任何理论分析都直接。
另一个投机采样提速的技巧是调整草稿模型的采样温度。草稿模型的温度越低,生成的候选越保守,匹配率越高;但太保守又会导致候选多样性下降,大模型的验证收益变小。实测下来,草稿温度设为 0.6 左右时,整体收益最大。
5. 一点个人体会
做了大半年 Model-Optimizer 这个项目,最大的感受是:推理优化更像一门“权衡的艺术”,而不是“堆叠绝招”的活儿。每次量化省下来的显存,最终都应该用于扩大 batch 或延长上下文,而不是眼睁睁看着 GPU 占用率不升反降。
我个人的建议是,无论用什么框架或工具,先把 profiling 基本功打牢。很多问题其实一眼就能看出来,只是大多数人没有在优化之前先做这一步的耐心。配置参数宁可保守一点,也不要一上来就追求极限,否则线上出现性能抖动时,排查代价会成倍增加。最后,每一次优化改动都要留记录,数据和收益都记下来,这些内容就是你下一台机器上线的避坑手册。