在深度学习工程中,最隐蔽、最难排查的 Bug 往往藏在卷积、注意力或损失函数之间看似平淡无奇的 reshape、view、transpose、permute、squeeze 和 repeat 调用里。

想象一个常见的训练事故:你在复现一篇 Vision Transformer 或多模态大模型论文时,将形状为 (batch, channels, height, width) 的特征图重排为序列张量。你随手写下了 x.view(b, -1, c),代码顺利跑通,输出的 shape、dtype 和 device 分毫不差,Loss 也在缓慢下降,但最终的验证集精度始终比论文低 5 个百分点。排查数周后你才惊觉:由于少了前置的 permute(0, 2, 3, 1),直接 view 把不同空间位置与不同通道的浮点数强行交错揉成了一团——因为元素总数乘积恰好相等,PyTorch 和 NumPy 全程保持静默,连一个 Warning 都没有报。

Alex Rogozhnikov 发表在 ICLR 2022(Oral)的代表作 Einops: Clear and Reliable Tensor Manipulations with Einstein-like Notation,正是为了从根源上终结这类“形状合法但结构碎裂”的静默灾难。Einops 没有发明任何新的数学算子,也没有编写一行 C++/CUDA 底层 Kernel,却成为了现代深度学习代码库(从 PyTorch、HuggingFace 到各类大模型源码)事实上的张量操作标准语言。

Einops paper title and abstract

深入研读 ICLR 2022 论文与 einops(v0.8.2)源码可以发现,它的成功建立在四条高度凝练的设计哲学之上:

  1. 将隐式位置契约升维为显式轴语义声明(Declarative Axis Semantics over Positional Index Arithmetic):拒绝使用脱离上下文的整数下标(如 permute(0, 2, 3, 1))和充满歧义的 -1 占位符,强制将“输入各维叫什么、输出各维怎么排、复合维度按什么次序拆合”编码进同一行字符串 Pattern(如 'b c h w -> b (h w) c')。每一处调用既是维度变换指令,也是一道自带上下文的运行时结构断言。
  2. 用三原语集合不变量锁死元素守恒与增减边界(Set Invariants Separating rearrange, reduce, and repeat):刻意不设计大包大揽的单一 transform() 接口,而是依据左右基本轴集合 ALA_L 与 ARA_R 的严格关系切分出三个正交原语——rearrange(AL=ARA_L = A_R,双射重排与元素守恒)、reduce(AR⊂ALA_R \subset A_L,显式消轴归约)与 repeat(AL⊂ARA_L \subset A_R,显式增轴复制)。原本在 NumPy/PyTorch 中碎片化的十余种异构 API 由此收拢为统一的集合代数模型。
  3. 静态结构配方编译与运行时形状解算分离(Two-Stage Compilation from ParsedExpression to TransformRecipe):为了消除字符串解析在高频训练循环中的 Python 开销,Einops 将执行拆解为两阶段编译并配以双层 LRU 缓存:第一阶段仅根据 Pattern 字符串与输入秩 ndim 静态编译出与具体 Batch 大小无关的 TransformRecipe;第二阶段结合输入 shape 解算未知轴因子,并通过代数化简将其坍缩为最多五步的后端基础原语流水线(reshape -> transpose -> reduce -> add_axes -> reshape)。
  4. 零核心依赖的最小后端契约与编译图穿透(Zero-Dependency Lazy Dispatch & Graph Compiler Penetration):通过检查 sys.modules 实现惰性后端加载(get_backend),仅用 AbstractBackend 的 9 个最小方法抹平 NumPy、PyTorch、JAX、TensorFlow、Apple MLX 等十余个框架的 API 差异;同时通过 SymInt 符号维度传递与 torch._dynamo.allow_in_graph 白名单,确保高层字符串 DSL 能够被现代 JIT 编译器(如 torch.compile)无损穿透并完成底层算子融合。

本文将沿着**“ICLR 2022 论文设计推导”与“einops v0.8.2 编译器源码实现”**双线展开,完整剖析 Einstein-like Pattern 语法机制、经典深度学习模型重构案例、两阶段 Recipe 编译器源码、内存视图(View vs Copy)物理条件以及生产级测试规范。


1. 为什么位置下标 API 是静默 Bug 的温床?

1.1 位置编号 API 与裸 reshape 的结构盲区

在 NumPy、PyTorch、TensorFlow 等主流张量库中,多维数组的传统操作界面建立在整数位置下标(0, 1, 2, ...)之上:

Mainstream tensor operations in the paper

这套面向机器内存布局的接口虽然对底层 C/C++ 实现极其直接,但在表达复杂网络结构变换时,却把所有维度的业务语义丢给了程序员的大脑。看一个论文开篇给出的经典对比:假设输入图像批次 x 的形状是 (b, c, h, w),现在我们需要把每个样本的高置顶、并将批次 b 与宽度 w 合并为 (h, b * w, c)。对比下面两行原生代码:

# 两行代码的输出 shape 均为 (h, b * w, c),且全程不报任何错误!
y1 = x.transpose(2, 0, 3, 1).reshape(h, b * w, c)
y2 = x.transpose(2, 3, 0, 1).reshape(h, b * w, c)

这两行代码在运行时都能顺利通过,输出的 shape 完全相同,但其中一行已经把图像的空间结构彻底撕碎了!论文用一组极其直观的视觉实验(对标注了 "einops" 字样的图像张量执行不同的 reshape 与转置序列)展示了这种静默损坏的后果:

Breaking tensor structure with reshape

为什么裸 reshape / view 如此危险?因为从数学上看,给定输入形状 (n1,n2,…,nm)(n_1, n_2, \dots, n_m) 与目标形状 (n1′,n2′,…,nk′)(n'_1, n'_2, \dots, n'_k),位置 API 的 reshape 只校验总元素个数守恒:

∏i=1mni=∏j=1knj′\prod_{i=1}^{m} n_i = \prod_{j=1}^{k} n'_j

只要等式成立,它就直接按底层内存的线性字节流重新切分步长(Stride),根本不管新维度 nj′n'_j 究竟是由哪几个旧维度按什么顺序拼出来的。在工程实践中,许多团队试图靠写代码注释来补救:

# x: [batch, heads, seq, dim]
x = x.permute(0, 2, 1, 3).contiguous().view(batch, seq, -1)

但注释无法参与运行时校验:如果上游模块重构后传进来的张量变成了 [batch, seq, heads, dim],这行代码依然会静默跑通(甚至当 heads == seq 时连形状都不会变),把 seq 和 heads 彻底搞反!

1.2 Einops 的五项语言设计目标与核心术语边界

为了把注释里的意图变成可执行的程序契约,Einops 确立了 Pattern Language 的五项核心设计准则:

设计准则解决的传统痛点在 Einops 中的具体体现
Semantic Information(语义内聚)下标 0, 2, 1, 3 脱离上下文,需翻阅多层调用确认维度含义直接用 batch、heads、seq、dim 等业务标识符就地声明输入与输出
Runtime Checks(运行时契约校验)view(b, -1, c) 在维度错位或无法整除时缺乏精细断言自动校验输入输出 Rank、基本轴集合守恒、已知维度匹配与复合轴整除关系
Strictly Determined Result(结果唯一确定)多步 reshape + transpose 的中间状态难以心算给定输入形状、Pattern 与显式轴长,输出形状与元素线性排列顺序唯一确定
Uniformity(表达统一性)squeeze、stack、concat、tile、repeat 各自有一套怪异参数全部收拢为 rearrange、reduce、repeat(以及后续扩展的 pack / einsum)
Framework Independence(跨框架一致)NumPy 用 transpose(axes),PyTorch 用 permute(*dims),API 割裂同一条 Pattern 在 NumPy、PyTorch、JAX、TensorFlow、MLX 上行为 100% 一致

在深入语法之前,先厘清三个基础概念边界:

  1. 关于“Tensor”的语境:在微分几何与物理学中,张量(Tensor)要求满足严格的坐标变换协变/逆变法则;而在深度学习工程与本文中,Tensor 指代携带 shape、dtype、device 与自动微分图的多维数组(nn-dimensional array)。
  2. 与爱因斯坦求和约定(Einstein Summation)的渊源:经典的爱因斯坦求和约定(Cij=∑kAikBkjC_{ij} = \sum_k A_{ik} B_{kj})通过重复下标表达张量缩并;Einops 借鉴了它“用命名轴符号替代位置下标来描述张量变换关系”的核心思想,并将其泛化到了任意维度重排、分块池化与广播复制上。
  3. 能力演进脉络:ICLR 2022 论文奠定了 rearrange、reduce、repeat 三大原语、两阶段 Recipe 编译器与 EinMix 层;在此之后,社区版本陆续演进出多字符 einsum(v0.5)、异构秩打包 pack / unpack(v0.6)、Python Array API 与 torch.compile 适配(v0.7)以及 tinygrad / Apple MLX 后端支持(v0.8.2)。

2. Einstein-like Pattern 语法体系与三原语集合不变量

2.1 命名轴、复合轴分解与 C-order 线性排列公式

一条标准的 Einops Pattern 由左侧输入轴表达式、箭头 -> 和右侧输出轴表达式三部分构成:

from einops import rearrange

# 输入形状: (batch=8, channel=3, height=32, width=32)
# 输出形状: (batch=8, height=32, width=32, channel=3)
y = rearrange(x, 'batch channel height width -> batch height width channel')

在表达式中,空格是物理维度(Axis)的分隔符:箭头左侧有几个由空格分隔的项,就代表断言输入张量是几维(Rank);每个合法的 Python 变量名代表一个不可再分的基本轴(Elementary Axis);而圆括号 (...) 则用于将多个基本轴合成为一个物理维度——称为复合轴(Composite Axis),或者将一个输入物理维度拆解为多个基本轴:

Einops pattern language

理解复合轴 (axis1 axis2 axis3) 的核心钥匙是 C-order(行优先词典序,最右侧的基本轴变化最快)。设三个基本轴的当前坐标分别为 (i1,i2,i3)(i_1, i_2, i_3),对应轴长分别为 (n1,n2,n3)(n_1, n_2, n_3),当它们在圆括号内合成为单一物理维度时,合成后的线性下标 i∈[0,n1n2n3−1]i \in [0, n_1 n_2 n_3 - 1] 严格遵循如下双射公式:

i=((i1⋅n2)+i2)⋅n3+i3i = ((i_1 \cdot n_2) + i_2) \cdot n_3 + i_3

这个公式揭示了两个极其关键的工程事实:

  • 括号内的轴顺序直接决定内存交错布局:在 rearrange(images, 'b c h w -> b (h w) c') 中,(h w) 表示 w 在内层变化最快(即按图像行从左到右扫描);如果写成 (w h),虽然输出形状依然是 (b, h*w, c),但底层变成了按列从上到下扫描!
  • 拆轴(Axis Decomposition)必须满足唯一可解性与整除性:当圆括号出现在箭头左侧(如 'b (h w) c -> b h w c')时,意味着要把长度为 L=196L = 196 的单一序列维拆成两个基本轴 h 和 w。由于一个方程 h⋅w=196h \cdot w = 196 有无穷多组因子解(14×1414 \times 14、7×287 \times 28、4×494 \times 49),Einops 强制要求每个输入复合轴中最多只能有 1 个未知长度的基本轴,其余基本轴必须通过关键字参数显式给定:
# x.shape == (8, 196, 768),显式传入 h=14 后,编译器唯一解出 w = 196 // 14 = 14
grid = rearrange(x, 'b (h w) c -> b h w c', h=14)

一旦传入的 h=15 无法整除 196(196 mod 15≠0196 \bmod 15 \neq 0),或者漏传了 h 导致存在两个未知数,Einops 会在执行底层算子前当场抛出 EinopsError。

2.2 整数匿名轴、单位轴(1 / ())与省略号 ... 多态

除了命名标识符,Einops 语法还设计了三类特殊语法糖来处理常量拆轴、维度挤压(Squeeze/Unsqueeze)与可变秩多态:

  1. 整数匿名轴(Anonymous Axis,大于 1 的整数字面量):当你只需要把某个维度按固定常数拆开(例如把融合计算的 QKV 投影维按常数 3 拆开),又不想专门起一个轴名再写一遍 qkv=3 时,可以直接在 Pattern 里写数字 3:
    # 直接将通道维按常数 3 拆至首维: (b, n, 3*d) -> (3, b, n, d)
    q, k, v = rearrange(qkv, 'b n (3 d) -> 3 b n d')
  2. 单位轴(Singleton Axis,数字 1 或空括号 ()):在复合轴乘法中,乘以 11 不会改变元素个数。因此,任何长度为 1 的物理维度本质上都是“包含 0 个基本轴的空复合轴 ()”。这让 squeeze 和 unsqueeze 自然成为了 rearrange 的特例:
    # 等价于 unsqueeze:在首尾插入长度为 1 的物理维度 (h, w) -> (1, h, w, 1)
    y = rearrange(x, 'h w -> 1 h w 1')
    
    # 等价于带安全断言的 squeeze(0):若第 0 维实际长度不为 1,立即报错拦截!
    y = rearrange(x, '() h w -> h w')
  3. 省略号 ...(Ellipsis,可变秩多态):当编写通用工具函数,希望同时兼容 (h, w)、(b, h, w) 乃至 (b, t, v, h, w) 等任意前置批次维度时,可以用 ... 匹配零个或多个未命名的维度:
    # 保持前置所有批次维度原封不动,仅合并末尾的 heads 与 dim
    y = rearrange(x, '... heads dim -> ... (heads dim)')

2.3 三大核心原语的集合不变量与十余种算子大一统

为什么 Einops 要把接口拆成 rearrange、reduce、repeat 三个函数?

这是它最精妙的防呆设计。设箭头左侧的基本轴集合为 ALA_L,右侧的基本轴集合为 ARA_R(不含长度为 1 的单位轴)。三个函数在编译器入口处分别锁死了三条互斥的基本轴集合不变量(Set Invariants):

核心原语基本轴集合不变量物理与代数语义编译器自动拦截的典型人为失误
rearrangeAL=ARA_L = A_R元素双射重排(总数严格守恒,仅改变视图布局或坐标映射)拦截拼写错误(如把 height 拼成 heigth)或意外漏写某个维度
reduceAR⊂ALA_R \subset A_L沿消去轴集合 AL∖ARA_L \setminus A_R 聚合归约(池化、求均值/最值)拦截在归约时误引入左侧不存在的新轴,或忘记消去池化窗口轴
repeatAL⊂ARA_L \subset A_R沿新增轴集合 AR∖ALA_R \setminus A_L 复制扩展(广播、Tile、Interleave)强制要求调用方为每一个新增轴 a∈AR∖ALa \in A_R \setminus A_L 显式传入长度参数

有了这三条集合不变量,原本在 NumPy 和 PyTorch 中散乱不堪的十几种操作(transpose、reshape、flatten、squeeze、expand_dims、stack、concatenate、split、max/mean、repeat、tile),全部被收编进了同一套自解释的声明式语法中:

Correspondence between mainstream operations and einops


3. 核心原语全景实战与神经网络层集成

3.1 rearrange、reduce 与 repeat 的高阶工程用法

在日常编写深度学习模型时,掌握以下三组惯用法(Idioms)能直接砍掉 80% 的胶水代码:

第一组:rearrange 接收张量列表,单步完成 stack / concat + permute。 当函数的第一个参数传入 list[Tensor] 时,rearrange 会自动把列表本身视作左侧的第 0 个逻辑基本轴!这意味着你不再需要先判断调 torch.stack 还是 torch.cat,只需看这个列表轴在右侧是独立成轴(对应 stack)还是并入其他括号(对应 concat):

# 1) 列表轴 views 独立保留 -> 等价于 stack 后再交换维度: 4 个 (b, c, h, w) -> (b, 4, c, h, w)
multi_view = rearrange([x1, x2, x3, x4], 'views b c h w -> b views c h w')

# 2) 列表轴 views 并入通道括号 (views c) -> 等价于沿通道维 concat: 4 个 (b, c, h, w) -> (b, 4*c, h, w)
concat_channels = rearrange([x1, x2, x3, x4], 'views b c h w -> b (views c) h w')

第二组:reduce 结合复合轴拆解,一行实现任意空间窗口池化与组内归一化统计。 给定归约算子 ρ∈{’sum’,’mean’,’max’,’min’,’prod’,’any’,’all’}\rho \in \{\text{'sum'}, \text{'mean'}, \text{'max'}, \text{'min'}, \text{'prod'}, \text{'any'}, \text{'all'}\}(或任意形如 Callable[[Tensor, tuple[int, ...]], Tensor] 的自定义归约函数),reduce 会先按左侧括号拆出局部窗口轴,再将右侧未出现的窗口轴一次性聚合消去:

yAR=ρAL∖AR(xAL)y_{A_R} = \rho_{A_L \setminus A_R}\left(x_{A_L}\right)
from einops import reduce

# 1) 全局平均池化 (Global Average Pooling): 消去 h 和 w,(b, c, h, w) -> (b, c)
gap = reduce(x, 'b c h w -> b c', 'mean')

# 2) 2x2 步长为 2 的最大池化 (MaxPool2d): 将高宽拆为网格 (h, w) 与核窗口 (kh, kw),消去 (kh, kw)
max_pooled = reduce(x, 'b c (h kh) (w kw) -> b c h w', 'max', kh=2, kw=2)

# 3) GroupNorm / InstanceNorm 组内均值统计:用 1 保留空间与组内通道单位轴,便于后续直接广播相减
group_mean = reduce(
    x,
    'b (groups group_c) h w -> b (groups 1) 1 1',
    'mean',
    groups=32,
)

第三组:repeat 用圆括号内的左右次序彻底终结 repeat_interleave 与 tile 的混淆。 在原生 PyTorch/NumPy 中,要把序列 [a, b, c] 变成 [a, a, b, b, c, c](元素级原地重复)还是 [a, b, c, a, b, c](整体序列循环平铺),需要分别查文档找 repeat_interleave 和 tile。而在 Einops 中,这完全由新增轴 r 在圆括号 (w r) 还是 (r w) 中的 C-order 位置决定:

from einops import repeat

# 1) 新增轴 r 在右侧 (w r),r 变化最快 -> 每个元素原地连续重复 r 次: [a, a, b, b, c, c]
interleaved = repeat(x, 'h w -> h (w r)', r=2)

# 2) 新增轴 r 在左侧 (r w),w 变化最快 -> 整段序列整体平铺 r 遍: [a, b, c, a, b, c]
tiled = repeat(x, 'h w -> h (r w)', r=2)

# 3) 2x 最近邻图像上采样 (Nearest-Neighbor Upsampling): 高和宽方向同时原地重复 2 次
upsampled = repeat(img, 'b c h w -> b c (h 2) (w 2)')

3.2 pack / unpack 与多字符 einsum:面向多模态与复杂缩并的扩展

随着多模态大模型(VLM)与复杂注意力机制的普及,einops 在后续版本中补齐了两块关键拼图:

1. pack 与 unpack(异构 Rank 张量的可逆序列打包)。 在多模态 Transformer 中,输入往往是一组维度数量(Rank)各不相同的张量:全局分类向量 cls_token 形状为 (b, c),2D 视觉特征图 image_grid 形状为 (b, h, w, c),音频或文本序列 text_tokens 形状为 (b, t, c)。如果手写代码,你必须先分别把它们 reshape 成 3D,记录各自的长度,调 torch.cat 拼起来,跑完 Attention 后再手算切片下标 [:, 1:1+h*w, :] 拆回去。

pack 引入了星号通配符 *,专门匹配“除首尾共享轴(如 b 和 c)之外的任意个中间维度”,自动将每个张量的中间维度展平并沿序列维拼接(总长度 P=∑i∏jni,jP = \sum_i \prod_j n_{i,j}),同时返回记录了各张量原始中间形状的元数据列表 packed_shapes:

from einops import pack, unpack

# 输入形状分别为: (b, c), (b, h, w, c), (b, t, c)
# pack 自动计算出 packed_shapes = [(), (h, w), (t,)],并输出统一序列 (b, 1 + h*w + t, c)
sequence, packed_shapes = pack([cls_token, image_grid, text_tokens], 'b * c')

# 统一送入标准 Transformer 层做跨模态自注意力交互
sequence = transformer_block(sequence)

# 前向结束后,直接传入同批次生成的 packed_shapes,一行无损还原各自的 2D / 4D / 3D 形状!
cls_out, image_out, text_out = unpack(sequence, packed_shapes, 'b * c')

2. einops.einsum(告别单字母猜谜的张量缩并)。 原生 torch.einsum('bqhd,bkhd->bhqk', q, k) 强制要求每个轴只能用一个英文字母表示,当张量有五六个维度时,满屏的 ijklmno 极易写串位置。einops.einsum 允许直接使用完整的单词轴名,并将 Pattern 字符串统一放在参数列表末尾:

from einops import einsum

# 1) 多头注意力 QK^T 缩并: s_{b,h,q,k} = \sum_d q_{b,q,h,d} \cdot k_{b,k,h,d}
scores = einsum(
    q, k,
    'batch query head dim, batch key head dim -> batch head query key',
)

# 2) 支持省略号多态的批量矩阵映射与方阵求迹 (Trace)
projected = einsum(weight, x, 'out_dim in_dim, ... in_dim -> ... out_dim')
matrix_trace = einsum(square_mat, 'idx idx ->')

3.3 Rearrange / Reduce 模块封装与 EinMix 参数化轴线性层

当你使用 nn.Sequential 构建网络,或者希望设计类似 MLP-Mixer 那样需要在特定维度(如空间 Token 维或分组通道维)上执行参数化线性投影的模块时,可以直接调用 einops.layers 与自带可学习权重的 EinMix 层:

from torch import nn
from einops.layers.torch import Rearrange, Reduce, EinMix

# 1) 将维度重排与池化直接嵌入 nn.Sequential
patch_embed = nn.Sequential(
    Rearrange('b c (h ph) (w pw) -> b (h w) (ph pw c)', ph=16, pw=16),
    nn.LayerNorm(16 * 16 * 3),
    nn.Linear(16 * 16 * 3, 768),
)

# 2) 使用 EinMix 一行实现 MLP-Mixer 的跨 Token 空间混合层 (无需手写 transpose + Linear + transpose)
token_mixer = EinMix(
    'b tokens channels -> b mixed_tokens channels',
    weight_shape='tokens mixed_tokens',
    bias_shape='mixed_tokens',
    tokens=196,
    mixed_tokens=196,
)

查看 einops/layers/_einmix.py 源码可以看到,EinMix 在 __init__ 阶段会自动根据 weight_shape 和 bias_shape 包含的轴,利用扇入度(Fan-in)完成均匀分布权重初始化,并将前向计算预编译为“前置 rearrange 拆轴 → 底层单字母 einsum 权重缩并 → 后置 rearrange 合轴 + 广播偏置”的三步高效管线。


4. 经典深度学习架构重构 Case Studies

4.1 视觉特征重排:Vision Permutator 与 ShuffleNet 通道混洗

论文正文展示的第一个重构案例是 Vision Permutator。该架构的核心思想是分别沿着图像的高度(Height)方向和宽度(Width)方向对分段通道特征做线性混合。在原始作者的开源代码中,为了完成这两个方向的投影,不得不编写两段充满魔术下标 permute 与 reshape 的冗长代码;而在 Einops 重构版中,高度混合与宽度混合呈现出了完美的数学对称性——两者的唯一区别仅仅是把 Pattern 中的 h 和 w 互换位置:

Vision Permutator case study

# 沿高度方向 (h) 混合分段特征: 将 h 并入线性层作用的最后一维 (h s)
x_h = rearrange(x, 'b h w (n s) -> b n w (h s)', s=s)
x_h = rearrange(proj_h(x_h), 'b n w (h s) -> b h w (n s)', s=s)

# 沿宽度方向 (w) 混合分段特征: 仅需交换上面两行中的 h 与 w
x_w = rearrange(x, 'b h w (n s) -> b n h (w s)', s=s)
x_w = rearrange(proj_w(x_w), 'b n h (w s) -> b h w (n s)', s=s)

另一个极具代表性的视觉算子是 ShuffleNet 的通道混洗(Channel Shuffle)。为了打破分组卷积(Group Convolution)各组之间的信息壁垒,ShuffleNet 需要把通道维 C 拆成 groups × channels_per_group 矩阵,转置后再展平回 C。对比原生 PyTorch 的 4 行位置操作与 Einops 的单行声明:

# 原生 PyTorch 写法:需手动计算整除、记忆维度下标 1 和 2,并显式处理 contiguous
batch, channels, height, width = x.shape
x_old = x.view(batch, groups, channels // groups, height, width)
x_old = x_old.transpose(1, 2).contiguous().view(batch, channels, height, width)

# Einops 写法:将 (groups channels_per_group) 翻转为 (channels_per_group groups),意图一目了然
x_new = rearrange(
    x,
    'b (groups c_per_group) h w -> b (c_per_group groups) h w',
    groups=groups,
)

4.2 可逆空间变换:Glow Squeeze、PixelShuffle 与 ViT Patchify 的 C-Order 陷阱

在生成流模型(如 Glow)、超分辨率上采样(Sub-pixel Convolution / PixelShuffle)以及 Vision Transformer(ViT)中,最核心的结构操作是空间网格与通道/序列之间的无损可逆映射。

看论文中 Glow 模型的 squeeze(Space-to-Depth)与 unsqueeze(Depth-to-Space)对比。在传统写法中,正向与逆向的 transpose 置换数组分别是 (0, 1, 4, 2, 5, 3) 和 (0, 3, 4, 1, 5, 2),肉眼根本无法判断这两个数组是否互为逆排列;而在 Einops 中,逆变换永远等于把正向 Pattern 箭头左右两侧的字符串直接对调:

Glow reversible squeeze and unsqueeze

def space_to_depth(x, factor=2):
    # 正向:把 2x2 空间子块 (fh, fw) 折叠进通道维
    return rearrange(x, 'b c (h fh) (w fw) -> b (c fh fw) h w', fh=factor, fw=factor)

def depth_to_space(y, factor=2):
    # 逆向:直接将箭头左右两侧对调,100% 保证代数可逆!
    return rearrange(y, 'b (c fh fw) h w -> b c (h fh) (w fw)', fh=factor, fw=factor)

同样的可逆对称性也直接应用于 ViT 的图像分块(Patchify / Unpatchify):

# Patchify: (b, 3, 224, 224) -> (b, 14*14, 16*16*3) = (b, 196, 768)
patches = rearrange(
    images,
    'b c (grid_h patch_h) (grid_w patch_w) -> b (grid_h grid_w) (patch_h patch_w c)',
    patch_h=16, patch_w=16,
)

# Unpatchify: 交换箭头两侧,将生成的 Patch 序列无损拼回完整图像
restored = rearrange(
    patches,
    'b (grid_h grid_w) (patch_h patch_w c) -> b c (grid_h patch_h) (grid_w patch_w)',
    grid_h=14, grid_w=14, patch_h=16, patch_w=16, c=3,
)

工程避坑指南(C-order 权重错位陷阱):在加载开源预训练 ViT 或 MAE 权重时,请务必核对最后一个括号里写的是 (patch_h patch_w c) 还是 (c patch_h patch_w)!两者的乘积都是 16×16×3=76816 \times 16 \times 3 = 768,但前者对应 [R0, G0, B0, R1, G1, B1, ...] 的像素交错排列,后者对应 [R0, R1, ..., G0, G1, ..., B0, B1, ...] 的通道平面排列。如果与预训练权重的线性投影层(nn.Linear(768, dim))顺序不一致,模型不报任何错,但提取出的视觉特征会彻底失真。

4.3 Multi-Head Attention 全链路重构

论文附录对比了标准多头注意力(Multi-Head Attention)的原生写法与 Einops 重构版:

Multi-head attention case study

结合 rearrange 与 einsum,多头注意力的“拆头 → 缩放点积 → Softmax → 聚合 Value → 并头”五步曲可以直接写成像数学公式一样清晰的自解释代码:

from einops import einsum, rearrange

# 1) 拆头:将融合通道维拆解为显式的 head 与 dim 轴
q = rearrange(q, 'b query (head dim) -> b head query dim', head=heads)
k = rearrange(k, 'b key   (head dim) -> b head key   dim', head=heads)
v = rearrange(v, 'b key   (head dim) -> b head key   dim', head=heads)

# 2) 沿 dim 轴点积缩并,得到注意力矩阵 (b, head, query, key)
scores = einsum(q, k, 'b head query dim, b head key dim -> b head query key') / (q.shape[-1] ** 0.5)
attn = scores.softmax(dim=-1)

# 3) 沿 key 轴加权聚合 Value,并将 head 与 dim 重新合并回特征维
out = einsum(attn, v, 'b head query key, b head key dim -> b head query dim')
out = rearrange(out, 'b head query dim -> b query (head dim)')

5. 编译器源码精读(上):从 ParsedExpression 到 TransformRecipe

为什么一句简单的字符串 'b c (h kh) (w kw) -> b h w c' 能够处理任意维度的拆分、置换、池化与广播?打开 einops v0.8.2 源码,你会发现它的核心其实是一个不折不扣的微型张量结构编译器。

无论是 rearrange、reduce 还是 repeat,在 einops/einops.py 中最终都汇聚到同一条两阶段编译与执行管线(其中 rearrange 和 repeat 只是将 operation 分别设为 'rearrange' 和 'repeat' 后复用通用管线):

用户调用: reduce(x, 'b c (h kh) (w kw) -> b h w c', 'mean', kh=2, kw=2)
  │
  ├─► Stage 0: get_backend(x) ──► 识别框架后端 (如 TorchBackend),获取输入秩 ndim = 4
  │
  ├─► Stage 1 [静态编译,一级 LRU 缓存 (maxsize=256)]:
  │     _prepare_transformation_recipe(pattern, operation='mean', axes_names=('kh','kw'), ndim=4)
  │       ├── ParsedExpression(left) & ParsedExpression(right) 词法扫描与括号语法树解析
  │       ├── 校验集合不变量 (A_R ⊂ A_L) 与单复合轴最多 1 个未知因子约束
  │       └── 产出与具体图像分辨率无关的静态配方 TransformRecipe
  │
  ├─► Stage 2 [运行时形状解算,二级 LRU 缓存 (maxsize=1024)]:
  │     _reconstruct_from_shape(recipe, shape=(8, 64, 32, 32), axes_dims=(2, 2))
  │       ├── 解出全部 6 个基本轴长度: b=8, c=64, h=16, kh=2, w=16, kw=2
  │       └── 代数化简产出五步算子参数: (init_shapes, axes_reordering, reduced_axes, added_axes, final_shapes)
  │
  └─► Stage 3 [后端原语执行]:
        _apply_recipe(backend, x, ...) ──► 依次调用 backend.reshape / transpose / reduce

5.1 词法解析器 ParsedExpression 与 AnonymousAxis 身份比较技巧

管线的第一站是位于 einops/parsing.py 的 ParsedExpression 类。它对箭头左、右两侧的字符串分别执行单趟扫描,提取出两个最关键的结构:

  • identifiers:当前侧出现的所有基本轴集合;
  • composition:一个二维嵌套列表 list[list[Axis]],外层列表的每个元素对应张量的一个物理维度,内层列表记录该物理维度由哪几个基本轴按 C-order 顺序合成(空列表 [] 代表数字 1 或 () 单位轴)。

例如,解析左侧字符串 'b c (h kh) (w kw)' 时,ParsedExpression 产出的 composition 为:

left.composition = [
    ['b'],          # 第 0 维:单一基本轴 b
    ['c'],          # 第 1 维:单一基本轴 c
    ['h', 'kh'],    # 第 2 维:复合轴 (h kh)
    ['w', 'kw'],    # 第 3 维:复合轴 (w kw)
]

在这里,parsing.py 藏着一个非常精巧的面向对象技巧:当用户在 Pattern 中写下大于 1 的匿名数字(例如 reduce(x, 'b (h 2) (w 2) -> b h w', 'max') 中的两个 2)时,如果直接用整数 2 或字符串 '2' 存入 composition,解析器就会误以为左边的两个 2 是同一个轴名(从而触发“单侧不允许重复轴名”报错)!

为了解决这个问题,ParsedExpression 每遇到一个大于 1 的整数,就会实例化一个独立的 AnonymousAxis("2") 对象。由于 AnonymousAxis 类故意不重写 __eq__ 方法(默认使用 Python 对象内存地址 id 做相等判断),因此左侧的两个 AnonymousAxis("2") 在集合 identifiers 中是两个完全不相等的独立基本轴,各自拥有固定长度 2,既避免了命名冲突,又防止了左右两侧碰巧相同的数字被错误配对转置!

5.2 _prepare_transformation_recipe 与静态 TransformRecipe 编译推演

拿到左右两个 ParsedExpression 后,einops/einops.py 中的 _prepare_transformation_recipe() 开始编译静态配方 TransformRecipe。

为了让你彻底看懂编译器内部做了什么,我们直接用刚才的 2×2 平均池化并转置 NHWC 的例子——reduce(x, 'b c (h kh) (w kw) -> b h w c', 'mean', kh=2, kw=2),一步步推演 TransformRecipe 的生成过程:

  1. 给左侧基本轴分配内部连续编号: 扫描左侧 ['b'], ['c'], ['h', 'kh'], ['w', 'kw'],共有 6 个基本轴,按出现顺序编号为:
    • 0: b, 1: c, 2: h, 3: kh, 4: w, 5: kw
  2. 划分已知轴(Known)与未知轴(Unknown): 因为调用方传入了关键字参数 axes_names=('kh', 'kw'),所以在静态编译期,轴 3 (kh) 和轴 5 (kw) 是已知轴,其余 0 (b), 1 (c), 2 (h), 4 (w) 是待从输入 x.shape 中推导的未知轴。编译器据此生成 input_composition_known_unknown:
    • 物理维 0 (b): known=[], unknown=[0]
    • 物理维 1 (c): known=[], unknown=[1]
    • 物理维 2 ((h kh)): known=[3], unknown=[2](仅 1 个未知数 2,合法!)
    • 物理维 3 ((w kw)): known=[5], unknown=[4](仅 1 个未知数 4,合法!)
  3. 计算基本轴置换数组 axes_permutation 与归约分界点 first_reduced_axis: 编译器观察右侧表达式 'b h w c',发现需要保留的基本轴按输出顺序是 [0 (b), 2 (h), 4 (w), 1 (c)];而被消去(需要做 mean 归约)的基本轴是 [3 (kh), 5 (kw)]。 编译器将“保留轴”排在前面,“待归约轴”统一排到尾部,得到:
    • axes_permutation = [0, 2, 4, 1, 3, 5]
    • first_reduced_axis = 4(意味着置换后的第 4、5 号维度将被一次性 reduce 消掉!)
  4. 记录最终输出合并规则 output_composite_axes: 归约消掉尾部两个轴后,剩下的 4 个轴刚好一一对应右侧的 'b h w c',故 output_composite_axes = [[0], [1], [2], [3]]。

将上述结果汇总,就是 TransformRecipe 的七个核心静态属性:

TransformRecipe 属性物理含义本例编译出的具体值
elementary_axes_lengths各基本轴的静态已知长度或未知占位符[Unknown, Unknown, Unknown, Arg(kh), Unknown, Arg(kw)]
axis_name2elementary_axis关键字参数名到基本轴编号的映射{'kh': 3, 'kw': 5}
input_composition_known_unknown每个输入物理维度的 (已知轴列表, 未知轴列表)[([], [0]), ([], [1]), ([3], [2]), ([5], [4])]
axes_permutation将拆开后的基本轴重排为“输出序 + 尾部归约序”[0, 2, 4, 1, 3, 5]
first_reduced_axis置换后尾部待归约轴的起始下标4(对应置换后的 kh 与 kw)
added_axesrepeat 新增广播轴的目标位置映射{}(本例非 repeat)
output_composite_axes归约/广播后,各输出物理维度包含的轴下标[[0], [1], [2], [3]]

5.3 运行时 Shape 方程求解、代数化简与五步标准流水线

进入 Stage 2,当一个真实形状为 x.shape = (8, 64, 32, 32) 且参数为 kh=2, kw=2 的张量到达 _reconstruct_from_shape_uncached() 时,求解器对每个输入物理维 dd(长度为 LdL_d)计算已知轴乘积 Pknown=∏i∈KdniP_{\text{known}} = \prod_{i \in K_d} n_i:

  • 对于物理维 2(L2=32L_2 = 32),已知轴 3 (kh) 长度为 Pknown=2P_{\text{known}} = 2,校验 32 mod 2==032 \bmod 2 == 0 通过,解出未知轴 2 (h) 的长度 n2=32//2=16n_2 = 32 // 2 = 16;
  • 同理解出全部 6 个基本轴的真实长度为 [8, 64, 16, 2, 16, 2]。

随后,_apply_recipe() 将解算出的参数送入五步标准后端执行流水线:

def _apply_recipe(backend, tensor, reduction_type, axes_dims):
    init_shapes, axes_reordering, reduced_axes, added_axes, final_shapes, n_axes_w_added = (
        _reconstruct_from_shape(recipe, backend.shape(tensor), axes_dims)
    )
    if init_shapes is not None:                       # Step 1. 初次 reshape:把输入复合轴拆成基本轴
        tensor = backend.reshape(tensor, init_shapes)
    if axes_reordering is not None:                   # Step 2. transpose:按目标顺序重排,并把归约轴移到末尾
        tensor = backend.transpose(tensor, axes_reordering)
    if len(reduced_axes) > 0:                         # Step 3. reduce:一次性对尾部待消去轴执行归约
        tensor = _reduce_axes(tensor, reduction_type=reduction_type, reduced_axes=reduced_axes, backend=backend)
    if len(added_axes) > 0:                           # Step 4. add_axes:插入单位轴并 expand/tile 广播 (repeat)
        tensor = backend.add_axes(tensor, n_axes=n_axes_w_added, pos2len=added_axes)
    if final_shapes is not None:                      # Step 5. 最终 reshape:将基本轴合并为目标输出复合轴
        tensor = backend.reshape(tensor, final_shapes)
    return tensor

不仅如此,编译器在产出这五步参数时还内置了代数化简优化:

  • 在刚才的例子里,Step 3 对尾部第 (4, 5) 维执行完 mean 归约后,张量形状已经直接变成了 (8, 16, 16, 64),恰好等于目标输出形状!因此编译器自动将 final_shapes 设为 None,直接省去了 Step 5 的多余 reshape!
  • 再比如当你调用 rearrange(x, 'a b c d -> (a b) (c d)') 时,由于相邻基本轴的相对次序完全没有改变,编译器会自动将相邻同序轴合并,把 init_shapes 和 axes_reordering 全部化简为 None,整条流水线直接坍缩为单次 backend.reshape!

6. 编译器源码精读(下):两级缓存、惰性多后端分发与 torch.compile 穿透

6.1 两级 @lru_cache 与解析开销基准实测

既然 Einops 内部有词法扫描、集合校验与整数方程求解,在训练循环中每秒调用上万次会不会拖慢 GPU?

答案藏在 einops/einops.py 的两级 @functools.lru_cache 设计中:

  1. 一级 Recipe 缓存(@functools.lru_cache(256),修饰 _prepare_transformation_recipe):缓存键为 (pattern, operation, axes_names_tuple, ndim)。同一个 Pattern 字符串在整个训练生命周期中只会被解析和编译一次。
  2. 二级 Shape 计划缓存(@functools.lru_cache(1024),修饰 _reconstruct_from_shape):缓存键为 (recipe, shape_tuple, axes_dims_tuple)。在固定输入形状的训练 Step 中,连基本的整除校验和五元组构造都会直接查表跳过,瞬间直达底层的 backend.reshape / transpose!

论文附录专门在极小的 NumPy 数组上测试了缓存机制的加速比,并统计了对流行视觉模型库的代码行数压缩效果:

Einops caching and applicability study

实验表明,在刻意构造 10,000 个互不相同的 Pattern 强行击穿缓存时,10,000 次调用耗时 663 ms;而在符合真实训练场景的缓存命中状态下,10,000 次调用仅耗时 37.1 ms(单次调度开销仅约 3.7 微秒,加速近 18 倍)。

6.2 零核心依赖的秘密:sys.modules 惰性分发与 AbstractBackend

为什么 pip install einops 可以在不安装任何深度学习框架的情况下秒级完成,却能在运行时无缝操作 PyTorch、JAX、TensorFlow、CuPy、Paddle、tinygrad 和 Apple MLX 的张量?

打开 einops/_backends.py,核心机制浓缩在 get_backend(tensor) 函数中:

_type2backend: Dict[type, AbstractBackend] = {}

def get_backend(tensor) -> AbstractBackend:
    _type = type(tensor)
    result = _type2backend.get(_type, None)
    if result is not None:
        return result

    for framework_name, backend in list(_loaded_backends.items()):
        if backend.is_appropriate_type(tensor):
            _type2backend[_type] = result = backend
            return result

    # 仅遍历那些 framework_name 已经存在于 sys.modules 中的 AbstractBackend 子类!
    for BackendSubclass in AbstractBackend.__subclasses__():
        if BackendSubclass.framework_name in sys.modules:
            backend = BackendSubclass()
            _loaded_backends[BackendSubclass.framework_name] = backend
            if backend.is_appropriate_type(tensor):
                _type2backend[_type] = result = backend
                return result

这段不到 25 行的分发器解决了三个棘手的系统问题:

  • 零启动拖累:绝不在 import einops 时主动 import torch 或 import tensorflow,只有当用户自己的代码已经导入了某个框架(从而出现在 sys.modules 中)时,才按需实例化对应的后端适配器;
  • 热路径 O(1)O(1) 查表:首次识别出 torch.Tensor 后,直接将 torch.Tensor -> TorchBackend 缓存在 _type2backend 字典中,后续千万次调用只需一次指针哈希查找;
  • 最小契约隔离:面对各大框架五花八门的 API 命名(如下图所示),AbstractBackend 仅要求子类实现 shape、reshape、transpose、reduce、add_axes、stack_on_zeroth_dimension、concat、einsum 等 9 个最小方法,新增一个框架后端(如 v0.8.2 新增的 MLXBackend)只需短短几十行代码。

Backend API comparison

此外,针对遵循 Python Array API Standard(提供 __array_namespace__() 接口)的现代数组库,einops 还提供了 einops.array_api 模块,直接通过标准命名空间调用 xp.reshape、xp.permute_dims 与 xp.broadcast_to。

6.3 PyTorch 图编译器适配:torch.compile、TorchDynamo 与 SymInt

在 PyTorch 2.x 时代,工程师最关心的是:在模型里写 rearrange 会不会导致 torch.compile 发生计算图断裂(Graph Break)?

答案是不会。打开 einops/_torch_specific.py,可以看到 Einops 针对 PyTorch 的两代编译器做了专门适配:

  1. 针对 torch.compile(TorchDynamo)的穿透适配:在 PyTorch 2.0 ~ 2.7 中,allow_ops_in_compiled_graph() 会自动将 rearrange、reduce、repeat、einsum、pack、unpack 注册进 torch._dynamo.allow_in_graph;而在 PyTorch 2.8+ 中,由于 Dynamo 字节码追踪器已经原生支持追踪 @lru_cache 与内部字典操作,Dynamo 可以直接穿透整条 _apply_recipe 管线,将最终生成的 tensor.reshape 与 tensor.permute 捕获进 FX Graph,交由后端的 TorchInductor 自动与前后的矩阵乘或激活函数做算子融合(Kernel Fusion)!
  2. 针对动态形状 torch.SymInt 的算术兼容:当开启 torch.compile(dynamic=True) 时,tensor.shape 中的动态批次或序列维度会由普通 Python int 变为符号整数 SymInt。在 _reconstruct_from_shape_uncached() 中,源码通过 isinstance(length, int) 区分静态常数与符号维度:对符号维度跳过仅限常数的取模断言,直接构造符号整除表达式 length // known_product,确保动态 Shape 计算图完整不断裂。

7. 物理内存布局(View vs Copy)、能力边界与生态横向对比

7.1 什么时候返回零拷贝 View?什么时候触发物理 Copy?

高频算子调优中常有人问:“rearrange 究竟是零拷贝视图(View)还是会发生物理内存复制(Copy)?”

这取决于底层张量的步长连续性条件(Stride Contiguity)。在 PyTorch 中,一个形状为 (d0,d1,…,dk−1)(d_0, d_1, \dots, d_{k-1})、步长为 (s0,s1,…,sk−1)(s_0, s_1, \dots, s_{k-1}) 的张量,两个相邻维度 ii 和 i+1i+1 能够免拷贝合并为一个维度的充要条件是:

si=di+1⋅si+1s_i = d_{i+1} \cdot s_{i+1}

我们用 x = torch.randn(2, 3, 4, 5)(标准 NCHW 连续张量,初始步长为 (60, 20, 5, 1))测试三种最常见的 rearrange 模式,底层的内存行为一目了然:

调用表达式编译器生成的底层算子序列输出张量的 Stride是否发生物理内存拷贝?
rearrange(x, 'b c h w -> b h w c')x.permute(0, 2, 3, 1)(60, 5, 1, 20)零拷贝(View):仅交换元数据中的 shape 与 stride 下标
rearrange(x, 'b c h w -> b c (h w)')x.reshape(2, 3, 20)(60, 20, 1)零拷贝(View):因 s2=5=d3⋅s3=5×1s_2 = 5 = d_3 \cdot s_3 = 5 \times 1,相邻维直接合并
rearrange(x, 'b c h w -> b (h w) c')x.permute(0, 2, 3, 1).reshape(2, 20, 3)(60, 1, 20)零拷贝(View):转置后 h 与 w 的步长为 5 和 1,依然满足 5=5×15 = 5 \times 1!
rearrange(x, 'b c h w -> b h (w c)')x.permute(0, 2, 3, 1).reshape(2, 4, 15)(60, 15, 1)触发物理 Copy:转置后 w 与 c 的步长为 1 和 20,不满足连续条件,reshape 自动分配新内存

论文在 Multi-Head Attention、Vision Permutator 与 Glow Unsqueeze 三个模块上对比了手写原生 PyTorch 代码与 Einops 重构版在 CPU / GPU 及 JIT 编译下的实测吞吐:

Einops performance comparison

可以看到,在正常训练批次规模下,由于两级缓存消除了 Python 解析开销,且底层生成的 reshape / permute 序列与手写代码完全一致,Einops 重构版与原生手写代码处于同一性能水平。

7.2 运行时检查的真实边界:能拦截什么,不能拦截什么?

把 Einops 用出生产级可靠性,关键在于清楚它的运行时契约能拦住什么,以及绝对拦不住什么:

错误类型Einops 能否自动拦截?典型场景与物理原因
输入维度数(Rank)不符能拦截输入是 3D (b, n, c) 却调用 'b c h w -> ...',当场报维度数不匹配
基本轴集合丢失或凭空多出能拦截rearrange 漏写了某个轴,或 repeat 忘了传新增轴长度,编译期直接报错
复合轴长度无法整除能拦截含 [CLS] 的 197 个 Token 传入 'b (h w) c -> b h w c', h=14,因 197 mod 14≠0197 \bmod 14 \neq 0 报错
两个等长轴(如 H=WH = W)写反不能拦截当图像是正方形 H=W=224H = W = 224 时,把 (h w) 写成 (w h),所有乘积与整除均合法,静默通过
调用点把上游张量的轴语义写反不能拦截上游传过来的是 NHWC,调用者却在左侧声明为 'b c h w -> ...',只要数值碰巧能整除就会按错误语义执行
跨函数边界的轴标签漂移不能拦截函数 A 返回 (b, tokens, c),函数 B 入口误以为是 (b, heads, c),Einops 不在 Tensor 对象上持久化存储轴名

7.3 与 jaxtyping、xarray 及 einx 的生态互补关系

针对上面提到的“跨函数边界缺乏静态检查(Stringly Typed)”问题,如果将 Einops 放回整个 Python 张量编程生态坐标系中,就能看清它与其他工具的黄金搭档关系:

方案 / 工具作用位置核心解决的问题与 Einops 的协作或取舍关系
原生位置 API (x.T, x.mean(-1))算子内部极简的单步维度操作2 维矩阵转置 x.T 或单轴求均值保留原生写法即可;涉及拆轴/合轴/多步转置时切换为 Einops
jaxtyping + beartype函数签名边界用类型注解声明函数入参与返回值的形状契约(如 Float[Tensor, "b t c"])最佳互补搭档:用 jaxtyping 守住函数进出口边界,用 einops 实现函数体内部的结构变换
xarray / NamedTensor数据对象生命周期将轴名作为元数据持久化绑定在数组对象上适合地球科学、金融表格分析;在深度学习密集算子链中维护对象元数据侵入性过高
einx (ICLR 2026)算子内部将 Einstein 语法进一步泛化到逐元素加减广播、Gather 索引与 vmap表达力更激进,而 Einops 刻意保持极小、正交的原语集以换取零心智负担与高稳定性

8. 生产级测试规范:用“互质非对称形状 + arange”揪出潜伏 Bug

既然当 H=W=14H = W = 14 或 Batch=Heads=8\text{Batch} = \text{Heads} = 8 时,写反两个等长维度不会触发任何形状异常,那我们在做模型重构和编写单元测试时,该如何 100% 揪出所有维度错位 Bug?

最有效的工程铁律是:在单元测试中,永远使用两两互不相同的素数(如 2, 3, 5, 7, 11)作为各基本轴的长度,并填充严格递增序列 np.arange 验证元素级排列等价性与正逆 Round-Trip 可逆性!

下面是一套可直接复制到项目 pytest 测试套件中的完整验证矩阵:

import numpy as np
import pytest
from einops import EinopsError, einsum, pack, rearrange, reduce, repeat, unpack

def test_einops_structural_invariants_and_roundtrip():
    # 1) 使用两两互质的轴长 (b=2, c=3, h=5, w=7) + arange,确保任何两轴写反都会导致数值断言失败
    x = np.arange(2 * 3 * 5 * 7).reshape(2, 3, 5, 7)
    np.testing.assert_array_equal(rearrange(x, 'b c h w -> b h w c'), x.transpose(0, 2, 3, 1))
    np.testing.assert_array_equal(reduce(x, 'b c h w -> b c', 'sum'), x.sum(axis=(2, 3)))
    np.testing.assert_array_equal(
        repeat(x, 'b c h w -> b copies c h w', copies=11),
        np.repeat(x[:, None], 11, axis=1),
    )

    # 2) 使用非对称网格 (h=3 != w=2, ph=5 != pw=7) 验证 Patchify / Unpatchify 的严格可逆性
    images = np.arange(2 * 3 * (3 * 5) * (2 * 7)).reshape(2, 3, 15, 14)
    patches = rearrange(images, 'b c (h ph) (w pw) -> b (h w) (ph pw c)', ph=5, pw=7)
    restored = rearrange(patches, 'b (h w) (ph pw c) -> b c (h ph) (w pw)', h=3, w=2, ph=5, pw=7, c=3)
    np.testing.assert_array_equal(restored, images)

    # 3) 验证异构 Rank 的 pack / unpack 无损还原与多字符 einsum 缩并精度
    inputs = [np.zeros((2, 5)), np.ones((2, 3, 5)), np.full((2, 4, 7, 5), 2.0)]
    packed, ps = pack(inputs, 'b * c')
    for before, after in zip(inputs, unpack(packed, ps, 'b * c')):
        np.testing.assert_array_equal(before, after)

    q = np.random.default_rng(0).normal(size=(2, 5, 3, 7))
    k = np.random.default_rng(1).normal(size=(2, 11, 3, 7))
    np.testing.assert_allclose(
        einsum(q, k, 'batch query head dim, batch key head dim -> batch head query key'),
        np.einsum('bqhd,bkhd->bhqk', q, k),
    )

    # 4) 验证破坏整除契约时精准抛出 EinopsError
    with pytest.raises(EinopsError):
        rearrange(np.zeros((2, 197, 8)), 'b (h w) c -> b h w c', h=14)

9. 总结

回看 Einops 从一篇讨论“如何写好 reshape”的 ICLR 2022 Oral 论文,成长为全行业深度学习基础设施的历程,它最深刻的启示在于对软件工程抽象边界的极致克制:

语义显式化 :用 'b c (h kh) (w kw) -> b h w c' 消灭魔术下标与过期的形状注释
契约集合化 :用 A_L 与 A_R 的集合关系切分 rearrange / reduce / repeat,在入口拦截维度丢失
编译两级化 :用静态 TransformRecipe + 动态 Shape 求解 + 双层 LRU 缓存,实现微秒级调度
后端最小化 :用 9 个 AbstractBackend 原语 + sys.modules 惰性加载,零依赖打通十余个计算框架

正如作者 Alex Rogozhnikov 所强调的:张量的形状(Shape)与轴变换关系本身就是一等公民级别的程序语义,绝不应该只留在易过期的注释、晦涩的位置下标和程序员脆弱的短期记忆里。

参考资料