这是一个复杂的问题,与现代处理器的架构特性和您的直觉密切相关,即 随机读取比随机写入慢,因为 CPU 必须等待读取数据
未经验证(大部分时间)。有几个原因我会详细说明。
现代处理器非常有效地隐藏读取延迟
虽然内存写入比内存读取更昂贵
尤其是在多核环境中
原因 #1 现代处理器可以有效地隐藏读取延迟。
现代superscalar可以同时执行多条指令,并改变指令执行顺序(out of order execution)。
虽然这些功能的第一个原因是增加指令吞吐量,
最有趣的结果之一是处理器能够隐藏内存写入(或复杂运算符、分支等)的延迟。
为了解释这一点,让我们考虑一个将数组复制到另一个数组的简单代码。
for i in a:
c[i] = b[i]
处理器执行的编译后代码会是这样的
#1. (iteration 1) c[0] = b[0]
1a. read memory at b[0] and store result in register c0
1b. write register c0 at memory address c[0]
#2. (iteration 2) c[1] = b[1]
2a. read memory at b[1] and store result in register c1
2b. write register c1 at memory address c[1]
#1. (iteration 2) c[2] = b[2]
3a. read memory at b[2] and store result in register c2
3b. write register c2 at memory address c[2]
# etc
(这太简单了,实际代码更复杂,必须处理循环管理、地址计算等,但目前这种简单化的模型就足够了)。
如问题中所述,对于读取,处理器必须等待实际数据。确实,1b 需要 1a 获取的数据,只要 1a 没有完成就无法执行。这样的约束称为依赖,我们可以说 1b 依赖于 1a。依赖关系是现代处理器中的一个主要概念。依赖关系表达了算法(例如,我将 b 写入 c)并且必须绝对尊重。但是,如果指令之间没有依赖关系,处理器将尝试执行其他未决指令,以保持那里的操作流水线始终处于活动状态。只要遵守依赖关系(类似于 as-if 规则),这可能会导致执行无序。
对于所考虑的代码,高级指令 2. 和 1. 之间(或 asm 指令 2a 和 2b 与之前的指令之间)没有依赖关系。实际上最终结果甚至是相同的,即 2. 在 1. 之前执行,并且处理器将在 1a 和 1b 完成之前尝试执行 2a 和 2b。 2a 和 2b 之间仍然存在依赖关系,但两者都可以发出。 3a 也是如此。和 3b.,依此类推。这是隐藏内存延迟的强大手段。如果由于某种原因 2.、3. 和 4. 可以在 1. 加载其数据之前终止,您甚至可能根本不会注意到任何减速。
这种指令级并行性由处理器中的一组“队列”管理。
保留站 RS 中的待处理指令队列(在最近的 pentium 中键入 128 μ 指令)。一旦指令所需的资源可用(例如指令 1b 的寄存器 c1 的值),指令就可以执行。
在 L1 缓存之前的内存顺序缓冲区 MOB 中的待处理内存访问队列。这是处理内存别名并确保内存写入或加载在同一地址的顺序所必需的(典型的 64 次加载,32 次存储)
出于类似原因,在写回寄存器(重新排序缓冲区或 168 个条目的 ROB)时强制执行顺序的队列。
和其他一些在指令获取时的队列,用于微操作生成、缓存中的写入和未命中缓冲区等
在执行前一个程序的某一时刻,RS 中会有许多挂起的存储指令,MOB 中有几个加载指令,ROB 中有等待退出的指令。
只要有数据可用(例如读取终止),相关指令就可以执行并释放队列中的位置。但是,如果没有发生终止,并且这些队列之一已满,则与此队列关联的功能单元会停止(如果处理器缺少寄存器名称,这也可能发生在指令问题上)。停顿是造成性能损失的原因,为了避免这种情况,必须限制队列填充。
这解释了线性和随机内存访问之间的区别。
在线性访问中,1/ 由于更好的空间局部性,未命中的数量会更小,并且因为缓存可以使用常规模式预取访问以进一步减少它 2/ 每当读取终止时,它将涉及完整的缓存行和可以释放几个挂起的加载指令,限制指令队列的填充。这样,处理器就会一直处于忙碌状态,并且内存延迟被隐藏。
对于随机访问,未命中的次数会更高,并且在数据到达时只能服务单个负载。因此,指令队列将迅速饱和,处理器停止,内存延迟无法再通过执行其他指令来隐藏。
处理器架构必须在吞吐量方面进行平衡,以避免队列饱和和停顿。实际上,在处理器的某个执行阶段通常有数十条指令,全局吞吐量(即内存(或功能单元)处理指令请求的能力)是决定性能的主要因素。这些待处理指令中的一些正在等待内存值的事实影响较小......
...除非你有很长的依赖链。
当一条指令必须等待前一条指令完成时,存在依赖性。使用读取的结果是一个依赖项。当涉及依赖链时,依赖关系可能会成为问题。
例如,考虑代码for i in range(1,100000): s += a[i]。所有的内存读取都是独立的,但是在s中有一个积累的依赖链。在前一个终止之前,不会发生任何添加。这些依赖项将使预订站迅速填满,并在管道中造成停顿。
但读取很少涉及依赖链。仍然可以想象所有读取都依赖于前一个读取的病态代码(例如for i in range(1,100000): s = a[s]),但它们在实际代码中并不常见。而且问题出在依赖链上,而不是因为它是读取;对于像 for i in range(1,100000): x = 1.0/x+1.0 这样的计算绑定依赖代码,情况会类似(甚至可能更糟)。
因此,除了在某些情况下,计算时间与吞吐量更相关,而不是与读取依赖关系,这要归功于超标量输出或顺序执行隐藏了延迟这一事实。就吞吐量而言,写入比读取更糟糕。
原因 2:内存写入(尤其是随机写入)比内存读取更昂贵
这与caches 的行为方式有关。缓存是由处理器存储部分内存(称为行)的快速内存。缓存行目前为 64 字节,允许利用内存引用的空间局部性:一旦存储了一行,该行中的所有数据都立即可用。这里的重要方面是缓存和内存之间的所有传输都是行。
当处理器对数据执行读取时,缓存会检查数据所属的行是否在缓存中。如果不是,则从内存中取出该行,存储在缓存中,并将所需的数据发送回处理器。
当处理器将数据写入内存时,缓存也会检查行是否存在。如果该行不存在,则缓存无法将其数据发送到内存(因为 所有 传输都是基于行的)并执行以下步骤:
- 缓存从内存中取出行并将其写入缓存行。
- 数据写入缓存,整行标记为已修改(脏)
- 当缓存中的行被抑制时,它会检查修改的标志,如果行已被修改,则将其写回内存(写回缓存)
因此,每次内存写入都必须在内存读取之前才能获取缓存中的行。这增加了一个额外的操作,但对于线性写入来说并不是很昂贵。第一个写入的字会发生缓存未命中和内存读取,但后续写入只会涉及缓存并被命中。
但随机写入的情况非常不同。如果未命中的数量很重要,则每次缓存未命中都意味着在将行从缓存中弹出之前进行读取,然后仅进行少量写入,这会显着增加写入成本。如果在单次写入后弹出一行,我们甚至可以认为写入的时间成本是读取的两倍。
请务必注意,增加内存访问(读取或写入)的次数往往会使内存访问路径饱和,并在全局范围内减慢处理器和内存之间的所有传输速度。
在任何一种情况下,写入总是比读取更昂贵。多核增强了这一方面。
原因 #3:随机写入会在多核中造成缓存未命中
不确定这是否真的适用于问题的情况。虽然 numpy BLAS 例程是多线程的,但我认为基本数组副本不是。但这是密切相关的,也是写入成本更高的另一个原因。
多核的问题是要确保正确的cache coherence,以便多个处理器共享的数据在每个核的缓存中正确更新。这是通过诸如MESI 之类的协议完成的,该协议在写入之前更新缓存行,并使其他缓存副本无效(读取所有权)。
虽然问题中的核心(或它的并行版本)之间实际上没有共享任何数据,但请注意该协议适用于 缓存行。每当要修改缓存行时,都会从保存最新副本的缓存中复制它,在本地更新,并且所有其他副本都无效。即使内核正在访问缓存行的不同部分。这种情况称为false sharing,它是多核编程的一个重要问题。
关于随机写入的问题,cache line 是 64 字节,可以容纳 8 个 int64,如果计算机有 8 个内核,每个内核平均会处理 2 个值。因此,有一个重要的错误共享会减慢写入速度。
我们进行了一些性能评估。它是在 C 中执行的,以包括对并行化影响的评估。我们比较了 5
处理大小为 N 的 int64 数组的函数。
只是 b 到 c (c[i] = b[i]) 的副本(由编译器使用 memcpy() 实现)
使用线性索引c[i] = b[d[i]] 复制其中d[i]==i (read_linear)
使用随机索引复制c[i] = b[a[i]],其中a 是随机索引
0..N-1 的排列(read_random 相当于原问题中的fwd)
写线性c[d[i]] = b[i] where d[i]==i (write_linear)
随机写入c[a[i]] = b[i] 和a 随机
0..N-1 的排列(write_random 相当于问题中的inv)
代码已使用gcc -O3 -funroll-loops -march=native -malign-double 编译
Skylake 处理器。性能是用_rdtsc() 衡量的,并且是
以每次迭代的周期给出。该函数执行多次(1000-20000取决于数组大小),进行10次实验并保持最小的时间。
数组大小范围从 4000 到 1200000。所有代码均使用 openmp 的顺序和并行版本测量。
这是结果图。函数有不同的颜色,粗线是顺序版,细线是平行版。
直接复制(显然)是最快的,由 gcc 实现
高度优化的memcpy()。这是一种估计内存数据吞吐量的方法。它的范围从小矩阵的 0.8 次迭代周期 (CPI) 到大矩阵的 2.0 CPI。
读取线性性能大约比 memcpy 长两倍,但有 2 次读取和 1 次写入,vs 1
直接复制的读取和写入。更多的索引增加了一些依赖性。最小值为 1.56 CPI,最大值为 3.8 CPI。写入线性稍长(5-10%)。
使用随机索引进行读写是原始问题的目的,值得更长的 cmets。这是结果。
size 4000 6000 9000 13496 20240 30360 45536 68304 102456 153680 230520 345776 518664 777992 1166984
rd-rand 1.86821 2.52813 2.90533 3.50055 4.69627 5.10521 5.07396 5.57629 6.13607 7.02747 7.80836 10.9471 15.2258 18.5524 21.3811
wr-rand 7.07295 7.21101 7.92307 7.40394 8.92114 9.55323 9.14714 8.94196 8.94335 9.37448 9.60265 11.7665 15.8043 19.1617 22.6785
小值(
中等值(10k-100k):L2 缓存为 256k,它可以容纳 32k int64 数组。之后,我们需要去 L3 缓存(12Mo)。随着大小的增加,L1 和 L2 中的未命中次数也会增加,计算时间也会相应增加。两种算法的未命中数相似,主要是由于随机读取或写入(其他访问是线性的,并且可以非常有效地被缓存预取)。我们检索 B.M. 中已经提到的随机读取和写入之间的因子二。回答。这可以部分解释为写入的双重成本。
大值 (>100k):方法之间的差异逐渐减小。对于这些大小,大部分信息存储在 L3 缓存中。 L3 大小足以容纳 1.5M 的完整阵列,并且线不太可能被弹出。因此,对于写入而言,在初始读取之后,可以在不弹出行的情况下进行大量写入,并且降低了写入与读取的相对成本。对于这些大尺寸,还需要考虑许多其他因素。例如,缓存只能提供有限数量的未命中(典型值 16),当未命中数很大时,这可能是限制因素。
关于随机读写的并行 omp 版本的一句话。除了小尺寸,将随机索引数组分布在多个缓存上可能不是优势,它们系统性地快两倍。对于大尺寸,我们清楚地看到随机读取和写入之间的差距由于错误共享而增加。
以当前计算机体系结构的复杂性,几乎不可能进行定量预测,即使是简单的代码,甚至对行为的定性解释也很困难,必须考虑到许多因素。正如其他答案中提到的,与 python 相关的软件方面也会产生影响。但是,虽然在某些情况下可能会发生这种情况,但在大多数情况下,由于数据依赖性,人们不能认为读取成本更高。