You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

16 | 技术探索你真的把CPU的潜能都挖掘出来了吗

你好,我是尉刚强。

通过上节课的学习我们现在已经了解并发设计和实现的相关技术和方法而所有这些技术方法的目的都是为了能最大程度地发挥CPU多核的性能。但我们还要知道的是CPU体系架构在解决单核性能瓶颈问题、提升处理软件性能的过程中其实并不是只可以采用增加核数这一种方式。

现在主流的CPU体系架构为了提升计算速度实际上都借鉴了GPU中的向量计算特点在硬件上引入了向量寄存器,并支持利用向量级指令来提升软件的性能。

这种利用单条指令执行多条数据的机制,我们通常称之为SIMDSingle Instruction Multiple Data技术比如MMX、SSE、AVX、FMA等支持SIMD技术的指令集。另外像英特尔、AMD等生产的不同款型的CPU也都会选择支持部分指令集技术来帮助提升计算速度。就以ClickHouse为例它之所以在分析数据上有卓越的性能表现其中一部分原因就在于其底层大量地使用了SIMD技术。

那么基于向量的SIMD技术的原理是什么为什么它可以提升计算速度呢我们在软件开发的过程中要如何使用这种技术来提升性能呢

今天这节课我就根据目前比较主流的AVX技术的工作原理和具体实现来帮你解答以上提出的这些问题。这样一来你在C/C++和Java语言的开发项目中就知道如何使用这种技术来开发高性能的软件了。

基于向量的SIMD技术是如何提升计算速度的

首先我们需要搞清楚一个问题就是基于AVX的SIMD技术为什么计算速度会比较快

这里我们可以先来看看下面这张图其中对比了SIMD指令与传统的SISDSingle Instruction Single Data指令执行4个数字的求和计算操作过程

图的左边代表的是单条指令执行单条数据的实现方式我们可以看到针对两组包含4个元素的数据在进行两两相加的操作时最少需要12条指令8条mov指令4条add指令才能完成业务。

而图的右边因为在CPU芯片中集成了比较大的寄存器从而就实现了多条数据导入和多条数据相加操作都可以在一条指令周期内完成减少了执行CPU的指令数进一步也就提升了计算速度。

目前支持AVX的CPU芯片最高已经可以支持512位的寄存器从而可以实现一个寄存器中保存16个浮点数的能力。因此相比传统的单个浮点数的计算来说其计算速度最高可以提升16倍所以对计算密集型的软件性能提升帮助很大。而对于GPU来说也正是因为它可以实现通过单条指令来运行矩阵或向量计算才可以在数据处理和人工智能领域有比较大的性能优势。

如何使用SIMD技术来提升软件性能

在了解了AVX向量指令集技术后接下来我们要解决的问题就是如何在软件开发的过程中使用这种技术来提升软件性能呢

实际上目前很多的编程语言都可以支持基于SIMD的编码开发。所以接下来我会针对C/C++和Java这两种使用广泛的编程语言来带你掌握SIMD技术的具体实现。

基于C/C++的SIMD实现

事实上针对AVX指令集目前的CPU硬件厂商已经把它的基本功能封装成了C函数库所以对于C/C++的编程用户来说就可以比较方便地使用AVX指令集开发程序。那接下来我们就先来看一下在C/C++语言中是如何使用AVX优化执行性能的吧。

首先我们来看一个具体的例子。在如下所示的函数中是使用传统的指令实现的两个double类型的数组求和操作

void vectorAdd(double* a, double* b, double* c){
    for(int i=0; i<4; i++) {
        c[i] = a[i] + b[i];  //一条代码仅能实现两个数字的计算。
    }                      
}

因此为了执行4个数字的相加操作程序需要遍历循环四次。而如果采用AVX指令集来实现相同功能的逻辑其执行过程是这样的

void vectorAdd(double *a, double *b, double *re)
{
    __m256d m1, m2; //avx指令集中支持的数据类型
    m1 = _mm256_set_pd(a[i], a[i + 1], a[i + 2], a[i + 3]); //转化为向量变量
    m2 = _mm256_set_pd(b[i], b[i + 1], b[i + 2], b[i + 3]);
    __m256d l1 = _mm256_add_pd(m1, m2); //向量相加操作;
    re[i + 3] = l1.m256d_f64[0];
    re[i + 2] = l1.m256d_f64[1];
    re[i + 1] = l1.m256d_f64[2];
    re[i]     = l1.m256d_f64[3];
}

我来给你具体分析一下:

  • 首先__m256d是AVX中支持的数据类型它代表的是256位的double向量。其实目前的AVX内部已经支持了很多数据类型而_m256则表示可以保存8个float数字的向量float长度32位256位可以保存256/32=8个
  • 接下来的两条_mm256_set_pd指令就实现了把4个double数字转换为double类型的向量。
  • 然后_mm256_add_pd实现了向量相加操作也就是把两个double类型的向量中的元素进行逐个相加再生出一个新的向量。
  • 最后再使用l1.m256d_f64接口将向量中的值转换到数组中。

如此一来通过以上的向量化计算改造我们就可以减少CPU执行的指令数目从而提升计算速度。

不过这里你要注意不同的SIMD指令集的用法差异是比较大的我推荐你可以参考一下Intel的官网文档其中涵盖了Intel CPU架构封装实现的各种向量指令集的接口定义。

同时在不同的CPU芯片之间它们对向量级计算的支持能力以及支持的SIMD指令集也都不太一样所以在进行软件开发之前我更推荐你先去了解下软件运行的CPU芯片是否支持对应的SIMD指令集。

另外从前面的数组求和操作示例中你可能会发现使用AVX指令集来编写程序会比较繁琐。所以如果你的产品对性能并没有极致的要求我比较推荐你采用编译器手段来实现AVX的指令优化。比如在做GCC编译时你可以增加下面的选项来编译软件这样程序在生成指令时就可以尽量生成向量级操作指令进而来提升软件性能。

gcc -mavx, -mavx2, -march=native

而如果你使用的是英特尔的芯片而且也使用了英特尔提供的C/C++编译器icc来进行编译构建那么你还可以使用下面的编译器宏来显式地告知编译器进行SIMD的相关优化

#pragma vector aligned
#pragma simd 

当然你还可以使用英特尔开发的Cilk Plus并行编程库来更高效地开发支持并行与向量化的程序以此帮助提升软件性能。

基于Java的SIMD实现

OK我们再来看看Java语言中是如何支持实现SIMD技术的。

其实在以前Java语言并没有提供直接使用向量级指令的能力。早期Java的设计者们是在HotSpot虚拟机中引入了一种叫做SuperWord的自动向量优化算法,这个算法会缺省地将循环体内的标量计算,自动优化为向量计算,以此来提升数据运算时的执行效率。

不过如果在JIT中采用这种自动向量优化机制其实存在一定的局限性。比如说它只能针对循环内的部分实现进行向量级的优化同时针对一些复杂计算过程像是中间包含分支判断、数据依赖等也会很难将其优化为向量计算指令。

但是在JDK 16之后JIT引入了向量化编程的直接支持这部分的模块代码在 jdk.incubator.vector模块中。当你安装了新版本的JDK并在代码中导入这个模块包之后你就可以基于向量级指令开发程序了。

那么下面我们就来看下在Java中具体要怎么用jdk.incubator.vector来开发基于向量化的程序。

首先我们来看一段基于Java它实现功能是两个数组内元素先进行平方后再进行数组对应元素间相加操作的代码示例

void scalarCalc(float[] a, float[] b, float[] c) {
   for (int i = 0; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]);
   }
}

由此你会发现这段代码的实现其实跟前面C/C++的实现过程是一样的它同样需要遍历数组中所有的元素依次根据公式计算出C中每个元素的值。

而如果是基于jdk.incubator.vector技术则调整优化后的代码实现如下

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_512;
void vectorCalc(float[] a, float[] b, float[] c) {
    for (int i = 0; i < a.length; i += SPECIES.length()) {
        var m = SPECIES.indexInRange(i, a.length);
        var va = FloatVector.fromArray(SPECIES, a, i, m);
        var vb = FloatVector.fromArray(SPECIES, b, i, m);
        var vc = va.mul(va).add(vb.mul(vb));
        vc.intoArray(c, i, m);
    }
}

其中,你需要重点关注的是vectorCalc函数它实现了和上一段代码相同的功能逻辑也是先计算平方再求和。此外代码FloatVector.SPECIES_512它代表的是将16个float数字放在一个向量中的类型这与AVX封装的C语言指令集也是类似的。

然后在使用jdk.incubator.vector进行运算的过程中也需要进行同样的转换过程所以这里首先也要将数组转换为向量类型。

接下来,你需要注意的是在va.mul(va)中基于向量的mul操作,它实现的功能是元素逐个相乘,如下图所示:

所以,中间的代码片段就变成了:

va.mul(va).add(vb.mul(vb))

这样一来,就可以实现原来的相同计算逻辑,如下:

 c[i] = (a[i] * a[i] + b[i] * b[i]);

同理由于基于向量级的操作可以将原来16个float上的乘法和加法操作都转换为一条指令减少了执行的指令数所以计算速度会变快。

目前jdk.incubator.vector中已经囊括了常用的运算操作功能包含的接口比较多详细的你可以参考官方API

不过到这里你可能会觉得好像所有基于AVX的编码实现都只是把之前代码的实现修改为基于AVX指令的实现。

但实际上并不只是这样,在真实的高性能编码过程中,它的核心挑战并不是修改之前的代码,而是针对同样的一段业务计算逻辑如何调整编码实现从而最大化地利用和发挥底层的CPU的向量级指令的能力。

我举个例子。下面这段代码展示的是一个float数组求和操作如果是在数组长度为N的情况下那么你可能就需要执行N次的求和操作

float sum(float[] a) {
   float sum =0.0;
   for (int i = 0; i < a.length; i++) {
        sum = sum + a[i] ;
   }
   return sum;
}

所以针对这种情况你就可以在原始数据构造阶段把数据记录到两个数组中然后利用向量级指令的求和计算就可以实现仅通过N/16次的向量加法操作之后将需要求和的数组规模下降一半的效果。

当然,这里我给出的只是一个很小的示例代码,在真实的业务计算中,你可以通过调整设计与实现,来改变业务功能的计算过程,从而更加充分地发挥向量化计算的性能优势。

小结

今天这节课我带你了解了针对CPU提供的SIMD技术的原理以及它是如何提升软件性能的。同时我还针对C/C++和Java这两种语言帮你明确了如何在具体编码过程中去使用这种技术来提升软件性能。如果你在参与一些CPU计算密集型的软件系统开发并且性能要求非常高那么就可以尝试使用今天课程中学习的SIMD技术来提升性能。

不过SIMD技术是一种比较贴近底层的优化技术只会在特定场景下才有效果。因此你在性能优化的过程中首先需要考虑其他可用的高性能编码实现技术只有当其他的高性能实现技术都已经发挥到极致而且通过打点分析确认通过计算数据向量化可以进一步提升性能时再考虑使用这种向量化优化性能的技术。

思考题

今天课程上讲解的向量级指令与人工智能CPU中的向量计算原理是一样的吗它们在使用中有什么差异

欢迎在留言区分享你的观点和看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。