【问题标题】:Make this for-loop more efficient?让这个 for 循环更高效?
【发布时间】:2018-05-25 18:00:27
【问题描述】:
    • 编辑-- 此代码将在优化关闭的情况下运行

完全透明这是一项家庭作业。

我在弄清楚如何优化这段代码时遇到了一些麻烦......

我的导师讨论了展开和拆分,但似乎都没有大大减少执行代码所需的时间。任何帮助将不胜感激!

for (i = 0; i < N_TIMES; i++) {

    // You can change anything between this comment ...

    int     j;

    for (j = 0; j < ARRAY_SIZE; j++) {
        sum += array[j];
    }

    // ... and this one. But your inner loop must do the *same
    // number of additions as this one does.

}

【问题讨论】:

  • “但似乎都没有大大减少执行代码所需的时间” - 为什么?你衡量它没有更好吗?
  • 我们是否遗漏了有关数组中内容类型的信息?例如。它是nn+ARRAY_SIZE*x 之间的有序数字列表吗?
  • @StoryTeller 任务的目标是让代码在 7 秒内运行。我能够使用展开减少大约 2 秒,但执行代码仍然需要大约 14 秒。
  • @FantasticMrFox 数组只是一个整数数组,全部初始化为 0
  • 在更好的计算机上运行它。说真的,“它必须在 7 秒内运行”是一个荒谬的要求。

标签: c for-loop gcc optimization


【解决方案1】:

首先,除非您使用-O0 显式编译,否则您的编译器可能已经对这个循环进行了优化,远远超出了您的预期。

包括展开,在展开之上还有矢量化等等。尝试手动优化它是你永远不应该做的事情,绝对永远不要做。最多可以成功地使代码更难阅读和理解,而在性能方面甚至很可能无法与编译器匹配。

至于为什么没有可衡量的收益?可能是因为你已经遇到了瓶颈,即使是“非优化”版本。对于大于处理器缓存的ARRAY_SIZE,即使编译器优化版本已经受到内存带宽的限制。

但为了完整起见,我们假设您没有遇到瓶颈,并且实际上您几乎关闭了优化(所以不超过-O1),并为此进行优化。

for (i = 0; i < N_TIMES; i++) {

    // You can change anything between this comment ...

    int j;
    int tmpSum[4] = {0,0,0,0};

    for (j = 0; j < ARRAY_SIZE; j+=4) {
        tmpSum[0] += array[j+0];
        tmpSum[1] += array[j+1];
        tmpSum[2] += array[j+2];
        tmpSum[3] += array[j+3];
    }

    sum += tmpSum[0] + tmpSum[1] + tmpSum[2] + tmpSum[3];

    if(ARRAY_SIZE % 4 != 0) {
        j -= 4;
        for (; j < ARRAY_SIZE; j++) {
            sum += array[j];
        }
    }

    // ... and this one. But your inner loop must do the *same
    // number of additions as this one does.

}

对于较小的array,几乎只剩下一个因素可能会降低性能。

不是循环的开销,所以简单的展开对于现代处理器来说毫无意义。不用麻烦,你不会击败分支预测的。

但是两条指令之间的延迟,直到一条指令写入的值可以被下一条指令再次读取,仍然适用。在这种情况下,sum 会不断地重新写入和读取,即使 sum 缓存在寄存器中,这种延迟仍然存在,处理器流水线必须等待。

解决方法是同时进行多个独立的添加,最后只是合并结果。顺便说一句,这也是大多数现代编译器都知道如何执行的优化。

除此之外,您现在还可以使用向量指令来表达第一个循环——这也是编译器会做的事情。此时,您又遇到了指令延迟,因此您可能必须再引入一组临时变量,这样您现在就有了两个独立的加法流,每个都使用向量指令。

为什么要求至少-O1?因为否则编译器甚至不会将tmpSum 放在寄存器中,或者会尝试表达例如array[j+0] 作为首先执行加法的指令序列,而不是仅仅使用一条指令。在这种情况下,如果不直接使用内联汇编,几乎不可能进行优化。


或者,如果您只是觉得(合法)作弊:

const int N_TIMES = 1000;
const int ARRAY_SIZE = 1024;
const int array[1024] = {1};
int sum = 0;

__attribute__((optimize("O3")))
__attribute__((optimize("unroll-loops")))
int fastSum(const int array[]) {
      int j;
      int tmpSum;

      for (j = 0; j < ARRAY_SIZE; j++) {
          tmpSum += array[j];
      }
      return tmpSum;
}
int main() {
    int i;
    for (i = 0; i < N_TIMES; i++) {
        // You can change anything between this comment ...


        sum += fastSum(array);

        // ... and this one. But your inner loop must do the *same
        // number of additions as this one does.

    }
    return sum;
}

然后编译器将应用上述几乎所有的优化。

【讨论】:

  • 感谢所有这些!
  • 使用四个累加器而不是一个有什么意义?你只是冒着增加开销的风险。而且你对最后一个块的j 的调整是错误的。
  • @YvesDaoust 屏蔽每个累加器上的写入到读取延迟,并允许并行调度累加。例如。 Intel 的 Core 架构在每个时钟周期调度多达 4 个独立的整数运算,但前提是可以找到那么多独立的指令。色块的调整应该是正确的,但我还是重新格式化了。
  • @Ext3h:我将“优化关闭”理解为接近 RAM 模型(您提到的优化将不存在,生成的代码将每次读取/写入累加器而不是保持它们在寄存器中)。在此模型中,使用一个或多个累加器没有区别(不考虑合并)。除了无用的程序复杂性。
  • @YvesDaoust 对,-O0 有这种副作用,但可以使用 register 关键字绕过 RAM 模型。然而,更大的问题是它 - 至少在 GCC 中 - 还阻止了具有静态偏移量的内存访问作为单个指令发出。
【解决方案2】:

除了作弊*,这个内部循环本质上是不可优化的。因为您必须获取所有数组元素并执行所有添加操作。

循环体执行:

  1. j 上的条件分支;
  2. 获取array[j]
  3. 累加到标量变量;
  4. j 的增量。

如前所述,2. 到 4. 是不可避免的。那么您所能做的就是通过循环展开来减少条件分支的数量(这会将条件分支变为无条件分支,但代价是迭代次数变得固定)。

您没有看到很大的不同也就不足为奇了。现代处理器是“循环感知”的,这意味着分支预测可以很好地适应这样的循环,因此分支的成本非常低。


作弊:

正如其他人所说,您可以完全绕过外循环。这只是利用了练习语句中的一个缺陷。

由于必须关闭优化,因此也应禁止使用内联汇编、编译指示、向量指令或内在函数(更不用说自动并行化)。

有可能在 long long 中打包两个 int。如果总和没有溢出,您将一次执行两个加法。但这合法吗?

人们可能会想到一种有利于缓存利用率的访问模式。但是这里没有希望,因为数组在每个循环中都被完全遍历,并且没有可能重用获取的值。

【讨论】:

  • 有很多优化可能,但这需要了解现代处理器的内部工作原理,特别是了解什么是管道停顿,以及超标量调度的先决条件是什么。您的幼稚解释限制为每 1-2 个周期累积一次。对于非向量化指令,限制实际上是每个周期大约 4 次累积。使用向量指令,每个周期最多可累积 8-32(SSE2 到 AVX512)。 4. 也是可以避免的,因为带有偏移量的加载没有额外的惩罚,所以不需要每次迭代都递增。
  • @Ext3h:所有这些考虑都属于“作弊”部分,与练习无关。
  • 他们为什么要这样做?唯一的条件是执行相同数量的累积。没有人说过要在该任务上浪费相同数量的 CPU 周期。
【解决方案3】:

假设您的意思是在运行时对sum 进行相同数量的添加(而不是在源代码中添加相同数量的添加),展开可能会给您类似的结果:

for (j = 0; j + 5 < ARRAY_SIZE; j += 5) {
    sum += array[j] + array[j+1] + array[j+2] + array[j+3] + array[j+4];
}
for (; j < ARRAY_SIZE; j++) {
    sum += array[j];
}

另外,由于您每次都通过外循环添加 same 值,因此您不需要处理它N_TIMES 次,只需执行以下操作:

for (i = 0; i < N_TIMES; i++) {
    // You can change anything between this comment ...
    int     j;
    for (j = 0; j < ARRAY_SIZE; j++) {
        sum += array[j];
    }
    sum *= N_TIMES;
    break;
    // ... and this one. But your inner loop must do the *same
    // number of additions as this one does.
}

这要求sum 的初始值为零,这很可能,但实际上您的问题中没有任何要求这样做,因此我将其作为此方法的先决条件。

【讨论】:

  • 对于您的第二个想法,您可以创建一个单独的tmpsum,将其乘以N_TIMES,然后将结果添加到sum
  • 如果ARRAY_SIZE % 5 != 0 您正在跳过最后一个ARRAY_SIZE % 5 元素,就像j &gt;= ARRAY_SIZE 在第一个循环终止后一样。第二部分违反了约束,必须执行相同数量的添加。
  • @ext3h 问题模棱两可,但它只提到了 inner loop 中添加的约束。似乎没有要求循环本身应该重复。
  • @Ext3h -- 你在这两个方面都错了。第一个解决方案包含第二个循环来对剩余的ARRAY_SIZE % 5 元素求和。并且第二个解决方案没有约束违反或歧义:问题明确指出“内循环必须执行相同数量的加法”,此解决方案确实如此。
猜你喜欢
  • 1970-01-01
  • 2019-08-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-11-10
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多