写给入门者的LLVM介绍 - 知乎 (zhihu.com)
代码优化与LLVM IR pass | Kiprey’s Blog
A Tour to LLVM IR(上) - 知乎 (zhihu.com)
第5章 LLVM中间表示 — Getting Started with LLVM Core Libraries 文档 (getting-started-with-llvm-core-libraries-zh-cn.readthedocs.io)
第一页-什么是LLVM
LLVM是一个编译器(确切的说是一套框架+基于框架的一些编译器实现)
LLVM IR(Intermediate Representation)是LLVM的一种中间表示,也可以视为中间代码(便于代码优化)
LLVM之所以优秀,在于以下几点:
- 1、LLVM的中间表达(IR)是可以dump出来成为可阅读的文本形式的(语法有点像汇编),看起来微不足道,但是其他很多编译器却只有内存中的数据结构,使得学习调试难度大增。
- 2、模块化的设计比较好,吸收了很多前人经验,也和设计者的架构功力息息相关。虽然始于学术项目,但LLVM一直受到工业界的支持(Apple),所以不仅好用,而且开源可定制。避免了在Java中类似面临选择HotSpot和Jikes的困境。
第二页-为什么关心LLVM
有时候一项工作看起来并不完全是个完整的编译行为,但只要涉及到源码到源码的转换,了解LLVM通常会有所帮助。
以下是一些使用 LLVM 完成并非所有编译操作的研究项目的示例:
- UIUC 的Virtual Ghost展示了您以使用编译器通道来保护进程免受受损操作系统内核的影响。
- UW 的CoreDet使多线程程序具有确定性。
- 在近似计算工作中,使用 LLVM pass 将错误注入程序以模拟容易出错的硬件。
LLVM 不只是用于实现新的编译器及编译优化!
第三页-LLVM的基本架构
这张图片显示了 LLVM 架构的主要部件:
- 前端是clang,获取源代码并将其转换为中间表示或 IR。这种翻译简化了编译器其余部分的工作。
- IR经过N个pass的优化处理。在一般情况下,pass 通常会优化代码:生成一个 IR 程序作为输出,它与作为输入的 IR 执行相同的操作,只是它更快更优。这是需要拓展定制的地方。使用相关工具可以通过在编译过程中查看和更改 IR 来进行。
- 后端,生成实际的机器码。很多时候不需要接触这部分。
尽管这种架构描述了当今大多数编译器,但这里值得注意的是 LLVM 的一个新颖之处:整个编译过程中使用相同的 IR 。在其他编译器中,每次传递都可能以独特的形式生成代码。
第四页-了解LLVM IR
LLVM IR 具有三种表示形式,这三种中间格式是完全等价的:
在内存中的编译中间语言(我们无法通过文件的形式得到)
在硬盘上存储的二进制中间语言(bitcode形式,格式为 .bc)
人类可读的代码语言(LLVM汇编文件形式,格式为 .ll)
第五页-汇编形式的LLVM IR
汇编形式的IR是可读的。所以这里用一个简单的例子展示一下汇编形式的IR。首先编写一个简单的c语言函数如下:
// add.cpp
int add(int a, int b) {
return a + b;
}
使用如下命令可以产生汇编形式的IR:
clang add.cpp -emit-llvm -S -c -o add.ll
具体的汇编IR如下:
; ModuleID = 'add.cpp'
source_filename = "add.cpp"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.15.0"
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @_Z3addii(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}
- 注释以 分号(;) 开头,分号(;)后面的注释指明了module的标识,
source_filename
是表明这个module是从什么文件编译得到的(如果你打开main.ll
会发现这里的值是main.cpp
),如果该modules是通过链接得到的,这里的值就会是llvm-link
。 - LLVM IR 是静态类型的(即在编写时每个值都有明确的类型)
- 局部变量的作用域是单个函数(比如
@main
中的%1
是一个i32*
类型的地址,而@foo
中的%1
是一个i32
类型的值) - 临时寄存器(或者说临时变量)拥有升序的名字(比如
@main
函数中的%1
,%2
,%3
) - 全局变量与局部变量由前缀区分,全局变量和函数名以
@
为前缀,局部变量以%
为前缀 - 大多数指令与字面含义相同(
alloca
分配内存并返回地址,load
从内存读出值,store
向内存存值,add
用于加法等)
第六页-内存中的IR
Module包含Functions,其中包含BasicBlocks,其中包含Instructions。除了 Module 之外的所有东西都来自Value。
![[Snipaste_2023-11-28_16-19-11.png|125]]
Module类,Module可以理解为一个完整的编译单元。一般来说,这个编译单元就是一个源码文件,比如一个后缀为cpp的源文件。每个module之间相互独立,module主要包含了声明或者定义的函数、全局变量等。
Function类,就是对应一个函数单元。主要包含了大量 BasicBlock 、参数和返回值类型、可变参数列表、函数的attribute和其他函数的基本信息Function由无数个 BasicBlock组成,使用列表存放,有且仅有一个 EntryBlock ,是列表中的第一个 BasicBlock,代码真正执行的时候,就从 EntryBlock开始执行。
BasicBlock类,这个类表示一个基本代码块,“基本代码块”就是一段没有控制流逻辑的基本流程,相当于程序流程图中的基本过程。控制流只能从基本块的第一个指令进入该块。也就是说,没有跳转到基本块中间的或末尾指令的转移指令。除了基本块的最后一个指令,控制流在离开基本块之前不会跳转或停机。
Instruction类,指令类就是LLVM中定义的基本操作,比如加减乘除这种算数指令、函数调用指令、跳转指令、返回指令等等
第七页-举例
下面,我们通过一个例子来介绍程序的控制流是如何通过基本块与终结指令描述的:
if.c
导出为 LLVM IR
这个程序的控制流如图所示(点一下PPT)
br i1 %9, label %10, label %11 ; A
br label %12 ; B
br label %12 ; C
br
指令一共在代码中出现了三次
在这里,我们先介绍一下br
指令的用法, br
指令的语法为 br + 标志位 + truelabel + falselabel
,或者br + label
形如上面代码中 A
用法的转移指令叫做条件转移,如果标志位为1
,程序会跳往truelabel
标记的basicblock
。如果标志位为0
,程序会跳往falseblock
标记的basicblock
。比如,在代码br i1 %9, label %10, label %11
中,如果%9
的值为1
,就会跳转往基本块%10
,如果为0
,就会跳转往基本块%11
。
形如上面代码中B,C
的用法的转移指令叫做无条件转移,他会在程序运行到此处时无条件跳转到目标基本块。在上面代码中B,C
两处的代码都会无条件跳转到基本块%12
。
如上图所示,%9
是icmp eq
指令(用来判断两个值是否相等,我们会在_推荐使用的指令_一节详细介绍)的结果,如果%7
等于%8
,那么%9
的值就会为1
,否则为0
。这条指令对应了源代码中的if(a == b)
,c=5
对应了基本块%10
,c=10
对应了基本块%11
,这两个基本块运行结束时都需要跳转到目标基本块%12
执行后面的语句putint(c)
以及return 0
。
第八页-了解常用的代码优化方法
删除公共子表达式
如果表达式x op y
先前已被计算过,并且从先前的计算到现在,x op y
中的变量值没有改变,则x op y
的这次出现就称为公共子表达式(common subexpression)
删除无用代码
无用代码(Dead-code):其计算结果永远不会被使用的语句
常量合并
如果在编译时刻推导出一个表达式的值是常量,就可以使用该常量来替代这个表达式。该技术被称为 常量合并
代码移动
这个转换的结果是那些 不管循环多少次都得到相同结果的表达式(即循环不变计算,loop-invariant computation),在进入循环之前就对它们进行求值。
强度削弱用较快的操作代替较慢的操作,如用 加 代替 乘 。(例:2*x ⇒ x+x)
删除归纳变量
对于一个变量x ,如果存在一个正的或负的常数c使得每次x被赋值时它的值总增加c ,那么x就称为归纳变量(Induction Variable)。在沿着循环运行时,如果有一组归纳变量的值的变化保持步调一致,常常可以将这组变量删除为只剩一个
为了优化给定的代码段,我们可以应用几种常见的编译器优化技术。这些包括但不限于常量传播、代数化简、强度削弱和消除冗余代码。下面是按阶段进行的优化步骤,以及每个阶段的代码变化情况:
初始代码
T1 = j - 2;
T2 = 4 * T1;
temp = A[T2];
T3 = j + 2;
T4 = T3 - 2;
T5 = 8 * T4;
T6 = A[T5];
阶段 1: 常量传播和代数化简
T1
和T4
实际上是相同的操作,都是j - 2
。T2
是4 * (j - 2)
,可以直接计算。T3
是j + 2
,T4
是T3 - 2
,所以T4
实际上就是j
。T5
是8 * j
(因为T4
现在是j
)。
优化后的代码:
T1 = j - 2; // 或者可以直接在T2中使用j - 2,从而完全省略这一行
T2 = 4 * (j - 2); // 代数化简
temp = A[T2];
// T3 = j + 2; // 不再需要,因为T4和T1相等
// T4 = j; // 不再需要,因为T4和T1相等
T5 = 8 * j; // 代数化简
T6 = A[T5];
阶段 2: 消除冗余代码
- 因为
T1
和T4
相等,我们可以消除T4
。 - 直接在
T2
和T5
的赋值中使用j - 2
和j
,进一步消除T1
和T4
。
优化后的代码:
T2 = 4 * (j - 2);
temp = A[T2];
T5 = 8 * j;
T6 = A[T5];
阶段 3: 强度削弱
- 在许多情况下,乘法操作比加法或位移操作更耗时。但在这个例子中,看起来没有直接的机会应用强度削弱。
最终优化后的代码
T2 = 4 * (j - 2);
temp = A[T2];
T5 = 8 * j;
T6 = A[T5];
这个优化过程消除了冗余的计算,简化了表达式,并减少了变量的使用,从而提高了代码的效率。需要注意的是,这些优化假设没有副作用和别名问题(即数组 A
在此期间不会被修改,且 j
是一个不变的值)。