深入解析 .NET RyuJIT 线性扫描寄存器分配器(LSRA)的吞吐量优化设计
【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime
导读
本文基于 docs/design/coreclr/jit/lsra-throughput.md 设计文档,系统梳理 .NET Runtime(RyuJIT 后端)线性扫描寄存器分配器(Linear Scan Register Allocator,LSRA)在吞吐量(编译速度)方面已知的次优环节,以及作者提出的重构路线:如何用更干净的containedness表示、把寄存器需求规格化并入RefPosition构建流程、并最终消除gtLsraInfo这一临时通信结构。读者读完可以掌握 RyuJIT 寄存器分配管线的整体数据流(Lowering→TreeNodeInfo→RefPosition→ 寄存器选择),理解GTF_CONTAINED标志与IsContained()的真实语义,并了解当前仓库源码中这些设计的落地情况。
背景:LSRA 在 RyuJIT 中的角色
在 .NET Runtime 的 RyuJIT 后端中,寄存器分配(Register Allocation,RA)是Lowering之后、CodeGen之前的关键优化与规整阶段,其实现集中在 src/coreclr/jit/lsra.cpp 与 src/coreclr/jit/lsra.h(以及各架构的lsraarm64.cpp、lsraxarch.cpp、lsrariscv64.cpp等文件)。
LSRA 的核心工作可概括为:
- 为每个节点的定义/使用建立
RefPosition:LinearScan::buildRefPositionsForNode()(定义于 src/coreclr/jit/lsra.h)为每个树节点生成相应的引用位置; - 为局部变量(lclVar)与"树临时量"(tree temps)维护生命周期区间(interval);
- 线性扫描:按程序点顺序为每个区间挑选物理寄存器,必要时生成 spill/reload。
该设计文档指出,当前实现的吞吐量(即编译器本身运行 LSRA 阶段所消耗的时间)存在多处可优化空间,并且其中多数优化点彼此关联,牵一发而动全身。
当前实现中的六个次优环节
文档首先列出了 LSRA 当前实现中六类已知的次优之处:
1. 额外的节点枚举遍(pre-pass)
在TreeNodeInfoInit遍之前,存在一次独立枚举节点的额外遍历。文档作者并不确定这次预遍历是否必须与TreeNodeInfoInit分离,需要进一步调查。
从源码结构看,各架构的
TreeNodeInfoInit逻辑已被拆分为独立的lsraarm.cpp、lsraarm64.cpp、lsraxarch.cpp、lsrariscv64.cpp等文件(见 src/coreclr/jit 目录),这正对应文档中"提取TreeNodeInfoInit方法到独立lsra{arch}.cpp文件"的重构动作。
2. containment 识别的双重表示与重复计算
"containment"(包含)是指:某个节点的结果计算可以被折叠进其父节点(例如 load/store 的地址计算被折叠进访存指令)。当前实现中:
- 识别发生在
Lowering阶段; - 通信通过节点上
gtLsraInfo字段完成——该字段平时不被使用; - 重复:当为节点构建
RefPosition时,containment 信息实际上被重复表达了一次; - 检查开销:
IsContained()至少在每个节点执行一次(在CodeGen::genCodeForTreeNode()开头),当判断当前节点操作数是否 contained 时还会再检查。
文档提出两条改进方向:
- 用更高效的 containment 表示,把分析留在
Lowering(那里已有父上下文、便于做既有变换),同时简化检查; - 或者在构建
RefPosition的过程中完成 containment 分析(但后面会说明为何最终没有走这条路)。
3. 寄存器需求的规格化时机
寄存器需求(source、destination 以及任何 internal register 的寄存器掩码)是在Lowering的最后一趟中规格化的,且本质上需要更多空间。文档指出关键洞察:
对"新寄存器定义"(节点目的地或 internal register)的需求是独立于父节点的,因此这部分可以在
LinearScan::buildRefPositionsForNode()中完成,无需像 contained 节点识别那样做双重遍历。
4. lastUse 位的单独遍历
RefPosition构建完成后,还需再遍历一遍以设置 lastUse 位。之所以单独做,是因为当前gtNext/gtPrev链接与真实代码生成顺序之间存在不一致。文档建议:一旦该问题解决,lastUse 位应由活跃性分析(liveness)在寄存器分配之前设置(对应 issue #7256)。
5. RefPosition 全部提前创建
所有RefPosition都在寄存器分配遍开始前一次性创建,但其中只有 lclVar 的RefPosition真正需要提前存在——因为 lclVar 与"树临时量"不同,可能有多处定义、跨基本块存活。树临时量的RefPosition理论上可以按需即时创建(on-the-fly),从而节省内存并改善局部性(对应 issue #7257)。
6. 候选寄存器循环缺少短路
LinearScan::tryAllocateFreeReg()与LinearScan::allocateBusyReg()中对所有候选寄存器的循环,可以在找到最优得分寄存器时提前短路退出。此外在 MinOpts(最小优化)模式下,甚至可以一旦找到合适候选就短路——当然这需要在吞吐量收益与代码质量影响之间权衡。
仓库佐证:当前 src/coreclr/jit/lsra.cpp 的寄存器选择逻辑中已有
// We'll set this to short-circuit remaining heuristics when we have a single candidate的注释,表明"候选收敛后短路剩余启发式"这一方向已部分落地;启发式评分体系定义在 src/coreclr/jit/lsra_score.h(如BUSY_REG_SEL_DEF(PREV_REG_OPT, ...)等)。
表示 Containedness:核心重构提案
针对上述问题 2/3,文档给出了详细的重构方案。作者最初的计划是:将TreeNodeInfoInit遍的功能与RefPosition构建合并,并彻底消除gtLsraInfo。但在把TreeNodeInfoInit方法提取为独立lsra{arch}.cpp文件的过程中,作者意识到:如果先把 containment 分析放进LinearScan,之后再把它拉回Lowering,会产生大量返工(throw-away work)。
同时,containedness 的当前表示"并不干净":
Lowering阶段通过"对节点行为的隐含知识 +gtLsraInfo.dstCount"来传达;CodeGen阶段又通过"相似的隐含节点特征 + 寄存器有无"来判断。
于是文档提出如下改进:
提案 A:引入树根标志(GTF_TREE_ROOT)
- 为每个节点添加一个标志,指示它是否为树根(tree root);
- 为了腾出该标志位,提议在非
LEGACY_BACKEND上消除GTF_REG_VAL。这需要一些额外清理,但可以顺带消除一批 hack——这些 hack 存在的原因在于:emitter 原本是为"动态分配寄存器"的代码生成器设计的(生成完代码后设置该标志表示已放入寄存器),而 RyuJIT 后端是在生成代码之前就完成寄存器分配,两者模型不匹配。
提案 B:定义新的寄存器值语义
| 寄存器值 | 由谁赋值 | 语义 |
|---|---|---|
REG_UNK | Lowering | 需要寄存器(must have a register) |
REG_OPT | Lowering | 定义处与使用处寄存器均为可选 |
REG_OPT_USE | Lowering | 定义处需要寄存器,使用处可选 |
REG_OPT_DEF(可能) | Lowering | 文档认为可能也需要,可作为补充 |
完成上述表示后,IsContained()可以被大幅简化。
备选方案:直接用GTF_CONTAINED标志位
文档也指出:或许更有效的做法是直接用额外的一个位作为真正的GTF_CONTAINED标志,这值得考虑;但初期用GTF_TREE_ROOT来简化 containedness 检查更容易落地——因为无需改动所有当前标记节点为 contained 的代码点。
仓库现状印证:从当前 src/coreclr/jit/gentree.h 可以看到
GTF_CONTAINED = 0x00000040, // This node is contained (executed as part of its parent),并配套IsContained()(gentree.h)、SetContained()(gentree.h)、ClearContained()(gentree.h)等方法。可以推断,最终实现选择了"直接使用GTF_CONTAINED标志"这条路线,而非初期设想的GTF_TREE_ROOT间接方案——这也说明文档中的备选讨论确实影响了后续实现方向。
把 Containedness 分析与 Lowering 合并
一旦上述表示改造完成,就可以把设置 containedness 的代码移入Lowering的第一趟(first pass)。文档坦诚地指出:这里很可能存在一些阶段顺序(phase ordering)挑战,但作者认为这些挑战并非不可逾越。
这一步的价值在于:containment 的判定本质上是"子节点能否折叠进父节点"的父上下文问题,而Lowering正是拥有父上下文的阶段;把判定留在Lowering,既避免了在LinearScan中重复推导隐含的节点行为知识,也让CodeGen侧的检查(如genCodeForTreeNode()开头的IsContained()判断)变得更加直白。
消除 gtLsraInfo(Issue #7225)
在 containedness 改造完成之后,gtLsraInfo就只剩下一个职责:传达寄存器需求(register requirements)。
因此文档给出最终目标:
- 仍保留
TreeNodeInfo数据结构与TreeNodeInfoInit()方法; - 但改为在
LinearScan::buildRefPositionsForNode()处理每个节点时按需调用它们; - 这样就不再需要"
Lowering最后一趟写入gtLsraInfo、LSRA 再读出来重复构建"的双重传递,gtLsraInfo字段可以被彻底删除(对应 issue #7225)。
这与文档前文"寄存器需求(新定义部分)独立于父节点、可在构建RefPosition时完成"的论点互相呼应:寄存器需求规格化天然适配"逐节点、按需"的构建模型,而 containedness 分析因为依赖父上下文,则应留在Lowering中。
仓库现状与后续脉络
从当前仓库源码可以观察到这次设计讨论的部分落地痕迹:
- 架构文件拆分:各架构的 LSRA 构建/初始化逻辑已分散在 src/coreclr/jit/lsraarm.cpp、src/coreclr/jit/lsraarm64.cpp、src/coreclr/jit/lsraxarch.cpp、src/coreclr/jit/lsrariscv64.cpp 等文件中,另有共享的 src/coreclr/jit/lsrabuild.cpp 承担
RefPosition构建; - contained 标志独立成位:
GTF_CONTAINED已成为独立的节点标志位,并配齐IsContained()/SetContained()/ClearContained()接口(src/coreclr/jit/gentree.h); - 寄存器选择短回路:候选寄存器启发式选择中存在"单一候选即短路"的机制(src/coreclr/jit/lsra.cpp);
- lastUse 体系:
RefPosition的 lastUse 语义贯穿分配主循环(见 src/coreclr/jit/lsra.cpp 中大量refPosition.lastUse的分支判断),其设置方式仍在演进。
需要说明的是:本设计文档写作时提到的
gtLsraInfo字段、issue 编号(#7225/#7256/#7257)对应当时的代码状态;在当前代码库中搜索gtLsraInfo已无匹配,可以推断该字段在后续迭代中已被移除,文档描述的重构目标基本完成。若读者想要核对具体实现细节,建议以 src/coreclr/jit/lsra.cpp、src/coreclr/jit/lsra.h 与 src/coreclr/jit/lsra_score.h 的当前内容为准。
小结:吞吐量优化的三条主线
把整份设计文档提炼为三条主线,便于快速把握:
- 减少遍历次数:合并冗余的节点预遍历;把 lastUse 位的设置并入活跃性分析(#7256);把树临时量的
RefPosition改为按需构建(#7257),避免一次性全量构建带来的内存与局部性开销; - 让信息各归其位:containment 分析留在拥有父上下文的
Lowering,用独立标志位(最终为GTF_CONTAINED)表示;寄存器需求则下沉到LinearScan::buildRefPositionsForNode()按需计算,从而消除gtLsraInfo(#7225); - 降低选择成本:在寄存器选择启发式(src/coreclr/jit/lsra_score.h)中引入短回路,命中最优/合适候选即提前退出,尤其让 MinOpts 路径受益。
这三条主线共同指向同一个目标:在不牺牲寄存器分配质量的前提下,让 LSRA 在大型方法上的编译时间显著下降,这正是该设计文档作为 RyuJIT 后端性能优化路线图的核心价值所在。
【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考