代码之家  ›  专栏  ›  技术社区  ›  the4naves

无法使用vectorcall返回多个SIMD矢量

  •  1
  • the4naves  · 技术社区  · 3 年前

    我目前正在开发一个程序,该程序可以在一个紧密的循环中处理大量数据。数据块被加载到YMM寄存器中,从中提取64位块以进行实际操作。

    这个循环是几个循环之一,程序根据正在处理的数据的确切内容在这些循环之间切换。因此,为了执行所述切换,必须偶尔(有时频繁)中断每个循环。为了使整个系统更易于操作,每个循环都包含在自己的函数中。

    我遇到的一个相当大的烦恼(不是第一次)是,在函数调用中保存256位和64位块相当困难。每个循环都处理相同的数据,所以当一个循环中断时丢弃这些寄存器,只为了立即加载回完全相同的数据是没有意义的。这并不会真正导致任何重大的性能问题,但它是可以测量的,而且总体上看起来很愚蠢。

    我尝试了大约一百万种不同的方法,但没有一种能给我一个合适的解决方案。当然,我可以简单地将块存储在外部切换循环中,并将它们作为引用传递给内部循环,但对生成的程序集的快速检查表明,无论我尝试什么,GCC和Clang都会恢复到指针,从而破坏了优化的整个要点。

    我也可以将每个循环标记为 always_inline ,打开LTO,到此为止,但我计划为其中一个循环添加一个手工编写的程序集版本,我不想被迫以内联方式编写它。实际上,我希望函数的声明能简单地向调用方发出信号,表明向量(和相关信息)将作为返回值在适当的寄存器中从函数中传递出去,从而使我能够将开销(无需内联)减少到最多几个寄存器/寄存器 mov s

    我发现的最接近的东西是 vectorcall 调用约定,由MSVC支持,至少部分由Clang和GCC支持。

    作为参考,我目前正在使用GCC,但如果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文档)

    此外,是否存在 任何 在GCC或Clang中,通过调用约定或某些编译器特定的标志,实现MSVC在上面的示例代码中给出的优化的方法?我很乐意尝试在编译器中编写一个自定义约定,但这远远超出了这个项目的范围。

    1 回复  |  直到 3 年前
        1
  •  2
  •   Peter Cordes    3 年前

    所有YMM寄存器都被呼叫阻塞 ,因此,非内联函数有点像是在寄存器中保留任何大量数据的挡箭牌。(Windows x64约定保留了xmm6..15的调用,但较宽的YMM寄存器仍然被阻塞。)相当多的整数寄存器也被阻塞,尤其是在x86-64 System V调用约定(非Windows)中。

    如果你的程序的有价值的状态只有这4个矢量和几个整数寄存器,那么是的,MSVC的x64 vectorcall 可以将向量传递给非内联函数,并将它们全部作为返回值返回。

    否则,其他状态将不得不在调用周围溢出/重新加载,因此手工编写的asm的唯一好选择是GNUC内联asm。


    x86-64 SysV在x/y/zmm0中返回1个矢量

    这个 x86-64 System V calling convention 最多可以在2个矢量寄存器(xmm/ymm/zmm)中返回,就像整数参数可以在最多6个regs中传递,但只能在RDX:RAX中返回一样。

    但XMM1仅在返回标量float或double的聚合时使用(总大小不超过16字节,因此返回值在XMM0和XMM1的低八字节中)。ABI文档的分类规则5(c)- 如果聚合的大小超过两个八字节,而第一个八字节不是 SSE或任何其他八字节都不是SSEUP,整个参数在内存中传递。 -一秒钟 __m128i 结构中的矢量将具有第二个SSE分类的八字节。这就是为什么这样的结构在内存中返回,而不是XMM0、XMM1。规则5c允许在YMM0或ZMM0中返回比16字节宽的单个矢量(其中后面的所有八个字节都是SSEUP),而不是其他情况。

    测试证实了这一点。具有 struct { __m256i v[2]; } ,GCC/clang在内存中返回,而不是YMM0/YMM1,请参阅下面的Godbolt链接。但是 struct { float v[3]; } 我们看到了 v[4] 在XMM1的元素1中返回(低64位的上半部分=八字节): Godbolt

    因此,AMD64 System V ABI的调用约定不适合您的用例,即使它可以在向量regs中返回2个向量。


    vectorcall 在GCC或clang中:与MSVC不同,只有1个矢量reg

    您可以使用声明asm函数的原型 __attribute__((ms_abi)) (gcc或clang)或 __attribute__((vectorcall)) (仅限于叮当声),但这实际上似乎并不像你描述MSVC工作的方式:一个多个的结构 __m256i 通过隐藏指针在内存中返回,即使使用 vectorcall ( Godbolt )

    Agner Fog对GCC错误报告的评论( 89485 )说clang针对Windows确实支持 __vectorcall ,但那个bug只是请求GCC支持它,而不是讨论它是否在寄存器中返回了多个向量。也许clang的实现 __vectorcall ABI对于多个向量的结构返回不与MSVC兼容吗?

    我没有Windows clang可供测试,也没有clang cl,它旨在与MSVC更兼容。


    asm("call foo" : "+x"(v0), ...); 包装器也不会碰撞其他regs

    正如你在评论中所建议的 能够 发明自己的调用约定,并通过内联asm向编译器描述它。只要它是一个纯函数,您甚至可以避免 "memory" 撞击。

    您确实需要停止编译器在调用程序中使用红色区域,因为 call 推送返回地址。看见 Inline assembly that clobbers the red zone

    编译器根本不知道这是一个函数调用 ;事实上,内联asm模板恰好在堆栈上推送/弹出一些东西,这是重要的一部分,而不是在执行从另一边出来之前它会跳到其他地方。编译器不解析asm模板字符串,只是替换 %operand s、 像printf。它不在乎是否显式引用操作数。

    所以你仍然有内联asm的所有优点和缺点( https://gcc.gnu.org/wiki/DontUseInlineAsm ),包括必须精确地描述输出:输入:对正在运行的代码块的编译器的破坏,比如如何在注释中为手工编写的asm-helper函数进行文档记录。

    加上的开销 呼叫 ret 与将你的asm写在asm语句本身中相比。 对于两个这么便宜的东西来说,这似乎很糟糕 vpermq 说明书你也许可以使用 asm(".include 'helper.s'" : "+x"(v0), ...); 如果你能把你的助手分成一个文件。(或者也许 .set 一个 .if 可以检查,这样你就可以从一个有多个块的文件中要求一个块?但这可能更难维持。)

    如果您正在使用 "m" 可能选择相对于RSP的寻址模式的操作数,也可能中断为 呼叫 推送返回地址。但你不会在这种情况下;您将强制编译器为操作数选择特定的寄存器,而不是让它选择要选择的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);
    }
    

    Godbolt gcc和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在处理寄存器分配的硬寄存器约束方面一如既往的糟糕相比:

    # 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_fo 是具有特殊调用约定的函数。 bar() 只是一个普通函数,它的调用方将假定会阻塞所有向量regs和一半整数regs,并且只能按值返回一个向量。

    推荐文章