
我第一次在同事的Transformer代码里看到x * torch.sigmoid(y)这行代码时注释里只写了三个字Gate。那会儿我刚工作不久第一反应是“这不对吧神经网络的乘法不都应该用矩阵乘法吗”后来才搞清楚这行代码用的正是阿达玛乘积Hadamard Product——两个形状完全相同的张量按对应位置一个一个乘。别看它的定义只有一句话在如今的深度学习里它几乎无处不在RNN的门控、LayerNorm的缩放、SiLU激活、SwiGLU前馈层背后全是它。这篇文章我就把平时在项目里对它的理解、推导和踩坑整理出来适合所有正在写训练代码的人。很多人学了矩阵乘法之后看到“乘法”两个字就默认是matmul这是第一个坑。阿达玛乘积从符号到计算逻辑都跟矩阵乘法不是一回事。搞清楚它RNN系列的公式会瞬间通顺很多手写自定义算子的时候也不会再把梯度算错。1. 阿达玛乘积到底是什么从一次“奇怪的乘法”开始1.1 定义、名字和它被单独立项的原因设矩阵 A 和 B 形状完全相同比如都是 n×m 的矩阵。它们的阿达玛乘积写作C A ⊙ B其中每个元素满足C_ij A_ij × B_ij也就是 A 的第 i 行第 j 列元素乘上 B 的同位置元素得到 C 的同位置元素。这里没有任何求和、没有行和列的交叉配对纯粹是“位置对位置”的乘法。这个运算最早和法国数学家雅克·阿达马Jacques Hadamard有关所以被翻译成“阿达玛乘积”。在一些数学文献里它也被称为“舒尔积”Schur product因为舒尔也研究过这类逐元素乘积的谱性质。在工程圈子里大家更常叫它“逐元素乘法”element-wise product“对应位置相乘”这个说法最直白。有一个问题很自然一个看起来这么简单的定义为什么值得单独取个名字原因是它的代数性质跟矩阵乘法完全不同。矩阵乘法一般不满足交换律AB ≠ BA。阿达玛乘积满足交换律A ⊙ B B ⊙ A。矩阵乘法会把不同位置的信息混合起来阿达玛乘积则严格保持每个位置的“独立性”。更关键的是神经网络里有一个非常常见的需求对一组特征做逐维度的独立缩放。矩阵乘法做不到这种精细控制因为它会在加权求和时把所有维度的信息混在一起。而阿达玛乘积天然就是干这个的。所以它值得一个名字也值得单独写一篇博文。1.2 三种直觉缩放、掩码、门控我在代码评审时经常用一个办法帮人建立直觉把A ⊙ B看成三种东西根据 B 的取值范围不同它有三种完全不同的语义。第一种是缩放。假设 A 是一个向量B 是另一个同形状的向量。如果 B 里全是固定的正数那么 A ⊙ B 就是把 A 的每个维度分别放大或缩小。比如 A 是 [身高, 体重, 年龄]B 是 [0.5, 2, 1]结果就是身高减半、体重翻倍、年龄不变。矩阵乘法做不到这种“只影响单个维度”的操作因为一行乘一列会把其他位置的信息混进来。第二种是掩码。如果 B 里的元素是 0 或 1那么 A ⊙ B 等价于把某些位置直接保留、某些位置清零。这就像在一张表格上用黑笔把不需要的行列涂掉保留的部分原样不动涂抹的部分变成 0。第三种是门控。如果 B 里的元素在 0 到 1 之间那 A ⊙ B 就是在做“放行多少”的控制。比如 B 是 0.9就保留 A 对应位置的 90%B 是 0.1就只让 10% 的信号通过。这比掩码更平滑也是 LSTM、GRU、SwiGLU 这类结构能工作的核心。这三种直觉指向同一个数学操作但应用场景完全不同。这也是为什么我在阅读论文代码时看到公式里出现一个乘号不会立刻默认是矩阵乘法而是先看它是不是逐元素乘。2. 计算逻辑与几何意义它和矩阵乘法完全不同2.1 一张表对比四种相关运算很多人记不清楚阿达玛乘积是因为它跟矩阵乘法、内积、外积长得太像。我把这四种运算放在一起一张表就能看明白。运算定义形状要求计算复杂度核心语义阿达玛乘积 A ⊙ BC_ij A_ij × B_ij形状完全一致O(n²)对应位置相乘无求和矩阵乘法 A BC_ij Σ_k A_ik × B_kjA 的列数等于 B 的行数O(n³)行与列组合的加权求和内积 a · bΣ_i a_i × b_i两个同长度向量O(n)逐元素相乘再全部求和外积 a ⊗ bᵀC_ij a_i × b_j两个任意长度向量O(n²)两两组合生成矩阵这里假设两个矩阵都是 n×n 的方阵所以复杂度用 O(n²) 和 O(n³) 表示。工程上阿达玛乘积因为不需要把不同位置的值相加数据访问模式非常规整在很多硬件上都可以做到极高的吞吐。我的一个生活化类比是这样矩阵乘法像全班交换作业每个人的最终得分是全班同学成绩的加权组合阿达玛乘积像同桌对答案只看自己对应的这一对坐标别人的成绩跟自己没关系。前者交换信息后者保持独立。从几何角度看阿达玛乘积对向量执行的是一种“各向异性缩放”。如果一个向量在标准正交基下表示为各个坐标分量乘上一个同形状的向量本质上是每个基方向上单独拉长或缩短。这就相当于用一个对角矩阵去乘原来的向量但省掉了构造对角矩阵那样庞大的存储浪费。2.2 它是向量内积的前半步内积的定义是Σ a_i × b_i。把它拆开看前面是一个阿达玛乘积后面是一个求和归约。也就是a · b sum(a ⊙ b)这个拆解在工程和数学上都很重要。工程上einsum(bd,bd-b, a, b)这种写法本质上就是“先逐元素乘再对最后一个维度求和”。把步骤拆开你可以决定在哪里优化。如果直接写成(a * b).sum(dim-1)中间会生成一个跟 a 形状一样的临时张量如果用 einsum很多后端会尽量把乘法和求和放在同一个循环里减少一次内存往返。数学上很多看似复杂的计算可以拆成“一个阿达玛乘积 一个归约”。比如注意力机制里的相似度打分、多头注意力里的加权求和、余弦相似度核心都是这个模式。理解了内积和阿达玛乘积的关系再看那些公式就不会觉得它们是一堆散装符号了。2.3 广播算不算阿达玛乘积工程定义与数学定义的边界严格数学定义里阿达玛乘积要求两个矩阵形状完全相同。但在 PyTorch、NumPy 这些框架里*运算会触发广播broadcasting。比如一个(batch, d)的张量乘一个(d,)的向量后者会被自动扩展成(batch, d)然后再逐元素乘。这种运算从实现上看是“广播后的逐元素乘法”很多人会直接把它也叫作阿达玛乘积。严格来说它不是因为参与运算的两个张量原始形状不一样。但在深度学习社区这个边界经常被模糊掉。LayerNorm 里的 gamma 缩放就是一个典型例子。gamma 是(d,)的向量归一化后的特征图是(batch, d)代码里写x_hat * gamma。严格说这是广播逐元素乘但它在特征维上做的正是“每个特征独立缩放”这件事语义和阿达玛乘积完全一致。SENet 里的通道注意力也是类似的道理。这个区别重要吗重要因为排查 bug 时必须知道这一点。如果一个形状是(batch, 1, d)另一个是(batch, seq, 1)直接相乘不会报错广播规则会构造出一个(batch, seq, d)的外积式扩展。你以为自己在做逐元素门控实际上在做一对多组合模型效果会变得非常奇怪。这种 bug 在纯手动分析形状的时候很难看出来所以我一般建议对这种操作使用显式的 einsum把维度含义写清楚。3. 你在深度学习模型里见到的每一个⊙五大典型场景3.1 RNN与LSTM/GRU门控机制的核心运算我第一次真正意识到阿达玛乘积的价值是在读 LSTM 公式的时候。LSTM 的核心更新公式是c_t f_t ⊙ c_{t-1} i_t ⊙ c̃_t h_t o_t ⊙ tanh(c_t)GRU 的更新公式也是类似h_t z_t ⊙ h_{t-1} (1 - z_t) ⊙ h̃_t这里的 f_t、i_t、o_t、z_t 都是门控向量取值在 0 到 1 之间形状和隐藏状态 h 完全一致。它们做的事情就是“决定记住多少旧信息、写入多少新信息、输出多少当前状态”。为什么这里不能用矩阵乘法替代这是理解 RNN 结构的关键。矩阵乘法会把门控向量的每个分量和其他隐藏状态的维度混合起来于是一个维度的“遗忘”会牵连其他维度门控的语义就被破坏了。阿达玛乘积让“第 i 个门控值只影响第 i 维隐藏状态”这种逐维度的精细控制是循环神经网络梯度能够流动的基础之一。PyTorch 的写法极其简单h_t z * h_prev (1 - z) * h_tilde这里*就是阿达玛乘积。要求 z、h_prev、h_tilde 形状完全一致都是(batch, hidden)。如果写成z h_prev轻则报维度错误重则运气好凑对维度却在语义上变成了一次完全错误的矩阵变换模型可能怎么训都训不好。3.2 激活函数中的逐元素相乘SiLU与Swish一类非常隐蔽的阿达玛乘积活在激活函数里。SiLU也叫 Swish的定义是SiLU(x) x * σ(x)也就是输入 x 和它的 sigmoid 结果逐元素相乘。这里x和σ(x)是完全相同形状的张量所以这就是阿达玛乘积。从语义上看σ(x) 在 x 很负时接近 0在 x 很大时接近 1。乘上 x 之后负值被抑制得很厉害正值大致保留。所以这个激活函数相当于给每个神经元配了一个平滑的开关。你不需要判断“这个神经元该不该死”而是让网络自己学出一套连续的开关系数。实现上它就是一个 memory-bound 算子和矩阵乘法的计算模式完全不同。很多高性能推理引擎把它和之前的卷积或全连接层融合成一个 kernel就是因为逐元素乘本身不贵贵的是反复读写显存。顺带说一句GELU 也沾点阿达玛乘积的边。GELU 的数学定义近似为x * Φ(x)这里 Φ 是标准正态分布的累积分布函数。虽然实际实现经常用 tanh 近似但本质上也是一次逐元素乘。只是工程上通常把它当成一个整体激活函数来实现不太会有人刻意拆开看。3.3 LayerNorm与逐元素缩放给每个特征单独调音量LayerNorm 的公式是y gamma ⊙ x_hat betax_hat 是归一化后的向量gamma 和 beta 是形状与特征维度一致的可学习参数。在代码里通常写x_hat * gamma beta其中x_hat * gamma就是广播版的阿达玛乘积。如果你想把这种缩放写成矩阵乘法需要一个对角矩阵 DD_ii 等于 gamma_i然后计算 D x_hat。但 gama 向量只是 d 个元素构造对角矩阵会白白浪费 d² 的内存乘法的计算量也从 O(d) 变成 O(d²)纯属亏本买卖。所以工程上一定会选逐元素乘。每次看到 LayerNorm 我总会跟新人强调这行代码里已经藏了一个阿达玛乘积。很多人学完“逐元素乘”这个概念转头就忘了其实它每天都在主干网络里跑。3.4 Attention Mask与通道注意力乘性掩码的应用与风险注意力机制里有大量“掩码”操作。Transformer 做因果推理时要让当前位置只能看到自己及之前的位置就需要把未来位置的注意力分数压掉。常见的做法是加性掩码logits logits mask # mask 里被屏蔽的位置是 -inf也有人写成乘性掩码logits logits * mask # mask 里被屏蔽的位置是 0乘性掩码在数学语义上就是阿达玛乘积注意力分数矩阵和掩码矩阵形状相同对应位置相乘0 的位置被清掉1 的位置保留。但实际工程中要小心数值问题因为 mask 里如果存在 0 和 -inf 同时出现的情况0 * (-inf)会直接算出 NaN。这个坑我在第 4.4 节单独说。另一个典型是视觉里的通道注意力。SENet 的做法是全局平均池化得到每个通道的统计量经过两层 MLP 和 sigmoid 生成(C, 1, 1)的通道权重再和原始特征图逐元素乘。out x * weight # weight 是通道维上的缩放系数这个weight经过广播后作用在每个通道的所有空间位置上本质上是“按通道缩放”和阿达玛乘积的语义一致。很多人把这个叫 channel-wise multiplication其实心里想的就是阿达玛乘积。3.5 SwiGLU与大模型FFN现代大模型里藏着的两次逐元素乘现在已经 2025 年如果你在跑 LLaMA、Qwen 这类模型前馈网络里几乎一定会见到 SwiGLU。它的核心结构是out (x W1 ⊙ swish(x W2)) W3也就是用x W1得到一个线性变换结果用x W2经过 Swish 得到一个门控信号然后两者逐元素乘。这行代码里其实吃了两处阿达玛乘积swish(x W2)内部是一次x W2的结果和它的 sigmoid 逐元素乘。外层线性变换结果 ⊙ 门控信号又是一次逐元素乘。也就是说一个大模型前馈层在短短几十个 token 的计算里至少出现了两次阿达玛乘积。理解这点对优化推理很有用如果能把两个逐元素乘和后面的矩阵乘法融合到一个 kernel 里就能减少好几轮显存读写。我在实践里做过类似融合收益比单纯调矩阵乘法库的 block size 来得更直接。4. 模型实操在框架里写对阿达玛乘积的正确姿势4.1 PyTorch、NumPy、TensorFlow 里的正确写法先列一张各框架的速查表免得再来回翻文档。框架阿达玛乘积矩阵乘法PyTorch*或torch.mul或torch.matmulNumPy*或np.multiply或np.dotTensorFlow*或tf.multiplytf.linalg.matmul在 PyTorch 里最常用的写法是import torch # 两个形状完全相同的张量 A torch.randn(4, 16) B torch.randn(4, 16) # 写法一运算符 * C A * B # 写法二显式函数 C2 torch.mul(A, B) # 写法三明确指定每个维度的语义 C3 torch.einsum(bd,bd-bd, A, B)我个人的习惯是如果是临时写在一个小实验脚本里直接用*就好如果这段代码要活很久或者形状容易搞混我会用 einsum 把每个维度的含义写出来。einsum 的好处是它强制你声明“哪些维度参与配对”一旦写错报错信息通常比乱广播更直观。4.2 手写反向传播为什么阿达玛乘积的梯度这么简单阿达玛乘积的前向计算简单反向传播同样简单。设 C A ⊙ B假设损失 L 对 C 的梯度是 G那么∂L/∂A G ⊙ B ∂L/∂B G ⊙ A推导过程很直接C_ij 只依赖 A_ij 和 B_ij不依赖任何其他位置。所以每个元素的梯度只跟同位置的梯度乘另一个同位置的输入有关。没有求和没有转置不需要任何跨维度累加。如果你需要写一个自定义的torch.autograd.Function固定写法是这样的import torch class HadamardFunction(torch.autograd.Function): staticmethod def forward(ctx, a, b): ctx.save_for_backward(a, b) return a * b staticmethod def backward(ctx, grad_output): a, b ctx.saved_tensors return grad_output * b, grad_output * a注意返回顺序必须和 forward 的入参顺序一致第一个入参是 a那么第一个返回值必须是grad_output * b。我见过有人把两个返回值顺序写反loss 还在下降但梯度其实已经从第一个维度流进了第二个维度模型非常难收敛最后靠gradcheck才查出来。还有一个小细节如果 A 和 B 是同一个变量比如y x * x梯度就不是grad_output * x而是2 * x * grad_output。因为两个分支的梯度都要贡献这在实现自定义激活函数时很容易漏。4.3 性能细节逐元素乘“便宜”但不要乱生成中间张量从硬件角度看阿达玛乘积没有归约步骤每个输出元素只做一次乘法和一次内存写入属于内存带宽受限操作。它比矩阵乘法的计算强度低很多在 GPU 上跑得飞快但有个容易被忽略的问题中间张量太多显存会迅速膨胀。最常见的一段低效代码是# 先产生 (batch, seq, hidden) 的完整中间结果再做求和 score (q * k).sum(dim-1)如果 q 和 k 都很庞大这里会先申请一块和它们一样大的显存存完乘积再读取做求和。更好的写法是score torch.einsum(...d,...d-..., q, k)einsum 会让后端尽量把乘法和求和合并到一次循环里。当然不同后端的实现细节不完全一样有时 einsum 也不一定真的省掉中间张量但在 PyTorch 里它通常是一个更优的起点。如果你在做高性能推理最终还是要靠自定义 kernel 把 mul 和 sum 或者 mul 和 add 融合在一起。一个非常朴素但能说明思路的 C 扩展例子是这样的at::Tensor fused_mul_sum(at::Tensor a, at::Tensor b) { auto batch a.size(0); auto hidden a.size(1); auto out at::empty({batch}, a.options()); const float* a_ptr a.data_ptrfloat(); const float* b_ptr b.data_ptrfloat(); float* out_ptr out.data_ptrfloat(); for (int64_t i 0; i batch; i) { float acc 0.0f; for (int64_t j 0; j hidden; j) { acc a_ptr[i * hidden j] * b_ptr[i * hidden j]; } out_ptr[i] acc; } return out; }这个循环把乘法和加法放在同一个循环体里完成避免了先写一个完整乘积矩阵再读回来。实际生产代码还需要加向量化和更细致的内存布局优化但原理就是减少中间张量的读写。4.4 数值稳定性掩码乘法为什么会悄悄产生 NaN注意力机制里最常见的数值问题就是乘法掩码和 -inf 混用导致 NaN。原因很简单在 IEEE 754 浮点数标准里0 × inf NaN。如果你把注意力分数矩阵和一个掩码矩阵做逐元素乘logits logits * mask # mask 中被屏蔽的位置填 0任何被 mask 的位置都会和 -inf 相乘结果变成 NaN。一旦 NaN 进入 softmax后面所有计算都会延续 NaN。正确做法是加法掩码logits logits mask # mask 中被屏蔽的位置填 -inf有限值加上 -inf 仍然等于 -inf。softmax 对 -inf 的输出会精确变成 0。这才是在 Transformer 里最常见的标准做法。如果你想用乘性掩码又不想让数值出问题有的人会把 mask 里的屏蔽位填成 -1e9 而不是 0。这样乘出来是一个很大的负数softmax 后非常接近 0一般不会报 NaN但严格来说注意力权重不完全是 0会在结果里留下一点渗漏。当数值很敏感时这点渗漏可能会影响梯度质量。我的建议是默认用加法掩码乘法掩码只在你确认不会引入 inf 的情况下再用。5. 常见问题与排查技巧实录5.1 广播陷阱你以为在逐元素乘实际在算外积我在写一个自定义 attention 时踩过一个大坑。当时 logits 的形状是(batch, num_heads, seq, seq)mask 的形状是(batch, seq, 1)。我写了一句logits logits * mask代码没报错训练 loss 却在某个 epoch 之后开始乱跳。排查半天发现广播规则把(batch, seq, 1)扩展到了整个(batch, num_heads, seq, seq)但它扩展的方向和我想的不一样。本来只想屏蔽指定时间步却让某些键位置完全失去注意力分布整体行为变得很奇怪。另一个高发场景是x x ** 2 # (batch, 1, hidden) y y ** 2 # (batch, seq, 1) z x * y # 得到 (batch, seq, hidden)但这不是哈达玛乘积而是外积式扩展如果这两个张量是从不同模块来的形状差一个维度PyTorch 不会报错而是在最后两个维度上做一次“所有位置两两组合”。这种 bug 的特征是训练能跑loss 前期也在降但模型性能上限很低。排查时只要在出问题的张量前后打印 shape基本就能定位。我的技巧是凡是语义上应该是“逐元素操作”的地方优先用 einsum 明确写出每个维度的语义比如bd,bd-bd。这样广播规则没有机会偷偷掺一脚。5.2 自定义算子的 backward 顺序写反前文已经提到过自定义autograd.Function时 backward 返回值顺序必须和 forward 入参顺序一致。一旦写反梯度会流向错误的位置。这里有一个很隐蔽的现象如果 forward 入参是 a 和 bbackward 返回的是grad_output * a和grad_output * b也就是把系数交换了。此时如果 a 和 b 的数值范围差不多网络可能在很多步内看起来还在正常工作因为梯度的“总量”没有变大或变小只是被分配到了错误的参数上。只有当某个参数本来应该接大梯度却接了小梯度时才能观察到收敛慢或干脆不收敛。解决这个问题没有捷径写完自定义算子立刻跑一遍 gradcheck下面这段代码可以直接复用from torch.autograd import gradcheck inputs ( torch.randn(4, 8, dtypetorch.double, requires_gradTrue), torch.randn(4, 8, dtypetorch.double, requires_gradTrue), ) print(gradcheck(HadamardFunction.apply, inputs))gradcheck 跑的是数值梯度对比如果反向传播公式写错它会在几个样本上迅速暴露问题。建议所有自定义算子都把这个检查加进单元测试别等训到一半再回头找。5.3 把*当成矩阵乘法一个让线性层直接失效的经典错误我从 NumPy 切到 PyTorch 时也干过这事。某次写线性层时顺手写了out weight * x我默认这是矩阵乘法。实际上 PyTorch 的*是逐元素乘如果 weight 形状是(64, 64)x 形状也是(64, 64)这行代码不会报错但含义变成了 64×64 个位置各乘各的。结果是模型参数没有做特征加权组合只是在每个输入位置乘了一个固定系数。这个结构几乎没有表达能力loss 会降一点但很快就卡死。排查方法很直接检查 weight 和 x 的形状关系如果 row 数和 col 数对不上矩阵乘法的要求那你大概率在用错算子。修正方案就是把*改成。这个坑在读别人代码时也常见尤其是从原生 Python 或者 NumPy 迁过来的项目。看到weight * x且 weight 是二维矩阵时第一反应就应该是检查它到底想表达矩阵乘法还是逐元素乘。5.4 不确定时用一个最小脚本验证有时你不确定某个张量操作到底在做什么最简单的方法不是翻文档而是造两个小矩阵直接手算。import numpy as np A np.array([[1.0, 2.0], [3.0, 4.0]]) B np.array([[5.0, 6.0], [7.0, 8.0]]) print(A * B) # 阿达玛乘积 print(A B) # 矩阵乘法我经常在同事开 issue 时让他们先跑这段代码再回来核对公式。一旦把这两者的差异印在脑子里再回头看 RNN、LayerNorm、Attention Mask 这些模块就不会再犯低级错误。6. 从论文公式到工程落地的小经验6.1 在论文里快速识别阿达玛乘积论文里的记号并不统一。除了最常见的 ⊙有些文章会写 ∘有些直接写*还有些干脆把两个符号相邻放着不写运算符。描述性文字里如果出现 “element-wise”“component-wise”“point-wise” 这些词基本就是在说阿达玛乘积。快速翻译规则看到公式里两个形状相同的张量之间出现一个没有求和符号的乘法那就是逐元素乘。RNN 的z_t ⊙ h_{t-1}、LSTM 的f_t ⊙ c_{t-1}、SwiGLU 的a ⊙ swish(b)全是同一个操作。落到代码里时先判断两个操作数的形状。如果完全相同直接写*如果只有部分维度不同需要先想清楚广播规则会不会给你惊喜。6.2 两个实战建议第一在代码注释里把乘法语义写清楚。我自己写*的时候尤其是参与复杂张量运算时都会加一行 shape 注释比如# gate_mul: [batch, hidden] * [batch, hidden] - [batch, hidden] h_t z * h_prev (1 - z) * h_tilde等过一个月再回来改代码时这行注释能帮你省下大量推理时间。第二凡是跟“门控”“掩码”“缩放”相关的操作先默认它可能是阿达玛乘积。凡是跟“全连接”“注意力分数汇总”“卷积”相关的操作先默认它是矩阵乘法或卷积。这个判断方向能帮你快速定位公式里到底该用*还是。阿达玛乘积的代码往往只有一行但它对模型结构的影响是决定性的。我在实际项目中最大的体会就是很多难以调试的训练问题根本不是复杂算法出了问题而是这种“简单的乘号”被用错了地方。写对这一个小符号比调一堆学习率策略都管用。