# 内存对齐对性能的影响 —— 为什么"地址是几的倍数"能决定快慢

> [cache-organization.md](/concepts/cache/cache-organization.md) 讲了缓存以 **64 字节 cache line** 为单位搬运，[mesi.md](/concepts/cache/mesi.md) §5 讲了**多核**下变量挤在同一行的伪共享。本篇讲一个更基础、**单线程也逃不掉**的问题：**数据放在什么地址（是不是 2/4/8/… 的倍数），直接影响访存要几次、会不会撕裂、能不能原子、SIMD 跑不跑得动。**


> "对齐"听起来是编译器的小事，实则贯穿性能与正确性：**对齐的访问一次搞定，未对齐的访问可能拆成两次、甚至跨 cache line、甚至让原子操作失效。** 本篇讲清对齐是什么、不对齐代价多大、结构体 padding 怎么来的、以及怎么用对齐提性能。

## 一、什么是对齐：地址必须是类型大小的整数倍

**自然对齐(natural alignment)**：一个大小为 N 字节的类型，它的地址应当是 **N 的整数倍**。

| 类型 | 大小 | 要求地址是…的倍数 |
|------|:---:|:---:|
| `char` | 1 | 1（任意地址都行）|
| `short` | 2 | 2 |
| `int` / `float` | 4 | 4 |
| `long` / `double` / 指针(64位) | 8 | 8 |
| SIMD `__m128`(SSE) | 16 | 16 |
| SIMD `__m256`(AVX) | 32 | 32 |

- 一个 `int` 放在地址 `0x1000`（4 的倍数）= **对齐**；放在 `0x1001` = **未对齐(misaligned)**。
- **为什么硬件想要对齐**：内存/缓存是按固定宽度的块访问的（比如按 8 字节字、按 64B cache line）。**一个对齐的 N 字节数据保证落在单个访问块内，一次取到**；未对齐的则可能横跨两个块，得取两次再拼。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ok>> #C8E6C9
  BorderColor<<ok>> #388E3C
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>> #C62828
}
rectangle "对齐:int @ 0x1000\n[ 1000 1001 1002 1003 ]\n完整落在一个 8 字节字内\n→ **一次访存**" <<ok>> as A
rectangle "未对齐:int @ 0x1006\n[ ...1006 1007 | 1008 1009... ]\n横跨两个 8 字节字\n→ **两次访存 + 拼接**" <<bad>> as B
A -right-> B
note bottom of B : 更糟:若正好跨在\n两个 64B cache line 边界上\n代价更大(见第三节)
@enduml
```

## 二、未对齐访问的代价：从"慢一点"到"直接崩"

未对齐访问的后果，取决于架构，分三档：

| 架构 | 未对齐访问的后果 |
|------|-----------------|
| **x86 / x86-64** | **允许**，但慢：硬件自动拆成多次访存再拼，多花几拍；跨 cache line 时更明显 |
| **老 ARM / MIPS / SPARC** | **直接崩**：触发对齐错误异常（`SIGBUS`）——很多"移植到 ARM 就崩"的 bug 源于此 |
| **现代 ARMv8** | 普通访问大多允许，但特定指令(如 `ldxr` 独占、部分 SIMD)仍要求对齐 |

即便在最宽容的 x86 上，未对齐也**不是免费**的：

- **拆分访问**：未对齐的读要发两次内存事务、再把两半拼起来，延迟和带宽都翻倍。
- **跨 cache line 撕裂(cache line split)**：最坏情况——数据正好跨在两条 64B cache line 的边界上，一次访问要碰**两条 line**，两条都可能各自 miss。这是未对齐里最贵的，有专门的性能计数器（第六节）。
- **跨页**：更极端地跨在两个 4KB 页边界上，可能触发**两次地址翻译**（两次 TLB 查询，见 [tlb.md](/concepts/cache/tlb.md)），甚至一次 TLB miss。

> **一句话**：x86 上未对齐"能跑但慢"，跨 cache line/跨页时慢得明显；很多非 x86 架构上未对齐**直接 SIGBUS**。对齐的本质，是**保证一次访存只碰一个访问块/一条 cache line**。

## 三、对齐与原子性：未对齐会让原子操作失效

这是对齐最容易被忽视的**正确性**后果（不只是性能）：

- **原子操作要求数据对齐**。一个跨 cache line 的变量，硬件无法用一条指令原子地读写它（要碰两条 line，中间可能被别的核插入）——`std::atomic` 依赖对齐来保证原子性。
- x86 上，`lock` 前缀对**对齐**的数据走高效的**缓存锁**（锁一条 line）；一旦数据**跨 cache line**，退化成古老而极慢的**总线锁(bus lock)**——锁住整条内存总线、几百上千拍，全核卡顿（见 [atomic.md](/concepts/cache/atomic.md)）。
- 所以 `std::atomic<T>` 会**自动保证 T 对齐**；但你若手动用未对齐地址做原子操作、或 `#pragma pack` 压扁了原子成员，就可能踩到"原子性丢失"或"总线锁灾难"。

> 记住：**对齐不仅是快慢问题，还是原子操作能否成立的前提。** 跨 cache line 的原子操作在 x86 上会触发全核卡顿的总线锁，在别的架构上可能直接不原子。

## 四、结构体里的对齐：padding 是怎么冒出来的

编译器为了让**每个成员都自然对齐**，会在成员间**插入填充字节(padding)**。这就是"结构体大小 ≠ 成员大小之和"的原因：

```c
struct Bad {
    char  a;    // 1 字节, 偏移 0
    // 3 字节 padding ← 为了让 int b 落在 4 的倍数
    int   b;    // 4 字节, 偏移 4
    char  c;    // 1 字节, 偏移 8
    // 7 字节 padding ← 为了让 double d 落在 8 的倍数
    double d;   // 8 字节, 偏移 16
};  // sizeof = 24（成员实际只用 14 字节,浪费 10 字节!）
```

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<u>> #C8E6C9
  BorderColor<<u>> #388E3C
  BackgroundColor<<p>> #FFCDD2
  BorderColor<<p>> #C62828
}
rectangle "偏移0: a(1)" <<u>> as A
rectangle "1~3: padding(3)" <<p>> as P1
rectangle "4~7: b(4)" <<u>> as B
rectangle "8: c(1)" <<u>> as C
rectangle "9~15: padding(7)" <<p>> as P2
rectangle "16~23: d(8)" <<u>> as D
A -right-> P1
P1 -right-> B
B -right-> C
C -right-> P2
P2 -right-> D
note bottom of P2 : 24 字节里 10 字节是空洞
@enduml
```

**优化：按大小降序排列成员**，把 padding 挤掉：

```c
struct Good {
    double d;   // 8, 偏移 0
    int    b;   // 4, 偏移 8
    char   a;   // 1, 偏移 12
    char   c;   // 1, 偏移 13
    // 2 字节尾部 padding(让数组里下一个也对齐)
};  // sizeof = 16（省了 8 字节）
```

**为什么这对性能重要**：

- **更小的结构体 = 更多能塞进一条 cache line = 更少 cache miss**。一个数组里遍历 `Good` 比 `Bad` 每条 cache line 能多装 50% 的元素。
- **热数据紧凑排布**（把常一起访问的字段放一起、冷字段拆出去）能显著减少访存。这是 data-oriented design 的核心。

> 准则：**结构体成员按大小从大到小排**，能自然消掉大部分中间 padding。用 `pahole` 工具能直接看结构体的空洞。

## 五、两种对齐目标：别和伪共享搞混

"对齐"在性能语境下其实有**两个方向相反**的目标，容易混：

| | 目标 | 手段 | 场景 |
|---|------|------|------|
| **紧凑对齐** | 让结构体**更小**、塞进更少 cache line | 成员按大小排序、消 padding | 单线程遍历大数组、省 cache/内存带宽 |
| **cache line 隔离** | 让热点变量**独占一行**、别和别的挤一起 | `alignas(64)` 撑大、填充到 64B | **多核**下防伪共享（见 [mesi.md](/concepts/cache/mesi.md) §5）|

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<a>> #C8E6C9
  BorderColor<<a>> #388E3C
  BackgroundColor<<b>> #FFE0B2
  BorderColor<<b>> #EF6C00
}
rectangle "紧凑(单线程/只读为主)\n把字段挤进尽量少的 line\n→ 少 cache miss、省带宽" <<a>> as A
rectangle "隔离(多核各写各的)\nalignas(64) 让热点变量独占一行\n→ 防伪共享的 line 乒乓" <<b>> as B
note bottom of A : 目标:小
note bottom of B : 目标:分开(甚至故意撑大)
@enduml
```

> **别用错方向**：单线程遍历图省 cache 就要**紧凑**；多核各写各的热点变量就要**隔离(撑大到独占 line)**。同一个"对齐"，两个相反的手法——判断依据是"这数据是单线程遍历、还是多核并发写"。多核伪共享的隔离手法详见 [mesi.md](/concepts/cache/mesi.md) §5，本篇不重复。

## 六、SIMD 对齐:向量化的硬门槛

SIMD（AVX/SSE，一条指令处理多份数据）对对齐尤其敏感：

- **对齐的加载/存储指令**（`movaps`/`vmovaps`）要求地址是 16/32/64 字节对齐，未对齐直接**崩(GP fault)**。
- **未对齐版本**（`movups`/`vmovups`）能跑但历史上更慢（现代 CPU 差距已小，但跨 cache line 仍有惩罚）。
- 想让编译器**自动向量化**你的循环，数据对齐是重要前提——`alignas(32) float buf[N];` 加对齐提示能让编译器放心用对齐指令。

> 数值计算/图像/ML 的热循环里，把数组对齐到 32/64 字节，是让 SIMD 满速的基本操作。

## 七、怎么观测和控制

### 控制对齐

```c
alignas(64) int x;                    // C++11: 强制 x 对齐到 64
struct alignas(64) CacheLineAligned { long v; };  // 结构体对齐
_Alignas(16) float buf[4];            // C11
#pragma pack(1)   // ⚠️ 强制紧凑、取消 padding —— 慎用!
struct Packed { char a; int b; };     // sizeof=5,但 b 未对齐,访问变慢/在某些架构崩
#pragma pack()
```

- `alignas` 是**加对齐**（提性能/满足 SIMD/防伪共享）。
- `#pragma pack` / `__attribute__((packed))` 是**取消对齐**（省空间，如网络协议包、文件格式）——**代价是未对齐访问**，只在"省空间比速度重要"且"确定架构容忍未对齐"时用。

### 观测

```bash
# 看结构体的 padding 空洞(需要 -g 调试信息)
pahole ./cpu_demo | less           # 直接列出每个结构体的成员偏移和 padding
# perf 看跨 cache line 的未对齐访问惩罚(事件名因型号而异)
perf stat -e mem_inst_retired.split_loads,mem_inst_retired.split_stores ./app
#   split_loads/stores = 跨 cache line 被拆分的访存次数,高=有未对齐热点
perf list | grep -i "split\|misalign\|unalign"
```

判据：**`split_loads`/`split_stores` 高，说明有热点数据未对齐或跨 cache line**，去查是不是 `#pragma pack` 压扁了、或数组元素大小不是 2 的幂导致错位。

## 八、和本仓库其他文档的关系

- **前置**：[cache-organization.md](/concepts/cache/cache-organization.md)（cache line 64B、访存以行为单位——对齐的意义全从这来）。
- **多核伪共享**：[mesi.md](/concepts/cache/mesi.md) §5（cache line 隔离防伪共享）——本篇第五节讲的"隔离"方向就指向它；本篇主讲**单次访存的对齐代价 + 结构体布局**，两者互补。
- **原子性**：[atomic.md](/concepts/cache/atomic.md)（对齐是原子操作的前提；跨 line 触发总线锁）。
- **地址翻译**：[tlb.md](/concepts/cache/tlb.md)（未对齐跨页 → 多次 TLB 查询）。
- **结构体在内存里的样子**：[../elf/memory-layout.md](/concepts/elf/memory-layout.md)（数据段布局）。
- **观测**：[../code/perf.md](/tools/code/perf.md)（split_loads 等事件）、`pahole`。

## 九、一句话总结

> **内存对齐 = 让 N 字节数据落在 N 的倍数地址上,本质是保证"一次访存只碰一个块/一条 cache line"。未对齐的代价:x86 上拆成多次访存(跨 cache line/跨页更贵)、很多非 x86 架构直接 SIGBUS、还会让跨 line 的原子操作退化成全核卡顿的总线锁(正确性问题)。结构体里编译器插 padding 保证成员对齐,把成员按大小降序排能挤掉空洞、让结构体更小塞进更少 cache line。注意"对齐"有两个相反方向:单线程遍历要紧凑(省 cache)、多核并发写要隔离(alignas(64) 防伪共享)。SIMD 对齐是向量化硬门槛。用 alignas 加对齐、pahole 看空洞、perf 的 split_loads 抓未对齐热点。**
