LLVM的IR指令及代码生成技术应用详解

发布时间 2023-03-28 04:46:15作者: 吴建明wujianming
LLVM的IR指令及代码生成技术应用详解
LLVM的IR指令详解
IR 指令是 LLVM 中的一个中间表示形式,用于表示程序的控制流、数据流、内存访问等等,它是一种基于 SSA 形式(Static Single Assignment)的静态单赋值形式。在 LLVM 中,每个 IR 指令都有一个唯一的操作码(opcode),用于标识该指令的类型,每个操作码对应了一组可能的操作数(operands),这些操作数可以是常量、寄存器或者其他指令的结果。
在 LLVM 的 IR 中,所有的数据类型都是基于 LLVM 类型系统定义的,这些数据类型包括整数、浮点数、指针、数组、结构体等等,每个数据类型都具有自己的属性,例如位宽、对齐方式等等。在 IR 中,每个值都有一个类型,这个类型可以被显式地指定,或者通过指令的操作数推导出来。
LLVM 的 IR 指令非常丰富,包括算术、逻辑、比较、转换、控制流等等,它们可以被用来表达复杂的程序结构,同时 IR 指令还可以通过 LLVM 的优化器进行优化,以生成高效的目标代码。
IR指令类型比较多,以下是一些常见的指令类型:
加减乘除指令:add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv 等
位运算指令:and, or, xor, shl, lshr, ashr 等
转换指令:trunc, zext, sext, fptrunc, fpext, fptoui, fptosi, uitofp, sitofp, ptrtoint, inttoptr, bitcast 等
内存指令:alloca, load, store, getelementptr 等
控制流指令:br, switch, ret, indirectbr, invoke, resume, unreachable 等
其他指令:phi, select, call, va_arg, landingpad 等
加减乘除指令
1.加法指令(add)
加法指令用于对两个数值进行相加。在 LLVM 中,加法指令的语法如下所示:
%result = add <type> <value1>, <value2>
其中,<type> 表示要进行加法运算的值的数据类型,可以是整数、浮点数等;<value1> 和 <value2> 分别表示相加的两个数,可以是常量、寄存器或者其他指令的结果。
在LLVM中,add指令的<type>参数指定了<value1>和<value2>的类型,同时也指定了<result>的类型。支持的类型包括:
整数类型:i1, i8, i16, i32, i64, i128等;
浮点类型:half, float, double, fp128等;
向量类型:<n x i8>, <n x i16>, <n x i32>等;
指针类型:i8*, i32*, float*等;
标签类型:metadata;
例如,如果我们想将两个整数相加并得到一个整数结果,可以使用以下指令:
%result = add i32 1, 2
这里,<type>指定为i32,<value1>为整数值1,<value2>为整数值2,<result>为整数类型i32。各种类型的内存空间大小(以位为单位)如下:
整数类型:i1占1位,i8占8位,i16占16位,i32占32位,i64占64位,i128占128位;
浮点类型:half占16位,float占32位,double占64位,fp128占128位;
向量类型:<n x i8>占n * 8位,<n x i16>占n * 16位,<n x i32>占n * 32位等;
指针类型:指针类型的大小取决于运行时的操作系统和架构,例如在32位操作系统上,指针类型通常占4个字节(32位),在64位操作系统上,指针类型通常占8个字节(64位);
标签类型:metadata类型通常占据与指针类型相同的空间;
需要注意的是,这里只是给出了各种类型在LLVM中的默认大小,实际上在使用LLVM IR时,开发者可以通过在类型后面加上数字来显式指定类型的大小,例如,i16类型可以通过i16 123来表示一个16位整数值123。
下面是一个加法指令的代码示例,将两个整数相加:
%x = add i32 2, 3
这个指令将常量 2 和 3 相加,结果保存到寄存器 %x 中。
除了常量之外,也可以使用寄存器或其他指令的结果作为加法指令的操作数,例如:
%x = add i32 %a, %b
%z = add i32 %x, %y
第一行代码将寄存器 %a 和 %b 中的值相加,结果保存到寄存器 %x 中;第二行代码将寄存器 %x 和 %y 中的值相加,结果保存到寄存器 %z 中。
在 LLVM 中还支持带进位的加法指令(add with carry)和带溢出的加法指令(add with overflow),这里不再赘述。
2.减法指令(sub)
减法指令用于对两个数值进行相减,语法为:
%result = sub <type> <value1>, <value2>
其中,<type> 表示要进行减法运算的值的数据类型,可以是整数、浮点数等;<value1> 和 <value2> 分别表示相减的两个数,可以是常量、寄存器或者其他指令的结果。
下面是一个减法指令的代码示例,将两个整数相减:
%diff = sub i32 %x, %y
这个指令将寄存器 %x 中的值减去 %y 中的值,结果保存到寄存器 %diff 中。
减法指令还有一种形式,可以用于计算两个浮点数之间的差值。语法为:
%result = fsub <type> <value1>, <value2>
其中,<type> 表示要进行减法运算的值的数据类型,必须是浮点数类型;<value1> 和 <value2> 分别表示相减的两个数,可以是常量、寄存器或者其他指令的结果。
下面是一个浮点数减法指令的代码示例,将两个单精度浮点数相减:
%diff = fsub float %x, %y
这个指令将寄存器 %x 中的单精度浮点数减去 %y 中的单精度浮点数,结果保存到寄存器 %diff 中。
3. 乘法指令(mul)
乘法指令用于对两个数值进行相乘,语法为:
%result = mul <type> <value1>, <value2>
其中,<type> 表示要进行乘法运算的值的数据类型,可以是整数、浮点数等;<value1> 和 <value2> 分别表示相乘的两个数,可以是常量、寄存器或者其他指令的结果。
下面是一个乘法指令的代码示例,将两个整数相乘:
%prod = mul i32 %x, %y
这个指令将寄存器 %x 和 %y 中的值相乘,结果保存到寄存器 %prod 中。我们还可以对浮点数进行乘法操作,如下所示:
%result = mul double %value1, %value2
这个指令将寄存器 %value1 和 %value2 中的值相乘,结果保存到 %result 中。需要注意的是,对于浮点数的乘法操作,需要使用 double 或 float 等浮点类型。
此外,LLVM 还提供了一些其他类型的乘法指令,例如向量乘法指令、无符号整数乘法指令等。具体的指令使用方法可以参考 LLVM 的官方文档。
4.除法指令(div)
除法指令用于对两个数值进行相除,语法为:
%result = <s/u>div <type> <value1>, <value2>
其中, 表示要执行有符号(`sdiv`)还是无符号(`udiv`)的除法运算; 表示要进行除法运算的值的数据类型,可以是整数、浮点数等;和 分别表示相除的两个数,可以是常量、寄存器或者其他指令的结果。
下面是一个除法指令的代码示例,将两个整数相除:
%quot = sdiv i32 %x, %y
这个指令将寄存器 %x 中的值除以 %y 中的值,结果保存到寄存器 %quot 中。由于使用了 sdiv 指令,因此进行的是有符号除法运算。
如果要进行无符号除法运算,可以使用 udiv 指令:
%quot = udiv i32 %x, %y
这个指令将寄存器 %x 中的值除以 %y 中的值,结果保存到寄存器 %quot 中。由于使用了 udiv 指令,因此进行的是无符号除法运算。
位运算指令
IR有多种位运算指令,包括位与(and)、位或(or)、位异或(xor)、位取反(not)等。这些指令可以对整数类型进行按位操作,并将结果存储到一个新的寄存器中。以下是 IR 中常见的位运算指令及其作用:
位与(and):将两个整数的二进制表示进行按位与操作。
位或(or):将两个整数的二进制表示进行按位或操作。
位异或(xor):将两个整数的二进制表示进行按位异或操作。
位取反(not):将一个整数的二进制表示进行按位取反操作。
这些指令都可以用类似的语法进行使用,其中 <type> 表示要进行位运算的整数的数据类型,可以是 i1、i8、i16、i32、i64 等;<value1> 和 <value2> 分别表示要进行位运算的整数,可以是常量、寄存器或其他指令的结果。例如:
%result = and i32 %x, %y
%result = or i32 %x, %y
%result = xor i32 %x, %y
%result = xor i32 %x, -1
第一个指令将 %x 和 %y 进行按位与操作,并将结果保存到 %result 中;第二个指令将 %x 和 %y 进行按位或操作,并将结果保存到 %result 中;第三个指令将 %x 和 %y 进行按位异或操作,并将结果保存到 %result 中;最后一个指令将 %x 和二进制全为 1 的数进行按位异或操作,即将 %x 的每一位取反,结果同样保存到 %result 中。
转换指令
trunc: 将一个整数或浮点数截断为比原来小的位数,即去掉高位的一些二进制位。
zext: 将一个整数或布尔值的位数增加,新位数的高位都填充为零,即进行零扩展。
sext: 将一个整数的位数增加,新位数的高位都填充为原有的最高位,即进行符号扩展。
fptrunc: 将一个浮点数截断为比原来小的位数,即去掉高位的一些二进制位。这是一种舍入操作,可能会丢失一些精度。
fpext: 将一个浮点数的位数增加,新位数的高位都填充为零,即进行浮点零扩展。
fptoui: 将一个浮点数转换为一个无符号整数。如果浮点数是负数,则结果为零。
fptosi: 将一个浮点数转换为一个带符号整数。如果浮点数是负数,则结果为负的最小整数。
uitofp: 将一个无符号整数转换为一个浮点数。
sitofp: 将一个带符号整数转换为一个浮点数。
ptrtoint: 将一个指针类型转换为一个整数类型。该指令通常用于将指针转换为整数进行计算。
inttoptr: 将一个整数类型转换为一个指针类型。该指令通常用于将整数转换为指针进行内存地址计算。
bitcast: 将一个值从一种类型转换为另一种类型,但是这些类型必须具有相同的位数。这个指令可以用来实现底层内存操作,例如将浮点数转换为整数以进行位运算。
下面是 IR 转换指令的详细的使用说明和示例:
1.trunc
trunc指令将一个整数或浮点数截断为比原来小的位数,即去掉高位的一些二进制位。trunc指令的使用格式如下:
%result = trunc <source type> <value> to <destination type>
其中,<source type>和<destination type>分别表示源类型和目标类型,<value>表示要转换的值。例如,下面的代码将一个64位整数截断为32位整数:
%long = add i64 1, 2
%short = trunc i64 %long to i32
在这个例子中,%long是一个64位整数,它的值是3(1+2)。%short是一个32位整数,它的值是3。由于%long被截断为32位整数,因此只有低32位的值保留下来。
2.zext
zext指令将一个整数或布尔值的位数增加,新位数的高位都填充为零,即进行零扩展。zext指令的使用格式如下:
%result = zext <source type> <value> to <destination type>
例如,下面的代码将一个8位整数扩展为16位整数:
%short = add i8 1, 2
%long = zext i8 %short to i16
在这个例子中,%short是一个8位整数,它的值是3(1+2)。%long是一个16位整数,它的值是3。由于%short被扩展为16位整数,因此高8位被填充为零。
3.sext
sext指令将一个整数的位数增加,新位数的高位都填充为原有的最高位,即进行符号扩展。sext指令的使用格式与zext指令类似:
%result = sext <source type> <value> to <destination type>
例如,下面的代码将一个8位整数扩展为16位整数:
%short = add i8 -1, 2
%long = sext i8 %short to i16
在这个例子中,%short是一个8位整数,它的值是1-2=-1。%long是一个16位整数,它的值是0xffff。由于%short被扩展为16位整数,因此高8位都被填充为1。
4.fptrunc
fptrunc指令将一个浮点数截断为比原来小的位数,即去掉高位的一些二进制位。fptrunc指令的使用格式如下:
%result = fptrunc <source type> <value> to <destination type>
例如,下面的代码将一个双精度浮点数截断为单精度浮点数:
%double = fadd double 1.0, 2.0
%float = fptrunc double %double to float
在这个例子中,%double是一个双精度浮点数,它的值是3.0(1.0+2.0)。%float是一个单精度浮点数,它的值是3.0。由于%double被截断为单精度浮点数,因此高位的值被截断掉,只有低位的值保留下来。
5.fpext
fpext指令将一个浮点数扩展为比原来大的位数,新位数的高位都填充为零。fpext指令的使用格式与fptrunc指令类似:
%result = fpext <source type> <value> to <destination type>
例如,下面的代码将一个单精度浮点数扩展为双精度浮点数:
%float = fadd float 1.0, 2.0
%double = fpext float %float to double
在这个例子中,%float是一个单精度浮点数,它的值是3.0(1.0+2.0)。%double是一个双精度浮点数,它的值是3.0。由于%float被扩展为双精度浮点数,新的高位都被填充为零。
6.fptoui
fptoui指令将一个浮点数转换为一个无符号整数。转换时,如果浮点数的值为负数,则结果为0。fptoui指令的使用格式如下:
%result = fptoui <source type> <value> to <destination type>
例如,下面的代码将一个双精度浮点数转换为32位无符号整数:
%double = fadd double 1.0, 2.0
%uint = fptoui double %double to i32
在这个例子中,%double是一个双精度浮点数,它的值是3.0(1.0+2.0)。%uint是一个32位无符号整数,它的值是3。由于%double的值为正数,因此可以转换为32位无符号整数。
7.fptosi
fptosi指令将一个浮点数转换为一个带符号整数。转换时,如果浮点数的值超出了目标类型的表示范围,则结果为该类型的最小值或最大值。fptosi指令的使用格式如下:
%result = fptosi <source type> <value> to <destination type>
例如,下面的代码将一个双精度浮点数转换为32位带符号整数:
%double = fadd double 1.0, -2.0
%i32 = fptosi double %double to i32
在这个例子中,%double是一个双精度浮点数,它的值是-1.0(1.0-2.0)。%i32是一个32位带符号整数,它的值是-1。由于%double的值为负数,因此可以转换为32位带符号整数。
8.uitofp
uitofp指令将一个无符号整数转换为一个浮点数。uitofp指令的使用格式如下:
%result = uitofp <source type> <value> to <destination type>
例如,下面的代码将一个32位无符号整数转换为单精度浮点数:
%uint = add i32 1, 2
%float = uitofp i32 %uint to float
在这个例子中,%uint是一个32位无符号整数,它的值是3。%float是一个单精度浮点数,它的值是3.0。由于%uint的值为正数,因此可以转换为单精度浮点数。
9.sitofp
sitofp指令将一个带符号整数转换为一个浮点数。sitofp指令的使用格式如下:
%result = sitofp <source type> <value> to <destination type>
例如,下面的代码将一个32位带符号整数转换为单精度浮点数:
%i32 = add i32 1, -2
%float = sitofp i32 %i32 to float
在这个例子中,%i32是一个32位带符号整数,它的值是-1。%float是一个单精度浮点数,它的值是-1.0。由于%i32的值为负数,因此可以转换为单精度浮点数。
10.ptrtoint
ptrtoint指令将一个指针类型转换为一个整数类型。ptrtoint指令的使用格式如下:
%result = ptrtoint <source type> <value> to <destination type>
例如,下面的代码将一个指针类型转换为64位整数类型:
%ptr = alloca i32
%i64 = ptrtoint i32* %ptr to i64
在这个例子中,%ptr是一个指向32位整数类型的指针。%i64是一个64位整数类型,它的值是指针%ptr的地址。由于指针类型和整数类型的位宽不同,因此需要使用ptrtoint指令进行类型转换。
11.inttoptr
inttoptr指令将一个整数类型转换为一个指针类型。inttoptr指令的使用格式如下:
%result = inttoptr <source type> <value> to <destination type>
例如,下面的代码将一个64位整数类型转换为指向32位整数类型的指针:
%i64 = add i64 1, 2
%ptr = inttoptr i64 %i64 to i32*
在这个例子中,%i64是一个64位整数类型,它的值是3。%ptr是一个指向32位整数类型的指针,它的值是3。由于整数类型和指针类型的位宽不同,因此需要使用inttoptr指令进行类型转换
12.bitcast
bitcast指令将一个值的位表示转换为另一个类型的位表示,但是它不会改变值本身。bitcast指令的使用格式如下:
%result = bitcast <source type> <value> to <destination type>
例如,下面的代码将一个64位双精度浮点数转换为64位整数类型:
%double = fadd double 1.0, -2.0
%i64 = bitcast double %double to i64
在这个例子中,%double是一个64位双精度浮点数,它的值是-1.0(1.0-2.0)。%i64是一个64位整数类型,它的值是0xbff8000000000000(-4616189618054758400)。由于双精度浮点数和64位整数类型的位宽相同,因此可以使用bitcast指令进行类型转换。
内存指令
LLVM IR提供了一些常见的内存指令,包括alloca、load、store、getelementptr、malloc、free、memset、memcpy和memmove等。这些指令可以用于内存分配、初始化和复制操作。下面将对这些指令逐一进行介绍,并提供相应的代码示例。
1.alloca
alloca指令用于在栈上分配内存,并返回一个指向新分配的内存的指针。alloca指令的使用格式如下:
%ptr = alloca <type>
其中,<type>是要分配的内存块的类型。例如,下面的代码分配一个包含5个整数的数组:
%array = alloca [5 x i32]
2.load
load指令用于从内存中读取数据,并将其加载到寄存器中。load指令的使用格式如下:
%val = load <type>* <ptr>
其中,<type>是要读取的数据的类型,<ptr>是指向要读取数据的内存块的指针。例如,下面的代码将一个整数数组的第一个元素加载到寄存器中:
%array = alloca [5 x i32]
%ptr = getelementptr [5 x i32], [5 x i32]* %array, i32 0, i32 0
%val = load i32, i32* %ptr
在这个例子中,%array是一个整数数组,%ptr是指向数组第一个元素的指针,load指令将%ptr指向的内存块中的数据加载到%val寄存器中。
3.store
store指令用于将数据从寄存器中写入内存。store指令的使用格式如下:
store <type> <val>, <type>* <ptr>
其中,<type>是要写入的数据的类型,<val>是要写入的数据的值,<ptr>是指向要写入数据的内存块的指针。例如,下面的代码将一个整数存储到一个整数数组的第一个元素中:
%array = alloca [5 x i32]
%ptr = getelementptr [5 x i32], [5 x i32]* %array, i32 0, i32 0
store i32 42, i32* %ptr
在这个例子中,%array是一个整数数组,%ptr是指向数组第一个元素的指针,store指令将整数值42存储到%ptr指向的内存块中。
4.getelementptr
getelementptr指令用于计算指针的偏移量,以便访问内存中的数据。getelementptr指令的使用格式如下:
%ptr = getelementptr <type>, <type>* <ptr>, <index type> <idx>, ...
其中,<type>是指针指向的数据类型,<ptr>是指向数据的指针,<index type>是索引的类型,<idx>是索引的值。getelementptr指令可以接受多个索引,每个索引都可以是任意类型的。索引类型必须是整数类型,用于计算偏移量。例如,下面的代码计算一个二维数组中的一个元素的指针:
%array = alloca [3 x [4 x i32]]
%ptr = getelementptr [3 x [4 x i32]], [3 x [4 x i32]]* %array, i32 1, i32 2
在这个例子中,%array是一个二维数组,%ptr是指向第二行第三列元素的指针
5.malloc
malloc指令用于在堆上分配内存,并返回一个指向新分配的内存的指针。malloc指令的使用格式如下:
%ptr = call i8* @malloc(i64 <size>)
其中,<size>是要分配的内存块的大小。例如,下面的代码分配一个包含10个整数的数组:
%size = mul i64 10, i64 4
%ptr = call i8* @malloc(i64 %size)
%array = bitcast i8* %ptr to i32*
在这个例子中,%size是10个整数占用的字节数,call指令调用malloc函数分配内存,%ptr是指向新分配的内存块的指针,bitcast指令将%ptr指针转换为整数指针类型。
6.free
free指令用于释放之前通过malloc指令分配的内存。free指令的使用格式如下:
call void @free(i8* <ptr>)
其中,<ptr>是指向要释放的内存块的指针。例如,下面的代码释放之前分配的整数数组:
%ptr = bitcast i32* %array to i8*
call void @free(i8* %ptr)
在这个例子中,%array是之前通过malloc指令分配的整数数组的指针,bitcast指令将%array指针转换为i8*类型的指针,call指令调用free函数释放内存。
7.memset
memset指令用于将一段内存区域的内容设置为指定的值。它的基本语法如下:
call void @llvm.memset.p0i8.i64(i8* %dst, i8 %val, i64 %size, i1 0)
其中,第一个参数%dst是要设置的内存区域的起始地址,它应该是指针类型。第二个参数%val是要设置的值,它应该是整型。第三个参数%size是内存区域的大小,它应该是64位整型。最后一个参数是一个布尔值,表示对齐方式。如果它为1,表示按照指针类型对齐;如果它为0,表示不按照指针类型对齐。
下面是一个简单的使用示例,将一个整型数组中的所有元素都设置为0:
define void @set_to_zero(i32* %array, i32 %size) {
entry:
%zero = alloca i32, align 4
store i32 0, i32* %zero, align 4
%array_end = getelementptr i32, i32* %array, i32 %size
call void @llvm.memset.p0i8.i64(i8* %array, i8 0, i64 sub(i32* %array_end, %array), i1 false)
ret void
}
8.memcpy
memcpy指令用于将一个内存区域的内容复制到另一个内存区域。它的基本语法如下:
call void @llvm.memcpy.p0i8.p0i8.i64(i8* %dst, i8* %src, i64 %size, i1 0)
其中,第一个参数%dst是目标内存区域的起始地址,它应该是指针类型。第二个参数%src是源内存区域的起始地址,它应该是指针类型。第三个参数%size是内存区域的大小,它应该是64位整型。最后一个参数是一个布尔值,表示对齐方式。如果它为1,表示按照指针类型对齐;如果它为0,表示不按照指针类型对齐。
下面是一个简单的使用示例,将一个整型数组复制到另一个数组中:
define void @copy_array(i32* %src, i32* %dst, i32 %size) {
entry:
%src_end = getelementptr i32, i32* %src, i32 %size
call void @llvm.memcpy.p0i8.p0i8.i64(i8* %dst, i8* %src, i64 sub(i32* %src_end, %src), i1 false)
ret void
}
9.memmove
memmove指令用于将源地址指向的内存块中的数据移动到目标地址指向的内存块中,其定义如下:
declare void @llvm.memmove.p0i8.p0i8.i32(i8* nocapture, i8* nocapture, i32, i32, i1)
该指令接受五个参数,分别是目标地址、源地址、拷贝的字节数、对齐方式、以及是否进行重叠检查的标志。其中,对齐方式参数表示内存块的对齐方式,如果不需要对齐则设为 1。如果进行重叠检查,需要将标志设为 true,否则设为 false。
下面是一个使用memmove指令将源地址指向的内存块中的数据移动到目标地址指向的内存块中的示例:
%src = alloca [10 x i32], align 4
%dst = alloca [10 x i32], align 4
%size = getelementptr [10 x i32], [10 x i32]* %src, i32 0, i32 10
%sizeVal = ptrtoint i32* %size to i32
call void @llvm.memmove.p0i8.p0i8.i32(i8* bitcast ([10 x i32]* %dst to i8*), i8* bitcast ([10 x i32]* %src to i8*), i32 %sizeVal, i32 4, i1 false)
该示例中,首先在堆栈上分配了两个 [10 x i32] 类型的内存块,并通过getelementptr指令获取了内存块的大小。然后调用了memmove指令将源地址指向的内存块中的数据移动到目标地址指向的内存块中。需要注意的是,需要使用bitcast指令将源地址和目标地址转换为i8*类型的指针。
控制流指令
控制流指令包括以下指令:
br:条件分支指令,根据条件跳转到指定的基本块。
switch:多路分支指令,根据输入值跳转到不同的基本块。
ret:函数返回指令,返回到调用函数的地方。
indirectbr:间接分支指令,跳转到存储在指定地址中的基本块。
invoke:调用指令,调用带异常处理的函数,并在异常发生时传递控制权。
resume:异常恢复指令,恢复在调用invoke指令时发生的异常。
unreachable:不可到达指令,表示程序不应该执行到该点,如果执行到该点会导致未定义行为。
这些指令在LLVM IR中用于控制程序的流程,支持高级优化技术,如控制流分析和基于SSA形式的变量重命名。LLVM的控制流指令包括条件分支指令br、多路分支指令switch、函数返回指令ret、间接分支指令indirectbr、调用指令invoke、异常恢复指令resume和不可到达指令unreachable。下面将逐一对这些指令进行说明并给出相应的代码示例。
1.条件分支指令(br)
br指令用于执行条件分支,根据条件跳转到不同的基本块。它的语法如下:
br i1 <cond>, label <iftrue>, label <iffalse>
其中<cond>是条件值,如果其值为真,则跳转到标记为<iftrue>的基本块;否则跳转到标记为<iffalse>的基本块。下面是一个简单的示例:
define i32 @test(i32 %a, i32 %b) {
%cmp = icmp eq i32 %a, %b
br i1 %cmp, label %equal, label %notequal
equal:
ret i32 1
notequal:
ret i32 0
}
在这个示例中,我们定义了一个函数test,它接受两个整数参数%a和%b。首先,我们使用icmp指令比较这两个值是否相等,并将结果保存在%cmp中。然后,我们使用br指令根据%cmp的值跳转到不同的基本块,如果它们相等,则返回1;否则返回0。
2.多路分支指令(switch)
switch指令用于执行多路分支,根据输入值跳转到不同的基本块。它的语法如下:
switch <intty> <value>, label <defaultdest> [ <intty> <val>, label <dest> ... ]
其中,<intty>是整数类型,<value>是输入值,<defaultdest>是默认跳转的基本块。后面的每对<val>, <dest>都表示一个选项,如果<value>等于<val>,则跳转到<dest>标记的基本块。下面是一个示例:
define i32 @test(i32 %a) {
switch i32 %a, label %default [
i32 0, label %zero
i32 1, label %one
]
zero:
ret i32 0
one:
ret i32 1
default:
ret i32 -1
}
在这个示例中,我们定义了一个函数test,它接受一个整数参数%a。然后,我们使用switch指令根据%a的值跳转到不同的基本块,如果%a等于0,则返回0;如果%a等于1,则返回1;否则返回-1。
3.函数返回指令(ret)
ret指令用于从函数中返回一个值。它的语法如下:
ret <type> <value>
其中,<type>是返回值的类型,<value>是返回的值。如果函数没有返回值,则<type>应该是void。下面是一个示例:
define i32 @test(i32 %a, i32 %b) {
%sum = add i32 %a, %b
ret i32 %sum
}
在这个示例中,我们定义了一个函数test,它接受两个整数参数%a和%b。首先,我们使用add指令将它们相加并将结果保存在%sum中。然后,我们使用ret指令返回%sum的值。
4.间接分支指令(indirectbr)
indirectbr指令用于根据间接地址跳转到不同的基本块。它的语法如下:
indirectbr <type> <address>, [ label <dest1>, label <dest2>, ... ]
其中,<type>是跳转目标的类型,<address>是指向跳转目标地址的指针。后面的每个<dest>表示一个跳转目标基本块的标记。下面是一个示例:
define i32 @test(i32* %ptr) {
%dest1 = label %one
%dest2 = label %two
indirectbr i8* %ptr, [ label %default, label %dest1, label %dest2 ]
one:
ret i32 1
two:
ret i32 2
default:
ret i32 -1
}
在这个示例中,我们定义了一个函数test,它接受一个指向整数地址的指针参数%ptr。然后,我们定义了三个标记,分别标记为%one、%two和%default。接着,我们使用indirectbr指令根据%ptr的值跳转到不同的基本块,如果它等于0,则返回1;如果等于1,则返回2;否则返回-1。
5.调用指令(invoke)
invoke指令用于调用一个函数,并在发生异常时传递控制权。它的语法与call指令类似,但是多了一个异常处理分支。下面是一个示例:
define void @test() {
%catch = catchswitch within none [label %catch] unwind label %cleanup
invoke void @foo()
to label %normal
unwind label %catch
normal:
catchret from %catch to label %end
end:
ret void
catch:
%excp = catchpad within %catch [i8* null]
call void @handle()
catchret from %excp to label
其中,我们定义了一个函数test,它不接受任何参数。首先,我们使用catchswitch指令创建一个异常处理块%catch,然后使用invoke指令调用函数foo。如果函数调用成功,就跳转到标记%normal;否则,就跳转到异常处理块%catch。在标记%normal处,我们使用catchret指令将控制权返回到异常处理块%catch。在异常处理块%catch中,我们使用catchpad指令创建一个异常处理块%excp,并调用handle函数来处理异常。最后,我们使用catchret指令将控制权返回到invoke指令的unwind标记%cleanup。
6.恢复指令(resume)
resume指令用于从异常处理块中恢复执行。它的语法如下:
resume <type> <value>
其中,<type>是要恢复的异常类型,<value>是异常值。下面是一个示例:
define void @test() {
%catch = catchswitch within none [label %catch] unwind label %cleanu
invoke void @foo()
to label %normal
unwind label %catch
normal:
catchret from %catch to label %end
end:
ret void
catch:
%excp = catchpad within %catch [i8* null]
%is_error = icmp eq i32 %excp, 1
br i1 %is_error, label %handle, label %next
handle:
call void @handle()
resume void null
next:
catchret from %excp to label %end
}
在这个示例中,我们使用resume指令从异常处理块中恢复执行。在标记%catch处,我们使用catchpad指令创建一个异常处理块%excp。然后,我们使用icmp指令将异常值与1进行比较,并根据比较结果跳转到标记%handle或标记%next。在标记%handle处,我们调用handle函数来处理异常,并使用resume指令从异常处理块中恢复执行。在标记%next处,我们使用catchret指令将控制权返回到invoke指令的unwind标记%cleanup。
7.不可达指令(unreachable)
unreachable指令用于表示程序不会执行到这里。它的语法如下:
unreachable
下面是一个示例:
define i32 @test(i32 %a, i32 %b) {
%is_zero = icmp eq i32 %b, 0
br i1 %is_zero, label %error, label %compute
compute:
%result = sdiv i32 %a, %b
ret i32 %result
error:
unreachable
}
在这个示例中,我们定义了一个函数test,它的功能是计算a / b的值。在标记%compute处,我们使用sdiv指令计算a / b的值,并使用ret指令将结果返回。在标记%error处,我们使用unreachable指令表示程序不会执行到这里,因为b的值为0。在这种情况下,我们不需要返回任何值,因为程序已经崩溃了。
其他指令
除了上述介绍的七种控制流指令,llvm还有其他常用的指令。在本文中,我们将介绍phi、select、call、va_arg和landingpad这五个指令。
1.phi
phi指令用于在基本块之间传递值。它的语法如下:
%result = phi <type> [ <value1>, <label1> ], [ <value2>, <label2> ], ...
其中,<type>是要传递的值的类型,<value1>是要传递的第一个值,<label1>是要从中传递第一个值的基本块。其他的<value>和<label>对也类似。下面是一个示例:
define i32 @test(i32 %a, i32 %b) {
%cmp = icmp slt i32 %a, %b
br i1 %cmp, label %if_true, label %if_false
if_true:
%result1 = add i32 %a, 1
br label %merge
if_false:
%result2 = add i32 %b, 1
br label %merge
merge:
%result = phi i32 [ %result1, %if_true ], [ %result2, %if_false ]
ret i32 %result
}
在这个示例中,我们定义了一个函数test,它的功能是比较a和b的值,并返回一个结果。在标记%if_true处,我们使用add指令计算a+1的值;在标记%if_false处,我们使用add指令计算b+1的值。然后,在标记%merge处,我们使用phi指令选择一个值。具体来说,如果%cmp的值为true,我们就选择%result1的值(即a+1);否则,我们就选择%result2的值(即b+1)。
2.select
select指令用于根据条件选择两个值中的一个。它的语法如下:
%result = select i1 <cond>, <type> <iftrue>, <type> <iffalse>
其中,<cond>是要测试的条件,<iftrue>是条件为真时返回的值,<iffalse>是条件为假时返回的值。下面是一个示例:
define i32 @test(i32 %a, i32 %b) {
%cmp = icmp slt i32 %a, %b
%result = select i1 %cmp, i32 %a, i32 %b
ret i32 %result
}
在这个示例中,我们定义了一个函数test,它的功能是比较a和b的值,并返回一个结果。我们使用icmp指令将a和b进行比较,并将比较结果存储在%cmp中。然后,我们使用select指令根据`
3.call
call指令用于调用函数。它的语法如下:
%result = call <type> <function>(<argument list>)
其中,<type>是函数返回值的类型,<function>是要调用的函数的名称,<argument list>是函数参数的列表。下面是一个示例:
declare i32 @printf(i8*, ...)
define i32 @test(i32 %a, i32 %b) {
%sum = add i32 %a, %b
%format_str = getelementptr inbounds [4 x i8], [4 x i8]* @.str, i64 0, i64 0
call i32 (i8*, ...) @printf(i8* %format_str, i32 %sum)
ret i32 %sum
}
在这个示例中,我们首先使用add指令计算a+b的值,然后使用getelementptr指令获取全局字符串@.str的指针,该字符串包含格式化字符串%d\n。最后,我们使用call指令调用函数printf,将%format_str和%sum作为参数传递给它。
4.va_arg
va_arg指令用于在变参函数中获取下一个参数。它的语法如下:
%result = va_arg <type*> <ap>
其中,<type*>是参数类型的指针,<ap>是包含参数列表的指针。下面是一个示例:
declare i32 @printf(i8*, ...)
define i32 @test(i32 %a, i32 %b, ...) {
%ap = alloca i8*, i32 0
store i8* %0, i8** %ap
%sum = add i32 %a, %b
%format_str = getelementptr inbounds [4 x i8], [4 x i8]* @.str, i64 0, i64 0
%next_arg = va_arg i32*, i8** %ap
%value = load i32, i32* %next_arg
%product = mul i32 %sum, %value
call i32 (i8*, ...) @printf(i8* %format_str, i32 %product)
ret i32 %sum
}
在这个示例中,我们定义了一个变参函数test,它接受两个整数参数a和b,以及一个不定数量的其他参数。我们首先使用alloca指令在栈上分配一个指针,用于存储下一个参数的地址。然后,我们使用store指令将第一个参数的地址存储在该指针中。接下来,我们使用va_arg指令获取下一个参数的地址,然后使用load指令将该参数的值加载到%value中。最后,我们使用mul指令计算(%a + %b) * %value的值,并使用printf函数将其输出到标准输出。
5.landingpad
landingpad指令用于实现异常处理。它的语法如下:
%result = landingpad <type>
其中,<type>是异常处理函数返回值的类型。下面是一个示例:
declare void @llvm.landingpad(i8*, i32)
define void @test() personality i8* bitcast (i32 (...)* @__gxx_personality_v0 to i8*) {
%exn = landingpad i8*
%exn_val = extractvalue { i8*, i32 } %exn, 0
%exn_selector = extractvalue { i8*, i32 } %exn, 1
call void @printf(i8* getelementptr inbounds ([23 x i8], [23 x i8]* @.str, i64 0, i64 0), i8* %exn_val, i32 %exn_selector)
resume { i8*, i32 } %exn
}
在这个示例中,我们定义了一个函数test,它实现了异常处理。首先,我们使用personality关键字指定异常处理函数__gxx_personality_v0,它是GCC C++异常处理库的一部分。然后,我们使用landingpad指令获取异常对象和异常类型编号。我们使用extractvalue指令将其拆分为异常对象和异常类型编号,并将它们传递给printf函数。最后,我们使用resume指令重新抛出异常。
这里的异常处理机制是比较复杂的,需要更加深入的了解,这里不再赘述。
LLVM代码生成技术及在数据库中的应用
随着IT基础设施的发展,现代的数据处理系统需要处理更多的数据、支持更为复杂的算法。数据量的增长和算法的复杂化,为数据分析系统带来了严峻的性能挑战。近年来,我们可以在数据库、大数据系统和AI平台等领域看到很多性能优化的技术,技术涵盖体系结构、编译技术和高性能计算等领域。作为编译优化技术的代表,本文主要介绍基于LLVM的代码生成技术(简称Codeden)。
LLVM是一款非常流行的开源编译器框架,支持多种语言和底层硬件。开发者可以基于LLVM搭建自己的编译框架并进行二次开发,将不同的语言或者逻辑编译成运行在多种硬件上的可执行文件。对于Codegen技术来说,我们主要关注LLVM IR的格式以及生成LLVM IR的API。
在本文的如下部分,我们首先对LLVM IR进行介绍,然后介绍Codegen技术的原理和使用场景,最后我们介绍在阿里云自研的云原生数据仓库产品AnalyticDB PostgreSQL中,Codegen的典型应用场景。
二、LLVM IR简介及上手教程
在编译器理论与实践中,IR是非常重要的一环。IR的全称叫做Intermediate Representation,翻译过来叫“中间表示”。对于一个编译器来说,从上层抽象的高级语言到底层的汇编语言,要经历很多个环节(pass),经历不同的表现形式。而编译优化技术有很多种,每种技术作用的编译环节不同。但是IR是一个明显的分水岭。IR以上的编译优化,不需要关心底层硬件的细节,比如硬件的指令集、寄存器文件大小等。IR以下的编译优化,需要和硬件打交道。LLVM最为著名是它的IR的设计。得益于巧妙地IR设计,LLVM向上可以支持不同的语言,向下可以支持不同的硬件,而且不同的语言可以复用IR层的优化算法。

  上图展示了LLVM的一个框架图。LLVM把整个编译过程分为三步:

· 前端,把高级语言转换为IR。
· 中端,在IR层做优化。
· 后端,把IR转化为对应的硬件平台的汇编语言。
因此LLVM的扩展性很好。比如你要实现一个名为toyc的语言、希望运行在ARM平台上,你只需要实现一个toyc->LLVM IR的前端,其他部分调LLVM的模块就可以了。或者你要搞一个新的硬件平台,那么只需要搞定LLVM IR->新硬件这一阶段,然后该硬件就可以支持很多种现存的语言。因此,IR是LLVM最有竞争力的地方,同时也是学习使用LLVM Codegen的最核心的地方。
(一)LLVM IR基本知识
LLVM的IR格式非常像汇编,对于学习过汇编语言的同学来说,学会使用LLVM IR进行编程非常容易。对于没学过汇编语言的同学,也不用担心,汇编其实并不难。汇编难的不是学会,而是工程实现。
因为汇编语言的开发难度,会随着工程复杂度的提升呈指数级上升。接下来我们需要了解IR中最重要的三部分,指令格式、Basic Block & CFG,还有SSA。完整的LLVM IR信息请参考https://llvm.org/docs/LangRef.html
· 指令格式
LLVM IR提供了一种类似于汇编语言的三地址码式的指令格式。下面的代码片段是一个非常简单的用LLVM IR实现的函数,该函数的输入是5个i32类型(int32)的整数,函数的功能是计算这5个数的和并返回。
LLVM IR是支持一些基本的数据类型的,比如i8、i32、浮点数等。LLVM IR中的变量的命名是以 "%"开头,默认%0是函数的第一个参数、%1是第二个参数,依次类推。机器生成的变量一般是以数字进行命名,如果是手写的话,可以根据自己的喜好选择合适的命名方法。
LLVM IR的指令格式包括操作符、类型、输入、返回值。例如 "%6 = add i32 %0, %1"的操作符号是"add"、类型是"i32"、输入是"%0"和“%1”、返回值是"%6"。总的来说,IR支持一些基本的指令,然后编译器通过这些基本指令的来完成一些复杂的运算。
例如,我们在C中写一个形如“A * B + C”的表达式在LLVM IR中是通过一条乘法和一条加法指令来完成的,另外可能也包括一些类型转换指令。
  • define i32 @ir_add(i32, i32, i32, i32, i32){
  • %6 = add i32 %0, %1
  • %7 = add i32 %6, %2
  • %8 = add i32 %7, %3
  • %9 = add i32 %8, %4
  • ret i32 %9
  • }Basic Block & CFG
了解了IR的指令格式以后,接下来我们需要了解两个概念:Basic Block(基本块,简称BB)Control Flow Graph(控制流图,CFG)
下图(左)展示了一个简单的C语言函数,下图(中)是使用clang编译出来的对应的LLVM IR,下图(右)是使用graphviz画出来的CFG。结合这张图,我们解释下Basic Block和CFG的概念。
 

 

 在我们平时接触到的高级语言中,每种语言都会有很多分支跳转语句,比如C语言中有for, while, if等关键字,这些关键字都代表着分支跳转。开发者通过分支跳转来实现不同的逻辑运算。汇编语言通常通过有条件跳转和无条件跳转两种跳转指令来实现逻辑运算,LLVM IR同理。

比如在LLVM IR中"br label %7"意味着无论如何都跳转到名为%7的label那里,这是一条无条件跳转指令。"br i1 %10, label %11, label %22"是有条件跳转,意味着这如果%10是true则跳转到名为%11的label,否则跳转到名为%22的label。
在了解了跳转指令这个概念后,我们介绍Basic Block的概念。一个Basic Block是指一段串行执行的指令流,除了最后一句之外不会有跳转指令,Basic Block入口的第一条指令叫做“Leading instruction”。除了第一个Basic Block之外,每个Basic Block都会有一个名字(label)。第一个Basic Block也可以有,只是有时候没必要。
例如在这段代码当中一共有5个Basic Block。Basic Block的概念,解决了控制逻辑的问题。通过Basic Block, 我们可以把代码划分成不同的代码块,在编译优化中,有的优化是针对单个Basic Block的,有些是针对多个Basic Block的。
CFG(Control Flow Graph,控制流图)其实就是由Basic Block以及Basic Block之间的跳转关系组成的一个图。例如上图所示的代码,一共有5个Basic Block,箭头列出了Basic Block之间的跳转关系,共同组成了一个CFG。如果一个Basic Block只有一个箭头指向别的Block,那么这个跳转就是无条件跳转,否则是有条件跳转。
CFG是编译理论中一个比较简单而且很基础的概念,CFG更进一步是DFG(Data Flow Graph,数据流图),很多进阶的编译优化算法都是基于DFG的。对于使用LLVM进行Codegen开发的同学,理解CFG的概念即可。
· SSA
SSA的全称是Static Single Assignment(静态单赋值),这是编译技术中非常基础的一个理念。SSA是学习LLVM IR必须熟悉的概念,同时也是最难理解的一个概念。细心的读者在观察上面列出的IR代码时会发现,每个“变量”只会被赋值一次,这就是SSA的核心思想。因为从编译器的角度来看,编译器不关心“变量”,编译器是以“数据”为中心进行设计的。每个“变量”的每次写入,都生成了一个新的数据版本,编译器的优化是围绕数据版本展开的。接下来我们用如下的C语言代码来解释这一思想。
 

 

 上图(左)展示了一段简单的C代码,上图(右)是这段代码的SSA版本,也就是“编译器眼中的代码”。在C语言中,我们知道数据都是用变量来存储的,因此数据操作的核心是变量,开发者需要关心变量的生存时间、何时被赋值、何时被使用。但是编译器只关心数据的流向,因此每次赋值操作都会生成一个新的左值。

例如左边代码只有一个a, 但是在右边的代码有4个变量,因为a里面的数据一共有4个版本。除了每次赋值操作会生成一个新的变量,最后的一个phi节点会生成一个新的变量。在SSA中,每个变量都代表数据的一个版本。
也就是说,高级语言以变量为核心,而SSA格式以数据为核心。SSA中每次赋值操作都会生成一个版本的数据,因此在写IR的时候,时刻牢记IR的变量和高层语言不同,一个IR的变量代表数据的一个版本。Phi节点是SSA中的一个重要概念。在这个例子当中,a_4的取值取决于之前执行了哪个分支,如果执行了第一个分支,那么a_4 = a_1, 依次类推。
Phi节点通过判断这段代码是从哪个Basic Block跳转而来,选择合适的数据版本。LLVM IR自然也是需要开发者写Phi节点的,在循环、条件分支跳转的地方,往往需要手写很多phi节点,这是写LLVM IR时逻辑上比较难处理的地方。
(二)学会使用LLVM IR写程序
熟悉LLVM IR最好的办法就是使用IR写几个程序。在开始写之前,建议先花30分钟-1个小时再粗略阅读下官方手册(https://llvm.org/docs/LangRef.html),熟悉下都有哪些指令的类型。接下来我们通过两个简单的case熟悉下LLVM IR编程的全部流程。
下面是一个循环加法的函数片段。这个函数一共包含三个Basic Block,loop、loop_body和final。
其中loop是整个函数的开始,loop_body是函数的循环体,final是函数的结尾。在第5行和第6行,我们使用phi节点来实现结果和循环变量。
define i32 @ir_loopadd_phi(i32*, i32){
br label %loop
loop:
%i = phi i32 [0,%2], [%newi,%loop_body]
%res = phi i32[0,%2], [%new_res, %loop_body]
%break_flag = icmp sge i32 %i, %1
br i1 %break_flag, label %final, label %loop_body
loop_body:
%addr = getelementptr inbounds i32, i32* %0, i32 %i
%val = load i32, i32* %addr, align 4
%new_res = add i32 %res, %val
%newi = add i32 %i, 1
br label %loop
final:
ret i32 %res;
}
下面是一个数组冒泡排序的函数片段。这个函数包含两个循环体。LLVM IR实现循环本身就比较复杂,两个循环嵌套会更加复杂。如果能够用LLVM IR实现一个冒泡算法,基本上就理解了LLVM的整个逻辑了。
define void @ir_bubble(i32*, i32) {
%r_flag_addr = alloca i32, align 4
%j = alloca i32, align 4
%r_flag_ini = add i32 %1, -1
store i32 %r_flag_ini, i32* %r_flag_addr, align 4
br label %out_loop_head
out_loop_head:
;check break
store i32 0, i32* %j, align 4
%tmp_r_flag = load i32, i32* %r_flag_addr, align 4
%out_break_flag = icmp sle i32 %tmp_r_flag, 0
br i1 %out_break_flag, label %final, label %in_loop_head
in_loop_head:
;check break
%tmpj_1 = load i32, i32* %j, align 4
%in_break_flag = icmp sge i32 %tmpj_1, %tmp_r_flag
br i1 %in_break_flag, label %out_loop_tail, label %in_loop_body
in_loop_body:
;read & swap
%tmpj_left = load i32, i32* %j, align 4
%tmpj_right = add i32 %tmpj_left, 1
%left_addr = getelementptr inbounds i32, i32* %0, i32 %tmpj_left
%right_addr = getelementptr inbounds i32, i32* %0, i32 %tmpj_right
%left_val = load i32, i32* %left_addr, align 4
%right_val = load i32, i32* %right_addr, align 4
;swap check
%swap_flag = icmp sge i32 %left_val, %right_val
%left_res = select i1 %swap_flag, i32 %right_val, i32 %left_val
%right_res = select i1 %swap_flag, i32 %left_val, i32 %right_val
store i32 %left_res, i32* %left_addr, align 4
store i32 %right_res, i32* %right_addr, align 4
br label %in_loop_end
in_loop_end:
;update j
%tmpj_2 = load i32, i32* %j, align 4
%newj = add i32 %tmpj_2, 1
store i32 %newj, i32* %j, align 4
br label %in_loop_head
out_loop_tail:
;update r_flag
%tmp_r_flag_1 = load i32, i32* %r_flag_addr, align 4
%new_r_flag = sub i32 %tmp_r_flag_1, 1
store i32 %new_r_flag, i32* %r_flag_addr, align 4
br label %out_loop_head
final:
ret void
}
我们把如上的LLVM IR用clang编译器编译成object文件,然后和C语言写的程序链接到一起,即可正常调用。在上面提到的case中,我们只使用了i32、i64等基本数据类型,LLVM IR中支持struct等高级数据类型,可以实现更为复杂的功能。
(三)使用LLVM API实现Codegen
编译器本质上就是调用各种各样的API,根据输入去生成对应的代码,LLVM Codegen也不例外。在LLVM内部,一个函数是一个class,一个Basic Block试一个class, 一条指令、一个变量都是一个class。用LLVM API实现codegen就是根据需求,用LLVM内部的数据结构去实现相应的IR。
 
Value *constant = Builder.getInt32(16);
Value *Arg1 = fooFunc->arg_begin();
Value *val = createArith(Builder, Arg1, constant);
 
Value *val2 = Builder.getInt32(100);
Value *Compare = Builder.CreateICmpULT(val, val2, "cmptmp");
Value *Condition = Builder.CreateICmpNE(Compare, Builder.getInt1(0), "ifcond");
 
ValList VL;
VL.push_back(Condition);
VL.push_back(Arg1);
 
BasicBlock *ThenBB = createBB(fooFunc, "then");
BasicBlock *ElseBB = createBB(fooFunc, "else");
BasicBlock *MergeBB = createBB(fooFunc, "ifcont");
BBList List;
List.push_back(ThenBB);
List.push_back(ElseBB);
List.push_back(MergeBB);
 
Value *v = createIfElse(Builder, List, VL);
如上是一个用LLVM API实现codegen的例子。其实这就是个用C++写IR的过程,如果知道如何写IR的话,只需要熟悉下这套API就可以了。
这套API提供了一些基本的数据结构,比如指令、函数、基本块、llvm builder等,然后我们只需要调用相应的函数去生成这些对象即可。
一般来说,首先我们先生成函数的原型,包括函数名字、参数列表、返回类型等。然后我们在根据函数的功能,确定都需要有哪些Basic Block以及Basic Block之间的跳转关系,然后生成相应的Basic。最后我们再按照一定的顺序去给每个Basic Block填充指令。逻辑是,这个流程和用LLVM IR写代码是相仿的。
三、Codegen技术分析
如果我们用上文所描述的方法,生成一些简单的函数,并且用C写出对应的版本进行性能对比,我们就会发现,LLVM IR的性能并不会比C快。一方面,计算机底层执行的是汇编,C语言本身和汇编是非常接近的,了解底层的程序员往往能够从C代码中推测出大概会生成什么样的汇编。
另一方面,现代编译器往往做了很多优化,一些大大减轻了程序员的优化负担。因此,使用LLVM IR进行Codegen并不会获得比手写C更好的性能,而且使用LLVM Codegen有一些明显的缺点。想要真正用好LLVM,我们还需要熟悉LLVM的特点。
(一)缺点分析
· 开发难
实际开发中几乎不会有工程使用汇编作为主要开发语言,因为开发难度太大了,有兴趣的小伙伴可以试着写个快排感受一下。即使是数据库、操作系统这样的基础软件,往往也只是在少数的地方会用到汇编。
使用LLVM IR开发会有类似的问题。比如上文展示的最复杂例子是冒泡算法。
开发者用C写个冒泡只需要几分钟,但是用LLVM IR写个冒泡可能要一个小时。另外,LLVM IR很难处理复杂的数据结构,比如结构体、类。除了LLVM IR中的那些基本数据结构外,新增一个复杂的数据结构非常难。因此在实际的开发当中,采用Codegen会导致开发难度指数级上升。
· 调试难
开发者通常通过单步跟踪的方式去调试代码,但是LLVM IR是不支持的。一旦代码出问题,只能是人肉一遍一遍看LLVM IR。如果懂汇编的话,可以通过单步跟踪生成的汇编进行调试,但是汇编语言和IR之间并不是简单的映射关系,因此只能一定程度上降低调试难度,并不完全解决调试的问题。
· 运行成本
生成LLVM IR往往很快,但是生成的IR需要调用LLVM 中的工具进行优化、以及编译成二进制文件,这个过程是需要时间的(请联想一下GCC编译的速度)。
在数据库的开发过程中,我们的经验值是每个函数大约需要10ms-100ms的codegen成本。大部分的时间花在了优化IR和IR到汇编这两步。
(二)适用场景
了解了LLVM Codegen的缺点,我们才能去分析其优点、选择合适场景。下面这部分是团队在开发过程中总结的适合使用LLVM Codegen的场景。
· 场景1:Java/python等语言
上文中提到过LLVM IR并不会比C快,但是会比Java/python等语言快啊。例如在Java中,有时候为了提升性能,会通过JNI调用一些C的函数提升性能。同理,Java也可以调用LLVM IR生成的函数提升性能。
· 场景2:硬件和语言不兼容
LLVM支持多种后端,比如X86、ARM和GPU。对于一些硬件与语言不兼容的场景,可以利用LLVM实现兼容。例如如果我们的系统是用Java语言开发、想要调用GPU,可以考虑用LLVM IR生成GPU代码,然后通过JNI的方法进行调用。这套方案不仅支持NVIDIA的GPU,也支持AMD的GPU,而且对应生成的IR也可以在CPU上执行。
· 场景3:逻辑简化
 
以数据库为例,数据库执行引擎在执行过程中需要做大量的数据类型、算法逻辑相关的判断。这主要是由于SQL中的数据类型和逻辑,很多是在数据库开发时无法确定的,只能在运行时决定。这一部分过程,也被称为“解释执行”。我们可以利用LLVM在运行时生成代码,由于这个时候数据类型和逻辑已经确定,我们可以在LLVM IR中删除那些不必要的判断操作,从而实现性能的提升。
4. LLVM在数据库中的应用
在数据库当中,团队是用LLVM来进行表达式的处理,接下来我们以PostgreSQL数据库和云原生数据仓库AnalyticDB PostgreSQL为对比,解释LLVM的应用方法。
PostgreSQL为了实现表达式的解释执行,采用了一套“拼函数”的方案。PostgreSQL中实现了大量C函数,比如加减法、大小比较等,不同类型的都有。
SQL在生成执行计划阶段会根据表达式符号的类型和数据类型选择相应的函数、把指针存下来,等执行的时候再调用。因此对于 "a > 10 and b < 5"这样的过滤条件,假设a和b都是int32,PostgreSQL实际上调用了“Int8AndOp(Int32GT(a, 10), Int32LT(b, 5))”这样一个函数组合,就像搭积木一样。
这样的方案有两个明显的性能问题。一方面这种方案会带来比较多次数的函数调用,函数调用本身是有成本的。另一方面,这种方案必须要实现一个统一的函数接口,函数内部和外部都需要做一些类型转换,这也是额外的性能开销。
Odyssey使用LLVM 进行codegen,可以实现最小化的代码。因为在SQL下发以后,数据库是知道表达式的符号和输入数据的类型的,因此只需要根据需求选取相应的IR指令就可以了。
因此只需要三条IR指令,就可以实现这个表达式,然后我们把表达式封装成一个函数,就可以在执行的时候调用了。这次操作,把多次函数调用简化成了一次函数调用,大大减少了指令的总数量。
// 样例SQL
select count(*) from table where a > 10 and b < 5;
// PostgreSQL解释执行方案:多次函数调用
result = Int8AndOp(Int32GT(a, 10), Int32LT(b, 5));
// AnalyticDB PostgreSQL方案:使用LLVM codegen生成最小化底层代码
%res1 = icmp ugt i32 %a, 10;
%res2 = icmp ult i32 %b, 5;
%res = and i8 %res1, %res2;
在数据库中,表达式主要出现在几个场景。一类是过滤条件,通常出现在where条件中。一类是输出列表,一般跟在select之后。有些算子,比如join、agg等,它的判断条件中也可能会出现一些比较复杂的表达式。
因此表达式的处理是会出现在数据库执行引擎的各个模块的。在AnalyticDB PostgreSQL版中,开发团队抽象出了一个表达式处理框架,通过LLVM Codegen来处理这些表达式,从而提高了执行引擎的整体性能。
 

 

 LLVM作为一个流行的开源编译框架,近年来被用于数据库、AI等系统的性能加速。由于编译器理论本身门槛较高,因此LLVM的学习有一定的难度。而且从工程上,还需要对LLVM的工程特点和性能特征有比较准确的理解,才能找到合适的加速场景。

阿里云数据库团队的云原生数据仓库产品AnalyticDB PostgreSQL版基于LLVM实现了一套运行时的表达式处理框架,能够有效地提高系统在进行复杂数据分析时的性能。
 
 
参考文献链接
LLVM Language Reference Manual — LLVM 17.0.0git documentation
https://mp.weixin.qq.com/s/iMcohNy16eSzEr_HbNCOIA