为什么线性读混排写入不比随机读线性写入快?

时间:2019-02-20 08:39:13

标签: python performance numpy x86 cpu-cache

我目前正在尝试更好地了解与内存/缓存相关的性能问题。我在某处读到,内存局部性对于读取比对写入更重要,因为在前一种情况下,CPU必须实际等待数据,而在后一种情况下,CPU可以将它们运送出去而忽略它们。

考虑到这一点,我进行了以下快速测试:我编写了一个脚本,该脚本创建了一个由N个随机浮点数和排列组成的数组,即一个以随机顺序包含数字0到N-1的数组。然后,它反复进行以下操作:(1)线性读取数据数组,然后按排列给定的随机访问模式将其写回到新数组,或者(2)按排列顺序读取数据数组,然后将其线性写入新数组。

令我惊讶的是(2)似乎始终比(1)快。但是,我的脚本有问题

  • 脚本使用python / numpy编写。这是一种高级语言,目前尚不清楚如何精确地实现读/写。
  • 我可能没有正确平衡这两种情况。

此外,下面的一些答案/评论表明我的最初期望是不正确的,并且根据cpu缓存的详细信息,两种情况都可能更快。

我的问题是:

  • 两者中的哪个(如果有的话)应该更快?
  • 这里的相对缓存概念是什么;它们如何影响结果

不胜感激的初学者解释。任何支持的代码都应该在C / cython / numpy / numba或python中。

可选:

  • 解释为什么绝对持续时间在问题规模上是非线性的(请参见下面的时序)。
  • 说明我明显不足的python实验的行为。

作为参考,我的平台是Linux-4.12.14-lp150.11-default-x86_64-with-glibc2.3.4。 Python版本是3.6.5。

这是我写的代码:

import numpy as np
from timeit import timeit

def setup():
    global a, b, c
    a = np.random.permutation(N)
    b = np.random.random(N)
    c = np.empty_like(b)

def fwd():
    c = b[a]

def inv():
    c[a] = b

N = 10_000
setup()

timeit(fwd, number=100_000)
# 1.4942631321027875
timeit(inv, number=100_000)
# 2.531870319042355

N = 100_000
setup()

timeit(fwd, number=10_000)
# 2.4054739447310567
timeit(inv, number=10_000)
# 3.2365565397776663

N = 1_000_000
setup()

timeit(fwd, number=1_000)
# 11.131387163884938
timeit(inv, number=1_000)
# 14.19817715883255

正如@Trilarion和@Yann Vernier指出的那样,我的代码片段没有达到适当的平衡,所以我将其替换为

def fwd():
    c[d] = b[a]
    b[d] = c[a]

def inv():
    c[a] = b[d]
    b[a] = c[d]

其中d = np.arange(N)(我将两种方式都进行了混编,以期减少试用缓存的影响)。我还用timeit取代了repeat,并将重复次数减少了10倍。

然后我得到

[0.6757169323973358, 0.6705542299896479, 0.6702114241197705]    #fwd
[0.8183442652225494, 0.8382121799513698, 0.8173762648366392]    #inv
[1.0969422250054777, 1.0725746559910476, 1.0892365919426084]    #fwd
[1.0284497970715165, 1.025063106790185, 1.0247828317806125]     #inv
[3.073981977067888, 3.077839042060077, 3.072118630632758]       #fwd
[3.2967213969677687, 3.2996009718626738, 3.2817375687882304]    #inv

因此似乎仍然存在差异,但是它更加微妙,现在可以根据问题的大小选择哪种方式。

4 个答案:

答案 0 :(得分:11)

  • 首先驳斥您的直觉:即使没有麻木的机制,fwd也胜过inv

numba 版本就是这种情况:

import numba

@numba.njit
def fwd_numba(a,b,c):
    for i in range(N):
        c[a[i]]=b[i]

@numba.njit
def inv_numba(a,b,c):
    for i in range(N):
        c[i]=b[a[i]]

N = 10000的时间:

%timeit fwd()
%timeit inv()
%timeit fwd_numba(a,b,c)
%timeit inv_numba(a,b,c)
62.6 µs ± 3.84 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
144 µs ± 2 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
16.6 µs ± 1.52 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
34.9 µs ± 1.57 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
    其次,Numpy必须处理可怕的对齐和(缓存)局部性问题。

从本质上讲,它是针对 BLAS / ATLAS / MKL 进行了调整的低级程序的包装。 花式索引是一个很好的高级工具,但是对于这些问题却是异端。没有从低层次直接介绍这一概念。

  

除非在获取项目的过程中只有一个索引数组,否则   事先检查索引的有效性。否则处理   在内部循环中进行优化。

在这种情况下,我们在这里。我认为这可以解释差异,以及为什么set比get慢。

这也解释了为什么手工制作的数字键盘常常更快:它不检查任何内容,并且在索引不一致时崩溃。

答案 1 :(得分:9)

您的两个NumPy代码段b[a]c[a] = b似乎是衡量混洗/线性读/写速度的合理启发法,因为我将通过在第一部分中查看底层的NumPy代码来进行争论在下面。

关于哪个应该更快的问题,似乎改组的读取线性写入通常可以胜出(如基准似乎显示的那样),但是速度的差异可能会受到改组的方式的影响。改组后的索引是,并且其中一个或多个:

  • CPU缓存读取/更新策略(write-back vs. write-through等)。
  • CPU如何选择(重新)执行(流水线)所需的指令。
  • CPU识别内存访问模式并预取数据。
  • 缓存移出逻辑。

即使假设已制定了哪些策略,也很难对这些影响进行建模和分析,因此我不确定适用于所有处理器的通用答案是否可行(尽管我不是硬件专家)。

尽管如此,在下面的第二部分中,我将基于某些假设尝试推断为什么改组后的读取线性写入速度明显更快。


“热门”花式索引

本节的目的是浏览NumPy源代码,以确定是否存在有关计时的明显解释,并尽可能清楚地了解A[B]或{{1}时发生的情况}。

此问题中getitemsetitem操作的华丽索引的迭代例程是“ trivial”:

  • A[B] = C是一个单步长的单索引数组
  • BA具有相同的内存顺序(C连续或Fortran连续)

此外,在我们的示例中,BA均为Uint Aligned

  

有效的复制代码:此处使用“ uint对齐”。如果数组的项目大小[N]等于1、2、4、8或16个字节,并且该数组是uint对齐的,则numpy将使用适当的N来B,而不是使用缓冲。通过执行*(uintN*)dst) = *(uintN*)src)

此处的要点是避免使用内部缓冲区来确保对齐。通过memcpy(dst, src, N)实现的基础复制就像“将偏移src中的X个字节放入偏移dst中的X个字节”一样简单。

编译器可能会将其非常简单地转换为*(uintN*)dst) = *(uintN*)src)指令(例如在x86上)或类似的指令。

执行项获取和设置的核心低级代码位于函数movmapiter_trivial_get中。这些函数是在lowlevel_strided_loops.c.src中产生的,其中的模板和宏使阅读变得有些困难(这是对高级语言的感激之情)。

坚持不懈,我们最终可以看到getitem和setitem之间没有什么区别。这是展示主循环的简化版本。宏行确定运行的是getitem还是setitem:

mapiter_trivial_set

正如我们可能期望的那样,这只是某种算术,以将正确的偏移量获取到数组中,然后将字节从一个存储位置复制到另一个存储位置。

额外的索引检查设置项

值得一提的是,对于setitem,索引的有效性(无论它们是否是目标数组的所有入站)都是从checked before copying开始(通过check_and_adjust_index),它也将负索引替换为相应的正指数。

在上面的代码片段中,您可以在主循环中看到 while (itersize--) { char * self_ptr; npy_intp indval = *((npy_intp*)ind_ptr); #if @isget@ if (check_and_adjust_index(&indval, fancy_dim, 0, _save) < 0 ) { return -1; } #else if (indval < 0) { indval += fancy_dim; } #endif self_ptr = base_ptr + indval * self_stride; /* offset into array being indexed */ #if @isget@ *(npy_uint64 *)result_ptr = *(npy_uint64 *)self_ptr; #else *(npy_uint64 *)self_ptr = *(npy_uint64 *)result_ptr; #endif ind_ptr += ind_stride; /* move to next item of index array */ result_ptr += result_stride; /* move to next item of result array */ 要求getitem,而对setitem则进行了一个更简单(可能是多余的)的负索引检查。

可以想象,这种额外的初步检查可能会对确定项目(check_and_adjust_index)的速度产生较小但负面的影响。


缓存未命中

由于两个代码段的代码是如此相似,因此怀疑在于CPU以及它如何处理对底层内存阵列的访问。

CPU缓存了最近访问过的小块内存(缓存行),因为它可能很快就会需要再次访问该内存区域。

对于上下文,高速缓存行通常为64个字节。我老化的笔记本电脑的CPU上的L1(最快)数据缓存为32KB(足以容纳约500个数组中的int64值,但是请记住,在执行NumPy代码段时,CPU会做其他需要其他内存的操作):

A[B] = C

您可能已经知道,对于顺序读取/写入内存,缓存非常有效,因为可以根据需要提取64字节的内存块并将其存储在CPU附近。重复访问该内存块比从RAM(或较慢的高级缓存)中获取数据要快。实际上,CPU甚至可能在程序请求之前就抢占了下一个缓存行。

另一方面,随机访问内存可能会导致频繁的高速缓存未命中。在这里,具有所需地址的内存区域不在CPU附近的快速缓存中,而必须从更高级别的缓存(速度较慢)或实际内存(速度要慢得多)中进行访问。

那么,CPU处理起来更快的方式是:频繁的数据读取遗漏还是数据写入遗漏?

让我们假设CPU的写策略是回写的,这意味着将修改后的内存写回到高速缓存中。将该高速缓存标记为已修改(或“脏”),并且仅在将该行从高速缓存中逐出后,更改才会被写回到主内存中(CPU仍可以从一个脏的高速缓存行中读取)。

如果我们要写一个大数组中的随机点,则期望CPU缓存中的许多缓存行将变脏。每次驱逐时都需要对主存储器进行写操作,如果高速缓存已满,则可能经常发生这种情况。

但是,当我们顺序写入数据并随机读取数据时,这种写入操作应该不会那么频繁,因为我们预计更少的缓存行会变脏,并且数据写回主内存的速度也会变慢,或者较慢的缓存速度会变慢。

如上所述,这是一个简化的模型,可能还有许多其他因素会影响CPU的性能。比我更专业的人也许可以改善这个模型。

答案 2 :(得分:8)

您的函数fwd没有触及全局变量c。您没有告诉它global c(仅在setup中),因此它具有自己的局部变量,并在cpython中使用STORE_FAST

>>> import dis
>>> def fwd():
...     c = b[a]
...
>>> dis.dis(fwd)
  2           0 LOAD_GLOBAL              0 (b)
              3 LOAD_GLOBAL              1 (a)
              6 BINARY_SUBSCR
              7 STORE_FAST               0 (c)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

现在,让我们尝试使用全局变量:

>>> def fwd2():
...     global c
...     c = b[a]
...
>>> dis.dis(fwd2)
  3           0 LOAD_GLOBAL              0 (b)
              3 LOAD_GLOBAL              1 (a)
              6 BINARY_SUBSCR
              7 STORE_GLOBAL             2 (c)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

即使如此,与inv函数调用setitem的全局函数相比,时间可能也会有所不同。

无论哪种方式,如果您想让它将写入 c,则都需要c[:] = b[a]c.fill(b[a])之类的东西。该赋值从右侧用对象替换变量(名称),因此可能会释放旧的c而不是新的b[a],并且这种内存改组的代价可能很高。

至于我认为您想衡量的效果,基本上是正向排列还是反向排列更昂贵,那将高度依赖于缓存。从原理上讲,正向置换(从线性读取以随机顺序的索引存储)可能会更快,因为它可以使用写入屏蔽,并且永远不会获取新数组,前提是高速缓存系统足够聪明,可以在写入缓冲区中保留字节掩码。如果数组足够大,则在执行随机读取时,向后运行会产生高速缓存冲突的高风险。

那是我的最初印象;正如您所说,结果是相反的。这可能是由于缓存实现不具有较大的写缓冲区或无法利用较小的写入而导致的。如果超速缓存访问仍然需要相同的内存总线时间,则读访问将有机会加载在需要之前不会从超高速缓存中删除的数据。使用多路缓存时,部分写入的行也有可能不会被选择驱逐;而且只有脏的高速缓存行需要内存总线时间才能减少。使用其他知识(例如,排列完成且不重叠)编写的较低级程序可以使用提示(例如非时间SSE编写)来改善行为。

答案 3 :(得分:5)

以下实验证实了随机写入比随机读取更快。对于小尺寸的数据(当它完全适合高速缓存时),随机写入代码的速度比随机读取代码的速度慢(可能是由于numpy中的某些实现特性),但是随着数据大小的增加,初始值是1.7倍几乎完全消除了执行时间上的差异(但是,在numba的情况下,这种趋势最终会发生奇怪的逆转)。

$ cat test.py 
import numpy as np
from timeit import timeit
import numba

def fwd(a,b,c):
    c = b[a]

def inv(a,b,c):
    c[a] = b

@numba.njit
def fwd_numba(a,b,c):
    for i,j in enumerate(a):
        c[i] = b[j]

@numba.njit
def inv_numba(a,b,c):
    for i,j in enumerate(a):
        c[j] = b[i]


for p in range(4, 8):
    N = 10**p
    n = 10**(9-p)
    a = np.random.permutation(N)
    b = np.random.random(N)
    c = np.empty_like(b)
    print('---- N = %d ----' % N)
    for f in 'fwd', 'fwd_numba', 'inv', 'inv_numba':
        print(f, timeit(f+'(a,b,c)', number=n, globals=globals()))

$ python test.py 
---- N = 10000 ----
fwd 1.1199337750003906
fwd_numba 0.9052993479999714
inv 1.929507338001713
inv_numba 1.5510062070025015
---- N = 100000 ----
fwd 1.8672701190007501
fwd_numba 1.5000483989970235
inv 2.509873716000584
inv_numba 2.0653326050014584
---- N = 1000000 ----
fwd 7.639554155000951
fwd_numba 5.673054756000056
inv 7.685382894000213
inv_numba 5.439735023999674
---- N = 10000000 ----
fwd 15.065879136000149
fwd_numba 12.68919651500255
inv 15.433822674000112
inv_numba 14.862108078999881