使用SIMD加速float 4x4矩阵乘法

自从我熟悉Intel处理器上的MMX,SSE和后来的AVX指令以来,已经过去了很多年。 一次,在x86汇编程序的背景下,它们似乎像是一种魔术,而这早已是世俗的事物。 他们深深吸引了我,几年前,我想到了为一个著名游戏编写自己的软件渲染器的想法。 答应这些指示的事情向我保证了这一点。 在某些时候,我什至考虑过编写它。 但是事实证明,编写文本比编写代码要复杂得多。

当时,我想避免在不同处理器上支持的问题。 我希望能够检查我的渲染器的最大可用数量。 我仍然和旧的AMD处理器有朋友,他们的上限是SSE3。 因此,当时我决定将自己限制为SSE3的最大值。 因此,有一个向量数学库,比在SSE上完全实现的要少一些,在SSE3之前很少包含。 但是,在某些时候,我想知道对于一些关键的矢量数学运算,我可以从处理器中挤出什么最大性能。 一种这样的操作是将4个矩阵乘以4。


实际上,为了娱乐起见,我决定更多地从事这项业务。 我已经写过并且一直在我在SSE上的软件渲染中使用矩阵乘法,这对我来说似乎足够了。 但是后来我决定看看将2个float4x4矩阵相乘可以从原则上挤出多少个度量。 在我当前的SSE上,这是16个时钟周期。 的确,自从我开始为某些指令写1 *而不是0 *以来,最近到IACA 3的过渡开始显示19。 显然,这只是分析仪中的一个缺陷。

简要介绍所使用的实用程序


对于代码分析,我使用了著名的英特尔架构代码分析器实用程序。 为了进行分析,我使用Haswell体系结构(HSW)作为支持AVX2的最低要求。 编写代码也非常方便使用:《 英特尔技术指南》和《 英特尔优化手册》

对于组装,我从控制台使用MSVS 2017社区。 我在带有内在函数的版本中编写代码。 您只需编写一次,通常它可以立即在不同平台上运行。 另外,x64 VC ++编译器不支持嵌入式汇编程序,但我希望它也能在x64下工作。

由于本文已经超出了SIMD编程的初学者水平,因此我将不介绍寄存器,指令,绘制(或刮削)精美的图片,而是尝试使用SIMD指令学习编程。 英特尔网站上有许多出色,清晰和详细的文档。

我想让一切变得更轻松。。。


这是时刻的开始,这使实现和本文都变得很复杂。 因此,我将对此稍作介绍。 对我来说,用元素的标准行布局编写矩阵乘法并不有趣。 谁需要它,所以他们就读大学或自己学习。 我们的目标是提高生产力。 首先,很久以前我切换到列布局。 我的软件渲染器基于OpenGL API,因此,为了避免不必要的转置,我开始将元素存储在列中。 这也很重要,因为矩阵乘法不是那么关键。 乘以2-5-10矩阵。 就是这样。 然后,我们将完成的矩阵乘以成千上万个顶点。 而且此操作更为关键。 当然,您每次都可以转置。 但是为什么,如果可以避免的话。

但是只回到矩阵。 我们确定了列的存储量。 但是,这可能会更加复杂。 对于我来说,将向量和矩阵行的高级元素存储在SIMD寄存器中比较方便,以便x处于最高浮点数(索引3),而w处于次要浮点(索引0)。 显然,在这里,我们将不得不退缩。

事实是,在矢量的软件渲染器中,您必须更频繁地操作w组件( 1 / z存储在其中),并且通过_ss版本的操作(仅在xmm寄存器的下浮点中的组件进行操作)非常方便,而无需触摸x,y, 。 因此,在SSE寄存器中,向量以易于理解的顺序x,y,z,w存储,并以相反的w,z,y,x顺序存储在存储器中。

此外,所有乘法选项也由单个功能实现。 这样做是因为我根据支持的指令类型使用它们来替代所需的选项。 好在这里描述。

我们实现基本功能


带循环的乘法,行有序


元素的行布局选项
for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[i][j] = 0.f; for (int k = 0; k < 4; ++k) { r[i][j] += m[i][k] * n[k][j]; } } } 


这里的一切都很简单明了。 对于每个元素,我们进行4次乘法和3次加法。 总共有64个乘法和48个加法。 而且这没有考虑到记录元素的读取。

简而言之,一切都很难过。 对于此选项,对于内部周期,IACA发出: x86组装为3.65个时钟周期,x64组装为2.97个时钟 。 不要问为什么小数。 我不知道 IACA 2.1并未遭受此困扰。 无论如何,这些数字应乘以约4 * 4 * 4 =64。即使您使用x64,结果也约为192小节。 显然,这是一个粗略的估计。 对于该选项,我看不出更准确地评估性能的意义。

循环实现,列有序


转置矩阵,重新排列行和列索引
 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[j][i] = 0.f; for (int k = 0; k < 4; ++k) { r[j][i] += m[k][i] * n[j][k]; } } } 


周期乘法,面向SIMD的存储


以相反的顺序将行存储在内存中
 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[j][3-i] = 0.f; for (int k = 0; k < 4; ++k) { r[j][3-i] += m[k][3-i] * n[j][3-k]; } } } 


这种实现在某种程度上简化了对内部发生的事情的理解,但显然还不够。

助手类


为了方便理解和编写参考和调试代码,实现两个辅助类很方便。 仅此而已,一切都只是为了理解。 我注意到,完整的向量和矩阵类的实现是一个单独的难题,并且未包含在本文的主题中。

向量和矩阵类
 struct alignas(sizeof(__m128)) vec4 { union { struct { float w, z, y, x; }; __m128 fmm; float arr[4]; }; vec4() {} vec4(float a, float b, float c, float d) : w(d), z(c), y(b), x(a) {} static bool equ(float const a, float const b, float t = .00001f) { return fabs(ab) < t; } bool operator == (vec4 const& v) const { return equ(x, vx) && equ(y, vy) && equ(z, vz) && equ(w, vw); } }; struct alignas(sizeof(__m256)) mtx4 { //           union { struct { float _30, _20, _10, _00, _31, _21, _11, _01, _32, _22, _12, _02, _33, _23, _13, _03; }; __m128 r[4]; __m256 s[2]; vec4 v[4]; }; //    mtx4() {} mtx4( float i00, float i01, float i02, float i03, float i10, float i11, float i12, float i13, float i20, float i21, float i22, float i23, float i30, float i31, float i32, float i33) : _00(i00), _01(i01), _02(i02), _03(i03) , _10(i10), _11(i11), _12(i12), _13(i13) , _20(i20), _21(i21), _22(i22), _23(i23) , _30(i30), _31(i31), _32(i32), _33(i33) {} //      operator __m128 const* () const { return r; } operator __m128* () { return r; } //   bool operator == (mtx4 const& m) const { return v[0]==mv[0] && v[1]==mv[1] && v[2]==mv[2] && v[3]==mv[3]; } //  static mtx4 identity() { return mtx4( 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f); } static mtx4 zero() { return mtx4( 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f); } }; 


测试参考功能


由于矩阵中元素的可接受顺序使理解变得很复杂,因此我们也不会被引用清除功能所打扰,该功能将在以后的实现中显示一切正常。 我们将与之比较后续结果。

要创建它,只需采取并扩大周期
 void mul_mtx4_mtx4_unroll(__m128* const _r, __m128 const* const _m, __m128 const* const _n) { mtx4 const& m = **reinterpret_cast<mtx4 const* const*>(&_m); mtx4 const& n = **reinterpret_cast<mtx4 const* const*>(&_n); mtx4& r = **reinterpret_cast<mtx4* const*>(&_r); r._00 = m._00*n._00 + m._01*n._10 + m._02*n._20 + m._03*n._30; r._01 = m._00*n._01 + m._01*n._11 + m._02*n._21 + m._03*n._31; r._02 = m._00*n._02 + m._01*n._12 + m._02*n._22 + m._03*n._32; r._03 = m._00*n._03 + m._01*n._13 + m._02*n._23 + m._03*n._33; r._10 = m._10*n._00 + m._11*n._10 + m._12*n._20 + m._13*n._30; r._11 = m._10*n._01 + m._11*n._11 + m._12*n._21 + m._13*n._31; r._12 = m._10*n._02 + m._11*n._12 + m._12*n._22 + m._13*n._32; r._13 = m._10*n._03 + m._11*n._13 + m._12*n._23 + m._13*n._33; r._20 = m._20*n._00 + m._21*n._10 + m._22*n._20 + m._23*n._30; r._21 = m._20*n._01 + m._21*n._11 + m._22*n._21 + m._23*n._31; r._22 = m._20*n._02 + m._21*n._12 + m._22*n._22 + m._23*n._32; r._23 = m._20*n._03 + m._21*n._13 + m._22*n._23 + m._23*n._33; r._30 = m._30*n._00 + m._31*n._10 + m._32*n._20 + m._33*n._30; r._31 = m._30*n._01 + m._31*n._11 + m._32*n._21 + m._33*n._31; r._32 = m._30*n._02 + m._31*n._12 + m._32*n._22 + m._33*n._32; r._33 = m._30*n._03 + m._31*n._13 + m._32*n._23 + m._33*n._33; } 


此处清楚地画出了经典算法,很难犯错(但您可以:-)。 IACA在其上发布了: x86-69.95措施,x64-64措施 。 这里大约有64个周期,我们将在将来查看此操作的加速情况。

SSE实施


经典SSE算法


为什么要经典? 因为它早已在FVec的实现中作为MSVS的一部分。 首先,我们将编写如何在SSE寄存器中显示矩阵元素。 在这里看起来已经很简单了。 只是一个转置矩阵。

 //     00, 10, 20, 30 // m[0] -  SIMD /   01, 11, 21, 31 // m[1] 02, 12, 22, 32 // m[2] 03, 13, 23, 33 // m[3] 

我们采用上述变体的展开代码。 不知何故他对上交所并不友好。 第一组行由结果矩阵的列的结果组成: r._00,r._01,r._02,r._03 。 我们有此列,但需要一行。 是的,M,N出现计算不便。 因此,我们重新排列算法的行,以使结果r是逐行的。

 //  ,     r[0] r00 = m00*n00 + m01*n10 + m02*n20 + m03*n30; r10 = m10*n00 + m11*n10 + m12*n20 + m13*n30; r20 = m20*n00 + m21*n10 + m22*n20 + m23*n30; r30 = m30*n00 + m31*n10 + m32*n20 + m33*n30; //  ,     r[1] r01 = m00*n01 + m01*n11 + m02*n21 + m03*n31; r11 = m10*n01 + m11*n11 + m12*n21 + m13*n31; r21 = m20*n01 + m21*n11 + m22*n21 + m23*n31; r31 = m30*n01 + m31*n11 + m32*n21 + m33*n31; //  ,     r[2] r02 = m00*n02 + m01*n12 + m02*n22 + m03*n32; r12 = m10*n02 + m11*n12 + m12*n22 + m13*n32; r22 = m20*n02 + m21*n12 + m22*n22 + m23*n32; r32 = m30*n02 + m31*n12 + m32*n22 + m33*n32; //  ,     r[3] r03 = m00*n03 + m01*n13 + m02*n23 + m03*n33; r13 = m10*n03 + m11*n13 + m12*n23 + m13*n33; r23 = m20*n03 + m21*n13 + m22*n23 + m23*n33; r33 = m30*n03 + m31*n13 + m32*n23 + m33*n33; 

但这已经好多了。 实际上,我们看到了什么? 根据每组中算法的列,我们具有矩阵m的行:
 m [0] = {00,10,20,30},m [1] = {01,11,21,31},m [2] = {02,12,22,32},m [3] = {03,13,23,33},
它们乘以矩阵n的相同元素。 例如,对于第一个组,它是: n._00,n._10,n._20,n._30 。 对于算法的每一行行,矩阵n的元素再次位于矩阵的一行中。

然后一切都变得很简单:我们只需按索引取矩阵m的行,但对于元素n取其行,并通过shuffle指令将其分散到寄存器的所有4个元素,以便乘以寄存器中矩阵m的行。 例如,对于元素n._00 (记住,它在寄存器中的移位具有索引3),它将是:
  _mm_shuffle_ps(n [0],n [0],_ MM_SHUFFLE(3,3,3,3)) 

以简化形式,该算法如下所示:

 //   n[0]={00,10,20,30} r[0] = m[0] * n00 + m[1] * n10 + m[2] * n20 + m[3] * n30; //   n[1]={01,11,21,31} r[1] = m[0] * n01 + m[1] * n11 + m[2] * n21 + m[3] * n31; //   n[2]={02,12,22,32} r[2] = m[0] * n02 + m[1] * n12 + m[2] * n22 + m[3] * n32; //   n[3]={03,13,23,33} r[3] = m[0] * n03 + m[1] * n13 + m[2] * n23 + m[3] * n33; 

基本的SSE实施
 void mul_mtx4_mtx4_sse_v1(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(0,0,0,0))))); r[1] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(0,0,0,0))))); r[2] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(0,0,0,0))))); r[3] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(0,0,0,0))))); } 


现在我们将算法中的元素n更改为对应的shuffle ,乘以_mm_mul_ps ,乘以_mm_add_ps ,就可以了。 可以用 但是,代码看起来比算法本身看起来差很多。 IACA对此代码发布: x86-18.89,x64-16个周期 。 这比上一个快4倍。 在SSE中注册第四个浮点数 几乎线性关系。

装饰SSE实施


尽管如此,在代码中看起来还是很糟糕。 我们将尝试通过编写一些语法糖来改善这一点。

操作员和改进员
 //        ( -    namespace) __m128 operator + (__m128 const a, __m128 const b) { return _mm_add_ps(a, b); } __m128 operator - (__m128 const a, __m128 const b) { return _mm_sub_ps(a, b); } __m128 operator * (__m128 const a, __m128 const b) { return _mm_mul_ps(a, b); } __m128 operator / (__m128 const a, __m128 const b) { return _mm_div_ps(a, b); } //_mm_shuffle_ps(u, v, _MM_SHUFFLE(3,2,1,0))   shuf<3,2,1,0>(u, v) template <int a, int b, int c, int d> __m128 shuf(__m128 const u, __m128 const v) { return _mm_shuffle_ps(u, v, _MM_SHUFFLE(a, b, c, d)); } template <int a, int b, int c, int d> __m128 shuf(__m128 const v) { return _mm_shuffle_ps(v, v, _MM_SHUFFLE(a, b, c, d)); } //    template <int i> __m128 shuf(__m128 const u, __m128 const v) { return _mm_shuffle_ps(u, v, _MM_SHUFFLE(i, i, i, i)); } template <int i> __m128 shuf(__m128 const v) { return _mm_shuffle_ps(v, v, _MM_SHUFFLE(i, i, i, i)); } //  float       , //    ,    template <int a, int b, int c, int d> __m128 shufd(__m128 const v) { return _mm_castsi128_ps(_mm_shuffle_epi32(_mm_castps_si128(v), _MM_SHUFFLE(a, b, c, d))); } template <int i> __m128 shufd(__m128 const v) { return _mm_castsi128_ps(_mm_shuffle_epi32(_mm_castps_si128(v), _MM_SHUFFLE(i, i, i, i))); } 


编译器可以完美地内联这些函数(尽管有时不带__forceinline)。

因此代码正在转向...
 void mul_mtx4_mtx4_sse_v2(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = m[0]*shuf<3>(n[0]) + m[1]*shuf<2>(n[0]) + m[2]*shuf<1>(n[0]) + m[3]*shuf<0>(n[0]); r[1] = m[0]*shuf<3>(n[1]) + m[1]*shuf<2>(n[1]) + m[2]*shuf<1>(n[1]) + m[3]*shuf<0>(n[1]); r[2] = m[0]*shuf<3>(n[2]) + m[1]*shuf<2>(n[2]) + m[2]*shuf<1>(n[2]) + m[3]*shuf<0>(n[2]); r[3] = m[0]*shuf<3>(n[3]) + m[1]*shuf<2>(n[3]) + m[2]*shuf<1>(n[3]) + m[3]*shuf<0>(n[3]); } 


因此,它已经更好,更易读。 IACA它发布了关于预期的结果:86 - 19,64 - 16(什么是不是小数?)。 实际上,性能没有改变,但是代码更加漂亮和易于理解。

对未来优化的贡献很小


让我们在铁版本中最近出现的功能级别上进行另一项改进。 多次加法(fma)操作fma(a,b,c)= a * b + c

多次添加实施
 __m128 mad(__m128 const a, __m128 const b, __m128 const c) { return _mm_add_ps(_mm_mul_ps(a, b), c); } 


为什么这是必要的? 首先,为了将来的优化。 例如,您可以根据需要通过相同的宏在完成的代码中简单地用mad替换mad 。 但是,我们现在将为优化奠定基础:

有多次添加的变体
 void mul_mtx4_mtx4_sse_v3(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = mad(m[0], shuf<3>(n[0]), m[1]*shuf<2>(n[0])) + mad(m[2], shuf<1>(n[0]), m[3]*shuf<0>(n[0])); r[1] = mad(m[0], shuf<3>(n[1]), m[1]*shuf<2>(n[1]) + mad(m[2], shuf<1>(n[1]), m[3]*shuf<0>(n[1])); r[2] = mad(m[0], shuf<3>(n[2]), m[1]*shuf<2>(n[2])) + mad(m[2], shuf<1>(n[2]), m[3]*shuf<0>(n[2])); r[3] = mad(m[0], shuf<3>(n[3]), m[1]*shuf<2>(n[3])) + mad(m[2], shuf<1>(n[3]), m[3]*shuf<0>(n[3])); } 


IACA: x86-18.89,x64-16 。 再次分数。 不过,IACA有时仍会产生奇怪的结果。 代码变化不大。 可能更糟。 但是优化有时需要做出这样的牺牲。

我们通过_mm_stream进行保存


各种优化指南再次建议不要为大容量保存操作拉入缓存。 当您处理成千上万个顶点时,通常这是合理的。 但是对于矩阵而言,这可能并不那么重要。 不过,我还是会添加它。

流保存选项
 void mul_mtx4_mtx4_sse_v4(__m128* const r, __m128 const* const m, __m128 const* const n) { _mm_stream_ps(&r[0].m128_f32[0], mad(m[0], shuf<3>(n[0]), m[1]*shuf<2>(n[0])) + mad(m[2], shuf<1>(n[0]), m[3]*shuf<0>(n[0]))); _mm_stream_ps(&r[1].m128_f32[0], mad(m[0], shuf<3>(n[1]), m[1]*shuf<2>(n[1])) + mad(m[2], shuf<1>(n[1]), m[3]*shuf<0>(n[1]))); _mm_stream_ps(&r[2].m128_f32[0], mad(m[0], shuf<3>(n[2]), m[1]*shuf<2>(n[2])) + mad(m[2], shuf<1>(n[2]), m[3]*shuf<0>(n[2]))); _mm_stream_ps(&r[3].m128_f32[0], mad(m[0], shuf<3>(n[3]), m[1]*shuf<2>(n[3])) + mad(m[2], shuf<1>(n[3]), m[3]*shuf<0>(n[3]))); } 


从字面上看,这里的时间没有任何变化。 但是,根据建议,我们现在不再再次触摸缓存。

AVX实施


基本AVX选项



接下来,我们进入下一步的优化。 SSE寄存器中包含第4个浮点数,而AVX中已经包含第4个浮点数。也就是说,理论上有机会减少执行的操作数量,如果不是一半,则提高生产率,至少是1.5倍。 但是有一点告诉我,过渡到AVX并非一切都会如此简单。 我们可以从双寄存器中获得必要的数据吗?

让我们尝试找出答案。 同样,我们写出上面使用的乘法算法。 您无法执行此操作,但是当所有内容都在附近并且无需向上滚动半页时,使用该代码更方便。

 //    : 00, 10, 20, 30, 01, 11, 21, 31, 02, 12, 22, 32, 03, 13, 23, 33 //   SSE: r0 = m0*n00 + m1*n10 + m2*n20 + m3*n30 r1 = m0*n01 + m1*n11 + m2*n21 + m3*n31 r2 = m0*n02 + m1*n12 + m2*n22 + m3*n32 r3 = m0*n03 + m1*n13 + m2*n23 + m3*n33 

在输出处,我们期望得到ymm = {r0:r1}ymm = {r2:r3}的结果 。 如果在SSE版本中我们的算法被泛化为列,那么现在我们需要将其泛化为行。 因此,在SSE选项的情况下无法正常工作。

如果考虑寄存器ymm中的矩阵m ,则分别得到ymm = {m0:m1}ymm = {m2:m3} 。 以前,我们在寄存器中只有矩阵列,现在只有列和行。

如果您尝试像以前那样操作,则必须将ymm = {m0:m1}乘以寄存器ymm = {n00,n00,n00,n00}:{n10,n10,n10,n10} 。 由于n00n01在矩阵n的同一行中,根据可用的AVX指令集判断,将它们分散ymm会很昂贵。 ymm寄存器内的float的两个四分之一(高xmm和低xmm )中的每一个都分别进行混洗置换

如果我们假设n个 YMM矩阵我们得到N00N10在较旧的2内部YMM xmm寄存器中两个元件。 {n00,n10,n20,n30}:{n01,n11,n21,n31} 。 通常,现有指令的索引为0到3。它的地址仅在两个内部ymm寄存器中的一个xmm寄存器中浮动。 无法廉价地n10从较旧的xmm转移到较年轻的xmm 。 然后必须多次重复此焦点。 我们不能忍受这种措施的损失。 有必要提出其他建议。

我们曾经对列进行泛化,但现在对行进行泛化。 因此,我们将尝试以一些不同的方式进行。 我们需要在{r0:r1}中得到结果。 这意味着必须对算法进行改进,而不是在算法的不同行中进行改进,而应同时进行两次改进。 在这里, 洗牌置换工作的弊端对我们来说将是一个加分。 当我们考虑矩阵n时,我们将看看ymm寄存器中的内容

 n0n1 = {00, 10, 20, 30} : {01, 11, 21, 31} n2n3 = {02, 12, 22, 32} : {03, 13, 23, 33} 

是的,我们注意到在ymm寄存器的不同xmm部分中,我们有元素0001 。 可以通过{_00,_00,_00,_00}中的permute命令通过寄存器乘以大小写:{_ 01,_01,_01,_01} ,这两个xmm部件仅指示一个索引3。 这正是我们所需要的。 实际上,系数也用在不同的行中。 仅现在才在对应的ymm寄存器中进行乘法运算,有必要保留{m0:m0} ,即矩阵m的第一行重复。

因此,我们将更详细地描述算法。 我们在ymm寄存器中读取矩阵m的双行:

 mm[0] = {m0:m0} mm[1] = {m1:m1} mm[2] = {m2:m2} mm[3] = {m3:m3} 

然后我们将乘法计算为:

 r0r1 = mm[0] * {n00,n00,n00,n00:n01,n01,n01,n01} + // permute<3,3,3,3>(n0n1) mm[1] * {n10,n10,n10,n10:n11,n11,n11,n11} + // permute<2,2,2,2>(n0n1) mm[2] * {n20,n20,n20,n20:n21,n21,n21,n21} + // permute<1,1,1,1>(n0n1) mm[3] * {n30,n30,n30,n30:n31,n31,n31,n31} // permute<0,0,0,0>(n0n1) r2r3 = mm[0] * {n02,n02,n02,n02:n03,n03,n03,n03} + // permute<3,3,3,3>(n2n3) mm[1] * {n12,n12,n12,n12:n13,n13,n13,n13} + // permute<2,2,2,2>(n2n3) mm[2] * {n22,n22,n22,n22:n23,n23,n23,n23} + // permute<1,1,1,1>(n2n3) mm[3] * {n32,n32,n32,n32:n33,n33,n33,n33} // permute<0,0,0,0>(n2n3) 

我们重写得更清楚:

 r0r1 = mm[0]*n0n1<3,3,3,3>+mm[1]*n0n1<2,2,2,2>+mm[2]*n0n1<1,1,1,1>+mm[3]*n0n1<0,0,0,0> r2r3 = mm[0]*n2n3<3,3,3,3>+mm[1]*n2n3<2,2,2,2>+mm[2]*n2n3<1,1,1,1>+mm[3]*n2n3<0,0,0,0> 

或以简化形式:

 r0r1 = mm[0]*n0n1<3> + mm[1]*n0n1<2> + mm[2]*n0n1<1> + mm[3]*n0n1<0> r2r3 = mm[0]*n2n3<3> + mm[1]*n2n3<2> + mm[2]*n2n3<1> + mm[3]*n2n3<0> 

一切似乎都很清楚。

剩下的只是写一个实现
 void mul_mtx4_mtx4_avx_v1(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 mm0 = _mm256_set_m128(m[0], m[0]); __m256 mm1 = _mm256_set_m128(m[1], m[1]); __m256 mm2 = _mm256_set_m128(m[2], m[2]); __m256 mm3 = _mm256_set_m128(m[3], m[3]); __m256 n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); __m256 y1 = _mm256_permute_ps(n0n1, 0xFF);//3,3,3,3 __m256 y2 = _mm256_permute_ps(n0n1, 0xAA);//2,2,2,2 __m256 y3 = _mm256_permute_ps(n0n1, 0x55);//1,1,1,1 __m256 y4 = _mm256_permute_ps(n0n1, 0x00);//0,0,0,0 y1 = _mm256_mul_ps(y1, mm0); y2 = _mm256_mul_ps(y2, mm1); y3 = _mm256_mul_ps(y3, mm2); y4 = _mm256_mul_ps(y4, mm3); y1 = _mm256_add_ps(y1, y2); y3 = _mm256_add_ps(y3, y4); y1 = _mm256_add_ps(y1, y3); __m256 n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); __m256 y5 = _mm256_permute_ps(n2n3, 0xFF); __m256 y6 = _mm256_permute_ps(n2n3, 0xAA); __m256 y7 = _mm256_permute_ps(n2n3, 0x55); __m256 y8 = _mm256_permute_ps(n2n3, 0x00); y5 = _mm256_mul_ps(y5, mm0); y6 = _mm256_mul_ps(y6, mm1); y7 = _mm256_mul_ps(y7, mm2); y8 = _mm256_mul_ps(y8, mm3); y5 = _mm256_add_ps(y5, y6); y7 = _mm256_add_ps(y7, y8); y5 = _mm256_add_ps(y5, y7); _mm256_stream_ps(&r[0].m128_f32[0], y1); _mm256_stream_ps(&r[2].m128_f32[0], y5); } 


它一直有趣的数字从IACA:86 - 12.53,64 - 12。 虽然,当然,我想要更好。 错过了什么。

AVX优化加语法糖


似乎在上面的代码中,AVX并未充分发挥其潜力。 我们发现不用在ymm寄存器中设置两条相同的行,而是可以使用broadcast ,它可以用两个相同的xmm值填充ymm寄存器。 同样,在此过程中,为AVX功能添加一些“语法糖”。

增强的AVX实施
 __m256 operator + (__m256 const a, __m256 const b) { return _mm256_add_ps(a, b); } __m256 operator - (__m256 const a, __m256 const b) { return _mm256_sub_ps(a, b); } __m256 operator * (__m256 const a, __m256 const b) { return _mm256_mul_ps(a, b); } __m256 operator / (__m256 const a, __m256 const b) { return _mm256_div_ps(a, b); } template <int i> __m256 perm(__m256 const v) { return _mm256_permute_ps(v, _MM_SHUFFLE(i, i, i, i)); } template <int a, int b, int c, int d> __m256 perm(__m256 const v) { return _mm256_permute_ps(v, _MM_SHUFFLE(a, b, c, d)); } template <int i, int j> __m256 perm(__m256 const v) { return _mm256_permutevar_ps(v, _mm256_set_epi32(i, i, i, i, j, j, j, j)); } template <int a, int b, int c, int d, int e, int f, int g, int h> __m256 perm(__m256 const v) { return _mm256_permutevar_ps(v, _mm256_set_epi32(a, b, c, d, e, f, g, h)); } __m256 mad(__m256 const a, __m256 const b, __m256 const c) { return _mm256_add_ps(_mm256_mul_ps(a, b), c); } void mul_mtx4_mtx4_avx_v2(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 const mm[] { _mm256_broadcast_ps(m+0), _mm256_broadcast_ps(m+1), _mm256_broadcast_ps(m+2), _mm256_broadcast_ps(m+3) }; __m256 const n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); _mm256_stream_ps(&r[0].m128_f32[0], mad(perm<3>(n0n1), mm[0], perm<2>(n0n1)*mm[1])+ mad(perm<1>(n0n1), mm[2], perm<0>(n0n1)*mm[3])); __m256 const n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); _mm256_stream_ps(&r[2].m128_f32[0], mad(perm<3>(n2n3), mm[0], perm<2>(n2n3)*mm[1])+ mad(perm<1>(n2n3), mm[2], perm<0>(n2n3)*mm[3])); } 


结果在这里已经更加有趣了。 IACA产生数字:x86-10 ,x64-8.58 ,看起来好多了,但仍然不是2倍。

AVX + FMA选项(最终)


让我们再尝试一次。 现在,再次调用FMA指令集是合乎逻辑的,因为它是在AVX之后添加到处理器中的。 只需更改单个mul +添加一次操作即可。 尽管我们仍然使用乘法指令为编译器提供更多的优化机会,并为处理器提供并行执行乘法的机会。 通常,我会在汇编器中查看生成的代码,以确保哪个选项更好。

在这种情况下,我们需要计算a * b + c * d + e * f + g * h 。 您可以这样做: fma(a,b,fma(c,d,fma(e,f,g * h))) 。 但是,正如我们看到的,如果不完成前一个操作就不可能在这里执行一个操作。 这意味着我们将无法使用成对乘法的功能,因为SIMD流水线允许我们这样做。 如果我们转换位计算FMA(A,B,C * d)+ FMA(E,F,G * H),我们看到,计算可以被并行化。 首先执行两个独立的乘法,然后执行两个独立的fma运算。

AVX + FMA实施
 __m256 fma(__m256 const a, __m256 const b, __m256 const c) { return _mm256_fmadd_ps(a, b, c); } void mul_mtx4_mtx4_avx_fma(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 const mm[]{ _mm256_broadcast_ps(m + 0), _mm256_broadcast_ps(m + 1), _mm256_broadcast_ps(m + 2), _mm256_broadcast_ps(m + 3) }; __m256 const n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); _mm256_stream_ps(&r[0].m128_f32[0], fma(perm<3>(n0n1), mm[0], perm<2>(n0n1)*mm[1])+ fma(perm<1>(n0n1), mm[2], perm<0>(n0n1)*mm[3])); __m256 const n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); _mm256_stream_ps(&r[2].m128_f32[0], fma(perm<3>(n2n3), mm[0], perm<2>(n2n3)*mm[1])+ fma(perm<1>(n2n3), mm[2], perm<0>(n2n3)*mm[3])); } 


IACA: x86-9.21,x64-8 。 现在很好。 可能有人会说可以做得更好的方法,但我不知道如何做。

基准测试


我立即注意到,这些数字不应被视为最终真理。 即使进行了固定测试,它们也会在一定范围内游泳。 而且,它们在不同平台上的行为也有所不同。 .


  • Function: . s — , mov ( ). , .
  • IACA cycles: IACA
  • Measured cycles: ( , )
  • IACA speedup: /
  • Measured speedup: / ( , )


loop_m 64. . .

i3-3770:


x86
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m70.0050.751.001.00
loop_m233.60119.210.300.43
sse_v1m18.8927.513.701.84
sse_v2m19.0027.613.681.84
sse_v3m18.8927.223.701.86
sse_v4s18.8927.183.701.87
avx_v1m13.0019.215.382.64
avx_v1s13.0003/205.382.53
avx_v2m10.0012.916.993.93
avx_v2s10.0017.346.992.93

x64
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m7068.601.001.00
loop_m233.60119.370.300.57
sse_v1m18.8921.983.703.12
sse_v2m19.0021.093.683.25
sse_v3m18.8922.193.703.09
sse_v4s18.8922.393.703.06
avx_v1m13.009.615.387.13
avx_v1s13.0016.905.384.06
avx_v2m10.009.206.997.45
avx_v2s10.0014.646.994.68

i7-8700K:


x86
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m69.9540.251.001.00
loop_m233.6079.490.300.51
sse_v1m18.8919.313.702.09
sse_v2m19.0019.983.682.01
sse_v3m18.8919.693.702.04
sse_v4s18.8919.673.702.05
avx_v1m13.0014.225.382.83
avx_v1s13.0014.135.382.85
avx_v2m10.0011.736.993.43
avx_v2s10.0011.816.993.41
AVX+FMAm9.2110.387.603.88
AVX+FMAs9.2110.327.603.90

x64
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m69.9557.111.001.00
loop_m233.6075.730.300.75
sse_v1m18.8915.833.703.61
sse_v2m19.0017.223.683.32
sse_v3m18.8915.923.703.59
sse_v4s18.8916.183.703.53
avx_v1m13.007.035.388.12
avx_v1s13.0012.985.384.40
avx_v2m10.005.406.9910.57
avx_v2s10.0011.396.995.01
AVX+FMAm9.219.737.605.87
AVX+FMAs9.219.817.605.82

. , .

BONUS


, , AVX512, . , . , AVX+FMA. , .

,
 __m512 operator + (__m512 const a, __m512 const b) { return _mm512_add_ps(a, b); } __m512 operator - (__m512 const a, __m512 const b) { return _mm512_sub_ps(a, b); } __m512 operator * (__m512 const a, __m512 const b) { return _mm512_mul_ps(a, b); } __m512 operator / (__m512 const a, __m512 const b) { return _mm512_div_ps(a, b); } template <int i> __m512 perm(__m512 const v) { return _mm512_permute_ps(v, _MM_SHUFFLE(i, i, i, i)); } template <int a, int b, int c, int d> __m512 perm(__m512 const v) { return _mm512_permute_ps(v, _MM_SHUFFLE(a, b, c, d)); } __m512 fma(__m512 const a, __m512 const b, __m512 const c) { return _mm512_fmadd_ps(a, b, c); } void mul_mtx4_mtx4_avx512(__m128* const r, __m128 const* const m, __m128 const* const _n) { __m512 const mm[]{ _mm512_broadcast_f32x4(m[0]), _mm512_broadcast_f32x4(m[1]), _mm512_broadcast_f32x4(m[2]), _mm512_broadcast_f32x4(m[3]) }; __m512 const n = _mm512_load_ps(&_n[0].m128_f32[0]); _mm512_stream_ps(&r[0].m128_f32[0], fma(perm<3>(n), mm[0], perm<2>(n)*mm[1])+ fma(perm<1>(n), mm[2], perm<0>(n)*mm[3])); } 


: x86 — 4.79, x64 — 5.42 (IACA SKX). , 64 48 .

PS




. . .

Source: https://habr.com/ru/post/zh-CN418247/


All Articles