【问题标题】:Indexing with modulo has a huge performance hit模数索引对性能有很大影响
【发布时间】:2026-01-10 15:05:01
【问题描述】:

我有一个简单的代码,它对数组中的元素求和并返回它们:

// Called with jump == 0
int performance(int jump, int *array, int size) {
  int currentIndex = 0;
  int total = 0;
  // For i in 1...500_000_000
  for (int i = 0; i < 500000000; i++) {
    currentIndex = (currentIndex + jump) % size;
    total += array[currentIndex];
  }
  return total;
}

我注意到一个奇怪的行为:% size 的存在对性能有非常大的影响(约慢 10 倍),即使 jump0,所以它不断地访问相同的数组元素 (0)。只需删除 % size 就能大大提高性能。

我原以为这只是产生这种差异的模计算,但现在说我用 total += array[currentIndex] % size; 替换我的 sum 行(因此也计算模)性能差异几乎不明显。

我在 arm64 机器上使用 -O3 和 clang 编译它。

这可能是什么原因造成的?

【问题讨论】:

  • 你的反汇编是什么样子的?
  • @old_timer 你可以在这里找到这两个文件:gist.github.com/PopFlamingo/abe364eabcadc78576ea9c1b2d642b1e
  • @old_timer 我在这里用 -Os 编译了它们,但性能差异是一样的
  • 外部链接在这里几乎没用,如果它不符合问题(在*站点/服务器上),那么它基本上不存在。除法是一项非常昂贵的操作,模数可能会或可能不会增加更多,无论是库还是指令,所以这并不令人惊讶,但反汇编可能会显示出比除法/模数更多的成本。
  • 您应该计算jump %= size,然后计算i=min(i+jump,i+jump-size),所有变量均无符号,以获得更好的模数吞吐量。

标签: c performance assembly arm64


【解决方案1】:

sdiv+msub 延迟听起来很正常,大约是 add 延迟的 10 倍。

即使这个内联的编译时间常数 size 不是 2 的幂,它仍然是一个 multiplicative inverse 和一个 msub(乘减)来获得余数,所以一个 dep至少有两个乘法和一个移位的链。

对于具有恒定大小(即使是正数)的有符号余数的关键路径上可能有额外的几条指令,因为数组也是有符号的int。例如-4 % 3 必须在 C 中生成 -1

假设我用 total += array[currentIndex] % size; 替换我的 sum 行(因此也计算模数)

剩余部分不是循环携带的依赖链的一部分。 (https://fgiesen.wordpress.com/2018/03/05/a-whirlwind-introduction-to-dataflow-graphs/)

多个余数计算可以并行进行,因为下一个array[idx] 加载地址仅取决于+= jump 加法指令。

如果您在吞吐量限制上没有瓶颈,那么这些剩余结果可能会以 1/时钟吞吐量准备就绪,并且 OoO exec 在迭代之间重叠 dep 链。唯一的延迟瓶颈是循环计数器/索引和total += ...,它们都是整数add,具有1个周期延迟。

所以说真的,瓶颈很可能是吞吐量(整个循环体),而不是那些延迟瓶颈,除非您在一个非常宽的 CPU 上进行测试,每个循环都可以完成很多工作。 (很惊讶你并没有因为引入% 而变得更慢。除非total 在你不使用结果的情况下在内联后得到优化。)

【讨论】:

  • (我假设您测试的 ARM64 执行无序执行,因此不同迭代中的独立乘法可以相互流水线化,而无需编译器展开和软件流水线,尽管如果您不立即尝试使用高延迟结果,即允许无序完成,只要按顺序开始执行,高端有序 CPU 就不会停止。)无论如何,最好说出您在哪个特定的 ARM64 微架构上进行测试,例如 Cortex-A57 或 Apple M1,以及编译器版本。
  • 对不起,我确实弄错了,我的意思是`total += array[currentIndex] % size`。感谢您的详细回答,您引用的一些元素我不熟悉,我会搜索它们。
  • @PopFlamingo:What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand? 有详细的讨论和更多链接,关于分析这种瓶颈与其他类型的代码。这就是我链接它的原因。有关更多一般介绍,请参阅 fgiesen.wordpress.com/2018/03/05/…How many CPU cycles are needed for each assembly instruction?
  • (OoO exec 在 ARM64 上的工作方式与在 x86-64 上类似,主要区别在于 x86-64 将一些单个指令解码为多个 uop。与所有 RISC 一样,ARM64 更像是 1:1 的指令 - > 通过管道进行的操作。)
  • 当然,您需要在 sdiv 之上添加一个 msub 才能获得剩余部分,这会增加几个延迟周期。但是签名% 在ARM64 上并不比未签名更难; sdiv / msub 为所有符号组合提供正确的 C 结果。