
在啃cann-recipes-infer这套代码的时候OfflineInference、Scheduler、ExecutionEngine、ModelWorker这四个名字反复出现在同一条调用链上。如果你和我一样一开始只是零散地读每个类就会陷入一个很别扭的状态每个类单独看起来都讲得通CreateSession、RegisterTask、Execute、WaitTaskDone方法名写得明明白白但真要把一次完整的离线推理请求从进入到返回串起来却总觉得中间接不上。直到我花了两个晚上把一次真实任务的调用栈从头到尾跟了一遍才意识到这条链路的设计核心不在某个类的内部实现而在层与层之间的握手方式。这篇文章就把这条执行链路完整拆开从入口拉到计算发生的最后一环顺便把代码里那些不会写进注释的时序细节和容易踩的坑一起讲清楚。1. 先把整条链路画在脑子里OfflineInference怎么把请求递进框架1.1 从init到inferOfflineInference的两个阶段classdo-not-postclassdo-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有SchedulerTensorRT-LLM有Schedulercann-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生态里对应着一个非常关键的概念——“图捕获”或“任务下发时的批量算子融合”。如果你在源码里看到类似BuildBatch、CreateTaskList的调用那大概率就是在做这个步骤。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 用一张时序表把四层握手摆出来把前面四层理清楚之后我觉得有必要把整条调用时序用文字再压一遍因为源码读的时候很容易在层与层的交接处迷失。阶段OfflineInferenceSchedulerExecutionEngineModelWorker1Init加载配置创建会话初始化队列和运行槽位初始化执行流和输出缓冲绑定设备加载模型分配io空间2Infer封装请求为Task接收Task加入等待队列等待调度结果空闲等待任务下发3等待结果回调扫描等待队列检查槽位将状态改为就绪从就绪队列取任务合并批次创建设备任务接收具体计算任务4——调用Execute下发批量计算填输入缓冲启动异步计算5—释放对应槽位等待同步/回调准备下一轮计算完成拷回主机内存6从回调中拿到结果—封装修饰最终Tensor结果写回预分配缓冲区7返回给调用者——进入下一轮空闲状态这张表看起来简单但它对应的代码路径其实牵涉到线程切换和状态同步。读源码时不只是在读某个方法的执行体还要在脑子里给不同线程各自画一条时间线然后再判断它们的交汇点在哪里。多数bug都出在这些交汇点上。5.2 源码里那些不显眼却决定成败的字段有几个字段值得单独拿出来说它们既不会主动打印日志也不会出现在接口文档里但直接影响链路能不能跑通。第一个是请求的“状态机”。从WAITING到READY从READY到RUNNING从RUNNING到DONE或ERROR。这个状态在源码里通常是一个枚举字段但它被多个线程读写线程安全就很重要。如果你看到有人用原子变量或者锁来保护它那绝对是在踩坑之后加上去的而不是一开始就设计好了。第二个是“请求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层负责真正碰硬件。只要你能沿着这条主线把每个类的职责边界画出来后续再读别的推理框架都会轻松很多。