For循环效率:合并循环

问题描述 投票:7回答:7

我一直认为减少迭代次数是提高程序效率的方法。由于我从未真正确认过,我开始测试这个。

我制作了以下C ++程序来测量两个不同函数的时间:

  • 第一个函数执行单个大循环并使用一组变量。
  • 第二个函数执行多个同样大的循环,但每个变量只有一个循环。

完整的测试代码:

    #include <iostream>
    #include <chrono>

    using namespace std;

    int* list1; int* list2;
    int* list3; int* list4;
    int* list5; int* list6;
    int* list7; int* list8;
    int* list9; int* list10;

    const int n = 1e7;

    // **************************************
    void myFunc1()
    {
        for (int i = 0; i < n; i++)
        {
            list1[i] = 2;
            list2[i] = 4;
            list3[i] = 8;
            list4[i] = 16;
            list5[i] = 32;
            list6[i] = 64;
            list7[i] = 128;
            list8[i] = 256;
            list9[i] = 512;
            list10[i] = 1024;
        }

        return;
    }

    // **************************************
    void myFunc2()
    {

        for (int i = 0; i < n; i++)
        {
            list1[i] = 2;
        }
        for (int i = 0; i < n; i++)
        {
            list2[i] = 4;
        }
        for (int i = 0; i < n; i++)
        {
            list3[i] = 8;
        }
        for (int i = 0; i < n; i++)
        {
            list4[i] = 16;
        }
        for (int i = 0; i < n; i++)
        {
            list5[i] = 32;
        }
        for (int i = 0; i < n; i++)
        {
            list6[i] = 64;
        }
        for (int i = 0; i < n; i++)
        {
            list7[i] = 128;
        }
        for (int i = 0; i < n; i++)
        {
            list8[i] = 256;
        }

        for (int i = 0; i < n; i++)
        {
            list9[i] = 512;
        }
        for (int i = 0; i < n; i++)
        {
            list10[i] = 1024;
        }

        return;
    }


    // **************************************
    int main()
    {
        list1 = new int[n]; list2 = new int[n];
        list3 = new int[n]; list4 = new int[n];
        list5 = new int[n]; list6 = new int[n];
        list7 = new int[n]; list8 = new int[n];
        list9 = new int[n]; list10 = new int[n];

        auto start = chrono::high_resolution_clock::now();

        myFunc1();

        auto elapsed = chrono::high_resolution_clock::now() - start;

        long long microseconds = chrono::duration_cast<chrono::microseconds>(elapsed).count();

        cout << "Time taken by func1 (micro s):" << microseconds << endl << endl;

        //

        start = chrono::high_resolution_clock::now();

        myFunc2();

        elapsed = chrono::high_resolution_clock::now() - start;

        microseconds = chrono::duration_cast<chrono::microseconds>(elapsed).count();

        cout << "Time taken by func2 (micro s):" << microseconds << endl << endl;

        delete[] list1; delete[] list2; delete[] list3; delete[] list4;
        delete[] list5; delete[] list6; delete[] list7; delete[] list8;
        delete[] list9; delete[] list10;

        return 0;
    }

现在我有了相互矛盾的假设:一方面,两个函数的操作量都相同,只是设置了一些变量。虽然另一方面,第二个功能经历了10倍以上的循环,因此(也许)也需要10倍的时间。

结果令人惊讶。在我的电脑上,func1()需要大约280毫秒而func2()需要大约180毫秒,第一个功能实际上是慢而不是更快。

现在的问题是:我的测试是否正确?合并for循环以最小化迭代总数甚至有用吗?人们有不同的经历吗?

编辑:我编译了所有禁用的优化,用于测试。编辑:更改函数调用的顺序给出相同的结果。此外,测量的时间变化非常小,因此我没有费心去取平均值。

编辑:我用-O3优化再次尝试了所有这一切。虽然确切的测量值当然不同,但结果确实保持不变。

c++ performance loops benchmarking
7个回答
6
投票

这里有三件重要的事情:

1)没有优化的基准测试是没有意义的。事实证明,在这种情况下有一个真正的效果,它不会随着优化而消失。实际上,反优化的调试构建在存储器中存储循环计数器的额外成本下隐藏了很多差异(将循环限制为每6个时钟1个而不是每个时钟1个),而不是自动向量化存储循环。

如果你还不知道为什么存在速度差异的asm + CPU微体系结构细节,那么在禁用优化的情况下测量它是不安全或有用的。


2)缓存冲突未命中(如果阵列相对于页面边界全部对齐)。相对于彼此倾斜阵列可以帮助很多。这可以自然地发生,取决于它们的分配方式,即使它们的大小不是2的大功率。

这些数组都很大,并且与new分开分配,所以它们可能都是页面对齐的(或者在对象之前放置一些信息(如大小)的实现中从页边界偏移16B)。在Linux上,glibc malloc / new通常通过使用mmap()从操作系统分配新页面来处理大量分配(并使用前16个字节用于该块的簿记),而不是移动brk()

4k别名意味着它们都在典型的L1d缓存中转到同一组,这在典型的x86 CPU上是8路关联的。 Why is the size of L1 cache smaller than that of the L2 cache in most of the processors?解释了为什么64套* 64B / line = 4096B页面大小(8-way = 32kiB)并不是巧合,因为这使得VIPT L1d缓存像没有同音词/同义词问题的PIPT一样工作。另见Which cache mapping technique is used in intel core i7 processor?

第9家商店将从第一家商店逐出商店的高速缓存行,因此每家商店的商店将被驱逐一次,而不是像连续商店那样完全写入。 (除非编译器在继续运行之前自动向量化并将整个缓存行充满存储到一个数组。)x86的强排序内存模型需要按程序顺序将存储缓冲区中的存储提交到L1d,因此它无法合并在提交之前将同一行中的非相邻存储转换为一个条目,或者如果一条行在不连续时进入,则提交多个未完成的存储。

(替换策略是伪LRU,而不是真正的LRU,因此有时您可能会发现在同一组中8或9次驱逐后线路仍然很热。)

提醒:上述内容仅适用于所有数组相对于页面具有相同的对齐方式。为其中一个指针过度分配和执行ptr = 128 + malloc(128 + size)可能会使其相对于其他指针偏斜,这有时值得做。

你说你有一台PC,所以我猜一个Intel CPU。 (Ryzen的L1d具有相同的几何形状,但Bulldozer系列没有。)


Intel's optimization manual section 3.6.10 Write Combining建议循环裂变用于写入超过4个输出流的循环这个建议在关于NT存储和WC存储器的部分中;它可能只适用于那种情况。无论哪种方式4都不是适用于现代英特尔的正确数字,除非你保守对其他超线程的考虑。

(英特尔)汇编/编译器编码规则58.(H影响,L一般性)如果内部循环写入超过四个数组(四个不同的缓存行),则应用循环分裂来分解循环体,使得只有四个数组正在写入每个结果循环的每次迭代。

TL:DR:对于NT商店(缓存旁路),Skylake和更新版本上最多12个输出流似乎没问题,或者Broadwell / Haswell和更老版本中的10个输出流似乎没问题。 (如果您同时读取任何内存,则更少)。这是那些CPU上的LFB(线路填充缓冲器)的数量。早期的CPU(在Nehalem之前)少于10个,并且可能无法将所有CPU用于NT商店。 (Where is the Write-Combining Buffer located? x86)LFB用于所有到/来自L1d的线路传输,因此例如挂起的加载未命中需要分配的LFB从L2等待该行。

(对于超线程,请记住,其他超线程在同一物理内核上竞争LFB,因此除非您可以禁用HT,否则不要依赖于使用所有12个LFB。)

但你不是在做NT商店。

conventional wisdom是这个4输出效率限制适用于正常(非NT)存储到WB存储器,但现代英特尔的情况并非如此。巧合的是,正常(WB =回写)商店的性能下降与NT商店的输出流数量大致相同。这篇机械同情文章对这个原因进行了一些猜测,但我们很确定它们听起来不对。

有关一些微基准测试,请参阅https://github.com/Kobzol/hardware-effects/issues/1。 (看看我自己,BeeOnRope和Hadi Brais之间关于LFB的讨论,这个4输出指南出现了:https://chat.stackoverflow.com/transcript/message/45474939#45474939之前在Size of store buffers on Intel hardware? What exactly is a store buffer?的评论中

@BeeOnRope还在Skylake上发布了a bar graph for regular (non-NT) stores interleaved to 1 to 15 output streams。对Skylake的任何数量的流量来说,性能在某种程度上是恒定的,然后在7和8时开始变得更糟(如果阵列全部以相同的方式对齐,可能来自L1d冲突未命中),并且从9及以上更加显着直到接近13到15的高原。(在1到6流的良好情况下的性能的约1/3)。

再次,对于超线程,如果它正在运行,那么另一个逻辑核心几乎肯定会产生一些内存流量,所以像4输出流这样的保守限制并不是一个糟糕的计划。但是性能不会在7或8时从悬崖上掉下来,所以如果花费更多的总工作,不一定会破坏你的循环。


另请参阅Enhanced REP MOVSB for memcpy,了解有关常规RFO存储与非RFO NT存储以及大量x86内存带宽问题的更多信息。 (特别是内存/ L3缓存延迟限制了大多数CPU的单核带宽,但在多核Xeon上却更糟糕:它们令人惊讶地具有比四核桌面更低的单核内存带宽。有足够的内核忙,你可以饱和它们来自四通道或6通道内存控制器的高聚合带宽;这是他们优化的情况。)

2.5)DRAM页面局部性:当数据最终从L3(最后一级缓存)中逐出时,发生回写到内存。脏缓存行被发送到内存控制器,内存控制器可以缓冲并批量分组,但仍然会有所有10个阵列的混合存储(和RFO加载)。双通道存储器控制器不能同时打开10个DRAM页面。 (我认为每个频道只有1个,但我不是DRAM时序的专家。请参阅Ulrich Drepper的What Every Programmer Should Know About Memory,它确实有一些细节。)https://pubweb.eng.utah.edu/~cs6810/pres/12-6810-15c.pdf提到DRAM开放/关闭页面政策,用于流式传输和分散存储。

这里的底线是,即使缓存可以处理许多输出流,DRAM可能更少,更快乐。注意,DRAM“页面”与虚拟存储器页面(4k)或巨大页面(2M)的大小不同。

说到虚拟内存,TLB应该没有10个输出流:现代x86 CPU有超过10个L1dTLB条目。希望它们足够关联,或者条目不是所有别名,所以我们不会在每个商店都获得TLB错过!


3)编译时别名分析

@RichardHodges发现了这一个)

你的大组合循环不能用gcc或clang自动矢量化。他们不能证明list1[10]也不是list4[9]或其他东西,所以他们不能用一个16字节的商店存储list1[8..11]

但是单阵列循环可以使用SSE或AVX轻松自动矢量化。 (令人惊讶的是没有使用wmemset调用或其他东西,仅使用gcc -O3clang -O2的常规自动矢量化器。这可能会转换为大型的NT存储,如果多个内核竞争内存带宽,这将有助于大多数。即使没有自动矢量化,识别也是有用的。)

这里需要的唯一别名分析是证明list1[i] = 2不会修改list1指针值本身(因为该函数读取循环内的全局,而不是将值复制到本地)。基于类型的别名分析(默认情况下-fstrict-aliasing处于启用状态)允许编译器证明,和/或如果list1指向自身,则在以后的循环迭代中访问对象外部将存在未定义的行为。

当你无法使用__restrict关键字(由C的限制中的几个编译器借用)时,智能编译器可以并且在某些情况下(例如输出数组对输入数组)自动向量化之前检查重叠。如果存在重叠,则它们会回退到安全的标量循环。

但是在这种情况下不会发生这种情况:gcc和clang根本不生成矢量化循环,它们只是在myFunc1中执行标量。如果每个商店在L1d中导致冲突错过,这使得这个4x比你给编译器足够的信息来完成它的工作更糟糕。 (对于32字节存储,AVX为8x)。通常,当主内存带宽是瓶颈(不是L1d缓存)时,16B与32B存储之间的差异很小,但这里它可能很大,因为如果它们都是别名,则10个输出流会破坏L1d的写入组合效果。

顺便说一句,使全局变量static int *__restrict line1等允许gcc自动矢量化myFunc1中的商店。但它并没有裂变循环。 (这是允许的,但我想它并不是在寻找优化。这取决于程序员这样做。)

// global modifier allows auto-vec of myFunc1
#define GLOBAL_MODIFIER  __restrict
#define LOCAL_MODIFIER  __restrict  // inside myFunc1

static int *GLOBAL_MODIFIER list1, *GLOBAL_MODIFIER list2,
       *GLOBAL_MODIFIER list3, *GLOBAL_MODIFIER list4,
       *GLOBAL_MODIFIER list5, *GLOBAL_MODIFIER list6,
       *GLOBAL_MODIFIER list7, *GLOBAL_MODIFIER list8,
       *GLOBAL_MODIFIER list9, *GLOBAL_MODIFIER list10;

我把你的代码on the Godbolt compiler explorer with gcc8.1 and clang6.0,用这个改变+一个从一个数组中读取的函数来阻止它们完全优化掉(这是因为我把它们变成了static。)

然后我们得到这个内部循环,它应该比标量循环运行速度快4倍。

.L12:    # myFunc1 inner loop from gcc8.1 -O3  with __restrict pointers
    movups  XMMWORD PTR [rbp+0+rax], xmm9       # MEM[base: l1_16, index: ivtmp.87_52, offset: 0B], tmp108
    movups  XMMWORD PTR [rbx+rax], xmm8 # MEM[base: l2_17, index: ivtmp.87_52, offset: 0B], tmp109
    movups  XMMWORD PTR [r11+rax], xmm7 # MEM[base: l3_18, index: ivtmp.87_52, offset: 0B], tmp110
    movups  XMMWORD PTR [r10+rax], xmm6 # MEM[base: l4_19, index: ivtmp.87_52, offset: 0B], tmp111
    movups  XMMWORD PTR [r9+rax], xmm5  # MEM[base: l5_20, index: ivtmp.87_52, offset: 0B], tmp112
    movups  XMMWORD PTR [r8+rax], xmm4  # MEM[base: l6_21, index: ivtmp.87_52, offset: 0B], tmp113
    movups  XMMWORD PTR [rdi+rax], xmm3 # MEM[base: l7_22, index: ivtmp.87_52, offset: 0B], tmp114
    movups  XMMWORD PTR [rsi+rax], xmm2 # MEM[base: l8_23, index: ivtmp.87_52, offset: 0B], tmp115
    movups  XMMWORD PTR [rcx+rax], xmm1 # MEM[base: l9_24, index: ivtmp.87_52, offset: 0B], tmp116
    movups  XMMWORD PTR [rdx+rax], xmm0 # MEM[base: l10_25, index: ivtmp.87_52, offset: 0B], tmp117
    add     rax, 16   # ivtmp.87,
    cmp     rax, 40000000     # ivtmp.87,
    jne     .L12      #,

(当然,这是针对x86-64的编译.x86 32位没有足够的寄存器来保存regs中的所有指针,所以你有一些负载。但是那些会在L1d缓存中命中而不是实际上是大多数吞吐量瓶颈:在每个时钟1个存储瓶颈的情况下,在这种情况下,只需存储常量就可以获得更多的工作量。)

这种优化就像展开循环4x并重新排列到每个阵列的第4组存储一样。这就是为什么如果编译器不知道它们是非重叠的,就无法完成。不幸的是,即使使用__restrict,clang也不会这样做。正常使用__restrict来保证不重叠是在函数args上,而不是本地或全局,但我没有尝试。

使用全局数组而不是全局指针,编译器会知道它们没有重叠(并且在任何地方都没有存储在内存中的指针值;数组地址将是链接时常量。)在您的版本中,数组本身有动态存储,它只是指向它们的指针,具有静态存储。


Interleaved full-cache-line stores:

如果myFunc1在移动到下一个数组之前将64个字节存储到一个数组怎么办?然后,您的编译器可以安全地将其编译为每次迭代每个阵列4(SSE),2(AVX)或1(AVX512)向量存储,覆盖整个64字节。

如果您将指针对齐64(或者如果编译器进行了一些别名分析并且到达每个输出数组中的第一个64字节边界),那么每个存储块都会完全写入一个缓存行,我们不会触及它稍后再试。

这样可以避免L1d冲突失误,对吗?好吧,也许,但除非你使用NT存储来避免RFO,硬件预取程序需要在存储器尝试提交之前将线路拉入L2然后进入L1d。所以它并不像你想象的那么简单,但是将存储结合到尚未到达的缓存行的写入组合缓冲区可以提供帮助。

Intel CPU中的L2流式预取器每页可以跟踪1个前向和1个后向访问,因此它应该没问题(如果阵列在L2中不是别名)。这是L1d预取,这是一个大问题。

它仍然会大大减少缓存到L2 /从L2跳出的缓存行数量。如果你有一个不能轻易分裂成多个循环的循环,至少要展开它,这样你就可以在继续之前写一个完整的缓存行

AVX512可能会有所作为; IDK如果Skylake-AVX512上对齐的vmovdqa64 [mem], zmm0可能会在将缓存行转换为MESI Modified状态时跳过加载旧值,因为它知道它覆盖了整个缓存行。 (如果没有合并屏蔽)。

即使用AVX512,gcc8.1也无需对齐输出指针;一个可能重叠的第一个和最后一个向量可能是一个很好的策略,对于像这样的简单情况,两次写入相同的内存不是问题。 (对齐对AVX512的影响比对Skylake硬件的AVX2有很大影响。)


4)Unexpectedly poor and weirdly bimodal performance for store loop on Intel Skylake表明,对于L1d / L2带宽,与存储流交错虚拟写入(到相同位置)可使其比1个连续流更差。

可能是因为在提交到L1d缓存之前在存储缓冲区中发生了存储合并/合并。但仅适用于相邻缓存行的相邻存储(因为x86的强排序内存模型不能允许存储无序提交到L1d)。

该测试不会遇到缓存冲突问题。但是,连续写一个完整的缓存行也应该有帮助。


5
投票

如果我不得不冒险猜测我会说你看到的是第一个函数中更频繁的内存缓存未命中的结果。

myFunc1()实质上是以随机访问方式执行10e8内存写入。

myFunc2()正在执行10个7e7字的10倍顺序存储器写入。

在现代内存架构上,我希望第二种更高效。


3
投票

你从一个循环中获得的东西是你失去了循环变量的增量。因此,在这样的情况下,循环的内容是如此微不足道,那么赋值(和测试)会产生很大的不同。

你的例子也没有考虑到;是连续的内存访问通常比随机访问更快。

在一个循环需要更长时间的函数中(尝试进入睡眠而不是赋值),你会发现差异对它的影响并不大。

获得性能改进的方法是从数学开始 - 正确的算法将始终购买最大的改进。理想情况下,这是在手指敲击键盘之前完成的。


3
投票

在尝试对代码进行基准测试时,您需要:

  1. 编译启用优化标志。
  2. 多次运行每个测试,以收集平均值。

你没有做到这两点。你可以使用-O3作为例子,至于平均值,我做了这个(我让函数从列表中返回一个元素):

for(int i = 0; i < 100; ++i)        
    dummy = myFunc1();

然后,我得到了这样的输出:

Time taken by func1 (micro s):206693

Time taken by func2 (micro s):37898

这证实了你所看到的,但差异是一个数量级(这是一个非常大的交易)。


在单个for循环中,您执行一次内务处理,并且循环的计数器递增一次。在几个for循环中,这是扩展的(你需要像你所拥有的for循环一样多次)。当循环的主体有点小事时,就像你的情况一样,那么它可以有所作为。


另一个问题是数据局部性。第二个函数有一个循环,一次填充一个列表(意味着将以连续的方式访问内存)。在第一个函数的大循环中,你将一次填充列表中的一个元素,归结为内存的随机访问(因为例如当list1被带入缓存时,因为你填充了它的一个元素,然后在你的代码的下一行,你将请求list2,这意味着list1现在没用了。但是,在第二个函数中,一旦你将list1放入缓存中,你将继续在缓存中使用它(而不是必须获取它)从内存),这导致主要的加速)。


我相信这个事实胜过另一个(大循环VS几个小循环)。因此,您实际上并不是基准测试您想要的内容,而是随机内存访问VS连续内存访问。


3
投票

你的假设基本上是有缺陷的:

  1. 循环迭代不会产生显着的成本。 这就是CPU优化的原因:紧密循环。 CPU优化可以使用专用电路用于循环计数器(例如PPC bdnz指令),以便循环计数器开销正好为零。 X86确实需要一个或两个CPU周期,但就是这样。
  2. 什么杀死你的表现通常是内存访问。 从L1缓存中获取值已经需要三到四个CPU周期的延迟。来自L1缓存的单个负载比循环控制具有更多延迟!更高级别的缓存。 RAM访问需要永远。

因此,为了获得良好的性能,通常需要减少访问内存所花费的时间。这可以通过以下方式完成

  • 避免内存访问。 最有效,最容易被遗忘的优化。你不支付你不做的事情。
  • 并行化内存访问。 避免加载某些值并使下一个所需值的地址取决于此。这种优化很棘手,因为它需要清楚地了解不同内存访问之间的依赖关系。 这种优化可能需要一些循环融合或循环展开来利用不同循环体/迭代之间的独立性。在您的情况下,循环迭代彼此独立,因此它们已经尽可能并行。 此外,正如MSalters在评论中正确指出的那样:CPU具有有限数量的寄存器。有多少取决于架构,32位X86 CPU仅有8个。因此,它根本无法同时处理十个不同的指针。它需要在堆栈上存储一些指针,引入更多的内存访问。显然,这违反了上述关于避免内存访问的观点。
  • 对存储器访问进行顺序化。 CPU的构建基于绝大多数内存访问是顺序的知识,并且它们针对此进行了优化。当您开始访问数组时,CPU通常会很快注意到,并开始预取后续值。

最后一点是你的第一个函数失败的地方:你在10个完全不同的内存位置访问10个不同的数组之间来回跳跃。这降低了CPU推断它应该从主内存中预取的缓存行的能力,从而降低了整体性能。


2
投票

此代码创建变量:

    list1 = new int[n]; list2 = new int[n];
    list3 = new int[n]; list4 = new int[n];
    list5 = new int[n]; list6 = new int[n];
    list7 = new int[n]; list8 = new int[n];
    list9 = new int[n]; list10 = new int[n];

但在内存实际修改之前,它几乎肯定不会创建实际的物理页面映射。有关示例,请参阅Does malloc lazily create the backing pages for an allocation on Linux (and other platforms)?

所以你的func1()必须等待创建RAM的实际物理页面,而你的func2()则不然。更改顺序,映射时间将归因于func2()性能。

给定代码发布的最简单的解决方案是在执行定时运行之前运行func1()func2()

如果在进行任何基准测试之前未确保已映射实际物理内存,则该映射将是您首次修改内存时测量的时间的一部分。


0
投票

我相信它比那更复杂。单个循环是否比多个循环更快取决于几个因素。

程序迭代一组数据的事实会花费你的东西(递增迭代器或索引;将迭代器/索引与某个值进行比较,让你知道循环已经完成)所以如果你把一个循环分成几个较小的循环循环,你需要支付更多的费用,以便多次迭代同一组数据。

另一方面,如果循环较小,则优化器具有更容易的工作,并且有更多方法来优化代码。 CPU还具有使循环运行更快的可能性,并且通常在小循环时效果最佳。

在将循环划分为较小的循环之后,我已经有了一些代码变得更快。我还编写了算法,当我将一些循环合并到一个循环中时,这些算法表现得更好。

通常有很多因素,很难预测哪一个占主导地位,所以答案是你应该总是测量并检查几个版本的代码,以找出哪一个更快。

© www.soinside.com 2019 - 2024. All rights reserved.