在numpy中优化划分

qaxu7uf2  于 2023-10-19  发布在  其他
关注(0)|答案(1)|浏览(169)

我有一个情况,我需要用同一个数组划分几个不同的大数组:

import numpy as np

a = np.random.uniform(-1, 1, size=(1000000,))
b = np.random.uniform(-1, 1, size=(1000000,))
c = np.random.uniform(-1, 1, size=(1000000,))
d = np.random.uniform(1, 100, size=(1000000,))

a1 = a / d
b1 = b / d
c1 = c / d

我知道除法比乘法慢,所以我很好奇,如果我写:

d_recip = 1 / d
a1 = a * d_recip
b1 = b * d_recip
c1 = c * d_recip

numpy是否已经在引擎盖下做了一些事情来优化这样的情况?如果我使用cython,像这样的东西会被优化吗?
我也很好奇,当除数是标量时,这是如何应用的:

import numpy as np

a = np.random.uniform(-1, 1, size=(1000000,))
b = np.random.uniform(-1, 1, size=(1000000,))
c = np.random.uniform(-1, 1, size=(1000000,))
d = 1.65

a1 = a / d
b1 = b / d
c1 = c / d

# faster? or has numpy already optimized this above?
d_recip = 1 / d
a2 = a * d_recip
b2 = b * d_recip
c2 = c * d_recip

我知道倒数方法不会产生位相同的结果,我只是好奇如何获得最快的速度时,确切的位不是一个问题。

nue99wik

nue99wik1#

Numpy在引擎盖下已经做了什么来优化这样的情况吗?
不。Numpy不会对这样的除法执行特殊优化(请参阅下面的证明)。
我知道除法比乘法慢
这确实是普遍正确的。几乎所有的平台。话虽如此,在最近的CPU上,实际上差距并不大,特别是当Numpy数组很大时。
实际上,* 整数 * 除法比乘法(特别是64位除法)昂贵得多。例如,在Intel Skylake(4-8岁)上,每21~90个周期可以计算一次64位整数除法,而每1个周期可以计算一次64位整数乘法。对于 * 浮点 * 数(FP),每4个周期可以计算 * 双精度 (64位)除法,而每1个周期可以计算2个双精度(DP)乘法。正如我们所看到的,浮点数的差距要小得多,尽管它仍然很大。不同CPU架构的数字会有所不同,但所有主流x86-64 CPU(Intel和AMD)的行为方式都是一样的:分割总是较慢(延迟和吞吐量)。尽管如此,在最近的CPU上,整数除法要快得多(而浮点数的情况大致相同)。事实上,64位整数除法在AMD Zen 4上只需要7-12个周期,在Intel IceLake上只需要10个周期,而64位整数乘法仍然需要1个周期。
在实践中,Numpy使用一个SIMD友好的代码,使C编译器能够生成快速的 SIMD 指令(例如:AVX),以便在运行时计算每个指令的多个数字。在这种情况下,乘法和除法之间的差距往往是两倍大,但两者都可以很快计算(对于Python代码)。例如,AVX可以每8个周期计算4次除法(即,0.5/循环)!
我很好奇,如果我写的更快:[...]
前一段只在计算是 * 计算绑定 * 的情况下才有意义。问题是计算在大多数机器上是内存受限的(或接近)。因此,除法的时间几乎是无关紧要的,因为RAM比CPU慢得多。话虽如此,结果可能会从一台机器到另一台机器发生变化,因为输入/输出阵列可以适应最近高端CPU的L3缓存,并且L3缓存比RAM快。此外,最近的高端机器有2个DDR5/LPDDR 5 RAM通道,可以达到相当高的带宽。Numpy可能无法使此类机器上的RAM饱和。在这种情况下,除法在这样的机器上可能会慢得多。
此外,由于Numpy * 分配内存 * 的方式,操作系统管理 * 虚拟内存页面 * 以及CPU缓存的操作方式(更不用说频率缩放管理),计时可能非常不稳定。因此,在编写基准时需要小心。最重要的是,计算d_recip会导致从RAM读取/写入更多的数据,如果合适的话,缓存中需要更多的空间(因此不太可能影响基准测试)。这也是为什么有些人觉得乘法比较慢的部分原因。
在我的机器上,40 GiB/s RAM和4.5 GHz i5- 9600 KF CPU,可以计算0.5 DP分频/周期。这意味着如果RAM不是瓶颈,CPU可以达到3 * 0.5 * 8 * 4.5e9 / 1024**3 = 50.3 GiB/s的吞吐量。实际上,我的RAM的吞吐量较慢,因此乘法和除法应该同样快。添加一个新的临时步骤,对饱和的RAM施加更大的压力,只会使事情变慢(下面提供了一些分析细节)。
如果我使用cython,像这样的东西会被优化吗?
嗯,这取决于很多参数...但总的来说不多。
在Cython中使用相同的代码不会对性能产生影响,因为Cython不会加速Numpy调用。如果你使用朴素的基本循环,那么Cython代码的行为将像Numpy一样。如果你在运行中计算1 / d的项,那么它将减少内存压力,所以第二个代码在所有主流机器上不应该比第一个慢。尽管如此,这两种代码的速度应该受到RAM的限制,所以应该关注这一点,而不是分割的成本
* 更好的是:在运行中计算数据,以避免在RAM中写入巨大的数组。如果可能的话,阵列应该适合该高速缓存。

在引擎盖下

下面是我在Debian Linux(带GCC)上使用numpy-1.26.1和CPython 3.11.2在我的机器上获得的一些分析细节。
以下是时间:

In [10]: %timeit -n 1000 a2 = a * d_recip
736 µs ± 2.48 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
In [11]: %timeit -n 1000 a1 = a / d
727 µs ± 1.31 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

因此,可以看出,在这种情况下,乘法和除法之间的结果与上面所预期和解释的相同。
执行Numpy数组DP除法会导致调用名为DOUBLE_divide_FMA3__AVX2的计算函数,而DP乘法则是DOUBLE_multiply_FMA3__AVX2。这两个函数的汇编代码如下:

DOUBLE_divide_FMA3__AVX2:

e3:┌─→vmovupd      0x20(%rcx,%rdi,1),%xmm7               
   │  vinsertf128  $0x1,0x30(%rcx,%rdi,1),%ymm7,%ymm0   
   │  sub          $0x8,%r8                             
   │  vmovupd      0x20(%rdx,%rdi,1),%xmm7              
   │  vinsertf128  $0x1,0x30(%rdx,%rdi,1),%ymm7,%ymm1   
   │  vmovupd      (%rcx,%rdi,1),%xmm4                  
   │  vmovupd      (%rdx,%rdi,1),%xmm5                  
   │  vdivpd       %ymm1,%ymm0,%ymm0                    <-----
   │  vinsertf128  $0x1,0x10(%rdx,%rdi,1),%ymm5,%ymm2   
   │  vinsertf128  $0x1,0x10(%rcx,%rdi,1),%ymm4,%ymm1   
   │  vdivpd       %ymm2,%ymm1,%ymm1                    <-----
   │  vmovupd      %xmm0,0x20(%rsi,%rdi,1)              
   │  vextractf128 $0x1,%ymm0,0x30(%rsi,%rdi,1)         
   │  vmovupd      %xmm1,(%rsi,%rdi,1)                  
   │  vextractf128 $0x1,%ymm1,0x10(%rsi,%rdi,1)         
   │  add          $0x40,%rdi                           
   │  cmp          $0x7,%r8 

DOUBLE_multiply_FMA3__AVX2:

e3:┌─→vmovupd      0x20(%rdx,%rdi,1),%xmm7              
   │  vmovupd      0x20(%rcx,%rdi,1),%xmm4              
   │  sub          $0x8,%r8                             
   │  vinsertf128  $0x1,0x30(%rdx,%rdi,1),%ymm7,%ymm0   
   │  vinsertf128  $0x1,0x30(%rcx,%rdi,1),%ymm4,%ymm1   
   │  vmovupd      (%rdx,%rdi,1),%xmm7                  
   │  vmovupd      (%rcx,%rdi,1),%xmm3                  
   │  vmulpd       %ymm1,%ymm0,%ymm0                    <-----
   │  vinsertf128  $0x1,0x10(%rcx,%rdi,1),%ymm3,%ymm2   
   │  vinsertf128  $0x1,0x10(%rdx,%rdi,1),%ymm7,%ymm1   
   │  vmulpd       %ymm2,%ymm1,%ymm1                    <-----
   │  vmovupd      %xmm0,0x20(%rsi,%rdi,1)              
   │  vextractf128 $0x1,%ymm0,0x30(%rsi,%rdi,1)         
   │  vmovupd      %xmm1,(%rsi,%rdi,1)                  
   │  vextractf128 $0x1,%ymm1,0x10(%rsi,%rdi,1)         
   │  add          $0x40,%rdi                           
   │  cmp          $0x7,%r8                             
   └──jg           e3

可以看到Numpy按照预期使用除法,并且除了使用AVX进行矢量化之外,没有执行任何特殊的优化。

更高级的分析结果表明,几乎所有的时间都花在加载/存储指令上,特别是在执行乘法时:乘法指令仅停止5%的时间,而除法指令仅停止32%的时间。在这两种情况下,我的RAM几乎都饱和了(阅读:23 GiB/s,写入:9.4 GiB/s)。请注意,每个核心的吞吐量是有限的,因此我希望在>= 2个核心的情况下并行达到~40 GiB/s。

备注

1请注意,因子3来自两点:

  • 必须读取输入数组,并且必须写入输出数组;
  • 写入数据需要在x86-64 CPU上的Numpy中写入(由于cache-line write allocations)。

相关问题