我目前正在尝试更好地了解与内存/缓存相关的性能问题。我在某处读到,内存局部性对于读取比对写入更重要,因为在前一种情况下,CPU必须实际等待数据,而在后一种情况下,CPU可以将它们运送出去而忽略它们。
考虑到这一点,我进行了以下快速测试:我编写了一个脚本,该脚本创建了一个由N个随机浮点数和排列组成的数组,即一个以随机顺序包含数字0到N-1的数组。然后,它反复进行以下操作:(1)线性读取数据数组,然后按排列给定的随机访问模式将其写回到新数组,或者(2)按排列顺序读取数据数组,然后将其线性写入新数组。
令我惊讶的是(2)似乎始终比(1)快。但是,我的脚本有问题
此外,下面的一些答案/评论表明我的最初期望是不正确的,并且根据cpu缓存的详细信息,两种情况都可能更快。
我的问题是:
不胜感激的初学者解释。任何支持的代码都应该在C / cython / numpy / numba或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
因此似乎仍然存在差异,但是它更加微妙,现在可以根据问题的大小选择哪种方式。
答案 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)
从本质上讲,它是针对 BLAS / ATLAS / MKL 进行了调整的低级程序的包装。 花式索引是一个很好的高级工具,但是对于这些问题却是异端。没有从低层次直接介绍这一概念。
除非在获取项目的过程中只有一个索引数组,否则 事先检查索引的有效性。否则处理 在内部循环中进行优化。
在这种情况下,我们在这里。我认为这可以解释差异,以及为什么set比get慢。
这也解释了为什么手工制作的数字键盘常常更快:它不检查任何内容,并且在索引不一致时崩溃。
答案 1 :(得分:9)
您的两个NumPy代码段b[a]
和c[a] = b
似乎是衡量混洗/线性读/写速度的合理启发法,因为我将通过在第一部分中查看底层的NumPy代码来进行争论在下面。
关于哪个应该更快的问题,似乎改组的读取线性写入通常可以胜出(如基准似乎显示的那样),但是速度的差异可能会受到改组的方式的影响。改组后的索引是,并且其中一个或多个:
即使假设已制定了哪些策略,也很难对这些影响进行建模和分析,因此我不确定适用于所有处理器的通用答案是否可行(尽管我不是硬件专家)。
尽管如此,在下面的第二部分中,我将基于某些假设尝试推断为什么改组后的读取线性写入速度明显更快。
本节的目的是浏览NumPy源代码,以确定是否存在有关计时的明显解释,并尽可能清楚地了解A[B]
或{{1}时发生的情况}。
此问题中getitem和setitem操作的华丽索引的迭代例程是“ trivial”:
A[B] = C
是一个单步长的单索引数组B
和A
具有相同的内存顺序(C连续或Fortran连续)此外,在我们的示例中,B
和A
均为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上)或类似的指令。
执行项获取和设置的核心低级代码位于函数mov
和mapiter_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