1. 从比赛评分规则倒推优化方向
打生成式AI模型优化赛,最容易犯的错误就是一上来就埋头调参、换算子、试量化,结果折腾两周发现分数没涨多少。我这次拿到第三名,回头看最大的经验其实是:先把评分规则吃透,再决定技术路线。
1.1 这类比赛到底在比什么
生成式AI模型优化赛的评分通常由几块构成:生成质量(比如FID、CLIP Score、人工评估)、推理性能(延迟、吞吐)、资源占用(显存、模型体积),以及方案的可复现性和工程完整度。不同赛道的权重差异很大——有的偏重"在限定硬件上跑得更快",有的偏重"在保持质量的前提下把模型压得更小"。
我拿到的赛题核心约束是:在单卡T4环境下,对给定的生成式模型做推理优化,要求在质量下降不超过阈值的条件下,尽可能提升吞吐并降低单次推理延迟。这个约束一摆出来,方向就清楚了——T4是关键变量。
T4这张卡大家应该都熟:Turing架构,16GB显存,FP16算力大约65 TFLOPS(Tensor Core),INT8大约130 TOPS。它没有FP8支持,也没有Hopper那套Transformer Engine。所以任何依赖FP8或者新架构特性的优化手段,在这张卡上直接出局。这一点如果一开始没意识到,后面会白走很多弯路。
1.2 为什么我把重心放在推理链路而非训练
比赛时间有限,训练侧的优化(比如蒸馏、剪枝后微调)周期长、不确定性高,而且生成式模型的微调很容易把质量搞崩,恢复成本很高。相比之下,推理链路的优化是"确定性收益"——只要你把ONNX导出、TensorRT引擎构建、精度校准这几步做扎实,性能提升是看得见摸得着的。
我的判断逻辑是这样的:
| 优化方向 | 预期收益 | 风险 | 时间成本 |
|---|---|---|---|
| 训练侧蒸馏 | 高 | 质量崩坏风险大 | 高 |
| 结构剪枝 | 中 | 需重训恢复 | 中高 |
| 推理引擎优化 | 中高 | 低 | 中 |
| 精度量化 | 高 | 需校准 | 中 |
| 调度与批处理 | 中 | 低 | 低 |
最后我选的是"推理引擎优化 + 精度量化 + 调度策略"三件套,训练侧只做了极轻量的处理。这个组合在T4上性价比最高。
1.3 一个容易被忽略的前提:先把基线跑稳
很多人一上来就想着优化,但连一个稳定的基线都没建立。我的做法是:先用PyTorch原生推理跑一遍,记录延迟、显存、生成质量的基线数据,并且固定随机种子、固定输入,保证每次对比都是可复现的。
这一步看起来笨,但它决定了你后面所有优化到底有没有效果。我见过太多人换了量化之后觉得"好像快了点",但因为没有基线,根本说不清快了多少、质量掉了多少。
提示:基线测试一定要跑至少3次取平均,T4在共享环境下会有明显的性能波动,单次测量不可信。
2. PyTorch到ONNX再到TensorRT的转换链路
这条链路是整篇方案的主干。说起来就三步:导出ONNX、构建TensorRT引擎、跑推理。但每一步都有坑,而且坑的位置往往和你想象的不一样。
2.1 ONNX导出:动态轴是第一个坎
生成式模型和普通分类模型最大的区别在于输入往往是动态的——序列长度可变、batch可变、甚至有些模型有条件的输入形状。ONNX导出时如果轴设成静态的,后面TensorRT构建引擎时就没法支持动态shape,吞吐直接锁死。
导出时的关键参数:
torch.onnx.export( model, dummy_input, "model.onnx", opset_version=17, input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch", 1: "seq_len"}, "attention_mask": {0: "batch", 1: "seq_len"}, "logits": {0: "batch", 1: "seq_len"} }, do_constant_folding=True )opset版本我选的是17,原因是它对Transformer类算子的支持比较完整,尤其是LayerNormalization和GELU这些。opset太低会导致某些算子被拆成一堆小算子,TensorRT融合起来效果差;opset太高又可能遇到TensorRT版本不支持的情况。17是个比较稳的平衡点。
导出后一定要用onnxruntime验证一遍数值一致性,别直接拿去转TensorRT。我踩过一次坑:导出的ONNX在CPU上跑出来结果和PyTorch差了0.3,查了半天发现是某个自定义算子导出时精度处理有问题。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"]) onnx_out = sess.run(None, {"input_ids": input_ids, "attention_mask": attention_mask}) # 和PyTorch输出对比 diff = np.abs(onnx_out[0] - torch_out.detach().numpy()).max() print(f"Max diff: {diff}")一般来说max diff在1e-4以内是可以接受的,超过1e-3就要查原因了。
2.2 TensorRT引擎构建:精度和shape的取舍
拿到ONNX之后,用trtexec或者Python API构建引擎。这里有几个关键决策:
精度模式:T4支持FP32、FP16、INT8。FP16基本是白送的,速度翻倍质量几乎无损,必开。INT8需要校准,收益更大但有质量风险。
动态shape范围:要设置opt shape、min shape、max shape。opt shape是你实际推理时最常出现的形状,TensorRT会针对它做最优kernel选择。如果opt设错了,性能会明显打折。
trtexec --onnx=model.onnx \ --saveEngine=model_fp16.engine \ --fp16 \ --minShapes=input_ids:1x1,attention_mask:1x1 \ --optShapes=input_ids:8x128,attention_mask:8x128 \ --maxShapes=input_ids:32x512,attention_mask:32x512 \ --workspace=4096workspace给4GB,T4总共16GB显存,留足余量给激活值。如果workspace给太小,TensorRT会放弃一些需要大临时空间的融合策略,性能会掉。
构建时间:T4上构建一个中等规模生成模型的引擎,FP16大概要5-15分钟,INT8校准还要更久。比赛时如果反复试错,时间很容易不够用。我的做法是先用小shape快速构建验证流程,流程跑通后再用完整shape构建最终引擎。
2.3 引擎缓存与序列化
TensorRT引擎构建一次很贵,所以一定要序列化保存。但要注意:引擎和GPU型号、TensorRT版本、CUDA版本强绑定。你在T4上构建的引擎,换到别的卡上直接不能用。比赛环境如果固定是T4,那没问题;如果环境会变,就要准备好重新构建的脚本。
import tensorrt as trt def build_engine(onnx_path, engine_path, fp16=True): logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser = trt.OnnxParser(network, logger) with open(onnx_path, "rb") as f: parser.parse(f.read()) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30) if fp16: config.set_flag(trt.BuilderFlag.FP16) profile = builder.create_optimization_profile() profile.set_shape("input_ids", (1,1), (8,128), (32,512)) profile.set_shape("attention_mask", (1,1), (8,128), (32,512)) config.add_optimization_profile(profile) engine = builder.build_serialized_network(network, config) with open(engine_path, "wb") as f: f.write(engine) return engine这段代码是我实际用的,注意EXPLICIT_BATCH这个flag,新版TensorRT里必须显式声明,否则动态shape会有问题。
3. INT8量化的校准策略与质量守住底线
FP16是保底,INT8才是拉开差距的地方。但INT8量化对生成式模型来说风险不小,尤其是注意力机制里的softmax和LayerNorm,量化误差会被放大。
3.1 为什么生成式模型比分类模型更难量化
分类模型最后输出的是类别概率,量化误差只要不改变argmax就行。但生成式模型是逐token生成的,每一步的误差都会累积,而且输出分布的形状(比如温度采样时的概率分布)对量化非常敏感。
我的实测数据:同一个模型,分类任务INT8量化后精度掉0.5%,生成任务INT8量化后BLEU掉了3个点。差距很明显。
所以校准集的选择就特别关键。不能用随便几条数据糊弄,校准集必须覆盖实际推理时的输入分布。
3.2 校准集怎么选
我用了大概500条校准样本,来源是训练集的子集,但做了分层采样——保证不同长度、不同类别的样本都有覆盖。校准集太小(比如100条)会导致量化参数估计不准,太大(几千条)则校准时间过长且收益递减。
class Calibrator(trt.IInt8EntropyCalibrator2): def __init__(self, data, cache_file): super().__init__() self.data = data self.cache_file = cache_file self.index = 0 self.batch_size = 8 self.device_input = cuda.mem_alloc(self.batch_size * 128 * 4) def get_batch_size(self): return self.batch_size def get_batch(self, names): if self.index >= len(self.data): return None batch = self.data[self.index:self.index+self.batch_size] cuda.memcpy_htod(self.device_input, batch) self.index += self.batch_size return [int(self.device_input)] def read_calibration_cache(self): if os.path.exists(self.cache_file): with open(self.cache_file, "rb") as f: return f.read() def write_calibration_cache(self, cache): with open(self.cache_file, "wb") as f: f.write(cache)校准算法我选的是EntropyCalibrator2,它对生成式模型的激活分布拟合比MinMax更好。MinMax容易被离群值带偏,Entropy方法更鲁棒。
3.3 分层量化:不是所有层都该INT8
这是我从比赛里学到的最重要的一课。全模型一刀切INT8,质量必崩。正确做法是分层处理:
- 注意力输出投影层、FFN的中间层:可以INT8
- LayerNorm、Softmax、最终的输出投影:保持FP16
- Embedding层:看情况,通常保持FP16
TensorRT支持通过set_layer_precision逐层设置精度。虽然API用起来有点繁琐,但收益很明显。我做了分层量化之后,质量损失从3个点压到了0.8个点,而速度只比全INT8慢了约8%。
# 构建时逐层设置精度 for i in range(network.num_layers): layer = network.get_layer(i) if layer.type == trt.LayerType.SOFTMAX or "layernorm" in layer.name.lower(): layer.precision = trt.float16 layer.set_output_type(0, trt.float16) else: layer.precision = trt.int8注意:逐层设置精度后,TensorRT可能会在层之间插入reformat操作,反而拖慢速度。所以设置完之后一定要用trtexec的
--dumpProfile看每层耗时,确认没有引入额外的转换开销。
3.4 量化后的质量验证
量化完不能只看速度,必须做质量回归。我的验证流程是:
- 用固定的一组prompt跑生成,对比量化前后的输出
- 计算BLEU/ROUGE或者用CLIP Score这类指标
- 人工抽查20-30条,看有没有明显的语义崩坏
这一步千万别省。我有一次量化后指标看着还行,但人工一看发现模型开始重复输出同一个token,这种问题指标是抓不出来的。
4. 批处理与调度:把T4的吞吐榨干
单条推理优化到极致,吞吐也就那样。真正拉开差距的是批处理和调度策略。
4.1 动态批处理的实现
生成式模型的推理有个特点:不同请求的生成长度不一样,如果按最长序列padding,短请求会浪费大量算力。解决办法是用动态批处理——把长度相近的请求凑成一批。
我实现了一个简单的长度分桶调度器:
class BucketScheduler: def __init__(self, buckets=[32, 64, 128, 256, 512], max_batch=32): self.buckets = buckets self.max_batch = max_batch self.queues = {b: [] for b in buckets} def add(self, request): length = len(request.input_ids) for b in self.buckets: if length <= b: self.queues[b].append(request) break def get_batch(self): for b in self.buckets: if len(self.queues[b]) >= self.max_batch: batch = self.queues[b][:self.max_batch] self.queues[b] = self.queues[b][self.max_batch:] return batch, b return None, None这个调度器把请求按长度分到不同的桶里,每个桶内的请求padding到桶的边界长度。实测下来,相比统一padding到512,吞吐提升了大约2.3倍。
4.2 batch size怎么定
batch size不是越大越好。T4显存有限,batch太大要么OOM,要么触发显存交换导致延迟飙升。我的做法是做一个batch size扫描:
| Batch Size | 延迟(ms) | 吞吐(tokens/s) | 显存占用(GB) |
|---|---|---|---|
| 1 | 45 | 22 | 2.1 |
| 4 | 68 | 59 | 3.8 |
| 8 | 102 | 78 | 6.2 |
| 16 | 178 | 90 | 10.5 |
| 32 | 356 | 90 | 15.8 |
可以看到batch到16之后吞吐就饱和了,再往上只是增加延迟。所以最优batch size在8-16之间。最终我选了12,兼顾吞吐和延迟。
4.3 流式输出与首token延迟
生成式AI应用对首token延迟(TTFT)很敏感。如果用户等3秒才看到第一个字,体验就很差。优化TTFT的关键是:
- 减少prefill阶段的计算量(可以用chunked prefill)
- 尽早开始解码,不要等整个batch的prefill都完成
我在实现里用了简单的流水线:prefill和decode分到不同的CUDA stream,prefill完成的序列立即进入decode。这样TTFT从原来的800ms降到了320ms左右。
5. 那些让我掉分的坑和最后的调优细节
前面讲的都是"做对了什么",这一节讲讲"做错了什么"。这些坑才是真正值钱的经验。
5.1 显存碎片导致的间歇性OOM
比赛跑到后期,我遇到一个诡异的问题:同样的batch size,跑几十次之后突然OOM。查了半天发现是显存碎片——TensorRT的workspace和PyTorch的缓存分配器抢显存,长时间运行后碎片化严重。
解决办法是统一显存管理:要么全用TensorRT的显存池,要么在推理前调用torch.cuda.empty_cache()并设置PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True。我最后是把预处理和后处理都挪到CPU上,GPU只跑TensorRT引擎,问题就消失了。
5.2 动态shape的profile设置不当
前面提过opt shape的重要性,但我实际踩的坑更细:opt shape的batch维度设成了1,结果TensorRT针对batch=1优化了kernel,实际跑batch=8时性能反而比不设opt还差。
正确的做法是opt shape要贴近实际推理时最常出现的形状。我最后把opt设成8x128,因为大部分请求的长度在100-150之间,batch在8左右。
5.3 校准缓存失效
INT8校准很慢,所以我把校准缓存存下来了。但有一次改了校准集之后忘了删缓存,结果用的还是旧的量化参数,质量一直上不去。查了两天才发现。
提示:校准缓存的命名一定要带上校准集版本和模型版本,比如
calib_v2_model_v3.cache,避免混淆。
5.4 最后的调优清单
比赛最后两天我做的调优,按收益排序:
- 分层量化:质量损失从3个点降到0.8个点,速度只损失8%
- 长度分桶调度:吞吐提升2.3倍
- opt shape调优:延迟降低约15%
- CUDA stream流水线:TTFT降低60%
- workspace调大:构建时融合更多算子,推理速度提升约5%
这些加起来,最终成绩比基线快了大约4.7倍,质量损失控制在1个点以内。第三名。
5.5 如果重来一次我会怎么做
如果还有机会,我会在比赛一开始就做两件事:一是建立完整的自动化评测流水线,每次改动自动跑质量+性能对比,避免手工测试的低效和误差;二是更早地做分层量化的实验,而不是等到最后一周才发现全INT8不行。
另外,T4这张卡虽然老,但它的INT8吞吐确实可观。如果赛题允许,我会尝试把部分计算卸载到CPU(比如embedding查表),进一步给GPU腾显存。这个思路在T4这种显存受限的卡上可能比单纯压模型更有效。
最后分享一个小心得:TensorRT的trtexec --dumpProfile和--dumpLayerInfo是排查性能问题的利器,能看到每一层的耗时和精度。很多人只知道用trtexec构建引擎,却不知道它的profile功能,白白浪费了一个强大的调优工具。