为什么 numpy.view(bool) 使 numpy.logic_and 显着更快?

ken*_*ken 7 python performance numpy

当传递numpy.ndarrayof uint8to时numpy.logical_and,如果我应用numpy.view(bool)到它的输入,它的运行速度会明显加快。

\n
a = np.random.randint(0, 255, 1000 * 1000 * 100, dtype=np.uint8)\nb = np.random.randint(0, 255, 1000 * 1000 * 100, dtype=np.uint8)\n\n%timeit np.logical_and(a, b)\n126 ms \xc2\xb1 1.17 ms per loop (mean \xc2\xb1 std. dev. of 7 runs, 10 loops each)\n\n%timeit np.logical_and(a.view(bool), b.view(bool))\n20.9 ms \xc2\xb1 110 \xc2\xb5s per loop (mean \xc2\xb1 std. dev. of 7 runs, 10 loops each)\n
Run Code Online (Sandbox Code Playgroud)\n

有人可以解释为什么会发生这种情况吗?

\n

此外,为什么numpy.logical_and不自动应用于view(bool)数组uint8?(有什么情况我们不应该使用 吗view(bool)?)

\n

编辑:

\n

看来这是Windows环境的问题。\n我刚刚在官方的python docker容器(debian)中尝试了同样的事情,发现它们之间没有区别。

\n

我的环境:

\n
    \n
  • 操作系统:Windows 10 专业版 21H2
  • \n
  • CPU:AMD锐龙9 5900X
  • \n
  • Python:Win32 上的 Python 3.10.2(tags/v3.10.2:a58ebcc,2022 年 1 月 17 日,14:12:15)[MSC v.1929 64 位 (AMD64)]
  • \n
  • numpy:1.22.2
  • \n
\n

Jér*_*ard 5

这是当前 Numpy 实现的性能问题。我还可以在 Windows 上重现此问题(使用带有 Numpy 1.20.3 的 Intel Skylake Xeon 处理器)。np.logical_and(a, b)执行基于缓慢条件跳转的非常低效的标量汇编代码,同时执行相对较快的SIMD 指令np.logical_and(a.view(bool), b.view(bool))

目前,Numpy 使用-types的特定实现bool。关于所使用的编译器,如果用于构建 Numpy 的编译器无法自动向量化代码(这在 Windows 上显然是这种情况),则通用实现可能会显着变慢(并解释为什么在其他平台上不是这种情况,因为编译器是可能不完全相同)。Numpy 代码可以针对非bool类型进行改进。请注意,Numpy 的矢量化是一项正在进行的工作,我们计划很快对其进行优化。


更深入的分析

以下是执行的汇编代码np.logical_and(a, b)

Block 24:                         
    cmp byte ptr [r8], 0x0        ; Read a[i]
    jz <Block 27>                 ; Jump to block 27 if a[i]!=0
Block 25:                         
    cmp byte ptr [r9], 0x0        ; Read b[i]
    jz <Block 27>                 ; Jump to block 27 if b[i]!=0
Block 26:                         
    mov al, 0x1                   ; al = 1
    jmp <Block 28>                ; Skip the next instruction
Block 27:                         
    xor al, al                    ; al = 0
Block 28:                         
    mov byte ptr [rdx], al        ; result[i] = al
    inc r8                        ; i += 1
    inc rdx                       
    inc r9                        
    sub rcx, 0x1                  
    jnz <Block 24>                ; Loop again while i<a.shape[0]
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,循环使用多个数据相关的条件跳转来写入ab读取每个项目。这是非常低效的,因为处理器无法用随机值来预测所采取的分支。因此,处理器会停滞几个周期(在现代 x86 处理器上通常约为 10 个周期)。

以下是执行的汇编代码np.logical_and(a.view(bool), b.view(bool))

Block 15:
    movdqu xmm1, xmmword ptr [r10]               ; xmm1 = a[i:i+16]
    movdqu xmm0, xmmword ptr [rbx+r10*1]         ; xmm0 = b[i:i+16]
    lea r10, ptr [r10+0x10]                      ; i += 16
    pcmpeqb xmm1, xmm2                           ; \
    pandn xmm1, xmm0                             ;  | Complex sequence to just do:
    pcmpeqb xmm1, xmm2                           ;  | xmm1 &= xmm0
    pandn xmm1, xmm3                             ; /
    movdqu xmmword ptr [r14+r10*1-0x10], xmm1    ; result[i:i+16] = xmm1
    sub rcx, 0x1                                 
    jnz <Block 15>                               ; Loop again while i!=a.shape[0]//16
Run Code Online (Sandbox Code Playgroud)

该代码使用称为 SSE 的SIMD 指令集,它能够在 128 位宽的寄存器上工作。没有条件跳转。该代码的效率要高得多,因为它每次迭代一次对 16 个项目进行操作,并且每次迭代应该更快。

请注意,最后的代码也不是最佳的,因为大多数现代 x86 处理器(例如 AMD 处理器)都支持 256 位 AVX-2 指令集(速度是其两倍)。此外,编译器会生成低效的 SIMD 指令序列来执行可优化的逻辑运算。编译器似乎假设布尔值可以是 0 或 1 之间的不同值。也就是说,输入数组太大,无法放入 CPU 缓存,因此代码受到RAM 吞吐量的限制,而不是第一个数组。这就是为什么 SIMD 友好的代码速度并没有显着加快的原因。对于处理器上小于 1 MiB 的数组(就像几乎所有其他现代处理器一样),两个版本之间的差异肯定要大得多。