引言:优化的悖论

在软件开发领域,编译器优化常被视为理所当然的性能提升手段。程序员们习惯性地在编译命令中添加-O2-O3,期望编译器施展魔法般的变换:消除冗余计算、内联函数调用、展开紧凑循环、向量化数值运算。这种信任建立在一个隐含的假设之上——更多的优化总是意味着更快的代码。

然而,现实远比这个假设复杂。2017年,Daniel Bernstein在ETAPS会议上发表了一场名为"优化编译器之死"的演讲,他尖锐地指出:现代优化编译器实际上处于一个尴尬的中间地带——对于大多数程序的绝大多数代码,优化收益微乎其微;而对于真正性能关键的那小部分代码,编译器又往往不如人类程序员。1

更令人困惑的是,在某些情况下,更高的优化级别反而会产生更慢的代码。一位开发者在2025年的实验中发现,使用-O3编译的斐波那契函数比-O2版本慢了13%。2 这不是孤例——Red Hat和Fedora曾长期使用-O2而非-O3编译Python,正是因为前者在实际测试中快了约1%。3

这些反直觉现象的背后,隐藏着编译器优化技术的深层矛盾。本文将从理论基础到工程实践,系统性地剖析优化编译器面临的根本性挑战,揭示为什么"优化"有时会成为性能的敌人。

编译器优化的理论极限

NP完全性:寄存器分配的数学宿命

编译器优化的第一个根本性障碍来自计算复杂性理论。1981年,Gregory Chaitin等人在经典论文中证明了寄存器分配问题是NP完全的。4 这意味着,除非P=NP,否则不存在能在多项式时间内找到最优寄存器分配方案的算法。

寄存器分配的核心任务是:将程序中的变量映射到有限数量的物理寄存器。当同时活跃的变量数量超过可用寄存器数量时,部分变量必须被"溢出"(spill)到内存中。访问内存比访问寄存器慢100-1000倍,因此溢出决策对性能影响巨大。

flowchart TD
    A[开始寄存器分配] --> B[构建活跃范围]
    B --> C[构建干涉图]
    C --> D[图着色]
    D --> E{着色成功?}
    E -->|是| F[分配完成]
    E -->|否| G[选择溢出候选]
    G --> H[插入溢出代码]
    H --> D
    
    subgraph 溢出代价分析
        G --> G1[计算溢出代价]
        G1 --> G2[权衡内存访问开销]
        G2 --> G3[选择代价最小的变量]
    end

主流编译器采用两种主要策略应对这一困境:

图着色算法(Chaitin风格)将寄存器分配建模为图着色问题。变量是图中的节点,如果两个变量同时活跃,则在它们之间连一条边。K个寄存器对应K种颜色,问题转化为用K种颜色给图着色,使得相邻节点颜色不同。图着色算法产生高质量代码,但时间复杂度高——干涉图在最坏情况下可能达到O(n²)规模。

线性扫描算法(Poletto风格)是一种贪心策略。它按照变量活跃区间的起始点顺序扫描,当寄存器不足时,将结束点最晚的变量溢出。线性扫描的时间复杂度为O(n),使其成为JIT编译器的首选——HotSpot客户端编译器、V8、Android Runtime都采用此算法。5 但贪心策略无法利用"活跃空洞"(变量生命周期中不需要其值的区间),可能导致不必要的溢出。

graph LR
    subgraph 图着色 vs 线性扫描
        A[图着色] --> A1[代码质量: 高]
        A --> A2[编译时间: O n²]
        A --> A3[适用场景: 静态编译]
        
        B[线性扫描] --> B1[代码质量: 中等]
        B --> B2[编译时间: O n]
        B --> B3[适用场景: JIT编译]
    end

数据流分析的不动点迭代

数据流分析是编译器优化的理论基础,其核心是求解约束系统的不动点。活跃变量分析、到达定义分析、可用表达式分析都遵循相同的数学框架:定义一个半格(semilattice)、定义一组单调的流函数、迭代求解最小(或最大)不动点。

Tarski-Knaster定理保证:给定一个完全格L和单调函数G,G的不动点构成一个完全格,因此不动点一定存在。6 但问题在于——不动点只是安全近似,不一定是精确解。

考虑常量传播分析。编译器想知道变量x在某个程序点是否总是具有常量值。如果分析报告"非常量",编译器就无法进行常量折叠优化。但分析可能因为路径合并导致信息丢失——两条路径分别传播x=5和x=5,合并后仍为x=5;但如果一条路径传播x=5,另一条传播未知值,合并结果就是"未知"。

这种保守性是必要的:编译器宁可错过优化机会,也不能错误优化导致程序行为改变。但这意味着某些理论上可优化的情况,实际无法优化。

函数内联:代码膨胀与缓存失效

函数内联是最强有力的编译器优化之一。将函数调用替换为函数体,消除了调用开销,更重要的是开启了后续优化的可能性——内联后的代码可以进行跨过程优化,比如常量传播、死代码消除。

但内联是一把双刃剑。每一个内联决策都在权衡:收益是消除调用开销和启用后续优化;代价是代码体积增加和编译时间延长。

内联的代价收益模型

现代编译器使用复杂的启发式模型决定是否内联。LLVM的InlineCost分析器考虑以下因素:7

  • 指令数量:函数体越大,内联代价越高
  • 调用频率:调用次数越多,内联收益越高
  • 参数特性:常量参数可能触发更多优化
  • 栈帧影响:内联可能改变寄存器压力

问题在于,这些模型基于静态分析和启发式规则,而非实际运行数据。Profile-Guided Optimization (PGO)可以提供运行时信息,但PGO需要额外的编译流程,许多项目并未采用。

代码膨胀的连锁反应

内联导致的代码膨胀会产生多层次的负面影响:

指令缓存失效:现代CPU的一级指令缓存通常只有32-64KB。当代码体积膨胀时,原本能完整放入缓存的代码可能超出缓存容量,导致缓存未命中率上升。一次缓存未命中可能消耗100-300个CPU周期,而函数调用开销通常只有几个到几十个周期。

分支预测压力:更大的代码意味着更多的分支指令。分支目标缓冲区(BTB)容量有限,当代码膨胀超出BTB容量时,分支预测准确率下降。现代CPU的流水线深度可达15-20级,分支预测失败意味着整个流水线被清空。

寄存器压力增加:内联后的函数体需要同时处理调用者和被调用者的活跃变量,可能导致寄存器溢出。一个精心优化的函数被内联后,可能因为寄存器压力增加而被迫溢出更多变量。

flowchart TD
    A[内联决策] --> B{评估收益}
    B -->|消除调用开销| C[直接收益]
    B -->|启用后续优化| D[间接受益]
    
    A --> E{评估代价}
    E -->|代码体积增加| F[指令缓存压力]
    E -->|分支增加| G[分支预测压力]
    E -->|寄存器需求增加| H[寄存器溢出风险]
    
    C --> I{净收益?}
    D --> I
    F --> I
    G --> I
    H --> I
    
    I -->|正| J[执行内联]
    I -->|负| K[放弃内联]

一个真实的案例

2025年的一篇博客文章展示了一个极端案例:一个精心构造的校验和函数,当使用-O3编译时比-O2慢22倍。2

问题的根源在于:-O3启用了更激进的内联策略,将校验和函数完全内联到调用循环中。编译器因此失去了优化机会——原本可以识别出循环迭代间的冗余计算,但内联后代码变得过于复杂,编译器的分析能力不足以发现优化机会。

解决方案出人意料地简单:使用__attribute__((noinline))禁止内联。禁止内联后,编译器重新获得了函数边界带来的分析优势,代码速度恢复到预期水平。

这个案例揭示了一个深刻的问题:编译器的优化决策是基于局部信息做出的,而性能是一个全局属性。局部最优的决策组合起来,可能产生全局次优甚至糟糕的结果。

循环优化:展开与向量化的双刃剑

循环是程序中计算密集的区域,也是编译器优化的重点目标。循环展开、自动向量化、循环不变量外提等技术旨在减少循环开销、利用SIMD指令、减少冗余计算。但这些优化同样存在负面效应。

循环展开的边际效益递减

循环展开的基本原理是:复制循环体多次,减少循环控制开销(条件判断、计数器更新),并为后续优化创造机会。例如,将4次迭代的循环展开为单次处理4个元素:

// 原始循环
for (i = 0; i < n; i++)
    a[i] = b[i] + c[i];

// 展开后
for (i = 0; i < n; i += 4) {
    a[i]   = b[i]   + c[i];
    a[i+1] = b[i+1] + c[i+1];
    a[i+2] = b[i+2] + c[i+2];
    a[i+3] = b[i+3] + c[i+3];
}

适度展开确实有益:现代CPU的分支预测器可以很好地预测固定次数的循环,减少预测失败;展开后的代码为指令调度提供更多灵活性。

但过度展开会产生反效果:

代码体积爆炸:展开因子为8意味着代码体积增加8倍(不考虑后续优化)。如果展开导致指令缓存失效,性能损失可能远超收益。

寄存器压力增加:展开后的代码需要同时保存更多中间值。当活跃变量超过寄存器数量时,编译器被迫插入溢出代码,这比循环控制开销昂贵得多。

循环迭代次数敏感性:当循环次数不是展开因子的整数倍时,需要额外的"清理"代码处理剩余迭代。这部分代码增加了代码体积,却很少被执行。

自动向量化:理想与现实的差距

自动向量化旨在将标量循环转换为SIMD指令,利用现代CPU的向量寄存器(AVX-512可达512位,一次处理16个单精度浮点数)。理论加速比可达向量宽度倍数,但实际效果往往大打折扣。

向量化的首要障碍是数据依赖分析。编译器必须证明循环迭代之间不存在数据依赖:

// 可以向量化:迭代独立
for (i = 0; i < n; i++)
    a[i] = b[i] + c[i];

// 无法向量化:迭代依赖
for (i = 1; i < n; i++)
    a[i] = a[i-1] + b[i];

第二类循环存在循环携带依赖——迭代i的计算需要迭代i-1的结果。编译器需要识别这种依赖,否则错误向量化会产生错误结果。

更棘手的是别名分析。考虑以下代码:

void compute(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i++)
        a[i] = b[i] + c[i];
}

编译器必须确定a、b、c三个指针是否指向重叠的内存区域。如果存在重叠,向量化可能导致错误。C99引入的restrict关键字可以帮助编译器:

void compute(float* restrict a, float* restrict b, 
             float* restrict c, int n);

这告诉编译器:a、b、c指向的内存区域不重叠。但如果程序员提供了错误的信息,结果是未定义行为。

对齐要求是另一个障碍。SIMD指令通常要求数据按特定边界对齐(如32字节或64字节)。未对齐的访问可能比标量访问更慢,或直接导致运行时错误。编译器需要生成运行时对齐检查代码,这增加了开销。

flowchart TD
    A[循环向量化分析] --> B{数据依赖检查}
    B -->|存在依赖| C[放弃向量化]
    B -->|无依赖| D{别名分析}
    D -->|可能重叠| E[插入运行时检查]
    D -->|无重叠| F{对齐分析}
    E --> G{运行时检查通过?}
    G -->|是| F
    G -->|否| C
    F -->|已对齐| H[生成向量代码]
    F -->|未对齐| I[生成对齐处理代码]
    I --> H

循环不变量外提的陷阱

循环不变量外提(LICM)将循环内部不随迭代改变的计算移到循环外部,看似总是有益。但考虑以下场景:

for (int i = 0; i < n; i++) {
    if (rare_condition) {
        result = expensive_computation(x);
    }
    // ...
}

如果expensive_computation是循环不变量,编译器可能将其外提。但问题是:如果rare_condition几乎从不为真,原本"按需计算"的代码变成了"始终计算"。外提反而增加了执行时间。

JDK 10曾出现过类似问题:HotSpot的C2编译器过度外提循环不变量,导致某些特定代码模式的性能下降约12%。8 这是一个典型的"优化假设运行时行为与实际不符"的案例。

O3 vs O2:更高优化级别的反直觉结果

编译器的优化级别选项(-O0、-O1、-O2、-O3)反映了优化激进程度的梯度。直觉上,-O3应该总是优于-O2。但实际情况远非如此。

O3启用的额外优化

以GCC为例,-O3在-O2的基础上额外启用了以下优化:2

  • -fgcse-after-reload:重载后的全局公共子表达式消除
  • -fipa-cp-clone:过程间常量传播克隆
  • -floop-interchange:循环交换
  • -floop-unroll-and-jam:循环展开与合并
  • -fpeel-loops:循环剥离
  • -fpredictive-commoning:预测性公共子表达式
  • -fsplit-loops:循环分裂
  • -fsplit-paths:路径分裂
  • -ftree-loop-distribution:循环分布
  • -ftree-partial-pre:部分冗余消除
  • -funroll-completely-grow-size:完全展开限制放宽
  • -funswitch-loops:循环分支外提
  • -fversion-loops-for-strides:步长版本化循环

每一个优化都有其适用场景,但也都有潜在风险。

斐波那契案例剖析

前面提到的斐波那契案例揭示了-fipa-cp-clone的问题。2 编译器在分析递归函数时,尝试通过数学变换加速计算。对于fib(42),-O3版本推导出一个求和公式:

$$\text{fib}(n) = 1 + \sum_{i=0}^{n-2} \text{fib}(i)$$

这个数学变换本身是正确的。问题在于:编译器基于这个公式生成了代码,但生成的代码效率不如-O2的直接递归版本。

这个案例说明了一个深层次问题:编译器在尝试"智能"优化时,可能做出次优决策。编译器没有完整的算法知识,只是应用了模式匹配和启发式规则。有时候,最简单的实现反而是最优的。

实际项目的选择

许多知名项目选择使用-O2而非-O3:

  • Linux内核:使用-O2,避免代码膨胀和不可预测行为
  • OpenBSD:整个操作系统和第三方软件包都使用-O2
  • GCC自身:使用-O2编译
  • VASP:材料科学计算软件,使用-O2作为默认,因为-O3在某些情况下会产生更慢、更耗内存的代码9

这不是保守主义,而是基于经验的理性选择。-O3的额外优化带来的收益高度依赖于代码特性,而风险(代码膨胀、编译时间、潜在bug)是确定的。

未定义行为:优化过度引发的安全陷阱

编译器优化与未定义行为(UB)的交互是C/C++编程中最危险的地带之一。C语言标准定义了大量未定义行为,而现代编译器越来越激进地利用UB进行优化。

未定义行为的语义

未定义行为意味着:当程序触发UB时,标准对其行为不做任何保证。程序可能崩溃、可能产生错误结果、也可能"意外"正常工作。常见的UB包括:

  • 有符号整数溢出
  • 空指针解引用
  • 数组越界访问
  • 使用未初始化的变量
  • 违反strict aliasing规则

从编译器优化角度看,UB提供了重要的分析假设:编译器可以假设UB永远不会发生。如果某个代码路径必然导致UB,编译器可以认为该路径不可达,从而删除相关代码。

激进优化的安全代价

考虑以下代码:

int foo(int* p) {
    int x = *p;   // 可能触发UB(如果p为NULL)
    if (!p) return 0;  // 死代码?
    return x;
}

在GCC和Clang的现代版本中,if (!p)检查可能被优化掉。编译器的推理是:解引用*p意味着p非空(否则是UB);既然p非空,!p恒为假,检查可删除。

这种优化在某些场景下提升了性能,但也引发了严重的安全漏洞。当程序员依赖这些检查防御恶意输入时,优化后的代码失去了保护能力。10

2017年,研究人员发现了多个Linux内核中的UB相关问题,部分源于编译器基于UB假设进行的激进优化。这引发了广泛讨论:编译器是否应该在优化激进性和程序员预期之间寻求更好的平衡?

调试优化的困境

另一个常被忽视的问题是优化对调试的影响。-O2及以上优化级别会:

  • 删除死代码:某些代码路径被优化掉,调试器无法到达
  • 内联函数:调用栈扁平化,难以追踪执行流
  • 重排指令:源代码顺序与机器指令顺序不一致
  • 消除变量:寄存器分配优化后,某些变量在内存中不存在
  • 合并相同代码:不同源代码位置合并到同一指令地址

GCC提供了-Og选项,专为调试设计:应用不影响调试的优化,保留变量位置和代码结构。但对于生产环境调试,-Og的性能可能不够理想。

graph TD
    subgraph 优化级别对比
        A["-O0: 无优化<br/>调试最佳, 性能最差"]
        B["-Og: 调试优化<br/>调试良好, 性能一般"]
        C["-O1: 基础优化<br/>调试可用, 性能良好"]
        D["-O2: 标准优化<br/>调试困难, 性能优秀"]
        E["-O3: 激进优化<br/>调试极难, 性能不确定"]
    end
    
    A --> B --> C --> D --> E

架构依赖:不同硬件的不同最优解

编译器优化必须考虑目标硬件的特性。同一优化策略在不同架构上可能产生截然不同的效果。

x86 vs ARM vs RISC-V

三种主流架构在设计哲学上有根本差异:

特性 x86 (CISC) ARM (RISC) RISC-V (RISC)
指令集复杂度
指令长度 可变 (1-15字节) 定长 (通常4字节) 定长 (通常4字节)
寄存器数量 (通用) 16 (64位模式) 31 31
内存访问 支持复杂寻址 Load/Store架构 Load/Store架构
条件执行 有限 ARM模式广泛支持 有限

这些差异直接影响最优编译策略:

寄存器压力:x86-64只有16个通用寄存器,ARM64有31个。同样的代码在x86上可能面临更严重的寄存器压力,需要更多溢出代码。内联决策在x86上需要更加保守。

指令延迟:不同架构的指令延迟差异显著。某些优化(如指令调度)需要针对具体架构调整。一个在x86上有益的指令重排,可能在ARM上适得其反。

向量化能力:x86的AVX-512提供512位向量,ARM的SVE是可伸缩向量扩展(支持128-2048位),RISC-V的V扩展同样可伸缩。最优向量化策略因架构而异。

跨架构编译的挑战

为多个架构编译同一代码库时,编译器面临两难:是生成架构特定的高效代码,还是生成跨架构兼容的通用代码?

理想情况下,编译器应该为每个目标架构生成最优代码。但这需要:

  1. 完整的架构性能模型
  2. 准确的指令延迟和吞吐量数据
  3. 内存层次结构信息(缓存大小、延迟)
  4. 分支预测器行为模型

现实中的编译器使用简化模型,可能导致次优决策。Intel的ICC编译器在Intel处理器上表现出色,但在AMD处理器上性能下降,部分原因就是过度针对Intel微架构优化。11

实践建议:如何正确使用编译器优化

理解编译器优化的局限性后,以下是实践层面的建议:

编译选项选择

  1. 默认使用-O2:对于大多数项目,-O2提供了良好的性能/风险平衡。只有在经过性能测试后确认-O3确实更快时,才考虑使用-O3。

  2. 考虑-Os:如果代码体积是关键指标(嵌入式系统、移动应用),-Os优化体积而非速度,通常也能获得不错的性能。

  3. 谨慎使用-Ofast:这个选项启用可能违反标准的优化(如假设浮点运算可结合)。只有在你完全理解其含义时使用。

  4. 启用链接时优化(LTO):LTO允许跨编译单元优化,但会增加链接时间。对于大型项目,评估是否值得。

Profile-Guided Optimization

PGO是克服编译器静态分析局限的有效方法:

# 步骤1:使用插桩编译
gcc -fprofile-generate -O2 myprogram.c -o myprogram

# 步骤2:使用代表性输入运行程序
./myprogram < representative_input.txt

# 步骤3:使用收集的profile重新编译
gcc -fprofile-use -O2 myprogram.c -o myprogram_optimized

PGO让编译器了解实际的运行时行为:哪些代码路径频繁执行、哪些分支更可能被选择。Chrome和Firefox报告PGO带来约10%的性能提升。1

但PGO有局限性:

  • 需要代表性的训练输入
  • 程序行为变化时需要重新profile
  • 增加构建流程复杂度

性能测试与验证

永远不要假设优化有效——验证它:

  1. 建立基准测试套件:覆盖关键性能路径
  2. 使用性能分析工具:perf、VTune、Instruments等
  3. 比较不同优化级别:在关键代码上测试-O2和-O3
  4. 关注回归:每次代码变更后运行性能测试

关键代码的手动优化

对于真正的性能关键代码,编译器优化可能不够:

  • 使用intrinsic函数手动向量化
  • 针对特定架构的汇编优化
  • 算法级别的优化(编译器无法替代O(n²)变为O(n log n))

编译器优化的价值在于:让程序员专注于算法和数据结构,而非底层细节。但在极端性能场景,手动优化仍有其价值。

未来展望:机器学习辅助优化

编译器优化领域正在引入机器学习技术,试图克服传统启发式方法的局限。

ML在编译器中的应用

内联决策:使用ML模型预测内联的收益,而非静态启发式。Google的MLGO项目使用强化学习训练内联决策模型,在LLVM上取得了优于默认启发式的结果。12

寄存器分配:ML模型学习选择最优的溢出候选,考虑复杂的历史依赖关系。

向量化决策:预测向量化是否真正有益,而非盲目尝试所有可能。

优化序列选择:不同代码片段可能需要不同的优化序列。ML可以学习何时应用哪种优化。

挑战与限制

ML辅助优化面临独特挑战:

训练数据:需要大量代码样本及其优化前后性能数据。收集这些数据成本高昂。

泛化能力:在训练数据上表现良好的模型可能在新代码上失效。

编译时间:ML推理增加了编译时间,需要权衡。

可解释性:ML模型的决策难以解释,调试困难。

尽管存在挑战,ML辅助优化代表了编译器技术的重要发展方向。未来编译器可能结合传统分析技术和ML模型,在更复杂的优化决策中做出更优选择。

timeline
    title 编译器优化技术演进
    1960s-1970s : 基础优化发展<br/>常量折叠, 死代码消除
    1980s : 图着色寄存器分配<br/>数据流分析成熟
    1990s : SSA形式普及<br/>过程间优化
    2000s : JIT编译兴起<br/>Profile-Guided优化
    2010s : 自动向量化增强<br/>LTO成熟
    2020s : 机器学习辅助优化<br/>自动化优化序列选择

结语

编译器优化是一个充满权衡的艺术。每一次优化决策都在收益与代价之间寻找平衡:寄存器分配权衡代码质量与编译时间,函数内联权衡调用开销与代码体积,循环展开权衡迭代开销与缓存效率。

不存在放之四海而皆准的最优策略。同一个优化,在某些代码上效果显著,在另一些代码上可能适得其反。理解这种两面性,是有效利用编译器优化的前提。

对程序员而言,最重要的认识是:编译器优化不是魔法。它是一种强大的工具,但也有其边界。明智的做法是:

  1. 理解编译器优化的能力和局限
  2. 使用性能测试验证优化效果
  3. 在关键场景考虑手动优化
  4. 保持对代码可读性的关注——过早优化是万恶之源

编译器优化技术仍在不断发展。从传统的静态分析到Profile-Guided Optimization,再到新兴的机器学习辅助优化,编译器正在变得更加智能。但无论技术如何进步,优化决策的本质——在不确定的信息下做出权衡——永远不会改变。

这正是编译器优化的魅力所在,也是它的根本挑战。


参考文献


  1. Tratt, L. (2017). What Challenges and Trade-Offs do Optimising Compilers Face? https://tratt.net/laurie/blog/2017/what_challenges_and_trade_offs_do_optimising_compilers_face.html ↩︎ ↩︎

  2. Namazov, A. (2025). When C++ O3 is Slower than O2. https://barish.me/blog/cpp-o3-slower/ ↩︎ ↩︎ ↩︎ ↩︎

  3. GCC Optimization Options. https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html ↩︎

  4. Chaitin, G. J. (1982). Register allocation & spilling via graph coloring. Proceedings of the 1982 SIGPLAN Symposium on Compiler Construction. ↩︎

  5. Poletto, M., & Sarkar, V. (1999). Linear Scan Register Allocation. ACM Transactions on Programming Languages and Systems. ↩︎

  6. Nielson, F., Nielson, H. R., & Hankin, C. (1999). Principles of Program Analysis. Springer. ↩︎

  7. LLVM InlineCost Analysis. https://llvm.org/docs/InlineCost.html ↩︎

  8. Balosin, I. (2018). Loop invariant code motion pitfall in JDK10. https://ionutbalosin.com/2018/05/loop-invariant-code-motion-pitfall-in-jdk10/ ↩︎

  9. Why does VASP use -O2 optimization by default rather than -O3? https://mattermodeling.stackexchange.com/questions/9470/ ↩︎

  10. Regehr, J. (2017). A Guide to Undefined Behavior in C and C++. https://blog.regehr.org/archives/1520 ↩︎

  11. Performance-Based Comparison of C/C++ Compilers. https://colfaxresearch.com/compiler-comparison/ ↩︎

  12. Trofin, M., et al. (2021). MLGO: A Machine Learning Guided Compiler Optimizations Framework. arXiv:2101.04808. ↩︎