CANN离线推理链路解析:四层握手与调度时序
2026/9/23 3:23:35 网站建设 项目流程

在啃cann-recipes-infer这套代码的时候,OfflineInference、Scheduler、ExecutionEngine、ModelWorker这四个名字反复出现在同一条调用链上。如果你和我一样,一开始只是零散地读每个类,就会陷入一个很别扭的状态:每个类单独看起来都讲得通,CreateSessionRegisterTaskExecuteWaitTaskDone,方法名写得明明白白,但真要把一次完整的离线推理请求从进入到返回串起来,却总觉得中间接不上。直到我花了两个晚上把一次真实任务的调用栈从头到尾跟了一遍,才意识到这条链路的设计核心不在某个类的内部实现,而在层与层之间的握手方式。这篇文章就把这条执行链路完整拆开,从入口拉到计算发生的最后一环,顺便把代码里那些不会写进注释的时序细节和容易踩的坑一起讲清楚。

1. 先把整条链路画在脑子里:OfflineInference怎么把请求递进框架

1.1 从init到infer:OfflineInference的两个阶段

class="do-not-post">class="do-not-post">接触过推理框架的人都知道,离线和在线推理的核心区别在于是否常驻服务。cann-recipes-infer里OfflineInference这个类名起得很直白,它面向的就是一次性任务:你把模型路径、输入数据准备好,调用一个接口,等结果回来,进程退出。这种模式下框架不需要维持HTTP服务、也不需要处理多租户,所以它的入口设计比在线推理要简单,但简单不代表草率,恰恰因为它是一次性流程,每一步都必须把资源生命周期管理到位,否则进程退出时不是内存泄漏就是设备锁残留。

OfflineInference在源码里承担了两个阶段的工作。第一个阶段是初始化,对应Init方法:解析模型配置、加载模型文件、创建推理所需的执行环境。第二个阶段是真正的推理,对应Infer方法:把输入数据封装成框架内部的请求对象,然后一路往下传。这两个阶段分开设计的原因很实际——初始化只做一次,但推理可能要跑多轮。例如你要对一批图片做批处理,第一张图进来时初始化已经完成,后续每张图都直接走Infer,不必重复加载模型和解构配置。

但真正让我觉得值得写一篇笔记的点,是Infer里面那几行看似不起眼的“创建请求”和“提交任务”的代码。表面上看,它只是往某个队列里塞了一个对象,但如果你追进去看这个对象的类型,会发现它已经不再是一个纯粹的“输入数据”,而是一个带有上下文、回调函数、状态标记的复杂结构。也就是说,OfflineInference这一层真正的职责不是“执行推理”,而是“把外部输入翻译成框架内部能理解的请求格式”。这就像你去餐厅吃饭,服务员不会直接进厨房炒菜,但会把你的口味偏好翻译成厨房能看懂的厨单。这个厨单,就是框架的请求对象。

1.2 请求从Python侧进入C++侧时的数据结构

如果你是通过Python接口调用的OfflineInference,还会跨过一层语言边界。Python侧把numpy数组或者PyTorch的Tensor转成C++侧的内存对象,这个过程远比分装一个结构体要复杂。源码里通常会做一个Tensor转换:把数据指针、形状、数据类型、设备信息抽取出来,封装进一个框架内部的Tensor描述结构,再把多个Tensor放进一个容器传给下层。这个容器在Scheduler眼里不是随便一个列表,而是带索引的、顺序敏感的输入单元,因为后续的调度、执行、回传都依赖这个索引来对齐结果。

很多人在读到这里时会有个幻觉,觉得“请求不是已经进入框架了吗”。其实还没有。OfflineInference::Infer只是把请求对象交给了调度器,就像你把文件放到了公司前台,前台会告诉你“好的,我帮你交给对应的人”,但文件还没到真正处理它的人手里。从这一刻起,请求的命运就交给了Scheduler。

2. Scheduler的核心循环:谁来决定一个请求现在能不能算

2.1 Scheduler的等待队列与运行槽位

Scheduler这个词在推理框架里已经被用滥了,vLLM有Scheduler,TensorRT-LLM有Scheduler,cann-recipes-infer里也有Scheduler。但每个框架的Scheduler干的事情其实不太一样,差别全在“调度什么”和“怎么调度”上。vLLM的Scheduler调度的是连续批次的token级显存块,目的是提高吞吐;而cann-recipes-infer里这个Scheduler调度的是“任务单元”,粒度更大,瞄准的是资源槽位。它维护了一个等待队列和一个运行集合。等待队列里放的是那些已经通过入口校验但还没获得执行资质的请求,运行集合里放的是已经被分配到推理设备上、正在执行或者等待执行结果的请求。

为什么一定要有这样一个中间层?原因在于算力资源是有限的。不管你的机器上有几张NPU或者GPU,真正能并行的任务数量是有上限的。如果每个请求进来都立刻往下抛,底层模型实例可能会被多个请求同时使用,造成资源竞争甚至数据错乱。Scheduler就是那个守门人:它对每个请求做一次“现在能不能跑”的判断,这个判断不是看请求优不优先,而是看当前有没有空闲的资源槽位。

在源码里,这个逻辑通常表现为一个ScheduleLoop或者Dispatcher线程,它周期性地扫描等待队列,尝试把请求状态从“等待中”改成“已就绪”。扫描间隔不会很短,因为频繁唤醒线程会白白消耗CPU;但也不能太长,否则请求延迟会变高。这个间隔在很多框架里是一个可配置参数,cann-recipes-infer里同样预留了对应的配置项,你可以根据自己的业务容忍度去调整。

2.2 一次调度的计算过程与优先级逻辑

一次具体的调度决策并不是简单地从队列头部拿一个请求,而是要考虑“槽位里还剩几个空位”和“这批请求之间有没有资源冲突”。举例来说,如果两个请求要用同一个模型文件且该模型只支持单实例,Scheduler就不能让它们同时进入运行集合;如果模型支持多实例,那么槽位数就可以放宽。在源码里,这些约束通常被抽成CanSchedule类的判断函数,里面做三件事:检查槽位余量、检查设备资源(显存/内存)、检查请求依赖。这三件事都通过,才能从等待队列摘下来。

优先级逻辑更容易被忽略。代码里往往会为每个请求打一个priority标记,默认情况下所有人都是平等状态,但如果你用接口传入优先级字段,Scheduler就会在执行调度时优先摘取高优先级请求。这个设计在离线场景里看似没必要,实际上很有用——当你需要在一个进程里同时处理多个模型推理,或者某个数据预处理任务必须抢在推理之前完成,优先级就能避免任务饿死。

我在读这段源码时特别留意了一个细节:Scheduler把请求状态改成“已就绪”之后,并不是直接去调用模型执行,而是把请求交给ExecutionEngine。这个交接动作在代码里常常只是一个Submit调用,但背后的含义是“调度完成,执行开始”。自此,请求不再由Scheduler负责,它的生命周期管理权移交给了下一层。

3. ExecutionEngine的任务编排:调度结果如何变成引擎上的可执行单位

3.1 Engine的输入输出缓冲区与步进循环

ExecutionEngine这个类名听起来像要干很多事,实际它的核心职责只有一个:把Scheduler送过来的“已就绪请求”编排成可执行的批次,并驱动计算设备完成计算。它和Scheduler的关键区别在于,Scheduler只负责“能不能做”的判断,Engine负责“怎么排布着做”的策略。你可以把Engine想象成餐厅后厨的案板:Scheduler是排号的服务员,他告诉你这桌客人已经可以入座了,但具体先洗菜还是先切菜,是案板师傅说了算。

源码里Engine通常会维护一组输入输出缓冲区,每个请求进来时,Engine会按请求的模型信息、张量形状、设备信息做一次归类。如果多个请求来自同一个模型且形状相近,Engine就会把它们合并成一个批次,这个动作在CUDA和CANN生态里对应着一个非常关键的概念——“图捕获”或“任务下发时的批量算子融合”。如果你在源码里看到类似BuildBatchCreateTaskList的调用,那大概率就是在做这个步骤。

Engine还有一个很典型的设计是“步进循环”(step loop)。它不同于Scheduler的调度循环,Scheduler的循环是扫描队列,Engine的循环是“不停地从输入缓冲区取数据、下发计算、回收结果”。这个循环既可以跑在同一个线程里,也可以单独起一个线程。在cann-recipes-infer的执行链路里,Engine层往往会通过一个TaskScheduler或者Executor对象来管理设备端的执行流。执行流的概念很关键:同一个模型的计算通常需要按依赖顺序执行,但不同模型之间的计算可以并发。Engine需要为每个独立计算链创建不同的执行流,然后用事件或回调来同步结果。

3.2 引擎层和vLLM的executor有哪些相似的取舍

这里我想插一句vLLM。很多人看到vLLM里EngineCore和Scheduler、Executor之间的交互一头雾水,其实把它拆成“调度决策”和“执行驱动”两层就能看明白。vLLM的Scheduler负责决定哪些sequence可以被decode、需要申请多少显存块;Executor负责把这些sequence真正变成GPU kernel调用。cann-recipes-infer虽然不处理token级调度,但它的Engine和Executor的分工思路高度相似:Engine管任务的逻辑编排,真正贴在设备驱动层的逻辑在ExecutionEngine内部又做了一次隔离。

这种“分层+再分层”的设计不是矫情,而是为了适配不同的硬件后端。你在源码里会看到,Engine层大部分逻辑都是设备无关的:队列操作、批次归类、任务状态管理,这些在任何硬件上都能复用;但真正下发计算的部分,往往被抽象成接口,由NPU、GPU、CPU不同后端各自实现。如果你要在这套代码里加一个新硬件支持,主要工作量就在实现最底层的计算下发接口,而不是去改调度逻辑。

这种抽象也有代价。代价就是你追源码的时候会发现自己在一层层接口之间跳来跳去。一个Execute调用可能经过三次虚函数分发才真正碰到设备API。但反过来也是好事,这意味着上层逻辑的稳定性很高,设备适配的bug不容易传染到调度层。

4. ModelWorker的最后一跳:模型加载、图执行与结果回传

4.1 绑定设备与图会话

终于到了ModelWorker。这一层是我个人觉得整个链路里最“硬核”的地方,因为它直接跟计算设备打交道。在cann-recipes-infer里,ModelWorker通常被设计成专门守护一个模型实例的组件。它知道模型被加载到哪张卡上,知道模型的输入输出张量格式,也持有真正执行推理的图会话或模型句柄。

如果你在代码里看到ModelWorker::Initialize,它一般会做几件事:设置当前线程绑定的设备(aclrtSetDevice)、加载模型文件(aclmdlLoadFromFile)、准备模型描述信息(aclmdlGetDesc)、分配模型输入输出缓冲区。这些缓冲区往往不是在Execute阶段才分配的,而是初始化阶段就一次性申请好。为什么要提前分配?因为设备侧内存申请是一个非常重的操作,如果在每轮推理都重复做,延迟会高得不可接受。提前分配好,每次推理只是往里填数据,这是高性能推理框架的标配做法。

但提前分配也带来了生命周期问题。如果ModelWorker被销毁时没有正确释放设备内存,轻则内存泄漏,重则导致设备上残留未回收的资源,影响同一进程后续创建的新会话。源码里这个释放路径非常容易看漏,因为它是藏在析构函数里,而析构函数可能被异步线程触发。我在一次调试崩溃时发现,问题就出在线程退出顺序和析构顺序不一致上,后面会专门讲。

4.2 计算完成后的数据返还路径

ModelWorker执行一次推理的核心调用通常是Execute(在CANN里对应aclmdlExecuteAsync之类)。这里有一个容易误解的点:异步执行不等于立即返回结果。异步的意思是你把任务下发到设备后,当前线程可以继续做别的事,但结果还没算出来。源码里会用一个aclrtSynchronizeStream或者aclrtWaitEvent之类的方法去等待计算完成。如果模型支持异步回调,也可以用回调函数在计算完成时通知上层。

很多初读源码的人会在这里产生一个认知偏差:以为ModelWorker::Execute返回了就代表推理完成。实际上这个返回值往往只说明“任务已经成功提交到设备”,而不是“结果已经写回缓冲区”。真正的完成信号来自同步点或者回调。在链路里,这个同步点最终会决定什么时候把结果送回上层。

结果回传路径也很有意思。ModelWorker计算完成后,结果还是设备内存里的数据,需要拷回主机内存,再封装成上层能识别的Tensor结构。这个拷贝操作在数据量大的时候会成为性能瓶颈。源码里通常会做几个优化:复用预先分配的主机内存缓冲区、支持D2D拷贝(如果结果还要继续给另一个设备用)、以及在连续多次推理时把拷贝操作和计算操作放在不同执行流里做流水线重叠。

5. 对照源码追一遍完整时序:从请求进入到结果返回的握手关系

5.1 用一张时序表把四层握手摆出来

把前面四层理清楚之后,我觉得有必要把整条调用时序用文字再压一遍,因为源码读的时候很容易在层与层的交接处迷失。

阶段OfflineInferenceSchedulerExecutionEngineModelWorker
1Init加载配置,创建会话初始化队列和运行槽位初始化执行流和输出缓冲绑定设备,加载模型,分配io空间
2Infer封装请求为Task接收Task,加入等待队列等待调度结果空闲等待任务下发
3等待结果回调扫描等待队列,检查槽位,将状态改为就绪从就绪队列取任务,合并批次,创建设备任务接收具体计算任务
4调用Execute下发批量计算填输入缓冲,启动异步计算
5释放对应槽位等待同步/回调,准备下一轮计算完成,拷回主机内存
6从回调中拿到结果封装修饰最终Tensor结果写回预分配缓冲区
7返回给调用者进入下一轮空闲状态

这张表看起来简单,但它对应的代码路径其实牵涉到线程切换和状态同步。读源码时不只是在读某个方法的执行体,还要在脑子里给不同线程各自画一条时间线,然后再判断它们的交汇点在哪里。多数bug都出在这些交汇点上。

5.2 源码里那些不显眼却决定成败的字段

有几个字段值得单独拿出来说,它们既不会主动打印日志,也不会出现在接口文档里,但直接影响链路能不能跑通。

第一个是请求的“状态机”。从WAITINGREADY,从READYRUNNING,从RUNNINGDONEERROR。这个状态在源码里通常是一个枚举字段,但它被多个线程读写,线程安全就很重要。如果你看到有人用原子变量或者锁来保护它,那绝对是在踩坑之后加上去的,而不是一开始就设计好了。

第二个是“请求ID”。它看起来只是一个递增的整数,但在结果回传时,上层必须靠这个ID把结果对应到具体请求。如果在链路某一层把ID弄丢了,结果匹配就会错乱。我在实际使用中遇到过类似的错乱,最后定位到是拷贝结构体时漏了这个字段。

第三个是“设备流句柄”。Engine给ModelWorker下发任务时,必须指定用哪条执行流。如果多个任务共享同一条流,它们之间天然串行;如果想并发,就需要不同的流。源码里经常出现aclrtCreateStream之后没有显式保存,导致后续只能用默认流,性能大打折扣。这一点不容易从日志里看出来,但实测吞吐差距很明显。

6. 源码阅读过程中我踩过的几个坑:线程、流与销毁顺序

6.1 线程侥幸心理导致的崩溃

读这套源码时,第一个让我差点砸键盘的问题是线程安全。Scheduler和Engine通常跑在不同的线程里,而ModelWorker的初始化可能又在另一个线程。如果你按“单线程思维”去读这些类,会觉得它们各自的内部逻辑都没有问题,但一旦有并发场景,状态变量的读写顺序就会变得不可控。

我遇到的一个典型案例是:期望一个模型实例被多个请求复用,但在并发提交时没有做好互斥,导致两个请求同时往同一个设备输入缓冲区写数据,结果第二个请求的输入把第一个请求的输入覆盖了,推理结果直接乱掉。这个问题的根因不是ModelWorker有没有加锁,而是ExecutionEngine在编排任务时没有对同一个ModelWorker实例做串行化访问。传统上应该在Engine层维护每个ModelWorker当前是否忙碌的标志,这个标志的更新必须是原子的,否则就会踩到上述竞态。

6.2 执行流与销毁顺序的坑

另一个典型的坑是aclrtDestroyStream的调用时机。ModelWorker持有自己创建的执行流,正常流程是推理全部结束、确认没有在途计算时再销毁流和模型实例。但如果你把销毁逻辑放在Engine的析构里,而Engine的析构又被某个异步回调触发,就很容易出现“回调还在跑,流已经销毁”的尴尬局面。设备侧的异步接口对这种问题尤其敏感,因为“在途任务”不一定能在销毁调用返回前真正结束。

解决的办法也不是特别复杂:要么在销毁前显式同步所有相关流,要么通过引用计数管理生命周期,让最后释放对象的人来执行销毁。源码里如果能找到一个WaitForAllTaskDone之类的方法,那基本就是为了这个目的存在的,千万别图省事跳过去。

6.3 日志噪音掩盖真正错误信息

最后一个坑跟代码无关,但跟排查效率强相关。这套框架在出错时会打印大量设备侧日志,而设备侧的日志默认会覆盖到每个算子级别,导致真正有用的错误信息被淹没。我读到ModelWorker的GetLastError相关代码时,发现框架本身提供了错误码转字符串的接口,但调用者的日志级别往往没设对,导致只看info看不到error。

如果你也在调试这条链路,建议直接开启ACL_ERROR级别的日志,并且在上层捕获到异常时把请求ID一并打印出来。否则你可能花一下午在几十万行日志里找一条真正的错误原因。

一点经验收尾

最后说一个纯粹属于个人经验的东西。读这种多层调用链的源码,最好的方式不是从入口开始一直往下啃,而是“两头夹”:先从入口理解请求的封装格式,再从ModelWorker理解计算的最小单元,最后再去看Scheduler和Engine怎么把这两头粘起来。这样你对“请求”和“计算”都有了清晰的锚点,中间层的调度和编排就变得容易理解了。

我按这个思路把cann-recipes-infer读下来之后,再去看vLLM的EngineCore和Scheduler、Executor交互流程,明显感觉顺畅了不少。很多推理框架的命名不同、粒度不同,但拆分逻辑是相似的:入口层负责翻译请求,调度层决定资源分配,执行层负责批量编排,Worker层负责真正碰硬件。只要你能沿着这条主线把每个类的职责边界画出来,后续再读别的推理框架,都会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询