【问题标题】:Why is linear read-shuffled write not faster than shuffled read-linear write?为什么线性读-洗牌写不比洗牌读-线性写快?
【发布时间】:2019-02-20 08:39:13
【问题描述】:

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

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

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

  • 脚本是用 python/numpy 编写的。这是一种相当高级的语言,目前尚不清楚读/写的具体实现程度。
  • 我可能没有正确平衡这两种情况。

此外,下面的一些答案/cmets 表明我最初的期望是不正确的,并且根据 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 所指出的,我的sn-ps 没有正确平衡,所以我将它们替换为

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

所以似乎仍然存在差异,但它更加微妙,现在可以根据问题的大小采取任何一种方式。

【问题讨论】:

  • 写可能比读要花更长的时间。非线性可能来自中间缓存的有限容量。
  • 我也运行了你的代码,有趣的是我没有得到相同的模式。我的前 5 次比你的高 40%,但 N = 1e6 的最后一次“inv”测试在这里似乎相当快(始终比相应的 fwd 测试短 50%)。
  • @Trilarion sn-ps 读取 N 个数字并写入 N 个数字,所以除非__setitem__/__getitem__ 有一些特殊性,否则这应该是平衡的。但是您的缓存大小参数实际上似乎很有意义。
  • @Trilarion 确实很有趣。也许我应该尝试更多尺寸?
  • 还取决于timeit中的重复次数。 N=100_000 重复 1_000 次使 inv 在我的情况下始终比 fwd 快,而重复 10_000 次则更慢。如果每次运行都需要相同的时间,就不应该发生。

标签: python performance numpy x86 cpu-cache


【解决方案1】:

这是一个复杂的问题,与现代处理器的架构特性和您的直觉密切相关,即 随机读取比随机写入慢,因为 CPU 必须等待读取数据 未经验证(大部分时间)。有几个原因我会详细说明。

  1. 现代处理器非常有效地隐藏读取延迟

  2. 虽然内存写入比内存读取更昂贵

  3. 尤其是在多核环境中

原因 #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 字节,允许利用内存引用的空间局部性:一旦存储了一行,该行中的所有数据都立即可用。这里的重要方面是缓存和内存之间的所有传输都是行

当处理器对数据执行读取时,缓存会检查数据所属的行是否在缓存中。如果不是,则从内存中取出该行,存储在缓存中,并将所需的数据发送回处理器。

当处理器将数据写入内存时,缓存也会检查行是否存在。如果该行不存在,则缓存无法将其数据发送到内存(因为 所有 传输都是基于行的)并执行以下步骤:

  1. 缓存从内存中取出行并将其写入缓存行。
  2. 数据写入缓存,整行标记为已修改(脏)
  3. 当缓存中的行被抑制时,它会检查修改的标志,如果行已被修改,则将其写回内存(写回缓存)

因此,每次内存写入都必须在内存读取之前才能获取缓存中的行。这增加了一个额外的操作,但对于线性写入来说并不是很昂贵。第一个写入的字会发生缓存未命中和内存读取,但后续写入只会涉及缓存并被命中。

但随机写入的情况非常不同。如果未命中的数量很重要,则每次缓存未命中都意味着在将行从缓存中弹出之前进行读取,然后仅进行少量写入,这会显着增加写入成本。如果在单次写入后弹出一行,我们甚至可以认为写入的时间成本是读取的两倍。

请务必注意,增加内存访问(读取或写入)的次数往往会使内存访问路径饱和,并在全局范围内减慢处理器和内存之间的所有传输速度。

在任何一种情况下,写入总是比读取更昂贵。多核增强了这一方面。

原因 #3:随机写入会在多核中造成缓存未命中

不确定这是否真的适用于问题的情况。虽然 numpy BLAS 例程是多线程的,但我认为基本数组副本不是。但这是密切相关的,也是写入成本更高的另一个原因。

多核的问题是要确保正确的cache coherence,以便多个处理器共享的数据在每个核的缓存中正确更新。这是通过诸如MESI 之类的协议完成的,该协议在写入之前更新缓存行,并使其他缓存副本无效(读取所有权)。

虽然问题中的核心(或它的并行版本)之间实际上没有共享任何数据,但请注意该协议适用于 缓存行。每当要修改缓存行时,都会从保存最新副本的缓存中复制它,在本地更新,并且所有其他副本都无效。即使内核正在访问缓存行的不同部分。这种情况称为false sharing,它是多核编程的一个重要问题。

关于随机写入的问题,cache line 是 64 字节,可以容纳 8 个 int64,如果计算机有 8 个内核,每个内核平均会处理 2 个值。因此,有一个重要的错误共享会减慢写入速度。


我们进行了一些性能评估。它是在 C 中执行的,以包括对并行化影响的评估。我们比较了 5 处理大小为 N 的 int64 数组的函数。

  1. 只是 b 到 c (c[i] = b[i]) 的副本(由编译器使用 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] where 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 的顺序和并行版本测量。

这是结果图。函数有不同的颜色,粗线是顺序版,细线是平行版。

直接复制(显然)是最快的,由 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 相关的软件方面也会产生影响。但是,虽然在某些情况下可能会发生这种情况,但在大多数情况下,由于数据依赖性,人们不能认为读取成本更高。

【讨论】:

  • 谢谢,很有教育意义!关于并行版本的一个问题。为什么它更快?该任务受 I/O 限制,而不是 CPU 限制,对吧?那么,这仅仅是单个(每个核心)缓存的更大组合容量吗?
  • 只有线性访问才真正受到 IO 限制。对于他们来说,memcpy() 的增益要么非常小,要么甚至为负。对于随机访问,使用 4 个处理器将 L1+L2 缓存的大小乘以 4,这将减少相应的缓存未命中。 L3 和内存的使用率较低解释了约 30% 的性能提升。
  • 非常广泛的答案。我确实希望图表的轴有标签,并且会有一个图表显示读取与写入的执行时间的比率。代码是公开的吗?如果是,可以让它在更多架构上运行。
  • 但读取很少涉及依赖链。对于数组是正确的,对于带有指针的数据结构(如树或链表)则不正确。 (数组的一种情况是二进制搜索。使用控制依赖而不是cmov 数据依赖可以让推测执行像预取一样工作,并且如果数据在缓存中是冷的,因此内存延迟> 分支错误预测惩罚会很有帮助。About the branchless binary search )
  • 不错的答案,但是关于 OoO exec 的早期部分夸大了它的能力。硬件预取对于隐藏内存延迟非常重要。即使使用 Skylake 的大型 224-uop ROB,OoO exec 也无法隐藏 4GHz CPU (7-cpu.com/cpu/Skylake.html) 上约 60 ns = 约 240 个时钟周期的 DRAM 延迟,尤其是在高吞吐量代码中,否则每个代码可能运行 3 或 4 uop钟。这需要 OoO exec 隐藏一些 ALU 延迟并重叠独立循环迭代。
【解决方案2】:
  • 首先反驳你的直觉:fwd 胜过inv,即使没有 numpy 机制。

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

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

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

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

【讨论】:

  • 正如您所说,从技术上讲,这不是一个答案,但仍然很有用。谢谢!
  • 很好的文档链接,尽管我要补充一点,它排除了我们的案例:“一维索引方法以一种相当简单的方式实现,因此通用索引代码将成为本节的重点。”
  • 我不确定我是否遵循您的最后一点。索引有效检查似乎对读取和写入都进行了,那么他们为什么要解释差异呢?
【解决方案3】:

您的两个 NumPy sn-ps b[a]c[a] = b 似乎是衡量随机/线性读/写速度的合理启发式方法,因为我将通过查看下面第一部分中的底层 NumPy 代码来尝试论证。

关于哪个应该更快的问题,shuffled-read-linear-write 通常可以获胜似乎是合理的(正如基准似乎显示的那样),但速度差异可能会受到“shuffle”方式的影响改组后的索引是,以及以下一项或多项:

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

即使对已实施哪些政策做出假设,这些影响也难以建模和分析推理,因此我不确定适用于所有处理器的一般答案是否可行(尽管我不是硬件专家)。

尽管如此,在下面的第二部分中,我将尝试解释为什么在某些假设下,shuffled-read-linear-write 显然更快。


“琐碎”的花式索引

本节的目的是通过 NumPy 源代码确定是否有任何明显的时序解释,并尽可能清楚地了解执行 A[B]A[B] = C 时会发生什么。

在这个问题中,支持 getitemsetitem 操作的花式索引的迭代例程是“trivial”:

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

此外,在我们的例子中,AB 都是 Uint Aligned

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

这里的重点是避免使用内部缓冲区来确保对齐。使用*(uintN*)dst) = *(uintN*)src) 实现的底层复制就像“将偏移 src 的 X 字节放入偏移 dst 的 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,索引的有效性(无论它们是否都是目标数组的入站)是 checked before copying 开始(通过check_and_adjust_index),它也将负索引替换为相应的正索引。

在上面的 sn-p 中,您可以看到 check_and_adjust_index 在主循环中调用了 getitem,而对 setitem 进行了更简单(可能是多余的)负索引检查。

这个额外的初步检查可能会对 setitem (A[B] = C) 的速度产生很小但负面的影响。


缓存未命中

由于两个代码 sn-ps 的代码非常相似,因此怀疑 CPU 以及它如何处理对底层内存数组的访问。

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

就上下文而言,缓存行通常为 64 字节。我老化的笔记本电脑 CPU 上的 L1(最快)数据缓存为 32KB(足以从数组中保存大约 500 个 int64 值,但请记住,当 NumPy sn-p 执行时,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 的性能。比我更专业的人可能会改进这个模型。

【讨论】:

    【解决方案4】:

    您的函数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
    

    即便如此,与调用setiteminv 函数相比,它可能在时间上有所不同。

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

    至于我认为您想要测量的效果,基本上是正向或反向排列的成本更高,这将高度依赖缓存。前向排列(从线性读取存储在随机排序的索引处)原则上可能更快,因为它可以使用写掩码并且永远不会获取新数组,假设缓存系统足够智能以在写缓冲区中保留字节掩码。如果数组足够大,在执行随机读取时,向后运行缓存冲突的风险很高。

    这是我最初的印象;正如你所说,结果是相反的。这可能是缓存实现没有大写缓冲区或无法利用小写的结果。如果缓存外访问无论如何都需要相同的内存总线时间,则读取访问将有机会加载不会在需要之前从缓存中清除的数据。使用多路缓存,部分写入的行也将有机会不被选择驱逐;并且只有脏缓存行需要内存总线时间才能下降。使用其他知识(例如,排列完整且不重叠)编写的较低级别的程序可以使用诸如非时间 SSE 写入之类的提示来改善行为。

    【讨论】:

    • 谢谢。我不得不承认我不太了解您的最后两段,我对缓存如何工作的了解非常有限。您介意详细说明一下,或者添加一两个指针吗?
    • quora.com/… 有缓存系统的介绍。另一件值得注意的事情是,外部 RAM 访问经常在大块中运行,例如整个缓存行,因此在写出之前收集整个缓存行会更有效。
    • 谢谢!让我看看我是否大致理解:写缓存充当缓冲区,接受来自 CPU 的写入,然后慢慢将它们传递到 RAM,而 CPU 可以做其他事情。但这只有在缓存中有足够的可用空间时才有效。如果所有行都包含尚未写回 RAM 的修改(这就是“脏”的意思吗?),那么 CPU 如果要写入当前不在高速缓存中的位置,则必须等待其他行被写回到 RAM 以释放所需的空间。这大致正确吗?
    • 是的,尽管一个典型的缓存有一组有限的地址,每行可以映射到(每个地址适合缓存中的路数,1 个用于直接映射缓存)。因此,与地址关联的所有行都是脏的就足够了,或者只是让驱逐逻辑选择了一条已经脏的行(该逻辑通常是 LRU 或伪随机)。写缓冲区可以保存任意地址,但大小也有限。因此,花哨的读取更有可能在将更多数据写入缓存行之前将其写入缓存行。
    【解决方案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
    

    【讨论】:

    • 在第一句话中,您写道随机写入速度更快,在接下来的句子中,您写道随机写入最初速度较慢,但​​最终速度较慢。粘贴的数字似乎表明随机写入总是至少与随机读取一样长。这似乎不一致。
    • @Trilarion 我没有写 random writes 最初较慢 - 我写的 random writing code 最初较慢(可能是因为numpy 实现的特性)。然后由于随机写入速度更快,它会赶上随机读取代码。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-06-14
    • 2018-11-30
    • 1970-01-01
    • 2013-11-26
    相关资源
    最近更新 更多