为什么不对 class MEMORY 类型执行尾调用优化?

Why is tailcall optimization not performed for types of class MEMORY?

我正在尝试理解 System V AMD64 - ABI 从函数按值返回的含义。

对于以下数据类型

struct Vec3{
    double x, y, z;
};

Vec3 类型属于 class MEMORY,因此 ABI 指定了关于 "Returning of Values" 的以下内容:

  1. If the type has class MEMORY, then the caller provides space for the return value and passes the address of this storage in %rdi as if it were the first argument to the function. In effect, this address becomes a “hidden” first argument. This storage must not overlap any data visible to the callee through other names than this argument.

    On return %rax will contain the address that has been passed in by the caller in %rdi.

考虑到这一点,以下(愚蠢的)功能:

struct Vec3 create(void);

struct Vec3 use(){
    return create();
}

可以编译为:

use_v2:
        jmp     create

在我看来,正如 ABI 向我们保证的那样,可以执行尾调用优化,create 会将 %rdi 传递的值放入 %rax 寄存器。

但是,none 的编译器(gcc、clang、icc)似乎正在执行此优化(此处 on godbolt)。生成的汇编代码将 %rdi 保存在堆栈上,只是为了能够将其值移动到 %rax,例如 gcc:

use:
        pushq   %r12
        movq    %rdi, %r12
        call    create
        movq    %r12, %rax
        popq    %r12
        ret

无论是对于这个最小的、愚蠢的函数还是对于现实生活中更复杂的函数,都不会执行尾调用优化。这让我相信,我一定是遗漏了一些禁止它的东西。


不用说,对于 class SSE 的类型(例如只有 2 个而不是 3 个双打),执行尾调用优化(至少通过 gcc 和 clang,live on godbolt):

struct Vec2{
    double x, y;
};

struct Vec2 create(void);

struct Vec2 use(){
    return create();
}

结果

use:
        jmp     create

System V AMD64 - ABI will return data from a function in registers RDX and RAX or XMM0 and XMM1. Looking at Godbolt 优化似乎是基于大小。编译器只会 return 最多 2 double 或 4 float 在寄存器中。


编译器总是错过优化。与 Scheme 不同,C 语言没有尾调用优化。 GCC 和 Clang 表示他们没有计划尝试并保证尾调用优化。听起来 OP 可以尝试询问编译器开发人员或使用所述编译器打开错误。

如果还没有为 gcc 和 clang 打开的副本,您应该报告一个遗漏的优化错误。

(在这种情况下,gcc 和 clang 都有相同的错过优化的情况并不少见;do not 仅仅因为编译器就认为某些事情是非法的不要这样做。 唯一有用的数据是当编译器 执行 执行优化时:它要么是编译器错误,要么至少一些编译器开发人员根据他们对任何标准的解释。)


我们可以看到 GCC 正在 returning 它自己的传入 arg 而不是 returning 它的副本,create() 将在 RAX 中 return。 是阻止尾调用优化的错过优化。

ABI 需要一个具有 MEMORY 类型 return 值的函数到 return RAX1 中的 "hidden" 指针。

GCC/clang 确实已经意识到他们可以通过传递自己的 return-值 space 来省略实际复制,而不是分配新的 space。但是要进行尾调用优化,他们必须意识到他们可以将被调用者的 RAX 值保留在 RAX 中,而不是将传入的 RDI 保存在调用保留寄存器中。

如果 ABI 不需要 returning RAX 中的隐藏指针,我预计 gcc/clang 将传入的 RDI 作为优化尾调用的一部分传递没有问题。

通常编译器喜欢缩短依赖链;这可能就是这里发生的事情。编译器不知道从 rdi arg 到 create()rax 结果的延迟可能只是一条 mov 指令。具有讽刺意味的是,如果被调用者 saves/restores 一些调用保留寄存器(如 r12),引入 return 地址指针的 store/reload,这可能是一种悲观。 (但这主要只在有任何东西使用它时才重要。我确实得到了一些 clang 代码来这样做,见下文。)


脚注 1:返回指针听起来是个好主意,但几乎总是调用者已经知道将 arg 放在自己的堆栈帧中的位置,并且只会使用像 8(%rsp) 这样的寻址模式而不是实际使用 RAX。至少在编译器生成的代码中,RAX return 值通常不会被使用。 (如有必要,调用者可以随时将其保存在自己的某个地方。)

正如 中所讨论的,在调用者的堆栈帧中使用 space 以外的任何东西来接收 retval 存在严重障碍。

如果调用者想将地址存储在某个地方(如果它是静态地址或堆栈地址),则将指针放在寄存器中只会在调用者中保存一个 LEA。

但是,这种情况接近 有用的情况。 如果我们传递自己的 retval space 到子函数,我们可能希望在调用后 修改 即 space。然后它对于轻松访问 space 很有用,例如在 return.

之前修改 return 值
#define T struct Vec3

T use2(){
    T tmp = create();
    tmp.y = 0.0;
    return tmp;
}

高效的手写asm:

use2:
        callq   create
        movq    [=11=], 8(%rax)
        retq

实际的 clang asm 至少仍然使用 return-value 优化,而不是 GCC9.1 复制。 (Godbolt)

# clang -O3
use2:                                   # @use2
        pushq   %rbx
        movq    %rdi, %rbx
        callq   create
        movq    [=12=], 8(%rbx)
        movq    %rbx, %rax
        popq    %rbx
        retq

这个 ABI 规则可能专门针对这种情况而存在,或者 ABI 设计者可能想象 retval space 可能是新分配的动态存储(调用者 如果 ABI 没有在 RAX 中提供它,则必须保存一个指针)。我没试过那种情况。