为什么numba的popcount代码比同等的C代码快两倍?

m3eecexj  于 2023-10-16  发布在  其他
关注(0)|答案(2)|浏览(111)

我有一个简单的python/numba代码:

from numba import njit
import numba as nb
@nb.njit(nb.uint64(nb.uint64))
def popcount(x): 
      b=0
      while(x > 0):
          x &= x - nb.uint64(1)   
          b+=1
      return b
@njit
def timed_loop(n):
    summand = 0
    for i in range(n):
        summand += popcount(i)
    return summand

它只是将整数0到n - 1的popcount相加。
当我计时时,我得到:

%timeit timed_loop(1000000)
340 µs ± 1.08 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

llvm巧妙地将popcount函数转换为本机CPU POPCNT指令,因此我们应该期望它很快。但问题是,有多快。
我想我会把它和C版本进行比较,看看速度上的差异。

#include <stdio.h>
#include <time.h>

// Function to calculate the population count (number of set bits) of an integer using __builtin_popcount
int popcount(int num) {
    return __builtin_popcount(num);
}

int main() {
    unsigned int n;
    printf("Enter the value of n: ");
    scanf("%d", &n);

    // Variables to store start and end times
    struct timespec start_time, end_time;

    // Get the current time as the start time
    clock_gettime(CLOCK_MONOTONIC, &start_time);

    int sum = 0;
    for (unsigned int i = 0; i < n; i++) {
        sum += popcount(i);
    }

    // Get the current time as the end time
    clock_gettime(CLOCK_MONOTONIC, &end_time);

    // Calculate the elapsed time in microseconds
    long long elapsed_time = (end_time.tv_sec - start_time.tv_sec) * 1000000LL +
                            (end_time.tv_nsec - start_time.tv_nsec) / 1000;

    printf("Sum of population counts from 0 to %d-1 is: %d\n", n, sum);
    printf("Elapsed time: %lld microseconds\n", elapsed_time);

    return 0;
}

然后我用-march=native -Ofast编译了这个。我尝试了gcc和clang,结果非常相似。

./popcount 
Enter the value of n: 1000000
Sum of population counts from 0 to 1000000-1 is: 9884992
Elapsed time: 732 microseconds

为什么numba比C代码快两倍?

tktrz96b

tktrz96b1#

TL;DR:GCC和Clang版本之间的性能差距是由于使用标量指令和SIMD指令造成的。Numba和Clang版本之间的性能差距来自两个版本之间不相同的整数**大小 *:64位与32位

性能结果

首先,我也能够重现我的英特尔i5- 9600 KF的问题。以下是结果(和版本):

Numba 0.56.4:  170.089 ms
Clang 14.0.6:  190.350 ms
GCC 12.2.0:    328.133 ms

为了理解发生了什么,我们需要分析所有编译器产生的汇编代码。

汇编代码

下面是GCC生成的热循环的汇编代码:

.L5:
    xorl    %edx, %edx
    popcntl %eax, %edx
    incl    %eax
    addl    %edx, %ebx
    cmpl    %ecx, %eax
    jne .L5

以下是Clang制作的一个:

.LBB1_3:                                # =>This Inner Loop Header: Depth=1
    vpand   %ymm5, %ymm0, %ymm12
    vpshufb %ymm12, %ymm6, %ymm12
    vpsrlw  $4, %ymm0, %ymm13
    vpand   %ymm5, %ymm13, %ymm13
    vpshufb %ymm13, %ymm6, %ymm13
    vpaddb  %ymm12, %ymm13, %ymm12
    vpunpckhdq  %ymm1, %ymm12, %ymm13   # ymm13 = ymm12[2],ymm1[2],ymm12[3],ymm1[3],ymm12[6],ymm1[6],ymm12[7],ymm1[7]
    vpsadbw %ymm1, %ymm13, %ymm13
    vpunpckldq  %ymm1, %ymm12, %ymm12   # ymm12 = ymm12[0],ymm1[0],ymm12[1],ymm1[1],ymm12[4],ymm1[4],ymm12[5],ymm1[5]
    vpsadbw %ymm1, %ymm12, %ymm12
    vpackuswb   %ymm13, %ymm12, %ymm12
    vpaddd  %ymm2, %ymm0, %ymm13
    vpaddd  %ymm12, %ymm8, %ymm8
    vpand   %ymm5, %ymm13, %ymm12
    vpshufb %ymm12, %ymm6, %ymm12
    vpsrlw  $4, %ymm13, %ymm13
    vpand   %ymm5, %ymm13, %ymm13
    vpshufb %ymm13, %ymm6, %ymm13
    vpaddb  %ymm12, %ymm13, %ymm12
    vpunpckhdq  %ymm1, %ymm12, %ymm13   # ymm13 = ymm12[2],ymm1[2],ymm12[3],ymm1[3],ymm12[6],ymm1[6],ymm12[7],ymm1[7]
    vpsadbw %ymm1, %ymm13, %ymm13
    vpunpckldq  %ymm1, %ymm12, %ymm12   # ymm12 = ymm12[0],ymm1[0],ymm12[1],ymm1[1],ymm12[4],ymm1[4],ymm12[5],ymm1[5]
    vpsadbw %ymm1, %ymm12, %ymm12
    vpackuswb   %ymm13, %ymm12, %ymm12
    vpaddd  %ymm3, %ymm0, %ymm13
    vpaddd  %ymm12, %ymm9, %ymm9
    vpand   %ymm5, %ymm13, %ymm12
    vpshufb %ymm12, %ymm6, %ymm12
    vpsrlw  $4, %ymm13, %ymm13
    vpand   %ymm5, %ymm13, %ymm13
    vpshufb %ymm13, %ymm6, %ymm13
    vpaddb  %ymm12, %ymm13, %ymm12
    vpunpckhdq  %ymm1, %ymm12, %ymm13   # ymm13 = ymm12[2],ymm1[2],ymm12[3],ymm1[3],ymm12[6],ymm1[6],ymm12[7],ymm1[7]
    vpsadbw %ymm1, %ymm13, %ymm13
    vpunpckldq  %ymm1, %ymm12, %ymm12   # ymm12 = ymm12[0],ymm1[0],ymm12[1],ymm1[1],ymm12[4],ymm1[4],ymm12[5],ymm1[5]
    vpsadbw %ymm1, %ymm12, %ymm12
    vpackuswb   %ymm13, %ymm12, %ymm12
    vpaddd  %ymm4, %ymm0, %ymm13
    vpaddd  %ymm12, %ymm10, %ymm10
    vpand   %ymm5, %ymm13, %ymm12
    vpshufb %ymm12, %ymm6, %ymm12
    vpsrlw  $4, %ymm13, %ymm13
    vpand   %ymm5, %ymm13, %ymm13
    vpshufb %ymm13, %ymm6, %ymm13
    vpaddb  %ymm12, %ymm13, %ymm12
    vpunpckhdq  %ymm1, %ymm12, %ymm13   # ymm13 = ymm12[2],ymm1[2],ymm12[3],ymm1[3],ymm12[6],ymm1[6],ymm12[7],ymm1[7]
    vpsadbw %ymm1, %ymm13, %ymm13
    vpunpckldq  %ymm1, %ymm12, %ymm12   # ymm12 = ymm12[0],ymm1[0],ymm12[1],ymm1[1],ymm12[4],ymm1[4],ymm12[5],ymm1[5]
    vpsadbw %ymm1, %ymm12, %ymm12
    vpackuswb   %ymm13, %ymm12, %ymm12
    vpaddd  %ymm12, %ymm11, %ymm11
    vpaddd  %ymm7, %ymm0, %ymm0
    addl    $-32, %edx
    jne .LBB1_3

以下是Numba制作的一个:

.LBB0_8:
    vpand   %ymm0, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm0, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpaddq  -40(%rsp), %ymm0, %ymm7
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm1, %ymm1
    vpand   %ymm7, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm7, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpaddq  -72(%rsp), %ymm0, %ymm7
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm2, %ymm2
    vpand   %ymm7, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm7, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpaddq  %ymm0, %ymm8, %ymm7
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm3, %ymm3
    vpand   %ymm7, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm7, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm4, %ymm4
    vpaddq  %ymm0, %ymm11, %ymm6
    vpand   %ymm6, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpsrlw  $4, %ymm6, %ymm6
    vpand   %ymm6, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpaddb  %ymm7, %ymm6, %ymm6
    vpaddq  %ymm0, %ymm12, %ymm7
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm1, %ymm1
    vpand   %ymm7, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm7, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpaddq  %ymm0, %ymm13, %ymm7
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm2, %ymm2
    vpand   %ymm7, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm7, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpaddq  %ymm0, %ymm14, %ymm7
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm3, %ymm3
    vpand   %ymm7, %ymm9, %ymm6
    vpshufb %ymm6, %ymm10, %ymm6
    vpsrlw  $4, %ymm7, %ymm7
    vpand   %ymm7, %ymm9, %ymm7
    vpshufb %ymm7, %ymm10, %ymm7
    vpaddb  %ymm6, %ymm7, %ymm6
    vpsadbw %ymm5, %ymm6, %ymm6
    vpaddq  %ymm6, %ymm4, %ymm4
    vpaddq  %ymm0, %ymm15, %ymm0
    addq    $-2, %rbx
    jne .LBB0_8

分析

首先,我们可以看到GCC代码使用了popcntl指令,它非常快,至少对于标量操作来说是这样。
Clang在我的机器上使用AVX-2 SIMD指令集生成汇编代码。这就是为什么Clang制作的程序与GCC相比如此之快:由于SIMD指令,它可以并行地对许多项进行操作。
Numba生成一个与Clang非常相似的代码。这并不奇怪,因为Numba基于LLVM-Lite(因此LLVM),而Clang也基于LLVM。但是,在解释性能影响方面存在一些小差异。事实上,Numba汇编代码比Clang对应代码操作的项目多两倍。这可以通过计算vpsrlw指令的数量(8 VS 4)来看出。我不希望这会有什么不同,因为Clang循环已经很好地展开了,而且展开更多的好处很小。实际上,这种更积极的展开是一种副作用。关键的区别是Numba操作64位整数,而C代码操作32位整数!这就是为什么Clang以不同的方式展开循环并生成不同的指令。事实上,较小的整数会导致Clang生成一系列指令来转换不同大小的整数,这效率较低。恕我直言,这是影响优化器的一个副作用,因为对较小项的操作通常可以生成更快的SIMD代码。在这种情况下,LLVM生成的代码似乎是次优的:它使端口5饱和(即, Shuffle /置换执行单元)在我的机器上,而一个人可以写一个代码不饱和它(不容易,虽然)。

加快C实现速度

您可以修复C++实现,以便操作64位整数

#include <stdio.h>
#include <time.h>
#include <stdint.h>

// Function to calculate the population count (number of set bits) of an integer using __builtin_popcount
uint64_t popcount(uint64_t num) {
    return __builtin_popcountl(num);
}

int main() {
    int64_t n;
    printf("Enter the value of n: ");
    scanf("%ld", &n);

    // Variables to store start and end times
    struct timespec start_time, end_time;

    // Get the current time as the start time
    clock_gettime(CLOCK_MONOTONIC, &start_time);

    int64_t sum = 0;
    for (int64_t i = 0; i < n; i++) {
        sum += popcount(i);
    }

    // Get the current time as the end time
    clock_gettime(CLOCK_MONOTONIC, &end_time);

    // Calculate the elapsed time in microseconds
    long long elapsed_time = (end_time.tv_sec - start_time.tv_sec) * 1000000LL +
                            (end_time.tv_nsec - start_time.tv_nsec) / 1000;

    printf("Sum of population counts from 0 to %ld-1 is: %ld\n", n, sum);
    printf("Elapsed time: %lld microseconds\n", elapsed_time);

    return 0;
}

这在我的机器上使用Clang生成了一个和Numba一样快的程序(GCC仍然生成一个缓慢的标量实现)。

备注

SIMD版本只有在您的真实代码是SIMD友好的情况下才有意义,也就是说,如果popcount可以应用于多个连续项。否则,标量实现的结果可能会有很大的不同(事实上,这三个编译器生成的代码非常接近,我希望它们同样快)。
AVX-512提供了SIMD指令VPOPCNTDQ,该指令的性能明显优于LLVM(仅使用)AVX-2生成的代码。由于我的机器上没有AVX-512,AVX-2也没有提供这样的指令,所以LLVM使用AVX-2生成汇编代码是有意义的。AVX-512指令可以并行计算16 x 32位整数中的1的数量,同时与其标量对应物花费大约相同的周期数。更准确地说,该指令仅适用于AVX512VPOPCNTDQ + AVX512VL指令集(AFAIK在所有支持AVX-512的CPU上都不可用)。到目前为止,该指令仅适用于少数x86-64微架构(例如。Intel Ice-Lake、Intel Sapphire急流和AMD Zen 4)。

0dxa2lsx

0dxa2lsx2#

我使用nanobench基准测试库对您的代码进行了基准测试:

#define ANKERL_NANOBENCH_IMPLEMENT
#include <nanobench.h>

// Function to calculate the population count (number of set bits) of an integer using __builtin_popcount
int popcount(int num) {
    return __builtin_popcount(num);
}

int main() {
    unsigned int n = 1000000;
    int sum = 0;

    ankerl::nanobench::Bench().minEpochIterations(100).run("popcount bench", [&] {
        for (unsigned int i = 0; i < n; i++) {
            sum += popcount(i);
        }
        ankerl::nanobench::doNotOptimizeAway(sum);
    });

    return 0;
}

编译它与

g++ -Ofast -march=native -I./nanobench/src/include/ ./main.cpp

这给了我这个输出:

|               ns/op |                op/s |    err% |     total | benchmark
|--------------------:|--------------------:|--------:|----------:|:----------
|          216,476.86 |            4,619.43 |    0.1% |      0.26 | `popcount bench`

因此,如果我正确地解释了结果,那么在一秒钟内,我们将执行4619次操作(一次迭代约为216 us)。
相比之下,使用numba + timeit

import numba as nb
from numba import njit

@nb.njit(nb.uint64(nb.uint64))
def popcount(x):
    b = 0
    while x > 0:
        x &= x - nb.uint64(1)
        b += 1
    return b

@njit
def timed_loop(n):
    summand = 0
    for i in range(n):
        summand += popcount(i)
    return summand

# warm the cache
timed_loop(1)

from timeit import timeit

t = timeit("timed_loop(n)", setup="n = 1000000", globals=globals(), number=1000)
print(t)

图纸:

0.13498791516758502

所以1000次迭代是0.135秒,1次迭代花费了~ 135 us。
所以,是的,你的观察是正确的。看起来numba是~ 2倍快。
我的规格:Python 3.11/Ubuntu 20.04/AMD 5700 x/g++ 9.4.0

相关问题