强制GCC执行memcpy运行时大小检查的循环展开?

28

有没有可靠的方法可以强制GCC(或任何编译器)在循环之外对memcpy()中的运行时大小检查进行分解(其中该大小不是编译时常量,但在该循环内是常量),为每个相关大小范围专门化循环,而不是在其中重复检查大小?

这是从一个用于高效内存分析大型数据集的开源库中报告的性能回归缩小的测试案例(在这里)(此回归恰好是由我的提交之一引起的...)

原始代码是Cython,但我已将其缩减为以下纯C代理。

void take(double * out, double * in,
          int stride_out_0, int stride_out_1,
          int stride_in_0, int stride_in_1,
          int * indexer, int n, int k)
{
    int i, idx, j, k_local;
    k_local = k; /* prevent aliasing */
    for(i = 0; i < n; ++i) {
        idx = indexer[i];
        for(j = 0; j < k_local; ++j)
            out[i * stride_out_0 + j * stride_out_1] =
            in[idx * stride_in_0 + j * stride_in_1];
    }
}

步长是可变的;一般来说,数组甚至不能保证是连续的(因为它们可能是更大数组的非连续切片)。然而,对于C连续的数组这个特定情况,我已经将上面的代码优化为以下形式:

void take(double * out, double * in,
          int stride_out_0, int stride_out_1,
          int stride_in_0, int stride_in_1,
          int * indexer, int n, int k)
{
    int i, idx, k_local;
    assert(stride_out_0 == k);
    assert(stride_out_0 == stride_in_0);
    assert(stride_out_1 == 1);
    assert(stride_out_1 == stride_in_1);
    k_local = k; /* prevent aliasing */
    for(i = 0; i < n; ++i) {
        idx = indexer[i];
        memcpy(&out[i * k_local], &in[idx * k_local],
               k_local * sizeof(double));
    }
}

(原始代码中不存在assertion; 取而代之的是检查连续性并在可能的情况下调用优化版本,否则调用未优化版本。)

这个版本在大多数情况下都能很好地进行优化,因为正常使用情况是小的n和大的k。然而,相反的使用情况也会发生(大的n和小的k),并且对于n == 10000k == 4的特定情况(不能排除是虚拟工作流的重要部分的代表),memcpy()版本比原始版本慢3.6倍。显然,这主要是由于k不是编译时常量造成的,这一事实可以通过下一个版本的表现得到证明(在特定的k == 4的情况下,根据优化设置的不同,该版本表现得几乎或完全与原始版本一样好,有时甚至更好。)

    if (k_local == 4) {
        /* this optimizes */
        for(i = 0; i < n; ++i) {
            idx = indexer[i];
            memcpy(&out[i * k_local], &in[idx * k_local],
                   k_local * sizeof(double));
        }
    } else {
        for(i = 0; i < n; ++i) {
            idx = indexer[i];
            memcpy(&out[i * k_local], &in[idx * k_local],
                   k_local * sizeof(double));
        }
    }

显然,为每个特定值 k 编写一个循环是不切实际的,因此我尝试了以下方法(作为第一次尝试,如果成功,则可以进一步推广):

    if (k_local >= 0 && k_local <= 4) {
        /* this does not not optimize */
        for(i = 0; i < n; ++i) {
            idx = indexer[i];
            memcpy(&out[i * k_local], &in[idx * k_local],
                   k_local * sizeof(double));
        }
    } else {
        for(i = 0; i < n; ++i) {
            idx = indexer[i];
            memcpy(&out[i * k_local], &in[idx * k_local],
                   k_local * sizeof(double));
        }
    }

遗憾的是,这个最新版本比起原始的memcpy()版本并没有更快,这让我对GCC的优化能力失去信心。

是否有任何方法可以通过任何手段给GCC提供额外的“提示”,帮助它在这里做正确的事情?(更好的是,是否有“提示”可以可靠地适用于不同的编译器?此库已编译为多个不同的目标。)

引用的结果是针对32位Ubuntu上使用“-O2”标志的GCC 4.6.3,但我还测试了GCC 4.7.2和“-O3”版本,并获得了类似(但不完全相同)的结果。我在LiveWorkspace上发布了我的测试工具,但时间是从我的自己的机器使用time(1)命令计算出来的(我不知道LiveWorkspace的时间计算有多可靠)。

编辑:我还考虑过只设置某些最小大小的“魔法数字”来调用memcpy(),并且可以通过反复测试找到这样的值,但我不确定我的结果在不同的编译器/平台上是否具有普适性。我可以在这里使用什么经验法则吗?

进一步编辑:实际上,在这种情况下,k_local变量有点无用,因为不存在别名;这是我运行某些实验后缩小的范围(k是全局的),我忘记我更改了它。只需忽略该部分。

编辑标签:我意识到在较新版本的Cython中也可以使用C ++,因此标记为C ++,以防有任何可以从C ++中获得帮助的内容...

最终编辑:除了降级到汇编语言进行专门的memcpy()之外,在我的本地计算机上,以下似乎是最好的经验解决方案:

    int i, idx, j;
    double * subout, * subin;
    assert(stride_out_1 == 1);
    assert(stride_out_1 == stride_in_1);
    if (k < 32 /* i.e. 256 bytes: magic! */) {
        for(i = 0; i < n; ++i) {
            idx = indexer[i];
            subout = &out[i * stride_out_0];
            subin = &in[idx * stride_in_0];
            for(j = 0; j < k; ++j)
                subout[j] = subin[j];
        }
    } else {
        for(i = 0; i < n; ++i) {
            idx = indexer[i];
            subout = &out[i * stride_out_0];
            subin = &in[idx * stride_in_0];
            memcpy(subout, subin, k * sizeof(double));
        }
    }

这里使用了一个“魔数”来判断是否调用memcpy(),但仍然针对已知为连续的小数组优化(因此在大多数情况下比原始版本更快,因为原始版本并没有做出这样的假设)。


1
我认为你在这里描述的内存布局是一种病态情况,必然会产生大量的缓存和TLB失效。你能测量一下吗? - Michael Foukarakis
1
我使用PAPI进行此类测量。至于优化memcpy,我认为您应该查看您的libc源代码。 - Michael Foukarakis
这种东西可能更适合作为注释,顺便说一下,但谢谢...我会尝试的。 - Stephen Lin
也许,原始的Cython是使用有符号的方式,这就是为什么我写成那样的原因,但我会检查一下。 - Stephen Lin
1
你可以尝试使用反馈指导优化,也许gcc会利用这些信息做一些聪明的事情?否则,我认为魔法截止是可行的方法。 - Mackie Messer
显示剩余32条评论
3个回答

7
最终,问题在于要求优化器根据多个变量的运行时行为进行假设。虽然可以通过在关键变量上使用“const”和“register”声明来为编译器提供一些编译时提示,但最终,您仍然依赖于优化器做出许多假设。此外,尽管memcpy()可能是内部函数,但并不保证它会是,即使/当它是,实现也可能相差很大。
如果目标是实现最佳性能,有时您就必须不依赖技术来解决问题,而是直接做。对于这种情况,最好的建议是使用内联汇编来解决问题。这样可以避免由编译器和优化器启发式方法导致的“黑盒子”解决方案的所有陷阱,并明确说明您的意图。内联汇编的关键好处是可以避免在内存复制问题的解决方案中出现任何推入/弹出和多余的“通用化”代码,并且可以直接利用处理器解决该问题的能力。缺点是维护,但考虑到您只需要处理英特尔和AMD即可涵盖大部分市场,这并非难以克服。
我还可以补充说,这种解决方案可能允许您利用多个核心/线程和/或GPU(如果有)并行进行复制,从而真正获得性能提升。虽然延迟可能会更高,但吞吐量很可能也会大大提高。例如,如果您可以利用GPU,当存在时,您可以在每次复制时启动一个内核并在单个操作中复制数千个元素。
与此相反的是,依赖编译器/优化器为您做出最佳猜测,使用“const”和“register”声明来为编译器提供提示,并使用特定的数字根据“最佳解决方案”路径进行分支...然而,这将非常依赖于编译器/系统,因此在不同的平台/环境下结果会大不相同。

5
回答并非用于讨论,而是用来回答所提出的问题。在这种情况下,如果未来的谷歌用户遇到了这篇帖子,他将不得不浏览多少“讨论”才能得到答案?Stack Overflow 的设置使得这个数字非常低。请编辑掉讨论性的部分,并将答案凝练成真正回答所提出的问题的内容。 - George Stocker
@KScottPiel,在George的编辑之后,这个表述有些误导(可以理解,因为George不是讨论的一部分)...如果你能编辑并澄清constregister和其他提示优化器的内容在这种情况下实际上并不起作用(不幸的是),我会接受它;否则(如果没有其他答案出现),我将不得不自己写一个更清晰的答案。 - Stephen Lin
2
顺便说一句,使用GPU会使问题变得更慢。将RAM复制--PCIe--> GPU --PCIe--> RAM比将RAM --> RAM复制要慢得多。CPU对CPU的RAM的访问速度比GPU快。 - Mr Fooz
如果你处理的是大型数组,那就不正确。如果你要循环遍历大量小的副本,那么每个副本都会有很多额外开销。举个例子...假设你需要复制2000个元素,并且在循环中每次复制需要{x}时间...那么整个数组的复制时间就是2000{x}。相反地,你可以将数组从CPU复制到GPU花费{a}纳秒,每个元素复制花费{y},然后再将数组复制回来花费{a}纳秒...整个复制时间为2{a} + {y}。哪个更快呢? - K Scott Piel
使用 size_t 作为每个数组索引也会消除对 i < 0 等的不必要检查,并使乘法无符号,这不会有害。我很抱歉这些建议可能对你的改进很少,但对心灵有好处。 :-) - Cecil Ward
显示剩余2条评论

2

SSE/AVX和对齐

如果您使用的是现代的Intel处理器,则可以选择使用SSE或AVX指令。虽然这并非专门涉及GCC,但请参见此处 如果您感兴趣并且拥有缓存,则我认为英特尔除了在Windows上还提供Linux版本的编译套件,而且它自带一套库。

还有这篇文章

线程(可怕)

我最近也遇到过类似的问题,即memcpy()花费太多时间。 在我的情况下,是一个大的memcpy()(约1MB),而不是像你所做的许多较小的memcpy()。

通过编写自己的多线程memcpy(),其中线程持久存在,并通过我的自定义pmemcpy()函数的调用获得任务的份额,我取得了非常好的效果。 持久的线程意味着开销非常低。 对于4个内核,我获得了4倍的改进。

因此,如果可能将循环分解为合理数量的线程(我为每个可用内核选择一个线程),并且您有一些闲置的内核,则可能会获得类似的好处。

实时人群使用的DMA技术

作为旁注,我有幸玩弄一些相当奇特的OpenVPX硬件。 基本上它是一堆板子,放在一个大盒子里,它们之间有高速串行RapidIO互连。 每个板都有一个DMA引擎,将数据驱动到另一块板的存储器中。

我去过的供应商在如何最大化CPU使用方面非常聪明。 聪明之处在于DMA引擎非常智能-可以编程执行即时矩阵转换,条带开采等操作,就像您试图做的事情一样。 而且由于它是单独的硬件,因此CPU在此期间不会被占用,因此可以忙于做其他事情。

例如,如果您正在进行合成孔径雷达处理之类的任务,那么您总是需要执行大型矩阵转换。 美妙之处在于转换本身根本不需要CPU时间-您只需将数据移动到另一个板上,它已经完成了转换。

无论如何,拥有这类东西的好处真的使人希望Intel CPU(和其他CPU)具有能够在内存-内存上工作而不仅仅是在内存-外设上工作的DMA引擎。 这将使像您这样的任务变得非常快速。


我可能会使用汇编语言,但是这个库通常以独立于平台的Python和Cython源代码发布(Cython被翻译成C并在用户机器上使用其主机编译器进行编译,除了Windows,它通常是预先构建的)...所以了解汇编指令确实有帮助...但如果可能的话,我宁愿让GCC自行处理合理的事情(以及使其在其他编译器上工作...可能不可能,但谁知道呢?) - Stephen Lin
如果你将外层循环分成4个线程(假设这适合n的值),那么会有所帮助。是的,你仍然会频繁调用memcpy,但是你会有4个线程来完成这个任务,而不是一个。如果n太小但k很大,那么你可以将内层循环分成4个线程。目标是给每个线程足够的工作量。 - bazza
它在Cython中,因此如果有帮助,可以在单个函数调用中释放GIL并进行多线程处理;虽然我不确定我们实际上是否经常有这样的用例,但我可能是错的。 - Stephen Lin
如果你进行并行化,我建议只是一直调用memcpy而不是试图找出最佳方法。这可能不是完全最优的,但总体加速仍然值得。 - bazza
在我的情况下,我有4个线程在非重叠数据上做了相当多的工作。这意味着在i7的L1和L2缓存中没有缓存/页面问题。在每个线程部分的边界处可能会出现L3故障。但是,四个线程之间只有3个边界,所以即使发生了故障,与整个任务相比也可以忽略不计。 - bazza
显示剩余11条评论

2
我认为最好的方法是进行实验,找到最佳的“k”值来在原始算法(带有循环)和使用memcpy优化的算法之间切换。不同CPU的最佳“k”值会有所不同,但不应该有太大差异;基本上它关于调用memcpy的开销、memcpy本身在选择最佳算法(基于大小、对齐等)时的开销与使用“朴素”的带有循环的算法之间的开销。
memcpy是gcc中的内部函数,但它并不能做到神奇。它基本上的作用是:如果大小参数在编译时已知且较小(我不知道阈值是多少),那么GCC将用内联代码替换对memcpy函数的调用。如果大小参数在编译时未知,则总是会调用库函数memcpy。

+1,但我并不是在要求“魔法”:这只是循环展开,编译器完全知道函数体!编译器拥有所有必要的信息,知道k在[0,4]之间,并且可以在我的最后一个版本中选择“小数组”版本...非常令人沮丧的是它不能利用这些信息... - Stephen Lin
@StephenLin:我认为问题在于这样的优化可能会使代码大小膨胀太多,而在一般情况下不值得这样做。 - janneb
我理解,但在这种情况下,我已经选择通过将代码加倍来膨胀它,编译器并没有领会我的意图。 - Stephen Lin
你在编译器中使用了哪些与优化相关的开关?例如 ‑O2‑O3 以及 ‑ffast‑math(有一定危险性)。 - Cecil Ward

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接