🏠 总目录📚 本教程 03 · cache 与数据布局 ← →
📑 本页目录(点开跳转)

03 · cache 与数据布局

⏱ 92 分钟 | ⭐ 同样的数据、同样的加法、同样的次数,只换遍历顺序 —— 实跑慢 16 倍


🎯 一句话

CPU 从内存拿东西,从来不是按「你要的那一个数」拿的,而是按固定大小的一整块拿的(本机实测 64 字节)。 你所有关于「快慢」的直觉都得从这一条重新长出来 —— 因为你用不用得上那一整块,决定了同一段代码是快还是慢十几倍。


🧩 一、先把那十几倍跑出来

⚠️ 先划一条界,因为这一章和 NumPy 板块隔得很近:

问题 归哪
「一个数组对象怎么描述自己的排布」「切片为什么有时是视图有时是拷贝」「为什么有时要重排一次」 《NumPy 与向量化思维》04
⭐ 「内存是按多大一块搬的」「为什么挨着访问就快」 这一章(硬件层)

这一章从头到尾只有一块裸内存和一个循环,不涉及任何数组对象的描述方式。下面这段就是全章的引子:

// c03_order.cpp
// g++ -O2 -std=c++17 c03_order.cpp -o c03_order
#include <cstdio>
#include <vector>
#include <chrono>

using Clock = std::chrono::steady_clock;
static double ms_since(Clock::time_point t) {
    return std::chrono::duration<double, std::milli>(Clock::now() - t).count();
}

int main() {
    const int N = 4096;                       // 4096 x 4096 个 double = 128 MB
    std::vector<double> m((size_t)N * N, 1.0);

    // ① 一行一行走:内存里挨着的元素,挨着访问
    auto t = Clock::now();
    double s1 = 0;
    for (int r = 0; r < N; ++r)
        for (int c = 0; c < N; ++c)
            s1 += m[(size_t)r * N + c];
    double t_row = ms_since(t);

    // ② 一列一列走:每次跳 N 个 double = 32768 字节
    t = Clock::now();
    double s2 = 0;
    for (int c = 0; c < N; ++c)
        for (int r = 0; r < N; ++r)
            s2 += m[(size_t)r * N + c];
    double t_col = ms_since(t);

    std::printf("矩阵 %d x %d double = %.0f MB\n", N, N, (double)N*N*8/1048576.0);
    std::printf("① 按行走: %8.2f ms   和 = %.0f\n", t_row, s1);
    std::printf("② 按列走: %8.2f ms   和 = %.0f\n", t_col, s2);
    std::printf("   慢了   : %8.2f 倍   (两个和一样吗: %s)\n",
                t_col / t_row, s1 == s2 ? "一样" : "不一样");
    return 0;
}

跑三次的实跑结果(g++ 15.1.0,-O2 -std=c++17):

次 ① 按行走 ② 按列走 慢了
1 13.64 ms 222.46 ms 16.31 倍
2 13.61 ms 223.18 ms 16.40 倍
3 13.60 ms 240.33 ms 17.67 倍

⭐ 两边做的加法一模一样:同样 1677 万次浮点加,同样的 128 MB 数据,两个和都是 16777216,连指令都几乎一样。唯一的差别是访问内存的顺序。

⚠️ 这不是「优化技巧」,这是默认状态下你可能白白损失掉的十几倍。而且注意:写成 ② 那样并不奇怪 —— 「按列求和」是个再自然不过的需求,谁都可能顺手写出来。


🧩 二、CPU 不按元素取数,按「一条」取

上一节那十几倍的原因只有一句话:内存到 CPU 之间的最小搬运单位不是一个数,是一条固定长度的块,叫 cache line(缓存行)。

本机这个长度是多少,标准库直接告诉你:

// c03_probe.cpp
// g++ -O2 -std=c++17 c03_probe.cpp -o c03_probe
#include <cstdio>
#include <new>

int main() {
#ifdef __cpp_lib_hardware_interference_size
    std::printf("hardware_destructive_interference_size = %zu\n",
                std::hardware_destructive_interference_size);
#else
    std::printf("(标准库没提供这个常量)\n");
#endif
    return 0;
}

实跑输出:

hardware_destructive_interference_size = 64

⭐ 64 字节。 于是第一节那两种走法的差别就清楚了:

每搬一条 64 字节 用得上几个 double 白搬了
① 按行走 里面 8 个 double 全是接下来马上要用的 8 个 0%
② 按列走 只用第一个,剩下 7 个下次用到时早被挤出去了 1 个 87.5%

🚦 把这件事直接量出来

如果成本真的挂在「搬了多少条」而不是「取了多少个数」上,那就应该能观察到这样一个现象:取的元素少 16 倍,耗时却不会少 16 倍。

// c03_line.cpp
// g++ -O2 -std=c++17 c03_line.cpp -o c03_line
#include <cstdio>
#include <vector>
#include <chrono>

using Clock = std::chrono::steady_clock;
static double ms_since(Clock::time_point t) {
    return std::chrono::duration<double, std::milli>(Clock::now() - t).count();
}

int main() {
    const size_t BYTES = 128u << 20;             // ⭐ 摸的范围固定 128 MB,比任何一级缓存都大
    const size_t N = BYTES / sizeof(int);
    std::vector<int> a(N, 1);
    const int REP = 5;                           // 每种间隔量 5 遍取最快的一遍

    std::printf("固定摸完同样的 %zu MB,只改「隔几个 int 取一个」\n", BYTES >> 20);
    std::printf("  间隔(字节)      取了多少个   总耗时(ms)\n");

    for (size_t k = 1; k <= 64; k *= 2) {
        double best = 1e18;
        long long s = 0;
        for (int r = 0; r < REP; ++r) {
            auto t = Clock::now();
            s = 0;
            for (size_t i = 0; i < N; i += k) s += a[i];
            double ms = ms_since(t);
            if (ms < best) best = ms;
        }
        std::printf("  %8zu   %12lld   %10.2f\n", k * sizeof(int), s, best);
    }
    return 0;
}

实跑(三次结果几乎一致,这里取第一次):

相邻两次隔多远 取了多少个元素 总耗时
4 字节 33554432 19.21 ms
8 字节 16777216 10.95 ms
16 字节 8388608 8.21 ms
32 字节 4194304 7.24 ms
64 字节 2097152 6.51 ms
128 字节 1048576 5.41 ms
256 字节 524288 3.81 ms

⭐ 看第一行和第五行:元素个数从 3355 万掉到 210 万,少了 16 倍,耗时却只从 19.21 ms 掉到 6.51 ms,只少了不到 3 倍。

为什么?因为这两行搬的 cache line 条数完全一样 —— 间隔在 64 字节以内时,无论你隔多少取一个,128 MB 里的每一条 line 都得被搬进来。省掉的只是「循环本身的指令」,省不掉「搬内存」。

⚠️ 要等间隔超过 64 字节(表里最后两行),才开始有整条 line 被完全跳过,总耗时这才继续往下掉。

⭐ 一句话读法:成本挂在「你碰到了多少条 line」上,不挂在「你用了多少个数」上。


🧩 三、这条 line 从多远的地方来

「搬一条」也不是只有一个价钱 —— 取决于它当时在哪一级。这一层结构可以自己测出来:让指针在一块内存里随机地一跳一跳走下去(随机是关键,否则硬件会提前把下一块取来,测不出真实代价),然后改变这块内存的大小。

// c03_ws.cpp
// g++ -O2 -std=c++17 c03_ws.cpp -o c03_ws
#include <cstdio>
#include <vector>
#include <chrono>
#include <numeric>
#include <random>
#include <algorithm>

using Clock = std::chrono::steady_clock;

int main() {
    std::printf("  数据量      每次跳一步的平均耗时(ns)\n");
    for (size_t kb = 8; kb <= 65536; kb *= 4) {
        size_t n = kb * 1024 / sizeof(int);          // 这么多个 int
        std::vector<int> next(n);
        // 造一条走遍所有格子的随机环:next[i] 是下一个要去的格子
        std::vector<int> perm(n);
        std::iota(perm.begin(), perm.end(), 0);
        std::mt19937 rng(12345);
        std::shuffle(perm.begin(), perm.end(), rng);
        for (size_t i = 0; i + 1 < n; ++i) next[perm[i]] = perm[i + 1];
        next[perm[n - 1]] = perm[0];

        const long long STEPS = 20'000'000;
        int p = 0;
        for (long long i = 0; i < (long long)n; ++i) p = next[p];   // 预热
        auto t = Clock::now();
        for (long long i = 0; i < STEPS; ++i) p = next[p];          // ⭐ 一步都不能提前预测
        double ns = std::chrono::duration<double, std::nano>(Clock::now() - t).count() / STEPS;
        std::printf("  %6zu KB    %8.2f     (p=%d)\n", kb, ns, p);
    }
    return 0;
}

跑两次的实跑结果:

数据总量 随机跳一步(第一次) (第二次)
8 KB 3.00 ns 4.05 ns
32 KB 5.10 ns 5.04 ns
128 KB 15.42 ns 11.76 ns
512 KB 38.53 ns 31.35 ns
2048 KB 150.49 ns 155.03 ns
8192 KB 315.75 ns 303.19 ns
32768 KB 344.19 ns 354.69 ns

⭐ 同一行代码,数据从 8 KB 长到 32 MB,一步的代价涨了大约 100 倍(3 ns → 344 ns)。台阶的位置就是各级缓存装不下的地方。

⚠️ 三条诚实声明: - 这些数字不是纯粹的缓存延迟,里面还含地址翻译等其它开销;它们回答的是「随机跳一步实际要等多久」,不是硬件手册上的延迟指标。 - 绝对值只对这台机器有效,你的一定不同。要看的是形状:平的一段、抬起来的台阶、最后的高原。 - 表里没有任何一个数字来自资料,全部是上面那段代码跑出来的。

📋 由此得到一条很实用的判据:

⭐ 「数据量刚好越过某一级缓存」是性能悬崖最常见的位置。 一个 batch size 从 32 调到 64、一个中间张量从 480 KB 长到 520 KB,代码一个字没改,耗时可能翻几倍。 这也是为什么「调大一点点反而慢了」这种现象经常不是玄学。

⚠️ 上面全是 CPU 这一侧。GPU 那边有自己的一套层次(寄存器 / Shared Memory / L2 / HBM)和自己的比值,AI 基础设施 03 第一节有一张完整的表,本章不重画那张表。


🧩 四、结构体为什么会莫名变胖

前面讲的是「怎么访问」。这一节开始讲「怎么摆」,第一件事是:你写的字段顺序,直接决定这个结构体占多少字节。

原因是对齐:硬件要求一个 8 字节的数必须放在 8 的倍数地址上,编译器只好在字段之间插空洞来满足它。

// c03_pad.cpp
// g++ -O2 -std=c++17 c03_pad.cpp -o c03_pad
#include <cstdio>
#include <cstdint>
#include <cstddef>

struct Bad  { char  flag; double value; char  tag; int32_t id; };   // 随手写的顺序
struct Good { double value; int32_t id; char  flag; char  tag; };   // 从大到小排

int main() {
    std::printf("字段本身加起来: 1 + 8 + 1 + 4 = %d 字节\n\n",
                (int)(sizeof(char) + sizeof(double) + sizeof(char) + sizeof(int32_t)));

    std::printf("Bad  实际占 %zu 字节  (对齐要求 %zu)\n", sizeof(Bad),  alignof(Bad));
    std::printf("  flag  在偏移 %zu\n", offsetof(Bad, flag));
    std::printf("  value 在偏移 %zu   <- 前面被塞了 7 字节空洞\n", offsetof(Bad, value));
    std::printf("  tag   在偏移 %zu\n", offsetof(Bad, tag));
    std::printf("  id    在偏移 %zu   <- 又塞了 3 字节\n\n", offsetof(Bad, id));

    std::printf("Good 实际占 %zu 字节  (对齐要求 %zu)\n", sizeof(Good), alignof(Good));
    std::printf("  value 在偏移 %zu\n", offsetof(Good, value));
    std::printf("  id    在偏移 %zu\n", offsetof(Good, id));
    std::printf("  flag  在偏移 %zu\n", offsetof(Good, flag));
    std::printf("  tag   在偏移 %zu\n\n", offsetof(Good, tag));

    std::printf("⭐ 同样四个字段,只换了声明顺序: %zu -> %zu 字节,省了 %.0f%%\n",
                sizeof(Bad), sizeof(Good),
                100.0 * (sizeof(Bad) - sizeof(Good)) / sizeof(Bad));
    std::printf("   一条 64 字节的 cache line 能装几个: Bad %zu 个 / Good %zu 个\n",
                64 / sizeof(Bad), 64 / sizeof(Good));
    return 0;
}

实跑输出:

算一算

字段本身加起来: 1 + 8 + 1 + 4 = 14 字节

Bad 实际占 24 字节 (对齐要求 8)

flag 在偏移 0

value 在偏移 8 <- 前面被塞了 7 字节空洞

tag 在偏移 16

id 在偏移 20 <- 又塞了 3 字节

Good 实际占 16 字节 (对齐要求 8)

value 在偏移 0

id 在偏移 8

flag 在偏移 12

tag 在偏移 13

⭐ 同样四个字段,只换了声明顺序: 24 -> 16 字节,省了 33%

一条 64 字节的 cache line 能装几个: Bad 2 个 / Good 4 个

⭐ 字段加起来只有 14 字节,Bad 却占 24 字节 —— 有 10 字节是空洞。 换个顺序变成 16 字节,一行逻辑都没改,省了 33%。

⚠️ 真正的代价在最后一行:一条 cache line 装 Bad 只能装 2 个,装 Good 能装 4 个。也就是说遍历一个 Bad 数组,搬内存的次数是 Good 的两倍 —— 第二节那条判据在这里生效了。

📋 两条能直接用的规矩:

规矩 说明
⭐ 字段按大小从大到小声明 最省事的做法,多数情况下自动最优
⚠️ 别自作聪明加 #pragma pack 它能去掉空洞,但会让某些字段落在没对齐的地址上 —— 在有些平台上性能更差甚至直接出错

⭐ 读源码时的用处:看到一个结构体的字段顺序「奇怪地按大小排列」,或者中间插着 char _pad[N] 这种看似没用的成员,那不是风格问题,是有人在控制布局。


🛑 读到这里可以停 —— 前半章讲完了(约 38 分钟)。 后半章还有:AoS 还是 SoA:同一批数据的两种摆法 · 💀 false sharing:两个变量互不相干,却互相拖累 · Python 侧也逃不掉 回来的时候不用重读,直接从下一节接着看就行。


🧩 五、AoS 还是 SoA:同一批数据的两种摆法

现在把「怎么摆」放大到一整个数组。假设有 800 万个粒子,每个粒子有 8 个属性。有两种摆法:

摆法 长什么样 内存里的顺序
AoS(结构体数组) 一个数组,每个元素是一个完整的粒子 x y z vx vy vz m q x y z vx vy vz m q …
SoA(数组的结构) 8 个数组,每个数组装一个属性 x x x x … 然后 y y y y … 然后 …

⭐ 如果你的循环只用其中一个属性,这两种摆法的差别是巨大的。

// c03_aos.cpp
// g++ -O2 -std=c++17 c03_aos.cpp -o c03_aos
#include <cstdio>
#include <vector>
#include <chrono>

using Clock = std::chrono::steady_clock;
static double ms_since(Clock::time_point t) {
    return std::chrono::duration<double, std::milli>(Clock::now() - t).count();
}

struct Particle {                 // 一个粒子的全部属性摆在一起
    float x, y, z;
    float vx, vy, vz;
    float mass, charge;
};                                // 8 个 float = 32 字节

int main() {
    const size_t N = 8'000'000;
    const int    REP = 5;

    // ① AoS:一个装着「结构体」的数组
    std::vector<Particle> aos(N);
    for (size_t i = 0; i < N; ++i) { aos[i].x = 1.0f; aos[i].mass = 2.0f; }

    // ② SoA:每个属性一个自己的数组
    std::vector<float> sx(N, 1.0f), sy(N), sz(N),
                       svx(N), svy(N), svz(N),
                       smass(N, 2.0f), scharge(N);

    double best_aos = 1e18, best_soa = 1e18;
    float r1 = 0, r2 = 0;

    for (int r = 0; r < REP; ++r) {                 // 只用 x 这一个字段
        auto t = Clock::now();
        float s = 0;
        for (size_t i = 0; i < N; ++i) s += aos[i].x;
        double ms = ms_since(t);
        if (ms < best_aos) { best_aos = ms; r1 = s; }
    }
    for (int r = 0; r < REP; ++r) {
        auto t = Clock::now();
        float s = 0;
        for (size_t i = 0; i < N; ++i) s += sx[i];
        double ms = ms_since(t);
        if (ms < best_soa) { best_soa = ms; r2 = s; }
    }

    std::printf("sizeof(Particle) = %zu 字节,%zu 个粒子共 %.0f MB\n",
                sizeof(Particle), N, (double)N * sizeof(Particle) / 1048576.0);
    std::printf("只求和 x 这一个字段(%zu MB 的有效数据):\n",
                (size_t)(N * sizeof(float) / 1048576));
    std::printf("  ① AoS  结构体数组: %8.2f ms   (和 %.0f)\n", best_aos, r1);
    std::printf("  ② SoA  数组的结构: %8.2f ms   (和 %.0f)\n", best_soa, r2);
    std::printf("  AoS / SoA        : %8.2f 倍\n", best_aos / best_soa);
    std::printf("⭐ AoS 每搬一条 64 字节的 cache line,只用得上其中 %zu 字节\n",
                64 / sizeof(Particle) * sizeof(float));
    return 0;
}

跑三次的实跑结果:

次 ① AoS ② SoA AoS / SoA
1 14.27 ms 6.03 ms 2.37 倍
2 15.92 ms 6.53 ms 2.44 倍
3 13.29 ms 6.36 ms 2.09 倍

⭐ sizeof(Particle) = 32 字节,一条 line 装 2 个粒子,而你只要 x —— 每搬 64 字节只用得上 8 字节,87.5% 的带宽是白花的。SoA 那边则是搬来的 64 字节里 16 个 float 全都要用。

📋 该选哪个:

你的循环 选
一次只碰一两个属性,但要碰所有元素(大部分数值计算) ⭐ SoA
一次要碰某个元素的全部属性(比如「找到这个粒子,把它整个打印出来」) AoS
两种都有 按最热的那个循环选,或者拆成两份

⭐ 这解释了一件你天天在用的事:为什么框架里的一批数据是「一个 x 张量 + 一个 y 张量」,而不是「一个装着样本对象的列表」。那就是 SoA。 深度学习的每个算子都是「对所有元素做同一件事」,正好落在 SoA 那一行。

⚠️ GPU 上这件事的名字不一样但道理一样:AI 基础设施 02 讲的「非合并访存」说的是「warp 里 32 个线程访问连续地址就合并成 1 次内存事务,否则可能变成 32 次、有效带宽掉到 1/32」—— ⭐ 那一章讲的是后果和怎么避开,这一章讲的是为什么内存非得按固定大小的块搬。 两边是同一个机制在两种硬件上的样子。


🧩 六、💀 false sharing:两个变量互不相干,却互相拖累

这一节兑现 02 章第六节留下的那句话 —— 那里说 shared_ptr 的引用计数是原子操作、「多线程下会真的争抢缓存行」。代价有多大,现在量出来。

⚠️ 前提:cache line 是缓存的最小单位,也是多核之间保持一致的最小单位。一个核改了某条 line 里的任何一个字节,其它核手上那条 line 的副本就整条作废。

于是就有了这个荒谬的场景:四个线程各改各的变量,谁也不碰别人的,但因为这四个变量挤在同一条 line 里,它们互相把对方的缓存打掉。

// c03_false.cpp
// g++ -O2 -std=c++17 -pthread c03_false.cpp -o c03_false
#include <cstdio>
#include <thread>
#include <vector>
#include <chrono>
#include <atomic>

using Clock = std::chrono::steady_clock;
static double ms_since(Clock::time_point t) {
    return std::chrono::duration<double, std::milli>(Clock::now() - t).count();
}

struct Packed  { std::atomic<long long> v; };              // 4 个挤在一起
struct Spread  { std::atomic<long long> v; char pad[56]; };// ⭐ 撑到 64 字节,各占一条 cache line

template <class Slot>
double run(int nthreads, long long iters) {
    std::vector<Slot> slots(nthreads);
    for (auto& s : slots) s.v.store(0, std::memory_order_relaxed);
    std::vector<std::thread> ts;
    auto t = Clock::now();
    for (int k = 0; k < nthreads; ++k)
        ts.emplace_back([&slots, k, iters] {
            for (long long i = 0; i < iters; ++i)
                slots[k].v.fetch_add(1, std::memory_order_relaxed);
        });
    for (auto& th : ts) th.join();
    return ms_since(t);
}

int main() {
    const int  T = 4;
    const long long ITER = 20'000'000;
    std::printf("硬件线程数: %u\n", std::thread::hardware_concurrency());
    std::printf("sizeof(Packed)=%zu  sizeof(Spread)=%zu\n\n",
                sizeof(Packed), sizeof(Spread));
    std::printf("%d 个线程,各自给【自己的】计数器加 %lld 次(互不相干的变量)\n", T, ITER);

    for (int r = 0; r < 3; ++r) {
        double a = run<Packed>(T, ITER);
        double b = run<Spread>(T, ITER);
        std::printf("  第%d次:  挤在一起 %8.2f ms   隔开 %8.2f ms   慢 %.2f 倍\n",
                    r + 1, a, b, a / b);
    }
    return 0;
}

实跑输出:

硬件线程数: 8

sizeof(Packed)=8 sizeof(Spread)=64

4 个线程,各自给【自己的】计数器加 20000000 次(互不相干的变量)

同样工作量,比较两种内存布局
实测轮次挤在一起隔开相对耗时
第1次1577.45 ms211.50 ms7.46 倍
第2次1824.92 ms175.98 ms10.37 倍
第3次1176.16 ms201.48 ms5.84 倍

💀💀 5.84 到 10.37 倍,而这四个线程之间没有任何逻辑上的共享。 唯一的区别是 char pad[56] 这一行 —— 把每个计数器撑到 64 字节,让它们各占一条 line。

⭐ 为什么它特别难查:

表现
代码看起来 完全正确,四个线程各写各的,没有锁、没有竞态、结果也对
加线程之后 ⚠️ 越加越慢,看起来像「这活并行不起来」
用一般的方法查 查不到 —— 没有锁竞争、没有系统调用、CPU 占用还很高
改法 让各线程写的东西隔开 64 字节,或者干脆各自在局部变量里累加,最后合并一次

⚠️ 回到 02 章那句话:shared_ptr 每次拷贝和析构都要原子地改一次引用计数。如果多个线程频繁拷贝同一个 shared_ptr,它们改的就是同一条 line 上的同一个计数器 —— 这已经不只是 false sharing,是真的在抢同一个数。⭐ 这就是「默认用 unique_ptr,说不清谁最后走才用 shared_ptr」那条建议在性能上的理由。


🛑 读到这里可以停 —— 已经读了约 63 分钟。 最后一段还有(约 28 分钟):Python 侧也逃不掉 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。


🧩 七、Python 侧也逃不掉

前面全是 C++。但这件事不是 C++ 的特性,是硬件的特性 —— 用纯 Python 也能把它跑出来。

01 章第二节说过「那 1000 个对象散落在堆的各处,遍历它们等于 1000 次指针跳转」,当时留了一句「这一条会在 03 章变成一个更大的数字」。就是下面这个:

# c03_pyscatter.py
import random, time, array

N = 2_000_000

# ① 一次性造出来的对象,地址大体是挨着的
objs = [float(i) for i in range(N)]
gaps = [abs(id(objs[i + 1]) - id(objs[i])) for i in range(1000)]
print("① 顺序造出来的 float 对象,相邻两个的地址差(前 1000 个的中位数):",
      sorted(gaps)[len(gaps) // 2], "字节")

# ② 把列表打乱:对象本身没动,但「访问顺序」和「内存顺序」错开了
shuffled = objs[:]
random.seed(0)
random.shuffle(shuffled)
gaps2 = [abs(id(shuffled[i + 1]) - id(shuffled[i])) for i in range(1000)]
print("② 打乱之后,相邻两个的地址差(中位数):",
      sorted(gaps2)[len(gaps2) // 2], "字节")

def timed(f, rep=5):
    best = 1e18
    for _ in range(rep):
        t = time.perf_counter()
        r = f()
        best = min(best, (time.perf_counter() - t) * 1000)
    return best, r

t1, s1 = timed(lambda: sum(objs))
t2, s2 = timed(lambda: sum(shuffled))
arr = array.array('d', objs)
t3, s3 = timed(lambda: sum(arr))

print(f"③ 顺序列表求和  : {t1:8.1f} ms")
print(f"   打乱列表求和  : {t2:8.1f} ms   (慢 {t2/t1:.2f} 倍)")
print(f"   array('d') 求和: {t3:8.1f} ms   (对顺序列表 {t1/t3:.2f} 倍)")
print("   三个和一样吗:", s1 == s2 == s3)

实跑输出(CPython 3.13.14,跑两次):

操作步骤

  1. 顺序造出来的 float 对象,相邻两个的地址差(前 1000 个的中位数): 32 字节
  2. 打乱之后,相邻两个的地址差(中位数): 20633632 字节
  3. 顺序列表求和 : 15.0 ms
  4. 打乱列表求和 : 41.5 ms (慢 2.77 倍)
  5. array('d') 求和: 11.7 ms (对顺序列表 1.29 倍)
  6. 三个和一样吗: True

第二次跑的打乱地址差是 29225504 字节,倍数 2.85。

⭐ 注意 shuffled 和 objs 装的是【同一批对象】 —— 同样 200 万个 float,同样的和(True),一个字节的数据都没多也没少。唯一变的是访问它们的顺序,慢了 2.77 / 2.85 倍。

⚠️ 而地址差那两行是证据:顺序造出来的对象相邻地址差 32 字节(挨着),打乱之后中位数是 2000 万字节 级别 —— 每一步都跳到一条全新的 line 上。

📋 顺手能用的三条:

现象 解释
⭐ 同一批数据,打乱顺序访问就是慢 和语言无关,第二节那条判据在起作用
array('d') 比顺序列表还快一点(1.21–1.29 倍) 它是一块连续的裸内存,不是 200 万个对象加 200 万个指针
⚠️ 但 array 的优势在这里没有第一节那么夸张 因为 sum() 每取一个还要造一个 Python float 对象,那笔开销盖过了布局的差别 —— ⭐ 瓶颈换了地方,布局的收益就被摊薄了

⚠️ 反过来说,在纯 Python 里追求 cache 友好基本不划算 —— 解释器开销(01 章那三笔)通常比布局的差别大得多。这一节的意义是让你认得这个现象,真要吃到这份收益,得让数据以连续裸内存的形式交给下面那一层。


🔗 这一章连到哪里

相关的地方 为什么
《NumPy 与向量化思维》04 「一个数组对象怎么描述自己的排布」「切片是视图还是拷贝」全在那边。⭐ 本章只讲硬件层:内存按 64 字节一块搬
01 章 那一章第二节说「1000 个对象散落在堆的各处,这一条会在 03 章变成一个更大的数字」—— 第七节的 2.77 / 2.85 倍就是它
02 章 那一章第六节说 shared_ptr 的原子计数「多线程下会真的争抢缓存行」—— 第六节把这句话量成了 5.84–10.37 倍
AI 基础设施 02 它讲「非合并访存」:warp 里 32 个线程访问连续地址合并成 1 次事务,否则有效带宽掉到 1/32。⭐ 那是后果和避法,本章是它背后的机制;warp / SM / GPU 侧的一切归它讲
AI 基础设施 03 它第一节有 GPU 侧完整的内存层次表(寄存器 / Shared Memory / L2 / HBM 及其带宽),⭐ 本章不重画那张表,只测了 CPU 这一侧
AI 基础设施 07 「少搬一次内存」正是算子融合的全部动机 —— 本章解释了为什么搬内存这么贵,那一章算能省多少
04 章 结构体的布局(第四节)不只是性能问题:两边对同一个结构体的布局理解不一致,程序会直接读错数据 —— 那是下一章的正题

✅ 检查点

  1. 4096×4096 的 double 矩阵,按行走和按列走实跑各多少毫秒?慢了几倍?两种走法有什么不同、有什么完全相同?
  2. 本机的 cache line 是多少字节?用什么方法测出来的(不是查资料)?
  3. 固定摸完 128 MB,间隔从 4 字节改成 64 字节:取的元素少了几倍?耗时少了几倍?为什么不成比例?
  4. 那条「成本挂在什么上」的判据怎么说?
  5. 随机跳一步的耗时,从 8 KB 涨到 32 MB 大约变了多少倍?为什么正文强调「要看形状不看绝对值」,还说了哪一条诚实声明?
  6. Bad 和 Good 两个结构体字段完全一样,各占多少字节?字段本身加起来是多少?一条 cache line 各能装几个?
  7. 字段该怎么声明?为什么不推荐用 #pragma pack 去掉空洞?
  8. AoS 和 SoA 分别是什么摆法?只求和 x 一个字段时实跑差几倍?sizeof(Particle) 是多少,一条 line 里有多少字节是白搬的?
  9. 为什么框架里是「一个 x 张量 + 一个 y 张量」而不是「一个样本对象列表」?
  10. false sharing 是什么?实跑慢了几倍?改法是什么?为什么它特别难查?
  11. 纯 Python 那个实验里,objs 和 shuffled 有什么不同、有什么相同?慢了几倍?地址差分别是多少?
  12. 为什么正文说「在纯 Python 里追求 cache 友好基本不划算」?
👀 答案
  1. 按行 13.64 / 13.61 / 13.60 ms,按列 222.46 / 223.18 / 240.33 ms,慢 16.31 / 16.40 / 17.67 倍。不同的只有访问内存的顺序;相同的是数据(同一个 128 MB 矩阵)、运算(1677 万次浮点加)、结果(两个和都是 16777216),连指令都几乎一样。
  2. 64 字节。用 std::hardware_destructive_interference_size 打印出来的 —— 是本机标准库报告的值,不是查来的。
  3. 元素从 33554432 个降到 2097152 个,少了 16 倍;耗时从 19.21 ms 降到 6.51 ms,只少了不到 3 倍。因为间隔在 64 字节以内时,要搬的 cache line 条数完全一样,省掉的只是循环本身的指令开销。要等间隔超过 64 字节才开始有整条 line 被跳过。
  4. 成本挂在「你碰到了多少条 cache line」上,不挂在「你用了多少个数」上。
  5. 从 3.00 ns(8 KB)涨到 344.19 ns(32 MB),大约 100 倍。三条诚实声明:① 这些数不是纯粹的缓存延迟,含地址翻译等其它开销;② 绝对值只对这台机器有效,要看的是形状(平的一段、台阶、高原);③ 表里没有任何一个数字来自资料,全部实跑。
  6. 字段本身加起来 14 字节;Bad 实际 24 字节(有 10 字节空洞),Good 16 字节,省 33%。一条 64 字节的 line:Bad 装 2 个,Good 装 4 个 —— 所以遍历 Bad 数组搬内存的次数是 Good 的两倍。
  7. 按大小从大到小声明。不推荐 #pragma pack:它能去掉空洞,但会让某些字段落在没对齐的地址上,在有些平台上性能更差甚至直接出错。
  8. AoS = 结构体数组(一个粒子的属性挨在一起);SoA = 数组的结构(每个属性一个数组)。只求和 x 时实跑 2.37 / 2.44 / 2.09 倍。sizeof(Particle) = 32 字节,一条 line 装 2 个粒子而只用得上 2 个 float = 8 字节,87.5% 白搬。
  9. 因为那就是 SoA。深度学习的每个算子都是「对所有元素做同一件事」,一次只碰一两个属性但要碰所有元素 —— 正好落在该选 SoA 的那一行。
  10. 多个线程各改各自的变量,但这些变量挤在同一条 cache line 里;一个核改了 line 里任何一个字节,其它核的整条副本作废。实跑 5.84–10.37 倍(挤在一起 1176–1825 ms,隔开 176–212 ms)。改法:让各线程写的东西隔开 64 字节(char pad[56]),或各自在局部变量里累加最后合并一次。难查是因为:代码完全正确、没有锁竞争和系统调用、CPU 占用还很高,症状只是「加线程越加越慢」。
  11. 装的是同一批对象,同样 200 万个 float,和相同(True),数据一个字节没变;只有访问顺序不同。慢 2.77 / 2.85 倍。地址差:顺序的中位数 32 字节,打乱后 20633632 / 29225504 字节。
  12. 因为解释器开销通常比布局的差别大得多(01 章那三笔)。实验里 array('d') 只比顺序列表快 1.21–1.29 倍,就是因为 sum() 每取一个还要造一个 Python float 对象,那笔开销盖过了布局差别 —— 瓶颈换了地方,布局的收益就被摊薄了。

🛑 可以停在这里

⚡ 走神救援

🏁 全章一句话:CPU 不按元素取数,按固定大小的一整块取——本机实测是 64 字节,⭐ 这个数是打印出来的,不是查来的。引子是同一个矩阵按行走和按列走:同样的数据量、同样的加法次数、同样的结果,只有访问顺序不同,慢了十几倍。

📏 ⭐⭐ 判据:成本挂在「碰到了多少条 line」上,不挂在「用了多少个数」上。 证据是固定摸完同样大的内存、只改间隔——取的数少了十几倍,耗时只少不到三倍,因为要搬的 line 条数一样。

⭐ 搬一条的价钱取决于它在哪一级,最近和最远差约两个数量级。由此得到 ⭐ 「数据量刚好越过某一级缓存」是性能悬崖最常见的位置——「调大一点点反而慢了」经常不是玄学。 ⚠️ 这些数绝对值只对本机有效,而且没有一个来自资料。

📦 怎么摆同样要紧:对齐会插空洞,只把字段从大到小重排就能省掉三分之一——关键后果是一条 line 能装下的对象数差一倍。⚠️ 别用 #pragma pack 硬去空洞。放大到数组就是 AoS 与 SoA 之别,⭐ 框架里「一个 x 张量加一个 y 张量」就是 SoA。

💀 false sharing 兑现了上一章的欠账:几个线程各加各自的计数器、毫无逻辑共享,只因挤在同一条 line 里就慢了将近一个数量级。⭐ 难查是因为代码完全正确、没有锁竞争、CPU 占用还高——症状只是「加线程越加越慢」。

🐍 Python 也逃不掉:同一批对象只打乱访问顺序就慢了两倍多。⚠️ 但换成紧凑数组只快了两成——每取一个还要造一个对象,瓶颈换了地方,布局收益就被摊薄。⭐ 所以纯 Python 里追求 cache 友好基本不划算。

下一节 👉 04-ABI与二进制兼容.md

打卡记录保存在你的浏览器里,首页能看到总进度