
本文字数:14267;估计阅读时间:36 分钟
审校:庄晓东(魏庄)

Meetup活动
ClickHouse Shenzhen User Group第1届 Meetup 火热报名中,详见文末海报!

在这篇文章中,我将描述向量化的工作原理,什么是CPU调度,如何找到CPU调度优化的空间,以及如何在ClickHouse中使用CPU调度。
首先,描述一下的问题。硬件供应商不断的向现代CPU的指令集中添加新指令。我们经常想使用最新的指令进行优化,其中最重要的是SIMD指令。但这样做主要的问题是兼容性。例如,如果你的程序是用AVX2指令集编译的,而你的CPU只支持SSE4.2,那么如果运行这样的程序,你将收到一个非法指令信号(SIGILL)。
还需要注意的一点是:可以专门设计适应应SIMD指令的数据结构和算法,例如现代整数压缩编解码器,或者稍后移植到这些指令,例如JSON解析。
为了在保持与旧硬件兼容的同时提高性能,代码的部分可以为不同的指令集编译,然后在运行时程序可以将执行分派到性能最佳的变体。
在本文的任何示例中,我将使用clang-15编译器。

向量化是一种优化,其中使用矢量操作,而不是标量操作处理数据。现代CPU具有特定的指令,允许您使用SIMD指令以矢量方式处理数据。这样的优化可以手动执行,也可以由编译器执行自动向量化。
让我们考虑以下代码示例:
void plus(int64_t * __restrict a, int64_t * __restrict b, int64_t * __restrict c, size_t size){for (size_t i = 0; i < size; ++i) {c[i] = b[i] + a[i];}}
如果我们在没有循环展开的情况下编译此代码,通过指定选项fno-unroll-loops,并且启用AVX2通过选项-mavx2,将生成以下汇编:
$ usr/bin/clang++-15 -mavx2 -fno-unroll-loops -O3 -S vectorization_example.cpp
# %bb.0:testq %rcx, %rcxje .LBB0_7# %bb.1:cmpq $4, %rcxjae .LBB0_3# %bb.2:xorl %r8d, %r8djmp .LBB0_6.LBB0_3:movq %rcx, %r8andq $-4, %r8xorl %eax, %eax.p2align 4, 0x90.LBB0_4: # =>This Inner Loop Header: Depth=1vmovdqu (%rdi,%rax,8), %ymm0vpaddq (%rsi,%rax,8), %ymm0, %ymm0vmovdqu %ymm0, (%rdx,%rax,8)addq $4, %raxcmpq %rax, %r8jne .LBB0_4# %bb.5:cmpq %rcx, %r8je .LBB0_7.p2align 4, 0x90.LBB0_6: # =>This Inner Loop Header: Depth=1movq (%rdi,%r8,8), %raxaddq (%rsi,%r8,8), %raxmovq %rax, (%rdx,%r8,8)incq %r8cmpq %r8, %rcxjne .LBB0_6.LBB0_7:vzeroupperretq
.LBB0_4: # =>This Inner Loop Header: Depth=1vmovdqu (%rdi,%rax,8), %ymm0vpaddq (%rsi,%rax,8), %ymm0, %ymm0vmovdqu %ymm0, (%rdx,%rax,8)addq $4, %raxcmpq %rax, %r8jne .LBB0_4
.LBB0_6: # =>This Inner Loop Header: Depth=1movq (%rdi,%r8,8), %raxaddq (%rsi,%r8,8), %raxmovq %rax, (%rdx,%r8,8)incq %r8cmpq %r8, %rcxjne .LBB0_6
# %bb.1:cmpq $4, %rcxjae .LBB0_3# %bb.2:xorl %r8d, %r8djmp .LBB0_6
另一个需要注意的重要事项是:输入数组指针上的__restrict关键字。它告诉编译器函数参数不会别名。这意味着它们特别不指向重叠的内存区域。如果未指定__restrict,则编译器将不会对循环进行向量化,或者仅在函数开头进行昂贵的运行时检查后才进行向量化,以确保数组确实不重叠。
此外,如果我们在没有fno-unroll-loops的情况下编译此示例并查看生成的循环,我们将看到编译器展开了向量化循环,该循环现在每次处理16个元素。
.LBB0_4: # =>This Inner Loop Header: Depth=1vmovdqu (%rdi,%rax,8), %ymm0vmovdqu 32(%rdi,%rax,8), %ymm1vmovdqu 64(%rdi,%rax,8), %ymm2vmovdqu 96(%rdi,%rax,8), %ymm3vpaddq (%rsi,%rax,8), %ymm0, %ymm0vpaddq 32(%rsi,%rax,8), %ymm1, %ymm1vpaddq 64(%rsi,%rax,8), %ymm2, %ymm2vpaddq 96(%rsi,%rax,8), %ymm3, %ymm3vmovdqu %ymm0, (%rdx,%rax,8)vmovdqu %ymm1, 32(%rdx,%rax,8)vmovdqu %ymm2, 64(%rdx,%rax,8)vmovdqu %ymm3, 96(%rdx,%rax,8)addq $16, %raxcmpq %rax, %r8jne .LBB0_4
如果我们使用这些选项编译我们的示例,将会得到以下输出:
$ usr/bin/clang++-15 -mavx2 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -Rpass-analysis=loop-vectorize -O3vectorization_example.cpp:7:5: remark: vectorized loop (vectorization width: 4, interleaved count: 4) [-Rpass=loop-vectorize]for (size_t i = 0; i < size; ++i) {
class SumFunction{public:void sumIf(int64_t * values, int8_t * filter, size_t size);int64_t sum = 0;};void SumFunction::sumIf(int64_t * values, int8_t * filter, size_t size){for (size_t i = 0; i < size; ++i) {sum += filter[i] ? 0 : values[i];}}
/usr/bin/clang++-15 -mavx2 -O3 -Rpass-analysis=loop-vectorize -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -c vectorization_example.cpp...vectorization_example.cpp:28:9: remark: loop not vectorized [-Rpass-missed=loop-vectorize]for (size_t i = 0; i < size; ++i) {
1. 您可以尝试修改代码,以便进行矢量化。在一些复杂的情况下,您可能需要重新设计数据表示。我强烈鼓励您查阅LLVM文档和gcc文档,这可以帮助您了解何时可以或不能执行自动矢量化的情况。
2. 您可以使用内部函数手动矢量化循环。由于需要额外的维护,这个选项不太受欢迎。
为了修复我们示例中的问题,我们需要在函数内部进行本地求和:
class SumFunction{public:void sumIf(int64_t * values, int8_t * filter, size_t size);int64_t sum = 0;};void SumFunction::sumIf(int64_t * values, int8_t * filter, size_t size){int64_t local_sum = 0;for (size_t i = 0; i < size; ++i) {local_sum += filter[i] ? 0 : values[i];}sum += local_sum;}
/usr/bin/clang++-15 -mavx2 -O3 -Rpass-analysis=loop-vectorize -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -c vectorization_example.cppvectorization_example.cpp:31:5: remark: vectorized loop (vectorization width: 4, interleaved count: 4) [-Rpass=loop-vectorize]for (size_t i = 0; i < size; ++i) {
.LBB0_5: # =>This Inner Loop Header: Depth=1vmovd (%rdx,%rax), %xmm5 # xmm5 = mem[0],zero,zero,zerovmovd 4(%rdx,%rax), %xmm6 # xmm6 = mem[0],zero,zero,zerovmovd 8(%rdx,%rax), %xmm7 # xmm7 = mem[0],zero,zero,zerovmovd 12(%rdx,%rax), %xmm1 # xmm1 = mem[0],zero,zero,zerovpcmpeqb %xmm5, %xmm8, %xmm5vpmovsxbq %xmm5, %ymm5vpcmpeqb %xmm6, %xmm8, %xmm6vpmovsxbq %xmm6, %ymm6vpcmpeqb %xmm7, %xmm8, %xmm7vpmovsxbq %xmm7, %ymm7vpcmpeqb %xmm1, %xmm8, %xmm1vpmaskmovq -96(%r8,%rax,8), %ymm5, %ymm5vpmovsxbq %xmm1, %ymm1vpmaskmovq -64(%r8,%rax,8), %ymm6, %ymm6vpaddq %ymm0, %ymm5, %ymm0vpmaskmovq -32(%r8,%rax,8), %ymm7, %ymm5vpaddq %ymm2, %ymm6, %ymm2vpmaskmovq (%r8,%rax,8), %ymm1, %ymm1vpaddq %ymm3, %ymm5, %ymm3vpaddq %ymm4, %ymm1, %ymm4addq $16, %raxcmpq %rax, %r9jne .LBB0_5

CPU调度是一种技术,当有多个针对不同CPU特性的编译版本时,在运行时,程序会检测您的计算机具有哪些CPU特性,并在运行时使用性能最佳的版本。您想要检查的最重要的指令集是SSE4.2、AVX、AVX2和AVX-512。
要实现CPU调度,首先,我们需要使用CPUID指令来检查当前CPU是否支持特定的特性。
您可以使用内联汇编调用cpuid指令,也可以使用定义了这些函数的cpuid.h头文件:
/* x86-64 uses %rbx as the base register, so preserve it. */#define __cpuid(__leaf, __eax, __ebx, __ecx, __edx) \__asm(" xchgq %%rbx,%q1\n" \" cpuid\n" \" xchgq %%rbx,%q1" \: "=a"(__eax), "=r" (__ebx), "=c"(__ecx), "=d"(__edx) \: "0"(__leaf))#define __cpuid_count(__leaf, __count, __eax, __ebx, __ecx, __edx) \__asm(" xchgq %%rbx,%q1\n" \" cpuid\n" \" xchgq %%rbx,%q1" \: "=a"(__eax), "=r" (__ebx), "=c"(__ecx), "=d"(__edx) \: "0"(__leaf), "2"(__count))#endif
bool hasSSE42(){uint32_t eax = 0;uint32_t ebx = 0;uint32_t ecx = 0;uint32_t edx = 0;__cpuid(0x1, eax, ebx, ecx, edx);return (ecx >> 20) & 1ul;}
void plusDefault(int64_t * __restrict a, int64_t * __restrict b, int64_t * __restrict c, size_t size){for (size_t i = 0; i < size; ++i) {c[i] = a[i] + b[i];}}__attribute__((target("sse,sse2,sse3,ssse3,sse4,avx,avx2")))void plusAVX2(int64_t * __restrict a, int64_t * __restrict b, int64_t * __restrict c, size_t size){for (size_t i = 0; i < size; ++i) {c[i] = a[i] + b[i];}}__attribute__((target("sse,sse2,sse3,ssse3,sse4,avx,avx2,avx512f")))void plusAVX512(int64_t * __restrict a, int64_t * __restrict b, int64_t * __restrict c, size_t size){for (size_t i = 0; i < size; ++i) {c[i] = a[i] + b[i];}}
....globl _Z8plusAVX2PlS_S_m # -- Begin function _Z8plusAVX2PlS_S_m....LBB4_4: # =>This Inner Loop Header: Depth=1vmovdqu (%rsi,%rax,8), %ymm0vmovdqu 32(%rsi,%rax,8), %ymm1vmovdqu 64(%rsi,%rax,8), %ymm2vmovdqu 96(%rsi,%rax,8), %ymm3vpaddq (%rdi,%rax,8), %ymm0, %ymm0vpaddq 32(%rdi,%rax,8), %ymm1, %ymm1vpaddq 64(%rdi,%rax,8), %ymm2, %ymm2vpaddq 96(%rdi,%rax,8), %ymm3, %ymm3vmovdqu %ymm0, (%rdx,%rax,8)vmovdqu %ymm1, 32(%rdx,%rax,8)vmovdqu %ymm2, 64(%rdx,%rax,8)vmovdqu %ymm3, 96(%rdx,%rax,8)addq $16, %raxcmpq %rax, %r8jne .LBB4_4...
....globl _Z10plusAVX512PlS_S_m # -- Begin function _Z10plusAVX512PlS_S_m....LBB5_4: # =>This Inner Loop Header: Depth=1vmovdqu64 (%rsi,%rax,8), %zmm0vmovdqu64 64(%rsi,%rax,8), %zmm1vmovdqu64 128(%rsi,%rax,8), %zmm2vmovdqu64 192(%rsi,%rax,8), %zmm3vpaddq (%rdi,%rax,8), %zmm0, %zmm0vpaddq 64(%rdi,%rax,8), %zmm1, %zmm1vpaddq 128(%rdi,%rax,8), %zmm2, %zmm2vpaddq 192(%rdi,%rax,8), %zmm3, %zmm3vmovdqu64 %zmm0, (%rdx,%rax,8)vmovdqu64 %zmm1, 64(%rdx,%rax,8)vmovdqu64 %zmm2, 128(%rdx,%rax,8)vmovdqu64 %zmm3, 192(%rdx,%rax,8)addq $32, %raxcmpq %rax, %r8jne .LBB5_4...
现在我们已经有了执行CPU调度所需的一切:
void plus(int64_t * __restrict a, int64_t * __restrict b, int64_t * __restrict c, size_t size){if (hasAVX512()) {plusAVX512(a, b, c, size);} else if (hasAVX2()) {plusAVX2(a, b, c, size);} else {plusDefault(a, b, c, size);}}
在这个例子中,我们创建了一个plus函数,它根据可用的指令集调度到具体的实现。这种CPU调度方法也被称为每次调用都进行调度。还有其他一些方法,您可以在Agner Fog的《在C++中优化软件:Windows、Linux和Mac平台的优化指南》第13.1节“CPU调度策略”中了解更多信息(https://www.agner.org/optimize/)。
每次调用都是最灵活的方法,因为它允许您使用模板函数和类成员函数,或根据运行时收集的一些统计数据选择一个实现。唯一的缺点是分支,尽管这种开销在函数执行大量工作时是可以忽略的。

现在要找到可以应用SIMD优化的地方?
如果您知道程序中哪些循环很热门,可以尝试为它们应用CPU调度。
如果您进行性能测试,可以使用AVX、AVX2、AVX-512编译您的程序,并比较性能报告,以找出使用CPU调度可以优化的程序中的位置。
这种带有性能测试的技术不仅适用于CPU调度,还适用于许多其他有用的优化。主要思想是使用不同的配置(编译器、编译器选项、库、分配器)编译代码,如果某些地方有性能提升,您可以手动优化它们。例如:
尝试不同的分配器和不同的库。
尝试不同的编译器选项(循环展开、内联阈值)。
为构建启用AVX/AVX2/AVX-512。

我们要感谢Dmitriy Kovalkov为ClickHouse添加了CPU调度框架。它成为本文描述的后续工作的基础。
首先,我想展示一下我们在ClickHouse中如何设计我们的调度框架。
enum class TargetArch : UInt32{Default = 0, /// Without any additional compiler options.SSE42 = (1 << 0), /// SSE4.2AVX = (1 << 1),AVX2 = (1 << 2),AVX512F = (1 << 3),AVX512BW = (1 << 4),AVX512VBMI = (1 << 5),AVX512VBMI2 = (1 << 6),};/// Runtime detection.bool isArchSupported(TargetArch arch);
例如,对于clang:
# define BEGIN_AVX512F_SPECIFIC_CODE \_Pragma("clang attribute push(__attribute__((target(\"sse,sse2,sse3,ssse3,sse4,\popcnt,avx,avx2, avx512f\"))), apply_to=function)")\# define BEGIN_AVX2_SPECIFIC_CODE \_Pragma("clang attribute push(__attribute__((target(\"sse,sse2,sse3,ssse3,sse4,\popcnt, avx,avx2\"))), apply_to=function)") \\# define END_TARGET_SPECIFIC_CODE \_Pragma("clang attribute pop")
#define DECLARE_AVX2_SPECIFIC_CODE(...) \BEGIN_AVX2_SPECIFIC_CODE \namespace TargetSpecific::AVX2 { \DUMMY_FUNCTION_DEFINITION \using namespace DB::TargetSpecific::AVX2; \__VA_ARGS__ \} \END_TARGET_SPECIFIC_CODE#define DECLARE_AVX512F_SPECIFIC_CODE(...) \BEGIN_AVX512F_SPECIFIC_CODE \namespace TargetSpecific::AVX512F { \DUMMY_FUNCTION_DEFINITION \using namespace DB::TargetSpecific::AVX512F; \__VA_ARGS__ \} \END_TARGET_SPECIFIC_CODE
DECLARE_DEFAULT_CODE (int funcImpl() {return 1;}) // DECLARE_DEFAULT_CODEDECLARE_AVX2_SPECIFIC_CODE (int funcImpl() {return 2;}) // DECLARE_AVX2_SPECIFIC_CODE/// Dispatcher functionint dispatchFunc() {#if USE_MULTITARGET_CODEif (isArchSupported(TargetArch::AVX2))return TargetSpecific::AVX2::funcImpl();#endifreturn TargetSpecific::Default::funcImpl();}
/// Function header#define MULTITARGET_FUNCTION_HEADER(...) __VA_ARGS__/// Function body#define MULTITARGET_FUNCTION_BODY(...) __VA_ARGS__#define MULTITARGET_FUNCTION_AVX512BW_AVX512F_AVX2_SSE42(FUNCTION_HEADER, name, FUNCTION_BODY) \FUNCTION_HEADER \\AVX512BW_FUNCTION_SPECIFIC_ATTRIBUTE \name##AVX512BW \FUNCTION_BODY \\FUNCTION_HEADER \\AVX512_FUNCTION_SPECIFIC_ATTRIBUTE \name##AVX512 \FUNCTION_BODY \\FUNCTION_HEADER \\AVX2_FUNCTION_SPECIFIC_ATTRIBUTE \name##AVX2 \FUNCTION_BODY \\FUNCTION_HEADER \\SSE42_FUNCTION_SPECIFIC_ATTRIBUTE \name##SSE42 \FUNCTION_BODY \\FUNCTION_HEADER \\name \FUNCTION_BODY \
template <typename Value>void NO_INLINE addManyImpl(const Value * __restrict ptr, size_t start, size_t end){ptr += start;size_t count = end - start;const auto * end_ptr = ptr + count;/// LoopT local_sum{};while (ptr < end_ptr){Impl::add(local_sum, *ptr);++ptr;}Impl::add(sum, local_sum);}
MULTITARGET_FUNCTION_AVX512BW_AVX512F_AVX2_SSE42(MULTITARGET_FUNCTION_HEADER(template <typename Value>void NO_SANITIZE_UNDEFINED NO_INLINE), addManyImpl,MULTITARGET_FUNCTION_BODY((const Value * __restrict ptr, size_t start, size_t end){ptr += start;size_t count = end - start;const auto * end_ptr = ptr + count;/// LoopT local_sum{};while (ptr < end_ptr){Impl::add(local_sum, *ptr);++ptr;}Impl::add(sum, local_sum);}))
template <typename Value>void NO_INLINE addMany(const Value * __restrict ptr, size_t start, size_t end){#if USE_MULTITARGET_CODEif (isArchSupported(TargetArch::AVX512BW)){addManyImplAVX512BW(ptr, start, end);return;}else if (isArchSupported(TargetArch::AVX512F)){addManyImplAVX512F(ptr, start, end);return;}else if (isArchSupported(TargetArch::AVX2)){addManyImplAVX2(ptr, start, end);return;}else if (isArchSupported(TargetArch::SSE42)){addManyImplSSE42(ptr, start, end);return;}#endifaddManyImpl(ptr, start, end);}
| Query | Old (s) | New (s) | Ratio of speedup(-) or slowdown(+) | Relative difference (new - old) old |
|---|---|---|---|---|
| SELECT sum(toNullable(toUInt8(number))) FROM numbers(100000000) | 0.110 | 0.077 | -1.428x | -0.300 |
| SELECT sum(number) FROM numbers(100000000) | 0.044 | 0.035 | -1.228x | -0.185 |
| SELECT sumOrNull(number) FROM numbers(100000000) | 0.044 | 0.036 | -1.226x | -0.183 |
| SELECT avg(number) FROM numbers(100000000) | 0.416 | 0.341 | -1.219x | -0.180 |
template <typename A, typename Op>struct UnaryOperationImpl{using ResultType = typename Op::ResultType;using ColVecA = ColumnVectorOrDecimal<A>;using ColVecC = ColumnVectorOrDecimal<ResultType>using ArrayA = typename ColVecA::Container;using ArrayC = typename ColVecC::Container;static void vector(const ArrayA & a, ArrayC & c){/// Loop Op::apply is template for operationsize_t size = a.size();for (size_t i = 0; i < size; ++i)c[i] = Op::apply(a[i]);}static void constant(A a, ResultType & c){c = Op::apply(a);}};
MULTITARGET_FUNCTION_WRAPPER_AVX2_SSE42(MULTITARGET_FH(static void NO_INLINE),vectorImpl,MULTITARGET_FB((const ArrayA & a, ArrayC & c) /// NOLINT{/// Loop Op::apply is template for operationsize_t size = a.size();for (size_t i = 0; i < size; ++i)c[i] = Op::apply(a[i]);}))
static void NO_INLINE vector(const ArrayA & a, ArrayC & c){#if USE_MULTITARGET_CODEif (isArchSupported(TargetArch::AVX2)){vectorImplAVX2(a, c);return;}else if (isArchSupported(TargetArch::SSE42)){vectorImplSSE42(a, c);return;}#endifvectorImpl(a, c);}
| Query | Old (s) | New (s) | Ratio of speedup(-) or slowdown(+) | Relative difference (new - old) old |
|---|---|---|---|---|
| SELECT roundDuration(toInt32(number))) FROM numbers(100000000) | 1.632 | 0.229 | -7.119x | -0.860 |
| SELECT intExp2(toInt32(number)) FROM numbers(100000000) | 0.148 | 0.105 | -1.413x | -0.293 |
| SELECT roundToExp2(toUInt8(number)) FROM numbers(100000000) | 0.144 | 0.102 | -1.41x | -0.291 |

编译器可以使用SIMD指令矢量化甚至复杂的循环。此外,您可以手动矢量化代码或设计面向SIMD的算法。但最大的问题是,如果要使用现代指令集,它可能会降低程序或库的可移植性。运行时CPU调度可以帮助您消除此问题,代价是为不同体系结构多次编译代码的部分。您可以通过性能测试找到提高性能的地方,并使用不同的配置编译代码。对于CPU调度优化,您可以使用AVX、AVX2、AVX512编译代码,并在性能有提升的地方手动应用CPU调度。在ClickHouse中,我们专门为此类优化设计了一个框架,并在许多地方提高了性能。

好消息:ClickHouse Shenzhen User Group第1届 Meetup 已经开放报名了,将于2024年1月6日在深圳南山区海天二路33号腾讯滨海大厦举行,扫码免费报名








