ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

.NET Runtime 32 位平台 64 位 long 的寄存器建模:RyuJIT 后端 DecomposeLongs 分解方案解析

.NET Runtime 32 位平台 64 位 long 的寄存器建模:RyuJIT 后端 DecomposeLongs 分解方案解析 .NET Runtime 32 位平台 64 位 long 的寄存器建模RyuJIT 后端 DecomposeLongs 分解方案解析【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime本文围绕 .NET Runtimedotnet/runtime中 CoreCLR JIT 的设计文档 longs-on-32bit-arch.md 展开为什么 32 位架构上无法把 64 位 long 值当作单个寄存器操作数来建模、RyuJIT 曾考虑过哪些长生命周期liveness方案、最终落地的分解Decomposition策略是什么并对照 decomposelongs.cpp 与 lower.cpp 的当前实现讲解从TYP_LONG节点到高低两半、再到 LSRA 寄存器分配的实际调用链路与关键代码细节。一、问题背景32 位寄存器装不下 64 位 long在 32 位目标x86、ARM32 等上一个long/ulong值必须由一对 32 位寄存器lo, hi承载而这对寄存器在一条指令序列中的存活区间往往并不相同。例如 64 位加法中lo 半的加法一旦执行就会破坏进位carry进位又必须被 hi 半的加法立即消费。要在 Lowering/LSRA 中做出高质量的寄存器分配就必须把 long 操作的两半的生命周期建模得足够精确。设计文档明确指出了当前 liveness 模型所支持的 5 类生命周期形态见 longs-on-32bit-arch.md内部寄存器internal registers与所有 source 冲突但不与 def 冲突非最后一次使用的 source与其他所有项冲突最后一次使用且非 DelayFree 的 source与所有 source 冲突但不与 def 冲突最后一次使用且 DelayFree 的 source与所有 source 和 def 都冲突Kill与所有 use 冲突但不与 def 冲突。文档还回顾了更早eons ago当年做 arm32 时的一套模型让除最后一个 def 之外的所有 def 都与 use 和内部寄存器冲突也就是说这些值从节点的第一个位置开始就进入 live 状态。这套模型的优点是支持输入寄存器复用——例如 long 的加、减和地址计算在第一个 def 寄存器变为 live 之后就可以复用输入寄存器。但文档同时指出该模型无法支持多寄存器返回的 call 节点多寄存器 call 节点要求所有 def 必须发生在 kill 之后并且 def 不得与任何 source 冲突。这正是 x86 上long返回值走 EDX:EAX、ARM32 上走 R0:R1 的场景相关的 IR 设计另见 multi-reg-call-nodes.md。文档接着给出了扩展 liveness 模型必须满足的两条约束也是后文理解整个方案取舍的钥匙Must be pay for play按需付费不能为了让 long 代码变好而无谓地加重所有非 long 代码生成路径的 LSRA 开销精确建模多指令节点在实践中不可行一个 long 节点实际展开为多条目标指令时可能有一个源操作数会被第一个结果覆盖而其他操作数仍须保持 live同时文档强调在 x86 上不能浪费寄存器可用通用寄存器本来就少。二、三条候选路线文档中的方案权衡设计文档给出了三条演进路线值得逐一理解因为最终落地的是第一条路线的带 temps变体。2.1 Decomposition分解——当前采用把 long 节点拆成两个 32 位节点是当时正在采取的方案。文档记录了一个关键教训初始实现中为了支持 IR 所要求的 valid tree traversal合法的树遍历性质而对节点做重排序直接导致了代码错误——因为像add这类指令carry 位必须由 hi 半计算立即消费一旦 lo/hi 两条子树被拆散重排进位语义就断了。2.2 Decomposition with temps带临时变量的分解为了在不改变 IR 根本的树排序性质tree ordering properties的前提下保住分解方案必须把子表达式求值进临时变量temps从而让 lo 与 hi 的计算保持在一起。文档对此也提出了担忧嵌套表达式会引入相当多的额外 temps。但作者引用了 mikedn 在 coreclr 分支上的实验文档指向其 lower.cpp 中约 L424 处的实验代码属外部历史链接当前仓库中该实验已合入主线实现结论是这一做法可能没我们担心的那么糟糕。其基本思想一句话概括每当需要分解 hi/lo 操作又必须让它们保持在一起即无法维持树遍历/线性顺序不变量时就生成一个 temp。2.3 Richer Liveness Model更丰富的 liveness 模型不分解另一条路线是在 IR 中保留TYP_LONG节点想办法扩展 Lowering、LSRA 和 CodeGen 共用的 liveness 模型来获得好的寄存器分配效果。文档用一个 Location 序列来描述各类操作的寄存器生命周期同一 Location 内发生的所有 use/def 视为同时变化、互相冲突最简单的模型是所有 use 在 Location 1def 在 Location 2一旦出现 RMW读-改-写操作数或者一个 IR 节点实际对应多条目标指令模型复杂度就急剧上升。文档给出了一张典型的 Location 表并假设 Lowering 阶段已固定求值顺序且先求 lo 操作数操作Location 1Location 2Location 3x yuse y.louse y.hidef x.lodef x.hix y zuse y.louse z.loy.hi livez.hi livedef x.louse y.hiuse z.hidef x.hix *p非 ldp 加载对use pdef x.lodef x.hix *pldp 加载对use pdef x.lodef x.hix *(pi*8)非 ldpuse puse idef x.lodef x.hix calluse argskill (end)def x.lodef x.hildp 为 ARM 的 load-pair 指令对x86 无此指令。文档随后指出一个微妙的权衡非 ldp 场景可以利用所有 source 必须与第一个 def 保持 live这一事实来简化模型但在x y z这种场景里如果假设所有输入都要对第一个 def 保持 live就会让 lo 寄存器比必要的活得更久从而无法把 y.lo/z.lo 的寄存器复用给 x.lo 等结果——也就是说越精确的模型越难与越简单的复用策略兼得。文档在结尾还留下 TO DO把这张表扩展到更多操作、并给出实际生成的指令以评估与最优liveness 模型的逼近程度。2.4 True Linear IR远期方向文档最后提出了一个更有野心的方向打破 valid tree traversal 限制允许线性 IR 中任意或至少更灵活的节点重排。收益是消灭嵌入语句embedded statements代价是影响至少 rationalizer 和 LSRA——它们在使用栈时隐式依赖这一树遍历性质——并且可能还有许多未知的其他影响。这与仓库中同目录的 removing-embedded-statements.md 属于同一条演进脉络。三、源码印证DecomposeLongs 在 Lowering 管线中的位置设计文档中的Decomposition temps路线在当前仓库中由 decomposelongs.cpp 完整实现文件头部注释L12-L21直接对应文档中的动机This file contains code to decompose 64-bit LONG operations on 32-bit platforms into multiple single-register operations so individual register usage and requirements are explicit for LSRA. The rationale behind this is to avoid adding code complexity downstream caused by the introduction of handling longs as special cases, especially in LSRA.这与文档 pay for play 的约束一致把 64 位操作拆成显式的单寄存器操作后下游的 LSRA/CodeGen 不需要任何 long 特判非 long 代码路径零负担。整个文件被#ifndef TARGET_64BIT包裹即仅在 32 位平台编译生效。它挂接在 Lowering 主流程中。查看 Lowering::DoPhase#if LOWER_DECOMPOSE_LONGS DecomposeLongs decomp(m_compiler, this); // Initialize the long decomposition class. if (m_compiler-compLongUsed) { decomp.PrepareForDecomposition(); } #endif // LOWER_DECOMPOSE_LONGS ... for (BasicBlock* const block : m_compiler-Blocks()) { ... #if LOWER_DECOMPOSE_LONGS if (m_compiler-compLongUsed) { decomp.DecomposeBlock(block); // 先分解 } #endif LowerBlock(block); // 再做常规 lowering }三个要点可以注意开关由LOWER_DECOMPOSE_LONGS宏和compLongUsed双条件控制——方法里根本没有 long 时完全不付出任何开销正是 pay for playDecomposeBlock必须先于LowerBlock因为分解会向块中插入新节点DecomposeBlock 的注释明确要求 This must be done before lowering the block, as decomposition can insert additional nodes分解按**执行序execution-order walk**进行且分解结果可能冒泡bubble up到更上层节点被再次分解见 DecomposeRangeHelper。3.1 预备动作把 long 局部变量结构化PrepareForDecomposition唯一做的事是 PromoteLongVars把所有可入寄存器register candidate的 long 局部变量当作两个 INT 组成的 struct做结构提升——为每个 long 变量创建 lo/hi 两个TYP_INT字段局部变量TryPromoteLongVar并记录lvFieldLclStart、置lvPromoted true。后续DecomposeLclVar遇到提升过的 long 变量时直接改写为对 lo/hi 两个字段局部变量的引用未提升的变量则退化为偏移 0/4 的两个GT_LCL_FLD。这一步把long 变量从 RA 眼中的一等对象变成了两个独立的 int 对象是整套方案的地基。3.2 核心骨架GT_LONG 节点与 FinalizeDecomposition分解的通用收尾在 FinalizeDecomposition把求得的loResult、hiResult两个 TYP_INT 结果用一个新的GT_LONG(lo, hi)节点包起来替换原节点在 LIR 中的使用点。GT_LONG本身是一种打包节点gentree.h 中通过gtOper GT_LONG识别它让后续阶段仍然能以整体形式引用这个 64 位值例如传给多寄存器返回值的存储而两个半的寄存器需求则完全显式、各自独立参与 liveness。3.3 进位语义如何在线性 IR 中保住文档第二节最担心的carry 必须被立即消费问题在实现中通过节点对 flags解决。以加/减法为例DecomposeArith 把一个TYP_LONG的GT_ADD拆成lo 半GetLoOper映射为GT_ADD_LO或GT_SUB_LO类型改为TYP_INT并打上GTF_SET_FLAGS——即 lo 指令负责设置标志位hi 半新建GT_ADD_HI或GT_SUB_HI节点插入紧随其后由 codegen 利用 lo 指令留下的 carry 完成 64 位进位传播。由于 hi 节点在 LIR 中紧跟lo 节点Range().InsertAfter(loResult, hiResult)进位不会被任何语句隔开文档中initial implementation ... causing the code to be incorrect 的历史问题就以固定 lo→hi 线性顺序 显式 hi 操作符的方式制度化地规避了。位运算OR/AND/XOR没有进位GetLoOper/GetHiOperL2397-L2455直接映射回自身。溢出检查GTF_OVERFLOW会被迁移到 hi 半节点上因为 64 位溢出最终由 hi 半决定。同样的思想出现在其他路径DecomposeNegx -x拆成两条带借位的操作且按架构定制——x86 用GT_ADD_HI(hi 0)ARM 用GT_SUB_HI(0 - hi)注释解释 ARM 倾向于用movs装载 0 并会设置标志位所以把装载 0 的节点放到 lo 结果之前以保护 lo 设置的标志DecomposeCastint→long 的符号扩展不生成一条扩展指令而是把源操作数存入临时局部变量ReplaceWithLclVar——正是文档用 temp 保持 lo/hi 在一起的做法lo 半取原值、hi 半生成GT_RSH(lo, 31)算术右移 31 位得到符号位填充零扩展则直接用GT_CNS(0)当 hi 半。3.4 移动类操作的temp 化DecomposeStoreInd / DecomposeIndDecomposeInd 处理 64 位间接加载先把地址子树存进临时变量address.ReplaceWithLclVar(m_compiler)lo 加载复用原GT_IND节点改类型为 INThi 加载用GT_LEA(addr4)构造addr4再新建一个GT_IND。DecomposeStoreInd 处理 64 位间接存储源码里保留了一段极有教学价值的注释示例// 输入地址表达式省略 // t51 const int 0x37C05E7D // t154 const int 0x2A0A3C80 // / --* t51 int // --* t154 int // t155 *gt_long long // / --* t52 byref // --* t155 long // * storeIndir long // // 最终输出 // /--* t52 byref // * st.lclVar byref V07 rat0 // 地址存入 temp // t158 lclVar byref V07 rat0 // ... // * storeIndir int // lo 半存储 // * lea(b 4) // hi 地址 // * storeIndir int // hi 半存储这正是文档Decomposition with temps思想的典型样本地址树只算一次存进 temp避免 lo/hi 两次存储时重复计算或产生副作用两次同样当 lo/hi 数据本身是复杂子表达式而非叶子节点时也会分别ReplaceWithLclVar存入 temp。3.5 位移与循环移位常量折出最优指令序列非常量退化为 helperDecomposeShift 展示了按常量特化的分解策略移位量先对 64 取模与各 helper 行为保持一致移位量为 0删除移位节点GT_LSH左移量 32拆成shl lo, n 借助GT_LONG(loCopy, hi)的GT_LSH_HIcodegen 生成shl/shld序列量 ≥32 时 lo 半直接为 0hi 半为lo (n-32)且无副作用的 hi 操作数被直接删除GT_RSH/GT_RSZ右移量 32GT_RSH_LO/GT_RSZ组合生成shrd/shr、shrd/sar序列有符号右移 ≥32 时 hi 半通过hiCopy 31完成符号位传播移位量非常量无法静态展开指令序列此时把所有操作数RepresentOpAsLocalVar存入局部变量后替换为 helper 调用CORINFO_HELP_LLSH/LRSH/LRSZ。DecomposeRotate 处理常量rol/ror量32 时干脆交换 hi/lo 两个操作数配合ReplaceWithLclVar防止x x 32这类原地移位被覆盖其他量拆成两个GT_LSH_HIrol或GT_RSH_LOror生成shld/shld、shrd/shrd序列。3.6 乘法与无符号取模多寄存器结果节点的分解这两类是 long 分解中唯一结果本身占两个寄存器的操作处理方式和多寄存器 call 一致——强制var mul形式DecomposeMul能走到这里的GT_MUL必带GTF_MUL_64RSLT即(long)int * (long)int形式其余早已在 morph 阶段转为 helper 调用。分解时剥掉 int→long 转换DecomposeCast对这类被GT_MUL消费的 cast 特意跳过分解见 L814-L826把节点改写为GT_MUL_LONG再由 StoreNodeToVar 保证结果落到局部变量——codegen 中 x86 生成mul结果在 edx:eax、ARM32 生成双寄存器结果并落到栈上。源码注释明确写道结果stored on the stack in genStoreLongLclVarDecomposeUMod能留下来的GT_UMOD保证除数是 2~0x3FFFFFFF 的常量 int分解后生成注释里给出的理想序列EDX hiOp1; EAX loOp1; idiv reg;余数在 EDX、hi 半补 0。StoreNodeToVar还顺带处理了与 multi-reg-call-nodes.md 的衔接若父节点已是GT_STORE_LCL_VAR则只给目标局部变量打lvIsMultiRegDest标记否则ReplaceWithLclVar强制var call/mul形式并在新变量上调用TryPromoteLongVar尝试提升——即把多寄存器返回结果直接摊成两个可入寄存器的 int 字段。3.7 分解后的还债优化丢弃 hi 半文档强调分解必须pay for play实现里也对应了几处在分解之后立即消除冗余的优化最典型的是 OptimizeCastFromDecomposedLong当分解出的GT_LONG只被一个截断到 int 类型的 cast 消费如int i (int)longValue时直接删除无副作用的 hi 半子树、去掉GT_LONG包装甚至若目标就是 int 再把 cast 本身也消掉若 hi 半有副作用则只SetUnusedValue保留副作用。同理DecomposeNode 主循环 开头还特判了隐式引用 long 局部变量 lo 半的TYP_INT本地节点直接改写成其提升后 lo 字段变量的显式引用。此外x86 硬件内建函数SSE/AVX路径存在一组跳过分解的例外long→浮点 cast 和直接消费/生产 long 的某些 HWIntrinsic内存间搬移可整体保留TYP_LONGL138-L201而 SSE/AVX 的 long↔浮点转换则走 DecomposeCast 中的向量化路径用 SIMD 指令AVX-512/AVX10.2在 32 位上也完成 64 位转换——这体现了能用单条指令完整处理 64 位值时就不必付出分解成本的同一原则。3.8 尚未支持的角落从 DecomposeNode 的 switch 分支 可见GT_LOCKADD/GT_XADD/GT_CMPXCHG等对TYP_LONG的原子操作目前是NYINot Yet Implementeddefault分支会直接断言失败并 dump Illegal TYP_LONG node。这提醒我们32 位平台的 long 支持面是有限且有边界的新操作若要支持必须补齐对应的DecomposeXxx分支。四、与 LSRA 的衔接为什么分解之后 LSRA 无需特判分解方案的直接收益在 LSRA 一侧LSRA 面对的每个节点都只涉及一个 32 位寄存器需求lo/hi 两个节点有各自独立的 use/def 位置于是文档第一节那张 Location 表所描述的多位置生命周期问题被摊平成了普通线性 liveness——不需要在 lsrabuild.cpp 或各架构的 lsraarm.cpp 中为 long 写第二套冲突模型。唯一遗留的整体性由GT_LONG打包节点承担它告诉 LSRA/CodeGen这两个 int 结果共同构成一个 64 位值例如供GT_STORE_MULTI_VAR消费但不再参与复杂的生命周期推理。LSRA 的整体设计可另见 lsra-detail.md。文档开头那句accurately enough that we can achieve good register allocation建模要精确到足以获得好的寄存器分配在此得到呼应分解把精确建模的问题前移成了生成什么节点对、按什么顺序插入的问题而后者由DecomposeLongs中每个DecomposeXxx函数的线性插入逻辑精确控制Range().InsertAfter/InsertBefore决定 hi 节点相对 lo 节点的位置保证了进位/借位依赖永远相邻。五、调试与验证视角DecomposeLongs的每个入口都带有JITDUMP埋点如 Decomposing TYP_LONG tree. BEFORE/AFTER、Promoting long local V%02u、各 temp 化步骤的 [DecomposeStoreInd]: Saving address tree to a temp var 等。使用 CoreCLR 调试 JIT 时可参考 viewing-jit-dumps.md 介绍的 JIT dump 机制开启 verbose 输出即可观察到每个 long 节点分解前后的 IR 形态、long 变量提升结果lvaTable after PromoteLongVars以及 temp 变量V07 rat0这类 runtime temp的引入过程——这是验证本文所述行为、排查 32 位平台 long 相关代码生成问题的最直接手段。六、小结设计权衡的完整闭环回顾 longs-on-32bit-arch.md 的三条路线最终仓库中的实现选择了一条清晰的闭环放弃精确 liveness 建模Richer Liveness Model 路线因为多指令节点的 Location 复杂度与寄存器复用收益不成正比尤其在 x86 这种寄存器稀缺的平台采用 Decomposition with tempsPrepareForDecomposition把 long 变量结构化为 lo/hi 两个 int 局部变量每个 long 节点按算子家族拆成节点对lo→hi 的线性顺序制度化地保住了进位/借位语义无法维持顺序的子表达式一律ReplaceWithLclVar存入 temp结果用GT_LONG打包以保持 64 位值的整体引用pay for play 落地为具体机制LOWER_DECOMPOSE_LONGS宏 compLongUsed编译/运行双重开关非 32 位平台整个文件不参与编译无 long 的方法零开销多寄存器结果long 返回值、64 位乘法走var call/mul强制形式 多寄存器 dest 标记与 multi-reg-call-nodes.md 描述的GT_STORE_MULTI_VAR/ReturnTypeDesc体系合流保留演进空间True Linear IR 方向解除 valid tree traversal 限制与文档中未完成的 TO DO扩展 Location 表、逼近最优 liveness仍是未来可能的优化入口而当前原子 long 操作的NYI分支则明确标示了支持边界。对需要在 32 位目标上处理 64 位整数的 JIT 开发者而言这套方案给出的方法论值得借鉴当 IR 的线性/树遍历不变量与一对寄存器协同生命周期冲突时用显式的节点对 局部顺序约束 按需引入的 temp通常比扩展一套全局 liveness 冲突模型更可控、也更易被下游寄存器分配器消化。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表