DoubleCloud 即将关闭。使用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

ClickHouse 中的 CPU 调度

author avatar
Maksim Kita
2023 年 10 月 3 日

概述

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

我们有一个加法函数,它接受指向 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);
    }
}

在这个示例中,我们创建了一个加法函数,该函数根据可用的指令集分派到具体的实现。这种 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 的努力,他将 CPU 分派框架添加到 ClickHouse。它作为本文所述后续工作的基础。

首先,我想展示我们在 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);
}

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

查询旧(秒)新(秒)加速 (-) 或减速 (+) 比例相对差异(新 - 旧)/ 旧
SELECT sum(toNullable(toUInt8(number))) FROM numbers(100000000)0.1100.077-1.428 倍-0.300
SELECT sum(number) FROM numbers(100000000)0.0440.035-1.228 倍-0.185
SELECT sumOrNull(number) FROM numbers(100000000)0.0440.036-1.226 倍-0.183
SELECT avg(number) FROM numbers(100000000)0.4160.341-1.219 倍-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);
}

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

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

总的来说,对一元函数进行这种优化可以将性能提高 1.15-2 倍。对于一些特定函数(如 roundDuration),这种优化可以将性能提高 2-7 倍。

总结

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

分享这篇文章

订阅我们的时事通讯

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