01-06-运行时-分层编译与PGO-运行时如何持续优化代码 分层编译与 PGO运行时如何持续优化你的代码系列C#与常用数据结构源码剖析 · 运行时底层剖析阅读时间约 40 分钟前置知识JIT 编译管线、JIT 优化一、引言传统的 JIT 编译是一次性的——方法被首次调用时编译之后代码不再变化。这种模式有一个根本性的权衡如果 JIT 为了速度而做全面优化编译时间会拖慢启动如果 JIT 为了快速启动而跳过优化运行时的性能就无法达到最佳。分层编译Tiered Compilation打破了这种权衡。.NET Core 3.0 引入的分层编译允许同一方法在运行期间被编译两次——先快速编译Tier0方法变热后再用完整优化重新编译Tier1。结合PGOProfile-Guided OptimizationTier0 阶段还会插入探针收集运行时数据——哪些类型最常见、哪些分支最常走、哪些方法最常被调用。Tier1 编译时利用这些数据做针对性优化。对于数据结构而言这意味着你代码中的ListT.Add()、Dictionary.TryGetValue()、foreach循环——这些高频操作在 Tier1 阶段会得到远优于 Tier0 的优化。二、分层编译的设计动机2.1 启动速度 vs 稳态性能的两难假设一个 ASP.NET Core 应用有 10000 个方法。在传统的单层 JIT 模式下如果全部用最高优化编译 → 启动时间长需要编译 10000 个方法如果全部用最低优化编译 → 启动快但稳态性能差分层编译的解决方案Tier0快速、少优化的编译尽快让代码跑起来Tier1只在方法被频繁调用时用完整优化重新编译2.2 Tier0 与 Tier1 的对比特性Tier0Tier1编译速度极快 1ms慢5-50ms内联极少激进去虚拟化无基于 PGO 数据边界检查消除无全面循环优化无Loop Cloning、Unrolling寄存器分配简单线性扫描全局分配代码质量低速全速探针插入PGO是否三、分层编译的晋升机制3.1 30 次调用 100ms 计时器一个方法从 Tier0 晋升到 Tier1 需要满足两个条件条件 1调用计数器达到 30 次每个方法的入口点Precode Stub中内嵌了一个调用计数器。每次调用递增达到 30 次后标记为可能热方法。30 这个数字来源于早期经验测试——大多数方法在被调用 30 次后确实值得重新编译。条件 2100ms 启动期计时器过期在应用启动时一个 100ms 的计时器开始运行。每次发生 Tier0 JIT 编译计时器重置。只有当计时器完全走完 100ms 而没有任何 Tier0 编译发生时调用计数才开始生效。这个设计的逻辑是如果仍在大规模 Tier0 JIT说明应用还在启动阶段此时触发 Tier1 编译会与启动争抢 CPU 资源反而拖慢启动。延迟 100ms 确保 Tier1 编译只在启动平息后进行。3.2 晋升流程方法首次调用 → Precode Stub 触发 Tier0 编译 → 生成 Tier0 代码带探针 → 每次调用递增计数器 → 计数器 ≥ 30 且 100ms 计时器过期 → 方法进入 Tier1 编译队列 → 后台线程执行 Tier1 编译 → 编译完成后更新入口点 → 指向 Tier1 代码 → 后续调用直接使用 Tier1 代码3.3 为什么 Tier1 在后台线程执行Tier1 编译可能耗时 5-50ms取决于方法复杂度。如果在调用线程上执行用户会感受到明显的延迟。后台线程使用线程池每次编译不超过 10ms超时后主动让出线程保证不阻塞前台操作。四、PGOProfile-Guided Optimization4.1 什么是 PGO传统优化依赖静态分析——JIT 查看代码结构推导哪些路径可能更重要。PGO 换了一个思路先跑一遍收集数据再用数据指导优化。分层编译 PGO 的工作流Instrumented Tier0在编译时插入探针Probe收集运行时数据数据收集方法运行期间探针记录基本块执行次数、虚调用的具体类型分布、边界检查的成功率Tier1 编译JIT 读取收集的数据做针对性优化4.2 探针收集的具体数据数据类型探针内容用途边计数每个基本块进入次数识别热路径、冷路径类类型分布虚调用/接口调用的接收者类型Guarded Devirtualization调用计数方法被调用的总次数内联决策4.3 Profile 数据的存储收集的数据存储在一个固定大小的Profile Slab中运行时分配的连续内存块。每个被检测的 Tier0 方法在 Slab 中保留一段空间存储其探针计数。Slab 满后后续的 Tier0 方法不再被检测——这是一种尽力而为的策略。4.4 PGO 驱动的优化内联增强对于调用计数高的 callee放宽内联的大小限制对于热路径上的方法更激进地内联冷路径上的方法往往不会被内联避免代码膨胀Guarded DevirtualizationGDV如果虚调用 90% 的情况接收Listint类型JIT 生成if (obj is Listint list) { list.Add(item); // 直接调用 } else { ((IListint)obj).Add(item); // 虚调用 }热/冷代码分离热路径的基本块被紧凑排列在一起提高指令缓存命中率冷路径如异常处理、错误分支被放到方法末尾的单独区域五、分层编译与数据结构的关系5.1 高频集合操作的优化路径for (int i 0; i 1000000; i) { list.Add(i); // Tier0慢无优化→ Tier1快内联去虚拟化 }在 Tier0 阶段list.Add没有被内联方法太大_items[i]的边界检查没有被消除在 Tier1PGO阶段JIT 检测到Add被高频调用 → 部分内联快速路径list.Count被内联为_size索引访问的边界检查依据循环上下文被消除5.2 Dictionary 的高频查找if (dict.TryGetValue(key, out var value)) { ... }Tier0TryGetValue完整调用无内联、GetHashCode可能经虚调用Tier1 PGOJIT 检测到 key 类型主要是string→GetHashCode去虚拟化 →TryGetValue的快速路径被内联5.3 foreach 的优化路径foreach (var item in list) { ... }Tier0生成标准枚举器模式GetEnumerator()→while (MoveNext())Tier1如果 PGO 数据表明是热点JIT 可以将枚举器转换为for (int i 0; i list._size; i)的等价位——消除枚举器分配六、Unity 中的分层编译Unity 目前不支持分层编译编辑器模式Mono JIT使用单层 JITMono Mini JIT无 Tier0/Tier1 区分IL2CPP 构建完全 AOT 编译根本不存在运行时的 JIT这意味着在 Unity 中你无法享受到 PGO 带来的动态优化。但对应的技巧仍然适用手写具体类型替代接口手动去虚拟化用for 替代 foreach避免枚举器分配用预分配 Capacity手动消除扩容从JIT 帮你优化的思维切换到你自己就是编译器的思维七、验证分层编译# 查看方法当前处于哪个 Tier DOTNET_TC_CallCounting1 DOTNET_TC_QuickJit1 # 启用 Tier0 # 禁用分层编译用于基准测试对比 DOTNET_TC_QuickJit0使用 BenchmarkDotNet 对比分层编译的开/关性能[SimpleJob(RuntimeMoniker.Net80)] [MemoryDiagnoser] public class TieredBenchmark { [Benchmark] public void Test() { /* 你的代码 */ } }八、总结分层编译 PGO 是 .NET 运行时越跑越快的秘密武器。对于数据结构使用者关键收获Tier0 和 Tier1 的代码质量差异显著——不要在冷启动时做性能结论PGO 让热路径的关键优化内联、去虚拟化更加精准高频调用的简单方法最受益于 Tier1 优化Unity 不支持分层编译——IL2CPP 下需要手动做等效优化下一篇GC 深度剖析内存分配、回收与你的数据结构选择