平时做模型部署的朋友,应该都有过这种感觉:训练一个模型,哪怕结构再复杂,只要显卡到位、显存管够,基本都能跑起来。可一旦到了上线阶段,把模型搬到服务器上做线上推理,面对的却是另一套标准——延迟要低、吞吐要高、内存占用要小,还得照顾不同硬件环境。这时候,一个好的推理加速引擎就比模型本身更能决定业务的体验。
这篇文章想聊的,是百度开源的跨平台 AI 推理加速引擎 Anakin。我第一次接触它的时候,项目已经不怎么活跃了,但它的设计思路放到今天依然很有参考价值。简单说,Anakin 是一个面向深度学习模型线上推理场景的高性能引擎,支持 CPU、GPU 和 FPGA 等多种硬件后端,核心目标是让训练好的模型在部署时跑得更快、更省资源。如果你正在做模型上线、服务压测,或者想了解推理引擎底层是怎么把模型“榨干”的,这篇内容应该能给你一些思路。
1. Anakin 到底是什么:先讲清楚它的定位
1.1 训练框架和推理引擎为什么不是一回事
很多人最开始会有一个疑问:模型是训练框架训练出来的,部署的时候直接用训练框架跑不就行了?答案是可以,但运行效率和性能完全是另一回事。
训练框架的设计目标是灵活,要支持动态图、自动求导、复杂的算子组合,方便研究者试错调参。但灵活性是有代价的:每次前向计算都要经过算子分发、动态内存分配、甚至图解释执行,这些开销在训练场景下可以容忍,因为训练本身就是重计算、多迭代的过程,几毫秒的调度开销可以被掩盖掉。可推理不一样,线上请求是实时的,用户点一次按钮,后台就得在几十毫秒内给出结果,任何多余的框架开销都会直接变成延迟和成本。
Anakin 的设计初衷,就是完全围绕推理场景来做减法。它不需要自动求导,不需要动态图,只需要把训练好的模型转换成静态计算图,然后在这个静态图上做各种激进的优化。静态图意味着结构确定,结构确定就意味着可以在真正执行之前,把内存规划、算子调度、底层指令选择这些事全部预计算好。这种“事前做功课、事后只管跑”的思路,是它和训练框架最本质的区别。
1.2 Anakin 支持的平台和硬件范围
跨平台是 Anakin 的另一个关键词。它做的不是简单的“一份代码到处编译”,而是在引擎架构层面把平台差异抽象掉:上层是统一的计算图表示和优化逻辑,下层是各硬件平台的高性能 kernel 实现。
具体到硬件后端,Anakin 主要覆盖了 CPU、GPU 和 FPGA 三类:
- CPU 后端:面向没有独立显卡的服务器场景,通过指令集优化(如 SSE、AVX)和多线程调度来提升计算密度。
- GPU 后端:面向高吞吐、大并发的算力场景,利用 CUDA 做算子融合和显存复用,减少访存开销。
- FPGA 后端:面向极致低延迟或特定功耗约束的场景,把固定算子烧录到硬件逻辑里。
这种多平台支持带来的最大好处是,一套模型转换流程可以复用,换硬件时不需要重新写部署代码,只需要换一个执行后端。对业务方来说,这大大降低了硬件迁移的成本。后续框架如 Paddle Lite 等也继承了很多类似的设计理念,可见 Anakin 在工程方向上的判断是超前的。
2. 核心设计拆解:为什么 Anakin 能把模型跑得更快
2.1 静态计算图:一切优化得以成立的前提
Anakin 的优化手段很多,但几乎所有优化都建立在一个共同基础上——静态计算图。所谓静态图,就是模型的结构在运行之前已经完全确定:有哪些算子、哪些数据依赖、哪些张量会被复用、哪些中间结果只存活一步。
为什么这个前提这么重要?我举一个很直观的例子:内存规划。动态图框架里,每个算子执行时都可能临时申请一块内存,用完了再释放,申请释放的频率非常高,内存分配器再快也扛不住这种开销。而在静止图中,引擎可以先遍历一遍整个图,分析每个张量的生命周期,然后用一个内存池统一规划,让生命周期不重叠的张量复用同一块内存。这个优化做下来,峰值内存占用可能直接降到原来的三分之一甚至四分之一。
静态图还让算子融合成为可能。比如一个卷积后面跟着批归一化和 ReLU,在动态图框架里,这是三个独立的算子,要执行三次数据读写。但在静态图里,引擎知道这三个操作在结构上是连续且没有分支的,于是可以把它们合并成一个融合算子,一次遍历搞定。
用生活化的类比来说,动态图像你每天出门之前才想今天要带什么,每次拿一件东西都要开一次门;静态图则是前一晚就规划好路线,把要带的东西装进一个包里,一次出门全带走。
2.2 算子融合与内存复用:性能提升的两大核心来源
算子融合是推理引擎最经典的优化手段之一。Anakin 在这块做了不少工作,最常见的融合模式包括:
- 卷积 + 批归一化 + ReLU 融合:将卷积的权重和批归一化的缩放、偏移参数提前合并,减少一次全图数据遍历。
- 矩阵乘 + 加(bias)融合:把偏置累加到矩阵乘的 epilogue 阶段,避免单独的内核写回。
- 多个逐元素算子连续融合:比如 Scale、偏移、激活函数等逐元素操作,全部合并到一次内核执行中。
以图像分类模型为例,假设输入是 224×224×3 的图片,网络第一层卷积输出的特征图是 112×112×64。如果不做融合,卷积写一遍显存、批归一化读一遍再写一遍、ReLU 再读一遍再写一遍,中间会产生至少四次全特征图的内存读写。每次读写的量是 112×112×64×4 字节(约 3.2MB),四次就是十几 MB 的流量。别小看这点数据,在 GPU 上,内存带宽往往是比算力更稀缺的资源,计算只花 0.2 毫秒,访存却要花 0.5 毫秒,整体就被访存拖死了。融合后,整个流程变成一次读入、一次写出,中间数据完全留在寄存器或共享内存里,访存开销直接降到三分之一。
内存复用方面,Anakin 的做法属于 arena 内存池思路。它在图初始化阶段就统计每个张量的对齐要求、大小、生存期,然后用一个专用的内存分配器做规划。生存期不重叠的张量共享物理内存,分配和回收只是移动指针的操作,运行阶段完全不碰系统分配器。实测中,这类方案通常能让模型推理的峰值内存占用下降 40% 到 70%,具体数字取决于网络结构的中间层大小。
注意:算子融合不是无脑乱合的,它对图结构有严格要求。只有数据流方向一致、没有分支、没有数据依赖反转的节点才适合融合。在阅读 Anakin 的源码时你会看到,融合逻辑里做了大量的模式匹配,就是为了确保安全性。
2.3 平台无关层与平台绑定层的拆分
Anakin 在架构上把引擎分成了两个层次:平台无关的逻辑层和平台绑定的执行层。
平台无关层负责的是计算图的表示、优化、调度策略。这部分面对的是“模型”,不是“硬件”。比如前面说的算子融合、内存规划、维度推导,都是在这一层完成的。它不关心最终 kernel 是跑在 CPU 上还是 GPU 上,只需要保证输出的图结构是正确的、优化的。
平台绑定层则负责真正干活。每个算子需要针对不同硬件写单独的 kernel 实现,比如同样是矩阵乘,CPU 上要走 AVX2 向量化加多线程分块,GPU 上要走共享内存分块加线程协作。这一层对上暴露统一接口,对下直接调用硬件指令。
为什么要拆得这么清楚?因为优化逻辑放错位置,就会变成一团乱麻。如果把硬件相关的判断逻辑混进图优化里,每加一个新硬件,所有的优化逻辑都要重写一遍。反过来,把图优化放在统一层次,新硬件只需要实现算子库和调度接口,就能复用上层所有的优化成果。这也是 Anakin 能够支持多类硬件但代码却不至于失控的核心原因。
这种分层设计后来在很多推理引擎中成了事实标准,包括后来的 Paddle Lite、各类移动端推理框架,基本都沿用了类似的思路。对做框架开发的同学来说,Anakin 是个很好的学习样本,能直观看到“哪些逻辑应该和硬件解耦”“哪些性能点是优化逻辑给的,哪些是底层 kernel 给的”。
3. 从训练好的模型到 Anakin 推理:一条完整的落地链路
3.1 模型转换:不是简单换个文件格式
拿到一个训练好的模型,想跑在 Anakin 上,第一步是格式转换。这里要特别提醒:模型转换不是“换个文件后缀”,而是把训练框架的计算图翻译成 Anakin 的计算图,同时做一次跨框架的算子映射和结构优化。
我拿一个实际项目来举例。某图像分类模型 X,原始训练是基于某个常见框架完成的,导出格式是普通的前向图模型文件。转换过程大致是:
- 用转换工具读取原始模型文件,解析出网络结构和权重。
- 把原始框架的算子逐层映射成 Anakin 算子,如卷积对应到卷结算子,池化对应到池化算子。
- 对图结构进行预处理,包括常量折叠、冗余节点消除、维度推导。
- 执行算子融合规则,把符合条件的模块合并。
- 输出 Anakin 专用的离线模型文件,其中包含优化后的图结构和融合后的权重数据。
这整个流程的核心难点在于算子映射的覆盖率。如果模型里有一个 Anakin 不支持的算子,转换就可能失败,或者只能走一个很慢的回退实现。实际项目中,我遇到最多的情况是模型里包含自定义算子或者比较冷门的算子,比如某些上采样方式或者特殊归一化。这时候有两个解决办法:一是改写模型结构,用支持的基础算子组合去等价替代;二是在 Anakin 中扩展自定义算子接口,把不支持的算子注册进去。前者工作量小但可能改变数值精度,后者工程量更大但更干净。
转换完成后,建议做两件事:对比转换前后的输出张量数值是否一致(误差一般控制在 1e-4 级别以内),以及在验证集上跑一遍推理结果,确认精度没有明显损失。
3.2 用 C++ API 接入业务:最小可用示例
Anakin 的对外接口以 C++ 为主,这对线上服务来说反而是优势,因为 C++ 的服务可以直接嵌进已有的后端架构,不引入额外的运行时依赖。下面是一段最基础的调用流程,我加上了必要的注释:
#include "anakin/graph/graph.h" #include "anakin/workspace/workspace.h" #include "anakin/saber/core/tensor.h" #include <iostream> #include <vector> int main() { // 1. 加载离线模型,得到优化后的计算图 anakin::graph::Graph graph; graph.load("/path/to/model.anakin.bin"); // 2. 创建工作空间 anakin::graph::WorkSpace workspace; // 3. 创建执行器,这一步会完成内存池规划、kernel 择优选型 auto engine = workspace.create_executor(graph); // 4. 构造输入张量,224x224 的 RGB 图像,归一化后数据 std::vector<float> input_data(224 * 224 * 3); // ... 填充图像数据 ... // 5. 获取输入输出张量的句柄 auto* in_tensor = engine->get_in("input"); auto* out_tensor = engine->get_out("output"); in_tensor->copy_from(input_data.data()); // 6. 执行推理 engine->prediction(); // 7. 读取输出 std::vector<float> output_data(out_tensor->size()); out_tensor->copy_to(output_data.data()); // ... 解析结果 ... return 0; }这段代码虽然短,但背后做的事情不少。第 3 步创建执行器时,引擎会做目标硬件的 kernel 选择:同一层卷积在 GPU 上可能有多种 kernel 实现,有的适合小通道数,有的适合大通道数,引擎会在初始化阶段根据实际 shape 选择最优版本。第 6 步的prediction()才是真正在跑计算,此时不会再有任何图分析、内存分配之类的额外开销。
如果你需要在多线程场景下跑推理,有几个关键配置值得注意:
- 线程数:CPU 场景下需要显式指定 OpenMP 线程数,默认值未必合适。
- 设备 ID:GPU 场景下需要指定显存设备和计算流,避免和其他服务抢占。
- 复用执行器:执行器一旦创建,尽量重复使用,不要在每次请求时重建,否则初始化开销会全部找回来。
3.3 CPU 与 GPU 部署的差异与选择
一个模型到底放 GPU 还是 CPU 上跑,不能只看算力高低,要结合业务形态来判断。我的经验是分三类场景看:
第一类是单路延迟敏感场景,比如在线搜索排序,每次请求只推理一条样本,延迟要求极短。这类场景在 CPU 上如果优化得好,往往能跑出接近 GPU 的效果,因为瓶颈主要在单次推理的时延,而 CPU 的低调度开销反而有优势。
第二类是批量吞吐场景,比如离线批量处理积压数据,一次推理一批几百条样本。这时候 GPU 的并行优势就体现出来了,吞吐量可以比 CPU 高一个量级。
第三类是资源受限的边缘场景,比如功耗和成本卡得很死,GPU 放不下,只能在 CPU 上做算子级的深度优化。Anakin 的 CPU 后端针对卷积、矩阵乘这类密集算子做了专门的指令集优化,配合多线程并行,实际能压榨出的性能可能比自己手动调优还要好。
举一个实际压测的例子:同样一个图像分类模型,输入 224×224×3,batch 取 1,在高端服务器 CPU 上单线程跑大约是 15 毫秒,开满多核后能压到 5 毫秒;而在同档次的 GPU 上单次约 3 毫秒。但如果 batch 提到 32,CPU 上延迟会涨到几十毫秒,GPU 却还能维持在 5 毫秒左右。这就很清楚说明了选型逻辑:低并发、低延迟靠 CPU,高并发、大吞吐靠 GPU。
4. 踩坑实录:我在实际接入中遇到的问题与排查
4.1 模型转换失败:先查算子覆盖,再查图结构
模型转换失败基本属于所有接入者都会碰到的第一道坎。最常见的报错有两类:一是提示某个算子类型找不到映射,二是提示某个输入维度无法推导。
第一种情况,优先去算子列表里查一下,看是不是冷门算子。如果是,就走等价替换路线,把模型里那个算子用基础算子重写。比如某些模型里的特殊归一化其实可以拆成减法、乘法和加法三个基础算子,虽然效率稍微低一点,但至少能跑通,后续再逐步优化。
第二种情况,一般是模型的输入维度是动态的,比如用了类似变长输入的特性。Anakin 要求转换时能够确定每个张量的形状,遇到这种情况可以先固定一个典型输入尺寸做转换,或者在代码里显式标记输入维度。实际业务中,绝大多数模型的输入都是固定 size 的图片或定长序列,所以这个问题不常见,但一旦遇到就很磨人。
4.2 多线程性能不升反降:绑定核数和 NUMA 的教训
我遇到过最诡异的一个性能问题是:设置线程数从 4 调到 8,推理延迟反而从 20 毫秒涨到了 35 毫秒。排查了很久,最后发现是线程调度和内存访问的局部性出了问题。
现在的服务器基本都是多路 CPU,内存访问存在 NUMA 架构,简单说就是每个 CPU 访问自己附近的内存快,访问远端内存慢。如果线程在不同 CPU 之间随意迁移,或者线程访问的内存恰好落在远端,性能就会严重劣化。Anakin 的多线程主要依赖 OpenMP,默认情况下线程是可以在核间自由迁移的,这在单路 CPU 上问题不大,但在多路服务器上就会引发上述现象。
解决办法是在启动时设置线程亲和性,把工作线程绑定到固定的物理核上,同时确保每个线程处理的数据块尽量放在本地内存。具体到代码上,可以通过环境变量或线程绑定接口来控制。比如:
export OMP_NUM_THREADS=8 # 开启线程绑定 export OMP_PROC_BIND=true export OMP_PLACES=cores这里要特别提醒:线程数不是越大越好。当线程数超过物理核数,超线程可能会增加切换开销。一般建议线程数等于物理核数,而不是逻辑核数。比如一台机器是 16 核 32 线程,设OMP_NUM_THREADS=16往往比 32 更快,因为 32 线程会导致两个线程抢同一个核的 L1/L2 缓存。
还有一点细节容易被忽略:改造完线程配置后,一定要用perf stat看看 cache-miss 的比例。如果 cache-miss 很高,说明数据布局或者线程映射还有问题,单纯加线程数是治标不治本。
4.3 动态输入尺寸:不改业务也能跑通的几种做法
线上业务的输入尺寸经常会有波动。比如做目标检测时,原始图大小不一,既不可能全部踩推理引擎推荐的固定尺寸,又不能强行 resize 到奇怪的比例丢失检测精度。
Anakin 这类静态图引擎处理动态输入,我的经验是三种做法混用:
第一种是 padding 策略。把所有输入先等比缩放,然后补边到固定尺寸,比如宽高统一到 416×416。这样做牺牲了一点点计算量,但推理引擎可以完整体验到静态图优化带来的全部收益。
第二种是固定 batch 和缓存池。把输入尺寸档位预先定义好,比如小图 320×320、中图 416×416、大图 608×608,引擎启动时对每档尺寸都做一次内存规划。请求来了,按实际 size 选最接近的一档填充。这样既实现了动态尺寸,又避免了反复的内存分配。
第三种是如果尺寸确实不可控,那就只能在图转换时放宽维度限制,但这样会失去部分静态优化的效果,性能会打折扣。所以最优顺序是:先试 padding,再试档位预分配,最后才是放宽限制。
5. 选型与后续扩展:关于推理引擎的个人经验
最后聊一点我在实际操作中的体会。推理引擎的选择,不能只看单次的 benchmark 数据,还要看整个部署链路的完整度。Anakin 的价值在于它把“高性能”和“多平台”这两个诉求提前在引擎层解决了,你不需要自己去调指令集、写融合规则、管显存规划,只需投入一次模型转换的工程成本,后续换硬件全都通用。
如果你正在做一个全新的推理服务,我建议先想清楚几个问题:线上流量是大报文还是小报文?延迟敏感还是吞吐敏感?硬件环境是固定的还是可能变了?想清楚这三件事,再决定要不要引入一个深度的推理引擎。对大多数场景来说,答案都是要的,因为训练框架直接做部署导致的资源浪费,在流量上来后是非常可观的。
Anakin 项目虽然看起来不那么活跃了,但它的很多设计思想都沉淀到了后来的框架中。如果你对算子融合、静态图优化、多平台抽象有兴趣,直接去读它的源码,比读任何分析文章都管用。建议从模型转换模块和计算图优化模块入手,这两个模块的逻辑最独立,也最能帮助你理解“推理加速”这个命题的本质。