I have a type mat4 which represents a float[4][4]. Internally it uses a 512-bit register.
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);
};
I also have a function transform_children(mat4 parent, std::span<mat4> children). It treats all mat4s as transformation matrices and transforms all the children (in place) by multiplying them with the parent. I wrote1 an optimised implementation using AVX512F intrinsics.
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);
}
}
Both GCC and Clang compile this nearly literally to optimised assembly. Unfortunately, MSVC does something weird. For some reason, instead of loading the value of bs[i] to a register and then storing it back to the array at the end of the iteration, it accesses the memory 4 times:
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
What could I do to make MSVC access memory only twice, like GCC and Clang2 do?
1. To be precise, GCC and Clang wrote this implementation (sort of). First, I wrote a the typical implementation using two nested loops. Then, I ran it through GCC using -mavx512f. GCC was smart enough to generate optimised vectorised code. Then, I converted this vectorised code from assembly back to C++ using intrinsics. Then, I compiled the new intrinsic code with Clang and it generated an even faster vectorised assembly. Then I converted Clang's assembly to C++ intrinsics again.
2. Clang accesses memory 4 times, but it unrolls the loop, so still two accesses per iteration