冒着重复的风险,也许我现在找不到类似的帖子:
我是用C++(具体来说是C++20)编写的。我有一个循环,每个循环都有一个计数器。我们称之为counter
。如果这个counter
达到了页面限制(我们称之为page_limit
),程序应该继续下一页。所以它看起来像这样:
const size_t page_limit = 4942;
size_t counter = 0;
while (counter < foo) {
if (counter % page_limit == 0) {
// start new page
}
// some other code
counter += 1;
}
现在我想知道,因为计数器很高:如果我不让程序每次计算模counter % page_limit
,而是制作另一个计数器,程序会运行得更快吗?它可能看起来像这样:
const size_t page_limit = 4942;
size_t counter = 0;
size_t page_counter = 4942;
while (counter < foo) {
if (page_counter == page_limit) {
// start new page
page_counter = 0;
}
// some other code
counter += 1;
page_counter += 1;
}
(我想你是想写if(x%y==0)
而不是if(x%y)
,以等效于计数器。)
我不认为编译器会为您进行此优化,所以这可能是值得的。它将是更小的代码大小,即使你不能测量速度差异。x % y == 0
方式仍然会分支(因此,当它为真时,在极少数情况下仍然会受到分支预测错误的影响)。它唯一的优点是不需要单独的计数器变量,只需要在循环的某个点上使用一些临时寄存器。但每次迭代都需要除数。
总的来说,这对代码大小来说应该更好,如果你习惯了这个习惯用法,可读性也不会降低。(特别是如果您使用if(--page_count == 0) { page_count=page_limit; ...
,那么所有的逻辑都在相邻的两行中。)
如果您的page_limit
是而不是编译时常数,这更有可能有所帮助dec/jz
每多次递减只取一次,它比div
/test edx,edx
/jz
便宜得多,包括前端吞吐量。(div
在英特尔CPU上被微编码为大约10个uops,因此即使它是一条指令,它仍然会占用前端多个周期,从而占用将周围代码放入无序后端的吞吐量资源)。
(对于常数除数,它仍然是乘法,右移,sub来得到商,然后乘和减来得到余数。所以仍然有几个单独的uop指令。尽管用小常数进行可分割性测试有一些技巧,但请参阅@Cassio Neri关于快速可分割性测试的答案(通过2,3,4,5,..,16)?其中引用了他的期刊文章;最近的GCC可能已经开始使用这些。)
但是,如果循环体在前端指令/uop吞吐量(在x86上)或除法器执行单元上没有瓶颈,那么无序的exec可能会隐藏div
指令的大部分成本。它不在关键路径上,因此如果它的延迟与其他计算并行发生,并且有空闲的吞吐量资源,那么它可能基本上是空闲的。(分支预测+推测执行允许在不等待分支条件已知的情况下继续执行,并且由于这项工作独立于其他工作,它可以"提前运行",因为编译器可以看到未来的迭代。)
尽管如此,让这项工作变得更便宜可以帮助CPU更快地发现和处理分支预测错误。但具有快速恢复功能的现代CPU可以在恢复时继续处理分支之前的旧指令。(当skylake CPU错误预测分支时,会发生什么?/通过提前计算条件来避免管道停滞)
当然,一些循环确实完全保持CPU的吞吐量资源繁忙,而不是缓存未命中或延迟链的瓶颈。每次迭代执行的uop越少,对其他超线程(或一般的SMT)就越友好。
或者,如果你关心你的代码在有序的CPU上运行(ARM和其他针对低功耗实现的非x86 ISA很常见),那么真正的工作必须等待分支条件逻辑。(只有硬件预取或缓存未命中加载之类的东西才能在运行额外代码以测试分支条件时做有用的工作。)
除法运算确实占用了ROB(ReOrder Buffer)中更多的空间,因此减少了CPU在无序执行时可以看到的距离,从而在代码之前/之后找到独立的运算
使用递减计数器
实际上,您不需要向上计数,而是需要手动使用向下计数器,该计数器可以编译为dec reg / jz .new_page
或类似值;所有普通的ISAs都可以非常便宜地做到这一点,因为这与你在普通循环底部发现的东西是一样的。(dec
/jnz
在非零时保持循环)
if(--page_counter == 0) {
/*new page*/;
page_counter = page_limit;
}
递减计数器在asm中更高效,在C中也同样可读(与递增计数器相比),所以如果你正在进行微优化,你应该这样写。相关:在手写asm FizzBuzz中使用该技术。也许还有一个3和5的倍数的asm和的代码审查,但它没有做任何匹配,所以优化它是不同的。
请注意,page_limit
只能在if主体内部访问,因此,如果编译器的寄存器数量不足,它可以很容易地溢出寄存器,只在需要时读取,而不会将寄存器与寄存器或乘数常数捆绑在一起。
或者,如果它是一个已知的常数,只需要立即移动指令。(大多数ISAs也有比较立即数,但不是全部。例如,MIPS和RISC-V只有比较和分支指令,它们使用指令字中的空间作为目标地址,而不是立即数。)许多RISC ISAs都有特殊的支持,可以有效地将寄存器设置为比大多数使用立即数的指令更宽的常数(如具有16位立即数的ARMmovw
,因此4092
可以在一条指令中为mov
而不是cmp
完成:它不适合按偶数计数旋转的12位)。
与除法(或乘法逆)相比,大多数RISC ISAs没有乘法立即数,并且乘法逆通常比一个立即数所能容纳的要宽。(x86确实有乘法立即数,但不适用于给你高一半的形式。)除法立即数更为罕见,即使x86也没有,但除非优化空间而不是速度,否则没有编译器会使用它。
像x86这样的CISC ISAs通常可以与内存源操作数相乘或除法,因此如果寄存器数量不足,编译器可以将除数保留在内存中(尤其是当它是运行时变量时)。每次迭代加载一次(在缓存中命中)并不昂贵。但是,如果循环足够短且没有足够的寄存器,则溢出和重新加载在循环内发生变化的实际变量(如page_count
)可能会引入存储/重新加载延迟瓶颈。(尽管这可能不合理:如果你的循环体足够大,需要所有寄存器,那么它可能有足够的延迟来隐藏存储/重新加载。)
如果除数是常数,大多数优化编译器会将除法或模运算转换为乘预生成的逆常数和移位指令。如果在循环中重复使用相同的除数值,也可能如此
Modulo乘以inverse得到商,然后将商数乘以除数积。然后原始数-乘积法和移位是最新的X86处理器上的快速指令,但分支预测也可以减少条件分支所需的时间,因此可能需要一个基准来确定哪一个是最好的。
如果有人把它放在我面前,我宁愿它是:
const size_t page_limit = 4942;
size_t npages = 0, nitems = 0;
size_t pagelim = foo / page_limit;
size_t resid = foo % page_limit;
while (npages < pagelim || nitems < resid) {
if (++nitems == page_limit) {
/* start new page */
nitems = 0;
npages++;
}
}
因为程序现在表达了处理的意图——page_limit
大小的块中的一堆东西;而不是试图优化操作。
编译器可能会生成更好的代码,这只是一件幸事。