为什么线性读取-随机写入并不比随机读取-线性写入更快?

53

我目前正在尝试更好地理解与内存/缓存相关的性能问题。我在某处读到过,对于读取操作而言,内存局部性比写入更重要,因为在前者的情况下,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

所以似乎仍然存在差异,但它要微妙得多,现在可以根据问题的大小而任意变化。


我也运行了你的代码,有趣的是我没有得到相同的结果。我的前五次运行时间比你的快40%,但是非常后面的“inv”测试对于N = 1e6似乎相当快(比相应的正向测试短50%,并且保持一致)。 - Trilarion
这也取决于timeit中的重复次数。在我的情况下,N=100_000且重复1,000次使得inv始终比fwd更快,而当重复10,000次时则变慢。如果每次运行所需时间相同,就不应该发生这种情况。 - Trilarion
@Trilarion 可能是因为 fwd 实际上没有使用预先分配的 c,而是创建(因此分配)了一个新的?稍后重复利用刚释放的 c 的副本,以便 fwd 可以超越 inv?无论如何,希望整个事情不只是一个巨大的测量偏差 :-( - Paul Panzer
你可以通过 c[:] = b[a] 强制使用全局变量 c,但这似乎不会改变时间。对于大的 N 和少量重复操作,inv 比 fwd 快约 50%。 - Trilarion
由于您没有将“setup”传递给“timeit”,因此初始分配成本(使“c[:]=x”如果“c”为空,则成本更高,而不是其形状与“x”匹配)将是微不足道的。 - Yann Vernier
显示剩余6条评论
5个回答

22
这是一个与现代处理器架构特性密切相关的复杂问题,你所认为的“随机读取比随机写入慢是因为CPU必须等待读取数据”并不经常被证实。我将详细说明几个原因。
1. 现代处理器非常有效地隐藏了读取延迟。 2. 内存写入比内存读取更加昂贵,尤其是在多核环境下。 3. 超标量可以同时执行多条指令,并改变指令执行顺序(乱序执行)。这些功能的第一个原因是增加指令吞吐量,而其中最有趣的后果之一是处理器能够隐藏内存写入(或复杂操作、分支等)的延迟。 4. 为了解释这一点,让我们考虑一个简单的代码,将一个数组复制到另一个数组中。
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.(或汇编指令 2a 和 2b 和前面的指令之间)之间没有依赖关系。实际上,即使在执行 1. 之前执行 2.,最终结果也将完全相同,处理器将尝试在完成 1a 和 1b 之前执行 2a 和 2b。2a 和 2b 之间仍然存在依赖关系,但两者都可以发出。类似地,对于 3a 和 3b,以及其他指令也是如此。这是一个强大的手段,用于隐藏内存延迟。如果由于某种原因 2、3 和 4 可以在 1 加载其数据之前终止,您甚至可能完全没有注意到任何减速。

这种指令级并行性由处理器中的一组“队列”管理。

  • 保留站RS中有一个待处理指令队列(在最近的Pentium中为128个微指令)。一旦指令所需的资源可用(例如,指令1b的寄存器c1的值),该指令就可以执行。

  • 在L1缓存之前,内存顺序缓冲区MOB中有一个待处理内存访问队列。这是为了处理内存别名并确保在同一地址处的内存写入或加载的顺序性(typ. 64个加载,32个存储)。

  • 用于在寄存器中写回结果时强制顺序性的队列(重排序缓冲区或ROB,共168个条目),出于类似的原因。

  • 还有其他一些队列,用于指令获取、μops生成、缓存中的写入和未命中缓冲区等。

在先前程序的某个执行点,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的行为方式有关。高速缓存是由处理器存储部分内存(称为line)的快速存储器。高速缓存行目前为64字节,并允许利用内存引用的空间局部性:一旦存储了一行,该行中的所有数据立即可用。这里的重要方面是高速缓存和内存之间的所有传输都是行
当处理器对数据执行读取时,高速缓存会检查数据所属的行是否在高速缓存中。如果没有,则从内存中提取该行,将其存储在高速缓存中,并将所需数据发送回处理器。
当处理器向内存写入数据时,缓存还会检查该行是否存在。如果该行不存在,则缓存无法将其数据发送到内存(因为所有传输都是基于行的),并执行以下步骤:
1. 缓存从内存中提取该行并将其写入缓存行。 2. 数据写入缓存并将整个行标记为已修改(脏)。 3. 当从缓存中抑制一行时,它会检查修改标志,如果该行已被修改,则将其写回内存(写回缓存)。
因此,每次内存写入都必须先进行内存读取以获取缓存中的行。这增加了额外的操作,但对于线性写入来说并不是很昂贵。第一个写入的字将会有一个缓存未命中和内存读取,但后续的写入只涉及缓存且命中。
但是,随机写入的情况非常不同。如果未命中的数量很重要,则每个缓存未命中都意味着在该行从缓存中弹出之前只有少量的写入,这显著增加了写入成本。如果一行在单次写入后被弹出,我们甚至可以认为写入的时间成本是读取的两倍。
需要注意的是,增加内存访问(读取或写入)的数量往往会饱和内存访问路径并全局减慢处理器与内存之间的所有传输。
在任何情况下,写入总是比读取更昂贵。而多核心增加了这个方面。
原因#3:随机写入在多核心中会导致缓存未命中。
我不确定这是否适用于问题的情况。虽然numpy BLAS例程是多线程的,但我认为基本数组复制不是。但它与此密切相关,并且是写入更昂贵的另一个原因。
多核的问题在于确保这样一种适当的高速缓存一致性方式,即由几个处理器共享的数据在每个核的缓存中得到适当更新。这是通过诸如MESI这样的协议完成的,该协议在写入缓存行之前更新缓存行,并使其他缓存副本无效(读取所有权)。
虽然在问题中(或其并行版本)实际上没有数据在核之间共享,但请注意,该协议适用于缓存行。每当要修改缓存行时,它都会从持有最新副本的缓存中复制,本地更新并使所有其他副本无效,即使核正在访问缓存行的不同部分。这种情况称为虚假共享,对于多核编程而言,这是一个重要问题。
关于随机写入的问题,缓存行是64字节,可以容纳8个int64,如果计算机有8个核心,则每个核心平均处理2个值。因此存在重要的虚假共享,这会减慢写入速度。
我们进行了一些性能评估。为了包括并行化影响的评估,它是用C执行的。我们比较了处理大小为N的int64数组的5个函数。
1.只是将b复制到c中(由编译器使用memcpy()实现)。 2.使用线性索引进行复制c[i] = b[d[i]],其中d[i]==i(read_linear)。 3.使用随机索引进行复制c[i] = b[a[i]],其中a是0..N-1的随机排列(read_random等同于原始问题中的fwd)。 4.使用线性写入c[d[i]] = b[i],其中d[i]==i(write_linear)。 5.使用随机写入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的顺序版本和并行版本的测量。

以下是结果图表。函数用不同的颜色表示,顺序版本用粗线表示,而并行版本则用细线表示。

enter image description here

直接复制(显然)是最快的方法,由gcc使用高度优化的memcpy()实现。这是一种通过内存获取数据吞吐量估计的方法。对于小矩阵,每次迭代的循环周期(CPI)为0.8,而对于大矩阵,每次迭代的CPI为2.0。

线性读取性能大约比memcpy长两倍,但是有2个读取和1个写入,而直接复制只有1个读取和1个写入。此外,索引添加了一些依赖关系。最小值为1.56 CPI,最大值为3.8 CPI。线性写入略长(5-10%)。

使用随机索引进行读写是原始问题的目的,并且值得更长的注释。以下是结果。

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):L1缓存为32k,可以容纳一个4k的uint64数组。请注意,由于索引的随机性,在大约1/8的迭代后,L1缓存将完全填满随机索引数组的值(因为缓存行是64字节,可以容纳8个数组元素)。访问其他线性数组将会迅速生成许多L1缺失,并且我们必须使用L2缓存。L1缓存访问需要5个周期,但它是流水线式的,可以每个周期服务几个值。L2访问时间更长,需要12个周期。随机读取和写入的缺失量类似,但我们看到当数组大小很小时,我们完全支付了双倍的写入所需的访问。

  • 中等数据(10k-100k):L2缓存为256k,可以容纳一个32k的int64数组。之后,我们需要进入L3缓存(12Mo)。随着大小的增加,L1和L2中的缺失次数增加,计算时间相应增加。两种算法具有类似数量的缺失,主要是由于随机读取或写入(其他访问是线性的,可以通过缓存非常有效地预取)。我们已经在B.M.答案中注意到了随机读取和写入之间的两倍因子。这可以部分地解释为写入的双重成本。

  • 大数据(大于100k):方法之间的差异逐渐减小。对于这些大小,大量信息存储在L3缓存中。L3大小足以容纳一个完整的1.5M数组,并且行不太可能被驱逐。因此,对于写入,在初始读取之后,可以进行更多的写入而不会发生行驱逐,并且写入相对于读取的成本降低。对于这些大型大小,还有许多其他需要考虑的因素。例如,缓存只能服务有限数量的缺失(typ. 16),当缺失数量很大时,这可能是限制因素。

关于随机读写的并行omp版本,除了小尺寸数据,随机索引数组分布在多个缓存中可能没有优势,它们通常快两倍。对于大尺寸数据,由于虚假共享,随机读写之间的差距明显增加。
现有计算机架构的复杂性使得即使对于简单代码,定量预测几乎是不可能的,甚至行为的定性解释也很困难,必须考虑许多因素。正如其他答案中提到的,与python相关的软件方面也可能会产生影响。但是,虽然在某些情况下可能会发生,但大多数情况下,不能认为读取更昂贵,因为存在数据依赖性。

2
谢谢,非常有教育意义!关于并行版本,我有一个问题。为什么它会更快呢?任务受到I/O限制,而不是CPU限制,对吧?那么,这是否只是每个核心缓存的更大组合容量呢? - Paul Panzer
1
只有线性访问才真正受到IO限制。对于它们来说,使用memcpy()的收益要么非常小,要么甚至为负。对于随机访问,使用4个处理器将L1+L2缓存的大小乘以4,这将减少相应的缓存未命中。较低的L3和内存使用解释了约30%的性能提升。 - Alain Merigot
非常详细的回答。但我希望图表的轴有标签,并且有一个显示读取与写入执行时间比率的图表。这个代码是公开的吗?如果是,我们可以让它在更多的架构上运行。 - Trilarion
但是读取很少涉及依赖链。这对于数组是正确的,但对于具有指针的数据结构(如树或链表)则不是正确的。 (数组的一个例子是二分查找。使用控制依赖性而不是cmov数据依赖性可以让推测执行像预取一样工作,并且如果数据在缓存中很冷,则可能会有所帮助,因此内存延迟>分支错误想罚款。关于无分支二分搜索 - Peter Cordes
不错的回答,但是关于OoO执行的早期部分有点夸大了它的能力。硬件预取非常重要,可以隐藏内存延迟。即使使用Skylake的大型224-uop ROB,OoO exec也无法隐藏DRAM延迟约60 ns = ~240个时钟周期在4GHz CPU上(https://www.7-cpu.com/cpu/Skylake.html),特别是在高吞吐量代码中,否则每个时钟周期可以运行3或4个uops。而这需要OoO exec来隐藏一些ALU延迟并重叠独立的循环迭代。 - Peter Cordes
显示剩余5条评论

11
  • 首先,反驳你的直觉:即使没有numpy机制,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=10,000的时间:

%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中调整的低级过程的包装器。 花式索引是一个很好的高级工具,但对于这些问题来说是异端邪说;在低级别上没有直接翻译这个概念。

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

我们就是这种情况。我认为这可以解释差异,以及为什么设置比获取要慢。

它也解释了为什么手工制作的numba通常更快:它不检查任何内容,并且在不一致的索引上崩溃。


正如你所说,虽然不是技术上的答案,但仍然很有用。谢谢! - Paul Panzer
好的文档链接,尽管我要补充一下它排除了我们的情况:“一维索引方法以相当直接的方式实现,因此通用索引代码将是本节的重点。” - Yann Vernier
1
我不确定我理解你的最后一点。索引有效性检查似乎是针对读和写都进行的,那么为什么它们会解释差异呢? - Paul Panzer

9

你的两个NumPy片段 b[a]c[a] = b 看起来是衡量混洗/线性读写速度的合理启发式方法,下面我将尝试通过查看第一部分中的底层NumPy代码进行论证。

关于哪一个更快的问题,混洗-读取-线性写入通常似乎会获胜(就像基准测试所显示的那样),但速度差异可能会受到混洗索引的“混乱程度”以及以下一个或多个因素的影响:

  • CPU高速缓存读取/更新策略(写回 vs. 写直通等)。
  • CPU如何选择重复指令的顺序(流水线处理)。
  • CPU识别内存访问模式和预取数据。
  • 高速缓存清除逻辑。

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

然而,在下面的第二节中,我将尝试推断为什么在某些假设条件下,洗牌-读取-线性写入似乎更快。


“琐碎”的花式索引

本节的目的是通过查看NumPy源代码,确定计时的明显解释是否存在,并尽可能清晰地了解执行A[B]A[B] = C时会发生什么。

支持此问题中getitemsetitem操作的花式索引的迭代例程是“琐碎”的:

  • B是具有单个步长的单索引数组
  • AB具有相同的内存顺序(都是C连续或都是Fortran连续)
此外,在我们的情况下,AB都是Uint Aligned

跨步复制代码:这里使用“uint对齐”代替。如果数组的项大小[N]等于1、2、4、8或16字节,并且数组是uint对齐的,则numpy将对适当的N执行*(uintN*)dst) = *(uintN*)src),而不是使用缓冲区。否则,numpy通过执行memcpy(dst, src, N)进行复制。

这里的重点是避免使用内部缓冲区来确保对齐。用*(uintN*)dst) = *(uintN*)src)实现的底层复制就像“将源偏移量处的X个字节放入目标偏移量处的X个字节”一样简单明了。

编译器可能会将其简单地转换为mov指令(例如在x86上),或类似指令。

核心的低级代码,用于获取和设置项目的功能在函数mapiter_trivial_getmapiter_trivial_set中。这些函数在lowlevel_strided_loops.c.src中生成,其中模板和宏使其有些难以阅读(这是感激高级语言的时候)。坚持下去,最终我们可以看到getitem和setitem之间几乎没有区别。这里是一个简化版的主循环以供说明。宏行确定我们是否运行getitem或setitem:
    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 */

正如我们所期望的那样,这只是一些算术运算,以获得正确的偏移量,并从一个内存位置复制字节到另一个位置。
为setitem增加额外的索引检查
值得一提的是,对于setitem,索引的有效性(目标数组中是否全部越界)在复制之前进行了检查(通过check_and_adjust_index),它还将负索引替换为相应的正索引。
在上面的片段中,可以看到在主循环中为getitem调用了check_and_adjust_index,而对于setitem,则会出现一个更简单的(可能是多余的)负索引检查。
这个额外的初步检查可能会对setitem(A[B] = C)的速度产生小但负面的影响。

缓存未命中

由于两段代码非常相似,因此怀疑落在了 CPU 以及其如何处理对底层内存数组的访问上。

CPU 缓存最近访问过的小块内存(缓存行),预计很快可能需要再次访问该内存区域,从而提高效率。

就上下文而言,缓存行通常为64字节。我年代久远的笔记本电脑CPU上的L1(最快)数据缓存为32KB(足以容纳数组中约500个int64值,但请记住,在NumPy片段执行期间,CPU将执行其他需要内存的操作):

$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K

如您所知,对于顺序读写内存,缓存可以很好地工作,因为会按需获取 64 字节内存块并将其存储在靠近 CPU 的位置。重复访问该内存块比从 RAM(或更慢的高级缓存)获取要快。实际上,CPU 可能甚至会在程序请求之前预取下一个缓存行。
另一方面,随机访问内存可能会导致频繁的缓存未命中。此时,具有所需地址的内存区域不在靠近 CPU 的快速缓存中,而必须从较高级别的缓存(较慢)或实际内存(速度更慢)中访问。
那么,对于 CPU 处理来说,哪个更快:频繁的数据读取未命中,还是数据写入未命中?
假设 CPU 的写入策略是写回,这意味着修改后的内存会被写回缓存。缓存被标记为已修改(或“脏”),只有当缓存行被驱逐出缓存时,更改才会写回主内存(CPU 仍然可以从脏缓存行中读取)。
如果我们在大型数组中写入随机点,则期望 CPU 缓存中的许多缓存行将变为脏。每次驱逐时都需要通过写入到主内存来清空缓存,如果缓存已满,则可能经常发生这种情况。
然而,当按顺序写入数据并随机读取时,我们预计较少的高速缓存行会变脏,并且写回主内存或较慢缓存的数据也不会那么频繁,因此这种写入应该发生得更少。

正如提到的那样,这是一个简化的模型,可能有许多其他因素影响CPU的性能。比我更专业的人可能能够改进这个模型。


8
您的函数 fwd 没有操作全局变量 c。您在 setup 中使用了 global c,但没有在函数中告诉它,所以它有自己的局部变量,并在 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

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

无论哪种方式,如果你想要把数据写入c中,你需要像这样使用c[:] = b[a]c.fill(b[a])。赋值操作将变量(名称)替换为右侧的对象,因此旧的c可能会被释放而不是新的b[a],这种内存移动可能会很昂贵。

至于你想要测量的效果,基本上是正向还是反向置换更加昂贵,这将高度依赖于缓存。正向置换(以线性读取中的随机顺序索引存储)原则上可能更快,因为它可以使用写掩码并且永远不会获取新数组,假设缓存系统足够智能地保留字节掩码在写缓冲区中。如果数组足够大,倒置运行高风险碰撞缓存,执行随机读取时。

这是我的初步印象;结果与你说的相反。这可能是缓存实现没有大型写缓冲区或不能利用小写操作的结果。如果超出缓存的访问需要相同的内存总线时间,读取访问将有机会加载在需要之前不会被清除的数据。对于多项式缓存,部分写入行也有可能不被选中进行驱逐;而只有脏的缓存行需要内存总线时间才能释放。编写具有其他知识的更低级程序(例如置换是完整且非重叠的)可以使用提示,如非暂态SSE写入来改进行为。


谢谢你。我必须承认,你最后两段的内容让我有些困惑,因为我对缓存的工作方式了解甚少。你能否进一步解释一下,或者提供一些指引? - Paul Panzer
https://www.quora.com/计算机中的缓存内存是如何工作的?数据如何管理?是什么使它成为“高速”内存提供了缓存系统的介绍。值得注意的另一件事是,外部RAM访问经常以大块操作,例如整个缓存行,因此在写出之前收集整个缓存行更有效。 - Yann Vernier
谢谢!让我看看我是否大致理解:写缓存作为缓冲区,接受来自CPU的写入,然后慢慢将它们传递到RAM,同时CPU可以做其他事情。但是,只有在缓存中有足够的空闲空间时才能实现这一点。如果所有行都包含尚未写回RAM的修改(这就是“脏”的含义吗?),那么如果CPU想要写入当前不在缓存位置的内容,则必须等待某些其他行被写回到RAM以释放所需的空间。这大致正确吗? - Paul Panzer
是的,尽管典型的缓存只有一组有限地址可以映射到每行(每个地址适合于缓存中有几种方式的缓存,直接映射缓存为1)。因此,对于与某个地址相关联的所有行来说,它们都足够脏,或者仅需要驱逐逻辑选择一个已经被选定的行(该逻辑经常是LRU或伪随机的)。写缓冲区可以保存任意地址,但也具有有限的大小。因此,在数据被写出之前,高级读取有更好的机会将更多数据放入缓存行中。 - Yann Vernier

4
以下实验证实随机写入比随机读取更快。对于数据较小(可以完全放在缓存中)的情况下,随机写入代码比随机读取代码慢(可能是因为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

1
在第一句话中,你写道随机写入比较快,而在接下来的几句话中,你又写道随机写入最初比较慢,但最终会变得不那么慢。贴出的数字似乎表明随机写入始终至少需要与随机读取相同的时间。这似乎不太一致。 - Trilarion
@Trilarion 我没有写过随机写入最初速度较慢 - 我写的是随机写入代码最初速度较慢(可能是由于numpy实现的特殊性)。然后,由于随机写入更快,它会追上随机读取代码。 - Leon

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接