系列目录 · 算子与布局 · Read in English
单独测试时,两个代码生成函数都很好。把它们组合起来,结果突然不对。第一个函数说:“我只是借个临时寄存器存基址。”第二个函数说:“我也是。”问题是,它们借的是同一个,而且谁也没问谁什么时候还。
字符串寄存器名特别方便:写起来短、看起来直接。它也没有所有权,无法告诉你某个名字现在是否装着仍然要用的地址、循环次数或中间值。当内核开始调用公共发射辅助函数,过去分散在不同文件里的“默认临时寄存器”就会在同一条指令流里相遇。
临时值如何被覆盖
一个教学错误可以浓缩成:
1 | outer: |
宿主 C++ 的变量作用域没有坏,生成的机器程序却已经被破坏。编译器后端同时生活在两个时间线上:现在执行的 C++ 与未来执行的指令。调试这类问题时,必须追问“这个变量装的是寄存器句柄,还是寄存器未来的值”。
作用域与寄存器所有权
历史修复选择一种很实用的中间方案:由发射器维护一个有限临时池,申请时返回不可复制、可移动的对象;对象离开作用域时归还寄存器。固定用途寄存器则用单独入口获取,不消耗临时池。这样调用者不需要硬编码“我想要第几个临时名字”,而是声明“我在这个范围里需要一个”。
可以用与原实现无关的教学伪代码表达:
1 | with emitter.borrow_scratch() as address: |
这个范围应该覆盖值在生成指令序列中的全部使用。过早结束会允许后续辅助函数复用它;过晚结束则会人为增加同时占用数量。在一个地址与参数装载结束后才开始循环的内核里,装载基址和循环计数器未必需要同时活跃。缩小前者的作用域,可以让后者安全复用临时槽。
注意这里的“安全”来自静态生成顺序和调用约定,不是句柄对象施了魔法。如果生成的控制流先跳出去、以后还会回来读取旧寄存器值,宿主对象提前析构仍然可能不正确。作用域池适合有纪律的局部发射流程;复杂的跨块活跃区间,需要更强的数据流分析或更明确的寄存器保留协议。
为什么禁止复制?因为两个对象如果都认为自己拥有同一个槽,析构时可能重复释放。更糟的是,一个对象先释放,池把寄存器分给第三个人,另一个复制品随后释放,又把仍在使用的槽标成空闲。这样的 bug 常在正常路径上看不见,在容器扩容或异常退出时突然发作。
移动则转移所有权。被移动对象需要变成不再负责释放的空状态;移动赋值先释放自己原先拥有的槽,再接管对方。历史实现明确处理这两件事。这里既有 C++ 资源管理原则,也有后端生成正确性的实际需求:它管理的是“哪些寄存器名可以再次使用”的编译期状态。
固定寄存器为什么单独表示?某些寄存器承担零值、栈指针或全局基址等固定角色,不应因为临时池紧张就被随意借走。固定句柄只描述使用权限或名字,不意味着已由临时池保留。尤其若接口允许把一个通常也能用作临时的寄存器作为“固定”对象返回,调用方仍需避免与池中借用冲突。所检查差异不能证明所有这样的冲突都已被自动禁止。
这就是类型或包装的边界:把字符串装进一个类,能改善表达,但如果最后仍暴露名字并允许任意使用,规则依然需要约束。可以把它视作从约定走向可检查接口的一步,不应该夸成“寄存器冲突从此不可能”。公开文章把能力说小一点,反而更有助于读者正确复用。
耗尽、验证与成本
临时池用尽时,怎么办?历史代码选择抛出明确错误,而不是偷偷复用仍活跃的槽。对局部代码生成而言,这比生成错误程序可靠。更完整的系统也许支持保存恢复或溢出到栈,但那会引入地址、对齐、调用约定及性能问题,不能在池耗尽时顺手加一个“兜底”就算完成。
耗尽测试应该申请到容量上限,再验证下一次申请失败;释放后重新申请,应能拿回槽位。还要测试内层作用域归还、外层句柄仍活跃、移动构造、移动赋值、异常退出,以及固定寄存器不会意外消耗池状态。历史测试覆盖容量、作用域归还、槽位复用和若干固定寄存器行为;移动路径的更全面组合测试属于本文建议。
为什么这个问题会出现在一个 Abs 相关提交里?因为很小的内核适合验证发射器的新抽象:先装载地址与参数,再发出一条向量运算,指令结构容易看清。提交标题可以是一个测试用例的名字,真正引入的工程能力却横跨所有可能复用发射器的内核。只看标题会错过这层设计。
可以把验证拆成两种。第一种检查宿主资源状态,例如同时借用的句柄不重名、已归还槽可复用。第二种检查生成程序,例如某个装载辅助函数前后,外层仍需使用的寄存器没有被覆盖。只测资源对象析构,不看指令流,无法证明嵌套发射是正确的。
这也连接到二进制回归。一个内核即使用了不同的临时寄存器,语义应保持;测试若固定整段字节,可能因为合法分配变化而频繁改 golden。更稳定的验证可以关注必要依赖与关键编码字段,再用少量精确 golden 锁定指令格式。全段 golden 有价值,但应知道它同时约束了哪些实现细节。
性能并非一定改善。局部池可能减少不必要的保存恢复,也可能因保守作用域耗尽而限制更复杂生成。它主要带来的可核实收益是所有权更清楚、冲突更早暴露。若要谈运行时性能,应检查最终指令数、临时值生存区间和保存恢复流量,而不是把 C++ 对象数量当成硬件开销。
一个工程实验是:挑选几个会嵌套装载描述符、生成双层循环的内核,打印每次借用归还事件与生成指令的位置,核对句柄活跃区间是否覆盖目标程序的使用区间。它比反复问“为什么这个寄存器又变了”更容易建立共同语言。
分支、重入与失败恢复
如果把这个局部资源池继续推广,还应回答“谁拥有发射器”。句柄保存对池的引用,就要求池的寿命覆盖所有活跃句柄。把一个临时句柄从局部函数返回到比发射器更长的作用域,或者在拥有者销毁后才析构,会破坏这种关系。类型系统可以限制部分用法,文档与测试也应明确剩余约束。
分支发射是另一个练习。宿主代码先生成 if 的真分支,再生成假分支,这两个分支运行时互斥,但宿主句柄可能在生成期间同时存在。保守池会把它们都视为活跃,造成不必要的寄存器压力。反过来,若某个值跨越分支汇合后仍要使用,就不能因为某段宿主生成函数返回而释放。这解释了为何局部作用域池既实用,也有明确上限:它借助宿主结构近似目标程序的活跃区间。
要降低压力,首先可以缩短只服务于配置装载的句柄范围,再申请长寿命循环计数器;也可以把独立装载阶段封装成不泄漏句柄的辅助函数。但不能为了腾出槽位,提前释放仍会在后续生成指令中引用的对象。优化作用域的标准应是目标程序最后一次使用,而不是让宿主代码看起来更整齐。
线程与重入同样需要明确约定。如果同一个发射器被并行使用,活动寄存器集合的读写就需要同步或禁止并发;即便每线程拥有独立发射器,静态的标签序号生成也可能影响确定性或存在竞争。相关历史内核中可见静态循环编号的用法,这只提供一个审查线索,不能在没有调用模型的情况下宣布发生竞态。可行建议是让唯一标签生成归发射器管理,使寿命和并发边界更一致。
错误处理应该保持池状态。申请失败以前已借出的句柄,异常展开时应自动归还;移动赋值释放旧资源后,新资源的所有权也应唯一。教学测试可以在嵌套层中故意抛异常,再申请同样数量的槽,确认没有泄漏。若发射器在异常前已经追加了部分指令,是否需要回滚指令流,则属于另一层事务问题:还了寄存器不等于取消了半个内核。
这些讨论也帮助界定“可维护”的含义。一个资源池可能只有几十行,却把过去散落在内核作者脑中的约定变成可观察状态。评审者能问出活跃数量、释放时刻、固定角色和耗尽行为,而不必逐文件记住谁默认使用哪个临时名。它的价值首先体现在推理难度下降;至于是否能减少指令、支持更复杂融合,应在这个更清楚的基础上再做验证。
从此以后,发射器里最值得提倡的一句注释可能是:“这个临时值到这里就不再需要。”写清楚最后一次使用的位置,既帮助正确性,也为未来的优化留下空间。借东西不难,知道什么时候能还,才是后端工程的基本礼貌。
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 !