Ch06: ARM 架构与 SIMD 入门——NEON 指令为什么重要
Part 2: 移动端推理基础(从零开始) 前置章节:Ch05: 为什么手机和服务器不一样 后续章节:Ch07: GPU 计算入门
一个老师,一道题,全班一起改
想象你是一位数学老师,要批改全班 40 个同学的试卷。标量方式(Scalar)就是一张一张批改:拿起第 1 张,看第 1 题,对答案,打分,放下;拿起第 2 张,看第 1 题,对答案,打分,放下……每张试卷你都要重复一遍”看题→对答案→打分”这个流程。批改 40 张试卷的第 1 题,你要重复这个流程 40 次。
现在换一种方式。你把 4 张试卷摊开在桌上,一眼扫过去,这 4 个同学的第 1 题答案分别是”A、C、A、B”,你心里的标准答案是”A”,于是你同时判定”对、错、对、错”,一次性给 4 张试卷打好分。然后再摊开下 4 张,同样一次性批改。这样批改 40 张试卷只需要重复 10 次而非 40 次。
这就是 SIMD 的核心思想——Single Instruction, Multiple Data(单指令多数据)。一条指令,同时对多个数据执行相同的操作。从 40 次操作减少到 10 次,理论加速比是 4 倍。而如果你桌子更大,能同时摊开 8 张甚至 16 张试卷,加速比就是 8 倍、16 倍。
这个概念对推理引擎至关重要。神经网络推理的核心操作——卷积、矩阵乘法、逐元素加法——本质上都是”对大量数据做相同的运算”。这恰恰是 SIMD 最擅长的场景。六个推理引擎(ncnn/MNN/ORT/TNN/Paddle-Lite/MACE)在 CPU 后端的性能优化中,SIMD 向量化是最核心、最基础、投入精力最多的技术。
ARM NEON:手机里的 SIMD 引擎
几乎所有现代手机的处理器都基于 ARM 架构。ARM 提供的 SIMD 指令集叫做 NEON(也称为 Advanced SIMD)。从 ARMv8-A(即 AArch64/ARM64)开始,NEON 是强制包含的——这意味着你可以放心假设所有 64 位 ARM 设备都支持 NEON。
NEON 的核心规格如下:
128-bit 寄存器:NEON 有 32 个 128-bit 宽的向量寄存器(v0-v31)。每个寄存器可以视为一个容器,能装不同数量的数据,取决于每个数据的大小。
一个 128-bit NEON 寄存器可以装:
┌─────────────────────────────────────────────────────┐
│ 4 个 float32 (32-bit) │
│ [ f0 | f1 | f2 | f3 ] │
├─────────────────────────────────────────────────────┤
│ 8 个 float16 (16-bit) │
│ [h0|h1|h2|h3|h4|h5|h6|h7] │
├─────────────────────────────────────────────────────┤
│ 16 个 int8 (8-bit) │
│ [i0|i1|i2|i3|i4|i5|i6|i7|i8|i9|iA|iB|iC|iD|iE|iF] │
└─────────────────────────────────────────────────────┘
这意味着:用 float32 时一条指令处理 4 个数据;用 float16 时处理 8 个;用 int8 时处理 16 个。这就是为什么量化到 int8 不仅减小了模型体积,还能让 SIMD 的并行度从 4 提升到 16——理论加速比提高了 4 倍。
标量 vs NEON:代码对比
下面用一个最简单的例子——两个数组逐元素相加——来直观感受 SIMD 带来的差异。
// ===== 标量版本 =====
// 逐个元素相加,循环4次
void add_scalar(const float* a, const float* b, float* c, int n) {
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i]; // 每次循环只处理1个元素
}
}
// ===== NEON 向量化版本 =====
#include <arm_neon.h>
void add_neon(const float* a, const float* b, float* c, int n) {
int i = 0;
for (; i + 3 < n; i += 4) {
float32x4_t va = vld1q_f32(a + i); // 一次从内存载入4个float到寄存器
float32x4_t vb = vld1q_f32(b + i); // 再载入4个float
float32x4_t vc = vaddq_f32(va, vb); // 一条指令完成4次加法
vst1q_f32(c + i, vc); // 一次写回4个结果
}
// 处理末尾不足4个的剩余元素
for (; i < n; i++) {
c[i] = a[i] + b[i];
}
}
分析这两个版本的差异。标量版本每次循环执行 1 次加法,处理 n 个元素需要 n 次循环。NEON 版本每次循环执行 4 次加法(vaddq_f32 一条指令完成),处理 n 个元素只需要 n/4 次循环。减少的不仅是加法的次数,还有循环控制本身的开销(比较、分支跳转等)。
NEON 内建函数(intrinsics)的命名规则值得记住:v 前缀表示向量操作,ld1/st1 是 load/store,add 是加法,q 后缀表示操作 128-bit 宽的寄存器(没有 q 则是 64-bit),_f32 表示数据类型。所以 vld1q_f32 就是”从内存连续加载 4 个 float32 到 128-bit 寄存器”。
更复杂的例子:乘加运算
推理引擎中最常见的操作不是简单加法,而是乘加(Multiply-Accumulate, MAC):累加 += a[i] * b[i]。卷积和矩阵乘法的本质都是大量的乘加运算。NEON 为此提供了专门的指令:
// 矩阵乘法中的一个内循环片段
float32x4_t sum = vmovq_n_f32(0.0f); // 初始化4个累加器为0
for (int k = 0; k < K; k += 4) {
float32x4_t va = vld1q_f32(A + k); // 载入A矩阵的4个元素
float32x4_t vb = vld1q_f32(B + k); // 载入B矩阵的4个元素
sum = vmlaq_f32(sum, va, vb); // 关键:一条指令完成4次乘法+4次加法
// vmlaq_f32(acc, a, b) 等价于 acc + a * b,但只用一条指令
}
// sum 中现在存着4个部分累加和,还需要水平相加得到最终标量结果
float result = vaddvq_f32(sum); // 4个float32求和为1个标量
vmlaq_f32(Vector Multiply-Accumulate)是推理引擎中使用频率最高的 NEON 指令之一。它在一条指令中完成了 4 次乘法和 4 次加法,总共 8 次浮点运算。在具有 FMA(Fused Multiply-Add)单元的 ARM 核心上,这 8 次运算可以在 1 个时钟周期内完成。
ARMv8.2 还引入了 vdotq_s32(点积指令),可以在一条指令中完成 4 组 int8 点积运算——每组 4 个 int8 的乘加——总共 16 次乘法和 12 次加法。这对 INT8 量化模型的推理速度提升巨大。
为什么推理引擎如此关注 SIMD
前面说了 SIMD 可以加速简单的加法。但推理引擎写那么多 NEON 代码,到底是在优化什么?答案是:卷积运算。
一个 3x3 卷积在一个输出位置上的计算:
output[y][x] = sum of (kernel[i][j] * input[y+i][x+j])
= 9 次乘法 + 8 次加法
= 17 次浮点运算
对于一个 64 通道输入、128 通道输出的 3x3 卷积层,处理一个 112x112 的特征图:总计算量 = 9(kernel面积)× 64(输入通道)× 128(输出通道)× 112 × 112(输出面积)= 约 92 亿次浮点运算。
如果每次只做 1 次运算(标量模式),即使在 3GHz 的 CPU 上(假设每周期 1 次运算),也需要约 3 秒——对于仅仅一个卷积层来说显然不可接受。用 NEON 的 vmlaq_f32 每条指令完成 8 次运算(4 次乘 + 4 次加),理论上可以把时间缩短到约 380ms。再加上 fp16(并行度翻倍)或 int8(并行度翻 4 倍),延迟可以进一步下降。
这就是为什么 ncnn 的 Convolution_arm.cpp 是整个代码库中最长、最复杂的文件之一——它针对不同的卷积参数(1x1、3x3、depthwise、Winograd 等)手写了多种 NEON 优化版本,每种版本都精心安排数据加载和运算指令的顺序,以最大化 SIMD 利用率。
理论加速比 vs 实际加速比
理论上,NEON 的加速比取决于数据类型:
| 数据类型 | 位宽 | 每个128-bit寄存器装几个 | 理论加速比 |
|---|---|---|---|
| float32 | 32 bit | 4 | 4x |
| float16 | 16 bit | 8 | 8x |
| int8 | 8 bit | 16 | 16x |
但实际加速比通常达不到理论值。主要原因有三个。
第一,数据加载和存储也需要时间。SIMD 计算再快,如果数据还在主内存里,CPU 要等几十甚至几百个时钟周期才能拿到数据。这就是为什么”数据排列方式”(内存布局)如此重要——后面会详细讲。
第二,不是所有计算都能向量化。比如循环的尾部处理(数组长度不是 4 的整数倍)、条件分支、不规则的内存访问模式,都很难用 SIMD 加速。
第三,指令流水线的依赖。如果一条 SIMD 指令的结果是下一条指令的输入,CPU 可能需要等前一条指令完成才能开始下一条——这叫做”数据依赖停顿”(data dependency stall)。优秀的 SIMD 代码会交错安排不相互依赖的指令,让流水线保持满载。
x86 的 SSE/AVX/AVX-512 对比 ARM NEON
虽然本导读聚焦移动端(ARM),但推理引擎往往也支持 x86 桌面/服务器平台。了解 x86 的 SIMD 发展历程有助于理解 ncnn 的 elempack 设计。
| 指令集 | 寄存器宽度 | float32 并行度 | int8 并行度 | 典型平台 |
|---|---|---|---|---|
| ARM NEON | 128 bit | 4 | 16 | 手机/平板 |
| x86 SSE | 128 bit | 4 | 16 | 旧款电脑 |
| x86 AVX | 256 bit | 8 | 32 | 现代 Intel/AMD |
| x86 AVX-512 | 512 bit | 16 | 64 | 服务器级 Intel |
| ARM SVE/SVE2 | 128-2048 bit (可变) | 4-64 | 16-256 | 新一代 ARM 服务器 |
几个关键观察:
SSE 和 NEON 的寄存器宽度相同(128-bit),所以 SSE 的代码和 NEON 代码在”一次处理几个数据”这个维度上是等价的。ncnn 在这两种架构上的 elempack 都是 4(float32 的情况)。
AVX 把寄存器宽度翻倍到 256-bit,一次能处理 8 个 float32。如果内存布局还是按 4 个一组打包(适配 NEON/SSE),那么在 AVX 上就浪费了一半的寄存器宽度。反过来,如果按 8 个一组打包(适配 AVX),在 NEON/SSE 上又不匹配。
AVX-512 更进一步到 512-bit,一次处理 16 个 float32。在 AVX-512 上如果还用 NC4HW4(按 4 打包),SIMD 利用率只有 25%——四分之三的计算槽位在空转。
这就引出了 ncnn 的 elempack 设计的精妙之处。
ncnn 的 elempack:SIMD 自适应打包
ncnn 的解决方案是让数据打包数(elempack)成为一个动态变量,根据运行时检测到的 CPU SIMD 能力自动调整:
// ncnn 运行时自动选择 elempack
int elempack = 1; // 默认:不打包
if (cpu_support_avx512()) elempack = 16; // AVX-512: 16个float一组
else if (cpu_support_avx()) elempack = 8; // AVX: 8个float一组
else if (cpu_support_neon() || cpu_support_sse()) elempack = 4; // NEON/SSE: 4个float一组
这个 elempack 值直接嵌入到 Mat 数据容器中:
class Mat {
void* data;
int elempack; // 打包数: 1, 4, 8, 或 16
size_t elemsize; // = sizeof(标量类型) × elempack
int dims, w, h, c;
size_t cstep; // channel 步长
};
当 elempack=4 且数据类型为 float32 时,elemsize = 4 × 4 = 16 字节。Mat 的每个”像素位置”不再存储 1 个 float 值,而是存储 4 个相邻 channel 的 float 值——恰好填满一个 128-bit NEON 寄存器。
对比 MNN 的 NC4HW4:MNN 把”按 4 打包”写死在了布局规范中。好处是整个推理栈统一,不需要中间转换。坏处是在 AVX(可以按 8 打包)或 AVX-512(可以按 16 打包)设备上,SIMD 利用率打折。
对比 ncnn 的 elempack:好处是在每种硬件上都能达到最优 SIMD 利用率。坏处是当相邻两层需要不同的 elempack 时(比如一层用 elempack=8 的 AVX 加速,下一层只有 elempack=4 的实现),需要插入 convert_packing 操作做数据重排——这个转换本身有性能开销。
ncnn 自适应:
AVX-512 设备 → elempack=16 → SIMD 利用率 100%
AVX 设备 → elempack=8 → SIMD 利用率 100%
NEON 设备 → elempack=4 → SIMD 利用率 100%
MNN NC4HW4:
AVX-512 设备 → 固定 pack=4 → SIMD 利用率 25%
AVX 设备 → 固定 pack=4 → SIMD 利用率 50%
NEON 设备 → 固定 pack=4 → SIMD 利用率 100%
ncnn 甚至为不同的 SIMD 类型提供了原生支持——Mat 可以直接用 NEON 或 AVX 类型填充:
mat.fill(float32x4_t{1.0f, 2.0f, 3.0f, 4.0f}); // ARM NEON
mat.fill(__m256{...}); // x86 AVX
这种”在数据结构层面绑定 SIMD 宽度”的设计,让 ncnn 的每个算子实现都可以假设”数据已经按最优方式排列好了”,不需要在算子内部做额外的打包/解包操作。
为什么”数据排列方式”如此重要
到这里你可能觉得”不就是一次处理几个数据的问题吗,为什么这么多篇幅在讲数据排列?”原因是:即使 SIMD 指令再强大,如果数据没有以正确的方式排列在内存中,加速效果也会大打折扣。这涉及一个计算机体系结构中的基本概念——缓存局部性(Cache Locality)。
CPU 缓存的基本工作方式
现代 CPU 访问主内存(LPDDR5X)需要约 100 个时钟周期的延迟——在 3GHz 的 CPU 上就是 33 纳秒。但 CPU 内部有多级缓存:L1 缓存(~1ns)、L2 缓存(~5ns)、L3 缓存(~15ns),它们的访问速度比主内存快 5-100 倍。
缓存的工作原理是”空间局部性”——当你访问内存地址 X 时,CPU 不仅把 X 处的数据加载到缓存中,还会把 X 附近的整个缓存行(cache line,ARM 上通常 64 字节)一起加载。这意味着:如果你接下来访问的数据恰好在 X 附近(同一个缓存行内),就不需要再去主内存取了——直接从缓存读,快 100 倍。
反过来,如果你每次访问的数据都在内存的不同位置(跳着访问),每次访问都会导致缓存未命中(cache miss),每次都要等 100 个时钟周期。连续访问和跳跃访问的性能差异,在实际中可以达到 10-100 倍。
数据布局影响缓存效率
这和推理引擎有什么关系?看一个具体例子。一个形状为 [1, 64, 112, 112] 的特征图(1个样本、64个channel、112x112空间尺寸),在内存中可以有不同的排列方式:
NCHW 布局:先排 channel 0 的所有像素(112x112 = 12544 个 float),再排 channel 1 的所有像素,依此类推。
内存: [C0的全部112x112像素][C1的全部112x112像素]...[C63的全部112x112像素]
NHWC 布局:先排位置 (0,0) 的所有 channel 值(64 个 float),再排位置 (0,1) 的 64 个值,依此类推。
内存: [位置(0,0)的64个通道值][位置(0,1)的64个通道值]...
NC4HW4 布局(MNN/TNN 使用):将 channel 按 4 个一组打包,同一空间位置的 4 个 channel 值连续存储。
内存: [位置(0,0)的C0-C3][位置(0,1)的C0-C3]...[位置(0,0)的C4-C7][位置(0,1)的C4-C7]...
卷积运算在一个输出位置上需要读取多个 channel 在相同空间位置的值。如果用 NCHW 布局,读取 channel 0 的值和 channel 1 的值在内存中相距 12544 × 4 = 50176 字节——远超一个 64 字节的缓存行,导致 cache miss。如果用 NC4HW4 布局,4 个 channel 的值紧挨着,一次缓存行加载就能读取到 4 个 channel 的值——完美的缓存命中。
更妙的是,NC4HW4 让 4 个 channel 的值连续存储,恰好可以用一条 vld1q_f32 指令一次性载入 NEON 寄存器——SIMD 友好性和缓存友好性在这个布局下达成了统一。
这就是为什么”数据排列方式”不是一个可有可无的细节,而是推理引擎性能的决定性因素。选错布局,SIMD 指令和缓存都发挥不出来,性能可能差 5-10 倍。
更高级的 NEON 应用:Winograd 和 im2col
了解 NEON 基础后,简要介绍推理引擎中两种利用 SIMD 的高级卷积策略,让你对后续章节(特别是 ncnn 精讲)有初步认识。
im2col(Image to Column)
im2col 是一种”空间换时间”的策略:把卷积运算转化为矩阵乘法。具体做法是把输入特征图中每个卷积窗口的数据展开(复制)成矩阵的一列,然后用一次大矩阵乘法完成所有输出位置的卷积。
原始卷积: 逐位置滑窗,每个位置做一次小矩阵乘法
im2col: 把所有窗口数据展开成大矩阵,一次大 GEMM 搞定
好处:矩阵乘法(GEMM)是计算机科学中优化最充分的操作之一,有大量针对 SIMD 和缓存的极致优化实现。坏处:im2col 展开会产生数据冗余(同一个像素可能被复制到多列),增加内存占用。
Winograd 变换
Winograd 是一种数学技巧——对于 3x3 卷积,Winograd F(2,3) 可以用 4 次乘法替代原本需要的 9 次乘法(但增加了加法次数)。在乘法比加法昂贵的硬件上(现代 CPU 的乘法和加法延迟接近,但带宽受限时减少乘法能减少内存读取),这可以带来约 2.25 倍的理论加速。
Winograd 的变换矩阵和逆变换矩阵是固定的,可以预计算。ncnn 在 Convolution_arm.cpp 中同时实现了 im2col 和 Winograd 两种策略,并根据卷积参数(kernel size、channel 数、输出尺寸)在运行时自动选择最优策略。对于 3x3 卷积 + 大输出特征图,Winograd 通常更快;对于 1x1 卷积或小输出特征图,im2col + GEMM 更优。
这两种策略的 SIMD 优化细节极其复杂(涉及寄存器分配、prefetch 预取、unroll 展开等底层技巧),是推理引擎核心工程能力的体现。现在只需要知道它们的存在和基本思路即可。
ARM 的下一代:SVE 和 SME
NEON 的 128-bit 固定宽度在移动端够用,但在 ARM 的服务器和高性能计算领域,需要更宽的 SIMD。ARM 推出了 SVE(Scalable Vector Extension)和 SVE2 指令集,最大的特点是向量长度可变——从 128-bit 到 2048-bit,由硬件决定。
SVE 的编程模型使用”谓词寄存器”(predicate register)来控制哪些通道参与计算,代码不需要硬编码向量宽度——同一份 SVE 代码在不同宽度的硬件上都能运行。这比 NEON 的固定 128-bit 更灵活。
SME(Scalable Matrix Extension)更进一步,直接在指令集层面支持矩阵运算——一条指令可以完成整个矩阵外积(outer product),无需拆成多条向量指令。
目前 SVE 主要出现在 ARM 服务器芯片(如 AWS Graviton3、富岳超算)中,移动端还没有广泛采用。但理解 SVE 的可变长度设计可以帮助你理解 ncnn 的 elempack 设计为什么有前瞻性——当未来移动端支持 SVE 时,elempack 可以自然扩展到更大的打包数,而 NC4HW4 这种固定打包方案就需要大改。
本章小结与关键数字速查
本章的核心信息可以归结为一句话:SIMD 是推理引擎在 CPU 上获得高性能的基石。ARM NEON 提供 128-bit 向量寄存器,一条指令可以同时处理 4 个 float32 或 16 个 int8。推理引擎的大量工程努力都投入在”让数据以 SIMD 友好的方式排列”和”用 SIMD 指令实现高效的卷积/矩阵乘法”上。
ncnn 的 elempack 设计是这个方向上的一个精妙解答——让打包数随硬件 SIMD 宽度动态调整,在每种硬件上都达到最优利用率。MNN/TNN 的 NC4HW4 则选择了固定打包的简洁路线——全栈统一,免去转换开销,代价是在宽 SIMD 设备上不是最优。
关键数字速查
| 项目 | 数值 |
|---|---|
| NEON 寄存器宽度 | 128 bit |
| NEON 寄存器数量 | 32 个 (v0-v31) |
| float32 并行度 (NEON) | 4 |
| float16 并行度 (NEON) | 8 |
| int8 并行度 (NEON) | 16 |
| SSE 寄存器宽度 | 128 bit (等同 NEON) |
| AVX 寄存器宽度 | 256 bit (2x NEON) |
| AVX-512 寄存器宽度 | 512 bit (4x NEON) |
| ARM 缓存行大小 | 64 bytes |
| L1 缓存延迟 | ~1 ns |
| 主内存延迟 | ~33 ns (约 100 时钟周期 @ 3GHz) |
| ncnn elempack (NEON) | 4 |
| ncnn elempack (AVX) | 8 |
| ncnn elempack (AVX-512) | 16 |
| MNN NC4HW4 打包数 | 固定 4 |
| TNN NC8HW8 (fp16) 打包数 | 固定 8 |
导读目录:README.md 上一章:Ch05: 为什么手机和服务器不一样——移动端约束全解 下一章:Ch07: GPU 计算入门——从渲染管线到通用计算