基于毕昇编译器,可使用如下两种方法使能SVE指令集:
1 | $ clang -mcpu=hip09 |
1 2 | $ clang -march=armv8+sve $ clang -march=armv8+sve+sve2 |
使能SVE指令集后,可以通过如下四种方式生成SVE指令:
毕昇编译器在-O2及以上的优化等级会使能自动矢量化,此外可通过-fvectorize选项单独打开自动矢量化相关优化。使能SVE指令集后,毕昇编译器会基于相关指令进行自动矢量化。
优化诊断:
实际用户代码中,会有很多循环因为各种原因(比如:过于复杂的控制流,数据类型不支持等)无法完成自动矢量化。毕昇编译器提供了打印诊断信息的能力方便开发者调试,通过该功能,用户可以获取到代码中所有循环是否成功进行自动矢量化以及循环未被自动矢量化的原因,并针对部分情况提示可能可以矢量化对应循环的方法。该功能包含如下三个选项:
1. -Rpass=loop-vectorize:提示所有被矢量化的循环。
2. -Rpass-missed=loop-vectorize:提示所有未被矢量化的循环。
3. -Rpass-analysis=loop-vectorize:提示所有未被矢量化的循环及其原因,并针对部分失败情况提示可以矢量化循环的方法。
例:test_Rpass.c
1 2 3 4 5 6 7 | #include "math.h" void test_rpass(float * a, int n) { for (int i = 0; i < n; i++) { a[i] = sqrt(a[i]); } } |
针对上述用例,仅添加-mcpu=hip09 -O3选项的情况下,编译器无法对其进行自动矢量化,因此有:
添加-Rpass-missed=loop-vectorize提示如下,
1 2 3 | test_Rpass.c:4:3: remark: loop not vectorized [-Rpass-missed=loop-vectorize] 4 | for (int i = 0; i < n; i++) { | ^ |
上文表示第4行的循环未被编译器自动矢量化。添加-Rpass-analysis=loop-vectorize提示如下:
1 2 3 4 5 6 | test_Rpass.c:5:12: remark: loop not vectorized: library call cannot be vectorized. Try compiling with -fno-math-errno, -ffast-math, or similar flags [-Rpass-analysis=loop-vectorize] 5 | a[i] = sqrt(a[i]); | ^ test_Rpass.c:4:3: remark: loop not vectorized: instruction cannot be vectorized [-Rpass-analysis=loop-vectorize] 4 | for (int i = 0; i < n; i++) { | ^ |
上文表示第4行的循环未被编译器自动矢量化,且原因是循环中存在库函数的调用,并提示用户可以尝试添加-fno-math-errno, -ffast-math等选项。该用例的情况,添加-ffast-math选项后编译器可以对该循环进行自动矢量化,此时再添加-Rpass=loop-vectorize选项则提示如下:
1 2 3 | test_Rpass.c:4:3: remark: vectorized loop (vectorization width: vscale x 4, interleaved count: 2) [-Rpass=loop-vectorize] 4 | for (int i = 0; i < n; i++) { | ^ |
上文表示第4行的循环成功被编译器矢量化了,并给出了矢量化的一些具体信息。
自动矢量化相关导语:
用户可以通过在代码中添加导语辅助编译器进行基于SVE的自动矢量化,下文会摘取若干与自动矢量化相关的导语及关键字进行介绍:
1. 告知编译器,忽略可能的内存依赖并对该循环进行矢量化:
1 | #pragma ivdep
|
fortran:
1 | !DIR$ IVDEP |
2. 告知编译器,不考虑编译器内部的costmodel,即使编译器分析认为矢量化会带来性能负收益,也强制对该循环进行矢量化:
1 | #pragma vector always
|
fortran:
1 | !DIR$ VECTOR ALWAYS |
3. 告知编译器不要对该循环进行自动矢量化:
1 | #pragma clang loop vectorize(disable)
|
fortran:
1 | !DIR$ NOVECTOR |
4. 告知编译器,该循环的迭代间不存在数据依赖,在进行自动矢量化分析过程中不需要考虑对应限制:
c/c++:
1 | #pragma clang loop vectorize(assume_safety)
|
5. 告知编译器,对该循环进行定长(fixed)/变长(scalable)的自动矢量化:
c/c++:
1 | #pragma clang loop vectorize(enable) vectorize_width(fixed)
|
1 | #pragma clang loop vectorize(enable) vectorize_width(scalable)
|
6. 告知编译器,对该未矢量化的循环进行指定次数的循环展开(unroll),_value_对应循环展开的次数:
1 | #pragma clang loop unroll_count(_value_)
|
fortran:
1 | !DIR$ UNROLL(_value_) |
7. 告知编译器,对该矢量化的循环进行指定次数的循环展开(interleave),_value_对应循环展开的次数:
c/c++:
1 | #pragma clang loop interleave_count(_value_)
|
8. 告知编译器,对指定变量的不规则访存可以使用TBL指令优化。_value_对应目标数据。_num_可选参数,指定TBL指令对应向量寄存器数量,当前支持1、2, 默认为1。
TBL指令的正确性由用户保证,即保证数据可以存储到指定数量的向量寄存器中,参考Arm对TBL指令介绍
c/c++:
1 | #pragma clang loop lookup(_value_, _num_)
|
下文针对#pragma clang loop vectorize(assume_safety)给出了一个具体的例子。
1 2 3 4 5 6 7 8 9 | //test_pragma.c void update(int *restrict x, int *restrict idx, int count) { #pragma clang loop vectorize(assume_safety) for (int i = 0; i < count; i++) { x[idx[i]]++; } } |
上述循环,若不添加导语,因为idx[i]中可能存在相同的值,如果进行了矢量化,会有存在内存冲突的可能,因此编译器不会进行自动矢量化,若用户确认idx[i]中不存在重复值,则可以在循环前添加#pragma clang loop vectorize(assume_safety)导语,告知编译器迭代间不存在数据依赖,编译器就会进行自动矢量化,生成的汇编指令中包含下述片段:
1 2 3 4 5 6 7 8 9 | … ld1w { z0.s }, p0/z, [x1, x10, lsl #2] add x10, x10, x11 cmp x9, x10 ld1w { z1.s }, p0/z, [x0, z0.s, sxtw #2] add z1.s, z1.s, #1 // =0x1 st1w { z1.s }, p0, [x0, z0.s, sxtw #2] b.ne .LBB0_7 … |
选项调优:
此外,用户可以通过调整编译选项来调整编译器的优化细节,从而进一步提升SVE自动矢量化代码的性能,下文会介绍一部分可供尝试的手段。
1. 指定定长编程VLS模式编程:
SVE允许VLA(vectorlength agnostic)和VLS(vector length specific)两种编程模式,也称(向量)变长编程和定长编程;两者的区别在于是否在编程时向量寄存器宽度是否已知。变长编程的好处显而易见,代码一次编译后可以在不同向量宽度的硬件上执行而不需要重新编译,而定长编程由于在编译时给编译器提供了向量宽度这一重要信息,编译器可以执行更多的优化从而生成的代码往往具有更佳的性能。毕昇编译器默认生成VLA代码,当用户通过-msve-vector-bits=<length>传入向量宽度信息时,编译器可以生成VLS代码。
1 2 | $ clang -march=armv8+sve -msve-vector-bits=256 #通过选项显示指定生成256-bit的VLS代码 $ clang -mcpu=hip09 #通过指定硬件平台间接指导编译器生成256-bit的VLS代码 |
2. -ffast-math选项:
进入fast-math模式,允许编译器对浮点运算进行更激进的优化。在该模式下,编译器可以使能一些损精度的SVE指令,一些阻碍自动矢量化的原因也可能因为更激进的浮点运算优化不再阻塞自动矢量化。
该选项会影响浮点精度,建议在对精度不敏感的情况下使用。
3. 使能SVE版本矢量化数学库:
毕昇编译器支持针对可并行进行的数学函数计算,生成对应数学函数的矢量化版本接口调用。该功能使用-fveclib=<mathlib-name>控制。针对鲲鹏平台,毕昇编译器集成了libksvml矢量化数学库,4.0.0及更高版本中集成的libksvml矢量化数学库提供了SVE版本的矢量化数学函数接口,可以使用-fveclib=KPL_SVML_SVE -fno-math-errno -lm -lksvml选项使能(可参考下述命令)。
1 | $ clang -O3 -mcpu=hip09 -fveclib=KPL_SVML_SVE -fno-math-errno -lm -lksvml -S test_veclib.c #指定生成libksvml支持的SVE版本矢量化数学函数调用,并链接对应库 |
可参考下述用例test_veclib.c:
1 2 3 4 5 6 | #include "math.h" void foo(double *f, int n) { for (int i = 0; i < n; ++i) f[i] = cos(f[i]); } |
通过上述编译命令,编译器生成的汇编指令会包括下述片段(出于简洁考虑,生成下述汇编时额外添加了-fno-unroll-loops选项关闭循环展开优化)。
1 2 3 4 5 6 | ld1d { z0.d }, p4/z, [x19, x23, lsl #3] bl _ZGVsNxv_cos #调用SVE版本的矢量化数学函数接口 st1d { z0.d }, p4, [x19, x23, lsl #3] add x23, x23, x22 cmp x21, x23 b.ne .LBB0_8 |
4. 调整/禁用Gather/Scatter操作:
Gather/Scatter指读/写数据时,对应的数据索引非连续的场景,如下述例子test_gather_scatter.c:
1 2 3 4 5 6 7 8 9 10 | void foo1 (int * __restrict__ y, int * __restrict__ x, int * __restrict__ idx, int size) { for (int i = 0; i < size; i++) { y[i] = x[idx[i]]; //读数据索引非连续 } } void foo2 (int * __restrict__ y, int * __restrict__ x, int * __restrict__ idx, int size) { for (int i = 0; i < size; i++) { y[idx[i]] = x[i]; //写数据索引非连续 } } |
使能SVE指令集后,毕昇编译器可以支持Gather/Scatter场景的矢量化,生成的汇编中包括如下片段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | … ld1w { z0.s }, p0/z, [x2, x10, lsl #2] ld1w { z0.s }, p0/z, [x1, z0.s, sxtw #2] #Gather指令 st1w { z0.s }, p0, [x0, x10, lsl #2] add x10, x10, x11 cmp x9, x10 b.ne .LBB0_6 … ld1w { z0.s }, p0/z, [x1, x10, lsl #2] ld1w { z1.s }, p0/z, [x2, x10, lsl #2] add x10, x10, x11 cmp x9, x10 st1w { z0.s }, p0, [x0, z1.s, sxtw #2] #Scatter指令 b.ne .LBB1_6 … |
但由于Gather/Scatter指令开销较大,自动矢量化过程中会基于cost model评估是否执行矢量化方案。但考虑到编译器的cost model是静态的评估分析,在实际场景中不一定可靠,可能会导致一些需要进行Gather/Scatter矢量化的场景未成功矢量化,或是不应该Gather/Scatter矢量化的场景进行了矢量化。比较典型的案例是,当Gather步长非常大时,cache misses可能会非常严重,Gather矢量化会因为“长尾效应”拖慢流水线。
针对上述问题,毕昇编译器对用户开放了调优选项,可以让用户手动调整Gather/Scatter矢量化的cost值,及完全禁用Gather/Scatter指令(可参考下述命令)。
1 2 3 | $ clang -mcpu=hip09 -O3 -mllvm -sve-gather-overhead=[constant unsigned] #Gather场景,值越大,生成的Gather指令越少,默认为5 $ clang -mcpu=hip09 -O3 -mllvm -sve-scatter-overhead=[constant unsigned] #Scatter场景,值越大,生成的Scatter指令越少,默认为5 $ clang -mcpu=hip09 -O3 -mllvm -prefer-gather-scatter=[true|false] #开启/关闭 Gather/Scatter场景矢量化,默认为true |
针对上文给出的例子,在打开-mcpu=hip09 -O3的情况下,毕昇编译器会进行Gather/Scatter场景矢量化,可以通过-mllvm -prefer-gather-scatter=false直接禁用Gather/Scatter或者通过-mllvm -sve-gather-overhead=10 -mllvm -sve-scatter-overhead=10调整cost使编译器不再进行Gather/Scatter矢量化。
5. BOSCC优化
针对带控制流的循环,也就是循环体中包含形如“if-else”/“if-continue”的代码(如下述用例)。如不使能BOSCC优化,编译器在进行自动矢量化时会将循环体拍平,消除所有分支,这种矢量化方案有一个比较大的缺点:无论条件是否成立,原始循环if分支内的所有代码都会执行。若实际场景中,X[i]大部分为0,则会因为自动矢量化引入大量冗余的访存操作,反而导致性能下降。
1 2 3 4 5 6 7 8 | //test_boscc.c void foo1 (int * __restrict__ A, int * __restrict__ B, int * __restrict__ C, int * __restrict__ X, int size) { for (unsigned i = 0; i < size; i++) { if (X[i]) { A[i] = B[i] + C[i]; } } } |
针对上述场景,可以通过选项:-enable-boscc-vectorization=[true|false]使能BOSCC优化,在矢量化代码中增加判断,若一次矢量操作中所有判断条件(对应X[i])均为否,则直接跳过本次矢量操作的后续代码。该优化对于大批量判断条件均不成立的数据集,性能有大幅度提升(相比未开启BOSCC特性)。
1 | $ clang -mcpu=hip09 -O3 -mllvm -enable-boscc-vectorization=true -S test_boscc.c #开启BOSCC优化,默认为false |
BOSCC特性并非针对所有场景都有性能收益,毕昇编译器提供上述选项开启BOSCC优化,作为一个可供用户尝试的调优手段。
6. 尾块折叠
尾块折叠优化技术是基于SVE向量化优化的延伸,利用predicate寄存器控制循环执行的有效迭代状态,从而将非整数倍向量化的分支部分折叠到核心的循环部分,消除尾块部分循环分支,实现codesize、性能优化。
例:tail-folding.c
1 2 3 4 | void over_epilogue (double * a, int N){ for (int i = 0; i < N; i++) a[i] = 2.0 * a[i]; } |
毕昇编译器在通过-mcpu选项指定鲲鹏架构时并使能SVE向量化时,会根据代码特征动态调整当前的尾块折叠策略。在此基础上,可以通过选项-mllvm --prefer-predicate-over-epilogue=控制是否需要生成尾块折叠,并调整其结构,该选项有如下三个可选配置。
针对上述用例,在开启-mcpu=hip09 -O3选项的情况下,毕昇编译器不会对进行尾块折叠,可以通过选项-mllvm --prefer-predicate-over-epilogue=predicate-else-scalar-epilogue控制编译器进行尾块折叠。
1 | $ clang -mcpu=hip09 -O3 -mllvm --prefer-predicate-over-epilogue=predicate-else-scalar-epilogue -S tail-folding.c #开启尾块折叠优化 |
若不添加上述选项,则编译生成的汇编代码会包括下述的汇编代码块,用于处理矢量化后剩余的尾块,若添加对应选项,则不会生成(乘加操作仅针对z寄存器进行)。
1 2 3 4 5 6 7 8 9 | … .LBB0_4: // %for.body // =>This Inner Loop Header: Depth=1 ldr d0, [x10] fadd d0, d0, d0 str d0, [x10], #8 subs x8, x8, #1 b.ne .LBB0_4 … |
毕昇编译器支持 ACLE (Arm C Language Extension) for SVE 中定义的全量SVE intrinsic接口,用户可以在C/C++等高级语言中直接通过调用对应的intrinsic接口生成对应的指令。详细的接口列表及对应的接口行为,功能请参阅ACLE for SVE。
使用SVE intrinsic进行编程需要引用头文件“arm_sve.h”,该头文件中提供了毕昇编译器当前支持的SVE intrinsic接口,向量类型(SVE vector type,对应z寄存器),predicate类型(SVE predicate type,对应p寄存器)的定义(具体细节可参阅ACLE for SVE)。下文给出了一个使用SVE intrinsic进行编程的例子test_intrinsic.c:
1 2 3 4 5 | #include <arm_sve.h> double test_sve_intrinsic(svbool_t pg, svfloat64_t op) { double result = svaddv(pg, op); return result; } |
上述例子中svfloat64_t为向量类型,表示64-bit 浮点类型的向量(对应zn.d),svbool_t为predicate类型(对应pn),svaddv为累加对应的SVE intrinsic。使用下述命令编译可以得到对应的汇编:
1 2 3 4 | $ clang -mcpu=hip09 -O3 test_intrinsic.c -S … faddv d0, p0, z0.d … |
faddv指令会将z0寄存器中,p0寄存器对应位为有效位的元素相加并将结果存放在d0寄存器中。更多使用SVE intrinsic进行编程的案例可以参考arm官方提供的文档SVE-SVE2-programming-examples。
若用户对SVE指令集有比较深入的了解,也可以直接使用SVE汇编代码编写函数(需要注意的是,编写的函数需要满足AAPCS(Procedure Call Standard for Arm Architecture)对函数调用的ABI要求),直接编写的SVE汇编代码在指定指令集的情况下,可以使用毕昇编译器生成对应的目标文件及可执行文件。例如下述用例test_assembly_code.s(通过上文SME intrinsic用例生成得到):
1 2 3 4 5 6 7 8 9 10 11 12 13 | .globl test_sve_intrinsic // -- Begin function test_sve_intrinsic .p2align 4 .type test_sve_intrinsic,@function .variant_pcs test_sve_intrinsic test_sve_intrinsic: // @test_sve_intrinsic .cfi_startproc // %bb.0: faddv d0, p0, z0.d // kill: def $d0 killed $d0 killed $z0 ret .Lfunc_end0: .size test_sve_intrinsic, .Lfunc_end0-test_sve_intrinsic .cfi_endproc |
可以通过下述命令编译得到目标文件:
1 | $ clang -mcpu=hip09 -O3 -c test_assembly_code.s |