简介
之前的两篇文章,分别介绍了CPU和CPU Cache两个话题,性能是永恒的核心。我们也谈到了优化CPU性能面临的三堵墙:
- The power wall
目前,运算速度提升30%,则需要两倍的电压和发热,并且这种设计思路无法满足移动设备,也不可能长久 - The memory wall
内存和CPU在性能上的差距拉大。 - The IPL wall
目前多数应用并没有很好的并行化设计(指令级别)
针对第一面墙,《The Free Lunch Is Over》文章很好的告诉我们,这种性能优化已经达到上限。针对第二面墙,CPU Cache是解决两者性能差距的缓冲区。今天,我们来看看第三面墙的解决方案。
现代CPU日益依赖并行技术来达到高性能。在多核,超线程以及支持多任务操作系统的指令等硬件技术的发展下,多线程似乎成了优化性能的一颗银弹。然而,很少有人了解CPU指令级别上的并行技术:在一个Cycle内CPU应用一组向量操作,同时对4或8个输入数据执行相同指令,产生对应4或8个结果,这称为SIMD (Single Instruction, Multiple Data)。
最早在超级计算机上应用SIMD技术,比如CDC Start-100。1996年,Intel针对X86指令集,推出了MMX扩展,这是第一次在商用硬件上支持SIMD技术,1999年,Intel在P3中推出了SSE(Streaming SIMD Extensions),基于128位寄存器,针对4个float的向量数据,提供了70个汇编指令。AVX(Advanced Vector Extensions) 是Intel的SSE延伸架构,总之,就是让CPU越来越像GPU。
实践
下面,我们来看一下,在C++中如何使用SSE。
union { __m128 result_sum4; float result_sum[4]; };
result_sum[0] = result_sum[1] = result_sum[2] = result_sum[3] = 0.0f;
result_sum4 = _mm_set_ps1(0.0f);
如上一个联合体(union),__m128是一个vector,里面对应4个float值,同理,还有两个变量类型__m128i和__m128d,分别对应int和double。如上对变量result_sum赋值后,result_sum4和result_sum共享同一块内存。当然,你也可以用SSE指令_mm_set_ps1对result_sum4初始化。
数据并行
for (int i = 0; i < 1000; i++)
{
pResult[i] = pL[i] + pR[i];
}
// SSE2.0
for (int i = 0; i < 1000; i += 4)
{
result_sum4 = _mm_add_ps(
_mm_setr_ps(pL[i], pL[i + 1], pL[i + 2], pL[i + 3]),
_mm_setr_ps(pR[i], pR[i + 1], pR[i + 2], pR[i + 3]));
pResult[i] = result_sum[0];
pResult[i + 1] = result_sum[1];
pResult[i + 2] = result_sum[2];
pResult[i + 3] = result_sum[3];
}
如上,一个for循环,左右两个数组求和。在SSE中,我们通过_mm_add_ps指令,实现四个元素的同步操作。同样,SSE中也提供了_mm_sub_ps ,_mm_mul_ps,_mm_div_ps分别对应减法,乘法和除法。
条件判断
下面,我们来看一个条件语句的代码:
int fun(floatx)
{
if (x < 0.1f)
{
return 0;
}
elseif (x > 0.9f)
{
return 1;
}
else
{
return -1;
}
}
__m128i fun4(__m128 x4)
{
__m128 mask = _mm_cmpge_ps(x4, _mm_set_ps1(0.1f));
__m128 fV1 = _mm_blendv_ps(_mm_set_ps1(0.0f), _mm_set_ps1(-1.0f), mask);
mask=_mm_cmpge_ps(_mm_set_ps1(0.9f), x4);
__m128 fV2 = _mm_blendv_ps(_mm_set_ps1(2.0f), _mm_set_ps1(0.0f), mask);
__m128i result4 = _mm_cvtps_epi32(_mm_add_ps(fV1, fV2));
return result4;
}
这里,_mm_cmpge_ps相当于if判断,_mm_blendv_ps对应?:语句,_mm_cvtps_epi32负责将float转为int。这样,通过SSE对应的实现4个一组的逻辑判断。
从学习的角度,SSE指令并不复杂,它提供了一组指令集,实现我们常见的数学运算和逻辑判断,初次使用可能会略有不适,但学习成本还是很低的。如果感兴趣,不妨了解一下max,min,sin 和power 等方法对应的SSE指令,你也可以访问如下网站,获取对应的指令说明。https://software.intel.com/sites/landingpage/IntrinsicsGuide/#techs=MMX,SSE,SSE2,SSE3,SSSE3,SSE4\_1,SSE4\_2,AVX,AVX2,FMA,AVX\_512
Tips
看上去SSE的使用并不复杂,无非就是把C++中惯用的+ - * /,以数据并行的思路进行改造,分别用对应的SSE指令替换一下就可以了。逻辑判断上略显复杂,但也都是小技巧而已。这是我最先编写SSE代码时的想法,应该和大家会有共鸣。然而,当我将一个反向传播神经网络的算法,以SSE2.0指令改造后,发现时间只减少到70%左右,说好的四分之一呢?我感觉自己被深深的欺骗了。
于是乎,我对这些基本操作进行了简单的测试,for循环100000000次,进行最简单的四则运算和指数,得出了如下的性能对比:
结论是,加减乘除大概相当,指数会有较大提高,我查看了Release下对应的汇编,发现编译器对这类简单的for循环,会自动编译为SSE指令,因此,对这类代码,我们并不需要改造,而且我们的改造不见得比编译器写的好,反而还可能会变慢。因为exp指数并没有对应的直接的SSE指令,是我找的一个第三方的库函数,因此才会有性能的较大提升。
要点1:20%的代码会消耗掉80%的时间,找到其中计算量最大,复杂度较高的部分,这是我们改造代码的重点。如果我们不确定优化是否有效,那就只能实践出真知。
其次,采用SSE指令集,需要将4个float合并为一个__m128,这个操作太频繁了,需要调用_mm_setr_ps或_mm256_loadu_pd这类的指令,在这个过程中,初始化这些变量也要浪费时间的,并且还要将结果在转回为4个float,这个成本是否可以避免。
这里,我们可以通过指针强转的方式省去这类初始化的时间消耗,代码如下:
__m128* ptrLeft = (__m128*)&pValueLeft[i];
但这里有一个要求,这里要求变量pValueLeft 16字节对齐,而一般float数组只会保证4字节对齐,因此,在声明变量的时候,我们需要显示指定16字节对齐,C++ 11中提供了alignas保证数组对齐,而指针类型的则需要通过__aligned_malloc_方法确保字节对齐。
同时,因为SSE是对四个float数据的并行化处理思路,这里就有一个很讨厌的情况,如果循环长度无法被4整除,剩余的部分不做改造,还是填上空值,再做一次并行计算,都是很烦人的选择,起码代码看上去不优雅,“异常”判断太多。我的建议是,重构数据布局,强制让所有的数组都被4整除。
这是一个较大的改进,因为新增的这些“零头”可能会干扰计算过程,改变数组中元素的值。因此,最好是设计阶段就能做好这类的数据布局,重构的话,需要确保这些数组参与的计算,不会因为额外增加的元素而破坏运算的准确性。
要点2:优化数据布局,数组首地址16字节对齐,长度被四整除。AVX?
至此,SSE编码方面的要点就差不多了,但我们不一样,我们是学过Cache的程序员,上一章有一个for for循环的示例,告诉我们行优先的效率要远远高于列优先,因为前者在内存上是连续的。而SSE主要就是针对计算量较大的部分(图像,神经网络等)的数据并行,因此,我们在代码改造中,要对这类代码重点照顾。比如如下的代码:
for (inti = 0; i < NUMHIDDEN4; i++)
{
// get error gradient for every hidden node
errorGradientsHidden[i] = GetHiddenErrorGradient(i);
// for all nodes in input layer and biasneuron
for (intj = 0; j < INPUTSIZE4; j++)
{
constintweightIdx = GetInputHiddenWeightIndex(j, i);
// calculate change in weight
deltaInputHidden[weightIdx] = errorGradientsHidden[i] * weightIdx;
}
}
这里,GetInputHiddenWeightIndex(j, i)是列优先,差评。从优化的角度,这里有很大的优化空间:
for (inti = 0; i < NUMHIDDEN4; i+= STEP_SSE)
{
// get error gradient for every hidden node
errorGradientsHidden[i] = GetHiddenErrorGradient(i);
}
// for all nodes in input layer and bias neuron
for (intj = 0; j < INPUTSIZE4; j++)
{
for (inti = 0; i < NUMHIDDEN4; i += STEP_SSE)
{
const int weightIdx = GetInputHiddenWeightIndex(j, i);
deltaInputHidden[weightIdx] = errorGradientsHidden[i] * weightIdx;
}
}
这里对for循环拆分,将i和j对调,并不影响最终的计算结果,但确保访问内存是连续的,然后在对两个for分别进行数据并行的改造。
要点3:SSE优化时,时刻提醒自己,这段代码在执行中,是否内存连续,是否有改造空间。
最后,我要说的是,虽然学习SSE并不难,但在实践中还有很多综合应用,并且后续可能会有新增的指令集,不同CPU之间的兼容问题,所以,不建议自己写,而是用一些专业的第三方库。好处是不用自己来维护升级,同时在逻辑上减轻耦合度。我们的重点不是写一套自己的SSE/AVX库。
要点4:专业的人做专业的事。基于我们对SSE理解的深度,结合自身代码的需要,合理移植VCL(vector class library),更好的让他山之石可以攻玉。
总结
SIMD的介绍就到这里,理论上并不复杂,实践中却需要顾及方方面面的可能点。至此,我们讲了CPU,谈到了Cache在性能优化中的巨大价值,本章学习了SIMD技术对数据并行的改造。
简言之,CPU的核心,就是对数据计算的优化。下一章是CPU的最后一篇,DOD (Data Oriented Design),整体上理解一下现代CPU和面向对象编程之间的冲突,作为C++程序员,我们如何批判的理解面向对象的得与失,从一个更大的维度来编写我们手下的代码。
作者:Peter6
原文链接:https://mp.weixin.qq.com/s/e36cBrBpsd8PXjid5D4VUQ
微信公众号:
推荐阅读
更多GPU及渲染技术干货请关注Arm Mali GPU技术专栏。