SIMD 初识

SIMD(Single Instruction Multiple Data)指令集是一种单条指令可以处理多条数据流的指令的集合。而一般我们使用的指令集是只能够单条指令处理单条数据流,这种指令集又称 SISD(Single Instruction Single Data)指令集。为了支持 SIMD,CPU 进行物理设计时会增加一些专用的向量寄存器,这些寄存器的长度往往大于通用寄存器,例如 128bit。而不同 CPU 往往其向量寄存器以及 SIMD 指令集有所不同,使用时需要注意。

当前 SIMD 已经在高性能编程中发挥重要作用,例如在数据库内核中,SIMD 已经被广泛使用。

image

image

有哪些 SIMD 指令集扩展?

不同的 CPU 架构支持的 SIMD 指令集扩展不同,比如:

  • x86: SSE、AVX、AVX2(Advanced Vector Extensions2)、AVX-512(Advanced Vector Extensions-512)
  • ARM:NEON
  • 龙芯:LSX、LASX
  • RISC-V:RVV

x86 指令集扩展

这里以 x86 为例,SIMD 指令集扩展的演进过程: 1997 ─► MMX │ 1999 ─► SSE │ 2000 ─► SSE2 │ 2003 ─► SSE3 │ 2004 ─► SSSE3 │ 2006 ─► SSE4.1 → SSE4.2 (2008) │ 2011 ─► AVX │ 2013 ─► AVX2 + FMA(Fused Multiply-Add,融合乘加) + BMI/BMI2 │ 2017 ─► AVX-512 系列 │ 2020+ ─► AMX (高级矩阵扩展)

查看当前 CPU 支持的 SIMD 指令集扩展信息:

postgres@slpc:~$ grep flags /proc/cpuinfo | head -n 1
flags		: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology tsc_reliable nonstop_tsc cpuid tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch pti ssbd ibrs ibpb stibp fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves avx_vnni arat umip gfni vaes vpclmulqdq rdpid movdiri movdir64b fsrm md_clear serialize flush_l1d arch_capabilities

其中,可以看到当前的 CPU 支持 sse、sse2、ssse3、sse4_1、sse4_2、avx、avx2(支持 256 位宽的向量运算)、avx_vvni(针对 AI 神经网络优化的指令集)。我这台消费级电脑没有支持 avx-512。

ARM 指令集扩展

ARM 指令集扩展的演进过程:

1998 ─► NEON (ARMv7) │ 2011 ─► VFPv4 + NEON 增强 │ 2013 ─► ARMv8 AArch64 + NEON 扩展 │ 2016 ─► ARMv8.2 FP16 支持 │ 2018 ─► ARMv8.2 Dot Product / Int8 │ 2019 ─► ARMv8.4 BF16 支持 │ 2020 ─► ARMv9 + SVE2 (可扩展矢量扩展) │ 2022 ─► SVE2.1 / SVE2.2

SIMD 编程

向量化编程方式:

  • 编译器自动向量化
  • 汇编语言
  • Intrinsics

image

自动向量化

现代编译器一般会提供 SIMD 优化,即自动向量化,在开优化的情景下,编译器会努力地将代码中的标量操作组合成向量指令。可参考 Auto-Vectorization in LLVM。也就是说,即使你编写的是标量代码,编译器也有可能会将其转换为效率更高的 SIMD 指令。

image

但是编译器自动优化的前提是要保证程序逻辑的绝对正确,所以,只有在满足特定条件下才会自动向量化。

  • 循环向量化:旨在实现循环间迭代的向量并行
  • 超级字并行向量化,旨在实现基本块内的向量并行

这里以循环向量化为例,说明自动向量化的大致过程:

  • 合法性分析:识别可向量化的循环,进行依赖分析,如检查出数据依赖则无法进行向量化,当前还有其他的限制,例如内存是否合法,内存是否连续等。
  • 收益分析:并不是所有的场景下循环向量化后性能比纯标量性能高,向量化是有一定的开销的,如果编译器评估后向量化并没有什么明显的收益,则取消向量化。
  • 中间表示转换:执行具体的向量化代码转换,生成 SIMD 指令。

默认情况下(例如 -O2),编译器只做基础优化,如果要进行自动向量化,通常需要开启 -O3 级别优化。常用编译选项如下:

  • -O3:开启所有优化,包括自动向量化。
  • -fopt-info-vec:显示自动向量化的优化信息。(gcc 编译器)
  • -fopt-info-vec-missed: 显示失败的自动向量化优化信息。(gcc 编译器)
  • -Rpass=loop-vectorize: 显示自动向量化优化信息。(clang 编译器)
  • -Rpass-missed=loop-vectorize: 显示失败的向量化优化信息。(clang 编译器)
  • -Rpass-analysis=loop-vectorize: 显示向量化分析信息,比如失败原因。
  • -march=native:使用当前 CPU 的指令集扩展。
  • #pragma GCC ivdep:忽略向量依赖(用于复杂循环)
  • pragma GCC unroll N:展开循环以便向量化。
// 遍历数组并求和(标量风格)
long long traverse_array(Array* arr) {
    long long sum = 0;
    for (size_t i = 0; i < arr->size; i++) {
        sum += arr->data[i];
    }
    return sum;
}

我们使用 -O3-fopt-info-vec 显示成功的优化,进行编译:

postgres@slpc:$ gcc -o array array.c -O3 -march=native -fopt-info-vec
array.c:39:26: optimized: loop vectorized using 32 byte vectors   # 成功进行 SIMD 优化
array.c:39:26: optimized: loop vectorized using 16 byte vectors

执行 gcc -S -O3 -march=native -o array.s array.c 查看一下汇编代码:

	.file	"array.c"
	.text
	.p2align 4
	.globl	traverse_array
	.type	traverse_array, @function
traverse_array:
.LFB53:
	.cfi_startproc
	endbr64
	movq	8(%rdi), %rcx
	testq	%rcx, %rcx
	je	.L9
	leaq	-1(%rcx), %rax
	movq	(%rdi), %rsi
	cmpq	$6, %rax
	jbe	.L10
	movq	%rcx, %rdx
	movq	%rsi, %rax
	vpxor	%xmm0, %xmm0, %xmm0
	shrq	$3, %rdx
	salq	$5, %rdx
	addq	%rsi, %rdx
	.p2align 4,,10
	.p2align 3
.L4:
	vpmovsxdq	(%rax), %ymm1
	vmovdqu	(%rax), %ymm3
	addq	$32, %rax
	cmpq	%rdx, %rax
	vpaddq	%ymm0, %ymm1, %ymm1
	vextracti128	$0x1, %ymm3, %xmm0
	vpmovsxdq	%xmm0, %ymm0
	vpaddq	%ymm1, %ymm0, %ymm0
	jne	.L4
	vmovdqa	%xmm0, %xmm2
	vextracti128	$0x1, %ymm0, %xmm0
	movq	%rcx, %rdx
	vpaddq	%xmm0, %xmm2, %xmm2     SIMD 指令
	andq	$-8, %rdx
	testb	$7, %cl
	vpsrldq	$8, %xmm2, %xmm0
	vpaddq	%xmm0, %xmm2, %xmm0
	vmovq	%xmm0, %rax
	je	.L20
	vzeroupper
.L3:
	movq	%rcx, %rdi
	subq	%rdx, %rdi
	leaq	-1(%rdi), %r8
	cmpq	$2, %r8
	jbe	.L7
	vmovdqu	(%rsi,%rdx,4), %xmm0
	movq	%rdi, %r8
	andq	$-4, %r8
	vpmovsxdq	%xmm0, %xmm1
	vpsrldq	$8, %xmm0, %xmm0
	addq	%r8, %rdx
	andl	$3, %edi
	vpaddq	%xmm2, %xmm1, %xmm1
	vpmovsxdq	%xmm0, %xmm0
	vpaddq	%xmm1, %xmm0, %xmm0
	vpsrldq	$8, %xmm0, %xmm1
	vpaddq	%xmm1, %xmm0, %xmm0
	vmovq	%xmm0, %rax
	je	.L1
.L7:
	movslq	(%rsi,%rdx,4), %r8
	leaq	0(,%rdx,4), %rdi
	addq	%r8, %rax
	leaq	1(%rdx), %r8
	cmpq	%rcx, %r8
	jnb	.L1
	movslq	4(%rsi,%rdi), %r8
	addq	$2, %rdx
	addq	%r8, %rax
	cmpq	%rcx, %rdx
	jnb	.L1
	movslq	8(%rsi,%rdi), %rdx
	addq	%rdx, %rax
	ret
	.p2align 4,,10
	.p2align 3
.L9:
	xorl	%eax, %eax
.L1:
	ret
	.p2align 4,,10
	.p2align 3
.L20:
	vzeroupper
	ret
.L10:
	vpxor	%xmm2, %xmm2, %xmm2
	xorl	%edx, %edx
	xorl	%eax, %eax
	jmp	.L3
	.cfi_endproc

发现其使用了 AVX 指令集扩展进行了优化。查看汇编还有一个工具,godbolt,能将在线代码生成汇编代码直接查看。

自动向量化,利用编译器的能力进行 SIMD 优化,程序员也可以在编译器无法自动判断是否能进行向量化的代码中增加提示信息提示编译器这里可以安全的进行向量化。例如 #pragma GCC ivdep 等。

// 帮助编译器自动向量化的技巧

// ❌ 阻碍向量化的模式
void bad_vectorization(float *a, float *b, float *c, int n) {
    // 问题 1: 别名问题(编译器无法确定是否重叠)
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

// ✅ 改进版本
void good_vectorization(float * restrict a, 
                        float * restrict b, 
                        float * restrict c, 
                        int n) {
    // 技巧 1: 使用 restrict 关键字(承诺无别名)
    // 技巧 2: 简单的循环结构
    // 技巧 3: 连续的内存访问模式
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

// 🎯 编译选项(GCC/Clang):
// -O3              : 启用自动向量化
// -ftree-vectorize : 显式开启向量化
// -fopt-info-vec   : 输出向量化报告
// -march=native    : 针对本机 CPU 优化

需要注意不同编译器向量化提示等用法可能不同,可参考 TiFlash 面向编译器的自动向量化加速编译器优化那些事儿(12):LLVM 自动向量化 LLVM Auto-Vectorization

Intrinsics

SIMD 编程,可以采用汇编语言实现,但是汇编语言编写 SIMD 代码门槛很高,对此,可以使用 Intrinsics。Intrinsics 是编译器内建的函数,由编译器提供,类似于内联函数,直接映射到 CPU 的 SIMD 指令集扩展,这些函数允许开发者在高级语言中编写 SIMD 代码,而无需使用汇编语言,Intrinsics 不是真正的函数调用,而是编译时被替换为对应的机器指令,因此性能接近汇编,但编程门槛更低。

Intrinsics 主要用于 x86(SSE、AVX、AVX2、AVX-512)、ARM(NEON)、RISC-V(RVV)等架构。它们通过头文件(如 <immintrin.h> for AVX)提供,函数名通常以 _mm_mm256 开头,表示操作的向量宽度(128 位或 256 位)。

基本原理:

  • 映射机制:每个 Intrinsic 函数对应一条或多条 SIMD 指令。编译器(如 GCC、Clang、MSVC)在编译时将这些函数直接翻译为硬件指令,而不是生成函数调用。这避免了函数调用的开销。
  • 向量操作:Intrinsics 操作向量寄存器(如 __m128 for 128 位,__m256 for 256 位),允许同时处理多个数据元素(e.g., 8 个 float 在 AVX2 中)。
  • 类型安全:使用特定类型(如 __m256),确保数据对齐和类型正确。未对齐访问可能导致性能下降或崩溃。
  • 编译器优化:Intrinsics 可以与编译器自动向量化结合,但 Intrinsics 提供更精确的控制。编译时需指定目标指令集(如 -mavx2),否则可能回退到标量代码。
  • 限制:Intrinsics 依赖硬件支持;运行时需检查 CPU 特性(如使用 cpuid)。不支持的指令会导致非法指令异常。

具体可参考

虽然更强大的 AVX-512 已经出现,但其在消费级平台上的支持并不统一,而 AVX2 其具有广泛的硬件支持,这里以 AVX2 为例说明 SIMD 编程的使用。

向量化代码示例 1
#include <immintrin.h>  // AVX2 头文件
#include <stdio.h>

// 标量版本(未优化)
void array_add_scalar(float *a, float *b, float *c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

// AVX2 优化版本(每次处理 8 个 float)
void array_add_avx2(float *a, float *b, float *c, int n) {
    int i = 0;
	// 省略处理 n<8 的情况...
    for (; i + 7 < n; i += 8) {  // 主循环:8 路并行
        __m256 va = _mm256_loadu_ps(&a[i]);  // 加载 8 个 float 到向量寄存器(未对齐加载)
        __m256 vb = _mm256_loadu_ps(&b[i]);
        __m256 vc = _mm256_add_ps(va, vb);   // 向量加法
        _mm256_storeu_ps(&c[i], vc);          // 存储结果
    }
    // 尾数处理:剩余元素用标量
    for (; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

int main() {
    float a[16] = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16};
    float b[16] = {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1};
    float c[16];
    array_add_avx2(a, b, c, 16);
    for (int i = 0; i < 16; i++) printf("%.0f ", c[i]);  // 输出: 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
    return 0;
}

编译

# 启用 AVX2
gcc -mavx2 -O3 example.c -o example

函数 _mm256_loadu_ps:从内存加载 256 位浮点数到向量寄存器。

/// Loads 8 single-precision floating point values from an unaligned
///    memory location pointed to by \a __p into a vector of [8 x float].
///
/// \headerfile <x86intrin.h>
///
/// This intrinsic corresponds to the <c> VMOVUPS </c> instruction.
///
/// \param __p
///    A pointer to a memory location containing single-precision floating
///    point values.
/// \returns A 256-bit vector of [8 x float] containing the moved values.
static __inline __m256 __DEFAULT_FN_ATTRS
_mm256_loadu_ps(float const *__p)
{
  struct __loadu_ps {
    __m256_u __v;
  } __attribute__((__packed__, __may_alias__));
  return ((const struct __loadu_ps*)__p)->__v;
}

image

image

向量化代码示例 2

数组累加求和,向量化代码示例:

float sum_scalar(float *a, size_t n) {
    float s = 0.0f;
    for (size_t i = 0; i < n; i++) {
        s += a[i];
    }
    return s;
}

float sum_avx2(float *a, int n)
{
    size_t i = 0; 
    __m256 vsum = _mm256_setzero_ps(); // 初始化向量寄存器为 0
    for (; i +7 < n; i += 8) {
        __m256 v = _mm256_loadu_ps(&a[i]); // 加载 8 个 float 到向量寄存器
        vsum = _mm256_add_ps(vsum, v); // 向量加法
    }

    // 水平累加向量寄存器中的元素
    float tmp[8];
    _mm256_storeu_ps(tmp, vsum); // 将向量寄存器的值存储到临时数组
    float sum = tmp[0] + tmp[1] + tmp[2] + tmp[3] + tmp[4] + tmp[5] + tmp[6] + tmp[7]; // 累加临时数组中的元素

    // 处理剩余元素
    for (; i < n; i++) {
        sum += a[i];
    }

    return sum;
}

image

image

编程参考资料:

SIMD 在 PostgreSQL 中的应用示例

Allows for SIMD execution of vector operations on x86 architecture, including JSON string parsing, ASCII string detection, and subtransaction ID searches.

在当前代码库 git log --all --grep='SIMD' 能看到:

  • doc: PG 16 relnotes, SIMD improvements(release-16.sgml,John Naylor)
  • Introduce helper SIMD functions for small byte arrays(simd.h / pg_lfind)
  • Optimize JSON escaping using SIMD(json.c)
  • Optimize xid/subxid searches in XidInMVCCSnapshot()(MVCC 快查)
  • Optimize pg_memory_is_all_zeros()(memutils.h)
  • Optimize COPY FROM (FORMAT {text,csv}) using SIMD
  • Optimize hex_encode()/hex_decode() using SIMD
  • Compute CRC32C using AVX-512
  • Use ARM Advanced SIMD (NEON) intrinsics where available
  • Refactor some SIMD and popcount macros 等

CRC32 校验

CRC-32C 是一种高效的循环冗余校验算法,广泛用于检测数据传输或存储中的错误。在 PG 中使用其进行数据完整性检测,主要包括 WAL 日志校验、数据页校验、控制数据文件 pg_control 等。CRC-32C 计算对 PG 性能具有较大影响,因此针对不同平台采用不同优化方案,支持特殊的指令优化,例如 Intel SSE4.2 指令集优化。最新的 PG 代码中支持 x86、arm、loongarch64 指令集优化。

 /*
 * pg_crc32c.h
 *	  Routines for computing CRC-32C checksums.
 *
 * The speed of CRC-32C calculation has a big impact on performance, so we
 * jump through some hoops to get the best implementation for each
 * platform. Some CPU architectures have special instructions for speeding
 * up CRC calculations (e.g. Intel SSE 4.2), on other platforms we use the
 * Slicing-by-8 algorithm which uses lookup tables.
 */

#if defined(USE_SSE42_CRC32C)
/*
 * Use either Intel SSE 4.2 or AVX-512 instructions. We don't need a runtime check
 * for SSE 4.2, so we can inline those in some cases.
 */

#include <nmmintrin.h>

#define COMP_CRC32C(crc, data, len) \
	((crc) = pg_comp_crc32c_dispatch((crc), (data), (len)))
#define FIN_CRC32C(crc) ((crc) ^= 0xFFFFFFFF)

extern pg_crc32c pg_comp_crc32c_sb8(pg_crc32c crc, const void *data, size_t len);
extern PGDLLIMPORT pg_crc32c (*pg_comp_crc32c) (pg_crc32c crc, const void *data, size_t len);
extern pg_crc32c pg_comp_crc32c_sse42(pg_crc32c crc, const void *data, size_t len);
#ifdef USE_AVX512_CRC32C_WITH_RUNTIME_CHECK
extern pg_crc32c pg_comp_crc32c_avx512(pg_crc32c crc, const void *data, size_t len);
#endif

#elif defined(USE_ARMV8_CRC32C)
/* Use ARMv8 CRC Extension instructions. */

#define COMP_CRC32C(crc, data, len)							\
	((crc) = pg_comp_crc32c_armv8((crc), (data), (len)))
#define FIN_CRC32C(crc) ((crc) ^= 0xFFFFFFFF)

extern pg_crc32c pg_comp_crc32c_armv8(pg_crc32c crc, const void *data, size_t len);

#elif defined(USE_LOONGARCH_CRC32C)
/* Use LoongArch CRCC instructions. */

#define COMP_CRC32C(crc, data, len)							\
	((crc) = pg_comp_crc32c_loongarch((crc), (data), (len)))
#define FIN_CRC32C(crc) ((crc) ^= 0xFFFFFFFF)

extern pg_crc32c pg_comp_crc32c_loongarch(pg_crc32c crc, const void *data, size_t len);  // 龙芯架构优化

#elif defined(USE_ARMV8_CRC32C_WITH_RUNTIME_CHECK)

/*
 * Use ARMv8 instructions, but perform a runtime check first
 * to check that they are available.
 */
#define COMP_CRC32C(crc, data, len) \
	((crc) = pg_comp_crc32c((crc), (data), (len)))
#define FIN_CRC32C(crc) ((crc) ^= 0xFFFFFFFF)

extern pg_crc32c pg_comp_crc32c_sb8(pg_crc32c crc, const void *data, size_t len);
extern pg_crc32c (*pg_comp_crc32c) (pg_crc32c crc, const void *data, size_t len);
extern pg_crc32c pg_comp_crc32c_armv8(pg_crc32c crc, const void *data, size_t len);

#else
/*
 * Use slicing-by-8 algorithm.
// ...

可以看到,PG 已经支持龙芯架构的优化了,龙芯加油!

其他

我们可以看到,因为 SIMD 与硬件指令集有依赖关系,所以需要根据不同的平台进行适配。对此,PG 提供统一平台抽象(SIMD 兼容层),目标是不依赖具体指令集调用方,只用封装接口。具体可以看 PG 源码 simd.h

static inline void
vector32_load(Vector32 *v, const uint32 *s)
{
#ifdef USE_SSE2
	*v = _mm_loadu_si128((const __m128i *) s);
#elif defined(USE_NEON)
	*v = vld1q_u32(s);
#endif
}

SIMD 在数据库中的应用

向量化执行引擎目前已经是很多数据库的标配了,除了 OLAP 数据库,很多 OLTP 型数据库,也支持向量化执行引擎。

可以参考 TiFlash 面向编译器的自动向量化加速

下面两篇参考资料非常值得阅读。 Advanced Database Systems vectorization-1

Advanced Database Systems vectorization-2


参考文档: 快速了解 SIMD 编译器优化那些事儿(12):LLVM 自动向量化 LLVM Auto-Vectorization ARM SIMD 指令集介绍 SIMD 自动向量化最新综述分享 TiFlash 面向编译器的自动向量化加速 Advanced Database Systems vectorization-1 Advanced Database Systems vectorization-2