近期我在开发 Video2X 的时候为了程序在支持 AVX2 和 AVX-512 的 CPU 上能完全发挥出扩展指令集的性能,同时又能够保持对不支持这些扩展的 CPU 的支持,研究了一下如何将一个函数多个架构的版本同时编译进可执行文件中,并且在运行时根据 CPU 支持的扩展动态选择应该调用的函数。我将在这篇文章中讲述如何在 GCC 和 LLVM 中用多版本控制来达成在程序运行时自动选择最佳的函数实现。
扩展指令集和自动向量化优化
在我们讨论为什么需要多版本控制之前,首先需要理解为什么我们需要更新的扩展指令集。请看下面这个简单的浮点数组计算函数:
1
2
3
4
5
6
|
double do_math(double a1[], double a2[]) {
for (int i = 0; i < 1000; i++) {
a1[i] += a2[i];
}
return a1[0];
}
|
如果我们使用默认的 Clang 选项以及 -O2
优化等级编译该程序,可以看到编译器使用了 addsd
等 SSE2 扩展指令集指令来处理浮点数计算:
SSE(Streaming SIMD Extensions)指令集是 x86 指令集的扩展,用于提供更好的浮点数计算性能和向量化计算支持。虽然这些指令比基础的 x87 FPU(Floating-Point Unit)指令集更快,但是仍然没有发挥出这块 AMD Ryzen 9 5950X 处理器的全部性能。5950X 处理器的 Zen 3 架构支持 AVX2(Advanced Vector Extensions 2)指令集,使用 AVX 可以进一步提升这些数学计算的性能。
我们可以使用 -march=native
或者 -mavx2
来让 LLVM 使用 AVX2 指令集进一步优化数学计算。启用 AVX2 指令集之后,我们可以看到 LLVM 对函数进行了自动向量化,并使用 AVX 指令集的 vaddpd
等指令进行计算以获得比 SSE 指令集和标量处理更好的性能:
但是这里有一个问题,如果我们使用了当前平台不支持的新指令集,例如 AVX-512,则程序会在执行时出错,因为当前的处理器并不支持 AVX-512 指令集:
我们如何能在只能发布一个可执行文件,兼容旧平台的同时,还能在支持新扩展指令集的设备上使用其支持的最新的指令集呢?这时候就需要引出函数的多版本控制了。
什么是函数多版本控制
简单来说,函数多版本控制(multiversioning)允许程序员给同一个函数编写针对不同 CPU 架构的多个版本以获得更好的性能。假设我们有这样一个将两个数组中的元素相加并保存到第三个数组中的函数,这个函数就非常适合用 AVX 指令集和向量化进行优化:
1
2
3
4
5
|
void add_arrays(const float *a, const float *b, float *result, size_t size) {
for (size_t i = 0; i < size; i++) {
result[i] = a[i] + b[i];
}
}
|
我们可以给基础 x86-64、AVX2 和 AVX-512 分别编写一个函数,并分别在每个函数内使用编译器内置 AVX 函数进行向量化编程与优化,然后编写一个调度机制来在运行时根据 CPU 支持的指令集扩展来决定调用哪一个函数实现。在 GCC 和 LLVM 中,我们可以使用 target
属性和 __builtin_cpu_supports
编译器内置函数来在程序运行时自动检测支持的指令集并使用最合适的版本。编译器会根据 target
属性中指定的架构对函数进行优化。需要注意的是,使用 target
属性定义的拥有多个版本函数必须要有一个默认(default
)实现。上述函数的多版本实现如下:
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
// 使用 AVX-512F 扩展指令集优化的版本
// 以下两种属性写法等价:
// __attribute__((target("avx512f")))
[[gnu::target("avx512f")]]
void add_arrays_avx512(const float *a, const float *b, float *result, std::size_t size) {
std::size_t i = 0;
// 使用 AVX-512F 每次处理 16 个元素
for (; i + 15 < size; i += 16) {
__m512 va = _mm512_loadu_ps(&a[i]);
__m512 vb = _mm512_loadu_ps(&b[i]);
__m512 vr = _mm512_add_ps(va, vb);
_mm512_storeu_ps(&result[i], vr);
}
// 处理剩余的元素
for (; i < size; i++) {
result[i] = a[i] + b[i];
}
}
// 使用 AVX2 扩展指令集优化的版本
[[gnu::target("avx2")]]
void add_arrays_avx2(const float *a, const float *b, float *result, std::size_t size) {
std::size_t i = 0;
// 使用 AVX2 每次处理 8 个元素
for (; i + 7 < size; i += 8) {
__m256 va = _mm256_loadu_ps(&a[i]);
__m256 vb = _mm256_loadu_ps(&b[i]);
__m256 vr = _mm256_add_ps(va, vb);
_mm256_storeu_ps(&result[i], vr);
}
// 处理剩余的元素
for (; i < size; i++) {
result[i] = a[i] + b[i];
}
}
// 默认的 x86-64 实现
[[gnu::target("default")]]
void add_arrays_default(const float *a, const float *b, float *result, std::size_t size) {
for (std::size_t i = 0; i < size; i++) {
result[i] = a[i] + b[i];
}
}
// 调度函数,根据运行时 CPU 支持的指令集动态选择最合适的函数实现
void add_arrays(const float *a, const float *b, float *result, std::size_t size) {
if (__builtin_cpu_supports("avx512f")) {
// 如果 CPU 支持 AVX-512F,优先调用 AVX-512F 的函数实现
add_arrays_avx512(a, b, result, size);
} else if (__builtin_cpu_supports("avx2")) {
// 如果 CPU 不支持 AVX-512,但是支持 AVX2,调用 AVX2 的函数实现
add_arrays_avx2(a, b, result, size);
} else {
// CPU 两种指令集都不支持,调用默认实现
add_arrays_default(a, b, result, size);
}
}
|
虽然以上的代码可以正常工作,但是这种写法极大增加了代码量。很多时候我们更希望编写更抽象的代码,让编译器来进行指令集和向量化方面的优化,这时候就可以使用 target_clones
属性来让编译器自动为函数生成多种架构的代码并自动生成调度函数。该属性的使用方法非常简单:
1
2
3
4
5
6
7
8
|
// 以下两种属性写法等价:
// __attribute__((target_clones("avx512f", "avx2", "default")))
[[gnu::target_clones("avx512f", "avx2", "default")]]
void add_arrays(const float *a, const float *b, float *result, size_t size) {
for (size_t i = 0; i < size; i++) {
result[i] = a[i] + b[i];
}
}
|
这时使用 Clang 编译,它就会自动为三种架构分别生成优化函数的实现,并将该函数的调用替换成一个解析(resolver)函数:
我们可以反汇编该解析函数查看其内部的逻辑,可以看到和我们手动实现的调度函数如出一辙:
以上的属性为 avx2
和 avx512f
两种架构创建了各自的函数实现,但实际上大多数支持 AVX-512F 基础指令集的 CPU 同时也支持更多更高级的 AVX-512 扩展指令集,例如 Intel 的 Skylake 架构就同时还支持 AVX-512VL、AVX-512DQ 和 AVX-512BW:
因此,我们可以使用 x86-64 微架构级别来代替 AVX2/AVX-512F 指令集,x86-64-v4
微架构级别同时囊括了 AVX-512F、AVX-512BW、AVX-512CD、AVX-512DQ 和 AVX-512VL 这五个指令集扩展,可以用来概括「支持 AVX-512 这一代的 CPU」:
1
2
3
4
5
6
|
[[gnu::target_clones("arch=x86-64-v4", "arch=x86-64-v3", "default")]]
void add_arrays(const float *a, const float *b, float *result, size_t size) {
for (size_t i = 0; i < size; i++) {
result[i] = a[i] + b[i];
}
}
|
单文件动态调度的弊端
虽然上述的两种方法使用方便快捷,但它们也有一些弊端:
- 增大文件体积:因为程序中需要同时存储一个函数在每个架构上实现的副本,可执行文件的体积将会比仅针对单个架构优化的文件大一些。如果您希望分发尽量小的可执行文件,应直接使用
-march
为每个对应的架构单独编译可执行文件。
- 仅能优化标注了属性的函数:相较于直接使用
-march=x86-64-v4
编译器参数,上述的方法仅能优化手动标注过 target_clones
属性的函数。项目内的其他代码和诸如通过 CMake add_subdirectory
等方法引入的外部为标注该属性的代码则不会应用该优化。如果想要获得更好的性能,让编译器尽量优化所有代码,可以考虑使用 -march
编译参数直接指定目标处理器架构。
- 额外调度性能开销:虽说调度/解析的额外性能开销并不大,但是对于优化到每个 CPU 周期的项目来说(例如高频交易),当然还是避免上述这两种方法比较好。
总结
对于对性能有一定要求,但是不吹毛求疵的程序,我们仅需要给性能开销大的函数前添加一个 target_clones
属性标签,就能让编译器自动给函数生成了所有架构的不同优化版本,并且在运行时自动调用,而不需要给每一种架构分辨编译一个可执行文件。对于性能优化要求更高的程序,我们也可以手动编写多个架构的函数实现,然后使用类似于 __builtin_cpu_supports
的函数在程序执行时根据 CPU 支持的指令集动态调度应该被执行的函数实现。
文章来源: https://k4yt3x.com/%E4%BD%BF%E7%94%A8%E5%87%BD%E6%95%B0%E5%A4%9A%E7%89%88%E6%9C%AC%E6%8E%A7%E5%88%B6%E5%9C%A8%E4%B8%80%E4%B8%AA%E4%BA%8C%E8%BF%9B%E5%88%B6%E6%96%87%E4%BB%B6%E4%B8%AD%E6%94%AF%E6%8C%81%E5%A4%9A%E7%A7%8D-cpu-%E6%9E%B6%E6%9E%84/
如有侵权请联系:admin#unsafe.sh