中间表示(IR)优化:LLVM IR基础、IR优化Pass、自定义Pass开发

聊到编译器优化,中间表示(IR)是个绕不开的话题。我个人觉得,IR 就像是编译器的“通用语言”——前端把源代码翻译成 IR,后端再从 IR 生成机器码。而 LLVM IR,可以说是目前最流行的中间表示之一。今天我们就来聊聊它的基础、优化 Pass,以及如何自己动手写一个 Pass。

LLVM IR 基础:三把钥匙

LLVM IR 有三种形式:内存中的数据结构、可读的文本格式(.ll 文件),以及二进制位码(.bc 文件)。我们平时调试或学习时,主要接触的是文本格式。它有几个核心概念,我习惯称之为“三把钥匙”。

1. 静态单赋值(SSA)形式

LLVM IR 强制使用 SSA 形式。什么意思呢?就是每个变量只能被赋值一次。这听起来有点反直觉——你想想看,我们写代码时经常给同一个变量赋不同的值。但在 SSA 形式下,编译器通过引入 phi 指令来解决这个问题。

举个例子:

; 原始 C 代码
int x = 1;
if (cond) x = 2;
return x;

; 对应的 LLVM IR
entry:
  br i1 %cond, label %then, label %else

then:
  br label %merge

else:
  br label %merge

merge:
  %x = phi i32 [ 2, %then ], [ 1, %else ]
  ret i32 %x

这里的 phi 指令会根据控制流选择正确的值。嗯,刚开始看可能有点绕,但习惯了就会发现它让数据流分析变得异常简单。

2. 类型系统

LLVM IR 是强类型的。每个值都有明确的类型,比如 i32 表示 32 位整数,float 表示单精度浮点数。还有指针类型 ptr(LLVM 14 之后统一用 ptr 代替了 i32* 这种写法)。

我在项目中遇到过一个问题:有人把 i32 和 i32* 搞混了,结果在内存访问时出了奇怪的 bug。说白了,类型就是 IR 的“安全带”,系好了能避免很多低级错误。

3. 指令集

LLVM IR 的指令集是 RISC 风格的,操作都很基础。比如加法 add、加载 load、存储 store、分支 br 等。每个指令都有明确的语义,没有隐式的副作用。

指令类别 示例 说明
算术运算 %r = add i32 %a, %b 整数加法,结果无溢出检查
内存访问 %v = load i32, ptr %p 从指针指向的地址加载 32 位整数
控制流 br i1 %c, label %t, label %f 条件分支,根据 %c 跳转
类型转换 %r = zext i8 %a to i32 零扩展,将 8 位整数扩展到 32 位

IR 优化 Pass:编译器的“魔法”

优化 Pass 是 LLVM 的核心。它们像流水线上的工人,一个个对 IR 进行变换,让代码跑得更快、体积更小。我按功能把它们分成几类。

标量优化 Pass

这类 Pass 主要针对单个基本块或函数内的操作。比如:

  • 死代码消除(DCE):删掉那些计算结果从未被使用的指令。我曾经见过一个项目,因为历史遗留问题,有 30% 的代码是死代码,跑一遍 DCE 后性能提升了 15%。
  • 常量传播:如果某个变量的值在编译期就能确定,直接替换成常量。比如 int x = 2 * 3; 会被优化成 int x = 6;。
  • 指令合并:把多个简单指令合并成一个。比如 a = b + 1; c = a + 1; 可以合并成 c = b + 2;。

循环优化 Pass

循环是程序的热点,也是优化的重点。常用的循环 Pass 有:

  • 循环不变代码外提(LICM):把循环内每次迭代都不变的计算提到循环外面。比如循环内的 int len = strlen(s); 应该只计算一次。
  • 循环展开:减少循环控制开销,增加指令级并行度。但要注意,展开太狠会导致代码膨胀,反而影响指令缓存。
  • 循环向量化:把标量操作转换成 SIMD 指令。我建议在开启向量化前,先确认目标 CPU 是否支持相应的指令集。

小技巧: 使用 opt -O2 -debug-pass=Arguments 可以查看默认启用了哪些 Pass。我个人习惯先跑一遍 -O2,再根据热点手动调整 Pass 顺序。

内联与函数优化

内联(Inlining)是把函数调用替换成函数体本身。这能消除调用开销,但也可能增加代码体积。LLVM 的内联 Pass 会基于启发式算法做决策——比如函数太小就内联,函数太大就跳过。

我曾经在嵌入式项目中遇到过一个问题:内联了一个看似很小的函数,结果因为该函数被调用了上千次,最终二进制体积暴涨了 40%。嗯,这里要注意,内联不是万能的,需要根据场景权衡。

自定义 Pass 开发:从零开始

有时候,LLVM 自带的 Pass 满足不了我们的需求。比如你想实现一个针对特定硬件的优化,或者想插入一些调试信息。这时候就需要自己写 Pass 了。

Pass 的两种类型

LLVM 支持两种 Pass:

  • FunctionPass:每次处理一个函数。适合大多数标量优化。
  • ModulePass:处理整个编译单元(一个 .ll 文件)。适合全局分析,比如查找所有函数中的某个模式。

写一个简单的 FunctionPass

假设我们要写一个 Pass,用来统计每个函数中 add 指令的数量。代码大概长这样:

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/Instructions.h"
#include "llvm/Support/raw_ostream.h"

using namespace llvm;

namespace {
  struct CountAddPass : public FunctionPass {
    static char ID;
    CountAddPass() : FunctionPass(ID) {}

    bool runOnFunction(Function &F) override {
      int addCount = 0;
      for (BasicBlock &BB : F) {
        for (Instruction &I : BB) {
          if (isa<BinaryOperator>(&I) && 
              cast<BinaryOperator>(&I)->getOpcode() == Instruction::Add) {
            addCount++;
          }
        }
      }
      errs() << "Function: " << F.getName() << " has " 
             << addCount << " add instructions\n";
      return false; // 我们没有修改 IR,所以返回 false
    }
  };
}

char CountAddPass::ID = 0;
static RegisterPass<CountAddPass> X("count-add", "Count Add Instructions Pass");

这段代码做了几件事:

  1. 继承 FunctionPass 并实现 runOnFunction 方法。
  2. 遍历函数内的所有基本块和指令,统计 add 指令的数量。
  3. 通过 RegisterPass 宏注册 Pass,并给它一个命令行名称 count-add。

避坑指南: 我曾经在写 Pass 时忘记在 runOnFunction 末尾返回 false,结果 LLVM 以为我修改了 IR,导致后续 Pass 做了很多不必要的重新计算。记住:如果你没有修改 IR,一定要返回 false。

编译与运行自定义 Pass

编译这个 Pass 需要链接 LLVM 库。假设你的 LLVM 安装在 /usr/local/llvm,可以这样编译:

clang++ -shared -fPIC -o CountAddPass.so CountAddPass.cpp \
  `llvm-config --cxxflags --ldflags --libs`

然后通过 opt 工具加载它:

opt -load ./CountAddPass.so -count-add -disable-output input.ll

这里的 -disable-output 表示不输出优化后的 IR,因为我们只是做统计。

知识体系总览

下面这张图展示了本章的核心逻辑:从 LLVM IR 基础出发,经过各类优化 Pass,最终通过自定义 Pass 实现特定需求。

LLVM IR 优化知识体系 LLVM IR 基础 SSA · 类型系统 · 指令集 IR 优化 Pass 标量 · 循环 · 内联 自定义 Pass FunctionPass · ModulePass 应用场景 • 性能调优:通过 Pass 消除冗余计算 • 代码体积优化:死代码消除、函数内联决策 • 硬件适配:自定义 Pass 生成特定指令序列

这张图把 IR 基础、优化 Pass 和自定义 Pass 串联了起来。说白了,基础是地基,Pass 是工具,自定义 Pass 就是你自己造的专属工具。三者缺一不可。

个人建议: 刚开始学自定义 Pass 时,别急着写复杂的逻辑。先写一个只打印函数名的 Pass,跑通了再逐步增加功能。这样能快速验证你的开发环境是否配置正确。

好了,关于 LLVM IR 优化就聊这么多。记住,优化不是一蹴而就的,需要结合具体场景反复试验。你想想看,同样的 Pass 在不同硬件上效果可能天差地别——这就是为什么我们需要深入理解 IR,而不是盲目套用优化选项。

想要更多资料,联系可可