文章来自微信公众号“科文路”,欢迎关注、互动。转发须注明出处。
原文地址:LLVM的Intrinsics函数及其实现 - 知乎 (zhihu.com)
LLVM 的 Intrinsics 函数及其实现
原创文章,转载请注明出处。
作者:汪岩
1 什么是 Intrinsic 函数
Intrinsic 函数是编译器内建的函数,由编译器提供,类似于内联函数。但与内联函数不同的是,因为 Intrinsic 函数是编译器提供,而编译器与硬件架构联系紧密,因此编译器知道如何利用硬件能力以最优的方式实现这些功能。通常函数的代码是 inline 插入,避免函数调用开销。LLVM 支持 Intrinsic 函数的概念。这些函数的名称和语义可以是预先定义,也可以自定义,要求遵守特定的约定。 在有些情况下,可能会调用库函数。例如,在参考文献 中列出的函数,都是调用 libc。总的来说,这些 Intrinsic 函数代表了 LLVM 语言的一种扩展机制,当添加到语言中时,不要求改变 LLVM 的任何转化过程。对其它编译器,Intrinsic 函数也称为内建函数。
- 在 LLVM 中,Intrinsic 函数一般是在 IR 级代码优化时引入的,也就是由前端产生。
- 也可以在程序代码中写 Intrinsic 函数,并通过前端直接发射。
这些函数名的前缀一般是保留字 “llvm.”。LLVM 后端选择用最高效的形式将 Intrinsic 函数转换给硬件执行,可以将 Intrinsic 函数拆分为一系列机器指令,也可以映射为单独一条机器指令,并直接调用相应的硬件功能。下文中会针对这两种情况给出实例。
Intrinsic 函数一般是外部函数,开发者不能在自己的代码中实现函数体,而只能调用这些 Intrinsic 函数。获得 Intrinsic 函数的地址是非法的。
2 输出 Intrinsic 函数
以下举例说明 LLVM 如何通过其 Intrinsic 函数优化特定部分代码。
1 2 3 4 5
| #include<string.h> int foo(void){ char str[10] = "str"; return 0; }
|
由 Clang 生产的 LLVM IR 如下:
1 2 3 4 5 6 7
| define i32 @foo() #0 { entry: %str = alloca [10 x i8], align 1 %0 = bitcast [10 x i8]* %str to i8* call void @llvm.memcpy.p0i8.p0i8.i64(i8* %0, i8* getelementptr inbounds ([10 x i8]* @foo.str, i32 0, i32 0), i64 10, i32 1, i1 false) ret i32 0 }
|
其中,llvm.memcpy 就是 clang 输出的 Intrinsic 函数。如果 LLVM 没有定义 llvm.memcpy,相应的内存操作 LLVM IR 代码就应该是一系列 store constant into str[0..3]
内存访问指令,而这些指令通常都是极耗时的。LLVM 后端可将 llvm.memcpy 拆分为一系列高效机器指令,也可以映射为一条特定的机器指令,直接调用硬件的内存操作功能。
再举一例。
1 2 3 4 5 6 7
| int func() { int a[5]; for (int i = 0; i != 5; ++i) a[i] = 0; return a[0]; }
|
使用 Clang 生成未经优化的 IR 代码,其中不包括任何 Intrinsic 函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| define dso_local i32 @_Z4funcv() #0 { entry: %a = alloca [5 x i32], align 16 %i = alloca i32, align 4 store i32 0, i32* %i, align 4 br label %for.cond for.cond: ; preds = %for.inc, %entry %0 = load i32, i32* %i, align 4 %cmp = icmp ne i32 %0, 5 br i1 %cmp, label %for.body, label %for.end for.body: ; preds = %for.cond %1 = load i32, i32* %i, align 4 %idxprom = sext i32 %1 to i64 %arrayidx = getelementptr inbounds [5 x i32], [5 x i32]* %a, i64 0, i64 %idxprom store i32 0, i32* %arrayidx, align 4 br label %for.inc for.inc: ; preds = %for.body %2 = load i32, i32* %i, align 4 %inc = add nsw i32 %2, 1 store i32 %inc, i32* %i, align 4 br label %for.cond for.end: ; preds = %for.cond %arrayidx1 = getelementptr inbounds [5 x i32], [5 x i32]* %a, i64 0, i64 0 %3 = load i32, i32* %arrayidx1, align 16 ret i32 %3 }
|
然后使用 opt 工具对 IR 做 O1 级别优化,得到 IR 如下:
1 2 3
| define i32 @_Z4funcv() #0 { … call void @llvm.memset.p0i8.i64(i8* %a2, i8 0, i64 20, i32 16, i1 false)
|
其中重要的优化是调用 Intrinsic 函数 llvm.memset.p0i8.i64 为数组填 0。Intrinsic 函数也能用来实现代码的向量化和并行化,从而生成更优化的代码。比如,可以调用 libc 中最优化版本的 memset。
有些 Intrinsic 函数可以重载,比如表示相同操作,但数据类型不同的一族函数。重载通常用来使 Intrinsic 函数可以在任何整数类型上操作。一个或多个参数类型或结果类型可以被重载以接受任何整数类型。
被重载的 Intrinsic 函数名中会包括重载的参数类型,函数名中的每一个参数类型前会有一个句点。只有被重载的类型才会有名称后缀。例如,llvm.ctpop 函数参数是任意宽度的整数,并且返回相同整型宽度的整数。这会引出一族函数,例如 i8 @llvm.ctpop.i8(i8 %val) and i29 @llvm.ctpop.i29(i29 %val). 其中都只有一种类型被重载,函数名中也只有一种类型后缀,如. i8 和. i29。以为参数类型和返回值类型匹配,二者在函数名中共用一个名称后缀。
3 如何定义新 Intrinsic 函数
在使用 LLVM 过程中,开发者也许需要对 LLVM 做定制。这时需要在 LLVM 中添加代码,可能是一个基础类型,可能是一个新 Intrinsic 函数,或者是新的指令。对 LLVM 做扩展需要很大的工作量,涉及更新扩展时要用到的所有 pass。而增加一个 Intrinsic 函数远比增加指令容易,并且对优化 pass 是透明的。如果开发者要增加的功能可以表示成函数调用,Intrinsic 函数是一个不错的可选方法。
要增加 intrinsic 函数,首先要在 LLVM 框架中定义该函数,还有可能要在 clang 中注册该函数,这样前端才能支持在 c 代码中使用这个 intrinsic 函数。这样就可能修改从前端到后端各个不同层次的代码。下例是在自定义后端中实现用自定义 Intrinsic 函数取代 NVVM Intrinsic 函数。
已知有如下 NVVM Intrinsic 函数,这些 Intrinsic 函数是用于支持读 PTX 特殊寄存器:
1 2 3 4 5 6 7 8 9 10 11 12 13
| i32 @llvm.nvvm.read.ptx.sreg.tid.x() i32 @llvm.nvvm.read.ptx.sreg.tid.y() i32 @llvm.nvvm.read.ptx.sreg.tid.z() i32 @llvm.nvvm.read.ptx.sreg.ntid.x() i32 @llvm.nvvm.read.ptx.sreg.ntid.y() i32 @llvm.nvvm.read.ptx.sreg.ntid.z() i32 @llvm.nvvm.read.ptx.sreg.ctaid.x() i32 @llvm.nvvm.read.ptx.sreg.ctaid.y() i32 @llvm.nvvm.read.ptx.sreg.ctaid.z() i32 @llvm.nvvm.read.ptx.sreg.nctaid.x() i32 @llvm.nvvm.read.ptx.sreg.nctaid.y() i32 @llvm.nvvm.read.ptx.sreg.nctaid.z() i32 @llvm.nvvm.read.ptx.sreg.warpsize()
|
这些 Intrinsic 函数在 .ll
中的调用形式如下:
1 2 3 4 5 6 7
| define <target>_kernel void @nvvm_read_ptx_sreg_tid_x() #0 { ... %tid.x = call i32 @llvm.nvvm.read.ptx.sreg.tid.x() ... ret void } declare i32 @llvm.nvvm.read.ptx.sreg.tid.x()
|
.ll
文件中的函数可以调用这类 Intrinsic,但要在 .ll
文件中用 declare
声明。这时,Intrinsic 函数的实现可以在另一个 .ll
文件中。或者在某个 lib 中。
如果希望用自定义 Intrinsic 函数取代 NVVM Intrinsic 函数,则需要先定义自定义 Intrinsic 函数。假设希望用 Intrinsic int_<target>_workitem_id
取代 llvm.nvvm.read.ptx.sreg.tid
,Intrinsic int_<target>_workitem_id
定义如下:
a. llvm/include/llvm/IR/Intrinsics<target>.td
:
如果在 llvm/include/llvm/IR/
路径下没有与自定义 backend 对应的 Intrinsics<target>.td
文件,可以拷贝已有 backend 的 td 文件,然后在其上修改,这是一个比较快捷的方法。增加对应的 td 文件后,不要忘记在 Intrinsics.td
中包含自定义 backend 的 td 文件,以便框架知道 td 文件的存在。
include "llvm/IR/Intrinsics<target>.td"
在 td 文件中增加自定义 Intrinsic 函数入口,描述 Intrinsic 函数的内存访问优化特性(这控制 Intrinsic 函数是否会被死代码消除、公共子表达式消除等)。任何使用 llvm_any*_ty 类型的 Intrinsic 函数会被 tblgen 认为重载,并在 Intrinsic 函数名中增加后缀。
下例中,Intrinsic<...>
中的内容是对函数签名,描述该 intrinsic 应该如何被调用。签名包括三个部分:返回类型、参数类型和一组标志。这组标志提示了在优化时应该如何处理这个 intrinsic。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class <target>ReadPreloadRegisterIntrinsic : Intrinsic<[llvm_i32_ty], [], [IntrNoMem, IntrSpeculatable]>; multiclass <target>ReadPreloadRegisterIntrinsic_xyz { def _x : <target>ReadPreloadRegisterIntrinsic; def _y : <target>ReadPreloadRegisterIntrinsic; def _z : <target>ReadPreloadRegisterIntrinsic; } let TargetPrefix = "<target>" in { ... defm int_<target>_workitem_id : <target>ReadPreloadRegisterIntrinsic_xyz; class <target>AtomicIncIntrin : Intrinsic<[llvm_anyint_ty], [llvm_anyptr_ty, LLVMMatchType<0>, llvm_i32_ty, llvm_i32_ty, llvm_i1_ty], [IntrArgMemOnly, NoCapture<0>], "", [SDNPMemOperand] >; def int_<target>_atomic_load_add_f32 : <target>AtomicIncIntrin;
|
LLVM 定义 Intrinsic 函数借用了 GCC builtin,如下例中的 GCCBuiltin<"__nvvm_read_ptx_ sreg_" # regname # "_x">;
。
b. llvm/include/llvm/IR/IntrinsicsNVVM.td
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| multiclass PTXReadSRegIntrinsic_v4i32<string regname> {
def _x : Intrinsic<[llvm_i32_ty], [], [IntrNoMem]>, GCCBuiltin<"__nvvm_read_ptx_sreg_" # regname # "_x">; def _y : Intrinsic<[llvm_i32_ty], [], [IntrNoMem]>, GCCBuiltin<"__nvvm_read_ptx_sreg_" # regname # "_y">; def _z : Intrinsic<[llvm_i32_ty], [], [IntrNoMem]>, GCCBuiltin<"__nvvm_read_ptx_sreg_" # regname # "_z">; def _w : Intrinsic<[llvm_i32_ty], [], [IntrNoMem]>, GCCBuiltin<"__nvvm_read_ptx_sreg_" # regname # "_w">; } ... defm int_nvvm_read_ptx_sreg_tid : PTXReadSRegIntrinsic_v4i32<"tid">; def int_nvvm_atomic_load_add_f32 : Intrinsic<[llvm_float_ty], [LLVMAnyPointerType<llvm_float_ty>, llvm_float_ty], [IntrArgMemOnly, NoCapture<0>]>;
|
c. 在. ll 文件 llvm/test/CodeGen/<target>/*intrinsics.ll
中增加测试用例:
1 2 3 4 5 6 7 8 9
| ; Check that nvvm intrinsics are replaced with <target> intrinsics ; RUN: opt -<target>-lower-intrinsics -S < %s | FileCheck %s --check-prefix=CHECK ; CHECK-LABEL: @nvvm_read_ptx_sreg_tid_x define <target>_kernel void @nvvm_read_ptx_sreg_tid_x() #0 { ; CHECK: @llvm.<target>.workitem.id.x() %tid.x = call i32 @llvm.nvvm.read.ptx.sreg.tid.x() ret void } declare i32 @llvm.nvvm.read.ptx.sreg.tid.x()
|
d. 在 LowerIntrinsics pass 中替换 intrinsics 函数,实现代码位于文件 llvm/lib/Target/<target>/<target>LowerIntrinsics.cpp
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| bool lowerNVVMIntrinsics(CallInst *CI);
bool <target>LowerIntrinsics::lowerNVVMIntrinsics(CallInst *CI) { IRBuilder<> Builder(CI); const Function *Callee = CI->getCalledFunction(); if (!Callee) { return false; } CallSite CS(CI); switch (Callee->getIntrinsicID()) { case Intrinsic::nvvm_read_ptx_sreg_tid_x: replaceCallWithIntrinsic(Intrinsic::<target>_workitem_id_x, CI, CS.arg_begin(), CS.arg_end()); return true; … default: return false; } }
|
/// 在此函数中将 NVVM intrinsic 函数转为自定义 intrinsic 函数 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| template <class ArgIt> static CallInst *replaceCallWithIntrinsic(Intrinsic::ID Intrinsic, CallInst *CI, ArgIt ArgBegin, ArgIt ArgEnd) { IRBuilder<> Builder(CI->getParent(), CI->getIterator()); SmallVector<Value *, 8> Args(ArgBegin, ArgEnd); CallInst *NewCI = NULL; if (Args.empty()) { NewCI = Builder.CreateIntrinsic(Intrinsic); } else { NewCI = Builder.CreateIntrinsic(Intrinsic, Args); } NewCI->setName(CI->getName()); if (!CI->use_empty()) CI->replaceAllUsesWith(NewCI); CI->eraseFromParent(); return NewCI; }
|
调用 opt 工具,执行如下命令:
bin/opt -<target>-lower-intrinsics -S ../test/CodeGen/<target>/nvvmintrinsics.ll
输出如下:
1 2 3 4 5 6 7
| ; ModuleID = '../test/CodeGen/<target>/nvvmintrinsics.ll' source_filename = "../test/CodeGen/<target>/nvvmintrinsics.ll" ; Function Attrs: nounwind define <target>_kernel void @nvvm_read_ptx_sreg_tid_x() #0 { %tid.x1 = call i32 @llvm.<target>.workitem.id.x() ret void }
|
可见,函数体中的 %tid.x = call i32 @llvm.nvvm.read.ptx.sreg.tid.x()
已经替换为 %tid.x1 = call i32 @llvm.<target>.workitem.id.x()
。
e. 在 llvm/lib/Target/<target>/<target>ISelLowering.cpp
将 PTX Intrinsic 函数转换成自定义 Intrinsic 函数后,还要实现自定义 Intrinsic 函数的具体功能。在这个例子中,就是要实现 @llvm.<target>.workitem.id.x()
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| SDValue <TARGET>TargetLowering::LowerOperation(SDValue Op, SelectionDAG &DAG) { … case ISD::INTRINSIC_WO_CHAIN: return LowerINTRINSIC_WO_CHAIN(Op, DAG); … } SDValue <TARGET>TargetLowering::LowerINTRINSIC_WO_CHAIN(...){ ... switch (IntrinsicID) { ... case Intrinsic::<target>_workitem_id_x: { return loadInputValue(DAG, &<target>::VGPR_32RegClass, MVT::i32, SDLoc(DAG.getEntryNode()), MFI->getArgInfo().WorkItemIDX); } …
|
4 Add relu
as LLVM intrinsic
下例说明为了支持 AI 模型中的 relu 激活函数,需要在 LLVM 中做的修改。首先要在 ISA 定义中增加向量 relu 指令,并在编译器中提供相应的 Intrinsic 函数支持。这个例子说明了 Intrinsic 函数重载的用法,实现过程如下:
a. 在 llvm/include/llvm/IR/Intrinsics<target>.td
中添加 Intrinsic 函数定义,其中支持 i32、i16、f32、f16 四种不同数据类型的 relu 操作:
1 2 3 4 5 6 7 8
| def int_<target>_m_relu_i32 : GCCBuiltin<"__builtin_<target>_m_relu_i32">, Intrinsic<[llvm_i32_ty], [llvm_i32_ty], [IntrConvergent]>; def int_<target>_m_relu_i16 : GCCBuiltin<"__builtin_<target>_m_relu_i16">, Intrinsic<[llvm_i16_ty], [llvm_i16_ty], [IntrConvergent]>; def int_<target>_m_relu_f32 : GCCBuiltin<"__builtin_<target>_m_relu_f32">, Intrinsic<[llvm_float_ty], [llvm_float_ty], [IntrConvergent]>; def int_<target>_m_relu_f16 : GCCBuiltin<"__builtin_<target>_m_relu_f16">, Intrinsic<[llvm_half_ty], [llvm_half_ty], [IntrConvergent]>;
|
b. 在目标平台的指令定义文件 <target>Instruction.td
中增加向量 relu 指令定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| let hasSideEffects = 1, mayStore = 1, mayLoad = 1 in { def V_RELU_I32 : MLOP1p_i32<"v_relu_i32", [(set i32:$vdst, (int_<target>_m_relu_i32 i32:$src0))] >; def V_RELU_I16 :MLOP1p_i16<"v_relu_i16", [(set i16:$vdst, (int_<target>_m_relu_i16 i16:$src0))] >; def V_RELU_F32 : MLOP1p_f32<"v_relu_f32", [(set f32:$vdst, (int_<target>_m_relu_f32 f32:$src0))] >; def V_RELU_F16 : MLOP1p_f16<"v_relu_f16", [(set f16:$vdst, (int_<target>_m_relu_f16 f16:$src0))] >; }
|
与 Intrinsic 函数定义相应,ISA 指令定义也支持 i32、i16、f32、f16 四种不同数据类型的 relu 操作。
c. 在测试用例 test/Codegen/<target>/relu.ll
中实现调用 relu Intrinsic 函数的代码(仅以 i32 数据类型为例):
1 2 3 4 5 6 7
| declare i32 @llvm.<target>.m.relu.i32(i32) … define void @test(i32 addrspace(1)* %out, i32 %in) { %res = call i32 @llvm.<target>.m.relu.i32(i32 %in) store i32 %res, i32 addrspace(1)* %out, align 4 ret void }
|
都看到这儿了,不如关注每日推送的“科文路”、互动起来~