llvm_history

第11章:LLVM 在 JIT 编译中的应用

本章深入探讨 LLVM 在即时编译(JIT)领域的应用与演进。我们将追溯从早期的传统 JIT 到现代 ORC JIT 架构的发展历程,分析 LLVM 如何成为构建高性能 JIT 编译器的首选基础设施。通过研究 Julia、PostgreSQL 等项目的集成经验,理解 JIT 编译在现代软件栈中的关键作用。

11.1 JIT 编译的基本概念与 LLVM 的优势

11.1.1 静态编译与 JIT 编译的权衡

即时编译(Just-In-Time Compilation)代表了一种介于解释执行和静态编译之间的执行策略。与传统的提前编译(Ahead-of-Time, AOT)不同,JIT 编译器在程序运行时将中间表示或源代码编译为机器码。

在 LLVM 出现之前,构建 JIT 编译器需要从零开始实现整个编译管线。Java HotSpot、.NET CLR 等系统都包含了自己的 JIT 实现,这些实现往往与特定语言或运行时紧密耦合。LLVM 的模块化设计改变了这一局面:

传统 JIT 架构:
源代码 → 字节码 → [专用 JIT 编译器] → 机器码

LLVM JIT 架构:
源代码 → LLVM IR → [LLVM JIT 基础设施] → 机器码
                        ↑
                   通用优化管线
                   多后端支持
                   调试信息生成

11.1.2 LLVM JIT 的核心优势

LLVM 为 JIT 编译提供了独特的价值主张:

  1. 成熟的优化管线复用:JIT 编译器可以直接使用 LLVM 的数百个优化 Pass,无需重新实现。这包括内联、循环优化、向量化等关键优化。

  2. 多目标架构支持:一次实现,多平台运行。LLVM 的后端支持让 JIT 编译器自动获得对 x86、ARM、RISC-V 等架构的支持。

  3. 增量式编译能力:LLVM 的模块化设计支持函数级别的编译和链接,非常适合 JIT 场景的按需编译。

  4. Profile-Guided Optimization (PGO):运行时收集的性能数据可以直接反馈给 LLVM 优化器,实现自适应优化。

11.1.3 LLVM JIT 的历史演进

LLVM 的 JIT 支持经历了三个主要阶段:

第一代:Legacy JIT(2003-2009)

第二代:MCJIT(2010-2016)

第三代:ORC JIT(2015-至今)

11.2 从 MCJIT 到 ORC JIT:架构演进

11.2.1 Legacy JIT 的局限性

早期的 LLVM JIT 实现虽然开创性,但存在严重的架构限制:

  1. 单一地址空间假设:Legacy JIT 假设编译和执行在同一进程中,无法支持跨进程或远程 JIT。

  2. 不完整的运行时支持:缺乏对异常处理、线程本地存储(TLS)、调试信息的完整支持。

  3. 内存管理问题:代码和数据的内存布局不灵活,难以实现代码缓存和垃圾回收。

  4. 符号解析限制:符号查找机制过于简单,不支持复杂的链接语义。

11.2.2 MCJIT 的改进与不足

MCJIT(Machine Code JIT)在 2010 年被引入,旨在解决 Legacy JIT 的问题:

// MCJIT 的基本使用模式
std::unique_ptr<Module> M = parseModule(IRString);
ExecutionEngine *EE = EngineBuilder(std::move(M))
    .setEngineKind(EngineKind::JIT)
    .setUseMCJIT(true)
    .create();
    
// 编译整个模块
EE->finalizeObject();

// 查找并执行函数
auto MainFn = EE->getFunctionAddress("main");
typedef int (*MainFnTy)();
int Result = reinterpret_cast<MainFnTy>(MainFn)();

MCJIT 的主要改进:

然而,MCJIT 仍有重要限制:

11.2.3 ORC JIT 的革命性设计

ORC(On-Request Compilation)JIT 是 Lang Hames 在 2015 年开始设计的全新架构,从根本上重新思考了 JIT 编译器的设计:

核心概念:JITDylib 和符号解析

ORC 引入了 JITDylib(JIT Dynamic Library)概念,模拟动态链接库的行为:

// ORC JIT 的模块化设计
class JITDylib {
  // 符号表:名称 → 地址映射
  SymbolTable Symbols;
  
  // 材料化单元:延迟编译的基本单位
  std::vector<std::unique_ptr<MaterializationUnit>> MUs;
  
  // 依赖关系追踪
  DependencyGraph Dependencies;
};

分层架构

ORC 采用分层设计,每层负责特定功能:

应用层
  ↓
LLJIT(高级 API)
  ↓
ExecutionSession(会话管理)
  ↓
IRCompileLayer(IR 编译)
  ↓
IRTransformLayer(IR 转换)
  ↓
ObjectLayer(对象文件处理)
  ↓
RTDyldObjectLinkingLayer(链接)
  ↓
机器码执行

延迟编译的实现

ORC 的延迟编译通过 MaterializationUnit 实现:

class MaterializationUnit {
public:
  // 声明此单元提供的符号
  virtual SymbolFlagsMap getSymbols() = 0;
  
  // 当符号被请求时触发编译
  virtual void materialize(MaterializationResponsibility R) = 0;
  
  // 支持丢弃未使用的定义
  virtual void discard(const JITDylib &JD, SymbolStringPtr Name) {}
};

11.2.4 ORC JIT 的高级特性

并发编译

ORC 原生支持多线程编译,通过 ThreadSafeModule 确保线程安全:

ThreadSafeModule TSM(std::move(M), std::make_unique<LLVMContext>());

// 可以在多个线程中并发编译不同模块
CompileThreadPool.async([TSM = std::move(TSM)]() mutable {
  return compileModule(std::move(TSM));
});

远程 JIT

ORC 支持跨进程和跨机器的 JIT 编译:

主机进程                     目标进程
   ↓                           ↑
编译 LLVM IR              执行机器码
   ↓                           ↑
通过 RPC 发送对象文件  →  加载并链接

Speculative Compilation

ORC 支持投机编译,预测性地编译可能执行的代码:

class SpeculativeJIT {
  // 基于运行时profile预测热点函数
  void speculativelyCompile(Function *F) {
    if (isProbablyHot(F)) {
      BackgroundQueue.push(F);
      CompileThread.notify();
    }
  }
};

11.3 延迟编译和分层编译策略

11.3.1 延迟编译的必要性

JIT 编译的启动开销是其主要挑战之一。编译所有代码会导致严重的启动延迟,而延迟编译(Lazy Compilation)通过按需编译来缓解这个问题。

延迟编译的核心思想:

  1. 初始阶段:只编译入口函数
  2. 桩函数注入:未编译函数被替换为编译触发桩
  3. 按需材料化:首次调用时触发实际编译
  4. 增量链接:新编译的代码动态链接到运行中的程序

11.3.2 ORC 的延迟编译实现

ORC 通过 LazyCallThroughManager 实现延迟编译:

// 延迟编译的工作流程
class LazyCallThroughManager {
  // 1. 为每个延迟函数创建桩
  JITTargetAddress createStub(Symbol S) {
    // 分配可执行内存
    auto StubAddr = allocateStub();
    
    // 生成跳转到 resolver 的指令
    emitResolverCall(StubAddr, S);
    
    return StubAddr;
  }
  
  // 2. 解析时触发编译
  JITTargetAddress resolve(Symbol S) {
    // 查找符号对应的材料化单元
    auto MU = findMaterializationUnit(S);
    
    // 触发编译
    MU->materialize();
    
    // 返回编译后的地址
    return getCompiledAddress(S);
  }
};

桩函数的汇编实现(x86-64):

lazy_stub_for_foo:
    ; 保存寄存器状态
    push %rax
    push %rcx
    push %rdx
    push %rsi
    push %rdi
    push %r8
    push %r9
    
    ; 调用解析器
    movq $foo_symbol_id, %rdi
    call lazy_resolver
    
    ; 恢复寄存器
    pop %r9
    pop %r8
    pop %rdi
    pop %rsi
    pop %rdx
    pop %rcx
    
    ; 跳转到真实函数
    ; rax 包含编译后的地址
    pop %r10  ; 临时保存原 rax
    jmp *%rax

11.3.3 分层编译策略

分层编译(Tiered Compilation)是现代 JIT 编译器的核心策略,通过多个编译层次平衡性能和延迟:

第0层:解释器或基线编译器
  ↓ (执行计数 > 阈值1)
第1层:快速编译,-O0 级别
  ↓ (执行计数 > 阈值2)
第2层:优化编译,-O2 级别
  ↓ (成为热点)
第3层:激进优化,-O3 + PGO

11.3.4 LLVM 中的分层编译实现

虽然 LLVM 本身不强制分层策略,但 ORC JIT 提供了构建分层系统的所有组件:

class TieredJIT {
  // 不同优化级别的编译层
  IRCompileLayer BaselineLayer;   // -O0
  IRCompileLayer OptimizedLayer;  // -O2
  IRCompileLayer AggressiveLayer; // -O3
  
  // 执行计数器
  std::map<StringRef, uint64_t> ExecutionCounts;
  
  void handleFunctionCall(StringRef Name) {
    ExecutionCounts[Name]++;
    
    if (ExecutionCounts[Name] == TIER1_THRESHOLD) {
      promoteToTier1(Name);
    } else if (ExecutionCounts[Name] == TIER2_THRESHOLD) {
      promoteToTier2(Name);
    }
  }
  
  void promoteToTier2(StringRef Name) {
    // 使用更高级的优化重新编译
    auto M = cloneModule(Name);
    
    // 应用 O2 级别优化
    PassManagerBuilder PMB;
    PMB.OptLevel = 2;
    PMB.populateModulePassManager(MPM);
    MPM.run(*M);
    
    // 替换函数实现
    OptimizedLayer.add(std::move(M));
  }
};

11.3.5 Profile-Guided JIT 优化

JIT 编译的独特优势是可以利用运行时信息进行优化:

class ProfileGuidedJIT {
  // 收集的运行时信息
  struct ProfileData {
    std::map<BasicBlock*, uint64_t> BlockFrequencies;
    std::map<CallInst*, Function*> CallTargets;
    std::map<BranchInst*, bool> BranchDirections;
  };
  
  void optimizeWithProfile(Module &M, const ProfileData &PD) {
    // 1. 基于块频率的内联决策
    for (auto &F : M) {
      for (auto &BB : F) {
        if (PD.BlockFrequencies[&BB] > HOT_THRESHOLD) {
          // 激进内联热点路径上的调用
          aggressiveInline(BB);
        }
      }
    }
    
    // 2. 虚函数去虚化
    for (auto &[CI, Target] : PD.CallTargets) {
      if (isIndirectCall(CI) && isMonomorphic(CI, Target)) {
        // 将间接调用转换为直接调用
        devirtualize(CI, Target);
      }
    }
    
    // 3. 分支预测优化
    for (auto &[BI, Direction] : PD.BranchDirections) {
      if (getBranchProbability(BI, Direction) > 0.95) {
        // 为高概率分支生成更优代码
        optimizePredictableBranch(BI, Direction);
      }
    }
  }
};

## 11.6 高级话题:Speculative Compilation 与 Profile-Guided JIT 优化

### 11.6.1 投机编译的设计理念

投机编译(Speculative Compilation)是现代 JIT 编译器的前沿技术,通过预测性地编译可能执行的代码路径来隐藏编译延迟。

#### ORC JIT 的投机编译框架

Lang Hames  ORC JIT 中实现了完整的投机编译支持:

```cpp
class SpeculativeJIT {
  // 编译队列
  std::queue<CompileTask> SpeculativeQueue;
  
  // 热度追踪器
  HotnessTracker Tracker;
  
  void recordCall(Function *Caller, Function *Callee) {
    Tracker.recordEdge(Caller, Callee);
    
    // 预测可能的调用目标
    auto Candidates = predictLikelyTargets(Callee);
    
    for (auto *F : Candidates) {
      if (!isCompiled(F) && !isQueued(F)) {
        // 加入投机编译队列
        SpeculativeQueue.push({F, Priority::SPECULATIVE});
      }
    }
  }
  
  // 基于机器学习的预测
  std::vector<Function*> predictLikelyTargets(Function *F) {
    // 使用历史数据和程序结构预测
    auto Features = extractFeatures(F);
    auto Predictions = MLModel.predict(Features);
    return getTopK(Predictions, 3);
  }
};

11.6.2 Profile-Guided JIT 优化的实现

Profile-Guided Optimization (PGO) 在 JIT 环境中具有独特优势:

class ProfileGuidedJIT {
  struct EdgeProfile {
    uint64_t count;
    double probability;
  };
  
  // 动态去优化和重编译
  void recompileWithProfile(Function *F) {
    // 1. 收集运行时数据
    auto Profile = collectRuntimeProfile(F);
    
    // 2. 注入 profile 元数据
    injectProfileMetadata(F, Profile);
    
    // 3. 应用 PGO 优化
    PassManagerBuilder PMB;
    PMB.OptLevel = 3;
    PMB.PGOInstrUse = Profile;
    
    // 4. 重新编译
    auto NewAddr = recompile(F, PMB);
    
    // 5. 原子替换函数指针
    atomicReplace(F, NewAddr);
  }
  
  // 自适应内联决策
  bool shouldInline(CallSite CS, const Profile &P) {
    // 基于实际调用频率的内联决策
    auto CallFreq = P.getCallFrequency(CS);
    auto CalleeSize = CS.getCallee()->size();
    
    // 动态调整内联阈值
    double Threshold = BaseThreshold * (1.0 + log(CallFreq));
    
    return CalleeSize < Threshold;
  }
};

11.6.3 去优化与 On-Stack Replacement

当投机假设失败时,需要去优化(Deoptimization):

class DeoptimizationManager {
  // On-Stack Replacement (OSR) 实现
  void performOSR(ExecutionContext *Ctx, 
                  DeoptReason Reason) {
    // 1. 暂停执行
    Ctx->suspend();
    
    // 2. 提取执行状态
    auto State = extractExecutionState(Ctx);
    
    // 3. 生成去优化版本
    auto *FallbackCode = generateFallback(State);
    
    // 4. 替换栈帧
    replaceStackFrame(Ctx, FallbackCode, State);
    
    // 5. 恢复执行
    Ctx->resume();
  }
  
  // 保护点插入
  void insertSafepoints(Function *F) {
    for (auto &BB : *F) {
      if (isLoopHeader(&BB)) {
        // 在循环头插入保护点
        IRBuilder<> Builder(&BB, BB.begin());
        Builder.CreateCall(SafepointPoll);
      }
    }
  }
};

11.6.4 Azul Falcon JIT 的创新

Azul Systems 的 Falcon JIT(基于 LLVM)引入了多项创新:

  1. ReadyNow! 技术:持久化 profile 数据,加速启动
  2. 连续性编译:持续优化热点代码
  3. 无暂停去优化:使用读屏障实现无停顿 OSR
// Falcon 的分层编译策略
class FalconTieredCompilation {
  enum Tier {
    INTERPRETER = 0,
    BASELINE_JIT = 1,
    OPTIMIZED_JIT = 2,
    FULLY_OPTIMIZED = 3
  };
  
  void promoteTier(Method *M) {
    auto CurrentTier = getTier(M);
    auto NextTier = Tier(CurrentTier + 1);
    
    switch (NextTier) {
    case BASELINE_JIT:
      // 快速编译,最小优化
      compileBaseline(M);
      break;
      
    case OPTIMIZED_JIT:
      // 标准优化 + 内联
      compileOptimized(M);
      break;
      
    case FULLY_OPTIMIZED:
      // 激进优化 + 投机优化
      compileAggressive(M);
      break;
    }
  }
};

11.7 本章小结

本章深入探讨了 LLVM 在 JIT 编译中的应用,主要内容包括:

  1. 架构演进:从 Legacy JIT 到 MCJIT,再到革命性的 ORC JIT,LLVM 的 JIT 架构不断演进以满足日益复杂的需求。

  2. ORC JIT 的创新
    • JITDylib 抽象提供了灵活的符号管理
    • MaterializationUnit 实现了真正的延迟编译
    • 分层架构支持高度定制化
    • 原生支持并发编译和远程 JIT
  3. 编译策略
    • 延迟编译通过桩函数机制按需编译
    • 分层编译平衡启动延迟和峰值性能
    • Profile-Guided 优化利用运行时信息
  4. 实际应用
    • PostgreSQL 使用 LLVM JIT 加速查询执行
    • HyPer 展示了 JIT 编译的极限性能
    • Julia 证明了动态语言可以达到静态语言性能
  5. 关键技术
    • 投机编译预测性地编译热点路径
    • On-Stack Replacement 支持动态去优化
    • 机器学习驱动的编译决策

LLVM JIT 的成功不仅在于技术创新,更在于其模块化设计让各种语言和系统能够轻松集成高性能 JIT 编译能力。

练习题

基础题

练习 11.1:解释 MCJIT 相比 Legacy JIT 的主要改进,以及为什么最终还是需要 ORC JIT?

提示 考虑对象文件格式、编译粒度、API 设计等方面。
答案 MCJIT 的改进:使用标准对象文件格式(ELF/COFF/MachO)、支持完整的重定位、更好的内存管理。但 MCJIT 必须编译整个模块,无法实现真正的延迟编译,API 受 ExecutionEngine 限制。ORC JIT 通过 MaterializationUnit 实现函数级延迟编译,支持并发编译,提供更灵活的分层架构。

练习 11.2:设计一个简单的延迟编译系统,说明桩函数如何触发实际编译。

提示 考虑桩函数的生成、符号解析、编译触发和地址替换。
答案 桩函数包含:1) 保存寄存器状态;2) 调用解析器并传递符号 ID;3) 解析器查找并编译对应函数;4) 恢复寄存器;5) 跳转到编译后的地址。关键是第一次调用触发编译,后续调用直接执行编译后的代码。

练习 11.3:比较 Julia 和传统动态语言(如 Python)的执行模型,解释 Julia 为什么能达到 C 的性能。

提示 考虑类型推断、方法特化、JIT 编译时机。
答案 Julia 通过激进的方法特化为每个类型组合生成专门的机器码,类型推断在编译时确定类型,避免运行时类型检查。Python 使用字节码解释器,即使有 JIT(PyPy)也难以完全消除动态类型开销。Julia 的多重分发和类型系统设计更适合优化。

挑战题

练习 11.4:设计一个 Profile-Guided JIT 系统,如何收集 profile 数据并触发重编译?考虑性能开销和准确性的权衡。

提示 考虑采样 vs 插桩、重编译触发条件、profile 数据的老化。
答案 使用混合策略:初始使用低开销采样,识别热点后使用精确插桩。设置阈值触发重编译(如执行次数超过 10000 或 profile 显著变化)。使用衰减因子处理老化数据,避免过时 profile 影响优化。关键是平衡 profile 收集开销和优化收益。

练习 11.5:分析数据库查询 JIT 编译的适用场景。什么类型的查询最受益于 JIT?什么情况下 JIT 反而有害?

提示 考虑查询复杂度、数据量、编译开销。
答案 JIT 适合:复杂表达式计算、大数据量扫描、重复执行的查询、OLAP 工作负载。JIT 有害:简单查询(编译开销大于收益)、小数据量、一次性查询、频繁变化的查询参数。关键是成本模型要准确预测 JIT 收益。

练习 11.6:设计一个支持投机编译的 JIT 系统,如何预测哪些函数值得提前编译?如何处理预测错误?

提示 考虑调用图分析、历史数据、机器学习预测、资源限制。
答案 使用多级预测:静态调用图分析识别可达函数、历史执行数据预测热点、机器学习模型基于代码特征预测。设置编译预算避免过度投机。预测错误时通过 LRU 淘汰未使用的编译代码,但保留 profile 数据用于未来预测。

练习 11.7:比较 ORC JIT 的远程 JIT 功能与 WebAssembly 的执行模型,两者在跨进程/跨机器执行上有何异同?

提示 考虑安全模型、性能特征、使用场景。
答案 ORC 远程 JIT 通过 RPC 发送编译后的机器码,信任模型较弱,适合受控环境。WebAssembly 发送中间表示,在目标环境编译,有强沙箱,适合不受信环境。ORC 性能更好但安全性较低,WebAssembly 相反。ORC 适合分布式 JIT,WebAssembly 适合浏览器和边缘计算。

练习 11.8:Julia 的”首次运行慢”问题的根本原因是什么?除了预编译,还有哪些可能的解决方案?

提示 考虑编译缓存、分层编译、解释器、AOT 编译。
答案 根本原因是 Julia 的激进特化策略导致大量编译。解决方案:1) 持久化编译缓存跨会话复用;2) 添加快速解释器或基线 JIT 减少初始延迟;3) 静态分析预测常用特化;4) 部分 AOT 编译常用代码路径;5) 延迟特化直到证明有益。关键是平衡性能和编译开销。

常见陷阱与错误

1. JIT 编译触发时机错误

// 错误:过早触发 JIT 编译
void executeQuery(Query q) {
    compileQuery(q);  // 每次都编译
    runQuery(q);
}

// 正确:基于成本模型
void executeQuery(Query q) {
    if (shouldCompile(q)) {
        auto compiled = getCachedOrCompile(q);
        runCompiled(compiled);
    } else {
        interpret(q);
    }
}

2. 内存管理错误

// 错误:不考虑代码内存的特殊性
void* allocateCode(size_t size) {
    return malloc(size);  // 不可执行!
}

// 正确:使用可执行内存
void* allocateCode(size_t size) {
    return mmap(nullptr, size, 
                PROT_READ | PROT_WRITE | PROT_EXEC,
                MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
}

3. 符号解析竞态条件

// 错误:非线程安全的符号解析
Function* resolve(Symbol s) {
    if (auto *F = Cache[s])
        return F;
    
    // 竞态条件!
    auto *F = compile(s);
    Cache[s] = F;
    return F;
}

// 正确:使用并发安全的解析
Function* resolve(Symbol s) {
    std::lock_guard<std::mutex> Lock(CacheMutex);
    if (auto *F = Cache[s])
        return F;
    
    auto *F = compile(s);
    Cache[s] = F;
    return F;
}

4. Profile 数据误用

// 错误:盲目信任 profile 数据
if (profile.getFrequency(BB) > 0) {
    // 可能是噪声或过时数据
    markAsHot(BB);
}

// 正确:考虑统计显著性
if (profile.getFrequency(BB) > threshold &&
    profile.getSampleCount(BB) > minSamples &&
    profile.getAge() < maxAge) {
    markAsHot(BB);
}

5. 编译缓存管理不当

// 错误:无限增长的缓存
CompiledCache[key] = compile(module);

// 正确:有界缓存with LRU
if (CompiledCache.size() >= maxSize) {
    evictLRU();
}
CompiledCache[key] = compile(module);
updateAccessTime(key);

最佳实践检查清单

JIT 系统设计

性能优化

正确性保证

调试支持

部署考虑