SIMD:一条指令同时处理多个数据

SIMD 全称 Single Instruction Multiple Data(单指令多数据),是 Flynn 分类法里的一种:一条指令同时对一组数据执行相同的操作,属于数据并行。与之相对的是普通的标量指令(SISD,Single Instruction Single Data),一条指令只处理一个数据。

CPU 指令集里的指令并不是都能批量操作数据的:只有一部分特定操作(加减乘除、位运算如异或/与/或、比较、移位等)才提供了"批量版本",这部分指令就叫 SIMD 指令;分支跳转这类指令就没有对应的 SIMD 形式。批量操作的前提是数据要打包进宽寄存器——比如一个 256 位的 YMM 寄存器可以看作 8 个 32 位整数拼在一起,一条 SIMD 指令对这个寄存器操作时,是把 8 个"数据槽"同时各自执行同一个操作(8 次加法或 8 次异或同时完成),而不是把整个 256 位当成一个数来算。这一整批 SIMD 指令加上背后的宽寄存器,合在一起就构成了一个 SIMD 指令集扩展。

现代 CPU 都提供了专门的向量寄存器和向量指令集来支持 SIMD:

架构 指令集 寄存器宽度
x86 SSE / SSE2 128 位
x86 AVX / AVX2 256 位
x86 AVX-512 512 位
ARM NEON 128 位
ARM SVE 可变长(128~2048 位)

举个例子,如果要对两个长度为 4 的 float 数组逐元素相加,用标量指令需要 4 次加法;如果用 128 位的 SSE 寄存器(正好能装下 4 个 32 位 float),一条向量加法指令就能同时算完这 4 组加法,吞吐量提升了 4 倍。

生成 SIMD 指令主要有三种途径:编译器自动向量化(比如把一个简单的 for 循环识别出来,自动改写成向量指令,站内旧文 向量化 讲的就是 Intel 编译器的自动向量化选项)、手写 intrinsics 函数、或者直接写汇编。

SIMD 的历史:从超算到主流 CPU

SIMD 这个概念出现得比个人电脑还早:

  • 1958 年:Bell Labs 的 Stephen H. Unger 提出了面向空间并行问题的类似构想,是 SIMD 思路最早的雏形。
  • 1966 年:Michael Flynn 提出 Flynn 分类法(SISD/SIMD/MISD/MIMD),“SIMD"这个术语由此正式确立。
  • 1972 年:ILLIAC IV 投入运行,它是历史上第一台大规模的阵列处理机(array processor),被公认为第一台真正意义上的 SIMD 计算机,但它是一台造价高昂的超级计算机,离普通用户很远。
  • 1994 年:HP 在 PA-7100LC 处理器上实现了 MAX-1 指令集,这通常被认为是第一个进入通用处理器(而不是专门的超算)指令集的 SIMD 扩展,目的是加速 MPEG 视频解码。
  • 1996 年:Intel 在 Pentium 处理器上推出 MMX 指令集,这是 SIMD 第一次进入主流消费级 CPU,只支持整数运算。
  • 1998 年:AMD 推出 3DNow!(支持浮点),Motorola/IBM 在 PowerPC G4 上推出 AltiVec。
  • 1999 年:Intel 在 Pentium III 上推出 SSE,第一次在 x86 上支持浮点向量运算,也就是本文表格里 SSE/SSE2 的起点,此后逐步演进出 AVX、AVX-512 等更宽的向量指令集。

所以严格来说,SIMD 的思想最早出现在 20 世纪 50 年代末,第一台实现它的机器是 1970 年代初的 ILLIAC IV 超级计算机;但要说"哪个型号的 CPU 第一个把 SIMD 做进指令集”,答案是 1994 年的 HP PA-7100LC;如果限定在大众更熟悉的 x86 主流处理器上,则是 1996 年的 Intel Pentium MMX。

缓存局部性:CPU 为什么偏爱连续内存

缓存局部性(locality)描述的是程序访问内存的规律性,分两种:

  • 时间局部性(temporal locality):最近访问过的数据,很可能很快会被再次访问,比如循环里反复读写的计数器。
  • 空间局部性(spatial locality):访问了某个地址之后,它附近的地址很可能很快也会被访问,比如顺序遍历一个数组。

CPU 从内存读数据时,并不是一个字节一个字节地读,而是以**缓存行(cache line)**为单位整块搬运,x86_64 上常见的缓存行大小是 64 字节。如果数据在内存里紧凑连续排列,一次缓存行加载就能顺带把后面即将用到的数据也一起搬进 CPU 缓存,减少缓存未命中(cache miss);反之,如果数据分散在内存的不同角落(比如链表的每个节点都是独立分配的),每访问一个新节点大概率都要触发一次新的内存加载。

这也是为什么数组通常比链表更"缓存友好":数组元素连续存放,遍历数组时空间局部性很好;链表节点在堆上各自独立分配,遍历时地址跳来跳去,缓存命中率更低。站内 开链法与开放地址法 提到开放地址法(连续数组)比开链法(链表节点分散)缓存局部性更好,说的正是这个道理。

两者如何配合

SIMD 和缓存局部性是两个独立的概念,但在实践中经常配合出现:SIMD 指令要一次性把一组连续数据加载进向量寄存器才能发挥并行效果,这本身就要求这组数据在内存里是连续排列的,也就是要求良好的空间局部性;反过来,即使完全不用 SIMD,把数据紧凑连续存放也能提升普通标量代码的缓存命中率。

一个典型的例子是 Swiss Table 哈希表 的控制字节数组:这些控制字节本身是连续存放的一段内存(缓存友好),同时又可以整组(8 个或 16 个)用 SIMD 指令或位运算一次性比较,把逐个比较槽位的开销变成一次批量比较,把两者的优势叠加在了一起。

维护记录

时间 修改内容 原因
2026-07-14 补充 SIMD 指令的准确定义:并非所有指令都有批量版本,批量操作依赖宽寄存器切分为多个数据槽 澄清 SIMD 概念,避免与"批量操作任意指令"混淆