我目前正在开发一个程序,可以在一个紧密的循环中处理大量数据。数据块被加载到YMM寄存器中,从中提取64位块以进行实际处理。
这个循环是几个循环中的一个,程序根据正在处理的数据的确切内容在这些循环之间切换。因此,每个回路必须偶尔中断(有时频繁地)以便执行所述切换。为了使整个系统更容易操作,每个循环都包含在自己的函数中。
我遇到的一个相当大的麻烦(不是第一次)是,在函数调用中保留256位和64位块是相当困难的。每个循环处理相同的数据,因此在一个循环中断时丢弃这些寄存器是没有意义的,只能立即加载完全相同的数据。这并不会真的导致任何重大的性能问题,但它是可测量的,而且总体上看起来很愚蠢。
我试过一百万种不同的方法,没有一种能给我一个合适的解决方案。当然,我可以简单地将块存储在外部切换循环中,并将它们作为引用传递给内部循环,但是对生成的程序集的快速检查表明,无论我尝试什么,GCC和Clang都恢复到指针,这破坏了优化的全部意义。
我也可以将每个循环标记为 always_inline,打开LTO,然后到此为止,但我计划添加其中一个循环的手写汇编版本,我不想被迫将其写入内联。我真正想要的是函数的声明简单地向调用者发出信号,向量(和相关信息)将作为返回值从函数中传递出来,在适当的寄存器中,允许我将开销(没有内联)减少到最多几个寄存器/寄存器mov
s。
我发现最接近的是vectorcall
调用约定,它得到MSVC的支持,至少部分得到Clang和GCC的支持。
作为参考,我目前正在使用GCC,但如果Clang有解决方案,我愿意切换到Clang。如果MSVC是唯一能够使用的编译器,我将使用内联选项。
我创建了这个简单的例子:
#include <immintrin.h>
struct HVA4 {
__m256i data[4];
};
HVA4 __vectorcall example(HVA4 x) {
x.data[0] = _mm256_permute4x64_epi64(x.data[0], 0b11001001);
x.data[2] = _mm256_permute4x64_epi64(x.data[2], 0b00111001);
return x;
}
它编译为
vpermq ymm0, ymm0, 201
vpermq ymm2, ymm2, 57
ret
在MSVC 19.35下使用/O2 /GS- /arch:avx2
。
这正是我想要的:我的向量参数在适当的SIMD寄存器中传递,并按原样返回。使用的寄存器甚至排队!从阅读MSDN文档来看,听起来我 * 应该 * 能够将其扩展到非同构聚合,即使不能,我也可以做到这一点。
然而,Clang是另一个故事。在16.0.0上使用-O3 -mavx2
会产生这样的混乱:
mov rax, rcx
vpermpd ymm0, ymmword ptr [rdx], 201
vmovaps ymmword ptr [rdx], ymm0
vpermpd ymm0, ymmword ptr [rdx + 64]
vmovaps ymmword ptr [rdx + 64], ymm0
vmovaps ymm0, ymmword ptr [rdx + 32]
vmovaps ymm1, ymmword ptr [rdx + 96]
vmovaps ymmword ptr [rcx + 96], ymm1
vmovaps ymmword ptr [rcx + 32], ymm0
vmovaps ymm0, ymmword ptr [rdx + 64]
vmovaps ymmword ptr [rcx + 64], ymm0
vmovaps ymm0, ymmword ptr [rdx]
vmovaps ymmword ptr [rcx], ymm0
vzeroupper
ret
我想展示GCC的尝试,但它可能会使这个问题的大小增加一倍。
然而,与的一般想法是相同的; GCC和Clang都完全拒绝为SIMD返回值使用多个寄存器,并且仅在某些情况下为参数使用多个寄存器(如果从结构体中删除向量,它们的情况会好得多)。虽然这可能是标准调用约定的预期行为(我怀疑他们实际上至少在返回值放置方面遵循SysV ABI),但vectorcall
* 显式地 * 允许它。
当然,vectorcall
是一个非标准属性,仅仅因为两个编译器有相同的名称并不意味着他们做同样的事情,等等,但至少Clang特别链接到MSDN文档,所以我希望它遵循它们。
这仅仅是一个bug吗?只是一个未实现的功能?(同样,它确实 * 链接 * 到MSDN文档)
此外,是否有 * 任何 * 方法可以在代码中实现MSVC给出的优化,如上面的例子,在GCC或Clang中,无论是通过调用约定,还是一些编译器特定的标志?我很乐意尝试在编译器中编写一个自定义约定,但这远远超出了本项目的范围。
1条答案
按热度按时间9jyewag01#
所有的YMM寄存器都是call-clobbered,所以非内联函数是一种在寄存器中保存任何大量数据的展示。(Windows x64约定保留了调用xmm6..15,但更广泛的YMM寄存器仍然会被调用。)相当多的整数寄存器也会被调用,特别是在x86-64 System V调用约定(非Windows)中。
如果你的程序的有价值的状态只有这4个向量和几个整数寄存器,那么是的,MSVC的x64
vectorcall
可以将向量传递给非内联函数,并将它们作为返回值返回。否则,其他状态将不得不在调用周围溢出/重新加载,因此手写asm的唯一好选择是GNU C inline asm。
x86-64 SysV返回x/y/zmm 0中的1个向量
x86-64 System V calling convention最多可以返回2个向量寄存器(xmm/ymm/zmm),就像整数参数可以在多达6个regs中传递,但只能在RDX:RAX中返回。
但是XMM 1仅在返回标量float或double的聚合时使用(总大小不超过16字节,因此返回值位于XMM 0和XMM 1的低8字节中)。ABI文档的分类规则5(c)- * 如果聚合的大小超过两个eightbyte,并且第一个eightbyte不是SSE或任何其他eightbyte不是SSEUP,则整个参数在内存中传递。这就是为什么这样一个结构体在内存中返回,而不是XMM 0,XMM 1。规则5c允许在YMM 0或ZMM 0中返回宽于16字节的单个向量(其中所有后面的八个字节都是SSEUP),而不是其他情况。
测试证实了这一点。对于
struct { __m256i v[2]; }
,GCC/clang返回内存中的值,而不是YMM 0/YMM 1,请参阅下面的Godbolt链接。但是对于struct { float v[3]; }
,我们看到v[4]
在XMM 1的元素1中返回(低64位的上半部分=一个8字节):上帝之箭因此,AMD 64 System V ABI的调用约定不适合您的用例,即使它可以在vector regs中返回2个vector。
GCC或clang中的
vectorcall
:与MSVC不同,只有1个矢量寄存器你可以用
__attribute__((ms_abi))
(gcc或clang)或__attribute__((vectorcall))
(仅clang)为asm函数声明一个原型,但这似乎并不像你描述的MSVC工作方式那样工作:多个__m256i
的结构在内存中被隐藏指针返回,即使是vectorcall
。(雷霆)Agner Fog在GCC bug报告(89485)上的评论说,针对Windows的clang确实支持
__vectorcall
,但该bug只是请求GCC支持它,而不是讨论它是否在寄存器中返回多个向量。也许clang的__vectorcall
实现与MSVC的多向量结构返回ABI不兼容?我没有Windows clang可供测试,或clang-cl,其目的是与MSVC更兼容。
asm("call foo" : "+x"(v0), ...);
Package 器也不会破坏其他规则正如你在评论中所建议的,你可以发明你自己的调用约定,并通过内联asm向编译器描述它。只要它是一个纯函数,您甚至可以避免
"memory"
的碰撞。您确实需要停止编译器在调用者中使用红色区域,因为
call
推送返回地址。参见 * Inline assembly that clobbers the red zone *编译器根本不知道是函数调用;重要的是,你的内联asm模板碰巧在堆栈上push/pop了一些东西,而不是在执行从另一边出来之前跳到别的地方。编译器不解析asm模板字符串,除非替换为
%operand
s,如printf。它不关心你是否显式引用一个操作数。所以你仍然拥有内联asm(https://gcc.gnu.org/wiki/DontUseInlineAsm)的所有优点和缺点,包括必须精确地描述输出:inputs:为你正在运行的代码块向编译器提供clobbers,就像你如何在注解中记录手写的asm helper函数一样。
**加上
call
和ret
与在asm语句本身中编写asm。**对于像两条vpermq
指令这样便宜的东西来说,这似乎非常糟糕。如果可以将helper文件拆分为一个文件,那么可以使用asm(".include 'helper.s'" : "+x"(v0), ...);
。(或者.set
可以检查的东西,这样你就可以从一个有多个块的文件中请求一个块?但这可能更难维持。)如果您使用的任何
"m"
操作数可能会选择相对于RSP的寻址模式,那么当call
推送返回地址时,也可能会中断。但你不会在这种情况下;你将迫使编译器为操作数选择特定的寄存器,而不是让它选择哪个YMM寄存器。所以它可能看起来像
Godboltgcc和clang将其编译为:
与GCC相比,GCC在处理其寄存器分配上的硬寄存器约束方面一如既往地糟糕:
无论你要在
asm_foo
函数中做什么,你都可以在asm模板中完成。然后你可以使用%0
而不是%%ymm0
来为编译器提供寄存器的选择。我将变量与传入的args排成一行,以便于编译器使用。asm_foo
是具有特殊调用约定的函数。bar()
只是一个普通的函数,它的调用者将假定clobbers所有的vector regs和一半的整数regs,并且只能按值返回一个vector。