为什么 np.zeros() 比使用 Python 在 Numba 中重新初始化现有数组更快?

kor*_*dar 5 python numpy calloc dynamic-memory-allocation numba

为什么numpy.zeros()比重新初始化现有数组更快?

我从事计算机建模工作,并在工作中使用 numba。有时需要有一个归零数组来累积某些操作的结果。一般来说,我认为对已分配的数组进行清零不会比创建一个充满零的新数组慢,但事实并非如此。我知道延迟选择(例如为什么Python的Numpy零和空函数之间的速度差异会随着较大的数组大小而消失?https: //vorpus.org/blog/why-does-calloc-exist/),但它必须采取是时候将其归零了。

据我所知,np.zeros使用calloc和所有加速都来自此调用,并且应该可以为其他语言重现。有什么保证吗,总是这样吗?这是好的做法还是不好的做法?

import numpy as np
import numba as nb
import benchit
nb.set_num_threads(1)

@nb.njit
def numba_operation(in_arr, out):
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            out[i,j] += in_arr[i,j] * 2 + 4
            
@nb.njit
def numba_operation_with_zeros(in_arr, out):
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            out[i,j] = 0
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            out[i,j] += in_arr[i,j] * 2 + 4

    
def every_time_generate_zeros(data):
    in_arr, out = data
    out = np.zeros(shape=(out.shape[0], out.shape[0]))
    numba_operation(in_arr, out)
    return out

def make_zeros_numba(data):
    in_arr, out = data
    numba_operation_with_zeros(in_arr, out)

def generate_arrays(n):
    in_arr = np.random.rand(2**n, 2**n)
    out = np.random.rand(2**n, 2**n)
    return in_arr, out

t = benchit.timings([every_time_generate_zeros, make_zeros_numba], 
                    {n:generate_arrays(n) for n in np.arange(9, 15, 1)}, 
                    input_name='2^n')
t.plot(modules=benchit.extract_modules_from_globals(globals()))
Run Code Online (Sandbox Code Playgroud)

结果:

代码输出

Jér*_*ard 5

TL;DR:观察到的行为是由于与 CPU 缓存和虚拟内存相关的几种低级影响的组合造成的。


对于大型数组,np.zeros实际上不会在主流平台上的物理内存中填充任何内容。在这种情况下,calloc系统调用在内部用于从操作系统 (OS) 保留归零的内存空间。该内存空间是虚拟分配的,而不是物理分配的。虚拟内存被分割成称为页面的小块。分配的页面仅在第一次接触主流操作系统时才会被填充。请注意,为了安全起见,malloc(由 调用)也会将内存归零(因为任何信息都不应从一个应用程序泄漏到另一个应用程序)。np.empty

这意味着np.zeros与手动用零填充数组(在主流操作系统上慢得多)相比,这对于大型数组来说很便宜(因为惰性/延迟初始化)。如果写入新分配的数组(例如 in )every_time_generate_zeros,则需要将页面清零。然而,内存清零是逐页动态执行的make_zeros_numba这与首先​​将整个数组归零然后再次用非零值填充的实现有很大的不同!事实上,经典页面通常只有几 KiB 宽(主流 x86-64 平台上为 4 KiB),因此它们可以放入L1 CPU 缓存中。当在every_time_generate_zeros尚未填充零的虚拟分配页中写入值时,会触发页面错误,并且处理器会用零填充整个页。然后,归零的页面位于缓存中,因此写入速度要快得多。这就是为什么make_zeros_numba在你的情况下速度较慢:数组需要存储到 DRAM 两次,因为它可能不适合(相同的)CPU 缓存(至少不适合n >= 2^12)。

幕后发生的事情相当复杂。事实上,几乎没有遗漏的细节使得这变得更加复杂,但我试图使解释相对简单,以便到目前为止很容易理解。


如果您想要快速的东西,那么您需要将数组虚拟地分割为动态填充/计算的块(即平铺),并且还要避免创建临时数组。然而,这在不平凡的代码中很难做到(事实上,并不总是可行)。由于存在内存墙,这对于性能至关重要。


附加注释和解释

请注意,页面错误的代价也很高。事实上,它们可能比在某些系统(通常是具有大 DRAM 带宽的服务器)上重复使用相同的阵列更昂贵。结果,出现了make_zeros_numba实际上可以更快的计算机!该行为还取决于操作系统和标准 C 库实现。

使用多个线程填充目标数组通常也会对两种方法的性能产生不同的影响。事实上,页面错误在某些系统(例如 Windows)上几乎无法扩展,而在其他一些系统(例如 Linux)上却可以很好地扩展。一般来说,DRAM 写入不会随着核心数量的增加而扩展:在大多数机器上,只有少数核心就足以使 DRAM 带宽饱和。

在用零填充内存时,我故意没有提到一个重要因素。现代 x86-64 处理器使用回写式 CPU 缓存。这意味着需要从 DRAM 读取数据才能写入缓存行(可能多次)。修改后的高速缓存行稍后会写回 DRAM(通常在高速缓存未命中期间)。读取 DRAM 来写入零的效率很低(浪费了一半的带宽)。这就是为什么现代 x86-64 处理器还具有专用指令来避免此问题:非临时存储(NT-store)。memcpy(也可能)系统memset调用通常在需要时使用它们。NT 存储仅对于不适合 RAM 的大型数组或从未直接重用的数组(后者在实践中很难知道)才值得。事实上,小型阵列往往适合 CPU 缓存,因此它们不需要一遍又一遍地存储到 DRAM(比 CPU 缓存慢得多),这就是为什么小型阵列的行为与大型阵列有很大不同。最近的现代 x86-64 处理器甚至具有特殊指令,可以比通常的指令更快地用零填充内存。

请注意,还有比经典页面大得多的大页面(例如 2 MiB),以便减少小型经典页面的开销(尤其是页面错误)。使用它们会严重影响性能,因为 L1 缓存通常足够大,可以容纳 1 个大页面。事实上,对于 L2(如果有的话)来说通常也是如此。LLC 缓存往往足够大,但它也比 L1/L2 慢得多。此外,大页可以被操作系统自动使用

最后,请注意,Numba JIT 可以足够聪明地用 来替换归零循环,memset由于 NT 存储,这可以显着加快速度。然而,到目前为止,事实证明这是依赖于平台的。


其他相关帖子: