分层编译与 PGO:运行时如何持续优化你的代码
系列:C#与常用数据结构源码剖析 · 运行时底层剖析
阅读时间:约 40 分钟
前置知识:JIT 编译管线、JIT 优化
一、引言
传统的 JIT 编译是一次性的——方法被首次调用时编译,之后代码不再变化。这种模式有一个根本性的权衡:如果 JIT 为了速度而做全面优化,编译时间会拖慢启动;如果 JIT 为了快速启动而跳过优化,运行时的性能就无法达到最佳。
分层编译(Tiered Compilation) 打破了这种权衡。.NET Core 3.0 引入的分层编译允许同一方法在运行期间被编译两次——先快速编译(Tier0),方法变热后再用完整优化重新编译(Tier1)。
结合 PGO(Profile-Guided Optimization),Tier0 阶段还会插入探针收集运行时数据——哪些类型最常见、哪些分支最常走、哪些方法最常被调用。Tier1 编译时利用这些数据做针对性优化。
对于数据结构而言,这意味着:你代码中的 List<T>.Add()、Dictionary.TryGetValue()、foreach 循环——这些高频操作在 Tier1 阶段会得到远优于 Tier0 的优化。
二、分层编译的设计动机
2.1 启动速度 vs 稳态性能的两难
假设一个 ASP.NET Core 应用有 10000 个方法。在传统的单层 JIT 模式下:
- 如果全部用最高优化编译 → 启动时间长(需要编译 10000 个方法)
- 如果全部用最低优化编译 → 启动快,但稳态性能差
分层编译的解决方案:
- Tier0:快速、少优化的编译,尽快让代码跑起来
- Tier1:只在方法被频繁调用时,用完整优化重新编译
2.2 Tier0 与 Tier1 的对比
| 特性 | Tier0 | Tier1 |
|---|---|---|
| 编译速度 | 极快(< 1ms) | 慢(5-50ms) |
| 内联 | 极少 | 激进 |
| 去虚拟化 | 无 | 基于 PGO 数据 |
| 边界检查消除 | 无 | 全面 |
| 循环优化 | 无 | Loop Cloning、Unrolling |
| 寄存器分配 | 简单 | 线性扫描全局分配 |
| 代码质量 | 低速 | 全速 |
| 探针插入(PGO) | 是 | 否 |
三、分层编译的晋升机制
3.1 30 次调用 + 100ms 计时器
一个方法从 Tier0 晋升到 Tier1 需要满足两个条件:
条件 1:调用计数器达到 30 次
每个方法的入口点(Precode Stub)中内嵌了一个调用计数器。每次调用递增,达到 30 次后标记为"可能热方法"。30 这个数字来源于早期经验测试——大多数方法在被调用 30 次后确实值得重新编译。
条件 2:100ms 启动期计时器过期
在应用启动时,一个 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(超时后主动让出线程),保证不阻塞前台操作。
四、PGO(Profile-Guided Optimization)
4.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 Devirtualization(GDV):
- 如果虚调用 90% 的情况接收
List<int>类型,JIT 生成:
if (obj is List<int> list) {
list.Add(item); // 直接调用
} else {
((IList<int>)obj).Add(item); // 虚调用
}
热/冷代码分离:
- 热路径的基本块被紧凑排列在一起(提高指令缓存命中率)
- 冷路径(如异常处理、错误分支)被放到方法末尾的单独区域
五、分层编译与数据结构的关系
5.1 高频集合操作的优化路径
for (int i = 0; i < 1000000; i++) {
list.Add(i); // Tier0:慢(无优化)→ Tier1:快(内联+去虚拟化)
}
在 Tier0 阶段:
list.Add没有被内联(方法太大)_items[i]的边界检查没有被消除
在 Tier1(PGO)阶段:
- JIT 检测到
Add被高频调用 → 部分内联(快速路径) list.Count被内联为_size- 索引访问的边界检查依据循环上下文被消除
5.2 Dictionary 的高频查找
if (dict.TryGetValue(key, out var value)) { ... }
- Tier0:
TryGetValue完整调用(无内联)、GetHashCode可能经虚调用 - Tier1 PGO:JIT 检测到 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):使用单层 JIT(Mono Mini JIT),无 Tier0/Tier1 区分
- IL2CPP 构建:完全 AOT 编译,根本不存在运行时的 JIT
这意味着在 Unity 中,你无法享受到 PGO 带来的动态优化。但对应的技巧仍然适用:
- 手写具体类型替代接口(手动去虚拟化)
- 用 for 替代 foreach(避免枚举器分配)
- 用 预分配 Capacity(手动消除扩容)
- 从"JIT 帮你优化"的思维切换到"你自己就是编译器"的思维
七、验证分层编译
# 查看方法当前处于哪个 Tier
DOTNET_TC_CallCounting=1
DOTNET_TC_QuickJit=1 # 启用 Tier0
# 禁用分层编译(用于基准测试对比)
DOTNET_TC_QuickJit=0
使用 BenchmarkDotNet 对比分层编译的开/关性能:
[SimpleJob(RuntimeMoniker.Net80)]
[MemoryDiagnoser]
public class TieredBenchmark
{
[Benchmark]
public void Test() { /* 你的代码 */ }
}
八、总结
分层编译 + PGO 是 .NET 运行时"越跑越快"的秘密武器。对于数据结构使用者,关键收获:
- Tier0 和 Tier1 的代码质量差异显著——不要在冷启动时做性能结论
- PGO 让热路径的关键优化(内联、去虚拟化)更加精准
- 高频调用的简单方法最受益于 Tier1 优化
- Unity 不支持分层编译——IL2CPP 下需要手动做等效优化

1085

被折叠的 条评论
为什么被折叠?



