让 G++ 使用自定义调用约定在寄存器而不是内存中传递更大的结构?
Get G++ to use a custom calling convention to pass larger structs in registers instead of memory?
小问题:
g++ 中是否有可用的编译器选项或函数属性强制编译器通过寄存器而不是堆栈传递结构成员。
长问题:
在我的应用程序中,我有一个函数句柄列表,我基本上是在循环中调用它们。由于每个函数只做少量工作,因此需要最小化函数调用开销。
我现在想在结构中传递参数。这样做的好处是,参数的更改只需要在一个地方完成,而不是在整个代码库中的 20 个地方。另一个优点是,一些参数基于添加或删除参数的模板参数。使用结构可以克服这个问题。
现在的问题是,如果结构有两个以上的成员,g++ 会将结构压入堆栈,而不是在寄存器中传递参数。这会导致性能下降 50%。我制作了一个小例子来说明问题:
#include <iostream>
struct A {
uint8_t n;
size_t& __restrict__ dataPos;
char* const __restrict__ data;
};
struct B {
size_t& __restrict__ dataPos;
char* const __restrict__ data;
};
__attribute__((noinline)) void funcStructA(A a) {
std::cout << "out struct A: n: " << a.n << " dataPos: " << a.dataPos << " data: " << a.data << std::endl;
}
__attribute__((noinline)) void funcStructB(uint8_t n, B b) {
std::cout << "out struct B: n: " << n << " dataPos: " << b.dataPos << " data: " << b.data << std::endl;
}
__attribute__((noinline)) void funcDirect(uint8_t n, size_t& __restrict__ dataPos, char* const __restrict__ data) {
std::cout << "out direct: n: " << n << " dataPos: " << dataPos << " data: " << data << std::endl;
}
int main(int nargs, char** args) {
char data[1000];
size_t pos = 100;
funcStructA(A{10, pos, data});
funcStructB(10, B{pos, data});
funcDirect(10, pos, data);
return 0;
}
main中的汇编代码(g++ -std=c++14 -O3, version 11.2.1 20220127 (Red Hat 11.2.1-9))为:
401119: push QWORD PTR [rsp+0x10]
40111d: push QWORD PTR [rsp+0x10]
401121: push QWORD PTR [rsp+0x38]
401125: call 401280 <funcStructA(A)>
40112a: add rsp,0x20
40112e: mov rsi,rbp
401131: mov rdx,r12
401134: mov edi,0xa
401139: call 4013a0 <funcStructB(unsigned char, B)>
40113e: mov rdx,r12
401141: mov rsi,rbp
401144: mov edi,0xa
401149: call 4014c0 <funcDirect(unsigned char, unsigned long&, char*)>
在functStructA
中,结构被压入堆栈,对于funcStructB
,成员通过寄存器传递。
我尝试在结构中移动 n
或通过引用传递它,但行为始终相同。
我通读了 gnu (https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attributes.html#Common-Function-Attributes, https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html#x86-Function-Attributes) 中可用的属性,但找不到符合我的问题的属性。我尝试了 cdcl
、fastcall
、ms_abi
,但变化不大。
通过引用传递结构会导致同样的问题。
clang++好像也有同样的问题。我会在接下来的几天运行进行测试。
如有任何帮助,我们将不胜感激。
您可以将 uint8_t
或其中一个指针作为单独的 arg 传递,以向编译器描述您想要的内容,或者将其填充到现有的 64 位成员之一(见下文)。
很遗憾,没有编译器选项 调整 C ABI / calling-convention 规则以在 x86-64 或寄存器中传递大于 16 字节的结构其他 ISA。 x86-64 System V ABI 不这样做,并且 GCC 不知道另一个调用约定。 Windows x64 ABI 最多只能在寄存器中传递 8 个字节的对象,甚至不能传递 16 个。
此外,您不能覆盖 non-trivially-copyable 对象(或任何确切标准)在内存中传递的 C++ ABI 规则,因此它们始终有一个地址。 (例如,在 x86-64 系统 V 中按堆栈上的值。)
据我所知,修改调用约定的唯一选项是 -mabi=ms
或任何现有调用约定 GCC 知道的 select。或者影响某些寄存器是 call-preserved 还是 call-clobbered 的那些,比如 -fcall-used-
reg (GCC manual) 和一些 ABI-affecting 选项,如 -fpack-struct[=n]
并非专门针对调用约定。 (不,-fpack-struct
无济于事。将 sizeof(A)
从 24 降到 17 不会让它适合 2 个 regs。
理论上 -fwhole-program
或 -flto
,GCC 可以 发明自定义调用约定,但 AFAIK 它没有。它可以利用另一个函数不会破坏某些寄存器的事实,就 inter-procedural 优化 (IPO) 而不是内联而言,但不会改变 args 的传递方式。
处理calling-convention开销的正常方法是确保小函数内联(例如通过-flto
编译允许cross-file内联),但如果您使用函数指针或使用虚函数,这将不起作用。
这不是成员数量,而是总大小,所以 x32 ABI(32 位 pointers/references 和 size_t
)会能够通过 /return 将该结构打包到两个寄存器中。 g++ -O3 -mx32
.
(x86-64 SysV 使用与内存中相同的布局将聚合打包到 up-to-2 寄存器中,因此较小的成员意味着更多的成员适合 16 字节。)
或者如果您可以接受 32 位 按值 大小或 48 位大小,您可以将 uint8_t
打包到a uint64_t
,甚至使用位域成员。但是由于您对 size_t& __restrict__ dataPos;
有一个间接级别(引用成员),该成员基本上是另一个指针;使用 uint32_t&
没有帮助,因为指针仍然是 64 位。我假设您出于某种原因需要它作为参考。
您可以将 uint8_t
打包到指针的高位字节中。即将推出的 HW 将有一个选项来优化它,忽略高位而不是从 48 位或 57 位强制执行正确的 sign-extension。否则,您只需使用轮班和 &
和 uintptr_t
手动执行此操作:Using the extra 16 bits in 64-bit pointers
或者因为在 x86-64 上获取寄存器底部的数据 in/out 更容易/更有效(例如 zero-latency movzx r32, r8
),shift指针向左。这意味着在 deref 之前,你只需要一个算术右移来重做 sign-extension。这比 mov r64,imm64
创建 0xff00000000000000
掩码更便宜,作为奖励它 sign- 扩展便宜所以它甚至可以在内核代码中工作。
理论上,编译器甚至可以在 left-shifting 之后编写一个部分寄存器来合并一个新的低位 8,以创建此数据。 (但是,如果写入内存,重叠的 qword 和字节存储可能会更好,甚至不需要移位。如果你 re-reading 不够快导致 store-forwarding 停顿。)
(但是,如果您有一个具有 LAM 功能的 CPU,您可以使用高 8 位并让 CPU 忽略这些位。)
小问题: g++ 中是否有可用的编译器选项或函数属性强制编译器通过寄存器而不是堆栈传递结构成员。
长问题: 在我的应用程序中,我有一个函数句柄列表,我基本上是在循环中调用它们。由于每个函数只做少量工作,因此需要最小化函数调用开销。
我现在想在结构中传递参数。这样做的好处是,参数的更改只需要在一个地方完成,而不是在整个代码库中的 20 个地方。另一个优点是,一些参数基于添加或删除参数的模板参数。使用结构可以克服这个问题。
现在的问题是,如果结构有两个以上的成员,g++ 会将结构压入堆栈,而不是在寄存器中传递参数。这会导致性能下降 50%。我制作了一个小例子来说明问题:
#include <iostream>
struct A {
uint8_t n;
size_t& __restrict__ dataPos;
char* const __restrict__ data;
};
struct B {
size_t& __restrict__ dataPos;
char* const __restrict__ data;
};
__attribute__((noinline)) void funcStructA(A a) {
std::cout << "out struct A: n: " << a.n << " dataPos: " << a.dataPos << " data: " << a.data << std::endl;
}
__attribute__((noinline)) void funcStructB(uint8_t n, B b) {
std::cout << "out struct B: n: " << n << " dataPos: " << b.dataPos << " data: " << b.data << std::endl;
}
__attribute__((noinline)) void funcDirect(uint8_t n, size_t& __restrict__ dataPos, char* const __restrict__ data) {
std::cout << "out direct: n: " << n << " dataPos: " << dataPos << " data: " << data << std::endl;
}
int main(int nargs, char** args) {
char data[1000];
size_t pos = 100;
funcStructA(A{10, pos, data});
funcStructB(10, B{pos, data});
funcDirect(10, pos, data);
return 0;
}
main中的汇编代码(g++ -std=c++14 -O3, version 11.2.1 20220127 (Red Hat 11.2.1-9))为:
401119: push QWORD PTR [rsp+0x10]
40111d: push QWORD PTR [rsp+0x10]
401121: push QWORD PTR [rsp+0x38]
401125: call 401280 <funcStructA(A)>
40112a: add rsp,0x20
40112e: mov rsi,rbp
401131: mov rdx,r12
401134: mov edi,0xa
401139: call 4013a0 <funcStructB(unsigned char, B)>
40113e: mov rdx,r12
401141: mov rsi,rbp
401144: mov edi,0xa
401149: call 4014c0 <funcDirect(unsigned char, unsigned long&, char*)>
在functStructA
中,结构被压入堆栈,对于funcStructB
,成员通过寄存器传递。
我尝试在结构中移动 n
或通过引用传递它,但行为始终相同。
我通读了 gnu (https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attributes.html#Common-Function-Attributes, https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html#x86-Function-Attributes) 中可用的属性,但找不到符合我的问题的属性。我尝试了 cdcl
、fastcall
、ms_abi
,但变化不大。
通过引用传递结构会导致同样的问题。
clang++好像也有同样的问题。我会在接下来的几天运行进行测试。
如有任何帮助,我们将不胜感激。
您可以将 uint8_t
或其中一个指针作为单独的 arg 传递,以向编译器描述您想要的内容,或者将其填充到现有的 64 位成员之一(见下文)。
很遗憾,没有编译器选项 调整 C ABI / calling-convention 规则以在 x86-64 或寄存器中传递大于 16 字节的结构其他 ISA。 x86-64 System V ABI 不这样做,并且 GCC 不知道另一个调用约定。 Windows x64 ABI 最多只能在寄存器中传递 8 个字节的对象,甚至不能传递 16 个。
此外,您不能覆盖 non-trivially-copyable 对象(或任何确切标准)在内存中传递的 C++ ABI 规则,因此它们始终有一个地址。 (例如,在 x86-64 系统 V 中按堆栈上的值。)
据我所知,修改调用约定的唯一选项是 -mabi=ms
或任何现有调用约定 GCC 知道的 select。或者影响某些寄存器是 call-preserved 还是 call-clobbered 的那些,比如 -fcall-used-
reg (GCC manual) 和一些 ABI-affecting 选项,如 -fpack-struct[=n]
并非专门针对调用约定。 (不,-fpack-struct
无济于事。将 sizeof(A)
从 24 降到 17 不会让它适合 2 个 regs。
理论上 -fwhole-program
或 -flto
,GCC 可以 发明自定义调用约定,但 AFAIK 它没有。它可以利用另一个函数不会破坏某些寄存器的事实,就 inter-procedural 优化 (IPO) 而不是内联而言,但不会改变 args 的传递方式。
处理calling-convention开销的正常方法是确保小函数内联(例如通过-flto
编译允许cross-file内联),但如果您使用函数指针或使用虚函数,这将不起作用。
这不是成员数量,而是总大小,所以 x32 ABI(32 位 pointers/references 和 size_t
)会能够通过 /return 将该结构打包到两个寄存器中。 g++ -O3 -mx32
.
(x86-64 SysV 使用与内存中相同的布局将聚合打包到 up-to-2 寄存器中,因此较小的成员意味着更多的成员适合 16 字节。)
或者如果您可以接受 32 位 按值 大小或 48 位大小,您可以将 uint8_t
打包到a uint64_t
,甚至使用位域成员。但是由于您对 size_t& __restrict__ dataPos;
有一个间接级别(引用成员),该成员基本上是另一个指针;使用 uint32_t&
没有帮助,因为指针仍然是 64 位。我假设您出于某种原因需要它作为参考。
您可以将 uint8_t
打包到指针的高位字节中。即将推出的 HW 将有一个选项来优化它,忽略高位而不是从 48 位或 57 位强制执行正确的 sign-extension。否则,您只需使用轮班和 &
和 uintptr_t
手动执行此操作:Using the extra 16 bits in 64-bit pointers
或者因为在 x86-64 上获取寄存器底部的数据 in/out 更容易/更有效(例如 zero-latency movzx r32, r8
),shift指针向左。这意味着在 deref 之前,你只需要一个算术右移来重做 sign-extension。这比 mov r64,imm64
创建 0xff00000000000000
掩码更便宜,作为奖励它 sign- 扩展便宜所以它甚至可以在内核代码中工作。
理论上,编译器甚至可以在 left-shifting 之后编写一个部分寄存器来合并一个新的低位 8,以创建此数据。 (但是,如果写入内存,重叠的 qword 和字节存储可能会更好,甚至不需要移位。如果你 re-reading 不够快导致 store-forwarding 停顿。)
(但是,如果您有一个具有 LAM 功能的 CPU,您可以使用高 8 位并让 CPU 忽略这些位。)