资讯动态

生成式AI模型优化赛T4推理优化实战:TensorRT与INT8量化

发布时间:2026/10/2 4:51:00 来源:尧图企业网站定制
1. 从比赛评分规则倒推优化方向打生成式AI模型优化赛最容易犯的错误就是一上来就埋头调参、换算子、试量化结果折腾两周发现分数没涨多少。我这次拿到第三名回头看最大的经验其实是先把评分规则吃透再决定技术路线。1.1 这类比赛到底在比什么生成式AI模型优化赛的评分通常由几块构成生成质量比如FID、CLIP Score、人工评估、推理性能延迟、吞吐、资源占用显存、模型体积以及方案的可复现性和工程完整度。不同赛道的权重差异很大——有的偏重在限定硬件上跑得更快有的偏重在保持质量的前提下把模型压得更小。我拿到的赛题核心约束是在单卡T4环境下对给定的生成式模型做推理优化要求在质量下降不超过阈值的条件下尽可能提升吞吐并降低单次推理延迟。这个约束一摆出来方向就清楚了——T4是关键变量。T4这张卡大家应该都熟Turing架构16GB显存FP16算力大约65 TFLOPSTensor CoreINT8大约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_version17, 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_foldingTrue )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(fMax 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 --onnxmodel.onnx \ --saveEnginemodel_fp16.engine \ --fp16 \ --minShapesinput_ids:1x1,attention_mask:1x1 \ --optShapesinput_ids:8x128,attention_mask:8x128 \ --maxShapesinput_ids:32x512,attention_mask:32x512 \ --workspace4096workspace给4GBT4总共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, fp16True): 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.indexself.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的中间层可以INT8LayerNorm、Softmax、最终的输出投影保持FP16Embedding层看情况通常保持FP16TensorRT支持通过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_batch32): 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)145222.1468593.88102786.2161789010.5323569015.8可以看到batch到16之后吞吐就饱和了再往上只是增加延迟。所以最优batch size在8-16之间。最终我选了12兼顾吞吐和延迟。4.3 流式输出与首token延迟生成式AI应用对首token延迟TTFT很敏感。如果用户等3秒才看到第一个字体验就很差。优化TTFT的关键是减少prefill阶段的计算量可以用chunked prefill尽早开始解码不要等整个batch的prefill都完成我在实现里用了简单的流水线prefill和decode分到不同的CUDA streamprefill完成的序列立即进入decode。这样TTFT从原来的800ms降到了320ms左右。5. 那些让我掉分的坑和最后的调优细节前面讲的都是做对了什么这一节讲讲做错了什么。这些坑才是真正值钱的经验。5.1 显存碎片导致的间歇性OOM比赛跑到后期我遇到一个诡异的问题同样的batch size跑几十次之后突然OOM。查了半天发现是显存碎片——TensorRT的workspace和PyTorch的缓存分配器抢显存长时间运行后碎片化严重。解决办法是统一显存管理要么全用TensorRT的显存池要么在推理前调用torch.cuda.empty_cache()并设置PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True。我最后是把预处理和后处理都挪到CPU上GPU只跑TensorRT引擎问题就消失了。5.2 动态shape的profile设置不当前面提过opt shape的重要性但我实际踩的坑更细opt shape的batch维度设成了1结果TensorRT针对batch1优化了kernel实际跑batch8时性能反而比不设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功能白白浪费了一个强大的调优工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑