为什么 numpy.view(bool) 使 numpy.logical_and 明显更快?

Why does numpy.view(bool) makes numpy.logical_and significantly faster?

uint8numpy.ndarray 传递给 numpy.logical_and 时,如果我将 numpy.view(bool) 应用于其输入,它运行速度会明显加快。

a = np.random.randint(0, 255, 1000 * 1000 * 100, dtype=np.uint8)
b = np.random.randint(0, 255, 1000 * 1000 * 100, dtype=np.uint8)

%timeit np.logical_and(a, b)
126 ms ± 1.17 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit np.logical_and(a.view(bool), b.view(bool))
20.9 ms ± 110 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

有人可以解释为什么会这样吗?

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

编辑:

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

我的环境:

这是当前 Numpy 实现的性能问题。我还可以在 Windows 上重现此问题(使用带有 Numpy 1.20.3 的 Intel Skylake Xeon 处理器)。 np.logical_and(a, b) 基于 慢条件跳转 执行 very-inefficient 标量汇编代码 np.logical_and(a.view(bool), b.view(bool)) 执行 relatively-fast SIMD指令.

目前,Numpy 使用 specific implementation 作为 bool 类型。关于所使用的编译器,如果用于构建 Numpy 的编译器无法自动矢量化代码,Windows 显然是这种情况,general-purpose 的实现速度可能会明显变慢(并解释为什么情况并非如此其他平台,因为编译器可能不完全相同)。 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]

如您所见,循环使用多个 data-dependent 条件跳转 来写入每个 ab 读取的项目。这是非常低效的,因为 采取的分支不能由具有随机值的处理器 predicted。结果,处理器 停顿几个周期 (在现代 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

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

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