ClickHouse中的CPU调度

ClickHouse中的CPU调度_第1张图片

本文字数:14267;估计阅读时间:36 分钟

作者:Maksim Kita

审校:庄晓东(魏庄)

本文在公众号【ClickHouseInc】首发

ClickHouse中的CPU调度_第2张图片

概述

在这篇文章中,我将描述向量化的工作原理,什么是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,并且启用AVX2通过选项-mavx2,将生成以下汇编:

$ /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代码”中了解更多信息(https://www.agner.org/optimize/)。

另一个需要注意的重要事项是:输入数组指针上的__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

有一个非常有用的工具,可以帮助您识别编译器在哪些地方执行或不执行矢量化以避免汇编检查。您可以向clang添加-Rpass=loop-vectorize-Rpass-missed=loop-vectorize-Rpass-analysis=loop-vectorize选项。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指令,也可以使用定义了这些函数的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

接下来,要检查某个CPU特性是否受支持,您需要检查Intel软件优化参考手册第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中,有一个目标属性可以做到这一点。在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调度策略”中了解更多信息(https://www.agner.org/optimize/)。

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

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

我们为目标体系结构定义了一个enum 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 
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 
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 < end_ptr)
    {
        Impl::add(local_sum, *ptr);
        ++ptr;
    }
    Impl::add(sum, local_sum);
}))

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

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

在应用此优化后,性能报告的一小部分如下:

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

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

template 
struct UnaryOperationImpl
{
    using ResultType = typename Op::ResultType;
    using ColVecA = ColumnVectorOrDecimal;
    using ColVecC = ColumnVectorOrDecimal
    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);
}

在应用此优化后,性能报告的一小部分如下:

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

总体而言,对于一元函数的这种优化将性能提高了1.15-2倍。对于一些特定函数,例如roundDuration,这样的优化提高了2-7倍的性能。

总结

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

Meetup 活动报名通知

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

ClickHouse中的CPU调度_第3张图片

ClickHouse中的CPU调度_第4张图片

​​联系我们

手机号:13910395701

邮箱:[email protected]

满足您所有的在线分析列式数据库管理需求

你可能感兴趣的:(软件工程,clickhouse)