编译器开发反思
纵横计不就,慷慨志犹存
今年5月到8月间参加了 CSCC 举办的编译系统设计赛,但实际上集中开发也就是7月。尽管达成了最初的目标——“学习编译器后端知识”与“找回写代码的感觉”,但最后的名次却不尽人意。虽然比赛8月早已结束,近日写总结也不算为晚(总还未完全忘记)。
我们的编译器实现
前端
- 使用
antlr4
生成lexer
和parser
,构建抽象语法树(AST)。 - 采用访问者模式遍历AST,构建中间代码(IR)。
中端
IR设计
IR结构设计参考了LLVM IR的设计,基本组件包括:
- Module: Module是IR的顶级容器,用于表示一个完整的程序或翻译单元。它包含函数、全局变量和相关的元数据。
- Function: Function表示程序中的一个函数,由一系列基本块(BasicBlock)组成。每个函数在Module中唯一标识。
- BasicBlock: BasicBlock是IR中的基本执行单元,由一系列指令组成。每个基本块以终结指令(如
br
或ret
)结束,并且在执行过程中不包含控制流跳转。 - Instruction: Instruction是IR中的操作指令,类似于机器指令。每个指令可以产生一个值,并作为其他指令的操作数。常见指令包括算术运算(如
add
、sub
)、内存访问(如load
、store
)和控制流指令(如br
、ret
)。 - Value: Value是IR中的数据表示,包括变量、常量和指令的结果。所有的Instruction和Constant都是Value的子类。
IR优化
中端实现了各类优化,提升了代码性能。实现的优化包括:
- MemToReg: 实现寄存器提升(Promote Memory to Register),减少内存访问。
- CommonSubexpElimination: 消除公共子表达式,减少重复计算。
- MergeBlock: 合并基本块以简化控制流图。
- Inlining: 函数内联,减少函数调用开销。
- LoopSimplify: 简化循环结构。
- LoopInvariantCodeMotion: 循环不变代码外提。
- GlobalCodeMotion: 全局代码移动。
- GlobalValueNumbering: 全局值编号,优化冗余计算。
- LoopUnroll: 循环展开,提高性能。
- TailRecursionElimination: 消除尾递归。
- DeadCodeElimination: 死代码消除。
- ConstantFolding: 常量折叠,计算常量表达式。
- GlobalVariableLocalize: 全局变量本地化,减少全局变量使用。
- StrengthReduction: 强度削减,用更低代价运算代替高代价运算。
- LoadElimination: 加载消除,减少不必要的内存加载。
- Reassociate: 重结合运算。
- GEPSimplify: 简化GEP指令。
- CFGSimplify: 控制流图简化。
- StoreElimination.cc: 存储消除,减少不必要的内存存储。
- LruCache.cc: 缓存递归纯函数的结果,避免冗余函数调用。
- InductionVariableSimplify.cc: 简化归纳变量。
后端
- Instruction Seletction: 将中间代码转换为面向目标机器的低层IR。
- Register Allocation: 采用SSA分配算法,先溢出以减小寄存器压力,再对寄存器进行着色。
- Peephole Optimization: 实施一系列小规模的代码优化,如指令合并和消除冗余指令。
- Instruction Scheduling: 采用 Local List Scheduling 算法,进行简单的指令重排,并针对 SIFive u74 的双发射特性进行了优化。
- Branch Simplification: 实施和控制流相关的一些优化,如消除冗余跳转指令,基本块排序。
开发的流程与思路
理想 vs 现实
最一开始确定的开发原则有两条:
- 渐进式开发
- 测试驱动开发
渐进式开发 主要指先实现 Sysy 的一个可工作子集,然后再不断扩充功能,直到实现完整的 Sysy 语言。在此之后慢慢增加各种优化。
这样做的好处有:
-
在大部分时间都有一个可以正常运作的编译器,可以增强开发者信心
-
每次实现一小部分功能,方便实现和测试
此外就是测试驱动开发,意思是每当实现一个功能时,先写好测试用例,然后进行开发。
这样做的好处有:
- 开发时有一个明确的目标,增强动力
- 每一部分代码都有测试,保证代码的正确性
同时编译器又可以分为前端,中端和后端三个部分。
最初的计划是小组四名成员,一人负责前端实现,两人负责中端框架实现,一人负责后端框架以及无优化下的翻译实现。在基本框架实现完成后进行调试,得到一个最简单的无优化编译器。之后再做进一步计划,渐进式不断增加新的优化pass。
然而这个计划却没有成功——我们前后中三端各自实现了一个月,才分别实现好,最后一起debug十余天后就到ddl了…
究其原因,是前端成为了我们的开发瓶颈。在后端和中端开发完成后,前端依然不能正常运行,以至于中后端bug无法及时暴露和修复。如果没有前端,中后端测试时只能通过直接调用API构造IR测试用例,但这又过于繁琐(当然也可以再实现一个 IR parser,这就更麻烦了,更何况有许多现成的 Sysy 语言测例可以利用)。所以我们还是选择等待前端完成后再debug,在这段时间抓紧时间实现一些优化 pass。但等到前端实现完成时,中后端也已经积累了非常多的bug,最后大把时间都花在了debug上…
陷入debug困境的原因
造成这样灾难性后果的原因主要有两点:
第一,开发规划不明确。
在一开始我认为前端较为简单,能在短时间内实现完整的 Sysy parser,而中端只是仿照 LLVM 实现,并不复杂(而且中前端的许多知识在本院课程中都已经学过)。所以实现一个可以 work 的无优化编译器应该并不困难。 可是当前端明显暴露出短板时,我才意识到直接实现一个完整前端的工作量较大,也许先实现一个 Sysy 的子集是更合理的。于是我告诉前端开发同学,“只要先实现 Sysy 的一个子集”,“能先跑起来就好”。可是当时的我却并没有 明确规定 Sysy 的哪个子集?如果我不去明确规定划分,前端可能也不知道如何选取一个易于实现的子集——如果前端有选取易于实现子集的能力,那么显然他也能在短时间内实现完整个前端。所以,前端开发同学最后还是花了一个月时间,实现了一个充满 bug 的完整前端。
第二,测试的基础设施不完善。
其次是测试驱动开发中遇到的问题:在前端遇到瓶颈时,中端和后端仍然不管不顾蒙头开发。可是现在回想一下,尽管没有前端不能进行系统测试,但是对部分函数进行单元测试总是可以的。实际上我在编写代码时也对部分函数感到不放心,但还是决定先把问题放着,等到集成测试时再 debug ——最后的集成测试时就成了一场灾难。实际上,c++虽然没有 rust 那么方便做单元测试,但是也有 Google test 等框架来帮忙。在前端迟迟没有完成的情况下,单元测试就更为必要了。
第三,出现问题时没有及时调整分工。
在前端遇到瓶颈时,当时更优的解决方法其实应该是让后端和中端的同学去协助前端,而不是让他们仍然开发各自模块。但是因为前端同学总是承诺马上就要做完了(实际上却一拖再拖),一直到20余天后中端开发同学才去帮忙实现。承诺往往是不可靠的,进行计划调整时其实应该更重视过往经验,而非开发人员的口头承诺。
亮点不充分的原因
这里的亮点指我们在比赛中与其他队伍相比,较为亮眼的部分。当然,最后就成绩来看,也并没有什么非常亮眼之处了。
大部分组的亮点基本可以分为以下三类:
- 出色,完备的代码
- 实现了好的算法
- 优秀的基础设施
因为赶工,基本就不求 基础设施 和 代码质量 … 可能唯一值得一提的是算法。
中端参考了往年代码,实现了大部分的常见优化。后端在寄存器分配时使用了基于弦图的SSA分配算法,并且针对硬件做了简单的 Instruction Scheduling。不过SSA分配算法带来的性能提升并不大(实现上也略有粗制滥造之处),指令重排也是后期临时添加,提升效果非常有限。尽管如此,因为中后端优化实现的都比较完全,在初赛时也有一个不错的成绩。
但8月因为种种事情,在决赛以及之前并没有做多少新的优化,而其他组这期间大都在中端做了许多激进的优化:如函数记忆化,数组访问优化,以及一些针对特殊用例的优化。
最后也得知,许多队伍都参照了往年代码(这也就是比赛为什么一年比一年卷的原因),因此他们的目标很明确:
- 实现往年第一名实现过的所有优化
- 在此基础上再寻找新的优化可能
而我们组就偏向于蒙头写代码,想到什么写什么了。
这里的教训时:如果对于名次还是比较在意,比赛开始时就应该早早搜集信息,明确目标。
团队管理的总结
开发出现问题的一个重要原因就是团队管理没有做好。虽然是四个人的小组,但大部分工作却由两个人完成。可以预想,如果只有两个人,开发效率反而会更高。或许,在开始时应该多了解一些团队管理的技巧的…
最一开始人们结成团队,肯定是拥有共同的目标。我们经过利益权衡后选择加入团队,以使得自己的利益最大化。可是加入团队后,却往往丢失了主动性,开始变得依赖管理者,并因为各种琐事而推脱团队事物。经常,只有管理者才一直把团队的利益放在心理(这是我在几次合作中的体会)。也许这也和不同文化有关,如「文化与组织」这本书里说的,华人在团队合作中往往习惯于集权,管理者往往具有更大权力,但相应的,成员也会更缺乏“主人翁意识”。在这种情况下,管理者正确运用自己的“权力”,做出更好地决策就更为重要了,在适当时候,不妨果断一些…
我在团队合作中感受到的问题有:
- 信息交流不足
- 几个陌生人组队,很容易陷入尴尬状态,不能充分讨论,博采众长。
- 此时就需要管理者破冰,主动召开会议,客观分析现状,让每个都发言,都能有参与感
- 这样经过充分讨论做出的决策,每个人也更认同
- 动机不足
- 偷懒是人的天性… 尤其是成员,如果管理者没有分配任务,明确目标,就很容易偷懒
- 所以就需要管理者主动给每个人分配有明确验收标准的任务,并确定DDL
- 难以决策
- 可能因为华人文化的特点,大家都习惯于听从管理者的命令,不愿意主动提出自己想法
- 此时决策就需要管理者更自信一点,去一锤定音了
- 虽然大家没有明确提出自己意见,但是他们认同决策与否可以通过执行决策是否积极来判断
- 但发现大家的动力不足时,应该主动了解原因,调整决策
后端知识学习经验
我主要负责后端的实现。因为之前对于编译器后端了解较少,实现时也在短时间内学习了大量新知识。
在学习新知识时,往往会遇到许多困难。比如在学习某个寄存器分配算法时,我会先去看看一些经典的大部头教科书,提出算法的 paper,以及相关课程的slide,或者 youtube 上的讲解视频,最后是一些程序员的博客,中文所写的综述。不过在学习时我也并没有明确规划,而是这个看一点那个看一点,最后还是感到一头雾水。
经典教科书的问题有:
- 英语所写,较难抓住重点和关键的思想;工作记忆有限,梳理不清条理
- 都是讲最经典的实现,用简单的例子慢慢引入,学习时较为耗费时间,且很难知道研究现状和该问题各种算法的综合比较
paper的问题有:
- 因为阅读经验不足,也难抓住重点
- context较多,就会变得难以理解;而且不知道该论文在领域内处于一个什么样的位置
slide的问题有:
- 虽然能抓住核心,但太简略难懂,许多图像意义需要脑补,不好理解
youtube视频以及博客的问题有:
- 虽然许多资料深入浅出,但许多深入内容的较少,也有许多价值不高的内容
….
经过一些挫折后,我尝试着给出一个较为系统化的新知识学习方案,也许能以后作为参考。
- STFW 搜集各种信息,并分类整理搜集到的信息
- 了解相关背景知识和发展历程,心里有一个大概的框架
- 深入学习其中一个经典案例,搞清楚核心的思想,基本的抽象;这一步可以做一些笔记,或者在草稿纸上模拟,以加深理解
- 再去搜集一些综述,了解该领域发展现状,能有一个更细致的框架
- 此时根据自己的目标 以及 刚刚学习到的框架,为目标选择一个适合的目标算法,然后去学习
- 当选定目标算法并实现时,可以参考开源项目代码以及开发人员的实现相关博客
总结
- 项目
- 明确规定目标:可以文档化规范化需求
- 完善基础设施:既有集成测试,也有单元测试
- 及时识别瓶颈并处理
- 留够时间做killer feature
- 管理
- 交流:充分协商,打开思路,让每个人获得参与感
- 动机:设置ddl
- 决策:果断决策,不断迭代(对于集中式团队)
- 比如:通过ddl完成情况来了解每个人的动力值,及时给动力值不足的人重新分配任务
- 学习
- 系统化