采取以下两个片段:
int main()
{
unsigned long int start = utime();
__int128_t n = 128;
for(__int128_t i=1; i<1000000000; i++)
n = (n * i);
unsigned long int end = utime();
cout<<(unsigned long int) n<<endl;
cout<<end - start<<endl;
}
和
int main()
{
unsigned long int start = utime();
__int128_t n = 128;
for(__int128_t i=1; i<1000000000; i++)
n = (n * i) >> 2;
unsigned long int end = utime();
cout<<(unsigned long int) n<<endl;
cout<<end - start<<endl;
}
我在C ++中对128位整数进行基准测试。当执行第一个(只是乘法)时,一切都在大约运行。 0.95秒当我还添加位移操作(第二个片段)时,执行时间会增加到惊人的2.49秒。
这怎么可能?我认为位移是处理器最轻的操作之一。怎么这么简单的操作会导致如此多的开销呢?我正在编译O3标志激活。
有什么想法吗?
答案 0 :(得分:6)
在我的4770K上对两者进行基准测试(使用GCC 4.7.3在-O2上生成的汇编)之后,我发现第一个每次迭代需要5个周期,第二个每次迭代需要9个周期。为什么这么大的差异?
事实证明,这是吞吐量和延迟之间的相互作用。主要杀手是shrd
,需要3个周期才能进入关键路径。这是它的一张图片(我忽略了i
的链条,因为它更快,并且有足够的备用吞吐量可以提前运行,它不会干扰):
这里的边缘是依赖关系,而不是数据流。
仅基于此链中的延迟,预期时间为每次迭代8个周期。但事实并非如此。这里的问题是,要发生8个周期,mul2
和imul3
必须并行执行,整数乘法只有1个/周期的吞吐量。所以它(任何一个)必须等待一个循环,并按周期保持链。我通过将imul
更改为add
来验证这一点,这将每次迭代的时间缩短为8个周期。将其他imul
更改为add
没有效果,正如基于此解释所预测的那样(它不依赖shrd
,因此可以提前安排,而不会干扰其他乘法)。
这些确切的细节仅适用于Haswell。
我使用的代码是:
section .text
global cmp1
proc_frame cmp1
[endprolog]
mov r8, rsi
mov r9, rdi
mov esi, 1
xor edi, edi
mov eax, 128
xor edx, edx
.L2:
mov rcx, rdx
mov rdx, rdi
imul rdx, rax
imul rcx, rsi
add rcx, rdx
mul rsi
add rdx, rcx
add rsi, 1
mov rcx, rsi
adc rdi, 0
xor rcx, 10000000
or rcx, rdi
jne .L2
mov rdi, r9
mov rsi, r8
ret
endproc_frame
global cmp2
proc_frame cmp2
[endprolog]
mov r8, rsi
mov r9, rdi
mov esi, 1
xor edi, edi
mov eax, 128
xor edx, edx
.L3:
mov rcx, rdi
imul rcx, rax
imul rdx, rsi
add rcx, rdx
mul rsi
add rdx, rcx
shrd rax, rdx, 2
sar rdx, 2
add rsi, 1
mov rcx, rsi
adc rdi, 0
xor rcx, 10000000
or rcx, rdi
jne .L3
mov rdi, r9
mov rsi, r8
ret
endproc_frame
答案 1 :(得分:1)
除非您的处理器能够支持本机128位操作,否则必须对操作进行软件编码以使用下一个最佳选项。
您的128位操作使用与8位处理器使用16位操作时相同的方案,这需要时间。
例如,使用64位寄存器的128位右移一位需要:
将最高有效寄存器右移为进位。 Carry标志将包含被移出的位
将最低有效寄存器右移,带进位。这些位将向右移位,进位标志位移到最高位位置。
如果不支持本机128位操作,则代码将占用相同64位操作的两倍的操作;有时候更多(比如乘法)。这就是你看到这种糟糕表现的原因。
我强烈建议在非常必要的地方使用128位。