算子与运行时:深度学习框架的两大核心抽象

发布时间:2026/10/12 7:07:34
算子与运行时:深度学习框架的两大核心抽象
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, devicecpu) def weighted_sum_cpu(input_a, input_b, weight): return weight * input_a input_b # 3. 实现GPU内核 ops.register_kernel(WeightedSum, devicegpu) 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), dtypefloat32) b graph.input(b, shape(64, 64), dtypefloat32) 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里有些框架则把融合逻辑下推到算子的内核实现里。你硬要划一条“这里归算子层、那里归运行时层”的线反而会限制理解。更实际的做法是面对一个性能问题先搞清楚你的框架提供哪层抽象、哪层接口可以动然后从最可能出问题的那层入手。我的经验里大多数性能问题出在访存和调度而不是所谓的“计算不够快”。你花大力气把一个算子的计算逻辑优化到极致不如把调度理顺、把访存模式调好收益反而更大。最后有一个小技巧分享给你当你在看一个不熟悉的框架源码时先找到它的算子注册宏按图索骥梳理出注册表结构。注册表就是算子和运行时之间的契约读懂了注册表整个框架的架构脉络也就清楚了。我每次接触新框架都是从注册表入手的这个方法我验证过很多次非常稳定。算子和运行时这门课入门靠概念精通靠踩坑。你踩过的坑越多对两者的理解就越深。希望这篇文章能让你少踩几个坑。