高级 #assembly#simd#performance

SIMD / 向量指令

SIMD(Single Instruction, Multiple Data,单指令多数据)让 CPU 用一条指令同时处理多个数据——是多媒体处理、科学计算和 AI 推理加速的基石

一次处理一堆数据

普通的 ADD 指令一次加两个数。但如果要对 100 万个数做加法——你得循环 100 万次。

SIMD(Single Instruction, Multiple Data,单指令多数据) 让 CPU 用一条指令同时处理多个数据:

普通加法(SISD):                    SIMD 加法:
ADD R0, R1                          ADD XMM0, XMM1
只加一对数                          同时加 4 对 32 位数(SSE)
                                   或 8 对 16 位数
                                   或 16 对 8 位数

类比:流水线上的打包

  • SISD(单指令单数据) = 一个工人一次打包一个箱子——慢但简单
  • SIMD(单指令多数据) = 一个工人一次把 4 个箱子推进打包机——效率翻 4 倍

但前提是:这 4 个箱子的打包方式必须完全一样——这就是 SIMD 的局限:只能做数据并行的操作,不能做不同的操作。

发展历史

MMX(1997)

  • 64 位寄存器(MM0-MM7)
  • 同时处理 8 个字节 / 4 个 16 位整数
  • 缺点:与 x87 浮点单元共用寄存器,切换开销大

SSE(1999-2006)

  • 128 位寄存器(XMM0-XMM15)
  • 可同时处理:
    • 4 个 32 位浮点数
    • 2 个 64 位浮点数
    • 16 个字节 / 8 个 16 位整数 / 4 个 32 位整数

AVX(2011)

  • 256 位寄存器(YMM0-YMM15)
  • 可同时处理:
    • 8 个 32 位浮点数
    • 4 个 64 位浮点数

AVX-512(2016)

  • 512 位寄存器(ZMM0-ZMM31)
  • 可同时处理 16 个 32 位浮点数
  • 用于服务器、HPC、AI 训练
寄存器大小演进:
┌──────────────┐
│  64 位 MMX   │  1997
├──────────────┤
│  128 位 XMM  │  1999 (SSE)
├──────────────┤
│  256 位 YMM  │  2011 (AVX)
├──────────────┤
│  512 位 ZMM  │  2016 (AVX-512)
└──────────────┘

💡 每一代都向下兼容——支持 AVX 的 CPU 也支持 SSE 和 MMX。

SIMD 寄存器

以 SSE(128 位)为例,XMM 寄存器可以”拆分”成不同的数据类型视图:

XMM0 寄存器(128 位):

作为 4 个 32 位整数:    [   int  ][   int  ][   int  ][   int  ]
                         XMM0[3]   XMM0[2]   XMM0[1]   XMM0[0]

作为 8 个 16 位整数:    [short][short][short][short][short][short][short][short]

作为 16 个字节:         [byte][byte][byte][byte]...[byte][byte][byte]

作为 4 个单精度浮点数:  [float][float][float][float]

关键在于:CPU 不知道寄存器里是什么类型——由指令决定如何解释这些位。

基本 SIMD 指令

数据加载和存储

; 加载 4 个 32 位整数到 XMM0
MOVDQU XMM0, [addr]        ; 从内存加载 128 位(不对齐也可以)

; 存储 XMM0 到内存
MOVDQU [addr], XMM0

; 加载标量(一个值)并广播到所有位置
; 把 [addr] 处的 32 位值复制到 XMM0 的 4 个位置
; 效果:XMM0 = {v, v, v, v}

算术运算

SSE 提供了多组算术指令——通过后缀区分处理的数据类型:

指令格式:  OP  DST, SRC
含义:     DST = DST OP SRC(逐元素)

PADDB   → 对 16 个字节做加法(Parallel ADD Bytes)
PADDW   → 对 8 个 16 位整数做加法(Words)
PADDD   → 对 4 个 32 位整数做加法(Doublewords)
PADDQ   → 对 2 个 64 位整数做加法(Quadwords)

ADDPS   → 对 4 个单精度浮点数做加法(Add Packed Single)
ADDPD   → 对 2 个双精度浮点数做加法(Add Packed Double)
; 例:向量加法 C[i] = A[i] + B[i](4 个 32 位整数)
MOVDQU XMM0, [A_addr]      ; XMM0 = {A[3], A[2], A[1], A[0]}
MOVDQU XMM1, [B_addr]      ; XMM1 = {B[3], B[2], B[1], B[0]}
PADDD  XMM0, XMM1          ; XMM0 = {A[3]+B[3], A[2]+B[2], A[1]+B[1], A[0]+B[0]}
MOVDQU [C_addr], XMM0      ; 存结果
执行过程可视化:

XMM0: [ A3 ][ A2 ][ A1 ][ A0 ]
        +     +     +     +
XMM1: [ B3 ][ B2 ][ B1 ][ B0 ]
        =     =     =     =
结果: [C3=C3][C2][C1][C0=C0]
        ↑                    ↑
      一次 PADDD 完成了 4 个加法!

逐元素乘法

乘法和加法不同,需要注意数据类型:

; 32 位整数乘法
PMULLD XMM0, XMM1     ; 低 32 位相乘(每对 32 位乘 → 低 32 位结果)
PMULUDQ XMM0, XMM1    ; 无符号 32 位乘 → 64 位结果(只处理偶数位置)

; 浮点乘法
MULPS  XMM0, XMM1     ; 4 个单精度浮点数逐元素相乘
MULPD  XMM0, XMM1     ; 2 个双精度浮点数逐元素相乘

实战:点积(Dot Product)

点积是向量运算的核心——A·B = A[0]*B[0] + A[1]*B[1] + A[2]*B[2] + A[3]*B[3]

; 计算 4 个浮点数的点积
; A = {a0, a1, a2, a3}, B = {b0, b1, b2, b3}
; 结果 = a0*b0 + a1*b1 + a2*b2 + a3*b3

MOVUPS XMM0, [A]       ; XMM0 = {a0, a1, a2, a3}
MOVUPS XMM1, [B]       ; XMM1 = {b0, b1, b2, b3}
MULPS  XMM0, XMM1      ; XMM0 = {a0*b0, a1*b1, a2*b2, a3*b3}

; 现在需要把 4 个乘积加起来——需要水平加法
HADDPS XMM0, XMM0      ; 水平加:XMM0 = {a0*b0+a1*b1, a2*b2+a3*b3, ...}
HADDPS XMM0, XMM0      ; 再水平加:XMM0[0] = 最终之和

HADDPS(水平加法)的执行过程:

第一步 HADDPS XMM0, XMM0:
XMM0 = {a0*b0, a1*b1, a2*b2, a3*b3}
                ↓ HADDPS
XMM0 = {a0*b0+a1*b1, a2*b2+a3*b3, a0*b0+a1*b1, a2*b2+a3*b3}

第二步 HADDPS XMM0, XMM0:
                ↓ HADDPS
XMM0 = {SUM, SUM, SUM, SUM}    ← XMM0[0] 就是最终结果

实战:图像像素处理

将 RGBA 图像每个像素的 RGB 分量乘以 1.2(亮度增强):

每个像素 4 字节:R G B A(各 8 位)
128 位 XMM 寄存器 = 4 个像素
// C 版本
for (int i = 0; i < num_pixels; i++) {
    pixels[i].r = min(255, pixels[i].r * 1.2);
    pixels[i].g = min(255, pixels[i].g * 1.2);
    pixels[i].b = min(255, pixels[i].b * 1.2);
    // A(透明度)保持不变
}
; SSE 版本——一次处理 4 个像素
; XMM0 = {pixel3, pixel2, pixel1, pixel0}(每个像素 32 位 RGBA)

; 拆分为字节 → 转换为浮点 → 乘以 1.2 → 截断 → 组合回去
; 实际代码会使用专门指令如 CVTDQ2PS 和 MINPS

; 简化示意:
.loop:
    MOVDQU XMM0, [pixels + i*16]   ; 加载 4 个像素
    ; ...转换、运算、截断...
    MOVDQU [pixels + i*16], XMM0   ; 存回 4 个像素
    ADD    i, 4
    CMP    i, num_pixels
    JL     .loop

💡 用 SIMD 优化后,处理 100 万像素的循环只需要 25 万次迭代(每次 4 个像素)。

数据洗牌(Shuffle)

SIMD 最强大的操作之一——重新排列寄存器内的数据:

; PSHUFD — 洗牌双字(32 位)
; PSHUFD XMM0, XMM1, imm8
; imm8 的每 2 位指定一个目标位置的来源

; 例:交换顺序 {D, C, B, A} → {A, B, C, D}
; imm8 = 00 01 10 11 (二进制) = 0x1B
; 位置 0 ← 元素 3 (11)
; 位置 1 ← 元素 2 (10)
; 位置 2 ← 元素 1 (01)
; 位置 3 ← 元素 0 (00)

MOVDQU  XMM0, [data]    ; XMM0 = {D, C, B, A}
PSHUFD  XMM0, XMM0, 0x1B  ; XMM0 = {A, B, C, D}

洗牌的应用:

  • 转置矩阵
  • 复制/广播值
  • 数据类型转换时的重新排列
  • 实现 Gather/Scatter 操作

编译器自动向量化

不需要手写 SIMD 的情况

现代编译器会自动把简单的循环转成 SIMD 指令——称为自动向量化(Auto-vectorization)

// 编译器会自动生成 SIMD 指令!
void add_arrays(int* a, int* b, int* c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}
gcc -O2 -ftree-vectorize -fopt-info-vec program.c

自动向量化的条件是:

条件说明
连续内存数组访问模式可预测
无数据依赖第 i 次迭代不依赖第 i+1 次的结果
对齐的数据16 字节对齐(SSE)或 32 字节对齐(AVX)
指针别名编译器不确定 a, b, c 是否指向同一块内存
间接访问a[b[i]] 这类 Gather 操作

帮助编译器向量化

// ❌ 可能不向量化
void add(int* a, int* b, int* c) {
    for (int i = 0; i < 1024; i++) {
        c[i] = a[i] + b[i];
    }
}

// ✅ 用 restrict 告诉编译器没有别名问题
void add(int* restrict a, int* restrict b, int* restrict c) {
    for (int i = 0; i < 1024; i++) {
        c[i] = a[i] + b[i];
    }
}

使用 Intrinsics(C 层面写 SIMD)

手写 SIMD 汇编繁琐且易错。GCC/Clang 提供了 Intrinsics(内联函数)——在 C 中直接调用 SIMD 指令:

#include <emmintrin.h>  // SSE2 intrinsics
#include <immintrin.h>  // AVX intrinsics

// 使用 SSE2 的向量加法
void add_float_sse(float* a, float* b, float* c) {
    __m128 va = _mm_loadu_ps(a);        // MOVUPS — 加载 4 个 float
    __m128 vb = _mm_loadu_ps(b);        // MOVUPS
    __m128 vc = _mm_add_ps(va, vb);     // ADDPS — 向量加法
    _mm_storeu_ps(c, vc);               // MOVUPS — 存回
}

// 一次处理 4 个元素,循环展开
void add_float_array(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i += 4) {
        __m128 va = _mm_loadu_ps(&a[i]);
        __m128 vb = _mm_loadu_ps(&b[i]);
        __m128 vc = _mm_add_ps(va, vb);
        _mm_storeu_ps(&c[i], vc);
    }
}

// AVX 版本——一次处理 8 个 float
#include <immintrin.h>
void add_float_avx(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i += 8) {
        __m256 va = _mm256_loadu_ps(&a[i]);
        __m256 vb = _mm256_loadu_ps(&b[i]);
        __m256 vc = _mm256_add_ps(va, vb);
        _mm256_storeu_ps(&c[i], vc);
    }
}

Intrinsics 命名规则

_mm_add_ps
  │    │   │
  │    │   └── ps = packed single(打包单精度浮点)
  │    │       pd = packed double(打包双精度浮点)
  │    │       epi32 = 32 位整数
  │    │       si128 = 128 位整数
  │    └── 操作名
  └── 前缀:_mm_ = SSE, _mm256_ = AVX, _mm512_ = AVX-512

SIMD 性能对比

操作标量(SISD)SSE(128 位)AVX(256 位)AVX-512
32 位整数加法1 个/次4 个/次8 个/次16 个/次
单精度浮点乘法1 个/次4 个/次8 个/次16 个/次
理论加速比16×

实际加速比通常在 2×-6× 之间(SSE)和 4×-12× 之间(AVX)——不是理论倍数,因为还有内存访问开销、数据对齐等问题。

什么时候用 SIMD?

场景适用性
图像/视频处理✅ 高度适合——像素数据天然并行
音频处理✅ 多个采样点同时处理
科学计算/矩阵运算✅ 矩阵乘法、向量运算的核心
加密算法✅ AES、SHA 都有专用 SIMD 指令
AI 推理✅ 神经网络卷积的核心
字符串搜索✅ 一次比较 16 个字符
分支密集的代码❌ 不适合——每个数据元素要走不同的路径
链表/树遍历❌ 不适合——内存不连续、结构不同

常见错误

错误 1:未对齐的内存访问

某些 SIMD 指令要求内存地址 16 字节对齐:

; 假设 buf 没有 16 字节对齐
MOVDQA XMM0, [buf]     ; ❌ 未对齐 → 一般保护错误(GP fault)!
MOVDQU XMM0, [buf]     ; ✅ MOVDQU 在不对齐时也正常工作(但稍慢)

错误 2:假设 SSE 寄存器被清零

; XMM0 的初始值是不确定的!
PADDD XMM0, XMM1      ; ❌ 如果忘了初始化 XMM0,结果不可预测

错误 3:数据类型混淆

; 加载了 4 个整数,却用了浮点加法
MOVDQU XMM0, [int_data]    ; 加载整数
ADDPS  XMM0, XMM1          ; ❌ 整数数据被解释为浮点数——结果完全错误!
PADDD  XMM0, XMM1          ; ✅ 应该用整数加法

错误 4:残留 XMM 状态导致性能下降

在 x86-64 上,频繁切换 SSE 和 AVX 指令会导致状态转换惩罚(频率降频):

// ❌ 混合使用导致 penalty
_mm_add_ps(a, b);       // SSE
_mm256_add_ps(c, d);    // AVX ← CPU 需要从 SSE 模式切换到 AVX 模式

// ✅ 尽量保持一致
_mm256_add_ps(a, b);    // 全用 AVX
_mm256_add_ps(c, d);    // 全用 AVX

SIMD 的实际收益:一个直观的对比

用 SIMD 处理数组求和——两种方式对比:

// 方式一:普通循环(SISD)
float sum_scalar(float* arr, int n) {
    float sum = 0;
    for (int i = 0; i < n; i++) sum += arr[i];
    return sum;
}

// 方式二:SSE 向量化(SIMD)
float sum_sse(float* arr, int n) {
    __m128 vec = _mm_setzero_ps();  // 初始化 4 个 0
    for (int i = 0; i < n; i += 4) {
        __m128 data = _mm_loadu_ps(&arr[i]);  // 一次加载 4 个
        vec = _mm_add_ps(vec, data);          // 一次加 4 个
    }
    // 最后把 4 个结果累加
    float result[4];
    _mm_storeu_ps(result, vec);
    return result[0] + result[1] + result[2] + result[3];
}

性能差异

  • 普通循环:n 次加法,n 次循环控制
  • SIMD 版本:n/4 次加法(一次加 4 个),n/4 次循环控制
  • 理论上快 4 倍——实际约 2-3 倍(有加载和对齐的开销)

这就是为什么现代编译器遇到循环处理连续数组时,会自动生成 SIMD 指令——前提是开启 -O2 优化。

编译器对 SIMD 的支持

要使用 SIMD intrinsics,需要包含对应的头文件:

指令集头文件GCC 编译选项
MMXmmintrin.h-mmmx
SSExmmintrin.h-msse
SSE2emmintrin.h-msse2
SSE3pmmintrin.h-msse3
SSSE3tmmintrin.h-mssse3
SSE4.1smmintrin.h-msse4.1
SSE4.2nmmintrin.h-msse4.2
AVXimmintrin.h-mavx
AVX2immintrin.h-mavx2
AVX-512immintrin.h-mavx512f
# 编译时开启 SSE4.2
gcc -msse4.2 -O2 -o program program.c

💡 最简单的做法:直接包含 <immintrin.h> 并加上 -march=native——编译器会自动启用当前 CPU 支持的所有 SIMD 指令集。

小结

SIMD 是 CPU 并行处理数据的重要武器:

概念要点
SIMD 本质一条指令处理多个数据元素
寄存器XMM(128 位)、YMM(256 位)、ZMM(512 位)
发展MMX → SSE → AVX → AVX-512
适用场景连续数据、相同操作、无数据依赖
编程方式手写汇编 / Intrinsics / 编译器自动向量化
加速比通常 2×-6×(SSE),4×-12×(AVX)

关键原则:

  • SIMD 适合数据并行——大量数据做同样的操作
  • SIMD 不适合控制并行——每个数据走不同的分支
  • 优先让编译器自动向量化,编译器做不好时再手写 Intrinsics
  • 从 SSE 开始学,AVX 只是更宽的 SSE

为什么这很重要? SIMD 是现代 CPU 最重要的性能特性之一——它通过硬件并行实现了”一条指令当多条用”。从手机里的图像处理到服务器上的 AI 推理,SIMD 无处不在。

至此,你已经完成了汇编语言的全部基础知识!接下来,你将从底层硬件和汇编的世界”往上走一层”——进入操作系统的大门。操作系统是连接硬件和应用程序的桥梁,你将学到进程、线程、内存管理、文件系统等核心概念(此内容将在后续章节详细讲解)。