![]() |
1
22
这是一个复杂的问题,与现代处理器的架构特性和您的直觉密切相关 随机读取比随机写入慢,因为CPU必须等待读取数据 未经验证(大多数情况下)。有几个原因我会详细说明。
原因1现代处理器能够有效地隐藏读取延迟。 现代的 superscalar 可以同时执行多条指令,改变指令执行顺序( out of order execution ). 虽然这些特性的第一个原因是为了增加教学思想, 最有趣的结果之一是处理器隐藏内存写入延迟(或复杂的运算符、分支等)的能力。 为了解释这一点,让我们考虑一个将数组复制到另一个数组中的简单代码。
一个经过编译的,由处理器执行的代码将以某种方式类似于
(这过于简单化,实际的代码更复杂,必须处理循环管理、地址计算等,但这种简单化的模型目前已经足够了)。 如问题中所述,对于读取,处理器必须等待实际数据。实际上,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指令、MOB中的几个加载指令和ROB中等待失效的指令。 一旦数据变为可用(例如,读取终止),依赖于指令就可以执行并释放队列中的位置。但是,如果没有发生终止,并且其中一个队列已满,则与此队列关联的功能单元将暂停(如果处理器缺少寄存器名,则在发出指令时也可能发生这种情况)。暂停是造成性能损失的原因,为了避免这种损失,必须限制队列填充。
这解释了线性内存访问和随机内存访问之间的区别。
处理器架构必须在吞吐量方面保持平衡,以避免队列饱和和暂停。实际上,在处理器的某个执行阶段通常有几十条指令,而全局吞吐量(即存储器(或功能单元)为指令请求提供服务的能力)是决定性能的主要因素。事实上,这些挂起的指令中有一些正在等待内存值,这对。。。 …除非你有很长的依赖链。 当指令必须等待前一条指令完成时,就存在依赖关系。使用读取结果是一种依赖关系。当涉及到依赖链时,依赖关系可能是一个问题。
例如,考虑一下
但是读取很少涉及依赖链。仍然可以想象病理代码的所有读取都依赖于前一个(例如
因此,除了在某些情况下,计算时间与吞吐量的关系比与读取依赖性的关系更大,这是因为超标量失效或顺序执行隐藏了延迟。对于吞吐量来说,写操作比读操作更糟糕。 原因2:内存写入(特别是随机写入)比内存读取更昂贵 这与 caches 表现。缓存是存储一部分内存的快速内存(称为 线 )由处理器处理。缓存线目前为64字节,允许利用内存引用的空间位置:存储一行后,该行中的所有数据立即可用。重要的是 缓存和内存之间的所有传输都是行 . 当处理器对数据执行读取时,缓存检查数据所属的行是否在缓存中。否则,该行将从内存中提取,存储在缓存中,并将所需数据发送回处理器。 当处理器将数据写入内存时,缓存也会检查是否存在行。如果该行不存在,则缓存无法将其数据发送到内存(因为 全部的 传输是基于行的),并执行以下步骤:
因此, 每次内存写入之前都必须有一个内存读取 在缓存中获取行。这增加了一个额外的操作,但对于线性写入来说并不昂贵。对于第一个写入的字,将有一个缓存未命中和一个内存读取,但是连续的写入只涉及缓存和被命中。 但随机写入的情况则大不相同。如果未命中的次数很重要,则每一次缓存未命中都意味着在从缓存中弹出行之前只读取少量的写操作,这将显著增加写入成本。如果一行在一次写入之后被弹出,我们甚至可以认为一次写入的时间开销是一次读取的两倍。 需要注意的是,增加内存访问(读或写)的数量往往会使内存访问路径饱和,并全局地减慢处理器和内存之间的所有传输。 无论哪种情况,写总是比读更贵。多核增强了这一点。 原因3:随机写入在多核中创建缓存未命中 不确定这是否真的适用于问题的情况。虽然numpy BLAS例程是多线程的,但我不认为基本数组拷贝是多线程的。但这是密切相关的,也是写作成本更高的另一个原因。 多核的问题是确保 cache coherence 以这样的方式,由多个处理器共享的数据在每个核心的缓存中被正确地更新。这是通过一个协议来完成的,比如 MESI 在写入缓存线之前更新缓存线,并使其他缓存副本失效(为所有权读取)。 虽然这些数据实际上都不是在问题的核心(或并行版本)之间共享的,但请注意,该协议适用于 缓存线 . 无论何时修改缓存线,都会从保存最新副本、本地更新的缓存中复制该缓存线,并且所有其他副本都将失效。即使核心正在访问缓存线的不同部分。这种情况叫做 false sharing 这是多核程序设计中的一个重要问题。 对于随机写入问题,缓存线是64字节,可以容纳8个int64,如果计算机有8个内核,每个内核平均处理2个值。因此,有一个重要的错误共享会减慢写入速度。 我们做了一些绩效评估。它是在C语言中执行的,以便包括对并行化影响的评估。我们比较了5个 处理大小为N的int64数组的函数。
代码已用
阵列大小从4000到1200000不等。所有代码都是用openmp的顺序和并行版本进行测量的。 这是结果的图表。函数有不同的颜色,顺序版本用粗线条表示,并行版本用细线条表示。
直接拷贝(显然)是最快的,由gcc实现
高度优化的
读取线性性能大约是MEMCPY的两倍,但是有2个读和写,VS 1。 直接拷贝的读写。更多的索引添加了一些依赖项。最小值为1.56 CPI,最大值为3.8 CPI。写线性的稍长(5-10%)。 使用随机索引进行读写是原始问题的目的,需要更长的注释。这是结果。
一个字的并行omp版本的随机读写。除了较小的大小之外,让随机索引数组分布在几个缓存上可能不是一个优势,它们在系统上要快~两倍。对于大容量,我们清楚地看到,由于错误共享,随机读写之间的差距增大。 几乎不可能用当前计算机体系结构的复杂性来进行定量预测,即使对于简单的代码,甚至对行为的定性解释也是困难的,并且必须考虑到许多因素。正如在其他答案中提到的,与python相关的软件方面也会产生影响。但是,虽然在某些情况下可能会发生这种情况,但大多数情况下,由于数据依赖性,人们不能认为读操作更昂贵。 |
![]() |
2
11
是这样的 麻木 版本:
N=10000的计时:
它本质上是来自 布拉斯/阿特拉斯/MKL 为之调整。 花式索引是一个很好的高级工具,但对于这些问题的异端者,在低层次上没有直接的概念。
我们在这件事上。我认为这可以解释差异,以及为什么set比get慢。 这也解释了为什么手工制作 麻木 通常更快:它不检查任何内容,并在不一致的索引上崩溃。 |
![]() |
3
9
你的两个裸体片段
关于哪一个应该更快的问题,似乎有理由认为,随机读线性写通常可以胜出(正如基准显示的那样),但速度的差异可能会受到随机索引的“随机”程度的影响,并且一个或多个:
即使假设有哪些策略,这些效果也很难进行建模和分析,因此我不确定是否可以得到适用于所有处理器的通用答案(尽管我不是硬件专家)。 不过,在下面的第二部分中,我将尝试解释为什么在某些假设下,无序读线性写显然更快。 “琐碎”花式索引
本节的目的是浏览NumPy源代码,以确定是否对计时有任何明显的解释,并尽可能清楚地了解当
迭代程序支持虚拟索引 getitem 和 setitem 这个问题的操作是“ trivial ":
此外,在我们的案例中
这里的要点是使用内部缓冲区来确保避免对齐。使用
编译器可能会很简单地将其转换为
执行项目获取和设置的核心低级代码在函数中
坚持不懈,我们最终会发现getitem和setitem之间没有什么区别。这是主循环的简化版本,以供说明。宏行确定运行的是getitem还是setitem:
如我们所料,这仅仅相当于一些算法,以获得数组的正确偏移量,然后将字节从一个内存位置复制到另一个内存位置。 setitem的额外索引检查
值得一提的是,对于setitem,索引的有效性(无论它们是否都是目标数组的inbounds)是
checked before copying
开始(通过
在上面的片段中,您可以看到
这种额外的初步检查可能会对setitem的速度产生微小但负面的影响(
缓存未命中因为这两个代码片段的代码非常相似,所以怀疑的焦点在于CPU以及它如何处理对底层内存数组的访问。 CPU缓存最近访问过的小内存块(缓存线),因为它可能很快需要再次访问该内存区域。 对于上下文,缓存线通常是64字节。我老化的笔记本电脑CPU上的L1(最快)数据缓存为32KB(足以容纳阵列中的500个int64值,但请记住,在执行NumPy代码段时,CPU将执行其他需要内存的操作):
正如您可能已经知道的,对于读/写内存,顺序缓存工作得很好,因为64字节的内存块是根据需要获取的,并且存储在靠近CPU的地方。对该内存块的重复访问比从RAM(或更高级别的缓存)中获取更快。实际上,CPU甚至可以在程序请求下一个缓存线之前抢先获取它。 另一方面,随机访问内存可能会导致频繁的缓存未命中。这里,具有所需地址的内存区域不在CPU附近的快速缓存中,而是必须从更高级别的缓存(较慢)或实际内存(较慢得多)访问。 那么,对于CPU来说,哪种处理速度更快:频繁的数据读取未命中,还是数据写入未命中? 假设CPU的写策略是写回,这意味着修改后的内存被写回缓存。缓存被标记为正在修改(或“脏的”),只有当缓存线被逐出时,更改才会被写回主内存(CPU仍然可以从脏的缓存线读取)。 如果我们正在写入一个大数组中的随机点,则预期CPU缓存中的许多缓存线将变脏。当每个内存都被逐出时,需要对主内存进行直写,如果缓存已满,这种情况可能会经常发生。 但是,当按顺序写入数据并随机读取数据时,这种直写应该不那么频繁,因为我们希望更少的缓存线变脏,数据回主内存或较慢的缓存不那么频繁。 如前所述,这是一个简化的模型,可能还有许多其他因素影响CPU的性能。比我更专业的人很可能会改进这种模式。 |
![]() |
4
8
你的职能
现在,让我们在全球范围内尝试一下:
即使如此,它可能在时间上与
不管怎样,如果你想写的话
进入之内
至于效果,我认为你想衡量,基本上是正向还是反向排列更昂贵,这将是高度依赖缓存。前向排列(以线性读取的随机顺序索引存储)原则上可能更快,因为它可以使用写掩蔽,并且永远不会获取新数组,前提是缓存系统足够聪明,可以在写缓冲区中保留字节掩蔽。如果数组足够大,则在执行随机读取时向后运行缓存冲突的高风险。 这是我最初的印象;正如你所说,结果恰恰相反。这可能是由于缓存实现没有大的写缓冲区或无法利用小的写操作造成的。如果缓存外访问仍然需要相同的内存总线时间,则读取访问将有机会加载在需要之前不会从缓存中删除的数据。使用多路缓存时,部分写入的缓存线也有可能不被选择用于排除;只有脏缓存线需要内存总线时间来丢弃。使用其他知识编写的低级程序(例如,置换是完整的和不重叠的)可以使用诸如非时态SSE写入之类的提示来改进行为。 |
![]() |
5
5
下面的实验证实了随机写入比随机读取快。对于较小的数据(当它完全适合缓存时),随机写入代码比随机读取代码慢(可能是由于
|
|
unfolx · numpy数组不等式的执行时间 7 月前 |
|
mchaudh4 · 用numpy表示三对角矩阵 7 月前 |
![]() |
Geremia · 2D NumPy数组+1D数组? 7 月前 |
![]() |
LMC · Numpy数组布尔索引以获取包含元素 7 月前 |
![]() |
HJA24 · 根据条件用值正向填充Numpy矩阵/掩码 8 月前 |
![]() |
Amarth Gûl · 找到一组向量的最近收敛点 8 月前 |
![]() |
Mr. W · numpy.divide是函数、类还是其他什么? 9 月前 |
![]() |
Mr. W · 为什么numpy.array在编辑内部数据时如此缓慢? 9 月前 |