4. 数据布局优化:AoS 到 SoA 的向量化收益验证
为什么 AoS 会挡住向量化
考虑一个粒子系统,每个粒子有 x, y, z, mass 四个 float 字段。AoS(Array of Structures)把同一粒子的字段连续存放,而 SoA(Structure of Arrays)把同一字段的所有粒子连续存放。自动向量化器要生成一条 4 路 SIMD 指令,需要 4 个连续且同类型的元素。在 AoS 中,相邻的 x 之间隔着 y, z, mass,步长为 16 字节,编译器只能判定为跨步访问(stride 4),通常放弃向量化或退化为标量 gather;在 SoA 中,x[i..i+3] 天然连续,可直接装入 128 位寄存器。
两种布局的最小实现
下面用同一套算法逻辑(对每个粒子做 x += vx * dt 的位置更新)分别实现 AoS 与 SoA 版本。两个版本都使用 restrict 指针,排除别名干扰,让差异只来自数据布局。数组长度取 N = 1 << 22(约 419 万粒子),保证数据量远超 L3 缓存,使内存带宽成为主要瓶颈。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define N (1u << 22)
#define DT 0.016f
typedef struct { float x, y, z, mass; } Particle;
/* AoS 版本 */
void update_aos(Particle *restrict p, const float *restrict vx, size_t n) {
for (size_t i = 0; i < n; ++i) {
p[i].x += vx[i] * DT;
p[i].y += vx[i] * DT;
p[i].z += vx[i] * DT;
}
}
/* SoA 版本 */
void update_soa(float *restrict x, float *restrict y, float *restrict z,
const float *restrict vx, size_t n) {
for (size_t i = 0; i < n; ++i) {
x[i] += vx[i] * DT;
y[i] += vx[i] * DT;
z[i] += vx[i] * DT;
}
}
static double now_sec(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec * 1e-9;
}
int main(void) {
Particle *aos = aligned_alloc(64, N * sizeof(Particle));
float *x = aligned_alloc(64, N * sizeof(float));
float *y = aligned_alloc(64, N * sizeof(float));
float *z = aligned_alloc(64, N * sizeof(float));
float *vx = aligned_alloc(64, N * sizeof(float));
if (!aos || !x || !y || !z || !vx) return 1;
for (size_t i = 0; i < N; ++i) {
aos[i] = (Particle){1.0f, 2.0f, 3.0f, 1.0f};
x[i] = 1.0f; y[i] = 2.0f; z[i] = 3.0f;
vx[i] = 0.5f;
}
double t0 = now_sec();
update_aos(aos, vx, N);
double t1 = now_sec();
update_soa(x, y, z, vx, N);
double t2 = now_sec();
printf("AoS: %.4f s\nSoA: %.4f s\n", t1 - t0, t2 - t1);
printf("check x[0]=%.3f x[N-1]=%.3f\n", x[0], x[N - 1]);
free(aos); free(x); free(y); free(z); free(vx);
return 0;
}
编译时打开向量化报告,直接观察编译器对两个循环的判定:
gcc -O3 -march=native -fopt-info-vec-optimized -fopt-info-vec-missed layout.c -o layout
报告与带宽的对照判读
在支持 AVX2 的 x86 平台上,典型输出会呈现如下差异(具体行号与措辞随 GCC 版本略有不同,但判定结论稳定):
| 观察项 | AoS 版本 | SoA 版本 |
|---|---|---|
| 向量化报告 | missed: not vectorized(跨步访问) | optimized: loop vectorized,width 8 |
| 每元素访存 | 读 16 B 结构体 + 写 16 B,实际只用 x/y/z | 读 3×4 B + 写 3×4 B,无冗余字段 |
| 有效带宽占比 | 低(mass 字段被白白搬运) | 高(几乎全部字节都是有效数据) |
| 预期运行时间 | 较长,受限于标量指令与冗余流量 | 较短,SIMD 吞吐 + 合并访存 |
loop vectorized,再看 width 是否等于目标 ISA 的向量宽度(AVX2 为 8 个 float)。若 AoS 报告为 missed,说明瓶颈在布局而非算法;此时改 SoA 后若 width 提升且时间下降,即完成布局收益的闭环验证。用 perf 量化内存带宽差异
运行时间受频率波动影响,配合 perf stat 观察访存事件更可靠。对两个版本分别执行:
perf stat -e cycles,instructions,cache-misses,LLC-load-misses ./layout
关注两个比值:instructions / cycles(IPC)与 LLC-load-misses。SoA 版本因 SIMD 打包,每处理 8 个元素只需约 1/8 的指令数,IPC 通常更高;同时 AoS 每次装载都带入 mass 字段,相同元素数下 LLC 缺失次数更多。若把 N 缩小到能放进 L2(如 1 << 15),两版差距会明显收窄——这正说明差异主要来自内存流量而非计算量,是布局优化的典型特征。
aligned_alloc 的分配大小必须是 64 的整数倍,N * sizeof(Particle) 在 N 为 2 的幂时天然满足;若改用任意 N,需向上取整到 64 的倍数,否则行为未定义。另外,SoA 版本中 x, y, z, vx 四个数组都加了 restrict,若实际调用时存在重叠,结果不可预期。可复现的验证清单
- 编译命令包含
-O3 -march=native,并开启-fopt-info-vec-optimized与-fopt-info-vec-missed。 - 确认 AoS 循环报告为未向量化,SoA 循环报告为已向量化且 width 与 ISA 匹配。
- 两版使用相同
N、相同DT、相同初始值,仅布局不同。 - 用
perf stat对比 IPC 与 LLC-load-misses,确认 SoA 的指令数与缺失次数均下降。 - 把
N缩到 L2 容量内重跑,观察差距收窄,佐证瓶颈在内存流量。 - 校验
x[0]与x[N-1]输出一致,确保两版算法逻辑等价。