3、中间表示(IR)设计:为什么需要IR、常见的IR形式(SSA、CFG)、从AST到IR的转换策略

好,咱们今天聊聊中间表示。也就是IR。

很多刚入行的朋友问我:为什么编译器里非要插一层IR?直接从前端语法树怼到后端指令不行吗?

嗯,我刚开始做Shader编译器的时候也这么想过。直到我亲手写了一个从AST直接生成指令的玩具编译器……那叫一个惨。改一个优化,整个后端都得重写。从那以后,我彻底服了IR的设计哲学。

3.1 为什么需要IR?

说白了,IR就是编译器里的“翻译中间人”。

前端解析HLSL、GLSL、MSL,生成的是语法树。后端要生成的是GPU指令。这两者之间,隔着一道天堑。

你想想看:

  • 前端AST里,一个for循环可能是一棵大树。后端指令里,只有跳转和条件判断。
  • 前端变量名可以叫“myAwesomeColor”。后端寄存器只有r0、r1、r2。
  • 前端支持各种高级语法糖。后端只认加减乘除和访存。

如果没有IR,每个优化都得同时理解前端和后端。那代码量……我见过一个项目,优化器写了8万行,改一个bug要两周。

IR的价值,我总结为三点:

  1. 解耦前后端:前端只负责生成IR,后端只负责消费IR。各管各的。
  2. 统一优化平台:所有优化都在IR上做。一次优化,所有语言、所有后端都受益。
  3. 降低复杂度:IR比AST简单,比指令抽象。它刚好卡在“够用”的位置。

核心观点:没有IR的编译器,就像没有中间层的软件架构——短期爽,长期痛。

3.2 常见的IR形式

IR不是只有一种。不同的场景,需要不同的IR。我个人习惯把IR分成两大类:高层次的和低层次的。

3.2.1 SSA(静态单赋值)

SSA是我最喜欢的IR形式。它的核心规则很简单:每个变量只赋值一次。

举个例子,普通代码:

x = a + b
x = x * c

在SSA里会变成:

x1 = a + b
x2 = x1 * c

看到了吗?x变成了x1和x2。每个版本只出现一次。

为什么这样做?因为优化起来太方便了。

  • 常量传播:如果a和b都是常量,x1直接算出来。不用管后面有没有人改x。
  • 死代码消除:如果x2没人用,直接删掉。不用担心x1还有别的用途。
  • 值编号:如果两个地方算的都是a+b,直接合并成一个。

我在项目中遇到过一个问题:没有SSA的时候,做全局值编号要搞“到达定值分析”,代码又臭又长。换成SSA后,同样的功能,代码量少了三分之二。

小技巧:SSA里的Phi节点(φ节点)是处理控制流合并的关键。比如if-else之后,变量的值来自两个分支,就用Phi来“选”一个。理解Phi,就理解了SSA的一半。

3.2.2 CFG(控制流图)

CFG不是一种IR,而是IR的“骨架”。它描述了代码的执行路径。

每个基本块(Basic Block)是一段顺序执行的代码。基本块之间用有向边连接,表示跳转关系。

为什么需要CFG?

  • 优化需要知道“从哪里来,到哪里去”:比如循环优化,你得知道哪些基本块构成了循环体。
  • 寄存器分配需要生命周期分析:变量在哪些基本块里是活的?这得靠CFG。
  • 死代码消除需要可达性分析:有些基本块永远执行不到?删掉。

我见过最坑的bug,就是CFG构建时漏了一条边。结果优化器以为某个基本块永远不会被执行,直接把它删了。嗯,那个bug查了我三天。

避坑指南:我曾经在构建CFG时,没有正确处理switch-case的fall-through。结果生成的代码在某些GPU上跑出了随机结果。从那以后,我每次构建CFG都会写一个验证器,检查每个基本块的入边和出边是否合理。

3.3 从AST到IR的转换策略

好,现在咱们聊聊最实际的问题:怎么把AST变成IR?

这个过程,我称之为“降级”(Lowering)。说白了,就是把高层次的树结构,变成低层次的图结构。

3.3.1 策略一:递归下降转换

这是最直接的方法。遍历AST的每个节点,递归地生成IR。

比如一个加法表达式:

// AST节点
AddExpr(left: MulExpr(2, 3), right: 4)

// 递归转换
1. 处理 MulExpr(2, 3) -> 生成 IR: t1 = mul 2, 3
2. 处理 4 -> 生成 IR: t2 = const 4
3. 处理 AddExpr -> 生成 IR: t3 = add t1, t2

这种方法简单直观。但有个问题:上下文信息容易丢失。

比如,你在一个循环里遇到了break语句。你得知道当前在哪个循环里,才能生成正确的跳转目标。这就需要维护一个“上下文栈”。

我的习惯:我会在转换器里维护一个“环境”对象,里面放着当前所在的函数、循环、switch等信息。每次进入一个作用域,就push一下;退出时pop。这样break、continue、return都能正确处理。

3.3.2 策略二:两阶段转换

有些编译器会分两步走:

  1. 第一阶段:把AST转换成一种“高层次的IR”。这种IR还保留了一些高级结构,比如循环、数组访问。
  2. 第二阶段:把高层次IR降级成低层次IR(SSA+CFG)。这时候循环展开成跳转,数组访问变成地址计算。

这样做的好处是:

  • 第一阶段可以做一些“高层次优化”,比如循环交换、数组重排。
  • 第二阶段专注于“低层次优化”,比如指令选择、寄存器分配。

我在做GPU Shader编译器时,就采用了这种策略。因为Shader里有很多纹理采样、导数计算等高级操作。如果直接降到SSA,这些信息就丢了,后面想做纹理合并优化就难了。

3.3.3 策略三:基于模式匹配的转换

这种方法更高级一些。你定义一组“转换规则”,每个规则描述了一种AST模式如何映射到IR模式。

比如:

规则1: AddExpr(MulExpr(a, b), c) -> IR: t1 = mul a, b; t2 = add t1, c
规则2: AddExpr(a, MulExpr(b, c)) -> IR: t1 = mul b, c; t2 = add a, t1

然后让一个“模式匹配引擎”自动应用这些规则。

这种方法的好处是:

  • 规则可以独立添加,互不干扰。
  • 可以很容易地支持多种后端(比如不同GPU架构的指令集)。

但缺点也很明显:规则多了之后,匹配顺序和冲突处理会变得很复杂。我曾经维护过一个有300多条规则的转换器,每次加新规则都要跑一遍全量测试,生怕把别的规则搞坏了。

3.4 实战中的注意事项

说了这么多理论,最后分享几个实战中的坑:

问题 表现 解决方案
SSA中Phi节点过多 IR体积膨胀,编译时间变长 做Phi节点最小化,只对必要的变量插入Phi
CFG中有不可达基本块 生成无效指令,GPU可能崩溃 转换后做一次“CFG清理”,删除不可达块
AST节点和IR指令一一对应 IR过于冗长,优化空间小 允许一个AST节点生成多条IR指令,或者多个AST节点合并成一条IR指令
忘记处理隐式类型转换 生成错误的IR,计算结果不对 在AST转换前先做类型检查,插入显式转换节点

曾经踩过的坑:我在做纹理采样指令的降级时,忘了处理“偏导数”指令。结果在某个GPU上,纹理采样结果全是错的。因为那个GPU的硬件要求偏导数必须在同一个基本块内计算。从那以后,我每次降级都会检查“硬件约束”,确保生成的IR满足目标GPU的要求。

好了,关于IR的设计,今天就聊这么多。下一章咱们聊聊具体的优化技术——常量折叠和传播。到时候我会分享一个我亲手优化的案例,性能提升了40%。