1. 编译器基础回顾
各位同学好,我是这门课的主讲。今天咱们聊聊编译器的基础。
说实话,很多做AI推理的同学,一开始都觉得编译器跟自己没啥关系。我当年也是这么想的——直到我在项目中亲手踩过一个坑:手写了一个算子,性能死活上不去,后来才发现是编译器后端没处理好。嗯,从那以后,我再也不敢小看编译器了。
这一节,我们快速回顾一下编译器是怎么把源代码变成机器码的。重点看三个阶段的职责划分:前端、中端、后端。
1.1 编译器的整体流程
先看一张图,这是我手画的编译器工作流程。
你看,整个流程分三段。每一段干的事完全不同。我习惯把编译器想象成一个翻译团队:前端是「语言专家」,中端是「逻辑优化师」,后端是「本地化专员」。
1.2 编译器前端:读懂你的代码
前端负责把源代码变成一种叫AST(抽象语法树)的东西。说白了,就是让计算机「理解」你写了啥。
前端内部又分三步走:
- 词法分析:把代码拆成一个个token。比如
int a = 1 + 2;会被拆成int、a、=、1、+、2、;。 - 语法分析:检查这些token的组合是否符合语法规则。比如
1 + 2合法,但+ 1 2就不行。 - 语义分析:检查类型、作用域等更深层的问题。比如
float a = "hello"类型不匹配,这里就会报错。
1 + 2,根节点是加法操作,左子节点是1,右子节点是2。
我记得有一次,团队里有人写了个很复杂的宏展开,前端解析后AST膨胀了10倍。嗯,这就是为什么有些代码看着简单,编译起来却慢得要命。
1.3 编译器中端:真正的优化战场
中端是编译器最核心的部分。它把AST转成一种叫IR(中间表示)的东西,然后做各种优化。
为什么要有IR?你想想看,如果每种语言都直接生成机器码,那编译器得为每种语言+每种硬件组合写一套代码。有了IR,前端只管生成IR,后端只管消费IR,中间做优化也只需要写一套。
常见的IR优化包括:
| 优化名称 | 作用 | 举例 |
|---|---|---|
| 常量折叠 | 编译时计算常量表达式 | a = 3 + 5 → a = 8 |
| 死代码消除 | 删除永远不会执行的代码 | 删除 if(false){...} |
| 循环展开 | 减少循环开销 | 把循环体复制多份 |
| 公共子表达式消除 | 避免重复计算 | a = b*c; d = b*c → 只算一次 |
这里有个代码示例,展示IR优化前后的对比:
// 原始代码
int x = 10;
int y = 20;
int z = x + y;
int w = x + y; // 重复计算
if (false) {
z = 100; // 死代码
}
// 优化后的IR(伪代码)
x = 10
y = 20
t1 = x + y // 公共子表达式消除,只算一次
z = t1
w = t1
// if(false) 被删除
1.4 编译器后端:生成目标机器码
后端负责把优化后的IR转成目标平台的机器码。这里要考虑具体的硬件特性。
后端主要做三件事:
- 指令选择:把IR操作映射到具体的CPU指令。比如加法操作,x86上用
ADD,ARM上用ADD,但具体编码不同。 - 寄存器分配:决定哪些变量放在寄存器里,哪些放在内存里。寄存器快但少,内存慢但多,这是个权衡。
- 指令调度:重新排列指令顺序,让CPU流水线更顺畅。比如把内存加载指令提前,避免CPU空等。
后端还有一个重要任务:处理ABI(应用程序二进制接口)。说白了,就是函数怎么调用、参数怎么传、返回值怎么拿。不同操作系统、不同硬件,ABI都不一样。
1.5 三端职责总结
最后,我用一个表格总结一下三端的核心职责:
| 阶段 | 输入 | 输出 | 核心关注点 |
|---|---|---|---|
| 前端 | 源代码 | AST / IR | 语法正确性、类型安全 |
| 中端 | IR | 优化后IR | 性能优化、平台无关 |
| 后端 | 优化后IR | 机器码 | 硬件适配、指令效率 |
好了,这一节就到这里。编译器基础回顾完了,后面我们就要进入正题——推理引擎的编译器适配。你会发现,很多推理引擎的优化技巧,本质上就是在编译器三端的框架下做文章。