并行计算与Neon简介

一、并行处理数据方式分类

根据处理器的指令处理数据的并行性特点,目前已经大规模实际应用的类型有四种:SISD MIMD SIMD SIMT。

SISD是伴随计算机诞生之初就出现的数据处理形式,后三种则是在SISD的基础上,提升芯片处理数据能力而发展出的三种并行化的数据处理方式类型,接下来将对四种类型进行概述。

要注意的是,四种类型并非完全互斥,比如SIMT和SISD可以结合,也可以和SIMD结合,MIMD同样可以同时和SISD和SIMD结合。

1.1 单指令流单数据流(SISD)

单指令单数据(Single Instruction Single Data),即一条指令处理一个数据,通常就是我们写的最简单的单线程程序的数据处理形式,在这个形式中,每条CPU指令通常只进行一次数据处理,包括读写、计算。

1.2 多指令流多数据流(MIMD)

多指令多数据(Multipe Instructions Multiple Data),即多条指令处理多个数据,通常就是指CPU上的多线程编程,多个线程运行在多个物理或逻辑CPU上,可以同步或异步地处理数据,从而增加相同时间下的数据处理速度。要注意的是,MIMD和SISD并非完全互斥,因为多线程中某个线程的执行,就可能是SISD,也可能是SIMD。

1.3 单指令流多数据流(SIMD)

单指令流多数据流(Single Instructions Multiple Data),即单个指令处理多个数据,它常见于CPU的向量指令集,例如X86的SSE、AVX指令集,ARM的Neon指令集,都是SIMD形式的向量指令集,同时某些移动平台芯片,例如高通、联发科等公司的SOC集成的DSP协处理器也是SIMD类型的处理器。

SIMD通常可用来加速处理相对简单的图像任务(通常是二维平面图像任务),或者加速一些矩阵计算,例如神经网络计算等。

本系列主要针对CPU向量指令进行介绍,对其它类型的SIMD不作更多描述。

向量指令集和普通指令集的不同从两个方面来考量:

  1. 寄存器宽度和读写:通常向量指令集使用的寄存器,会比处理器位宽大至少一倍。比如ARM64上,Neon指令集有32个128位的向量寄存器,而通用寄存器都是64位。X86-64上,AVX2指令集的寄存器宽度达到256位,AVX512指令集甚至达到了512位。

    除了宽度更大之外,向量寄存器也支持同时对寄存器上不同位置的数据同时操作。一般通用寄存器,一个寄存器通常会被认为是一个数据,但一个向量寄存器可以分为多个lane,每个lane可以视为单独的数据,并支持被同时分开读写。

  2. 数据操作:一条向量指令通常可以同时读写多个向量寄存器,并且可以将一个向量寄存器分为不同lane,当多个数据处理。以Neon为例,一条加法指令可以将两个128位向量寄存器分为4个32位大小的int类型数据,并将两个两个寄存器的4个数据对应相加,存放到第三个向量寄存器上,过程如下图所示。

    如上过程若使用普通指令集,则需要通过循环才能完成:

    int v1[4];
    int v2[4];
    int v3[4];
    for (int i = 0; i < 4; i++ ) {
        v3[i] = v1[i] + v2[i];
    }
    

    显然,通过向量指令集可以将循环压缩到一条指令,而即便有内存读写,也可以压缩到三条向量指令,相比于常规手法,向量指令集的效率无疑非常高。

    Neon就是本系列的研究目标,即如何通过高效利用向量指令集来针对某些类型的计算任务进行优化,降低处理时间、提升处理效率。

1.4 单指令流多线程流(SIMT)

单指令流多线程流(Single Instruction Multiple Threads)是一类比较特殊的并行计算方法,它和前三种不同,是一种是伴随GPU出现而出现的数据并行处理方法。在GPU或类似的处理器上,一条指令发送给多个指令流执行单元来同时同步执行,即同时执行多个完全相同的指令流,即同时执行多个程序相同的线程。但不同线程会通过其特定的标志来区别待处理的数据对象,从而让不同的线程处理不同数据,达到并行化处理数据的目的。另外,在GPU上一个线程内也存在SISD和SIMD,从而更加强化GPU数据处理能力,以适应图形渲染算法等复杂的任务。

二、Arm64 Neon向量指令集

前文已经介绍过,Neon是Arm上的SIMD类型指令集,它在arm32时期就已经存在,并在arm64上得到加强。本文主要介绍arm64上的Neon。

2.1 寄存器

在arm64上,Neon有32个128位专用的向量寄存器,用Vn表示(V0-V31),每个寄存器可以表示多种类型,包括:

  1. 一个128位类型,以Vn表示(V0-V31)
  2. 两个64位类型,用D表示,即寄存器被分割为2个通道(plane)
  3. 4个32位类型,用S表示,即寄存器被分割为4个通道(plane)
  4. 8个16位类型,用H表示,即寄存器被分割为8个通道(plane)
  5. 16个8位类型,用B表示,即寄存器被分割为16个通道(plane)

一个或多个寄存器内多个相同类型的数据可以构成“向量”,当然向量指令集也支持单个数据操作,因此也有单个数据的“标量”。

向量指令集可以同时对向量中所有的数据同时进行操作,例如图1中就是将两个128位寄存器视为两个由4个32位int类型数据组成的向量,一条向量加法指令可以同时对两个向量的所有对应的数据元素进行对应加法,并生成一个新的向量放入第三个寄存器中。

相比之下,在通用寄存器上的数据处理,一条指令一次通常只能将一个寄存器视为一个数据。例如操作一个8位整数,该数据所在的整个64位寄存器都被视为8位整数,剩下56位实际不参与运行,从而产生了“浪费”。

此外,一个向量寄存器被分割为多个通道后,对某个通道的操作具有独立性,不会影响其它通道,例如不会产生上溢或下溢。

2.2 向量指令

Neon的向量指令种类比较丰富,包括常见的算数操作,例如加减乘除、比较运算、位运算等,也包括数据读写操作,包括内存和寄存器之间的数据读写、寄存器之间的数据读写等。

但是和普通指令不同的是,向量指令可以操作多个寄存器和每个寄存器的多个通道,因此除了指定寄存器、内存地址或者偏移量等指令所需的信息外,还需要指定向量寄存器的划分方式、要对寄存器上几个通道、哪些通道进行操作。

向量指令操作数最大为4个向量寄存器,即128*4=512位(64字节),比如向量指令一次可以将4个向量寄存器的数据与另外四个向量寄存器的数据进行对应相加,最终存放到第三组四个向量寄存器中,若数据类型为32位整数,那么一条加法指令相当于执行了16次32位整数加法,因此在某些场景下,向量指令集的效率远远高于普通指令集。

此外,一条向量指令最多可以从内存中加载64字节数据到向量寄存器中,但要注意的是,由于处理器微架构、访存机制等因素,该指令的实际执行时间也许并不比展开为多条普通内存数据加载指令更快,在本系列的后续文章中会进行探讨。

2.3 使用方式

由于向量操作的特点,普通编程一般无法直接使用向量指令集,但可以利用三种方式使用Neon加速:

  1. 手工编写Neon汇编代码:

这种方式一般可以做到性能最大化。通常使用Neon加速的场景都是针对某些算法的优化,程序员一般对算法比较了解,因此可以使用各种技巧最大化利用Neon进行优化。而另外两种方法都要依赖编译器,编译器并不知道算法的特点,因此可能达不到手写汇编的效果。

  1. 使用Neon内建函数

部分编译器提供 <arm_neon.h> 头文件来帮助程序员使用Neon,该头文件中有与Neon指令和寄存器相似和对应的内建函数和数据类型,它们非常类似类似汇编,被编译器识别后会优先以Neon进行编译,因此可以使用内建函数模仿Neon汇编编写程序。但是要注意的是,编译器实际可能并不会按开发者的想法编译出最终机器码,例如可能使用普通指令,甚至使用标准库函数的方式来实现具体效果,因此最终效果可能不符预期。

  1. 使用编译器提供的向量优化能力自动对代码进行向量化,通过使用编译选项来启用:

    1. -ftree-vectorize:执行矢量优化,默认开启-ftree-loop-vectorize-ftree-slp-vectorize
    2. -ftree-loop-vectorize:执行循环矢量优化,将循环展开以减少迭代次数,并在循环中执行更多操作
    3. -ftree-slp-vectorize:将多个标量操作捆绑在一起操作,减少操作次数和指令数量
    4. -O3:O3优化会默认开启-ftree-vectorize

相比于前两种方式,第三种方式的优化范围更广,可以对很多细节进行优化,但对某些特定的算法,它的优化结果可能并不如前两者。此外,向量化后代码可能会存在某些异常行为,尤其是使用O3优化,因此需要谨慎使用。

posted @   绝对精神的自我展开  阅读(189)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器
· PowerShell开发游戏 · 打蜜蜂
点击右上角即可分享
微信分享提示