博客 / 工程

ClickHouse 中的 CPU 调度

author avatar
Maksim Kita
2023 年 10 月 3 日 - 20 分钟阅读

概述

在这篇文章中,我将描述向量化是如何工作的,什么是 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];
   }
}

我们有一个 plus 函数,它接受指向 abc 数组的 3 个指针以及这些数组的大小。此函数计算 a 和 b 数组元素的总和,并将其写入数组 c。

如果我们编译此代码,不通过指定选项 fno-unroll-loops 来进行循环展开,并通过选项 -mavx2 指定 AVX2 支持,则将生成以下汇编代码

$ /usr/bin/clang++-15 -mavx2 -fno-unroll-loops -O3 -S vectorization_example.cpp
# %bb.0:
	testq	%rcx, %rcx
	je	.LBB0_7
# %bb.1:
	cmpq	$4, %rcx
	jae	.LBB0_3
# %bb.2:
	xorl	%r8d, %r8d
	jmp	.LBB0_6
.LBB0_3:
	movq	%rcx, %r8
	andq	$-4, %r8
	xorl	%eax, %eax
	.p2align	4, 0x90
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
	vmovdqu	(%rdi,%rax,8), %ymm0
	vpaddq	(%rsi,%rax,8), %ymm0, %ymm0
	vmovdqu	%ymm0, (%rdx,%rax,8)
	addq	$4, %rax
	cmpq	%rax, %r8
	jne	.LBB0_4
# %bb.5:
	cmpq	%rcx, %r8
	je	.LBB0_7
	.p2align	4, 0x90
.LBB0_6:                                # =>This Inner Loop Header: Depth=1
	movq	(%rdi,%r8,8), %rax
	addq	(%rsi,%r8,8), %rax
	movq	%rax, (%rdx,%r8,8)
	incq	%r8
	cmpq	%r8, %rcx
	jne	.LBB0_6
.LBB0_7:
	vzeroupper
	retq

在最终汇编代码中,有两个循环。向量化循环,一次处理 4 个元素

.LBB0_4:                                # =>This Inner Loop Header: Depth=1
	vmovdqu	(%rdi,%rax,8), %ymm0
	vpaddq	(%rsi,%rax,8), %ymm0, %ymm0
	vmovdqu	%ymm0, (%rdx,%rax,8)
	addq	$4, %rax
	cmpq	%rax, %r8
	jne	.LBB0_4

和标量循环

.LBB0_6:                                # =>This Inner Loop Header: Depth=1
	movq	(%rdi,%r8,8), %rax
	addq	(%rsi,%r8,8), %rax
	movq	%rax, (%rdx,%r8,8)
	incq	%r8
	cmpq	%r8, %rcx
	jne	.LBB0_6

在函数汇编代码的开头,有一个检查,具体取决于数组大小,该检查确定选择哪个循环

# %bb.1:
	cmpq	$4, %rcx
	jae	.LBB0_3
# %bb.2:
	xorl	%r8d, %r8d
	jmp	.LBB0_6

此外,需要注意的重要一点是 vzeroupper 指令。编译器插入此指令是为了避免混合 SSE 和 VEX AVX 指令的惩罚。您可以在 Agner Fog 优化子例程汇编语言:x86 平台优化指南。13.2 混合 VEX 和 SSE 代码 中阅读有关它的更多信息。

另一个需要注意的重要事项是输入数组指针上的 __restrict 关键字。它 告诉编译器 函数参数不相互别名。这尤其意味着它们不指向重叠的内存区域。如果未指定 __restrict,则编译器将根本不会向量化循环,或者仅在函数开头执行代价高昂的 运行时检查 以确保数组确实不重叠之后才进行向量化。

此外,如果我们编译此示例时不使用 fno-unroll-loops 并查看生成的循环,我们将看到编译器展开了向量化循环,现在一次处理 16 个元素。

.LBB0_4:                                # =>This Inner Loop Header: Depth=1
	vmovdqu	(%rdi,%rax,8), %ymm0
	vmovdqu	32(%rdi,%rax,8), %ymm1
	vmovdqu	64(%rdi,%rax,8), %ymm2
	vmovdqu	96(%rdi,%rax,8), %ymm3
	vpaddq	(%rsi,%rax,8), %ymm0, %ymm0
	vpaddq	32(%rsi,%rax,8), %ymm1, %ymm1
	vpaddq	64(%rsi,%rax,8), %ymm2, %ymm2
	vpaddq	96(%rsi,%rax,8), %ymm3, %ymm3
	vmovdqu	%ymm0, (%rdx,%rax,8)
	vmovdqu	%ymm1, 32(%rdx,%rax,8)
	vmovdqu	%ymm2, 64(%rdx,%rax,8)
	vmovdqu	%ymm3, 96(%rdx,%rax,8)
	addq	$16, %rax
	cmpq	%rax, %r8
	jne	.LBB0_4

有一个非常有用的工具可以帮助您识别编译器执行或不执行向量化以避免汇编检查的位置。您可以将 -Rpass=loop-vectorize-Rpass-missed=loop-vectorize-Rpass-analysis=loop-vectorize 选项添加到 clang。gcc 也有类似的 选项

如果我们使用这些选项编译我们的示例,将有以下输出

$ /usr/bin/clang++-15 -mavx2 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -Rpass-analysis=loop-vectorize -O3

vectorization_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.cpp

vectorization_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=1
	vmovd	(%rdx,%rax), %xmm5              # xmm5 = mem[0],zero,zero,zero
	vmovd	4(%rdx,%rax), %xmm6             # xmm6 = mem[0],zero,zero,zero
	vmovd	8(%rdx,%rax), %xmm7             # xmm7 = mem[0],zero,zero,zero
	vmovd	12(%rdx,%rax), %xmm1            # xmm1 = mem[0],zero,zero,zero
	vpcmpeqb	%xmm5, %xmm8, %xmm5
	vpmovsxbq	%xmm5, %ymm5
	vpcmpeqb	%xmm6, %xmm8, %xmm6
	vpmovsxbq	%xmm6, %ymm6
	vpcmpeqb	%xmm7, %xmm8, %xmm7
	vpmovsxbq	%xmm7, %ymm7
	vpcmpeqb	%xmm1, %xmm8, %xmm1
	vpmaskmovq	-96(%r8,%rax,8), %ymm5, %ymm5
	vpmovsxbq	%xmm1, %ymm1
	vpmaskmovq	-64(%r8,%rax,8), %ymm6, %ymm6
	vpaddq	%ymm0, %ymm5, %ymm0
	vpmaskmovq	-32(%r8,%rax,8), %ymm7, %ymm5
	vpaddq	%ymm2, %ymm6, %ymm2
	vpmaskmovq	(%r8,%rax,8), %ymm1, %ymm1
	vpaddq	%ymm3, %ymm5, %ymm3
	vpaddq	%ymm4, %ymm1, %ymm4
	addq	$16, %rax
	cmpq	%rax, %r9
	jne	.LBB0_5

CPU 调度基础知识

CPU 调度是一种技术,当您的代码有针对不同 CPU 功能的多个编译版本时,在运行时,您的程序会检测您的机器具有哪些 CPU 功能,并在运行时使用性能最佳的版本。您要检查的最重要的指令集是 SSE4.2、AVX、AVX2 和 AVX-512。

要实现 CPU 调度,首先,我们需要使用 CPUID 指令来检查当前 CPU 是否支持特定功能。

您可以使用内联汇编或使用定义了这些函数的 cpuid.h 标头调用 cpuid 指令

/* 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

接下来,要检查是否支持某些 CPU 功能,您需要查看 英特尔软件优化参考手册第 5 章 手册以获取特定指令。例如,对于 SSE4.2

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;
}

现在我们需要使用不同的指令编译我们的函数。在 clang 中,有一个 target 属性可以做到这一点。在 gcc 中,也有相同的 属性。例如

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];
    }
}

在此示例中,我们还为 AVX2 和 AVX-512 编译了我们的 plus 函数。在最终汇编代码中,我们可以检查编译器是否使用 AVX2 来向量化 plusAVX2 函数的循环

...

.globl	_Z8plusAVX2PlS_S_m              # -- Begin function _Z8plusAVX2PlS_S_m

...

.LBB4_4:                                # =>This Inner Loop Header: Depth=1
	vmovdqu	(%rsi,%rax,8), %ymm0
	vmovdqu	32(%rsi,%rax,8), %ymm1
	vmovdqu	64(%rsi,%rax,8), %ymm2
	vmovdqu	96(%rsi,%rax,8), %ymm3
	vpaddq	(%rdi,%rax,8), %ymm0, %ymm0
	vpaddq	32(%rdi,%rax,8), %ymm1, %ymm1
	vpaddq	64(%rdi,%rax,8), %ymm2, %ymm2
	vpaddq	96(%rdi,%rax,8), %ymm3, %ymm3
	vmovdqu	%ymm0, (%rdx,%rax,8)
	vmovdqu	%ymm1, 32(%rdx,%rax,8)
	vmovdqu	%ymm2, 64(%rdx,%rax,8)
	vmovdqu	%ymm3, 96(%rdx,%rax,8)
	addq	$16, %rax
	cmpq	%rax, %r8
	jne	.LBB4_4

...

以及 AVX-512 用于 plusAVX512 的向量化循环

...

.globl	_Z10plusAVX512PlS_S_m    # -- Begin function _Z10plusAVX512PlS_S_m

...

.LBB5_4:    # =>This Inner Loop Header: Depth=1
	vmovdqu64	(%rsi,%rax,8), %zmm0
	vmovdqu64	64(%rsi,%rax,8), %zmm1
	vmovdqu64	128(%rsi,%rax,8), %zmm2
	vmovdqu64	192(%rsi,%rax,8), %zmm3
	vpaddq	(%rdi,%rax,8), %zmm0, %zmm0
	vpaddq	64(%rdi,%rax,8), %zmm1, %zmm1
	vpaddq	128(%rdi,%rax,8), %zmm2, %zmm2
	vpaddq	192(%rdi,%rax,8), %zmm3, %zmm3
	vmovdqu64	%zmm0, (%rdx,%rax,8)
	vmovdqu64	%zmm1, 64(%rdx,%rax,8)
	vmovdqu64	%zmm2, 128(%rdx,%rax,8)
	vmovdqu64	%zmm3, 192(%rdx,%rax,8)
	addq	$32, %rax
	cmpq	%rax, %r8
	jne	.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 调度策略 中阅读有关其他方法的信息。

每次调用时调度是最灵活的方法,因为它允许您使用模板函数和类成员函数或根据运行时收集的一些统计信息选择实现。唯一的缺点是分支,尽管如果您的函数执行大量工作,则此开销可以忽略不计。

CPU 调度优化位置

现在如何找到可以应用 SIMD 优化的位置?

  1. 如果您知道程序中的哪些循环是热点,您可以尝试对它们应用 CPU 调度。
  2. 如果您有性能测试,您可以编译带有 AVX、AVX2、AVX-512 的程序并比较性能报告,以找出程序中可以使用 CPU 调度优化的位置。

这种使用性能测试的技术不仅可以应用于 CPU 调度,还可以应用于许多其他有用的优化。主要思想是使用不同的配置(编译器、编译器选项、库、分配器)编译您的代码,如果某些位置的性能有所提高,您可以手动优化它们。例如

  1. 尝试不同的分配器和不同的库。
  2. 尝试不同的编译器选项(循环展开、内联阈值)。
  3. 为构建启用 AVX/AVX2/AVX-512。

ClickHouse 中的 CPU 调度

我们要感谢 Dmitriy Kovalkov 为 ClickHouse 添加了 CPU 调度框架 所做的努力。它为本文中描述的后续工作奠定了基础。

首先,我想展示我们如何在 ClickHouse 中设计调度框架。

enum class TargetArch : UInt32
{
    Default  = 0,         /// Without any additional compiler options.
    SSE42    = (1 << 0),  /// SSE4.2
    AVX      = (1 << 1),
    AVX2     = (1 << 2),
    AVX512F  = (1 << 3),
    AVX512BW    = (1 << 4),
    AVX512VBMI  = (1 << 5),
    AVX512VBMI2 = (1 << 6),
};

/// Runtime detection.
bool isArchSupported(TargetArch arch);

我们为目标架构定义了一个枚举 TargetArch,在 isArchSupported 函数内部,我们使用了已经讨论过的 CPUID 指令集检查。然后,我们定义了一堆 BEGIN_INSTRUCTION_SET_SPECIFIC_CODE 部分,这些部分将目标属性应用于整个代码块。

例如,对于 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")

然后,对于每个指令集,我们定义一个单独的命名空间 TargetSpecific::INSTRUCTION_SET。AVX2 和 AVX512 的示例

#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_CODE

DECLARE_AVX2_SPECIFIC_CODE (
    int funcImpl() {
        return 2;
    }
) // DECLARE_AVX2_SPECIFIC_CODE

/// Dispatcher function
int dispatchFunc() {
#if USE_MULTITARGET_CODE
    if (isArchSupported(TargetArch::AVX2))
        return TargetSpecific::AVX2::funcImpl();
#endif
    return TargetSpecific::Default::funcImpl();
}

上面的示例适用于独立的函数,但是当我们有类成员函数时,它们不起作用,因为这些函数不能包装到命名空间中。对于这种情况,我们有另一堆宏。我们需要在类成员函数名称之前插入特定属性,并生成具有不同名称的函数,最好带有 SSE42、AVX2、AVX512 等后缀。我们可以使用 MULTITARGET_FUNCTION_HEADERMULTITARGET_FUNCTION_BODY 宏将函数拆分为头文件和主体。然后,在函数名称之前插入特定属性。例如,对于 AVX-512 (BW)、AVX-512 (F)、AVX2 和 SSE4.2,它可以如下所示

/// 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 \

我们在计算密集型位置使用 CPU 调度,例如,在哈希、几何函数、字符串处理函数、随机数生成函数、一元函数和聚合函数中。例如,让我们看看我们如何在聚合函数中使用 CPU 调度。在 ClickHouse 中,如果存在没有键的 GROUP BY,例如 SELECT sum(value), avg(value) FROM test_table,则聚合函数直接在批处理中处理数据。对于 sum 函数,有以下实现

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;

    /// Loop
    T 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;

    /// Loop
    T local_sum{};
    while (ptr &lt end_ptr)
    {
        Impl::add(local_sum, *ptr);
        ++ptr;
    }
    Impl::add(sum, local_sum);
}))

我们现在可以根据最快的可用 CPU 指令集调度到正确的实现

template <typename Value>
void NO_INLINE addMany(const Value * __restrict ptr, size_t start, size_t end)
{
#if USE_MULTITARGET_CODE
 if (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;
    }
#endif

    addManyImpl(ptr, start, end);
}

这是应用此优化后性能报告的一小部分

查询旧版 (s)新版 (s)加速(-) 或减速(+) 比率相对差异 (新版 - 旧版) / 旧版
SELECT sum(toNullable(toUInt8(number))) FROM numbers(100000000)0.1100.077-1.428x-0.300
SELECT sum(number) FROM numbers(100000000)0.0440.035-1.228x-0.185
SELECT sumOrNull(number) FROM numbers(100000000)0.0440.036-1.226x-0.183
SELECT avg(number) FROM numbers(100000000)0.4160.341-1.219x-0.180

一般来说,sum 和 avg 聚合函数的这种优化将性能提高了 1.2-1.6 倍。类似的优化也可以应用于其他聚合函数。现在让我们看一下一元函数中的 CPU 调度优化

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 operation
        size_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);
    }
};

在该示例中,有一个循环,它使用 Op::apply 对数组 a 的元素应用一些模板操作,并将结果写入数组 c。在我们将此循环包装到我们的调度框架中之后,循环代码将如下所示

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 operation
    size_t size = a.size();
    for (size_t i = 0; i < size; ++i)
        c[i] = Op::apply(a[i]);
}))

现在我们需要根据当前可用的 CPU 指令集调度到适当的函数

static void NO_INLINE vector(const ArrayA & a, ArrayC & c)
{
#if USE_MULTITARGET_CODE
    if (isArchSupported(TargetArch::AVX2))
    {
        vectorImplAVX2(a, c);
        return;
    }
    else if (isArchSupported(TargetArch::SSE42))
    {
        vectorImplSSE42(a, c);
        return;
    }
#endif

    vectorImpl(a, c);
}

这是应用此优化后性能报告的一小部分

查询旧版 (s)新版 (s)加速(-) 或减速(+) 比率相对差异 (新版 - 旧版) / 旧版
SELECT roundDuration(toInt32(number))) FROM numbers(100000000)1.6320.229-7.119x-0.860
SELECT intExp2(toInt32(number)) FROM numbers(100000000)0.1480.105-1.413x-0.293
SELECT roundToExp2(toUInt8(number)) FROM numbers(100000000)0.1440.102-1.41x-0.291

一般来说,一元函数的这种优化将性能提高了 1.15-2 倍。对于某些特定函数(如 roundDuration),此类优化将性能提高了 2-7 倍。

总结

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

分享这篇文章

订阅我们的新闻资讯

随时了解功能发布、产品路线图、支持和云服务!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image