缓存友好的离线随机读取

时间:2017-09-05 16:24:52

标签: algorithm performance optimization x86 cpu-cache

在C ++中考虑这个函数:

void foo(uint32_t *a1, uint32_t *a2, uint32_t *b1, uint32_t *b2, uint32_t *o) {
    while (b1 != b2) {
        // assert(0 <= *b1 && *b1 < a2 - a1)
        *o++ = a1[*b1++];
    }
}

它的目的应该足够清楚。遗憾的是,b1包含随机数据并废弃缓存,导致foo成为我程序的瓶颈。无论如何我可以优化它吗?

这是一个类似于我的实际代码的SSCCE:

#include <iostream>
#include <chrono>
#include <algorithm>
#include <numeric>

namespace {
    void foo(uint32_t *a1, uint32_t *a2, uint32_t *b1, uint32_t *b2, uint32_t *o) {
        while (b1 != b2) {
            // assert(0 <= *b1 && *b1 < a2 - a1)
            *o++ = a1[*b1++];
        }
    }

    constexpr unsigned max_n = 1 << 24, max_q = 1 << 24;
    uint32_t data[max_n], index[max_q], result[max_q];
}

int main() {
    uint32_t seed = 0;
    auto rng = [&seed]() { return seed = seed * 9301 + 49297; };
    std::generate_n(data, max_n, rng);
    std::generate_n(index, max_q, [rng]() { return rng() % max_n; });

    auto t1 = std::chrono::high_resolution_clock::now();
    foo(data, data + max_n, index, index + max_q, result);
    auto t2 = std::chrono::high_resolution_clock::now();
    std::cout << std::chrono::duration<double>(t2 - t1).count() << std::endl;

    uint32_t hash = 0;
    for (unsigned i = 0; i < max_q; i++)
        hash += result[i] ^ (i << 8) ^ i;
    std::cout << hash << std::endl;
}

这不是Cache-friendly copying of an array with readjustment by known index, gather, scatter,它会询问随机写入并假设 b 是一种排列。

2 个答案:

答案 0 :(得分:4)

首先,让我们看看上面代码的实际性能:

$ sudo perf stat ./offline-read
0.123023
1451229184

 Performance counter stats for './offline-read':

        184.661547      task-clock (msec)         #    0.997 CPUs utilized          
                 3      context-switches          #    0.016 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               717      page-faults               #    0.004 M/sec                  
       623,638,834      cycles                    #    3.377 GHz                    
       419,309,952      instructions              #    0.67  insn per cycle         
        70,803,672      branches                  #  383.424 M/sec                  
            16,895      branch-misses             #    0.02% of all branches        

       0.185129552 seconds time elapsed

我们正在获得0.67的低IPC,可能几乎完全由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

所以~60kk中的约370k个循环在未完成的未命中时是直接停止的。实际上,在foo()中以这种方式停止的循环部分要高得多,接近90%,因为perf也在测量init和accumulate代码,这需要大约三分之一的运行时间(但没有明显的L3未命中)。

这并不意外,因为我们知道随机读取模式a1[*b1++]基本上没有局部性。事实上,LLC-load-misses的数量是1600万 1 ,几乎完全对应于a1的1600万随机读数。 2

如果我们假设100%的foo()等待内存访问,我们就可以了解每次未命中的总费用:0.123 sec / 16,114,063 misses == 7.63 ns/miss。在我的方框中,在最好的情况下,内存延迟大约为60 ns,因此每次小于8 ns意味着我们已经提取了大量内存级并行(MLP):大约8个未命中必须重叠并且在 - 实际上是平均飞行(甚至完全忽略b1的流媒体负载和o的流媒体写入的额外流量。

所以我不认为有很多调整可以应用于简单循环以做得更好。仍有两种可能性:

  • 写入o的非临时存储,如果您的平台支持它们。这会删除RFO对正常商店隐含的读数。它应该是一个直接的胜利,因为o永远不会再读(在定时部分内!)。
  • 软件预取。仔细调整a1b1的预取可能有助于一点。然而,影响将相当有限,因为我们已经接近如上所述的MLP限制。此外,我们预计b1的线性读取几乎完全由硬件预取器预取。 a1的随机读取看起来好像可以预取,但实际上,循环中的ILP通过无序处理导致足够的MLP(至少在最近的x86等大型OoO处理器上)。 / p>

    在评论中,用户harold已经提到过他尝试预取只有很小的效果。

因此,由于简单的调整不可能带来很多成果,所以你只能改变循环。一个&#34;显而易见的&#34;转换是对索引b1(以及索引元素的原始位置)进行排序,然后按排序顺序从a1进行读取。这将a1的读取从完全随机转换为几乎 3 线性,但现在写入都是随机的,这不是更好。

排序然后取消

关键问题是在a1控制下的b1读取是随机的,而a1很大,基本上每次读取都会丢失到DRAM。我们可以通过对b1进行排序,然后阅读a1以获得置换结果来解决此问题。现在你需要&#34; un-permute&#34;结果a1以最终顺序获得结果,这只是另一种排序,这次是在&#34;输出索引&#34;。

这里有一个工作示例,其中包含给定的输入数组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), ...),这将为您提供排序{{1数组b和排序的索引列表b'如图所示:

i'

现在,您可以在 i' = [ 2, 1, 3, 0] b' = [ 0, 1, 1, 3] 的控制下从o'读取置换结果数组a。此读取按顺序严格增加,并且应能够以接近b'速度运行。实际上,你可以利用宽连续的SIMD读取和一些shuffle来做几次读取,然后将4字节元素移动到正确的位置(复制一些元素并跳过其他元素):

memcpy

最后,您取消 a = [00, 10, 20, 30] b' = [ 0, 1, 1, 3] o' = [00, 10, 10, 30] 取消o'以获取o,概念上只需对置换索引o'上的i'进行排序:

  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进行完全排序。你只想对它进行排序&#34;足够&#34;这使ab'控制下的后续读取或多或少是线性的。例如,16个元素适合缓存行,因此您根本不需要根据最后4位进行排序:无论如何都将读取相同的线性缓存行序列。您还可以对更少的位进行排序:例如,如果您忽略了5个最低有效位,您就可以在&#34;几乎线性的&#34;中读取高速缓存行。方式,有时从完美的线性模式交换两个缓存行,如:0, 1, 3, 2, 5, 4, 6, 7。在这里,你仍然可以获得L1缓存的全部好处(后续对缓存行的读取总是会被命中),我怀疑这样的模式仍然可以预取,如果没有,你可以随时使用软件预取来帮助它

您可以在系统上测试忽略位的最佳数量。忽略位有两个好处:

  • 在基数搜索中做的工作量较少,可以减少所需的传递次数,也可以在一次或多次传递中需要更少的存储桶(这有助于缓存)。
  • 可能要做的工作少于&#34;撤消&#34;最后一步中的排列:如果通过检查原始索引数组b来撤消,则忽略位意味着在撤消搜索时可以获得相同的节省。

缓存阻止工作

以上描述列出了几个连续的,不相交的通道中的每一个,每个通道都在整个数据集上工作。在实践中,您可能希望将它们交错以获得更好的缓存行为。例如,假设您使用MSD基数-256排序,您可以执行第一次传递,将数据排序为256个桶,每个桶大约256K元素。

然后,您可以完成仅对第一个(或前几个)存储桶进行排序,然后根据生成的a块继续读取b',而不是进行完整的第二次传递。您可以保证此块是连续的(即最终排序序列的后缀),因此您不会放弃读取中的任何位置,并且通常会缓存您的读取。您也可以进行去置换o'的第一遍,因为o'的块在缓存中也很热(也许您可以将后两个阶段合并为一个循环)。

智能去置换

优化的一个方面是如何实现o'的去置换。在上面的描述中,我们假设一些索引数组i最初具有值[0, 1, 2, ..., max_q],其与b一起排序。这是概念它是如何工作的,但您可能不需要立即实际实现i并将其作为辅助数据进行排序。例如,在基数排序的第一次传递中,i的值是隐式已知的(因为您正在迭代数据),因此可以计算出免费的 4 并写入在第一次通过期间没有按出排序顺序出现。

可能还有更有效的方法来做&#34; unsort&#34;操作比保持完整索引。例如,原始未排序的b数组在概念上具有执行unsort所需的所有信息,但我很清楚如何使用它来有效地解除输出。

是否更快?

那么这实际上会比天真的方法更快吗?它在很大程度上取决于实现细节,尤其包括实现排序的效率。在我的硬件上,天真的方法是每秒处理大约1.4亿个元素。缓存感知基数排序的在线描述似乎在2亿到6亿个元素/秒之间,并且由于您需要其中的两个,如果您相信这些数字,那么大加速的机会似乎有限。另一方面,这些数字来自较旧的硬件,并且用于稍微更一般的搜索(例如,对于密钥的所有32位,而我们可以使用少至16位)。

只有仔细实施才能确定它是否可行,可行性还取决于硬件。例如,在无法维持尽可能多的MLP的硬件上,排序 - 未分类的方法变得相对更有利。

最佳方法还取决于max_nmax_q的相对值。例如,如果max_n >> max_q,那么读取将是&#34;稀疏&#34;即使有最佳排序,所以天真的方法会更好。另一方面,如果max_n << max_q,则通常会多次读取相同的索引,因此排序方法将具有良好的读取局部性,排序步骤本身将具有更好的局部性,并且进一步优化可明确处理重复读取是可能的。

多个核心

从问题是否有兴趣并行化这个问题并不清楚。 foo()的天真解决方案已经确实承认了一个直截了当的&#34;并行化,您只需将ab数组分区为相同大小的块,每个线程都可以,这似乎可以提供完美的加速。不幸的是,您可能会发现自己比线性扩展更糟糕,因为您将遇到内存控制器中的资源争用以及在套接字上的所有核心之间共享的关联的非核心/非核心资源。因此,当您添加更多核心 6 时,您无法清楚地获得对内存的纯并行随机读取负载的吞吐量。

对于基数排序版本,大多数瓶颈(存储吞吐量,总指令吞吐量)都在核心,所以我希望它能够通过额外的核心合理扩展。正如Peter在评论中提到的,如果你正在使用超线程,那么排序可能在核心本地L1和L2缓存中具有良好局部性的额外好处,有效地让每个兄弟线程使用整个缓存,而不是将有效容量减少一半。当然,这涉及仔细管理线程关联,以便兄弟线程实际使用附近的数据,而不仅仅是让调度程序做它做的任何事情。

1 你可能会问为什么LLC-load-misses不会说32或4800万,因为我们还必须阅读b1的所有1600万个元素然后accumulate()调用会读取所有result。答案是LLC-load-misses仅计算实际错过L3中的需求未命中。其他提到的读取模式是完全线性的,因此预取器将始终在需要之前将线路带入L3。这些并不算作&#34; LLC未命中&#34;通过定义perf使用。

2 你可能想知道我知道的负载是否来自a1foo的读取:我只是使用perf recordperf mem确认未命中来自预期的汇编指令。

3 几乎线性因为b1不是所有索引的排列,所以原则上可以跳过和重复索引。然而,在高速缓存行级别,很可能每个高速缓存行都按顺序读取,因为每个元素有大约63%的可能性被包含,而高速缓存行有16个4字节元素,所以&# 39;任何给定缓存中元素的概率只有千分之一。因此,在缓存行级别工作的预取工作正常。

4 这里我的意思是价值的计算是免费的或几乎是免费的,但当然写作仍然是成本。这仍然比前期实现更好[&34;但是,这种方法首先创建需要i写入的[0, 1, 2, ...]数组max_q,然后再次需要另外max_q次写入以在第一个基数排序过程中对其进行排序。隐式实现仅引发第二次写入。

5 实际上,实际定时部分foo()的IPC 更低:基于我的计算约为0.15。报告的整个过程的IPC是定时部分的IPC的平均值,并且之前和之后的初始化和累积代码具有更高的IPC。

6 值得注意的是,这与依赖负载延迟绑定工作流的扩展方式不同:负载正在进行随机读取但只能有一个负载正在进行,因为每个负载取决于结果由于负载的串行特性并未使用许多下游资源,因此最后扩展的规模非常好(因为负载的串行特性)(但是通过更改核心循环来处理多个依赖,这样的负载在概念上也可以加速甚至在单个核心上并行加载流。)

答案 1 :(得分:0)

您可以将索引划分为更高位索引相同的存储区。请注意,如果索引不是随机的,则桶会溢出。

fire_requests