为什么线性读-混洗写并不比混洗读-线性写快?

2024-07-01

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

where d = np.arange(N)(我以两种方式对所有内容进行洗牌,以期减少跨试验缓存的影响)。我也更换了timeit with 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

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


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

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

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

  3. 尤其是在多核环境中

原因#1 现代处理器可以有效隐藏读取延迟。

Modern 超标量 https://en.wikipedia.org/wiki/Superscalar_processor可以同时执行多条指令,并改变指令执行顺序(乱序执行 https://en.wikipedia.org/wiki/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 规则),这可能会导致执行无序。

对于所考虑的代码,有no高级指令 2. 和 1. 之间(或者 asm 指令 2a 和 2b 与之前的指令之间)的依赖关系。实际上,最终结果甚至是相同的,即 2. 在 1. 之前执行,并且处理器将在 1a 和 1b 完成之前尝试执行 2a 和 2b。 2a 和 2b 之间仍然存在依赖关系,但两者都可以发出。 3a 也类似。和3b.,等等。这是一个强有力的手段隐藏内存延迟。如果由于某种原因 2.、3. 和 4. 可以在 1. 加载其数据之前终止,您甚至可能根本没有注意到任何减速。

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

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

  • L1 缓存之前的内存顺序缓冲区 MOB 中的待处理内存访问队列。这是处理内存别名并确保同一地址内存写入或加载的顺序性所必需的(典型值 64 次加载,32 次存储)

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

  • 以及取指令时的一些其他队列,用于微操作生成、高速缓存中的写入和未命中缓冲区等

在执行前一个程序的某个时刻,RS 中将有许多待处理的存储指令,MOB 中将有多个加载指令,ROB 中将有等待退出的指令。

一旦数据可用(例如读取终止),相关指令就可以执行并释放队列中的位置。但是,如果没有发生终止,并且这些队列之一已满,则与该队列关联的功能单元将停止(如果处理器缺少寄存器名称,则在指令发出时也可能发生这种情况)。停顿会造成性能损失,为了避免这种情况,必须限制队列填充。

这解释了线性内存访问和随机内存访问之间的差异。
在线性访问中,1/ 由于更好的空间局部性,并且由于缓存可以以规则模式预取访问以进一步减少未命中次数,因此 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 https://en.wikipedia.org/wiki/CPU_cache表现。高速缓存是存储一部分内存(称为line)由处理器。缓存行目前为 64 字节,允许利用内存引用的空间局部性:一旦存储了一行,该行中的所有数据都立即可用。这里重要的一点是缓存和内存之间的所有传输都是线路.

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

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

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

Hence, 每次内存写入之前都必须先进行内存读取获取缓存中的行。这增加了额外的操作,但对于线性写入来说并不是很昂贵。对于第一个写入的字,将会出现高速缓存未命中和内存读取,但连续的写入将只涉及高速缓存并被命中。

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

值得注意的是,增加内存访问(读取或写入)的数量往往会使内存访问路径饱和,并全局减慢处理器和内存之间的所有传输速度。

无论哪种情况,写入总是比读取更昂贵。多核增强了这方面的能力。

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

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

多核的问题是确保正确的缓存一致性 https://en.wikipedia.org/wiki/Cache_coherence以这种方式,由多个处理器共享的数据在每个核心的高速缓存中被正确更新。这是通过诸如MESI https://en.wikipedia.org/wiki/MESI_protocol在写入之前更新缓存行,并使其他缓存副本无效(读取所有权)。

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

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


我们做了一些绩效评估。它是用 C 语言执行的,以便评估并行化的影响。我们比较了5个 处理大小为 N 的 int64 数组的函数。

  1. 只是 b 到 c 的副本 (c[i] = b[i])(由编译器实现memcpy())

  2. 使用线性索引复制c[i] = b[d[i]] where d[i]==i (read_linear)

  3. 使用随机索引复制c[i] = b[a[i]] where 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] with 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 次 直接复制的读取和写入。更多的索引增加了一些依赖性。最小值为 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-100k):L2 缓存为 256k,可容纳 32k int64 数组。之后,我们需要进入L3缓存(12Mo)。随着大小的增加,L1 和 L2 中的未命中次数也会增加,计算时间也会相应增加。两种算法的未命中次数相似,主要是由于随机读取或写入(其他访问是线性的,并且可以由缓存非常有效地预取)。我们检索 B.M. 中已经提到的随机读取和写入之间的因子 2。回答。这可以部分地用写入的双重成本来解释。

  • 大值(>100k):方法之间的差异逐渐减小。对于这些大小,很大一部分信息存储在 L3 缓存中。 L3大小足以容纳1.5M的完整阵列,并且线不太可能被弹出。因此,对于写入,在初始读取之后,可以在没有行弹出的情况下完成大量写入,并且降低了写入与读取的相对成本。对于这些大尺寸,还需要考虑许多其他因素。例如,缓存只能服务有限数量的未命中(通常为 16),并且当未命中数量很大时,这可能是限制因素。

关于随机读写的并行 omp 版本的一言以蔽之。除了小尺寸之外,将随机索引数组分布在多个缓存上可能不是一个优势,但系统上它们的速度要快两倍。对于大尺寸,我们清楚地看到随机读取和写入之间的差距由于错误共享而增加。

以目前计算机体系结构的复杂性来进行定量预测几乎是不可能的,即使是简单的代码,甚至对行为的定性解释也很困难,必须考虑到许多因素。正如其他答案中提到的,与 python 相关的软件方面也会产生影响。但是,虽然在某些情况下可能会发生这种情况,但大多数时候,我们不能认为由于数据依赖性而导致读取成本更高。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

为什么线性读-混洗写并不比混洗读-线性写快? 的相关文章

随机推荐

  • 对于 Android 开发,我可以在图像视图上使用 JPG 图像而不是 PNG 图像吗?

    这个问题的主要目的是了解考虑以下场景 在 Android 开发中选择 PNG 和 JPG 的最佳选择是什么 1 使用jpg图像作为背景是一个好的选择吗 2 与 png 相比 jpg 图像的加载时间会更长吗 3 与 png 相比 jpg 会花
  • 当用户使用 Internet Explorer 时重定向到新页面

    我使用下面的代码将使用 Internet Explorer 的用户重定向到新页面 但显然代码有问题 因为当我使用 Internet Explorer 时该网站不再加载 这是代码 由于我不知道我做错了什么 如果有人可以发布使用正确编码的正确方
  • 为什么我收到 TypeError: array[i] is undefined? [复制]

    这个问题在这里已经有答案了 因此 在我的程序中 我有一个包含值的字典 散列的数组 当我循环遍历该数组时 我得到了我需要的值 但 for 循环之后的任何代码都不会执行 因为控制台输出 TypeError array i is undefine
  • 使用 Python 映射字母数字字符串

    我有一个姓名数据集 根据名称的字母数字字符串 我需要将它们映射到子名称 如下所示 Name Subname 9 AIF 09 9A09 980 PD Z09A 980P09 15 KIC 12 15K12 PIA 110H P 110 IC
  • 跨三个表的 LEFT JOIN(带有连接表)

    在Postgres中 有没有办法执行left join在由联结表链接的表之间 并在链接表上进行一些过滤 比如说 我有两张桌子 humans and pets 我想执行一个查询 其中包含人类 ID 和宠物名称 如果人类 ID 存在 但他们没有
  • Angular 5:表内的动态表单验证

    我正在尝试使用表单组验证表内的输入字段 但无法实现相同的目标 我使用 ngFor 来迭代数据 因为我必须在表的第一列中显示该数据 而其他列只是输入文本字段 我必须在其中添加表单验证 因此 我添加了该字段的唯一表单控件名称的索引 HTML代码
  • 有关堆栈大小的警告消息

    I use Visual Studio 2010 with Code Analysis活性 在我的代码中 有一行在函数中分配一些内存 TCHAR someString 40000 代码分析抛出警告信息 警告 C6262 函数使用 40000
  • Firebase 和 Google Play 服务之间要使用哪些依赖项?

    我最近开始使用 Firebase 但我无法完全理解它与 Google Play Services 的关系 我知道Firebase是一个移动平台 在Android上它是基于Google Play Services的 但是为什么有一些模块与Go
  • 如何在JNA中填充结构体数组?

    我正在尝试在 JNA 中使用以下 Windows API UINT WINAPI GetRawInputDeviceList Out opt PRAWINPUTDEVICELIST pRawInputDeviceList Inout PUI
  • Java 中的简单 Kerberos 客户端?

    Google Chrome 和 IE 等应用程序可以透明地处理 Kerberos 身份验证 但是我找不到一个 简单 的 Java 解决方案来匹配这种透明度 我发现的所有解决方案都需要存在 krb5 conf 文件和 login conf 文
  • 找不到 PHPUnit 的 TextUI/command.php

    我为我的 symfony2 项目安装了 phpunit 如下所示 如何使用从composer安装的phpunit https stackoverflow com questions 13764309 how to use phpunit i
  • 使用 PHP 解析 XML 导航站点地图

    我正在从 XML 文件实现 PHP 站点地图解析器 我做得相对不错 但是 我需要解析器更加动态 我需要实现一个递归函数 它将继续循环找到的每个 child node 一个节点可以在另一个 child node 中包含许多 child nod
  • 与 ADO.NET、SQLite 和 TSQL 的只读连接

    我的代码通过一个连接读取并通过另一个连接写入 我不想意外地使用读取连接进行写入 我怎样才能使连接只读 我正在使用 SQLite ATM 并将在原型结束时将代码部分转换为 tsql 您可以将 Read Only True 添加到只读连接 Da
  • WTL 子窗口事件处理

    我正在开发窗口应用程序 因为我在左侧和右侧有 2 个子窗口 我想分别处理两个窗口的输入事件 如何实现 My code class EditorWindow public DxWindow public CSplitterWindow m v
  • 模块化应用程序堆栈中的虚拟数据和单元测试策略

    如何管理用于测试的虚拟数据 将它们保留在各自的实体中 在一个单独的测试项目中 使用外部资源的序列化程序加载它们 或者只是在需要的地方重新创建它们 我们有一个应用程序堆栈 其中包含多个模块 这些模块依赖于另一个模块 每个模块都包含实体 每个模
  • 如何在另一个 php 脚本的后台运行 php 脚本(如更新按钮)

    当我按下 更新 按钮时 我将如何运行一个 php 脚本 然后它将运行脚本 x1 php 没有回显或其他输出 成功或失败 然后更新当前页面 我知道更新部分可以使用 ajax 完成 但我不确定如何以及如何让 x1 php 脚本在后台运行并在完成
  • 创建类路径资源 META-INF/cxf/cxf.xml 中定义的名为“cxf”的 bean 时出错

    我只是尝试使用 Apache CXF 和 Spring by Maven 运行一个简单的 Web 服务应用程序 但是在启动 Tomcat 时出现以下错误 org springframework beans factory BeanCreat
  • 使用 PyQt5/Pyside2 设置重复的 SVG 图案作为主窗口/Qwidget 背景

    我已经通过生成了 SVG css 代码http www heropatterns com http www heropatterns com 我正在尝试使用它作为我的主窗口 Qwidget 的背景 我希望背景随着窗口变大或缩小而调整大小 我
  • 以编程方式解析和编辑 C++ 源文件

    我想以编程方式解析和编辑 C 源文件 我需要更改 添加代码的某些部分 即函数 类块等 中的代码 我也 最好 能够得到评论 我想做的部分事情可以用下面的代码来解释 CPlusPlusSourceParser cp new CPlusPlusS
  • 为什么线性读-混洗写并不比混洗读-线性写快?

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