它只是在搬数据,为什么还会算错:张量变换的隐藏契约

坐标、步长、别名和量化参数,让搬数据也有语义。

Posted by Bruce Lee on 2026-09-09

系列目录 · 工程与交付 · Read in English

“这个补丁应该很安全,它没有计算,只是移动一下数据。”

半小时后,下游矩阵乘法的结果全变了。大家先检查乘法,再检查量化比例,最后发现搬运操作保持了每个字节,却改变了每个字节代表的位置。

数据移动最容易获得一种不应有的信任:只要数值没变,语义就没变。张量的语义还包括坐标、形状、布局、别名关系和量化轴。搬对了字节,不一定搬对了张量。

同一块内存可以讲两个不同的故事

考虑形状为 [2,3] 的矩阵:

1
2
a b c
d e f

连续存储是 a b c d e f。如果把形状直接改为 [3,2],得到的是:

1
2
3
a b
c d
e f

如果执行转置,逻辑结果却是:

1
2
3
a d
b e
c f

前者可能只改元数据;后者可以表示成带非连续步长的视图,也可以真的重排数据。选择哪一种,取决于消费者能否理解这个布局,以及缓冲区的生存期和别名规则。

于是一个“免费变换”至少要回答三个问题:元素对应关系是否正确?后续操作是否接受新的步长?共享底层存储是否会受到写入影响?少回答一个,零拷贝就可能变成延迟暴露的错误。

Concat 的轴,决定了能否整段复制

把两个 [2,2] 张量沿第零维拼接,连续布局下常可将两段数据顺序复制。沿第一维拼接时,结果则要逐行交错:

1
2
3
左输入        右输入        输出
a b e f a b e f
c d g h c d g h

直接复制左输入全部字节,再复制右输入全部字节,会产生错误顺序。

通用实现经常把维度划成 outer、axis、inner 三部分,在每个 outer 单元里复制各输入的 axis×inner 段。这个表达既说明了为什么某些拼接只需少数大拷贝,也说明了为什么另一些拼接会产生很多小传输。

历史规范整理还暴露了一个容易被文档掩盖的差异:图层允许可变数量输入,而某个硬件描述可能只有有限个源地址。它们之间需要分阶段拼接、临时缓冲或其他方案。不能看到一个二输入例子,就在支持列表里写下任意数量输入。

Split 和 Slice:多个结果不只是多个名字

将输入切成多个结果,可以生成独立缓冲,也可以生成多个视图。后一种方案节省复制,却引入了生命周期问题:输入的逻辑操作已经结束,底层存储是否仍被输出视图使用?

Slice 还必须明确起止位置、步长、负索引和边界归一化。一个只展示步长为 1 的连续切片示例,没有证明负步长和跨步切片也能使用同一条搬运路径。

例如 [0,1,2,3,4,5] 取位置 1、3、5,得到 [1,3,5]。它的总元素数量是 3,却不是一段连续的 3 元素内存。仅凭“输出大小正确”来构造 memcpy,是一个特别容易通过简单测试的错误。

应分别测试连续片段、跨步片段、空结果以及边界索引。对于不支持的情况,明确拒绝通常比默默生成一段看似合理的连续数据更容易维护。

Gather 把地址变成了运行时数据

Gather 与规则切片的差别在于,选取位置可能由另一个张量决定。读取索引本身就成为计算的一部分。

如果输入是 [8,13,21,34],索引是 [2,0,2],输出应为 [21,8,21]。重复索引必须保留,顺序也不能被排序优化改变。

一个 Gather 契约至少包括索引类型、轴、输出形状规则、负索引策略和越界行为。不同前端或算子版本可能有不同约定,后端不能用自己习惯的数组访问方式悄悄补全。

它还提出了一个性能问题:相同输出大小并不意味着相同搬运成本。连续索引、重复热点索引和分散索引可能有很不同的访存行为。合理的实验应固定输出元素数,分别改变索引分布,再观察有效带宽和延迟。

Pad 的“补零”,到底是哪一个零

在仿射量化中,实数与整数的关系可写成:

1
real = scale * (integer - zero_point)

如果 zero_point 为 117,整数 117 才代表实数零。往量化张量的边缘填入整数 0,会填出一个负的实数偏移,而不是数学上的零。

这类错误有时会被卷积内部的 padding 路径掩盖,因为计算单元可能对边界有专门处理;显式 Pad 操作却真的在缓冲区写入数据。不能把两种实现路径的假设互相借用。

还要区分常数填充、边缘复制和反射填充。它们在空维度、小尺寸以及填充宽度接近输入大小时,需要不同的合法性规则。

量化轴会跟着坐标移动

假设一个张量沿通道轴采用不同的 scale。做 Permute 时,如果通道从第一轴移到最后一轴,量化轴也应更新;做沿通道轴的 Gather 时,scale 列表可能需要按相同索引选择或复制。

更隐蔽的是 Concat。两个整数张量的数据类型相同,不代表它们采用相同的 scale 和 zero_point。直接拼接整数可以保持存储值,却无法让一个统一的输出量化参数同时解释两种不同标尺。

可能的解决方法包括要求输入量化参数一致、先重定标,或者使用输出允许的逐轴表达。每个选择都有成本,也各有适用限制。核心问题是:数据移动操作是否同时移动了“如何解释数据”的信息?

这正是“只是 memcpy”叙事最容易遗漏的一层。

怎样让搬运错误无处藏身

随机输入适合扩大覆盖,定位布局问题时,带坐标编码的输入往往更直观。可以令二维输入满足:

1
value(row, column) = 10 * row + column

只要测试尺寸足够小、不发生数值冲突,就能从错误输出反推行列是否交换、步长是否错用、边界是否截断。对高维输入,可以使用不相混淆的坐标编码,或直接保留坐标元组作为参考表示。

建议把测试拆成几类:

类别 关键问题
元素映射 每个输出位置究竟来自哪个输入坐标
布局 连续、非连续、对齐填充是否得到相同逻辑值
别名 写入一个视图会不会影响另一个仍存活的值
量化 数据与 scale、zero_point、axis 是否同步变化
边界 空结果、负轴、非法索引是否有明确处理

那次重构会议最后改掉的,不只是一个地址计算。大家把“没有计算”改成了更准确的描述:“它不改变被选中的实数值,但必须保持坐标与解释规则。”这句话多了几字,却少了很多侥幸。


返回系列目录 · 上一篇 · 下一篇


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !