c++ 无法使用vectorcall返回多个SIMD向量

l7wslrjt  于 2023-05-20  发布在  其他
关注(0)|答案(1)|浏览(205)

我目前正在开发一个程序,可以在一个紧密的循环中处理大量数据。数据块被加载到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中,无论是通过调用约定,还是一些编译器特定的标志?我很乐意尝试在编译器中编写一个自定义约定,但这远远超出了本项目的范围。

9jyewag0

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函数一样。

**加上callret与在asm语句本身中编写asm。**对于像两条vpermq指令这样便宜的东西来说,这似乎非常糟糕。如果可以将helper文件拆分为一个文件,那么可以使用asm(".include 'helper.s'" : "+x"(v0), ...);。(或者.set可以检查的东西,这样你就可以从一个有多个块的文件中请求一个块?但这可能更难维持。)

如果您使用的任何"m"操作数可能会选择相对于RSP的寻址模式,那么当call推送返回地址时,也可能会中断。但你不会在这种情况下;你将迫使编译器为操作数选择特定的寄存器,而不是让它选择哪个YMM寄存器。
所以它可能看起来像

#include <immintrin.h>

auto bar(__m256i v0_in, __m256i v1_in, __m256i v2_in, __m256i v3_in){
    // clang does pass args in the right regs for vectorcall
    // (after taking into account that the first arg-reg slot is taken by the hidden pointer because of disagreement about aggregate returns)
  register __m256i v0 asm("ymm0") = v0_in;  // force "x" constraints to pick a certain register for asm statements.
  register __m256i v1 asm("ymm1") = v1_in;
  register __m256i v2 asm("ymm2") = v2_in;
  register __m256i v3 asm("ymm3") = v3_in;

   v1 = _mm256_add_epi64(v1, v3);  // do something with the incoming args, just for example
    __m256i vlocal = _mm256_add_epi64(v0, v2);  // compiler can allocate this anywhere

    // declare some integer register clobbers if your function needs any
    // the fewer the better; the compiler can keep its own stuff in those regs otherwise
  asm("call asm_foo" : "+x"(v0), "+x"(v1), "+x"(v2), "+x"(v3) : : "rax", "rcx", "rdx");
  // if you don't compile with -mno-red-zone, then  "add $-128, %%rsp ; call ; sub $-128, %%rsp".
  //  But you don't want that each call inside a loop, so just use -mno-red-zone
    return _mm256_add_epi64(vlocal, v2);
}

Godboltgcc和clang将其编译为:

# clang16 -O3 -march=skylake -mno-red-zone

bar(long long __vector(4), long long __vector(4), long long __vector(4), long long __vector(4)):
        vpaddq  ymm1, ymm3, ymm1
        vpaddq  ymm4, ymm2, ymm0      # compiler happened to pick ymm4 for vlocal, a reg not clobbered by the asm statement.
# inline asm starts here
        call    asm_foo
# inline asm ends here
  # if we just return v2, we get  vmovaps ymm0, ymm2
        vpaddq  ymm0, ymm4, ymm2     # use ymm4 which was *not* clobbered by the inline asm statement,
                                     # along with the v2 = ymm2 output of the asm

        ret

与GCC相比,GCC在处理其寄存器分配上的硬寄存器约束方面一如既往地糟糕:

# gcc13 -O3 -march=skylake -mno-red-zone

bar(long long __vector(4), long long __vector(4), long long __vector(4), long long __vector(4)):
        vmovdqa ymm5, ymm2      # useless copies, silly compiler.
        vmovdqa ymm4, ymm0
        vpaddq  ymm1, ymm1, ymm3
        vpaddq  ymm4, ymm4, ymm5
        call asm_foo
        vpaddq  ymm0, ymm4, ymm2
        ret

无论你要在asm_foo函数中做什么,你都可以在asm模板中完成。然后你可以使用%0而不是%%ymm0来为编译器提供寄存器的选择。我将变量与传入的args排成一行,以便于编译器使用。
asm_foo是具有特殊调用约定的函数。bar()只是一个普通的函数,它的调用者将假定clobbers所有的vector regs和一半的整数regs,并且只能按值返回一个vector。

相关问题