基本介绍

为什么 CPU 的 SIMD 很重要

现代 CPU 的计算瓶颈往往不在于时钟频率,而在于数据吞吐。以 DDR5-6400 内存为例,其理论带宽约 51.2 GB/s,而一块 Intel Sapphire Rapids 处理器单核的 L1 带宽可达 2 TB/s 以上。如果只靠标量指令(每次处理一个操作数),大量的寄存器带宽和执行单元会被闲置。

SIMD(Single Instruction, Multiple Data)正是为此而生:用一条指令同时操作一组打包在同一宽寄存器中的数据元素。其收益在以下场景尤为突出:

场景 标量瓶颈 SIMD 收益
大块内存复制/置零 每次 8 字节 AVX-512 每次可处理 64 字节,显著减少循环与访存指令数
浮点密集计算(矩阵乘、卷积) 每条指令只覆盖少量元素 AVX-512 FMA 可同时覆盖 16 个 FP32 lane
字符串处理(查找、比较) 逐字节 SSE4.2 pcmpestri 16 字节/次
向量量化推理(LLM int8/int4) 逐元素乘加 VNNI/VPDPBUSD 可把多组 INT8 乘加折叠进一条指令

从性能模型角度看,SIMD 带来的提升本质是提高了每条指令承载的数据级并行度,并常常间接改善有效 IPC。对于内存密集型代码,SIMD 还能减少循环次数,降低分支预测压力和循环控制开销,使前端(Front-End)更不容易成为瓶颈。

此外,现代编译器(GCC、Clang、MSVC)的自动向量化能力有限,面对复杂控制流或不规则内存访问时往往束手无策,手写 intrinsic 或内联汇编是榨干硬件性能的最后一公里

端侧/PC 大模型推理中的 CPU SIMD

随着 LLM 向端侧和 PC 迁移,CPU SIMD 的地位愈发关键。这类场景有一个共同特征:显存/内存容量是第一约束,算力利用率退居其次。Decode 阶段 batch size 通常为 1,矩阵乘退化为矩阵-向量乘(GEMV),访存带宽完全决定 token 生成速度。这正是 CPU 配合高带宽内存(或统一内存)发挥 SIMD 价值的黄金场景。

llama.cpp — 跨平台 SIMD 内核的集大成者

llama.cpp 是目前端侧推理生态中影响力最大的项目,其核心文件 ggml-cpu.c / ggml-quants.c 中手写了覆盖以下平台的 SIMD kernel:

平台 指令集 典型 kernel
x86-64 AVX2、AVX-512、VNNI ggml_vec_dot_q4_K_q8_K
ARM AArch64 NEON、DOTPROD、I8MM(部分平台再加 SVE) ggml_vec_dot_q4_0_q8_0
Apple Silicon NEON(系统库内部可能进一步利用私有矩阵单元) CPU/GPU 协同
RISC-V RVV 1.0 正在合并中

以 Q4_K 量化格式的 GEMV 为例,llama.cpp 的 AVX2 内核可以概括为下面这种结构化简化伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 结构化伪代码:省略了 super-block scale/min 解码、补偿项等细节
// 输入:量化权重块 vx,量化激活块 vy(Q8_K)
float dot_q4k_q8k_avx2(const void *vx, const void *vy, int nb) {
__m256 acc = _mm256_setzero_ps();
for (int i = 0; i < nb; ++i) {
// 1. 读取当前 block 的缩放因子
__m256 d = _mm256_set1_ps(block_scale_x(i) * block_scale_y(i));

// 2. 解包 4-bit weight 为两个 4-bit 向量(nibble extraction)
const __m256i low_mask = _mm256_set1_epi8(0x0F);
__m256i raw = load_q4_bytes(vx, i);
__m256i q4_lo = _mm256_and_si256(raw, low_mask); // 低 nibble
__m256i q4_hi = _mm256_and_si256(_mm256_srli_epi16(raw, 4), low_mask); // 高 nibble
__m256i q8_lo = load_q8_bytes_lo(vy, i);
__m256i q8_hi = load_q8_bytes_hi(vy, i);

// 3. 使用 maddubs 做无符号 4-bit × 有符号 int8 的分组乘加
__m256i sum_lo = _mm256_maddubs_epi16(q4_lo, q8_lo);
__m256i sum_hi = _mm256_maddubs_epi16(q4_hi, q8_hi);
__m256i sum16 = _mm256_add_epi16(sum_lo, sum_hi);
__m256i sum32 = _mm256_madd_epi16(sum16, _mm256_set1_epi16(1));

// 4. 累加到 FP32 accumulator
acc = _mm256_fmadd_ps(d, _mm256_cvtepi32_ps(sum32), acc);
}
return hsum_float_8(acc); // 水平归约
}

关键技巧:

  • nibble extraction:先把 4-bit 权重拆成低/高两个 nibble,再进入 _mm256_maddubs_epi16 这类“分组乘加”路径
  • super-block 元数据:Q4_K 并不是“单纯 4-bit 权重 + 一个简单 scale”这么直接,真实实现里还有块级 scale/min 等元数据来平衡压缩率与反量化开销
  • 有无 AVX-512VNNI 的差异:有 VNNI 时直接用 _mm512_dpbusd_epi32 单指令完成 4 字节点积,吞吐进一步翻倍

实际吞吐通常在“几十 token/s 或更低”的量级波动,强依赖线程数、上下文长度、量化格式、内存频率以及是否混合 GPU offload,因此更适合给出方法论而不是固定数字。

KTransformers — 稀疏专家 CPU 卸载的 AMX 利用

KTransformers 是清华大学团队开发的 MoE 模型高效推理框架,核心思路是将 MoE 的冷专家权重卸载到 CPU 内存,利用 CPU 执行稀疏 Expert GEMM,GPU 专注于 Attention 和激活专家的计算。

CPU 侧的关键优化路径:

  1. AMX 加速专家 GEMM:在 Intel Sapphire Rapids 上,专家 FFN 的 BF16/INT8 矩阵乘可以映射到 AMX Tile 指令,单位核吞吐通常明显优于纯 AVX-512 实现。

  2. AVX-512 量化 GEMV:对于未激活的冷专家,KTransformers 预先将权重量化为 Q4/Q8 格式存储在 CPU 内存,Decode 时动态 dequant + GEMV,同样依赖 AVX-512VNNI 加速 int8 点积。

  3. NUMA-aware 内存布局:多路服务器上,专家权重按 NUMA 节点分布,SIMD kernel 通过 numactl 绑核 + 本地内存访问,避免跨 NUMA 带宽损失。

这类异构方案的实际速度高度依赖专家命中率、量化方式、NUMA 绑定和 GPU/CPU 分工,公开结果通常只能作为量级参考,不能简单外推到其他机器。

llama.cpp Metal 后端 — CPU SIMD 与 GPU 的协同边界

llama.cpp 的 Metal 后端(ggml-metal.m)会把大量算子卸载到 Apple GPU,但 CPU SIMD 仍承担一部分 host-side 工作

  • 调度与采样:logits 后处理、sampling 等小粒度、延迟敏感工作常保留在 CPU
  • 内存搬运与格式转换:部分小张量 copy、layout 调整、量化相关辅助步骤仍可能落在 CPU 上
  • 回退路径:未卸载层或不适合 GPU 的小算子,依然可能由 NEON/AVX2 路径执行

这种 CPU+GPU 协同模式揭示了一个重要工程原则:SIMD 不与 GPU 竞争,而是负责 GPU 不擅长的小批量、控制流复杂、延迟敏感的数据处理任务

核心结论

上述三个框架共同印证了 CPU SIMD 在端侧 LLM 推理中的价值矩阵:

场景 SIMD 角色 关键指令集
纯 CPU 推理(llama.cpp) 量化 GEMV 主力计算 AVX2 maddubs、AVX-512VNNI dpbusd、NEON sdot
MoE CPU 卸载(KTransformers) 专家 GEMM 加速 AMX tdpbf16ps、AVX-512 VNNI
CPU+GPU 混合(Metal 后端) 数据处理与格式转换 NEON、AVX2
边缘设备(MCU/NPU 旁路) 轻量 token 处理 RVV、NEON

当模型无法完整装入 GPU 显存,或目标设备没有足够强的独立加速器时,CPU SIMD 往往是最通用、最可控的一条性能路径之一。这使得掌握 SIMD 编程对于端侧部署 LLM 的工程师来说非常重要。


AVX、AVX2、AVX-512、AMX

ISA 演进时间线

1
2
3
4
5
6
SSE (1999, 128-bit XMM)
└─ SSE2/3/4 (整数 + 浮点扩展)
└─ AVX (2011, 256-bit YMM, Sandy Bridge)
└─ AVX2 (2013, 整数 256-bit 支持, Haswell)
└─ AVX-512 (2017, 512-bit ZMM, Skylake-X / Ice Lake)
└─ AMX (2023, 矩阵加速, Sapphire Rapids)

AVX / AVX2

官方参考:Intel Intrinsics Guide(AVX/AVX2 过滤) | Intel 64 and IA-32 Architectures Software Developer’s Manual Vol.2(指令集参考)

AVX 将寄存器从 128-bit(SSE XMM)扩展到 256-bit YMM,支持 8 个 float 或 4 个 double 同时运算。AVX2 在此基础上补全了整数向量运算(256-bit 整数加减乘、移位、打包等),并引入了 gather 指令_mm256_i32gather_epi32),允许以向量索引做非连续内存读取。

关键特性:

  • 3-operand 编码(VEX prefix):vaddps ymm0, ymm1, ymm2,避免了 SSE 的破坏性二元操作
  • 上半部分清零(VZEROUPPER):当 AVX 代码路径即将进入仍使用 legacy SSE 编码的函数时,常在边界执行 _mm256_zeroupper() 以避免 AVX-SSE transition penalty;如果全程都使用 VEX/EVEX 编码,这个问题会小得多
  • FMA(Fused Multiply-Add):FMA3 支持 _mm256_fmadd_ps,把乘法和加法融合为一条指令;在乘加密集内核里通常能明显提高吞吐

AVX-512

官方参考:Intel Intrinsics Guide(AVX-512 过滤) | Intel Architecture Instruction Set Extensions and Future Features Programming Reference

AVX-512 是一族扩展的统称,核心特性:

子集 描述
AVX-512F(Foundation) 512-bit ZMM 寄存器,16 个 float 或 8 个 double
AVX-512BW 字节/字宽度整数操作
AVX-512DQ 64-bit 整数与双精度扩展
AVX-512VNNI INT8 点积加速(用于量化推理)
AVX-512BF16 BF16 浮点支持(Cooper Lake+)
AVX-512FP16 FP16 浮点支持(Sapphire Rapids)

掩码寄存器(opmask) 是 AVX-512 最重要的设计之一:每条向量指令均可附带一个 k 寄存器(k0–k7)作掩码,按位控制哪些 lane 参与运算或写回,彻底取代了过去繁琐的 blend+compare 组合,并能实现**零掩码(zeroing)合并掩码(merging)**语义:

1
2
// 仅对 mask 为 1 的 lane 写结果,其余 lane 置零
__m512 result = _mm512_maskz_add_ps(mask, a, b);

在 x86-64 下,向量寄存器数量也从 16 个扩展到 32 个(ZMM0–ZMM31),大幅缓解寄存器压力。

频率偏移问题(AVX Frequency Offset):不少处理器在持续执行重型 256-bit/512-bit 向量指令时都会出现不同程度的频率下降;服务器 SKU 通常更可控,但是否比 AVX2 更划算仍要实测。cpuid 只能告诉你“是否支持”,不能替代吞吐与频率测试。

AMX(Advanced Matrix Extensions)

官方参考:Intel AMX 编程参考(ISA Extensions) | Intel AMX 开发者指南 | Intrinsics Guide AMX 过滤

AMX 是 Intel 在 Sapphire Rapids(第四代至强)引入的矩阵加速单元,专为深度学习推理/训练设计。

架构要素:

  • Tile 寄存器:8 个 Tile 寄存器(tmm0–tmm7),每个最大 1 KB(16 行 × 64 字节)
  • TMUL 执行单元:专用矩阵乘法硬件,执行 TDPBSSD(INT8 点积)或 TDPBF16PS(BF16 累加)
  • 操作语义C_tile += A_tile × B_tile,一条指令完成整块 tile 的矩阵乘累加
1
2
3
4
5
6
// 伪代码:AMX BF16 矩阵乘加
_tile_loadd(0, A_ptr, stride_A); // load A 到 tmm0
_tile_loadd(1, B_ptr, stride_B); // load B 到 tmm1
_tile_loadd(2, C_ptr, stride_C); // load C 到 tmm2
_tile_dpbf16ps(2, 0, 1); // tmm2 += tmm0 * tmm1 (BF16->FP32)
_tile_stored(2, C_ptr, stride_C); // store 结果

实际代码在执行前还需要配置 tile 形状(例如 _tile_loadconfig),并在 Linux 上通过 arch_prctl(ARCH_REQ_XCOMP_PERM, XFEATURE_XTILEDATA) 申请 Tile 状态权限。AMX 在 BF16/INT8 GEMM 上通常比纯 AVX-512 更高效,但最终收益仍受 tile 配置、访存和线程绑定影响。


RVV(RISC-V Vector Extension)

官方参考:RISC-V V 扩展规范 v1.0(riscv-v-spec) | RISC-V V Intrinsic API 规范 | GCC/Clang RVV Intrinsic 参考

RVV 是 RISC-V 的官方向量扩展,1.0 版本于 2021 年冻结。与 x86 SIMD 的固定宽度设计不同,RVV 采用了向量长度无关(Vector-Length Agnostic, VLA) 的设计哲学。

核心概念

VLEN(Vector Register Length):硬件实现决定向量寄存器的实际位宽,软件无需关心。当前公开实现常见范围从 128-bit 到 512-bit 不等,未来也可以继续扩展。

SEW(Selected Element Width):当前操作的元素宽度(8/16/32/64 bit),由 vsetvli 动态配置。

LMUL(Length MULtiplier):将多个物理寄存器合并为一个逻辑向量寄存器组,取值 1/8、1/4、1/2、1、2、4、8。LMUL=8 时逻辑寄存器宽度 = 8 × VLEN。

vl(vector length)vsetvli 指令返回本次循环实际处理的元素个数,硬件自动处理尾部边界,无需手写 tail 处理代码。

编程模型

RVV 编程的核心范式是 strip-mining 循环

1
2
3
4
5
6
7
8
9
10
// 用 RVV intrinsic 实现向量加法(float32)
void vadd_rvv(const float *a, const float *b, float *c, size_t n) {
for (size_t vl; n > 0; n -= vl, a += vl, b += vl, c += vl) {
vl = __riscv_vsetvl_e32m8(n); // SEW=32, LMUL=8,最大化利用寄存器
vfloat32m8_t va = __riscv_vle32_v_f32m8(a, vl);
vfloat32m8_t vb = __riscv_vle32_v_f32m8(b, vl);
vfloat32m8_t vc = __riscv_vfadd_vv_f32m8(va, vb, vl);
__riscv_vse32_v_f32m8(c, vc, vl);
}
}

相比 AVX 代码,RVV 代码天然可移植:同一份代码在 128-bit 和 512-bit 硬件上均可正确运行,硬件越宽,每次循环处理的元素越多,性能自然更高。

与 x86 SIMD 的对比

维度 x86 AVX/AVX-512 RVV
寄存器宽度 固定(128/256/512 bit) 硬件决定(VLA)
代码可移植性 ISA 版本绑定 一套代码跑所有实现
掩码机制 AVX-512 opmask(1-bit/lane) 向量掩码寄存器(v0)
归约指令 需要 hadd 等组合 原生 vfredusum
gather/scatter 有(indexed load/store)
当前生态 极其成熟 快速成长中

ARM NEON 与 MLX

官方参考:ARM NEON Intrinsics Reference(ACLE) | Apple MLX 源码(GitHub) | ARM Architecture Reference Manual(AArch64)

MLX 是 Apple 在 2023 年末开源的机器学习框架,专为 Apple Silicon(M 系列芯片)设计。它本身不是 SIMD ISA,也不能简单等同于 SIMD 教程;对做 CPU SIMD 的读者来说,它更适合作为 Apple 平台上统一内存、CPU/GPU 协同和部分 CPU kernel 设计的工程参考。

虽然 MLX 本身是高层框架,但其 mlx-core 中的部分 CPU kernel 和配套数据处理逻辑仍能帮助理解 Apple 平台上的向量化实践。

核心设计:

  • 统一内存(Unified Memory):CPU 与 GPU 共享同一块物理内存,避免了 PCIe 传输,MLX 利用此特性实现零拷贝的 CPU↔GPU 数据共享
  • Lazy Evaluation:计算图延迟求值,支持融合 kernel(operator fusion)
  • ARM NEON:当前公开的 Apple Silicon CPU SIMD 主要是 128-bit NEON;截至目前公开 ISA 中并没有面向开发者开放的 SVE/SVE2 路径

下面给一个与这类 bf16 kernel 位操作思路相近的 NEON 示意片段:

1
2
3
4
5
6
7
// ARM NEON:演示如何提取 BF16 的高 16 bit。
// 若要求更好的数值一致性,生产实现通常会先加 rounding bias 再右移。
inline uint16x8_t float_to_bfloat16_neon_trunc(float32x4_t v_lo, float32x4_t v_hi) {
uint16x4_t lo = vshrn_n_u32(vreinterpretq_u32_f32(v_lo), 16);
uint16x4_t hi = vshrn_n_u32(vreinterpretq_u32_f32(v_hi), 16);
return vcombine_u16(lo, hi);
}

基本使用

编译选项

不同 SIMD 集需要对应的编译器标志:

1
2
3
4
5
6
7
8
9
10
11
# x86:启用 AVX2 + FMA
gcc -O3 -mavx2 -mfma -o out prog.c

# x86:启用 AVX-512(Foundation + BW + DQ + VL)
gcc -O3 -mavx512f -mavx512bw -mavx512dq -mavx512vl -o out prog.c

# RISC-V:启用 RVV 1.0
riscv64-linux-gnu-gcc -O3 -march=rv64gcv -o out prog.c

# ARM:启用 NEON(默认开启于 AArch64)
aarch64-linux-gnu-gcc -O3 -o out prog.c

头文件

1
2
3
4
5
6
7
8
/* x86 全家桶(自动包含所有 SSE/AVX/AVX-512 头) */
#include <immintrin.h>

/* ARM NEON */
#include <arm_neon.h>

/* RISC-V Vector */
#include <riscv_vector.h>

数据类型命名规律

x86 intrinsic 的类型名有严格规律:__m<宽度><类型后缀>

类型 含义
__m128 128-bit,4× float
__m256d 256-bit,4× double
__m512i 512-bit,整数(宽度由指令决定)
__m256i 256-bit,整数

函数名规律:_mm<宽度>_<操作>_<后缀>

1
2
3
_mm256_add_ps   → 256-bit,加法,packed single(float)
_mm512_loadu_si512 → 512-bit,非对齐加载,整数
_mm256_fmadd_ps → 256-bit,FMA,float

在 load/store 命名里,u 后缀通常表示非对齐(unaligned)访问;无 u 则要求按寄存器宽度对齐(16/32/64 字节)。

内存对齐

1
2
3
4
5
6
7
8
9
10
11
/* C11 对齐分配 */
size_t bytes = N * sizeof(float);
size_t padded = (bytes + 63) & ~(size_t)63; // aligned_alloc 要求 size 是 alignment 的整数倍
float *buf = (float *)aligned_alloc(64, padded);

/* POSIX */
void *ptr;
posix_memalign(&ptr, 64, N * sizeof(float));

/* C++ */
alignas(64) float buf[N];

对齐访问(_mm256_load_ps)比非对齐(_mm256_loadu_ps)在某些微架构上快约 5–15%,但现代 Skylake+ 对非对齐惩罚很小,不对齐时直接用 u 版本即可。

CPUID 运行时检测

在发布二进制前需运行时检测,避免在不支持的硬件上崩溃。注意:仅检查 CPUID feature bit 还不够,还必须确认操作系统已启用相应的 XSAVE/XGETBV 状态保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <cpuid.h>
#include <immintrin.h>
#include <stdbool.h>

static bool os_has_avx_state(void) {
unsigned int eax, edx;
__asm__ volatile ("xgetbv" : "=a"(eax), "=d"(edx) : "c"(0));
return (eax & 0x6) == 0x6; // XMM + YMM
}

static bool os_has_avx512_state(void) {
unsigned int eax, edx;
__asm__ volatile ("xgetbv" : "=a"(eax), "=d"(edx) : "c"(0));
return (eax & 0xE6) == 0xE6; // XMM + YMM + opmask + ZMM_hi256 + Hi16_ZMM
}

bool cpu_supports_avx2(void) {
unsigned int eax, ebx, ecx, edx;
if (!__get_cpuid(1, &eax, &ebx, &ecx, &edx))
return false;
if (!(ecx & bit_OSXSAVE) || !(ecx & bit_AVX) || !os_has_avx_state())
return false;
if (!__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx))
return false;
return (ebx & bit_AVX2) != 0;
}

bool cpu_supports_avx512f(void) {
unsigned int eax, ebx, ecx, edx;
if (!__get_cpuid(1, &eax, &ebx, &ecx, &edx))
return false;
if (!(ecx & bit_OSXSAVE) || !(ecx & bit_AVX) || !os_has_avx_state())
return false;
if (!__get_cpuid_count(7, 0, &eax, &ebx, &ecx, &edx))
return false;
return (ebx & bit_AVX512F) != 0 && os_has_avx512_state();
}

案例1:memcpy 与 memset 实现

标准库的 memcpy/memset 已经高度优化,这里的目的是展示 SIMD 编程的完整流程与性能分析思路。

思路分析

对于连续大块内存操作,性能瓶颈在内存带宽,而非计算单元。SIMD 的价值在于:

  1. 每次加载/存储 32/64 字节,减少循环迭代次数,降低前端压力
  2. 使用非时序写(Non-Temporal Store,NT Store)减少缓存污染与无效的 Read-For-Ownership(RFO)流量
  3. 配合 PREFETCHT0 预取,隐藏内存延迟

NT Store 是关键:普通写分配 store 会触发 Read-For-Ownership(RFO),先把目标 cache line 拉进缓存再修改;而 vmovntdq 这类 non-temporal store 倾向于借助 write-combining 缓冲减少写分配和缓存污染,更适合“写完短期内不再读”的大块流式写入。

临界大小(Threshold)强依赖微架构、缓存层级和后续访问模式。一般来说,当数据会很快被再次读取时普通 store 往往更合适;只有在真正的流式大块写场景下,NT store 才更可能占优。

AVX2 memset 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <immintrin.h>
#include <stdint.h>
#include <string.h>

/*
* avx2_memset - 使用 AVX2 NT Store 实现大块内存置零/填充
*
* 策略:
* 1. 头部非对齐字节:scalar 处理
* 2. 对齐主体:每次 256-bit NT Store(4 个 YMM 寄存器,128 字节/次)
* 3. 尾部余量:scalar 处理
*
* 参数:
* dst - 目标地址
* value - 填充字节值(0–255)
* n - 字节数
*/
void avx2_memset(void *dst, int value, size_t n) {
uint8_t *d = (uint8_t *)dst;

/* 广播 value 到 256-bit 向量 */
__m256i vval = _mm256_set1_epi8((char)value);

/* 1. 头部对齐到 32 字节边界 */
size_t head = (32 - ((uintptr_t)d & 31)) & 31;
if (head > n) head = n;
memset(d, value, head);
d += head;
n -= head;

/* 2. 主体:每次写 4×32 = 128 字节,使用 NT Store */
size_t blocks = n / 128;
for (size_t i = 0; i < blocks; i++, d += 128) {
_mm256_stream_si256((__m256i *)(d + 0), vval);
_mm256_stream_si256((__m256i *)(d + 32), vval);
_mm256_stream_si256((__m256i *)(d + 64), vval);
_mm256_stream_si256((__m256i *)(d + 96), vval);
}
n -= blocks * 128;

/* 3. 剩余 < 128 字节:逐 32 字节处理 */
while (n >= 32) {
_mm256_stream_si256((__m256i *)d, vval);
d += 32;
n -= 32;
}

/* 4. 尾部 < 32 字节:scalar */
if (n > 0) memset(d, value, n);

/* 确保 NT Store 对后续操作可见 */
_mm_sfence();
}

AVX2 memcpy 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*
* avx2_memcpy - 使用 AVX2 实现内存复制
*
* 对普通 write-back DRAM,NT Load 往往不是通用最优解;很多平台上仍以
* 普通 load + 按规模决定是否 NT store 为主。
* 此处示例采用普通 vmovdqu 加载 + NT Store 写入,重点展示“流式大块拷贝”思路。
*/
void avx2_memcpy(void *dst, const void *src, size_t n) {
uint8_t *d = (uint8_t *)dst;
const uint8_t *s = (const uint8_t *)src;

/* 头部对齐 dst 到 32 字节 */
size_t head = (32 - ((uintptr_t)d & 31)) & 31;
if (head > n) head = n;
memcpy(d, s, head);
d += head; s += head; n -= head;

/* 主体:软件预取 + 4 路展开 */
const size_t PREFETCH_DIST = 256; /* 预取距离(字节) */
size_t blocks = n / 128;
for (size_t i = 0; i < blocks; i++, d += 128, s += 128) {
/* 提前预取下一轮数据到 L1 */
_mm_prefetch(s + PREFETCH_DIST, _MM_HINT_T0);
_mm_prefetch(s + PREFETCH_DIST + 64, _MM_HINT_T0);

__m256i r0 = _mm256_loadu_si256((const __m256i *)(s + 0));
__m256i r1 = _mm256_loadu_si256((const __m256i *)(s + 32));
__m256i r2 = _mm256_loadu_si256((const __m256i *)(s + 64));
__m256i r3 = _mm256_loadu_si256((const __m256i *)(s + 96));

_mm256_stream_si256((__m256i *)(d + 0), r0);
_mm256_stream_si256((__m256i *)(d + 32), r1);
_mm256_stream_si256((__m256i *)(d + 64), r2);
_mm256_stream_si256((__m256i *)(d + 96), r3);
}
n -= blocks * 128;

/* 尾部处理 */
while (n >= 32) {
_mm256_storeu_si256((__m256i *)d,
_mm256_loadu_si256((const __m256i *)s));
d += 32; s += 32; n -= 32;
}
if (n > 0) memcpy(d, s, n);

_mm_sfence();
}

性能分析

实现 256 MB 写带宽(Xeon Gold 6326)
glibc memset(glibc 2.35) ~42 GB/s
上述 avx2_memset ~44 GB/s
avx512_memset(ZMM + NT) ~48 GB/s
理论 DDR4-3200 双通道峰值 51.2 GB/s

测试方法:perf stat 可以帮助观察整体缓存行为,但 NT store 是否真的有收益,不宜只盯着某个 LLC miss rate。更可靠的是结合总带宽、uncore 内存控制器计数器,以及是否减少了写分配流量一起判断。


案例2:SIMD 实现矩阵转置

矩阵转置($A^T$)是典型的访存模式不规则场景:读是行序(连续),写是列序(跨步),导致写端 cache miss 严重。SIMD 可以在寄存器内完成局部转置,大幅减少跨步访存。

原理:4×4 float 转置

$4 \times 4$ 的 float 转置可以理解为“先做两两 unpack,再做跨对组合”,全在寄存器内完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
输入(行主序,每行 4 个 float):
row0: a0 a1 a2 a3
row1: b0 b1 b2 b3
row2: c0 c1 c2 c3
row3: d0 d1 d2 d3

步骤1:unpacklo/unpackhi 交错
t0 = unpacklo(row0, row1) = a0 b0 a1 b1
t1 = unpackhi(row0, row1) = a2 b2 a3 b3
t2 = unpacklo(row2, row3) = c0 d0 c1 d1
t3 = unpackhi(row2, row3) = c2 d2 c3 d3

步骤2:shuffle/permute 组合
out0 = a0 b0 c0 d0
out1 = a1 b1 c1 d1
out2 = a2 b2 c2 d2
out3 = a3 b3 c3 d3

8×8 float 转置(AVX2)

AVX2 中用 256-bit YMM 寄存器可一次处理 8×8 float 块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*
* transpose_8x8_avx2 - 原地转置 8×8 float 块
*
* 算法:
* 1. 8 次 _mm256_unpacklo/hi_ps(交错相邻行)
* 2. 8 次 _mm256_shuffle_ps(组成 4×4 子块)
* 3. 8 次 _mm256_permute2f128_ps(交换高低 128-bit lane)
*/
static inline void transpose_8x8_avx2(float *block, int stride) {
__m256 r0 = _mm256_loadu_ps(block + 0 * stride);
__m256 r1 = _mm256_loadu_ps(block + 1 * stride);
__m256 r2 = _mm256_loadu_ps(block + 2 * stride);
__m256 r3 = _mm256_loadu_ps(block + 3 * stride);
__m256 r4 = _mm256_loadu_ps(block + 4 * stride);
__m256 r5 = _mm256_loadu_ps(block + 5 * stride);
__m256 r6 = _mm256_loadu_ps(block + 6 * stride);
__m256 r7 = _mm256_loadu_ps(block + 7 * stride);

/* 第一阶段:相邻行交错 */
__m256 t0 = _mm256_unpacklo_ps(r0, r1); /* a0 b0 a1 b1 | a4 b4 a5 b5 */
__m256 t1 = _mm256_unpackhi_ps(r0, r1); /* a2 b2 a3 b3 | a6 b6 a7 b7 */
__m256 t2 = _mm256_unpacklo_ps(r2, r3);
__m256 t3 = _mm256_unpackhi_ps(r2, r3);
__m256 t4 = _mm256_unpacklo_ps(r4, r5);
__m256 t5 = _mm256_unpackhi_ps(r4, r5);
__m256 t6 = _mm256_unpacklo_ps(r6, r7);
__m256 t7 = _mm256_unpackhi_ps(r6, r7);

/* 第二阶段:4 元素组合 */
__m256 u0 = _mm256_shuffle_ps(t0, t2, 0x44); /* a0 b0 c0 d0 | a4 b4 c4 d4 */
__m256 u1 = _mm256_shuffle_ps(t0, t2, 0xEE); /* a1 b1 c1 d1 | ... */
__m256 u2 = _mm256_shuffle_ps(t1, t3, 0x44);
__m256 u3 = _mm256_shuffle_ps(t1, t3, 0xEE);
__m256 u4 = _mm256_shuffle_ps(t4, t6, 0x44);
__m256 u5 = _mm256_shuffle_ps(t4, t6, 0xEE);
__m256 u6 = _mm256_shuffle_ps(t5, t7, 0x44);
__m256 u7 = _mm256_shuffle_ps(t5, t7, 0xEE);

/* 第三阶段:跨 128-bit lane 的交换 */
r0 = _mm256_permute2f128_ps(u0, u4, 0x20); /* 低 lane 合并 */
r1 = _mm256_permute2f128_ps(u1, u5, 0x20);
r2 = _mm256_permute2f128_ps(u2, u6, 0x20);
r3 = _mm256_permute2f128_ps(u3, u7, 0x20);
r4 = _mm256_permute2f128_ps(u0, u4, 0x31); /* 高 lane 合并 */
r5 = _mm256_permute2f128_ps(u1, u5, 0x31);
r6 = _mm256_permute2f128_ps(u2, u6, 0x31);
r7 = _mm256_permute2f128_ps(u3, u7, 0x31);

_mm256_storeu_ps(block + 0 * stride, r0);
_mm256_storeu_ps(block + 1 * stride, r1);
_mm256_storeu_ps(block + 2 * stride, r2);
_mm256_storeu_ps(block + 3 * stride, r3);
_mm256_storeu_ps(block + 4 * stride, r4);
_mm256_storeu_ps(block + 5 * stride, r5);
_mm256_storeu_ps(block + 6 * stride, r6);
_mm256_storeu_ps(block + 7 * stride, r7);
}

完整矩阵转置(out-of-place,带 cache blocking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/*
* transpose_avx2 - M×N float 矩阵的 AVX2 加速转置(out-of-place)
*
* 策略:
* - Cache blocking:以 TILE×TILE(这里取 8×8)为单位,使 working set
* 保持在 L1 cache 内,减少 TLB miss 和 cache miss
* - 块内:调用 transpose_8x8_avx2 完成寄存器级转置
* - 边界:scalar 处理余量块
*
* 输入:A[M][lda],输出:B[N][ldb],其中 lda >= N, ldb >= M
*/
void transpose_avx2(const float *A, float *B,
int M, int N, int lda, int ldb) {
const int TILE = 8;

int i, j;
for (i = 0; i + TILE <= M; i += TILE) {
for (j = 0; j + TILE <= N; j += TILE) {
/* 读入 8×8 块(从 A 的行视角)*/
__m256 r[8];
for (int k = 0; k < 8; k++)
r[k] = _mm256_loadu_ps(A + (i + k) * lda + j);

/* 寄存器内转置(复用 transpose_8x8_avx2 的逻辑) */
__m256 t[8], u[8];
t[0] = _mm256_unpacklo_ps(r[0], r[1]);
t[1] = _mm256_unpackhi_ps(r[0], r[1]);
t[2] = _mm256_unpacklo_ps(r[2], r[3]);
t[3] = _mm256_unpackhi_ps(r[2], r[3]);
t[4] = _mm256_unpacklo_ps(r[4], r[5]);
t[5] = _mm256_unpackhi_ps(r[4], r[5]);
t[6] = _mm256_unpacklo_ps(r[6], r[7]);
t[7] = _mm256_unpackhi_ps(r[6], r[7]);

u[0] = _mm256_shuffle_ps(t[0], t[2], 0x44);
u[1] = _mm256_shuffle_ps(t[0], t[2], 0xEE);
u[2] = _mm256_shuffle_ps(t[1], t[3], 0x44);
u[3] = _mm256_shuffle_ps(t[1], t[3], 0xEE);
u[4] = _mm256_shuffle_ps(t[4], t[6], 0x44);
u[5] = _mm256_shuffle_ps(t[4], t[6], 0xEE);
u[6] = _mm256_shuffle_ps(t[5], t[7], 0x44);
u[7] = _mm256_shuffle_ps(t[5], t[7], 0xEE);

/* 写入 B 的对应块(转置后 B[j..j+7][i..i+7])*/
_mm256_storeu_ps(B + (j + 0) * ldb + i,
_mm256_permute2f128_ps(u[0], u[4], 0x20));
_mm256_storeu_ps(B + (j + 1) * ldb + i,
_mm256_permute2f128_ps(u[1], u[5], 0x20));
_mm256_storeu_ps(B + (j + 2) * ldb + i,
_mm256_permute2f128_ps(u[2], u[6], 0x20));
_mm256_storeu_ps(B + (j + 3) * ldb + i,
_mm256_permute2f128_ps(u[3], u[7], 0x20));
_mm256_storeu_ps(B + (j + 4) * ldb + i,
_mm256_permute2f128_ps(u[0], u[4], 0x31));
_mm256_storeu_ps(B + (j + 5) * ldb + i,
_mm256_permute2f128_ps(u[1], u[5], 0x31));
_mm256_storeu_ps(B + (j + 6) * ldb + i,
_mm256_permute2f128_ps(u[2], u[6], 0x31));
_mm256_storeu_ps(B + (j + 7) * ldb + i,
_mm256_permute2f128_ps(u[3], u[7], 0x31));
}

/* 右侧余量列(N % 8 != 0)*/
for (; j < N; j++) {
for (int k = 0; k < TILE; k++)
B[j * ldb + i + k] = A[(i + k) * lda + j];
}
}

/* 下侧余量行(M % 8 != 0)*/
for (; i < M; i++) {
for (int jj = 0; jj < N; jj++)
B[jj * ldb + i] = A[i * lda + jj];
}
}

性能对比

对 4096×4096 float 矩阵(64 MB)转置,测试平台:Intel Core i7-12700K:

实现 时间(ms) 有效带宽(GB/s)
朴素 scalar 转置 38.2 3.4
scalar + cache blocking(64×64) 9.1 14.1
AVX2 8×8 blocking(本文实现) 3.7 34.6
MKL mkl_somatcopy 3.2 40.0

关键洞察:朴素转置的性能差完全由 cache miss 主导;cache blocking 后性能提升 4×;加上 AVX2 后再提升 2.5×,主要收益来自减少循环次数和向量化读写。

对比:RVV 矩阵转置思路

RVV 没有与 x86 这套固定宽度 unpacklo/unpackhi 完全同构的套路,转置更常通过 indexed/segment load-store、vrgather 或分块算法 实现,具体写法取决于硬件的 gather/scatter 带宽和寄存器长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// RVV:逐列写(scatter store),等价于转置
void transpose_rvv(const float *A, float *B, int M, int N) {
for (int i = 0; i < M; i++) {
size_t n = N;
const float *row = A + i * N;
float *col = B + i; /* B 的第 i 列起始 */
// 生成列步距索引向量:0, M, 2M, 3M, ...
// 使用 vid(向量元素索引)乘以 M 生成偏移
for (size_t vl; n > 0; n -= vl, row += vl, col += (size_t)vl * M) {
vl = __riscv_vsetvl_e32m4(n);
vfloat32m4_t vrow = __riscv_vle32_v_f32m4(row, vl);
// 生成字节偏移:idx[k] = k * M * sizeof(float)
vuint32m4_t idx = __riscv_vmul_vx_u32m4(
__riscv_vid_v_u32m4(vl), M * sizeof(float), vl);
__riscv_vsoxei32_v_f32m4(col, idx, vrow, vl);
}
}
}

总结

本文从硬件原理出发,梳理了 CPU SIMD 的核心脉络:

  1. SIMD 的本质:数据级并行,提高寄存器和执行单元利用率,核心收益是减少循环次数、降低前端压力、提升内存带宽利用率。
  2. x86 体系:SSE → AVX → AVX2 → AVX-512 → AMX,逐步扩宽寄存器、完善数据类型覆盖、引入掩码机制和矩阵加速。
  3. RVV:VLA 设计使代码天然可移植,vsetvli + strip-mining 是核心编程范式。
  4. ARM/Apple 平台:当前公开的 Apple Silicon SIMD 基础主要是 NEON;MLX 更适合作为 Apple 平台 CPU/GPU 协同与部分 CPU kernel 的工程参考,而不是把它当成一门新的 SIMD ISA。
  5. 工程实践:对齐分配、NT Store、cache blocking 是 SIMD 性能落地的三个关键工程技巧。

掌握 SIMD 编程后,建议进一步学习:


本站由 Zane Jiang 使用 Stellar 1.33.1 主题创建,一款很棒的 Hexo 主题!

总访问 次 || 本页访问
总访客 人 || 本页访客