【问题标题】:Optimize nested loops for pattern-filling an array, to help the compiler produce efficient ARM assembly?优化用于模式填充数组的嵌套循环,以帮助编译器生成高效的 ARM 程序集?
【发布时间】:2020-04-29 05:13:09
【问题描述】:

我刚刚被分配重新编写以下 C 函数,以帮助 ARM 编译器生成更高效的汇编代码。有谁知道怎么做?

void some_function(int *data)
{
    int  i, j;

    for (i = 0; i < 64; i++)
    {
        for (j = 0; j < 64; j++)
            data[j + 64*i] = (i + j)/2;
    }
}

【问题讨论】:

  • 编译器可能已经做得很好了。一种可能性是在循环外部引入int k = 0;,并在内部循环中使用data[k++] = (i + j) / 2;
  • 我同意 Jonathan 关于信任编译器的观点。我还想提出一个优化建议。尝试摆脱内部循环内的/2。为此,将循环更改为 for (j = 0; j &lt; 64; j+=2) 并在其中执行两个(而不是一个)适当的分配。这类似于(但不完全是)循环展开,并避免了潜在的昂贵划分。
  • @Jake'Alquimista'LEE:现在您已经为不需要它的程序添加了另外 16,384 字节的内存和缓存使用,并且对于运行速度可能比memcpy 更快的代码(原样的代码只需要写入内存,其中的少数操作可能与内存并行完成,而memcpy 必须同时读取和写入)。
  • @Jake'Alquimista'LEE:如果在多次使用后需要多次重置表,则在程序执行期间执行多次表生成。或者,即使它只需要一次,在运行时初始化它也可能比从磁盘读取它(从程序文件的数据)更快。此外,学生练习不需要在生产代码中有用才能教给学生有用的技能,就像特定的举重动作需要在现实生活中有用才能增强肌肉和改善健康一样。
  • @tum_:我的许多 cmets 的目的不是解决一个特定问题,而是在它在太多学生的头脑中种下、生根、成长之前清除陈腐和错误的“知识” , 并传播。在这种情况下,人们应该采用memcpy(因为它简单、容易或已经高度优化)而不真正评估它相对于其他解决方案的成本和收益的想法就是这样一种。新手比专家多得多,而 Stack Overflow 是这种杂草的沃土,它们蔓延后更难控制。

标签: c assembly arm micro-optimization


【解决方案1】:

首先(正如 Jonathan Leffler 所提到的)编译器可能已经做得很好,以至于试图通过编写特定的 C 代码来进行优化通常在商业上是有问题的,也就是说,您在开发时间上损失的钱比通过稍微快一点的时间赚的钱要多代码。
但有时它是值得的;让我们假设这里是这种情况。

如果您确实进行了优化,请在测量时进行。很有可能编写最终不太优化的代码,因为以微妙的方式,否则可能的编译器优化会被挫败。此外,优化是否有效以及优化的程度取决于环境,即需要在所有潜在环境中进行测量。

好的,经过明智的破解,下面是我演示 cmets 中提出的优化的代码,其中之一是 Jonathan Leffler:

/* Jonathan Leffler */
void some_function(int *data)
{
    int  i, j;
    int  k = 0;

    for (i = 0; i < 64; i++)
    {
        for (j = 0; j < 64; j++)
        {
            data[k++] = (i + j)/2;
        }
    }
}

/* Yunnosch 1, loop unrolling by 2 */
void some_function(int *data)
{
    int  i, j;

    for (i = 0; i < 64; i++)
    {
        for (j = 0; j < 64; j+=2)
            data[j +     64*i] = (i + j  )/2;
            data[j + 1 + 64*i] = (i + j+1)/2;
    }
}

/* Yunnosch 1 and Jonathan Leffler */
void some_function(int *data)
{
    int  i, j;
    int k=0; /* Jonathan Leffler */

    for (i = 0; i < 64; i++)
    {
        for (j = 0; j < 64; j+=2) /* Yunnosch */
        {
            data[k++] = (i + j  )/2;
            data[k++] = (i + j+1)/2; /* Yunnosch */
        }
    }
}

/* Yunnosch 2, avoiding the /2, including Jonathan Leffler */
/* Well, duh. This is harder than I thought... 
   I admit that this is NOT tested, I want to demonstrate the idea.
   Everybody feel free to help the very grateful me with fixing errors. */
void some_function(int *data)
{
    int  i, j;
    int  k=0;

    for (i = 0; i < 32; i++) /* magic numbers I normally avoid, 32 is 64/2 */
    {
        for (j = 0; j < 32; j++)
        {
            data[k     ] = (i + j);
            data[k+1   ] = (i + j);
            data[k  +64] = (i + j);
            data[k+1+64] = (i + j +1);
            k+=2;
        }
        k+=64;
    }
}

最后一个版本基于所需结果中的以下可观察 2x2 组模式,如 2D 解释所示:

00 11 ...
01 12 ...

11 22 ...
12 23 ...
.. ..
.. ..
.. ..
´´´´

【讨论】:

  • 哦,嗯,是的,避免/2 并不像我在问题下的 cmets 中想象的那么容易。未经测试的版本:godbolt.org/z/vVzwsY 也将 outer 循环展开 2,内部循环的 2 个版本涵盖了 1+00+1 不会“克服”右边的事实shift,但是1+1 这样做了,所以我们在处理奇数i 的循环中有store; tmp++; store。将这些循环融合到一个存储到 j+0..1 + 64*(i+0)j+0..1 + 64*(i+1) 的循环中可能会很好,如果 ARM 微架构不受两个输出流到不同缓存行的干扰,那么重用相同的 tmp。
  • @PeterCordes 感谢您的意见。如果我声明我的提案在知道你的提案之前就已经完成,我希望时机能让我觉得合理。
  • 最后一个版本对我来说很合适。这与我展开的版本的 2x2 模式的基本思想/分析相同,但您的将其融合为一个循环。 godbolt.org/z/TSHQj6 显示它编译合理,在寻址模式下具有正确的偏移量。起初我很怀疑,但后来我发现 k+=64 超出了我们存储的 k+64 部分。 (而且内循环中的大部分指令都是存储,所以在一个简单的内核上,指令吞吐量会出现瓶颈,这有很大一部分有用的工作。外循环逻辑非常简单,有利于代码大小)
  • 不错的编辑。我刚刚完成审查(我自己发现了大多数问题),当他们神奇地全部修复时。 @JonathanLeffler
【解决方案2】:

优化 C 代码以为特定编译器/处理器生成“更高效的汇编代码”是您通常不应该做的事情。编写清晰易懂的 C 代码,让编译器进行优化。

即使您使用 C 代码进行各种技巧并最终为您的特定编译器/处理器提供“更高效的汇编代码”,结果可能是简单的编译器升级可能会毁掉整个事情,您会必须再次更改 C 代码。

对于像你的代码这样简单的东西,从一开始就用汇编代码编写它。但请注意,您必须成为该处理器/汇编语言的真正专家才能击败一个体面的编译器。

无论如何...如果我们想猜测,这是我的猜测:

void some_function(int *data)
{
    int  i, j, x;

    for (i = 0; i < 64; i++)
    {
        // Handle even i-values
        x = i/2;
        for (j = 0; j < 64; j += 2)
        {
            *data = x;
            ++data;
            *data = x;
            ++data;
            ++x;        // Increment after writing to data twice
        }

        ++i;

        // Handle odd i-values
        x = i/2;
        for (j = 0; j < 64; j += 2)
        {
            *data = x;
            ++data;
            ++x;        // Increment after writing to data once
            *data = x;
            ++data;
        }
    }
}

这个想法是 1) 用指针增量替换数组索引和 2) 用整数增量替换 (i+j)/2

没有做过任何测量,所以我不能肯定这会是一个好的解决方案。我将把它留给 OP。


与上面的想法相同,但还有一些调整(由@user3386109 提出)

void some_function(int *data)
{
    for (int i = 0; i < 32; i++)
    {
        // when i is even, the output is in matched pairs
        int value = i;
        for (int j = 0; j < 32; j++)
        {
            *data++ = value;
            *data++ = value++;
        }

        // when i is odd, the output starts with a singleton
        // followed by matched pairs, and ending with a singleton
        value = i;
        *data++ = value++;
        for (int j = 0; j < 31; j++)
        {
            *data++ = value;
            *data++ = value++;
        }
        *data++ = value;
    }
}

【讨论】:

  • 我在使用 clang 编译的 x64 上进行了尝试,结果看起来很干净。不确定它对 ARM 的转化效果如何。
  • @user3386109 我喜欢您的建议的一点是,您的代码中的模式生成比我的更容易理解。如果 OP 真的做了一些真实的测量并给我们一些反馈,那可能会很有趣。
  • 同意,很高兴知道这些优化是否真的超过了编译器自己会做的事情:)
猜你喜欢
  • 2017-11-05
  • 2015-01-09
  • 2015-09-30
  • 1970-01-01
  • 2021-05-19
  • 2014-06-05
  • 1970-01-01
  • 1970-01-01
  • 2014-11-22
相关资源
最近更新 更多