提问人:user22200698 提问时间:11/13/2023 更新时间:11/13/2023 访问量:131
现代处理器访问内存的速度通常比递减顺序更快吗?
Do modern processors typically access memory faster in increasing order than decreasing order?
问:
我听说倒计时比循环倒计时快。但是,我也听说正向访问内存(按内存地址升序)比反向访问内存快。就像我有一个重复一行代码 N 次的循环一样,倒计时可能会稍微快一些。但是,如果我访问当前值为 N 的数组,并且倒计时,我将以向后的方式访问内存。这会不会更慢,并可能首先否定倒计时的所有性能优势?
这会:
short array[1024];
for (int i = 0; i < 1024; ++i) {
do_something_with(array[i]);
}
比这更快:
short array[1024];
for (int i = 1024; i--;) {
do_something_with(array[i]);
}
我正在尝试在现代机器上编写最快的代码。
答:
后者在某些处理器上可能更快,如果您仍然可以测量它。对于了解组装的人来说,原因非常出乎意料。在某些处理器上,我们可以免费将递减的结果与零进行比较;但与 1024 相比,需要花费一条指令。
内存访问对内存硬件无关紧要;内存硬件的工作方式不大,即 RAM 访问的顺序依赖性关心增加或减少序列。但是,处理器功能(如预加载内存)可能会产生取决于访问方向的影响。(现在,如果它是映射内存,你可能会观察到增加访问通常比减少旋转磁头磁盘上的访问更快;但这在SSD上也不应该存在)。
注意“如果你仍然可以测量它”;这些天越来越难了;而且大多数情况下,没有人关心你是否能从最热门的循环中榨取那一点点性能。
我正在尝试在现代机器上编写最快的代码。
明智地利用你的时间和才能。
至少在97%的时间里,像这样的担忧是浪费时间。为清楚起见,编写代码。向上或向下,哪一个最能表达更高级别的代码的意图?
当复杂程度的顺序相同时,最好专注于更大的问题,让编译器处理小问题。
评论
在没有考虑特定系统的情况下谈论优化没有多大意义。如果你对“现代”的定义是高端的分支预测和缓存,那么这是一个用例。另一个现代用例可能是没有此类功能的 ARM Cortex M0 到 M3。
古老的“你应该总是倒计时”的伎俩可以追溯到大约 30 年前,当时编译器在优化代码方面很糟糕。它基于这样一个事实,即在许多系统上,比较与零比比较与值快几个时钟周期。我想自信地说,编译器现在已经足够聪明了,可以为你做这种优化,但在验证它之前,我不会假设那么多。
使用最新的 gcc for x86_64 对两个版本进行基准测试,可提供几乎相同的代码 https://godbolt.org/z/E8Tb9hWKx。一个使用 ,一个使用 ,但无论哪种方式都使用相同的指令,所以它没有带来任何好处 - 它只会使 C 代码变得模糊。据推测,就数据缓存使用而言,向上计数可能更有益,但我不认为这在这里很重要。add
sub
cmp
但是,如果我们将 godbolt 目标切换到更古老的东西,例如旧的 AVR,那么两个版本都会被优化为出于任何原因使用 a (由于是 2 位,它仍然必须分 16 步进行比较)https://godbolt.org/z/K7c7nrMo6。这是一个古老的、缓慢的 8-bitter,所以在这里选择正确的指令更重要,没有缓存或分支预测这样的东西。显然,前一个版本因此更胜一筹(对于 AVR),因为它是最具可读性的。因此,在使用 30 年前的 CPU 时,30 年前的技巧甚至无关紧要,因为我们选择了现代编译器。subi
i
i
一些经验法则:
- 不要手动优化代码,除非您在启用优化的情况下进行编译时确实注意到了性能瓶颈。
- 不要手动优化代码,除非你对 C 如何转换为汇编程序有一定的了解,并且至少对目标硬件有一点了解。
- 如果您是初学者,请不要手动优化代码。除非您拥有至少 5+ 年的编程经验,否则您不太可能在这方面做得不错。
- 不要教初学者手动优化代码。
但是,如果我访问当前值为 N 的数组,并且倒计时,我将以向后的方式访问内存。这会不会更慢,并可能首先否定倒计时的所有性能优势?
这需要一些解释,所以我将首先给出一个结论:向前或向后内存访问是否可能在时间上有所不同,主要取决于它们是否与处理器前瞻功能匹配,以及这些功能在方向上是否对称。
在访问方向方面,内存访问时间的主要驱动因素是处理器预加载功能。内存硬件访问小于或大于其他最近访问的地址的时间通常没有差异。但是处理器(通常不是全部)会查看地址访问模式,并尝试预加载可能很快就会使用的内存。
例如,如果进程访问缓存行 13、14 和 15,则处理器可能会在进程实际执行任何加载指令之前从内存中请求缓存行 16。这在几个方面变得复杂。
首先,如果进程在内存中飞速运行,以尽可能快的速度处理数据,那么处理器预加载可能没有任何优势,因为一旦处理器无论如何都会尝试,该进程就会请求下一个内存。对于同时具有多个“运行中”指令的超标量处理器来说尤其如此,因为这允许“未来”加载指令在先前的数据处理指令处理先前加载的数据之前请求内存。因此,处理器预加载在数据处理量至少比内存使用量多一点的算法上效果最好。
其次,大多数算法不是简单地使用流中的数据,无论是向前还是向后。该过程可能使用缓存行 13、14、15 等中的数据,但也引用堆栈位置,并且可能使用缓存行 13、14 和 15 中的数据,但也使用缓存行 79、80、81 等中的数据。因此,处理器设计人员可能会尝试识别连续访问,即使它们被其他访问中断。然后,处理器行为的预测变得复杂。您的进程在连续访问之间将有多少次“其他”访问?对于处理器来说,这会不会太多?
第三,从某些角度来看,常规的访问流可能看起来并不规律。假设您有一个元素数组,每个缓存行有四个元素,并且您访问一个常规的元素序列:0、5、10、15、20、25、30、35、40,...它们位于缓存行 0(元素 0-3,包含 0)、1(4-7,包含 5)、2 (8-11)、3 (12-15)、5 (20-23)、6 (24-27)、7 (28-31)、8 (32-35) 和 10 (40-43) 中。如果处理器正在监视访问了哪些缓存行,而不是访问了哪些地址,则它会看到一个不规则的序列:0、1、2、3、5、6、7、8、10,其中缺少 4 和 9。当处理器看到 0、1、2 和 3 时,它可能预加载了缓存行 4,这浪费了时间和缓存空间,因为没有使用 4。或者,由于程序未使用 4,因此处理器可能不会预加载 5。
这意味着您的内存访问时间可能会受到数据访问模式(它实际访问的内存,而不是循环的写入方式)和处理器的前瞻功能的影响。而且,在回答您的问题时,向前或向后内存访问在时间上是否可能有所不同,主要取决于它们是否与处理器前瞻功能匹配,以及这些功能在方向上是否对称。
如果处理器设计识别的向后序列与向前序列相同(反射),则向前或向后访问内存应该无关紧要。如果处理器设计识别某些前向序列,但不能识别相应的后向序列,则与此类前向序列匹配的代码的性能可能优于与此类后向序列匹配的代码。
也就是说,我无法评论处理器具有非对称前瞻功能的普遍性。现有的处理器型号太多了。我有一些回忆,在使用英特尔处理器时,反向前瞻是一项新功能,但我希望现在这很常见。但是,我不能说是否所有公认的访问模式都是对称的,或者市场上的哪些处理器具有对称的前瞻功能。
评论
do_something_with