Skip to content

推理:从前向传播到推理引擎

本页速览 推理(inference)是模型上线后的每一次前向传播。本文讲透推理的本质、与训练的根本差异、一次推理的延迟构成,以及推理引擎在做什么——图优化、算子融合、kernel 选择。

推理:从前向传播到推理引擎

一句话定义:推理(inference)是模型上线之后、每次收到输入时执行的那一次前向传播(forward pass)——把训练好的权重参数固化为只读资产,对新的输入计算出输出。

行业洞察:绝大多数公司的 GPU 预算中,推理的计算量最终会超过训练。一个在内部训练了 3 个月的推荐模型,上线后每天被调用上亿次;OpenAI 曾披露推理成本约占总基础设施支出的 40% 以上,而随 LLM 爆发,推理在总 GPU 采购中的占比只会更高。这也是为什么"部署工程师"的核心议题不是"把模型跑起来"(那是训练的延伸),而是"把模型跑得又对、又快、又省"——前向传播只做一次,但要做对、做到极致。

一、推理:一次只读的前向传播

1. 训练与推理的本质差异

训练(training)和推理共享同一套前向传播数学,但工程的约束完全不同。理解这张差异表,是读懂后面所有部署优化的前提:

维度训练推理
传播方向前向 + 反向(backward)仅前向
权重状态持续更新(可写)冻结(只读)
Batch 大小大(64~1024),追求吞吐多变:在线以 1 为主,离线可大
延迟敏感度不敏感,分钟级可接受极敏感,P99 要毫秒级
数值精度FP32 / BF16(需保梯度精度)FP16 / INT8 / INT4 皆可
显存需求权重+梯度+优化器状态+激活(约 16~20 倍参数量)权重+激活(约 1.2~2 倍参数量)
核心瓶颈算力(FLOPs)带宽(memory-bound,见下文)
正确性判断损失函数收敛输出与预期一致(有时还要保证可复现)
失败代价重训线上事故

一个常见的显存误区

训练一个 7B 模型需要约 200GB+ 显存(含梯度与优化器状态),而部署它只需约 14GB(FP16 权重)——"训练这么大,部署肯定更吃显存"是错的。推理不用保存梯度,权重冻结后还可以量化压缩,显存压力反而小一个数量级。详情见 GPU 与硬件选型

2. 推理只做一件事:算 y = f(x)

无论模型多复杂,推理的计算图都是把输入逐层变换到输出。以一个 BERT 分类模型为例:

text
输入 token 序列
   │  embedding 查表

12 层 Transformer 编码器(每层含 Self-Attention + FFN)
   │  LayerNorm / residual

[CLS] 向量 → 线性层 → softmax

类别概率分布(推理产物)

关键工程含义有三条:

  1. 权重只读:上线后权重不再变化,因此可以提前做图优化、算子融合、甚至编译成专用二进制(如 TensorRT engine),把运行时开销降到最低。
  2. 输出可缓存:训练时同一输入只出现一次;推理时同一输入(如热门商品的 embedding)会被反复查询——这让结果缓存成为推理服务最重要的优化手段之一。
  3. 离线可预计算:凡是与输入无关的计算(常量折叠、权重重排),都能在部署阶段完成,运行时只执行必要算子。

3. 推理的三种形态

按调用方式,推理可以分为三类,后文 部署架构模式 会展开:

  • 在线推理(online inference):收到请求立即返回,毫秒级,如推荐、翻译、对话。本文第 三 节的延迟构成就是讲它。
  • 离线推理(offline / batch inference):一次性处理大批量数据,小时级,如生成用户画像、日报。
  • 流式推理(streaming inference):事件驱动、逐条处理,如实时风控、欺诈检测。

二、一次在线推理的延迟构成

1. 端到端时间线:延迟藏在"非模型"部分

很多团队优化了大半年模型,压测发现 P99 没降下来——原因常常是瓶颈根本不在模型。一次典型在线推理的延迟构成如下:

text
  客户端                API 网关               推理服务             GPU
   │  ① 请求序列化(JSON)     │                      │                │
   │───────────────────────►│  ② 网络传输(RTT)       │                │
   │                        │──────────────────────►│                │
   │                        │  ③ 反序列化/参数校验    │                │
   │                        │  ④ 预处理(特征拼接/归一化) │               │
   │                        │  ⑤ 排队等待(batch/调度)  │──► ⑥ 前向传播  │
   │                        │  ⑦ 后处理(softmax/argmax)│                │
   │                        │  ⑧ 响应序列化           │                │
   │◄───────────────────────│──────────────────────────│                │
   │  ⑨ 网络回传(RTT)        │                      │                │

以推荐场景一个典型数字为例(单卡 T4,单条请求,batch=1):

阶段典型耗时占比说明
① 序列化/网络(各一次)0.5~2 ms5~10%大 payload 时显著
④ 预处理0.5~3 ms5~15%特征拼接、归一化、embedding 查表
⑤ 排队等待0~100 ms波动最大高并发时是主要延迟来源
⑥ 模型前向2~50 ms40~80%batch=1 时实际被带宽卡住
⑦ 后处理0.1~0.5 ms<5%softmax / 过滤 / 截断

先测后优化的顺序

优化延迟的第一原则是先打点再动手:用 OpenTelemetry 把 ②~⑧ 各阶段都埋上点(详见 监控与可观测性)。经常发现"模型只占 40% 延迟,排队占 40%"——那该优化的是调度而不是模型。

2. 决定前向延迟的两个物理量:算力与带宽

模型前向(⑥)的快慢由两个硬件物理量决定:

  • 算力(FLOPs):每秒能算多少次浮点运算,单位 TFLOPS。
  • 带宽(memory bandwidth):每秒能从显存读出/写入多少数据,单位 GB/s。

对每个算子,存在一个"算术强度"(arithmetic intensity)比值:每字节数据要配多少次运算。比值低于硬件平衡点(Roofline 模型中的 ridge point)就是 memory-bound(带宽受限),高于才是 compute-bound(算力受限)。

推理几乎总是 memory-bound:一次矩阵乘 [1, 4096] × [4096, 4096] 需要读 64MB 权重、只做 34 亿次运算;A100 的 FP16 算力约 312 TFLOPS、带宽约 2TB/s,其平衡点约 156 FLOP/byte,而这笔计算只有 52 FLOP/byte——时间全花在把权重从显存搬进计算单元上。这就是"推理吃带宽、不吃算力"这句话的出处,也是 量化 能带来接近线性提速的根本原因:INT8 权重占一半字节,搬运时间几乎减半。

3. batch=1 的残酷现实

在线推理默认 batch=1(一条请求一次前向),此时没有足够的并行计算量喂饱 GPU,GPU 利用率常只有个位数百分比。这是推理与训练最大的工程落差:训练工程师追求"把 GPU 打满",推理工程师追求的恰恰是"在小 batch 下把延迟压到最低"。解法是动态批处理(dynamic batching)、多请求共享,详见 性能优化与容量规划

三、推理引擎在做什么

1. 为什么 PyTorch 直接跑推理不够快

模型在 PyTorch 里"能跑",但部署时通常要过一道推理引擎(inference engine,如 ONNX Runtime、TensorRT、OpenVINO、TFLite)。原因是 PyTorch 的 eager mode(即时执行模式)为训练做了大量让步:

  • 每个算子独立派发、动态调度,Python 解释开销巨大;
  • 算子边界保留了大量中间张量分配;
  • 不做内存复用,显存申请/释放频繁;
  • 泛化的 kernel 没有针对具体 shape 特化。

推理引擎则把这些"训练时无所谓、推理时昂贵"的开销全部消灭。它做的事可以归纳为四步。

2. 计算图优化(graph optimization)

先拿到整张计算图,再做与输入无关的变换:

text
原始图                         优化后
┌──────────────────┐        ┌──────────────────┐
│ conv ── bn ── relu│  融合  │ conv+bn+relu      │
│ add ─────────────│  ───►  │ (单 kernel)     │
│ mul(常量)        │  折叠   │ 常数 8.0         │
│ softmax(常输入)  │  ───►  │ 预计算 0.5       │
└──────────────────┘        └──────────────────┘

具体手段包括:

  • 算子融合(operator fusion):把 conv + batch_norm + relu 合并为一个 kernel,省去中间张量的写回与再读取。对 memory-bound 场景,融合省下的带宽往往就是提速本身。
  • 常量折叠(constant folding):权重与输入无关的运算在部署阶段预先算掉,例如 BatchNorm 在推理期可以折叠进卷积的权重与偏置(业界标准做法,PyTorch 里 torch.quantization.fuse_modules 就是这个)。
  • 内存规划(memory planning):提前分析各张量的生命周期,复用同一块显存,避免运行时反复 malloc。典型例子是 ONNX Runtime 的内存 arena 与 TensorRT 的 memory pool。
  • 死代码消除与冗余消除:删掉训练专用算子(dropout、gradient 相关节点)。

3. kernel 选择:同一个算子,几十种实现

同样一个矩阵乘,不同 shape、不同布局、不同硬件上最快的实现截然不同。引擎会在部署时做 kernel autotuning:对一组候选 kernel 做 benchmark,选出最快者并缓存。典型差异来源:

  • 输入 shape[1, 4096]×[4096, 4096](推理常见)与 [64, 4096]×[4096, 4096](训练常见)的最优 tile 划分完全不同。
  • 数据布局:NCHW 与 NHWC 在部分硬件上差异可达 30%+;TensorRT 甚至会按需插入布局转换(layout transform)。
  • 张量核心(tensor core)可用性:FP16 走 tensor core 还是 CUDA core,性能差 5~10 倍。
  • tile 大小与并行策略:算子内切分到线程块、warp 的方式。

这就是"为什么引擎能比原框架快 2~10 倍"的答案——不是数学变了,而是同样的数学被换成了更贴近硬件的执行方式。TensorRT 在 fp16 下对 ResNet-50 的延迟可以做到 PyTorch 的 1/3 到 1/5,正是图优化 + kernel 特化 + 编译三者的叠加。

4. 引擎的代价:可移植性换性能

推理引擎的优化越激进,对输入 shape、模型结构、硬件型号的假设就越强。因此:

  • 动态 shape 受限:TensorRT 允许动态 shape,但要在预设范围内(如 batch 1~32),超范围需重新优化;
  • 构建一次、部署一处:为 A100 构建的 engine 不能直接跑在 T4 上;
  • 精度可能漂移:算子重排、低精度 kernel、不同的 accumulation 顺序会让结果与训练框架有微小差异。

关于格式层面的选择,见 模型格式与转换;关于"为什么引擎结果和原框架不一样"的排查,见 常见陷阱与反模式

四、CPU 推理与 GPU 推理

1. 两者的物理本质不同

维度CPUGPU
核心数数十(大核)数千~数万(小核)
缓存大(L2/L3 数十 MB)小(主要靠显存带宽)
擅长串行逻辑、分支、小 batch大规模并行矩阵运算
典型延迟单 token 快,吞吐有限batch 起来后吞吐极高
精度FP32 / INT8(AVX-512、AMX)FP16/INT8/INT4 + tensor core

2. 什么时候该用 CPU

低 QPS + 小模型是 CPU 的舒适区:一个 100MB 的分类模型在 CPU 上延迟 5~10ms,而 GPU 上也只有 2~3ms——为了省那几毫秒花几万块买 GPU 不划算。LLM 时代 CPU 推理在 llama.cpp 的优化下(AVX2/AVX-512、oneDNN)也能跑到可用的速度,例如 7B 量化模型在 8 核服务器上约 5~10 token/s,适合个人与内部工具场景。

3. 一个简单的决策规则

text
模型前向 < 20ms 且 QPS < 50 且模型 < 1GB   → CPU 足够
模型是纯矩阵运算、batch 可合并、延迟敏感   → GPU
边缘设备 / 无 GPU 环境                     → CPU 或 NPU(见 /concepts/hardware)

五、推理精度的来源差异

1. 同一模型,不同引擎,结果为什么不完全一样

这是部署工程师几乎必然遇到的困惑:ONNX Runtime 和 PyTorch 跑同一个模型,输出有 1e-4 量级差异。来源有三:

  1. 算子实现不同:不同框架对同一数学运算(如 layernorm、softmax)采用不同的浮点计算顺序与中间精度(FP32 accumulation vs FP16 accumulation)。
  2. 融合改变中间精度conv+bn 融合后,中间张量不再写回显存,可能全程保持 FP16,省了精度损失反而也可能改变结果。
  3. 低精度 kernel:FP16/INT8 计算本身就有舍入误差,误差会沿网络累积。

2. 数值校验的标准姿势

  • 设定阈值:经验值——FP32 基线对比下,输出最大绝对误差 < 1e-3(CV 任务)、余弦相似度 > 0.999 通常可接受;INT8 量化后要求精度损失 < 1%(相对基线指标)。
  • 用真实数据抽样:不能用训练集拍脑袋造样本,要采线上同分布数据,详见 模型格式与转换 的数值校验一节。
  • 对"业务指标"负责:最终该对比的是 AUC、BLEU、Rouge 这类任务指标,而非逐位一致。

权衡与取舍

决策点选项 A选项 B怎么选
框架直接跑 vs 过引擎快、省事快 2~10 倍、可编译线上必须过引擎,开发环境可直跑
通用引擎 vs 专用引擎ONNX Runtime/OpenVINOTensorRT/TFLite追求硬件极限选专用,追求可移植选通用
高精度 vs 低精度FP16/FP32,省心INT8/INT4,快但需校验先用 FP16 上线,延迟不够再量化(见量化
batch=1 快还是 batch 大快延迟优、GPU 空转吞吐优、延迟劣在线选小 batch + batching,离线选大 batch

一句话总结:推理的工程本质,是把"一次可复现的前向传播"优化成"硬件上最快的那一次"——先保证数学正确,再逐层榨取硬件性能。

延伸阅读

参考资料