【发布时间】:2014-12-03 16:57:48
【问题描述】:
我希望我已将我的问题简化为一个简单且可重现的测试用例。源代码(here)包含一个相同的简单循环的 10 个副本。每个循环的形式为:
#define COUNT (1000 * 1000 * 1000)
volatile uint64_t counter = 0;
void loopN(void) {
for (int j = COUNT; j != 0; j--) {
uint64_t val = counter;
val = val + 1;
counter = val;
}
return;
}
变量的“易失性”很重要,因为它强制在每次迭代时从内存中读取和写入值。每个循环使用 '-falign-loops=64' 对齐到 64 字节并生成相同的程序集,除了相对于全局的相对偏移量:
400880: 48 8b 15 c1 07 20 00 mov 0x2007c1(%rip),%rdx # 601048 <counter>
400887: 48 83 c2 01 add $0x1,%rdx
40088b: 83 e8 01 sub $0x1,%eax
40088e: 48 89 15 b3 07 20 00 mov %rdx,0x2007b3(%rip) # 601048 <counter>
400895: 75 e9 jne 400880 <loop8+0x20>
我在 Intel Haswell i7-4470 上运行 Linux 3.11。我正在使用 GCC 4.8.1 和命令行编译程序:
gcc -std=gnu99 -O3 -falign-loops=64 -Wall -Wextra same-function.c -o same-function
我还在源代码中使用 attribute((noinline)) 以使程序集更清晰,但这不是观察问题所必需的。我发现 shell 循环中最快和最慢的函数:
for n in 0 1 2 3 4 5 6 7 8 9;
do echo same-function ${n}:;
/usr/bin/time -f "%e seconds" same-function ${n};
/usr/bin/time -f "%e seconds" same-function ${n};
/usr/bin/time -f "%e seconds" same-function ${n};
done
它产生的结果在每次运行中保持一致,大约为 1%,最快和最慢函数的确切数量取决于确切的二进制布局:
same-function 0:
2.08 seconds
2.04 seconds
2.06 seconds
same-function 1:
2.12 seconds
2.12 seconds
2.12 seconds
same-function 2:
2.10 seconds
2.14 seconds
2.11 seconds
same-function 3:
2.04 seconds
2.04 seconds
2.05 seconds
same-function 4:
2.05 seconds
2.00 seconds
2.03 seconds
same-function 5:
2.07 seconds
2.07 seconds
1.98 seconds
same-function 6:
1.83 seconds
1.83 seconds
1.83 seconds
same-function 7:
1.95 seconds
1.98 seconds
1.95 seconds
same-function 8:
1.86 seconds
1.88 seconds
1.86 seconds
same-function 9:
2.04 seconds
2.04 seconds
2.02 seconds
在这种情况下,我们看到 loop2() 是执行速度最慢的之一,而 loop6() 是最快的之一,两者之间的差异刚刚超过 10%。我们通过使用不同的方法重复测试这两种情况来再次确认这一点:
nate@haswell$ N=2; for i in {1..10}; do perf stat same-function $N 2>&1 | grep GHz; done
7,180,104,866 cycles # 3.391 GHz
7,169,930,711 cycles # 3.391 GHz
7,150,190,394 cycles # 3.391 GHz
7,188,959,096 cycles # 3.391 GHz
7,177,272,608 cycles # 3.391 GHz
7,093,246,955 cycles # 3.391 GHz
7,210,636,865 cycles # 3.391 GHz
7,239,838,211 cycles # 3.391 GHz
7,172,716,779 cycles # 3.391 GHz
7,223,252,964 cycles # 3.391 GHz
nate@haswell$ N=6; for i in {1..10}; do perf stat same-function $N 2>&1 | grep GHz; done
6,234,770,361 cycles # 3.391 GHz
6,199,096,296 cycles # 3.391 GHz
6,213,348,126 cycles # 3.391 GHz
6,217,971,263 cycles # 3.391 GHz
6,224,779,686 cycles # 3.391 GHz
6,194,117,897 cycles # 3.391 GHz
6,225,259,274 cycles # 3.391 GHz
6,244,391,509 cycles # 3.391 GHz
6,189,972,381 cycles # 3.391 GHz
6,205,556,306 cycles # 3.391 GHz
考虑到这一点得到证实,我们重新阅读了每本英特尔架构手册中的每一个字,筛选了整个网络上提到“计算机”或“编程”的每一页,并在山顶上独自冥想 6年。没有得到任何启蒙,我们回到文明,刮胡子,洗澡,问 StackOverflow 的专家:
这里可能发生什么?
编辑:在本杰明的帮助下(见下面他的回答),我想出了一个更多的succinct test case。这是一个独立的 20 条装配线。从使用 SUB 更改为 SBB 会导致 15% 的性能差异,即使结果保持不变并且执行了相同数量的指令。解释?我想我离一个越来越近了。
; Minimal example, see also http://stackoverflow.com/q/26266953/3766665
; To build (Linux):
; nasm -felf64 func.asm
; ld func.o
; Then run:
; perf stat -r10 ./a.out
; On Haswell and Sandy Bridge, observed runtime varies
; ~15% depending on whether sub or sbb is used in the loop
section .text
global _start
_start:
push qword 0h ; put counter variable on stack
jmp loop ; jump to function
align 64 ; function alignment.
loop:
mov rcx, 1000000000
align 64 ; loop alignment.
l:
mov rax, [rsp]
add rax, 1h
mov [rsp], rax
; sbb rcx, 1h ; which is faster: sbb or sub?
sub rcx, 1h ; switch, time it, and find out
jne l ; (rot13 spoiler: foo vf snfgre ol 15%)
fin: ; If that was too easy, explain why.
mov eax, 60
xor edi, edi ; End of program. Exit with code 0
syscall
【问题讨论】:
-
您如何考虑后台进程中断您的程序? (我对 Linux 不是很熟悉,但在 Windows 上,当尝试对简单但长时间运行的代码进行计时时,这是一个大问题。)当线程进入睡眠状态时,它会花费一个未知的时间间隔而不运行,而计时器仍然 -可能 - 滴答作响。
-
这似乎不是处理器争用的问题。这可能解释了相同 N 的运行时间的差异,但它不能解释不同 N 的运行时间的一致、统计上显着的差异。
-
loop6和loop1的地址对齐方式是什么 - 它们都是 32 字节对齐的吗?它们的整体对齐方式是否相同? -
@NathanKurz 你是否也禁用了涡轮增压?您需要确保 CPU 不会动态调整频率。另外,您是否尝试更改循环执行顺序?
-
如果你正在执行该进程 10 次(即程序每次运行一次循环),那么答案是 缓存状态。如果您在同一进程中重复循环(即单个程序执行循环 10 次),那么答案是 缓存状态 和 分支预测.
标签: c loops assembly intel memory-alignment