1. 为什么一个一线工程师要啃AI-Infra这块硬骨头
干了六年后端,从业务逻辑写到中间件,从单体拆到微服务,我一度觉得自己对“工程”这两个字已经摸得差不多了。直到去年公司开始把推荐系统往自研推理平台上迁,我才发现自己对“工程”的理解有多窄。模型训练那边丢过来一个ONNX文件,说“你们工程侧部署一下”,我打开一看,输入维度是动态的,算子版本对不上,显存占用忽高忽低,QPS稍微上来一点延迟就飙到几百毫秒。那一刻我意识到,AI-Infra不是“运维帮忙把模型跑起来”那么简单,它是一整套从硬件到框架、从编译到调度、从单卡到集群的独立技术栈。
这篇文章是我自己从零补AI-Infra的完整记录,第一章聚焦在推理侧的基础设施搭建与性能调优。为什么从推理切入而不是训练?因为对大多数一线工程师来说,训练任务通常有专门的算法团队和集群资源在管,而推理是真正要落到生产环境、扛住线上流量、跟业务代码耦合在一起的部分。你可能会遇到的情况包括:模型在测试环境跑得好好的,上线后P99延迟直接爆炸;显存明明够,但吞吐量死活上不去;想加一张卡做张量并行,结果发现通信开销比计算还大。这些问题不是调几个参数就能解决的,需要你对底层的数据流、内存管理、算子调度有系统性的理解。
这篇文章适合谁看?如果你是有后端或运维背景、正在被拉去搞模型部署的工程师,或者你是算法出身但发现推理性能怎么都调不上去的开发者,再或者你单纯想搞清楚“AI-Infra到底在干什么”,那这篇内容应该能帮你省下不少翻文档和踩坑的时间。我会从整体设计思路讲起,然后拆解核心细节,接着给出一套可复现的实操流程,最后把我遇到过的典型问题和排查方法整理出来。全程不堆术语,尽量用工程视角把“为什么这么设计”讲清楚。
2. 推理基础设施的整体设计与选型逻辑
2.1 推理系统的核心需求拆解
在动手搭任何东西之前,我习惯先把需求拆到不能再拆。推理基础设施的核心需求其实就四个字:低延迟、高吞吐。但这四个字背后是一堆相互制约的指标。延迟分P50、P95、P99,业务方通常只关心P99,因为那是用户体验的底线。吞吐量要看是单卡吞吐还是端到端吞吐,前者影响成本,后者影响容量规划。还有一个容易被忽略的指标是资源利用率,包括GPU利用率和显存利用率,这两个直接决定你的单位推理成本。
我见过不少团队一上来就追求极致吞吐,把batch size拉到最大,结果P99延迟从50ms涨到800ms,业务方直接投诉。也见过为了压延迟把batch size设成1,GPU利用率常年不到15%,一张A100跑出了T4的效果。所以第一步不是选框架,而是明确你的业务场景到底偏向哪一端。在线推荐、搜索排序这类场景对延迟极度敏感,通常要求P99在100ms以内,这时候吞吐量要让位于延迟。而离线批量推理、数据预处理这类场景,吞吐量优先,延迟可以放宽到秒级甚至分钟级。
注意:不要用平均值来评估延迟。平均值会掩盖长尾问题,而长尾恰恰是用户体验的杀手。我习惯在压测时同时看P50、P95、P99和最大值,如果P99和P50差距超过3倍,说明系统存在明显的排队或资源争抢问题。
2.2 推理框架选型的几个关键考量
市面上推理框架不少,我实际用过的主要是三类:通用深度学习框架自带推理模式、专用推理引擎、编译器路线。通用框架比如PyTorch的TorchScript和TensorFlow的SavedModel,优点是生态好、算子全、调试方便,缺点是性能优化不够极致,尤其是动态shape场景下开销明显。专用推理引擎比如ONNX Runtime、TensorRT,针对推理做了大量图优化和算子融合,性能通常能比通用框架提升30%到200%,但算子覆盖有限,遇到自定义算子就得自己写插件。编译器路线比如TVM、XLA,理论上能针对特定硬件生成最优代码,但编译时间长、调试难度大,适合模型结构稳定且对性能有极致要求的场景。
我的选型逻辑是这样的:先看模型结构是否稳定。如果模型每周都在变,那优先选ONNX Runtime这种兼容性好、转换成本低的方案。如果模型结构固定且对性能要求极高,再考虑TensorRT或TVM。再看硬件平台。NVIDIA GPU上TensorRT的优化效果最明显,但如果你用的是其他加速卡,就得看对应厂商的推理引擎支持情况。最后看团队技术栈。如果团队里没人懂CUDA和算子开发,那强行上TensorRT只会让自己陷入无尽的调试泥潭。
| 框架类型 | 代表方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| 通用框架推理 | TorchScript、SavedModel | 生态好、算子全、调试方便 | 性能优化有限 | 模型频繁迭代、快速验证 |
| 专用推理引擎 | ONNX Runtime、TensorRT | 性能好、图优化强 | 算子覆盖有限、转换成本高 | 模型稳定、性能敏感 |
| 编译器路线 | TVM、XLA | 理论性能最优、硬件适配灵活 | 编译复杂、调试困难 | 模型固定、极致性能需求 |
2.3 硬件资源规划的基本逻辑
硬件规划这块,我踩过的最大坑是只看显存不看带宽。很多人选卡的时候只关心显存够不够放模型,忽略了显存带宽对推理性能的影响。推理过程本质上是大量的矩阵运算和内存访问,显存带宽决定了数据搬运的速度。举个例子,同样是一个7B参数的模型,FP16精度下权重大约14GB,如果显存带宽是900GB/s,理论上加载一遍权重需要15ms左右,但如果带宽只有300GB/s,加载时间就变成46ms,这还没算计算时间。所以选卡的时候,显存带宽和显存容量同样重要。
另一个容易忽略的点是CPU和GPU的配比。推理服务的前处理和后处理通常在CPU上做,如果CPU核心数不够,GPU再快也会被拖累。我的经验是,对于计算密集型的视觉模型,CPU和GPU的配比至少是4:1,也就是一张GPU配4个物理核心。对于NLP类模型,前处理相对轻量,2:1到3:1通常够用。内存方面,建议至少是显存的2倍,因为除了模型权重,还有中间激活值、KV Cache、请求队列等开销。
提示:如果你用的是云上的弹性实例,建议先按保守配置跑一轮压测,观察GPU利用率和显存占用曲线,再决定是否升配。我见过太多一上来就买顶配卡、结果利用率不到20%的案例。
3. 核心细节解析与实操要点
3.1 模型转换与图优化的关键步骤
模型从训练框架转到推理引擎,中间最关键的一步是图转换。以PyTorch到ONNX再到TensorRT这条链路为例,第一步是导出ONNX。导出的时候有几个参数必须注意:opset_version建议选11或以上,因为低版本对动态shape的支持不好;dynamic_axes要正确设置,把batch维度和序列维度标成动态,否则推理时只能跑固定shape;do_constant_folding建议开启,能在导出阶段就折叠掉一些常量计算。
导出之后别急着转TensorRT,先用ONNX Runtime跑一遍,确认输出和PyTorch一致。我遇到过好几次导出后精度对不上的情况,排查下来通常是某个算子在不同opset下的行为差异,或者动态shape的推导逻辑有问题。确认无误后再用TensorRT的ONNX parser转成engine。转engine的时候,精度模式的选择很关键。FP32最稳但最慢,FP16通常能提速30%到50%且精度损失可接受,INT8需要校准集且精度损失因模型而异。我的建议是先用FP16跑一轮,如果精度不达标再考虑FP32,INT8除非你对精度损失有明确容忍度,否则不要轻易上。
# PyTorch导出ONNX的典型代码 torch.onnx.export( model, dummy_input, "model.onnx", opset_version=13, input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "logits": {0: "batch", 1: "sequence"} }, do_constant_folding=True )3.2 动态Shape与Batch调度的处理策略
动态shape是推理部署里最让人头疼的问题之一。训练时通常用固定shape,推理时请求的序列长度千变万化,如果每个请求都单独跑一次,GPU利用率会低得可怜。常见的处理策略有三种:Padding到固定长度、分桶、连续批处理。Padding最简单,把所有请求pad到当前batch的最大长度,缺点是短请求被长请求拖累,浪费计算资源。分桶是把请求按长度分成几个区间,每个区间内pad到该区间的最大长度,比纯Padding好一些,但桶的划分需要根据实际请求分布来调。连续批处理是更高级的方案,它不要求整个batch同时开始和结束,而是动态地把新请求插入到正在执行的batch中,典型代表是vLLM的PagedAttention机制。
我实际用下来,分桶+动态batch是性价比最高的方案。具体做法是:先统计线上请求的长度分布,按P50、P90、P99分成三到四个桶,每个桶设置不同的最大batch size。短请求桶可以设大batch,长请求桶设小batch。调度器根据请求长度把它分配到对应的桶里,等桶内请求数达到阈值或者等待时间超过上限就触发一次推理。这样既保证了GPU利用率,又不会让短请求等太久。
注意:分桶的边界不要设得太死。我一开始按固定长度分桶,结果发现请求长度集中在某个边界附近时,两个桶的利用率都很低。后来改成按百分位数动态调整边界,效果好很多。
3.3 显存管理与KV Cache优化
对于Transformer类模型,KV Cache是显存占用的主要来源之一。以7B模型为例,FP16精度下,每个token的KV Cache大约占用0.5MB,如果序列长度是2048,batch size是16,那KV Cache就要占用16GB左右,比模型权重还大。所以KV Cache的管理直接决定了你能支持多大的并发。
常见的优化手段有几种。第一是量化KV Cache,把FP16降到INT8,显存占用直接减半,精度损失通常在可接受范围内。第二是分页管理,像操作系统管理内存那样把KV Cache分成固定大小的块,按需分配,避免预分配带来的浪费。第三是共享前缀,如果多个请求有相同的系统提示词,那这部分KV Cache可以共享,不用每个请求都存一份。第四是及时释放,请求结束后立刻回收KV Cache,不要等GC。
我实测下来,分页管理+共享前缀的组合效果最明显。在一个客服对话场景里,系统提示词占了序列长度的30%左右,共享前缀直接把KV Cache占用降低了25%。再加上分页管理,整体显存占用比朴素方案少了40%,同样的卡能多扛将近一倍的并发。
3.4 算子融合与内存复用
推理引擎的图优化里,算子融合是提升性能最直接的手段之一。所谓算子融合,就是把多个连续的小算子合并成一个大的算子,减少kernel launch次数和中间结果的显存读写。比如常见的Conv+BN+ReLU融合,原本需要三次kernel launch和两次中间结果写回,融合后只需要一次。在Transformer模型里,LayerNorm、Attention、残差连接这些都可以做融合。
内存复用是另一个关键优化。推理过程中会产生大量中间张量,如果每个都单独分配显存,碎片化会很严重。好的推理引擎会做内存池化,预先分配一大块显存,然后按需切分给不同的中间张量,用完就还回池子。这样不仅减少了分配开销,还能提高显存利用率。我在调试一个视觉模型时发现,开启内存池后显存峰值从18GB降到了13GB,效果非常明显。
提示:算子融合和内存复用通常是推理引擎自动做的,但你可以通过调整优化级别来控制。TensorRT的
builder_config里有max_workspace_size参数,设得太小会导致某些融合策略无法启用,设得太大又浪费显存。我的经验是设为显存总量的10%到20%比较合适。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
我用的基础环境是Ubuntu 20.04 + CUDA 11.8 + cuDNN 8.6 + TensorRT 8.5。CUDA和cuDNN的版本必须和TensorRT匹配,否则会出现各种奇怪的链接错误。安装TensorRT的时候,建议用tar包而不是deb包,因为tar包可以自定义安装路径,方便多版本共存。Python环境用conda管理,创建一个独立的env,避免和系统Python冲突。
# 创建conda环境 conda create -n ai-infra python=3.10 conda activate ai-infra # 安装PyTorch(用于模型导出) pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 # 安装ONNX和ONNX Runtime pip install onnx==1.14.0 onnxruntime-gpu==1.15.1 # 安装TensorRT(假设tar包已解压到/opt/TensorRT-8.5.3.1) export LD_LIBRARY_PATH=/opt/TensorRT-8.5.3.1/lib:$LD_LIBRARY_PATH pip install /opt/TensorRT-8.5.3.1/python/tensorrt-8.5.3.1-cp310-none-linux_x86_64.whl安装完成后,用trtexec --version验证TensorRT是否可用。如果报错找不到库,检查LD_LIBRARY_PATH是否包含TensorRT的lib目录。这一步我卡了将近两个小时,后来发现是conda环境里的cudatoolkit和系统CUDA版本冲突,把conda里的cudatoolkit卸掉就好了。
4.2 模型导出与ONNX验证
以一个小型Transformer模型为例,先导出ONNX。导出前要确保模型处于eval模式,并且用torch.no_grad()包住推理过程。导出后,用ONNX Runtime加载并跑一遍,和PyTorch的输出做对比。对比的时候不要只看最终输出,中间层的输出也建议抽查几个,因为有些误差会在多层累积后才显现。
import onnxruntime as ort import numpy as np # 加载ONNX模型 sess = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider"]) # 构造输入 input_ids = np.random.randint(0, 30000, size=(1, 128)).astype(np.int64) attention_mask = np.ones((1, 128), dtype=np.int64) # 推理 outputs = sess.run(None, {"input_ids": input_ids, "attention_mask": attention_mask}) # 和PyTorch输出对比 torch_output = model(torch.from_numpy(input_ids), torch.from_numpy(attention_mask)) diff = np.abs(outputs[0] - torch_output.logits.detach().numpy()).max() print(f"Max diff: {diff}")如果diff在1e-4以内,说明导出基本没问题。如果diff很大,先检查opset版本和动态shape设置,再检查是否有自定义算子没有正确导出。
4.3 TensorRT Engine构建与精度校准
ONNX验证通过后,用TensorRT的Python API构建engine。构建过程中可以设置精度模式、最大batch size、最大workspace size等参数。如果要用INT8,还需要提供一个校准集,校准集的样本要尽量覆盖线上请求的分布,否则量化误差会很大。
import tensorrt as trt 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("model.onnx", "rb") as f: parser.parse(f.read()) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30) # 4GB config.set_flag(trt.BuilderFlag.FP16) # 如果要INT8 # config.set_flag(trt.BuilderFlag.INT8) # config.int8_calibrator = MyCalibrator(calibration_data) engine = builder.build_engine(network, config) with open("model.engine", "wb") as f: f.write(engine.serialize())构建engine的时间可能比较长,尤其是大模型,我遇到过构建一个7B模型的engine花了将近40分钟。构建完成后,用trtexec跑一下benchmark,看看延迟和吞吐量是否符合预期。
4.4 服务化部署与压测
Engine构建好后,用Triton Inference Server或者自己写一个gRPC服务来承载。Triton的好处是支持多模型、多版本、动态batch,而且有现成的监控指标。自己写服务的话,灵活性更高,但需要自己处理请求队列、batch调度、健康检查这些。
我选的是Triton,因为它的动态batch功能开箱即用。配置的时候,max_batch_size要根据显存和延迟要求来定,dynamic_batching的preferred_batch_size可以设几个档位,Triton会自动选择最合适的。压测用wrk或者locust,重点看P99延迟和GPU利用率。如果P99延迟超标,先看是不是batch size太大导致排队,再检查是不是有请求触发了重新编译或者显存不足。
# Triton模型配置示例 name: "transformer" platform: "tensorrt_plan" max_batch_size: 32 input [ { name: "input_ids" data_type: TYPE_INT64 dims: [-1] }, { name: "attention_mask" data_type: TYPE_INT64 dims: [-1] } ] output [ { name: "logits" data_type: TYPE_FP16 dims: [-1, -1] } ] dynamic_batching { preferred_batch_size: [8, 16, 32] max_queue_delay_microseconds: 5000 }压测的时候,我习惯先用固定并发跑一轮,找到延迟拐点,再用阶梯式加压看系统在过载情况下的表现。过载测试很重要,因为线上流量不可能永远平稳,你得知道系统在什么压力下会崩,崩的时候是优雅降级还是直接雪崩。
5. 常见问题与排查技巧实录
5.1 精度对不上问题的排查思路
精度问题是推理部署里最常遇到的,表现是推理结果和训练框架不一致。排查的时候按这个顺序来:先确认输入是否一致,包括数据类型、shape、数值范围。我遇到过因为输入没有做归一化导致输出完全错误的情况。再确认算子版本,不同opset版本下同一个算子的行为可能有差异,尤其是涉及动态shape和广播的时候。然后检查精度模式,FP16和INT8都会引入误差,如果误差在可接受范围内就没事,如果超出预期就要考虑回退到FP32。最后检查图优化,有些融合策略在特定模型结构下会产生数值不稳定,可以尝试关闭某些优化选项。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 输出完全错误 | 输入未归一化、shape错误 | 对比输入数据 | 修正前处理逻辑 |
| 误差在1e-3左右 | FP16精度损失 | 切换FP32对比 | 接受或回退FP32 |
| 误差在1e-2以上 | INT8量化误差过大 | 检查校准集分布 | 重新校准或回退FP16 |
| 特定输入报错 | 动态shape推导失败 | 检查dynamic_axes设置 | 修正导出参数 |
5.2 显存不足与OOM的应急处理
显存OOM是另一个高频问题。应急处理的手段有几个:降低batch size最直接,但会影响吞吐量。开启显存池化,减少碎片化。量化KV Cache,把FP16降到INT8。限制最大序列长度,超过长度的请求直接拒绝或者截断。启用分页管理,按需分配KV Cache。如果这些都不行,那就只能换卡或者加卡了。
我遇到过一次比较诡异的OOM,显存监控显示还有余量,但就是分配失败。排查下来是显存碎片化太严重,虽然总量够,但没有连续的大块内存。后来开启内存池化就解决了。所以显存监控不能只看总量,还要看碎片率。
提示:建议在服务里加一个显存水位告警,当显存占用超过80%时触发告警,超过90%时自动降低batch size或者拒绝新请求。这样能避免OOM导致的进程崩溃。
5.3 延迟毛刺与长尾问题的定位
延迟毛刺是指P99延迟远高于P50,通常由几个原因导致:请求长度分布不均,长请求拖累了短请求。batch调度不合理,某些batch里混入了超长请求。显存回收不及时,导致后续请求等待。CPU前处理成为瓶颈,GPU在等CPU。网络抖动,请求传输时间不稳定。
定位的时候,我习惯在请求链路的关键节点打时间戳,包括请求到达、前处理完成、推理开始、推理结束、后处理完成、响应发出。这样能快速定位到延迟发生在哪个阶段。如果是前处理慢,就优化CPU代码或者增加CPU核心。如果是推理慢,就看是不是batch里有长请求。如果是后处理慢,通常是解码或者格式化的问题。
5.4 模型更新与版本管理的实操经验
模型更新是另一个容易出问题的环节。我的经验是永远不要直接替换线上模型,而是用灰度发布的方式。Triton支持多版本模型共存,可以先把新版本加载进来,用少量流量验证,确认无误后再逐步切量。切换的时候要注意显存是否够同时加载两个版本,如果不够,就得先卸载旧版本再加载新版本,但这会导致短暂的服务不可用。
另一个坑是模型文件的版本号管理。我见过因为版本号写错导致加载了旧模型的情况。建议用模型文件的哈希值作为版本号,这样能确保每次加载的都是正确的文件。同时,模型文件要放在共享存储上,避免多实例部署时文件不一致。
6. 一些踩坑后的个人体会
AI-Infra这个方向,入门容易精通难。刚开始你可能觉得不就是把模型跑起来吗,但真正到了生产环境,你会发现每一个环节都有无数细节可以优化。我最大的体会是不要过早追求极致性能,先把链路跑通,确保功能正确,然后再逐步优化。很多性能问题在功能不完善的时候是看不出来的,只有链路完整了,瓶颈才会暴露。
另一个体会是监控比优化更重要。没有完善的监控,你根本不知道系统在什么状态,优化也就无从下手。我建议至少监控这几个指标:GPU利用率、显存占用、P50/P95/P99延迟、QPS、错误率、请求队列长度。这些指标能覆盖大部分常见问题。
最后,保持对底层原理的好奇心。推理引擎帮你做了很多优化,但如果你不理解它为什么这么做,遇到问题时就会束手无策。比如算子融合,你知道它减少了kernel launch,但如果你不知道kernel launch的开销有多大,就不知道这个优化值不值得。所以有空的时候多看看CUDA编程、计算机体系结构这些底层知识,长期来看回报很大。
这个系列我计划写三章,第一章聚焦推理侧的基础设施,第二章会讲训练侧的分布式策略和通信优化,第三章会涉及多机多卡的调度和容错。如果你也在补AI-Infra这块,希望这些内容能帮你少走点弯路。