01-06-运行时-分层编译与PGO-运行时如何持续优化代码

分层编译与 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 的对比

特性Tier0Tier1
编译速度极快(< 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 的工作流:

  1. Instrumented Tier0:在编译时插入探针(Probe),收集运行时数据
  2. 数据收集:方法运行期间,探针记录基本块执行次数、虚调用的具体类型分布、边界检查的成功率
  3. 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 运行时"越跑越快"的秘密武器。对于数据结构使用者,关键收获:

  1. Tier0 和 Tier1 的代码质量差异显著——不要在冷启动时做性能结论
  2. PGO 让热路径的关键优化(内联、去虚拟化)更加精准
  3. 高频调用的简单方法最受益于 Tier1 优化
  4. Unity 不支持分层编译——IL2CPP 下需要手动做等效优化

下一篇GC 深度剖析:内存分配、回收与你的数据结构选择

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

淡海水

感谢支持 共同进步 好运++

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值