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