系列目录 · 工程与交付 · Read in English
“只是发一条乘法指令,为什么要经过四层?”
评审桌上有人写下一行 emit("mul", a, b, out)。它短小、直接,甚至很像最终汇编。另一位同事问:“这个 a 是张量、寄存器编号、内存地址,还是要被广播的标量?”
房间安静了几秒。字符串的简洁,来自它把问题藏起来了。
编译器代码生成中的一条指令,往往经历几种身份:它首先是数学运算,然后成为某个计算内核的执行步骤,再成为一组硬件操作,最后才成为固定宽度的二进制字段。混淆这些身份,最常见的后果不是立刻报错,而是下层开始猜测上层的意图。
第一层只回答“算什么”
以二维矩阵乘法为例,上层契约可以写成:
1 | A: [M, K] |
这里需要明确转置、累加类型、量化参数和是否带激活。它不必知道第几个配置寄存器存放某个地址。
这条边界很有用。假设布局改变,上层数学语义通常不变;假设某个硬件版本重新编码地址字段,上层也不应该跟着修改。反过来,如果激活融合改变舍入发生的位置,它就可能影响数值语义,不能伪装成纯编码调整。
“上层不认识寄存器”并不意味着上层什么都不用检查。维度不匹配、输出类型不合法等错误,越早被明确拒绝,越容易解释给模型使用者。
第二层回答“如何组织计算”
内核层决定分块、循环、数据复用和临时缓冲区。例如把输出分成多个小矩形,每次装载一段输入,再沿 K 维累加。
这层最容易混入两类彼此独立的信息:
- 张量信息:形状、步长、元素类型、量化参数。
- 执行资源:存储地址、寄存器、事件、依赖和循环标签。
如果拿一个通用整数同时代表“张量第几个维度”和“寄存器编号”,编译器可能仍然通过 C++ 类型检查,却失去了表达边界的能力。
可以把设计问题转成一个具体提问:当内核请求“装载这个切片”时,它是否已经说明了切片的逻辑范围与物理步长?如果没有,下层就会被迫从地址和总字节数倒推形状。这种倒推在连续布局上偶尔有效,在广播、对齐和非连续视图上很快失效。
第三层回答“硬件需要哪些动作”
发射层把一个执行步骤拆成硬件动作:配置地址、设置步长、触发搬运、计算、等待依赖。它需要了解硬件能力,却不应重新解释矩阵乘法的数学定义。
教学例子中,一个搬运请求可以包含:
1 | source: MemoryAddress |
这些名字看起来啰嗦,却可以阻止很具体的错误:把元素步长直接写进按字节计量的字段,把地址当成寄存器编号,或者忘记检查元素数量是否超出编码范围。
强类型不能自动证明算法正确。它的价值是让一类错误更难表达,并让剩下的检查有明确归属。地址对齐应在哪层验证、寄存器不足由谁报告、立即数超范围是否允许展开,都应写进这层的契约。
第四层回答“这些位怎样排列”
最终编码层负责操作码、字段宽度、符号扩展、字节序和保留位。它接收的应该是已经明确的指令结构,而不是需要继续猜测意义的字符串列表。
历史设计整理中,一个值得推广的选择是按编码格式组织类型,而不是机械地为每个操作码建立一套继承体系。例如多个算术指令可能共享相同的寄存器字段布局,只在操作码上不同;另一些访存指令则需要完全不同的地址字段。
这有两种常见极端:
- 所有指令都使用
vector<string>,结果非法状态太容易出现。 - 每个操作码都有庞大的独立类,结果相同校验和编码逻辑散落各处。
按格式组织载荷,再明确允许的操作码集合,是一种折中。它是否适合某个项目,取决于硬件格式是否稳定、操作码之间真正共享多少结构。类型数量本身不是设计质量指标。
标签位移为什么必须依据最终指令长度
现在考虑一个分支:
1 | jump done |
假设 load_constant 在某些值上只需一条机器指令,在另一些值上需要两条。如果先按照“一行等于一条”计算标签地址,再展开伪指令,跳转目标就可能落错位置。
可靠的组织方式通常需要区分:
- 语义操作和伪指令。
- 展开后的实际指令序列。
- 按实际长度计算的标签位置。
- 最终字段检查与编码。
某些架构还存在分支距离影响指令长度的情况,可能需要迭代放宽或固定长度策略。
这也解释了为什么“伪汇编看起来正确”不能代替二进制检查:人眼阅读的那一层,未必已经决定最终位置。
一个好错误消息应该在哪一层出现
讨论中的问题很快从“为什么这么多层”变成了“这个错误应该谁来报”。
| 问题 | 更合适的检查位置 |
|---|---|
| 矩阵相乘的收缩维不相等 | 运算语义入口 |
| 当前内核不支持某种布局 | 内核选择或布局规划 |
| 临时寄存器申请失败 | 资源管理与发射边界 |
| 一个立即数字段装不下 | 编码校验,或有明确规则的展开层 |
| 跳转标签未定义 | 标签解析阶段 |
下层发现上层漏掉的问题时仍然要防御,但错误信息应包含足够上下文。例如“字段溢出”适合定位编码器,“不支持这个张量步长”更适合解释内核限制。把所有失败都压到最终断言里,会使不同性质的问题看起来完全一样。
怎样证明分层没有把错误藏得更深
每层都需要独立于自身实现的观察点:
- 运算层用小尺寸参考计算检查语义。
- 内核层检查切片覆盖、边界和访存范围。
- 发射层检查操作顺序、依赖和资源生命周期。
- 编码层检查固定输入对应的精确字节,并测试边界拒绝。
这些检查相互补充。一个能够生成相同字节的编码器,可能仍然编码了错误的数学操作;一个数值上正确的例子,也可能没有碰到立即数的边界。
相关历史测试逐步加强了伪汇编、实际汇编和二进制的比较。这个事实能支持“检查跨层一致性很有价值”,不能支持“整个指令集已经完整验证”。
可以设计一个实验:对一个内核分别改变布局、块大小和立即数范围,观察变化停留在哪些层。如果一个纯编码字段调整迫使数学语义层修改,边界可能泄漏;如果改变张量形状却没有触发任何合法性检查,边界也可能过于松散。
四层架构最终要买到的是可解释性:错的是数学、组织方式、硬件动作,还是编码位。能准确回答这个问题,调试才不需要从模型输入一路猜到最后一个字节。
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 !