做推理引擎这几年,我最大的感受是:很多人手上的模型跑得起来,但跑不快、跑不稳,出了问题也不知道该从哪一层开始查。根本原因是把“AI 模型”和“推理引擎”当成两个隔离的黑盒——模型文件一丢,引擎一跑,结果不对就怪模型,性能不行就怪引擎,两边互相甩锅。
这篇内容想把这层纸捅破。我会从推理引擎的视角,把《AI 模型导论与推理基础原理》这个方向的核心拆开讲清楚:模型在推理引擎眼里到底是什么、一次前向传播在底层如何流动、引擎做了哪些你看不见的优化、以及性能指标和问题排查怎么落地。适合刚转入推理加速方向的同学,也适合已经在做部署、但对底层原理还觉得含糊的工程师。
1. 推理引擎到底在解决什么问题
1.1 从一次模型上线说起
先聊个场景。你在训练框架里训好了一个模型,导出成权重文件,丢给推理引擎去加载。接下来发生的事情,远比“读参数、跑算子”这几个字复杂。
推理引擎的职责本质上是一句话:把训练好的模型,在尽量少损失精度的前提下,以最低的延迟和成本跑在目标硬件上。这个目标拆开来看,至少涉及三个层面。
第一是模型的表示层。训练框架里的模型是一堆层(Layer)和操作(Operator)的集合,引擎要把它转录成自己内部的计算图,再交给后端调度执行。这个转录过程很讲究:有些层可以合并,有些算子可以重新排序,有些常量可以在加载阶段就提前算掉,这些都属于引擎的“看家本领”。
第二是硬件的适配层。同一个模型,跑到GPU、CPU、专用的AI加速芯片上,路径完全不同。GPU擅长大规模的并行矩阵运算,CPU更吃向量化和缓存命中率,端侧芯片则要抠功耗和内存带宽。引擎要针对不同的硬件做指令调度和显存布局,这是普通开发者很少接触、但实际影响巨大的环节。
第三是运行时的服务层。模型跑起来之后不是一条直线跑完就结束,而是要被持续调用,要处理动态变化的输入,要控制并发请求,要在有限的显存里塞下尽量多的用户。这一层考验的已经不是模型本身,而是系统的调度设计。
所以我的观点很明确:推理引擎不是“模型的搬运工”,它是一套完整的系统工程。理解了这一点,你再看后续的图优化、算子融合、内存复用,就会清楚每一项优化到底在解决哪个环节的问题。
1.2 推理引擎关心的三个核心指标
做推理方向,绕不开三个词:延迟、吞吐、显存占用。但真正理解它们的人不多,因为这三个指标的优先级在不同场景下是完全不同的。
延迟(Latency)是“单个请求从进来到结果返回所花的时间”。在线服务最敏感,比如对话场景,用户发出一个问句,期望几百毫秒内就开始返回第一个token。延迟直接决定用户体验,属于“硬约束”。
吞吐(Throughput)是“单位时间能处理多少请求”。离线批量计算最看重这个,比如用模型批量跑一批文档的处理任务,没人关心单条是不是慢了10毫秒,只关心一晚上能不能跑完十万条。
显存占用则像一个隐形天花板。同样的GPU,显存规划做得好,可能一次装两个模型或者多塞几倍的用户量;做得差,一个模型就把显存吃满了,剩下的请求全部排队。
从做引擎优化的角度,我更愿意把问题反过来看:你要先想清楚自己的业务属于延迟敏感型还是吞吐敏感型,再决定优化方向的优先级。对话服务优先压首token延迟和显存占用,离线批处理优先压吞吐和显存带宽。没有统一的“最优配置”,只有匹配场景的“合适配置”。
2. AI 模型基础:推理引擎眼里的一张图
2.1 计算图:模型不是魔法,是一张有向图
很多初学者把模型想得很玄,其实从推理引擎的视角看,模型就是一张有向无环图(DAG)。
节点是算子,边是数据流向。输入张量从图的入口进入,沿着边经过一个一个节点,每个节点做一次数学运算,最终在出口吐出结果。
举一个很简单的例子。一个线性层加一个激活函数,在训练代码里写起来是output = relu(x @ W + b),在推理引擎的计算图里就是三个节点:矩阵乘、逐元素加、ReLU。引擎拿到这张图之后,第一件事是遍历一遍,看有没有可以简化的地方。
比如x @ W + b这个结构里,W和b是常量。如果引擎在加载模型时发现图层面的y = x @ W + b后面紧跟一个 ReLU,它不会傻乎乎按顺序跑三个算子的核函数,而是会做算子融合。这个后面细讲,但你要先记住一点:引擎对模型的理解,就是把高级语言描述的层结构,降维成可执行的计算图,再尽量压缩这张图的执行成本。
计算图的组织方式也有讲究。业界常见的格式有静态图和动态图两种思路。静态图是在执行前就把整个流程定死,好处是能做全局优化,坏处是不灵活;动态图是边解释边执行,灵活但优化空间小。推理端几乎都倾向静态图,因为部署场景的输入尺寸虽然会变,但整体计算结构是固定的,静态图能把能省的都省掉。
2.2 张量、算子和数据布局
理解了图,再往下需要认识两个基础概念:张量和算子。
张量就是多维数组,是数据在引擎中流动的基本单位。一个四维张量,我们经常用形状来描述它,比如[batch, channel, height, weight],这也就是常说的 NCHW 布局。数据布局这玩意儿,看着只是“顺序不同”,但对性能的影响是数量级的。
原因在于硬件的缓存机制。CPU 和 GPU 的缓存是按局部性原理工作的,如果数据的存储顺序和计算时的访问顺序一致,缓存命中率就高,访问速度就快;不一致,缓存频繁失效,性能直接崩塌。
不同硬件厂商对数据布局有各自的偏好。有的芯片要求做成NC1HWC0这种特殊分块格式,因为它的计算单元是固定宽度对齐的,数据不做重排,算子的执行效率就上不来。这类重排动作通常由引擎在加载模型时自动完成,但如果你手工写算子或者调试性能问题,就必须知道这个背景。
算子则是计算图的节点,也是真正干活的部分。常见的算子包括矩阵乘(GEMM)、卷积(Convolution)、逐元素操作(Elementwise)、归一化(Normalization)、Softmax等等。每个算子在硬件上都有一个最擅长运行的形态,引擎的工作之一就是把这个形态找出来。
2.3 精度:模型不是躺在内存里的原始数据
还有一个模型基础,必须专门提出来讲——精度(Precision)。
训练出来的权重默认是 FP32,也就是每个数用 32 位浮点表示。FP32 精度高,但是占空间、算得慢。于是大家想到一个办法:能不能用更少的位数来近似表达这些权重?这就是量化的源头。
量化的主流路线是:
- FP16 / BF16:半精度浮点,16 位,用来跑训练和推理都很常见。BF16 的动态范围和 FP32 接近,指数位多、尾数位少,在推理中表现稳定。
- INT8:每个数用 8 位整数表示,配一个缩放因子把浮点范围映射到整数范围。工程上应用最广,因为显存占用直接减半,计算速度也明显提升。
- INT4 及以下:进一步压缩,主要用在超大模型的场景,精度损失需要靠算法补偿。
量化听起来简单,做起来有不少坑。直接拿 FP32 权重强行转 INT8,往往会发现模型输出精度明显下降,原因很简单:不同层对数值误差的敏感度不一样。有的层对量化非常敏感,稍微有点误差,结果就偏了;有的层很皮实,随便压都行。
工程上正确的思路是校准(Calibration)。拿一批有代表性的输入数据,统计每一层激活值的分布范围,然后据此确定量化参数。这个动作做得好不好,直接决定量化后模型的精度表现。我在实际项目中踩过一次坑:某模型直接套用通用量化参数,结果精度掉了两个多点,后来重新采集校准数据集、对敏感层做了混合精度的保留,才把精度恢复到可接受范围。这就是标准的经验——量化不是一个开关,而是一套参数需要调教的过程。
3. 推理基础原理:一次前向传播到底怎么跑
3.1 从输入到输出的完整链路
现在把视角从模型结构切换到数据流动。以一个大语言模型的生成推理为例,完整走一遍前向,你会看到整个推理引擎在忙什么。
第一步是文本的 Tokenization。用户输入的句子,需要被切分成一个一个小单元,称为 token。这些 token 再用字典映射成整数 ID,变成模型能接受的数字。
第二步是 Embedding 查表。每个 token ID 去 embedding 表里查一个向量出来,这个向量的维度通常是模型隐藏层的大小。这一步在 GPU 上其实是一个巨型查表操作,访存开销不小,但在推理优化中经常被忽略。
第三步是主计算体。向量序列经过多层 Transformer 结构,每一层内部有注意力计算、归一化、前馈网络这些子模块。数据在层间流动,形状基本不变,但数值不断被精炼。这一步是大语言模型计算的核心,也是量化、算子融合、内存优化的主战场。
第四步是输出头。最后一层隐藏状态经过一个线性映射和 Softmax,得到每个 token 的概率分布。取概率最高的那个 token ID,再查回文本,就完成了当前一轮的生成。
生成式模型和普通模型有一个重要差别:它不是一次跑完就结束的。对话时给模型一个输入,模型输出一个 token 后,要把这个 token 拼接回输入序列,重新做一次前向,再生成下一个 token,如此循环往复,直到出现结束标志或达到最大长度。这个循环的每一步都依赖前一步的结果,无法并行,这是推理延迟最大的结构性瓶颈之一。
3.2 矩阵乘法与访存开销
大模型前向计算里,占比最高的运算是什么?答案是矩阵乘法,尤其是线形层里的X @ W。
理解矩阵乘法的计算特征,是理解后续所有优化手段的钥匙。
一次矩阵乘法涉及两个维度的工作量:计算量和访存量。计算量是指要做多少次乘加运算;访存量是指要把多少数据从内存搬到计算单元里。两者的比例称为算术强度。
现代硬件的计算单元非常快,快到在很多时候“等数据”比“算数据”还慢。这就是内存墙问题。拿一个 7B 规模的模型举例,它的权重总量有大约 14GB(FP16 存储时)。推理时,哪怕只生成一个 token,原则上也要把这 14GB 的权重扫一遍,才能得到这个 token 的预测结果。
这时候你就能理解一个反直觉的事实:单个 token 的生成延迟,主要不是被计算速度卡住,而是被单次计算需要读取的权重大小卡住的。显卡计算再快,也得先把权重从显存搬进计算单元。所以大模型推理经常是“访存带宽受限”的,减少读取的数据量,比提升计算速度更有效。这也是为什么量化能带来几倍加速——它不是让计算变快了,而是让需要搬的数据变小了。
3.3 注意力机制:生成式推理的隐形瓶颈
如果说矩阵乘法是主计算体,那么注意力机制就是那个“最讲细节”的部分。
注意力操作的核心是一个加权聚合过程:每个 token 会计算自己和序列中所有 token 的相关性分数,然后按分数加权取信息。公式这里不展开,但有一个推理特有的问题必须讲清楚——KV Cache。
为了计算当前 token 和历史的注意力,理论上需要重新计算历史 token 的中间结果,但那样做太浪费了。于是引擎会做一层缓存:把历史 token 算好的两个中间矩阵(K 和 V)存下来,下次生成新 token 时直接读取,只计算新 token 相关的部分。
KV Cache 的引入,让生成式推理的架构发生了根本变化。它会随序列长度增长而线性扩大。假设一个模型的隐藏层维度是 128,共 32 层,每个 token 的 KV Cache 大小是2(K和V) × 32(层数) × 128(维度) × 2字节 × 长度。四个用户同时生成长文,显存占用一下子就上来了。
KV Cache 的管理是大模型推理优化的重要战场。常规做法是一次性分配一个固定的最大长度缓存,浪费严重;更聪明的做法是按页分配,用量多大就分配多少,这就是业界常见的 PagedAttention 思想。这块框架和引擎已经做得比较成熟了,但理解里面为什么省显存,对排查 OOM 问题非常有帮助。
4. 核心优化手段:引擎在背后做了哪些事
4.1 图优化:把计算图“盘”顺
引擎拿到计算图后的第一轮动作,通常是一组纯逻辑层面的优化,不需要关心底层硬件,成本低、收益稳定。
常见的图优化包括:
- 常量折叠。计算图中如果存在一些输入完全确定的子图,比如
权重 × 偏置这种加载后就不变的部分,引擎可以在初始化阶段直接算出结果,运行时就不用再算一遍。 - 死代码消除。训练结构里常用 dropout,推理时这个操作是恒等映射,直接被摘掉。
- 冗余节点删除。比如连续的转置操作,
transpose → transpose两步等于白干,直接合并或删掉。
图优化看着不起眼,但对整体性能的综合贡献经常在 10% 到 30% 之间,关键是它几乎不改变模型数值结果,属于“稳赚不赔”的优化。质量高的引擎会在图层面反复做多轮迭代,直到没有可优化的模式为止。
4.2 算子融合:减少搬运而不是减少计算
图优化的下一步是算子融合。这个优化的核心逻辑不是减少计算量,而是减少数据搬运和内核启动的开销。
打个比方。你要给一堆人发三张表格让他们轮流填:填完表1交给A,A算完后把结果搬到B那里填表2,B算完再搬到C那里填表3。每一步搬运都有时间成本。算子融合做的事情是,把这三个岗位合成一个人,原地完成三张表,省掉中间的搬运和等待。
具体到模型结构,最经典的融合模式是线性层的QKV 融合。Transformer 里的自注意力需要同时算三组矩阵——Q、K、V。常规做法是三个独立的矩阵乘,各自读一遍输入数据。融合后的做法是把三个权重矩阵拼成一个大的,一次矩阵乘同时输出三份结果,输入数据只读一次。
类似的融合还能用在残差连接、LayerNorm、激活函数这些组合上。每融合一组,就能省一次内核启动、一轮显存读写。累积起来的收益相当可观。
4.3 内存规划与显存复用
内存管理对推理引擎的影响,经常被低估。一个简单的事实是:显存分配和释放的开销很大,频繁分配会导致性能抖动。
引擎的做法之一是内存池。在启动时或首次推理时,一次性向显存申请一大块内存,后续算子的中间结果都在这块池子里复用,不到万不得已不触发新的分配。
另一招是生命周期分析和复用。引擎会分析张量的生命周期:某个张量在计算结束后就不再被使用,它的内存空间就可以被后续的张量覆盖。这就像宿舍床位管理——A同学退宿后,B同学直接补位,而不是再新建一栋楼。
对大模型场景,显存大头不只是权重、激活值,还有前面提过的 KV Cache。在显存总量固定的情况下,权重、KV Cache、临时计算空间三者需要动态平衡。很多框架提供显存利用率配置,调节这个值就是在调节三类空间的分配策略。默认配置在多数场景下表现不差,但如果你面对的部署场景很特殊,理解这个平衡点就非常重要了。
4.4 批处理与动态形状:工程细节见真章
最后一个绕不开的优化手段是批处理(Batching)。
GPU 是强并行架构,单个请求计算的时候,核心里有大量的计算单元在闲置。把多个请求拼成一批一起跑,能显著提升吞吐。但大模型生成有一个特殊性:每个请求的长度和生成进度不一样,生成了 10 个 token 的请求和刚输入请求,计算时长差别巨大。把进度差很大的请求硬绑在一起,整批都得等最慢的那个。
于是业界普遍采用动态批处理和连续批处理策略。连续批处理的思路是:把请求队列维护好,有请求完成就立刻从等待队列拉一个新的补进去,而不是等整批跑完再一起换。这个策略对吞吐的提升非常显著,一个框架用不用连续批处理,同样的硬件条件下吞吐可能差一倍以上。
动态形状则是另一个工程暗坑。很多引擎为了极致优化,会假设模型输入尺寸固定,这种假设让内存规划更简单、算子更高效,但一遇到变长输入就麻烦了。有的引擎选择了在遇到新形状时重新编译,就会产生明显的“首个请求卡顿”现象。做服务端部署时,如果你发现第一个请求特别慢、后续就快了,多半就是引擎在首次遇到某个输入形状时触发了一次重新优化。
5. 性能分析:如何量化推理快不快
5.1 延迟、吞吐、首token延迟:别用平均值骗自己
做推理方向的性能优化,最怕的就是只盯着单一平均值看。
以对话应用为例,用户感知最强的指标是首 token 延迟(TTFT),也就是从发起请求到模型吐出第一个字的时间。这个时间决定了“对话有没有开始”。
生成过程中,用户感知的是每个 token 的间隔速度(TPOT),这个速度不稳定,体验就会显得卡顿。之前某公司发布的对话响应体验波动问题,排查到最后就是 Token 生成节奏不稳定,而不是平均延迟高。
统计上还一定要关注尾延迟。P50 和 P99 是两个完全不同的世界。P50 是 50% 的请求在这个时间内完成,P99 是 99% 的请求都能在这个时间内完成。在线服务的优化重点应该在 P99 上,因为最差体验的那批用户,往往是最容易流失的用户。一个系统如果 P50 漂亮但 P99 很差,说明存在明显的性能毛刺,这种毛刺通常来自显存抖动、缓存未命中、锁竞争这些资源竞争问题。
5.2 用 Profiler 定位瓶颈在哪里
当性能表现不符合预期,第一步不是瞎猜优化手段,而是做性能剖析(Profiling)。常见的思路是分层往下看。
先看整体。GPU 的核心利用率是多少?显存带宽利用率是多少?如果核心利用率很低,说明算子的并行度没铺满,或者是等待数据传输的时间过多;如果带宽已经打满,说明瓶颈在访存,追加纯算力优化帮助不大。
再看算子。跑一遍全模型的时间,逐个算子统计耗时占比。耗时最高的前几个算子,就是重点优化对象。我之前分析过一个场景,发现耗时最高的居然是一个看起来不起眼的 LayerNorm,因为它在每次残差连接后都触发了一次全局同步。解决方式就是把它融合进前后算子里,耗时立刻降了。
再看调度。多次推理之间的间隔时间有多少被内核启动、显存拷贝、动态内存分配占据。这些开销在模型推理的单个内核时间面前可能很小,但请求量上来后,它们会累积成明显的系统瓶颈。
5.3 手把手搭一个最简的推理基准测试
性能优化必须有数据锚点,不然你做了改动也无法判断是变好了还是变坏了。
最简单的方法是用 Python 里的time.perf_counter()对模型推理整体计时。但注意,这样测出的数字受启动开销影响很大,适合粗略基线,不精确。更严谨的做法是先跑若干轮预热(Warmup),排除初始化、缓存冷启动的影响,然后统计多次推理的时间分布。
在命令行层面,也可以用nvidia-smi观察显存占用峰值,用nvidia-smi dmon观察 GPU 利用率随时间的变化。多数推理框架也自带简单的时间统计接口,直接调用就能拿到端到端延迟。
这里分享一个我常用的简单排查流程:先用预热后的平均延迟做基线,再用 Profiler 统计算子耗时占比,最后针对 Top 耗时的算子做单一改动并重新测基线。一次只动一个变量,做出来的优化你才敢上线。
顺便提一个反直觉的结论:普通架构里 CPU 与 GPU 之间的数据传输是最容易被忽略的隐藏瓶颈。很多时候 GPU 利用率看着不低,延迟却下不来,问题其实出在数据拷贝环节,不是在 GPU 算得慢,而是数据送得慢。
6. 常见问题与排查技巧实录
6.1 精度偏差:量化后效果不对怎么办
量化之后模型结果变差,是推理部署里最高频的问题之一。
排查思路有严格的顺序。先确认是不是校准数据的问题。校准集要和真实业务数据的分布尽量一致,如果校准集用的是通用文本,业务是特定风格的内容,量化参数就可能不准。其次是确认是不是敏感层的问题,有些层对量化误差非常敏感,一个层的参数量不大,却在精度表现上起决定性作用。
实操方案通常是混合精度和敏感层回退。先用工具逐层计算量化前后的输出误差,按误差从大到小排序,误差最大的那几层改回 FP16 计算,其他层保持 INT8。这个方案可以做到只牺牲一点性能收益,换回大部分精度。实测在很多模型上,回退不到 10% 的层,精度就完全恢复了。
6.2 显存 OOM:跑着跑着就爆了
显存溢出是推理服务最让人头疼的问题,因为它通常发生在线上高峰期。
OOM 的常见原因有三个。一是显存碎片太多,频繁分配释放导致空间不连续,整体剩余空间足够但凑不出一块大的连续内存。这种情况的解法是检查引擎的内存池配置和显存空间复用策略。二是 KV Cache 配置不合理,最大长度设得过大,导致缓存区占用过大。三是并发限制和显存预算没匹配上,需要重新核算每个请求的显存占用值。
这里建议在部署前做一个粗估:显存预算 = 权重大小 + KV Cache 上限 + 激活值临时空间 + 推理框架的工作区。照这个公式去反推可以支持的并发数,就能避免线上高峰期爆显存的尴尬。
6.3 性能抖动:为什么 P99 比 P50 差很多
性能抖动的根源通常不在 GPU 本身,而在资源竞争。
最常见的是 CPU 侧的抖动。数据预处理、token 化、调度逻辑跑在 CPU 上,CPU 一忙,GPU 就等着被投喂数据。所以给推理服务足够的 CPU 配额,和 GPU 一样重要。
第二常见的是显存带宽竞争。多个计算任务同时进行时,显存带宽是共享的,其中一个任务占到带宽,其他任务的访存就变慢。解决思路通常是把大请求错峰部署,或者把显存带宽敏感的算子和计算敏感的算子分配到不同的硬件上执行。
第三是锁竞争和线程调度问题。推理框架内部如果有多线程并行处理请求,线程间同步做得不好,就会有明显的性能毛刺。遇到这类问题,查看框架的事件循环配置和线程数设置往往比调 GPU 配置更有效。
6.4 动态输入尺寸导致性能滑坡
有些用户会在推理时传入和训练时完全不同的输入尺寸,比如超长序列。序列一长,注意力计算复杂度呈二次方增长,同时 KV Cache 也随序列线性增大。常规优化在短序列上表现良好,长序列下就完全失效了。
处理这类问题的根本思路有两个方向。一是算法层面,用稀疏注意力、滑动窗口注意力等手段降低长序列的复杂度;二是工程层面,针对目标序列长度单独做一次专门优化,避免通用配置拖慢所有场景。
我在实际项目中遇到过一个案例:某个对话系统偶尔会收到长达几千字符的输入,结果单次推理时间是常规输入的十几倍。最后通过设置长文本专门处理路径、限定注意力计算范围,才把尾延迟压下来。这种“异常长输入”的情况不在常规路径里,却是服务稳定性的隐形杀手。
写在最后的一点个人体会
做推理引擎方向这么久,我越来越觉得,这个领域真正的门槛不是会调某个框架,而是建立起一套完整的心智模型:拿到一个模型,你能在脑子里把它想象成一张图;出现性能瓶颈,你能判断出它属于计算约束、访存约束还是调度约束;精度出了问题,你知道该从量化参数、校准集还是敏感层入手。
如果从零开始自学,我建议的顺序是:先把模型基础的计算图和张量概念吃透,再手动搭建一两次完整推理链路,然后研究某个开源推理引擎的图优化和调度源码,最后再上手性能分析和调优。资料不在多,关键是每一步都跟着推理引擎的实际行为走一遍。
这个方向后续可以扩展的还有很多:多模型并发调度、异构设备的混合部署、稀疏化推理、更低比特的量化算法。但无论技术怎么演进,推理的基础原理——计算图、访存、算子形态、资源竞争——是永远不变的内核,值得多花时间打扎实。