陆国伟- · 2021年08月20日

CPU SIMD简介

简介

之前的两篇文章,分别介绍了CPUCPU 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。

aijishu_cpu1.jpg

实践

下面,我们来看一下,在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,sinpower 等方法对应的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次,进行最简单的四则运算和指数,得出了如下的性能对比:

aijishu_cpu2.jpg

结论是,加减乘除大概相当,指数会有较大提高,我查看了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
微信公众号:
LET.jpg

推荐阅读

更多GPU及渲染技术干货请关注Arm Mali GPU技术专栏。
推荐阅读
关注数
101
内容数
18
Arm Mali GPU系列相关技术干货
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息