系列目录 · 数值与量化 · Read in English
“模型里只有一小条 scale,一小条 bias。怎么会内存不够?”屏幕上的分配失败看起来像在跟常识争论。直到有人把编译后常量 shape展开:那两条小向量,已经各自变成了一整张特征图。
广播在数学中像一个省略号;在实现里,它可能是一条地址规则,也可能是成千上万次复制。两者不能只用同一个词概括。
一、广播的语义很便宜,物化不一定便宜
设教学输入为 [N,C,H,W]=[1,12,20,28]。逐通道仿射参数各有 C个元素。
如果 scale和 bias都按通道保留,参数总元素数为 2*C=24。若两者都展开到输入形状,则为 2*N*C*H*W=13440。二者相差 N×H×W倍。这里是数量推导,不是实际设备内存占用;物理占用还要叠加存储位宽、对齐和分块。
历史实现出现过多个阶段:按通道向量复用,沿某个空间维展开,再完整展开到特征图。每一种都可能让当时的内核契约更容易满足,也可能把复杂性转移到别处。
完整展开的优点是两输入 shape一致,通用逐元素乘加可以直接消费;缺点是参数文件、搬运量和缓冲区可能膨胀。保留紧凑参数的优点是少搬数据,代价是广播寻址必须正确,且调度器需要理解这种复用。
二、C 在哪里,不能靠“哪个维度碰巧相等”决定
通道优先 [C,H,W]与通道最后 [H,W,C]的线性序列不同。展开参数时,前者应在一个通道覆盖整个空间后再换下一个参数,后者每个像素都重复一遍通道参数。
用三个参数 [a,b,c]说明:
1 | 通道优先:a a a ...,b b b ...,c c c ... |
若形状某个空间维恰好等于 C,基于“找一个大小等于参数长度的维度”的推断会产生歧义。历史后续常量展开逻辑支持更多形状和标量参数,但这不消除靠尺寸推断语义的通用风险。
较稳健的原则是显式保留布局或通道轴;若必须采用启发式,要定义优先级并拒绝无法可靠识别的情形。参数长度检验只证明数量兼容,不能证明轴身份正确。
教学中最好故意测试 C=H或 C=W,以及所有维度互不相等两类输入。前一类抓歧义,后一类抓简单错轴。只选“漂亮的正方形”会让很多错误互相配合。
三、逻辑元素数不等于物理内存
假设一个教学设备要求每个通道向量按 A字节对齐,每元素 b字节。对于通道最后的布局,一种可能的物理行走规则是:
1 | pixel_stride = align_up(C*b, A) |
若 C很小,padding可能大于有效数据。又假设 C=1时允许把 W个元素当成一行连续处理,则可改为:
1 | row_stride = align_up(W*b, A) |
这两种路径为何不同,取决于设备存储与执行规则;不能对所有硬件照搬。历史专用仿射路径确实区分了单通道和多通道,并用不同循环步长减少某些布局下的浪费。
要特别小心子字节类型。若每元素只有若干位,先把“单元素字节数”向上取整,再乘元素数,可能高估存储;若设备采用打包,需要先求总位数再转换为字节。但读取、对齐和向量访问规则还可能在行或块边界重新取整。内存估算必须与实际地址生成共享同一契约。
四、临时缓冲区是否真的需要整张图?
专用仿射内核可以先算乘法,写临时区,再读临时区加偏置。如果每次只处理一个小块,而且乘加立即相邻完成,理论上临时区可能只需要一个块。
但若调度模型把乘法和加法分开,或允许异步执行、流水重叠,就需要重新计算生存期。不能因为代码文字上相邻,就断言中间结果马上可以覆盖。
历史早期实现申报过与输出类似大小的工作区,后续代码路径又出现直接使用输出作中间承接的设计。由此能提出一个很实用的审查问题:资源申报和实际使用是否同步更新?若代码已不再读某个工作区,但接口仍申请它,编译器可能在“无用预留”上耗尽内存。
这个问题不限于仿射算子。任何内核从多阶段变成原地处理后,都应该复核额外 workspace、别名条件、依赖关系和对齐要求。
五、为什么乘一的简化能缓解内存,却不是完整答案
历史有一条针对内存耗尽的修复,把系数为一的场景下降为加偏置;偏置也为零时下降为重量化。这样可以避免一部分常量和中间节点。
这很合理,但适用范围有限。一般系数仍然需要处理广播与工作区;近似等于一还涉及 BatchNorm 的量化处理讨论的误差条件。不能因一个特例不再报错,就宣布广播内存模型已经解决。
更一般的优化候选包括:延迟物化广播,利用零步长或广播描述符;把紧凑参数按 tile载入;在相邻逐元素计算中复用参数;允许安全的输出缓冲区复用。它们都需要实际支持和测试。
六、性能和资源应一起观测
粗略流量模型可以写为:
1 | 完整展开参数:额外参数流量与 N*C*H*W 成正比。 |
但“理想”很重要。若紧凑参数每处理一小块就重新搬运,真实流量会比 C大;若完整常量留在合适存储并多次使用,其成本也可能被摊薄。还要考虑循环指令、描述符加载和 DMA小块效率。
建议测试矩阵如下,均待执行:
| 维度 | 覆盖 | 观察量 |
|---|---|---|
| 布局 | 通道优先、通道最后 | 参数对应关系及地址步长 |
| 通道 | 1、小值、对齐边界附近 | padding与特殊路径 |
| 参数 | 标量、逐通道、两者混合 | 广播规则 |
| 形状 | C等于空间维、互不相等 | 通道推断歧义 |
| 资源 | 物化前后、不同 tile | 峰值局部内存与生存期 |
| 数据 | 各通道不同参数、坐标编码输入 | 错位不能被常量数据掩盖 |
历史差异支持“存在广播策略调整和资源简化”,并没有提供可复用的峰值内存测量表。公开文章能诚实给出的,是如何建立这张表、哪些数必须由执行验证产生。
当一条小向量把局部内存塞满时,不一定是分配器不够聪明。它可能只是在认真执行一个过早兑现的省略号。让广播保持为规则,直到真的需要复制的那一刻,往往比继续压榨几条乘法指令更有价值。
七、给内存账本增加“同时活着”这一列
把所有张量大小相加,通常不是准确的峰值内存;只看最大单个张量,也不是。真正要计算的是某个调度时刻仍然存活的输入、参数、输出和临时区之和,再考虑对齐与不可复用约束。一个完整展开的常量若很早载入、很晚释放,即使本身不大,也可能把峰值推到最坏位置。
教学上可以画一张五列时间表:加载输入、加载参数、执行乘法、执行加法、输出回写。逐列标记哪些缓冲区必须保留。如果乘法完成后输入不再使用,输入空间是否可以用于加法输出?若输入还被旁路分支消费,就不能覆盖。图中仅多一个使用者,就可能让原地优化从合法变非法。
广播参数还应与激活区别对待。它们可能跨多个 tile复用,不适合每个 tile都重复物化整张图;但长期占据局部内存也会减少给激活的空间。一个可比较的方案是在更高层存储保持紧凑参数,每次按通道块载入。代价是更多小块传输及同步,需要用真实传输粒度评估,不能只看总字节数。
估算接口与地址生成之间应共享对齐规则。若估算按紧凑字节数分配,代码生成却按对齐行距访问,会发生越界;反过来,估算重复计算 padding而实际访问紧凑,可能无谓拒绝本来能放下的模型。这两类问题一个表现为运行错,一个表现为编译失败,却可能源于同一份布局契约缺失。
资源测试最好同时保存逻辑大小、物理大小和峰值活跃大小三个数。逻辑大小解释模型规模,物理大小解释 padding,峰值活跃大小解释调度。如果只给出“内存下降了”,读者无法判断是常量变小、布局改变还是生存期缩短,也就无法迁移这项经验。
针对历史中的单位系数简化,最具体的验证不是仅确认编译成功,而是比较变换前后有哪些常量和临时缓冲区消失,同时检查输出数值域保持正确。这样可以确认改善确实来自预期原因,而不是某个无关的分块变化碰巧绕开分配失败。
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 !