首先,让我们来看一下上面代码的实际性能:
$ sudo perf stat ./offline-read
0.123023
1451229184
Performance counter stats for './offline-read':
184.661547 task-clock (msec)
3 context-switches
0 cpu-migrations
717 page-faults
623,638,834 cycles
419,309,952 instructions
70,803,672 branches
16,895 branch-misses
0.185129552 seconds time elapsed
我们的IPC很低,只有0.67,可能主要是由于对DRAM
5的负载未命中引起的。让我们确认一下:
sudo ../pmu-tools/ocperf.py stat -e cycles,LLC-load-misses,cycle_activity.stalls_l3_miss ./offline-read
perf stat -e cycles,LLC-load-misses,cpu/event=0xa3,umask=0x6,cmask=6,name=cycle_activity_stalls_l3_miss/ ./offline-read
0.123979
1451229184
Performance counter stats for './offline-read':
622,661,371 cycles
16,114,063 LLC-load-misses
368,395,404 cycle_activity_stalls_l3_miss
0.184045411 seconds time elapsed
我想你知道,620,000个周期中有大约370,000个周期因未完成的缺失而被暂停。实际上,在foo()
中以这种方式暂停的周期比例要高得多,接近90%,因为perf
还测量了大约三分之一的运行时间(但没有重要的L3缺失)的初始化和accumulate
代码。
这并不出乎意料,因为我们知道随机读取模式a1 [* b1 ++]
几乎没有局部性。实际上,LLC-load-misses
的数量为16百万1,几乎完全对应于a1
的16百万次随机读取。2
如果我们假设100%的
foo()
花费在等待内存访问上,我们就可以大致了解每个缺失的总成本:
0.123秒/ 16,114,063个缺失 == 7.63 ns/miss
。在我的计算机上,最佳情况下的内存延迟约为60 ns,因此每个缺失不到8 ns意味着我们已经提取了很多内存级并行性(MLP):平均需要重叠和正在进行处理的8个缺失才能实现这一点(甚至完全忽略来自
b1
的流式加载和
o
的流式写入所需的额外流量)。
因此,我认为对于简单循环,您不能应用太多优化。但仍有两种可能性:
由于简单的调整不太可能有很大的效果,所以您需要转换循环。一种“明显”的转换是对索引b1
(以及索引元素的原始位置)进行排序,然后按排序顺序从a1
中读取。这将把对a1
的读取从完全随机变成几乎3线性,但现在写入是全部随机的,这并没有改善情况。
排序和取消排序
关键问题在于由b1
控制的a1
的读取是随机的,而且a1
很大,每次读取都会导致缺失到DRAM。我们可以通过对b1
进行排序,然后按顺序读取a1
来获取一个排列结果来解决这个问题。现在您需要“取消排列”结果a1
以获得最终顺序的结果,这只是另一种排序,这次是在“输出索引”上进行。
这是一个使用给定的输入数组
a
,索引数组
b
和输出数组
o
的工作示例,以及
i
表示每个元素的(隐式)位置:
i = 0 1 2 3
a = [00, 10, 20, 30]
b = [ 3, 1, 0, 1]
o = [30, 10, 00, 10] (desired result)
首先,对数组
b进行排序,将原始数组位置
i作为次要数据(或者您可以将其视为对元组进行排序
(b[0], 0), (b[1], 1), ...),这将为您提供已排序的
b数组
b'和已排序的索引列表
i',如下所示:
i' = [ 2, 1, 3, 0]
b' = [ 0, 1, 1, 3]
现在你可以在
b'的控制下从
a中读取排列结果数组
o'
。这个读取过程按照严格递增顺序进行,应该能够接近
memcpy
速度运行。实际上,你可能可以利用广泛连续的SIMD读取和一些洗牌来做几个读取并一次移动4字节元素到正确位置(复制一些元素并跳过其他元素)。
a = [00, 10, 20, 30]
b' = [ 0, 1, 1, 3]
o' = [00, 10, 10, 30]
最后,你需要对
o'
进行去排列以得到
o
,概念上只需按照排列后的索引
i'
对
o'
进行排序即可。
i' = [ 2, 1, 3, 0]
o' = [00, 10, 10, 30]
i = [ 0, 1, 2, 3]
o = [30, 10, 00, 10]
完成!
现在这是最简单的技术想法,不太适合缓存(每个迭代概念上都会遍历一个或多个2^26字节的数组),但至少完全使用了它读取的每一条缓存线(与原始循环不同,原始循环只从缓存行中读取单个元素,这就是为什么即使数据仅占用100万个缓存行,你也有1600万次缺失!)。所有的读取都是更或多少线性的,所以硬件预取将有很大帮助。
你获得多少加速可能取决于你如何实现排序:它们需要快速且对缓存敏感。几乎肯定某种类型的缓存感知基数排序效果最好。
以下是改进此内容的一些注释:
优化排序量
你实际上不需要完全对
b
进行排序。你只需要将其排序“足够”,以使在
b'
的控制下对
a
的后续读取更加线性。例如,16个元素适合于缓存行,因此您不需要根据最后4位进行排序:无论如何都将读取相同的缓存行的线性序列。您还可以更少地排序:例如,如果您忽略了5个最低有效位,则会以“几乎线性”的方式读取缓存行,有时从完全线性模式中交换两个缓存行,如:
0、1、3、2、5、4、6、7
。在这里,您仍将获得L1缓存的全部好处(对缓存行的后续读取将始终命中),我认为这样的模式仍然会被良好地预取,如果没有,您总是可以通过软件预取来帮助它。
您可以在系统上测试忽略的最佳位数。忽略位有两个好处:
- 在基数搜索中需要做的工作更少,可以通过减少所需的遍数或在一个或多个遍历中需要更少的桶(有助于缓存)来实现。
- 在最后一步中,"撤销"排列可能需要的工作量也可能更少:如果通过检查原始索引数组b来进行撤销并忽略位,则意味着撤销搜索时可以获得相同的节省。
缓存块处理
上述描述将所有内容按顺序分为几个不连续的遍历,每个遍历都在整个数据集上进行。在实践中,您可能希望交错它们以获得更好的缓存行为。例如,假设您使用 MSD 基数-256 排序,则可以进行第一次遍历,将数据排序为大约 256K 元素的 256 个桶。
如果不进行完整的第二遍排序,您可以完成仅对第一个(或前几个)桶进行排序,并根据生成的 b'
块执行对 a
的读取。您保证此块是连续的(即最终排序序列的后缀),因此您不会放弃读取中的任何局部性,并且您的读取通常会被缓存。您还可以进行去排列的第一遍处理 o'
,因为 o'
的块也在高速缓存中(也许您可以将后两个阶段合并为一个循环)。
智能去排列
优化的一个方面是如何实现对o'的去排列。在上面的描述中,我们假设一些索引数组i最初具有值[0,1,2,...,max_q],并与b一起排序。这是它的工作方式的
概念,但您可能不需要立即实现i并将其排序为辅助数据。例如,在基数排序的第一遍中,可以隐式地知道i的值(因为您正在迭代数据),因此可以免费计算并在第一遍中写出,而无需按排序顺序出现。
还可能有更有效的方法来执行“取消排序”操作,而不是维护完整的索引。例如,原始未排序的b数组在概念上具有执行取消排序所需的所有信息,但我不清楚如何使用它来高效地取消排序。
它会更快吗?
这样做是否比朴素方法更快?这在很大程度上取决于实现细节,特别是所实现排序的效率。在我的硬件上,朴素方法每秒处理约140万个元素。在线缓存感知基数排序的描述似乎从200到600百万个元素/秒不等,而且由于需要两个排序,如果你相信这些数字,那么大幅加速的机会似乎有限。另一方面,这些数字来自较旧的硬件,并且对于稍微更一般的搜索(例如,对于密钥的所有32位,而我们可能只能使用16位),这些数字也适用。
只有仔细的实现才能确定其可行性,而可行性还取决于硬件。例如,在无法维持太多MLP的硬件上,排序-取消排序方法变得相对更有利。
最佳方法还取决于
max_n
和
max_q
的相对值。例如,如果
max_n >> max_q
,即使使用最佳排序,读取也会很“稀疏”,因此朴素的方法会更好。另一方面,如果
max_n << max_q
,则通常会多次读取相同的索引,因此排序方法将具有良好的读取局部性,排序步骤本身将具有更好的局部性,并且进一步优化可以明确处理重复读取。
多核
从问题中并不清楚您是否有兴趣并行化。对于
foo()
的朴素解决方案已经允许“直接”并行化,其中您只需将
a
和
b
数组分成大小相等的块,每个线程一个,这似乎提供了完美的加速。不幸的是,您可能会发现,由于内存控制器和相关的uncore/offcore资源在套接字上的所有核心之间共享,因此您将遇到资源争用,因此您将得到远低于线性比例的扩展。因此,当您添加更多核心时,纯并行随机读取负载对内存的吞吐量将不清楚会增加多少。
对于基数排序版本,大多数瓶颈(存储吞吐量、总指令吞吐量)在核心部分,因此我预计它将随着额外核心的增加而合理扩展。正如彼得在评论中提到的那样,如果您正在使用超线程,则排序可能具有良好的局部性能,在核心本地L1和L2缓存中,有效地让每个兄弟线程使用整个缓存,而不是将有效容量减半。当然,这涉及仔细管理线程亲和力,以使兄弟线程实际上使用附近的数据,而不仅仅是让调度程序执行其操作。
1 你可能会问为什么 LLC-load-misses
不是3200万或4800万,因为我们还要读取b1
的1600万个元素,然后accumulate()
调用会读取整个result
。答案是,LLC-load-misses
只计算在L3中实际缺失的需求缺失。其他提到的读取模式是完全线性的,因此预取器将始终在需要之前将行带入L3中。按perf使用的定义,这些不算作“LLC缺失”。
2 你可能想知道我如何确定缺失加载全部来自于foo
中对a1
的读取:我只是使用perf record
和perf mem
确认缺失来自于预期的汇编指令。
3 线性近似,因为b1
不是所有索引的排列,所以原则上可能会跳过和重复的索引。但在缓存行级别上,每个元素有约63%的概率被包含在内,因此高度可能按顺序读取每个缓存行,并且缓存行具有16个4字节元素,因此任何给定缓存没有零元素的机会只有大约1000万分之1。因此,在缓存行级别上运行的预取将很好地工作。
4 这里我指的是计算值的成本几乎为零,但写入仍然需要成本。然而,这仍然比“预先实例化”方法要好得多,该方法首先创建 i 数组[0, 1, 2, ...]
,需要max_q
次写入,然后再需要另外max_q
次写入才能在第一个基数排序中对其进行排序。隐式实例化仅产生第二个写入。
5 实际定时部分 foo()
的IPC要低得多:根据我的计算,大约为0.15。整个进程的报告IPC是定时部分IPC和初始化累加代码之前和之后的平均值,这些代码具有更高的IPC。
6 值得注意的是,这与依赖加载延迟限制工作流程的缩放方式不同:一个正在进行随机读取但只能有一个加载正在进行的加载(因为每个加载都依赖于上一个结果)非常适合多个核心,因为加载的串行性质不使用许多下游资源(但是这样的加载在单个核心上也可以概念上加速,方法是更改核心循环以处理多个依赖加载流并行处理)。