LLVM 学习记录:编译器之美 - 后端基础:20~22

Posted by 叉叉敌 on May 24, 2024

学习内容来自极客时间的付费课程,我是针对性的学习,直接进入后端的学习,从 20 讲开始,前端的词法解析,直接跳过。

https://time.geekbang.org/column/intro/100034101

```graph TD

A[前端] –> B{词法分析} B –> C{语法分析} C –> D{语义分析} D –> E{生成中间代码} E –> F{优化} F –> G{生成目标代码} G –> H[后端]

```

你是不是觉得汇编很难?

不瞒你说,我一开始也觉得汇编好高级呀,看不懂呀,太难了。

这个是什么鬼语法。其他汇编比高级语言简单,没有复杂的类型,没有多的语法结构。通常就是把数据拷贝到寄存器,处理一下,再保存回内存。

只是我们见到的,都是高级语言,都是处理过的,辨识度高。

为了降低后端工作量,提高软件复用度,就需要引入中间代码(Intermediate Representation,IR)的机制,它是独立于具体硬件的一种代码格式。各个语言的前端可以先翻译成 IR,然后再从 IR 翻译成不同硬件架构的汇编代码。如果有 n 个前端语言,m 个后端架构,本来需要做 m*n 个翻译程序,现在只需要 m+n 个了。这就大大降低了总体的工作量。

像 Rust 就充分利用了 LLVM,GCC 的各种语言,如 C、C++、Object C 等,也是充分共享了后端技术。

不得不说 rust 的开源社区建设太好了。

代码分析和优化

依赖于机器的优化,则是依赖于硬件的特征。现代的计算机硬件设计了很多特性,以便提供更高的处理能力,比如并行计算能力,多层次内存结构(使用多个级别的高速缓存)等等。编译器要能够充分利用硬件提供的性能,比如:

  • 寄存器优化。对于频繁访问的变量,最好放在寄存器中,并且尽量最大限度地利用寄存器,不让其中一些空着,有不少算法是解决这个问题的,教材上一般提到的是染色算法;

  • 充分利用高速缓存。高速缓存的访问速度可以比内存快几十倍上百倍,所以我们要尽量利用高速缓存。比如,某段代码操作的数据,在内存里尽量放在一起,这样 CPU 读入数据时,会一起都放到高速缓存中,不用一遍一遍地重新到内存取。

  • 并行性。现代计算机都有多个内核,可以并行计算。我们的编译器要尽可能把充分利用多个内核的计算能力。这在编译技术中是一个专门的领域。比如 NVIDIA 的 cuda

  • 流水线。CPU 在处理不同的指令的时候,需要等待的时间周期是不一样的,在等待某些指令做完的过程中其实还可以执行其他指令。就比如在星巴克买咖啡,交了钱就可以去等了,收银员可以先去处理下一个顾客,而不是要等到前一个顾客拿到咖啡才开始处理下一个顾客。涉及到 data hazard

  • 指令选择。有的时候,CPU 完成一个功能,有多个指令可供选择。而针对某个特定的需求,采用 A 指令可能比 B 指令效率高百倍。比如 X86 架构的 CPU 提供 SIMD 功能,也就是一条指令可以处理多条数据,而不是像传统指令那样一条指令只能处理一条数据。在内存计算领域,SIMD 也可以大大提升性能,我们在第 30 讲的应用篇,会针对 SIMD 做一个实验。

  • 其他优化。比如可以针对专用的 AI 芯片和 GPU 做优化,提供 AI 计算能力,等等。

刚接触编译技术的时候,可能会把视线停留在前端技术上,以为能做 Lexer、Parser 就是懂编译了。实际上,词法分析和语法分析比较成熟,有成熟的工具来支撑。相对来说,后端的工作量更大,挑战更多,研究的热点也更多。比如,人工智能领域又出现了一些专用的 AI 芯片和指令集,就需要去适配。

程序运行的环境

下面这一段是我直接 copy 过来的,我还没有消化掉。后面慢慢看。

alt text

一般来讲,代码区是在最低的地址区域,然后是静态数据区,然后是堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。

代码区(也叫文本段)存放编译完成以后的机器码。这个内存区域是只读的,不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码,还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。

静态数据区保存程序中全局的变量和常量。它的地址在编译期就是确定的,在生成的代码里直接使用这个地址就可以访问它们,它们的生存期是从程序启动一直到程序结束。它又可以细分为 Data 和 BSS 两个段。Data 段中的变量是在编译期就初始化好的,直接从程序装在进内存。BSS 段中是那些没有声明初始化值的变量,都会被初始化成 0。

堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。比如,我们在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。

而栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉

关于优化,可以看这个里面的一些思路:我目前还没有看。https://www.agner.org/optimize/