做模型部署三年多,我踩过最深的坑就是“训练流畅、部署稀烂”。前阵子把一批视觉模型接进内部推理服务,P99 延迟怎么压都下不来,换了几种常规优化手段效果都有限,最后把一套开源的跨平台 AI 推理加速引擎 Anakin 翻出来重新搭了推理链路,才总算把问题理顺。今天不准备兜圈子,直接顺着 Anakin 这个引擎,聊聊它到底解决什么问题、内部怎么做优化、实际部署服务时有哪些可以照搬的经验。
先说清一件事:推理引擎和训练框架是两套逻辑。训练要的是吞吐率,数据可以大批量怼进去,反向传播算得越开心越好;推理要的是延迟和资源边界,线上请求一个接一个来,不可能等 batch 攒满再处理。Anakin 的设计目标非常聚焦——跨硬件、低延迟、高吞吐、内存可控。适合做两件事:一是把不同框架训练出来的模型统一接入生产环境,二是让同一个模型能在 CPU、GPU、嵌入式设备甚至 FPGA 上跑出相对稳定的性能。
如果你正被“模型上线性能差”“多硬件适配麻烦”“显存内存疯涨”这几个问题折磨,这篇文章应该对你有用。
1. 推理场景的特殊性:先搞清楚它和训练框架的“认知差”
1.1 训练框架与推理引擎的本质区别
很多从算法转工程的同学,一开始会把推理引擎当成“简化版训练框架”,这个认知会害死人。训练阶段,模型权重不断更新,计算图虽然固定,但每个 batch 都要前向加反向,计算量是推理的好几倍,所以优化重心放在矩阵运算吞吐上,不在乎单次请求延迟。推理阶段权重已经固定,模型只做前向计算,但服务端要同时面对成千上万个请求,每个请求都必须快速响应,偶尔一个慢请求就会拖垮整体体验。
我举个特别直观的例子。训练时模型输入的图片可以随便 resize、可以随机裁剪,batch size 可以开到 64 甚至 128,因为反正要跑很多轮 epoch,多等几毫秒无所谓。但线上推理时,一张图进来就得马上出结果,batch size 往往只有 1。这时候如果推理引擎还是按训练框架那套思路做批处理调度,单请求延迟根本压不住。这也是为什么“推理引擎不能直接用训练框架”的根本原因:目标函数不同,优化手段必然不同。
1.2 推理引擎盯着的三个硬指标
一个推理引擎值不值得用,我一般只看三个指标。
延迟,尤其是 P99 延迟。平均延迟低不代表用户体感好,总有一小撮请求会被各种抖动拖慢,P99 才是线上真实水平。吞吐量,单位时间能处理多少请求,这决定服务容量和成本。内存或显存占用,推理服务往往长时间运行,内存持续涨就是事故前兆,BatchNorm、concat 这类算子如果不做内存复用,随便一个稍复杂的模型就能把显存吃满。
这三个指标互相牵制。想降低延迟,可能就得牺牲吞吐;想把吞吐拉满,延迟又会升高。推理引擎的价值就是在给定硬件资源下,找到这三个指标的平衡点,而 Anakin 的优化设计基本就是围绕它们展开的。
1.3 Anakin 的出现到底解决了什么
Anakin 最早是某大型互联网公司内部孵化的一套高性能推理引擎,后来逐步对外开放。它的典型应用场景有几种:第一,训练框架五花八门,有些模型从 A 框架训练出来,到生产环境要用 C++ 服务部署,Anakin 提供一个统一的模型入口;第二,同一套模型可能既要在服务器的 CPU 上跑,也要在 GPU 卡上跑,还要下沉到边缘盒子,Anakin 做跨硬件适配;第三,许多模型在原始框架上没有任何优化,直接推理性能极差,Anakin 可以通过计算图优化把延迟拉低。
说白了一句话:它解决的是“模型训练完之后,工程上如何又快又省地跑起来”这件事。
2. Anakin 的整体架构:一个编译器式的推理引擎
2.1 分层结构:从模型解析到 Kernel 执行
Anakin 的架构我习惯比作“编译器”。前端负责把不同框架的模型解析成统一的计算图,比如 Caffe、ONNX 出来的模型,先经过转换模块变成引擎内部的数据结构;中端做计算图优化,包括算子融合、常量折叠、内存规划;后端针对 CPU、GPU、FPGA 等不同硬件生成可执行的算子或 kernel。整个过程和“源代码 -> 中间表示 -> 优化 -> 机器码”非常像。
之所以采用这种分层,最大的好处是优化逻辑可以复用。算法工程师不管底层硬件差异,只需要研究计算图层面的通用优化;后端工程师也不需要关心模型来源,只需要让自己负责的硬件把算子跑得足够快。这个边界清晰的设计,是它能支撑多种硬件后端的前提。
2.2 为什么一定要有一个统一 IR
做跨平台引擎的人都会遇到一个问题:不同框架、不同硬件各有各的“脾气”。如果没有统一中间表示,每接入一个新框架就要给每个硬件重新写一套适配,工作量指数级爆炸。Anakin 用统一 IR 把模型描述成一个有向无环图,节点是算子,边是张量数据流,后续所有优化都基于这个 IR 来做。
统一 IR 还有一个隐藏价值:方便做算子等价替换。比如看到某个子图是一个 Conv 后面跟着 BatchNorm,中端优化器可以把它们替换成一个新的融合算子。这个替换只针对 IR 做一次,CPU、GPU、FPGA 后端都能同时受益。如果不用统一 IR,每个硬件后端都要写一遍融合逻辑,既容易出 bug,又难保持一致。
2.3 运行时调度与算子库
Anakin 还有一个运行时层,负责线程池管理、设备管理和算子调度。简单说,计算图优化完成后会生成一个执行计划,运行时按照拓扑顺序调度每个算子执行。调度器会尽量保证算子之间没有依赖的部分可以并行,有依赖的部分减少等待。
算子库方面,CPU 后端有针对 AVX2、NEON 等指令集优化过的实现;GPU 后端则结合了手写 kernel 和成熟算子库,兼顾性能和兼容性。实际用下来,这种“基础算子自研 + 复杂算子选择性依赖第三方库”的策略比较稳妥,不会因为依赖某个闭源库而被卡脖子。
3. 核心优化手段拆解:为什么同一个模型能差好几倍性能
3.1 计算图优化:常量折叠与算子融合
计算图优化是推理引擎拉开性能差距的第一道关。常见的优化有三类:常量折叠、死代码消除、算子融合。
常量折叠比较容易理解。比如某个节点做的是2 + 3,训练时模型已经算完了,推理阶段没必要再算一次,直接在加载模型时把这个节点替换成常量结果。这种优化能砍掉一些零碎的计算子图。
算子融合是性能提升的大头。我举一个典型例子:Conv + BatchNorm + ReLU。这三个算子如果一个个单独执行,每一步都要把中间结果写回内存,再读出来给下一个算子,访存开销非常大。BatchNorm 在推理阶段其实是一组线性变换,可以合并到 Conv 的权重里;ReLU 是一个逐元素激活,可以和 Conv 的输出计算融合在一起。
融合之后,原本需要读写多次内存的三个算子,变成了一个算子。用数学表达就是:先算 BatchNorm 的等效 scale 和 shift,推理时 BatchNorm 的输出等于scale * x + shift,把 scale 乘进卷积权重,把 shift 加进卷积偏置,ReLU 再直接作用到输出上。代码层面看多了一步权重预处理,但线上推理每张图能省掉两次全张量读写,性能差 30% 以上是很常见的。
注意:不要小看 ReLU 融合。很多模型激活前就是全连接层或卷积层,融合后不产生中间 tensor,内存占用和访存带宽压力都会明显下降。
3.2 内存复用:从频繁申请到池化复用
另一个关键优化是内存复用。推理引擎每层都要产生中间 tensor,如果每个 tensor 都独立申请、独立释放,内存分配器的开销会非常惊人,而且会产生大量碎片,长时间运行后内存占用缓缓往上爬。
Anakin 的方式是做内存池和生命周期分析。先分析计算图上每个 tensor 的使用范围,从哪个算子产生,到哪个算子之后就不再被使用,然后给互不冲突的 tensor 规划同一块内存。这个过程很像多人合租,甲住周一和周二,乙住周三和周四,房间可以复用,只要不撞时间就行。通过这种机制,整个模型推理需要的内存往往能压缩到原始实现的几分之一。
显存层面同理。GPU 反复执行cudaMalloc是非常贵的,Anakin 会在执行前把能预分配的显存都规划好,执行过程中尽量复用。我见过有人直接在 GPU 上跑原始框架,显存吃满 8GB;同样模型塞进 Anakin,优化后只需要 3GB 左右。这个差距在推理服务成本上是真金白银。
3.3 Kernel 优化与数据布局:缓存命中的“玄学”与科学
图优化做完了,剩下的就是算子本身要足够快。Kernel 优化可以从几个方向下手:指令集向量化、循环分块、数据布局转换、并行策略。
数据布局是一个常被忽略但影响巨大的点。很多框架默认用 NCHW 布局,通道维排在一起,卷积实现方便。但在 CPU 上,NCHW 对缓存不太友好,尤其小通道数情况下,读取数据时经常把整行数据都加载进缓存,实际用到的却很少。NHWC 布局把每个像素的通道数据放一起,访存更连续,配合向量化指令能显著提高缓存命中率。Anakin 在部分场景会使用类似 NC4HW4 的分块布局,把通道拆成 4 个一组,对齐到 SIMD 指令宽度,实测效果比简单 NCHW 好不少。
矩阵运算核心部分则用 GEMM 的思路处理。所谓 GEMM 就是矩阵乘法优化,把大矩阵切分成小块,让数据尽量留在 CPU 的 L1/L2 缓存里,减少频繁访存。卷积大部分情况下可以转成 GEMM,这也是很多推理引擎的通用套路。
举一个理论例子:假设一个 224x224x3 的输入,普通逐点计算时,每算一个输出点都要读好几次输入,且都是随机偏址读,缓存命中率很低。改成分块 GEMM 和布局转换后,数据按块加载,同一块数据被重复使用的次数变多,延迟从数百毫秒降到几十毫秒并不夸张。
3.4 低精度量化:用精度换速度的经典策略
除了图优化和 kernel 优化,Anakin 还支持 FP16、INT8 等低精度推理。这个策略的本质是利用硬件对低精度计算更强的吞吐能力。INT8 在 CPU 上有 AVX512 相关指令支持,在 GPU 上有 Tensor Core,算力比 FP32 高了数倍,同时模型体积和内存占用也能明显下降。
量化并不是简单把 float 转 int,中间要做 scale 校准,否则模型精度会掉得很凶。工程上通常的做法是喂一批真实数据进去统计激活值的分布,再决定量化区间。实际操作中我发现,如果模型结构比较规则,INT8 精度损失通常能控制在可接受范围内;如果是包含大量动态数值范围的模型,建议先在验证集上多跑几轮评估再上。
4. 多硬件后端适配:CPU、GPU、FPGA 的思路差异
4.1 CPU 后端:掌控指令集与多线程调度
CPU 推理是服务器场景最普遍的形态,因为不是所有业务都舍得配 GPU。Anakin 在 CPU 后端最核心的事情有三件:使用 SIMD 指令集做向量化、多线程并行切分计算任务、优化内存访问模式。
如果你自己写推理代码,一定要在构建时明确打开指令集编译选项,比如 AVX2、AVX512、FMA。这些指令能让一条指令同时处理多个数据,计算密度成倍提升。运行时还可以设置线程数,比如 8 核机器不一定要开 8 个线程,因为超线程和锁竞争会拖累吞吐。最佳线程数要实测,模拟数据压一轮看曲线。
多线程切分也不是简单把任务丢给线程池就行。算子内部要把数据切成块,保证线程之间没有重复计算,同时尽量减少同步次数。Anakin 的运行时调度在这里发挥了重要作用,不同算子之间的依赖关系梳理清楚后,可以流水线化执行,CPU 利用率明显比原始框架高。
4.2 GPU 后端:显存规划与并发执行
GPU 推理主要矛盾不在算力,而在显存和调度。GPU 上频繁申请显存、释放显存会引入大量同步开销,Anakin 的内存池机制在这里价值最大。显存预分配和复用做完后,显存占用稳定,也不会因为碎片导致后续请求失败。
GPU 后端还要考虑并发策略。当多个请求同时进来,是串行执行还是一个请求独占一个 CUDA stream,效果差别很大。通常做法是提前建好几个 CUDA stream,把请求分散到不同 stream 上执行,再配合多线程做数据预处理和结果后处理,让 GPU 计算与 CPU 数据搬运重叠起来。这样能让 GPU 利用率明显提升,但也别开太多 stream,太多并发流会争抢显存带宽,反而让单请求延迟变差。
我踩过一次坑:第一批请求进来时,GPU 显存还没来得及预分配,前几个推理特别慢,过了几秒才稳定。后面加了预热流程,服务启动时先跑若干次空推理,把显存池初始化好,线上表现才正常。
4.3 FPGA 后端:为固定模型做定制流水线
FPGA 不是通用计算的最佳选择,但在功耗敏感、延迟要求极端的场景里有独特价值。Anakin 对 FPGA 的支持思路比较特别:它不只是“把算子编译到 FPGA”,而是针对固定模型做定制流水线。因为 FPGA 的逻辑电路一旦烧录,修改成本较高,所以它更适合模型结构基本不变的场景。
举个实际场景:某个边缘设备的模型已经定版,输入尺寸、模型层数、算子类型都固定,这时候用 FPGA 把整个网络映射成硬件流水线,可以实现极低延迟和极低功耗。但如果模型经常更新,今天加一个算子明天改一个结构,FPGA 就不合适了,反而不如 CPU/GPU 灵活。
这也是跨平台引擎和单一硬件引擎的差别:不是所有硬件都要做到最强的通用性,而是让合适的工作负载跑在合适的硬件上。
4.4 异构执行的取舍
Anakin 还支持把计算图的一部分放在 CPU、一部分放在 GPU,也就是异构执行。这个能力听上去很酷,但工程上要谨慎。跨设备数据传输的代价往往高于算子本身的计算时间,如果没有特别大的算子需要放到另一设备上,强行异构反而会拖慢整体。
我个人的判断标准是:单个子图计算量占比超过 20%,且它的计算特性和设备特性高度匹配,再考虑异构拆分。否则就老老实实跑单一设备,把数据搬运开销省下来。
5. 从零开始部署一个模拟项目:完整实操记录
5.1 环境准备与构建
下面这部分是基于 Anakin 常见用法整理的实操记录,我用一个模拟项目 X 来演示。假设手头是个图像分类模型,输入是固定尺寸的图片,输出是类别概率。
首先准备构建环境,Linux 系统,装上 CMake、C++ 编译器和对应后端依赖。如果要用 GPU,需要装好驱动和 CUDA 工具链。构建命令大概长这样:
git clone https://git.example.com/anakin/anakin.git cd anakin mkdir build && cd build cmake .. -DUSE_GPU=ON -DUSE_OPENMP=ON make -j16这里有两个容易出问题的点。第一,USE_GPU开关要在构建前确定好,后期切换会导致整条链路不一致;第二,CPU 平台记得打开 OpenMP 开关,否则多线程切分功能不生效,性能会打折扣。构建完成后,会生成推理库文件的路径,之后编译自己的服务程序时链接它即可。
5.2 模型转换与计算图优化
模型转换是所有部署流程里最需要耐心的环节。先把训练好的模型导出成一个通用格式,比如 ONNX,然后用 Anakin 的转换工具加载,生成引擎可以识别的模型文件。
转换过程中,要确认输入输出节点的名字,并且把输入 shape 固定下来。为什么强调固定 shape?因为 Anakin 的计算图优化是静态优化,很多融合和内存规划工作都需要在编译期完成。如果输入 shape 不固定,优化器无法做精确的内存规划,很多优化手段就只能退化为动态执行,性能会差不少。
转换完成后,可以打印优化前后的计算图对比,观察哪些算子被融合了,哪些常量被折叠了。这一步像体检报告,能直接看出引擎到底做了多少优化。如果发现某些高频算子没有被融合,可能要检查模型文件本身是否带有奇怪的子图结构。
5.3 编写推理调用程序
模型转换完成,接下来写推理程序。核心流程是:创建计算图、优化计算图、创建执行器、填输入、执行、取输出。下面是示意代码:
#include "anakin/graph/graph.h" #include "anakin/graph/executor.h" // 1. 创建图并加载模型 anakin::Graph graph; graph.load("model.anakin.bin", device); // 2. 执行优化 graph.optimize(); // 3. 创建执行器 auto executor = graph.get_executor(); // 4. 获取输入输出 tensor auto input_tensor = executor.get_input("input"); auto output_tensor = executor.get_output("output"); // 5. 填充输入数据 float* input_data = input_tensor.mutable_data(); memcpy(input_data, image_data, image_size * sizeof(float)); // 6. 执行推理 executor.launch(); // 7. 读取输出 const float* output_data = output_tensor.data();这段代码只是示意,具体 API 在不同版本里会有差异,但整体流程是一致的。写代码时我习惯把“创建执行器”这件事放在服务启动阶段完成,不要在每次请求时重复创建,否则光对象构造和内存分配就能吞掉几百毫秒。
5.4 性能验证与调参
推理程序跑通后,性能验证一定要模拟线上流量模式,而不是只用一两个样本测试。压测工具自己写或者用现成的都行,关键是记录这几个指标:平均延迟、P99 延迟、QPS、内存变化。
如果 P99 比平均值高很多,优先看有没有请求排队现象。一个常见问题是线程池和请求线程的比例没调好,导致算子内部线程和业务线程互相竞争。我通常会先固定输入 batch=1 做一轮压测,确认单请求延迟达标,再逐步加深并发度,观察吞吐和延迟的变化曲线,找到拐点。找不到拐点时就拿 CPU/GPU 监控图看,哪个资源接近瓶颈就在哪个方向调。
6. 常见问题与排查技巧实录
6.1 第一次推理特别慢,后面的请求才变正常
这个现象基本可以断定是初始化开销。计算图优化、内存预分配、GPU 上下文初始化,这些工作都集中在第一次 launch 时完成。解决办法是在服务启动前做一次预热推理,用一个假输入把该初始化的全部初始化掉。线上发布时尤其重要,预热完再对外接流量。
6.2 内存占用不断上涨,最终爆掉
推理服务长期运行内存涨,最大嫌疑是内存池没有合理回收,或者图优化时每个请求都创建了新执行器。先检查请求处理路径是不是每次都在动态创建算子、动态分配中间 tensor。正确做法是复用执行器、复用输入输出 buffer,让中间内存尽量走内存池。如果内存池本身有碎片化问题,可以按生命周期更长的粒度规划内存块,避免小块频繁分配。
6.3 CPU 和 GPU 推理结果不一致
不同硬件上的浮点计算顺序不同,结果有微小差异是正常的。但如果差异大到影响线上判断,通常问题是某一端的算子实现有 bug 或者做了激进优化。排查思路比较简单:先固定一个简单模型,逐层输出对比,看差异从哪一层开始出现。另外要检查是否一边用了低精度推理,另一边用的是 FP32,两边精度明显不一致,需要统一量化设置。
6.4 模型输入形状要动态变化,怎么办
Anakin 的静态图优化不太适合频繁变 shape 的场景。如果业务必须动态输入,一种做法是设定几个固定 shape 档位,比如 224、416、640,每个档位建一份优化后的执行器,请求来了按最近档位走;另一种做法是切到动态执行模式,接受部分性能损失。我对团队的建议永远是:线上服务输入 shape 尽量固定,换来的是性能稳定和内存可控。
6.5 常见问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 首请求延迟高 | 初始化未预热 | 启动阶段跑预热推理 |
| 内存持续上涨 | 执行器重复创建或内存池未复用 | 检查请求生命周期 |
| CPU 利用率低 | 指令集未开启、线程数不合理 | 检查编译选项和线程参数 |
| GPU 显存暴涨 | 显存池未生效 | 确认后端显存预分配 |
| 多线程不稳定 | 动态 shape 或数据竞争 | 固定输入 shape、控制并发 |
| 结果不一致 | 精度设置不一致 | 统一 FP32/INT8 策略 |
7. 我自己的一套选型和落地建议
Anakin 这类跨平台推理引擎,最大的价值不是某个算子比别人快多少,而是把你从“每个硬件每个模型都要手工调一遍”的泥潭里拉出来。我自己用下来的体感是:如果团队模型量不大,任务单一,直接用官方优化过的推理框架加自身服务代码就够了;但如果要管理多个模型、多个硬件后端,统一接入一套跨平台引擎,长期收益明显更高。
有一点要提醒:再好的引擎也救不了脏模型。我在实际项目里见过不少性能惨淡的情况,追到最后发现是模型里堆了大量无效算子、无意义的 reshape 和超高精度的冗余计算。先把模型本身整理干净,再用引擎优化,效果才明显。动手之前,我建议先花半天时间把线上真实流量统计清楚,看看输入变化幅度、QPS 峰值、延迟红线,再决定用哪个后端、要不要量化、开多少并发。
最后再分享一个小技巧:任何推理引擎接入后,都别急着调一堆高级参数。先把“固定 shape + 预热 + 内存池复用 + 合理线程数”这四个基础动作做到位,性能通常已经达到优化后水平的八成。剩下两成再根据监控数据逐项抠,避免一开始就把配置搞复杂,后面出了问题反而不知道是哪一步引起的。