我有一个简单的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代码快两倍?
2条答案
按热度按时间tktrz96b1#
TL;DR:GCC和Clang版本之间的性能差距是由于使用标量指令和SIMD指令造成的。Numba和Clang版本之间的性能差距来自两个版本之间不相同的整数**大小 *:64位与32位。
性能结果
首先,我也能够重现我的英特尔i5- 9600 KF的问题。以下是结果(和版本):
为了理解发生了什么,我们需要分析所有编译器产生的汇编代码。
汇编代码
下面是GCC生成的热循环的汇编代码:
以下是Clang制作的一个:
以下是Numba制作的一个:
分析
首先,我们可以看到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位整数:
这在我的机器上使用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)。0dxa2lsx2#
我使用nanobench基准测试库对您的代码进行了基准测试:
编译它与
这给了我这个输出:
因此,如果我正确地解释了结果,那么在一秒钟内,我们将执行4619次操作(一次迭代约为216 us)。
相比之下,使用
numba
+timeit
:图纸:
所以1000次迭代是0.135秒,1次迭代花费了~ 135 us。
所以,是的,你的观察是正确的。看起来numba是~ 2倍快。
我的规格:Python 3.11/Ubuntu 20.04/AMD 5700 x/g++ 9.4.0