是什么阻止了将函数参数用作隐藏指针?

What prevents the usage of a function argument as hidden pointer?

我试图理解 System V AMD64 - ABI's calling convention 的含义并查看以下示例:

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

struct Vec3 do_something(void);

void use(struct Vec3 * out){
    *out = do_something();
}

A Vec3-变量是 MEMORY 类型,因此调用者 (use) 必须为 returned 变量分配 space 并将其作为隐藏指针传递给被叫方(即 do_something)。这就是我们在生成的汇编程序中看到的(on godbolt,用 -O2 编译):

use:
        pushq   %rbx
        movq    %rdi, %rbx           ;remember out
        subq    , %rsp            ;memory for returned object
        movq    %rsp, %rdi           ;hidden pointer to %rdi
        call    do_something
        movdqu  (%rsp), %xmm0        ;copy memory to out
        movq    16(%rsp), %rax
        movups  %xmm0, (%rbx)
        movq    %rax, 16(%rbx)
        addq    , %rsp            ;unwind/restore
        popq    %rbx
        ret

我明白,指针 out 的别名(例如作为全局变量)可以在 do_something 中使用,因此 out 不能作为隐藏指针传递给 do_something:如果可以,out 将在 do_something 内更改,而不是在 do_something return 时更改,因此某些计算可能会出错。例如这个版本的 do_something 会 return 错误的结果:

struct Vec3 global; //initialized somewhere
struct Vec3 do_something(void){
   struct Vec3 res;
   res.x = 2*global.x; 
   res.y = global.y+global.x; 
   res.z = 0; 
   return res;
}

if out where an alias for the global variable global and were used as hidden pointer passed in %rdi, res 也是 [=30] 的别名=],因为编译器会直接使用隐藏指针指向的内存(C中的一种RVO),而不会在returned时实际创建一个临时对象并复制它,那么res.y就是2*x+y(如果 x,yglobal 的旧值)而不是 x+y 作为任何其他隐藏指针。

有人向我建议,使用 restrict 应该可以解决问题,即

void use(struct Vec3 *restrict out){
    *out = do_something();
}

因为现在编译器知道 out 没有可以在 do_something 中使用的别名,所以汇编器可以像这样简单:

use:
    jmp     do_something ; %rdi is now the hidden pointer

然而,gcc 和 clang 都不是这种情况 - 汇编器保持不变(参见 godbolt)。

是什么阻止了将 out 用作隐藏指针?


注意:所需的(或非常相似的)行为将通过稍微不同的函数签名实现:

struct Vec3 use_v2(){
    return do_something();
}

这导致(见 godbolt):

use_v2:
    pushq   %r12
    movq    %rdi, %r12
    call    do_something
    movq    %r12, %rax
    popq    %r12
    ret

大幅改写:

I understand, that an alias of pointer out (e.g. as global variable) could be used in do_something and thus [out] cannot be passed as hidden pointer to do_something: if it would, out would be changed inside of do_something and not when do_something returns, thus some calculations might become faulty.

除了 do_something() 中的别名考虑外,timing 相对于 *out 修改时间的差异是无关紧要的,因为 use()的来电者无法区分。此类问题仅在与其他线程的访问相关时出现,如果有可能,那么除非应用适当的同步,否则它们无论如何都会出现。

不,问题主要在于 ABI 定义了如何将参数传递给函数并接收它们的 return 值。它指定

If the type has class MEMORY, then the caller provides space for the return value and passes the address of this storage in %rdi

(强调已添加)。

我承认有解释的余地​​,但我认为这是比调用者指定存储 return 值的位置更强有力的声明。它 "provides" space 对我来说意味着所讨论的 space 属于调用者(您的 *out 不属于调用者)。通过类比参数传递,有充分的理由将其更具体地解释为调用者在堆栈 (因此在其自己的堆栈帧中)为 return 值,实际上这正是您观察到的值,尽管细节并不重要。

根据该解释,被调用函数可以自由假设 return 值 space 与任何 space 不相交,它可以通过除它的一个指针之外的任何指针访问争论。这是由更一般的要求补充的,即 return space 不被别名( 也不通过函数参数)并不与该解释相矛盾。因此,如果 space 实际上是函数可访问的其他内容的别名,它可能会执行不正确的操作。

如果函数调用要与单独编译的 do_something() 函数一起正常工作,则编译器不能随意偏离 ABI 规范。特别是,对于单独编译,编译器无法根据函数调用者的特征做出决定,例如那里已知的别名信息。如果 do_something()use() 在同一个翻译单元中,那么编译器可能会选择将 so_something() 内联到 use(),或者它可能会选择执行您正在查找的优化for 没有内联,但在一般情况下不能安全地这样做。

It was suggested to me, that using restrict should solve the problem,

restrict 为编译器提供了更大的优化余地,但这本身并没有让您有任何理由期望可能会进行特定的优化。事实上,语言标准明确规定

A translator is free to ignore any or all aliasing implications of uses of restrict.

(C2011, 6.7.3.1/6)

restrict-限定 out 表示编译器不需要担心它被别名化为在对 use() 的调用范围内访问的任何其他指针,包括在函数执行期间它调用的其他函数。那么,原则上,我可以看到一个编译器利用它来通过为 return 值提供其他人的 space 而不是提供 space 本身来缩短 ABI,但只是因为它可以做不代表一定会做。

What prevents the usage of out as hidden pointer?

ABI 合规性。调用者应该提供属于它而不是其他人的 space 来存储 return 值。然而,作为一个实际问题,我在符合 restrict 条件的情况下没有看到任何会使 ABI 快捷方式无效的情况,因此我认为这不是相关编译器已实现的优化。

NB: The desired (or very similar) behavior would be achieved for a slightly different function-signature: [...]

在我看来,这种情况像是尾调用优化。我没有看到执行该优化的编译器有任何内在的不一致,但不是你所询问的那个,尽管可以肯定的是,这是一个不同的 ABI 快捷方式示例。

允许函数假定其 return 值对象(由隐藏指针指向)与 anything 其他对象不同。即它的输出指针(作为隐藏的第一个参数传递)没有任何别名。

您可以将其视为隐藏的第一个 arg 输出指针,其上有一个隐式 restrict。 (因为在C抽象机中,return值是一个单独的对象,而x86-64 System V指定调用者提供space。x86- 64 SysV 不授予调用者引入别名的许可。)

使用其他私有的本地作为目的地(而不是单独的专用 space 然后复制到真正的本地)是可以的,但是不能使用可能指向可以通过其他方式到达的东西的指针。这需要进行逃逸分析,以确保指向此类局部变量的指针未传递到函数外部。

我认为 x86-64 SysV 调用约定通过让 调用者 提供一个真正的 return 值对象来模拟 C 抽象机,而不是强制 callee 在需要时创建临时文件,以确保对 retval 的所有写入都发生在任何其他写入之后。这不是 "the caller provides space for the return value" 的意思,IMO。

这绝对是 GCC 和其他编译器在实践中解释它的方式,这是调用约定中重要的重要部分,该约定已经存在了这么长时间(从第一个 AMD64 硅之前一两年开始,所以在 2000 年代初期).


在这种情况下,您的优化会在完成后中断:

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

__attribute__((noinline))
struct Vec3 do_something(void) {  // copy glob3 to retval in some order
    return (struct Vec3){glob3.y, glob3.z, glob3.x};
}

__attribute__((noinline))
void use(struct Vec3 * out){   // copy do_something() result to *out
    *out = do_something();
}


void caller(void) {
    use(&glob3);
}

根据您建议的优化,do_something 的输出对象将是 glob3。但它也显示为 glob3.

do_something 的有效实现是按源顺序将元素从 glob3 复制到 (%rdi),这会在读取 glob3.x 之前执行 glob3.x = glob3.y作为 return 值的第三个元素。

这实际上 gcc -O1 的作用 (Godbolt compiler explorer)

do_something:
    movq    %rdi, %rax               # tmp90, .result_ptr
    movsd   glob3+8(%rip), %xmm0      # glob3.y, glob3.y
    movsd   %xmm0, (%rdi)             # glob3.y, <retval>.x
    movsd   glob3+16(%rip), %xmm0     # glob3.z, _2
    movsd   %xmm0, 8(%rdi)            # _2, <retval>.y
    movsd   glob3(%rip), %xmm0        # glob3.x, _3
    movsd   %xmm0, 16(%rdi)           # _3, <retval>.z
    ret     

注意 glob3.y, <retval>.x 存储在加载 glob3.x 之前。

因此,如果源中的任何地方都没有 restrict,GCC 已经为 do_something 发出了 asm,假设 retval 和 glob3.[=92= 之间没有别名]


我不认为使用 struct Vec3 *restrict out 一点帮助都没有:这只是告诉编译器在 use() 中你不会通过任何其他方式访问 *out 对象姓名。由于 use() 不引用 glob3,因此将 &glob3 作为参数传递给 use.

restrict 版本不是 UB

我这里可能是错的; @M.M 在评论中争辩说 *restrict out 可能会使此优化安全,因为 do_something() 的执行发生在 out() 期间。 (编译器实际上仍然没有这样做,但也许他们会被允许用于 restrict 指针。)

更新:Richard Biener said 在 GCC 未优化错误报告中 M.M 是正确的 ,如果编译器可以证明函数 returns 通常(不是异常或 longjmp),优化在理论上是合法的(但仍然不是 GCC 可能寻找的东西):

If so, restrict would make this optimization safe if we can prove that do_something is "noexcept" and doesn't longjmp.

是的。

有一个 noexecpt 声明,但没有(据我所知)可以放在原型上的 nolongjmp 声明。

所以这意味着只有当我们可以看到另一个函数的主体时,它才有可能(即使在理论上)作为过程间优化。除非noexcept也意味着没有longjmp.

@JohnBollinger 和@PeterCordes 的回答为我解决了很多问题,但我决定 bug gcc-developers。这是我对他们的回答的理解。

正如@PeterCordes 所指出的,被调用方假设隐藏指针是限制性的。然而,它也做出了另一个(不太明显的)假设:隐藏指针指向的内存是未初始化.

为什么这很重要,借助 C++ 示例可能更容易理解:

struct Vec3 do_something(void){
   struct Vec3 res;
   res.x = 0.0; 
   res.y = func_which_throws(); 
   res.z = 0.0; 
   return res;
}

do_something 直接写入 %rdi 指向的内存(如本问答中的多个清单所示),并且允许这样做,只是因为该内存是 uninitialized:如果 func_which_throws() 抛出并且异常在某处被捕获,那么没有人会知道,我们只更改了结果的 x 分量,因为没有人知道它之前有哪个原始值传递给 do_something(没有人可以读取原始值,因为它是 UB)。

上面的代码会因为 out-pointer 作为隐藏指针传递而中断,因为可以观察到,在抛出和捕获异常的情况下,只有一部分而不是整个内存被更改。

现在,C 有一些类似于 C++ 的异常:setjmp and longjmp。以前从未听说过它们,但与 C++ 相比,它看起来像是示例 setjmp 最好描述为 try ... catch ...longjmp 最好描述为 throw

这意味着,同样对于 C,我们必须确保调用者提供的 space 未初始化。

即使没有 setjmp/longjmp 也存在一些其他问题,其中包括:与 C++ 代码的互操作性,它有例外,以及 gcc 编译器的 -fexceptions 选项。


推论:如果我们有一个用于单元化内存的限定符(我们没有),那么所需的优化将是可能的,例如uninit,然后

void use(struct Vec3 *restrict uninit out);

会成功的。