【问题标题】:Optimizing a slow loop优化慢循环
【发布时间】:2016-04-27 14:56:44
【问题描述】:

代码看起来像这样,内部循环需要大量时间:

#define _table_derive                  ((double*)(Buffer_temp + offset))
#define Table_derive(m,nbol,pos)        _table_derive[(m) + 5*((pos) + _interval_derive_dIdQ * (nbol))]
char *Buffer_temp=malloc(...);

for (n_bol=0; n_bol<1400; n_bol++)  // long loop here
    [lots of code here, hundreds of lines with computations on doubles, other loops, etc]

    double ddI=0, ddQ=0;

    // This is the original code
    for(k=0; k< 100; k++ ) {
            ddI += Table_derive(2,n_bol,k);
            ddQ += Table_derive(3,n_bol,k);
    }
    ddI /= _interval_derive_dIdQ;
    ddQ /= _interval_derive_dIdQ;
    [more code here]
}

oprofile 告诉我大部分运行时间都花在了这里(第二列是时间百分比):

129304  7.6913 :for(k=0; k< 100; k++) {
275831 16.4070 :ddI += Table_derive(2,n_bol,k);
764965 45.5018 :ddQ += Table_derive(3,n_bol,k);

我的第一个问题是:我可以依靠 oprofile 来指示代码慢的正确位置吗(我在 -Og 和 -Ofast 中尝试过,它基本上是一样的)。

我的第二个问题是:为什么这个非常简单的循环比 sqrt、atan2 和之前的数百行计算慢?我知道我没有显示所有代码,但是代码很多,对我来说没有意义。

我尝试了各种优化器技巧来矢量化(不起作用)或展开(起作用),但收效甚微,例如:

    typedef double aligned_double __attribute__((aligned(8)));
    typedef const aligned_double* SSE_PTR;
    SSE_PTR TD=(SSE_PTR)&Table_derive(2,n_bol,0);   // We KNOW the alignement is correct because offset is multiple of 8

    for(k=0; k< 100; k++, TD+=5) {
        #pragma Loop_Optimize Unroll No_Vector
        ddI += TD[0];
        ddQ += TD[1];
    }

我检查了优化器的输出: “-Ofast -g -march=native -fopt-info-all=missed.info -funroll-loops” 在这种情况下,我得到“循环展开 9 次”,但如果我尝试矢量化,我得到(简而言之): “不能强制对齐参考”, “矢量对齐可能无法到达”, “矢量化未对齐的访问”, “访问的未知对齐方式:*(prephitmp_3784 + ((sizetype) _1328 + (long unsigned int) (n_bol_1173 * 500) * 2) * 4)”

有什么方法可以加快速度?

附录: 谢谢大家的cmet,我会在这里尝试回答:

  • 是的,我知道代码很丑(不是我的),而且您还没有看到真正的原始代码(这是一个巨大的简化)
  • 我被这个数组卡住了,因为 C 代码位于库中,而大型数组一旦被 C 处理和修改,就会传递给调用者(IDL、Python 或 C)。
  • 我知道使用一些结构而不是将 char* 转换为复杂的多维 double* 会更好,但请参见上文。在第一次编写这个 prog 时,结构可能不是 C 规范的一部分(开个玩笑……也许吧)
  • 我知道对于矢量化器来说,使用数组结构比使用结构数组更好,但是,叹息...见上文。
  • 有一个实际的外循环(在调用程序中),所以这个整体数组的总大小约为 2Gb
  • 按原样,在没有优化的情况下运行大约需要 15 分钟,并且在我重写了一些代码(更快的 atan2,数组内部的一些手动对齐...)并且我使用 -Ofast 和 -march=native
  • 由于硬件约束发生变化,我正努力加快速度以跟上数据流。
  • 我尝试使用 Clang,但收益很小(几秒钟),但我看不到获取优化报告的选项,例如 -fopt-info。我是否必须将程序集视为了解发生了什么的唯一选择?
  • 该系统是一个拥有 500Gb RAM 的 64 核,但我无法插入任何 OpenMP pragma 来并行化上述代码(我已经尝试过):它读取一个文件,将其完全解压缩到内存中(2Gb),按顺序分析它(诸如'+='之类的东西)并将一些结果吐出给调用 IDL/Python。全部在一个内核上(但其他内核非常忙于实际采集和后期处理)。 :(
  • 没用,感谢您的出色建议:删除 ddQ += ... 似乎将时间百分比转移到上一行:376280 39.4835:ddI+=...
  • 这给我们带来了更好的结果:删除两者(因此整个循环)保存...什么都没有!所以我想正如彼得所说,我不能相信探查器。如果我分析无环编,我会得到更均匀的时间分布(以前只有 1 秒以上的 3 行,现在大约 10 行,所有这些都像简单的变量分配一样无意义)。

我猜这个内循环从一开始就是一条红鲱鱼;我将使用手动计时重新开始优化。谢谢。

【问题讨论】:

  • 如果ddQ 行确实占用了您 45% 的时间,那么只需将其注释掉就可以大大加快速度。是吗?
  • 矢量化器的输出可能与宏_table_derive 定义中的offset 组件有关。如果此数组所需大小的上限足够小,则使用普通的本地数组(doubles)或本地 VLA 可能是有意义的。否则,如果您可以取出偏移量,使_table_deriveBuffer_temp 的开头开始,那么这也应该解决有关对齐的任何不确定性。
  • 总的来说,整个Buffer_temp/offset/casting 的东西都有不好的代码气味。事实上,大多数铸件本身就有不好的代码气味。让编译器尽可能容易地理解代码中发生的事情往往会提高编译器的优化能力。
  • for(k=0; k&lt; 100; k++ ) { ... } 可以重写为x = foo(); for(k=100; --k; ) { ddI +=_table_derive[x]; ddQ +=_table_derive[x+1]; x += _interval_derive_dIdQ} 或类似的东西。
  • _table_derived 的访问模式对缓存不是很友好?它是一张按需调入的巨型桌子吗?即使它全部在内存中,您也可能会遭受大量缓存未命中。省略的代码可能对缓存更友好,因此速度相对较快。整体代码真的很慢吗?还是只是优化器指向内部循环并说大部分时间都花在这里?

标签: c performance gcc optimization oprofile


【解决方案1】:

我的第一个问题是:我可以依靠 oprofile 来指示正确的 代码慢的地方

不准确。据我了解,周期通常由等待输入的指令(或其他一些执行资源)负责,而不是产生输入或释放任何其他执行资源的指令缓慢。

但是,在您的 oprofile 输出中,它很可能实际上是最后一个循环。这个外循环里面还有其他内循环吗?

您是否配置过缓存未命中?除了循环之外,还有许多有趣的东西的计数器。

还要注意,要真正了解性能,您需要查看 asm 上的配置文件注释,而不是 C。例如奇怪的是,一个 add 占的时间比另一个多,但这可能只是将 insn 映射到源代码行的问题。


re: 注释掉循环的性能结果:

所以如果没有那个内部循环,程序就不会运行得更快吗?如果外部循环已经触及该内存,也许您只是缓存未命中的瓶颈,而内部循环只是再次触及该内存?尝试perf record -e L1-dcache-load-misses ./a.out 然后perf report。或 oprofile 等效项。

也许内循环微指令被卡住等待发布,直到外循环中的慢东西退休。现代 Intel CPU 中的 ReOrder Buffer (ROB) 大小约为 200 uop,大多数 insn 解码为单个 uop,因此乱序窗口约为 200 条指令。

注释掉内循环也意味着外循环中任何循环携带的依赖链在内循环运行时都没有时间完成。移除该内循环可能会对外循环的瓶颈产生质的变化,从吞吐量到延迟。


re:-Ofast -march=native 的速度提高了 15 倍。好的,这很好。未优化的代码是可怕的,不应将其视为任何类型的“基线”或任何性能。如果您想与某物进行比较,请与-O2 进行比较(不包括自动矢量化、-ffast-math-march=native)。

尝试使用-fprofile-generate / -fprofile-use。 profile-use 包括-funroll-loops,所以我认为当有可用的分析数据时该选项最有效。

re:自动并行化:

您必须使用 OpenMP 编译指示或 gcc options(如 -floop-parallelize-all -ftree-parallelize-loops=4)专门启用它。如果存在非平凡的循环携带依赖项,则可能无法实现自动并行化。该 wiki 页面也很旧,可能无法反映自动并行化的最新技术。我认为 OpenMP 提示要并行化哪些循环是比让编译器猜测更明智的方法,尤其是。没有-fprofile-use


我尝试使用 Clang,但收益很小(几秒钟),但我没有看到获取优化报告的选项,例如 -fopt-info。我是否必须将程序集视为了解发生了什么的唯一选择?

clang manual says 您可以使用clang -Rpass=inline 进行内联报告。 The llvm docs say,向量化过程的名称是loop-vectorize,因此您可以使用-Rpass-missed=loop-vectorize,或-Rpass-analysis=loop-vectorize 告诉您哪个语句导致向量化失败。

查看 asm 是了解它是否自动矢量化严重的唯一方法,但要真正判断编译器的工作,你必须知道如何自己编写高效的 asm(所以你知道大约它可以做的事情。)请参阅http://agner.org/optimize/,以及标签wiki中的其他链接。

我没有尝试将您的代码放在 http://gcc.godbolt.org/ 上以尝试使用不同的编译器,但如果您的示例使 asm 代表您从完整源代码中看到的内容,您可以发布一个链接。


自动矢量化

for(k=0; k< 100; k++ ) {
        ddI += Table_derive(2,n_bol,k);
        ddQ += Table_derive(3,n_bol,k);
}

这应该自动矢量化,因为 2 和 3 是连续的元素。如果将表拆分为多个表,您将获得更好的缓存局部性(对于这部分)。例如将每组 5 个元素中的元素 2 和 3 保留在一个数组中。将一起使用的其他元素分组到表格中。 (如果有重叠,例如另一个循环需要元素 1 和 3,那么可能会拆分无法自动矢量化的那个?)

re:问题更新:您不需要 struct-of-arrays 即可使用 SSE 自动矢量化。一个 16B 的向量恰好包含两个 doubles,因此编译器可以将 [ ddI ddQ ] 的向量与 addsd 累加。但是,对于 AVX 256b 向量,它必须执行 vmovupd / vinsertf128 才能从相邻结构中获取那对 doubles,而不是单个 256b 负载,但这没什么大不了的。但是,内存局部性是一个问题。在您接触的缓存行中,每 5 个 doubles 中您只使用了 2 个。


即使没有-ffast-math,它也可能会自动矢量化,只要您的目标是具有双精度矢量的 CPU。 (例如 x86-64 或 32 位 -msse2)。

gcc 喜欢为可能未对齐的数据做大的序言,使用标量直到它到达对齐的地址。这会导致臃肿的代码,尤其是。带有 256b 个向量和小元素。不过,double 应该不会太糟糕。不过,试试 clang 3.7 或 clang 3.8。 clang 使用未对齐的负载自动矢量化可能未对齐的访问,当数据在运行时对齐时,这没有额外的成本。 (gcc 针对数据未对齐的极少数情况进行了优化,因为未对齐的加载/存储指令在旧 CPU(例如 Intel pre-Nehalem)上速度较慢,即使在用于对齐数据时也是如此。)


如果您的 char 数组不能证明每个 double 甚至是 8B 对齐的,它可能会击败自动矢量化器。就像@JohnBollinger 评论的那样,这真的很难看。如果你有一个包含 5 个双精度的结构数组,那么就这样声明吧!

如何将其写为结构数组:

保留“手动”多维索引,但将基本一维数组设为double 或更好的struct 类型的数组,因此编译器将假定每个double 都是8B 对齐的。

您的原始版本还引用了全局 Buffer_temp 来访问数组。 (或者它是本地的?)任何可能为其别名的存储都需要重新加载基指针。 (C 的别名规则允许 char* 对任何东西进行别名,但我认为您在取消引用之前转换为 double* 可以避免这种情况。无论如何您都不会存储到内部循环内的数组中,但我假设您在外部数组。)

typedef struct table_derive_entry {
    double a,b,c,d,e;
} derive_t;

void foo(void)
{
    // I wasn't clear on whether table is static/global, or per-call scratch space.
    derive_t *table = aligned_alloc(foo*bar*sizeof(derive_t), 64);            // or just malloc, or C99 variable size array.

    // table += offset/sizeof(table[0]);   // if table is global and offset is fixed within one call...

// maybe make offset a macro arg, too?
#define Table_derive(nbol, pos)     table[offset/sizeof(derive_t) + (pos) + _interval_derive_dIdQ / sizeof(derive_t) * (nbol))]


    // ...        
    for(k=0; k< 100; k++ ) {
         ddI += Table_derive(n_bol, k).b;
         ddQ += Table_derive(n_bol, k).c;
    }
    // ...
}
#undef Table_derive

如果_interval_derive_dIdQoffset 并不总是5 * 8B 的倍数,那么您可能需要声明double *table = ...; 并将您的Table_derive 修改为类似

#define Table_derive(nbol, pos)   ( ((derive_t *)(double_table + offset/sizeof(double) + _interval_derive_dIdQ / sizeof(double) * (nbol)))[pos] )

FP划分:

ddI /= _interval_derive_dIdQ;
ddQ /= _interval_derive_dIdQ;

你能把double inv_interval_derive_dIdQ = 1.0 / _interval_derive_dIdQ; 提升到循环之外吗?乘法比除法便宜得多,尤其是。如果延迟很重要,或者 sqrt 也需要 div 单位。

【讨论】:

  • 谢谢。请参阅我对原始帖子的补充。
  • @dargaud:更新了我的答案,举了一个例子,说明如何用结构数组和其他一些更新来编写它。
  • 哇,感谢您提供的所有详细信息(尤其是关于 Clang 报告的信息)。我至少需要一个月的时间才能再次进行这项工作,但届时会发布结果。
猜你喜欢
  • 2020-10-22
  • 1970-01-01
  • 1970-01-01
  • 2017-07-20
  • 1970-01-01
相关资源
最近更新 更多