跳转至

缓存优化与数据布局

难度:⭐⭐⭐⭐ | 建议用时:2 周 | 前置要求:C++语言核心/Eigen基础与SLAM数学预备 Eigen 基础,并行编程框架 并行框架,实时约束与高性能数据传递 实时约束,内存分配策略与pmr 内存分配策略


前置自测

答不出两题以上,建议先复习数组连续存储、结构体布局、并行归约、内存分配和 CPU 缓存基本概念。 缓存优化不是“加一个关键字让程序更快”。 它要求你从 CPU 实际搬运数据的方式出发,重新审视点云、地图、残差和统计量的布局。

  1. CPU 访问一个 float 时,为什么常常会把相邻几十字节也加载进缓存?
  2. AoS 和 SoA 分别是什么?它们各自适合哪些访问模式?
  3. 为什么链表遍历通常比数组遍历慢?
  4. false sharing 为什么会让多个线程写不同变量也变慢?
  5. alignas(64) 解决的是什么问题?它会带来什么代价?
  6. 热数据和冷数据为什么应该分离?
  7. Morton 编码为什么能改善体素地图遍历的空间局部性?
  8. LRU cache 中 list + unordered_map 为什么能做到 O(1) 更新?
  9. perf stat 里的 cache miss 指标应该怎样解释?
  10. 数据布局优化和算法复杂度优化哪个更优先?

本章目标

学完本章,你将能够:

  • 解释 L1/L2/L3 cache、cache line、空间局部性、时间局部性和内存带宽的基本关系。
  • 从访问模式判断点云结构应该使用 AoS、SoA 还是 AoSoA。
  • 识别结构体 padding、对齐和字段顺序对内存占用的影响。
  • 将高频访问字段和低频访问字段分离,降低 cache 污染。
  • 解释 false sharing,并用 padding、线程本地结构和合并阶段消除它。
  • 用 Morton 编码组织体素 key,理解空间填充曲线对局部性的价值和边界。
  • 设计固定容量 LRU 地图分区缓存,控制大地图内存占用。
  • 使用简单 benchmark 和 perf 思路验证布局优化,而不是只看理论。
  • 完成 Mini SLAM Cache-Aware Point Store:支持 AoS/SoA 转换、热冷分离、线程本地统计和 Morton 体素排序。

知识树

缓存优化与数据布局
├── 缓存基础(36.1)
│   ├── L1/L2/L3 层级与延迟
│   ├── cache line 64 字节的物理原因
│   ├── 空间局部性与时间局部性
│   └── 关联度与冲突 miss
├── 数据布局三选一(36.2 - 36.4)
│   ├── AoS:按对象,适合完整点处理
│   ├── SoA:按字段,适合批量单字段和 SIMD
│   └── AoSoA:折中,块内 SoA、块间 AoS
├── 优化手段(36.5 - 36.9)
│   ├── 热冷数据分离
│   ├── false sharing 与 padding
│   ├── 结构体对齐与字段顺序
│   ├── Morton 编码(空间填充曲线)
│   └── LRU 分区缓存
├── 高级主题(36.12 - 36.15)
│   ├── Roofline 模型与算术强度
│   ├── 预取与顺序/随机访问
│   ├── SIMD 向量化与布局
│   └── NUMA / 嵌入式平台差异
└── 测量与验证(36.10)
    └── perf stat / sizeof / offsetof / benchmark

本章在课程中的位置:内存分配策略与pmr 解决”内存从哪里分配、什么时候释放”。 本章解决“数据放在内存里以后,CPU 怎样访问它”。 如果分配策略很干净,但数据布局让 CPU 每次都等待主存,系统仍然会慢。 缓存优化是从算法到硬件之间的桥。


36.1 Cache line:CPU 搬运的不是单个变量 ⭐⭐

工程问题:内存访问经常比计算更贵

现代 CPU 做一次浮点加法很快。 但从主存取数据慢得多。 CPU 依靠多级缓存隐藏主存延迟。

CPU 缓存层级的硬件原理

要理解缓存优化,必须先理解 CPU 缓存为什么存在、为什么分成多级、以及每一级的物理特性如何决定了它的行为。

为什么需要缓存? 现代 CPU 核心的时钟频率通常在 3-5 GHz,每个时钟周期可以发起 1-2 次访存请求。但 DDR4/DDR5 主存的随机访问延迟约 50-100 纳秒(200-400 个时钟周期)。如果 CPU 每次需要数据都等待主存,大部分时间都在空转。缓存的本质是**用更小但更快的存储器作为主存的数据副本**,利用程序访问的局部性原理(temporal locality + spatial locality),让大多数访问能在几个时钟周期内完成。

为什么分成 L1/L2/L3 三级? 这是速度、容量和成本之间的物理权衡。SRAM(静态随机存取存储器,用于缓存)的速度随容量增大而下降——更大的 SRAM 阵列意味着更长的导线、更多的解码逻辑、更大的访问延迟。与其建造一个"中等大小、中等速度"的缓存,不如分成多级:

层级 典型容量 典型延迟 关联度 每核独享/共享 物理原因
L1 数据缓存 32-48 KB 4-5 个时钟周期(~1ns) 8-12 路组相联 每核独享 紧贴核心,导线最短
L1 指令缓存 32-48 KB 4-5 个时钟周期 8 路组相联 每核独享 数据和指令分开避免争抢
L2 缓存 256 KB - 2 MB 10-15 个时钟周期(~3-5ns) 8-16 路组相联 通常每核独享 更大 SRAM 阵列,较近
L3 缓存 4 - 64 MB 30-50 个时钟周期(~10-20ns) 16-20 路组相联 所有核心共享 最大 SRAM,充当多核间的共享缓冲
主存(DRAM) 8 - 128 GB 150-300 个时钟周期(~50-100ns) - 全系统共享 DRAM 密度高但速度慢

为什么 L1 这么小? 因为 L1 必须在 1 个时钟周期内开始返回数据(实际上通常是 4-5 个周期完成,但流水线设计允许每周期发起一次新访问)。要达到这个速度,SRAM 阵列必须非常小(导线短、解码快),而且要紧贴在 CPU 核心旁边。32KB 大约是当前工艺下能保持这个延迟的上限。

关联度是什么? 关联度决定了"一个内存地址可以被放在缓存的哪些位置"。8 路组相联意味着每个内存块有 8 个候选位置。如果关联度太低(比如直接映射,只有 1 个候选位置),不同地址映射到同一位置的概率很高,会频繁冲突驱逐(conflict miss)。如果关联度太高(全相联,任何位置都可以),硬件需要并行比较所有标签,功耗和面积都不可接受。8-16 路是实践中的平衡点——冲突概率足够低,同时硬件代价可接受。

为什么 L3 是共享的? 因为 L3 的主要作用之一是在多核之间共享数据。当核心 A 写入一个缓存行,核心 B 需要读取同一个地址时,L3 充当了中间缓冲——B 可以直接从 L3 获取数据,而不需要等待主存。这也是为什么 L3 的大小对多线程程序如此重要:如果工作集超过 L3 容量,多核之间的数据共享就必须通过主存完成,性能会急剧下降。

cache line 的 64 字节为什么是这个大小? 这个数值也是多个约束的权衡结果: - 太小(如 16 字节):空间预取效率低,每次加载只带来少量有用数据。主存总线协议的事务开销(地址、命令、确认)占比太高。 - 太大(如 256 字节):缓存中每条行占用更多空间,能容纳的不同地址更少。而且 false sharing 的概率增大——两个不相关的变量更容易落在同一行中。 - 64 字节:在 DDR 突发传输长度(通常 64 字节一次 burst)、缓存容量利用率和 false sharing 概率之间取得了平衡。这不是物理常数,ARM 某些处理器使用 128 字节 cache line,未来随着内存技术演进可能变化。

教学重点是数量级:访问主存比访问 L1 cache 慢得多。 因此,高性能代码的关键常常是让数据尽量连续、尽量复用、尽量少随机跳转。

反面失败:把性能问题只归因于算法复杂度

两个循环复杂度都是 O(n):

for (std::size_t i = 0; i < n; ++i) {
    sum += values[i];
}
for (Node* p = head; p != nullptr; p = p->next) {
    sum += p->value;
}

复杂度同为 O(n)。 但数组遍历通常更快。 因为数组连续,CPU 可以预取后续 cache line。 链表节点分散在堆上,每次 next 都可能跳到新的内存位置。

抽象不变量:空间局部性和时间局部性

缓存友好的访问通常满足:

  1. 空间局部性:访问一个地址后,很快访问附近地址。
  2. 时间局部性:访问一个数据后,很快再次访问它。

点云顺序遍历有空间局部性。 反复使用同一个小矩阵有时间局部性。 随机哈希表查找通常局部性差。

规则推导:cache line 放大了“无用字段”的成本

假设 cache line 是 64 字节。 一个点类型:

struct PointXYZIRT {
    float x;
    float y;
    float z;
    float intensity;
    std::uint16_t ring;
    double timestamp;
};

由于对齐和 padding,这个结构体可能是 32 字节。 一个 cache line 只能装 2 个点。 如果某个算法只访问 x,y,z,那么 intensity, ring, timestamp 也被一起加载。 这就是热路径上的无用数据。

代码验证:查看结构体大小和字段偏移

#include <cstddef>
#include <cstdint>
#include <iostream>

struct PointXYZIRT {
    float x;
    float y;
    float z;
    float intensity;
    std::uint16_t ring;
    double timestamp;
};

int main() {
    std::cout << sizeof(PointXYZIRT) << "\n";
    std::cout << offsetof(PointXYZIRT, x) << "\n";
    std::cout << offsetof(PointXYZIRT, timestamp) << "\n";
}

字段顺序会影响 padding。 不要只看源码里字段数量。 要看 sizeofoffsetof

本质洞察:CPU 缓存的工作单位不是 C++ 变量,而是 cache line(通常 64 字节)。这意味着**访问一个 4 字节 float 时,CPU 实际上加载了 64 字节**。如果这 64 字节中大部分是你不需要的数据,缓存的有效利用率就很低。缓存优化的本质是让每次加载的 64 字节中,尽可能多的数据是后续计算真正需要的。

CPU 缓存和超市购物车是类似的心智模型:你不会每次只从货架拿一件商品再回收银台结账,而是一次性把货架上附近的几件商品都放进购物车。如果货架按"你的购物清单"排列(空间局部性好),一次扫过去就能拿到所有需要的东西。如果你需要的商品散落在超市各处(随机访问),你就要跑很多趟,购物车大部分时间都是半空的。

如果 cache line 不存在——CPU 每次只加载精确请求的字节——会怎样?那么主存带宽会被海量的小请求淹没。cache line 是一个批量预取机制:既然访问了地址 X,X 附近的数据很可能马上也会被访问(空间局部性假设),所以一次性加载一整块。这个假设在数组顺序遍历时几乎完美成立,在链表随机跳转时几乎完全失败。

⚠️ 编程陷阱:用链表存储高频遍历的点云数据 错误做法:用 std::list<Point> 存储点云,因为"需要频繁在中间插入删除"。 现象:遍历速度比 std::vector 慢 5-20 倍,即使元素数量相同。 根本原因:链表节点分散在堆的各处,遍历时每次 next 指针跳转都可能触发 cache miss。而数组元素连续存储,CPU 预取器可以提前加载后续 cache line。 正确做法:大部分场景使用 std::vector。即使需要中间插入删除,先评估"遍历频率 vs 插入频率"——如果遍历远多于插入,vector + 标记删除通常比 list 快。

💡 概念误区:认为 O(n) 数组遍历和 O(n) 链表遍历性能相同 新手想法:"两者都是 O(n),复杂度一样,性能应该差不多。" 实际上:复杂度分析忽略了常数因子,而 cache miss 的代价正是这个常数因子中最大的部分。一次 L1 cache hit 约 1ns,一次 cache miss 访问主存约 50-100ns。链表遍历中大部分访问是 cache miss,数组遍历中大部分是 cache hit。

练习

  1. [实验题] 分别用 std::vector<int>std::list<int> 存储 100 万个整数,测量顺序遍历求和的耗时差异。用 perf stat 观察两者的 cache-miss 比例。
  2. [估算题] 一个 PointXYZIRT 结构体是 32 字节。一个 64 字节 cache line 能装几个点?如果算法只访问 x/y/z(12 字节),cache line 有效利用率是多少?
  3. [设计题] 一个体素地图使用 std::unordered_map<VoxelKey, VoxelData>。分析其遍历时的 cache 行为,并提出一种更 cache 友好的替代方案。

36.2 AoS:对象视角的数据布局 ⭐⭐

工程问题:PCL 默认点云布局符合“按点处理”的直觉

AoS 是 Array of Structures:

struct PointXYZI {
    float x;
    float y;
    float z;
    float intensity;
};

std::vector<PointXYZI> points;

内存布局:

x0 y0 z0 i0 | x1 y1 z1 i1 | x2 y2 z2 i2 | ...

如果算法每次处理一个完整点,AoS 很自然。 例如点云坐标变换:

PointXYZI transformPoint(const PointXYZI& p, const Transform& T);

一次需要 x,y,z,可能还保留 intensity。 AoS 让一个点的字段靠在一起。

反面失败:算法只访问一个字段,却加载整个点

范围过滤:

for (const PointXYZI& p : points) {
    if (p.x > min_x && p.x < max_x) {
        count++;
    }
}

这里只用 x。 但 AoS 会把 y,z,intensity 也带入 cache。 如果点类型更大,例如包含 ring、timestamp、normal、label,浪费更明显。

抽象不变量:AoS 适合字段共同访问

AoS 适合:

场景 原因
单点坐标变换 同时访问 x/y/z
ICP 残差计算 点坐标一起使用
写入 PCL API 生态兼容
小点类型 无用字段少
面向对象接口 每个点是完整对象

不适合:

场景 原因
单字段统计 cache line 利用率低
GPU coalesced 访问 同字段连续更好
大量冷字段 热路径被污染
SIMD 单字段向量化 数据不连续

工程边界:PCL 兼容性也是实际约束

很多机器人项目使用 PCL。 PCL API 通常期望 AoS 风格点类型。 即使 SoA 在某个内核更快,也可能需要在边界转换。

工程上常见做法:

  1. 外部接口保留 PCL/AoS。
  2. 内部热点转换为 SoA 或 AoSoA。
  3. 热点结束后转换回 AoS。
  4. 只有当转换成本小于收益时才这么做。

不要为了追求布局纯粹,破坏整个生态兼容性。

代码验证:AoS 距离过滤

#include <vector>

struct PointXYZI {
    float x = 0.0f;
    float y = 0.0f;
    float z = 0.0f;
    float intensity = 0.0f;
};

std::vector<PointXYZI> filterRangeAoS(const std::vector<PointXYZI>& points,
                                      float max_range2) {
    std::vector<PointXYZI> out;
    out.reserve(points.size());

    for (const PointXYZI& p : points) {
        const float r2 = p.x * p.x + p.y * p.y + p.z * p.z;
        if (r2 < max_range2) {
            out.push_back(p);
        }
    }
    return out;
}

这段代码清楚、兼容性好。 是否需要 SoA,要靠热点测量决定。

⚠️ 编程陷阱:大点类型中冷字段污染热路径缓存 错误做法struct PointFull { float x,y,z; float intensity; uint16_t ring; double timestamp; float nx,ny,nz; uint32_t label; char semantic[16]; };——所有字段塞进一个结构。 现象:距离过滤只访问 x/y/z,但每加载一个点就带入 60+ 字节的冷数据,cache 有效利用率不到 20%。 根本原因:AoS 布局中,一个点的所有字段紧邻存储。即使只访问 12 字节坐标,整个结构都被加载进 cache line。 正确做法:对热点路径做热冷分离,或在内部热点转换为 SoA。

练习

  1. [分析题] 一个点类型包含 x/y/z/intensity/ring/timestamp/normal/label,共约 56 字节。一个 64 字节 cache line 只能装 1 个点。如果算法只用 x/y/z,有效利用率是多少?拆分成热/冷两个结构后呢?
  2. [设计题] PCL 生态要求 AoS 格式。如果内部热点算法使用 SoA 更快,设计一个边界转换方案:在哪里做 AoS->SoA 转换,在哪里做 SoA->AoS 转换,如何判断转换是否值得?

36.3 SoA:字段视角的数据布局 ⭐⭐

工程问题:批量处理同一字段时,SoA 更贴近硬件

SoA 是 Structure of Arrays:

struct PointCloudSoA {
    std::vector<float> x;
    std::vector<float> y;
    std::vector<float> z;
    std::vector<float> intensity;
};

内存布局:

x0 x1 x2 x3 ...
y0 y1 y2 y3 ...
z0 z1 z2 z3 ...
i0 i1 i2 i3 ...

如果算法只访问 x,cache line 里几乎全是有用数据。 如果算法同时访问 x/y/z,CPU 会在三个连续数组中顺序走,也仍然可预取。

反面失败:SoA 破坏单点接口

很多代码习惯写:

PointXYZI p = cloud[i];

SoA 中没有天然的 PointXYZI&。 你可以构造一个临时点,但不能返回真正引用。 如果强行包装代理对象,接口复杂度会上升。

这就是 SoA 的代价:硬件友好,接口不如 AoS 直观。

抽象不变量:SoA 适合批处理,不适合对象身份

SoA 适合:

  1. 距离过滤。
  2. 投影。
  3. 强度归一化。
  4. GPU 上传。
  5. SIMD 处理。
  6. 大量点的同构计算。

不适合:

  1. 每个点有复杂方法。
  2. 外部库要求 PointT*
  3. 点对象需要稳定地址。
  4. 频繁随机访问完整点对象。

代码验证:SoA 容器

#include <cassert>
#include <vector>

struct PointCloudSoA {
    std::vector<float> x;
    std::vector<float> y;
    std::vector<float> z;
    std::vector<float> intensity;

    std::size_t size() const {
        return x.size();
    }

    void reserve(std::size_t n) {
        x.reserve(n);
        y.reserve(n);
        z.reserve(n);
        intensity.reserve(n);
    }

    void pushBack(float px, float py, float pz, float pi) {
        x.push_back(px);
        y.push_back(py);
        z.push_back(pz);
        intensity.push_back(pi);
    }

    void checkInvariant() const {
        assert(x.size() == y.size());
        assert(x.size() == z.size());
        assert(x.size() == intensity.size());
    }
};

SoA 的一个新不变量是:所有数组长度必须一致。 这需要封装,不要让外部随意操作四个 vector。

AoS vs SoA 的量化分析:从访问步幅和 SIMD 两个角度

上面从直觉角度解释了 AoS 和 SoA 的区别。但要在工程中做出正确选择,需要更定量的分析。下面从两个互补的角度——内存访问步幅(stride)SIMD 向量化——精确地分析两种布局的性能差异。

角度一:访问步幅与缓存效率

考虑一个点云中有 \(N\) 个点,每个点有 4 个 float 字段(x, y, z, intensity)。分析一个只访问 x 坐标的循环(比如 x[i] > threshold 的距离过滤)。

在 AoS 布局中,第 \(i\) 个点的 x 坐标位于地址 base + i * sizeof(PointXYZI) = base + i * 16。连续两个点的 x 坐标之间间隔 16 字节。一个 64 字节的 cache line 包含 4 个完整点,但只有 4 个 x 值(16 字节)是后续计算需要的,其余 48 字节(y, z, intensity)被加载进缓存但未使用。缓存有效利用率 = 4/16 = 25%

在 SoA 布局中,第 \(i\) 个 x 坐标位于地址 x_base + i * sizeof(float) = x_base + i * 4。连续两个 x 值之间间隔 4 字节。一个 64 字节的 cache line 包含 16 个连续的 x 值,全部是后续计算需要的。缓存有效利用率 = 100%

对于只访问 x 的循环,SoA 比 AoS 需要的 cache line 加载次数少了 4 倍。如果 \(N = 100000\),AoS 需要加载 \(100000 \times 16 / 64 = 25000\) 个 cache line,SoA 只需要 \(100000 \times 4 / 64 = 6250\) 个——减少了 75% 的内存带宽需求。

但如果循环同时访问所有 4 个字段呢?AoS 中每个 cache line 的 64 字节全部有效(4 个点 x 16 字节),有效利用率 100%。SoA 中需要同时遍历 4 个数组,每个数组各自加载 cache line——总共加载的数据量和 AoS 相同,但 cache line 总数增加(4 倍的数组,每个数组的元素更密集但数组更多),而且 CPU 预取器需要追踪 4 个独立的内存流,这在某些处理器上可能超出预取器的能力(典型 CPU 有 4-8 个独立的流预取器)。

步幅分析的结论:如果算法只访问部分字段,SoA 显著优于 AoS;如果算法访问所有字段,两者接近或 AoS 略优。

角度二:SIMD 向量化

SIMD(单指令多数据)可以用一条指令同时处理多个数据元素。AVX2 的 __m256 寄存器宽 256 位(32 字节),可以容纳 8 个 float。考虑一个简单的缩放操作 x[i] *= scale

在 SoA 布局中,8 个连续的 x 值在内存中连续排列(步幅 4 字节)。一条 _mm256_load_ps 指令可以一次加载 8 个 x 值到 SIMD 寄存器,一条 _mm256_mul_ps 完成 8 次乘法,一条 _mm256_store_ps 写回 8 个结果。3 条指令完成 8 个元素的处理。

在 AoS 布局中,8 个 x 值分布在 8 个点结构体中,步幅 16 字节(每个 PointXYZI 是 16 字节)。无法用一条 load 指令加载 8 个 x 值——它们不连续。需要用 _mm256_i32gather_ps(gather 指令)从非连续地址收集数据。Gather 指令在大多数 CPU 上比连续 load 慢 2-4 倍(因为需要发起多次独立的内存请求)。或者需要先加载完整的结构体再提取 x 分量——同样浪费带宽。

SIMD 分析的结论:SoA 天然适合 SIMD 向量化,因为同类数据连续排列。AoS 中的 SIMD 需要 gather/scatter 操作,开销显著增大。这也是 GPU 编程中 SoA 几乎是强制要求的原因(CUDA基础与Thrust)——GPU 的 coalesced memory access 本质上就是对连续地址的 SIMD 加载。

综合决策框架

访问模式 推荐布局 原因
只访问 1-2 个字段 SoA 缓存利用率高 4 倍以上
访问所有字段但需要 SIMD SoA gather 指令代价太高
访问所有字段,逐点处理 AoS 单点数据局部性最好
外部库要求特定布局 AoS(PCL)或 SoA(Thrust) 接口兼容性
混合:部分阶段逐字段,部分逐点 AoSoA 或转换 折中方案(36.4 节)

SoA 距离过滤

PointCloudSoA filterRangeSoA(const PointCloudSoA& cloud,
                             float max_range2) {
    cloud.checkInvariant();

    PointCloudSoA out;
    out.reserve(cloud.size());

    for (std::size_t i = 0; i < cloud.size(); ++i) {
        const float px = cloud.x[i];
        const float py = cloud.y[i];
        const float pz = cloud.z[i];
        const float r2 = px * px + py * py + pz * pz;
        if (r2 < max_range2) {
            out.pushBack(px, py, pz, cloud.intensity[i]);
        }
    }
    return out;
}

这段代码看起来比 AoS 啰嗦。 但对大规模批处理,它可能更接近 CPU 和 GPU 的理想访问模式。

AoS 和 SoA 的关系,类似于"按员工归档"和"按属性归档"的区别。AoS 是一个文件夹包含一个员工的所有信息(姓名、工资、部门、照片);SoA 是所有员工的姓名放一个文件夹,所有工资放另一个文件夹。如果 HR 需要查看某个员工的全部信息,AoS 更方便(打开一个文件夹);如果财务需要统计全公司工资总额,SoA 更方便(只遍历工资文件夹,不碰其他数据)。

AoS vs SoA 的本质:从内存访问步幅和 SIMD 利用率两个角度

上面从缓存有效利用率的角度比较了 AoS 和 SoA。但还有一个同样重要的维度:SIMD 向量化能力。理解这个维度能更完整地解释为什么数值计算密集的场景越来越倾向 SoA。

内存访问步幅(stride):当一个循环遍历数组中的某个字段时,"步幅"是两次连续访问之间的内存距离。

在 AoS 布局中,如果 PointXYZI 是 16 字节,访问所有点的 x 字段的步幅就是 16 字节(每隔 16 字节读一个 float)。在 SoA 布局中,所有 x 连续存储,步幅是 4 字节(每隔 4 字节读一个 float,即紧密排列)。

步幅对性能的影响远比直觉中大。cache line 是 64 字节,如果步幅是 16 字节,一个 cache line 只包含 4 个有用的 x 值(64/16=4),有效利用率 25%。如果步幅是 4 字节,一个 cache line 包含 16 个有用的 x 值(64/4=16),有效利用率 100%。步幅每增大 N 倍,缓存有效利用率就降低 N 倍——这在大规模点云处理中直接转化为 N 倍的内存带宽浪费。

SIMD lane 利用率:SIMD 指令(如 AVX-256)一次处理 8 个 float。编译器向量化一个循环时,需要从内存中加载 8 个连续的 float 到一个 256 位寄存器中。

在 SoA 布局中,x[0], x[1], ..., x[7] 在内存中连续排列。编译器可以用一条 vmovups 指令把 8 个 x 值加载到一个 YMM 寄存器,然后用一条 vmulps 做 8 次乘法——完美的向量化。

在 AoS 布局中,points[0].x, points[1].x, ..., points[7].x 分散在 8 个 16 字节间隔的位置。编译器有两个选择:(1) 用 gather 指令(如 vgatherdps)从分散的地址收集 8 个 x 值到一个寄存器——gather 指令比连续加载慢 2-4 倍;(2) 放弃向量化,用标量指令逐个处理——完全失去 SIMD 加速。两种情况下 AoS 的 SIMD 效率都显著低于 SoA。

**这两个角度合在一起**解释了一个常见的性能差异:对于"遍历所有点只处理 x 字段"这类操作,SoA 比 AoS 快 2-4 倍(来自缓存有效利用率的提升)甚至 4-8 倍(如果 SIMD 向量化也因此生效)。但对于"每次处理一个完整点的所有字段"这类操作,AoS 让一个点的所有字段紧邻存储,一次缓存行加载就能获取全部所需数据,反而比 SoA(需要从 4 个不连续数组各取一个元素)更友好。

本质洞察:AoS vs SoA 的选择不是"哪个更好"的问题,而是**"热路径访问哪些字段"的问题**。如果热路径总是同时访问一个对象的所有字段,AoS 更好;如果热路径只访问所有对象的同一个字段,SoA 更好。大多数真实系统同时存在两种访问模式,需要在接口边界做转换或选择折中方案(AoSoA)。

💡 概念误区:认为 SoA 在所有场景都比 AoS 快 新手想法:"SoA 对缓存更友好,所以应该全部用 SoA。" 实际上:SoA 只在批量单字段访问时更友好。如果每次操作需要完整点(x/y/z/intensity),SoA 反而需要从 4 个不连续的数组中各取一个元素,可能比 AoS 的单次连续加载更慢。此外,SoA 破坏了"点"作为对象的封装,接口和调试都更困难。

🧠 思维陷阱:把数据布局优化当作第一步 新手想法:"代码慢了,先改成 SoA 看看。" 实际上:数据布局优化应该在算法优化和数据量优化之后。如果算法复杂度是 O(n^2) 可以降到 O(n log n),改布局的收益微乎其微。如果可以通过降采样把数据量减半,收益也远大于布局调整。布局优化是"最后一公里"优化,不是第一步。 正确思维:算法 > 数据量 > 数据布局 > 向量化 > 多线程。

练习

  1. [实现题]PointCloudSoA 添加一个 invariant 检查方法,确保四个数组长度始终一致。讨论何时应该调用这个检查——每次 pushBack?每帧开始?只在 debug 模式?
  2. [性能题] 用同一组 100 万点的数据,分别用 AoS 和 SoA 实现距离过滤,测量端到端耗时(包含数据准备和结果提取)。AoS->SoA 转换的成本是否被后续计算的加速所覆盖?

36.4 AoSoA:在接口和向量化之间折中 ⭐⭐⭐

工程问题:AoS 和 SoA 都不是万能答案

有时我们希望:

  1. 小块内部同字段连续,方便 SIMD。
  2. 块作为整体有对象边界,方便调度和缓存。
  3. 不想把全局四个大数组传得到处都是。

AoSoA 是 Array of Structures of Arrays。 它把点分成小块,每块内部用 SoA。

Block 0:
    x[0..15], y[0..15], z[0..15]
Block 1:
    x[0..15], y[0..15], z[0..15]
...

抽象不变量:块大小服务于 SIMD 和 cache

块大小可以选择 8、16、32 等。 它受这些因素影响:

  1. SIMD 宽度。
  2. cache line 大小。
  3. 点字段数量。
  4. 算法每次处理多少点。
  5. 是否需要线程分块。

AoSoA 常见于高性能数值库和粒子系统。 SLAM 中可以用于点云热点内核。

代码验证:固定块 SoA

#include <array>
#include <cstddef>
#include <vector>

template <std::size_t BlockSize>
struct PointBlock {
    std::array<float, BlockSize> x{};
    std::array<float, BlockSize> y{};
    std::array<float, BlockSize> z{};
    std::array<float, BlockSize> intensity{};
    std::size_t size = 0;
};

template <std::size_t BlockSize>
class PointCloudAoSoA {
public:
    void pushBack(float px, float py, float pz, float pi) {
        if (blocks_.empty() || blocks_.back().size == BlockSize) {
            blocks_.push_back(PointBlock<BlockSize>{});
        }
        auto& block = blocks_.back();
        const std::size_t i = block.size++;
        block.x[i] = px;
        block.y[i] = py;
        block.z[i] = pz;
        block.intensity[i] = pi;
    }

    const std::vector<PointBlock<BlockSize>>& blocks() const {
        return blocks_;
    }

private:
    std::vector<PointBlock<BlockSize>> blocks_;
};

这个结构牺牲了简单随机访问。 换来的是块级处理时更好的局部性。

工程边界:AoSoA 适合库内部,不一定适合公开接口

公开接口应尽量稳定、简单、兼容。 AoSoA 可以作为内部优化布局。 例如:

PCL/AoS 输入
    -> 转换成 AoSoA 工作集
    -> 执行热点计算
    -> 输出结果或索引

如果热点计算收益足够,转换成本可以接受。 如果数据规模小,转换可能得不偿失。

⚠️ 编程陷阱:AoSoA 块大小选择不当导致 SIMD 效率低下 错误做法:块大小设为 3 或 7 等非 SIMD 宽度的倍数。 现象:编译器无法有效向量化块内循环,生成的汇编中有大量标量操作。 根本原因:SIMD 指令通常处理 4(SSE/128-bit)或 8(AVX/256-bit)个 float。块大小应该是 SIMD 宽度的倍数,让编译器生成对齐的向量加载和计算指令。 正确做法:块大小选择 8、16 或 32 等 2 的幂次,同时兼顾 cache line 和 SIMD 宽度。

练习

  1. [设计题] 为一个点云处理内核选择 AoSoA 块大小。已知:SIMD 宽度 8 float,cache line 64 字节,每点 3 个 float 字段。计算不同块大小(8, 16, 32)下每个块的字节数和 cache line 覆盖数。
  2. [分析题] AoSoA 相比 SoA 的额外复杂度在哪里?在什么条件下 AoSoA 的收益能覆盖其实现和维护成本?

36.5 热冷数据分离 ⭐⭐

工程问题:点类型越来越大,但热点只用一小部分

实际点类型可能包含:

  1. x/y/z。
  2. intensity。
  3. ring。
  4. timestamp。
  5. normal。
  6. color。
  7. semantic label。
  8. covariance。

但很多热点只需要 x/y/z。 把所有字段塞进一个结构,会让热路径加载大量冷字段。

反面失败:关键帧结构包含所有内容

struct KeyFrame {
    Pose pose;
    Image image;
    PointCloud cloud;
    DescriptorMatrix descriptors;
    DebugInfo debug;
    VisualizationCache visualization;
    std::string name;
};

Tracking 可能只需要 pose。 但如果关键帧对象巨大且分散,遍历关键帧位姿时会带来缓存污染。

抽象不变量:热路径只携带热字段

可以拆成:

struct KeyFrameHot {
    Pose pose;
    std::uint64_t id = 0;
    std::uint64_t stamp_ns = 0;
};

struct KeyFrameCold {
    Image image;
    PointCloud cloud;
    DescriptorMatrix descriptors;
    DebugInfo debug;
    VisualizationCache visualization;
    std::string name;
};

热路径遍历 KeyFrameHot。 需要可视化或回环时再访问 KeyFrameCold

规则推导:热冷分离提高 cache line 有效率

假设原始 KeyFrame 是 256 字节。 一个 64 字节 cache line 装不下一个对象。 遍历 1000 个关键帧位姿,可能读取大量无用冷数据。

拆分后 KeyFrameHot 可能只有 64 字节以内。 遍历时每个 cache line 都接近有用。

工程边界:拆分会增加间接访问

热冷分离的代价:

  1. 结构更多。
  2. 需要维护索引关系。
  3. 访问完整对象时多一次查找。
  4. 代码复杂度上升。

只有热点路径明确时才值得拆。 不要为了理论上的 cache 友好,把所有结构都拆碎。

代码验证:热冷关键帧存储

#include <cstdint>
#include <vector>

struct KeyFrameHot {
    Pose pose;
    std::uint64_t id = 0;
    std::uint64_t stamp_ns = 0;
    std::uint32_t cold_index = 0;
};

struct KeyFrameCold {
    PointCloud cloud;
    DescriptorMatrix descriptors;
    DebugInfo debug;
};

class KeyFrameStore {
public:
    std::uint32_t add(Pose pose,
                      std::uint64_t stamp_ns,
                      KeyFrameCold cold) {
        const std::uint32_t cold_index =
            static_cast<std::uint32_t>(cold_.size());
        cold_.push_back(std::move(cold));

        const std::uint32_t id = static_cast<std::uint32_t>(hot_.size());
        hot_.push_back(KeyFrameHot{pose, id, stamp_ns, cold_index});
        return id;
    }

    const std::vector<KeyFrameHot>& hot() const {
        return hot_;
    }

    const KeyFrameCold& cold(const KeyFrameHot& keyframe) const {
        return cold_[keyframe.cold_index];
    }

private:
    std::vector<KeyFrameHot> hot_;
    std::vector<KeyFrameCold> cold_;
};

这不是唯一设计。 它表达的是:高频遍历和低频大数据访问分开。

热冷分离就像图书馆的开架区和闭架区:常借的书放在容易拿到的地方(热数据在紧凑结构中),不常借的书放在仓库里需要时再取(冷数据通过索引间接访问)。如果把所有书都放在同一个书架上,常借的书被不常借的书挤占了空间,找书的人要翻过大量无关书籍。

如果不做热冷分离、把 KeyFrame 的所有字段放在一个结构中会怎样?假设 KeyFrame 包含位姿(64 字节)、图像(数 MB)、点云(数 MB)、描述子和调试信息。遍历 1000 个关键帧的位姿需要访问 1000 个 KeyFrame 对象。每个对象数 MB,分散在堆的各处。遍历这 1000 个位姿可能产生上千次 cache miss,而实际有用的数据(位姿)只有 64KB。拆分后遍历 KeyFrameHot 数组只需顺序读取约 64KB 连续数据,几乎全部 cache hit。

⚠️ 编程陷阱:过度拆分导致间接访问成本超过收益 错误做法:把一个 32 字节的小结构也拆成热/冷两部分。 现象:热路径反而变慢,因为每次访问冷字段都需要通过索引间接跳转。 根本原因:对于小对象(< cache line),所有字段本来就在同一个 cache line 里,拆分反而引入间接访问的开销。 正确做法:只对"大对象中包含少量高频字段"的情况做热冷分离。如果整个对象本身就很小,保持完整结构更好。

练习

  1. [分析题] 一个 KeyFrame 包含 Pose(64B)、Image(300KB)、PointCloud(1.6MB)、Descriptors(50KB)和 Debug(200B)。如果 Tracking 只需遍历位姿,估算热冷分离前后遍历 500 个关键帧的 cache miss 数量级差异。
  2. [设计题] 热冷分离后,热数据和冷数据通过什么关联?索引?ID?指针?分析各方案在插入、删除、序列化场景下的优缺点。

36.6 false sharing:逻辑独立不等于硬件独立 ⭐⭐

工程问题:多线程写不同变量也可能互相拖慢

CPU cache line 是一致性协议的单位。 如果两个核心写同一个 cache line 上的不同变量,这个 cache line 会在核心之间来回转移所有权。 这就是 false sharing。

并行编程框架 讲并行归约时提到线程本地统计。 如果线程本地统计量紧挨在 vector 中,就可能发生 false sharing。

MESI 协议:false sharing 背后的硬件机制

要真正理解 false sharing 为什么会造成性能灾难,需要理解 CPU 缓存一致性协议——最常见的是 MESI 协议(以及它的变体 MOESI、MESIF)。MESI 协议定义了每个 cache line 在每个核心的缓存中可以处于四种状态之一:

状态 含义 其他核心的副本 与主存一致性
**M**odified 本核心已修改,数据是脏的 其他核心没有副本 不一致(未写回)
**E**xclusive 本核心独占,数据是干净的 其他核心没有副本 一致
**S**hared 多个核心持有只读副本 其他核心也有副本 一致
**I**nvalid 本核心的副本无效 不关心 -

正常情况下的 MESI 工作流程(没有 false sharing):

假设核心 A 要写入变量 x,这个 cache line 当前在核心 A 的缓存中处于 Exclusive 状态。核心 A 可以直接写入并转为 Modified 状态——不需要任何总线通信,延迟约 1-4ns。这是最快的情况。

false sharing 情况下的 MESI 工作流程

假设 counter_acounter_b 位于同一个 64 字节 cache line 中。核心 A 反复写 counter_a,核心 B 反复写 counter_b。每次写入都会触发以下协议交互:

  1. 核心 A 写 counter_a:核心 A 需要 cache line 处于 Modified 或 Exclusive 状态。如果核心 B 也持有该行的副本(Shared 或 Modified),核心 A 必须先向总线发送"请求独占所有权"(Request For Ownership, RFO)消息。
  2. 总线广播 RFO:互连总线把这个请求传递给核心 B。核心 B 收到后,必须把自己的副本标记为 Invalid。如果核心 B 的副本是 Modified 状态(B 刚写过 counter_b),B 还必须先把数据写回到共享缓存(L3)或主存,然后再 Invalid。
  3. 核心 A 获取所有权:核心 A 获得 cache line 的独占所有权,转为 Modified 状态,写入 counter_a。整个过程耗时约 40-100ns(取决于互连拓扑和 L3 延迟)。
  4. 核心 B 写 counter_b:核心 B 的 cache line 已经是 Invalid。要写入 counter_b,B 必须重新获取这个 cache line——又是一次 RFO,核心 A 必须放弃所有权...

如此循环往复。每次写入都触发一次跨核心的缓存一致性事务,延迟从 1-4ns 暴增到 40-100ns——慢了 10-50 倍。而且两个核心写的是完全不同的变量!程序员的逻辑视角是"两个独立操作",硬件的物理视角是"争抢同一个缓存行"。

为什么叫"false" sharing? 因为从应用程序语义看,两个线程没有共享任何数据——它们操作的是不同的变量。但从硬件缓存一致性协议看,它们"共享"了同一个 cache line——这个共享是"虚假的",是数据布局的意外产物,不是程序逻辑的需要。

量化 false sharing 的影响:假设一个简单的计数器递增循环运行 1 亿次。无 false sharing 时(每个计数器在独立 cache line),每次递增约 1ns,总耗时约 0.1 秒。有 false sharing 时(两个计数器在同一 cache line),每次递增约 50ns(包含 RFO 延迟),总耗时约 5 秒——慢了 50 倍。而且随着核心数增加,竞争更严重:4 个核心写同一 cache line 的不同位置,每次写入可能需要等待 3 个其他核心的 Invalidate 确认,延迟进一步增大。

反面失败:线程本地 cost 数组

std::vector<double> local_cost(num_threads, 0.0);

#pragma omp parallel
{
    const int tid = omp_get_thread_num();
    for (int i = 0; i < work_per_thread; ++i) {
        local_cost[tid] += compute(i);
    }
}

每个线程写不同 local_cost[tid]。 逻辑没有数据竞争。 但多个 double 可能位于同一个 cache line。 高频写入会互相干扰。

抽象不变量:高频并发写字段按 cache line 隔离

修复:

#include <new>

struct alignas(std::hardware_destructive_interference_size) PaddedDouble {
    double value = 0.0;
};

然后:

std::vector<PaddedDouble> local_cost(num_threads);

工程边界:padding 只用于高频并发写

padding 会增加内存占用。 如果只是低频写,或者所有线程只读,就没必要。 缓存优化要看访问模式。 不是看到共享结构就加 alignas(64)

代码验证:线程本地统计

#include <cstdint>
#include <new>
#include <omp.h>
#include <vector>

struct alignas(std::hardware_destructive_interference_size) ThreadStats {
    double cost = 0.0;
    std::uint64_t count = 0;
};

Stats evaluateParallel(const std::vector<Residual>& residuals) {
    const int threads = omp_get_max_threads();
    std::vector<ThreadStats> local(static_cast<std::size_t>(threads));

    #pragma omp parallel
    {
        const int tid = omp_get_thread_num();
        ThreadStats& stats = local[static_cast<std::size_t>(tid)];

        #pragma omp for schedule(static)
        for (int i = 0; i < static_cast<int>(residuals.size()); ++i) {
            const double r = residuals[static_cast<std::size_t>(i)].value;
            stats.cost += r * r;
            ++stats.count;
        }
    }

    Stats total;
    for (const ThreadStats& stats : local) {
        total.cost += stats.cost;
        total.count += stats.count;
    }
    return total;
}

这个结构让每个线程高频写自己的 cache line。 合并阶段低频串行执行。

本质洞察:false sharing 是不是"共享数据",而是**"共享 cache line"**。两个线程写的是完全不同的变量,但因为这两个变量恰好位于同一个 64 字节的 cache line 上,硬件的缓存一致性协议就会让这个 cache line 在两个核心之间反复弹跳。程序员的视角是"独立写入",硬件的视角是"争抢同一块缓存"。

false sharing 就像合租公寓中的共用冰箱:你和室友各自只动自己那一层的食物(逻辑独立),但每次开冰箱门都会影响所有层的温度(物理共享)。如果你和室友频繁开关冰箱,冰箱温度永远稳定不下来。解决方案是每人用独立的小冰箱(padding 隔离 cache line)。

如果没有 false sharing 问题——每个线程的数据天然在不同 cache line 上——会怎样?那么多线程写操作的性能会接近理想的线性扩展。这正是为什么"每个线程使用独立的大对象"通常比"所有线程共享一个数组的不同下标"更快的原因:大对象自然跨越多个 cache line,不需要额外 padding。

⚠️ 编程陷阱:std::vector<double> local_cost(num_threads) 引发 false sharing 错误做法:用连续 vector 存储每个线程的局部累加器,让线程 tidlocal_cost[tid]现象:8 线程并行比 4 线程还慢。perf 显示大量 cache miss,但没有数据竞争。 根本原因:8 个 double(64 字节)恰好占一个 cache line。所有线程写同一个 cache line 的不同位置,触发 cache line bouncing。 正确做法:使用 alignas(64) 的 padded 结构体,或使用 reduction 避免线程本地数组。

练习

  1. [实验题] 构造一个 false sharing 示例:4 个线程分别递增 counters[0..3]uint64_t,无 padding),测量每秒递增次数。然后改用 alignas(64) 的 padded 版本,对比性能差异。
  2. [分析题] std::hardware_destructive_interference_size 在什么情况下可能不是 64?ARM 平台上这个值通常是多少?如果编译器不提供这个常量,应该用什么保守默认值?
  3. [设计题] 一个 8 线程并行体素构建中,每个线程维护一个 ThreadStats(cost + count,共 16 字节)。这些 stats 存在一个 std::vector<ThreadStats> 中。是否会发生 false sharing?如何修复?

36.7 结构体字段顺序、对齐与 padding ⭐

工程问题:字段顺序会改变对象大小

看两个结构:

struct BadLayout {
    char valid;
    double timestamp;
    int id;
};

struct BetterLayout {
    double timestamp;
    int id;
    char valid;
};

字段相同。 sizeof 可能不同。 原因是对齐要求。 double 通常需要 8 字节对齐。 把小字段夹在大字段前面可能引入 padding。

抽象不变量:按对齐从大到小排列通常更紧凑

一般经验:

  1. 大对齐字段放前面。
  2. 小字段聚在一起。
  3. bool 标志可以打包,但要注意可读性。
  4. 热字段优先布局,不只追求最小 sizeof。

最小对象不一定最快。 如果为了压缩字段导致热字段跨 cache line,反而可能变慢。

代码验证:静态检查大小

#include <cstddef>
#include <type_traits>

struct PointMeta {
    double timestamp;
    float intensity;
    std::uint32_t id;
    std::uint16_t ring;
    std::uint8_t flags;
};

static_assert(std::is_standard_layout_v<PointMeta>);
static_assert(alignof(PointMeta) >= alignof(double));

可以在关键结构上加 static_assert。 这能避免后续修改字段时悄悄改变布局。

工程边界:不要随意使用 #pragma pack

强制紧凑打包可能降低对齐。 某些架构上非对齐访问更慢,甚至不合法。 对于磁盘文件格式或网络协议,可以使用显式序列化。 不要为了省几个字节在热路径对象上滥用 packed 结构。

⚠️ 编程陷阱:字段顺序导致意外的 padding 膨胀 错误做法struct S { char a; double b; int c; };——小字段夹在大字段前面。 现象sizeof(S) 可能是 24 字节而不是预期的 13 字节。 根本原因double 需要 8 字节对齐。char a 后面会被插入 7 字节 padding。改为 struct S { double b; int c; char a; }; 后,sizeof(S) 可能降为 16 字节。 正确做法:按对齐要求从大到小排列字段。在关键结构上加 static_assert(sizeof(S) == expected_size) 防止后续修改引入意外膨胀。

练习

  1. [实验题] 写两个字段相同但顺序不同的结构体,用 sizeofalignofoffsetof 分析 padding 差异。哪种排列方式更紧凑?
  2. [分析题] #pragma pack(1) 能消除所有 padding,但为什么不应该在热路径结构上使用?从对齐和性能角度解释。

36.8 Morton 编码:把空间邻近变成整数邻近 ⭐⭐⭐

工程问题:体素哈希表随机访问破坏局部性

体素地图常用三维整数坐标:

(ix, iy, iz)

如果直接放进哈希表,空间相邻的体素在内存中可能完全分散。 遍历局部区域时,CPU 很难预取。

Morton 编码把三维坐标位交错成一维整数。 空间邻近的体素在编码后往往也接近。

抽象不变量:位交错保留部分空间局部性

简化二维例子:

x bits: x2 x1 x0
y bits: y2 y1 y0
Morton: y2 x2 y1 x1 y0 x0

三维时把 x/y/z 的位交错。 这样排序后的 Morton key 会形成 Z-order 曲线。

代码验证:三维 Morton 编码示意

#include <cstdint>

std::uint64_t splitBy3(std::uint32_t value) {
    std::uint64_t x = value & 0x1fffff;
    x = (x | (x << 32)) & 0x1f00000000ffff;
    x = (x | (x << 16)) & 0x1f0000ff0000ff;
    x = (x | (x << 8)) & 0x100f00f00f00f00f;
    x = (x | (x << 4)) & 0x10c30c30c30c30c3;
    x = (x | (x << 2)) & 0x1249249249249249;
    return x;
}

std::uint64_t morton3D(std::uint32_t x,
                       std::uint32_t y,
                       std::uint32_t z) {
    return splitBy3(x) | (splitBy3(y) << 1) | (splitBy3(z) << 2);
}

这个版本假设坐标已经转换为非负整数,并且范围适合 21 bit。 真实体素地图要处理负坐标偏移和尺度。

工程边界:Morton 不是最近邻数据结构

Morton 排序改善局部遍历。 它不自动替代 KD-tree、iVox、哈希邻域查询或八叉树。 它适合:

  1. 体素块排序。
  2. 局部块加载。
  3. 邻近块批量遍历。
  4. 缓存友好的空间索引。

如果需要精确最近邻,仍要结合算法结构。

Morton 编码就像图书馆的分类号系统:按主题、子主题、编号层层编码,使得同类书籍在书架上物理相邻。空间填充曲线把三维空间的邻近关系"折叠"成一维排序,让空间相邻的体素在排序后也尽量相邻。

⚠️ 编程陷阱:Morton 编码未处理负坐标导致 key 错误 错误做法:直接把 float 坐标转为 uint32_t 后做位交错,没有处理负值。 现象:负坐标区域的点产生非常大的 key,排序后和正坐标区域混在一起。 根本原因:负数转为无符号整数时会变成很大的正数。空间相邻的两个体素(一个在 x=-1,一个在 x=0)可能产生差异巨大的 key。 正确做法:先加偏移量把所有坐标平移到非负范围,或使用有符号 Morton 编码方案。

练习

  1. [代码题] 为 Morton 编码写边界测试:坐标 (0,0,0)、(-1,0,0)、(0,-1,0)、大坐标 (10000,10000,10000)。确保相邻坐标的 key 值也相邻。
  2. [分析题] Morton 编码(Z-order 曲线)和 Hilbert 曲线哪个能更好地保持空间局部性?Hilbert 曲线的实现复杂度如何?在什么场景下 Morton 的简单性更重要?

36.9 LRU 地图分区缓存 ⭐⭐⭐

工程问题:大地图不能全部常驻热内存

长期运行机器人会积累大地图。 如果所有点云块、关键帧图像和描述子都常驻内存:

  1. RSS 持续增长。
  2. cache 命中率下降。
  3. 后端维护成本上升。
  4. 加载和保存变慢。

地图分区缓存的思想是:

只保留当前区域附近的 K 个分区
远离区域卸载或转为冷存储
再次访问时重新加载

抽象不变量:LRU 维护“最近使用”的顺序

LRU = Least Recently Used。 每次访问一个 key:

  1. 如果 key 存在,把它移动到队首。
  2. 如果 key 不存在,加载并插入队首。
  3. 如果容量超限,淘汰队尾。

要做到 O(1),通常使用:

std::list 保存顺序
std::unordered_map 从 key 找 list iterator

代码验证:最小 LRU cache

#include <list>
#include <optional>
#include <unordered_map>

template <class Key, class Value>
class LruCache {
public:
    explicit LruCache(std::size_t capacity)
        : capacity_(capacity) {}

    Value* get(const Key& key) {
        auto it = index_.find(key);
        if (it == index_.end()) {
            return nullptr;
        }
        items_.splice(items_.begin(), items_, it->second);
        return &it->second->second;
    }

    void put(Key key, Value value) {
        auto it = index_.find(key);
        if (it != index_.end()) {
            it->second->second = std::move(value);
            items_.splice(items_.begin(), items_, it->second);
            return;
        }

        items_.emplace_front(std::move(key), std::move(value));
        index_[items_.front().first] = items_.begin();

        if (items_.size() > capacity_) {
            const Key& old_key = items_.back().first;
            index_.erase(old_key);
            items_.pop_back();
        }
    }

private:
    using Item = std::pair<Key, Value>;

    std::size_t capacity_ = 0;
    std::list<Item> items_;
    std::unordered_map<Key, typename std::list<Item>::iterator> index_;
};

这个结构适合解释 LRU。 实时路径中,std::listunordered_map 会分配。 实际使用时应放在非实时地图管理线程,或结合对象池和固定容量结构。

工程边界:缓存策略属于系统行为

地图分区缓存不仅是数据结构问题。 它还需要定义:

  1. 分区大小。
  2. 预取半径。
  3. 淘汰是否允许丢失未保存修改。
  4. 加载失败如何降级。
  5. 后台 I/O 是否影响前端。
  6. 当前定位失败时是否扩大加载范围。

LRU 是局部策略。 机器人系统还需要任务调度和安全策略。

⚠️ 编程陷阱:LRU 缓存容量太小导致频繁抖动 错误做法:LRU 容量设为 3 个分区,但机器人经常在 4 个分区的交界处移动。 现象:几乎每帧都触发 eviction 和 reload,缓存命中率接近 0%。 根本原因:工作集大于缓存容量时,LRU 退化为"每次都 miss"。这和 CPU cache 的 thrashing 是同一个问题。 正确做法:监控缓存命中率。如果命中率持续低于 80%,考虑增加容量或优化分区粒度。

练习

  1. [代码题]LruCache 增加命中率统计:记录 get 的 hit/miss 次数,提供 hitRate() 方法。
  2. [设计题] 一个机器人在室内走廊中来回巡逻。走廊被分成 10 个地图分区,每次巡逻覆盖 6 个分区。LRU 容量应该设为多少?如果改用"双向预取"(根据运动方向预加载前方分区),容量是否可以降低?

36.10 如何测量缓存优化 ⭐⭐

工程问题:cache 优化必须用硬件计数器验证

看代码猜 cache 命中率很容易错。 至少要测:

  1. 总耗时。
  2. cache miss。
  3. 分支 miss。
  4. 指令数。
  5. CPU cycles。
  6. 多线程扩展效率。

Linux 下可用 perf stat 做初步观察:

perf stat -e cycles,instructions,cache-references,cache-misses ./bench_layout

不同平台事件名可能不同。 具体以当前系统支持为准。

反面失败:只看单次耗时

单次耗时受很多因素影响:

  1. CPU 频率。
  2. 缓存冷热。
  3. 后台进程。
  4. 内存分配。
  5. 输入数据分布。

布局 benchmark 应固定输入、多次运行、报告分位数。

🧠 思维陷阱:认为 perf 数据可以直接跨平台比较 新手想法:"桌面 CPU 上 cache miss 率 5%,Jetson 上 cache miss 率 8%,说明 Jetson 的缓存更差。" 实际上:不同 CPU 的 cache 大小、结构(inclusive/exclusive)、预取策略和硬件计数器定义都不同。桌面 CPU 可能有 32MB L3,Jetson 可能只有 2MB。相同的代码在不同硬件上 cache miss 率不可直接比较——需要在各自平台上分别做优化前后对比,而不是跨平台比较绝对值。 正确思维:cache 优化的目标是"在目标平台上降低 cache miss",而不是"达到某个绝对 miss 率"。

抽象不变量:同一算法,不同布局,输入必须一致

比较 AoS 和 SoA 时:

  1. 点数一致。
  2. 点值一致。
  3. 编译选项一致。
  4. 输出检查一致。
  5. 预热策略一致。
  6. 包含转换成本时要说明。

如果 SoA 版本不包含 AoS->SoA 转换成本,而实际系统每帧都要转换,就会高估收益。

代码验证:布局 benchmark 框架

#include <chrono>
#include <iostream>
#include <vector>

template <class Func>
double timeMs(Func func) {
    using Clock = std::chrono::steady_clock;
    const auto t0 = Clock::now();
    func();
    const auto t1 = Clock::now();
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

void benchmarkLayouts(const std::vector<PointXYZI>& aos,
                      const PointCloudSoA& soa) {
    constexpr float kRange2 = 100.0f;

    const double t_aos = timeMs([&] {
        auto out = filterRangeAoS(aos, kRange2);
        doNotOptimize(out.size());
    });

    const double t_soa = timeMs([&] {
        auto out = filterRangeSoA(soa, kRange2);
        doNotOptimize(out.size());
    });

    std::cout << "AoS ms: " << t_aos << "\n";
    std::cout << "SoA ms: " << t_soa << "\n";
}

doNotOptimize 可以用 benchmark 框架提供的工具实现。 教学代码里可以把结果打印或校验,避免编译器把计算优化掉。


36.11 Mini SLAM Cache-Aware Point Store

工程问题:把布局策略做成可切换实验

本章项目为 Mini SLAM 增加 cache-aware 点云存储。 目标:

  1. 支持 AoS 输入输出。
  2. 内部支持 SoA 热路径。
  3. 支持热冷关键帧拆分。
  4. 支持线程本地 padded 统计。
  5. 支持 Morton 体素 key 排序。
  6. 提供布局 benchmark。

模块结构

mini_slam_cache/
  include/
    point_aos.hpp
    point_soa.hpp
    point_aosoa.hpp
    hot_cold_keyframe_store.hpp
    padded_stats.hpp
    morton.hpp
    layout_benchmark.hpp
  tests/
    test_soa_invariant.cpp
    test_morton_order.cpp
    test_lru_cache.cpp
    test_layout_equivalence.cpp

AoS 与 SoA 转换

PointCloudSoA toSoA(const std::vector<PointXYZI>& points) {
    PointCloudSoA out;
    out.reserve(points.size());
    for (const PointXYZI& p : points) {
        out.pushBack(p.x, p.y, p.z, p.intensity);
    }
    return out;
}

std::vector<PointXYZI> toAoS(const PointCloudSoA& cloud) {
    cloud.checkInvariant();
    std::vector<PointXYZI> out;
    out.reserve(cloud.size());
    for (std::size_t i = 0; i < cloud.size(); ++i) {
        out.push_back(PointXYZI{
            cloud.x[i],
            cloud.y[i],
            cloud.z[i],
            cloud.intensity[i]});
    }
    return out;
}

转换本身有成本。 项目验收时要分别测:

  1. 纯 SoA 内核耗时。
  2. AoS->SoA 转换耗时。
  3. SoA->AoS 转换耗时。
  4. 端到端耗时。

Morton 体素排序

#include <algorithm>
#include <vector>

struct VoxelItem {
    std::uint64_t key = 0;
    std::uint32_t point_index = 0;
};

std::vector<VoxelItem> buildSortedVoxelItems(const PointCloudSoA& cloud,
                                             float leaf) {
    std::vector<VoxelItem> items;
    items.reserve(cloud.size());

    for (std::size_t i = 0; i < cloud.size(); ++i) {
        const auto coord = voxelCoord(cloud.x[i], cloud.y[i], cloud.z[i], leaf);
        const std::uint64_t key = morton3D(coord.x, coord.y, coord.z);
        items.push_back(VoxelItem{key, static_cast<std::uint32_t>(i)});
    }

    std::sort(items.begin(), items.end(), [](const VoxelItem& a,
                                             const VoxelItem& b) {
        return a.key < b.key;
    });

    return items;
}

排序后的相邻元素更可能来自空间邻近体素。 这有利于后续分组、块处理和局部地图访问。

验收标准

项目 标准
AoS/SoA 转换 输出点值一致
SoA invariant 四个数组长度始终一致
false sharing 修复 padded 统计版本可运行
Morton 编码 相同输入 key 稳定
LRU cache get/put/evict 行为正确
benchmark 报告端到端和内核耗时
工程说明 明确转换成本是否计入

36.12 内存带宽上限与 Roofline 直觉 ⭐⭐⭐

工程问题:线程越多,内存带宽不一定越多

点云处理中有很多简单循环:

for (const PointXYZI& p : points) {
    out.push_back(transform(p));
}

如果每个点只做少量计算,却要读写大量内存,程序可能受内存带宽限制。 这时加线程会很快遇到上限:

1 线程:20 GB/s
2 线程:38 GB/s
4 线程:62 GB/s
8 线程:68 GB/s

从 4 到 8 线程几乎没有收益。 不是调度器坏了,而是内存带宽已经接近饱和。

抽象不变量:算术强度决定优化方向

算术强度:

\[ I = \frac{\text{floating point operations}}{\text{bytes moved}} \]

如果 \(I\) 很低,说明每搬很多字节只做少量计算。 这类程序更容易受内存带宽限制。 如果 \(I\) 很高,说明数据加载后会做很多计算。 这类程序更容易受计算单元限制。

点云坐标变换通常算术强度不高。 复杂描述子计算、图像卷积、深度网络推理算术强度更高。

反面失败:继续给带宽受限循环加线程

如果一个循环已经把内存带宽打满,再把线程数从 8 加到 16,可能出现:

  1. 耗时不降。
  2. cache miss 增加。
  3. 其他实时线程被抢占。
  4. p99 延迟变差。

这时更有效的优化方向是减少搬运字节:

  1. 热冷分离。
  2. SoA 或 AoSoA。
  3. 避免中间数组。
  4. 原地处理。
  5. 降低数据精度或压缩冷字段。

代码验证:估算每点搬运字节

#include <cstddef>
#include <iostream>

void estimateBandwidth(std::size_t points,
                       double elapsed_ms) {
    const std::size_t bytes_read = points * sizeof(PointXYZI);
    const std::size_t bytes_write = points * sizeof(PointXYZI);
    const double gb = static_cast<double>(bytes_read + bytes_write) / 1e9;
    const double seconds = elapsed_ms / 1000.0;
    std::cout << "approx bandwidth GB/s: " << gb / seconds << "\n";
}

这是粗略估算。 真实内存流量还包括 cache write allocate、预取、临时对象和未命中回填。 但它能帮助判断优化方向。


36.13 预取、顺序访问与随机访问 ⭐⭐

工程问题:CPU 可以提前猜顺序访问,猜不透随机跳转

硬件预取器擅长发现顺序访问:

for (std::size_t i = 0; i < points.size(); ++i) {
    sum += points[i].x;
}

如果访问模式是随机索引:

for (std::size_t i = 0; i < indices.size(); ++i) {
    sum += points[indices[i]].x;
}

CPU 很难提前知道下一次访问哪里。 cache miss 更容易暴露出来。

抽象不变量:把随机访问变成批量顺序访问

常见做法:

  1. 对索引排序,让访问尽量顺序。
  2. 按体素或块分组处理。
  3. 把随机访问结果收集成连续工作集。
  4. 对热点数据做紧凑拷贝。
  5. 使用 Morton key 提高空间邻近访问概率。

这些做法都有成本。 只有当随机访问是热点时才值得。

反面失败:KD-tree 节点散落在堆上

KD-tree 搜索需要沿树跳转。 如果每个节点单独 new,节点可能分散在内存各处。 一次查询会产生许多不可预测内存访问。

更 cache-friendly 的结构通常把节点放在数组中:

nodes[0], nodes[1], nodes[2], ...

节点用整数索引指向子节点。 这能减少指针追逐,提高预取机会。

代码验证:索引而不是指针

struct KdNode {
    PointXYZI point;
    std::int32_t left = -1;
    std::int32_t right = -1;
    std::uint8_t axis = 0;
};

class CompactKdTree {
public:
    const KdNode& node(std::int32_t index) const {
        return nodes_[static_cast<std::size_t>(index)];
    }

private:
    std::vector<KdNode> nodes_;
};

索引比指针更容易序列化、压缩和重排。 它也让节点存储可以连续化。


36.14 SIMD、对齐与数据布局的关系 ⭐⭐⭐

工程问题:编译器需要看见可向量化模式

SIMD 一次处理多个数据。 编译器更容易向量化这种循环:

for (std::size_t i = 0; i < n; ++i) {
    x[i] = x[i] * scale + bias;
}

因为数据连续、循环边界清楚、没有复杂别名。

这种循环更难向量化:

for (Point* p : point_ptrs) {
    p->x = p->x * scale + bias;
}

因为指针数组可能指向任意位置。 编译器无法轻易证明访问不重叠,也无法形成连续加载。

抽象不变量:向量化喜欢连续、对齐、无别名

有利条件:

  1. 连续数组。
  2. 固定 stride。
  3. 简单循环。
  4. 分支少。
  5. 编译器能证明输入输出不重叠。
  6. 数据对齐较好。

SoA 往往比 AoS 更适合单字段 SIMD。 AoSoA 可以让小块内部更适合 SIMD。

工程边界:手写 SIMD 不是第一选择

优化顺序通常是:

  1. 改善算法。
  2. 改善数据布局。
  3. 让编译器自动向量化。
  4. 使用 Eigen/标准库/成熟库。
  5. 最后才考虑手写 intrinsic。

手写 SIMD 可读性差、平台相关强。 教学阶段更重要的是写出编译器能优化的布局。

代码验证:SoA 缩放

void scaleX(PointCloudSoA& cloud, float scale, float bias) {
    for (std::size_t i = 0; i < cloud.size(); ++i) {
        cloud.x[i] = cloud.x[i] * scale + bias;
    }
}

这个循环结构简单。 编译器更容易生成向量化代码。 可以用编译器的 vectorization report 检查是否成功。 不同编译器选项不同,具体以当前工具链文档为准。


36.15 NUMA、嵌入式共享内存与平台差异 ⭐⭐

工程问题:同一份代码在不同硬件上表现不同

桌面工作站、服务器和嵌入式平台内存结构不同。 服务器可能有 NUMA。 嵌入式 SoC 可能 CPU/GPU 共享物理内存。 桌面独立 GPU 通常通过 PCIe 传输。

这会影响:

  1. 数据布局选择。
  2. CPU-GPU 数据传输成本。
  3. 内存带宽上限。
  4. 线程绑定策略。
  5. cache miss 的代价。

NUMA 直觉

NUMA = Non-Uniform Memory Access。 在多路 CPU 系统中,某个核心访问本地内存更快,访问另一个 CPU socket 附近的内存更慢。

如果线程在 socket 0,数据却主要分配在 socket 1,访问会变慢。 这类问题在桌面小机器上不明显,在服务器上可能很明显。

嵌入式平台边界

Jetson 等 SoC 平台常见 CPU/GPU 共享物理内存。 这会降低 CPU-GPU 拷贝成本,但不代表没有同步和带宽成本。 CPU 和 GPU 仍然共享内存带宽。 如果 GPU 大量访问内存,CPU 实时线程也可能受到影响。

工程结论

数据布局优化不能脱离平台。 同一个 SoA 改造:

  1. 在桌面 CPU 上可能提升 SIMD。
  2. 在 GPU 上可能提升 coalesced access。
  3. 在小嵌入式 CPU 上可能受限于缓存容量。
  4. 在服务器上可能受 NUMA 影响。

因此最终结论必须来自目标平台测量。


🔧 故障排查手册

现象 常见原因 检查方法 修复方向
SoA 版本结果错 数组长度不一致 invariant 检查 封装 push/resize
SoA 更慢 转换成本超过收益 分开测转换和内核 只在热点使用
并行效率低 false sharing padded 对比 线程本地结构隔离
内存占用变大 padding 或热冷拆分过度 打印 sizeof/RSS 只优化热点
Morton 查询不正确 负坐标处理错误 构造边界测试 坐标偏移或有符号编码
LRU 抖动频繁 容量太小 记录命中率 增大容量或预取
perf 指标波动大 输入和频率不稳定 多次运行 固定输入和环境
结构体突然变大 新字段引入 padding static_assert sizeof 调整字段顺序
GPU 上传慢 AoS 转 SoA 每帧重复 分阶段计时 尽量保持 SoA
热冷分离后代码乱 所有权关系不清 画数据关系图 用 ID 连接冷热数据

布局选择自检清单

选择布局前先回答:

  1. 热点循环每次访问哪些字段?
  2. 访问是顺序、随机,还是按索引间接访问?
  3. 数据是否需要频繁传给 PCL、Eigen、CUDA 或 ROS2 消息?
  4. 转换成本是否计入端到端耗时?
  5. 热字段和冷字段是否被混在同一个 cache line?
  6. 多线程是否高频写相邻统计量?
  7. 当前瓶颈是计算、内存带宽、cache miss 还是锁竞争?
  8. 目标平台是桌面 CPU、服务器、还是嵌入式 SoC?

如果这些问题没有答案,先测量,不要先改布局。


36.16 练习与跨章综合题

  1. sizeofalignofoffsetof 分析一个点结构体,调整字段顺序后解释 padding 如何变化。
  2. 把一个 AoS 点云转换成 SoA,分别测量转换成本和后续坐标变换成本,判断端到端是否值得。
  3. 构造一个 false sharing 示例:多个线程写相邻计数器,再用 padding 版本对比耗时。
  4. 为 Morton key 编码写边界测试:包含负坐标、大坐标、体素边界点和重复点。
  5. 跨章综合题:结合 内存分配策略与pmr 的 frame arena 和本章 SoA 布局,为 ICP 前端设计一个 FrameScratch。要求说明哪些数据按点连续存储、哪些统计量线程本地存储、哪些结果需要转换回 PCL/ROS2 消息。

这些练习要求把“缓存友好”具体化为可观测结构:大小、对齐、访问顺序、转换成本和端到端收益。


36.17 本章小结

缓存优化的核心是:CPU 按 cache line 搬数据,而不是按 C++ 变量的语义搬数据。 如果热路径只需要 12 字节坐标,却每次加载 64 字节里大量冷字段,性能会被内存系统限制。

本章主线:

  1. 数组通常比链式结构更缓存友好。
  2. AoS 适合按对象处理,SoA 适合批量同字段处理。
  3. AoSoA 是向量化和接口之间的折中。
  4. 热冷数据分离能减少高频路径 cache 污染。
  5. false sharing 是多线程中非常隐蔽的性能问题。
  6. 字段顺序、对齐和 padding 会改变真实内存占用。
  7. Morton 编码能把空间局部性部分转化为整数排序局部性。
  8. LRU 分区缓存能控制大地图常驻内存。
  9. 布局优化必须测端到端收益,不能只看内核。

内存分配策略与pmr 控制分配行为。 缓存优化与数据布局 控制访问行为。 这两章合起来,是理解高性能 SLAM C++ 代码的硬件基础。 接下来的 CUDA 章节会把“数据并行”和“数据布局”推进到 GPU。


延伸阅读

  1. Chandler Carruth, "Efficiency with Algorithms, Performance with Data Structures", CppCon 2014 ⭐⭐——data-oriented design 的经典演讲,用 benchmark 量化证明缓存局部性对性能的决定性影响。
  2. Ulrich Drepper, "What Every Programmer Should Know About Memory", 2007 ⭐⭐——完整覆盖 CPU 缓存层级、NUMA、TLB 和预取机制,虽然写于 2007 年但核心原理至今适用。
  3. Andrei Alexandrescu, "Speed Is Found in the Minds of People", CppCon 2019 ⭐⭐⭐——从微基准到系统级优化的思维框架,强调测量驱动而非直觉驱动。
  4. Morton / Z-order curve 和 Hilbert curve 空间索引资料 ⭐⭐⭐——体素地图遍历的空间局部性优化,理解空间填充曲线如何把多维邻近性映射到一维排序。
  5. ARM Cortex-A / Apple M 系列 / RISC-V 处理器缓存文档 ⭐⭐——嵌入式和移动平台的 cache line 可能是 128 字节(如 Apple M 系列),需要针对性调整 padding 和布局策略。
  6. Eigen、PCL、nanoflann、small_gicp 源码中的数据布局选择 ⭐⭐——真实机器人项目如何在 AoS/SoA/接口兼容性之间做权衡。
  7. Linux perf statperf record、cachegrind 等性能分析工具文档。

36.18 预取指令与软件预取策略 ⭐⭐⭐

工程问题:cache miss 的延迟无法被乱序执行完全隐藏

现代 CPU 的乱序执行引擎可以在等待 cache miss 返回数据的同时执行其他指令,但这种隐藏能力有限。当访问模式不规则(如 kd-tree 遍历、哈希表查找)时,CPU 的硬件预取器无法预测下一个访问地址,cache miss 暴露为流水线停顿。

软件预取(software prefetch)允许程序员显式告诉 CPU "接下来我要访问这个地址",让数据提前加载到缓存中。在 C++ 中通过编译器内建函数使用:

// GCC/Clang 预取内建函数
// 参数 1:地址
// 参数 2:0=读预取,1=写预取
// 参数 3:时间局部性(0=无局部性 → NTA, 3=强局部性 → L1)
__builtin_prefetch(&points[i + prefetch_distance], 0, 3);

在 SLAM 中的实际应用

kd-tree 最近邻搜索是预取受益的典型场景。在遍历树节点时,下一个要访问的子节点地址在当前步已知,但硬件预取器无法预测树形结构的跳转。

// kd-tree 搜索中的预取示意
void searchKdTree(const KdNode* node, const Point& query) {
    if (!node) return;

    // 计算当前节点的分割判断
    double diff = query[node->split_dim] - node->split_value;

    // 预取即将访问的子节点(在实际访问之前)
    const KdNode* near_child = diff < 0 ? node->left : node->right;
    const KdNode* far_child = diff < 0 ? node->right : node->left;
    __builtin_prefetch(near_child, 0, 3);

    // 处理当前节点的距离计算
    double dist = computeDistance(node->point, query);
    updateBest(dist, node->point);

    // 递归——此时 near_child 的数据可能已经在 L1 缓存中
    searchKdTree(near_child, query);
    if (needSearchFar(diff, best_dist)) {
        searchKdTree(far_child, query);
    }
}

预取的工程边界

预取并非总能提升性能。以下条件决定了预取是否有效:

条件 有利 不利
预取距离 可调到恰好覆盖 cache miss 延迟 太近(数据未到)或太远(被驱逐)
数据访问密度 每次 miss 后有大量计算 密集顺序访问(硬件预取已够用)
地址可预测性 下 N 步的地址在当前步已知 地址依赖于上一步的计算结果
带宽利用率 带宽有余量 带宽已饱和(预取反而争抢带宽)

对于 SLAM 中的体素地图查询、kd-tree 搜索和哈希表查找,预取通常能获得 10-30% 的加速。但这个数字高度依赖平台和数据规模——小数据集可能完全在缓存中,预取无效;大数据集的改善更明显。

⚠️ 概念误区:认为预取能解决所有 cache miss 问题

预取只能隐藏延迟,不能增加带宽。如果系统瓶颈是内存带宽(每秒能传输的数据总量),预取无法帮助——数据搬得再早,总带宽还是那么多。判断瓶颈是延迟还是带宽,可以用 perf statLLC-load-missesmem-loads-retired 指标配合 Roofline 模型分析。


36.19 缓存一致性协议对多线程的影响 ⭐⭐⭐

从 false sharing 到更深层的理解

本章 36.10 节讨论了 false sharing——多线程写同一 cache line 中不同位置导致性能下降。要真正理解 false sharing 为什么代价高昂,需要理解 CPU 缓存一致性协议的工作方式。

主流多核 CPU 使用 MESI 协议(或其变体 MOESI/MESIF)维护缓存一致性。每条 cache line 在每个核心的 L1 缓存中有四种状态:

状态 含义 读操作 写操作
**M**odified 本核独占修改,其他核无效 直接读 直接写
**E**xclusive 本核独占且与内存一致 直接读 转为 M,直接写
**S**hared 多核共享,与内存一致 直接读 需要发送 Invalidate 消息
**I**nvalid 本核缓存无效 需要从其他核/内存加载 需要从其他核/内存加载

当线程 A 写一条处于 Shared 状态的 cache line 时,它必须向所有持有该 line 的核心发送 Invalidate 消息,等待确认后才能写入。这个过程涉及核间通信(通常通过环形总线或 mesh 网络),延迟为几十到几百纳秒。

false sharing 的代价来源正是这里:即使两个线程写的是同一 cache line 内不同的独立变量,MESI 协议仍然会在两个核心之间反复传递这条 cache line 的所有权(Shared → Invalid → Modified → Shared → ...)。这种"乒乓效应"可以将吞吐量降低 10-100 倍。

对 SLAM 多线程架构的指导

理解 MESI 协议后,可以做出更精准的多线程数据布局决策:

  1. 只读共享数据最友好:所有核心缓存同一 cache line 的 Shared 副本,没有 Invalidate 开销。因此全局配置参数、常量查找表等应声明为 const 并放在独立的 cache line 中。

  2. 写密集的统计量必须隔离:每帧累计的匹配点数、误差和、迭代次数等计数器,如果由多线程分别更新,必须放在不同的 cache line 中(padding 到 64 字节对齐)。

  3. 生产者-消费者模式需要关注 cache line 边界:双缓冲、SPSC 队列等结构中,生产者写入的缓冲区和消费者读取的缓冲区应位于不同的 cache line。

// ❌ 计数器紧密排列——高频 false sharing
struct alignas(64) GlobalStats {
    std::atomic<int> matches_thread0;    // 与下一个变量在同一 cache line
    std::atomic<int> matches_thread1;    // 每次更新触发 Invalidate
    std::atomic<int> matches_thread2;
    std::atomic<int> matches_thread3;
};

// ✅ 每个计数器独占一条 cache line
struct PaddedCounter {
    alignas(64) std::atomic<int> value;
};
PaddedCounter matches[4];  // 各线程更新互不干扰

类比:cache line 的所有权传递就像一块只能由一个人持有的白板。 即使两个人在白板的不同角落写字,白板仍然需要在两人之间反复传递。 padding 相当于给每个人一块独立的白板——物理隔离消除了传递开销。


36.20 端到端优化案例:ICP 配准的缓存优化实战 ⭐⭐⭐

把本章所有概念串联起来,看一个完整的端到端优化过程:ICP(Iterative Closest Point)点云配准的缓存优化。

**基线实现**的数据布局:

// AoS 布局——每个点包含所有字段
struct Point {
    float x, y, z;           // 12 字节——最近邻搜索只需要这三个
    float intensity;          // 4 字节——配准不需要
    float normal_x, normal_y, normal_z;  // 12 字节——法线配准需要
    uint32_t ring;            // 4 字节——配准不需要
    double timestamp;         // 8 字节——配准不需要
};  // sizeof = 40 字节(含 padding 可能更大)

最近邻搜索阶段只需要 x, y, z(12 字节),但每次加载一个点会带入整个 40 字节结构。一条 64 字节 cache line 只能装 1.6 个点(实际是 1 个),而如果只存坐标,同一条 cache line 能装 5 个点。

优化后的热冷分离布局

struct PointCloudSplit {
    // 热数据:最近邻搜索阶段高频访问
    std::vector<float> x, y, z;           // SoA 坐标

    // 温数据:法线配准阶段访问
    std::vector<float> nx, ny, nz;        // SoA 法线

    // 冷数据:配准阶段不访问
    std::vector<float> intensity;
    std::vector<uint32_t> ring;
    std::vector<double> timestamp;
};

在十万点规模的配准测试中,这种布局改造通常带来以下量级的改善(具体数字因平台而异):

阶段 AoS cache miss rate SoA cache miss rate 原因
kd-tree 构建 ~15-25% ~5-10% 只加载坐标数据,cache 利用率提升
最近邻搜索 ~20-35% ~8-15% 同上
法线估计 ~10-20% ~5-12% 法线字段连续存储
残差计算 ~5-10% ~3-8% 坐标和法线分别连续访问

端到端加速取决于哪个阶段是瓶颈。如果最近邻搜索占总时间的 70%,且其中 50% 时间花在 cache miss 上,那么 cache miss 率从 25% 降到 10% 可以带来约 20% 的端到端加速。但这些数字必须在目标平台实测——不要凭直觉预估。

布局优化的度量驱动方法论

布局优化必须由度量驱动,不能凭直觉猜测。以下是完整的度量方法:

Step 1:确定瓶颈是计算还是内存

# 用 perf stat 收集硬件计数器
perf stat -e cache-misses,cache-references,instructions,cycles \
    ./my_slam --input test_data.bag

# 关键比值:
# instructions / cycles = IPC(Instructions Per Cycle)
#   IPC < 1.0 → 可能是内存瓶颈(CPU 在等数据)
#   IPC > 2.0 → 计算密集(CPU 很忙)
# cache-misses / cache-references = miss rate
#   > 10% → 缓存利用不佳,布局优化可能有效

Step 2:定位热点函数的 cache 行为

# 用 perf record + perf annotate 定位每条指令的 cache miss
perf record -e LLC-load-misses ./my_slam
perf annotate --source --symbol=nearestNeighborSearch

Step 3:布局改造后对比

改造前后使用完全相同的输入数据和硬件环境,只比较端到端时间和硬件计数器。不要用微基准(只测内核)的结果预估系统级收益——数据转换、内存分配和缓存预热的开销可能抵消内核层面的加速。

本质洞察:缓存优化不是"让数据更小"或"让结构更紧凑", 而是"让 CPU 每次从内存搬运的 64 字节中,有用数据的比例最大化"。 这个比例就是**有效带宽利用率**——它才是布局优化的真正目标函数。


36.21 跨平台缓存差异的工程处理 ⭐⭐

不同平台的缓存参数对比

布局优化的参数(cache line 大小、L1/L2/L3 容量、TLB 大小)因平台而异。同一份优化代码在不同硬件上可能表现不同。

平台 Cache line L1d L2 L3 典型用途
Intel Core i7 64 B 32-48 KB 256-512 KB 8-36 MB 开发工作站
AMD Ryzen 64 B 32 KB 512 KB 16-64 MB 开发工作站
Apple M 系列 128 B 128 KB 4 MB 12-36 MB 移动开发
ARM Cortex-A76 64 B 64 KB 256 KB 共享 2 MB 嵌入式(Jetson)
RISC-V (SiFive U74) 64 B 32 KB 2 MB 新兴嵌入式

Apple M 系列的 128 字节 cache line 意味着 false sharing 的 padding 需要从 64 字节增加到 128 字节。在跨平台代码中,padding 大小应通过编译期常量参数化:

// 平台感知的 cache line 大小
#if defined(__APPLE__) && defined(__ARM_ARCH)
    inline constexpr std::size_t kCacheLineSize = 128;
#else
    inline constexpr std::size_t kCacheLineSize = 64;
#endif

// C++17 提供了标准方式
#include <new>
inline constexpr std::size_t kCacheLineSize =
    std::hardware_destructive_interference_size;  // 可能为 64 或 128

struct alignas(kCacheLineSize) PaddedCounter {
    std::atomic<int> value;
};

std::hardware_destructive_interference_size(C++17)旨在提供编译期的 cache line 大小信息。但实践中部分编译器(如 GCC)因为交叉编译场景的不确定性而未实现这个常量。在 GCC 未实现的平台上需要手动定义。

面向最小公分母的优化策略

当代码需要在多个平台上运行时(如开发在 x86 工作站、部署在 ARM Jetson),优化策略应面向最小公分母:

  1. 结构体大小:按最大 cache line(128 字节)做 padding,在所有平台上都安全
  2. 数据对齐:使用 alignas 而非平台特定的对齐指令
  3. SIMD:使用 Eigen 的跨平台 SIMD 封装,而非手写 intrinsics
  4. 预取距离:不同平台的最优预取距离不同,用运行时参数配置而非硬编码

在部署前,必须在目标硬件上完整运行性能测试。开发机上的优化结果不能直接外推到部署硬件——L3 缓存大小、内存带宽和乱序执行窗口的差异可能导致完全不同的性能特征。

故障排查手册(补充条目)

现象 常见原因 检查方法 修复方向
优化后跨平台性能不一致 cache line 大小或 L2/L3 容量差异 在目标平台运行 getconf LEVEL1_DCACHE_LINESIZElscpu 用编译期常量参数化 cache line 大小和 padding
SoA 转换后接口兼容性丧失 PCL/ROS2 接口要求 AoS 布局 检查数据消费者的布局要求 在系统边界做一次转换,内部保持 SoA
预取指令加入后性能无变化或更差 数据已在缓存中(规模太小)或带宽已饱和 perf stat 检查 cache miss rate 和 IPC 确认 miss rate > 10% 才值得加预取;带宽饱和时预取无效
alignas 对齐后内存占用翻倍 过度对齐小对象浪费了大量 padding sizeofoffsetof 打印实际布局 只对热路径中高频访问的结构体做对齐,非热路径保持默认
perf stat 数据波动大 CPU 频率动态调节或后台进程干扰 固定 CPU 频率(cpupower frequency-set -g performance),关闭不必要的后台服务 多次运行取中位数,排除首次运行的预热效应

缓存优化在实时控制中的特殊考虑

在实时控制循环中(1 kHz WBC),缓存优化有一个额外维度:缓存预热的确定性。控制循环的首次迭代通常比后续迭代慢,因为数据还不在缓存中。如果首次迭代的延迟超过了截止时间,即使后续迭代都在时限内也是一次 deadline miss。

解决方案是在控制循环启动前做一次"预热迭代"(dummy iteration),用真实或合成数据执行完整的控制计算链,让所有热路径数据加载到缓存中。预热迭代的结果被丢弃,但它确保了后续每帧的缓存状态是温热的。这个技巧在 内存分配策略与pmr 中的预分配策略(预触页面)是同一思想的不同层面——预分配消除 malloc 延迟,预热消除 cache miss 延迟。

实时控制中的缓存优化总结:预分配(消除分配延迟)+ 预热(消除首次 cache miss)+ 热冷分离(减少稳态 cache miss)+ padding(消除 false sharing)——四层措施共同保障最坏情况延迟在截止时间之内。