You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

18 KiB

25 | 后端技术的重用LLVM不仅仅让你高效

在编译器后端,做代码优化和为每个目标平台生成汇编代码,工作量是很大的。那么,有什么办法能降低这方面的工作量,提高我们的工作效率呢?**答案就是利用现成的工具。**

在前端部分我就带你使用Antlr生成了词法分析器和语法分析器。那么在后端部分我们也可以获得类似的帮助比如利用LLVM和GCC这两个后端框架。

相比前端的编译器工具如LexFlex、YaccBison和Antlr等对于后端工具了解的人比较少资料也更稀缺如果你是初学者那么上手的确有一些难度。不过我们已经用2024讲铺垫了必要的基础知识也尝试了手写汇编代码这些知识足够你学习和掌握后端工具了。

本节课我想先让你了解一些背景信息所以会先概要地介绍一下LLVM和GCC这两个有代表性的框架的情况这样当我再更加详细地讲解LLVM带你实际使用一下它的时候你接受起来就会更加容易了。

两个编译器后端框架LLVM和GCC

LLVM是一个开源的编译器基础设施项目主要聚焦于编译器的后端功能代码生成、代码优化、JIT……。它最早是美国伊利诺伊大学的一个研究性项目核心主持人员是Chris Lattner克里斯·拉特纳

LLVM的出名是由于苹果公司全面采用了这个框架。苹果系统上的C语言、C++、Objective-C的编译器Clang就是基于LLVM的最新的Swift编程语言也是基于LLVM支撑了无数的移动应用和桌面应用。无独有偶在Android平台上最新的开发语言Kotlin也支持基于LLVM编译成本地代码。

另外由Mozilla公司Firefox就是这个公司的产品开发的系统级编程语言RUST也是基于LLVM开发的。还有一门相对小众的科学计算领域的语言叫做Julia它既能像脚本语言一样灵活易用又可以具有C语言一样的速度在数据计算方面又有特别的优化它的背后也有LLVM的支撑。

OpenGL和一些图像处理领域也在用LLVM我还看到一个资料说阿里云的工程师实现了一个Cava脚本语言用于配合其搜索引擎系统HA3。

LLVM的logo一只漂亮的龙

还有在人工智能领域炙手可热的TensorFlow框架在后端也是用LLVM来编译。它把机器学习的IR翻译成LLVM的IR然后再翻译成支持CPU、GPU和TPU的程序。

所以这样看起来你所使用的很多语言和工具背后都有LLVM的影子只不过你可能没有留意罢了。所以在我看来要了解编译器的后端技术就不能不了解LLVM。

与LLVM起到类似作用的后端编译框架是GCCGNU Compiler CollectionGNU编译器套件。它支持了GNU Linux上的很多语言例如C、C++、Objective-C、Fortran、Go语言和Java语言等。其实它最初只是一个C语言的编译器后来把公共的后端功能也提炼了出来形成了框架支持多种前端语言和后端平台。最近华为发布的方舟编译器据说也是建立在GCC基础上的。

LLVM和GCC很难比较优劣因为这两个项目都取得了很大的成功。

在本课程中我们主要采用LLVM但其中学到的一些知识比如IR的设计、代码优化算法、适配不同硬件的策略在学习GCC或其他编译器后端的时候也是有用的从而大大提升学习效率。

接下来我们先来看看LLVM的构成和特点让你对它有个宏观的认识。

了解LLVM的特点

LLVM能够支持多种语言的前端、多种后端CPU架构。在LLVM内部使用类型化的和SSA特点的IR进行各种分析、优化和转换

LLVM项目包含了很多组成部分

  • LLVM核心core。就是上图中的优化和分析工具还包括了为各种CPU生成目标代码的功能这些库采用的是LLVM IR一个良好定义的中间语言在上一讲我们已经初步了解它了。

  • Clang前端是基于LLVM的C、C++、Objective-C编译器

  • LLDB一个调试工具

  • LLVM版本的C++标准类库。

  • 其他一些子项目。

我个人很喜欢LLVM想了想主要有几点原因

首先LLVM有良好的模块化设计和接口。以前的编译器后端技术很难复用而LLVM具备定义了良好接口的库方便使用者选择在什么时候复用哪些后端功能。比如针对代码优化LLVM提供了很多算法语言的设计者可以自己选择合适的算法或者实现自己特殊的算法具有很好的灵活性。

第二LLVM同时支持JIT即时编译和AOT提前编译两种模式。过去的语言要么是解释型的要么编译后运行。习惯了使用解释型语言的程序员很难习惯必须等待一段编译时间才能看到运行效果。很多科学工作者习惯在一个REPL界面中一边写脚本一边实时看到反馈。LLVM既可以通过JIT技术支持解释执行又可以完全编译后才执行这对于语言的设计者很有吸引力。

第三有很多可以学习借鉴的项目。Swift、Rust、Julia这些新生代的语言实现了很多吸引人的特性还有很多其他的开源项目而我们可以研究、借鉴它们是如何充分利用LLVM的。

第四全过程优化的设计思想。LLVM在设计上支持全过程的优化。Lattner和Adve最早关于LLVM设计思想的文章《LLVM: 一个全生命周期分析和转换的编译框架》,就提出计算机语言可以在各个阶段进行优化,包括编译时、链接时、安装时,甚至是运行时。

以运行时优化为例基于LLVM我们能够在运行时收集一些性能相关的数据对代码编译优化可以是实时优化的、动态修改内存中的机器码也可以收集这些性能数据然后做离线的优化重新生成可执行文件然后再加载执行**这一点非常吸引我,**因为在现代计算环境下,每种功能的计算特点都不相同,确实需要针对不同的场景做不同的优化。下图展现了这个过程(图片来源《 LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation》

我建议你读一读Lattner和Adve的这篇论文(另外强调一下,当你深入学习编译技术的时候,阅读领域内的论文就是必不可少的一项功课了)。

第五LLVM的授权更友好。GNU的很多软件都是采用GPL协议的所以如果用GCC的后端工具来编写你的语言你可能必须要按照GPL协议开源。而LLVM则更友好一些你基于LLVM所做的工作完全可以是闭源的软件产品。

而我之所以说“LLVM不仅仅让你更高效”就是因为上面它的这些特点。

现在你已经对LLVM的构成和特点有一定的了解了接下来我带你亲自动手操作和体验一下LLVM的功能这样你就可以迅速消除对它的陌生感快速上手了。

体验一下LLVM的功能

首先你需要安装一下LLVM参照官方网站上的相关介绍下载安装。因为我使用的是macOS所以用brew就可以安装。

brew install llvm

因为LLVM里面带了一个版本的Clang和C++的标准库与本机原来的工具链可能会有冲突所以brew安装的时候并没有在/usr/local下建立符号链接。你在用LLVM工具的时候要配置好相关的环境变量。

# 可执行文件的路径
export PATH="/usr/local/opt/llvm/bin:$PATH"
# 让编译器能够找到LLVM
export LDFLAGS="-L/usr/local/opt/llvm/lib"
export CPPFLAGS="-I/usr/local/opt/llvm/include”

安装完毕之后我们使用一下LLVM自带的命令行工具分几步体验一下LLVM的功能

1.从C语言代码生成IR
2.优化IR
3.从文本格式的IR生成二进制的字节码
4.把IR编译成汇编代码和可执行文件。

从C语言代码生成IR代码比较简单上一讲中我们已经用到过一个C语言的示例代码

//fun1.c 
int fun1(int a, int b){
    int c = 10;
    return a+b+c;
}

用前端工具Clang就可以把它编译成IR代码

clang -emit-llvm -S fun1.c -o fun1.ll

其中,-emit-llvm参数告诉Clang生成LLVM的汇编码也就是IR代码如果不带这个参数就会生成针对目标机器的汇编码所生成的IR我们上一讲也见过你现在应该能够读懂它了。你可以多写几个不同的程序看看生成的IR是什么样的比如if语句、循环语句等等这时你完成了第一步

; ModuleID = 'function-call1.c'
source_filename = "function-call1.c"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.14.0"

; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @fun1(i32, i32) #0 {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i32, align 4
  store i32 %0, i32* %3, align 4
  store i32 %1, i32* %4, align 4
  store i32 10, i32* %5, align 4
  %6 = load i32, i32* %3, align 4
  %7 = load i32, i32* %4, align 4
  %8 = add nsw i32 %6, %7
  %9 = load i32, i32* %5, align 4
  %10 = add nsw i32 %8, %9
  ret i32 %10
}

attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.module.flags = !{!0, !1}
!llvm.ident = !{!2}

!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 7, !"PIC Level", i32 2}
!2 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"}

上一讲我们提到过可以对生成的IR做优化让代码更短你只要在上面的命令中加上-O2参数就可以了这时你完成了第二步

clang -emit-llvm -S -O2 fun1.c -o fun1.ll

这个时候函数体的核心代码就变短了很多。这里面最重要的优化动作是从原来使用内存alloca指令是在栈中分配空间store指令是往内存里写入值优化到只使用寄存器%0、%1是参数%3、%4也是寄存器

define i32 @fun1(i32, i32) #0 {
  %3 = add nsw i32 %0, %1
  %4 = add nsw i32 %3, 10
  ret i32 %4
}

你还可以用opt命令来完成上面的优化具体我们在27、28讲中讲优化算法的时候再细化。

**另外LLVM的IR有两种格式。**在示例代码中显示的,是它的文本格式,文件名一般以.ll结尾。第二种格式是字节码bitcode格式文件名以.bc结尾。**为什么要用两种格式呢?**因为文本格式的文件便于程序员阅读,而字节码格式的是二进制文件,便于机器处理,比如即时编译和执行。生成字节码格式之后,所占空间会小很多,所以可以快速加载进内存,并转换为内存中的对象格式。而如果加载文本文件,则还需要一个解析的过程,才能变成内存中的格式,效率比较慢。

调用llvm-as命令我们可以把文本格式转换成字节码格式

llvm-as fun1.ll -o fun1.bc

我们也可以用clang直接生成字节码这时不需要带-S参数而是要用-c参数。

clang -emit-llvm -c fun1.c -o fun1.bc

因为.bc文件是二进制文件不能直接用文本编辑器查看而要用hexdump命令查看这时你完成了第三步

hexdump -C fun1.bc

LLVM的一个优点就是可以即时编译运行字节码不一定非要编译生成汇编码和可执行文件才能运行这一点有点儿像Java语言这也让LLVM具有极高的灵活性比如可以在运行时根据收集的性能信息改变优化策略生成更高效的机器码。

再进一步我们可以把字节码编译成目标平台的汇编代码。我们使用的是llc命令命令如下

llc fun1.bc -o fun1.s

用clang命令也能从字节码生成汇编代码要注意带上-S参数就行了

clang -S fun1.bc -o fun1.s

**到了这一步,我们已经得到了汇编代码,**接着就可以进一步生成目标文件和可执行文件了。

实际上使用LLVM从源代码到生成可执行文件有两条可能的路径

  • 第一条路径,是把每个源文件分别编译成字节码文件,然后再编译成目标文件,最后链接成可执行文件。

  • 第二条路径,是先把编译好的字节码文件链接在一起,形成一个更大的字节码文件,然后对这个字节码文件进行进一步的优化,之后再生成目标文件和可执行文件。

第二条路径比第一条路径多了一个优化的步骤,第一条路径只对每个模块做了优化,没有做整体的优化。所以,如有可能,尽量采用第二条路径,这样能够生成更加优化的代码。

现在你完成了第四步对LLVM的命令行工具有了一定的了解。总结一下我们用到的命令行工具包括clang前端、llvm-as、llc其他命令还有opt代码优化、llvm-dis将字节码再反编译回ll文件、llvm-link链接你可以看它们的help信息并练习使用。

在熟悉了命令行工具之后我们就可以进一步在编程环境中使用LLVM了不过在此之前需要搭建一个开发环境。

建立C++开发环境来使用LLVM

LLVM本身是用C++开发的所以最好采用C++调用它的功能。当然采用其他语言也有办法调用LLVM

  • C语言可以调用专门的C接口
  • 像Go、Rust、Python、Ocaml、甚至Node.js都有对LLVM API的绑定
  • 如果使用Java也可以通过JavaCPP类似JNI技术调用LLVM。

在课程中我用C++来做实现因为这样能够最近距离地跟LLVM打交道。与此同时我们前端工具采用的Antlr也能够支持C++开发环境。所以我为playscript建立了一个C++的开发环境。

**开发工具方面:**原则上只要一个编辑器加上工具链就行但为了提高效率有IDE的支持会更好我用的是JetBrains的Clion

**构建工具方面:**目前LLVM本身用的是CMake而Clion刚好也采用CMake所以很方便。

**这里我想针对CMake多解释几句**因为越来越多的C++项目都是用CMake来管理的LLVM以及Antlr的C++版本也采用了CMake你最好对它有一定了解。

CMake是一款优秀的工程构建工具它类似于Java程序员们习惯使用的Maven工具。对于只包含少量文件或模块的C或C++程序,你可以仅仅通过命令行带上一些参数就能编译。

不过实际的项目都会比较复杂往往会包含比较多的模块存在比较复杂的依赖关系编译过程也不是一步能完成的要分成多步。这时候我们一般用make管理项目的构建过程这就要学会写make文件。但手工写make文件工作量会比较大而CMake就是在make的基础上再封装了一层它能通过更简单的配置文件帮我们生成make文件帮助程序员提升效率。

整个开发环境的搭建我在课程里就不多写了,你可以参见示例代码所附带的文档。文档里有比较清晰的说明,可以帮助你把环境搭建起来,并运行示例程序。

另外我知道你可能对C++并不那么熟悉。但你应该学过C语言所以示例代码还是能看懂的。

课程小结

本节课为了帮助你理解后端工具我先概要介绍了后端工具的情况接着着重介绍了LLVM的构成和特点然后又带你熟悉了它的命令行工具让你能够生成文本和字节码两种格式的IR并生成可执行文件最后带你了解了LLVM的开发环境。

本节课的内容比较好理解因为侧重让你建立跟LLVM的熟悉感没有什么复杂的算法和原理而我想强调的是以下几点

1.后端工具对于语言设计者很重要,我们必须学会善加利用;
2.LLVM有很好的模块化设计支持即时编译JIT和提前编译AOT支持全过程的优化并且具备友好的授权值得我们好好掌握
3.你要熟悉LLVM的命令行工具这样可以上手做很多实验加深对LLVM的了解。

最后我想给你的建议是一定要动手安装和使用LLVM写点代码测试它的功能。比如写点儿C、C++等语言的程序并翻译成IR进一步熟悉LLVM的IR。下一讲我们就要进入它的内部调用它的API来生成IR和运行了

一课一思

很多语言都获得了后端工具的帮助比如可以把Android应用直接编译成机器码提升运行效率。你所经常使用的计算机语言采用了什么后端工具有什么特点欢迎在留言区分享。

最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你分享给更多的朋友。