是什么阻止了将函数参数用作隐藏指针?
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,y
是 global
的旧值)而不是 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);
会成功的。
我试图理解 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,y
是 global
的旧值)而不是 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 indo_something
and thus [out
] cannot be passed as hidden pointer todo_something
: if it would,out
would be changed inside ofdo_something
and not whendo_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);
会成功的。