mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1358 字
4 分钟
数据导向设计
2026-02-18

同样的算法、同样的数据量,只是把 struct { float x, y, z; } 的数组换成三个独立的 float[] 数组,性能就能提升 10 倍。这不是魔法——是缓存行在起作用。连续的 float[] 让一次缓存行加载(64 字节)拿到 16 个有效数据,而交错排列的 struct 数组每次加载只用到 4 字节,其余 60 字节全是当前不需要的数据。**数据导向设计(Data-Oriented Design, DOD)**的核心就一句话:让数据布局适配 CPU 缓存层次,而不是适配你的抽象模型。

一、DOD vs OOD#

1.1 设计哲学对比#

graph TB subgraph "面向对象设计 OOD" O1["对象 = 数据 + 行为"] O2["关注:抽象、封装、继承"] O3["数据布局:分散在堆上"] O4["缓存:指针追逐"] end subgraph "数据导向设计 DOD" D1["数据与行为分离"] D2["关注:内存布局、缓存命中"] D3["数据布局:连续数组"] D4["缓存:顺序访问"] end style O4 fill:#ffcdd2,stroke:#c62828 style D4 fill:#c8e6c9,stroke:#2e7d32
维度OODDOD
核心抽象对象数据 + 变换
数据布局分散(堆分配)连续(数组/SoA)
访问模式随机(指针追逐)顺序(线性遍历)
缓存行为差(频繁 miss)好(高命中率)
适用场景UI、业务逻辑游戏引擎、高性能计算

1.2 为什么数据布局重要#

CPU 读取数据的代价:
L1 缓存命中: ~1 ns (4 cycles)
L2 缓存命中: ~5 ns (20 cycles)
L3 缓存命中: ~20 ns (80 cycles)
主存访问: ~100 ns (400 cycles)
一个缓存行 = 64 字节
如果你要处理 1000 个粒子,每个粒子 64 字节:
顺序访问(缓存友好):~1000 次 L1 命中 = ~1 μs
随机访问(指针追逐):~1000 次 L3/主存 = ~20-100 μs
差距:20-100 倍!
Note

现代 CPU 的性能瓶颈不是计算速度,而是数据喂给 CPU 的速度。一个 3 GHz 的 CPU 每秒执行 30 亿个周期,但每次主存访问需要 400 个周期——CPU 在等数据时什么也做不了。

二、AoS vs SoA#

2.1 Array of Structures#

// AoS:每个粒子是一个完整的结构体
struct Particle {
float x, y, z; // 位置:12 字节
float vx, vy, vz; // 速度:12 字节
float r, g, b, a; // 颜色:16 字节
float mass; // 质量:4 字节
float lifetime; // 生命周期:4 字节
}; // 总计:48 字节
// AoS 数组:粒子在内存中连续,但每个粒子的所有字段在一起
Particle particles[1000];
// 更新位置:只需要 x, y, z, vx, vy, vz
// 但缓存行包含整个 Particle(48 字节)
// 有效数据:24 字节 / 48 字节 = 50% 缓存利用率
void updatePositions(Particle* particles, int count, float dt) {
for (int i = 0; i < count; i++) {
particles[i].x += particles[i].vx * dt;
particles[i].y += particles[i].vy * dt;
particles[i].z += particles[i].vz * dt;
}
}

2.2 Structure of Arrays#

// SoA:每种字段一个独立数组
struct Particles {
float* x, *y, *z; // 位置
float* vx, *vy, *vz; // 速度
float* r, *g, *b, *a; // 颜色
float* mass; // 质量
float* lifetime; // 生命周期
int count;
};
// SoA 更新位置:只加载 x, y, z, vx, vy, vz
// 缓存行只包含同类型数据
// 有效数据:100% 缓存利用率
void updatePositions(Particles* p, float dt) {
for (int i = 0; i < p->count; i++) {
p->x[i] += p->vx[i] * dt;
p->y[i] += p->vy[i] * dt;
p->z[i] += p->vz[i] * dt;
}
// SIMD 友好!可以向量化
}

2.3 内存布局对比#

graph TB subgraph "AoS 内存布局" A1["P0: x y z vx vy vz r g b a m l"] A2["P1: x y z vx vy vz r g b a m l"] A3["P2: x y z vx vy vz r g b a m l"] A4["P3: x y z vx vy vz r g b a m l"] A_NOTE["更新位置时:加载 x y z vx vy vz<br/>跳过 r g b a m l<br/>缓存利用率 50%"] end subgraph "SoA 内存布局" S1["x: P0 P1 P2 P3 ..."] S2["y: P0 P1 P2 P3 ..."] S3["z: P0 P1 P2 P3 ..."] S4["vx: P0 P1 P2 P3 ..."] S5["vy: P0 P1 P2 P3 ..."] S6["vz: P0 P1 P2 P3 ..."] S_NOTE["更新位置时:只加载 x y z vx vy vz<br/>缓存利用率 100%<br/>SIMD 友好"] end style A_NOTE fill:#ffcdd2,stroke:#c62828 style S_NOTE fill:#c8e6c9,stroke:#2e7d32
对比维度AoSSoA
缓存利用率低(加载不需要的字段)高(只加载需要的字段)
SIMD 友好差(数据不连续)好(同类型数据连续)
单元素访问好(一次缓存行加载)差(需要多个缓存行)
代码可读性好(自然的 OOP 风格)差(分散的字段)
内存分配简单(一次 malloc)复杂(多次 malloc)

三、填充与对齐#

3.1 结构体填充规则#

// 未优化的结构体:24 字节(3x 对齐填充)
struct BadLayout {
char a; // 1 字节 + 7 字节填充
double b; // 8 字节
char c; // 1 字节 + 7 字节填充
};
// sizeof(BadLayout) = 24 字节
// 有效数据:10 字节,浪费:14 字节 (58%)
// 优化后的结构体:16 字节
struct GoodLayout {
double b; // 8 字节
char a; // 1 字节
char c; // 1 字节 + 6 字节填充
};
// sizeof(GoodLayout) = 16 字节
// 有效数据:10 字节,浪费:6 字节 (37%)

3.2 手动对齐控制#

#include <alignas>
// 缓存行对齐:避免伪共享
struct alignas(64) CacheLineAligned {
std::atomic<int> counter; // 独占一个缓存行
};
// 多个计数器,每个独占缓存行
struct Counters {
alignas(64) std::atomic<int> http_requests;
alignas(64) std::atomic<int> grpc_requests;
alignas(64) std::atomic<int> db_queries;
};
// 每个计数器独占 64 字节缓存行
// 不同线程更新不同计数器不会伪共享

3.3 填充规则表#

类型大小对齐要求
char11
short22
int44
float44
double88
pointer8 (64-bit)8
Tip

结构体字段排序规则:按大小降序排列。大字段在前,小字段在后,最小化填充浪费。Clang 有 -Wpadded 警告选项,可以检测结构体填充。

四、实战优化案例#

4.1 案例 1:粒子系统#

// 游戏引擎中的粒子系统
// 10000 个粒子,每帧更新
// AoS 版本:~2.5ms/帧
void updateParticlesAoS(Particle* particles, int count, float dt) {
for (int i = 0; i < count; i++) {
particles[i].x += particles[i].vx * dt;
particles[i].y += particles[i].vy * dt;
particles[i].z += particles[i].vz * dt;
particles[i].lifetime -= dt;
}
}
// SoA 版本 + SIMD:~0.3ms/帧 (8x 加速)
#include <immintrin.h>
void updateParticlesSoA(Particles* p, float dt) {
__m256 dt_vec = _mm256_set1_ps(dt);
int i = 0;
for (; i + 8 <= p->count; i += 8) {
__m256 x = _mm256_loadu_ps(&p->x[i]);
__m256 vx = _mm256_loadu_ps(&p->vx[i]);
__m256 y = _mm256_loadu_ps(&p->y[i]);
__m256 vy = _mm256_loadu_ps(&p->vy[i]);
__m256 z = _mm256_loadu_ps(&p->z[i]);
__m256 vz = _mm256_loadu_ps(&p->vz[i]);
x = _mm256_fmadd_ps(vx, dt_vec, x);
y = _mm256_fmadd_ps(vy, dt_vec, y);
z = _mm256_fmadd_ps(vz, dt_vec, z);
_mm256_storeu_ps(&p->x[i], x);
_mm256_storeu_ps(&p->y[i], y);
_mm256_storeu_ps(&p->z[i], z);
}
// 处理剩余元素...
}

4.2 案例 2:数据库行存储#

// 数据库行存储:OLTP 场景
// 只需要 id 和 name,但加载了整行
// 宽行:加载不需要的字段
struct Row {
int64_t id; // 8
char name[256]; // 256
char email[256]; // 256
char address[512]; // 512
char bio[1024]; // 1024
// 总计:2056 字节
};
// 查询 SELECT id, name FROM users
// 需要的数据:264 字节
// 实际加载:2056 字节
// 缓存利用率:12.8%
// 列式存储:只加载需要的列
struct ColumnarTable {
int64_t* ids; // 连续的 id 数组
char** names; // 连续的 name 数组
char** emails; // ...
// 查询时只加载 ids 和 names
// 缓存利用率:~100%
};

4.3 案例 3:网络包处理#

// 网络包处理:只关心头部字段
// 加载整个包结构
struct Packet {
uint8_t dst_mac[6];
uint8_t src_mac[6];
uint16_t ethertype;
uint8_t version_ihl;
uint8_t tos;
uint16_t total_length;
uint16_t identification;
// ... 更多字段
uint8_t payload[1500]; // 载荷
}; // 总计 ~1536 字节
// 分层处理:只加载需要的头部
struct EthernetHeader {
uint8_t dst_mac[6];
uint8_t src_mac[6];
uint16_t ethertype;
}; // 14 字节
struct IPv4Header {
uint8_t version_ihl;
uint8_t tos;
uint16_t total_length;
// ... 只到选项字段
}; // 20-60 字节

五、性能分析工具#

5.1 perf 缓存事件#

# 测量缓存 miss
perf stat -e cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses \
./my_program
# 输出示例:
# 1,234,567,890 cache-references
# 12,345,678 cache-misses # 1.0% miss rate
# 5,678,901,234 L1-dcache-loads
# 567,890,123 L1-dcache-load-misses # 10.0% L1 miss rate
# 使用 cachegrind 模拟缓存行为
valgrind --tool=cachegrind ./my_program
# 输出:I1/D1/L2 缓存的命中/miss 统计

5.2 perf mem 分析#

# 记录内存访问事件
perf record -e mem-loads,mem-stores ./my_program
perf report
# 查看内存访问延迟分布
perf mem record ./my_program
perf mem report

六、何时使用 DOD#

场景推荐 DOD?原因
游戏引擎粒子系统强烈推荐大量同质数据,批量处理
数据库存储引擎推荐列式存储、向量化执行
网络包处理推荐批量处理、SIMD
UI 组件系统不推荐异质数据、单元素访问多
业务逻辑层不推荐复杂对象关系、代码可读性更重要
配置管理不推荐数据量小,缓存不是瓶颈
Warning

DOD 不是银弹。在异质数据、单元素访问频繁的场景下,SoA 反而更慢(多个缓存行加载)。选择 AoS 还是 SoA 取决于访问模式:批量处理用 SoA,单元素访问用 AoS。

七、AoS vs SoA 深入对比#

7.1 AoS vs SoA 的性能基准测试#

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <immintrin.h>
#define N 10000000
// AoS 版本
struct ParticleAoS {
float x, y, z; // 位置
float vx, vy, vz; // 速度
float r, g, b, a; // 颜色
float mass; // 质量
float lifetime; // 生命周期
}; // 48 字节
// SoA 版本
struct ParticleSoA {
float *x, *y, *z;
float *vx, *vy, *vz;
float *r, *g, *b, *a;
float *mass;
float *lifetime;
};
int main() {
// 分配 AoS
struct ParticleAoS *aos = aligned_alloc(64, N * sizeof(struct ParticleAoS));
for (int i = 0; i < N; i++) {
aos[i].x = i; aos[i].y = i; aos[i].z = i;
aos[i].vx = 1; aos[i].vy = 1; aos[i].vz = 1;
}
// 分配 SoA
struct ParticleSoA soa;
soa.x = aligned_alloc(64, N * sizeof(float));
soa.y = aligned_alloc(64, N * sizeof(float));
soa.z = aligned_alloc(64, N * sizeof(float));
soa.vx = aligned_alloc(64, N * sizeof(float));
soa.vy = aligned_alloc(64, N * sizeof(float));
soa.vz = aligned_alloc(64, N * sizeof(float));
for (int i = 0; i < N; i++) {
soa.x[i] = i; soa.y[i] = i; soa.z[i] = i;
soa.vx[i] = 1; soa.vy[i] = 1; soa.vz[i] = 1;
}
float dt = 0.016f;
clock_t start, end;
// AoS 更新位置
start = clock();
for (int i = 0; i < N; i++) {
aos[i].x += aos[i].vx * dt;
aos[i].y += aos[i].vy * dt;
aos[i].z += aos[i].vz * dt;
}
end = clock();
printf("AoS 更新位置: %.3f\n", (double)(end - start) / CLOCKS_PER_SEC);
// SoA 更新位置(标量)
start = clock();
for (int i = 0; i < N; i++) {
soa.x[i] += soa.vx[i] * dt;
soa.y[i] += soa.vy[i] * dt;
soa.z[i] += soa.vz[i] * dt;
}
end = clock();
printf("SoA 更新位置(标量): %.3f\n", (double)(end - start) / CLOCKS_PER_SEC);
// SoA + AVX2 更新位置
__m256 dt_v = _mm256_set1_ps(dt);
start = clock();
for (int i = 0; i + 8 <= N; i += 8) {
__m256 x = _mm256_loadu_ps(&soa.x[i]);
__m256 vx = _mm256_loadu_ps(&soa.vx[i]);
x = _mm256_fmadd_ps(vx, dt_v, x);
_mm256_storeu_ps(&soa.x[i], x);
// y, z 同理...
}
end = clock();
printf("SoA+AVX2 更新位置: %.3f\n", (double)(end - start) / CLOCKS_PER_SEC);
free(aos);
return 0;
}
版本缓存利用率SIMD 友好典型加速比
AoS~50%(加载了不需要的颜色/质量)1.0x(基准)
SoA 标量~100%1.5-2x
SoA + AVX2~100%4-8x

7.2 AoSoA(Array of Structures of Arrays)#

AoS 和 SoA 的折中方案——将数据分成固定大小的块,每块内用 SoA:

// AoSoA: 每块 8 个粒子(适配 AVX2 的 256 位寄存器)
struct ParticleBlock {
float x[8], y[8], z[8]; // 位置
float vx[8], vy[8], vz[8]; // 速度
float r[8], g[8], b[8], a[8]; // 颜色
float mass[8];
float lifetime[8];
}; // 384 字节 = 6 个缓存行
// 访问第 i 个粒子的 x 坐标
int block = i / 8;
int offset = i % 8;
float px = blocks[block].x[offset];
// SIMD 处理一整块
__m256 x = _mm256_load_ps(blocks[block].x); // 直接加载!连续的!
布局缓存利用率SIMD 友好单元素访问内存分配
AoS简单
SoA复杂
AoSoA中等中等

八、缓存友好的数据结构#

8.1 缓存友好的哈希表#

// 链式哈希表:指针追逐,缓存不友好
struct ChainHashNode {
int key;
int value;
struct ChainHashNode *next; // 指针追逐!
};
// 开放寻址哈希表:连续内存,缓存友好
struct OpenAddressingHash {
int *keys; // 连续数组
int *values; // 连续数组
int *metadata; // 0=空, 1=占用, 2=已删除
int capacity;
};
int hash_lookup(struct OpenAddressingHash *ht, int key) {
int idx = key % ht->capacity;
while (ht->metadata[idx] != 0) {
if (ht->metadata[idx] == 1 && ht->keys[idx] == key)
return ht->values[idx];
idx = (idx + 1) % ht->capacity; // 线性探测
}
return -1; // 未找到
}
// 线性探测的局部性很好——相邻的槽在同一缓存行中

8.2 缓存友好的树结构#

// 普通二叉搜索树:节点分散在堆上
struct BSTNode {
int key;
struct BSTNode *left, *right; // 指针追逐
};
// B 树 / B+ 树:节点包含多个键,减少树高
#define BTREE_ORDER 16 // 每个节点 16 个键
struct BTreeNode {
int num_keys;
int keys[BTREE_ORDER - 1];
struct BTreeNode *children[BTREE_ORDER];
// 节点大小约 256 字节 = 4 个缓存行
// 一次缓存未命中加载 15 个键
};

九、填充与对齐规则详解#

9.1 结构体填充的完整规则#

C/C++ 编译器遵循以下对齐规则:

  1. 每个成员的偏移量必须是其对齐要求的整数倍
  2. 结构体的总大小必须是其最大对齐要求的整数倍
  3. 数组的对齐要求等于元素的对齐要求
// 经典面试题:以下结构体的大小?
struct S1 { char a; int b; short c; };
// a: offset 0, 1 byte
// 填充: 3 bytes (int 需要 4 字节对齐)
// b: offset 4, 4 bytes
// c: offset 8, 2 bytes
// 填充: 2 bytes (总大小需要是 4 的倍数)
// sizeof(S1) = 12
struct S2 { int b; short c; char a; };
// b: offset 0, 4 bytes
// c: offset 4, 2 bytes
// a: offset 6, 1 byte
// 填充: 1 byte
// sizeof(S2) = 8 ← 比 S1 节省 33%!

9.2 伪共享的检测与修复#

// 多线程计数器:伪共享
struct Counters {
std::atomic<int> http_requests; // 偏移 0
std::atomic<int> grpc_requests; // 偏移 4 ← 同一缓存行!
std::atomic<int> db_queries; // 偏移 8 ← 同一缓存行!
};
// 3 个原子变量在同一个 64 字节缓存行中
// 不同线程更新不同计数器 → 伪共享!
// 修复方案 1:缓存行对齐
struct alignas(64) CacheLine {
std::atomic<int> counter;
};
struct CountersFixed {
CacheLine http_requests; // 独占缓存行
CacheLine grpc_requests; // 独占缓存行
CacheLine db_queries; // 独占缓存行
};
// 每个计数器独占 64 字节,不同线程更新不会互相驱逐
// 修复方案 2:线程本地计数 + 定期汇总
struct ThreadLocalCounters {
int http_requests; // 非原子,线程本地
int grpc_requests;
int db_queries;
};
// 每个线程有自己的计数器(在 TLS 中)
static __thread struct ThreadLocalCounters tl_counters;
void increment_http() {
tl_counters.http_requests++; // 无锁,无伪共享
}
// 定期汇总到全局
void flush_counters() {
global_http += tl_counters.http_requests;
tl_counters.http_requests = 0;
}
Warning

缓存行对齐会浪费内存——每个计数器从 4 字节膨胀到 64 字节。如果只有少量计数器,浪费可以接受;但如果有数千个计数器(如 per-CPU 统计),需要权衡内存和性能。Linux 内核使用 DEFINE_PER_CPU 宏,每个 CPU 一个副本,既避免伪共享又不过度浪费内存。

十、实战案例研究#

10.1 案例:日志系统的性能优化#

// 原始版本:每条日志一个 malloc
struct LogEntry {
long timestamp; // 8
int level; // 4
int thread_id; // 4
char message[256]; // 256
struct LogEntry *next; // 8
}; // 280 字节,但 malloc 开销大
// 优化版本 1:环形缓冲区(连续内存)
struct LogBuffer {
struct LogEntry entries[4096]; // 连续数组
int head, tail;
// 所有日志在连续内存中,缓存友好
};
// 优化版本 2:冷热分离
struct HotLogData {
long timestamp; // 8
int level; // 4
int thread_id; // 4
}; // 16 字节,4 条/缓存行
struct ColdLogData {
char message[256]; // 256 字节
}; // 只在需要显示时才访问
struct LogBufferOptimized {
struct HotLogData hot[4096]; // 热数据连续
struct ColdLogData cold[4096]; // 冷数据连续但分开
};
// 过滤日志级别时只遍历 hot 数组,缓存利用率 100%
// 只有需要输出的日志才访问 cold 数组

10.2 案例:网络连接表的优化#

// 原始:链表 + 大结构体
struct Connection {
int fd;
int state;
char remote_addr[46]; // IPv6 地址字符串
time_t last_active;
int bytes_sent, bytes_recv;
char user_agent[256]; // 冷数据!
struct Connection *next; // 指针追逐
};
// 优化:数组 + 冷热分离
struct HotConnData {
int fd;
int state;
time_t last_active;
int bytes_sent, bytes_recv;
}; // 24 字节,2-3 条/缓存行
struct ColdConnData {
char remote_addr[46];
char user_agent[256];
}; // 302 字节
struct ConnTable {
struct HotConnData hot[MAX_CONN]; // 连续数组
struct ColdConnData cold[MAX_CONN]; // 冷数据分开
int count;
};
// 遍历活跃连接时只访问 hot 数组
// 缓存行利用率从 24/302 = 8% 提升到 100%
graph LR subgraph AoS["AoS 布局"] A1["x y z"] --> A2["x y z"] --> A3["x y z"] end subgraph SoA["SoA 布局"] S1["x x x"] --> S2["y y y"] --> S3["z z z"] end AoS -->|"变换"| SoA style AoS fill:#ffcdd2,stroke:#c62828 style SoA fill:#c8e6c9,stroke:#2e7d32
flowchart TB DATA["原始数据结构"] --> ANALYZE["分析访问模式<br/>热字段 vs 冷字段"] ANALYZE --> SPLIT["拆分热/冷字段"] --> REORG["重排内存布局<br/>热字段连续"] REORG --> VERIFY["验证缓存命中率<br/>perf stat"] style ANALYZE fill:#bbdefb,stroke:#1565c0 style REORG fill:#c8e6c9,stroke:#2e7d32

十一、总结#

上一章探讨了性能计数器与 Top-Down 分析。

技术效果适用场景
SoA 替代 AoS2-8x 加速批量处理同质数据
字段重排序减少 20-50% 内存浪费所有结构体
缓存行对齐消除伪共享多线程共享计数器
冷热数据分离提高缓存命中率访问频率差异大的字段
SIMD 向量化4-8x 加速SoA + 简单运算

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

数据导向设计
https://blog.souloss.com/posts/cpu-architecture/data-oriented-design/
作者
Souloss
发布于
2026-02-18
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时