Chaofan's

Bonvenon al la malpura mondo.

快速了解SIMD

SIMD即Single Instruction Multiple Data缩写,单指令多数据,表示CPU设计中一种提高程序并行度的技术。和它对应的SISD、MISD和MIMD三个概念已很少有人提及,倒是GPU又引入了个新概念叫SIMT (Single Instruction Multiple Threads)。

换种理解方式或许更实在些:SIMD和SIMT表示逻辑上有多个执行的实体,但只有一个执行的状态。至于其他三种:

  • 一个执行实体,一个执行状态,属于单线程
  • 一个执行实体,多个执行状态,属于协程
  • 多个执行实体,多个执行状态,属于多线程

更实用地说,编程用途上的SIMD就是一组指令集扩展,能用一条指令同时完成若干个数据的相同操作。由于大量计算程序最耗费时间的代码都是核心的若干循环,如果能有SIMD指令的帮助,虽然复杂度还是没有改变,但相当于耗时除以一个不小的系数,不算免费午餐也是廉价午餐了。

快速上手SIMD指令

这里用C语言举例,看下面的代码。

#include <stdio.h>

typedef unsigned vecu32 __attribute__((vector_size(16)));

extern unsigned data0[];
extern unsigned data1[];
extern unsigned datalen;

int main(void) {
  for (int i = 0; i < 100000; ++i) {
    int j = 0;
    for (; j + 4 <= datalen; j += 4) {
      vecu32 a = *(vecu32*)(&data0[j]);
      vecu32 b = *(vecu32*)(&data1[j]);
      *(vecu32*)(&data0[j]) = a + b;
    }
    for (; j < datalen; ++j)
      data0[j] += data1[j];
  }
}

为阻止编译器做常量优化,这里把两个数组的定义放置在另一个文件里,长度都为65536个unsigned。在Apple M1上,使用Apple Clang 16搭配-O选项,运行时间是0.87秒。而如果不使用vecu32,即注释掉中间用到vecu32的for循环,运行时间会来到2.40秒。这里一个vecu32是16字节,即能容纳4个unsigned。虽然没有完全达到4倍性能差距,但2.8倍也是非常明显的性能提升,奥秘就在这里声明的vecu32类型。

如果你稍微熟悉GCC或Clang,就能发现这里的__attribute__是GCC风格属性扩展,而vector_size属性就是为向量类型准备的。在编译器语境,SIMD容器就被称作向量 (vector),实际上确实比C++的std::vector更接近数学上向量的原始定义。这里一个vecu32能容纳4个unsigned变量,那我能否定义一个32字节的vecu32来进一步加速呢?

先看当前版本的汇编。在ARM上用clang -S可以发现输出中有一条add.4s v0, v0, v1指令,查询文档可得知这条指令的意思就是向量整数加。再把源码中的16改成32,for循环头上的4改成8编译一遍,会发现执行时间几乎没有变化,而汇编里有了两条 add.4s而没有想象中的add.8s存在。失落之余先别着急,在输出汇编的编译命令中加入-arch x86_64 -mavx,能惊喜地发现X86平台是有32字节SIMD指令的,叫vpaddd。甚至,甚至可以更进一步,把上面代码的向量尺寸改为64字节,循环步长改为8,然后加上-mavx512f选项,会发现X86版本还能有对应的单条指令 (尽管名字还叫vpaddd,但寄存器类型变了,实质上是和前面的vpaddd算不同的指令)。

这告诉我们一个道理,SIMD指令的支持范围和CPU架构有关,和编译器选项也有关。

SIMD和CPU架构

不同CPU架构支持的SIMD长度和类型各有不同,但主流指令集都有面向SIMD的扩展。

x86

最早的x86 SIMD扩展指令集叫MMX,来自1996年的Intel,以加速多媒体程序。但它仅支持64位长度,并且只能加速8位到32位的整数操作。因为早期x86 CPU的古怪设计,这个MMX指令不光短,还会占用浮点数的寄存器,用今天的目光看实在鸡肋。AMD也看不下去,推出了名叫3DNow!的扩展,支持浮点SIMD。为了应对,Intel很快推出了新的SIMD扩展,也就是今天有名的SSE (Streaming SIMD Extensions)。

SSE支持128位长度的向量,且可容纳如4xfloat、2xdouble、4xint、8xshort或16xchar等不同类型。更重要的是,因为过去X86对浮点的支持过于奇葩 (x87指令集),SSE对标量(即单个)浮点数的操作也做了延伸,float和double的操作终于可以对应到和整数相似的指令了。SSE经历了多次扩展,涵盖了整数和浮点数从算术到重排和加密等各种操作。对今天的x86 CPU来说,SSE可以视作默认支持。

十年后,Intel又发布了新的SIMD扩展,称作AVX (Advanced Vector eXtensions),总体和SSE相似,不过长度又扩展了一倍,支持256位向量。AVX还有更变态的延伸版本叫做AVX-512,顾名思义就是512位向量,8个double或者64个char同时操作。

ARM

ARMv7引入了高级SIMD扩展,通常也被称作NEON。和x86的MMX/SSE类似,NEON支持64位和128位两种向量。虽然ARM处理器家族比x86更复杂多样,但今天也基本可以假定,主流ARM芯片都支持NEON指令集。

一部分面向服务器的ARM处理器,为了支持更长的向量,走了和x86不同的道路,推出了称作SVE (Scalable Vector Extension) 的动态向量扩展。和AVX固定256位、AVX512固定512位不同,SVE没有固定向量长度,而是在向量操作之外,又引入了一组谓词指令和寄存器,这就可以在汇编层面体现上层的循环逻辑,从而在运行时确定向量长度(也就代表着循环次数)。这样做的好处是:一个为SVE编译的二进制程序,在不同向量长度的CPU上都可不经改动执行,自动获得硬件向量变长带来的性能提升。

SVE支持128到2048位的向量,ARMv9引入的SVE2又加入了若干新指令。本文不计划深入讨论SVE的使用。普通桌面级的ARM CPU (如Apple M1),并不支持SVE。

RISC-V

RISC-V把除最基本整数指令外的所有指令都归类为扩展,并以单独的字母标记,如扩展F和D分别表示单精度和双精度浮点数。RISC-V曾经有个叫做P (Packed SIMD) 的扩展,但今天更主流的是扩展V。RISC-V的创造者之一David Patterson (《计算机体系结构:量化研究方法》的作者之一),曾经写过一篇文章批评传统的SIMD指令设计不够灵活,增加了复杂度。

也因此,RISC-V的扩展V (经常称作RVV) 指令设计更类似ARM SVE。而细节上更加灵活。比如说,向量长度存储在一个特殊寄存器中,计算指令也并不包含元素长度,具体元素多长会由特别指令设置。一条vfadd.vv可能是f32也可能是f64。

在RISC-V语境中,SIMD向量两个词有明显区分:SIMD指x86风格的定长定类指令,向量指可动态扩展的多数据指令。但在本文其他部分,不作严格区分。

POWER

在Mac电脑还在使用PowerPC CPU的年代,苹果、IBM和摩托罗拉组建过所谓的AIM联盟。90年代末还没有今天的GPU概念,多媒体相关的加速都由CPU完成。为了和x86 SSE竞争,AIM在PowerPC指令集上推出了Vector Media eXtension (VMX) 扩展。该扩展引入了一种128位向量类型,支持整数和部分单精度浮点指令。

PowerPC Mac的绝唱,PowerMac G5,支持该指令集。而后续IBM服务器上的POWER指令集依然包括这个扩展。VMX更出名的名称叫AltiVec,但2004年摩托罗拉半导体部门分拆为飞思卡尔公司,AltiVec商标由飞思卡尔持有。为避免商标纠纷,IBM用到的场合继续称之为VMX。在GCC和Clang等编译器眼中,这个指令集依然叫AltiVec。

从POWER指令集2.06版开始(即POWER 7),POWER在VMX基础上引入了新的VSX (Vector Scalar eXtension) 指令集。在传统的POWER浮点指令外,VSX加入了一组新的和IEEE-754完全兼容的标量和向量浮点指令,类似SSE。浮点和向量寄存器也统一起来,VSX共64个128位寄存器,传统的32个浮点寄存器成为了前32个VSX寄存器前64位的别名,32个VMX寄存器则对应到后32个VSX寄存器。

WebAssembly

虽然WebAssembly不是真实的CPU指令集,但因为设计上考虑性能,加入SIMD指令也有助于模拟和JIT执行。Wasm的SIMD类型相对简单,固定128位,配合不同类型的计算指令,和SSE、NEON、VMX/VSX都能对上。

编译器与SIMD

虽然SIMD能给计算密集的程序带来提升,但我们并不是每次都要手写这堆奇怪的语法才能用上SIMD。在开优化的情景下,编译器会努力地将代码中的标量操作组合成向量指令,这部分功能在编译器中称作Vectorizer。LLVM中有循环Vectorizer和SLP Vectorizer两类,前者针对循环而后者针对非循环。其实,开头那个程序如果用O3编译,即使不手工使用向量扩展,Clang也可以生成出向量指令。

像开头例子中这样简单的循环,循环体内只出现了一次加法,为了「凑」出四个加法构成SIMD,编译器需要像我们手写的代码一样把循环体扩充为原来的4倍,然后让循环次数除以4,并且还要处理剩下几个余数的情况,所以汇编容易变得非常大。这种优化叫做循环展开 (Loop Unrolling)。有关其他自动向量化中可能涉及到的优化,可以查阅LLVM向量化的官方文档

如果还是有要手写向量化代码的情况,除开头提到的__attribute__((vector_size()))外,每个平台都提供了自己的C语言扩展:

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注