假设你是一座大型水电站的总调度员。面前摊开一张庞大流域图,几十条支流从不同山头汇聚,最终注入水库。每条支流都有名字,比如“第 k 道溪流”“第 j 道引渠”。爱因斯坦发现一个规律:凡是名字相同的支流,最后必然在某个闸口汇合,水量直接相加。于是他索性在图纸上划掉所有 Σ 求和符号,只留下河道的名字——这就是“爱因斯坦求和约定”。
einsum 正是把这个约定变成一行字符串的魔法:它用 ik,kj->ij 这样短短一段“河网接线图”,让计算机像调度员一样,把张量之间的乘法、加法、转置、批量变换一次调度完毕。这篇文章会带你从零认识 einsum:先看懂求和约定怎么省略 Σ,再学会 einsum 的字符串语法,逐个破解点积、转置、矩阵乘法、批量乘法,最后看看它的效率优势和 PyTorch / NumPy 实战写法。学完之后你会发现,许多需要五六个函数拼凑的张量运算,一行 einsum 就能干净利落写出来。
对 AI 从业者来说,einsum 是张量代码的“通用语言”:模型里任何复杂的维度变换都能用一行表达,既减少维度 bug 又提升可读性,是算法工程与面试中的高频利器。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
求和约定精义
你还是那座水电站的调度员。图纸上,每条支流都有自己的名字:上游来水叫 a,支流分别叫 i、j、k……每当图纸上出现两个名字完全相同的河,它们必然在某个闸口汇合,水量相加。爱因斯坦发现这个规律后,索性把纸面上所有的 Σ 求和符号都擦掉,只留下河道名字——名字重复,就意味着“在这里求和”。
在数学里,这种“名字”叫做下标。向量 a 的第 i 个分量写作 aᵢ,矩阵 A 的第 i 行第 j 列写作 Aᵢⱼ。当同一个下标在同一项里出现两次,比如 aᵢₖ bₖⱼ 里的 k,我们就知道 k 要从 1 到 n 全部加一遍。这就是求和约定的核心:重复下标即求和。
一个式子里其实有两类下标。一类叫“哑标”,它在某一项里出现两次、最终结果里消失——像汇流后被干流吸收的支流;另一类叫“自由下标”,它只在项里出现一次、会原样出现在结果里——像保留下来的主干河道。判断谁求和、谁保留,是读懂 einsum 字符串的第一步。
所以,读一张 einsum 图纸也好,读一个数学式子也好,你只需问一句:什么名字重复了?重复即汇流,汇流即相加。
- 自由下标 i、j这两个下标同时出现在等式左右两侧,表示它们对应的维度会保留下来、不求和。
- 哑标 kk 在同一项里出现两次,按约定自动求和,这就是“重复下标即求和”。
- 求和上下界k 从 1 加到 n,n 是该维度的长度,也就是矩阵 a 的第二维大小。
- 恒等号 ≡表示爱因斯坦约定与显式写 Σ 的写法完全等价,没有信息丢失。
einsum语法入门
作为调度员,你光有口头约定还不够,得给计算机一张“接线图”。einsum 的字符串就是这张图:逗号把各条来水的河道分隔开,箭头左边标注每条河的维度名,箭头右边写出汇流后要保留的河道名。ik,kj->ij 这一行,就是完整的河网拓扑。
在代码里,一个 einsum 调用长这样:einsum('ik,kj->ij', A, B)。字符串里的每个字母对应一个张量的维度,字母顺序对应轴的顺序。箭头左侧是各输入张量的下标串,右侧是输出张量的下标串;凡是“进得了输入、却写不进输出”的下标,都会被自动求和。
具体规则可以拆成四条:第一,逗号分隔多个输入张量;第二,箭头左侧是输入的维度结构;第三,箭头右侧决定输出张量的布局;第四,如果完全省略箭头和输出串,比如只写 'ij',等价于把所有下标都求和,返回一个标量。
字符串虽短,却是一份完整的河网拓扑图。读懂了它,后面所有运算都只是“照着图纸接水管”。
- 逗号把 A 的下标串
ik和 B 的下标串kj分隔开,相当于图纸上两条入水河道各自的名字列表。 - 箭头左右分隔符,左侧是输入河道结构,右侧是汇流后保留的河道。
- 输出下标 ij决定结果矩阵的布局,i 在前表示行维度,j 在后表示列维度。
- 调换输出顺序如果箭头右边写成 ji,张量的两个轴就对调了,这正是转置的来源。
基本运算映射
调度员上岗前要背几张标准图纸:两支独流直接合并成一条干流、一张网格的行列名互换、一个方形水库的自循环水量……这些基础操作在 einsum 里都只有一行。
先看向量点积。两个向量 a、b 的下标串都是 i,箭头右边为空,表示所有维度都被求和,最后得到一个标量。再看矩阵转置,ij->ji 把输出下标顺序对调,数据布局随之翻转。最后看矩阵求迹,ii-> 让同一个下标 i 出现两次,自动取对角线元素并求和。
这三张图就是 einsum 的“九九乘法表”。背熟它们,复杂运算不过是三张图的叠加。
- ij→ji输出时交换下标位置,数据的行列存放方式随之对调,得到转置。
- ii→输入出现两个 i,等价于自动取方阵对角线,再把对角元素相加。
- 直观理解一个是交换河道行列名字,一个是只数方形水库的自循环水量。
矩阵乘法核心
现在画最经典的一幅图:矩阵乘法。A 是上游河网(i 行、k 列),B 是下游河网(k 行、j 列),中间的 k 像一道公共闸门——A 放出的每一股水,要和 B 接住的水相乘,再汇入 C 的 (i, j) 位置。这就是 ik,kj->ij 的全部含义。
这也解释了“贴标签”的本质:写 ik,kj->ij 时,你已经告诉计算机,A 的第二维和 B 的第一维是同一个 k,必须对齐相乘再累加;其他维度各自保留。这比手动 reshape 再 matmul 清晰得多,因为维度契约是显式的。
一次汇流、一道公共闸门,矩阵乘法这张大图就被 einsum 收进了一张小纸条。
- ik 与 kjA 的下标表示行 i、列 k,B 的下标表示行 k、列 j。
- 公共下标 k两个矩阵通过 k 对接,k 的维度长度必须一致,这一步叫“缩并”。
- 输出 ij结果保留 i 和 j,形状为 (i, j),即 A 的行数 × B 的列数。
- 隐式三重循环等价于 for i / for j / for k 三层循环,最内层做累加。
批量运算升华
真实工程里很少只有一张河网,而是成千上万张同时调度。比如一个批次 64 张图,每张都要做矩阵乘法。给每张河网加一个相同的批次号 b,你只需要维护一张接线图,所有批次的河网同时执行——这就是批量矩阵乘法 bik,bkj->bij。
注意一个特别容易迷糊的点:b 在输入里出现了两次,但它会被求和吗?不会。einsum 的铁律是:只要下标写进了输出串,它就是自由下标,重复出现也不求和。b 出现在输出里,所以它是批次维;k 没出现在输出里,才被缩并。
再看一个“零缩并”的例子——外积。两个向量的下标 i、j 没有重复,所有维度都保留,结果是一个 i×j 的矩形网格。
给河道名字加个批次号,一张图纸就能调度一万条河网;让名字完全不重复,就得到外积。einsum 的灵活,全在这几个字母的增减之间。
- 无重复下标整条式子没有任何需要汇流的同名河道。
- 全部保留i 和 j 都出现在输出里,结果是一个 i 行 j 列的矩阵。
- 等价函数等价于
np.outer(u, v),是零缩并的“纯外积”。
效率优势解密
调度员最怕什么?怕水在半路被存进临时水库,再放出来。多步矩阵运算经常干这种事:先算 XW₁,把中间结果写进内存,再读出来和 W₂ 相乘。einsum 相当于挖一条直通隧道,中间结果根本不落地。
看这个三输入例子:bik,kl,lj->bij,把三个张量塞进一次调用,k 和 l 两个哑标依次缩并。对比传统的链式写法 (X @ W1) @ W2,链式写法必须先在内存里生成 XW₁ 这个中间张量,einsum 则在内部融合计算。
代价与收益要诚实说:融合并不总是碾压。在 GPU 上遇到很大的矩阵时,高度优化的 cuBLAS matmul 可能更快;但当你做的是大量小矩阵、或需要多步缩并合成一次调用时,einsum 往往能省下可观的内存带宽。NumPy 还提供 optimize=True 和 einsum_path 来寻找最优缩并顺序。
效率的秘诀不是“算得更快”,而是“少搬几次家”。水不落地,自然省力。
- 三个输入一次调用传入三个张量,逗号分隔,对应三条入水河。
- 哑标 k 与 lk 连接 X 与 W₁,l 连接 W₁ 与 W₂,依次缩并。
- 输出 b, i, j只保留批次和最终行列维度,中间维度全部汇入干流。
- 关键差异链式写法生成中间张量 XW₁(形状 b,i,l),einsum 在内部融合,避免这趟“内存往返”。
实战应用技巧
上岗前,调度员还得看一遍操作手册:不同工厂的 API 怎么称呼、特殊情况怎么处理、哪些名字不能用作河道名。
先说 PyTorch:torch.einsum('bik,bkj->bij', x, w) 返回新张量,并且完全支持自动求导,可以放心写在神经网络前向里。NumPy 的 np.einsum 用法相同,还额外提供 np.einsum_path 打印优化后的缩并顺序。两者都支持省略箭头:只写 'ij' 表示对所有下标求和,返回标量。再一个实战要点:ASCII 字母总共 52 个,遇到更高阶张量或动态变化的批次维,要用省略号 ...,比如 '...ij,...jk->...ik',它把“位置未知的前导维度”全部交给框架处理。
最后看一个 AI 里出镜率最高的例子——注意力机制的 QKV 缩并。Transformer 的自注意力打分与加权可以浓缩成一行 einsum:
手册虽短,但有了它,任何河网图你都能一行接好。einsum 就是张量世界的通用接线图:读懂了河名,就读懂了整个模型。
- b、q、k、v批次、查询、键、值四条河道的名字。
- 下标 k 缩并Q 和 K 通过 k 对齐相乘后求和,得到注意力打分矩阵。
- 输出 bqv查询维和值维保留,键维被缩并,再接 softmax 就是自注意力核心。
- 工程价值手写 reshape + matmul 至少三行,einsum 一行保维度不错。
AI 应用场景
注意力机制中的 QKV 缩并
阶段:模型前向计算 / 特征融合
解决的问题:Transformer 的多头注意力里,Q、K、V 三个张量要做批量矩阵乘法。传统写法需要先 reshape、再 transpose、再 bmm,维度稍不留神就错位;用 einsum 把维度契约写进字符串,编译器直接按河网图调度,Bug 率大幅下降。
具体案例:训练 BERT 时,自注意力的打分与加权可以写成 torch.einsum('bqk,bkv->bqv', Q, K, V)。假设 batch=32、序列长=128、头维度=64,这一行就完成了 32 个样本各自 128×64 与 64×128 的批量矩阵乘,代码可读性和执行效率都很好。
推荐系统双塔召回中的向量打分
阶段:召回阶段 / 相似度计算
解决的问题:双塔模型把用户和物品分别编码成向量,线上要对上亿物品计算相似度。手写循环逐条内积太慢,把全部物品向量堆成矩阵后用 einsum 批量内积,一条指令完成“百万向量 × 用户向量”的点积。
具体案例:在召回服务中,用户向量 u 形状 (d,),物品向量矩阵 V 形状 (N, d),用 np.einsum('d,nd->n', u, V) 一次性得到 N 个相似度分数,再 top-k 截断,延迟从几十毫秒降到几毫秒。
科学计算中的张量网络缩并
阶段:数值模拟 / 张量网络计算
解决的问题:量子物理、量子化学中经常要把多个高维张量的公共维度缩并,下标关系错综复杂。einsum 把“哪些维度相乘、哪些维度求和”显式写成字符串,既方便验证数学推导,也能借助优化器寻找最省内存的缩并顺序。
具体案例:在量子态演化模拟中,把两个三阶张量的公共物理维缩并,写 np.einsum('ijk,klm->ijlm', A, B, optimize=True),配合 einsum_path 观察最优路径,内存峰值显著下降。
Python 演示
import numpy as np def naive_einsum(spec, arrays): """极简 einsum:把输入张量按字母广播对齐后逐元相乘,再在非输出维度求和。""" inputs, out = spec.replace(' ', '').split('->') input_specs = inputs.split(',') order = sorted(set(''.join(input_specs) + out)) aligned = [] for s, a in zip(input_specs, arrays): if len(set(s)) != len(s): raise ValueError('简化版不支持重复下标(如 ii->)') dims = {ch: a.shape[t] for t, ch in enumerate(s)} # 每个输入: 按 order 顺序排列轴,缺失字母补 1 维 target = [s.index(ch) for ch in order if ch in s] shape = [dims[ch] if ch in dims else 1 for ch in order] aligned.append(np.transpose(a, target).reshape(shape)) prod = aligned[0] for arr in aligned[1:]: prod = prod * arr drop = [t for t, ch in enumerate(order) if ch not in out] return prod.sum(axis=tuple(drop)) if drop else prod if __name__ == '__main__': A = np.array([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]) B = np.array([[9., 8., 7.], [6., 5., 4.], [3., 2., 1.]]) u = np.array([1., 2., 3.]) v = np.array([4., 5., 6.]) assert np.allclose(naive_einsum('ik,kj->ij', [A, B]), A @ B) assert np.allclose(naive_einsum('i,i->', [u, v]), u @ v) assert np.allclose(naive_einsum('ij->ji', [A]), A.T) assert np.allclose(naive_einsum('i,j->ij', [u, v]), np.outer(u, v)) X = np.random.rand(4, 2, 3) W1 = np.random.rand(3, 5) W2 = np.random.rand(5, 7) Y = naive_einsum('bik,kl,lj->bij', [X, W1, W2]) assert np.allclose(Y, X @ W1 @ W2) print('全部断言通过:naive_einsum 与 NumPy 结果一致')
order = sorted(set(''.join(input_specs) + out))收集所有出现的字母,按字母序排成统一的“河网坐标系”。target = [s.index(ch) for ch in order if ch in s]找出当前输入张量各轴在统一坐标系里的位置,用来转置。shape = [dims[ch] if ch in dims else 1 for ch in order]缺失的字母补 1 维,为广播对齐做准备。aligned.append(np.transpose(a, target).reshape(shape))转置加 reshape,把每个张量都“摆”到统一坐标系里。prod = prod * arr所有对齐后的张量逐元素相乘,NumPy 广播自动补齐维度。drop = [t for t, ch in enumerate(order) if ch not in out]找出没写进输出串的下标位置,也就是要缩并的哑标。return prod.sum(axis=tuple(drop)) if drop else prod沿这些维度求和,得到最终输出张量。assert np.allclose(naive_einsum('ik,kj->ij', [A, B]), A @ B)用官方矩阵乘法验证自定义实现正确。
常见误区
误区`bik,bkj->bij` 里 b 重复出现了两次,按爱因斯坦约定肯定也被求和了,输出形状应该是 (i, j) 吧?
正解einsum 的铁律是:下标只要写进输出串,就永远是自由下标,重复出现也不求和。只有“进了输入却没进输出”的下标才会被求和。b 写在了输出里,所以它是批次维,k 才是被缩并的哑标。就像同名河道如果图纸上标注“保留”,它就不会汇入大海。
为何常见:很多人先学了数学里的爱因斯坦求和约定,误以为“重复 = 求和”无条件成立,忽略了 einsum 用输出串二次声明了哪些维度保留。
误区既然 einsum 能避免中间张量,它肯定比所有常规写法都快,以后一律用 einsum 就行。
正解避免中间张量通常能省内存、减少内核启动,但这不是必然。在 GPU 上做大矩阵乘法时,cuBLAS 的 matmul 经过厂商级优化,速度和 einsum 相当甚至更快;einsum 的优势更多体现在多步缩并、大量小矩阵、维度显式化等场景。性能要用 benchmark 说话,不要拍脑袋。
为何常见:许多博客强调“einsum 更快”,读者把特定场景的优化效果放大成了普适结论。
误区`ij->ji` 和 `ij->ij` 输出的都是同一个矩阵的数值,顶多长得不一样,值差不多。
正解在张量世界里,轴的顺序就是数据的布局。ij->ji 输出转置,ij->ij 保持原样。在深度学习里,序列维度和特征维度一旦放反,整个网络的形状全部错乱。einsum 字符串里,输出下标的顺序就是结果张量的轴顺序。
为何常见:低维情况下“转置后的数值集合还在”,很多人只盯着数值,忽略了位置语义才是张量的灵魂。
误区既然 einsum 能替代几十个函数,那排序、找最大值这些操作也该能用 einsum 表达。
正解einsum 只能表达“逐元素相乘 + 求和 / 轴变换”这一族运算。任何需要比较、条件分支、非线性变换的操作都无法用 einsum 描述;它擅长的是线性代数里乘加结合的运算。另外,ASCII 下标字母有限,动态维度要靠省略号 ... 处理。
为何常见:把“表达力强”误解成“无所不能”,忽视了 einsum 的代数本质是乘加运算的压缩表达。
面试题
Q1. einsum('ij->ji', A) 的作用是?
- A.矩阵转置← 正确答案
- B.全元素求和
- C.向量内积
- D.矩阵乘法
答案:A。箭头右侧只写 ji,把输入下标对调,相当于把矩阵的行列交换,即转置。B 是 'ij->' 的效果,C 需要两个向量输入,D 需要两个矩阵。
Q2. einsum('i,i->', a, b) 返回什么形状?
- A.向量
- B.标量← 正确答案
- C.矩阵
- D.三维张量
答案:B。箭头右边为空表示所有下标都被求和,两个向量缩并后得到单个数值,即标量,等价于向量内积 a·b。
Q3. 在 'ik,kj->ij' 中,下标 k 是什么?
- A.输出维度
- B.求和缩并← 正确答案
- C.批次维度
- D.对角维度
答案:B。k 在输入中出现两次但没出现在输出中,按约定自动求和,是典型的哑标。输出维度是 i 和 j,批次维通常用 b,对角维需要重复下标 ii。
Q4. 矩阵迹的 einsum 写法是?
- A.'ij->ji'
- B.'ii->'← 正确答案
- C.'i,i->'
- D.'ij->ij'
答案:B。重复下标 ii 表示自动取对角线并求和,得到矩阵的迹。A 是转置,C 是向量内积,D 是恒等变换。
Q5. 关于 einsum('bik,bkj->bij'),以下说法正确的是?
- A.输出形状为 (b, i, j)← 正确答案
- B.下标 k 被求和缩并← 正确答案
- C.下标 b 是批次维← 正确答案
- D.下标 i 被求和缩并
答案:ABC。输出串保留了 b、i、j,所以形状是 (b,i,j),且 b 是自由下标、k 是哑标。i 写在了输出里,属于自由下标,不会求和,D 错误。
Q6. 下列哪些运算可以用 einsum 表达?
- A.向量内积← 正确答案
- B.矩阵转置← 正确答案
- C.批量矩阵乘法← 正确答案
- D.对数组排序
答案:ABC。einsum 只能表达乘加轴变换,内积、转置、批量矩阵乘法都在此列。排序需要比较与交换,无法用逐元素乘加表达,D 错误。
Q7. einsum('ij', A) 省略箭头,其语义是?
- A.返回原矩阵
- B.全元素求和← 正确答案
- C.对矩阵转置
- D.直接报错
答案:B。省略箭头等价于输出串为空,所有下标都参与求和,返回标量。这与“箭头右侧为空”的语义一致,只是写法更简短。
Q8. einsum('i,j->ij', u, v) 等价于?
- A.np.outer← 正确答案
- B.np.dot
- C.矩阵乘法
- D.矩阵求迹
答案:A。两个输入没有重复下标,所有维度都保留,逐元素相乘形成外积矩阵,等价于 np.outer(u, v)。np.dot 会做缩并,矩阵乘需要公共下标,求迹需要重复下标。
Q9. 关于 einsum 效率优势的说法,正确的有?
- A.可避免中间张量← 正确答案
- B.融合内核减少内存读写← 正确答案
- C.任何情况都快于 matmul
- D.NumPy 支持路径优化← 正确答案
答案:ABD。einsum 融合多步缩并、免去中间张量是常见优势,但 GPU 上大矩阵 matmul 也很强,不能说任何情况都快,C 过于绝对。NumPy 的 einsum_path 可以寻找最优缩并顺序,D 正确。
Q10. 在 PyTorch 中处理动态批次维的推荐写法是?
- A.用大写字母
- B.用省略号 ……← 正确答案
- C.用数字下标
- D.无法支持
答案:B。动态批次维的数量和长度在写代码时未知,ASCII 字母表无法表达。省略号 ... 可以匹配任意数量的前导维度,例如 '……ij,……jk->……ik' 能把所有批次维整体保留,是通用且健壮的写法。