第一行正确,第二行失踪:对齐、Stride 与常量填充的连环案

逻辑宽度、物理步长、对齐补齐与参数表必须一致。

Posted by Bruce Lee on 2026-04-03

系列目录 · 算子与布局 · Read in English

“第一行都对,第二行开始像抽奖。”

这往往是一个很友善的错误:它已经用结果形状提示了故障边界。若运算公式错,第一行通常也逃不掉;若第一行正确、跨行开始错误,应优先检查地址步长、物理填充和循环结束时的指针位置。当然这只是定位假设,需要证据确认。

写清布局与步长

教学张量为 HWC,[2,3,5],每元素 1 字节。假设每个像素的通道块都按 A=16 字节对齐,那么逻辑上每个像素只有 5 字节,物理上却占 16 字节。一行占 48 字节,总占用为 96 字节。连续逻辑数据只有 30 字节。如果代码仍按 5 字节推进到下一个像素,就会钻进 padding。

把布局写成公式,比说“要对齐”有用得多:

1
2
3
4
5
6
7
align(x,A) = ceil(x/A)*A
C > 1:
pixel_stride = align(C*element_bytes, A)
row_stride = W*pixel_stride
C == 1:
pixel_stride = element_bytes
row_stride = align(W*element_bytes, A)

第二条分支十分关键。单通道可以让一行的像素连续放置,再对整行补齐;多通道则可能以像素通道块为对齐单位。它们都叫 HWC,物理步长却不同。仅把最后一维乘上元素字节数,不足以决定整个张量的寻址。

已检查的一组历史改动抽出简单激活布局计算,让内存占用估算和广播步长都使用相同规则,并同步修正常量序列化。另一个历史方案为部分算子区分紧凑输入与物理输入,通过显式地址循环处理填充。二者说明同一原则:布局必须是生产者、消费者和分配器共享的契约。不能按提交日期把不同分支的结构误写成顺次合并。

为什么输出步长也要独立算?因为输入输出的元素宽度可能不同。即使 shape 相同,输入一个通道占 1 字节,输出占 2 字节,跨越同样的 C 个通道后所需的对齐量也可能不同。若“顺手复用输入 stride”,小通道时偶尔因为同落一个对齐桶而过关,跨到下一个桶才突然失败。

最容易制造这种假象的测试是恰好对齐的形状。它们让 logical_stride == physical_stride,旧代码也能工作。教学上应围绕边界选点:A-1AA+1 字节,以及在更宽元素类型下换算后的边界。再补上 C=1 分支,避免只测了多通道的另一种布局。

广播边界与地址回卷

广播还引入一个更细的陷阱。主输入可能是多通道,广播小输入是 [H,W,1]。主输入每个像素跳到下一个对齐块,小输入则在一行内每次只前进一个元素,行末还要跨过 padding。此时一个统一步长跑 H*W 次已经不够,通常需要内外两层循环。

设小输入一行 W=3 个单字节值,行距为 16。循环每处理一个像素后,仅在尚有内层工作时增加 1。处理完第三个像素时,地址停在行起点加 2 的位置。跳到下一行起点,需要增加:

1
2
3
boundary_step = row_stride - (W-1)*inner_step
= 16 - 2
= 14

不是 13,也不是 16。公式中的 W-1 来自“最后一次不执行内层地址增加”的控制流。只看 shape 写步长,忽略指令顺序,很容易差一个元素。历史修复明确构造边界步长,并在循环发射中先判断是否结束,再做相应的地址推进。

如果某个广播向量需要在下一外层重新从头读,边界步长还可能为负。于是字段校验必须按有符号范围处理。把负数转换为无符号数后再比较,或者把字段当成更大的无符号正数空间,都会破坏合法性判断。历史更改把相关范围检查收紧为有符号语义,并增加超界拒绝测试。这里无需披露具体设备字段宽度,通用问题已经足够清楚。

常量填充与布局不变量

到这里,很多人会宣布修完,结果常量输入仍然错。这是连环案的下一位角色:常量文件通常存逻辑紧凑序列。如果内存分配器分了 96 字节,而序列化器只把 30 字节紧凑数据放在开头,消费者按正确物理 stride 读取,反而会更稳定地读到错误值。

正确做法是在构建常量二进制时按相同布局安置逻辑元素:

1
2
3
4
5
physical = padding_buffer(required_physical_bytes)
for h,w:
source = (h*W+w)*logical_channel_bytes
target = h*row_stride + w*pixel_stride
copy exactly logical_channel_bytes

历史代码构造物理长度的缓冲区,检查逻辑源长度、分配长度和宿主可表示的大小,再按偏移复制。padding 初始化为确定值,便于产物稳定和测试。填充字节并不自动等于量化实数零:若 zero point 非零,二者应区分。若硬件保证不消费 padding,确定填充即可;若会读取它参与计算,就需要另行证明填充值的语义。

由此可以写出三个重要不变量。第一,分配大小至少覆盖最后一个逻辑元素的物理地址。第二,常量生产者与算子消费者对同一坐标计算相同偏移。第三,内外循环的边界地址与直接坐标公式一致。三个不变量能把许多“某个算子坏了”的现象收束到共享布局协议上。

回归、成本与单位检查

本文建议用很小的地址轨迹表做回归。列出 (h,w,c)、源偏移、目标偏移和每次循环后的地址,并让测试比较最后一个像素到下一行第一个像素的跳转。历史测试已检查常量填充后的数据排列、多个算子的 stride 寄存器以及超界诊断。

对齐的性能成本不能藏起来。教学例子中有效数据为 30 字节、物理分配 96 字节,有效载荷比例仅为 30/96。这是示例的算术结果,不是实际设备带宽利用率,因为缓存、总线和计算访问行为还会影响传输。它足以说明:小通道可能因为填充显著增加内存占用,循环次数也可能成为负担。

后续优化可以研究能否合并相邻块、选择不同布局、预先打包常量、在图级消除重复搬运。这些都是待测建议。不能为了减少循环就重新把 padding 当逻辑数据,也不能让分配器和代码生成各自发明一套“更快”的布局。共享计算函数往往既减少 bug,也让性能讨论从猜测变成可核算的成本。

除了单位与边界,还有一种足以让评审现场安静三秒的错误:一个字段叫 stride,大家却没约定它的单位。某一层传元素数,另一层当字节数,遇到单字节类型时完全正确,换成双字节类型才露馅。因此变量名里写明字节,或者用独立的字节量类型,比注释一句“注意乘大小”更可靠。历史修复中的多类型测试正好帮助检查这一点。

低比特打包进一步说明,元素个数与字节地址不是可以随时互换的概念。假设每个元素占半个字节,奇数通道块的末尾可能只使用一个字节中的一半。下一个块从另一半开始,还是必须对齐到新字节,取决于格式契约。用普通整数字节宽度去表示这种布局,会在一开始就丢失信息。保守地拒绝未支持格式,是一种可检查的能力边界;要新增支持,则应设计位偏移或显式打包描述。

常量 padding 测试也应区分三个长度:源文件的逻辑数据长度、布局所需的物理长度、分配器实际给出的容量。容量比物理长度大,不代表多出的空间就该被当作有效常量写出;源长度不足,也不能用零填充悄悄补齐逻辑元素。历史序列化检查源长度必须符合预期,并要求物理数据不超分配空间。这个顺序能让输入错误在编译期暴露,而不是变成运行时随机误差。

对负步长的回卷,可以用一张更简单的地址账本检查。假设共享向量有三个块,内层从第一块走到第三块,下一外层又应回到第一块。因为末次不再前进一步,回卷量是负的两个块距。若程序作者按照“已经走了三步”计算,就会回到缓冲区之前。不要只验证负数落在字段范围内;落在范围内的错误地址依然是错误。范围检查与语义检查是两条互补的防线。

还有一个对齐方向问题:应先把字节数对齐,再转成元素数量,还是先对元素数对齐?在元素大小能整除对齐单位、且没有打包的特定条件下,两者可以转换;离开这些条件,结果未必相同。公共布局函数最好直接围绕最终地址单位计算,让上层不必重复推导。将这个函数同时提供给内存估算与数据生成,也方便用相同输入做一致性测试。

若未来准备优化布局,建议保留一个低速、直接按坐标公式计算地址的参考实现。它可以只在本地测试使用,不进入生产代码生成。每种优化循环与参考比较若干小形状,就能同时覆盖普通推进、行末补齐和跨组回卷。这个参考不应调用被测循环计划的相同函数,否则两者可能共享同一个错误,测试看似严密,实际上只是自己给自己点头。

当第二行终于回来时,真正修好的通常不只是某一条 stride。是程序里好几位参与者终于对“下一个元素在哪里”达成了同一个答案。


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


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 !