一、什么是算子:从数学函数到计算图中的基本单元
大家好,我是你们的老朋友。今天咱们聊聊算子融合,但得先从最基础的概念说起——算子到底是什么?
说实话,我刚入行那会儿,听到「算子」这个词也觉得挺唬人的。后来做了一年多的编译器开发,才慢慢摸清楚它的本质。说白了,算子就是计算图里的一个节点,它完成一个特定的数学运算。
1.1 从数学函数说起
你想想看,数学里的函数长什么样?
y = f(x) // 一元函数
z = g(x, y) // 二元函数
output = h(x1, x2, ..., xn) // 多元函数
算子其实就是这个「f」、「g」、「h」。只不过在深度学习里,我们把它搬到了计算图上。
举个例子,一个简单的卷积算子:
# 伪代码示意
def conv2d(input, kernel):
# input: [N, C, H, W]
# kernel: [C_out, C_in, K_h, K_w]
output = 对每个位置做乘加运算
return output
嗯,这里要注意:算子接收输入张量,经过计算,输出新的张量。它就是一个计算单元。
1.2 计算图中的算子
我习惯把计算图想象成一个工厂流水线。每个算子就是一台机器,数据从一台机器流到另一台机器。
核心要点:算子 = 计算图中的节点 = 一个数学运算的封装
比如一个简单的神经网络层:
# 一个全连接层的计算图
输入 → MatMul(矩阵乘) → Add(偏置) → ReLU(激活) → 输出
↑算子1 ↑算子2 ↑算子3
这里 MatMul、Add、ReLU 都是算子。它们各自完成自己的工作,然后传给下一个。
1.3 算子的分类:计算密集型 vs 访存密集型
做算子融合的时候,这个分类特别重要。我踩过不少坑,都是因为没搞清楚算子的「性格」。
| 类型 | 特点 | 典型算子 | 瓶颈 |
|---|---|---|---|
| 计算密集型 | 计算时间远大于访存时间 | 卷积、矩阵乘、FFT | 算力(FLOPS) |
| 访存密集型 | 访存时间远大于计算时间 | ReLU、Add、Reshape、Concat | 带宽(GB/s) |
计算密集型算子,说白了就是「算得多,读写得少」。比如一个 1024x1024 的矩阵乘,你要做几十亿次乘加,但输入输出数据也就几 MB。这时候 GPU 的算力是瓶颈。
访存密集型算子,就是「算得少,读写多」。比如 ReLU,每个元素就做个 max(0, x),但你要把整个张量从显存读进来,再写回去。这时候带宽是瓶颈。
我的经验:融合算子的时候,通常是把访存密集型的算子「粘」到计算密集型算子旁边。比如把 ReLU 融合进卷积,这样 ReLU 就不用单独读写一次显存了。
1.4 为什么这个分类这么重要?
我曾经优化一个模型,发现性能卡在了一堆 ElementWise 算子上。每个算子就做一点点计算,但都要读写一遍显存。这就是典型的访存瓶颈。
你想想看:
- 一个卷积 + ReLU,分开执行:卷积写一次显存,ReLU 读一次再写一次
- 融合后:卷积算完直接做 ReLU,省掉一次读写
对于大模型来说,省掉一次显存读写可能就是几十毫秒的差距。累积起来,整个训练时间能缩短 20%-30%。
避坑指南:我曾经把两个计算密集型算子(比如两个卷积)强行融合,结果因为寄存器压力太大,性能反而下降了。融合不是万能的,要看清算子的「性格」。
1.5 本章知识体系
为了让你更直观地理解,我画了一张图:
这张图把整个知识脉络串起来了。从算子定义出发,到计算图,再到分类,最后落到融合策略。后面的章节我们会一步步深入每个环节。
1.6 小结
今天咱们聊了三个核心点:
- 算子是什么:计算图里的基本运算单元,本质就是数学函数的封装
- 计算图怎么组织算子:像流水线一样,数据依次流过每个算子
- 算子分两类:计算密集型和访存密集型,它们的优化思路完全不同
我个人觉得,理解算子的分类是入门算子融合最关键的一步。你想想看,如果你连算子的「性格」都不了解,怎么知道该不该融合、怎么融合呢?
下一章咱们会深入计算图,看看算子之间是怎么连接的,以及为什么有些连接方式会导致性能问题。到时候我会拿一个真实项目里的例子来讲解,保证让你印象深刻。