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 位寄存器。

AoS:粒子字段交错存放 stride = 4 个 float = 16 B,x 分量不连续 x0 y0 z0 m0 x1 y1 z1 m1 x2 y2 z2 m2 x3 y3 跨步装载:需 4 次标量读取 + 拼接,向量化受阻 SoA:同字段连续存放 x 数组 stride = 1 个 float = 4 B,天然连续 x[] x0 x1 x2 x3 x4 … y[] y0 y1 y2 y3 … z[] z0 z1 z2 z3 … 连续装载:1 条 128 位 load 指令 向量化判据 • 连续同类型元素 → 可打包为 SIMD 向量 • AoS 的 x 分量步长 16 B,编译器报 “not vectorized: unsupported data layout” • SoA 的 x 分量步长 4 B,报 “vectorized: width 4 (SSE/AVX)” • 收益来源:指令数下降 + 访存合并

两种布局的最小实现

下面用同一套算法逻辑(对每个粒子做 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] 输出一致,确保两版算法逻辑等价。