llvm_history

第2章:LLVM IR - 编译器的通用语言

引言

LLVM IR(Intermediate Representation,中间表示)是整个 LLVM 编译器基础设施的核心。它就像编译器世界的”世界语”——一种精心设计的通用语言,能够准确表达从高级语言到机器码转换过程中的所有必要信息。本章将深入探讨 LLVM IR 的设计哲学、核心特性以及其演化历程,特别关注 Chris Lattner 最初的设计决策如何影响了后续二十年的发展。

学习目标:

2.1 IR 设计哲学:在高层抽象和机器码之间找平衡

2.1.1 为什么需要中间表示?

在传统的编译器设计中,直接从源语言翻译到目标机器码会导致 $N \times M$ 的复杂度问题:N 种源语言需要为 M 种目标架构各写一个完整的编译器。中间表示的引入将这个问题分解为 $N + M$ 的复杂度:

源语言 1 ───┐
源语言 2 ───┼──→ 统一的 IR ──→ 优化 ──→ IR' ───┬──→ x86-64
源语言 3 ───┘                                  ├──→ ARM
                                               └──→ RISC-V

2.1.2 LLVM IR 的设计原则

Chris Lattner 在 2000 年设计 LLVM IR 时,确立了几个核心原则,这些原则深刻影响了后续二十年的编译器设计:

  1. SSA 形式(Static Single Assignment):每个变量只被赋值一次,这简化了数据流分析和优化算法的实现。这个决策源于 1980 年代末期的编译器研究成果,特别是 Cytron 等人在 1991 年发表的经典论文。Lattner 认识到 SSA 不仅是一个理论上优雅的表示,更是实用的工程选择——它让 def-use 链的构建变得平凡,使得诸如常量传播、死代码消除等优化的实现复杂度从 O(n²) 降低到 O(n)。

  2. 强类型系统:早期的 LLVM IR 采用了严格的类型系统,每个值都有明确的类型。这与无类型的汇编语言形成鲜明对比。Lattner 的这个设计受到了 Java 字节码的启发,但又避免了其过于高级的抽象。类型信息的保留使得 LLVM 能够进行更精确的别名分析和内存优化。然而,这个决策在 15 年后被部分推翻——opaque pointers 的引入表明,过度的类型信息有时反而成为负担。

  3. 无限寄存器:IR 假设有无限多个虚拟寄存器可用,将寄存器分配推迟到代码生成阶段。这个抽象极大地简化了优化器的实现——优化算法不需要考虑寄存器溢出问题,可以自由地创建临时变量。这种设计哲学后来被许多现代编译器采纳,包括 GCC 的 GIMPLE 和 Rust 的 MIR。

  4. 三地址码形式:大多数指令采用”result = operation operand1, operand2”的形式,简洁且易于分析。这种规范化的表示使得模式匹配变得容易,也便于实现窥孔优化(peephole optimization)。三地址码的限制迫使复杂表达式被分解为简单操作序列,这反而有助于暴露更多的优化机会。

  5. 显式控制流图:基本块和分支指令构成了显式的 CFG(Control Flow Graph)。每个基本块以终结指令(terminator)结束,如 br、ret、switch 等。这种设计使得控制流分析变得直接——不需要通过扫描指令流来重建 CFG。

  6. 模块化和链接:LLVM IR 从一开始就支持模块(module)概念和链接时优化(LTO)。这个前瞻性的设计使得 LLVM 能够进行全程序优化,这在 2000 年还是相当先进的概念。

2.1.3 抽象层次的权衡

LLVM IR 的抽象层次经过精心选择,需要在以下几个维度找到平衡。这个平衡点的选择反映了 Lattner 对”实用编译器”的理解:

保留的高级信息

丢失的高级信息

接近机器的特性

这种抽象层次的选择产生了深远的影响。例如,循环信息的丢失意味着每个需要循环信息的优化 pass 都必须首先运行 LoopInfo 分析;类型信息的保留(以及后来的简化)影响了整整一代的优化算法设计;GEP 指令的引入启发了后续许多 IR 设计(如 Rust 的 MIR)采用类似的地址计算抽象。

2.1.4 LLVM IR 的三种形式

LLVM IR 实际上有三种等价的表示形式,这种设计提供了极大的灵活性。这个”一个 IR,三种形式”的设计是 LLVM 成功的关键因素之一:

  1. 文本形式(.ll 文件):人类可读的汇编式语法
    define i32 @add(i32 %a, i32 %b) {
    entry:
      %result = add i32 %a, %b
      ret i32 %result
    }
    

    文本形式的设计借鉴了汇编语言的可读性,但加入了更多的结构。每个指令占一行,使用 % 前缀表示局部值,@ 前缀表示全局值。这种设计使得 LLVM IR 可以用普通文本编辑器查看和编辑,极大地方便了调试。有趣的是,LLVM 的测试套件大量使用文本形式的 IR,通过 FileCheck 工具进行模式匹配测试。

  2. 二进制形式(.bc 文件):紧凑的位码(bitcode)表示,用于存储和传输

Bitcode 使用了精心设计的编码方案,采用可变长度整数(VBR - Variable Bit Rate)编码和缩写(abbreviation)机制来压缩常见模式。一个典型的 bitcode 文件比等价的文本形式小 4-5 倍。Bitcode 的设计目标包括:

Bitcode 格式的稳定性使得 Apple 可以要求 iOS 应用提交 bitcode,以便在新硬件发布时重新优化应用。

  1. 内存形式:C++ 对象的层次结构,用于程序化操作和优化

内存形式是 LLVM 优化器真正操作的表示。核心类包括:

内存表示采用了高效的数据结构:

这三种形式可以无损地相互转换,这是 LLVM 工具链灵活性的关键。转换工具包括:

这种设计的优势在实践中不断显现。例如,可以用脚本生成文本形式的 IR 进行实验;可以将 bitcode 嵌入到二进制文件中实现 LTO;可以通过 C++ API 直接构造和操作 IR。这种灵活性是 LLVM 被广泛采用的重要原因。

2.2 SSA(静态单赋值)形式的选择与实现

2.2.1 SSA 的核心概念

静态单赋值形式是现代编译器优化的基石。在 SSA 形式中,每个变量只能被赋值一次。这看似简单的约束带来了深远的影响:

传统形式

x = 1
y = x + 2
x = 3
z = x + y

SSA 形式

%x1 = 1
%y1 = add %x1, 2
%x2 = 3
%z1 = add %x2, %y1

2.2.2 Phi 节点:处理控制流汇聚

SSA 形式的关键挑战是处理控制流汇聚点。LLVM 使用 phi 节点来解决这个问题:

entry:
  br i1 %cond, label %then, label %else

then:
  %x.then = add i32 %a, 1
  br label %merge

else:
  %x.else = add i32 %a, 2
  br label %merge

merge:
  %x = phi i32 [ %x.then, %then ], [ %x.else, %else ]
  ; %x 的值取决于控制流来自哪个前驱块

Phi 节点的语义是:根据控制流的来源选择相应的值。这个看似简单的机制支撑了整个 SSA 体系。

2.2.3 SSA 构造算法

LLVM 实现了经典的 Cytron 等人提出的 SSA 构造算法,主要步骤包括:

  1. 计算支配边界(Dominance Frontier): \(DF(X) = \{Y | X \text{ 支配 } Y \text{ 的某个前驱但不严格支配 } Y\}\)

  2. 放置 Phi 节点:在变量定义点的支配边界迭代放置 phi 节点

  3. 变量重命名:通过深度优先遍历支配树进行变量重命名

2.2.4 SSA 形式的优化优势

SSA 形式极大地简化了许多优化算法:

  1. 常量传播:由于每个变量只有一个定义,追踪常量值变得简单直接

  2. 死代码消除:没有使用的定义可以直接删除,无需担心影响后续的同名变量

  3. 全局值编号(GVN):相同的计算可以通过比较 SSA 值轻松识别

  4. 寄存器分配:SSA 形式天然提供了活跃变量分析所需的 def-use 链

2.3 类型系统演化:从简单到复杂,再到 opaque pointers

2.3.1 早期的强类型系统(2000-2015)

LLVM 最初采用了一个相当丰富的类型系统,这在当时的低级 IR 中是独特的:

基础类型

i1          ; 布尔类型
i8, i16, i32, i64  ; 整数类型
float, double      ; 浮点类型
void              ; 空类型

派生类型

i32*              ; 指向 i32 的指针
[10 x i32]        ; 10 个 i32 的数组
{i32, float}      ; 结构体
<4 x float>       ; SIMD 向量类型
i32 (i32, i32)*   ; 函数指针类型

这个类型系统的设计目标是:

2.3.2 类型系统的复杂性增长

随着 LLVM 支持越来越多的语言和特性,类型系统变得越来越复杂:

  1. 结构体布局:需要处理对齐、填充等 ABI 细节
    %struct.packed = type <{ i8, i32 }>  ; 紧凑布局
    %struct.normal = type { i8, i32 }     ; 自然对齐
    
  2. 不完整类型:支持递归数据结构
    %struct.Node = type { i32, %struct.Node* }
    
  3. 地址空间:支持 GPU 等异构架构
    @global_var = addrspace(1) global i32 0  ; GPU 全局内存
    @shared_var = addrspace(3) global i32 0  ; GPU 共享内存
    

2.3.3 类型系统的问题浮现

到 2015 年左右,复杂的类型系统开始显示出其弊端:

  1. 前端负担:C++ 前端需要生成复杂的类型转换,即使在语义上是无关的
    ; 同样的内存操作,不同的指针类型需要 bitcast
    %p1 = bitcast i32* %ptr to i8*
    %val = load i8, i8* %p1
    
  2. 优化阻碍:类型的存在有时反而阻碍优化
    ; 不同类型的指针被视为不同,即使指向同一内存
    %a = load i32, i32* %ptr1
    %ptr2 = bitcast i32* %ptr1 to float*  
    store float 1.0, float* %ptr2
    ; 优化器难以识别这是对同一位置的存储
    
  3. 维护成本:每个优化 pass 都需要处理类型转换,增加了复杂性

2.3.4 Opaque Pointers 的提出

2015 年,社区开始讨论移除指针类型信息的可能性。核心观察是:

2.3.5 迁移过程(2016-2023)

Opaque pointers 的迁移是 LLVM 历史上最大的技术债务清理项目之一:

阶段 1(2016-2019):准备工作

阶段 2(2019-2021):逐步迁移

; 旧语法
%val = load i32, i32* %ptr

; 新语法
%val = load i32, ptr %ptr

阶段 3(2021-2023):完成迁移

2.3.6 Opaque Pointers 的影响

这次迁移带来了显著的好处:

  1. 简化的 IR:不再需要频繁的 bitcast ```llvm ; 之前 %p1 = bitcast i32* %ptr to i8* %val = load i8, i8* %p1

; 现在 %val = load i8, ptr %ptr


2. **更好的优化机会**:优化器可以更容易地识别等价操作

3. **降低前端复杂性**:前端不再需要跟踪精确的指针类型

4. **更小的 IR 大小**:移除了大量的 bitcast 指令

## 2.4 内存模型和指针算术的设计考量

### 2.4.1 LLVM 的内存模型基础

LLVM 采用了一个相对简单但功能强大的内存模型:

1. **平坦地址空间**:默认情况下,所有指针都指向同一个地址空间
2. **字节可寻址**:最小的可寻址单位是字节
3. **明确的 load/store**:所有内存访问必须通过显式的 load/store 指令

### 2.4.2 GetElementPtr(GEP)指令:LLVM 的独特设计

GEP 是 LLVM IR 中最具特色也最容易被误解的指令之一。它执行地址计算但不访问内存:

```llvm
; C 代码: &array[i].field
; 假设 struct S { int x; float y; };
; S array[10];

%struct.S = type { i32, float }
%arrayptr = alloca [10 x %struct.S]

; 计算 &array[i].y 的地址
%ptr = getelementptr [10 x %struct.S], ptr %arrayptr, i32 0, i32 %i, i32 1
;                    ~~~~~~~~~~~~~~~~       ~~~~~~~~~  ~~~~~~  ~~~~~~  ~~~~~~
;                    基础类型                基础指针    数组    索引i   字段y

GEP 的设计优势:

  1. 类型安全的指针算术:编译器知道每一步的偏移量
  2. 优化友好:地址计算与内存访问分离,便于优化
  3. 别名分析:GEP 保留了结构信息,有助于精确的别名分析

2.4.3 内存对齐和数据布局

LLVM 使用数据布局字符串来描述目标的内存特性:

target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"

解析这个字符串:

2.4.4 原子操作和内存序

LLVM 支持 C++11 风格的原子操作和内存序:

; 原子加载
%val = load atomic i32, ptr %ptr acquire, align 4

; 原子存储
store atomic i32 %val, ptr %ptr release, align 4

; 原子 RMW(Read-Modify-Write)操作
%old = atomicrmw add ptr %ptr, i32 1 seq_cst

; 比较交换
%pair = cmpxchg ptr %ptr, i32 %expected, i32 %new seq_cst seq_cst
%val = extractvalue { i32, i1 } %pair, 0
%success = extractvalue { i32, i1 } %pair, 1

支持的内存序:

2.4.5 别名分析和 TBAA

LLVM 提供了多层次的别名分析:

  1. 基础别名分析:基于地址计算的保守分析

  2. TBAA(Type-Based Alias Analysis):基于类型的别名分析 ```llvm !0 = !{!”Simple C/C++ TBAA”} !1 = !{!”int”, !0} !2 = !{!”float”, !0}

; int 和 float 指针不会别名 %x = load i32, ptr %p1, !tbaa !1 store float 1.0, ptr %p2, !tbaa !2


3. **作用域别名分析**:用于标记不相交的内存区域

### 2.4.6 地址空间:支持异构计算

LLVM 通过地址空间支持 GPU 等异构架构:

```llvm
; AMDGPU 的地址空间约定
; 0 - Generic (flat)
; 1 - Global 
; 3 - Local (shared)
; 4 - Constant
; 5 - Private (stack)

@global_array = addrspace(1) global [256 x float] zeroinitializer
@shared_mem = addrspace(3) global [64 x i32] undef

define void @kernel(ptr addrspace(1) %out, ptr addrspace(1) %in) {
  ; 从全局内存加载
  %val = load float, ptr addrspace(1) %in
  
  ; 存储到共享内存
  %shared_ptr = addrspacecast ptr addrspace(3) @shared_mem to ptr
  store float %val, ptr %shared_ptr
  
  ret void
}

2.5 与其他 IR 的比较:GCC GIMPLE、Java bytecode、.NET CIL

2.5.1 GCC GIMPLE:渐进式降级的哲学

GCC 的 GIMPLE(后来演化为 GIMPLE/RTL 体系)代表了与 LLVM IR 不同的设计哲学。GIMPLE 采用了多层次的 IR 策略,这反映了 GCC 作为成熟编译器的历史包袱和渐进改进的路径。

GIMPLE 的三个层次

  1. High GIMPLE:保留了大量的高级语言特性
    • 仍然包含复杂的控制流结构(如 for 循环)
    • 保留异常处理的高级语义
    • 类型系统接近源语言
  2. Low GIMPLE:类似于 LLVM IR 的抽象层次
    • 控制流降级为基本块和跳转
    • 内存访问显式化
    • 但仍保留一些高级类型信息
  3. RTL(Register Transfer Language):接近机器的表示
    • 包含目标相关的信息
    • 寄存器已分配(或部分分配)
    • 指令调度的基础

GIMPLE vs LLVM IR 的关键差异

// 源代码
for (int i = 0; i < n; i++) {
    sum += array[i];
}

在 High GIMPLE 中,这个循环结构被保留:

<for_stmt>
  <init> i = 0
  <cond> i < n  
  <incr> i = i + 1
  <body>
    D.1234 = array[i]
    sum = sum + D.1234

而在 LLVM IR 中,从一开始就是低级的 CFG:

entry:
  br label %for.cond

for.cond:
  %i = phi i32 [ 0, %entry ], [ %inc, %for.body ]
  %cmp = icmp slt i32 %i, %n
  br i1 %cmp, label %for.body, label %for.end

for.body:
  %arrayidx = getelementptr i32, ptr %array, i32 %i
  %val = load i32, ptr %arrayidx
  %sum.old = load i32, ptr %sum
  %sum.new = add i32 %sum.old, %val
  store i32 %sum.new, ptr %sum
  %inc = add i32 %i, 1
  br label %for.cond

for.end:
  ret void

这种差异带来了不同的优化机会和挑战:

GCC 选择多层次 IR 的历史原因值得深思。GCC 始于 1987 年,当时编译器理论还在发展中。RTL 是最早的 IR,后来发现需要更高级的表示来进行优化,于是逐步添加了 GIMPLE。这种演化路径导致了:

  1. 维护成本高:需要在多个 IR 层次间转换,每次转换都可能丢失信息
  2. 优化分散:不同的优化在不同层次进行,难以协同
  3. 但也有灵活性:可以在最合适的抽象层次进行特定优化

2.5.2 Java Bytecode:面向虚拟机的设计

Java bytecode 代表了完全不同的设计目标——它不是为了优化,而是为了可移植性和安全性设计的。

栈式 vs 寄存器式架构

Java bytecode 采用栈式架构,而 LLVM IR 采用寄存器式(SSA)架构:

// Java 源码
int add(int a, int b) {
    return a + b;
}

Java bytecode(栈式):

iload_0    // 将局部变量 0(参数 a)压栈
iload_1    // 将局部变量 1(参数 b)压栈
iadd       // 弹出两个值,相加,结果压栈
ireturn    // 返回栈顶值

LLVM IR(寄存器式):

define i32 @add(i32 %a, i32 %b) {
  %result = add i32 %a, %b
  ret i32 %result
}

设计权衡分析

  1. 代码密度:栈式指令通常更紧凑,因为不需要指定操作数
    • Java bytecode 的 iadd 只有 1 字节
    • 但 LLVM 的 add 指令需要编码结果和两个操作数
  2. 优化难度:栈式架构使优化变得困难
    • 数据依赖隐含在栈操作中
    • 需要额外的分析来重建数据流图
    • JVM 的 JIT 编译器(如 HotSpot)第一步就是将字节码转换为 SSA 形式
  3. 验证简单性:栈式架构更容易验证
    • 类型检查可以通过简单的栈模拟完成
    • 这对 Java 的安全模型至关重要
  4. 解释执行效率:栈式架构天然适合解释执行
    • 不需要寄存器分配
    • 解释器实现简单

Java bytecode 的类型系统也值得对比:

// Java bytecode 保留了完整的面向对象类型信息
invokevirtual #2  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

// LLVM IR 中虚函数调用被降级为间接调用
%vtable = load ptr, ptr %obj
%method_ptr = getelementptr ptr, ptr %vtable, i32 2
%method = load ptr, ptr %method_ptr
call void %method(ptr %obj, ptr %string)

这种差异反映了设计目标的不同:

2.5.3 .NET CIL:泛型和元数据的集成

.NET 的公共中间语言(CIL,也称 MSIL)提供了另一种有趣的设计点。它在某些方面比 Java bytecode 更高级,在另一些方面又更接近 LLVM IR。

CIL 的独特特性

  1. 真泛型支持:不同于 Java 的类型擦除
    // CIL 保留泛型信息
    .method public static !!T Max<T>(!!T a, !!T b) 
     where T : IComparable<T>
    {
     ldarg.0
     ldarg.1
     callvirt instance int32 IComparable`1<!!T>::CompareTo(!!T)
     ldc.i4.0
     ble.s LESS
     ldarg.0
     ret
    LESS:
     ldarg.1
     ret
    }
    

LLVM IR 则需要为每个具体类型生成专门的函数(单态化):

define i32 @Max_i32(i32 %a, i32 %b) { ... }
define float @Max_float(float %a, float %b) { ... }
  1. 丰富的元数据系统:CIL 包含完整的类型信息、属性、注解等
    .custom instance void [mscorlib]System.ObsoleteAttribute::.ctor(string) 
     = ( "This method is deprecated" )
    

LLVM 的元数据系统相对简单,主要用于优化提示:

!0 = !{!"branch_weights", i32 100, i32 1}  ; 分支概率提示
  1. 值类型 vs 引用类型的显式区分
    .locals init (
     [0] int32 value_type,        // 值类型,在栈上
     [1] class String ref_type    // 引用类型,在堆上
    )
    

LLVM IR 不区分值类型和引用类型,一切都是通过指针和 alloca/malloc 来管理。

评估类型指令集设计

CIL 的指令集设计介于 Java bytecode 和 LLVM IR 之间:

// CIL:部分类型化的指令
ldloc.0      // 加载局部变量 0(类型由元数据确定)
ldc.i4.s 10  // 加载 int32 常量 10
add          // 通用加法(类型由栈确定)

这种设计的优劣:

2.5.4 设计哲学的对比总结

通过对比这些 IR,我们可以看到不同的设计权衡:

特性 LLVM IR GCC GIMPLE Java Bytecode .NET CIL
主要目标 优化和代码生成 渐进式编译 可移植性和安全性 托管执行环境
架构 寄存器式(SSA) 混合(树+CFG) 栈式 栈式
类型系统 简单(现在更简单) 分层 面向对象 泛型+面向对象
抽象层次 中低级 多层次 高级 中高级
优化友好度 极高 中等 中等
验证难度 困难 中等 简单 简单
元数据 最小化 中等 丰富 极其丰富

这些设计选择的深层原因:

  1. LLVM IR:Chris Lattner 的目标是创建一个”编译器的编译器”——一个可重用的优化框架。因此选择了最有利于优化的表示形式。

  2. GCC GIMPLE:作为改造既有系统的产物,必须在保持兼容性的同时逐步改进。多层次设计是历史和现实的妥协。

  3. Java Bytecode:Sun 的目标是”一次编写,到处运行”。安全性和可移植性优先于性能,栈式架构简化了验证器的实现。

  4. .NET CIL:微软试图支持多语言(C#、VB.NET、F#等),需要更丰富的类型系统。同时作为后来者,吸收了 Java 的经验教训(如真泛型)。

这些不同的设计选择没有绝对的优劣,而是针对不同目标的合理权衡。LLVM IR 的成功在于它准确把握了自己的定位——作为优化和代码生成的中间表示,而不试图解决所有问题。