编译器前端:从代码到中间表示的“翻译官”
大家好,我是你们的老朋友。今天咱们聊聊编译器前端。
很多人觉得编译器前端就是“词法分析+语法分析”,背背概念就完了。但说实话,我在AI芯片领域摸爬滚打这些年,发现前端才是整个编译器的“门面”。你想想看,如果前端解析错了,后端再怎么优化也是白搭。今天我就把这块掰开揉碎了讲清楚。
词法分析与语法分析:编译器的“眼睛”和“大脑”
先说说词法分析。说白了,就是把你的代码拆成一个个“单词”——我们叫它Token。比如 int a = 10; 这句话,词法分析会把它拆成 int、a、=、10、; 这五个Token。
我个人习惯用Flex或者手写状态机来做词法分析。在AI芯片的场景下,我们经常要处理一些特殊的算子语法,比如 @tensor 或者 #pragma 指令。这时候,标准的词法规则就不够用了,得自己扩展。
语法分析呢,就是把这些Token按照语法规则组装起来。我常用的是递归下降解析器,因为它直观、好调试。举个例子,解析一个加法表达式:
// 伪代码示例
Expr parseExpr() {
Expr left = parseTerm();
while (peek() == '+' || peek() == '-') {
char op = consume();
Expr right = parseTerm();
left = new BinaryExpr(left, op, right);
}
return left;
}
这里要注意优先级的问题。比如 a + b * c,你得先解析乘法,再解析加法。我在项目中遇到过,有人把优先级搞反了,结果 2 + 3 * 4 算出来是20,而不是14。这种bug特别隐蔽,因为代码看起来没错,但结果就是不对。
抽象语法树(AST):代码的“骨架”
语法分析的结果,就是一棵抽象语法树。AST把代码的结构信息保留下来,但去掉了那些无关紧要的细节,比如分号、括号之类的。
举个例子,if (x > 0) { y = 1; } 这行代码,AST大概长这样:
IfStmt
├── Condition: BinaryExpr (x > 0)
└── Then: AssignStmt (y = 1)
我习惯把AST节点设计成基类加派生类的形式。基类有个 accept() 方法,方便做访问者模式。这样后续做类型检查、代码生成都很方便。
符号表管理:编译器的“通讯录”
符号表,说白了就是记录变量、函数、类型这些信息的“大本子”。你在代码里写了个 int a,符号表里就得记下:a 是个int类型,作用域在哪,地址是什么。
在AI芯片的编译器里,符号表尤其重要。因为我们经常要处理张量(tensor)的维度信息、数据布局(layout)这些复杂属性。我习惯用哈希表来实现符号表,查找快,插入也快。
作用域管理是个难点。比如:
{
int x = 1;
{
int x = 2; // 内层x覆盖外层x
}
// 这里x又变回1了
}
我常用的做法是“作用域栈”。进入一个新作用域时,压入一个新表;退出时,弹出这个表。这样查找变量时,从栈顶往下找,就能正确处理覆盖关系。
我曾经犯过一个错误:忘记在退出作用域时清理符号表,结果导致内存泄漏。嗯,这种细节问题,往往要等到跑大模型的时候才会暴露出来。
类型系统与类型推导:编译器的“质检员”
类型系统负责检查你的代码里类型是否匹配。比如你不能把字符串赋值给整数变量。在AI芯片领域,类型系统还要处理张量的形状(shape)和数据类型(dtype)。
类型推导就更有意思了。比如在Python里,你写 a = b + c,编译器得自己推断出 a 的类型。我常用的算法是Hindley-Milner类型推导,它基于约束求解,非常优雅。
举个例子:
def add(x, y):
return x + y
编译器会推导出:x 和 y 必须是支持加法操作的类型,比如int或float。如果传入一个字符串和一个整数,类型检查就会报错。
reshape 操作,你不告诉它目标形状,它根本不知道你要干嘛。
中间表示(IR)设计原则:编译器的“通用语言”
IR是编译器前端的输出,也是后端的输入。一个好的IR,能让整个编译器架构清晰、扩展性强。
我总结了几条IR设计原则:
- 简洁性: IR的指令集不要太多。我见过一个IR有200多种指令,结果后端优化器根本写不完。一般20-50种指令就够用了。
- SSA形式: 静态单赋值形式,每个变量只赋值一次。这能大大简化数据流分析。我在项目中用过SSA,确实让优化器好写了很多。
- 类型信息: IR里要保留类型信息,尤其是张量的形状和数据类型。这样后端在做内存分配时,才能知道要分配多大空间。
- 可扩展性: 设计IR时,要预留扩展点。比如加一个
custom_op字段,方便以后支持新的算子。
举个例子,一个简单的加法IR可能长这样:
%1 = load @a
%2 = load @b
%3 = add %1, %2
store %3, @c
这里 %1、%2、%3 都是SSA变量,每个只赋值一次。这样后续做常量传播、死代码删除都非常方便。
我曾经参与过一个项目,IR设计得过于复杂,结果前端和后端开发人员天天吵架。后来我们重新设计了一个精简的IR,大家才消停下来。所以说,IR设计一定要克制,别想着一次把所有功能都塞进去。
知识体系总览
下面这张图,是我自己画的编译器前端知识体系。你可以把它当作一个“地图”,随时回来看看。
这张图把编译器前端的几个核心模块串起来了。从词法分析开始,一步步走到IR生成。每个模块都有自己的职责,但又紧密配合。
好了,今天就聊到这儿。编译器前端这块,说难不难,说简单也不简单。关键是要动手写,光看理论是学不会的。我建议你找个开源项目,比如LLVM或者MLIR,看看人家是怎么实现的。相信我,读一遍源码,比看十遍书都有用。
公众号:蓝海资料掘金营,微信deep3321