assembly 如何使MSVC生成在寄存器中缓存内存的汇编?

lb3vh1jj  于 2022-11-13  发布在  其他
关注(0)|答案(2)|浏览(861)

我有一个mat4类型,它表示一个float[4][4]。它内部使用一个512位寄存器。

union alignas(16 * sizeof(float)) mat4 {
private:
    __m512 m512;
    __m512d m512d;
    ALWAYS_INLINE mat4(__m512 m512) : m512{m512} {}
    ALWAYS_INLINE mat4(__m512d m512d) : m512d{m512d} {}
    ALWAYS_INLINE operator __m512&() { return m512; }
    ALWAYS_INLINE operator __m512d&() { return m512d; }
    ALWAYS_INLINE operator const __m512&() const { return m512; }
    ALWAYS_INLINE operator const __m512d&() const { return m512d; }
    ALWAYS_INLINE mat4& operator=(__m512 _m512) {
        m512 = _m512;
        return *this;
    }
    ALWAYS_INLINE mat4& operator=(__m512d _m512d) {
        m512d = _m512d;
        return *this;
    }

public:
    friend void __vectorcall transform_children(mat4 parent, std::span<mat4> children);
};

我还有一个函数transform_children(mat4 parent, std::span<mat4> children)。它将所有mat4视为变换矩阵,并通过将它们与parent相乘来变换所有children(原地)。我使用AVX512F内部函数编写了1一个优化实现。

void __vectorcall transform_children(mat4 parent, std::span<mat4> children) {
    mat4* const __restrict bs = children.data();
    const size_t n = children.size();

    ASSUME(n != 0);

    const mat4 zmm1 = _mm512_permute_ps(parent, 0);
    const mat4 zmm2 = _mm512_permute_ps(parent, 85);
    const mat4 zmm3 = _mm512_permute_ps(parent, 170);
    const mat4 zmm0 = _mm512_permute_ps(parent, 255);

    for (int i = 0; i < n; ++i) {
        mat4& __restrict zmm4 = bs[i];
        mat4 zmm5 = _mm512_shuffle_f64x2(zmm4, zmm4, 85);
        zmm5 = _mm512_mul_ps(zmm5, zmm2);
        mat4 zmm6 = _mm512_shuffle_f64x2(zmm4, zmm4, 0);
        zmm6 = _mm512_fmadd_ps(zmm1, zmm6, zmm5);
        zmm5 = _mm512_shuffle_f64x2(zmm4, zmm4, 170);
        zmm4 = _mm512_shuffle_f64x2(zmm4, zmm4, 255);
        zmm4 = _mm512_fmadd_ps(zmm0, zmm4, zmm6);
        zmm4 = _mm512_fmadd_ps(zmm3, zmm5, zmm4);
    }
}

GCCClang几乎都是把这个编译成优化的汇编。不幸的是,MSVC做了一些奇怪的事情。由于某种原因,它不是把bs[i]的值加载到寄存器,然后在迭代结束时把它存回数组,而是访问内存4次:

void transform_children(mat4,std::span<mat4,4294967295>) PROC ; transform_children, COMDAT
        mov     ecx, DWORD PTR _children$[esp]
        vpermilps zmm4, zmm0, 0
        vpermilps zmm5, zmm0, 85                        
        vpermilps zmm6, zmm0, 170                 
        vpermilps zmm7, zmm0, 255                 
        test    ecx, ecx
        je      SHORT $LN36@transform_
        mov     eax, DWORD PTR _children$[esp-4]
        npad    8
$LL4@transform_:
        lea     eax, DWORD PTR [eax+64]
        vmovupd zmm3, ZMMWORD PTR [eax-64]              ; HERE
        vshuff64x2 zmm0, zmm3, zmm3, 85       
        vmulps  zmm0, zmm0, zmm5
        vshuff64x2 zmm1, zmm3, zmm3, 0
        vmovups zmm2, zmm4
        vfmadd213ps zmm2, zmm1, zmm0 
        vshuff64x2 zmm0, zmm3, zmm3, 255 
        vmovupd ZMMWORD PTR [eax-64], zmm0              ; HERE
        vfmadd231ps zmm2, zmm7, ZMMWORD PTR [eax-64]    ; HERE
        vshuff64x2 zmm1, zmm3, zmm3, 170               
        vmovups zmm0, zmm6
        vfmadd213ps zmm0, zmm1, zmm2
        vmovups ZMMWORD PTR [eax-64], zmm0              ; HERE
        sub     ecx, 1
        jne     SHORT $LL4@transform_
$LN36@transform_:
        vzeroupper
        ret     8
void transform_children(mat4,std::span<mat4,4294967295>) ENDP ; transform_children

我可以做些什么来使MSVC只访问内存两次,就像GCC和Clang2那样?
1.确切地说,GCC和Clang编写了这个实现首先,我使用两个嵌套循环编写了一个典型的实现。然后,我使用-mavx512f通过GCC运行它。GCC足够聪明,可以生成优化的矢量化代码。然后,我使用内部函数将这个矢量化代码从汇编代码转换回C++。然后,我用Clang编译了新的内部代码,它生成了一个更快的矢量化程序集,然后我又把Clang的程序集转换成了C++内部代码。
2. Clang访问内存4次,但它展开了循环,因此每次迭代仍有两次访问

rseugnpd

rseugnpd1#

TL:DR:当MSVC必须通过mat4类的重载转换在__m512d__m512之间进行转换时,MSVC的表现很糟糕。因此,只需使用__m512内部函数来完成所有操作,包括128位通道的重排。
MSVC制作更糟糕的代码是不幸的,但并不令人震惊; MSVC的优化器是well known,一般来说不是很好。MSVC不做严格的别名,尽管__m512可以为任何东西取别名,所以IDK,如果这是相关的。
看起来你应该只使用一个__m512(或者mat4)临时变量,而不是告诉编译器重复修改bs[i],并希望它实际上不会。
特别是在从__m512d(从pd又名f64混洗)到mat4__m512(对于单精度FMA)以及反向的隐式转换中。_mm512_shuffle_f32x4_mm512_shuffle_f64x2的直接替代;两者都使用shuffle-control immediate来选择128位通道,屏蔽的32位和64位元素粒度并不重要,因为您没有屏蔽。对压缩浮点数据使用f32x4 shuffle更符合习惯,所以通常更喜欢这样。
这样编写可以让MSVC生成您想要的asm;使用一个__m512变量需要使内部类型全部匹配(如果我不想在shuffle周围使用_mm512_castps_pdpd_ps);事实上,这是我第一次注意到__m512d__m512类型差异。

for (int i = 0; i < n; ++i) {
        __m512 zmm4 = bs[i];
        mat4 zmm5 = _mm512_shuffle_f32x4(zmm4, zmm4, 85);
        zmm5 = _mm512_mul_ps(zmm5, zmm2);
        mat4 zmm6 = _mm512_shuffle_f32x4(zmm4, zmm4, 0);
        zmm6 = _mm512_fmadd_ps(zmm1, zmm6, zmm5);
        zmm5 = _mm512_shuffle_f32x4(zmm4, zmm4, 170);
        zmm4 = _mm512_shuffle_f32x4(zmm4, zmm4, 255);
        zmm4 = _mm512_fmadd_ps(zmm0, zmm4, zmm6);
        zmm4 = _mm512_fmadd_ps(zmm3, zmm5, zmm4);
        bs[i] = zmm4;
    }

MSVC 19.32(Godbolt,与v19.latest相同)正在从循环底部的_zmm0$1$[esp+64]重新加载您的zmm0常量,就在vmovups [eax-64], zmm1存储到bs[i]之前。它似乎在循环的后面使用ZMM 3作为临时变量,覆盖常量。它也有一些类似vmovups zmm1, zmm7的指令。
但是,这只发生在像您链接的那样的32位构建中,而不是像https://godbolt.org/z/GWszEnfP5那样的普通64位构建中,在这种构建中,它不会将任何向量常量溢出到堆栈中。如果Windows x64使XMM16..31像XMM6..15一样所有调用保留。你希望不是,那太多调用保留寄存器了。)它仍然只使用ZMM0..7,所以它可以在32位代码中这样做,它只是失败了。
-mabi=ms为目标的32位模式GCC没有那些浪费的zmm到zmm移动指令;它能够安排其FMA来就地修改zmm4(在ZMM 0中),适当地调度乱序,以便寄存器可以重用。(https://godbolt.org/z/9sGbYn71o

对所有内部函数使用相同的向量类型也适用于MSVC

即使有了引用,我们也可以在不额外存储/重新加载zmm4on Godbolt的情况下获得asm,其中x86 MSVC v19.latest在之后,只是将shuffle更改为f32x4

for (int i = 0; i < n; ++i) {
        mat4& __restrict zmm4 = bs[i];
        mat4 zmm5 = _mm512_shuffle_f32x4(zmm4, zmm4, 85);
        zmm5 = _mm512_mul_ps(zmm5, zmm2);
        mat4 zmm6 = _mm512_shuffle_f32x4(zmm4, zmm4, 0);
        zmm6 = _mm512_fmadd_ps(zmm1, zmm6, zmm5);
        zmm5 = _mm512_shuffle_f32x4(zmm4, zmm4, 170);
        zmm4 = _mm512_shuffle_f32x4(zmm4, zmm4, 255);
        zmm4 = _mm512_fmadd_ps(zmm0, zmm4, zmm6);
        zmm4 = _mm512_fmadd_ps(zmm3, zmm5, zmm4);
        //bs[i] = zmm4;
    }

我认为把一个向量加载到寄存器中,然后进行处理,再存储回内存,这样写代码更符合习惯,特别是像zmm4这样的名字,对于引用变量来说,这似乎很奇怪;如果您考虑的是asm和寄存器,那么引用变量就不算什么了。像zmm4这样的名称并不意味着在修改寄存器后会更新内存。
使用非引用意味着你只修改了一个局部的__m512(或者如果你想使用非引用的mat4,你可以修改mat4),这对于编译器来说总是更容易优化到一个寄存器中。(尽管在你的循环中没有任何其他的内存引用可以作为别名,即使没有__restrict。)
顺便说一句,内部函数允许您为向量变量使用稍微有意义的名称,如vmatmativbsivchild,而不是zmm4。编译器实际上不太可能将C++ zmm4变量保存在ZMM 4寄存器中,因此,当以这种方式命名变量时,比较asm和C++需要更多的脑力。例如,您会得到类似vmovups zmm3, ZMMWORD PTR _zmm0$1$[esp+64]的指令
使用像zmm0这样的名称通常会放弃内部函数相对于汇编函数的清晰度/可读性优势。
事实上,你更喜欢编译器使用ZMM16..31,这样在编译完成时就不需要vzeroupper了。除了你链接了一个32位的构建版本在Godbolt上??这很奇怪,所以你只有ZMM0..7。你链接了一个64位的构建版本在GCC上。

e4eetjau

e4eetjau2#

通过定义这些

ALWAYS_INLINE operator __m512&() { return m512; }
ALWAYS_INLINE operator __m512d&() { return m512d; }
ALWAYS_INLINE operator const __m512&() const { return m512; }
ALWAYS_INLINE operator const __m512d&() const { return m512d; }

从技术上讲,您可以断开__restrict的接地:在使用zmm4的内部函数中,不同位置返回的引用指向相同的位置,因此您出现了别名。MSVC++似乎正确地得出了您出现别名的结论。因此,编译器每次都从内存中重新加载该值。
请注意,这里的__restrict表示mat4对象的this引用,而不是上面引用的转换运算符返回的引用:

mat4& __restrict zmm4 = bs[i];

你不仅是别名,而且你还双关语的类型(虽然在一个法律的的方式-通过一个union)。
最好的解决方案应该是使用强制转换内部函数,并将临时值存储在一个专用的const变量中。

相关问题