我有一个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);
}
}
GCC和Clang几乎都是把这个编译成优化的汇编。不幸的是,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次,但它展开了循环,因此每次迭代仍有两次访问
2条答案
按热度按时间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_pd
和pd_ps
);事实上,这是我第一次注意到__m512d
与__m512
类型差异。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
即使有了引用,我们也可以在不额外存储/重新加载
zmm4
on Godbolt的情况下获得asm,其中x86 MSVC v19.latest在之后,只是将shuffle更改为f32x4
。我认为把一个向量加载到寄存器中,然后进行处理,再存储回内存,这样写代码更符合习惯,特别是像
zmm4
这样的名字,对于引用变量来说,这似乎很奇怪;如果您考虑的是asm和寄存器,那么引用变量就不算什么了。像zmm4
这样的名称并不意味着在修改寄存器后会更新内存。使用非引用意味着你只修改了一个局部的
__m512
(或者如果你想使用非引用的mat4
,你可以修改mat4
),这对于编译器来说总是更容易优化到一个寄存器中。(尽管在你的循环中没有任何其他的内存引用可以作为别名,即使没有__restrict
。)顺便说一句,内部函数允许您为向量变量使用稍微有意义的名称,如
vmat
、mati
、vbsi
或vchild
,而不是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上。e4eetjau2#
通过定义这些
从技术上讲,您可以断开
__restrict
的接地:在使用zmm4
的内部函数中,不同位置返回的引用指向相同的位置,因此您出现了别名。MSVC++似乎正确地得出了您出现别名的结论。因此,编译器每次都从内存中重新加载该值。请注意,这里的
__restrict
表示mat4
对象的this
引用,而不是上面引用的转换运算符返回的引用:你不仅是别名,而且你还双关语的类型(虽然在一个法律的的方式-通过一个
union
)。最好的解决方案应该是使用强制转换内部函数,并将临时值存储在一个专用的
const
变量中。