向量化计算cell_为什么向量化计算(vectorization)会这么快?

babf4e62baaec06c6c110f4c544734de.png

背景

在一次iOS程序的性能测试过程中,我们发现一个自己写的argmax函数的耗时严重超出预期——这个预期是基于平常神经网络中的argmax op的速度得到的直接感官体验。不过这也不算意外,第一个版本我们只是用了for循环去实现argmax,在那时,我们已经有所预感这会是个性能瓶颈。最终,我们将for循环实现替换成了iOS库中提供的vDSP_maxvi(value, 1, &maxValue, &maxIndex), (vDSP_Length)channel),性能得到了解放。

就像这样,时不时的,在使用numpy库或者各种Tensor张量库进行计算的时候,我们都会感叹这些库计算的速度之快,以至于远远超越自己写的for循环。然后,我们就会逐渐并且越来越多的听说到一个词——vectorization(向量化计算)——其带来了巨大的计算性能。

什么是vectorization?

向量化计算(vectorization),也叫vectorized operation,也叫array programming,说的是一个事情:将多次for循环计算变成一次计算。

0327f88bc2dd4d0e9e1fc20e3834366d.png

上图中,左侧为vectorization,右侧为寻常的For loop计算。将多次for循环计算变成一次计算完全仰仗于CPU的SIMD指令集,SIMD指令可以在一条cpu指令上处理2、4、8或者更多份的数据。在Intel处理器上,这个称之为SSE以及后来的AVX,在Arm处理上,这个称之为NEON。

因此简单来说,向量化计算就是将一个loop——处理一个array的时候每次处理1个数据共处理N次,转化为vectorization——处理一个array的时候每次同时处理8个数据共处理N/8次。

vectorization如何让速度更快?

我们以x86指令集为例,1997年,x86扩展出了MMX指令集,伴随着80-bit的vector寄存器,首开向量化计算的先河。 之后,x86又扩展出了SSE指令集 (有好几个版本, 从SSE1到SEE4.2),伴随着128-bit寄存器。而在2011年,Intel发布了Sandy Bridge架构——扩展出了AVX指令集(256-bit寄存器)。在2016年,第一个带有AVX-512寄存器的CPU发布了(512-bit寄存器,可以同时处理16个32-bit的float数)。SSE和AVX各有16个寄存器。SSE的16个寄存器为XMM0-XMM15,AVX的16个寄存器为YMM0-YMM15。XMM registers每个为128 bits,而YMM寄存器每个为256bit(AVX512为512bit)。

SSE有3个数据类型:__m128 , __m128d 和 __m128i,分别代表Float、double (d) 和integer (i)。AVX也有3个数据类型: __m256 , __m256d 和 __m256i,分别代表Float、double (d) 和 integer (i)。

10561121a3565fa70bfbb5213c3dbbe8.png

Gemfield使用下面一小段C++程序来展示一下AVX带来的计算速度:

#include <immintrin.h>
#include <iostream>
#include <chrono>
#include <ctime> 

const int N = 8;
const int loop_num = 100000000;
float gemfield_i[8] = {1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8};
float gemfield_m[8] = {2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9};
float gemfield_a[8] = {11.1,12.2,13.3,14.4,15.5,16.6,17.7,18.8};
float gemfield_o[8] = {0};

__m256 gemfield_v_i = _mm256_set_ps(8.8,7.7,6.6,5.5,4.4,3.3,2.2,1.1);
__m256 gemfield_v_m = _mm256_set_ps(9.9,8.8,7.7,6.6,5.5,4.4,3.3,2.2);
__m256 gemfield_v_a = _mm256_set_ps(18.8,17.7,16.6,15.5,14.4,13.3,12.2,11.1);
__m256 gemfield_v_o = _mm256_set_ps(0,0,0,0,0,0,0,0);


void syszuxMulAndAddV() {
    auto start = std::chrono::system_clock::now();
    for(int j=0; j<loop_num; j++){
        gemfield_v_o += _mm256_fmadd_ps(gemfield_v_i, gemfield_v_m, gemfield_v_a);
    }
    auto end = std::chrono::system_clock::now();
    std::chrono::duration<double> elapsed_seconds = end-start;
    std::cout << "resultV: ";
    // float* f = (float*)&gemfield_v_o;
    for(int i=0; i<N; i++){
        std::cout<<gemfield_v_o[i]<<" ";
    }
    std::cout<< "nelapsed time: " << elapsed_seconds.count() << "sn";
}

void syszuxMulAndAdd(){
    auto start = std::chrono::system_clock::now();
    for(int j=0; j<loop_num; j++){
        for(int i=0; i<N; i++){
            gemfield_o[i] += gemfield_i[i] * gemfield_m[i] + gemfield_a[i];
        }
    }
    auto end = std::chrono::system_clock::now();
    std::chrono::duration<double> elapsed_seconds = end-start;
    std::cout << "result: ";
    for(int i=0; i<8; i++){
        std::cout<<gemfield_o[i]<<" ";
    }
    std::cout<< "nelapsed time: " << elapsed_seconds.count() << "sn";
}

int main() {
    syszuxMulAndAdd();
    syszuxMulAndAddV();
    return 0;
}

编译并运行:

#compile civilnet.cpp
gemfield@ThinkPad-X1C:~$ g++ -march=skylake-avx512 civilnet.cpp -o civilnet

#run civilnet
gemfield@ThinkPad-X1C:~$ ./civilnet
result: 2.68435e+08 5.36871e+08 5.36871e+08 1.07374e+09 1.07374e+09 2.14748e+09 2.14748e+09 2.14748e+09 
elapsed time: 2.39723s
resultV: 2.68435e+08 5.36871e+08 5.36871e+08 1.07374e+09 1.07374e+09 2.14748e+09 2.14748e+09 2.14748e+09 
elapsed time: 0.325577s

for loop计算消耗了2.39723秒,而vectorization计算消耗了0.325577s,可以看到AVX的计算速度远超for loop,因为AVX使用了下面这样的并行方式:

a2398eddef3f4c9fee0e152960b0fb3a.png

除了vectorization,还有什么可以让CPU计算速度更快?

如今的CPU并不是大多数程序员所想象的那个黑盒子——按照PC寄存器指向的地址load指令一条一条的执行,这样的CPU在486之后就灭绝了。现代CPU(Intel Core2后,AMDBulldozer后)的管线宽度为4个uops,一个时钟周期内最多可以执行4条指令(如果同时有loads、stores和single-uop的ALU指令)。因此,vectorization并不是CPU唯一一种并行计算的方式 。在指令与指令层面同样有并行机制,可以让一个单独的CPU core在同一时间内执行多条CPU指令。当排队中的多条CPU指令包含了loads、stores、ALU,多数现代的CPU可以在一个时钟周期内同时执行4条指令。平均下来,CPU在每个时钟周期内同时执行2条指令甚至更好——这仰仗于程序如何更好的优化。

接下来,应用层的程序员还会熟悉这一点:多线程——在多个处理器核上同时运行多个指令序列。比如,在gemfield的机器上,cpu型号为“Core(TM) i9-9820X CPU”,cpu核为10个,使用超线程技术将CPU核扩展为20个逻辑核/线程数:

gemfield@AI3:~$ cat /proc/cpuinfo | grep -i "processor"
processor       : 0
processor       : 1
processor       : 2
processor       : 3
processor       : 4
processor       : 5
processor       : 6
processor       : 7
processor       : 8
processor       : 9
processor       : 10
processor       : 11
processor       : 12
processor       : 13
processor       : 14
processor       : 15
processor       : 16
processor       : 17
processor       : 18
processor       : 19

gemfield@AI3:~$ cat /proc/cpuinfo | grep -i "processor" | wc -l
20

在这台机器上,我们可以同时运行20个线程(因为20个核是由HT扩展出来的,真正能同时运行的线程数量位于10个到20个之间)。只不过20个超线程对计算密集型的加速并非20倍(也即并非超线程数),而是10倍(也即cpu核数):

“由于超线程只是为每个核心提供两组线程上下文单元,两个线程其实是共享各种核内运算部件的。超线程的好处是线程之间往往没有各种数据依赖关系,两个线程的指令流可以尽量填充流水线并充分利用乱序多发射能力。互相掩盖对方的各种延迟,提高每个核心的利用效率。这里的向量计算已经完整地利用了浮点乘加的吞吐能力,所以超线程并不带来好处”,出自https://zhuanlan.zhihu.com/p/28226956。

因此,一个像Gemfield机器上这样的强大CPU,它拥有20个逻辑核、10个CPU核,每个核的每个时钟周期平均执行2个vector计算,每个vector计算可以同时操作8个float数。因此,至少在理论上,gemfield的机器可以在一个时钟周期内执行10 * 2 * 8 = 160个操作(当前,不同的指令有不同的吞吐量)。

总结

因此,我们一共在3个层面上通过并行化来提高CPU的计算速度:

1,vectorization,也就是SIMD指令集;

2,cpu pipeline width及乱序执行;

3,多核处理器及多线程;

CPU通过上述不同层面的并行化来孜孜不倦的提高计算速度,而这种使用并行化来提高计算速度的理念,正是GPU与生俱来的天赋。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值