【问题标题】:Why calls when jmps would suffice?为什么在 jmps 足够时调用?
【发布时间】:2012-05-23 01:16:26
【问题描述】:

我有两个文件:

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

int main()
{
    unsigned int input;
    scanf("%u", &input);

    switch (input)
    {
        case 0: print0(); break;
        case 1: print1(); break;
        case 2: print2(); break;
        case 3: print3(); break;
        case 4: print4(); break;
    }
    return 0;
}

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

int main()
{
    unsigned int input;
    scanf("%u", &input);

    static void (*jt[])() = { print0, print1, print2, print3, print4 };
    jt[input]();
    return 0;
}

我希望它们被编译成几乎相同的汇编代码。在这两种情况下都会生成跳转表,但first file 中的调用由jmp 表示,而second one 中的调用由call 表示。为什么编译器不优化calls?是否可以提示 gcc 我希望看到 jmps 而不是 calls?

使用gcc -Wall -Winline -O3 -S -masm=intel 编译,GCC 版本 4.6.2。 GCC 4.8.0 生成的代码略少,但问题仍然存在。

UPD:将jt 定义为const void (* const jt[])() = { print0, print1, print2, print3, print4 }; 并使函数static const inline 没有帮助:http://ideone.com/97SU0

【问题讨论】:

  • 有性能差异吗?而且我不相信在第二种情况下调用是内联的。你可以发布程序集吗?
  • 也许是因为在后一种情况下函数是间接调用的?如果将 jt[] 设为一个包含 5 个指向函数的 const 指针的 const 数组会发生什么?
  • @Alex - 你打败了我!可以在运行时修改非常量指针数组。
  • @Joulukuusi 是的,我就是这么想的。在第二种情况下,调用没有内联。 (我看不出如何在不退化为第一种情况的情况下将它们内联)。所以你可能想改变那句话的措辞。
  • TBH,它是一个带有一个字节操作码的 6502 8 位处理器,那么为什么不直接在汇编器中实现仿真器呢?一个 256 字节的跳转表很容易。

标签: c gcc assembly x86


【解决方案1】:

我的猜测是,这种优化与您在 switch 之后立即有一个 return 语句这一事实有关:优化器意识到它可以捎带嵌入到您的 print0..print4 中的返回值函数,并将call 减少到jmp; CPU 在选定的printN 中命中的ret 作为main 的返回值。

尝试在开关之后插入一些代码,看看编译器是否会将jmp 替换为call

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

int main()
{
    unsigned int input;
    scanf("%u", &input);

    switch (input)
    {
        case 0: print0(); break;
        case 1: print1(); break;
        case 2: print2(); break;
        case 3: print3(); break;
        case 4: print4(); break;
    }
    /* Inserting this line should force the compiler to use call */
    printf("\nDone");
    return 0;
}

编辑: 您在ideone 上的代码有一个jmp,原因不同:它相当于:

static const char* LC0 ="Zero";
static const char* LC1 ="One";
static const char* LC2 ="Two";
static const char* LC3 ="Three";
static const char* LC4 ="Four";

int main()
{
    unsigned int input;
    scanf("%u", &input);

    switch (input)
    {
        case 0: printf(LC0); break;
        case 1: printf(LC1); break;
        case 2: printf(LC2); break;
        case 3: printf(LC3); break;
        case 4: printf(LC4); break;
    }
    printf("\nDone");
    return 0;
}

【讨论】:

  • 谢谢!尽管如此,即使在这种情况下,编译器也会输出jmpideone.com/FBHuZ
  • 哦,真的。我稍微修改了第一个文件:ideone.com/GJPQi 不过,输出中有jmpideone.com/F9AMo
  • @Joulukuusi 但是所有的跳转都会导致共同的延续(尽管它出现在汇编输出的较早点)。
  • 如果可能的话,您能否提供一个最低限度的测试?我似乎想不出一个不同的结局——如果我把printf扔掉,编译器会扔掉剩下的。
  • @Joulukuusi 尝试将一个printf 替换为puts,将另一个替换为fputs("...", stdout),再将另一个替换为fprintf(stdout, "..."),以避免优化共享调用。
【解决方案2】:

因为函数指针数组是可变的。编译器决定它不能假设指针不会改变。您可能会发现 C++ 的程序集不同,和/或将 jt 设为 const。

【讨论】:

  • 我并不特别赞同这个论点。证明数组永远不会改变是微不足道的。它被声明为main 的本地地址,并且永远不会在main() 内修改 - 也不会占用它的地址。不过,编译器是否会进行这种数据依赖性分析是另一回事。
  • 那个,和jt const 没有任何改变。
  • 谢谢!不幸的是,将 jt 的声明更改为 const void (* const jt[])() 并没有帮助。
【解决方案3】:

编译器编写者有很多工作要做。显然,他们优先考虑回报最大和最快的工作。

switch 语句在各种代码中都很常见,因此对它们执行的任何优化都会对许多程序产生影响。

这段代码

jt[input](); 

不太常见,因此在编译器设计者的 TODO 列表中要长得多。也许他们还没有(还)发现尝试优化它是值得的?这会为他们赢得任何已知的基准吗?或者改进一些广泛使用的代码库?

【讨论】:

    【解决方案4】:

    您是否分析过不同的代码?我认为可能会提出一个论点,即间接调用 已优化。以下分析是使用针对 x64 平台 (MinGW) 的 GCC 4.6.1 完成的。

    如果您查看使用 jt[input]() 时发生的情况,调用会导致执行以下代码序列:

    • 间接调用printX() 函数之一
    • printX() 函数为printf() 设置参数,然后
    • 跳转到printf()
    • printf() 调用将直接返回到`间接调用的站点。

    总共3个分支。

    当你使用 switch 语句时会发生什么:

    • 为每种情况间接跳转到一些自定义代码(内联printX() 调用)
    • “案例处理程序”为printf() 调用加载适当的参数
    • 致电printf()
    • printf() 调用将返回到“案例处理程序”
    • 跳转到 switch 的退出点(除了一个内联退出代码的 case 处理程序 - 其他 case 跳转到那里)

    一共4个分支(一般情况下)。

    在这两种情况下,您都有: - 一个间接分支(一个是调用,另一个是跳转) - 到printf() 的一个分支(一个是跳转,另一个是调用) - 返回调用站点的分支

    但是,当使用 switch 语句时,会有一个额外的分支到达开关的“结束”(在大多数情况下)。

    现在,如果您实际分析事物,处理器可能会比间接调用更快地处理间接跳转,但我猜即使是这种情况,基于开关的代码中使用的附加分支仍然会通过函数指针推动比例以支持调用。


    对于那些感兴趣的人,这里是使用 jk[input](); 生成的汇编程序(两个示例都是使用针对 x64 的 GCC MinGW 4.6.1 编译的,使用的选项是 -Wall -Winline -O3 -S -masm=intel):

    print0:
        .seh_endprologue
        lea rcx, .LC4[rip]
        jmp printf
        .seh_endproc
    
    // similar code is generated for each printX() function
    // ...
    
    main:
        sub rsp, 56
        .seh_stackalloc 56
        .seh_endprologue
        call    __main
        lea rdx, 44[rsp]
        lea rcx, .LC5[rip]
        call    scanf
        mov edx, DWORD PTR 44[rsp]
        lea rax, jt.2423[rip]
        call    [QWORD PTR [rax+rdx*8]]
        xor eax, eax
        add rsp, 56
        ret
    

    下面是为基于开关的实现生成的代码:

    main:
        sub rsp, 56
        .seh_stackalloc 56
        .seh_endprologue
        call    __main
        lea rdx, 44[rsp]
        lea rcx, .LC0[rip]
        call    scanf
        cmp DWORD PTR 44[rsp], 4
        ja  .L2
        mov edx, DWORD PTR 44[rsp]
        lea rax, .L8[rip]
        movsx   rdx, DWORD PTR [rax+rdx*4]
        add rax, rdx
        jmp rax
        .section .rdata,"dr"
        .align 4
    .L8:
        .long   .L3-.L8
        .long   .L4-.L8
        .long   .L5-.L8
        .long   .L6-.L8
        .long   .L7-.L8
        .section    .text.startup,"x"
    .L7:
        lea rcx, .LC5[rip]
        call    printf
        .p2align 4,,10
    
    
    .L2:
        xor eax, eax
        add rsp, 56
        ret
    
    .L6:
        lea rcx, .LC4[rip]
        call    printf
        jmp .L2
    
         // all the other cases are essentially the same as the one above (.L6)
         // where they jump to .L2 to exit instead of simply falling through to it
         // like .L7 does
    

    【讨论】:

    • 谢谢!我想描述纯粹的指令,但我找不到这样做的方法。我的程序集输出与您的略有不同 - 使用 jt[input]() 时生成了 call _printf(而不是您的 jmp printf)。此外,在调用printf 之前和之后还有一些堆栈对齐。这有关系吗?
    • @Joulukuusi:我认为您正在生成 32 位 x86 代码,而我正在生成 64 位 x64 代码。对于间接调用情况,x86 代码生成似乎不如 x64 代码生成优化。但是,我仍然不确定它最终是否不如 switch 语句优化(但也许)。我认为堆栈调整是由于调用争用要求(?)而完成的。这些调整可以在switch 中优化掉,因为它能够内联间接调用,因为通过特定指针有单独的间接调用。
    • 我认为原则上,编译器可以将 jt[input]() 调用“扩展”为五个单独的直接调用,并最终得到与 switch 语句相同的代码,但正如 Bo Persson 所提到的那样,这种情况可能还不够常见,不足以引起编译器维护人员的注意。请注意,调用执行堆栈对齐的函数的 x86 代码仍然具有与 switch 语句产生的“优化”代码相似数量的分支(根据我的计数,两种方式都有 4 个分支)。所以它的性能仍然可能与 x86 上的 switch 几乎相同。
    • 我试图在我的电脑上分析这两种情况。以下是所用源的链接:ideone.com/WE5N4ideone.com/OQJa8 我确保两个汇编输出都包含跳转表。 sample.bin 是一个随机的 250MB zip 存档。根据gprof,第一个程序运行了3.80s,第二个程序运行了4.69s。你怎么看,我能相信这个结果吗?
    • @Joulukuusi:20% 的差异非常有说服力;它这么大也很令人惊讶。但话又说回来,这类事情往往会产生令人惊讶的结果。您是否多次运行这两种情况(在两者之间交替)只是为了查看结果是否一致?
    【解决方案5】:

    后一个函数的代码在间接的call 和后续的ret 之间没有任何作用吗?如果间接调用的地址计算使用后一个函数需要保留其值的寄存器,我不会感到惊讶(这意味着它必须在计算之前保存该值,并在一段时间后恢复它)。虽然可以在间接调用之前移动寄存器恢复代码,但编译器只能在其被编程识别为合法机会的情况下执行此类代码移动。

    另外,虽然我认为这并不重要,但我建议例程不应该是 inline,因为编译器将无法以这种方式执行它们。

    【讨论】:

    • 谢谢!是的,它使用eax 进行地址计算 - call [DWORD PTR _jt.1677[0+eax*4]]。紧随其后(即在被调用函数返回之后)跟随xor eax,eaxleaveret。找不到为什么必须保留eax。顺便说一句,我已经在问题中包含了汇编输出的链接。关于inline 的建议 - 你能解释一下吗?
    • @Joulukuusi:返回值在eax中。由于编译器无法推断被调用函数返回时 eax 中的值,因此它必须将 eax 加载为零。如果您要从void 函数进行间接调用,或者调用int 类型的函数并返回从该函数返回的值,则可以消除xor,也许允许使用间接@987654334 @ 而不是call
    【解决方案6】:

    第一种情况(通过switch())为我创建了以下内容(Linux x86_64 / gcc 4.4):

      400570:       ff 24 c5 b8 06 40 00    jmpq   *0x4006b8(,%rax,8)
    [ ... ]
      400580:       31 c0                   xor    %eax,%eax
      400582:       e8 e1 fe ff ff          callq  400468 <printf@plt>
      400587:       31 c0                   xor    %eax,%eax
      400589:       48 83 c4 08             add    $0x8,%rsp
      40058d:       c3                      retq
      40058e:       bf a4 06 40 00          mov    $0x4006a4,%edi
      400593:       eb eb                   jmp    400580 <main+0x30>
      400595:       bf a9 06 40 00          mov    $0x4006a9,%edi
      40059a:       eb e4                   jmp    400580 <main+0x30>
      40059c:       bf ad 06 40 00          mov    $0x4006ad,%edi
      4005a1:       eb dd                   jmp    400580 <main+0x30>
      4005a3:       bf b1 06 40 00          mov    $0x4006b1,%edi
      4005a8:       eb d6                   jmp    400580 <main+0x30>
    [ ... ]
    Contents of section .rodata:
    [ ... ]
     4006b8 8e054000 p ... ]
    

    注意.rodata 内容@4006b8 是打印的网络字节顺序(无论出于何种原因......),值是40058e,它在上面的main 内 - 其中arg-initializer/jmp 块开始。那里的所有mov/jmp 对使用八个字节,因此(,%rax,8) 间接。因此,在这种情况下,序列为:

    jmp <to location that sets arg for printf()>
    ...
    jmp <back to common location for the printf() invocation>
    ...
    call <printf>
    ...
    retq
    

    这意味着编译器实际上优化了 static 调用站点 - 而是将它们全部合并为一个内联的 printf() 调用。这里使用的表格是jmp ...(,%rax,8)指令,表格包含在程序代码中

    第二个(带有显式创建的表)为我执行以下操作:

    0000000000400550 <print0>:
    [ ... ]
    0000000000400560 <print1>:
    [ ... ]
    0000000000400570 <print2>:
    [ ... ]
    0000000000400580 <print3>:
    [ ... ]
    0000000000400590 <print4>:
    [ ... ]
    00000000004005a0 <main>:
      4005a0:       48 83 ec 08             sub    $0x8,%rsp
      4005a4:       bf d4 06 40 00          mov    $0x4006d4,%edi
      4005a9:       31 c0                   xor    %eax,%eax
      4005ab:       48 8d 74 24 04          lea    0x4(%rsp),%rsi
      4005b0:       e8 c3 fe ff ff          callq  400478 <scanf@plt>
      4005b5:       8b 54 24 04             mov    0x4(%rsp),%edx
      4005b9:       31 c0                   xor    %eax,%eax
      4005bb:       ff 14 d5 60 0a 50 00    callq  *0x500a60(,%rdx,8)
      4005c2:       31 c0                   xor    %eax,%eax
      4005c4:       48 83 c4 08             add    $0x8,%rsp
      4005c8:       c3                      retq
    [ ... ]
     500a60 50054000 00000000 60054000 00000000  P.@.....`.@.....
     500a70 70054000 00000000 80054000 00000000  p.@.......@.....
     500a80 90054000 00000000                    ..@.....
    

    再次注意,当 objdump 打印数据部分时,字节顺序颠倒了 - 如果你把它们反过来,你会得到 print[0-4]() 的函数地址。

    编译器通过间接call 调用目标 - 即表的使用直接在call 指令中,并且表已_显式地创建为数据。

    编辑:
    如果您像这样更改源:

    #include <stdio.h>
    
    static inline void print0() { printf("Zero"); }
    static inline void print1() { printf("One"); }
    static inline void print2() { printf("Two"); }
    static inline void print3() { printf("Three"); }
    static inline void print4() { printf("Four"); }
    
    void main(int argc, char **argv)
    {
        static void (*jt[])() = { print0, print1, print2, print3, print4 };
        return jt[argc]();
    }

    main() 创建的程序集变为:

    0000000000400550 <main>:
      400550:       48 63 ff                movslq %edi,%rdi
      400553:       31 c0                   xor    %eax,%eax
      400555:       4c 8b 1c fd e0 09 50    mov    0x5009e0(,%rdi,8),%r11
      40055c:       00
      40055d:       41 ff e3                jmpq   *%r11d
    

    哪个看起来更像你想要的?

    这样做的原因是您需要“无堆栈”函数才能执行此操作 - 尾递归(通过 jmp 而不是 ret 从函数返回)只有在您完成所有堆栈时才有可能已经清理了,或者不必做任何事情,因为您在堆栈上没有什么要清理的。编译器可以(但不需要)选择在最后一次函数调用之前进行清理(在这种情况下,最后一次调用可以由 jmp 进行),但只有当您返回从该函数获得的值时才有可能,或者如果你“返回void”。而且,如前所述,如果您实际上使用堆栈(就像您的示例对input 变量所做的那样),则没有什么可以使编译器强制以尾递归结果的方式撤消此操作。

    编辑2:

    第一个示例的反汇编,具有相同的更改(argc 代替 input 并强制 void main - 没有标准符合性的 cmets,请这是一个演示),导致以下汇编:

    0000000000400500 <main>:
      400500:       83 ff 04                cmp    $0x4,%edi
      400503:       77 0b                   ja     400510 <main+0x10>
      400505:       89 f8                   mov    %edi,%eax
      400507:       ff 24 c5 58 06 40 00    jmpq   *0x400658(,%rax,8)
      40050e:       66                      data16
      40050f:       90                      nop
      400510:       f3 c3                   repz retq
      400512:       bf 3c 06 40 00          mov    $0x40063c,%edi
      400517:       31 c0                   xor    %eax,%eax
      400519:       e9 0a ff ff ff          jmpq   400428 <printf@plt>
      40051e:       bf 41 06 40 00          mov    $0x400641,%edi
      400523:       31 c0                   xor    %eax,%eax
      400525:       e9 fe fe ff ff          jmpq   400428 <printf@plt>
      40052a:       bf 46 06 40 00          mov    $0x400646,%edi
      40052f:       31 c0                   xor    %eax,%eax
      400531:       e9 f2 fe ff ff          jmpq   400428 <printf@plt>
      400536:       bf 4a 06 40 00          mov    $0x40064a,%edi
      40053b:       31 c0                   xor    %eax,%eax
      40053d:       e9 e6 fe ff ff          jmpq   400428 <printf@plt>
      400542:       bf 4e 06 40 00          mov    $0x40064e,%edi
      400547:       31 c0                   xor    %eax,%eax
      400549:       e9 da fe ff ff          jmpq   400428 <printf@plt>
      40054e:       90                      nop
      40054f:       90                      nop
    

    这在某方面更糟(两个 jmp 而不是一个)但在另一种方面更好(因为它消除了static 函数并内联代码)。在优化方面,编译器几乎做了同样的事情。

    【讨论】:

    • 谢谢!我得到了非常接近的结果——汇编输出的链接在 OP 帖子中。但是,问题是 - 为什么编译器不将第二种情况下的 call 优化为两个 jmps(就像第一种情况一样)?这将消除堆栈操作,这可能比两个 jmps 慢。
    • 我不太明白你的意思;在这两种情况下,您都是通过call 调用实际函数。 IE。此处创建的main() 不是尾递归(如果它使用jmp print... 而不是call print...; retq,那就是这样)。但原因是 main() 执行 return 0 的事实 - 因此它不能是尾递归
    • 但是关于堆栈使用,还有一个事实是要考虑将&amp;input(本地变量的地址)提供给scanf() 强制堆栈分配。这反过来又会阻止编译器使函数尾递归,即使您将返回类型设置为void。查看我的编辑。
    • 抱歉我的措辞糟糕!在第一种情况下,编译器通过jmp [DWORD PTR L8[0+eax*4]] 调用任何printX 函数。在第二种情况下,这是由call [DWORD PTR _jt.1677[0+eax*4]] 完成的,另外每个printX 函数都执行sub esp, 28add esp, 28。我认为subadd 是多余的,如果编译器将call 更改为两个jmps - 一个在printX 标签内,一个回到call 之后的位置。
    • 您的程序集适用于 32 位...在这种情况下,您不能拥有无堆栈代码,因为参数在堆栈上传递,而您的 printX 将参数传递给printf()。必须以某种方式为此分配空间。在 64 位模式下(如上图所示,您的源的 mod)main()print[0-4]() 都以 jmp 结尾。
    猜你喜欢
    • 2018-02-05
    • 2016-07-28
    • 2020-05-19
    • 2017-05-26
    • 1970-01-01
    • 2021-09-14
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多