资讯动态

数学张量与工程张量:指标、爱因斯坦求和与einsum实战

发布时间:2026/9/18 9:41:25 来源:尧图企业网站定制
1. 先把张量这个词从神坛上请下来我刚开始接触张量的时候被折磨得不轻。教材前面讲坐标变换、讲上下标、讲求和指标一套符号体系铺开来等到终于看到张量就是多重线性映射这句话人已经晕了。后来做深度学习发现情况反过来了PyTorch 里的torch.Tensor被翻译成张量但此张量非彼张量。它只是能放进显卡的 n 维数组而已。两拨人用同一个词说的却不是同一件事这就是我见过的绝大多数张量入门卡点的根源。所以这篇内容我想做的事情很明确把数学意义上的张量和工程意义上的张量放在一起讲讲清楚它们各自的语言规则尤其是求和指标这一套东西到底在表达什么、为什么值得花时间学。适合谁看如果你是物理、力学、材料方向的本科生或研究生正在被张量分析的第一章劝退或者你是做深度学习工程的天天写einsum、permute、broadcast但不太清楚这些操作背后的数学依据——那这篇就是为你写的。两种背景的读者我都不打算放弃前面偏概念中间偏符号后面落到代码你可以按需跳读。我给这篇文章定的基调是能算的必须能写成代码验证能写的必须说清楚它为什么这么写。符号体系不是排版洁癖它是为了让你在三十个指标翻滚的公式里不迷路。1.1 从物理量和坐标变换理解张量的本质很多人对张量的第一印象是高维数组。这个印象来自编程但在数学上它是错的至少是不完整的。真正定义张量的从来不是有几个指标而是它在坐标变换下如何变化。拿最常见的一阶张量——向量——举例。一个位移向量在二维平面上是 (dx, dy)你把坐标系旋转 θ 角新的分量变成 (dx, dy)两者之间用一个旋转矩阵联系。关键点在于这个变换关系是由坐标系的变化唯一决定的跟向量本身代表什么物理量无关。再看二阶张量比如弹性力学里的应力张量 σ_ij它描述的是某个面上单位面积受力这件事你在数学上可以把它摆成 3×3 的数表但真正让它成为张量的是这样一个事实换一组基之后新的 9 个分量可以通过两次坐标变换矩阵算出来。这里我要强调一个我踩过的坑。当年我做了个练习把一组 9 个数随意赋值然后假装它是应力张量去做坐标变换算完发现结果和实验对不上。原因很简单——那 9 个数是我编的它不满足任何物理约束比如对称性、平衡方程自然也不是任何真实场的分量。这件事给我的教训是张量是带着变换规则的数组而不是一堆数字。判断一个对象是不是张量先问它的分量在坐标变换下怎么变而不是先数它有几个下标。一个可操作的判据是如果某个量的分量在你换基之后能够写成旧分量 × 若干个变换矩阵的乘积再求和这种标准形式那它就是张量如果多出来一项额外的平移项那它通常不是连接系数就是这个经典反例。这个判据在实践中比任何抽象定义都管用尤其是做连续介质、电磁场、广义相对论这类内容的时候。1.2 阶、指标与分量一套符号系统的三个层次明白了怎么变之后再来看怎么记。张量的语言里有三个层次的东西初学时最容易混。第一层是阶rank / order也就是需要的指标个数。0 阶是标量1 阶是向量2 阶是矩阵3 阶以上就只能靠想象了。我在讲课时通常提醒这里的阶是指标个数不是矩阵的维数。一个 2 阶张量可以是 3×3也可以是 4×4阶和维数是两个正交的概念。第二层是指标的位置也就是上标和下标。在欧氏空间的直角坐标系下上标下标可以不分因为度规是单位矩阵升降指标什么都没改变。但一旦进入一般坐标甚至只是换成球坐标、柱坐标上下标就必须分开写。这是张量符号体系里最容易被新手忽略、但在实际计算中最容易出错的地方。第三层是分量。张量本身是抽象的几何对象写下 A_ij 的时候你写的是它在某组基下的分量。这一点听起来像哲学但实操上非常关键当你做数值计算时程序里存放的永远是分量数组你必须在心里记住当前用的是哪组基、哪个坐标系。提示初学阶段如果分不清张量和分量可以先按工程习惯把两者当同一个东西用但心里要留一个标注——等进入曲线坐标或者非正交基这个缺口会立刻暴露出来。我个人建议的学习顺序是先用直角坐标系把指标运算练熟手写到不假思索再引入一般坐标理解度规和升降指标最后再看协变导数、曲率这些更深的东西。跳过第二步直接上第三步基本是自讨苦吃。1.3 机器学习语境下的张量其实降级了说个可能不太政治正确但很实在的判断PyTorch 里的Tensor在数学意义上更接近多维数组加自动求导节点而不是严格定义的张量。它没有坐标变换的概念也没有协变逆变之分。torch.randn(3, 4, 5)就是一堆数你把它喂进网络它不会问你你是几阶张量、在哪个坐标系下。那为什么还要学数学张量我的理由有三个都很功利。一是运算的直觉。einsum(bij,bjk-bik, A, B)这行代码本质上就是批量矩阵乘法写成指标式就是 C^i{}{jk} Σ_j A^i{}{jl} B^l{}_{k}。你看代码里的字符串跟指标式几乎是一一对应的。学会了指标语言写einsum就不再靠查文档而是靠推导。二是形状变换的底气。permute、transpose、view这些操作本质上是改变指标的排列顺序或者合并指标。如果你只会背 API遇到 5 维的特征图会疯如果你习惯用指标去思考会很清楚哪两个指标该合并、哪个该放在前面。三是读懂论文。张量网络、张量分解CP、Tucker、量子多体、连续介质这些方向的文献里指标满天飞。看不懂指标等于看不懂半个领域。所以我的定位是数学张量给你骨架工程张量给你肌肉。缺哪个都会跛。2. 指标记号上下标真不是排版洁癖很多教材一上来就写 A^i_j学生第一反应是为什么要分上下第二反应是下标上标不就是排版吗。我在各种场合解释过这个问题最有效的类比是这样上标是方向类指标下标是测量类指标。向量本身位移、速度是方向类的东西用上标而梯度、动量这类吃掉一个方向吐出个数的东西是测量类的用下标。你算一个向量在某方向上的投影就是把两者缩并掉。理解了这层直觉后面看到升降指标、看到度规就不会觉得它是凭空冒出来的规则。2.1 逆变与协变它们到底差在哪里先给个可操作的定义逆变分量上标随基向量反向变化协变分量下标随基向量同向变化。这句话背下来不难难的是理解它为什么必须如此。考虑二维斜坐标系下的一个位移向量。你把基向量拉长一倍要表示同一个物理位移坐标数值就得砍一半——这就是反向变化。而梯度这种东西不一样你把基向量拉长一倍同样的坐标间隔对应的物理距离变大梯度的分量反而变大——这就是同向变化。两类东西的变换规律天然相反所以必须用两套记号区分。一个特别实用的检验方法写公式的时候检查等式两边是否平衡。如果左边是一个自由上标右边也必须有一个自由上标如果两边同位置出现了同一个自由指标但一个在上一个在下那这个式子基本就是错的除非中间显式使用了度规升降。注意在直角坐标系下做练习时上标下标可以混着写不太会出错但一定要养成标注的习惯。等到你处理球坐标或一般的非正交基这个习惯能救你半条命。我在带实验的时候就遇到过这种情况某位同学在球坐标下推导应变把所有指标都写成下标结果算出来的剪切分量符号全反了。检查了半天代码最后发现是方程本身就写错了。这类错误非常隐蔽因为数值上看起来像对的。2.2 哑指标、自由指标以及怎么一眼看出求和范围这是我最想强调的一节因为它是所有张量计算的检查表。规则其实很简单在一个单项式里某个指标出现两次一次上标、一次下标就对这个指标从 1 到 n 求和。这个被求和的指标叫哑指标dummy index求和完就消失了。剩下的、在等式两边都出现的指标叫自由指标free index它决定这个式子是几个方程。举几个例子请你跟我一起在脑子里数A^i B_i 里i 出现两次求和结果是标量。自由指标为零。A^i{}_{jk} B^j 里j 出现两次求和i 和 k 各出现一次是自由指标。结果是一个带 i、k 两个自由指标的对象。A_{ijk} B^{jk} 里j 和 k 都出现两次都求和结果对 i 是自由的是一个 1 阶张量。这里有几个新手几乎必踩的坑我列出来供你对照第一同一个指标出现三次以上表达式就是错的。比如 A_{ii} B_ii 出现了三次这在标准爱因斯坦约定下没有意义。有人会把它理解成先求和 i再乘 B_i 再求和——那是两步操作必须用括号或者引入新指标写清楚。第二哑指标可以随便改名但只能改没被占用的名字。A^i B_i 等于 A^j B_j这个自由改名是推导中最常用的技巧也是化简公式的核心手段。但你不能把 A^i B_i C_i 里的某一个 i 改成 j 之后还理直气壮因为原式本身就是错的。第三求和只在单项式内部进行跨项不求和。这一点在相对论性记号下是硬规定很多初学者会把 (A_i B_i) C_i 误当成对两个 i 都求和实际上它的展开是 A_i C_i B_i C_i两个求和是分开的。数值上碰巧一样但写成 A_i C_i B_i C_i 才是正确的表达方式。我自己的习惯是每写下一个稍微复杂的公式就用笔在指标下面画圈出现两次的画一个圈标记求和出现一次的在等号两边核对是否一致。这个动作看起来很笨但它让我在一次长达三页的推导中零错误。指标检查是廉价的重算一遍是昂贵的。2.3 用指标重写你最熟悉的那些运算把常见运算翻译成指标式你会发现它们其实是一套东西的不同形态。这个表格我建议你手工抄一遍抄完基本就记牢了。运算名称指标表达式说明向量点积A^i B_i缩并掉唯一的指标得到标量向量外积A^i B^j两个自由指标得到 2 阶张量矩阵乘向量A^i{}_j v^j吃掉一个方向指标得到新向量矩阵乘矩阵A^i{}_k B^k{}_j共享 k 指标并缩并矩阵的迹A^i{}_i上下指标相同并缩并双线性形式x^i A_{ij} y^j连续两次缩并得标量逐元素积A_{ij} B_{ij} 不对应为 A_{ij} B^{ij} 整体再取注意这是整体求和不是逐元素最后一行我特意写得不那么正式因为这是个高频误区A_{ij} B_{ij} 在爱因斯坦约定下是一个标量全部求和而不是逐元素相乘得到的矩阵。要在代码里表达逐元素积Hadamard 积指标式必须写成 C_{ij} A_{ij} B_{ij}等号左边有自由指标右边同样的自由指标出现这是逐元素而孤立地写 A_{ij} B_{ij} 没有自由指标它就是全和。这个区别在写einsum的时候对应得非常直接import torch A torch.randn(3, 4) B torch.randn(3, 4) torch.einsum(ij,ij-, A, B) # 全部求和输出标量 torch.einsum(ij,ij-ij, A, B) # 逐元素积输出 3x4一个-右边写不写指标结果完全不同。我见过不止一个人在调试时把这两种情况搞混然后花了半小时怀疑人生。记住-右侧出现的指标就是自由指标没出现的就求和。3. 爱因斯坦求和约定把 Σ 藏起来之后爱因斯坦当年的动机非常实际广义相对论的公式里 Σ 号太多写起来像刷墙看起来像噪声。于是他把求和号省掉只靠指标出现次数来隐含表达求和。这个约定后来被整个物理学界接受现在又通过einsum这种 API 反向输入到了深度学习领域。但省略是有代价的。Σ 号显式存在的时候你一眼能看出哪些指标被求和、范围是多少。省略之后这些信息全靠约定和上下文一旦写错公式看起来还挺像那么回事。所以接下来这几节我要把约定的边界和禁忌说清楚。3.1 约定规则与几条不能碰的红线标准爱因斯坦求和约定的完整表述是这样的当一个单项式中某个指标字母恰好出现两次时隐含对该指标在其全部取值范围内求和通常是 1 到 n出现一次则为自由指标。从这条规则派生出几条红线一个指标不能出现三次及以上。如果确实需要三重求和必须引入辅助指标或者显式写 Σ。上下标必须配对。在一般坐标下求和的一对指标必须一上一下。如果你写 A_{ii}在欧氏直角坐标下没问题那其实是在用度规但在曲线坐标下这是个需要说明的操作。求和指标的字母可以随意更换但不能与其他已有指标冲突。这条听起来琐碎但在长推导里是出错的高发区。我通常的习惯是求和指标用 i, j, k, l自由指标用 a, b, c, d字母分组使用避免视觉混淆。跨项不求和。前面已经强调过但值得再说一次因为它在化简分式表达式时特别容易犯。还有一个更隐蔽的规定在同一个表达式的不同项里自由指标的集合和位置必须完全一致。比如 A^i_j B^i_j 是合法的A^i_j B_i{}^j 就不合法除非你显式地用度规把指标挪过去。这条规则实际上是张量方程必须协变的直接体现它保证了方程在任何坐标系下形式相同。提示如果你在做推导时发现等式两边的自由指标对不上别急着改字母先怀疑是否有项被漏掉或者写错。至少有一半的情况是漏项。3.2 用 einsum 把公式逐字翻译成代码这是我觉得张量符号体系最爽的一个应用场景。你不需要在脑子里走一遍求和顺序只要把指标式照抄成字符串就行。看一个具体的例子。假设你要实现一个双线性池化类似的操作数学式是 s x^i A_{ij} y^j用einsum写出来是import torch torch.manual_seed(0) x torch.randn(8) A torch.randn(8, 8) y torch.randn(8) s torch.einsum(i,ij,j-, x, A, y) print(s.shape) # torch.Size([])我特别喜欢这个例子的原因是i,ij,j-这串字符几乎就是 x^i A_{ij} y^j 的直译i、ij、j 分别对应三个操作数的指标-后面为空表示全部缩并成标量。你不需要知道einsum内部是先做矩阵乘还是先做点积它自己会处理。再举几个更实操的B, N, C 4, 16, 32 X torch.randn(B, N, C) W torch.randn(C, C) # 对每个 batch、每个 token 做线性变换 Y torch.einsum(bnc,cd-bnd, X, W) # 注意力里的分数矩阵Q 和 K 都形如 (B, H, N, D) Q torch.randn(2, 8, 16, 32) K torch.randn(2, 8, 16, 32) scores torch.einsum(bhid,bhjd-bhij, Q, K)第二个例子里的bhid,bhjd-bhij是标准的注意力打分公式。我第一次看到这行代码的时候有点懵但一旦把它写成指标式 Q^{bhid} K_{bhjd} → S^{bhij}就非常清楚d 指标被缩并掉保留 b、h、i、j 四个自由指标。指标在哪个位置、要不要保留全由-右边控制。一个必须提醒的细节einsum支持隐式模式也就是不写-。比如torch.einsum(ij,jk, A, B)它会自动把重复指标缩并、把剩下指标按字母序排列输出。这个机制很省事但也很危险——你必须在脑子里跑一遍字母排序才知道输出形状是什么。我的建议是生产代码里永远写显式-。多敲几个字符换来的是可读性和零歧义。3.3 常用运算的指标式与 einsum 对照为了让你能直接抄我把高频操作整理成一张表。左中右分别是数学式、einsum字符串、以及是否可以直接用原生 API 替代。数学式einsum 字符串原生 API 等价A_{ij} B^{jk}ij,jk-ikA BA_{ij} B_{ji}ij,ji-(A * B.t()).sum()A_{ii}ii-torch.trace(A)A_{ii}不求和取对角ii-itorch.diagonal(A)A_{ij} 转置ij-jiA.t()A_{ij} 全和ij-A.sum()A_i B_ji,j-ijtorch.outer(A, B)A_{ij} B_{ij}ij,ij-(A * B).sum()A_{ij} B_{ij}逐元素ij,ij-ijA * B批量矩阵乘 A_{bij} B_{bjk}bij,bjk-biktorch.bmm(A, B)这张表里有一行值得单独说ii-i。在 PyTorch 里这个操作返回的是原张量的视图不是拷贝。也就是说你修改对角元素会同时修改原张量而torch.diagonal的行为是类似的但在某些版本上对非连续张量的表现有差异。这种细节平时不重要做原地运算in-place的时候就是 bug 的来源。我在一次梯度累积的实现里就被这个坑过本以为改的是副本结果把原始权重改了训练直接跑飞。另外那张表最后一列我的建议是能用原生 API 就别用einsum。原因很简单torch.matmul、torch.bmm这些是高度优化的算子底层可能调用了 cuBLAS 或者特定的 kernel而einsum在某些情况下会退化成通用的逐元素累加性能差好几倍。einsum的价值在于我懒得查 API和这个运算太特殊没有现成的而不是用它写什么都更优雅。4. 两个万能砖块delta 与 epsilon如果只允许我教两个符号我会选 Kronecker delta 和 Levi-Civita 符号。原因无他在实际推导中几乎所有化简都靠这两个东西。delta 负责搬指标epsilon 负责处理反对称结构。把它们练熟你的推导速度会提升一个量级。4.1 Kronecker delta 的指标替换功能定义很简单δ^i_j 当 i j 时等于 1否则等于 0。但它的作用远不止单位矩阵这么简单。核心性质是指标替换δ^i_j A^j A^i。你把它理解成一个过滤器它把 A 的第 j 个分量按上面那个公式搬到了 i 的位置求和过程中只有 i j 的那一项活下来。这个操作在化简公式时无比好用。再来两个常用的性质δ^i_i n其中 n 是指标的取值范围空间的维数。这是个经常被忽略的式子比如在三维空间里 δ^i_i 3。δ^i_j δ^j_k δ^i_k这是单位矩阵自乘等于自身在指标语言下的表达。举个例子体验一下。假设你要化简 A^i{}_j δ^j_k B^k{}_l按顺序替换先把 j 换成 k或者说把 δ^j_k 拿去把 A 里的 j 换成 k得到 A^i{}_k B^k{}_l。整个过程就是把同一个字母对齐、然后消掉 delta。用熟了完全是机械操作。注意δ 的上下标位置决定它把指标搬到哪。δ^i_j A^j A^i 是把下标搬成上标如果是 δ^i_j A_i那结果是 A_j方向相反。这在做升降指标的时候必须看清楚。4.2 Levi-Civita 符号与叉积、行列式ε_{ijk} 的定义是全反对称ε_{123} 1。也就是说指标是 1,2,3 的偶排列时为 1奇排列时为 -1有重复指标时为 0。三维下它有三个独立非零分量1,2,3 及其偶排列。它最经典的用途是写叉积(a × b)i ε{ijk} a^j b^k你看一个向量叉乘用指标式写出来就是这么一行不需要右手定则之类的文字说明。三个坐标分量一一展开和高中课本完全一致。它另一个用途是写行列式det(A) ε_{ijk} A^i{}_1 A^j{}_2 A^k{}_3或者更常见的形式 det(A) ε_{ijk} A^i{}_l A^j{}_m A^k{}_n ε^{lmn} / ……等各种变体。这个式子在证明行列式在基变换下的行为时非常关键因为它把行列式的反对称性用指标完全暴露出来了。实操里我最常用 epsilon 的地方是化简三重积和旋度相关表达式。比如 a·(b × c) 这个混合积用指标写就是 ε_{ijk} a^i b^j c^k一眼就能看出它对任意两个向量交换都变号——这就是混合积的反对称性用文字描述要写一大段用指标一行搞定。4.3 epsilon-delta 恒等式与它的实战价值这是张量运算里最值得背下来的公式之一ε_{ijk} ε_{ilm} δ_{jl} δ_{km} - δ_{jm} δ_{kl}它的推广版本缩并掉不同数量的指标是ε_{ijk} ε_{ijl} 2 δ_{kl}ε_{ijk} ε_{ijk} 6这三个式子建议直接背推导可以事后补。它们的价值在于把所有含叉积的表达式化成不含 epsilon 的形式。我给你演示一个经典推导三向量叉积公式 a × (b × c) b(a·c) - c(a·b)。写成指标式左边第 i 个分量是[a × (b × c)]i ε{ijk} a^j (b × c)^k ε_{ijk} a^j ε_{klm} b^l c^m把两个 epsilon 里重复的 k 缩并利用恒等式 ε_{ijk} ε_{klm} δ_{il} δ_{jm} - δ_{im} δ_{jl}代回去 (δ_{il} δ_{jm} - δ_{im} δ_{jl}) a^j b^l c^m a^j b^i c^j - a^j b^j c^i b^i (a·c) - c^i (a·b)推导完毕。全程机械操作没有一步需要灵光一现。这就是指标语言的力量把几何直觉转化成可以盲执行的代数操作。我当年学矢量分析的时候这个公式是死记硬背的学会了 epsilon-delta 恒等式之后它变成了一个三行的练习。5. 动手实操从手写指标到 PyTorch 跑通前面四节基本是纸上功夫这一节我们全部落到代码。我的习惯是每一个手推的公式都用einsum实现一遍再跟原生 API 的结果对比。这样既能验证推导又能建立符号-代码之间的映射直觉。5.1 环境准备与基础张量构造假设你已经装好了 PyTorch先做版本检查。这个动作看起来多余但einsum在不同版本上对非连续张量、对 ellipsis 的处理确实存在差异写代码前确认版本能省很多事。import torch import numpy as np print(torch.__version__) print(torch.cuda.is_available()) # 有卡的话会返回 True torch.manual_seed(42) # 一阶向量 v torch.arange(6, dtypetorch.float32) # 二阶矩阵 M torch.arange(12, dtypetorch.float32).reshape(3, 4) # 三阶批量比如 batch 里的特征图 T torch.randn(2, 3, 4) print(v.shape, M.shape, T.shape, T.stride())跑完之后重点看两样东西形状shape和步长stride。形状决定逻辑上有几维、每维多长步长决定物理内存怎么排。后面第五小节会专门讲这个。另外提醒一句 dtype。默认的浮点构造可能是 float32但在某些旧代码或 numpy 转换场景下会拿到 float64。float64 的张量显存占用是 float32 的两倍而且很多 GPU 算子对 float64 支持很差。我在一次迁移实验里就是因为误用了 float64速度慢了四倍排查了半天才发现是数据类型的问题。5.2 用 einsum 实现典型张量运算并交叉验证下面这段代码我一共做了六件事每一件都同时给出einsum写法和原生写法最后断言两者结果一致。建议你直接抄下来跑一遍感受一下两种写法之间的对应关系。import torch torch.manual_seed(0) A torch.randn(3, 4) B torch.randn(4, 5) x torch.randn(4) y torch.randn(4) # 1) 矩阵乘法C_ik A_ij B_jk C1 torch.einsum(ij,jk-ik, A, B) C2 A B assert torch.allclose(C1, C2, atol1e-6) # 2) 矩阵乘向量y_i A_ij x_j v1 torch.einsum(ij,j-i, A, x) v2 A x assert torch.allclose(v1, v2, atol1e-6) # 3) 外积O_ij x_i y_j O1 torch.einsum(i,j-ij, x, y) O2 torch.outer(x, y) assert torch.allclose(O1, O2, atol1e-6) # 4) 迹与对角 S torch.randn(5, 5) tr torch.einsum(ii-, S) dg torch.einsum(ii-i, S) assert torch.allclose(tr, torch.trace(S), atol1e-6) assert torch.allclose(dg, torch.diagonal(S), atol1e-6) # 5) 双线性形式s x_i A_ij y_j s1 torch.einsum(i,ij,j-, x, A[:, :4] if A.shape[1] 5 else A, x[:A.shape[1]]) # 为免形状不匹配单独构造一个方阵 Aq torch.randn(4, 4) s2 torch.einsum(i,ij,j-, x, Aq, x) s3 x Aq x assert torch.allclose(s2, s3, atol1e-6) # 6) 批量矩阵乘C_bik A_bij B_bjk Xb torch.randn(8, 3, 4) Yb torch.randn(8, 4, 5) Z1 torch.einsum(bij,bjk-bik, Xb, Yb) Z2 torch.bmm(Xb, Yb) assert torch.allclose(Z1, Z2, atol1e-6) print(所有交叉验证通过)跑通之后你会发现一个有意思的现象einsum那几行代码跟左边的指标式几乎可以逐字符对照。这就是我一直强调的符号即代码。当你下次遇到一个没见过现成 API 的运算比如两个四阶张量在中间两个指标上缩并直接写ijkl,klmn-ijmn就行不用去查文档里有没有对应的函数。顺手提一个优化方向einsum的默认缩并顺序不一定是最优的。当操作数有三个以上时不同缩并顺序的计算量差异可能达到几个数量级。这时候可以用opt_einsum库来寻找最优路径接口基本兼容。# 需要先安装pip install opt_einsum from opt_einsum import contract # 三个张量的链式缩并opt_einsum 会自动寻找较优的顺序 # 这在张量网络、多体物理的代码里几乎是标配5.3 形状变换与内存布局为什么 view 会突然报错这是工程侧最容易翻车的地方而且它跟指标思维直接相关。在数学上指标的排列顺序无所谓在内存里顺序决定了数据怎么排布进而决定了哪些操作是零成本、哪些要拷贝。先说结论PyTorch 的张量有两个属性shape和stride。shape是逻辑视图stride是物理地址的跳跃规则。当 stride 和 shape 匹配也就是满足连续条件时view可以零拷贝地改变逻辑形状一旦不匹配view就会报错。import torch x torch.arange(12).reshape(3, 4) print(x.stride()) # (4, 1) print(x.is_contiguous()) # True y x.t() print(y.shape) # torch.Size([4, 3]) print(y.stride()) # (1, 4) 注意这里变了 print(y.is_contiguous()) # False # y.view(-1) 会直接报错view size is not compatible with input tensors # y.reshape(-1) 则不会报错因为它会在必要时悄悄拷贝一份 z y.reshape(-1) print(z.shape, z.is_contiguous())这里的关键理解是转置没有移动任何数据它只是交换了 stride。所以y的逻辑顺序和内存顺序不再一致。view要求内存连续所以它拒绝工作reshape更宽容它会先判断能不能 view不能的话就调用contiguous()拷一份新的。提示reshape的宽容是有代价的。如果你在高频循环里反复reshape一个非连续张量会产生大量隐式拷贝性能断崖式下跌。这时候显式写x.contiguous().view(...)反而更好排查因为你知道拷贝发生在哪里。再来说permute。它和transpose是同一类操作都是重排指标顺序。用指标语言来理解特别自然x.permute(0, 2, 1)就是把形状为 (a, b, c) 的张量指标从 (i, j, k) 变成 (i, k, j)。它同样不拷贝数据只改 stride。所以permute之后紧跟着view大概率会报错。有一个常用的组合模式值得记住# 把 (B, C, H, W) 转成 (B, H, W, C) 再拉平成 (B, H*W, C) x torch.randn(4, 32, 16, 16) x x.permute(0, 2, 3, 1) # (B, H, W, C) x x.contiguous().view(4, 16 * 16, 32)这三行的顺序不能乱。先用permute调整逻辑顺序再用contiguous让它物理上连续最后view合并后两个指标。如果你把contiguous省掉第二行可能不会立刻报错但后面某个算子遇到非连续输入时会突然抛异常或者更糟——跑得对但很慢。5.4 张量网络视角把缩并画成图张量网络这个概念听起来吓人其实核心思想就一条每个张量是一个节点每条边是一个被缩并的指标图的结构决定了缩并的复杂度。矩阵乘法 C_ik A_ij B_jk 是最简单的例子两个节点 A 和 B共享一条边 j输出节点有两条自由边 i 和 k。批量矩阵乘 C_bik A_bij B_bjk 就是在这张图上多了一条 b 边而 b 是贯穿的、不被缩并的。一旦你习惯这种画法很多性能问题会变得直观。比如三个张量的链式缩并 A_ij B_jk C_kl你可以先缩并 j 再缩并 k也可以先缩并 k 再缩并 j。这两种顺序在数学上等价但计算量可能差好几个数量级。判断依据是中间结果的大小每次缩并后留下的自由指标越多中间张量就越大显存和算力开销就越恐怖。这也是opt_einsum这类工具存在的理由它会搜索缩并顺序找出中间张量最小的路径。我在做注意力机制的性能优化时曾经把一个四张量缩并的路径从先缩并大的改成先缩并小的显存峰值降了将近一半。顺带说一句记忆张量这个词。在工程语境里它通常指的是被显式缓存下来、跨多个计算步骤复用的中间张量。最典型的就是自回归推理时缓存的那部分键值张量——每一步都要读它、追加它但不重新计算它。它的设计目标是用空间换时间代价是显存占用。所以如果你在显存告急的情况下看到类似memory tensor 占用过高的日志处理思路不是优化算子而是考虑降低批量大小、缩短序列长度或者做分块的缓存管理。这个概念跟数学上的张量没关系纯粹是个工程术语别被名字骗了。6. 常见问题与排查技巧实录前面五节讲的是应该怎么做这一节讲出错的时候怎么找。我把这些年踩过的坑整理成三类按排查成本从低到高排列。6.1 指标类错误的排查清单指标写错是最难查的一类错误因为代码会正常跑完只是结果不对。我的排查顺序是这样的现象可能原因检查方法等式两边自由指标数量不一致漏写或错写指标逐项数指标出现次数某个指标出现三次需要引入辅助指标拆成两步缩并或改名结果维度对不上上下标位置错误检查是否显式用了度规结果差一个符号epsilon 指标顺序反了检查排列奇偶性结果差一个维度因子遗漏了对角项的 n检查 δ^i_i 是否被正确求值某个分量恒为零被求和的指标范围写错确认指标取值范围是否为 1..n这里面我觉得最值得展开的是差一个维度因子。δ^i_i n 这个式子在三维空间里是 3在四维时空里是 4。如果你在推导时把它当成 1结果会差好几倍而且这种错误在小规模测试里往往看不出来——因为你测的是标量没有量纲参照。我个人的习惯是每完成一个推导至少用两个不同的指标命名方式重写一遍检查最终表达式是否一致。这个做法有点笨但它拦下过我好几个错误。6.2 PyTorch 里的高频坑工程侧的坑大多集中在形状和连续性上我把最常遇到的几个列一下。第一个坑是einsum的隐式模式。前面提过这里再强调一次torch.einsum(ij,jk, A, B)不会报错但它的输出顺序是按字母排序自动决定的。如果你的代码依赖输出形状某次换了张量维度顺序输出形状可能就变了然后错误会在下游很远的地方才暴露出来。统一写显式-是唯一的解法。第二个坑是 dtype 不一致。einsum不会自动帮你做类型提升float32 和 float64 混用会报错或者静默提升影响性能。调试时先打印所有输入张量的 dtype。第三个坑是非连续输入。einsum本身能处理非连续张量但性能会下降。如果你的瓶颈在einsum上先检查输入是不是转置或 permute 之后的视图必要时先contiguous()。第四个坑是把einsum用在原地操作场景。einsum不支持 in-place 输出所以如果你在做需要原地修改的层就别指望它用matmul配合add_之类的写法。第五个坑是广播和 ellipsis 混用。...ij,...jk-...ik这种写法很方便但它对省略号覆盖的维度有隐含要求两边的批次维度必须能广播。如果你不确定就老老实实把维度写全。我在一个多模态项目里就吃过这个亏视觉特征和文本特征的批次维度顺序不同用 ellipsis 写的einsum没有报错但实际配对错了结果是图 A 配文 B损失还能降模型就是学不好。这种 bug 只能靠单元测试拦下来——每写完一个不平凡的einsum立刻用一个已知答案的小张量验证一遍。6.3 性能与显存别在小事上翻车最后聊一点实操中容易被忽略的成本问题。先算一笔简单的账。一个形状为 (1024, 1024, 64) 的 float32 张量元素个数是 1024 × 1024 × 64 67,108,864每个元素 4 字节总共约 256 MiB。如果你在前向过程中保留三四个这样的中间张量用于反向传播显存会在一瞬间被吃光。这不是理论推演是我在调大 batch size 时反复撞的墙。所以写张量运算时我的三条经验是一是优先用原生 APImatmul、bmm、conv2d这些底层都有专门优化比通用einsum快得多。只有当运算确实没有现成实现时才上einsum。二是注意中间张量的大小。多张量缩并时先缩并输出维度小的那一对指标能显著降低中间结果的规模。三是警惕隐式拷贝。reshape、contiguous、类型转换、CPU-GPU 之间的搬运这些都是隐式开销的来源。用torch.cuda.memory_allocated()之类的接口定期看看显存占用比事后猜要靠谱。提示调试显存问题时先把 batch size 设成 1跑一遍完整前向反向记录峰值占用再线性外推。这比直接上大 batch 然后 OOM 要高效得多。我在实际使用中还有一个体会张量运算的性能问题百分之八十出在不该拷贝的地方拷贝了和该缓存的没缓存这两件事上真正需要换算法的场景反而少。所以与其一上来就研究手工写 CUDA kernel不如先把形状、连续性、缩并顺序这三件事理顺。说到最后我个人在张量这件事上的体会是概念、符号、代码这三样东西必须轮着练。只读概念会飘只练符号会僵只写代码会盲。我自己的做法是每学一个新公式先在纸上手推一遍再用einsum实现最后用一个反例验证它——比如故意打乱指标顺序看结果是不是按预期变了。这个手推—实现—反证的三步循环比读十篇教程都管用。至于更深的张量分解、张量网络等这套基本语言练到不假思索再往上走会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价