为什么在Intel Skylake CPU上,我的空循环如果被调用为函数,则运行速度会快两倍?

8

我进行了一些测试,比较了C和Java,并发现了一些有趣的事情。将我的完全相同的基准测试代码与优化级别1(-O1)在主函数中调用的函数中运行,而不是在main函数本身中运行,可以将性能提高大约一倍。 我正在打印test_t的大小以确保该代码被编译为x64。

我将可执行文件发送给我的朋友,他正在运行i7-7700HQ,并获得了类似的结果。我正在运行i7-6700。


以下是较慢的代码:

#include <stdio.h>
#include <time.h>
#include <stdint.h>

int main() {
    printf("Size = %I64u\n", sizeof(size_t));
    int start = clock();
    for(int64_t i = 0; i < 10000000000L; i++) {
        
    }
    printf("%ld\n", clock() - start);
    return 0;
}

同时速度更快:

#include <stdio.h>
#include <time.h>
#include <stdint.h>

void test() {
    printf("Size = %I64u\n", sizeof(size_t));
    int start = clock();
    for(int64_t i = 0; i < 10000000000L; i++) {
        
    }
    printf("%ld\n", clock() - start);
}

int main() {
    test();
    return 0;
}

我也会提供汇编代码给你深入研究。但我不懂汇编语言。 更慢:

    .file   "dummy.c"
    .text
    .def    __main; .scl    2;  .type   32; .endef
    .section .rdata,"dr"
.LC0:
    .ascii "Size = %I64u\12\0"
.LC1:
    .ascii "%ld\12\0"
    .text
    .globl  main
    .def    main;   .scl    2;  .type   32; .endef
    .seh_proc   main
main:
    pushq   %rbx
    .seh_pushreg    %rbx
    subq    $32, %rsp
    .seh_stackalloc 32
    .seh_endprologue
    call    __main
    movl    $8, %edx
    leaq    .LC0(%rip), %rcx
    call    printf
    call    clock
    movl    %eax, %ebx
    movabsq $10000000000, %rax
.L2:
    subq    $1, %rax
    jne .L2
    call    clock
    subl    %ebx, %eax
    movl    %eax, %edx
    leaq    .LC1(%rip), %rcx
    call    printf
    movl    $0, %eax
    addq    $32, %rsp
    popq    %rbx
    ret
    .seh_endproc
    .ident  "GCC: (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0"
    .def    printf; .scl    2;  .type   32; .endef
    .def    clock;  .scl    2;  .type   32; .endef


更快:
    .file   "dummy.c"
    .text
    .section .rdata,"dr"
.LC0:
    .ascii "Size = %I64u\12\0"
.LC1:
    .ascii "%ld\12\0"
    .text
    .globl  test
    .def    test;   .scl    2;  .type   32; .endef
    .seh_proc   test
test:
    pushq   %rbx
    .seh_pushreg    %rbx
    subq    $32, %rsp
    .seh_stackalloc 32
    .seh_endprologue
    movl    $8, %edx
    leaq    .LC0(%rip), %rcx
    call    printf
    call    clock
    movl    %eax, %ebx
    movabsq $10000000000, %rax
.L2:
    subq    $1, %rax
    jne .L2
    call    clock
    subl    %ebx, %eax
    movl    %eax, %edx
    leaq    .LC1(%rip), %rcx
    call    printf
    nop
    addq    $32, %rsp
    popq    %rbx
    ret
    .seh_endproc
    .def    __main; .scl    2;  .type   32; .endef
    .globl  main
    .def    main;   .scl    2;  .type   32; .endef
    .seh_proc   main
main:
    subq    $40, %rsp
    .seh_stackalloc 40
    .seh_endprologue
    call    __main
    call    test
    movl    $0, %eax
    addq    $40, %rsp
    ret
    .seh_endproc
    .ident  "GCC: (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0"
    .def    printf; .scl    2;  .type   32; .endef
    .def    clock;  .scl    2;  .type   32; .endef

这是我的批处理脚本编译代码:

@echo off
set /p file= File to compile: 
del compiled.exe
gcc -Wall -Wextra -std=c17 -O1 -o compiled.exe %file%.c
compiled.exe
PAUSE

对于编译为汇编:

@echo off
set /p file= File to compile: 
del %file%.s
gcc -S -Wall -Wextra -std=c17 -O1 %file%.c
PAUSE

1
你是否以随机顺序多次运行测试,丢弃异常值? - pmg
2
@SergeyA 如果我使用高于-O1的任何选项,它都会被优化掉,这就是为什么我使用-O1而不是-O3的原因。 - Anonymous Noob
2
@pmg 我已经进行了多次测试,每次之间相隔几分钟,并且以相对任意/随机的顺序进行,同时与我的朋友讨论这种奇怪的情况。当然有一些差异,但并不是很大。总体趋势是,更快的代码大约比更慢的代码快两倍左右。我还尝试将最大值从100亿翻倍到200亿,结果也是如此。值得注意的是,结果在几秒钟内完成,因此不可能是微小的开销或随机延迟造成的。 - Anonymous Noob
6
可能是由于分支的地址(作为随机副作用在两个版本中不同)与修复 jcc erratum 的结果结合在一起,如果您提供两种情况下 L2 的地址,则可以进行检查。 - harold
2
我无法复现它。 - 0___________
显示剩余21条评论
1个回答

16

慢速版:

输入图像描述

请注意,sub rax,1\ jne对在..80(即32字节边界)的跨度。 这是英特尔关于此问题的文档中提到的情况之一,如下图所示:

输入图像描述

因此,此操作/分支对受到JCC勘误修复的影响(这将导致它无法缓存在μop缓存中)。 我不确定那是否是原因,还有其他因素在起作用,但这是一个问题。

在快速版本中,分支没有“触及”32字节边界,因此不受影响。

输入图像描述

可能还有其他影响。但是,由于越过了32字节边界,在慢速情况下,即使没有修复JCC问题的情况下,循环也会在µop缓存中跨越2个块,如果循环无法从Loop Stream Detector执行,则每次迭代可能需要运行2个周期(在某些处理器上通过另一个修复程序SKL150禁用)。请参见此答案有关循环性能的内容。

针对一些评论说他们无法重现此问题的情况,确实有各种可能:

  • 减速的原因可能是操作/分支对恰好交叉32字节边界的精确放置,这是纯粹的偶然事件。编译源代码不太可能重现相同的情况,除非使用与原始帖子作者使用的相同设置的同一编译器。
  • 即使使用相同的二进制文件,不管哪种效果负责,奇怪的效果也只会发生在特定的处理器上。

2
有趣的事实:即使没有JCC勘误,微小的循环(由两个缓存行拆分)也可能在Skylake系列CPU上运行一半的速度(它们的循环缓冲区被a microcode update in SKL禁用,并且在后来的CPU上直到Ice Lake)。因为uop缓存每个周期只能从1行获取,所以它必须在行之间交替获取整个循环体。(哦,你已经提到了那种效果。) - Peter Cordes
1
我认为即使在这个子/JCC循环中(它可以宏合并成一个单一的uop),即使没有JCC勘误,它实际上也可能无法宏合并,并分别以2个uop出现在2个不同的高速缓存行中。如果跨越I-cache线边界(64字节)完美分裂,则宏融合不起作用。但是,在具有工作LSD的CPU上,这两个uop仍然可以“展开”,并以4个为一组发出。(在Haswell及更高版本上,或者至少在SnB上以2个为一组) - Peter Cordes
请注意,类似的影响也会影响 AMD Ryzen 处理器:当一个小循环被分成两个缓存行时,指令解码会变慢,导致整个循环变慢(由于指令缓存)。 - Jérôme Richard

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接