系列目录 · 资源与调度 · Read in English
“我只借一个寄存器,装一下地址,马上还。”
“巧了,外面的循环计数器也只借了同一个。”
这类争执不会出现在代码评审聊天里。它会出现在机器码里:循环次数突然变成一段地址,或者配置加载覆盖了尚未使用的标量值。每个辅助函数单独看都很短,拼起来却像一间没有借用登记簿的工具房。
问题不是程序员记不住寄存器名字,而是名字没有表达所有权。字符串 "tmpA" 只回答“用哪一个”,没有回答“现在谁占着它”。
从两种时间理解所有权
编译器生成设备代码时,有两个时间轴。第一条是编译器在主机上执行的时间:构造指令、分配临时槽、调用辅助函数。第二条是生成的程序在设备上执行的时间:循环反复执行、描述符被消费、异步任务运行。
RAII 管理的是第一条时间轴上的资源租约。离开 C++ 作用域时,租约释放,之后的生成代码可以再次使用这个寄存器。这样做正确的前提是:生成出来的运行时指令已经不会再需要其中的旧值。
这句话不能省略。把运行时仍然活跃的计数器放进一个过短的主机作用域,RAII 会准时释放错误的东西。反过来,把所有临时句柄都保留到整个内核生成结束,通常比较保守,却可能白白制造寄存器压力。
一个重新设计的例子如下:
1 | 生成配置阶段: |
两个租约不重叠,因此可能复用同一个物理寄存器。源代码中的变量名不同,机器寄存器却相同,这完全合理。测试若坚持旧版必须出现“某个固定临时寄存器”,反而会把合法的资源复用误判为退化。
已核实的改动,到底改变了什么
相关提交沿着三个层次推进。首先,为立即数加载、地址配置加载和参数加载增加接受资源句柄的接口,使调用方不必主动拆出字符串名字。其次,把算术、裁剪、符号、规约、查表等内核迁移到作用域句柄,并把分支、递减和步长加载纳入同一接口。最后,把描述符加载、矩阵计算、卷积、输入输出搬运和循环状态计算中的固定临时名也逐步替换。
这里有一个容易忽略的细节:某个复合内核用单独代码块圈住配置加载临时量,离开代码块以后才申请循环计数器。它不是为了好看而多写一对大括号;大括号直接决定两个阶段能否复用资源。
另一处接口把恒定零寄存器包装成独立类型。固定架构寄存器和可回收临时寄存器属于不同资源类别,前者不应该从临时池里“申请”。类型表达这个区别之后,辅助函数的签名本身就能参与评审。
为什么不能只统一命名约定
约定“内层函数只用第二个临时寄存器”很快会遇到三种反例。内层函数再调用另一个辅助函数;外层循环增加第二层计数器;某条地址装载路径因为立即数装不下,额外需要一个寄存器。命名约定实际是在手工维护一个跨函数的全局分配表,变化越多,成本越高。
句柄使分配器成为唯一的资源账本。不过,如果低层接口仍接受任意字符串,绕过账本的通道仍然存在。因此,这次迁移应理解为逐步扩大显式所有权覆盖范围,不能仅凭出现 RAII 就宣称所有寄存器冲突已被消灭。
比较稳妥的接口规则是:普通内核只拿句柄;底层编码器保留必要的原始寄存器表示;需要固定寄存器时使用独立入口;跨辅助函数借用已有句柄时用引用传递,避免悄悄申请新资源。
资源安全不等于整段生成可回滚
设某个内核已经生成十条指令,在生成第十一条时发现字段越界并抛出异常。局部句柄析构,临时池恢复,解决的是资源泄漏。前十条指令可能仍留在指令流里。
因此要分别回答两道题:
- 失败以后,临时资源是否归还?
- 失败以后,指令、标签、依赖计数等生成状态是否恢复?
第一题可以由 RAII 承担,第二题需要事务、快照或“失败即废弃整个上下文”的协议。把二者混为一谈,就会在错误恢复路径里生成一份看起来很完整的半成品。错误处理的边界会继续讨论这个边界。
同样,标量配置寄存器的租约不自动解决异步完成事件的寿命。设备队列可能尚未执行完某条命令,相关同步资源何时能复用,必须遵守它自己的语义。
性能收益应该怎么讲
可以合理推测,缩短主机侧句柄的持有范围,有助于降低同时占用的临时槽数量,也让复杂内核更容易组合。但从几个字符串变为句柄,不能直接推导运行时间减少。理想情况下,生成的指令序列几乎一样;收益主要体现在正确性、可维护性和容纳更复杂组合的能力。
有时寄存器编号变化会影响二进制快照。此时应该先确定允许变化的维度:编码是否合法、运行时语义是否相同、资源峰值是否下降。若目标是严格保持既有机器码,就必须解释为何编号变化可以接受,或者有意识地约束分配顺序。
还有一种可能的编译时间代价:句柄分配使用集合查找或动态对象管理。它是否明显,取决于数据结构、内核粒度和生成频率,需要测量,不能凭“现代 C++”四个字自动判为零成本。
再追问一步:句柄能被复制吗?
把句柄传进辅助函数以后,还有一件事情值得检查:它是所有者,还是借用者?若两个可复制对象都认为自己负责归还同一槽位,第一次析构便可能让资源重新可分配,第二个对象却仍然保存可用外观。教学设计通常应让所有权句柄不可复制,只允许移动;辅助函数接受引用,表达“我借用,但不归还”。
移动以后,原句柄也必须进入明确的空状态。否则调试打印看起来仍然有一个寄存器名,调用者可能误以为它还有效。这里讨论的是资源接口审查方向,不能仅凭几个内核迁移就断言底层所有移动、失效和重复释放行为已经验证完整。
可以设计一个小排查表:外层持有计数器,内层借用同一计数器做递减;另一个内层函数申请独立地址临时量;随后一个故障路径提前退出。检查每条路径上谁归还什么,并把生成的寄存器使用区间画在同一条时间线上。只看正常返回的一条路径,很容易漏掉真正制造冲突的分支。
还有一个看起来像性能优化的陷阱:为了避免反复申请,把几个句柄提升到函数最外层。申请次数减少了,同时存活量却增加了,反而可能更早触及资源上限。应该测量的是峰值租约与实际分配开销,而不是单纯数 acquire 调用了几次。资源管理里的“更少调用”,不总是“更少占用”。
把回归测试从名字升级为契约
| 情况 | 应验证的性质 | 证据层级 |
|---|---|---|
| 两个同时活跃的临时量 | 不能获得相同资源 | 建议强化的资源契约 |
| 内层作用域结束后再次申请 | 已释放槽可以复用 | 已阅读的生命周期测试覆盖这一思路 |
| 固定零寄存器参与分支 | 不消耗临时池配额 | 已有相关测试 |
| 带句柄的配置、步长、分支接口 | 汇编语义与原接口一致 | 提交新增或扩展的测试 |
| 辅助函数中途失败 | 资源归还,且失败上下文处理明确 | 建议补充的故障注入 |
| 嵌套循环调用配置辅助函数 | 外层计数器不被覆盖 | 建议补充的组合测试 |
如果要做进一步实验,可以构造参数化的嵌套内核,逐步增加循环层数、配置装载路径和同时活跃临时量。记录寄存器峰值、生成指令数、编译时间,并用一个最小解释器验证计数器最终值。对比对象应是相同语义的两种生成策略,不应拿不同算子数量的模型比较。
最后留一个适合代码评审的问题:当你看到 auto temp = acquire(),不要只问“有没有自动释放”,还要问“这个作用域是否恰好覆盖了生成程序中最后一次使用”。所有权已经有了名字,寿命才是下一步必须讲清楚的故事。
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 !