ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

.NET RyuJIT 异常处理写穿优化(EH Write Thru)深入解析:让跨 EH 的局部变量重新获得寄存器分配资格

.NET RyuJIT 异常处理写穿优化(EH Write Thru)深入解析:让跨 EH 的局部变量重新获得寄存器分配资格 .NET RyuJIT 异常处理写穿优化EH Write Thru深入解析让跨 EH 的局部变量重新获得寄存器分配资格【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime导读本文基于 dotnet/runtime 仓库的设计文档 docs/design/coreclr/jit/eh-writethru.md系统讲解 RyuJIT 中的异常处理写穿优化Exception Handling Write Through Optimization。该优化允许横跨异常处理流handler / filter / finally的局部变量在方法内被重新视为寄存器候选enregisterable从而消除云负载场景下由 async、foreach、using 注入的大量异常处理结构对局部变量分配带来的惩罚。读完本文你将掌握该变换的动机、阶段划分、核心算法、LSRA 备选方案以及通过 GenTree 树与汇编 diff 理解其实际效果的方法并能在源码中定位到对应的配置开关与实现位置。什么是异常处理写穿优化写穿Write through是针对存活期跨越异常处理控制流的局部变量所做的一种优化这里的异常处理控制流包括 handler、filter、finally。对于每一个跨越此类构造存活的变量至少要满足以下两个最低要求在可达定义reaching definition与任何通往 handler 的控制流点之间向该变量在栈上的位置插入一次存储store在从 filter 或 finally 返回与向上暴露的使用upward exposed use之间插入一次加载load。从概念上讲这保证了变量在栈上的值能够穿越会杀死所有存活寄存器的异常流而保持正确。该变换将一个局部变量拆分为两部分一个可寄存器化的编译器临时变量proxy代理在方法主体中承载该变量的所有运算栈上的原始局部变量作为异常流的内存后盾。对于在 EH 构造内部也有出现的局部变量还会在 handler 内插入从栈局部到 temp 的加载使该 temp 在 handler 内部同样可以被分配寄存器。其本质是异常控制流的寄存器状态不可预测因此跨 EH 的变量必须落到栈上才能保证正确性而写穿优化通过在关键边界上补齐 store/load把整个方法都只能用栈退化成为只在 EH 边界用栈。动机为什么历史不做现在必须做文档 eh-writethru.md 给出了两个历史原因和一个现实变化异常处理曾经很稀有。历史上 JIT 不做这个变换因为 EH 少见为此付出的编译时间不划算用户可控。过去也容易建议用户既然你能控制 EH 出现的位置就把 EH 从性能关键方法中移除。这两点如今都不再成立原因是云负载cloud workloads成为焦点异步编程注入 EH非阻塞 async 调用大量出现在性能关键路径上而 async 特性的实现本身就会注入异常处理构造用户很难管理或移除它们语言构造内建 EHforeach与using语句长期内在地使用 EH这些构造同样难以由用户消除且经常出现在 profile 高位文档点名 TechEmpower 上的 Kestrel 是典型例子MSIL 的隐式异常在 MSIL 中基础操作就可能抛出有语义含义的异常不像 C 必须显式 throw 才可能抛出因此被注入的 handler 可能在方法中劣化pessimize大量局部变量的寄存器分配。由于云负载中这些问题叠加出现执行该变换应当带来明确的收益。设计设计的总体目标是保持前述约束为任何跨越 EH 边界的局部变量在栈上保留一个正确的值同时尽量不阻塞全局优化。阶段划分紧邻 SSA 构建之前写穿阶段被安排在morph 之后、SSA 构建之前紧贴 SSA 阶段。这样安排有两个好处保证广泛的全局优化能够作用于该变换产生的 IR 形态此时 IR 尚未进入 SSA后续优化可继续展开避免阶段顺序phase ordering问题阻塞寄存器分配机会。该阶段会对 IR 做一次完整遍历将所有 EH 局部变量的出现appearance改写为 proxy依据 EH 控制流语义在流图flow graph的合适块中插入 reload重新加载。为了在栈上保留所需的值还会在每个定义之后插入一次 store把 proxy 中的新值拷回栈位置。这会留下偏多非最优的 store 数量但设计上是有意为之文档明确指出消除/更优放置 store 的昂贵分析将作为更高编译层级higher compilation tier的全局优化来实施。JIT 建模 EH 的三个约束设计上的褶皱该设计基于 JIT 对 EH 的建模方式有以下三点限制直接决定了每个定义都 store这一朴素策略的合理性JIT 不显式建模异常流一个块、甚至块内的一条语句都可能有多个可抛异常点exception-raising site保护区域内所有定义视为可达对于受保护区域内的语句以及所有活着进入任何可达 handler 的变量JIT 假定区域内所有定义都可能到达 handler 中的使用——因为定义点与异常点之间的精确交织无法预知。因此每个定义都是可达定义哪怕两个紧邻的 store 之间没有任何读取不区分具体 handler 可达性JIT 不建模从某个受保护区域能到达哪些 handler因此只要变量活着进入方法中任意handler就认为它活着进入该 handler。文档也坦诚有可能做得比每个定义都 store更好但这通常需要修改 JIT 的 EH 模型、并引入更耗吞吐量的分析。基于上述考虑该设计被选中进一步改进留待未来优化。吞吐量Throughput考量识别跨 EH 的局部变量需要全局活跃性分析global liveness这带来显著的活跃性分析成本。为了缓解写穿阶段紧挨 SSA 构建之前执行典型的无 EH 场景下写穿的活跃性分析结果可以直接被 SSA 构建复用当存在 EH 局部变量时由于新增了局部变量今天的实现必须为 SSA重建活跃性但未来可以实现对 RyuJIT 活跃性分析的增量更新基于 worklist 的活跃性分析来改善吞吐量写穿变换的完整 IR 遍历同样昂贵因此文档指出初始实现可能需要在AOTcrossgen编译中阶段化执行直到 tiering 能够把更昂贵的分析移出启动路径。核心算法直接在 SSA 构建前的 IR 上执行文档给出了清晰的算法步骤运行于 SSA 构建之前的 IR 之上运行全局活跃性分析识别跨越 EH 边界的局部变量作为副产品这些局部变量会被标记为不要寄存器化do not enregister为每个 EH 局部变量创建新的局部变量 proxy该 proxy 可以被寄存器化遍历流图中的每个块对块中每个树tree做后序遍历并将所有 EH 局部变量的出现替换为对应的 proxy插入一条将 proxy 定义拷贝回 EH 局部变量栈上的语句若是EH handler 入口块在块头插入从 EH 局部变量到 proxy 的 reload若是finally 或 filter 出口在其后继块块头插入从 EH 局部变量到 proxy 的 reload对方法入口块为参数形式的 EH 局部变量插入 reload从参数位置到 proxy。结束时任何 proxy 都不应跨越 EH 流存活且所有值更新都会写回栈位置。这样 IR 进入 SSA 与寄存器分配时主体部分非 EH 区域的变量都具备干净的寄存器候选形态。备选算法在 LSRA线性扫描寄存器分配器内部实现设计文档还记录了一个备选方案——把写穿逻辑下沉到LSRALinear Scan Register Allocator内部作为 Interval 与 RefPosition 的属性来处理标志设计为Interval增加WriteThru标志对所有被活跃性分析视为 exceptVarsEH 相关变量的 lclVars 设置为RefPosition也增加WriteThru标志之所以两者都要是因为在异常变量场景下我们希望所有 def 都写穿而其他用途可能只需要部分 def 写穿即该 def 溢出但目标寄存器仍然存活并非某 lclVar 的所有 def。活跃性与区间创建活跃性分析中将异常变量标记为lvLiveInOutOfHndlr存活进出 handler但不标记为lvDoNotEnregister创建区间interval时若 lclVar 被标记为lvLiveInOutOfHndlr则在该 interval 上设置isWriteThru。寄存器映射register mapping的处理将handler 入口块视为无前驱保留空的inVarToRegMaps所有进入的变量都在栈上对EH 出口块将outVarToRegMap置空。分配期间的差异化处理def若分配到寄存器则始终标记为 writeThru若完全没有分配到寄存器则照常标记为 spillAfteruse绝不标记 spillAfter因为在 use 处栈位置始终是有效的。决议/写回阶段将所有isWriteThru的 def 标记GTF_SPILL如同 spillAfter但保留寄存器分配且 interval 保持活跃断言isWriteThruinterval 的 use 永远不会被标记为 spillAfter。函数序言prolog在genFnProlog()中确保分配了寄存器的入参寄存器参数如果被标记为lvLiveInOutOfHndlr也要存储到栈上。该 LSRA 方案面临的挑战文档如实记录了该方案的两个问题人为活跃性膨胀当前活跃性分析会把所有 exceptVars 加入所有ehBlockHasExnFlowDsc返回 true 的块的 live-in 集合这比严格进/出 EH 区域产生了更多人为活跃性可能的性能回退若 EH 局部变量在非 EH 代码中很少被引用写穿在性能上可能劣于始终使用内存。这与已知的 spill 放置与分配问题相关但不完全相同——即何时应选择不保持寄存器存活、直接在有需要时于内存中产生值。仓库中的落地情况与配置开关虽然设计文档中生产实现尚列为下一步工作但仓库源码中已经可以看到该优化的核心骨架。在 src/coreclr/jit/compiler.cpp 中编译器初始化时通过配置与本地寄存器优化开关共同决定是否启用lvaEnregEHVars (compEnregLocals() JitConfig.EnableEHWriteThru());该标志声明于 src/coreclr/jit/compiler.h并在 src/coreclr/jit/compiler.cpp 中随compEnregLocals()再次收紧。配置项定义于 src/coreclr/jit/jitconfigvalues.hRELEASE_CONFIG_INTEGER(EnableEHWriteThru, EnableEHWriteThru, 1)即默认开启值为 1可通过运行时配置覆盖关闭便于 A/B 对比与回归分析。在 LSRA 侧isWriteThru已经作为 Interval 的真实属性在 src/coreclr/jit/lsra.cpp 中被大量使用在活跃性/区间构建时若变量具备单定义寄存器候选lvSingleDefRegCandidate等条件则为其 interval 设置isWriteThrulsra.cpp决议阶段依据isWriteThru决定值的写回方式——若写穿 interval 的寄存器不再存活值总是要写回栈lsra.cpp、lsra.cpp在 EH 边界hasEHBoundaryIn的块中会断言当前 interval 必须是写穿区间lsra.cpp从实现层面印证了任何跨 EH 流的活跃值都以栈为后盾的约束。此外代码生成侧通过IsLiveInOutOfHandler()对应文档中的lvLiveInOutOfHndlr判断变量是否需要强制保持栈上视图例如 src/coreclr/jit/codegencommon.cpp 在lvaEnregEHVars开启时对存活进出 handler 的变量做特殊处理src/coreclr/jit/lclvars.cpp 在变量被标记 do-not-enreg 的调试输出中也展示了该状态。在 src/coreclr/jit/lsrabuild.cpp 中当lvaEnregEHVars开启时LSRA 还会为 finally 变量finallyVars在需要时生成零初始化ZeroInit的 RefPosition确保引用类型/compInitMem场景下 finally 中变量的内存视图被正确初始化。从源码结构看当前实现更接近设计文档中备选算法In LSRA的路线EH 变量不再被整体禁止入寄存器而是以isWriteThruinterval 的形式参与分配并在 EH 边界保证栈视图的一致性。这印证了文档中lvLiveInOutOfHndlr但不lvDoNotEnregister的设计思路。后续步骤Next steps设计文档记录了该工作的推进状态截至文档撰写时概念验证原型Proof of concept prototypeWriteThru 阶段的生产级实现优化示例/回归测试套件测试完整 CI 测试通过JIT 基准测试 diffKestrel TechEmpower 数据。实战示例从一个 catch 看写穿优化全貌文档提供了一个完整的端到端示例一个局部变量sum同时存活于方法主体与 catch handler并在其中被修改。这正是写穿优化要处理的典型形态。源码片段class Enreg01 { int val; double dist; public Enreg01(int x) { val x; dist (double)x; } [MethodImpl(MethodImplOptions.NoInlining)] public int foo(ref double d) { return (int)d; } [MethodImpl(MethodImplOptions.NoInlining)] public int Run() { int sum val; try { TryValue(97); } catch (ValueException e) { Console.WriteLine(Catching {0}, Convert.ToString(e.x)); sum val e.x; foo(ref dist); sum val; } return sum; } [MethodImpl(MethodImplOptions.NoInlining)] public int TryValue(int y) { if (y 97) { Console.WriteLine(Throwing 97); throw new ValueException(97); } else { return y; } } }Run()是唯一包含 catch 的方法因此只有它被 EH WriteThru 修改。变换后的 GenTree 形态文档给出了 EH Write Thru 之后的 JIT dump节选关键结构。首先是为 EH 局部变量创建可寄存器化 proxy 的日志Creating enregisterable proxies: lvaGrabTemp returning 8 (V08 tmp5) (a long lifetime temp) called for Add proxy for EH Write Thru.. Creating proxy V08 for local var V00 lvaGrabTemp returning 9 (V09 tmp6) (a long lifetime temp) called for Add proxy for EH Write Thru.. Creating proxy V09 for local var V01即V00 this获得 proxyV08 tmp5V01 loc0即sum获得 proxyV09 tmp6——它们都是 long lifetime temp长生命周期临时变量这是将栈局部变量拆分为可入寄存器 temp的直观体现。变换后的控制流图节选显示 EH 结构BBnum descAddr ref try hnd preds weight [IL range] [jump] [EH region] [flags] BB01 [00000263A1C161B8] 1 1 [000..007) i label target BB02 [00000263A1C162D0] 1 0 BB01 1 [007..012) T0 try { } keep i try label gcsafe BB03 [00000263A1C16500] 2 BB02,BB04 1 [050..052) (return) i label target gcsafe funclets follow BB04 [00000263A1C163E8] 0 0 0 [012..050)- BB03 ( cret ) H0 F catch { } keep i rare label target gcsafe flet可以看到BB02属于 try 区域T0BB04是 catch handlerH0 F catchBB03是同时被BB02正常路径与BB04handler 出口到达的返回块。关键 IR 重写包括方法入口BB01V00 this被加载到 proxyV08 tmp5V01 loc0被加载到 proxyV09 tmp6后续主体中对该变量的访问一律使用 proxy返回块BB03在 return 之前将V09 tmp6proxy的值写回V01 loc0栈即每个定义之后写回栈的体现catch 块BB04块头先从栈上的V00/V01重新加载到 proxyV08/V09mov rcx, gword ptr [V00 rbp10H]对应的 V08 tmp5等语句handler 内部对sum的三次修改sum val e.x;、foo(ref dist);、sum val;全部在 proxyV09 tmp6上进行handler 末尾再把 proxy 写回栈。这完整对应了文档算法中的三条规则handler 入口块头 reload、finally/filter 出口后继块头 reload、每个定义后写回栈。寄存器分配与代码生成后的汇编 diff文档以 unified diff 形式给出了基线 vs 写穿的汇编对比x64节选核心差异- mov rcx, gword ptr [V00 rbp10H] - mov ecx, dword ptr [rcx16] - mov dword ptr [V01 rbp-14H], ecx mov rsi, gword ptr [V00 rbp10H] mov edi, dword ptr [rsi16] mov dword ptr [V01 rbp-24H], edi在 try 区域BB03 对应位置基线需要mov rcx, gword ptr [V00 rbp10H]每次从栈重载this写穿后改为mov rcx, rsi注释为Elided reload in try region——this的 proxyrsi在 try 区保持寄存器存活消除了栈重载在 catch handlerIG07中写穿版本在 handler 入口执行mov rcx, gword ptr [V00 rbp10H]与mov ecx, dword ptr [V01 rbp-24H]注释为Reload of proxy register——即 handler 入口块头从栈重载 proxy基线版本中多处mov edx, dword ptr [V01 rbp-14H]、mov rcx, gword ptr [V00 rbp10H]被标记为Elided stack access消除的栈访问写穿版本以寄存器运算add ebx, dword ptr [rdi16]等替代handler 中sum的最终修改以Store of proxy registermov dword ptr [V01 rbp-24H], ebx写回栈保证返回块经cret汇合能读到正确的值。代价则是栈帧增大多压入/弹出r14、rbx两个被调用者保存寄存器diff 中push r14/push rbx与pop rbx/pop r14成对出现prolog/epilog 与 funclet prolog/epilog 均相应扩展。效果总结文档给出的 diff 统计直截了当Summary of diff: replaced 6 loads and 2 stores with 2 loads, 1 store, 2 push, 2 pop.即用 2 次加载、1 次存储、2 次压栈、2 次弹栈替换了原先的 6 次加载和 2 次存储。在 try 主体内原本每次需要从栈重载的this/sum被寄存器取代仅在 EH 边界handler 入口重载、出口写回保留栈同步这正是写穿优化的收益模型主体以寄存器运算为主EH 边界用少量栈同步换取正确性。总结与工程启示异常处理写穿优化是 RyuJIT 应对云负载中 EH 无处不在这一现实的关键变换。其核心思想朴素而有效把跨 EH 必须进栈的全局约束收窄为仅在 EH 边界进行栈同步从而让方法主体恢复寄存器分配的自由度。设计上它刻意选择了每个定义都 store的保守策略把更精细的 store 放置留给更高编译层级的全局优化体现了 JIT 吞吐量与优化收益之间的权衡。对于编译期读者可以沿着以下路径继续深入设计文档docs/design/coreclr/jit/eh-writethru.md配置开关与默认值src/coreclr/jit/jitconfigvalues.h启用标志的设置src/coreclr/jit/compiler.cppLSRA 中isWriteThruinterval 的构建与决议src/coreclr/jit/lsra.cpp、src/coreclr/jit/lsrabuild.cpp代码生成侧对存活进出 handler 变量的处理src/coreclr/jit/codegencommon.cpp。如果你在使用 .NET 处理性能关键路径上的异常、async 或 foreach/using 结构理解这一优化有助于解释为何现代 JIT 下 EH 的寄存器惩罚显著小于从前以及为什么像EnableEHWriteThru这样的开关可以用于 A/B 验证运行时行为。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表