1. 从一道面试题说起:算子到底是什么
我入行做推理引擎优化这些年,被问过最多的一个问题不是“你优化过什么模型”,而是——“你说你在优化算子,那算子到底是什么?它和运行时又是什么关系?”
每次遇到这种问题,我都会想起自己刚接触深度学习框架时的困惑。那时候看框架源码,满屏都是Operator、Kernel、Runtime,每个词都认识,连在一起就不知道它们在干什么。后来在某个深夜调一个卷积算子精度问题,调了整整六个小时,突然想明白了一个类比:算子是菜谱,运行时是厨房。
菜谱上写“土豆切块、下锅翻炒、加盐出锅”,这就是算子——它描述“做什么”和“怎么做”。厨房里有人洗菜、有人切菜、有人管火候、有人盯时间,这就是运行时——它负责把菜谱变成一盘真正能端上桌的菜。没有菜谱,厨房不知道该干什么;没有厨房,菜谱永远只是一张纸。
这个类比陪我走过了很多项目,后来我把它用在了技术分享里。所谓“第05章-算子与运行时”,其实讲的就是深度学习框架里最核心的两个抽象:算子(Operator)定义了计算逻辑,运行时(Runtime)负责调度和执行。今天我想把这章内容拆开揉碎了讲清楚,不讲那种只能应付考试的概念,而是从源码实现和实际部署的角度,看看这两者到底是怎么协同工作的,以及你在写代码、搭框架、调性能的时候,应该如何理解和利用它们。
这篇文章适合这几类人看:
- 正在读框架源码,对
Op和Runtime的边界感到模糊的初学者 - 需要自定义算子接入现有训练/推理框架的工程开发
- 做推理引擎或编译优化,需要理解算子调度原理的性能优化工程师
不管你属于哪一类,看完这篇文章,你应该能回答三个问题:算子层和运行时层分别管什么、它们之间的接口如何设计、以及遇到性能瓶颈时应该去改哪一层。
2. 算子层:从数学公式到硬件指令的桥梁
2.1 算子的本质:一种描述,而非一种实现
先明确一个很容易混淆的点:算子(Operator)本质上是数学层面的描述,不是代码层面的实现。
比如矩阵乘法算子,它描述的就是C = A × B这个数学操作。至于这个操作在CPU上用AVX指令算,在GPU上用CUDA Core算,在NPU上用矩阵单元算,那是Kernel(内核)的事,不是算子的事。算子只负责说“我要做一个矩阵乘法”,不负责说“这个乘法怎么算”。
这个分离非常关键。因为它让框架层、算子层、内核层各司其职:
- 框架层负责构图:把神经网络描述成一张有向无环图(DAG),每个节点是一个算子
- 算子层负责描述:定义每个节点的输入输出、属性参数、形状推导规则
- 内核层负责执行:针对不同硬件提供算子的具体实现,也就是Kernel
我们平时说的“写一个算子”,大部分时候其实是在写两样东西:算子的定义(描述)和算子的内核(实现)。在成熟的框架里,这两块通常是分开的,用不同的语言、放在不同的目录里。以某个主流框架为例,算子定义通常写在op_def.cc里,GPU内核写在op_kernel.cu里,CPU内核写在op_kernel.cc里,三者通过注册宏绑定在一起。
注意:算子定义和内核实现分离,是工程上的刻意设计,不是随意为之。它让你在新增硬件支持时,不用改动任何已有的计算图描述代码,只新增对应硬件的Kernel即可。
2.2 算子注册:框架如何知道你写了什么
假设你现在要写一个自定义算子,叫“加权求和”,逻辑很简单:输入两个张量,计算output = weight * a + b,其中weight是超参数。你的任务不只是写实现代码,还得让框架认识这个算子,这个过程就叫算子注册。
注册要解决的问题有三个:
第一,建立算子的唯一标识。每个算子需要一个名字,这个名字在计算图里作为节点的类型标识。注册宏一般长这样:REGISTER_OP("WeightedSum")。注意这个名字必须全局唯一,而且最好有命名空间风格,避免和其他算子冲突。
第二,声明算子的接口签名。包括输入参数列表、输出参数列表、属性参数列表。比如输入有两个张量a和b,属性有一个浮点数weight,输出有一个张量output。这一步决定了图优化器怎么理解你的算子,也是后续自动求导、形状推断的基础。
第三,绑定内核实现。告诉框架:这个算子,在什么设备上,用哪个函数实现。注册宏可能是REGISTER_KERNEL_BUILDER(Name("WeightedSum").Device(DEVICE_GPU), WeightedSumGPUKernel)。这句话的意思是:如果这个算子被放置在GPU上执行,就调用WeightedSumGPUKernel这个函数。
我当年第一次写算子注册的时候犯过一个低级错误:改了算子实现,忘了改注册的版本号或重新编译,结果图优化器总是匹配到旧的实现,排查了一整个下午。后来我养成一个习惯:任何算子的修改,先看注册入口,再看实现体,确认两者同步再动手编译。这个习惯帮我省掉了大量无谓的debug时间。
2.3 算子的形状推断与类型检查:为图优化铺路
算子定义里还有一个不太起眼但极其重要的部分:形状推断(Shape Inference)。它做的事情是:给定输入张量的形状,推导输出张量的形状。
为什么这很重要?因为计算图的构建往往是“懒”的——节点先建好,数据流在执行时才真正流动。但图优化器、内存规划器、设备放置器都需要在构图阶段就知道每个张量的形状,才能提前做内存复用和调度规划。没有形状推断,整个优化都没法进行。
拿卷积算子举例:输入是[N, C, H, W],卷积核大小是K,步长是S,填充是P,输出形状的高宽就是(H + 2P - K) / S + 1。这个公式必须在算子定义里写清楚,框架才能在构图阶段算出后续所有张量的形状。
类型检查也同样重要。有些算子只支持浮点类型,你传个整数张量进去,需要在构图阶段就报错,而不是等到执行阶段才炸出难以定位的运行时错误。形状推断和类型检查本质上是在构图阶段做静态验证,把一大批错误提前拦截。
我见过不少初学者在自定义算子时把这部分跳过了,觉得“反正实现对了就行”。等到框架做静态图优化时,你的算子因为缺少形状推断导致整个图无法优化,只能退回动态图模式,性能掉一个数量级,这才后悔当初没写。算子的形状推断接口是标准接口的一部分,不是可选项。
3. 运行时层:算子是零件,运行时才是那台机器
3.1 运行时在Framework里的位置
如果说算子层关注的是“计算是什么”,那运行时层关注的就是“计算怎么跑起来”。一个完整的深度学习运行时,至少要管理四件事:设备管理、内存管理、任务调度、流同步。它们共同决定了一个计算图的执行效率,而算子只是被调度和执行的对象。
你可以这么理解:算子定义了计算图的节点,运行时则定义了计算图的“玩法”——谁先执行、在哪执行、用什么资源执行、执行完结果放哪。
在这个层面,有三个关键设计几乎贯穿着所有主流框架:
第一个是设备上下文(Device Context)。它封装了设备和硬件相关的状态,包括设备ID、显存分配器、流句柄等。每个算子在被执行时,都需要依赖设备上下文来获取资源。设备上下文的意义在于:把设备相关的复杂逻辑隔离在一个抽象接口后面,让算子开发者不需要关心底层硬件细节。
第二个是执行流(Stream)机制。这个大家可能比较熟,尤其做GPU优化的人。流可以理解为一个按序执行的任务队列。你把一系列内核启动放同一个流里,它们就会按顺序执行;放不同流里,它们可能并行执行。流的调度策略直接决定了计算和拷贝能否重叠、并发内核能否同时运行。
第三个是执行器(Executor)或调度器(Dispatcher)。它负责遍历计算图,决定每个节点的执行时机。调度策略有静态顺序、动态拓扑等,不同策略就对应不同的并发度和资源利用率。
关键认知:运行时不做数学计算,但它决定数学计算跑多快。两套实现完全相同的算子,放在不同的运行时调度策略下,性能差距可以超过一倍。这就是为什么性能优化不能只盯着算子实现,必须算子和运行时一起看。
3.2 执行模式的演化:从静态图到即时编译
运行时层这些年最大的变化,是执行模式的演化。早期深度学习框架普遍采用静态图执行:先完整构图,再整体执行。静态图的好处是优化空间大——框架在构图完成后可以对整个图做融合、剪枝、常量折叠等优化,然后生成一个高效的执行计划。坏处是调试困难,你想打印中间结果都得动用特殊手段,非常不直观。
后来动态图火了:边定义边执行,写起来像写普通程序一样自然,调试体验极好。但动态图的性能天然吃亏,因为每次执行都要重新解释一遍计算图,没有静态图那样的整体优化空间。
再后来的趋势,是动静统一和即时编译:把用户写的Python层代码自动追踪成计算图,然后由编译器级别的运行时把它编译成高效的执行计划。这种模式里,运行时的角色不再是简单的“调度器”,而是融合了图优化、代码生成、内核编译的复杂系统。
这个演进的背后逻辑其实很朴素:开发者想要动态图的体验,同时想要静态图的性能。运行时就是那个用工程手段调和这对矛盾的角色。
3.3 运行时调度究竟在“调”什么
调度这个词听起来抽象,我把它的核心拆成三个决策:
第一个是设备放置决策。一个算子放在CPU还是GPU上执行?这个决策可以静态做(用户在代码里指定),也可以动态做(运行时根据负载情况自动分配)。一个典型场景:数据加载和预处理算子放CPU,模型计算算子放GPU。如果图优化器能在构图时就把这种放置给安排好,执行时就能避开CPU和GPU之间的频繁数据拷贝。
第二个是并发决策。多个相互独立的算子,是串行执行还是并行执行?这关系到依赖分析。运行时需要维护一张“谁依赖谁”的拓扑关系图,找出可以并行的节点集合,然后通过多流或多线程让它们同时跑。这里有一个很多人忽略的细节:算子之间的依赖不只是数据依赖,还有资源依赖。比如两个算子都要用大块显存,即使数据上完全独立,也不适合同时执行。
第三个是融合决策。两个相邻的算子,能不能合并成一个算子?比如“卷积 + 偏置 + ReLU”在大多数框架里已经被融合成一个算子,因为这样能减少Kernel启动次数和中间张量的内存读写。融合决策是运行时和编译器协作完成的,这是性能优化的最大金矿之一。
我记得有一个优化案例,某模型里有大量的小算子,每个执行时间只有几十微秒,但启动开销和内存访问开销很大。通过运行时层的算子融合,把三四十个小算子合并成五六个大算子,端到端推理延迟降了接近一半。这就是调度的力量,不是靠改某个算子的实现,而是靠调整算子的执行组织方式。
4. 协同工作机制:算子和运行时如何握上手
4.1 从构图到执行的完整链路
理解了算子层和运行时层的职责,接下来看看它们在实际执行中是怎么协作的。我以一次完整的推理调用为例,拆一下各层干的事。
第一步:构图。你调用框架的API构建模型,每个API调用在底层创建一个算子节点,节点之间按数据依赖连边,形成一张计算图。这一步中,算子定义里的形状推断和类型检查已经被调用过了,图里每个张量的形状和类型都是清晰的。
第二步:图优化。计算图传给运行时后,先经过一系列图优化pass。比如算子融合pass检查相邻节点能不能合并,常量折叠pass检查有没有可以预先算好的子图。这些pass的操作对象是算子节点,但pass框架本身是运行时层的东西。
第三步:内存规划。优化后的图进入内存规划阶段。运行时根据每个张量的生命周期,规划它们在内存中的复用布局。这一步完全没有执行任何计算,但它决定了每次执行时的内存分配开销。
第四步:内核绑定。每个算子节点需要找到它在目标设备上的内核实现。这个查找工作通常用算子类型加上设备类型作为键,去注册表里查。查到的内核函数会被包装成可执行的任务。
第五步:任务调度执行。运行时启动执行器,按照优化后的执行顺序,把内核任务下发到设备上执行。GPU场景下,这些任务被提交到Stream里异步执行;CPU场景下,分发给线程池里的工作线程。
以上这五步,前三步属于“编译期”或“构图期”,后两步属于“执行期”。在静态图模式下,前四步只做一次,第五步可以重复执行多次——这就是静态图性能好的根本原因:编译开销被摊薄了。
4.2 注册表模式的妙处
整个协作链条里,最关键的机制是注册表模式。算子注册和内核绑定都依赖一张全局注册表,这张表以“算子名 + 设备类型”为键,存着算子定义、形状推断函数、内核工厂函数。
为什么用注册表而不是硬编码的if-else?因为注册表模式天然支持扩展。你写了一个新算子、实现了一个新内核,只需要在代码里调用注册宏,这个算子就自动被框架全局可见。不需要去改框架核心代码,不需要重新编译别人的代码。
这个机制不仅是工程上的便利,更是生态建设的基础。框架通过注册表开放了能力,第三方开发者通过注册表贡献算子实现。你去看那些成熟的算子库,本质就是一堆注册宏的集合:REGISTER_KERNEL_BUILDER(...)一行接一行,每行完成一个算子内核的接入。
4.3 一个从零构建算子的完整示例
为了把上面的概念落到地上,我实际演示一个从零构建算子的过程,假设我们用Python风格的方式来实现,这里用一个简化伪代码来说明逻辑:
# 1. 定义算子,声明接口 @ops.register("WeightedSum") class WeightedSumOp: """计算 output = weight * input_a + input_b""" def __init__(self, weight): self.weight = weight def infer_shape(self, input_a_shape, input_b_shape): # 形状推断:两个输入形状必须一致 assert input_a_shape == input_b_shape, "输入形状不一致" return input_a_shape # 输出形状与输入一致 def validate_type(self, input_a_dtype, input_b_dtype): # 类型检查:只支持浮点类型 assert input_a_dtype in ["float32", "float64"], "仅支持浮点类型" assert input_b_dtype == input_a_dtype, "输入类型不一致" # 2. 实现CPU内核 @ops.register_kernel("WeightedSum", device="cpu") def weighted_sum_cpu(input_a, input_b, weight): return weight * input_a + input_b # 3. 实现GPU内核 @ops.register_kernel("WeightedSum", device="gpu") def weighted_sum_gpu(input_a, input_b, weight): # 这里假设使用某种GPU编程接口实现 return gpu_parallel_elementwise( lambda a, b: weight * a + b, input_a, input_b ) # 4. 使用算子构建计算图 a = graph.input("a", shape=(64, 64), dtype="float32") b = graph.input("b", shape=(64, 64), dtype="float32") op_node = graph.add_node("WeightedSum", inputs=[a, b], attrs={"weight": 0.5}) output = graph.output(op_node)这个示例虽然用伪代码写,但逻辑和实际框架是吻合的。每一步都能对应到我前面讲的概念:register对应注册表机制,infer_shape对应形状推断,register_kernel对应内核绑定。你把这个流程跑通了,自定义算子的基本能力就算掌握了。
我建议你实际操作时,从单核CPU实现开始,不要一上来就写GPU内核。CPU版本逻辑简单、容易调试,验证正确性也方便。等CPU版结果正确了,再迁移到GPU上做并行化。跳过这一步直接写GPU内核,一旦结果不对,你很难分清是并行逻辑错了还是算子定义错了。
5. 真刀真枪的踩坑记录:算子接入运行时遇到的那些问题
5.1 形状推断错误造成的连锁崩溃
我在一个项目里遇到过这样一件事:某个自定义的稀疏算子,形状推断写错了——返回的输出形状比实际大了一圈。结果运行时在内存规划阶段给这个算子多分配了显存,后面的算子虽然正常工作,但在最终的数值输出阶段,发现结果里多了一块莫名其妙的数据。
排查过程非常折磨,因为问题不出在执行逻辑,而出在构图阶段。最后是通过对比算子的推断形状和实际输出形状才定位到问题。这件事告诉我:算子的形状推断不是“辅助功能”,而是运行时安全性的第一道防线。推断错了,后面每一步都可能错,而且错误会传导到看起来完全不相关的环节。
后来我在设计算子时,会先单独写形状推断的单元测试,验证各种边界情况——空张量、零维张量、形状不匹配的输入。通不过测试就不往下写实现。这个习惯虽然一开始有点繁琐,但长期来看非常省钱。
5.2 流同步缺失导致的随机性错误
另一个印象深刻的问题是流同步。当时写一个融合算子,包含两个GPU内核,设计上应该在一个流里顺序执行。结果实现时,两个内核被提交到了不同的流上,而且没有做事件同步。
症状表现为:程序大部分时候运行正常,但隔一段时间会随机出现一次结果错误。这种随机性错误最麻烦,因为难以复现,而且时机无规律。我当时花了很长时间才意识到是流同步问题——两个流并行执行,数据写入和读取产生了竞争。
排查的突破口是:把两个内核强制放到同一个流里,错误消失了。这就锁定了问题方向。从那以后我养成了一个习惯:涉及多流的地方,先画一张流依赖图,标清楚每个内核在哪个流上、哪个事件等哪个事件,再动手写代码。不要凭感觉安排流。
经验:流同步问题的典型特征就是“间歇性错误”。如果你的GPU算子时不时出错,而且无法稳定复现,第一个怀疑对象就是流同步。
5.3 算子融合带来的精度漂移
还有一个容易踩的坑是融合算子的精度问题。算子融合和单独执行在数学上应该是等价的,但浮点运算不满足结合律,融合后中间结果的舍入方式变了,会导致微小精度差异。大部分场景下这个差异可以忽略,但在一些极致精度要求的场景下会成为问题。
我遇到过一次:模型融合后精度测试差了一个千分位,看起来很小,但就是过不了测试标准。排查后发现是融合后的Kernel把中间结果从float32降到了float16,而原始实现全程保持float32。把这个精度设置改回来,差异消失。
所以,如果你做算子融合,一定要在融合前记录基准精度,融合后做对比测试。不要默认“融合就是无损的”。合适的经验准则是:融合的核心收益来自减少内存访问和内核启动,而不是改变计算精度。要让融合后的计算精度尽量和融合前一致。
5.4 问题排查速查表
我把这几类常见问题整理成一张表,方便你排查时对照:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 结果错误但偶发性强 | 流同步缺失,多流竞争 | 检查内核是否在同一流,加事件同步 |
| 输出形状与预期不符 | 形状推断写错 | 单测形状推断接口,验证边界条件 |
| 自定义算子不被框架识别 | 注册遗漏或注册名冲突 | 检查注册宏是否执行,名称是否唯一 |
| 融合后精度轻微下降 | 中间张量精度被改变 | 对比融合前后的中间精度设置 |
| 运行时内存持续增长 | 内存规划未覆盖新算子 | 检查张量生命周期是否有循环引用 |
| 算子在A设备能跑B设备报错 | 内核未绑定B设备 | 检查注册表的设备类型键值 |
这张表是我从多次实际排障中总结的,未必能覆盖所有场景,但大体上把最常遇到的问题都列出来了。遇到新问题,建议按“先定位是哪一层出错——是算子定义、内核实现、还是调度逻辑”这个思路去拆,不要上来就乱猜。
6. 性能优化视角:瓶颈在算子还是在运行时
6.1 如何判断瓶颈归属
性能优化第一件事,不是动手改代码,而是先判断瓶颈在哪一层。这里我分享一个简单但有效的判断方法:
第一步:看算子的计算强度。如果算子的计算量很小(比如逐元素操作),但数量非常多,那瓶颈大概率在运行时调度,因为启动开销和调度开销占了大头。反之,如果算子本身计算密集(比如大矩阵乘、大卷积),瓶颈大概率在算子内核实现。
第二步:看GPU利用率。通过性能分析工具,查看GPU的算力利用率和内存带宽利用率。如果算力利用率很低但显存带宽打满,说明算子实现受内存访问限制,要从数据布局和访存模式入手优化。如果GPU利用率上不去,且各个内核之间有大量空闲间隙,说明调度有瓶颈。
第三步:做对比实验。把某个算子的执行时间单独测出来,再放进完整图里测一遍。如果单独执行很快、放进图里变慢,说明是运行时调度的问题;如果单独执行就慢,说明内核实现本身有待优化。
这个方法不能保证百分之百精准,但能帮你快速缩小问题范围,避免把时间浪费在错误的层面上。
6.2 算子层面的优化思路
如果瓶颈在算子内核实现,常见的优化方向有五个:
访存优化。大多数算子不是计算密集,而是访存密集。核心思路是提高数据局部性,让数据在寄存器、共享内存、缓存里被反复使用,减少对主存/显存的访问。比如矩阵分块(Tiling)、向量化加载、缓存复用这些技术,本质上都是访存优化。
并行度优化。确保你的内核有足够的并行度。GPU场景下,要检查线程块大小、网格大小是否合理。太小则硬件利用率不足,太大则可能导致资源竞争。一般经验是先按硬件的最大线程数来配置,再微调。
指令级优化。使用向量化指令、融合乘加指令(FMA)等。编译器开启优化选项之后,代码写法会影响指令生成的效率。这层优化通常放在最后,因为收益相对有限,且对代码可读性伤害最大。
精度优化。用足够满足精度要求的低精度类型,比如float16或bfloat16。显存占用减半,带宽压力减半,计算吞吐可能翻倍。前提是精度衰减可接受。
算法优化。等价变换计算方式,比如用Winograd变换加速卷积、用快速傅里叶变换加速某些算子。这层优化收益最大,但复杂度也最高。
6.3 运行时层面的优化思路
如果瓶颈在运行时,方向就不同了:
算子融合。前面提过多次,这是收益最大的运行时优化。把连续多个小算子合成一个大算子,减少内核启动次数和中间张量的内存读写。工程上需要维护一个融合规则库,说明哪些算子组合可以安全融合。
内存池化。运行时通过内存池复用张量内存,避免每次执行都做内存分配和释放。一个高性能的运行时,内存分配开销几乎为零——因为所有内存都在第一次执行时规划好,后续执行全部复用。
执行流复用。频繁创建和销毁Stream是很大的开销。成熟的运行时会维护一个Stream池,需要时就取,用完就还。尽量减少跨Stream的同步操作,因为同步意味着闲置等待。
图级并行。通过依赖分析,把没有依赖关系的子图分配到不同的流或线程上并行执行。这特别适合那种结构里天然存在多条独立分支的模型。
编译优化。运行时集成的编译组件会对计算图做全局优化,比如算子融合、布局转换消除、常量折叠等。这一步做得好,能把前面提到的各种优化打包成一个整体策略。
核心认知是:算子优化解决的是“单个操作做得快不快”,运行时优化解决的是“整个图跑得顺不顺”。两者缺一不可,但在不同场景下优先级不同。
7. 写在最后的实操体会
回顾这些年和算子和运行时打交道的经历,我最大的体会是:这两者之间没有绝对的边界,边界是随着框架设计而流动的。在不同的框架里,同一个优化可能被放在不同层做。有些框架倾向于把融合逻辑放在编译器的图优化pass里,有些框架则把融合逻辑下推到算子的内核实现里。你硬要划一条“这里归算子层、那里归运行时层”的线,反而会限制理解。
更实际的做法是:面对一个性能问题,先搞清楚你的框架提供哪层抽象、哪层接口可以动,然后从最可能出问题的那层入手。我的经验里,大多数性能问题出在访存和调度,而不是所谓的“计算不够快”。你花大力气把一个算子的计算逻辑优化到极致,不如把调度理顺、把访存模式调好,收益反而更大。
最后有一个小技巧分享给你:当你在看一个不熟悉的框架源码时,先找到它的算子注册宏,按图索骥梳理出注册表结构。注册表就是算子和运行时之间的契约,读懂了注册表,整个框架的架构脉络也就清楚了。我每次接触新框架,都是从注册表入手的,这个方法我验证过很多次,非常稳定。
算子和运行时这门课,入门靠概念,精通靠踩坑。你踩过的坑越多,对两者的理解就越深。希望这篇文章能让你少踩几个坑。