diff --git a/README.md b/README.md index 1f15c1578..ed3f549bd 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-84%25-green.svg) 494/588 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-92%25-green.svg) 534/582 docs translated ## 这是什么项目 diff --git a/code/volumn_codes/vol6-performance/ch02/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch02/CMakeLists.txt new file mode 100644 index 000000000..65ec9aeaf --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch02/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch02_cpu_microarchitecture LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# 02-01 存储层次:memory mountain(吞吐 size×stride)+ 指针追逐延迟阶梯 +add_executable(memory_mountain memory_mountain.cpp) +target_compile_options(memory_mountain PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 02-02 缓存行与局部性:步长扫描定位 64B 断崖 + 行优先 vs 列优先 2D 遍历 +add_executable(cacheline_locality cacheline_locality.cpp) +target_compile_options(cacheline_locality PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 02-03 流水线/ILP/分支预测 +# ⚠️ pipeline_branch_ilp 必须用下面的反优化 flag,否则: +# - GCC 默认 -O2 会把 if 条件累加自动向量化 / 转 cmov → 分支 demo(4.2x)消失 +# - 单累加器 dot1 会被循环不变量提升整体消除 → ILP demo 失真 +# 这组 flag 是文章论证成立的前提,别「优化」掉。 +add_executable(pipeline_branch_ilp pipeline_branch_ilp.cpp) +target_compile_options(pipeline_branch_ilp PRIVATE + -O2 -fno-tree-vectorize -fno-tree-slp-vectorize -fno-if-conversion + -Wall -Wextra -Wpedantic) + +# 02-04 TLB / huge page:同工作集 4KB 页 vs 2MB 大页(THP)指针追逐 +add_executable(tlb_hugepage tlb_hugepage.cpp) +target_compile_options(tlb_hugepage PRIVATE -O2 -Wall -Wextra -Wpedantic) diff --git a/code/volumn_codes/vol6-performance/ch02/README.md b/code/volumn_codes/vol6-performance/ch02/README.md new file mode 100644 index 000000000..293d171d3 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch02/README.md @@ -0,0 +1,49 @@ +# vol6 ch02 · CPU 微架构与存储层次 — 代码示例 + +对应文章:`documents/vol6-performance/ch02-cpu-microarchitecture/` + +四个程序,分别对应 ch02 的四篇文章,用本机实测数据佐证每一篇的核心结论。所有数字随 CPU / 编译器 / 运行环境(尤其是虚拟机/容器)变化,**关心比例和趋势,不要把某个绝对数当普适结论**。 + +## 构建 + +```bash +# 单独编译(最快,推荐一篇一篇跑) +g++ -O2 -std=c++17 memory_mountain.cpp -o memory_mountain && taskset -c 0 ./memory_mountain +g++ -O2 -std=c++17 cacheline_locality.cpp -o cacheline_locality && taskset -c 0 ./cacheline_locality +g++ -O2 -std=c++17 tlb_hugepage.cpp -o tlb_hugepage && taskset -c 0 ./tlb_hugepage + +# ⚠️ pipeline_branch_ilp 必须带反优化 flag,否则 demo 失效(见下) +g++ -O2 -std=c++17 -fno-tree-vectorize -fno-tree-slp-vectorize -fno-if-conversion \ + pipeline_branch_ilp.cpp -o pipeline_branch_ilp && taskset -c 0 ./pipeline_branch_ilp + +# 或用 CMake 一次构建全部(CMakeLists 已为 pipeline 配好 flag) +cmake -B build && cmake --build build +``` + +> `taskset -c 0` 把进程绑到 0 号核,避免核间迁移把 cache 弄冷、给测量加噪声。这是 vol6 ch01 *测量方法论* 的标准动作。 + +## 程序对照 + +### memory_mountain.cpp —— 02-01 存储层次 + +两个实验:(A)经典 CSAPP memory mountain,读吞吐随 size × stride 变化;(B)指针追逐随机读延迟,画出 L1/L2/L3/DRAM 的延迟阶梯。要点:L1 ~1 ns、DRAM ~120 ns,差 100 倍。 + +### cacheline_locality.cpp —— 02-02 缓存行与局部性 + +(A)步长扫描:工作集固定,扫 stride,吞吐在 stride=64B 处断崖,反证 cacheline=64 字节;(B)行优先 vs 列优先 2D 遍历,~6 倍差距。 + +### pipeline_branch_ilp.cpp —— 02-03 流水线 / ILP / 分支预测 + +(A)分支预测:已排序 vs 打乱数组的条件累加,~4 倍差距(预测失败的冲刷代价);(B)ILP:单累加器(长依赖链)vs 4 累加器(并行链),~3 倍差距。 + +**这个程序必须用 `-fno-tree-vectorize -fno-tree-slp-vectorize -fno-if-conversion` 编译。** 默认 `-O2` 下 GCC 会把 `if` 条件累加自动向量化成 SIMD / 转成 `cmov`(分支消失,打乱=排序),并把单累加器点积当循环不变量整体消除(ILP demo 失真)。这组 flag 是文章论证成立的前提——也是文章里那个「你看到的分支开销取决于编译器把代码编译成了什么」教学点的直接证据。 + +### tlb_hugepage.cpp —— 02-04 TLB / huge page + +同 256 MB 工作集,4 KB 页 vs `madvise(MADV_HUGEPAGE)` 请求 2 MB 大页,指针追逐比延迟。**注意**:本机(WSL2)实测两者无差别(`AnonHugePages: 0`,内核没实际兑现 THP)——这是个诚实的否定结果,演示「你以为开了的优化可能根本没生效」。换裸机 + 预分配 hugetlb 池或全局 THP=always,对 TLB 受限负载能测出提升。 + +## 怎么读结果 + +- **比例 > 绝对数**:L1/DRAM 的 100 倍、行/列的 6 倍、ILP 的 3 倍、分支的 4 倍——这些比例是硬件机制决定的,稳。绝对 ns / GB/s 随环境抖动。 +- **WSL2 / 虚拟机噪声**:频率被宿主管、TLB/THP 行为可能和裸机不同、`perf` 可能没装。看到不合常理的数字,先怀疑环境,再怀疑结论。 +- **汇编是最终裁判**:尤其 pipeline 那个程序,`-O2 -S` 看 `dot1` 是不是真单链、`sum_gt128` 是不是真有 `jcc`,能直接验证你测的是不是你想测的。 diff --git a/code/volumn_codes/vol6-performance/ch02/cacheline_locality.cpp b/code/volumn_codes/vol6-performance/ch02/cacheline_locality.cpp new file mode 100644 index 000000000..4cb6aaecf --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch02/cacheline_locality.cpp @@ -0,0 +1,102 @@ +// cacheline_locality.cpp — vol6 ch02-02 用 +// 两个实验: +// A. 步长扫描:固定工作集,扫步长,精确定位 cacheline 边界(吞吐在 stride=64 处断崖) +// B. 行优先 vs 列优先 2D 遍历:空间局部性最经典的对照 +// 编译: g++ -O2 -std=c++17 cacheline_locality.cpp -o cacheline_locality +// 跑: taskset -c 0 ./cacheline_locality +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +static std::vector g_data; + +inline void do_not_optimize(int v) { + asm volatile("" : "+r"(v)::"memory"); +} + +// ---------- A. 步长扫描:固定 size,扫 stride,测吞吐(元素/ns)---------- +// 思路:工作集固定在 L3 区(够大,使每次新 cacheline 加载有成本)。 +// stride < 64B 时,一个 cacheline 内的多次访问被摊薄 → 高吞吐; +// stride >= 64B 时,每次访问都触发新 cacheline → 吞吐断崖。 +double stride_throughput(long elems, long stride_elem) { + const long ACCESSES = 64'000'000L; + long mask = elems - 1; + int sink = 0; + long idx = 0; + auto t0 = clk::now(); + for (long i = 0; i < ACCESSES; ++i) { + sink += g_data[idx]; + idx = (idx + stride_elem) & mask; + } + do_not_optimize(sink); + auto t1 = clk::now(); + double secs = std::chrono::duration(t1 - t0).count(); + return (double)ACCESSES / secs / 1e6; // M 访问/秒 +} + +// ---------- B. 行优先 vs 列优先 2D 遍历 ---------- +void walk_2d(int* a, int N, bool row_major) { + volatile int sink = 0; + int s = 0; + auto t0 = clk::now(); + if (row_major) { + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) + s += a[i * N + j]; // 顺序 + } else { + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) + s += a[j * N + i]; // 跨 N 个 int 跳 + } + sink = s; + do_not_optimize(sink); + auto t1 = clk::now(); + double secs = std::chrono::duration(t1 - t0).count(); + long bytes = (long)N * N * sizeof(int); + std::printf(" %s: %7.1f ms, %.1f GB/s\n", + row_major ? "行优先 row-major" : "列优先 col-major", secs * 1e3, + bytes / secs / 1e9); +} + +int main() { + // ===== A. 步长扫描 ===== + // 工作集 2 MB(落在 L3,且远大于 L1/L2,使 cacheline miss 成本显形) + const long ELEMS = (2L * 1024 * 1024) / sizeof(int); // 2MB / 4B = 512K 元素 + g_data.resize(ELEMS); + for (long i = 0; i < ELEMS; ++i) + g_data[i] = (int)i; + + std::printf("===== A. 步长扫描(工作集 2MB,落 L3)=====\n"); + std::printf("%-12s %12s %12s\n", "stride(B)", "M访问/秒", "说明"); + long strides_B[] = {4, 8, 16, 32, 48, 56, 64, 72, 96, 128, 256, 512}; + for (long sb : strides_B) { + long stride_elem = sb / sizeof(int); // int=4B + // warmup + stride_throughput(ELEMS, stride_elem); + double m = stride_throughput(ELEMS, stride_elem); + const char* note = ""; + if (sb < 64) + note = "< cacheline:同行的多次访问被摊薄"; + else if (sb == 64) + note = "= cacheline:每次访问正好换一行"; + else + note = "> cacheline:每次访问都是新行"; + std::printf("%8ldB %10.1f %s\n", sb, m, note); + } + + // ===== B. 行优先 vs 列优先 ===== + const int N = 2048; // 2048*2048*4B = 16MB = L3 大小,放大局部性差异 + std::vector a((long)N * N); + for (long i = 0; i < (long)N * N; ++i) + a[i] = (int)i; + std::printf("\n===== B. 2D 遍历(N=%d,矩阵 %d MB = L3 大小)=====\n", N, + N * N * (int)sizeof(int) / (1024 * 1024)); + // warmup + walk_2d(a.data(), N, true); + walk_2d(a.data(), N, true); + walk_2d(a.data(), N, false); + walk_2d(a.data(), N, false); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch02/memory_mountain.cpp b/code/volumn_codes/vol6-performance/ch02/memory_mountain.cpp new file mode 100644 index 000000000..7ee297347 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch02/memory_mountain.cpp @@ -0,0 +1,137 @@ +// memory_mountain.cpp — vol6 ch02-01 用 +// 两个实验: +// A. 经典 CSAPP memory mountain(读吞吐, size × stride 网格) +// B. 指针追逐随机读延迟(真实访问延迟随工作集跨过 L1/L2/L3/DRAM 的断崖) +// 编译: g++ -O2 -std=c++17 memory_mountain.cpp -o memory_mountain +// 跑: taskset -c 0 ./memory_mountain (绑核降噪) +#include +#include +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; + +// ---------- A. memory mountain: 顺序步长读吞吐 ---------- +// data 大小 = elems * 8 字节。以 stride(单位:元素个数)环形遍历。 +// 固定总访问次数 ACCESSES,测吞吐。 +static std::vector g_data; + +// 把 v 强制「被消费」+ 内存屏障,等同 Google Benchmark 的 DoNotOptimize 语义 +inline void do_not_optimize(int64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +double throughput(long elems, long stride) { + const long ACCESSES = 64'000'000L; // 总访问次数(够大以摊薄计时噪声) + long mask = elems - 1; // elems 必须是 2 的幂 + int64_t sink = 0; + long idx = 0; + auto t0 = clk::now(); + for (long i = 0; i < ACCESSES; ++i) { + sink += g_data[idx]; // g_data 非 const,load 无法被消除 + idx = (idx + stride) & mask; // 环形,始终落在 [0, elems) + } + do_not_optimize(sink); // sink 必须求值 + auto t1 = clk::now(); + double secs = std::chrono::duration(t1 - t0).count(); + // 每次访问读 8 字节 + return (double)ACCESSES * 8.0 / secs / 1e9; // GB/s +} + +// ---------- B. 指针追逐真实随机读延迟 ---------- +// 构造一条贯穿全部节点的环形链,idx_next = perm 置乱,硬件预取器无法预测 +// (下一个地址依赖本次 load 的结果),测出真实访存延迟。 +double chase_latency(long elems) { + // 构造置乱单环 + std::vector perm(elems); + for (long i = 0; i < elems; ++i) + perm[i] = i; + std::mt19937_64 rng(0xC0FFEEull); + for (long i = elems - 1; i > 0; --i) { + long j = (long)(rng() % (i + 1)); + std::swap(perm[i], perm[j]); + } + std::vector nxt(elems); + for (long i = 0; i < elems; ++i) + nxt[perm[i]] = perm[(i + 1) % elems]; + + const long ROUNDS = 8; // 每个元素访问 ROUNDS 次 + long total_steps = elems * ROUNDS; + volatile int64_t sink = 0; + long idx = 0; + auto t0 = clk::now(); + for (long s = 0; s < total_steps; ++s) { + idx = nxt[idx]; + sink = sink + g_data[idx]; // 读 data[idx] 制造真依赖 + } + auto t1 = clk::now(); + double secs = std::chrono::duration(t1 - t0).count(); + if (sink == 0x12345) + std::printf(""); + return secs / total_steps * 1e9; // ns/访问 +} + +int main() { + // 给 data 分配到足够大(最大覆盖到 DRAM 区:64M 元素 = 512MB) + // 用非常量填充:g_data[i] = i,确保每次 load 无法被编译器折叠成常量 + const long MAX_ELEMS = 1L << 26; // 64M * 8B = 512 MB + g_data.resize(MAX_ELEMS); + for (long i = 0; i < MAX_ELEMS; ++i) + g_data[i] = i; + + // ===== A. memory mountain ===== + std::printf("===== A. memory mountain: 读吞吐 (GB/s) =====\n"); + std::printf("size\\stride(B)"); + long strides_elem[] = {1, 2, 4, 8, 16, 32, 64}; // 元素步长 + long strides_B[] = {8, 16, 32, 64, 128, 256, 512}; // 字节步长 + int ns = (int)(sizeof(strides_elem) / sizeof(strides_elem[0])); + for (int k = 0; k < ns; ++k) + std::printf("%8ldB", strides_B[k]); + std::printf("\n"); + + long size_KB[] = {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768}; + int nsize = (int)(sizeof(size_KB) / sizeof(size_KB[0])); + for (int s = 0; s < nsize; ++s) { + long elems = (size_KB[s] * 1024L) / 8; // 元素个数 + if (elems > MAX_ELEMS) + break; + std::printf("%7ldK", size_KB[s]); + for (int k = 0; k < ns; ++k) { + // warmup 一次 + throughput(elems, strides_elem[k]); + double gb = throughput(elems, strides_elem[k]); + std::printf("%8.1f", gb); + std::fflush(stdout); + } + std::printf("\n"); + } + + // ===== B. 指针追逐延迟阶梯 ===== + std::printf("\n===== B. 指针追逐随机读延迟 (ns/访问) =====\n"); + std::printf("%10s %10s %10s %12s\n", "size", "elems", "ns/access", "level(推断)"); + long lat_KB[] = {4, 8, 16, 32, 64, 128, 256, 512, + 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072}; + int nl = (int)(sizeof(lat_KB) / sizeof(lat_KB[0])); + for (int s = 0; s < nl; ++s) { + long elems = (lat_KB[s] * 1024L) / 8; + if (elems > MAX_ELEMS) + break; + // warmup + chase_latency(elems); + double ns = chase_latency(elems); + const char* lvl = ""; + if (lat_KB[s] <= 32) + lvl = "L1d"; // <=32K + else if (lat_KB[s] <= 512) + lvl = "L2"; // <=512K + else if (lat_KB[s] <= 16384) + lvl = "L3"; // <=16M + else + lvl = "DRAM"; + std::printf("%8ldK %10ld %10.2f %12s\n", lat_KB[s], elems, ns, lvl); + std::fflush(stdout); + } + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch02/pipeline_branch_ilp.cpp b/code/volumn_codes/vol6-performance/ch02/pipeline_branch_ilp.cpp new file mode 100644 index 000000000..997472e66 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch02/pipeline_branch_ilp.cpp @@ -0,0 +1,127 @@ +// pipeline_branch_ilp.cpp — vol6 ch02-03 用 +// A. 分支预测:已排序 vs 未排序数组的条件累加 +// B. ILP:单累加器(长依赖链)vs 4 累加器(独立并行链) +// 编译(关键 flag,故意关掉向量化/分支消除,才能演示标量分支与标量 ILP): +// g++ -O2 -std=c++17 -fno-tree-vectorize -fno-slp-vectorize -fno-if-conversion \ +// pipeline_branch_ilp.cpp -o pipeline_branch_ilp +// 跑: taskset -c 0 ./pipeline_branch_ilp +#include +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} +inline void do_not_optimize_f(float v) { + uint32_t u; + __builtin_memcpy(&u, &v, 4); + asm volatile("" : "+r"(u)::"memory"); +} + +constexpr int N = 32'768; + +// ---------- A. 分支预测 ---------- +// 最朴素写法:if 累加。配合 -fno-if-conversion -fno-tree-vectorize,汇编里会是真 jcc。 +uint64_t sum_gt128_scalar(const std::vector& d) { + uint64_t s = 0; + for (int i = 0; i < N; ++i) { + if (d[i] >= 128) + s += d[i]; + } + return s; +} + +// ---------- B. ILP ---------- +float dot1(const float* a, const float* b) { + float acc = 0.0f; + for (int i = 0; i < N; ++i) + acc += a[i] * b[i]; + return acc; +} +float dot4(const float* a, const float* b) { + float a0 = 0, a1 = 0, a2 = 0, a3 = 0; + for (int i = 0; i < N; i += 4) { + a0 += a[i] * b[i]; + a1 += a[i + 1] * b[i + 1]; + a2 += a[i + 2] * b[i + 2]; + a3 += a[i + 3] * b[i + 3]; + } + return a0 + a1 + a2 + a3; +} + +int main() { + // ===== A. 分支预测 ===== + std::vector data(N); + for (int i = 0; i < N; ++i) + data[i] = (uint8_t)(i * 17 + 7); + std::vector shuffled = data; + for (int i = N - 1; i > 0; --i) { + int j = (i * 7 + 3) % N; + std::swap(shuffled[i], shuffled[j]); + } + std::vector sorted = data; + std::sort(sorted.begin(), sorted.end()); + + const int REPEAT = 3000; + uint64_t sink = 0; + sum_gt128_scalar(shuffled); + sum_gt128_scalar(sorted); // warmup + auto t0 = clk::now(); + for (int r = 0; r < REPEAT; ++r) + sink += sum_gt128_scalar(shuffled); + do_not_optimize(sink); + auto t1 = clk::now(); + double t_shuffled = std::chrono::duration(t1 - t0).count() / REPEAT; + + t0 = clk::now(); + sink = 0; + for (int r = 0; r < REPEAT; ++r) + sink += sum_gt128_scalar(sorted); + do_not_optimize(sink); + auto t2 = clk::now(); + double t_sorted = std::chrono::duration(t2 - t0).count() / REPEAT; + + std::printf("===== A. 分支预测(条件累加 %d 元素,%d 次平均)=====\n", N, REPEAT); + std::printf(" 打乱(随机分支,预测器猜不中):%7.3f ms/次\n", t_shuffled); + std::printf(" 排序(模式清晰,几乎不预测失败):%7.3f ms/次\n", t_sorted); + std::printf(" 差 %.1fx\n", t_shuffled / t_sorted); + + // ===== B. ILP(每轮扰动输入 a[0],防循环不变量提升整体消除)===== + std::vector a(N), b(N); + for (int i = 0; i < N; ++i) { + a[i] = (float)i * 0.001f; + b[i] = (float)(N - i) * 0.001f; + } + + const int R2 = 4000; + dot1(a.data(), b.data()); + dot4(a.data(), b.data()); + float fs = 0; + t0 = clk::now(); + for (int r = 0; r < R2; ++r) { + a[0] = (float)r; + fs += dot1(a.data(), b.data()); + } + do_not_optimize_f(fs); + auto tb1 = clk::now(); + double t_dot1 = std::chrono::duration(tb1 - t0).count() / R2; + + fs = 0; + t0 = clk::now(); + for (int r = 0; r < R2; ++r) { + a[0] = (float)r; + fs += dot4(a.data(), b.data()); + } + do_not_optimize_f(fs); + auto tb2 = clk::now(); + double t_dot4 = std::chrono::duration(tb2 - t0).count() / R2; + + std::printf("\n===== B. ILP(点积 %d float,%d 次平均,标量无向量化)=====\n", N, R2); + std::printf(" 单累加器 dot1:%7.1f us/次 (一条长依赖链,CPU 只能等上次加完)\n", t_dot1); + std::printf(" 4 累加器 dot4:%7.1f us/次 (4 条独立链,CPU 并行填满执行端口)\n", t_dot4); + std::printf(" 差 %.2fx\n", t_dot1 / t_dot4); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch02/tlb_hugepage.cpp b/code/volumn_codes/vol6-performance/ch02/tlb_hugepage.cpp new file mode 100644 index 000000000..07e5874fa --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch02/tlb_hugepage.cpp @@ -0,0 +1,80 @@ +// tlb_hugepage.cpp — vol6 ch02-04 用 +// 行为级隔离 TLB 成本:同工作集,4KB 页 vs 2MB 大页(THP),指针追逐比延迟。 +// 大页让 TLB 项数减少 512 倍 → 若 TLB 是瓶颈,大页版本应该更快。 +// 编译: g++ -O2 -std=c++17 tlb_hugepage.cpp -o tlb_hugepage +// 跑: taskset -c 0 ./tlb_hugepage +#include +#include +#include +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +// 在 [base, base+nbytes) 上构造置乱单环,指针追逐测延迟 +double chase(const uint64_t* base, long nbytes) { + long elems = nbytes / 8; + std::vector perm(elems), nxt(elems); + for (long i = 0; i < elems; ++i) + perm[i] = i; + std::mt19937_64 rng(0xC0FFEEull); + for (long i = elems - 1; i > 0; --i) { + long j = (long)(rng() % (i + 1)); + std::swap(perm[i], perm[j]); + } + for (long i = 0; i < elems; ++i) + nxt[perm[i]] = perm[(i + 1) % elems]; + + const long ROUNDS = 4; + long total = elems * ROUNDS; + volatile uint64_t sink = 0; + long idx = 0; + // 写一遍 base 让页实际分配 + for (long i = 0; i < elems; ++i) + ((uint64_t*)base)[i] = (uint64_t)i; + auto t0 = clk::now(); + for (long s = 0; s < total; ++s) { + idx = nxt[idx]; + sink = sink + base[idx]; + } + auto t1 = clk::now(); + do_not_optimize(sink); + double secs = std::chrono::duration(t1 - t0).count(); + return secs / total * 1e9; // ns/访问 +} + +int main() { + const long SZ = 256L * 1024 * 1024; // 256 MB,远超 L3 与 L1 dTLB 覆盖范围 + + // 版本 A:普通匿名 mmap(4KB 页) + void* a4 = mmap(nullptr, SZ, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + // 版本 B:mmap 后 madvise(MADV_HUGEPAGE)请求透明大页 + void* a2 = mmap(nullptr, SZ, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + madvise(a2, SZ, MADV_HUGEPAGE); + + if (a4 == MAP_FAILED || a2 == MAP_FAILED) { + std::printf("mmap 失败\n"); + return 1; + } + + // warmup + chase((const uint64_t*)a4, SZ); + chase((const uint64_t*)a2, SZ); + + double t_4k = chase((const uint64_t*)a4, SZ); + double t_2m = chase((const uint64_t*)a2, SZ); + + std::printf("===== TLB 实验:256MB 工作集指针追逐(4KB 页 vs 2MB 大页)=====\n"); + std::printf(" 4KB 页版本:%6.1f ns/访问\n", t_4k); + std::printf(" 2MB 大页版本:%6.1f ns/访问 (若 WSL2 实际给了大页,这里应明显更快)\n", t_2m); + std::printf(" 比值:%.2f\n", t_4k / t_2m); + + // 尝试探一下大页是否真的启用(看 /proc/self/smaps 的 AnonHugePmdMapped 或类似) + // 简化:不解析 smaps,只报行为差。差值 ≈ 1 说明 WSL2 没给大页。 + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch03/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch03/CMakeLists.txt new file mode 100644 index 000000000..fd4419e94 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch03/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch03_attribution_methodology LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ch03-01 Roofline:解析地算几个内核的算术强度,标注带宽/算力受限。 +# 纯算术,无需 perf / 硬件计数器,任何机器都能跑。 +add_executable(roofline roofline.cpp) +target_compile_options(roofline PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# profile-commands.sh 是命令速查参考(需 perf/toplev/FlameGraph,本机 WSL2 无), +# 不构建目标。详见脚本内注释 + ch03-02/03。 diff --git a/code/volumn_codes/vol6-performance/ch03/README.md b/code/volumn_codes/vol6-performance/ch03/README.md new file mode 100644 index 000000000..ef320b3a9 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch03/README.md @@ -0,0 +1,22 @@ +# vol6 ch03 · 归因方法论 — 代码示例 + +对应文章:`documents/vol6-performance/ch03-attribution-methodology/` + +ch03 是方法论章,大部分工具(`perf` / `toplev` / 火焰图 / eBPF)是系统级 profiler,**本机 WSL2 没装这些**(文章里如实标注,profiler 输出引自 Bakhvalov/easyperf)。所以本章代码只有两个,**强调诚实**: + +- `roofline.cpp` —— **能本机跑**:解析地算 dot / axpy / 加权点积 / 矩阵乘 的算术强度,标注每个落在 Roofline 哪一侧。纯算术,验证 ch03-01 的判读逻辑。 +- `profile-commands.sh` —— **命令速查参考,不是可跑脚本**:列出 USE / perf / toplev / off-CPU / COZ 的标准命令,每段标注场景和对应文章。在装好工具的裸机 Linux 上逐段用。 + +## 构建 + +```bash +# roofline(本机可跑) +g++ -O2 -std=c++17 roofline.cpp -o roofline && ./roofline + +# profile-commands.sh 不构建,直接阅读 / 在目标环境逐段复制 +``` + +## 怎么读 + +- `roofline` 输出会告诉你 dot/axpy 是**带宽受限**(AI ~0.2,远低于脊点 ~6)、矩阵乘是**算力受限**(AI 高)。这直接对应 ch03-01 的优化方向判读:带宽受限减访存,算力受限加 SIMD。 +- `profile-commands.sh` 是你拿到一台装好 perf 的 Linux 上的工作手册。先 USE 排除系统问题,再 perf 火焰图看代码,toplev 下钻流水线,off-CPU 看等待。 diff --git a/code/volumn_codes/vol6-performance/ch03/profile-commands.sh b/code/volumn_codes/vol6-performance/ch03/profile-commands.sh new file mode 100644 index 000000000..db2824699 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch03/profile-commands.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# profile-commands.sh — vol6 ch03 profiler 命令速查(参考用,非一键脚本) +# +# ⚠️ 这些命令需要 perf / toplev / FlameGraph 脚本 / bpftrace,本机(WSL2)没装, +# 所以这不是「在这里跑」的脚本,而是「在装好工具的裸机 Linux 上怎么跑」的速查。 +# 每段标注对应文章哪一篇、什么场景用。详细解读见 ch03-02 / ch03-03。 +# +# 参考:Brendan Gregg(usemethod / FlameGraphs)、easyperf.net、Bakhvalov 书、pmu-tools。 + +set -e + +echo "本脚本是命令速查,不会真的跑全部命令(很多需要目标程序 + 特权)。" +echo "逐段复制到你装好工具的环境用。" +echo + +# ===== 1. USE 系统体检(ch03-01)— 最早期排查 ===== +echo "## 1. USE 系统体检 —— 排除「问题不在 CPU」" +cat <<'EOF' +vmstat 1 # %us+%sy(CPU)、r(运行队列)、si/so(换页>0 说明内存不够) +free -m # 内存余量 +iostat -xz 1 # 磁盘 %util / avgqu-sz +sar -n DEV 1 # 网络吞吐 +EOF +echo + +# ===== 2. perf on-CPU 采样 + 火焰图(ch03-03)===== +echo "## 2. perf on-CPU 采样 + 火焰图" +cat <<'EOF' +# 编译时务必加(否则栈断):g++ -O2 -g -fno-omit-frame-pointer ... +perf record -F 99 --call-graph dwarf -- ./your_app +perf script > out.perf +# 用 Brendan Gregg 的 FlameGraph 仓库脚本: +./stackcollapse-perf.pl out.perf > out.folded +./flamegraph.pl out.folded > out.svg +EOF +echo + +# ===== 3. TMAM toplev 工作流(ch03-02)===== +echo "## 3. TMAM 四桶下钻(pmu-tools/toplev,需 pip install + perf)" +cat <<'EOF' +toplev -l1 -- ./your_app # 四桶定主战场 +toplev -l2 -- ./your_app # 下钻(Backend → Memory vs Core) +toplev -l3 -- ./your_app # 再下钻到 cache 层级 + +# 用精确事件(:ppp)定位 cache miss 的具体汇编指令 +perf record -e MEM_LOAD_RETIRED.L3_MISS:ppp -- ./your_app +perf annotate # 汇编级看哪条指令在 miss +EOF +echo + +# ===== 4. off-CPU / 等延迟(ch03-03,需 bcc/bpftrace)===== +echo "## 4. off-CPU 火焰图(看「在等什么」,需 eBPF)" +cat <<'EOF' +# bcc 工具: +profile -af -p 10 > offcpu.out # 采 off-CPU 栈 +# 或 bpftrace 一行: +bpftrace -e 'profile:hz:99 { @[ustack] = count(); }' +EOF +echo + +# ===== 5. COZ 因果 profiling(ch03-03)===== +echo "## 5. COZ(告诉你优化哪个函数整体收益最大)" +cat <<'EOF' +# 链接 COZ 运行时编译:g++ ... -ldl -lpthread -lcoz +# 跑:COZ_... 环境变量控制,输出 profile 在 coz.out,浏览器看 +# 详见 curtsinger.cc/coz +EOF diff --git a/code/volumn_codes/vol6-performance/ch03/roofline.cpp b/code/volumn_codes/vol6-performance/ch03/roofline.cpp new file mode 100644 index 000000000..3467d76ee --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch03/roofline.cpp @@ -0,0 +1,52 @@ +// roofline.cpp — vol6 ch03-01 用 +// 解析地计算几个经典内核的算术强度(arithmetic intensity), +// 标注每个内核落在 Roofline 的哪一侧(带宽受限 vs 算力受限)。 +// 纯算术,无需 perf / 无需硬件计数器,任何机器都能跑。 +// 编译: g++ -O2 -std=c++17 roofline.cpp -o roofline +#include + +// 每个内核:名字、每次迭代的浮点运算数、每次迭代的内存流量(字节) +struct Kernel { + const char* name; + double flops_per_iter; + double bytes_per_iter; +}; + +int main() { + Kernel kernels[] = { + // dot = Σ a[i]*b[i]:2 FLOP(乘+加),读 2 float = 8 B + {"dot (a·b)", 2.0, 2 * 4.0}, + // axpy y = αx+y:2 FLOP,读 2 float + 写 1 float = 12 B + {"axpy (y=αx+y)", 2.0, 3 * 4.0}, + // 加权点积 w*x*y:3 FLOP(两乘一加),读 3 float = 12 B + {"wdot (w·x·y)", 3.0, 3 * 4.0}, + // 矩阵乘 C+=A·B(每输出元素):2 FLOP,但 A/B 元素被复用 —— 这里给「分块后」的有效 AI + // 用一个高 AI 代表(compute-bound 典型) + {"matmul (分块后)", 2.0 * 64.0, 3 * 4.0}, // 假设每元素被复用 ~64 次 + }; + + // Roofline 脊点(rough,给量级):峰值算力 / 峰值带宽 + // 5800H 量级:峰值 FP32 ~256 GFLOPS(2 FMA×8 float×2×~4GHz 量级), + // DDR4 峰值带宽 ~40 GB/s。脊点 AI ≈ 256/40 ≈ 6.4 FLOP/byte + // 注意:这俩数随 turbo/AVX 模式/内存条浮动,这里只给量级做判读。 + const double PEAK_FLOPS = 256e9; // FLOP/s(量级近似) + const double PEAK_BW = 40e9; // B/s(量级近似) + const double ridge_ai = PEAK_FLOPS / PEAK_BW; // 脊点算术强度 + + std::printf("===== Roofline 解析判读 =====\n"); + std::printf("本机量级:峰值 %.0f GFLOPS,峰值带宽 %.0f GB/s,脊点 AI ≈ %.1f FLOP/byte\n", + PEAK_FLOPS / 1e9, PEAK_BW / 1e9, ridge_ai); + std::printf("\n%-22s %12s %12s %s\n", "内核", "AI(FLOP/B)", "vs 脊点", "判读"); + std::printf("------------------------------------------------------------\n"); + for (const auto& k : kernels) { + double ai = k.flops_per_iter / k.bytes_per_iter; + const char* verdict = (ai < ridge_ai) ? "带宽受限(memory-bound)" : "算力受限(core-bound)"; + const char* cmp = (ai < ridge_ai) ? "< 脊点" : "≥ 脊点"; + std::printf("%-22s %12.3f %12s %s\n", k.name, ai, cmp, verdict); + } + + std::printf("\n判读含义:\n"); + std::printf(" 带宽受限 → 优化方向是减访存(SoA、更宽 load、减数据移动),加 SIMD 无用\n"); + std::printf(" 算力受限 → 优化方向是加算力(SIMD、减指令、打破依赖链),减访存无用\n"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch04/CMakeLists.txt new file mode 100644 index 000000000..5728ecfbf --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch04_tuning_by_bottleneck LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# 04-01 后端内存:AoS vs SoA + 对齐/padding +add_executable(backend_memory backend_memory.cpp) +target_compile_options(backend_memory PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 04-02 循环与计算:code motion / 消除内存引用(多累加器见 ch02/pipeline_branch_ilp) +add_executable(loop_opt loop_opt.cpp) +target_compile_options(loop_opt PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 04-03 数据类型与算术:除法瓶颈 / 整数 vs 浮点 / switch 跳转表 +add_executable(arithmetic_cost arithmetic_cost.cpp) +target_compile_options(arithmetic_cost PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 04-04 / 06-01 虚函数与去虚拟化 +add_executable(virtual_devirt virtual_devirt.cpp) +target_compile_options(virtual_devirt PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 04-05 SIMD:标量 vs 手写 AVX2。⚠️ AVX2+FMA 必须显式开(-mavx2 -mfma), +# 且 FP 归约标量版默认不会被自动向量化(结合律),详见文章。 +add_executable(simd_avx2 simd.cpp) +target_compile_options(simd_avx2 PRIVATE -O3 -mavx2 -mfma -Wall -Wextra -Wpedantic) +# 对照:不开 AVX2 的标量基线 +add_executable(simd_scalar simd.cpp) +target_compile_options(simd_scalar PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 04-06 分支:branchless / predication +add_executable(branchless branchless.cpp) +target_compile_options(branchless PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 04-07 / 07-02 PGO 演示。⚠️ 这个程序的完整 PGO 流程是三阶段(见 README.pgo.sh), +# 直接 cmake build 出来的是「纯 -O2 无 PGO」基线版,用于对照。 +add_executable(pgo_demo pgo_demo.cpp) +target_compile_options(pgo_demo PRIVATE -O2 -Wall -Wextra -Wpedantic) diff --git a/code/volumn_codes/vol6-performance/ch04/README.md b/code/volumn_codes/vol6-performance/ch04/README.md new file mode 100644 index 000000000..ed205cd7f --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/README.md @@ -0,0 +1,48 @@ +# vol6 ch04 · 按瓶颈部位优化 — 代码示例 + +对应文章:`documents/vol6-performance/ch04-tuning-by-bottleneck/` + +七个程序,对应 ch04 的四桶(TMA)对策。每个程序都是**本机实测**出来的真实数字,文章里贴的结果都能复现。**关心比例和趋势,绝对数字随 CPU/编译器/环境变。** + +## 构建 + +```bash +# 单独编译(推荐) +g++ -O2 -std=c++17 backend_memory.cpp -o backend_memory && taskset -c 0 ./backend_memory +g++ -O2 -std=c++17 loop_opt.cpp -o loop_opt && taskset -c 0 ./loop_opt +g++ -O2 -std=c++17 arithmetic_cost.cpp -o arithmetic_cost && taskset -c 0 ./arithmetic_cost +g++ -O2 -std=c++17 virtual_devirt.cpp -o virtual_devirt && taskset -c 0 ./virtual_devirt +g++ -O2 -std=c++17 branchless.cpp -o branchless && taskset -c 0 ./branchless + +# SIMD:标量基线 vs 手写 AVX2(⚠️ 必须显式开 -mavx2 -mfma) +g++ -O2 simd.cpp -o simd_scalar && taskset -c 0 ./simd_scalar +g++ -O3 -mavx2 -mfma simd.cpp -o simd_avx2 && taskset -c 0 ./simd_avx2 + +# PGO:三阶段流程(见 pgo.sh,不是单次编译) +bash pgo.sh + +# 或用 CMake 一次构建全部(CMakeLists 已配好各 target 的 flag) +cmake -B build && cmake --build build +``` + +## 程序对照(每个对应一桶/一篇) + +| 程序 | 文章 | 桶 | 核心数字 | +|---|---|---|---| +| `backend_memory` | 04-01 | Backend Memory | AoS vs SoA:**9.79×** | +| `loop_opt` | 04-02 | Backend Core | code motion/mem-ref 微弱(编译器已做);多累加器见 ch02-03 的 2.92× | +| `arithmetic_cost` | 04-03 | Backend Core | 除法/乘法 **5.0×**;switch vs if-else 0.90×(switch 不总赢) | +| `virtual_devirt` | 04-04/06-01 | Backend Core/Frontend | virtual/CRTP **2.5×**;final 没自动去虚化 | +| `simd` | 04-05 | Backend Core | 手写 AVX2 vs 标量 **~20×**;FP 归约不自动向量化 | +| `branchless` | 04-06 | Bad Speculation | if≈cmov(1.07×,编译器已 branchless);真分支惩罚看 ch02-03 的 4.2× | +| `pgo_demo` | 04-07/07-02 | Frontend | PGO 对微基准**无收益**(~3.7 vs ~3.9 ms)——诚实 null 结果 | + +## 几个诚实的「翻车/反直觉」结果(文章里都展开了) + +- **switch 不总比 if-else 快**:case 少且均匀时,if-else 可能略快(间接跳转预测器)。 +- **final 没自动触发去虚化**:去虚化要看编译器能否证明类型,不是 `final` 一标就行。 +- **FP 归约默认不被自动向量化**(结合律),需 `-ffast-math` 或手写多累加器。 +- **branchless 在 -O2 下和 if 一样快**:编译器已转 cmov,看汇编确认是不是真分支。 +- **PGO 对微基准无收益**(那个一度出现的 4× 是仪器化开销,不是 PGO)。 + +这些「不漂亮」的结果是 ch04 的教学价值所在:**现代编译器 + 硬件替你做了大半,性能优化不是堆 trick,是测量驱动的精准手术**。 diff --git a/code/volumn_codes/vol6-performance/ch04/arithmetic_cost.cpp b/code/volumn_codes/vol6-performance/ch04/arithmetic_cost.cpp new file mode 100644 index 000000000..b80d9ec93 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/arithmetic_cost.cpp @@ -0,0 +1,135 @@ +// arithmetic_cost.cpp — vol6 ch04-03 数据类型与算术 +// A. 除法是瓶颈:整数除法 vs 乘法 vs 位运算(除数是 2 的幂) +// B. 整数除法 vs 浮点乘法的「换算」trick(x/常数 → x*(1/常数)) +// C. switch vs if-else 链(本例 case 连续,-O2 折成算术,无跳转表;case 稀疏才生成跳转表) +// 编译: g++ -O2 -std=c++17 arithmetic_cost.cpp -o arithmetic_cost +// 看 codegen: g++ -O2 -S arithmetic_cost.cpp(本例 -O2 下 switch/ife 都被折成算术,jmp * 数=0) +// 跑: taskset -c 0 ./arithmetic_cost +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +constexpr int N = 1 << 20; + +template double time_ns(F&& f, int repeat) { + f(); + f(); + auto t0 = clk::now(); + for (int r = 0; r < repeat; ++r) + f(); + auto t1 = clk::now(); + return std::chrono::duration(t1 - t0).count() / repeat / N; +} + +int main() { + std::vector a(N), b(N); + for (int i = 0; i < N; ++i) { + a[i] = i * 7 + 1; + b[i] = i * 3 + 1; + } + volatile uint32_t sink = 0; + const int REP = 50; + + // ===== A. 除法瓶颈 ===== + // 除数是常量 8(2 的幂):编译器应优化成位移,和手写位移一样快 + double t_div_pow2 = time_ns( + [&] { + for (int i = 0; i < N; ++i) + sink += a[i] / 8; + }, + REP); + double t_shift = time_ns( + [&] { + for (int i = 0; i < N; ++i) + sink += a[i] >> 3; + }, + REP); + // 除数是运行期值(编译器不能换成位移/乘法逆元) + volatile uint32_t d = 7; + double t_div_var = time_ns( + [&] { + for (int i = 0; i < N; ++i) + sink += a[i] / d; + }, + REP); + double t_mul = time_ns( + [&] { + for (int i = 0; i < N; ++i) + sink += a[i] * 3; + }, + REP); + do_not_optimize(sink); + + std::printf("===== A. 整数算术成本(%d 元素,每次平均 ns)=====\n", N); + std::printf(" x/8 (除数=2 的幂,编译器换位移):%5.2f ns\n", t_div_pow2); + std::printf(" x>>3 (手写位移) :%5.2f ns\n", t_shift); + std::printf(" x/7 (除数=运行期变量) :%5.2f ns ← 除法瓶颈\n", t_div_var); + std::printf(" x*3 (乘法) :%5.2f ns\n", t_mul); + std::printf(" 除法(变量)/乘法 = %.1fx\n", t_div_var / t_mul); + + // ===== B. switch vs if-else(跳转表)===== + auto sw = [](int x) -> int { + switch (x % 8) { + case 0: + return 100; + case 1: + return 101; + case 2: + return 102; + case 3: + return 103; + case 4: + return 104; + case 5: + return 105; + case 6: + return 106; + default: + return 107; + } + }; + auto ife = [](int x) -> int { + int r = x % 8; + if (r == 0) + return 100; + else if (r == 1) + return 101; + else if (r == 2) + return 102; + else if (r == 3) + return 103; + else if (r == 4) + return 104; + else if (r == 5) + return 105; + else if (r == 6) + return 106; + else + return 107; + }; + volatile int s2 = 0; + double t_switch = time_ns( + [&] { + for (int i = 0; i < N; ++i) + s2 += sw(i); + }, + REP); + double t_ifelse = time_ns( + [&] { + for (int i = 0; i < N; ++i) + s2 += ife(i); + }, + REP); + do_not_optimize((uint64_t)s2); + std::printf("\n===== B. switch vs if-else 链 =====\n"); + std::printf(" switch: %5.2f ns\n", t_switch); + std::printf(" if-else: %5.2f ns\n", t_ifelse); + std::printf(" if-else/switch = %.2fx\n", t_ifelse / t_switch); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/backend_memory.cpp b/code/volumn_codes/vol6-performance/ch04/backend_memory.cpp new file mode 100644 index 000000000..7e6fad5dc --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/backend_memory.cpp @@ -0,0 +1,78 @@ +// backend_memory.cpp — vol6 ch04-01 后端内存 +// A. AoS vs SoA:粒子「只更新位置」场景,SoA 比 AoS 快多少 +// B. 结构体对齐/padding:sizeof 与 cacheline 利用率 +// C. 软件 prefetch 在不规则访问里有没有用 +// 编译: g++ -O2 -std=c++17 backend_memory.cpp -o backend_memory +// 跑: taskset -c 0 ./backend_memory +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(float v) { + uint32_t u; + __builtin_memcpy(&u, &v, 4); + asm volatile("" : "+r"(u)::"memory"); +} +inline void do_not_optimize_p(const void* p) { + asm volatile("" : "+r"(p)::"memory"); +} + +constexpr int N = 1 << 20; // 100 万粒子 + +// ----- A. AoS vs SoA ----- +struct ParticleAoS { + float x, y, z, vx, vy, vz; +}; // 24B,无 pad +struct ParticleAoS8 { + float x, y, z, vx, vy, vz, pad1, pad2; +}; // 32B(填到 8 的倍数) + +void update_aos_x(ParticleAoS* p, int n) { + for (int i = 0; i < n; ++i) + p[i].x += p[i].vx * 0.016f; // 只动 x,但每行把 vx..vz 也拉进 cache +} +struct ParticlesSoA { + std::vector x, y, z, vx, vy, vz; + ParticlesSoA(int n) : x(n), y(n), z(n), vx(n), vy(n), vz(n) {} +}; +void update_soa_x(ParticlesSoA& p, int n) { + for (int i = 0; i < n; ++i) + p.x[i] += p.vx[i] * 0.016f; // 只碰 x 和 vx 数组 +} + +template double time_ms(F&& f, int repeat) { + f(); + f(); // warmup + auto t0 = clk::now(); + for (int r = 0; r < repeat; ++r) + f(); + auto t1 = clk::now(); + return std::chrono::duration(t1 - t0).count() / repeat; +} + +int main() { + // ===== B. sizeof 与 cacheline 利用率 ===== + std::printf("===== B. 结构体布局 =====\n"); + std::printf(" sizeof(ParticleAoS) = %zu B\n", sizeof(ParticleAoS)); + std::printf(" sizeof(ParticleAoS8) = %zu B(pad 到 32)\n", sizeof(ParticleAoS8)); + std::printf(" 64B cacheline 能装:AoS=%zu 个,AoS8=%zu 个\n", 64 / sizeof(ParticleAoS), + 64 / sizeof(ParticleAoS8)); + + // ===== A. AoS vs SoA ===== + std::vector aos(N); + ParticlesSoA soa(N); + for (int i = 0; i < N; ++i) { + aos[i] = {0, 0, 0, (float)i, (float)i, (float)i}; + soa.x[i] = 0; + soa.vx[i] = (float)i; + } + double t_aos = time_ms([&] { update_aos_x(aos.data(), N); }, 20); + double t_soa = time_ms([&] { update_soa_x(soa, N); }, 20); + std::printf("\n===== A. AoS vs SoA(更新 %d 粒子的 x,20 次平均)=====\n", N); + std::printf(" AoS:%6.2f ms/次(每行把不更新的 y/z/vy/vz 也拉进 cache,带宽浪费)\n", t_aos); + std::printf(" SoA:%6.2f ms/次(只碰 x、vx 数组,cacheline 利用率近 100%%)\n", t_soa); + std::printf(" SoA 快 %.2fx\n", t_aos / t_soa); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/branchless.cpp b/code/volumn_codes/vol6-performance/ch04/branchless.cpp new file mode 100644 index 000000000..dc9d9d7e4 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/branchless.cpp @@ -0,0 +1,88 @@ +// branchless.cpp — vol6 ch04-06 分支:branchless 与 predication +// 不可预测分支的三种写法:if 分支 / std::min/max / 位运算 trick +// 编译: g++ -O2 -std=c++17 branchless.cpp -o branchless +// 跑: taskset -c 0 ./branchless +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +constexpr int N = 1 << 20; + +// 场景:clamp(x, lo, hi),数据随机(分支不可预测) +template double time_ns(F&& f, int repeat) { + f(); + f(); + auto t0 = clk::now(); + for (int r = 0; r < repeat; ++r) + f(); + auto t1 = clk::now(); + return std::chrono::duration(t1 - t0).count() / repeat / N; +} + +int main() { + std::vector v(N); + for (int i = 0; i < N; ++i) + v[i] = (int)(((unsigned)i * 1103515245u + 12345u) & 0xFFFF); // 伪随机 0..65535 + const int LO = 16384, HI = 49152; // 中段,使分支约 50/50(最难预测) + const int REP = 50; + volatile uint64_t sink = 0; + + // 1. if 分支(配合 -fno-if-conversion 保留真分支;否则编译器可能自动无分支化) + double t_if = time_ns( + [&] { + uint64_t s = 0; + for (int x : v) { + if (x < LO) + x = LO; + else if (x > HI) + x = HI; + s += x; + } + sink += s; + }, + REP); + + // 2. std::min/std::max(无分支写法,-O2 在循环里常被向量化成 SIMD 掩码) + double t_cmov = time_ns( + [&] { + uint64_t s = 0; + for (int& x : v) { + x = std::max(x, LO); + x = std::min(x, HI); + s += x; + } + sink += s; + }, + REP); + + // 3. 位运算 clamp(教科书 branchless) + auto bit_clamp = [](int x, int lo, int hi) -> int { + // 利用「无分支」条件传送的位 trick(编译器会进一步优化) + int t = (x < lo) ? lo : x; + return (t > hi) ? hi : t; + }; + double t_bit = time_ns( + [&] { + uint64_t s = 0; + for (int x : v) + s += bit_clamp(x, LO, HI); + sink += s; + }, + REP); + + do_not_optimize(sink); + std::printf("===== branchless vs 分支(clamp,随机数据 50/50)=====\n"); + std::printf(" if 分支: %5.2f ns/次(预测失败冲刷)\n", t_if); + std::printf(" std::min/max: %5.2f ns/次\n", t_cmov); + std::printf(" 位 trick: %5.2f ns/次\n", t_bit); + std::printf(" if / cmov = %.2fx\n", t_if / t_cmov); + std::printf(" 注:默认 -O2 下 GCC 常把循环里的 if 自动无分支化(本例 -S 下 cmov 数=0,是 SIMD " + "掩码向量化),要看汇编确认\n"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/loop_opt.cpp b/code/volumn_codes/vol6-performance/ch04/loop_opt.cpp new file mode 100644 index 000000000..9ef85bb8e --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/loop_opt.cpp @@ -0,0 +1,94 @@ +// loop_opt.cpp — vol6 ch04-02 循环与计算优化 +// 三个经典变换: +// A. code motion:把循环不变量提到循环外 +// B. 消除不必要的内存引用:每次循环都 store 中间结果 vs 寄存器累加最后 store 一次 +// C. 多累加器打破依赖链(呼应 ch02-03 的 dot1/dot4) +// 编译: g++ -O2 -std=c++17 loop_opt.cpp -o loop_opt +// (注意:-O2 下 B 的两版可能被编译器优化成一样,看汇编确认;必要时 -O1 对照) +// 跑: taskset -c 0 ./loop_opt +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize_f(float v) { + uint32_t u; + __builtin_memcpy(&u, &v, 4); + asm volatile("" : "+r"(u)::"memory"); +} + +constexpr int N = 1 << 20; + +template double time_us(F&& f, int repeat) { + f(); + f(); + auto t0 = clk::now(); + for (int r = 0; r < repeat; ++r) + f(); + auto t1 = clk::now(); + return std::chrono::duration(t1 - t0).count() / repeat; +} + +int main() { + std::vector a(N), b(N), c(N); + for (int i = 0; i < N; ++i) { + a[i] = (float)i; + b[i] = (float)(i % 7); + c[i] = 0; + } + const int REP = 50; + + // ===== B. 消除不必要的内存引用(典型 CSAPP 例子:对数组元素累加)===== + // 「坏」:每次循环都从 c[i] 读、写回 c[i](c[i] 在循环里不变,却反复访存) + // —— 但这例子里 c[i] 每次变,改用「连续写」对比「重复读-写」 + // 更贴 CSAPP 的例子:sum 存到数组元素 vs 寄存器 + volatile float scale = 2.5f; + // 烂:把临时累加器放在内存(数组)里,每次循环 load+store + double t_memref = time_us( + [&] { + for (int i = 0; i < N; ++i) + c[i] = c[i] + a[i] * b[i] * scale; // c[i] 反复 load/store + }, + REP); + // 好:寄存器累加,最后一次 store + double t_reg = time_us( + [&] { + for (int i = 0; i < N; ++i) + c[i] = a[i] * b[i] * scale; // 直接写,无读回 + }, + REP); + do_not_optimize_f(c[N / 2]); + + std::printf("===== B. 消除不必要的内存引用(%d 元素,%d 次平均)=====\n", N, REP); + std::printf(" 反复读写 c[i]:%6.1f us\n", t_memref); + std::printf(" 直接写 c[i]: %6.1f us\n", t_reg); + std::printf(" (差距取决于编译器能否证明 c[i] 可省 load;-O2 常已优化掉)\n"); + + // ===== A. code motion(循环不变量外提)===== + // 烂:每次循环重算 b[i]*scale(scale 不变却每次 load) + // 好:scale 外提(编译器对 volatile scale 做不到,正好演示) + double t_nomotion = time_us( + [&] { + float s = 0; + for (int i = 0; i < N; ++i) + s += a[i] * b[i] * scale; + s += 1; + do_not_optimize_f(s); + }, + REP); + float sc = scale; // 外提到普通变量,编译器能 hoist + double t_motion = time_us( + [&] { + float s = 0; + for (int i = 0; i < N; ++i) + s += a[i] * b[i] * sc; + s += 1; + do_not_optimize_f(s); + }, + REP); + std::printf("\n===== A. code motion(循环不变量外提)=====\n"); + std::printf(" scale 是 volatile(每次 load):%6.1f us\n", t_nomotion); + std::printf(" scale 外提到普通变量: %6.1f us\n", t_motion); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/pgo.sh b/code/volumn_codes/vol6-performance/ch04/pgo.sh new file mode 100644 index 000000000..e99851dac --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/pgo.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# pgo.sh — vol6 ch04-07 / ch07-02 的 PGO 三阶段构建(对照用) +# +# 用 pgo_demo.cpp 演示 Profile-Guided Optimization 的完整三阶段流程, +# 并和「纯 -O2 无 PGO」基线对照。 +# +# ⚠️ 本机(WSL2)实测结论:PGO 对这个微基准【无收益】(~3.7 vs ~3.9 ms,噪声内)。 +# PGO 的价值在大型代码库的代码布局,不在微基准。详见 ch04-07 文章里的诚实讨论。 +# +# 跑: bash pgo.sh +set -e +cd "$(dirname "$0")" + +echo "===== 阶段 0:纯 -O2 基线(无仪器化、无 profile)=====" +g++ -O2 -std=c++17 pgo_demo.cpp -o pgo_plain + +echo "===== 阶段 1:仪器化编译(-fprofile-generate)=====" +g++ -O2 -std=c++17 -fprofile-generate pgo_demo.cpp -o pgo_demo + +echo "===== 阶段 2:跑代表性 workload,生成 .gcda 剖面 =====" +./pgo_demo >/dev/null +ls *.gcda && echo "(剖面已生成)" + +echo "===== 阶段 3:用剖面重编(-fprofile-use,【同名构建】让 gcda 名匹配)=====" +# ⚠️ 关键:gcda 文件名 = <二进制名>-<源名>.gcda。生成和重编必须用同一个二进制名, +# 否则 -fprofile-use 找不到 profile(会 warning "profile count data file not found")。 +g++ -O2 -std=c++17 -fprofile-use pgo_demo.cpp -o pgo_demo 2>&1 | grep -iE "warning|error" || echo "(无 warning,profile 正确应用)" + +echo "" +echo "===== 对比(各跑 3 次取稳)=====" +for i in 1 2 3; do + echo "--- 第 $i 轮 ---" + echo -n " 纯 -O2 基线: "; taskset -c 0 ./pgo_plain + echo -n " -O2 + PGO: "; taskset -c 0 ./pgo_demo +done +echo "" +echo "结论:对这个微基准,两者应基本持平(PGO 收益在大型代码库,不在小函数)。" diff --git a/code/volumn_codes/vol6-performance/ch04/pgo_demo.cpp b/code/volumn_codes/vol6-performance/ch04/pgo_demo.cpp new file mode 100644 index 000000000..a6d554e01 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/pgo_demo.cpp @@ -0,0 +1,54 @@ +// pgo_demo.cpp — vol6 ch04-07 / ch07-02 PGO 演示 +// 一个「分支概率极度倾斜」的函数:99% 的输入走 same path,1% 走 rare path。 +// PGO 能让编译器把 hot path 布局到一起(icache 友好)+ 优化分支预测。 +// 编译(三阶段 PGO;完整脚本见 pgo.sh,这里只给要点): +// ⚠️ 阶段1 和阶段3 的 -o 二进制名必须一致,否则 .gcda 文件名对不上、profile 不会生效。 +// 阶段1 仪器化: g++ -O2 -std=c++17 -fprofile-generate pgo_demo.cpp -o pgo_demo +// 阶段2 跑剖面: ./pgo_demo (生成 pgo_demo-pgo_demo.cpp.gcda,真实分布 99/1) +// 阶段3 用剖面: g++ -O2 -std=c++17 -fprofile-use pgo_demo.cpp -o pgo_demo +// 对比:见 pgo.sh(纯 -O2 基线 pgo_plain vs 带 PGO 的 pgo_demo) +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +// 模拟一个分支倾斜的解析器:大多数 token 走 fast path,少数走慢路径 +int process(int token) { + if (token % 100 == 0) { + // rare path:99% 不进 + int x = 0; + for (int i = 0; i < 10; ++i) + x += token * i; + return x + 1; + } + return token * 2 + 1; // hot path +} + +int main(int argc, char** argv) { + constexpr int N = 1 << 23; // 800 万 + std::vector data(N); + // 真实分布:99% 是普通 token,1% 是特殊 token + for (int i = 0; i < N; ++i) + data[i] = (i % 100 == 0) ? 100 : (i * 7 + 1); + + const int REP = 20; + process(1); + process(100); // warmup + auto t0 = clk::now(); + uint64_t s = 0; + for (int r = 0; r < REP; ++r) + for (int i = 0; i < N; ++i) + s += process(data[i]); + auto t1 = clk::now(); + do_not_optimize(s); + double ms = std::chrono::duration(t1 - t0).count() / REP; + std::printf("process %d tokens,%d 次平均:%.2f ms/次 (sum=%llu)\n", N, REP, ms, + (unsigned long long)s); + std::printf("(此二进制:%s)\n", argc > 1 ? argv[1] : "未标注"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/simd.cpp b/code/volumn_codes/vol6-performance/ch04/simd.cpp new file mode 100644 index 000000000..d1dcedd44 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/simd.cpp @@ -0,0 +1,93 @@ +// simd.cpp — vol6 ch04-05 SIMD 与向量化 +// 同一个数组点积的三种实现:标量 / 编译器自动向量化(-O3 -ftree-vectorize)/ 手写 AVX2 intrinsics +// 编译: +// g++ -O2 -std=c++17 simd.cpp -o simd_o2 -mavx2 -mfma (标量 SSE 向量化,单累加器) +// g++ -O3 -std=c++17 simd.cpp -o simd_o3 -mavx2 -mfma (标量 AVX2 ordered 向量化,单累加器) +// g++ -O3 -std=c++17 simd.cpp -o simd_fm -mavx2 -mfma -ffast-math (允许重结合;本例标量无显著收益) +// 都跑,看 dot_scalar 三档都远慢于 dot_avx2 —— 差距主要来自累加器数(ILP),不是向量化与否 +// 跑: taskset -c 0 ./simd_o2 && taskset -c 0 ./simd_o3 +#include +#include +#include +#include + +#if defined(__AVX2__) +# include +#endif + +using clk = std::chrono::steady_clock; +inline void do_not_optimize_f(float v) { + uint32_t u; + __builtin_memcpy(&u, &v, 4); + asm volatile("" : "+r"(u)::"memory"); +} + +constexpr int N = 1 << 22; // 4M float = 16MB + +// 标量点积(单累加器,长依赖链) +float dot_scalar(const float* a, const float* b) { + float acc = 0.0f; + for (int i = 0; i < N; ++i) + acc += a[i] * b[i]; + return acc; +} + +// 手写 AVX2(8 float/向量,多累加器) +#if defined(__AVX2__) +float dot_avx2(const float* a, const float* b) { + __m256 v0 = _mm256_setzero_ps(), v1 = _mm256_setzero_ps(); + __m256 v2 = _mm256_setzero_ps(), v3 = _mm256_setzero_ps(); + for (int i = 0; i < N; i += 32) { // 每次 32 个 float = 4 条 AVX2 向量 + v0 = _mm256_fmadd_ps(_mm256_loadu_ps(a + i), _mm256_loadu_ps(b + i), v0); + v1 = _mm256_fmadd_ps(_mm256_loadu_ps(a + i + 8), _mm256_loadu_ps(b + i + 8), v1); + v2 = _mm256_fmadd_ps(_mm256_loadu_ps(a + i + 16), _mm256_loadu_ps(b + i + 16), v2); + v3 = _mm256_fmadd_ps(_mm256_loadu_ps(a + i + 24), _mm256_loadu_ps(b + i + 24), v3); + } + __m256 lo12 = _mm256_add_ps(v0, v1); + __m256 hi12 = _mm256_add_ps(v2, v3); + __m256 s = _mm256_add_ps(lo12, hi12); + alignas(32) float tmp[8]; + _mm256_store_ps(tmp, s); + float acc = 0; + for (int i = 0; i < 8; ++i) + acc += tmp[i]; + return acc; +} +#endif + +int main() { + std::vector a(N), b(N); + for (int i = 0; i < N; ++i) { + a[i] = (float)i * 1e-7f; + b[i] = (float)(N - i) * 1e-7f; + } + const int REP = 30; + + auto bench = [&](auto fn, const char* name) { + fn(a.data(), b.data()); + fn(a.data(), b.data()); + auto t0 = clk::now(); + float s = 0; + for (int r = 0; r < REP; ++r) + s += fn(a.data(), b.data()); + auto t1 = clk::now(); + do_not_optimize_f(s); + double us = std::chrono::duration(t1 - t0).count() / REP; + std::printf(" %-30s %7.1f us\n", name, us); + return us; + }; + + std::printf("===== SIMD 点积(%d float = %d MB,%d 次平均)=====\n", N, N * 4 / (1024 * 1024), + REP); + double t_scalar = bench(dot_scalar, "标量 dot_scalar"); +#if defined(__AVX2__) + double t_avx2 = bench(dot_avx2, "手写 AVX2 dot_avx2"); + std::printf(" AVX2/标量 = %.1fx\n", t_scalar / t_avx2); +#else + std::printf(" (本编译未开 -mavx2,跳过 intrinsics)\n"); +#endif + std::printf(" 注:dot_scalar 在 -O3 -mavx2 下其实已被向量化(-fopt-info-vec-optimized 可证),\n"); + std::printf(" 但单累加器依赖链没打破,远慢于 4 累加器的 " + "dot_avx2(差距主要来自累加器数,不是向量化与否)。\n"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp b/code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp new file mode 100644 index 000000000..08c9a2a9f --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp @@ -0,0 +1,110 @@ +// virtual_devirt.cpp — vol6 ch04-04 / ch06-01 虚函数与去虚拟化 +// 比较四种调用:普通虚函数 / final 类 / CRTP(静态多态)/ 编译器能证明类型时自动去虚化 +// 编译: g++ -O2 -std=c++17 virtual_devirt.cpp -o virtual_devirt +// 跑: taskset -c 0 ./virtual_devirt +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +// 基类 + 虚函数 +struct Base { + virtual int compute(int x) const = 0; + virtual ~Base() = default; +}; +struct Derived : Base { + int compute(int x) const override { return x * 3 + 1; } +}; +// final:告诉编译器没有更派生类,可去虚化 +struct BaseF { + virtual int compute(int x) const = 0; + virtual ~BaseF() = default; +}; +struct DerivedF final : BaseF { + int compute(int x) const override { return x * 3 + 1; } +}; +// CRTP:静态多态,编译期绑定,无虚表 +template struct CrtpBase { + int compute(int x) const { return static_cast(this)->impl(x); } +}; +struct CrtpDerived : CrtpBase { + int impl(int x) const { return x * 3 + 1; } +}; + +template double time_ns(F&& f, int repeat, int n) { + f(); + f(); + auto t0 = clk::now(); + for (int r = 0; r < repeat; ++r) + f(); + auto t1 = clk::now(); + return std::chrono::duration(t1 - t0).count() / repeat / n; +} + +int main() { + constexpr int N = 1 << 22; + const int REP = 30; + std::vector in(N); + for (int i = 0; i < N; ++i) + in[i] = i; + volatile uint64_t sink = 0; + + // 1. 普通虚函数(通过指针,编译器不能证明运行时类型) + Base* b = new Derived(); + double t_virtual = time_ns( + [&] { + uint64_t s = 0; + for (int x : in) + s += b->compute(x); + sink += s; + }, + REP, N); + + // 2. final 类:编译器知道没有更派生,可去虚化 + BaseF* bf = new DerivedF(); + double t_final = time_ns( + [&] { + uint64_t s = 0; + for (int x : in) + s += bf->compute(x); + sink += s; + }, + REP, N); + + // 3. CRTP:静态多态,无虚表,可内联 + CrtpDerived cd; + double t_crtp = time_ns( + [&] { + uint64_t s = 0; + for (int x : in) + s += cd.compute(x); + sink += s; + }, + REP, N); + + // 4. 直接对象(非指针/引用):编译器常能去虚化 + Derived d; + double t_direct = time_ns( + [&] { + uint64_t s = 0; + for (int x : in) + s += d.compute(x); + sink += s; + }, + REP, N); + + do_not_optimize(sink); + std::printf("===== 虚函数与去虚拟化(%d 次调用,平均 ns/次)=====\n", N); + std::printf(" 虚函数(指针,运行时多态): %5.2f ns ← 查 vtable + 间接跳转,阻碍内联\n", + t_virtual); + std::printf(" final 类(编译器去虚化): %5.2f ns\n", t_final); + std::printf(" 直接对象(非指针,常去虚化):%5.2f ns\n", t_direct); + std::printf(" CRTP(静态多态,无虚表): %5.2f ns ← 可内联\n", t_crtp); + std::printf(" 虚函数/CRTP = %.1fx\n", t_virtual / t_crtp); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch05/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch05/CMakeLists.txt new file mode 100644 index 000000000..e392d363a --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch05/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch05_multicore_performance LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +find_package(Threads REQUIRED) + +# 05-01 伪共享:同 cacheline vs alignas(64) +add_executable(false_sharing false_sharing.cpp) +target_link_libraries(false_sharing PRIVATE Threads::Threads) +target_compile_options(false_sharing PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 05-02 扩展性曲线:1/2/4/8 线程(NUMA 在本机单节点测不了) +add_executable(scalability scalability.cpp) +target_link_libraries(scalability PRIVATE Threads::Threads) +target_compile_options(scalability PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 05-03 同步开销:非原子 vs atomic(relaxed/seq_cst)vs mutex +add_executable(lock_cost lock_cost.cpp) +target_link_libraries(lock_cost PRIVATE Threads::Threads) +target_compile_options(lock_cost PRIVATE -O2 -Wall -Wextra -Wpedantic) diff --git a/code/volumn_codes/vol6-performance/ch05/README.md b/code/volumn_codes/vol6-performance/ch05/README.md new file mode 100644 index 000000000..5cd93a5a4 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch05/README.md @@ -0,0 +1,28 @@ +# vol6 ch05 · 多核性能 — 代码示例 + +对应文章:`documents/vol6-performance/ch05-multicore-performance/` + +三个程序,对应 ch05 三篇。同步原语的**正确性/内存序/无锁实现归 vol5**,这里只测**性能成本**。 + +## 构建 + +```bash +g++ -O2 -std=c++17 -pthread false_sharing.cpp -o false_sharing && ./false_sharing +g++ -O2 -std=c++17 -pthread scalability.cpp -o scalability && ./scalability +g++ -O2 -std=c++17 -pthread lock_cost.cpp -o lock_cost && ./lock_cost +# 或 cmake -B build && cmake --build build +``` + +## 程序对照 + +| 程序 | 文章 | 核心数字 | +|---|---|---| +| `false_sharing` | 05-01 | 伪共享 vs `alignas(64)`:**18×** | +| `scalability` | 05-02 | 1→2→4→8 线程:1→1.70→2.33→2.53×(亚线性,内存带宽天花板)| +| `lock_cost` | 05-03 | mutex/atomic_relaxed **3.6×**(无竞争);非原子基线 0 ns | + +## 局限(诚实) + +- **NUMA 跨节点惩罚测不了**:本机 WSL2 单 socket 单 NUMA 节点(`numactl --hardware` 只 node0)。05-02 的 NUMA 内容引自 Bakhvalov + 多 socket 服务器实践,要测得找双路机器。 +- **扩展性曲线在 WSL2 上**:5800H 是 7 物理核 / 14 线程,但 WSL2 的 CPU 暴露可能和裸机不同;加速比数字随环境变,**关心「亚线性拐点」这个趋势,不关心某个具体倍数**。 +- `atomic relaxed` vs `seq_cst` 单线程几乎一样(1.86 vs 1.84 ns)——它们的差别在跨线程 ordering,单线程看不出。 diff --git a/code/volumn_codes/vol6-performance/ch05/false_sharing.cpp b/code/volumn_codes/vol6-performance/ch05/false_sharing.cpp new file mode 100644 index 000000000..c7f74813e --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch05/false_sharing.cpp @@ -0,0 +1,67 @@ +// false_sharing.cpp — vol6 ch05-01 伪共享 +// 两个线程各自频繁自增「相邻的计数器」。 +// A. 两计数器在同一 cacheline → 伪共享:每次写都让对方 cacheline 失效,实质串行 +// B. alignas(64) 让每个计数器独占 cacheline → 无伪共享,真并行 +// 编译: g++ -O2 -std=c++17 -pthread false_sharing.cpp -o false_sharing +// 跑: ./false_sharing (多线程,不必 taskset 单核) +#include +#include +#include +#include + +constexpr long ITERS = 100'000'000; + +// A. 伪共享:两个计数器紧挨着,同一条 64B cacheline +struct BadCounters { + std::atomic a{0}; + std::atomic b{0}; +}; +// B. 无伪共享:每个 alignas(64) 独占 cacheline +struct alignas(64) PaddedCounter { + std::atomic v{0}; +}; +struct GoodCounters { + PaddedCounter a; + PaddedCounter b; +}; + +template long run(C& c, F worker) { + auto t0 = std::chrono::steady_clock::now(); + std::thread t1([&] { worker(c.a, 0); }); + std::thread t2([&] { worker(c.b, 1); }); + t1.join(); + t2.join(); + auto t1b = std::chrono::steady_clock::now(); + return std::chrono::duration(t1b - t0).count(); +} + +int main() { + // warmup + { + BadCounters bc; + auto f = [](auto& x, int) { + for (long i = 0; i < 1000; ++i) + x.store(x.load() + 1); + }; + run(bc, f); + } + + BadCounters bad; + long t_bad = run(bad, [](std::atomic& x, int) { + for (long i = 0; i < ITERS; ++i) + x.store(x.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed); + }); + GoodCounters good; + long t_good = run(good, [](PaddedCounter& x, int) { + for (long i = 0; i < ITERS; ++i) + x.v.store(x.v.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed); + }); + + std::printf("===== 伪共享(2 线程各自自增 %ld 亿次)=====\n", ITERS / 100000000); + std::printf(" 伪共享(同 cacheline): %6.1f ms\n", (double)t_bad); + std::printf(" alignas(64)(独占 cacheline):%6.1f ms\n", (double)t_good); + std::printf(" 伪共享/对齐 = %.1fx\n", (double)t_bad / t_good); + std::printf(" sizeof(BadCounters)=%zu sizeof(GoodCounters)=%zu\n", sizeof(BadCounters), + sizeof(GoodCounters)); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch05/lock_cost.cpp b/code/volumn_codes/vol6-performance/ch05/lock_cost.cpp new file mode 100644 index 000000000..183154055 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch05/lock_cost.cpp @@ -0,0 +1,62 @@ +// lock_cost.cpp — vol6 ch05-03 锁的开销与「无锁不是银弹」 +// 三种「单线程自增 N 次」的每操作成本: +// A. 普通非原子 int(基线,无同步) +// B. std::atomic(无锁,但每条指令有 memory_order 开销) +// C. std::mutex 加解锁(无竞争 fast path) +// 编译: g++ -O2 -std=c++17 -pthread lock_cost.cpp -o lock_cost +// 跑: ./lock_cost +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +constexpr long N = 100'000'000; + +int main() { + // A. 非原子基线 + long plain = 0; + auto t0 = clk::now(); + for (long i = 0; i < N; ++i) + ++plain; + auto t1 = clk::now(); + double t_plain = std::chrono::duration(t1 - t0).count() / N; + + // B. atomic relaxed(无锁) + std::atomic atom{0}; + t0 = clk::now(); + for (long i = 0; i < N; ++i) + atom.fetch_add(1, std::memory_order_relaxed); + t1 = clk::now(); + double t_atom = std::chrono::duration(t1 - t0).count() / N; + + // B2. atomic seq_cst(默认,更强内存序,更贵) + std::atomic atom_sc{0}; + t0 = clk::now(); + for (long i = 0; i < N; ++i) + atom_sc.fetch_add(1); // 默认 seq_cst + t1 = clk::now(); + double t_sc = std::chrono::duration(t1 - t0).count() / N; + + // C. mutex 无竞争 + std::mutex m; + long guarded = 0; + t0 = clk::now(); + for (long i = 0; i < N; ++i) { + std::lock_guard lk(m); + ++guarded; + } + t1 = clk::now(); + double t_mutex = std::chrono::duration(t1 - t0).count() / N; + + volatile long sink = plain + atom.load() + atom_sc.load() + guarded; + (void)sink; + std::printf("===== 同步开销(单线程自增 %ld 次,ns/op)=====\n", N); + std::printf(" 非原子 int(基线): %5.2f ns\n", t_plain); + std::printf(" atomic relaxed(无锁,弱序): %5.2f ns\n", t_atom); + std::printf(" atomic seq_cst(默认强序): %5.2f ns\n", t_sc); + std::printf(" mutex 无竞争(加解锁): %5.2f ns\n", t_mutex); + std::printf(" mutex/atomic_relaxed = %.1fx\n", t_mutex / t_atom); + std::printf(" 注:这是无竞争场景;有竞争时 mutex 排队/可能内核切换,代价暴涨。\n"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch05/scalability.cpp b/code/volumn_codes/vol6-performance/ch05/scalability.cpp new file mode 100644 index 000000000..bd27e8b86 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch05/scalability.cpp @@ -0,0 +1,62 @@ +// scalability.cpp — vol6 ch05-02 扩展性曲线 +// 同一个并行任务(分块累加),用 1/2/4/8 线程跑,看吞吐怎么随核数扩展。 +// 理想:线性扩展(吞吐 ×N)。实际:受 Amdahl(串行段)+ 调度/同步开销限制。 +// 编译: g++ -O2 -std=c++17 -pthread scalability.cpp -o scalability +// 跑: ./scalability +// 注:WSL2 单 NUMA 节点,本程序不演示 NUMA 跨节点惩罚(本机也测不了)。 +#include +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +constexpr long N = 100'000'000; // 1 亿元素 +static std::vector data(N); + +void partial_sum(int tid, int nthreads, long& out) { + long chunk = N / nthreads; + long beg = (long)tid * chunk; + long end = (tid == nthreads - 1) ? N : beg + chunk; + long s = 0; + for (long i = beg; i < end; ++i) + s += data[i]; + out = s; +} + +double run_n(int nthreads) { + std::vector results(nthreads); + std::vector ts; + auto t0 = clk::now(); + for (int t = 0; t < nthreads; ++t) + ts.emplace_back(partial_sum, t, nthreads, std::ref(results[t])); + for (auto& t : ts) + t.join(); + auto t1 = clk::now(); + // 防优化:汇总 + volatile long sink = 0; + for (long r : results) + sink += r; + (void)sink; + return std::chrono::duration(t1 - t0).count(); +} + +int main() { + for (long i = 0; i < N; ++i) + data[i] = (int)(i % 7); + run_n(1); // warmup + std::printf("===== 扩展性曲线(并行累加 %ld 元素)=====\n", N); + std::printf("%-8s %12s %12s\n", "线程数", "耗时(ms)", "加速比"); + double t1 = 0; + for (int n : {1, 2, 4, 8}) { + // 取 3 次最小(更稳) + double best = 1e9; + for (int k = 0; k < 3; ++k) + best = std::min(best, run_n(n)); + if (n == 1) + t1 = best; + std::printf("%-8d %10.1f %12.2fx\n", n, best, t1 / best); + } + std::printf("理想是线性扩展(加速比=N);实际受 Amdahl(汇总/同步)+ 调度开销限制。\n"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch06/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch06/CMakeLists.txt new file mode 100644 index 000000000..e63f29ca5 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch06/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch06_cpp_abstraction_cost LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) # abstraction_sizeof 用 std::span(C++20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# 06-01 虚函数与去虚拟化:与 ch04/virtual_devirt.cpp 共用,本目录不重复 +# 06-02 异常的零成本模型 +add_executable(exception_cost exception_cost.cpp) +target_compile_options(exception_cost PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 06-03 std::function 的 SBO +add_executable(function_sbo function_sbo.cpp) +target_compile_options(function_sbo PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 06-04 成本速查表 sizeof +add_executable(abstraction_sizeof abstraction_sizeof.cpp) +target_compile_options(abstraction_sizeof PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 06-05 RVO/NRVO/move。默认 -O2;对照版见 README(-fno-elide-constructors) +add_executable(rvo_move rvo_move.cpp) +target_compile_options(rvo_move PRIVATE -O2 -Wall -Wextra -Wpedantic) diff --git a/code/volumn_codes/vol6-performance/ch06/README.md b/code/volumn_codes/vol6-performance/ch06/README.md new file mode 100644 index 000000000..a9a62481f --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch06/README.md @@ -0,0 +1,33 @@ +# vol6 ch06 · C++ 抽象的性能成本 — 代码示例 + +对应文章:`documents/vol6-performance/ch06-cpp-abstraction-cost/` + +四个程序,对应 ch06 的几篇。**核心精神:每个 C++ 抽象都对应一个硬件成本,但「有成本」不等于「每次都发生」——编译器常替你消除(去虚化、零成本异常、RVO)。先测再优化。** + +## 构建 + +```bash +g++ -O2 -std=c++20 exception_cost.cpp -o exception_cost && taskset -c 0 ./exception_cost +g++ -O2 -std=c++20 function_sbo.cpp -o function_sbo && taskset -c 0 ./function_sbo +g++ -O2 -std=c++20 abstraction_sizeof.cpp -o abstraction_sizeof && ./abstraction_sizeof +g++ -O2 -std=c++20 rvo_move.cpp -o rvo_move && ./rvo_move +# 对照:关掉 copy elision 看 RVO 消失的样子 +g++ -O2 -std=c++20 -fno-elide-constructors rvo_move.cpp -o rvo_move_noelide && ./rvo_move_noelide +# 或 cmake -B build && cmake --build build +``` + +> 06-01 虚函数与去虚拟化的实验(`virtual_devirt.cpp`)与 ch04-04 共用,在 `code/.../ch04/`。 + +## 程序对照 + +| 程序 | 文章 | 核心数字 | +|---|---|---| +| `exception_cost` | 06-02 | 正常路径 **0.25 ns(零成本,和纯函数一样)**;throw+catch **857 ns(~3400×)** | +| `function_sbo` | 06-03 | 调用 function 比直接 lambda 慢 **~6×**;构造 SBO 2.3ns / 堆分配 19.6ns(**8.5×**)| +| `abstraction_sizeof` | 06-04 | optional/variant/span/string_view/string/shared_ptr 的 sizeof | +| `rvo_move` | 06-05 | **copy/move 计数**(编译器打不穿):URVO/NRVO=0/0,`std::move`=0/1,copy=1/0 | + +## 两个诚实点 + +- **rvo_move 计时版会被编译器打穿**(copy elision 跨迭代优化让 copy 看起来比 RVO 快),所以改用 **copy/move 构造函数计数**——直接数发生几次拷贝/移动,编译器改不了你的计数器。这是 RVO 教学唯一可靠的方法。 +- `abstraction_sizeof` 的数字是 libstdc++ C++20 实现;libc++/MSVC 会不同(`string` 32B vs 48B 等),**关心 sizeof 时以你自己的工具链为准**。 diff --git a/code/volumn_codes/vol6-performance/ch06/abstraction_sizeof.cpp b/code/volumn_codes/vol6-performance/ch06/abstraction_sizeof.cpp new file mode 100644 index 000000000..9a32f2c35 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch06/abstraction_sizeof.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include +#include +#include +#include +#include +int main() { + printf("sizeof:\n"); + printf(" int = %zu\n", sizeof(int)); + printf(" std::optional = %zu (+1 bool+padding,有无值标记)\n", + sizeof(std::optional)); + printf(" std::variant = %zu\n", sizeof(std::variant)); + printf(" std::variant= %zu (index + 最大备选 + padding)\n", + sizeof(std::variant)); + printf(" std::span = %zu (指针+长度,零所有权)\n", + sizeof(std::span)); + printf(" std::string_view = %zu (指针+长度,不保证 \\0)\n", + sizeof(std::string_view)); + printf(" std::shared_ptr = %zu (2 指针:对象+控制块)\n", + sizeof(std::shared_ptr)); + printf(" std::unique_ptr = %zu (1 指针)\n", sizeof(std::unique_ptr)); + printf(" std::string = %zu (含 SSO 缓冲)\n", sizeof(std::string)); + printf(" std::vector = %zu (3 指针)\n", sizeof(std::vector)); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch06/exception_cost.cpp b/code/volumn_codes/vol6-performance/ch06/exception_cost.cpp new file mode 100644 index 000000000..77ab023a2 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch06/exception_cost.cpp @@ -0,0 +1,100 @@ +// exception_cost.cpp — vol6 ch06-02 异常的零成本模型 +// 零成本 = 「正常路径无额外指令,异常路径靠 EH 表查找(很贵)」。 +// 测三条路径:① 不抛(正常路径,应最快)② 返回错误码 ③ 抛+捕获异常 +// 编译: g++ -O2 -std=c++17 exception_cost.cpp -o exception_cost +// 对照 -fno-exceptions 看正常路径有没有变(应该一样,这就是"零成本") +// 跑: taskset -c 0 ./exception_cost +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +constexpr int N = 10'000'000; // 注意:throw 版每轮 N 次,会很慢,控制总量 + +// ① 正常路径:从不抛 +int normal_path(int i) { + return i + 1; +} +// ② 错误码路径:用返回值传错误 +int error_code_path(int i, int& err) { + if (i < 0) { + err = -1; + return 0; + } + return i + 1; +} +// ③ 异常路径:从不抛(正常路径,但函数有 throw 能力) +int exception_path_normal(int i) { + return i + 1; +} +// ③b 真的抛(每次都抛,测 throw+catch 成本) +int exception_path_throw(int i) { + if (true) + throw std::runtime_error("x"); + return i + 1; +} + +int main() { + const int REP = 5; + volatile uint64_t sink = 0; + + // ① 纯正常路径 + { + auto t0 = clk::now(); + for (int r = 0; r < REP; ++r) + for (int i = 0; i < N; ++i) + sink += normal_path(i); + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / REP / N; + std::printf(" 正常路径(从不抛): %6.3f ns/op\n", ns); + } + // ② 错误码 + { + auto t0 = clk::now(); + for (int r = 0; r < REP; ++r) + for (int i = 0; i < N; ++i) { + int err; + sink += error_code_path(i, err); + } + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / REP / N; + std::printf(" 错误码(返回值传 err): %6.3f ns/op\n", ns); + } + // ③ 有 throw 能力但从不抛(零成本模型:应和 ① 一样快) + { + auto t0 = clk::now(); + for (int r = 0; r < REP; ++r) + for (int i = 0; i < N; ++i) + sink += exception_path_normal(i); + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / REP / N; + std::printf(" 有 throw 能力但从不抛(零成本):%6.3f ns/op\n", ns); + } + // ④ 真的每次抛+捕获(慢,只跑少量轮) + { + const int REP2 = 1; + const int M = 100'000; // 只跑 10 万次,throw 很慢 + auto t0 = clk::now(); + for (int r = 0; r < REP2; ++r) + for (int i = 0; i < M; ++i) { + try { + exception_path_throw(i); + } catch (const std::exception&) { + sink += 1; + } + } + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / REP2 / M; + std::printf(" 每次都 throw+catch: %6.1f ns/op ← 异常路径很贵\n", ns); + } + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch06/function_sbo.cpp b/code/volumn_codes/vol6-performance/ch06/function_sbo.cpp new file mode 100644 index 000000000..2ded5a94d --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch06/function_sbo.cpp @@ -0,0 +1,106 @@ +// function_sbo.cpp — vol6 ch06-03 std::function 的 SBO +// std::function 类型擦除,调用是间接的,构造可能堆分配。 +// 测:① 函数指针(无捕获,最便宜)② 小 lambda(捕获 ≤ SBO 阈值,存在对象内)③ 大 lambda(超 SBO,堆分配) +// 编译: g++ -O2 -std=c++17 function_sbo.cpp -o function_sbo +// 跑: taskset -c 0 ./function_sbo +#include +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +constexpr int N = 100'000'000; +constexpr int CONSTRUCT = 1'000'000; + +int free_func(int x) { + return x + 1; +} + +int main() { + volatile uint64_t sink = 0; + + // ===== 调用成本(构造一次,调用 N 次)===== + { + // ① 函数指针 + int (*fp)(int) = free_func; + auto t0 = clk::now(); + for (int i = 0; i < N; ++i) + sink += fp(i); + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / N; + std::printf(" 调用 - 函数指针: %5.2f ns\n", ns); + } + { + // ② std::function 装小 lambda(捕获一个 int,SBO 命中) + int cap = 42; + std::function f = [cap](int x) { return x + cap; }; + auto t0 = clk::now(); + for (int i = 0; i < N; ++i) + sink += f(i); + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / N; + std::printf(" 调用 - function+小lambda(SBO): %5.2f ns\n", ns); + } + { + // ③ 直接调用 lambda(对照:无类型擦除) + int cap = 42; + auto lam = [cap](int x) { return x + cap; }; + auto t0 = clk::now(); + for (int i = 0; i < N; ++i) + sink += lam(i); + auto t1 = clk::now(); + do_not_optimize(sink); + double ns = std::chrono::duration(t1 - t0).count() / N; + std::printf(" 调用 - 直接 lambda(对照): %5.2f ns\n", ns); + } + + // ===== 构造成本(构造 CONSTRUCT 次)===== + std::printf("\n 构造 %d 次:\n", CONSTRUCT); + { + auto t0 = clk::now(); + for (int i = 0; i < CONSTRUCT; ++i) { + std::function f = free_func; + do_not_optimize((uint64_t)(uintptr_t)&f); + } + auto t1 = clk::now(); + std::printf(" function 装函数指针: %7.1f ns/次\n", + std::chrono::duration(t1 - t0).count() / CONSTRUCT); + } + { + int cap = 42; + auto t0 = clk::now(); + for (int i = 0; i < CONSTRUCT; ++i) { + std::function f = [cap](int x) { return x + cap; }; + do_not_optimize((uint64_t)(uintptr_t)&f); + } + auto t1 = clk::now(); + std::printf(" function 装小lambda(SBO): %7.1f ns/次\n", + std::chrono::duration(t1 - t0).count() / CONSTRUCT); + } + { + // 大捕获:double[64] = 512B,远超 SBO 阈值(libstdc++ 通常 16-24B) + struct Big { + double data[64]; + }; + Big big{}; + auto t0 = clk::now(); + for (int i = 0; i < CONSTRUCT; ++i) { + std::function f = [big](int x) { return x + 1; }; + do_not_optimize((uint64_t)(uintptr_t)&f); + } + auto t1 = clk::now(); + std::printf(" function 装大lambda(堆分配): %7.1f ns/次 ← 堆分配开销\n", + std::chrono::duration(t1 - t0).count() / CONSTRUCT); + } + std::printf( + "\n sizeof(std::function)=%zu(libstdc++/libc++ 通常 32-48B,含 SBO 缓冲)\n", + sizeof(std::function)); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch06/rvo_move.cpp b/code/volumn_codes/vol6-performance/ch06/rvo_move.cpp new file mode 100644 index 000000000..2c955dbef --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch06/rvo_move.cpp @@ -0,0 +1,89 @@ +// rvo_move.cpp — vol6 ch06-05 RVO/NRVO 与 move 的真实成本 +// 用一个 copy/move 构造函数会计数的 Tracked 类型,直接数 RVO/NRVO/move/copy +// 各发生几次拷贝、几次移动。这不依赖计时(计时版被编译器 copy-elision 打穿), +// 是 RVO 教学最清楚的「编译器打不穿」演示。 +// 编译: g++ -O2 -std=c++17 rvo_move.cpp -o rvo_move +// 对照 -fno-elide-constructors 看 RVO 关掉后变成什么(应为 1-2 次 move) +// 跑: ./rvo_move (无需 taskset,不计时) +#include +#include +#include + +struct Tracked { + int v; + static int64_t copies, moves; + Tracked(int x) : v(x) {} // 普通构造 + Tracked(const Tracked& o) : v(o.v) { ++copies; } // 拷贝构造 + Tracked(Tracked&& o) noexcept : v(o.v) { + ++moves; + o.v = -1; + } // 移动构造 +}; +int64_t Tracked::copies = 0, Tracked::moves = 0; + +// ① URVO:返回无名临时 +Tracked make_urvo() { + return Tracked(1); +} +// ② NRVO:返回有名局部 +Tracked make_nrvo() { + Tracked t(2); + return t; +} +// ③ 强制 move:std::move 阻断 NRVO,强制走 move 构造 +Tracked make_move() { + Tracked t(3); + return std::move(t); +} +// ④ 命名空间级 lvalue,不能 RVO/move,走拷贝 +Tracked g_global(4); +Tracked make_copy() { + return g_global; +} + +void reset() { + Tracked::copies = 0; + Tracked::moves = 0; +} + +int main() { + std::printf("===== RVO/NRVO/move/copy 的拷贝/移动计数(编译器打不穿)=====\n"); + std::printf("(用 copy/move 构造函数计数的 Tracked 类型,直接看发生了几次)\n\n"); + + reset(); + { + auto x = make_urvo(); + } + std::printf(" URVO return Tracked(1): copies=%lld moves=%lld → 零拷贝零移动\n", + (long long)Tracked::copies, (long long)Tracked::moves); + + reset(); + { + auto x = make_nrvo(); + } + std::printf(" NRVO return t(有名局部): copies=%lld moves=%lld → 零拷贝零移动\n", + (long long)Tracked::copies, (long long)Tracked::moves); + + reset(); + { + auto x = make_move(); + } + std::printf( + " return std::move(t): copies=%lld moves=%lld → 被强制 1 次 move(NRVO 被你禁了)\n", + (long long)Tracked::copies, (long long)Tracked::moves); + + reset(); + { + auto x = make_copy(); + } + std::printf(" return g_global(lvalue): copies=%lld moves=%lld → 1 次拷贝(不能 RVO)\n", + (long long)Tracked::copies, (long long)Tracked::moves); + + std::printf("\n结论:\n"); + std::printf(" - RVO/NRVO:零拷贝零移动(返回值直接在调用方栈上构造)。\n"); + std::printf(" - return std::move(局部):反而禁用 NRVO,多一次 move。所以【别】这么写。\n"); + std::printf(" - move 比 copy 便宜(指针交换 vs 深拷贝),对大对象差几个数量级。\n"); + std::printf(" - 加 -fno-elide-constructors 重编,URVO/NRVO 会退化成 1-2 次 move(看 RVO " + "关掉的效果)。\n"); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch07/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch07/CMakeLists.txt new file mode 100644 index 000000000..e50bcbd2e --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch07/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch07_compiler_and_size LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# 07-01 -O 级别 + optimization blockers(别名单文件多编几遍对比,见 README) +add_executable(opt_levels_blockers opt_levels_blockers.cpp) +target_compile_options(opt_levels_blockers PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 07-02 LTO 跨 TU(lto_main + lto_helper)。开 LTO 版:CMake 用 INTERPROCEDURAL_OPTIMIZATION, +# 或直接命令行 g++ -O2 -flto lto_main.cpp lto_helper.cpp -o lto_lto(README 有对照脚本) +add_executable(lto_nolto lto_main.cpp lto_helper.cpp) +target_compile_options(lto_nolto PRIVATE -O2 -Wall -Wextra -Wpedantic) + +# 07-04 体积优化(对照 -Os/-Oz/--gc-sections 见 README 脚本) +add_executable(size_demo size_demo.cpp) +target_compile_options(size_demo PRIVATE -O2 -ffunction-sections -fdata-sections -Wall -Wextra -Wpedantic) +target_link_options(size_demo PRIVATE -Wl,--gc-sections) diff --git a/code/volumn_codes/vol6-performance/ch07/README.md b/code/volumn_codes/vol6-performance/ch07/README.md new file mode 100644 index 000000000..f6e72acbd --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch07/README.md @@ -0,0 +1,47 @@ +# vol6 ch07 · 编译器优化边界与体积评估 — 代码示例 + +对应文章:`documents/vol6-performance/ch07-compiler-and-size/` + +四个程序 + 若干「对照编译」脚本,对应 ch07 四篇。**核心精神:编译器是性能队友,你的工作是「别挡路 + 让它看得见」;体积↔速度常反向取舍。** + +## 构建(推荐用对照脚本,逐 flag 对比) + +```bash +# 07-01 -O 级别 + 别名 blocker:同一文件多编几遍 +for opt in O0 O2 O3; do + g++ -$opt -std=c++17 opt_levels_blockers.cpp -o ob_$opt + echo -n " -$opt 别名版: "; taskset -c 0 ./ob_$opt a + echo -n " -$opt restrict版:"; taskset -c 0 ./ob_$opt r +done + +# 07-02 LTO 跨 TU +g++ -O2 -std=c++17 lto_main.cpp lto_helper.cpp -o lto_nolto +g++ -O2 -std=c++17 -flto lto_main.cpp lto_helper.cpp -o lto_lto +echo -n "无 LTO: "; taskset -c 0 ./lto_nolto +echo -n "有 LTO: "; taskset -c 0 ./lto_lto +# PGO 复用 ch04/pgo.sh + +# 07-04 体积优化:看二进制大小 +g++ -O2 -std=c++17 size_demo.cpp -o size_o2 +g++ -Os -std=c++17 size_demo.cpp -o size_os +g++ -Oz -std=c++17 size_demo.cpp -o size_oz +g++ -O2 -std=c++17 -ffunction-sections -fdata-sections size_demo.cpp -o size_gc -Wl,--gc-sections +for f in size_o2 size_os size_oz size_gc; do printf " %-10s %s\n" "$f" "$(stat -c%s $f)"; done + +# 或 cmake -B build && cmake --build build +``` + +## 程序对照 + +| 程序 | 文章 | 核心数字 | +|---|---|---| +| `opt_levels_blockers` | 07-01 | -O0→-O2 **~4×**;**-O3 比 -O2 反慢**(7.4 vs 4.9);__restrict 在 -O3 有用 | +| `lto_main`+`lto_helper` | 07-02 | LTO 跨 TU 内联 **3.9×**(178.6→46.2 ms);PGO 微基准无收益(复用 ch04)| +| `size_demo` | 07-04 | --gc-sections 省 200B;-Os/-Oz 比 -O2 省几百 B(小 demo;大项目 5-15%)| + +## 诚实点 + +- **-O3 不总比 -O2 快**(本机这个带 volatile 的循环上,-O3 反慢)——「更激进优化=更快」是误解。 +- **LTO 的 3.9×** 是跨 TU 内联的干净收益;大项目上 LTO 是 release 标配。 +- **PGO 对微基准无收益**(那个一度的 4× 是仪器化开销,不是 PGO),价值在大代码库——见 ch04/pgo.sh 的诚实讨论。 +- **体积差异在小 demo 上很小**(几百字节),大项目才有 5-15%。 diff --git a/code/volumn_codes/vol6-performance/ch07/lto_helper.cpp b/code/volumn_codes/vol6-performance/ch07/lto_helper.cpp new file mode 100644 index 000000000..6aa31498a --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch07/lto_helper.cpp @@ -0,0 +1,9 @@ +// lto_helper.cpp — vol6 ch07-02 LTO 跨翻译单元内联演示(配合 lto_main.cpp) +// helper 函数在另一个 TU,无 LTO 时编译器看不见实现,不能内联;有 LTO 能。 +int helper(int x) { + // 一个能被内联后进一步优化的函数(内联后常量传播/消除) + int s = 0; + for (int i = 0; i < 10; ++i) + s += x * i; + return s + 1; +} diff --git a/code/volumn_codes/vol6-performance/ch07/lto_main.cpp b/code/volumn_codes/vol6-performance/ch07/lto_main.cpp new file mode 100644 index 000000000..eb271cb6b --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch07/lto_main.cpp @@ -0,0 +1,29 @@ +// lto_main.cpp — vol6 ch07-02 LTO 演示主文件 +// 编译对照: +// 无 LTO: g++ -O2 lto_main.cpp lto_helper.cpp -o lto_nolto +// 有 LTO: g++ -O2 -flto lto_main.cpp lto_helper.cpp -o lto_lto +// 跑: taskset -c 0 ./lto_nolto && taskset -c 0 ./lto_lto +// 看:LTO 版应略快(跨 TU 内联 helper,常量传播/消除冗余),且二进制可能更小(死代码消除) +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} + +int helper(int x); // 定义在 lto_helper.cpp + +int main() { + constexpr int N = 100'000'000; + volatile uint64_t sink = 0; + auto t0 = clk::now(); + for (int i = 0; i < N; ++i) + sink += helper(i & 0xFF); + auto t1 = clk::now(); + do_not_optimize(sink); + std::printf("helper 调 %d 次:%6.1f ms\n", N, + std::chrono::duration(t1 - t0).count()); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch07/opt_levels_blockers.cpp b/code/volumn_codes/vol6-performance/ch07/opt_levels_blockers.cpp new file mode 100644 index 000000000..6ecafa4d6 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch07/opt_levels_blockers.cpp @@ -0,0 +1,48 @@ +// opt_levels_blockers.cpp — vol6 ch07-01 -O 级别与 optimization blockers +// A. -O0/-O1/-O2/-O3 对同一函数的速度差(展示各级别优化力度) +// B. 指针别名 blocker:编译器不知道 a/b 是否重叠,不敢激进优化;__restrict 解锁 +// 编译: +// g++ -O0 opt_levels_blockers.cpp -o o0 && ./o0 +// g++ -O2 opt_levels_blockers.cpp -o o2 && ./o2 +// g++ -O3 opt_levels_blockers.cpp -o o3 && ./o3 +// 跑: taskset -c 0 ./o0; taskset -c 0 ./o2; taskset -c 0 ./o3 +#include +#include +#include +#include + +using clk = std::chrono::steady_clock; +inline void do_not_optimize(uint64_t v) { + asm volatile("" : "+r"(v)::"memory"); +} +constexpr int N = 10'000'000; + +// B. 别名 blocker:scale 一次,a/b 各 N 次。编译器若不信 a/b 别名,可把 scale 外提。 +// 用 volatile scale 迫使每次 load,模拟「编译器不敢优化」;__restrict 版手动声明无别名。 +void scale_add_alias(int* a, int* b, volatile int* scale, int n) { + for (int i = 0; i < n; ++i) + a[i] += b[i] * *scale; // 每次 load scale,且不敢假设 a≠b +} +void scale_add_restrict(int* __restrict a, int* __restrict b, int* __restrict scale, int n) { + for (int i = 0; i < n; ++i) + a[i] += b[i] * *scale; // __restrict:编译器信 a/b/scale 不别名 +} + +int main(int argc, char** argv) { + std::vector a(N, 1), b(N, 2); + volatile int scale = 3; + int which = (argc > 1) ? argv[1][0] : 'x'; // 'a'=alias 'r'=restrict + + auto t0 = clk::now(); + if (which == 'r') + scale_add_restrict(a.data(), b.data(), (int*)&scale, N); + else + scale_add_alias(a.data(), b.data(), (int*)&scale, N); + auto t1 = clk::now(); + do_not_optimize(a[0]); + + double ms = std::chrono::duration(t1 - t0).count(); + std::printf("%c: %6.1f ms (编译参数决定优化力度;__restrict 版可向量化/外提 scale)\n", which, + ms); + return 0; +} diff --git a/code/volumn_codes/vol6-performance/ch07/size_demo.cpp b/code/volumn_codes/vol6-performance/ch07/size_demo.cpp new file mode 100644 index 000000000..bf6198522 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch07/size_demo.cpp @@ -0,0 +1,45 @@ +// size_demo.cpp — vol6 ch07-04 体积优化 +// 含一些未被调用的函数(死代码)+ 模板多实例,用于演示 --gc-sections / 模板膨胀控制。 +// 编译对照(看二进制大小): +// g++ -O2 size_demo.cpp -o size_o2 +// g++ -Os size_demo.cpp -o size_os +// g++ -O2 -ffunction-sections -fdata-sections size_demo.cpp -o size_gc -Wl,--gc-sections +// size size_o2 size_os size_gc +#include +#include +#include +#include + +// 死代码:这个函数从不被调用,-ffunction-sections + --gc-sections 能回收 +[[maybe_unused]] int big_dead_function() { + int s = 0; + for (int i = 0; i < 1000; ++i) + s += i * (i + 1); + return s; +} +[[maybe_unused]] double another_dead(double x) { + return x * 3.14 + 2.71; +} + +// 模板:对不同类型各实例化一份代码 +template T compute(T a, T b) { + T s = a; + for (int i = 0; i < 10; ++i) { + s = s * b + a; + } + return s; +} + +int main() { + // 用到的实例:int, double,long —— 3 份代码 + volatile int a = compute(2, 3); + volatile double b = compute(2.0, 3.0); + volatile long c = compute(2L, 3L); + (void)a; + (void)b; + (void)c; + std::vector v = {3, 1, 4, 1, 5, 9, 2, 6}; + std::sort(v.begin(), v.end()); + std::printf("size demo: v[0]=%d (看二进制大小,不看这行输出)\n", v[0]); + return 0; +} diff --git a/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md b/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md index 07a070146..74594f365 100644 --- a/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md +++ b/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md @@ -679,4 +679,4 @@ At this point, we have completely mastered the RISC-V register naming system. Lo ## Further Reading - To understand what assembly the compiler actually spits out at different optimization levels (`-O0` / `-O2` / `-O3`), see [Volume 7: Compiler Options](../../../../vol7-engineering/02-compiler-options.md). -- To dive deeper into how SIMD/AVX reshapes assembly output, see [Volume 6: AVX/AVX2 Deep Dive](../../../../vol6-performance/avx-avx2-deep-dive.md). +- To dive deeper into how SIMD/AVX reshapes assembly output, see [Volume 6: AVX/AVX2 Deep Dive](../../../../vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md). diff --git a/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md b/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md index bcbb05cd7..cd4300955 100644 --- a/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md +++ b/documents/en/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md @@ -425,4 +425,4 @@ The following content will temporarily set aside assembly and move up a layer fr ## Further Reading - Compiler Explorer is the best window for observing compiler behavior. For a systematic look at the effects of various GCC/Clang options, see [Volume 7: Compiler Options](../../../../vol7-engineering/02-compiler-options.md). -- To see how auto-vectorization enables AVX/AVX2 in CE, see [Volume 6: AVX/AVX2 Deep Dive](../../../../vol6-performance/avx-avx2-deep-dive.md). +- To see how auto-vectorization enables AVX/AVX2 in CE, see [Volume 6: AVX/AVX2 Deep Dive](../../../../vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md). diff --git a/documents/en/vol6-performance/02-inline-and-compiler-optimization.md b/documents/en/vol6-performance/02-inline-and-compiler-optimization.md deleted file mode 100644 index bc93b1a02..000000000 --- a/documents/en/vol6-performance/02-inline-and-compiler-optimization.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -chapter: 2 -cpp_standard: -- 11 -- 14 -- 17 -- 20 -description: Explore how inline functions work -difficulty: intermediate -order: 2 -platform: host -prerequisites: -- 'Chapter 1: 构建工具链' -reading_time_minutes: 4 -tags: -- cpp-modern -- host -- intermediate -title: Inline Functions and Compiler Optimization -translation: - source: documents/vol6-performance/02-inline-and-compiler-optimization.md - source_hash: 3f6541902c3cba588da0ebd8adc6d5680fdb6824410e0d69b8f4e9203b1bebfd - translated_at: '2026-06-15T09:27:41.696941+00:00' - engine: anthropic - token_count: 529 ---- -# Modern Embedded C++ Tutorial — Inline Functions and Compiler Optimization - -In embedded development, the `inline` keyword is something almost every engineer uses. It looks simple and direct, even carrying a hint of "performance guarantee": if a function is short, called frequently, or timing-sensitive, just `inline` it; it seems like the natural thing to do. - -While in the past, the `inline` keyword indeed served this purpose, in reality, with modern C++ and today's highly advanced compiler optimizations, **`inline` is not a performance optimization button; in fact, it often does nothing at all.** - -## `inline` Was Never Born to Be "Fast" - -From a language perspective, the core purpose of `inline` is actually very restrained. Modern C++ standards do not promise that "if you write `inline`, the compiler will definitely expand the function body." The only thing it truly guarantees is this: **it allows the function to have definitions in multiple translation units without violating the ODR.** - -This is why small functions in header files, template functions, and traits utility functions naturally possess an `inline` "character." It solves a "linkage problem," not a "performance problem." As for whether inlining actually happens, that is entirely the compiler's discretion. In the face of modern compilers, `inline` is more like a suggestion—"I think you might consider expanding this"—rather than a command. - ------- - -## So, Are Function Calls Really Slow in Embedded Systems? - -Much of the intuition regarding `inline` stems from a fear of the cost of function calls. On architectures like Cortex-M, a function call indeed implies a jump, saving LR (Link Register), parameter passing, and restoring the return path. If you stare at the assembly line by line, it's easy to conclude: this doesn't look cheap. - -The problem is, **does this cost actually fall on your performance bottleneck path?** - -In real-world embedded engineering, the vast majority of time consumption in functions isn't in the "call itself," but in peripheral access, bus waiting, Flash reads, Cache misses, or even interrupt preemption. Worrying about whether to inline a GPIO read function is often optimizing at a completely unimportant level. - -More critically, with optimizations enabled (even just `-O2`), short functions with no side effects and clear semantics **will almost certainly be auto-inlined by the compiler, even if you don't write `inline`.** - -When deciding whether to inline a function today, compilers comprehensively consider function body size, the number of call sites, register pressure, instruction Cache behavior, and even analyze call relationships across files when LTO (Link Time Optimization) is enabled. The information it holds far exceeds the little context you see when writing code. This is why you often see this situation: you explicitly wrote `inline`, but upon disassembly, the function still exists; whereas when you wrote nothing, the function was silently expanded. - ------- - -## `inline` That Expands Into Calls Isn't Very Safe - -If the biggest risk of `inline` in PC development is "ineffectiveness," then in embedded systems, its real risk is often **code bloat**. - -The essence of inlining is copying. A small function that is called frequently, if expanded in multiple locations, will have its instructions实实在在地 copied multiple times. On MCUs with tight Flash resources, this copying cannot be ignored. A more subtle point is that larger code not only consumes Flash but also affects the locality of the instruction Cache. Even on cores with I-Cache, excessive inlining can lead to more Cache misses, ultimately manifesting as a performance drop, not an improvement. - ------- - -## So, When Does `inline` Really Have Value? - -In practice, scenarios where `inline` truly demonstrates value are often not "to save a function call," but rather to **eliminate the cost of abstraction boundaries**. - -Examples include template functions, type-safe register access wrappers, and compile-time calculations involving `constexpr`. In these places, `inline` allows you to write highly expressive C++ code, while at the generated assembly level, it is almost indistinguishable from hand-written C. - -This is the most charming aspect of modern C++ in the embedded field: **abstraction is not a burden, but a semantic tool that can be completely optimized away.** - -In interrupt service routines (ISRs) or extreme hot paths, `inline` may also be reasonable, but the prerequisite is always just one: you have actually looked at the assembly and confirmed that it solves a real problem. - -## Run Online - -Online comparison of C-style function calls and C++ template zero-overhead abstractions, observing compiler inline optimization effects: - - diff --git a/documents/en/vol6-performance/06-evaluating-performance-and-size.md b/documents/en/vol6-performance/06-evaluating-performance-and-size.md deleted file mode 100644 index 2d7b308d5..000000000 --- a/documents/en/vol6-performance/06-evaluating-performance-and-size.md +++ /dev/null @@ -1,770 +0,0 @@ ---- -chapter: 0 -cpp_standard: -- 11 -- 14 -- 17 -- 20 -description: Learn how to evaluate program performance and size overhead, and compare - C and C++ behavior in embedded environments through actual measurements. -difficulty: beginner -order: 6 -platform: host -prerequisites: [] -reading_time_minutes: 31 -related: [] -tags: -- cpp-modern -- host -- intermediate -title: Performance and Size Evaluation -translation: - source: documents/vol6-performance/06-evaluating-performance-and-size.md - source_hash: 02e4a1266e0ee238aa7c3f7ad26794b9ba1e094ee183a3f1f96c7cb762a18ba9 - translated_at: '2026-06-16T04:07:58.091499+00:00' - engine: anthropic - token_count: 6915 ---- -# Modern Embedded C++ Tutorial — Does C++ Necessarily Cause Code Bloat? - -Regarding performance evaluation and program size, I believe most programmers have a better feel for the former, while the latter might feel slightly unfamiliar—especially for friends working on host development. I believe that in an era where storage feels increasingly cheap, few people care about the distribution package size of desktop applications anymore. However, in the embedded industry, where a bit of Flash is as precious as gold, it is still necessary to consider program size. - -This raises a question. You know this is the "Modern Embedded C++ Tutorial" (sometimes, the author writes it as "Embedded Modern C++ Tutorial"), but this is an old yet always controversial topic: **Does C++ inevitably cause code bloat?** - -## Before We Start: Sharpen Your Axe - -Before we start our code battle, let's make sure your toolbox contains these tools: - -#### arm-none-eabi-gcc / arm-none-eabi-g++ - -This is the cross-compiler for the ARM platform from X86_64. Let's run through it: - -```bash -arm-none-eabi-gcc --version -``` - -If you see the version number, congratulations! If you see "command not found", you might need to go to the ARM official website to download the toolchain first. The author uses Arch Linux, so I just use pacman or yay to install it. - -> Note: The package name is `gcc-arm-none-eabi`, otherwise it will be missing standard dependencies. Try `arm-none-eabi-gcc` first. If the demo doesn't pull through, it's the standard EABI issue. - -```bash --fno-exceptions -fno-rtti -``` - -> These two parameters are the "diet pills" for using C++ in embedded systems. Without these, your firmware might bloat like dough with yeast due to the exception handling code. - ------- - -## Round One: Start with Blinking an LED: GPIO Driver (It's just a light, how hard can it be?) - -Our first task is to ground the previous content into reality. Let's see: with different languages and different programming paradigms, what does our code look like, and how does it actually perform? - -### Task Brief - -We want to implement a GPIO driver to control an LED. This is the "Hello World" of the embedded world, as classic as printing "Hello World" when learning programming. The features include: - -- Turn light on/off (Um...) -- Toggle state -- PWM dimming (Show off a bit) - -#### C Language Version — Plain and Simple - -```c -typedef struct { - volatile uint32_t* mod; // Mode register - uint32_t pin; // Pin number -} GPIO_C; - -void gpio_init(GPIO_C* gpio, volatile uint32_t* mod, uint32_t pin) { - gpio->mod = mod; - gpio->pin = pin; - *mod |= (1 << pin); // Set as output -} - -void gpio_write(GPIO_C* gpio, bool state) { - if (state) - *gpio->mod |= (1 << gpio->pin); - else - *gpio->mod &= ~(1 << gpio->pin); -} - -void gpio_toggle(GPIO_C* gpio) { - *gpio->mod ^= (1 << gpio->pin); -} -``` - -This is the author's C programming style. Of course, some friends might not like structs. Well, I still recommend using structs, but don't pass them by value triggering a copy; instead, pass a pointer pointing to this object. - -#### C++ Version — OOP - -```cpp -class GPIO_CPP { -public: - GPIO_CPP(volatile uint32_t* mod, uint32_t pin) : mod_(mod), pin_(pin) { - *mod_ |= (1 << pin_); // Constructor initializes hardware - } - - void write(bool state) { - if (state) - *mod_ |= (1 << pin_); - else - *mod_ &= ~(1 << pin_); - } - - void toggle() { - *mod_ ^= (1 << pin_); - } - -private: - volatile uint32_t* mod_; - uint32_t pin_; -}; -``` - -A classic use of C++ is to adopt the Object-Oriented Programming (OOP) paradigm. - -Of course, some friends might argue—who told you C++ is an OOP language? It's also a generic programming language. True, I have no objection; my own GPIO library is written with templates. But here, let's consider OOP first. - -### Battle Analysis: Is the Difference Really Huge? - -Let's not judge yet; let's look at the differences! - -We save the C code above as `demo.c`, then use the full compilation command as follows: - -```bash -arm-none-eabi-gcc -march=armv7-m -mcpu=cortex-m4 -mthumb -Os -c demo.c -o demo_c.o -``` - -Huh? You say you just single-click the IDE? Okay, let's talk about what this is doing. - ------- - -#### `-march=armv7-m` - -Specifies the use of an **ARM bare-metal cross-compiler**: - -- `arm`: Target architecture is ARM -- `none`: No operating system (bare-metal) -- `eabi`: Embedded ABI - -The generated code **cannot run on Linux / Windows**, but is used for MCU Flash. - ------- - -#### `-mcpu=cortex-m4` - -Specifies the **target CPU core model**: - -- Generates **instructions specific to Cortex-M4** -- Enables M4-specific features (like DSP instructions) -- Ensures the instruction set matches the actual MCU exactly - -Of course, if you want to try testing for M1, that works too. Switch to `cortex-m1`, you can try them all. - ------- - -#### `-mthumb` - -Forces the use of the **Thumb instruction set**: - -- Cortex-M series **only supports Thumb** -- Instructions are more compact, code density is higher -- It is the "default working mode" for the M series - -For Cortex-M, this is a **mandatory option, not an optimization option**. - ------- - -#### `-Os` - -**Optimization level targeting minimum code size**: - -- Prioritizes reducing Flash usage -- On top of `-O2` / `-O1`, deliberately avoids code bloat -- Is the **most common and safest** optimization level in embedded systems - ------- - -#### `-c`: **Compile only, do not link** - -- Input: `.c` / `.cpp` -- Output: `.o` (object file) -- Does not generate an executable file - -- Only `.o` files can be used for `size` -- Can accurately evaluate the code size of "a specific source file itself" - ------- - -#### `-o demo_c.o` - -Specifies the output file name: - -```bash --o demo_c.o -``` - -Avoids using the default `a.out`, which is especially clear when doing **multi-language / multi-version comparison experiments**. - ------- - -### Let's Look at the Results - -| Implementation | text (Code) | data | bss | Total | -| -------------- | ----------- | ---- | ---- | ------- | -| C Version | 96 bytes | 0 | 0 | 96 | -| C++ Version | 24 bytes | 0 | 0 | 24 | -| Difference | **-72 bytes** | 0 | 0 | **-72** | - -**Surprised? Unexpected?** - -The C++ version is actually **72 bytes smaller**, a 75% reduction in code size! This reduction buys us: - -- ✅ Better encapsulation (private members won't be randomly modified) -- ✅ Automatic initialization (won't forget to call `init`) -- ✅ Type safety (won't pass wrong pointers) -- ✅ More intuitive syntax (`gpio.write(true)` is much nicer than `gpio_write(&gpio, true)`) - -**Key Discovery**: C++'s inline optimization makes the entire `example_cpp` function only 24 bytes, smaller than the C version's multiple functions combined! The compiler optimized all operations into direct register operations. - -### The Truth at the Assembly Level - -If you don't believe it, let's look at the assembly code generated by the compiler (this is the compiler's "X-ray vision"): - -**C version `example_c` (96 bytes, containing multiple function calls):** - -```asm -example_c: - push {r4, lr} - mov r4, r0 - bl gpio_init - mov r0, r4 - movs r1, #1 - bl gpio_write - mov r0, r4 - bl gpio_toggle - pop {r4, pc} -``` - -**C++ version `example_cpp` (Only 24 bytes, fully inlined):** - -```asm -example_cpp: - ldr r3, [r0, #4] - movs r2, #1 - str r2, [r3] - ldr r3, [r0, #4] - ldr r2, [r3] - eors r2, r2, #1 - str r2, [r3] - bx lr -``` - -**See? The C++ version is more concise and efficient!** - -The compiler inlined all C++ class methods, eliminating function call overhead and generating optimal register operations directly. The C version, due to function separation, required extra stack operations and function jumps. - -**Conclusion**: C++ encapsulation is "zero-overhead abstraction"—not only zero overhead, but in many cases, even more efficient! This isn't marketing hype; it's real! - ------- - -## Round Two: Ring Buffer (UART's Best Friend) - -### Task Brief - -The Ring Buffer is the "Swiss Army Knife" of embedded systems. When UART data floods in like a torrent, you need a place to temporarily store them. This is where the ring buffer comes in—a data container where the head and tail connect, and nothing is wasted. - -Imagine a sushi conveyor belt; plates go around in a circle. You put plates down (write), and others take plates (read). As long as the belt isn't full, it keeps spinning. - -#### C Language Version — Just Plain - -```c -typedef struct { - uint8_t buffer[256]; - volatile uint32_t head; - volatile uint32_t tail; -} RingBuffer_C; - -void rb_init(RingBuffer_C* rb) { - rb->head = 0; - rb->tail = 0; -} - -bool rb_put(RingBuffer_C* rb, uint8_t data) { - uint32_t next = (rb->head + 1) % 256; - if (next == rb->tail) return false; - rb->buffer[rb->head] = data; - rb->head = next; - return true; -} - -bool rb_get(RingBuffer_C* rb, uint8_t* data) { - if (rb->tail == rb->head) return false; - *data = rb->buffer[rb->tail]; - rb->tail = (rb->tail + 1) % 256; - return true; -} -``` - -#### C++ Version — Generic - -Okay, here we write generic code—generics have a fault, which is the code bloat issue. - -```cpp -template -class RingBuffer_CPP { - std::array buffer_; - size_t head_ = 0; - size_t tail_ = 0; -public: - bool put(uint8_t data) { - size_t next = (head_ + 1) % Size; - if (next == tail_) return false; - buffer_[head_] = data; - head_ = next; - return true; - } - - bool get(uint8_t& data) { - if (tail_ == head_) return false; - data = buffer_[tail_]; - tail_ = (tail_ + 1) % Size; - return true; - } -}; -``` - ------- - -### Part 1: Ring Buffer Implementation Comparison - -Let's look at the results: - -| Implementation | text (Code) | data | bss | Total | -| -------------- | ----------- | ---- | ---- | ------- | -| C Version | 218 bytes | 0 | 0 | 218 | -| C++ Version | 150 bytes | 0 | 0 | 150 | -| Difference | **-68 bytes** | 0 | 0 | **-68** | - -**Surprised? Unexpected?** - -The C++ version is actually **68 bytes smaller**, a 31% reduction in code size! This is while implementing full ring buffer functionality. This reduction buys us: - -- ✅ Better encapsulation (internal indices won't be modified externally) -- ✅ Automatic constructor initialization (won't forget to call `init`) -- ✅ Type safety (won't pass wrong pointers) -- ✅ More intuitive method calls (`rb.put(data)` is much nicer than `rb_put(&rb, data)`) - -**Key Discovery**: C++ eliminates function call overhead through inline optimization, and the compiler can better optimize class methods. The C version needs multiple independent functions (`rb_init`, `rb_put`, `rb_get`, `rb_available`, `rb_free_space`, `rb_clear`), while the C++ version fuses these operations more compactly through smart inlining. - -### The Truth at the Assembly Level - -If you don't believe it, let's look at the assembly code generated by the compiler: - -**C version `example_c_rb` (depends on multiple functions):** - -```asm -example_c_rb: - push {r4, lr} - mov r4, r0 - bl rb_init - movs r2, #42 - mov r0, r4 - bl rb_put - mov r0, r4 - movs r1, #0 - bl rb_get - pop {r4, pc} -``` - -**C++ version `example_cpp_rb` (fully inlined):** - -```asm -example_cpp_rb: - movs r2, #42 - str r2, [r0] - ldrb r3, [r0] - str r3, [r0, #1] - bx lr -``` - -**See? The C++ version eliminated all function calls!** - -The compiler inlined all methods together, reducing stack operations, function jumps, and register saves. The C version, because of function separation, needs extra `bl` instructions and stack frame setup for every `rb_put` and `rb_get`. - ------- - -## Round Three: State Machine (The Art of Button Debouncing) - -### Task Brief - -Button debouncing is a "required course" for embedded engineers. Mechanical buttons generate chatter (bouncing) when pressed and released (like a spring vibrating back and forth). If not handled, one press might be registered as a dozen. - -We want to implement a state machine to: - -- Detect button press -- Detect button release -- Detect long press (holding for more than 1 second) -- Debounce (ignore chatter within 50ms) - -### C Language Version: Classic State Machine - -```c -typedef enum { IDLE, PRESSED, HOLD } State; -typedef void (*Callback)(void); - -typedef struct { - State state; - uint32_t last_time; - Callback on_press; - Callback on_release; -} Button_C; - -void button_init(Button_C* btn, Callback press_cb, Callback release_cb) { - btn->state = IDLE; - btn->on_press = press_cb; - btn->on_release = release_cb; -} - -void button_update(Button_C* btn, bool pin_state, uint32_t now) { - switch (btn->state) { - case IDLE: - if (pin_state && (now - btn->last_time > 50)) { - btn->state = PRESSED; - if (btn->on_press) btn->on_press(); - } - break; - // ... other states - } - btn->last_time = now; -} -``` - -### C++ Version: Object-Oriented State Machine - -```cpp -class Button_CPP { - enum class State { Idle, Pressed, Hold }; - State state_ = State::Idle; - uint32_t last_time_ = 0; - std::function on_press_; - std::function on_release_; - -public: - Button_CPP(std::function press_cb, std::function release_cb) - : on_press_(press_cb), on_release_(release_cb) {} - - void update(bool pin_state, uint32_t now) { - switch (state_) { - case State::Idle: - if (pin_state && (now - last_time_ > 50)) { - state_ = State::Pressed; - if (on_press_) on_press_(); - } - break; - // ... other states - } - last_time_ = now; - } -}; -``` - -### Battle Analysis: The Cost of std::function - -| Implementation | text (Code) | data | bss | Total | -| --------------------- | ----------- | ---- | ---- | -------- | -| C Version | 172 bytes | 0 | 0 | 172 | -| C++ Version (std::function) | 306 bytes | 0 | 0 | 306 | -| Difference | **+134 bytes** | 0 | 0 | **+134** | - -**This time the difference is obvious!** The C++ version increased **code size by 78%**. The cost of these 134 bytes comes from these places: - -- `std::function`'s type erasure mechanism (requires virtual function tables) -- Extra overhead for lambda captures -- Runtime support code for dynamic polymorphism - -So, this is trying to tell you—our C++ doesn't mean all abstractions are zero overhead. Taking **`std::function` as an example: it brings significant code bloat (78% growth)**. Moreover: **lambda captures have hidden costs, because each lambda requires extra storage and management code. Friends familiar with Lambdas should know this—it generates a struct with an `operator()` call, storing every captured object**: - -The alternative here is also simple: - -```cpp -// Use function pointer or template callback instead -template -class Button { - Callback on_press_; -public: - Button(Callback press_cb) : on_press_(press_cb) {} - // ... -}; -``` - -## Let's Talk - -#### Code Size Comparison Table - -Let's review: - -**Case 1: GPIO Operation Encapsulation** - -In the GPIO operation scenario, C++ class encapsulation showed surprising advantages. The C version required 96 bytes to implement `gpio_init`, `gpio_write`, `gpio_toggle`, and other functions, while the C++ version compressed the entire operation sequence to just 24 bytes through compiler inline optimization, reducing code size by 75%. This huge difference comes from the compiler's ability to fully inline C++ member function calls, eliminating function call overhead and stack frame management. - -**Case 2: Ring Buffer Implementation** - -The ring buffer implementation further validates C++'s advantages. The C version required implementing six independent functions: `rb_init`, `rb_put`, `rb_get`, `rb_available`, `rb_free_space`, `rb_clear`, totaling 218 bytes. The C++ version reduced code size to 150 bytes through class encapsulation and method inlining, saving 31% of space. The key is that the compiler can see the complete call chain, allowing for more aggressive optimization. - -**Case 3: The Warning of std::function** - -Not all C++ features are suitable for embedded development. When using `std::function` to implement callbacks, code swelled from the C version's 172 bytes to 306 bytes, an increase of 78%. This is because `std::function` requires type erasure mechanisms, virtual table support, and management code for lambda captures. This case reminds us that in resource-constrained environments, we must carefully choose which C++ features to use. - -| Feature | Code Growth | Suggestion | -| --------------------- | ------------ | ----------------------------------------------------- | -| Class Encapsulation (Basic) | -75% to -31% | Highly Recommended (Actually smaller in tests) | -| Class Encapsulation (With Templates) | +4% | Highly Recommended (Almost zero overhead) | -| Virtual Functions | +20-40% | Use with caution (Consider CRTP as alternative) | -| Exception Handling | +50-100% | Disable (`-fno-exceptions`) | -| RTTI | +30-50% | Disable (`-fno-rtti`) | -| std::function | +78% | Use with caution (Replace with function pointer or template) | -| Templates (Generic Containers) | +4% | Highly Recommended (Compile-time optimization) | - -### Performance Comparison Table - -Based on cycle count analysis at the assembly level: - -| Category | C Implementation | C++ Implementation | Difference | -| -------------------- | ----------------- | ------------------ | ---------- | -| Single GPIO Operation | 8-10 cycles | 8-10 cycles | 0% | -| Buffer Read/Write | 12-15 cycles | 12-15 cycles | 0% | -| Complete Inlined Op | Needs function call | Fully inlined | C++ Faster | - -**Key Discovery**: With optimizations enabled, C++'s zero-overhead abstraction is not a marketing slogan, but a verifiable fact. The assembly code generated by the compiler shows that C++ class methods and C functions are identical at the single operation level, while in complex operation scenarios, C++ is even faster due to inline optimization. - ------- - -## Best Practices: How to Elegantly Use C++ in Embedded Systems - -### 1. Compiler Options (Slimming Configuration) - -The golden compiler configuration for embedded C++ development is as follows: - -```bash --fno-exceptions -fno-rtti -Os -ffunction-sections -fdata-sections -``` - -This configuration ensures C++ code remains efficient and compact in embedded environments. Tests show that correctly configured C++ code can achieve a size comparable to or even smaller than C. - -### 2. Recommended C++ Features - -The following features are verified by tests to perform excellently in embedded systems: - -**Classes and Objects (Highly Recommended)** - -Class encapsulation is a core advantage of C++, capable of abstracting hardware resources into objects. Tests show that simple class encapsulation not only doesn't increase code size but actually reduces it due to compiler optimization. For example, encapsulating GPIO registers into a class provides type safety and better interfaces while maintaining zero overhead. - -**Constructors and Destructors (Highly Recommended)** - -Constructors provide automatic initialization, and destructors implement the RAII pattern. This is C++'s most powerful resource management mechanism. In embedded systems, destructors can automatically shut down peripherals and release resources, avoiding leaks. Compilers can usually fully inline simple constructors. - -**Templates (Highly Recommended)** - -Templates provide compile-time code generation with absolutely zero runtime overhead. Ring buffer tests show the template version increases code size by only 4% while providing type safety and size parameterization. Compared to C macros, templates are safer and easier to debug. - -**constexpr (Highly Recommended)** - -`constexpr` functions calculate at compile time, embedding results directly into code. Can be used for calculating configuration parameters, lookup table generation, etc., with completely zero runtime overhead. - -**References and Inline Functions (Highly Recommended)** - -References avoid unnecessary copies, and inline functions eliminate function call overhead. In embedded systems, reasonable use of references can significantly improve performance, especially when passing structs. - -**Operator Overloading (Moderately Recommended)** - -Operator overloading makes code more intuitive, e.g., using `buffer << data` instead of `buffer_write(data)`. As long as it's not abused, operator overloading brings no extra overhead. - -### 3. Cautiously Used C++ Features - -The following features have certain overheads and need to be weighed based on the actual situation: - -**Virtual Functions (Use with Caution)** - -Virtual functions introduce a vtable, adding a 4-byte pointer overhead per object, and each call requires an indirect jump. If polymorphism is truly needed, consider using CRTP (Curiously Recurring Template Pattern) to implement compile-time polymorphism, avoiding runtime overhead. - -**std::function (Use with Caution)** - -Tests show `std::function` causes 78% code bloat. If a callback mechanism is needed, prioritize function pointers (same overhead as C) or template callbacks (zero overhead). Only consider `std::function` when lambdas with captured state are needed. - -**Dynamic Memory Allocation (Use with Caution)** - -`new` and `delete` can lead to memory fragmentation in embedded systems. Suggest using placement new with a static memory pool, or using stack objects. If dynamic memory must be used, consider custom allocators. - -**STL Containers (Use with Caution)** - -Standard library containers like `std::vector` and `std::map` can be large in implementation. Suggest testing code size first, or using container libraries specifically optimized for embedded (like EASTL). For simple scenarios, hand-rolled fixed-size containers might be more appropriate. - -### 4. Forbidden C++ Features - -The following features should be completely avoided in embedded systems: - -**Exception Handling (Forbidden)** - -The exception handling mechanism can cause code bloat of 50-100% and introduces unpredictable execution paths. Embedded systems need deterministic behavior; use error codes or assertions instead of exceptions. Must add the `-fno-exceptions` compiler option. - -**RTTI (Forbidden)** - -Run-Time Type Information increases code by 30-50% and is rarely needed in embedded systems. Disable with `-fno-rtti`. If type identification is needed, a simple manual type tag system can be implemented. - -**iostream Library (Forbidden)** - -`std::cout` and `std::cin` introduce huge code (tens of KB), far beyond what embedded systems can bear. Use traditional `printf`/`scanf` or specialized embedded logging libraries. - -**Multiple Inheritance (Forbidden)** - -Multiple inheritance increases complexity and code size, and can lead to the diamond problem. In embedded systems, single inheritance or composition patterns are sufficient. - ------- - -## Practical Advice: When to Use C, When to Use C++? - -### Scenarios for Choosing C - -**Extremely Resource-Constrained Environments** - -When the target hardware has less than 8KB Flash and less than 1KB RAM, C is the safer choice. Such systems are usually simple sensor nodes or controllers that don't require complex abstractions. - -**Team Skill Stack Limitations** - -If team members are unfamiliar with C++, or the project timeline is tight, forcing C++ might do more harm than good. C has a gentler learning curve and is easier to master. - -**Pure C Codebase Integration** - -When integrating a large amount of existing C code, using C avoids the hassle of mixed programming. Although C++ can call C code, in some cases, a pure C project is simpler. - -**Insufficient Toolchain Support** - -Some older or specialized compilers have incomplete C++ support and may produce inefficient code. In this case, C is the more reliable choice. - -### Scenarios for Choosing C++ - -**Medium to High Resource Systems** - -When Flash is greater than 16KB and RAM is greater than 2KB, C++ advantages start to show. Such systems have enough space to accommodate C++ abstraction mechanisms while benefiting from encapsulation and type safety. - -**Complex State Management** - -When implementing complex logic like state machines, protocol stacks, or sensor fusion, C++ class encapsulation can significantly reduce complexity. Objects can encapsulate state and behavior, making code easier to maintain. - -**Need Code Reuse** - -When there are multiple similar modules (e.g., multiple UARTs, multiple timers), C++ templates are safer and easier to debug than C macros. Templates provide compile-time type checking and parameterization. - -**Modern Development Practices** - -If the team is familiar with modern C++ (C++11 and later) and can correctly use features like smart pointers, move semantics, and lambdas, development efficiency will significantly improve. - -### Mixed Usage (Best Practice) - -Many successful embedded projects adopt a layered mixed strategy: - -**Low-Level Driver Layer: Use C** - -Low-level drivers that directly manipulate registers are written in C to ensure stability and portability. This code is usually not complex, and C is sufficient. - -**Middle Abstraction Layer: Use C++** - -Wrap low-level drivers into C++ classes to provide object-oriented interfaces. For example, wrapping a UART driver into a `SerialPort` class provides a safer, more easy-to-use API. - -**Application Logic Layer: Use C++** - -Business logic, state machines, and data processing are implemented in C++, utilizing features like classes, templates, and RAII to simplify code. - -**Module Interface: Use `extern "C"`** - -Interfaces between modules use `extern "C"` declarations to ensure C and C++ modules can collaborate seamlessly. This maintains flexibility while avoiding name mangling issues. - ------- - -## Online Run - -Compare C and C++ GPIO encapsulation and ring buffer differences in code behavior and `sizeof` online: - - - -## Exercise Time: Try It Yourself - -### Exercise 1: Actual Measurement - -Implement the three examples above on your development board and measure: - -1. Flash usage (use `size`) -2. RAM usage (check `.bss` and `.data` sections) -3. Run time (use DWT cycle counter) - -### Exercise 2: Optimization Challenge - -Try to optimize the ring buffer: - -1. When Size is a power of 2, replace modulo with bitwise operations (`% Size` → `& (Size - 1)`) -2. Implement zero-copy `peek` operation -3. Add interrupt-safe version (disable interrupts or use atomic operations) - -### Exercise 3: Design Decisions - -Choose C or C++ for the following scenarios: - -1. Simple UART driver (only send/receive) → **Your choice?** -2. Sensor fusion algorithm (Kalman filter) → **Your choice?** -3. 1ms real-time control loop → **Your choice?** -4. OTA firmware upgrade module → **Your choice?** - -### Exercise 4: Code Review - -Find the problems in the following C++ code: - -```cpp -class BadExample { - std::vector data; -public: - void add(int val) { data.push_back(val); } - void process() { - for (auto& d : data) { - // Complex processing - } - } -}; -``` - -**Improved Version**: - -```cpp -template -class GoodExample { - std::array data; - size_t count = 0; -public: - bool add(int val) { - if (count >= N) return false; - data[count++] = val; - return true; - } - void process() { - for (size_t i = 0; i < count; ++i) { - // Complex processing - } - } -}; -``` - -## Final Words - -Quoting Bjarne Stroustrup (Father of C++): - -> "C++ is not a language you have to use in its entirety, but a language you can choose to use." - -In embedded systems, we need to be smart choosers, not blind followers. Use C++'s powerful features to improve code quality while avoiding those that don't fit in resource-constrained environments. diff --git a/documents/en/vol6-performance/10-asan-family-and-memory-safety.md b/documents/en/vol6-performance/10-asan-family-and-memory-safety.md deleted file mode 100644 index be84374a1..000000000 --- a/documents/en/vol6-performance/10-asan-family-and-memory-safety.md +++ /dev/null @@ -1,411 +0,0 @@ ---- -title: 'ASan Tool Family and Memory Safety: Shadow Memory, Heartbleed, and Sanitizer Selection' -description: 'Starting with Heartbleed, we break down the shadow memory trio of AddressSanitizer, test OOB, UAF, global out-of-bounds, and UBSan, and clarify the responsibilities and mutual exclusivity of the five brothers: ASan, LSan, MSan, TSan, and UBSan.' -chapter: 6 -order: 10 -platform: host -difficulty: advanced -cpp_standard: -- 11 -- 14 -- 17 -- 20 -reading_time_minutes: 22 -prerequisites: -- 动态内存管理(new/delete 与智能指针) -- 并发程序调试技巧(ThreadSanitizer) -related: -- 动态内存管理(new/delete 与智能指针) -- 并发程序调试技巧(ThreadSanitizer) -- C 语言动态内存管理(malloc/free 与 valgrind) -tags: -- host -- cpp-modern -- advanced -- 内存安全 -- 调试 -- 内存管理 -translation: - source: documents/vol6-performance/10-asan-family-and-memory-safety.md - source_hash: 8062233844a7990d1e7112dd028dffa5f17ee3f2c46a0f517645d984738e3f8b - translated_at: '2026-06-25T13:28:09.799801+00:00' - engine: anthropic - token_count: 4210 ---- -# The ASan Tool Family and Memory Safety: Shadow Memory, Heartbleed, and Sanitizer Selection - -> PS: This content was migrated from notes I took during college. It has undergone limited verification through research. If you find any technical inaccuracies or outright errors, please report an issue or submit a pull request! - -After writing C/C++ for a while, you have likely been tortured by these classes of problems repeatedly: reading one element past the end of an array, using a pointer after it has been freed, or calling `delete` twice on the same pointer. These errors share a nasty characteristic—they are **undefined behavior** (the infamous UB). It's not that they "always crash," but rather "they run fine in a Debug build, then randomly explode in Release or on a different machine." Even worse, the crash location is often far removed from the actual buggy code, and the stack trace might point to some innocent library function. - -Why does this happen? Because these bugs corrupt the memory manager's own metadata, and the explosion only happens during the next `malloc`/`free` operation that touches that corrupted location. Previous volumes focused on performance; this time, we switch dimensions to another topic: how to use tools to catch bugs before they cause production incidents. The protagonists are AddressSanitizer (ASan) and the entire family of sanitizer tools behind it. - -Don't rush to treat ASan as just a "add a flag and you're done" utility. The design behind it—shadow memory and compile-time instrumentation—is actually one of the most significant engineering advancements in C/C++ memory safety over the last decade. Furthermore, it was originally invented to plug a hole that terrified the entire internet. We start with that hole. - -## The Origin: Heartbleed and Buffer Over-reads - -In April 2014, CVE-2014-0160 was disclosed under the name Heartbleed. This was a vulnerability hidden in a seemingly harmless feature of the OpenSSL implementation—the TLS heartbeat extension. The protocol is simple: the client sends arbitrary data and tells the server, "This data is N bytes long, please read it back verbatim," to verify the connection is still alive. - -The vulnerability lay in the fact that the server **trusted the length N reported by the client without verifying that N did not exceed the actual length of the data it held**. Consequently, an attacker simply needed to report a large N (for example, 64 KB), and the server would "read back" 64 KB from its own process memory to the attacker. What was read could be the TLS private keys of other sessions, user passwords, or session tokens—everything adjacent to that buffer in process memory would be leaked. - -The nature of this bug was not an out-of-bounds **write**, but an out-of-bounds **read** (buffer over-read). Write overflows corrupt data and are easily exposed; over-reads are much quieter, as the process itself does not crash, and data simply silently leaks out. ASan was frequently cited back then because it was one of the few tools capable of **reliably detecting over-reads**—as long as that out-of-bounds memory touched the redzones planted by ASan, a single read would trigger an immediate error. - -We will use a few dozen lines of Modern C++ to recreate a Heartbleed-shaped bug, and then have ASan catch it in the act. This is the flagship demo for this article, and we will refer to it repeatedly. - -```cpp -// oob_read.cpp —— 复刻 Heartbleed 形状的越界读 -// Platform: host Standard: C++20 -// 编译: g++ -std=c++20 -O1 -fsanitize=address -g oob_read.cpp -o oob_read -#include -#include -#include - -// 心跳回显:客户端说"还给我 n 字节"。服务器照办,但不校验 n 上界。 -std::string read_back(const std::array& buf, int n) -{ - return std::string(buf.data(), n); // n 可能远大于 8 -} - -int main() -{ - std::array buf{'H', 'i', '!', 0, 0, 0, 0, 0}; - // 只授权了 8 字节,却要求"读回" 64 字节 —— 经典 over-read - auto leaked = read_back(buf, 64); - std::printf("读到 %zu 字节: %.8s...\n", leaked.size(), leaked.c_str()); -} -``` - -If we compile and run this without ASan, the code will likely "appear to work correctly": the `std::string` constructor dutifully copies 64 bytes starting from `buf.data()`, reading the irrelevant bytes on the stack. The program won't crash. This is exactly what makes over-reads so dangerous. - -However, if we add `-fsanitize=address` and run it again, the situation changes drastically. Here is the output from the local GCC 16.1.1: - -```text -================================================================= -==37023==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x72175e1f0028 at pc 0x761760d29ac2 ... -READ of size 64 at 0x72175e1f0028 thread T0 - #0 0x... in memcpy (/usr/lib/libasan.so.8+0x129ac1) - ... - #6 0x... in read_back[abi:cxx11](std::array const&, int) oob_read.cpp:11 - #7 0x... in main oob_read.cpp:18 - ... - - This frame has 2 object(s): - [32, 40) 'buf' (line 16) - [64, 96) 'leaked' (line 18) <== Memory access at offset 40 partially underflows this variable -SUMMARY: AddressSanitizer: stack-buffer-overflow oob_read.cpp:11 in read_back -``` - -Pay attention to two details. First, the error type is `stack-buffer-overflow`, occurring on line 11 of `read_back`—specifically the `return std::string(buf.data(), n);` line. This precision in source location is exactly why compiling with `-g` is essential. Second, ASan even tells us that there are two objects on the stack: `buf` occupies `[32, 40)` and `leaked` occupies `[64, 96)`. The out-of-bounds read location (offset 40) falls right between them. This level of forensic detail is what fundamentally distinguishes ASan from "adding an assertion and hunting slowly." - -## So, what exactly does ASan do to achieve this? - -### Part One: Compile-Time Instrumentation (CTI) - -ASan is not a post-mortem profiler; rather, it **rewrites your code at compile time**. When you add `-fsanitize=address`, the compiler (whether GCC or Clang) inserts extra check instructions before and after every memory access—every `*p`, every array subscript, every `memcpy`. This technique is called **compile-time instrumentation (CTI)**, also known as static instrumentation. - -Let's first verify that it actually "touches your code." Compile the `oob_read.cpp` from above without ASan, then again with ASan, and compare the **size of their code segments (.text)**—that is, the actual machine instructions packed into the binary: - -```text -普通构建 .text: 2792 字节 -ASan 构建 .text: 5736 字节 (+105%) -``` - -(On the local machine with GCC 16.1.1, `g++ -std=c++20 -O1 -g`, checking the `.text` section with `size`.) The doubled size represents the check instructions the compiler inserts before and after every memory access. Note a pitfall here: **don't compare the total binary file size**—ASan's runtime library, `libasan.so.8`, is **dynamically linked** (visible via `ldd`) and not baked into the executable, so the total file size only increases by about 5%. The `.text` code section, which truly reflects the amount of instrumentation, is where the doubling occurs. The cost is increased size and slower execution—but compared to the bugs it can catch, this overhead is negligible during development. CTI is determined at **compile time**, so you must include `-fsanitize=address` during **compilation**, and also during **linking**. If you add it only when compiling the main program but omit it when linking a third-party `.a` library, memory accesses inside that library won't be instrumented, and ASan will be blind to that code. The complete workflow is: - -```bash -g++ -std=c++20 -O1 -fsanitize=address -g -c a.cpp -o a.o # 编译带 -g++ -std=c++20 -O1 -fsanitize=address -g main.cpp a.o -o app # 链接也带 -``` - -The `-fsanitize=address` flag must be present in **both** the compilation and linking stages. If it is missing from either one, it will not work. - -### The Second Piece: Shadow Memory - -Instrumentation alone is not enough. The check code inserted by instrumentation needs a "ledger" to answer the question: "Is this address accessible right now?" This ledger is the **shadow memory**. - -The core idea is an elegant design: **use one byte of shadow memory to record the accessibility state of eight bytes of actual memory**. In other words, AddressSanitizer maps the entire process address space to a contiguous shadow region in 8-byte chunks, with a ratio of 1:8. This way, checking if an address is valid simply involves calculating its corresponding shadow byte and reading it; there is no need to maintain complex hash tables. - -AddressSanitizer prints the values of the shadow bytes at the end of its report. Let's look at the legend in the actual output: - -```text -Shadow byte legend (one shadow byte represents 8 application bytes): - Addressable: 00 - Partially addressable: 01 02 03 04 05 06 07 - Heap left redzone: fa - Freed heap region: fd - Stack left redzone: f1 - Stack mid redzone: f2 -``` - -This covers the complete semantics of the 1:8 mapping. `00` indicates that all eight bytes are accessible; `01` through `07` indicate that only the first few bytes are valid (for example, `03` means the first three bytes are accessible and the last five are not, used for partially accessible regions at the end of alignments); `fa` is the redzone around heap allocations—ASan secretly surrounds every block of memory you `new` with a "no-entry zone", so if you read `fa`, you have a heap buffer overflow; `fd` is memory that has already been `free`'d, so touching it is a use-after-free; `f1`/`f2` are redzones for stack objects. - -Looking back at the shadow dump in the error report above: - -```text -=>0x72175e1f0000: f1 f1 f1 f1 00[f2]f2 f2 00 00 00 00 f3 f3 f3 f3 -``` - -`00` represents the body of `buf` (8 bytes, plus 1 shadow byte). The immediately following `[f2]` is the mid redzone between stack objects. The address of our out-of-bounds read landed exactly on this `f2`—which ASan spotted immediately. This is why the shadow memory mechanism can achieve byte-level precision. - -### The Third Piece: Runtime Library + Quarantine - -Instrumentation and shadow regions alone aren't enough; someone needs to **keep the ledger**. The ASan runtime library (`libasan`) completely replaces functions like `new`/`delete` and `malloc`/`free` with its own versions. Every time memory is allocated, the runtime paints redzones in the shadow region for it; every time it is freed, the corresponding shadow memory is marked as `fd`. - -A key design here is called **quarantine**. When memory is `free`'d, ASan doesn't return it to the system for reallocation immediately. Instead, it tosses it into a quarantine queue to "cool off." Why? Because for bugs like use-after-free, if you `free` memory and immediately reallocate it to someone else, the shadow state of that block resets to `00`, and subsequent erroneous reads won't be caught. By quarantining it for a while, we ensure that the "freed" state persists long enough to collide with any subsequent invalid accesses. - -However, quarantine isn't unlimited—the queue has a cap. Once full, the oldest freed memory is recycled via FIFO. So, ASan's detection of use-after-free isn't 100%—if the quarantine window has passed and the memory has been reallocated, that specific invalid read might slip through. But with sufficient test coverage, the vast majority of UAFs will be caught. - -### The Cost: 2-4x Overhead, and Why It's Worth It - -With this three-piece combo, ASan typically incurs **2-4x runtime slowdown and 3-5x memory overhead** (shadow memory takes 1/8, plus redzones and quarantine). That sounds like a lot, but it depends on who you compare it to. - -Traditional memory checking tools like Valgrind (Memcheck) use **Dynamic Binary Instrumentation** (DBI)—they don't recompile your program. Instead, at runtime, they translate every machine instruction into their own intermediate representation, analyzing and executing them one by one. High precision and no recompilation needed, but the cost is 20-50x slowdown. A test that takes 1 second normally might take half a minute with Valgrind, making it often impossible to include in daily CI. - -ASan shifts the analysis cost **upfront to compile time** (CTI). At runtime, it only performs table lookups, keeping the overhead down to 2-4x. This magnitude means you can **permanently** enable ASan in development and CI to run full test suites, rather than remembering to manually run Valgrind occasionally. This is ASan's fundamental advantage over Valgrind—it's not that it's more accurate, but that it's **affordable**. - -::: warning No Valgrind Installed Locally -All ASan/UBSan outputs in this article were run locally on real hardware (GCC 16.1.1 / Clang 22, WSL2). Valgrind is not installed in the local environment (`which valgrind` → not found), so actual Valgrind output is not included here. Students who need Valgrind can run `apt install valgrind` on Debian/Ubuntu. For usage, see the Valgrind section in vol1's [C Dynamic Memory Management](../vol1-fundamentals/c_tutorials/14-dynamic-memory.md). Remember the essential difference between the two commands: **ASan is compile-time `-fsanitize=address` (CTI), while Valgrind is runtime `valgrind ./prog` (DBI)**. -::: - -## The Tool Family: Five Sanitizers, Each for a Category - -ASan is actually just one member of a family. This set of tools was originally implemented by Google engineers as patches for GCC and Clang, later becoming standard in mainstream compilers. The family has five members, each watching for a specific class of errors: - -| Tool | Flag | What it Catches | Typical Overhead | -|------|------|-----------------|------------------| -| **ASan** (AddressSanitizer) | `-fsanitize=address` | OOB read/write, use-after-free, double-free, stack/global overflow | 2-4x slowdown | -| **LSan** (LeakSanitizer) | `-fsanitize=leak` | Memory leaks (heap memory not freed at exit) | Near zero overhead | -| **MSan** (MemorySanitizer) | `-fsanitize=memory` | Reading uninitialized memory (use of uninitialized value) | ~3x slowdown | -| **TSan** (ThreadSanitizer) | `-fsanitize=thread` | Data races, deadlocks | 5-15x slowdown | -| **UBSan** (UndefinedBehaviorSanitizer) | `-fsanitize=undefined` | Undefined behavior (signed overflow, null pointer dereference, out-of-bounds shift, etc.) | Configurable, most sub-checks have low overhead | - -Among the five, ASan is the workhorse and almost essential for daily development. LSan is enabled by default with ASan (in GCC/Clang on supported environments). MSan is only fully available on Clang and requires **the entire program** to be compiled as MSan (even libc needs a MSan version, or false positives will be rampant). TSan specifically monitors concurrency; we covered it in detail in vol5's [Debugging Concurrency](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md). UBSan acts as a "finisher," with low overhead that allows it to be combined with others. - -### ASan and TSan are Mutually Exclusive: An Iron Rule - -These five tools cannot be combined arbitrarily. The most important constraint is: **ASan and TSan cannot be enabled simultaneously**. ASan needs its own shadow memory layout, and TSan needs its own; the two mechanisms will conflict. The compiler will reject you outright at compile time: - -```text -$ g++ -std=c++20 -fsanitize=address,thread -g conflict.cpp -o conflict -cc1plus: error: '-fsanitize=thread' is incompatible with '-fsanitize=address' -``` - -The error message is straightforward. The engineering consequence of this constraint is that within a project's CI, memory error detection and data race detection require **two separate builds**—one with ASan and one with TSan—each running the test suite independently. Volume 5's chapter on TSan covered this "dual build" practice in detail, so we will just focus on the conclusion here. - -As for MSan, it is incompatible with both ASan and TSan (it requires all code to be "clean" and run through its own uninitialized memory tracking), and it only supports Clang. Consequently, it is the least used in real-world projects. LSan and UBSan are the two "versatile" options—LSan has almost zero overhead and can be a permanent fixture, while most of UBSan's sub-options can also be enabled alongside ASan. - -## In Practice: ASan Catches Three Typical Errors - -Just talking about principles isn't satisfying. Let's write code for the three types of memory errors that最容易 trip us up in C++, and let ASan catch them one by one. The following three sections are based on actual local runs. - -### Heap Use-After-Free - -Smart pointers can prevent most use-after-free (UAF) issues, but as long as a project still contains raw pointers or C-style APIs, this hole cannot be completely plugged. Here is a minimal example—we release a `unique_ptr`, but then hold onto the raw pointer it previously returned to read data: - -```cpp -// ... code block would follow ... -``` - -```cpp -// uaf.cpp —— use-after-free -// Platform: host Standard: C++20 -// 编译: g++ -std=c++20 -O1 -fsanitize=address -g uaf.cpp -o uaf -#include -#include - -int main() -{ - auto p = std::make_unique(42); - int* raw = p.get(); // 拿到裸指针 - p.reset(); // 这里释放 —— raw 立刻变成悬空指针 - std::printf("悬空指针读到的值: %d\n", *raw); // use-after-free -} -``` - -Run with ASan: - -```text -================================================================= -==37082==ERROR: AddressSanitizer: heap-use-after-free on address 0x7a948abe0010 ... -READ of size 4 at 0x7a948abe0010 thread T0 - #0 0x... in main uaf.cpp:12 - -0x7a948abe0010 is located 0 bytes inside of 4-byte region [0x7a948abe0010,0x7a948abe0014) -freed by thread T0 here: - #0 0x... in operator delete(void*, unsigned long) (/usr/lib/libasan.so.8+0x12e4c1) - ... - #4 0x... in main uaf.cpp:11 - -previously allocated by thread T0 here: - #0 0x... in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341) - ... - #2 0x... in main uaf.cpp:9 - -SUMMARY: AddressSanitizer: heap-use-after-free uaf.cpp:12 in main -``` - -This report is the most valuable part of ASan. It doesn't just tell you "the read on line 12 is a use-after-free"; it simultaneously presents **two phases of this memory's history**: allocated by `make_unique` at `uaf.cpp:9` and freed by `reset` at `uaf.cpp:11`. Looking at these two lines, the causal chain of the bug becomes complete—this is exactly the value of the quarantine and redzone mechanisms: freed memory is marked as `fd` instead of being immediately reclaimed, so subsequent erroneous reads can collide with it. - -That `[fd]` in the shadow dump is the smoking gun: - -```text -=>0x7a948abe0000: fa fa[fd]fa fa fa fa fa ... -``` - -`fd` = freed heap region. This is the result of ASan's "bookkeeping" capabilities. - -### Global Buffer Overflow - -Global and static variables are also protected by redzones. ASan will reliably detect out-of-bounds access to global arrays as well: - -```cpp -// global_oob.cpp —— 全局数组越界 -// 编译: g++ -std=c++20 -O1 -fsanitize=address -g global_oob.cpp -o global_oob -#include -int g[4] = {1, 2, 3, 4}; -int main() { std::printf("g[5] = %d\n", g[5]); } -``` - -```text -==38356==ERROR: AddressSanitizer: global-buffer-overflow on address 0x63ca65acd074 ... -SUMMARY: AddressSanitizer: global-buffer-overflow global_oob.cpp:5 in main -``` - -The error type is explicitly marked as `global-buffer-overflow`. ASan uses different redzone encodings to distinguish between stack, heap, and global regions (`f1`/`f2` for stack, `fa` for heap, `f9` for global), so we can see at a glance which type of memory the out-of-bounds access occurred on. - -::: warning Regarding the claim that "Global OOB requires Clang 11" -Some older resources might claim that "ASan requires Clang 11 or higher to detect global variable out-of-bounds access." The historical context for this is that early ASan support for global variable redzones was incomplete; Clang 11 introduced improvements like the ODR indicator (`-fsanitize-address-use-odr-indicator`) to solidify global detection. However, **today**—with GCC 8.3+ and current Clang versions—global out-of-bounds detection is enabled by default and works out of the box. The example above was caught immediately with default settings on the local GCC 16.1.1. Therefore, this "version threshold" is obsolete for modern toolchains, so don't be misled by outdated documentation. -::: - -### Leaks: LSan Wraps Up at Exit - -Finally, let's look at memory leaks. LSan operates differently than the previous sanitizers—it doesn't report errors during execution. Instead, when `main` returns and the program is about to exit, it scans all "live" heap allocations and flags those that are no longer referenced or have not been freed. Let's write a minimal example that intentionally leaks: - -```cpp -// leak.cpp —— 故意泄漏 -// Platform: host Standard: C++20 -// 编译: g++ -std=c++20 -O1 -fsanitize=address -g leak.cpp -o leak -#include -#include -int main() -{ - int* p = (int*)std::malloc(sizeof(int) * 4); // 拿了堆内存 - p[0] = 42; - std::printf("ptr = %p\n", (void*)p); // 让指针逃逸,防止整段被优化器删掉 - // 没有 free,程序退出时 p 指向的内存泄漏 -} -``` - -Running with ASan (GCC 16.1.1 / WSL2, LSan is enabled by default with ASan): - -```text -ptr = 0x730c4cbe0010 -================================================================= -==364484==ERROR: LeakSanitizer: detected memory leaks - -Direct leak of 16 byte(s) in 1 object(s) allocated from: - #0 0x... in malloc (/usr/lib/libasan.so.8+0x12c161) - #1 0x... in main leak.cpp:8 - ... - -SUMMARY: AddressSanitizer: 16 byte(s) leaked in 1 allocation(s). -``` - -Note that the report is printed **after** `main` returns. This is how LSan's "cleanup on exit" mechanism works. Volume 1's [Dynamic Memory Management](../vol1-fundamentals/ch12/02-new-delete.md) also provided an equivalent example for comparison. - -::: warning The "Silent Exit" Trap of LSan -In mainstream Linux environments (GCC 16.1.1 / Clang 22), LSan is enabled by default alongside ASan, and the example above can be caught reliably on a local machine. However, be wary of a real-world pitfall: **leaks are only scanned when the process exits normally**. If your program is killed by `SIGKILL`, calls `_exit` to bypass `atexit` hooks, or runs in certain containers/sandboxes where LSan's exit hooks don't execute, the leak report will **silently vanish**. The program appears to have "no errors," but in reality, LSan never had a chance to scan. - -Troubleshooting steps: Confirm the process exits via a normal `return`. If necessary, explicitly force it on with `ASAN_OPTIONS=detect_leaks=1`. Long-running services (which never exit) cannot use LSan's "cleanup on exit" model; you must switch to Valgrind Massif or heap sampling instead. Do not assume that "no report from LSan means no leaks." -::: - -## UBSan: Turning "Undefined Behavior" from Silent Failures into Errors - -Having covered the mainstays of the ASan family, let's look at UBSan, the finisher. C/C++ has a blood-pressure-raising feature: **Undefined Behavior (UB)**. The compiler's attitude toward UB is: "Since the standard doesn't specify what happens, I'll assume it doesn't happen and optimize freely." The consequence is that errors like signed integer overflow, out-of-bounds shifts, and null pointer dereferences often **appear to run perfectly fine**—until one day you turn on `-O2` or switch compiler versions. The optimizer, relying on the assumption that "this won't overflow," performs aggressive transformations, and the program suddenly produces outrageous results. - -UBSan's strategy is to insert a runtime check next to every operation that could produce UB. If UB actually occurs, it immediately prints a `runtime error: ...` report (by default, it does not abort the program, but this can be configured). The overhead is low, and many sub-features can coexist with ASan. - -Let's look at a minimal example that packs three classic types of UB into one: - -```cpp -// ubsan.cpp —— UBSan 捕获未定义行为 -// Platform: host Standard: C++20 -// 编译: g++ -std=c++20 -O1 -fsanitize=undefined -g ubsan.cpp -o ubsan -#include -#include - -int main() -{ - int arr[4]{1, 2, 3, 4}; - int idx = 10; - std::printf("越界下标 arr[10] = %d\n", arr[idx]); // 下标越界 - - int max = std::numeric_limits::max(); - std::printf("有符号溢出: %d\n", max + 1); // 有符号整数溢出 - - int shift = 32; - std::printf("左移 32 位: %d\n", 1 << shift); // 位移量 >= 位宽 -} -``` - -Run with UBSan: - -```text -ubsan.cpp:11:55: runtime error: index 10 out of bounds for type 'int [4]' -ubsan.cpp:11:16: runtime error: load of address 0x7ffe8a0525c8 with insufficient space for an object of type 'int' -ubsan.cpp:14:16: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' -ubsan.cpp:17:42: runtime error: shift exponent 32 is too large for 32-bit type 'int' -``` - -All three UBs were caught, pinpointed exactly to `file:line:column`. The list of UBs covered by UBSan is extensive. Common ones include: - -- **Arithmetic**: Signed integer overflow/underflow, division by zero. -- **Shift**: Shift amount negative or greater than/equal to bit width, left shift changing the sign bit. -- **Memory/Pointer**: Null pointer dereference, misaligned memory access, object size mismatch (accessing via wrong pointer type). -- **Array**: Out-of-bounds subscripts (`-fsanitize=bounds`). This overlaps with ASan's OOB detection but focuses differently—ASan looks at redzones, while UBSan looks at array sizes known at compile time. - -The overhead of UBSan depends on which sub-options you enable. `-fsanitize=undefined` is a collection of default sub-options, most of which are lightweight. The truly expensive one is `-fsanitize=integer` (which treats unsigned overflow as an error, high overhead, many false positives, use with caution in production). Recommendation for daily use: enable `-fsanitize=undefined` alongside ASan. It has a low cost and high yield. - -## Selection: Which Tool to Use for a Memory Bug - -Now that all five siblings have been introduced. The question arises—when you are face-to-face with a weird bug, in what order should you select your tools? We locate them by "symptom": - -- **Crashes on run / Segfault / Intermittent crashes**: Start with ASan and run reproduction tests. OOB, UAF, and double-free are the three most common causes of segfaults; ASan catches them all. -- **Intermittent incorrect results / Weird values across functions**: Suspect UAF or data race. First, use ASan to rule out UAF. If ASan is silent, build a separate TSan version to check for data races (remember, the two are mutually exclusive and cannot be enabled simultaneously). -- **Calculated values are ridiculous / Behavior changes under `-O2`**: Almost certainly a UB lock; go straight to UBSan. -- **Reading "looks normal" garbage values / Behavior depends on uninitialized data**: MSan (Note: Clang only, requires full program compilation). -- **Process eats more and more memory / Suspected leak**: LSan (reports on exit), or for long-running services, use Valgrind massif / heap sampling. - -A sound engineering practice is to **have two builds resident in CI**—one set with `ASan+UBSan` and one with `TSan`, running on every commit. The overhead is acceptable (ASan+UBSan is in the 2-4x range), and it buys you the ability to catch the most expensive bugs—like "intermittent crashes after deployment"—before they leave the building. - -::: warning ASan Is Not a Silver Bullet -ASan is powerful, but it has unavoidable limitations that you must keep in mind. - -First, **it only catches paths that are "actually executed"**. CTI is runtime detection; if code isn't run, the check won't trigger. If your test coverage is insufficient and a certain OOB path is never triggered, ASan won't catch it—this is exactly why ASan should be paired with good test cases, or even fuzzing. Fuzzing is responsible for running out rare paths, and ASan is responsible for reporting the moment an error occurs on those paths. - -Second, **it only catches memory errors**. Logic errors (calculation mistakes), concurrency errors (data races), and integer overflow UB are not ASan's concern—the latter belongs to UBSan, the former to TSan. Don't expect one flag to solve all problems. - -Third, **do not enable in production**. 2-4x slowdown and extra memory are a disaster under production load. ASan/UBSan/TSan are tools for the **development/testing/CI stages**; these flags must be removed in release builds. - -Fourth, **it has false positive boundaries**. Certain custom stack unwinding mechanisms (`swapcontext`, `vfork`) can cause ASan's shadow region logic to fail and report false positives. The line `HINT: this may be a false positive if your program uses some custom stack unwind mechanism` in the report is a reminder of this. -::: - -## Summary - -Starting from the Heartbleed over-read vulnerability that terrified the world, we've dissected the ASan tool family in this article. Let's wrap up with a few key conclusions: - -- **The ASan Trio**: Compile-time instrumentation (CTI) rewrites every memory access; shadow memory uses a 1:8 shadow byte to record the accessibility state of every 8 bytes of application memory; a runtime library replaces `new`/`delete` and uses quarantine to isolate freed memory. Overhead is 2-4x slowdown, far lower than Valgrind's 20-50x, making it viable for permanent CI residence. -- **Shadow Memory Encoding**: `00` accessible, `01`-`07` partially accessible, `fa` heap redzone, `fd` freed, `f1`/`f2` stack redzone, `f9` global redzone. The shadow dump at the end of an ASan report is a visual representation of this encoding. -- **Tool Family**: ASan (OOB/UAF), LSan (leaks), MSan (uninitialized reads, Clang only), TSan (data races), UBSan (UB). **ASan and TSan are mutually exclusive**, so maintain separate builds in CI. -- **UBSan is the Finisher**: Low overhead, can coexist with ASan, turning "silent UBs" like signed overflow, shift out-of-bounds, and null pointer dereferences into explicit `runtime error`s. -- **Selection Mnemonic**: For segfaults, use ASan first. For intermittent errors, rule out UAF with ASan then check races with TSan. For ridiculous arithmetic, use UBSan. For memory growth, check LSan. Always disable in production environments. - -True memory safety isn't achieved by catching bugs with tools, but by guarding against them at the syntactic level using RAII, smart pointers, `std::span`, and range `for` loops—mechanisms that make it physically impossible to write OOB or dangling accesses. Those are the topics of vol1 and vol3. The value of the ASan toolset lies in the transition period—before you've replaced every raw pointer and wrapped third-party C libraries. It acts as the "last line of defense," forcing latent memory bugs to reveal themselves during development rather than detonating at 3 AM in production. - -## Reference Resources - -- [AddressSanitizer · google/sanitizers Wiki](https://github.com/google/sanitizers/wiki/AddressSanitizer) — Official ASan documentation, the authoritative source for shadow memory mechanisms, 1:8 mapping, and 2x overhead. -- [Clang: AddressSanitizer](https://clang.llvm.org/docs/AddressSanitizer.html) — Clang-specific ASan docs, including global detection evolution like `-fsanitize-address-use-odr-indicator`. -- [Clang: ThreadSanitizer](https://clang.llvm.org/docs/ThreadSanitizer.html) — TSan documentation, source for ASan↔TSan mutual exclusion (see vol5 Concurrency Debugging). -- [Clang: UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) — List of UBSan sub-options and overhead. -- [Valgrind User Manual](https://valgrind.org/docs/manual/manual.html) — DBI methodology and Memcheck/Helgrind, reference for the 20-50x overhead comparison. diff --git a/documents/en/vol6-performance/11-memory-safety-asan-valgrind.md b/documents/en/vol6-performance/11-memory-safety-asan-valgrind.md deleted file mode 100644 index a3d8f5514..000000000 --- a/documents/en/vol6-performance/11-memory-safety-asan-valgrind.md +++ /dev/null @@ -1,491 +0,0 @@ ---- -title: 'Valgrind vs. ASan Comparison: JIT Interpretation vs. Compile-time Instrumentation' -description: Break down the responsibilities of the Valgrind suite (Memcheck, Callgrind, Cachegrind, Helgrind/DRD, and Massif), compile and run six classic memory errors with ASan, and thoroughly explain the essential differences between "dynamic binary translation" and "compile-time shadow memory instrumentation. -chapter: 6 -order: 11 -platform: host -difficulty: advanced -cpp_standard: -- 11 -- 14 -- 17 -- 20 -tags: -- host -- cpp-modern -- advanced -- 内存安全 -- 调试 -- 内存管理 -reading_time_minutes: 28 -prerequisites: -- 动态内存管理(new/delete 与智能指针) -- C 语言动态内存(malloc/free 与 valgrind 速览) -related: -- ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 -- 并发程序调试技巧(TSan / Helgrind 深入) -- 动态内存管理 -translation: - source: documents/vol6-performance/11-memory-safety-asan-valgrind.md - source_hash: 6cfba1678566f8d5e146ab1d149ee1297c8f9e5a6c0ef64c81c72a73090a073e - translated_at: '2026-06-25T13:29:35.943066+00:00' - engine: anthropic - token_count: 5270 ---- -# Valgrind vs. ASan: JIT Interpretation vs. Compile-Time Instrumentation - -> PS: This content has been migrated from the author's university notes. Key conclusions have been verified on the local machine using GCC 16.1.1 + Valgrind 3.25.1; if there are any omissions, issues or PRs are welcome. - -Let's start with a scenario we've likely all encountered: a piece of C++ code runs perfectly fine locally, but crashes sporadically in production, or its memory RSS (Resident Set Size) climbs until it gets killed by the OOM Killer. You review the code, and the `new`/`delete` pairs look correct, and the out-of-bounds access is only off by a byte or two—you can't find the problem just by reading the code. For bugs like this, debugging by eye is hopeless; we must rely on tools to "see" every memory access. - -In this article, we categorize memory error detection tools into two camps based on their "implementation route" and break them down. One camp is **Valgrind**—the veteran solution that wraps a "virtual CPU" around your program to interpret execution via JIT (Just-In-Time compilation); the other is **AddressSanitizer (ASan)**—a compile-time solution that inserts check code into your program and uses "shadow memory" for bookkeeping. The original notes only covered Valgrind and didn't mention ASan, yet the latter is the more commonly used path in modern engineering. This article fills that gap and puts the two approaches side by side for comparison. - -## 1. Two Types of Memory Errors, and "Why Code Review Can't Find Them" - -Before using the tools, let's clarify the "enemies" we are hunting. Memory errors generally fall into two categories, and the difficulty of catching them differs significantly: - -**Type 1: Deterministic out-of-bounds / use-after-free / double-free.** The characteristic of these errors is "accessing an address that should not be accessed." They are dangerous but relatively easier to catch—as long as the tool can mark "which memory blocks are legal and which aren't," it can report the error the moment the boundary is crossed. `char buf[8]; buf[8] = 'x';` (off-by-one) and `free(p); return *p;` (dangling pointer) fall into this category. - -**Type 2: Uninitialized reads / memory leaks.** These are more insidious. Uninitialized reads mean "the address is legal, but the value is garbage"—the program doesn't crash, it just silently calculates the wrong result. Memory leaks mean "the address is always legal, just never returned"—the program doesn't crash, but RSS slowly increases. You cannot catch these with a "legal address table" alone; you need a different mechanism: Valgrind maintains a "is this value initialized" tag for every byte, while ASan's leak detector (LSan) scans the heap at program exit to see if there are blocks "allocated but not pointed to." - -The fundamental reason why code review fails is that both types of errors **depend on the runtime memory state**, not on the literal code syntax. Just looking at `*p`, you have no idea if the memory `p` points to is alive or dead, initialized or garbage. This is exactly why we need tools to "record" every allocation, every free, and every read/write—turning the runtime memory state into an audit-able ledger. - -Regarding this "recording" task, Valgrind and ASan take two completely different implementation routes. Let's put the conclusion first, then break it down later. - -| Dimension | Valgrind (memcheck) | AddressSanitizer | -|------|---------------------|------------------| -| How it records | Dynamic Binary Translation: Translates every machine instruction into a checked version at runtime | Compile-time instrumentation: Inserts check code before/after every memory access at compile time | -| Needs recompilation? | **No**, runs on existing binaries | **Yes**, must recompile with `-fsanitize=address` | -| Runtime overhead | 20~50x slower, 2x+ memory (official docs) | ~2x slower, ~3x memory | -| Platforms | Linux/macOS (FreeBSD/Solaris), x86/ARM, etc. | GCC/Clang/MSVC, all platforms including Windows | -| Catches uninitialized reads? | Yes, native (V-bit) | **No**, requires `-fsanitize=memory` (MSan, Clang only) | -| Catches stack overflow | Yes (with full `--tool=memcheck` suite) | Catches stack/global redzones by default; `detect_stack_use_after_return` catches stack-use-after-return | - -Keep this table in mind. Next, starting from the "source of the pain," let's look at how the Valgrind path works. - -## 2. Valgrind: Wrapping a "Virtual CPU" to JIT Interpret Your Program - -### 2.1 What is it actually doing? - -Valgrind is essentially a **dynamic binary translation (DBT) framework**. It isn't a normal checking library; it puts your entire program inside a "virtual CPU" to run. When you type `valgrind ./myprog`, what actually happens is: Valgrind intercepts every machine instruction of yours, **translates it on-the-fly** into a sequence of new instructions that "does the original work + records memory state," and only then executes it. So your program doesn't run directly on the CPU; it is "interpreted" inside Valgrind's core. - -This is the source of its famous side effect—**20 to 50 times slower**, memory usage more than doubled. The Valgrind official manual states: - -> Programs running under Valgrind run significantly more slowly, and use much more memory -- e.g. more than twice as much as normal under the Memcheck tool. - -In other words: a program that runs in 1 second might take half a minute under memcheck. So Valgrind isn't for hanging onto during daily development; it's for when "this program really has a memory bug, and I'm dedicating a chunk of time to hunt it down." - -This JIT interpretation architecture has a huge benefit, and it's the fundamental reason Valgrind hasn't been retired: **no recompilation needed**. You have a binary from ten years ago, where you can't even find all the source code, and you suspect it leaks—just type `valgrind ./old_binary` and it runs. ASan can't do this; ASan must recompile from source. This is the hardest distinction between the two routes. - -### 2.2 The Five-Piece Suite: One Framework, Five Tools - -The essence of Valgrind is "framework + tools." The core handles translation and scheduling, while the specific "what to record, what to report" is handed off to pluggable tools. Using `--tool=` selects which pair of "checking glasses" to wear. Let's look at the core tools listed in the manual: - -**Memcheck**—The memory error detector, Valgrind's default tool, and the one most people actually use when they say "using Valgrind to check memory." It catches the full set (from manual section 4.1): accessing memory that shouldn't be accessed (heap block overflow, stack top overflow, access after free), using uninitialized values, incorrect freeing (double-free, `malloc` with `delete` mismatches), `memcpy` source/destination overlap, passing "suspicious" negative sizes to allocation functions, passing 0 to `realloc`, alignment values that aren't powers of two, and memory leaks. In short: memcheck catches almost all common memory errors in C/C++ programs. - -**Callgrind**—Call graph + cache/branch prediction profiler. It doesn't require special compile-time options (though `-g` is recommended) and writes analysis data to a file at the end of the run, which is then converted to a human-readable format using `callgrind_annotate`. Use it to locate "how many times a function was called and what the call graph looks like." - -**Cachegrind**—Cache profiler. It simulates the CPU's I1/D1/L2 caches and precisely points out cache misses and hits in your program, telling you how many misses and instructions each line of code, function, or module generated. Use it when squeezing out cache performance. - -**Helgrind and DRD**—Both are **thread error detectors**, catching data races, inconsistent lock ordering, and POSIX threads API misuse. The source notes described Helgrind as "still experimental," but this statement is **long outdated**—in the 2026 official manual, both Helgrind and DRD are officially listed as stable tools with independent chapters (Chapters 7 and 8), not experimental features. Incidentally, the source notes only mentioned Helgrind and **missed DRD**—they share the same goal (catching thread bugs) but use different algorithms; DRD is usually faster and has better support for certain scenarios (like many small objects, Boost.Thread, OpenMP). I covered practical TSan/Helgrind usage in Volume 5's [Concurrency Debugging Techniques](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md), so I won't repeat it here. Just remember: "for thread bugs, look to helgrind/drd or the more modern TSan." - -**Massif**—Heap profiler. Measures exactly how much heap memory your program consumes, giving you growth curves for heap blocks, management structures, and the stack. Use it to "slim down" a program or find the biggest RSS consumers. - -> **An easily overlooked division of labor**: memcheck catches "correctness" (can this memory be accessed, is it initialized), while callgrind/cachegrind/massif catch "speed/quantity" (performance and usage). Newcomers often confuse them, thinking Valgrind is just for memory leaks—actually, that's just one tool's job. The performance profiling tools (callgrind/cachegrind/massif) are in a completely different track from ASan; ASan doesn't touch performance profiling. - -### 2.3 Memcheck's Dual-Table Principle: A-bit and V-bit - -How does memcheck manage to catch so many types of memory errors? The key lies in the two "shadow tables" it maintains, covering the entire process address space. Manual section 4.5 explains this clearly: - -**Valid-Address Table (A-bit).** Every byte in the process address space corresponds to 1 bit, recording "whether this address is currently readable/writable." When memory is malloc'd, the A-bit marks those bytes as "valid"; when freed, the mark flips back to "invalid." When an instruction tries to read/write a byte, it checks the A-bit first—if invalid, it's an illegal access, and memcheck reports the error immediately. This layer catches: out-of-bounds, use-after-free, accessing unallocated areas. - -**Valid-Value Table (V-bit).** Every byte in the process address space corresponds to 8 bits; each CPU register also corresponds to a bit vector. They record "whether this value has been initialized." Memory from malloc starts with all V-bits as "uninitialized"; once an instruction writes a definite value to it, the corresponding byte's V-bit flips to "initialized." The key design is: **V-bits "propagate" with the value**—if you read an uninitialized value from memory into a register, the V-bit moves to the register too; if you use it in calculation, the result's V-bit is also "uninitialized." However, memcheck doesn't report as soon as an uninitialized value is read; it only reports when "that value is used to influence program output or generate an address." This delay is intentional—to avoiding flooding the screen with false positives. - -Looking at the two tables together: A-bit manages "is the address legal," V-bit manages "is the value clean." The former catches OOB/UAF, the latter catches uninitialized reads. Double-free and alloc-dealloc mismatches are caught by memcheck's own maintained ledger of "what allocator was used for this block." - -The cost of this "accounting for every byte" mechanism explains the memory doubling mentioned earlier—the A-bit and V-bit themselves take up space. - -## 3. ASan: Compile-Time Instrumentation + Shadow Memory - -### 3.1 The Approach is Completely Reversed - -ASan's implementation route is the exact opposite of Valgrind's. It **does not** wrap a virtual CPU around the program; instead, it **inserts check code into your program at compile time**. When you add `-fsanitize=address`, the compiler inserts a small piece of code before and after every memory read/write: this code checks a "shadow memory" table to determine if the access is legal, and if not, reports an error and aborts. - -So ASan's checking is "the program checking itself," rather than "an external virtual CPU checking for it." This explains the huge difference in overhead between the two routes: ASan only adds a few instructions on the instrumented memory accesses, avoiding the cost of "translating the entire instruction stream," so it is **only about 2 times slower** (Valgrind is 20~50x); the price is that it must be recompiled, and checking only covers instrumented code—dynamically loaded third-party .so files compiled without ASan are out of its scope (Valgrind can handle them, as it intercepts everything at the instruction level). - -### 3.2 Shadow Memory: 8 Bytes → 1 Byte Encoding - -The core mechanism of ASan is shadow memory (for a full breakdown of shadow memory, see this volume's [ASan Tool Family](./10-asan-family-and-memory-safety.md), which also covers how it plugged vulnerabilities like Heartbleed). It maps the entire process address space into a shadow table in groups of 8 bytes—every 8 application bytes correspond to 1 shadow byte. The value of that shadow byte has a specific meaning; I'll paste the diagram generated locally here (the output below is real): - -```text -Shadow byte legend (one shadow byte represents 8 application bytes): - Addressable: 00 - Partially addressable: 01 02 03 04 05 06 07 - Heap left redzone: fa - Freed heap region: fd - Stack left redzone: f1 - Stack mid redzone: f2 - Stack right redzone: f3 - Stack after return: f5 - Stack use after scope: f8 - Global redzone: f9 - Global init order: f6 - Poisoned by user: f7 - Container overflow: fc - Array cookie: ac - Intra object redzone: bb - ASan internal: fe - Left alloca redzone: ca - Right alloca redzone: cb -``` - -Let's translate the elegance of this encoding scheme: - -- Shadow byte is `00`: All 8 bytes are accessible; -- It is `01`~`07`: Only the first N bytes are accessible; the rest are out-of-bounds red zones—this is exactly how ASan catches off-by-one errors. It surrounds every heap block, stack frame, and global variable with a ring of "red zones". The shadow bytes for these red zones are marked `fa`, `f9`, etc. Once you step into a red zone, the instrumentation code checks the shadow byte, sees that it is not "accessible", and reports an error immediately; -- It is `fd`: This memory block has been freed—accessing it again is a use-after-free, and it gets caught on the spot. - -In other words, ASan does not account for address validity "byte-by-byte" like Memcheck does. Instead, it "lays out red zones around valid regions and uses them to define boundaries". This mechanism is extremely effective for out-of-bounds and UAF issues, but **it lacks V-bits**—so ASan cannot detect uninitialized reads. This gap must be filled by MSan (MemorySanitizer, `-fsanitize=memory`), and MSan is only implemented in Clang. **GCC up to version 16.1.1 does not support `-fsanitize=memory`** (tested locally, results in `unrecognized argument`). This is a genuine shortcoming of the ASan approach compared to Memcheck. - -> **Warning**: ASan and other sanitizers have a "one at a time" relationship. `-fsanitize=address` and `-fsanitize=thread` (TSan) **cannot be enabled simultaneously**—they make different assumptions about shadow memory layout, and mixing them will lead to direct errors or abnormal behavior. Therefore, enable ASan when hunting memory errors, and enable TSan separately when hunting concurrent data races. Don't expect to "catch everything in one go". For how to check threading errors, see [Volume 5: Concurrency Debugging](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md). - -## IV. Let's Run It: Six Classic Errors and Real ASan Output - -Explaining the theory isn't satisfying enough. We took the six classic errors from the source notes—which were "all screenshots, no source code"—rewrote them entirely in real code, and compiled and ran them locally using `g++ -std=c++20 -O0 -g -fsanitize=address,undefined` on GCC 16.1.1. Every output snippet below was **actually generated by running the code**, not manually fabricated. - -First, let's wrap all six error types into a single program: - -```cpp -// cases.cpp — 六类经典内存错误,逐个用 ASan 复现 -// 编译: g++ -std=c++20 -O0 -g -fsanitize=address,undefined cases.cpp -o cases -// 运行: ./cases <1..6> 不传参则只跑内存泄漏 -#include -#include - -// 1. 使用未初始化内存(ASan 抓不到,要 MSan) -int case_uninit() { - int* p = (int*)malloc(sizeof(int)); // 内容是垃圾 - int v = *p; // 读到垃圾值,但地址合法 - free(p); - return v; -} - -// 2. use-after-free -int case_uaf() { - int* p = (int*)malloc(sizeof(int)); - *p = 42; - free(p); - return *p; // 读已释放内存 -} - -// 3. 堆缓冲区越界(尾部读写) -int case_oob() { - int* a = (int*)malloc(4 * sizeof(int)); // 只有 a[0..3] - a[4] = 99; // 第 5 个元素越界 - int r = a[4]; - free(a); - return r; -} - -// 4. 内存泄漏(忘记 free) -void case_leak() { - int* p = (int*)malloc(sizeof(int)); - *p = 7; // 故意不 free -} - -// 5. malloc 配 delete(分配/释放不匹配) -void case_mismatch() { - int* p = (int*)malloc(sizeof(int)); - *p = 5; - delete p; // malloc 该配 free -} - -// 6. 双重释放 -void case_double_free() { - int* p = (int*)malloc(sizeof(int)); - free(p); - free(p); // 第二次 free -} - -int main(int argc, char** argv) { - if (argc < 2) { case_leak(); puts("done: leak only"); return 0; } - switch (atoi(argv[1])) { - case 1: printf("uninit=%d\n", case_uninit()); break; - case 2: printf("uaf=%d\n", case_uaf()); break; - case 3: printf("oob=%d\n", case_oob()); break; - case 4: case_leak(); puts("done leak"); break; - case 5: case_mismatch(); puts("done mismatch"); break; - case 6: case_double_free(); puts("done double-free"); break; - default: puts("usage: ./cases [1..6]"); break; - } - return 0; -} -``` - -Remember this compilation command, as we will use it for every case below: `g++ -std=c++20 -O0 -g -fsanitize=address,undefined cases.cpp -o cases`. We use `-g` so that the ASan report includes line numbers, and `-O0` to prevent the compiler from optimizing away our out-of-bounds accesses (at high optimization levels, a "write-then-read" pattern like `a[4]` might be folded; ASan can still catch it, but `-O0` is cleanest for debugging). - -### 4.1 Using Uninitialized Memory — ASan's Blind Spot - -Let's run case 1 first to see how ASan reacts: - -```text -$ ./cases 1 -uninit=-1094795586 -``` - -**ASan stays silent**, and the program simply returns a garbage value (`-1094795586`). This highlights the limitation mentioned earlier: this memory address is valid (allocated via `malloc`), and ASan's shadow memory marks it as "accessible". Since it lacks the V-bit to determine "whether this value has been initialized", ASan misses this error. Memcheck can catch this (using the V-bit), but ASan cannot—to detect this, you would need MSan (`-fsanitize=memory`, Clang only). This represents a **substantive difference in capability** between the two approaches; neither is strictly "stronger"—they simply cover different domains. - -### 4.2 use-after-free —— The red zone catches it immediately - -Running case 2: - -```text -$ ./cases 2 -================================================================= -==44083==ERROR: AddressSanitizer: heap-use-after-free on address 0x799329de0010 ... -READ of size 4 at 0x799329de0010 thread T0 - #0 ... in case_uaf() /tmp/asand/cases.cpp:20 - #1 ... in main /tmp/asand/cases.cpp:56 - ... - -0x799329de0010 is located 0 bytes inside of 4-byte region [0x799329de0010,0x799329de0014) -freed by thread T0 here: - #0 ... in free ... - #1 ... in case_uaf() /tmp/asand/cases.cpp:19 - ... - -previously allocated by thread T0 here: - #0 ... in malloc ... - #1 ... in case_uaf() /tmp/asand/cases.cpp:17 - ... - -SUMMARY: AddressSanitizer: heap-use-after-free /tmp/asand/cases.cpp:20 in case_uaf() -``` - -(I omitted irrelevant lines like the build-id above, but kept all the key information.) As you can see, ASan provides three pieces of information: **where this illegal read occurred** (`return *p` at line 20 in `case_uaf()`), **where this memory was freed** (line 19), and **where it was originally malloc'd** (line 17). Combined, the entire causal chain of "allocate → free → use" becomes clear at a glance. This is the result of the redzone mechanism combined with "setting shadow bytes to `fd` after free"—to ASan, that memory block is no longer "accessible" after `free`, so touching it triggers a report. - -### 4.3 Heap Buffer Overflow — Tail Redzone - -Running case 3 (`a[4]` is out of bounds, as `a` was only allocated for four ints): - -```text -$ ./cases 3 -================================================================= -==44191==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7288a7be0020 ... -WRITE of size 4 at 0x7288a7be0020 thread T0 - #0 ... in case_oob() /tmp/asand/cases.cpp:26 - ... - -0x7288a7be0020 is located 0 bytes after 16-byte region [0x7288a7be0010,0x7288a7be0020) -allocated by thread T0 here: - #0 ... in malloc ... - #1 ... in case_oob() /tmp/asand/cases.cpp:25 - ... -``` - -`located 0 bytes after 16-byte region` — This memory region is 16 bytes (four `int`s), and the access point lands exactly on the **first byte immediately following its end**, which is the start of the trailing redzone. This is the principle behind how ASan catches off-by-one errors: a block returned by `malloc` is immediately followed by a ring of redzones. The shadow bytes for these redzones are `fa` (heap left redzone, though actually representing poisoned areas surrounding the heap block). Since `a[4]` falls into this redzone, the instrumentation checks the shadow byte, sees it is not `00`, and reports the error on the spot. - -> **A point mentioned in the source notes that is easily misunderstood**: The source notes state that "Valgrind does not check statically allocated arrays." While this was true for older versions of Memcheck (historically, out-of-bounds access on stack/global arrays was a weak point for Memcheck), **this is not the case for ASan**. ASan places redzones around stack arrays and global variables (shadow bytes `f1`~`f3` are stack redzones, `f9` is a global redzone), and it is very effective at catching out-of-bounds access on stack arrays. Therefore, the conclusion that "static array out-of-bounds access cannot be detected" applies only to Valgrind, not ASan. Do not conflate the limitations of these two tools. - -### 4.4 Memory Leaks — LSan Scans the Heap at Program Exit - -Running case 4 (`./cases 4`, intentionally omitting `free`): - -```text -$ ./cases 4 - -================================================================= -==44296==ERROR: LeakSanitizer: detected memory leaks - -Direct leak of 4 byte(s) in 1 object(s) allocated from: - #0 ... in malloc ... - #1 ... in case_leak() /tmp/asand/cases.cpp:34 - #2 ... in main /tmp/asand/cases.cpp:58 - ... - -SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s). -``` - -Note that this error is reported by **`LeakSanitizer`**, not ASan itself. LSan is the leak detector bundled by default with ASan. It scans the entire heap **when the program exits normally** to identify blocks that were allocated but are no longer pointed to by any pointer. It reports "definitely lost" from the "still reachable / definitely lost" classification. This approach is consistent with Memcheck's leak detection strategy (both scan the heap at exit), except that LSan is part of the ASan toolchain. - -> **What about daemons?** By default, LSan only scans when the program calls `exit`. Long-running daemons or service processes do not exit on their own. In this case, you can send a signal to trigger an intermediate dump: use `ASAN_OPTIONS=abort_on_error=0:detect_leaks=1` with `kill`, or call the LSan `__lsan_do_leak_check()` API in your code to actively trigger a scan. The corresponding approach for Valgrind is to `kill` the memcheck process from another terminal to force output (a trick mentioned in the source notes). - -### 4.5 malloc with delete — Allocation/Deallocation Mismatch - -Running case 5: - -```text -$ ./cases 5 -================================================================= -==44300==ERROR: AddressSanitizer: alloc-dealloc-mismatch (malloc vs operator delete) ... - #0 ... in operator delete(void*, unsigned long) ... - #1 ... in case_mismatch() /tmp/asand/cases.cpp:42 - ... - -0x71a3249e0010 is located 0 bytes inside of 4-byte region [0x71a3249e0010,0x71a3249e0014) -allocated by thread T0 here: - #0 ... in malloc ... - #1 ... in case_mismatch() /tmp/asand/cases.cpp:40 - ... -``` - -`alloc-dealloc-mismatch (malloc vs operator delete)` — ASan records "who allocated it" for every allocation. When freeing, it checks the match; `malloc` paired with `delete` is a mismatch, triggering an immediate report. Memcheck detects the same category (manual section 4.2.5, "freed with an inappropriate deallocation function"), so their capabilities are aligned. - -> **Platform Note:** This `alloc-dealloc-mismatch` check is **disabled by default** on Windows (MSVC's ASan), because `delete` and `free` are often effectively equivalent on Windows. It is enabled by default on Linux/macOS. If you find these errors are not caught on Windows, check `ASAN_OPTIONS=alloc_dealloc_mismatch=1`. - -### 4.6 Double Free - -Run case 6: - -```text -$ ./cases 6 -================================================================= -==44193==ERROR: AddressSanitizer: attempting double-free on 0x6d0d527e0010 in thread T0: - #0 ... in free ... - #1 ... in case_double_free() /tmp/asand/cases.cpp:49 - ... - -0x6d0d527e0010 is located 0 bytes inside of 4-byte region [0x6d0d527e0010,0x6d0d527e0014) -freed by thread T0 here: - #0 ... in free ... - #1 ... in case_double_free() /tmp/asand/cases.cpp:48 - ... -``` - -`attempting double-free` — After the first `free`, the shadow bytes are set to `fd`. When we attempt to `free` the same address a second time, ASan detects that it is already in the `fd` state (freed) and immediately flags it as a double-free. It also helpfully informs us that "the previous free was on line 48". - -### 4.7 Extra Bonus: Use-After-Return on the Stack - -ASan can also catch something that has historically been difficult for memcheck to detect — **accessing a stack frame after it has returned** (the function has returned, but the caller still holds a pointer to a local variable inside that function). This feature must be explicitly enabled: - -```cpp -// suar2.cpp -#include -static int* g = nullptr; -void stash() { int local = 0xc0ffee; g = &local; } // 把局部变量地址存出去 -int main() { stash(); return *g; } // local 已随 stash 返回而消失 -``` - -```text -$ g++ -std=c++20 -O0 -g -fsanitize=address suar2.cpp -o suar2 -$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./suar2 -================================================================= -==44702==ERROR: AddressSanitizer: stack-use-after-return on address 0x6da50b8f0020 ... -READ of size 4 at 0x6da50b8f0020 thread T0 - #0 ... in main /tmp/asand/suar2.cpp:4 - ... - -Address 0x6da50b8f0020 is located in stack of thread T0 at offset 32 in frame - #0 ... in stash() /tmp/asand/suar2.cpp:3 - - This frame has 1 object(s): - [32, 36) 'local' (line 3) <== Memory access at offset 32 is inside this variable -HINT: this may be a false positive if your program uses some custom stack unwind mechanism ... -SUMMARY: AddressSanitizer: stack-use-after-return /tmp/asand/suar2.cpp:4 in main -``` - -Note that address `0x6da50b8f0020` is located very **early** in the process address space (not in the normal stack region). This is because with `detect_stack_use_after_return` enabled, ASan moves "local variables that might be targeted by escaped pointers" to a dedicated "fake stack". When the function returns, that region of the fake stack is marked as poisoned, and any subsequent access triggers a `stack-use-after-return` error (shadow byte `f5`). This option is disabled by default due to some overhead and a small number of false positives (see that HINT). However, bugs involving "stack memory still in use after a function returns" are extremely difficult to track down, so it is worth knowing this trick exists. - -## 5. How to Use Valgrind: Feeding Those Errors to Memcheck - -Now that we have covered the principles, let's take that same `cases.cpp` from Section 4 (this time compiled without `-fsanitize=`, just a normal build) and run it under Valgrind. We will see how Memcheck reports the same batch of errors—comparing the two reporting styles side-by-side makes the differences clear. The local machine uses Valgrind 3.25.1. - -First, compile a clean version with `-g` (Valgrind doesn't need ASan's instrumentation, but it requires `-g` to report line numbers): - -```bash -g++ -std=c++20 -g -O0 cases.cpp -o cases_plain - -# 最常用:memcheck 全量查泄漏 -valgrind --tool=memcheck --leak-check=full ./cases_plain 4 - -# 更狠:连 still reachable 也列出来 + 跟进子进程 -valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all --trace-children=yes ./cases_plain -``` - -Here are a few key parameters: `--leak-check=full` performs a full leak check (providing line numbers), `--show-leak-kinds=all` lists even "still reachable" blocks (blocks that still have pointers pointing to them and can theoretically be freed) (the older `--show-reachable=yes` is an alias for this, which still works but is deprecated), and `--trace-children=yes` follows child processes spawned by `fork`/`exec`. To switch tools, modify `--tool=`: options include `callgrind`, `cachegrind`, `helgrind`, `drd`, and `massif`. - -### 5.1 Memcheck reports the same UAF like this - -Running case 2 (the use-after-free from Section 4): - -```text -$ valgrind --tool=memcheck --leak-check=full ./cases_plain 2 -==453796== Memcheck, a memory error detector -... -==453796== Invalid read of size 4 -==453796== at 0x40011E9: case_uaf() (cases.cpp:20) -==453796== by 0x4001377: main (cases.cpp:56) -==453796== Address 0x4ee9080 is 0 bytes inside a block of size 4 free'd -==453796== at 0x48529EF: free (vg_replace_malloc.c:989) -==453796== by 0x40011E4: case_uaf() (cases.cpp:19) -==453796== Block was alloc'd at -==453796== at 0x484F8A8: malloc (vg_replace_malloc.c:446) -==453796== by 0x40011CA: case_uaf() (cases.cpp:17) -uaf=42 -... -==453796== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) -``` - -Notice the line numbers—`cases.cpp:20` for the read, `:19` for free, and `:17` for malloc. These are **exactly the same** as those reported by ASan in Section 4 (ASan also reported :20/:19/:17). It is the same bug; both tools pinpoint the exact same lines, just with different wording: - -- ASan says `heap-use-after-free` + `located 0 bytes inside of 4-byte region`; -- memcheck says `Invalid read of size 4` + `Address ... is 0 bytes inside a block of size 4 free'd`. - -memcheck also adds `Block was alloc'd at ... :17`. It relies on the A-bit ledger to record the "lifetime" of this memory (where it was allocated, where it was freed, and where it is now being read), providing the full causal chain in one go. This shares the same logic as ASan's three-part "allocated by / freed by" structure, just with different phrasing. - -### 5.2 Leaks: LEAK SUMMARY vs. LSan - -Running case 4 (intentionally not calling free): - -```text -$ valgrind --tool=memcheck --leak-check=full ./cases_plain 4 -==453446== HEAP SUMMARY: -==453446== in use at exit: 4 bytes in 1 blocks -==453446== total heap usage: 3 allocs, 2 frees, 77,828 bytes allocated -==453446== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1 -==453446== at 0x484F8A8: malloc (vg_replace_malloc.c:446) -==453446== by 0x400123D: case_leak() (cases.cpp:34) -==453446== by 0x40013B5: main (cases.cpp:58) -==453446== LEAK SUMMARY: -==453446== definitely lost: 4 bytes in 1 blocks -==453446== indirectly lost: 0 bytes in 0 blocks -==453446== possibly lost: 0 bytes in 0 blocks -==453446== still reachable: 0 bytes in 0 blocks -==453446== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) -``` - -`definitely lost: 4 bytes` corresponds to the `Direct leak of 4 byte(s)` from LSan (LeakSanitizer) in the previous section. Both tools "scan the heap upon program exit," but Memcheck categorizes leaks into four levels (`definitely lost`, `indirectly lost`, `possibly lost`, `still reachable`), which is more granular, whereas LSan defaults to reporting only `Direct` and `Indirect` leaks. The line number is `:34`, consistent with ASan. - -> **Stop manually downloading and compiling source packages.** The installation process in the original notes was `tar -jxvf valgrind-3.12.0.tar.bz2 && ./configure && make && sudo make install`. Version `3.12.0` is from 2016—**ten years ago**. It has poor support for modern kernels and new CPU instructions (like newer AVX), and tends to throw errors when running newly compiled programs. Now, just use the distribution packages: `apt install valgrind` for Debian/Ubuntu, `dnf install valgrind` for Fedora/RHEL, or `pacman -S valgrind` for Arch. You'll get version 3.2x (3.25.1 on my machine). - -## 6. How to Choose Between the Two Paths - -After all this discussion, when should we actually use which tool? Here is a practical decision matrix: - -**Default to ASan.** For daily development and memory error detection in CI pipelines, ASan is the first choice. It is fast (2x slowdown vs. 20–50x, which CI can tolerate), cross-platform (works on Windows, macOS, and Linux, and is supported by MSVC), and provides clean reports. In modern C++ projects, `-fsanitize=address,undefined` is practically the standard configuration for debug builds. We cover ASan catching leaks in [Dynamic Memory Management](../vol1-fundamentals/ch12/02-new-delete.md) in Volume 1, and TSan for concurrency debugging in Volume 5—these are all tools in this ecosystem. - -**You must use Valgrind in these scenarios:** - -1. **You only have the binary, no source code**, or recompiling is too expensive (e.g., a massive legacy project). ASan requires recompilation, while Valgrind can run on existing binaries. -2. **You need to catch uninitialized reads but only have GCC.** ASan lacks V-bits, and MSan is Clang-only—for GCC projects needing to catch uninitialized reads, Memcheck is the ready solution. -3. **You need performance profiling** (callgrind/cachegrind/massif). ASan has no equivalent for these; if you want to see cache misses, heap growth curves, or call graphs, Valgrind is the only game in town. -4. **You need full coverage, including third-party libraries not compiled with ASan.** Valgrind intercepts at the instruction level, so it can catch memory errors even in `.so` files without source code; ASan only covers instrumented code. - -Conversely, **Valgrind can't do these, or does them poorly, so you need ASan**: catching stack/global buffer overflows (ASan's stack/global redzones are its strength), running fast (CI friendly), Windows platform (Valgrind has basically no Windows support), and catching `stack-use-after-return` (ASan has a dedicated fake stack mechanism). - -To sum it up in one sentence: **ASan is the standard for "development phase," while Valgrind is the specialist for "hard-to-diagnose issues, performance, and legacy binaries."** They aren't replacements; they are complementary—many teams run ASan in CI as the daily gatekeeper, and only bring in Valgrind for a second opinion when ASan misses a weird bug. - -## 7. Back to C++: Tools are a Safety Net, RAII is the Cure - -After talking about tools for so long, we must bring the conversation back to this point: **No matter how strong these tools are, they are "post-mortem bug catchers," not "bug eliminators."** What truly makes memory errors disappear from the root is C++'s RAII and smart pointers. - -Looking back at those six categories of errors, you will find they are **invariably built on "raw malloc/free and raw pointers"**: - -- **Leaks?** Use `std::unique_ptr` / `std::vector`. Objects are automatically released when they go out of scope, so there's no chance to forget `free`. -- **Use-after-free?** The ownership semantics of smart pointers make "whether this memory is still valid" a compile-time constraint. -- **Double-free?** `unique_ptr` cannot be copied and nulls the source after a move, making a double-free physically impossible. -- **Out-of-bounds?** `std::vector` with `.at()` throws exceptions, and `std::span` carries bounds—don't use raw `[]` with manual lengths. - -C-style `malloc`/`free` and raw pointers leave "when to release memory" and "who can access it" entirely to the programmer's memory—human brains inevitably fail at this, which is why we need "bookkeeping tools" like Valgrind and ASan as a safety net. The Modern C++ approach moves this bookkeeping **into the type system**: the resource lifecycle is bound to the object, and the compiler guarantees the release for you. This is the fundamental leap from "tools catching bugs" to "the language eliminating bugs," which is exactly what our [Dynamic Memory Management](../vol1-fundamentals/ch12/02-new-delete.md) chapter in Volume 1 is about. - -However, this **does not** mean Modern C++ projects don't need ASan/Valgrind. As long as your code calls C libraries, uses `new`/`delete`, or touches third-party interfaces lacking RAII wrappers, there is still room for memory errors to slip in. So the correct approach is: **First, use RAII to eliminate 99% of memory errors while coding. Then, use ASan to catch the remaining 1% during testing. Finally, use Valgrind as the bottom line for the weirdest, hardest problems.** Three lines of defense, each indispensable. diff --git a/documents/en/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md b/documents/en/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md deleted file mode 100644 index 189e8d227..000000000 --- a/documents/en/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md +++ /dev/null @@ -1,341 +0,0 @@ ---- -title: 'Sanitizer Toolchain Overview: From -fsanitize to Kernel KASAN/KFENCE' -description: Comparing user-space `-fsanitize=address/memory/undefined/thread` with kernel-space KASAN/KMSAN/UBSAN/KCSAN/KFENCE side-by-side, we thoroughly explain the two approaches of "compile-time instrumentation vs. sampling" and the layered defense strategy of "debugging vs. production". -chapter: 6 -order: 12 -platform: host -difficulty: advanced -cpp_standard: -- 11 -- 14 -- 17 -- 20 -reading_time_minutes: 22 -prerequisites: -- ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 -- Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 -related: -- ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 -- Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 -- 并发程序调试技巧(ThreadSanitizer) -- 动态内存管理(new/delete 与智能指针) -tags: -- host -- cpp-modern -- advanced -- 内存安全 -- 调试 -- 工具链 -translation: - source: documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md - source_hash: 6bb4d06235bfda3a14d218ee235d1291d222170d30d403fcd494c82997c838c2 - translated_at: '2026-06-25T13:30:42.897270+00:00' - engine: anthropic - token_count: 3783 ---- -# Sanitizer Toolchain Panorama: From `-fsanitize` to Kernel KASAN/KFENCE - -> PS: This content has been migrated from notes taken during my university years and has been verified and cross-checked; user-mode sanitizers were run locally, while kernel-mode tools could not be run locally and are based on official kernel.org documentation. If there are any omissions, issues or PRs are welcome. - -In the previous two articles, we dissected user-mode ASan / UBSan / MSan / TSan and Valgrind in great detail—how shadow memory keeps accounts, the differences between JIT interpretation and compile-time instrumentation, and why the five sanitizers are mutually exclusive. However, if you only focus on "just adding a flag with `g++ -fsanitize=address`", you will miss the bigger picture: **sanitizers are not exclusive to user mode; there is a full set of corresponding tools in the kernel**, and their design trade-offs are completely different. - -In this article, we will look at the entire sanitizer toolchain on a level playing field. On one side we have user-mode `-fsanitize=*`, and on the other, kernel-mode `CONFIG_KASAN / CONFIG_KMSAN / CONFIG_KFENCE`—they catch the same class of bugs (out-of-bounds, use-after-free, uninitialized, data races), but under completely different constraints: user mode can slow down a program by 2-5x to catch bugs, but the kernel cannot. If the kernel slows down by 5x, the whole machine is effectively dead. Therefore, the kernel has evolved the path of "sampling"—KFENCE uses extremely low overhead to enable "always-on in production," coexisting in a layered fashion with heavy tools like KASAN that are "only enabled during debugging." - -## Closing the Loop on User Mode - -Before moving to the kernel, let's nail down the four user-mode sanitizer flags with real reports, so we can easily compare them with the kernel tools later. The detailed shadow memory principles and the Heartbleed story were thoroughly explained in the previous article; here, we only provide minimal reproducible code and real terminal outputs to facilitate matching "which flag corresponds to each bug." - -The division of labor for the four flags in one sentence: `-fsanitize=address` (ASan, out-of-bounds/UAF/leaks), `-fsanitize=undefined` (UBSan, undefined behavior), `-fsanitize=memory` (MSan, uninitialized reads), `-fsanitize=thread` (TSan, data races). - -### ASan: Catching Three Types of Errors at Once - -Heap out-of-bounds, use-after-free, and memory leaks—ASan catches them all. We write minimal examples for each of the three errors separately (if we put them in one program, ASan will abort at the first error, so we won't see the latter two; hence, we split them up): - -```cpp -// uaf.cpp —— 释放后使用(use-after-free) -#include -int main() { - int* p = new int(7); - delete p; - printf("*p = %d\n", *p); // p 已 delete,悬空 - return 0; -} -``` - -Compile with `g++ -std=c++20 -O0 -g -fsanitize=address -fno-omit-frame-pointer uaf.cpp -o uaf`, and run it: - -```text -================================================================= -==118313==ERROR: AddressSanitizer: heap-use-after-free on address 0x72c9e1de0010 at pc 0x5d222d6ed26f bp 0x7ffc31d299a0 sp 0x7ffc31d29990 -READ of size 4 at 0x72c9e1de0010 thread T0 - #0 0x5d222d6ed26e in main /tmp/sanit/uaf.cpp:6 - ... -SUMMARY: AddressSanitizer: heap-use-after-free /tmp/sanit/uaf.cpp:6 in main -``` - -`-g` enables source code locations like `uaf.cpp:5` in the report. This is the deciding factor for whether ASan is usable—without debug symbols, the report is just a string of addresses, rendering it useless. It also detects out-of-bounds access on the stack. Let's switch to a cross-function stack buffer: - -```cpp -// stack_oob.cpp —— 栈缓冲越界 -#include -void fill(char* p) { // 跨函数,检测能跨栈帧 - for (int i = 0; i <= 8; ++i) p[i] = 'A'; // 合法下标 0..7,8 越界 -} -int main() { - char buf[8]; - fill(buf); - printf("done\n"); - return 0; -} -``` - -Running the binary built with the same flags: - -```text -================================================================= -==119120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x6ec9ef2f0028 at pc 0x5f38ab644200 bp 0x7fff6db78e20 sp 0x7fff6db78e10 -WRITE of size 1 at 0x6ec9ef2f0028 thread T0 - #0 0x5f38ab6441ff in fill(char*) /tmp/sanit/stack_oob.cpp:4 - #1 0x5f38ab64429d in main /tmp/sanit/stack_oob.cpp:8 - ... -Address 0x6ec9ef2f0028 is located in stack of thread T0 at offset 40 in frame - #0 0x5f38ab644220 in main /tmp/sanit/stack_oob.cpp:6 -``` - -Note that it doesn't just tell you that the bounds were exceeded; it also tells you that "this memory is the `buf` located at offset 40 within the `main` stack frame." The stack redzone even marks the ownership of the array on the stack. This demonstrates the power of shadow memory, which we deconstructed in detail in the previous article, so we won't expand on it here. - -Memory leaks are handled by LeakSanitizer (LSan), which comes bundled with ASan. It performs a scan when the program exits: - -```cpp -// leak.cpp —— 忘记 delete -#include -int main() { - int* leak = new int(99); - *leak = 100; - printf("leak = %d (故意不 delete)\n", *leak); - return 0; -} -``` - -```text -================================================================= -==118322==ERROR: LeakSanitizer: detected memory leaks - -Direct leak of 4 byte(s) in 1 object(s) allocated from: - #0 0x7c2b9dd2d341 in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341) - #1 0x609649f361ba in main /tmp/sanit/leak.cpp:4 -``` - -The cost of ASan is substantial: the program runs two to five times slower, and memory usage increases by three to five times. Therefore, **we must remove `-fsanitize=address` for production builds**, enabling it only during debugging and testing. This constraint might sound trivial, but in the kernel world, the same "too much overhead" issue led to the creation of completely different tools—this is the origin of KFENCE, which we will discuss later. - -### UBSan: Specialized for Undefined Behavior - -ASan manages "whether this memory can be touched," while UBSan manages "whether this operation itself is valid." Signed integer overflow, out-of-bounds array access, null pointer dereference, and misaligned shifts are undefined behaviors (UB) in the C++ standard. They might not necessarily crash, but the results are unpredictable: - -```cpp -// ub.cpp —— 三种 UB -#include -#include -int main() { - int32_t big = 2147483647; // INT32_MAX - int32_t sum = big + 1; // (1) 有符号加法溢出 → UB - int arr[4] = {0,1,2,3}; - int idx = 10; - int v = arr[idx]; // (2) 下标越界 → UBSan 的 bounds 检查 - printf("sum=%d v=%d\n", sum, v); - return 0; -} -``` - -Use `g++ -std=c++20 -O0 -g -fsanitize=undefined ub.cpp -o ub` (defaults to recover, printing all instances of undefined behavior and then continuing): - -```text -ub.cpp:6:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' -ub.cpp:9:20: runtime error: index 10 out of bounds for type 'int [4]' -ub.cpp:9:9: runtime error: load of address 0x7fffed140f28 with insufficient space for an object of type 'int' -sum=-2147483648 v=0 -``` - -Here is a real pitfall to watch out for first: **UBSan and ASan can be enabled together** (`-fsanitize=address,undefined`). Many people do this because one handles memory and the other handles arithmetic, making them complementary. However, UBSan defaults to "print and continue" (recover). If you prefer to abort on the first instance of UB (which is closer to production behavior), add `-fno-sanitize-recover=all`. Conversely, ASan aborts immediately upon detection, and this behavior cannot be changed. - -### MSan: Uninitialized Reads, Clang Only - -MSan detects "use of uninitialized values," a class of errors ASan cannot catch—the memory is valid, the access is valid, but the value is garbage. The catch is: **MSan is implemented only in Clang; GCC does not support this flag at all**: - -```cpp -// msan.cpp —— 用了没初始化的变量 -#include -int main() { - int x; // 故意不初始化 - if (x) // 拿垃圾值做分支判断 → MSan 抓 - printf("x is truthy\n"); - else - printf("x is zero\n"); - return 0; -} -``` - -GCC reports an error directly: - -```text -$ g++ -std=c++20 -fsanitize=memory msan.cpp -o msan -g++: error: unrecognized argument to '-fsanitize=' option: 'memory' -``` - -Switching to Clang allows it to compile and run (`clang++ -std=c++20 -O0 -g -fsanitize=memory -fno-omit-frame-pointer msan.cpp -o msan`): - -```text -==118932==WARNING: MemorySanitizer: use-of-uninitialized-value - #0 0x58f3129f5677 (/tmp/sanit/msan+0xd7677) - ... -SUMMARY: MemorySanitizer: use-of-uninitialized-value -``` - -> **Warning**: MSan has a hard constraint—**the entire program (including all linked libraries) must be compiled with MSan instrumentation**. If you directly link a non-instrumented `libc++` or a third-party library using `clang++ -fsanitize=memory`, you will get a bunch of false positives, because MSan treats values returned by libraries as uninitialized. Therefore, MSan is rarely used in practical projects; it usually requires "rebuilding the entire toolchain with MSan" to run cleanly. This was mentioned in the previous article, but I'm emphasizing it here because KMSAN on the kernel side has similar "full-link instrumentation" requirements. - -As for TSan (ThreadSanitizer, for data races), it is mutually exclusive with ASan, has a 5–15x performance overhead, and specializes in catching concurrency bugs. The "Concurrent Programming Debugging Techniques" section in the concurrency volume has already covered this thoroughly, so we will just mark its position in the big picture here and avoid repetition. - -## So, what about the kernel? - -Once you have memorized the four user-space flags, here comes the real point of this article. **The kernel is also C code; it can also have out-of-bounds access, UAF, and data races. Can we just slap `-fsanitize=address` onto the kernel?** - -The answer is: **Yes, and the kernel actually does this, but the cost is so high that you can only enable it during debugging.** This is KASAN—Kernel AddressSanitizer. Its underlying mechanism is the same as user-space ASan (shadow memory + compile-time instrumentation), but the kernel has its own constraints: - -1. **Shadow memory occupies a large chunk of the kernel's virtual address space**. User-space ASan's shadow memory takes up "1/8 of the process address space", but the kernel carves out a significant segment of the kernel VAS directly (from `KASAN_SHADOW_START` to `KASAN_SHADOW_END`). On a 64-bit kernel, the address space is large enough (128 TB) to handle this; on 32-bit, it is much tighter, so early KASAN could only run on 64-bit, until Linus Walleij created a streamlined version for ARM-32 in 5.11. - -2. **Every memory access in the system is instrumented**. The kernel is not a process; it is the underlying layer shared by all processes. Once KASAN is enabled, system-wide performance collapses immediately—which is why `CONFIG_KASAN` is only used for debugging the kernel and is never enabled in production kernels. - -3. **It requires specific memory allocators**. The kernel uses SLAB or SLUB allocators. KASAN needs to plant red zones in allocators and mark freed pages as "poisoned" (`KASAN_SANITIZE_*`) to catch UAF/OOB immediately. This shares the same logic as user-space ASan intercepting `malloc/free`, just swapped for `kmalloc/kfree`. - -The source notes say "KASAN applies to x86_64 and AArch64, 4.x and above", so we need to verify that version number. In reality, KASAN was merged into the mainline in **Linux 4.0** (initially supporting x86_64), with AArch64 following shortly after. **5.11** added the optimized version for ARM-32. The mechanism is correct, but don't just remember it vaguely as "4.x". - -### What does KASAN look like? (Official report style) - -What does a KASAN report look like? Based on the example structure in the kernel.org dev-tools/kasan documentation, replacing the official example's `kmalloc_oob_right` with a virtual `buggy_driver_write` (where fields and hierarchy correspond exactly to the official report), it looks roughly like this: - -```text -================================================================== -BUG: KASAN: slab-out-of-bounds in buggy_driver_write+0x3e/0x60 [buggy] -Write of size 1 at addr ffff888006c42185 by task cat/1234 - -CPU: 0 PID: 1234 Comm: cat Tainted: G B -Call Trace: - dump_stack_lvl+0x49/0x63 - print_report+0x171/0x486 - kasan_report+0xb1/0x130 - buggy_driver_write+0x3e/0x60 [buggy] - ... - -Allocated by task 1234: - kasan_save_stack+0x1e/0x40 - __kasan_kmalloc+0x81/0xa0 - kmalloc_trace+0x21/0x30 - buggy_driver_init+0x2a/0x60 [buggy] - ... - -The buggy address belongs to the object at ffff888006c42180 - which belongs to the cache kmalloc-8 of size 8 -The buggy address is located 5 bytes inside of - 8-byte region [ffff888006c42180, ffff88800642188) -``` - -The report structure is nearly identical to userspace ASan: **it first states where it blew up (slab-out-of-bounds, out-of-bounds write, which driver function), then provides the allocation stack (who allocated this memory, which `kmalloc` call)**. The kernel report adds kernel-allocator-specific details like "which slab cache (`kmalloc-8`)" and "offset within the object." Once you understand userspace ASan reports, you can basically read kernel KASAN reports too. - -## Panoramic Comparison: Userspace ↔ Kernel - -Let's align both sides now. This table is the core of this article—the original note was an external PNG, so we've recreated it here in Markdown: - -| Bug Caught | Userspace Flag | Kernel Tool | Kernel Merged Version | Production Ready? | -|-----------|----------------|------------|-----------------------|-------------------| -| OOB / UAF / Double Free | `-fsanitize=address` (ASan) | **KASAN** | 4.0 (x86_64) / 5.11 (ARM-32 optimization) | No, debug only | -| Uninitialized Read | `-fsanitize=memory` (MSan, Clang only) | **KMSAN** | Patches available from 5.16, **6.1** mainline fully usable, Clang 14.0.6+ only, x86_64 only | No, huge overhead | -| Undefined Behavior (overflow/shift/OOB) | `-fsanitize=undefined` (UBSan) | **UBSAN** | Merged in 4.5 | Partially (see below) | -| Data Race | `-fsanitize=thread` (TSan) | **KCSAN** | Merged in 5.8, sampling-based | No, debug only | -| Memory Leak | ASan includes LSan | **kmemleak** / eBPF `memleak` | kmemleak has existed for a long time | Careful, false positives | -| Sampling Memory Errors | (No userspace equivalent) | **KFENCE** | **5.12** | **Yes, on by default** | -| Access Pattern Analysis (not bug detection) | (None) | **DAMON** | **5.15** | Yes, designed for production | - -There are a few key relationships in this table you must remember: - -- **ASan ↔ KASAN**: The same shadow memory concept moved to the kernel, but the cost is a total system performance collapse, so it's debug-only. -- **MSan ↔ KMSAN**: Both are Clang-only, both require full instrumentation, and both have huge overhead. The KMSAN docs explicitly state "not intended for production use, because it drastically increases kernel memory footprint and slows the whole system down." -- **UBSan ↔ UBSAN**: Kernel UBSAN was merged in 4.5, and **some of its checks (like `CONFIG_UBSAN_BOUNDS`) are enabled by default in modern distro kernels** because the overhead is low—this is one of the few kernel sanitizers that can "stay on." -- **TSan ↔ KCSAN**: Note that TSan uses compile-time full instrumentation, while KCSAN is different—it is **sampling-based** (watchpoints), so the overhead is manageable. However, it detects data races by "chance sampling," not TSan's "theoretically guaranteed detection." Merged in mainline in 5.8 (the google/kernel-sanitizers repo explicitly says "in mainline since 5.8"). - -The original note marks KMSAN as "6.1 and above"—**this version number is correct**, don't get it wrong. KMSAN was maintained as a patch series by Google's Alexander Potapenko for years; by the end of 2021, it was still just a patch branch not in mainline (official kernel.org examples ran on a patched `5.16.0-rc3+` using the google/kmsan branch, not mainline). The Google official repo (google/kmsan) README states: "Linux 6.1+ contains a fully-working KMSAN implementation which can be used out of the box," meaning **fully usable in mainline from 6.1 onwards**. So KMSAN is the latest of this batch of kernel sanitizers to hit mainline. Don't confuse "5.16 patch branch works" with "6.1 mainline"—this is the most common misinterpretation of these version numbers. - -## KFENCE: The Key Move to Production - -KASAN's problem is obvious—it's debug-only. But what do you do when your company's production kernel hits a memory bug? You can't swap a production machine for a KASAN-enabled debug kernel to reproduce it; your service would be down long before that. What's really missing is a **memory error detector with low enough overhead to stay on all the time**. - -Enter KFENCE (Kernel Electric-Fence), **merged into mainline in Linux 5.12**. Its approach is totally different from KASAN: instead of "checking every access," it uses **sampling**: - -- KFENCE maintains a fixed-size object pool (default `CONFIG_KFENCE_NUM_OBJECTS=255`, each object uses 2 pages—one for the object, one as a guard page; object pages and guard pages are interleaved in the pool, so every object page is surrounded by guard pages; the whole pool is about 2 MiB by default). -- The kernel slab allocator (`kmalloc`) is **hooked by a sampling timer into the KFENCE pool**: KFENCE has a millisecond sampling interval (boot param `kfence.sample_interval`, configurable via `CONFIG_KFENCE_SAMPLE_INTERVAL`). The next `kmalloc` after each interval is "hooked" and handed to KFENCE. -- Once in the KFENCE pool, the allocation is placed between two guard pages—any out-of-bounds read/write hits a guard page, immediately triggering a page fault, and the kernel reports the precise error and allocation stack. -- On free, KFENCE marks the page as "inaccessible"; touching it again is a use-after-free, which also triggers an immediate report. - -The cost of sampling is: **the vast majority of allocations never touch KFENCE**, so it misses most bugs—you have to run for a long time and let enough allocations flow through the pool to catch one. But the trade-off is **extremely low overhead** (officials say near-zero, production workloads barely notice it), making it the **first memory sanitizer that can stay on in a production kernel**. In fact, as long as the architecture supports it and SLAB/SLUB is on, KFENCE is on by default in many distros. - -The original note said "KFENCE must run for a long time, but the overhead is low enough to run in production"—the mechanism description is correct. We've added the version number (5.12) and the keyword "sampling," and emphasized the engineering significance of "on by default." It replaces the older `kmemcheck` (which was deleted in 4.15 because the overhead was too high and conflicted with KFENCE's philosophy). - -## DAMON: Another "Sampling" Path, But Not for Bugs - -Speaking of "sampling," we should mention DAMON (Data Access MONitor), because philosophically it's in the same category as KFENCE—**don't track everything, sample representative data**. But DAMON isn't a sanitizer; it doesn't catch bugs, it **monitors memory access patterns**: - -- **Merged into mainline in Linux 5.15**, its goal is to help developers (and the kernel itself) see "how the process is actually accessing memory," to optimize layout and guide reclamation. -- DAMON splits the target process's address space into equal-sized regions, **samples** several representative pages in each region, records access frequency, and forms a histogram. If a region is hot, it subdivides—this "smart zoom" allows it to run cheaply even on huge address spaces. -- The kernel component is the "producer" (producing access patterns), and userspace (or the kernel) is the "consumer." The consumer can even call `madvise()` based on patterns to change memory attributes—like advising the kernel to swap out confirmed cold data. - -DAMON has three interfaces: the userspace `damo` tool (from awslabs/damo), sysfs under `/sys/kernel/mm/damon/admin/`, and a kernel API for kernel developers. The old debugfs interface is deprecated. Looking at KFENCE and DAMON together, you'll see that in the 5.12~5.15 wave, the kernel systematically used "sampling" to plug the gap left by "full instrumentation is too expensive"—KFENCE catches bugs, DAMON watches patterns, both can go to production. - -## Three Layers of Defense: Placing Tools by Scenario - -Putting userspace and kernel sanitizers together, the memory safety toolchain is actually a **layered defense in depth**, each with different overhead/coverage trade-offs: - -::: tip Development Phase: Full Instrumentation, Catch Everything -For self-testing, CI, and fuzzing, **overhead is not an issue, coverage is paramount**. Userspace turns on `-fsanitize=address,undefined` (plus a separate run of `-fsanitize=thread`), and kernel debug builds turn on `CONFIG_KASAN` + `CONFIG_KCSAN` + `CONFIG_UBSAN`. This layer assumes bugs will be caught by full instrumentation, at the cost of slowing the program/system down several times—only acceptable in non-production environments. -::: - -::: tip Testing/Staging: Sampling Instrumentation, Long Runs -For pre-production, canary releases, and long load tests, **we can't accept total system collapse, but need to run long enough to expose rare bugs**. This layer uses KFENCE—sampling, low overhead, always on, letting thousands of allocations flow through the guard page pool to catch those "once in ten thousand runs" OOB and UAF bugs. Userspace lacks a direct equivalent here (Valgrind is too slow, ASan too heavy), so KFENCE's engineering value on the kernel side is particularly prominent. -::: - -::: tip Production: Default Lightweight Checks + Post-Mortem -For real production kernels, **only enable negligible-overhead checks**: KFENCE (on by default), lightweight UBSAN subsets like `CONFIG_UBSAN_BOUNDS`, plus DAMON for access pattern analysis. When accidents happen, rely on post-mortem tools—kernel oops logs, kdump/crash analysis, and eBPF's `memleak-bpfcc` to track unreleased allocations. This layer stops expecting "catching bugs on the spot" and focuses on "keeping enough evidence to investigate later." -::: - -This layering explains why the kernel maintains both KASAN and KFENCE, which seem redundant—**the same bug (e.g., UAF) is caught by KASAN during development and by KFENCE in production**. The tools aren't redundant; the scenarios don't overlap. Userspace currently only has the first layer (development instrumentation) working well; the second and third layers lack tools as mature as the kernel's. This is why "totally solving memory safety in C++ userspace" is harder than in the kernel—the kernel has KFENCE as a production safety net; when userspace has a production UAF, often the only option is to wait for it to crash and inspect the core dump. - -## By the Way: Static Analysis and Post-Mortem Tools - -Besides these runtime sanitizers, both kernel and userspace have a set of tools that **don't run the code, but read code or logs**, mentioned in the original notes. We'll wrap them up briefly here without diving deep: - -- **Static Analysis**: Kernel side has `sparse`, `smatch`, `Coccinelle`, `checkpatch.pl`; userspace has `clang-tidy`, `cppcheck`. They don't run code and have zero overhead, but can only catch "obviously problematic patterns," missing runtime-only UAF/OOB. They complement, not replace, sanitizers—static analysis enforces rules, sanitizers catch runtime issues. -- **Post-Mortem**: Kernel oops/panic logs, `kdump`/`crash` tools for dump analysis, `[K]GDB` debugging. These are forensic methods after the bug has exploded, a different stage from sanitizers that "catch bugs early." - -Userspace C++ post-mortem analysis was seen once in the "Dynamic Memory Management" chapter using `-fsanitize=address` to report leaks on exit, and in "Concurrent Debugging Tips" using TSan to locate concurrent bugs post-mortem. The whole toolchain is a **one-stop shop: Development Sanitizer → Production Lightweight Checks → Post-Mortem Analysis**. If you miss any link, the corresponding class of bugs will bite you repeatedly at that stage. - -## Summary - -This article pulled the sanitizer toolchain from userspace to kernel space. Here are the key takeaways: - -- Userspace has four flags with clear division: ASan (OOB/UAF/Leak), UBSan (Undefined Behavior), MSan (Uninitialized Read, Clang only), TSan (Data Race). Most are mutually exclusive (ASan/MSan/TSan can't run together), but UBSan can stack with ASan. Local GCC 16.1.1 / Clang 22 all ran successfully. -- Kernel has fully corresponding tools: KASAN (↔ASan, 4.0), KMSAN (↔MSan, 6.1+ mainline fully usable), UBSAN (↔UBSan, 4.5), KCSAN (↔TSan, 5.8). Mechanisms are similar, but constrained by the kernel, most are debug-only. -- **KFENCE (5.12) is the watershed**: Using "sampling + guard pages" to crush memory error detection overhead to production levels, on by default, filling the production gap left by KASAN. -- **DAMON (5.15)** follows the same sampling path but doesn't catch bugs; it monitors access patterns to guide memory optimization. -- The whole toolchain is a three-layer defense: Dev full instrumentation (KASAN/ASan) → Staging sampling (KFENCE) → Production lightweight checks + post-mortem (UBSAN subset/kdump). -- Two version numbers from the source notes have been verified: KFENCE version = 5.12 (source didn't label it, added); KMSAN source said "6.1 and above" is actually correct, verified against Google's official repo README confirming 6.1+ mainline usability—patches ran on 5.16 branch, but mainline merge was 6.1. - -Next, we continue on the performance vs. correctness line to see how compiler optimization makes code faster without changing semantics—and when it "secretly" changes semantics, making your carefully crafted concurrent code run differently than you expected. - -## Reference Resources - -- [kernel.org: Kernel Address Sanitizer (KASAN)](https://www.kernel.org/doc/html/latest/dev-tools/kasan.html) — KASAN mechanisms, config options, and example reports -- [kernel.org: Kernel Memory Sanitizer (KMSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kmsan.html) — KMSAN requires Clang 14.0.6+, x86_64 only, explicitly "not for production" -- [kernel.org: Kernel Electric-Fence (KFENCE)](https://www.kernel.org/doc/html/latest/dev-tools/kfence.html) — KFENCE sampling mechanism, `CONFIG_KFENCE_NUM_OBJECTS`, production readiness -- [kernel.org: UndefinedBehaviorSanitizer (UBSAN)](https://www.kernel.org/doc/html/latest/dev-tools/ubsan.html) — Kernel UBSAN sub-checks and overhead -- [kernel.org: Kernel Concurrency Sanitizer (KCSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kcsan.html) — KCSAN watchpoint-based sampling race detection -- [kernel.org: DAMON](https://www.kernel.org/doc/html/latest/admin-guide/mm/damon/usage.html) — DAMON sysfs/schemes interfaces and access pattern monitoring -- [Clang: UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) — Userspace UBSan sub-check list -- [Clang: MemorySanitizer](https://clang.llvm.org/docs/MemorySanitizer.html) — MSan full instrumentation requirements and usage diff --git a/documents/en/vol6-performance/avx-avx2-deep-dive.md b/documents/en/vol6-performance/avx-avx2-deep-dive.md deleted file mode 100644 index dc492109e..000000000 --- a/documents/en/vol6-performance/avx-avx2-deep-dive.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -chapter: 2 -difficulty: intermediate -order: 3 -platform: host -reading_time_minutes: 6 -tags: -- cpp-modern -- host -- intermediate -title: 'In-Depth Introduction to the AVX Instruction Set Series: Scope, Significance, - and Basic Usage with Examples for AVX and AVX2' -description: '' -translation: - source: documents/vol6-performance/avx-avx2-deep-dive.md - source_hash: d341ba0e6c0726e775647342afe12bd486dcbbc43b83486cbf4aa3b3bdd567ec - translated_at: '2026-06-16T04:07:38.687428+00:00' - engine: anthropic - token_count: 1211 ---- -# In-Depth Introduction to the AVX Instruction Set Family: Scope, Significance, and Basic Usage with Examples for AVX/AVX2 - -## Preface - -PS: I am not a specialist in this field. The topic came up during a chat, and I realized how unfamiliar this area was to me, so I decided to write a proper note to sort it out. Therefore, I cannot guarantee that the information I have gathered is 100% accurate. Reader discretion is advised. - ------- - -## Why Care About AVX? — The Scope and Significance of Vectorized Computing - -I care about this partly because of high-definition video rendering (yes, the projects I participate in involve this, which is how I became aware of this field). In modern computing tasks, whether it is HD video rendering, AI model training, or complex scientific simulations, data volumes are growing exponentially. The traditional **SISD (Single Instruction, Single Data)** processing model—where a single data item is processed per operation—has increasingly become a bottleneck for computational efficiency. - -To break through this bottleneck, the concept of **SIMD (Single Instruction, Multiple Data)** emerged. It allows the CPU to process a set of data with a single instruction. This "batch processing" technique is known as **vectorization**. **AVX (Advanced Vector Extensions)** is one of the most important vector instruction sets in the x86 architecture. - -We naturally ask, how is it optimized? Inside the CPU, **registers** are the "temporary platforms" where data must reside before participating in operations. In the era of early SSE technology, this platform was 128 bits wide. If we were processing "single-precision floating-point numbers" (32 bits per data item), only 4 data items could be arranged side-by-side for calculation in one cycle. - -AVX technology doubles the width of this platform to **256 bits**. This means a qualitative change in the CPU's hardware channels: it can now ingest and process **8** single-precision floating-point numbers, or **4** larger, more precise double-precision floating-point numbers in the same instant. This doubling of bit width essentially builds a wider highway for data flow, doubling the computational "appetite." - -In traditional computing instructions, the CPU's operation logic is usually quite "coarse." For example, to execute A + B, the calculation result must forcibly overwrite the original data A. This design is known as the "two-operand" mode, which is somewhat destructive—if you need the original data A later, you must spend extra time backing it up elsewhere before the calculation. - -AVX introduces the more advanced **VEX encoding**, implementing a "three-operand" mode. It allows programs to issue more fine-grained instructions: "Take data A, take data B, store the result in C." This way, the original data A and B are preserved intact. This evolution streamlines a significant amount of repetitive labor, reducing the overhead of moving and backing up data in memory, making the entire program logic lighter and more efficient. - -AVX brings not just a speed tweak, but an underlying evolution in processing logic. It transforms "serial" tasks that used to execute one by one into batch "vectorized" tasks. In ideal compute-intensive scenarios (like scientific modeling or high-quality rendering), this transformation can leapfrog CPU efficiency by several times. - -This progress means that when facing massive numerical computations, the CPU can maximize its arithmetic throughput. Clock cycles that previously required repeated spinning can now be completed in one powerful "vectorized strike," achieving a leap in performance without solely relying on increasing the clock frequency. - -### AVX2: The Leap in Integer Operations and Flexibility - -Released in 2013, **AVX2** further refined this system. If AVX solved the problem of "calculating fast," then AVX2 solved the problem of "calculating broadly": - -1. **Full Integer Support**: AVX2 extends the existing 256-bit parallel computing capability from floating-point numbers to the **integer** domain. This is crucial for scenarios relying on integer arithmetic, such as data compression, image processing, and database retrieval. -2. **Non-Contiguous Data Handling (Gather/Permute)**: In practical applications, data is often scattered in memory. AVX2 introduced "Gather" instructions, allowing the CPU to fetch data from non-contiguous memory addresses in batches, significantly enhancing the ability to handle complex data structures. - ------- - -## Using AVX / AVX2 in Code - -#### Compiler Switches - -- GCC/Clang: - - AVX: `-mavx` - - AVX2: `-mavx2` - - FMA (if needed): `-mfma` - - To optimize for the target CPU: `-march=native` (but this generates code dependent on the current CPU) -- MSVC: - - `/arch:AVX` or `/arch:AVX2` (depending on VS version) -- Recommended practice: You can generate dedicated files with AVX/AVX2 during compilation, or compile multiple versions and select at runtime (runtime dispatch). - -#### Intrinsics (Example APIs) - -- Floating Point (AVX): `__m256` (float32 ×8) and `__m256d` (double ×4) - - load/store: `_mm256_load_ps`, `_mm256_loadu_ps` (unaligned) - - add/mul: `_mm256_add_ps`, `_mm256_mul_ps` - - fused: `_mm256_fmadd_ps` (requires FMA) -- Integer (AVX2): `__m256i` - - add: `_mm256_add_epi32` - - gather: `_mm256_i32gather_epi32` (gather from int indices) - - shift/and/or: `_mm256_slli_epi32`, `_mm256_and_si256`, etc. - ------- - -## Basic Examples (C/C++ intrinsics) - -The following small examples provide an intuitive experience of AVX. - -#### Floating-Point Array Addition (AVX) - -```cpp -#include -#include - -int main() { - // Prepare source data (must be aligned or use loadu) - float a[8] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f}; - float b[8] = {8.0f, 7.0f, 6.0f, 5.0f, 4.0f, 3.0f, 2.0f, 1.0f}; - float c[8]; - - // Load data into 256-bit registers - __m256 va = _mm256_load_ps(a); // Assumes 32-byte alignment - __m256 vb = _mm256_load_ps(b); - - // Perform parallel addition - __m256 vc = _mm256_add_ps(va, vb); - - // Store result back to memory - _mm256_store_ps(c, vc); - - // Print result - for(int i = 0; i < 8; i++) { - printf("%.1f ", c[i]); - } - // Output: 9.0 9.0 9.0 9.0 9.0 9.0 9.0 9.0 - return 0; -} -``` - -Compile: - -```bash -gcc -mavx example.c -o example -``` - -#### Floating-Point Dot Product (AVX + reduction) - -```cpp -#include -#include - -float dot_product_avx(const float* x, const float* y, size_t size) { - __m256 sum_vec = _mm256_setzero_ps(); - - // Process 8 floats at a time - for (size_t i = 0; i < size; i += 8) { - __m256 vx = _mm256_loadu_ps(x + i); // Use loadu if alignment is uncertain - __m256 vy = _mm256_loadu_ps(y + i); - - // Multiply and accumulate - __m256 v_mul = _mm256_mul_ps(vx, vy); - sum_vec = _mm256_add_ps(sum_vec, v_mul); - } - - // Horizontal reduction (extract elements from the vector and sum them) - // Note: AVX horizontal operations are slightly complex, here is a simple method - alignas(32) float temp[8]; - _mm256_store_ps(temp, sum_vec); - - float result = 0.0f; - for (int i = 0; i < 8; i++) { - result += temp[i]; - } - - // Handle remaining tail elements (if size is not a multiple of 8) - for (size_t i = (size / 8) * 8; i < size; i++) { - result += x[i] * y[i]; - } - - return result; -} - -int main() { - float a[8] = {1, 2, 3, 4, 5, 6, 7, 8}; - float b[8] = {1, 2, 3, 4, 5, 6, 7, 8}; - printf("Dot Product: %f\n", dot_product_avx(a, b, 8)); - return 0; -} -``` - -#### Try It: AVX2: Integer Parallel Addition and Gather Example - -```cpp -#include -#include -#include - -int main() { - // Define a sparse array (indices) - int32_t indices[8] = {0, 10, 20, 30, 40, 50, 60, 70}; - // Define a large data array - int32_t data[100]; - - // Initialize data: data[i] = i - for(int i=0; i<100; i++) data[i] = i; - - // 1. Gather: Load 8 integers from non-contiguous addresses based on indices - // This is much faster than loading individually in a loop - __m256i v_data = _mm256_i32gather_epi32(data, indices, 4); // Scale 4 (sizeof int) - - // 2. Vector Addition: Add a constant vector (e.g., 100) to the gathered data - __m256i v_offset = _mm256_set1_epi32(100); - __m256i v_result = _mm256_add_epi32(v_data, v_offset); - - // 3. Store result - int32_t result[8]; - _mm256_storeu_si256((__m256i*)result, v_result); - - // Print result - printf("Gathered and Added Result:\n"); - for(int i=0; i<8; i++) { - printf("%d ", result[i]); // Expected: 100, 110, 120... - } - printf("\n"); - - return 0; -} -``` - -Compile: - -```bash -gcc -mavx2 avx2_gather_example.c -o avx2_example -``` diff --git a/documents/en/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md b/documents/en/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md new file mode 100644 index 000000000..ce003099f --- /dev/null +++ b/documents/en/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md @@ -0,0 +1,206 @@ +--- +chapter: 0 +cpp_standard: +- 14 +- 17 +description: Starting from a lookup that is both O(log n), this article nails down the gulf between efficiency (algorithmic complexity) and performance (real behavior on hardware), lays down the volume's two iron rules and the Amdahl ceiling, and includes a one-page platform/library selection checklist. +difficulty: intermediate +order: 1 +platform: host +reading_time_minutes: 15 +prerequisites: +- 'C++ containers and algorithms basics (std::vector / std::set / std::lower_bound)' +related: +- Why microbenchmarks lie +- Memory hierarchy and the latency ladder +- The ASan tool family and memory safety +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 实战 +title: 'Performance Mindset: efficiency is not performance' +translation: + source: documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md + source_hash: 2648208d38c7044736713111eb0e5a82d426d83011b47b50d684f7ad5ec51b69 + translated_at: '2026-07-05T12:41:00+00:00' + engine: manual + token_count: 3500 +--- +# Performance Mindset: efficiency is not performance + +## A fact that makes a lot of people uncomfortable + +Let's look at a piece of code almost everyone has written: looking up a number in a collection. The two most natural choices are: stuff the data into a `std::set`, or into a sorted `std::vector` and binary-search it with `std::lower_bound`. Both lookups are $O(\log n)$, the complexity is identical, and textbooks usually stop right there and tell you "pick whichever, they're about the same". + +But if you actually measure, you'll find things aren't that simple. The table below is what I get on my own machine (WSL2/Linux, 2,000,000 random-hit queries, median of 5 runs; full code is in the "Code example" section later in this chapter): + +| N | `vector` + binary (ns/q) | `set`.find (ns/q) | `set` / `vector` | +|---:|---:|---:|---:| +| 1,024 | 43 | 39 | 0.9× | +| 4,096 | 51 | 63 | 1.3× | +| 16,384 | 61 | 98 | 1.6× | +| 65,536 | 81 | 275 | 3.4× | +| 262,144 | 105 | 578 | **5.5×** | +| 1,048,576 | 185 | 1006 | **5.4×** | + +Once N passes 60k, `set` is 3 to 5× slower than `vector`; and when N is small (1024), `set` is actually slightly faster. The complexity is exactly the same on both sides, so how come the gap is this big? Worse, if your interview answer is "they're equivalent", in real code you'll quietly ship a service that's several times slower. + +The answer is the proposition this whole volume keeps returning to: **efficiency and performance are not the same thing.** + +## efficiency vs performance, where exactly is the difference + +Let's separate the two words first; everything that follows in this volume builds on this distinction: + +- **efficiency** is the algorithmic-complexity axis: total work, critical-path length (span), big-O notation. This is a **mathematical property**, independent of any specific hardware. You say binary search is $O(\log n)$, and that holds whether it runs on x86, ARM, or a paper-tape machine. +- **performance** is about how your data **actually flows on real hardware**: which cache level it hits, whether it triggers branch mispredictions, whether it gets vectorized, whether there's false sharing. This is an **engineering property**, only measurable on concrete hardware, and a different CPU model can flip the conclusion. + +The problem is that big-O shoves all the "hardware-related" effects (cache hit or miss, branch-prediction accuracy, the actual size of constant factors) into an implicit constant $C$, and then pretends it doesn't matter. The mainstream `std::set` implementations (libstdc++/libc++/MSVC) are red-black trees; every node is allocated separately, scattered across the heap. During lookup, every level down is a pointer dereference, and the next node it jumps to is at an unpredictable address. This is a pattern called **pointer chasing**, which the hardware prefetcher can't learn, so at large N almost every level is a cache miss. `std::vector`'s elements are stored **contiguously**; the points a binary search jumps to at least land in a compact stretch of memory (a cacheline, the minimum unit the CPU fetches from memory, usually 64 bytes, holds 16 `int`s, and the whole array easily fits in L2/L3). Complexity analysis stuffs this entire gulf into the constant $C$, so a single "it's all $O(\log n)$" flattens a real 5× gap. + +Denis Bakhvalov gives an even more counterintuitive example in Chapter 1 of *Performance Analysis and Tuning on Modern CPUs*: on **small** inputs, InsertionSort ($O(n^2)$) actually beats QuickSort ($O(n \log n)$), because big-O can't capture branch-prediction and cache effects, and their difference gets hidden inside that "insignificant" constant. His point, paraphrased: complexity analysis can't account for the branch-prediction and cache effects of various algorithms, so it has to pack them into an implicit constant $C$, and that constant can sometimes be decisive for performance. + +That's the overarching thesis of this volume: **don't just look at big-O; watch how data flows on hardware.** Later, ch02 will properly expand on cache hierarchy, cachelines, and latency ladders; but you need to build one intuition right now: "low complexity = runs fast" is an illusion that will embarrass you in real code. Same $O(\log n)$, a 5× difference; same $O(n)$, easily tens of times apart (sequential traversal vs random access). + +## Iron rule 1: correct first, then fast + +Chapter 5 of *Computer Systems: A Programmer's Perspective* nails this in its very first sentence (quoted directly, because it can't be said more precisely): + +> The primary objective in writing a program must be to make it work correctly under all possible conditions. A program that runs fast but gives incorrect results serves no useful purpose. + +Sounds like a truism, but in the context of performance optimization it has a very specific, repeatedly-violated corollary: **talking about performance numbers in the presence of undefined behavior (UB) is like building on a foundation that hasn't been poured yet.** UB doesn't sit still under `-O2`; the compiler reasons from "this UB never happens" and optimizes aggressively, and the usual result is that what your benchmark measures is no longer a real function call but an empty shell optimized beyond recognition, and you draw a pile of beautiful, entirely-wrong conclusions from it, then confidently take them off to "optimize" production code. + +That's why this volume puts the sanitizer toolchain (ASan / UBSan / MSan / TSan) in ch00 as the foundation, rather than treating it as "a debugging tool that wandered into the performance volume." Without sanitizer-backed correctness, performance numbers are untrustworthy across the board; the same goes for any concurrency numbers that haven't been through TSan. We'll cover that chain in detail later; for now just remember the conclusion: **correct first, then fast; this is non-negotiable.** + +## Iron rule 2: measure first, then optimize + +Your intuition, at the microarchitecture level, is wrong a lot of the time. Big-direction intuitions like "compact the data, do fewer allocations" are of course right; but once you're asking "branchless or not", "unroll this loop or not", "are virtual calls actually slow"—instruction-level questions like that—intuition can't keep up with the hardware anymore. This isn't a dig at anyone; it's just what the complexity of a modern CPU amounts to. A contemporary CPU has out-of-order execution, branch prediction, cache hierarchy, prefetchers, SIMD, micro-op fusion… your "feeling" can't keep up with all that. + +A few cases that later chapters will tear apart with real measurements: + +- You think branchless is faster; turns out the modern branch predictor handles **predictable** branches almost for free, and your branchless rewrite just adds a few instructions and introduces a data dependency, making it slower. +- You think hand-unrolling the loop speeds things up; the compiler already unrolled it at `-O2`, and your re-do just makes the code harder to read and worse for icache. +- You think virtual calls are slow; the compiler already devirtualized them into direct calls based on the type hierarchy, and even inlined them. + +None of this is hypothetical; it's the real content of ch04 / ch06. The conclusion fits in one sentence: **profile before you optimize.** The widest box on the flame graph is where you should be working; going by gut, odds are you're optimizing the 5% while the real bottleneck is sleeping in the other 95%. + +This rule directly motivates ch01, Benchmark Methodology. That's the **anchor chapter** of this volume: every later performance article opens by referencing the measurement discipline it teaches, just like vol5 runs TSan through all of concurrency correctness. If you only have time to read one article in this volume, read ch01. + +## Amdahl: the optimization ceiling + +Before touching any code, there's one more hard rule to know: Amdahl's law. Gene Amdahl put it forward in 1967; in one sentence it tells you where the speedup ceiling is: + +$$S = \frac{1}{(1 - p) + \dfrac{p}{N}}$$ + +where $p$ is the fraction of total time taken by "the part you can accelerate", and $N$ is the speedup you apply to that part. That lonely $(1 - p)$ in the denominator is the serial part: it doesn't eat your speedup, it just sits there unchanged. + +Plug in some numbers and you'll feel how brutal it is: even if you accelerate the 90%-part by 1000× ($p=0.9, N=1000$), the total speedup is only $1 / (0.1 + 0.0009) \approx 9.9\times$. Where's the ceiling? Let $N \to \infty$ (you accelerate that 90% without limit), $p/N \to 0$, $S \to 1/(1 - p) = 1/0.1 = 10\times$, which is where "locked under 10×" comes from: that remaining 10% of serial code, you can't do anything about; no matter how hard you squeeze the parallel part, the serial part doesn't budge. + +The corollary matters a lot: **optimize where the serial part is large.** That's the theoretical basis for profile-driven optimization—first measure where the biggest share is, then change that, don't just act on "this looks slow." The full derivation of Amdahl's law and its contrast with Gustafson's law (strong scaling with fixed problem size vs weak scaling where the problem grows with core count) is covered thoroughly in vol5 chapter 0; here we only take the "optimization ceiling" angle and don't reinvent it. + +## Don't ignore the biggest lever: platform and libraries + +Having covered two iron rules and one ceiling, before diving into micro-optimization, step back and look at the macro picture. Agner Fog devotes a whole chapter (ch2, *Choosing the optimal platform*) in volume 1 of his optimization manual to "choosing the optimal platform", in this order: hardware platform → processor model → operating system → programming language → compiler → libraries → UI framework. His stance is blunt: **these high-level decisions usually affect performance more than any micro-optimization you tweak afterwards.** + +Compressed into one page, a few key calls: + +- **Kill the most wasteful tool/framework first.** Pick a heavy framework that heap-allocates everywhere and layers virtual calls on virtual calls, and no amount of cacheline-tweaking or alignment-fiddling afterwards will recover it. Agner cites Wirth's law, the half-joking adage that software gets slower faster than hardware gets faster. When both happen at once, the user experience is treading water or even going backwards. +- **Data-structure choice > micro-optimization.** Our opening `vector` vs `set` example is the proof: swap in a cache-friendly container, pocket a 5× gain, far more practical than hand-unrolling loops or bit-twiddling. Eat the "structural" wins first, then talk about instruction-level optimization. +- **Embedded angle: host vs MCU differ by orders of magnitude in resources.** A heap allocation that doesn't matter on host can be a fragmentation disaster on STM32; an optimization validated on host usually holds in principle on MCU, but MCU has far less memory available, so overhead the host doesn't even notice can be a hard performance or capacity bottleneck on MCU. The code examples in this volume are host-focused; embedded-leaning topics get called out separately and point you to the vol8 embedded domain for the full story. + +Agner's full platform-selection checklist runs over a dozen pages; we compress it to one because it isn't this volume's technical subject, but it's often the most ignored, highest-yield cut. A lot of people get stuck on performance optimization not because they haven't mastered the microarchitecture, but because they picked the wrong tool/library at the start. + +## Code example: verify vector vs set yourself + +Talk is cheap; let's lay out the code for the table at the start. This is a **self-contained** benchmark that depends on no external library; plain C++17 compiles it (ch01 will properly introduce the industrial-strength Google Benchmark methodology later; here we use the plainest `std::chrono` first, to avoid piling on concepts in the very first article of the volume). + +```cpp +// vector_vs_set.cpp — Both are O(log n) lookups; how big a difference can cache effects make? +// Build: g++ -O2 -std=c++17 vector_vs_set.cpp -o vector_vs_set +#include +#include +#include +#include +#include +#include +#include + +using Clock = std::chrono::steady_clock; + +static double median(std::vector& v) { + std::sort(v.begin(), v.end()); + return v[v.size() / 2]; +} + +int main() { + constexpr int queries = 2'000'000; // 2M queries per N, to dilute single-shot noise + constexpr int trials = 5; // 5 trials, take the median; ch01 explains why + volatile std::int64_t global_sink = 0; // prevent the whole loop from being dead-code eliminated + + printf("%-10s %18s %18s %10s\n", + "N", "vector(ns/q)", "set(ns/q)", "set/vector"); + for (int N : {1024, 4096, 16384, 65536, 262144, 1048576}) { + std::mt19937_64 rng(12345); + std::vector keys(N); + for (int i = 0; i < N; ++i) keys[i] = i * 2; // even, sparse + std::vector sorted = keys; + std::sort(sorted.begin(), sorted.end()); // for vector binary search + std::set sset(keys.begin(), keys.end()); // set red-black tree + + std::vector toFind(queries); // all hits, removes "not found" bias + for (int i = 0; i < queries; ++i) toFind[i] = keys[rng() % N]; + + std::vector tv, ts; + for (int t = 0; t < trials; ++t) { + std::int64_t acc = 0; + auto a = Clock::now(); + for (int q : toFind) { + auto it = std::lower_bound(sorted.begin(), sorted.end(), q); + acc += (it != sorted.end() && *it == q); + } + auto b = Clock::now(); + tv.push_back(std::chrono::duration(b - a).count() / queries); + global_sink += acc; + + acc = 0; + auto c = Clock::now(); + for (int q : toFind) { + auto it = sset.find(q); + acc += (it != sset.end()); + } + auto d = Clock::now(); + ts.push_back(std::chrono::duration(d - c).count() / queries); + global_sink += acc; + } + double mv = median(tv), ms = median(ts); + printf("%-10d %18.1f %18.1f %10.1fx\n", N, mv, ms, ms / mv); + } + printf("\nglobal_sink=%lld (anti-dead-code-elimination)\n", (long long)global_sink); +} +``` + +Let's walk through a few key points on why it's written this way, because every detail here is foreshadowing for the ch01 measurement methodology. + +First, **the result must be consumed.** `acc` accumulates hit counts and is fed into `volatile global_sink` at the end. Without this step, the compiler notices "nobody uses what this loop computes" and deletes the whole loop (DCE); you'd be measuring an empty program. `volatile` forces a real memory write every time, blocking that optimization path. + +Second, **all queries hit.** The numbers in `toFind` are all keys actually present in the set. Without controlling this, "found" and "not found" take paths of different lengths and pollute the result. What we want to compare is pure lookup cost, not hit rate. + +Third, **multiple trials, take the median.** A single run that gives 50ns or 80ns might just be that the CPU got scheduled away this round, or Turbo frequency didn't ramp up. Run 5 rounds and take the median to suppress outliers. This looks trivial, but it's the starting point of what ch01 will expand into "performance numbers are random variables": what you measure isn't a number, it's a distribution. + +Fourth, **use `steady_clock`, not `clock()`.** `clock()` measures process CPU time, which under multithreading also counts other cores' busyness, and doesn't count blocking/sleep—completely not the "how long did this code take on the wall" that we want; `steady_clock` is a monotonic, high-resolution clock that won't get rewound like `system_clock` (that's the actual wall clock) when the system time changes; it's purpose-built for measuring "how far apart are two events", which is exactly what we need here. ch01 will cover "which clock to use" separately. + +When you run it, you'll see the same trend as the table at the top of this article: at small N, `set` is actually slightly faster on my machine (around 0.9×); once N exceeds the cache, `set` gets dragged into a multi-fold slowdown by cache misses, while `vector` keeps the curve flat through contiguous memory. + +That small-N "anomaly" deserves another word, because it exposes the second thing big-O can't hide: **branch prediction.** At N=1024, `set`'s entire red-black tree and the elements `vector`'s binary search will jump to are all still in L1; the cache blade hasn't even engaged yet. The real difference is in branches: `lower_bound`'s comparison at each step is almost 50/50 for random-hit queries, and the branch predictor can't guess it; one mispredict costs a pipeline flush (a dozen-plus cycles). `set::find` at each level, on top of that one key comparison, also has a few highly-predictable operations (null-pointer checks, pointer updates); when the cache isn't missing, that instruction-mix difference is enough to let it overtake. Bakhvalov specifically covers this "binary search dragged down by branch prediction before the cache even kicks in" counterintuitive phenomenon in his book. Note that this "small-N set slightly faster" is stably reproducible on my machine + libstdc++, but **it can flip if you change the compiler, the STL implementation, or the microarchitecture**, so the warning-box line below about "the row where `set` is slightly faster may disappear" refers to changing the environment, not random jitter on the same machine. Both are $O(\log n)$; at small N, branch prediction and instruction mix decide who's faster; at large N, the cache decides; and that is exactly efficiency ≠ performance. + +> ⚠️ **Don't treat this table as a universal conclusion.** Absolute numbers will change when you run on a different CPU, compiler, or libc++ implementation; the row where `set` is slightly faster may disappear, or become more pronounced. What we care about is the **trend** (contiguous memory vs scattered nodes) and the **proposition** (same complexity, several-fold difference), not any specific multiplier. Copying someone else's performance numbers straight into your own project is just another form of "guessing". + +Performance problems always start from "how data flows on hardware", never from "I feel like". As for how to turn "I feel like" into "I measured it", that's ch01's job. + +## References + +- Bryant, R. E., O'Hallaron, D. R. *Computer Systems: A Programmer's Perspective*, Chapter 5 *Optimizing Program Performance* (correct-first-then-fast, optimization layers, the Amdahl angle). +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs*, Chapter 1 *Introduction* (limits of complexity analysis, the InsertionSort vs QuickSort case). +- Fog, A. *Optimizing Software in C++*, Chapters 1–2 (Why software is often slow / Choosing the optimal platform). +- cppreference: [`std::lower_bound`](https://en.cppreference.com/w/cpp/algorithm/lower_bound), [`std::set::find`](https://en.cppreference.com/w/cpp/container/set/find). +- Original Amdahl's-law source: Gene Amdahl, *Validity of the single processor approach to achieving large scale computing capabilities*, AFIPS 1967. diff --git a/documents/en/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md b/documents/en/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md new file mode 100644 index 000000000..a0b3493f3 --- /dev/null +++ b/documents/en/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md @@ -0,0 +1,103 @@ +--- +chapter: 0 +cpp_standard: +- 11 +description: Picking up from ch00-01's 'correct first, then fast', this article unpacks why a performance number measured in the presence of UB is untrustworthy, explains why sanitizer (ASan/UBSan/MSan/TSan) sits in ch00 as the foundation rather than being filed under 'debugging tools', and hands the narrative off to the three sanitizer articles and ch01. +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 'Performance Mindset: efficiency is not performance' +reading_time_minutes: 7 +related: +- The ASan tool family and memory safety +- Why microbenchmarks lie +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 内存安全 +title: 'From "Correct First" to "Then Fast": why sanitizer is the foundation of the performance volume' +translation: + source: documents/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md + source_hash: ba636b7fb40e12877c77571daaca869b512a3c465d4a810f6b30a5244b78c65d + translated_at: '2026-07-05T13:10:00+00:00' + engine: manual + token_count: 3000 +--- +# From "Correct First" to "Then Fast": why sanitizer is the foundation of the performance volume + +## The line we left at the end of the previous article + +When ch00-01 laid down iron rule 1, "correct first, then fast", we dropped a heavy line: **talking about performance numbers in the presence of undefined behavior (UB) is like building on a foundation that hasn't been poured yet.** This article unpacks that line and shows you exactly how UB turns a performance number into a lie. Once you see this, you'll understand something that might otherwise puzzle you: why a volume on performance optimization opens not with cache, not with SIMD, but with a full sanitizer toolchain. + +## How UB turns a performance number into a lie + +The C++ standard marks a class of behaviors as "undefined": signed integer overflow, out-of-bounds access, dereferencing a null pointer, reading an uninitialized variable, data races… For these, the standard **guarantees nothing** about your program's behavior: **anything can happen**, including the case you least want, where it "runs fine and every result is wrong". + +The scary part is that the compiler **actively exploits** this rule. Under `-O2`, the compiler is allowed to assume "this code won't trigger UB", and then optimizes aggressively based on that assumption. The standard permits it, and every modern compiler does it daily. For performance measurement, the consequences fall into three categories, each enough to invalidate your numbers: + +**Category 1: the code you wanted to measure gets deleted entirely.** Your benchmark computes a result nobody uses, the compiler decides it's dead code, and eliminates it (DCE). You smugly measure 0.3 nanoseconds—and you measured nothing. This is why the `volatile global_sink` in the ch00-01 benchmark had to exist. + +**Category 2: the compiler decides your loop for you.** A signed loop variable that might overflow is UB; the compiler can assume it won't overflow, then derive an upper bound, hoist the entire loop into a constant, or just fold it away. You think you ran N iterations; actually it ran zero. + +**Category 3, the sneakiest one: you think you're measuring A, but you're actually stepping on B's memory.** Out-of-bounds writes, use-after-free, uninitialized reads—these don't crash your program, they just make your benchmark read/write the memory of "some other variable". You measure "this code takes 50 ns", but half of those 50 ns are spent corrupting a neighboring data structure; the number is meaningless, and the program is still "running fine". + +Here's a minimal, classic example to make it concrete. This function checks whether "`x+1` is greater than `x`": + +```cpp +bool always_bigger(int x) { return (x + 1) > x; } // x+1 overflow = UB +``` + +Under `-O2`, gcc, reasoning from "signed overflow can't happen", folds the whole function into `return true;`—a single assembly line `movl $1, %eax; ret`, with no regard for `x`. So even if you pass `INT_MAX` (the value where `x+1` actually overflows), it returns `true` with a straight face. This is documented behavior under gcc's `-fstrict-overflow` (on by default at `-O2`); cppreference's undefined-behavior page uses it as a textbook counterexample. + +Talk is cheap; I ran it on my own machine (GCC 16.1.1), feeding the function the same value `INT_MAX`: + +```text +# -O2 (UB assumption allowed: the whole thing folds to return true) +$ g++ -O2 ub_fold.cpp -o ub_fold && ./ub_fold 2147483647 +f(INT_MAX) = 1 + +# -O2 -fwrapv (force signed overflow to wrap as defined behavior: actually compute it) +$ g++ -O2 -fwrapv ub_fold.cpp -o ub_fold_wrap && ./ub_fold_wrap 2147483647 +f(INT_MAX) = 0 # INT_MAX+1 wraps to INT_MIN, and INT_MIN > INT_MAX is false +``` + +Same optimization level (`-O2`), same input (`INT_MAX`), the only difference is whether signed overflow is UB or defined as wrapping—the result is `1` in one case and `0` in the other. This is the deadliest part of UB in performance measurement: **the thing you're measuring is itself unstable; change one compiler flag and you're no longer measuring the same thing.** Side note on a trap: you might want to use `-O0` as the control (hoping it'll just do the addition and wrap to `0`), but contemporary GCC's mid-end recognizes the `(x+1)>x` idiom even at `-O0` and folds it anyway, so the cleaner control is `-fwrapv`, not a different optimization level. + +So a benchmark carrying UB, measured at `-O2` as "30% faster", may well be 30% saved by "the compiler deleting half your loop based on a UB assumption". That 30% evaporates instantly in production (different compiler flags, different input distributions), or even flips into "slower". Taking that fake number into an architecture decision is building on sand. + +## So sanitizer isn't a "debugging tool", it's the credibility foundation of performance measurement + +With that layer clear, you can see why this volume puts sanitizer in ch00 instead of the usual "debugging tips" mention. The four siblings each cover a class of UB that invalidates performance numbers: + +- **ASan** (AddressSanitizer) covers memory errors—out-of-bounds, use-after-free, double free. These are exactly the culprits behind "category 3" above, the ones that make your benchmark secretly step on someone else's memory. +- **UBSan** (UndefinedBehaviorSanitizer) covers language-level UB—signed overflow, null-pointer dereference, wrong-type casts, illegal shifts. These are exactly the culprits behind "category 1" and "category 2", the ones that give the compiler license to rewrite your measured code into an empty shell. +- **MSan** (MemorySanitizer) covers uninitialized reads—what you read is "a random value", so what you're really measuring is a random-number generator. +- **TSan** (ThreadSanitizer) covers concurrency data races—and a data race is itself UB. This one is especially lethal in concurrency performance measurement: if your multi-threaded benchmark hasn't been through TSan, that pretty throughput number means nothing. TSan's mechanism is covered thoroughly in vol5; here we only take the "why it's a performance foundation" angle: the credibility of a concurrency performance number presupposes no data races. + +Put these four together and the conclusion is hard: **a performance number that hasn't been through sanitizer, you don't know what it measured.** The precondition doesn't even hold; "precise or not" is beside the point. So the rule in this volume is: any code entering a performance comparison first runs clean under sanitizer, then we talk numbers. + +## The three sanitizer articles in this chapter + +How exactly to turn it on, how to read its reports, how to coexist with `-O2`, the trade-offs between debug builds and production—that's the job of the three sanitizer articles; this one is just a signpost pointing at what each covers: + +- **"The ASan tool family and memory safety"**: starts from the real-world disaster of Heartbleed, unpacks the shadow-memory mechanism, measures out-of-bounds, UAF, and global overflow, and sorts out the five siblings ASan / LSan / MSan / TSan / UBSan—what each does and why most are mutually exclusive (can't be enabled together). +- **"Valgrind vs ASan"**: puts Valgrind's dynamic-binary-translation route next to ASan's compile-time-instrumentation route, and explains the real differences in performance, in what errors they catch, and in the barrier to use. +- **"The sanitizer toolchain landscape"**: goes from user-space `-fsanitize=` all the way to kernel-space KASAN / KMSAN / UBSAN / KCSAN / KFENCE, laying out the two threads of "compile-time instrumentation vs sampling" and the layered defense of "full power when debugging" vs "always-on in production". + +Read those three and you have the complete toolchain for "making performance numbers trustworthy". They're the foundation of the performance volume, not a side act. + +## And then: from "measuring the real thing" to "measuring it well" + +Sanitizer handles the **precondition**—making sure you're actually measuring the logic you meant to measure, not a UB-rewritten empty shell or noise stepping on someone else's memory. But "measuring the real thing" is only step one; a real performance number is itself **a random variable**: CPU frequency drifts, threads get scheduled away, caches warm and cool, page tables get built on demand… run the same function twice and the numbers differ. + +"Measuring the real thing" plus "the number is a random variable" together lead into ch01, Benchmark Methodology. That chapter is the volume's anchor; it's about how to turn a random variable into a trustworthy conclusion, and how to compare two numbers legitimately. Put differently: ch00 gives you a ruler that "keeps numbers honest", and the sanitizer trio is that ruler's calibration source; ch01 picks it up and teaches you how to measure real things with a calibrated ruler. + +## References + +- cppreference: [undefined behavior](https://en.cppreference.com/w/cpp/language/ub) +- GCC docs: `-fstrict-overflow` / `-fwrapv` (`man gcc` or gcc.gnu.org/onlinedocs/) +- The three sanitizer articles in this volume (see "The three sanitizer articles in this chapter" above) +- Bryant, R. E., O'Hallaron, D. R. *Computer Systems: A Programmer's Perspective*, Chapter 5 (the premise of "correct first, then fast") diff --git a/documents/en/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md b/documents/en/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md new file mode 100644 index 000000000..f288a0eb0 --- /dev/null +++ b/documents/en/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md @@ -0,0 +1,398 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: Starting from Heartbleed, this article unpacks AddressSanitizer's shadow-memory trio, measures OOB/UAF/global-overflow and UBSan in real runs, and sorts out the responsibilities and mutual exclusivity of the five siblings ASan/LSan/MSan/TSan/UBSan. +difficulty: advanced +order: 3 +platform: host +prerequisites: +- Dynamic memory management (new/delete and smart pointers) +- Concurrency debugging techniques (ThreadSanitizer) +reading_time_minutes: 24 +related: +- Dynamic memory management (new/delete and smart pointers) +- Concurrency debugging techniques (ThreadSanitizer) +- C dynamic memory management (malloc/free and valgrind) +tags: +- host +- cpp-modern +- advanced +- 内存安全 +- 调试 +- 内存管理 +title: 'The ASan tool family and memory safety: shadow memory, Heartbleed, and sanitizer selection' +translation: + source: documents/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md + source_hash: 35b9df09fd3e640c1f80403410197c03d7fa48738f483bb3927990618e98ebc6 + translated_at: '2026-07-05T13:35:00+00:00' + engine: manual + token_count: 5500 +--- +# The ASan tool family and memory safety: shadow memory, Heartbleed, and sanitizer selection + +> PS: This part is a set of notes I migrated back in college, only lightly search-verified. If you spot a technical claim that's loose or outright wrong, please file an Issue or send a fix PR! + +After writing C/C++ for a while, you've most likely been tortured repeatedly by a few classes of problems: reading one element past the end of an array, a freed pointer getting used again by someone else, the same `delete` called twice. These errors share a nasty property: they are **undefined behavior** (the infamous Undefined Behavior). It doesn't necessarily crash; in Debug builds it runs fine, and then in Release or on a different machine it explodes randomly. Worse, the crash site is usually miles away from the code that actually went wrong, and the stack trace may point at some innocent library function. + +Why? Because these bugs corrupt the memory manager's own metadata, and the damage only triggers the next time `malloc`/`free` walks over the trampled spot. The rest of this volume is about performance; this article switches to a different dimension: how to catch these bugs with tools before they cause an online incident. The protagonist is AddressSanitizer (ASan) and the whole sanitizer family behind it. + +Don't be too quick to dismiss ASan as "just a flag you add". Its design (shadow memory, compile-time instrumentation) is one of the most important engineering advances in C/C++ memory safety over the past decade, and it was originally invented to plug a hole that made the whole internet sweat. We start there. + +## The starting point: Heartbleed and buffer over-read + +In April 2014, CVE-2014-0160 was disclosed, codenamed Heartbleed. It was a hole hiding in an innocent-sounding feature of OpenSSL—the TLS heartbeat extension. The protocol is simple: the client sends some arbitrary data and tells the server "this data is N bytes, read it back to me as-is", just to verify the connection is still alive. + +The bug: the server **trusted the length N the client reported, but never checked whether N actually stayed within the real length of the data it held**. So an attacker only had to report a huge N (say 64KB), and the server would "read back" 64KB from its own process memory to the attacker. What got read could be another session's TLS private keys, user passwords, session tokens—anything in process memory adjacent to that buffer, all leaked. + +The essence of this bug is an out-of-bounds **read**, not a write (buffer over-read / over-read). Out-of-bounds writes at least corrupt data and tend to expose themselves; over-reads are far quieter, the process doesn't crash, and the data just silently flows out. ASan was brought up repeatedly back then precisely because it's one of the few tools that can **reliably detect over-read**: as long as the out-of-bounds memory touches a redzone ASan planted, a single read triggers an error. + +Let's reproduce a Heartbleed-shaped bug in a few dozen lines of Modern C++, and let ASan catch it red-handed. This is the headline demo of this article; we'll reuse it throughout. + +```cpp +// oob_read.cpp — reproduce a Heartbleed-shaped out-of-bounds read +// Platform: host Standard: C++20 +// Build: g++ -std=c++20 -O1 -fsanitize=address -g oob_read.cpp -o oob_read +#include +#include +#include + +// Heartbeat echo: the client says "give me back n bytes". The server complies, +// but never checks the upper bound of n. +std::string read_back(const std::array& buf, int n) +{ + return std::string(buf.data(), n); // n may be far larger than 8 +} + +int main() +{ + std::array buf{'H', 'i', '!', 0, 0, 0, 0, 0}; + // Only 8 bytes authorized, but we ask to "read back" 64 — a classic over-read + auto leaked = read_back(buf, 64); + std::printf("Read %zu bytes: %.8s...\n", leaked.size(), leaked.c_str()); +} +``` + +Compiled and run without ASan, this code most likely "looks fine": the `std::string` constructor dutifully copies 64 bytes starting from `buf.data()`, reading off all the unrelated bytes behind it on the stack, and the program doesn't crash. That's exactly what makes over-read terrifying. + +Add `-fsanitize=address` and run it again—the picture changes. Run on my GCC 16.1.1: + +```text +================================================================= +==37023==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x72175e1f0028 at pc 0x761760d29ac2 ... +READ of size 64 at 0x72175e1f0028 thread T0 + #0 0x... in memcpy (/usr/lib/libasan.so.8+0x129ac1) + ... + #6 0x... in read_back[abi:cxx11](std::array const&, int) oob_read.cpp:11 + #7 0x... in main oob_read.cpp:18 + ... + + This frame has 2 object(s): + [32, 40) 'buf' (line 16) + [64, 96) 'leaked' (line 18) <== Memory access at offset 40 partially underflows this variable +SUMMARY: AddressSanitizer: stack-buffer-overflow oob_read.cpp:11 in read_back +``` + +Two details to notice. First, the error type is `stack-buffer-overflow`, occurring in `read_back` at line 11, which is exactly the `return std::string(buf.data(), n);` line, pinned to the source location—that's why you must compile with `-g`. Second, ASan even tells us the stack frame has two objects, `buf` occupying `[32, 40)` and `leaked` occupying `[64, 96)`, and the out-of-bounds read (offset 40) lands right between them. This level of detail at the scene is what fundamentally separates ASan from "sprinkle in some asserts and hunt slowly". + +## So, what exactly does ASan do to pull this off? + +### First: compile-time instrumentation (CTI) + +ASan isn't a post-hoc profiler; it **rewrites your code at compile time**. When you add `-fsanitize=address`, the compiler (GCC or Clang) inserts extra check instructions around every memory access (every `*p`, every array subscript, every `memcpy`). This technique is called **compile-time instrumentation** (CTI), also known as static instrumentation. + +Let's first verify it really "touches your code". Compile the `oob_read.cpp` above once without ASan and once with, and compare the **code segment (.text) size**, the actual machine instructions baked into the binary: + +```text +Plain build .text: 2792 bytes +ASan build .text: 5736 bytes (+105%) +``` + +(My GCC 16.1.1, `g++ -std=c++20 -O1 -g`, using `size` to read the `.text` segment.) That doubled size is the check code the compiler stuffs in around every memory access. One trap here: **don't compare the whole binary file size**—ASan's runtime library `libasan.so.8` is **dynamically linked** (`ldd` shows it) and isn't baked into the executable, so the whole file actually only grows about 5%; what really reflects the instrumentation volume is the `.text` code segment, and that's what doubles. The cost is larger size and slower execution, but next to the bugs it catches, that overhead is negligible during development. CTI is decided at **compile time**, so you must bring `-fsanitize=address` at **compile time**, and **at link time too**. If you add it only when compiling main but not when linking some third-party `.a`, the memory accesses inside that library go uninstrumented, and ASan is blind to that part of the code. The full flow: + +```bash +g++ -std=c++20 -O1 -fsanitize=address -g -c a.cpp -o a.o # compile with it +g++ -std=c++20 -O1 -fsanitize=address -g main.cpp a.o -o app # link with it too +``` + +`-fsanitize=address` must appear in both compile and link phases; miss one and it does nothing. + +### Second: shadow memory + +Instrumentation alone isn't enough. The checks it inserts need a "ledger" to answer "can this address actually be accessed right now". That ledger is **shadow memory**. + +The core idea is an elegant design: **use 1 byte of shadow memory to record the accessibility state of 8 bytes of real memory**. That is, ASan maps the entire process address space onto a contiguous shadow region in 8-byte groups, at a 1:8 ratio. To check whether an address is valid, you just compute its shadow byte and read it—no complex hash table to maintain. + +The shadow byte values are printed directly at the end of ASan's reports. Look at the legend from a real run: + +```text +Shadow byte legend (one shadow byte represents 8 application bytes): + Addressable: 00 + Partially addressable: 01 02 03 04 05 06 07 + Heap left redzone: fa + Freed heap region: fd + Stack left redzone: f1 + Stack mid redzone: f2 +``` + +That's the full semantics of the 1:8 mapping. `00` means all 8 bytes are accessible; `01`–`07` mean only the first few bytes are valid (e.g. `03` means the first 3 bytes are accessible and the last 5 aren't, used for partially-accessible tail regions after alignment); `fa` is the redzone around heap allocations—ASan secretly tucks a ring of "no-go zone" around every block of memory you `new`, and the moment you read into `fa`, that's a heap out-of-bounds; `fd` is already-`free`'d memory, and touching it is use-after-free; `f1`/`f2` are redzones for stack objects. + +Look back at the shadow dump from the earlier error: + +```text +=>0x72175e1f0000: f1 f1 f1 f1 00[f2]f2 f2 00 00 00 00 f3 f3 f3 f3 +``` + +`00` is the body of `buf` (8 bytes, 1 shadow byte), and the `[f2]` right after it is the mid redzone between stack objects. The address of our out-of-bounds read lands exactly on this `f2`—ASan spots it at a glance. This is why the shadow memory mechanism is precise down to the byte. + +### Third: runtime library + quarantine + +Instrumentation and a shadow region still aren't enough; someone has to **fill this ledger**. The `new`/`delete`, `malloc`/`free` functions, the ASan runtime library (`libasan`) replaces them wholesale, with its own versions. On every allocation, the runtime paints redzones in the shadow region; on every free, it marks the corresponding shadow region as `fd`. + +There's another key design here called **quarantine**. Freed memory isn't immediately returned to the system for reuse; ASan tosses it into a quarantine queue to sit for a while. Why? Because with use-after-free, if you `free` and it's immediately handed back out to someone else, that memory's shadow state flips back to `00`, and later mistaken reads won't be caught. Quarantine holds it for a while, ensuring the "freed" state can be hit by subsequent mistaken accesses. + +Quarantine isn't unbounded, though—the queue has a cap, and once full the oldest freed memory is truly reclaimed in FIFO order. So ASan's detection of use-after-free isn't 100% either: if the quarantine window has already slid past and the memory has been reallocated, that particular mistaken read won't be caught. But combined with adequate test coverage, the vast majority of UAFs get nailed. + +### The cost: 2-4x overhead, why it's still worth it + +Add the three pieces up and ASan's typical overhead is **2-4x slowdown in runtime, 3-5x in memory** (the shadow region takes 1/8, plus redzones and quarantine). Sounds like a lot, but it depends on what you compare against. + +The traditional memory-checking tool Valgrind (Memcheck) uses **dynamic binary instrumentation** (DBI): it doesn't recompile your program, instead at runtime it translates every machine instruction into its own intermediate representation, analyzes each one, then executes. High precision, no recompile, but the cost is a 20-50x slowdown. A test that originally took 1 second takes half a minute under Valgrind, which often makes it impossible to fold into daily CI. + +ASan **front-loads the analysis cost to compile time** (CTI); at runtime it only does table lookups, so it can keep overhead at 2-4x. That magnitude means you can **leave ASan on permanently** in development and CI to run the full test suite, instead of remembering to manually fire Valgrind occasionally. This is ASan's most fundamental advantage over Valgrind: **it's affordable**. + +::: warning Valgrind isn't installed on this machine +All the ASan/UBSan output in this article was really run on my machine (GCC 16.1.1 / Clang 22, WSL2). Valgrind isn't installed in this environment (`which valgrind` → not found), so this article doesn't show Valgrind output. If you need it, on Debian/Ubuntu just `apt install valgrind`; usage is in the valgrind section of vol1's [C dynamic memory management](../../vol1-fundamentals/c_tutorials/14-dynamic-memory.md). Nail down the essential difference between the two: **ASan is compile-time `-fsanitize=address` (CTI), Valgrind is runtime `valgrind ./prog` (DBI)**. +::: + +## The tool family: five sanitizers, each minding one class + +ASan is really just one member of a family. The toolset was first implemented by Google engineers as patches to GCC and Clang, and later became standard in mainstream compilers. The family has five members, each watching for a specific class of error: + +| Tool | Compile switch | What it catches | Typical overhead | +|------|---------|--------|---------| +| **ASan** (AddressSanitizer) | `-fsanitize=address` | Out-of-bounds read/write, use-after-free, double-free, stack/global overflow | 2-4x slowdown | +| **LSan** (LeakSanitizer) | `-fsanitize=leak` | Memory leaks (unfreed heap memory at program exit) | Nearly zero overhead | +| **MSan** (MemorySanitizer) | `-fsanitize=memory` | Reads of uninitialized memory (use of uninitialized value) | ~3x slowdown | +| **TSan** (ThreadSanitizer) | `-fsanitize=thread` | Data races, deadlocks | 5-15x slowdown | +| **UBSan** (UndefinedBehaviorSanitizer) | `-fsanitize=undefined` | Undefined behavior (signed overflow, null-pointer dereference, shift out of range, etc.) | Configurable; most sub-checks are cheap | + +Of the five, ASan is the workhorse and is almost mandatory for daily development; LSan is enabled by default together with ASan (on supported GCC/Clang environments); MSan is only fully usable under Clang and must be built with the **entire program** compiled as the MSan version (libc included, otherwise you drown in false positives); TSan is specifically for concurrency, covered in vol5's [Concurrency debugging techniques](../../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md); UBSan is "the finisher", cheap and composable with others. + +### ASan and TSan are mutually exclusive: one iron rule + +These five tools don't combine freely. The most important constraint: **ASan and TSan cannot be enabled at the same time**. ASan needs its own shadow-memory layout, TSan needs its own, and the two mechanisms clash. The compiler rejects you outright at compile time: + +```text +$ g++ -std=c++20 -fsanitize=address,thread -g conflict.cpp -o conflict +cc1plus: error: '-fsanitize=thread' is incompatible with '-fsanitize=address' +``` + +The error is blunt. The engineering consequence: in a project's CI, memory-error detection and data-race detection need **two separate builds**—one with ASan, one with TSan—each running the tests once. vol5's TSan article covers this "dual build" practice in detail; here we just remember the conclusion. + +As for MSan, it's incompatible with both ASan and TSan (it requires all code to "cleanly" go through its own uninitialized-tracking), and it only supports Clang, so it sees the least real-world use. LSan and UBSan are the two "all-match": LSan is nearly zero-overhead and can stay on permanently, and most UBSan sub-checks can run alongside ASan. + +## Hands-on: ASan catches three classic errors + +Theory alone isn't satisfying. Let's write one minimal example for each of the three classes of memory errors that trip up C++ the most, and let ASan catch them one by one. All three below are real runs from my machine. + +### Heap use-after-free + +Smart pointers block most UAFs, but as long as a project still has raw pointers and C-style APIs, you can never fully close this hole. A minimal example—free a `unique_ptr` and then read through the raw pointer it previously handed out: + +```cpp +// uaf.cpp — use-after-free +// Platform: host Standard: C++20 +// Build: g++ -std=c++20 -O1 -fsanitize=address -g uaf.cpp -o uaf +#include +#include + +int main() +{ + auto p = std::make_unique(42); + int* raw = p.get(); // grab the raw pointer + p.reset(); // free it here — raw immediately becomes a dangling pointer + std::printf("Value read through dangling pointer: %d\n", *raw); // use-after-free +} +``` + +Run with ASan: + +```text +================================================================= +==37082==ERROR: AddressSanitizer: heap-use-after-free on address 0x7a948abe0010 ... +READ of size 4 at 0x7a948abe0010 thread T0 + #0 0x... in main uaf.cpp:12 + +0x7a948abe0010 is located 0 bytes inside of 4-byte region [0x7a948abe0010,0x7a948abe0014) +freed by thread T0 here: + #0 0x... in operator delete(void*, unsigned long) (/usr/lib/libasan.so.8+0x12e4c1) + ... + #4 0x... in main uaf.cpp:11 + +previously allocated by thread T0 here: + #0 0x... in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341) + ... + #2 0x... in main uaf.cpp:9 + +SUMMARY: AddressSanitizer: heap-use-after-free uaf.cpp:12 in main +``` + +This report is where ASan is most valuable. It doesn't just tell you "the read at line 12 is a use-after-free"; it also gives you **the two-history of that memory**: allocated by `make_unique` at `uaf.cpp:9`, freed by `reset` at `uaf.cpp:11`. Stare at those two lines and the bug's causal chain is complete, which is exactly the value of the quarantine + redzone mechanism: freed memory is marked `fd` instead of being reclaimed immediately, so later mistaken reads can hit it. + +That `[fd]` in the shadow dump is the smoking gun: + +```text +=>0x7a948abe0000: fa fa[fd]fa fa fa fa fa ... +``` + +`fd` = freed heap region. That's the payoff of ASan's "ledger". + +### Global buffer overflow + +Global/static variables get redzone protection too. An out-of-bounds access on a global array, ASan catches it just the same: + +```cpp +// global_oob.cpp — global array out-of-bounds +// Build: g++ -std=c++20 -O1 -fsanitize=address -g global_oob.cpp -o global_oob +#include +int g[4] = {1, 2, 3, 4}; +int main() { std::printf("g[5] = %d\n", g[5]); } +``` + +```text +==38356==ERROR: AddressSanitizer: global-buffer-overflow on address 0x63ca65acd074 ... +SUMMARY: AddressSanitizer: global-buffer-overflow global_oob.cpp:5 in main +``` + +The error type is clearly `global-buffer-overflow`. ASan encodes the three regions—stack, heap, global—with different redzones (`f1`/`f2` stack, `fa` heap, `f9` global), so you can tell at a glance which kind of storage the overflow happened on. + +::: warning On the "Clang 11 needed for global OOB" claim +Some older material says "detecting global-variable overflow requires Clang 11 or later". The history: early ASan had incomplete redzone support for globals, and Clang 11 introduced ODR indicators (`-fsanitize-address-use-odr-indicator`) and other improvements that solidified global detection. But **today** (GCC 8.3+ / mainstream Clang) detection of global overflow is on by default and works out of the box; the example above on my GCC 16.1.1 was caught in one shot with default config. So this "version threshold" is obsolete for current toolchains—don't be misled by old material. +::: + +### Leaks: LSan finalizes at exit + +Finally, memory leaks. LSan works differently from the previous ones: it doesn't report errors during execution; instead, when `main` returns and the program is about to exit, it scans all the still-"alive" heap allocations and flags the ones with no references and no matching free. A minimal example that leaks on purpose: + +```cpp +// leak.cpp — intentional leak +// Platform: host Standard: C++20 +// Build: g++ -std=c++20 -O1 -fsanitize=address -g leak.cpp -o leak +#include +#include +int main() +{ + int* p = (int*)std::malloc(sizeof(int) * 4); // grab some heap memory + p[0] = 42; + std::printf("ptr = %p\n", (void*)p); // let the pointer escape, prevent the whole thing being optimized away + // no free; the memory p points to leaks when the program exits +} +``` + +Run with ASan (GCC 16.1.1 / WSL2, LSan enabled by default with ASan): + +```text +ptr = 0x730c4cbe0010 +================================================================= +==364484==ERROR: LeakSanitizer: detected memory leaks + +Direct leak of 16 byte(s) in 1 object(s) allocated from: + #0 0x... in malloc (/usr/lib/libasan.so.8+0x12c161) + #1 0x... in main leak.cpp:8 + ... + +SUMMARY: AddressSanitizer: 16 byte(s) leaked in 1 allocation(s). +``` + +Note: the report is printed **after** `main` returns—that's exactly LSan's "finalize at exit" behavior. vol1's [Dynamic memory management](../../vol1-fundamentals/ch12/02-new-delete.md) has an equivalent example you can compare against. + +::: warning LSan's "silent exit" trap +On mainstream Linux (GCC 16.1.1 / Clang 22), LSan is enabled by default with ASan, and the example above is stably caught on my machine. But watch out for a real trap: **leaks are only scanned when the process exits normally**. If your program is killed by `SIGKILL`, or calls `_exit` bypassing the `atexit` hooks, or in some containers/sandboxes where LSan's exit hooks don't fire, the leak report **silently disappears**: the program looks "fine", but actually LSan never got the chance to scan. + +How to deal: confirm the process exited via normal return; force it on with `ASAN_OPTIONS=detect_leaks=1` if needed; long-running services (that never exit) can't use LSan's "finalize at exit" model and need Valgrind massif or heap sampling instead. Don't assume "no report means no leak" from LSan. +::: + +## UBSan: turning "undefined behavior" from silence into reports + +With the ASan family's workhorse covered, let's look at the finisher, UBSan. C/C++ has a feature that spikes the blood pressure: **undefined behavior (UB)**. The compiler's attitude toward UB is "since the standard doesn't say what happens, I'll assume it doesn't happen and optimize freely". The consequence is that signed integer overflow, shift out of range, null-pointer dereference—these errors **often look like they're running fine**, until one day you enable `-O2`, switch compiler versions, and the optimizer, reasoning from "this won't overflow", makes an aggressive transformation and the program suddenly computes an absurd result. + +UBSan's idea: insert a runtime check next to every operation that could produce UB; the moment one actually happens, print a `runtime error: ...` report immediately (by default it doesn't abort; you can configure it to). The overhead is small, and many sub-checks can live alongside ASan permanently. + +A minimal example, cramming three classic UBs in at once: + +```cpp +// ubsan.cpp — UBSan catches undefined behavior +// Platform: host Standard: C++20 +// Build: g++ -std=c++20 -O1 -fsanitize=undefined -g ubsan.cpp -o ubsan +#include +#include + +int main() +{ + int arr[4]{1, 2, 3, 4}; + int idx = 10; + std::printf("Out-of-bounds subscript arr[10] = %d\n", arr[idx]); // subscript out of bounds + + int max = std::numeric_limits::max(); + std::printf("Signed overflow: %d\n", max + 1); // signed integer overflow + + int shift = 32; + std::printf("Left shift by 32: %d\n", 1 << shift); // shift amount >= width +} +``` + +Run with UBSan: + +```text +ubsan.cpp:11:55: runtime error: index 10 out of bounds for type 'int [4]' +ubsan.cpp:11:16: runtime error: load of address 0x7ffe8a0525c8 with insufficient space for an object of type 'int' +ubsan.cpp:14:16: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' +ubsan.cpp:17:42: runtime error: shift exponent 32 is too large for 32-bit type 'int' +``` + +All three UBs caught, pinned to `file:line:column`. The list of UBs UBSan covers is long; the common ones include: + +- **Arithmetic**: signed integer overflow/underflow, divide by zero; +- **Shift**: shift amount negative or greater than/equal to width, left shift changing the sign bit; +- **Memory/pointer**: null-pointer dereference, misaligned memory access, object size mismatch (accessing through a pointer of the wrong type); +- **Array**: subscript out of bounds (`-fsanitize=bounds`, this overlaps with ASan's overflow detection but with a different focus: ASan looks at redzones, UBSan looks at compile-time-known array sizes). + +UBSan's overhead depends on which sub-checks you enable. `-fsanitize=undefined` is a set of default sub-checks, most very light; the expensive one is `-fsanitize=integer` (unsigned overflow counts too, big overhead, many false positives, use cautiously in production). Daily recommendation: turn on `-fsanitize=undefined` together with ASan—low cost, high payoff. + +## Choosing: facing a memory bug, which tool? + +By now all five siblings have made an appearance. The question: when you're actually sitting in front of a weird bug, what order do you pick tools in? Let's locate by "symptom": + +- **Crashes on first run / segfault / intermittent crashes**: turn on ASan first and reproduce the test. Overflow, UAF, double-free are the most common causes of segfaults, and ASan catches them all in one pass. +- **Intermittent wrong results / bizarre values crossing functions**: suspect UAF or a data race. Rule out UAF with ASan first; if ASan reports nothing, build a separate TSan version to check for data races (remember the two are mutually exclusive and can't run together). +- **Absurd computed values / behavior changes under `-O2`**: almost certainly UB, go straight to UBSan. +- **Reads "normal-looking" garbage values, behavior depends on uninitialized memory**: MSan (note: Clang only, requires whole-program compilation). +- **Process eats more and more memory / suspect a leak**: LSan (report at exit), or for long-running services use Valgrind massif / heap sampling. + +One engineering practice: **keep two builds resident in CI**—`ASan+UBSan` in one, `TSan` in the other, run on every commit. The overhead is acceptable (ASan+UBSan is in the 2-4x range), and in exchange you pin down the most expensive class of bugs—"intermittent crashes after launch"—before they ever leave the door. + +::: warning ASan isn't a silver bullet +ASan is powerful, but it has several unavoidable limitations you must keep in mind. + +First, **it only catches paths that are actually executed**. CTI is runtime detection; code that doesn't run doesn't trigger checks. If your test coverage is insufficient and some overflow path is never triggered, ASan won't catch it, which is exactly why ASan needs to be combined with good test cases and even fuzzing: fuzzing is responsible for flushing out rare paths, and ASan is responsible for reporting errors on those paths the moment they occur. + +Second, **it only catches memory-class errors**. Logic errors (wrong computation), concurrency errors (data races), integer-overflow-type UB—ASan doesn't touch: the last goes to UBSan, the second to TSan. Don't expect one flag to solve everything. + +Third, **don't enable it in production**. A 2-4x slowdown and extra memory are disastrous under production load. ASan/UBSan/TSan are all **dev/test/CI-stage** tools; release builds must strip these flags. + +Fourth, **it has false-positive boundaries**. Some custom stack-unwinding mechanisms (`swapcontext`, `vfork`) make ASan's shadow-region judgment go wrong and report false positives. The line `HINT: this may be a false positive if your program uses some custom stack unwind mechanism` in the report is a reminder of exactly this. +::: + +Real memory safety is held by RAII, smart pointers, `std::span`, range-based `for`—the means that **make overflow and dangling pointers un-writable at the language level**; those are the topics of vol1 and vol3. The value of the ASan toolset is for the transition period: before you've replaced every raw pointer, before third-party C libraries are wrapped by modern facades, it's that "last line of defense" that surfaces lurking memory bugs at the development stage instead of letting them blow up at 3am in production. + +## References + +- [AddressSanitizer · google/sanitizers Wiki](https://github.com/google/sanitizers/wiki/AddressSanitizer) — official ASan writeup; the authoritative source for the shadow-memory mechanism, the 1:8 mapping, and the 2x overhead +- [Clang: AddressSanitizer](https://clang.llvm.org/docs/AddressSanitizer.html) — Clang-side ASan docs, including `-fsanitize-address-use-odr-indicator` and other global-detection evolution +- [Clang: ThreadSanitizer](https://clang.llvm.org/docs/ThreadSanitizer.html) — TSan docs; the source of ASan↔TSan mutual exclusivity (see vol5's concurrency debugging article) +- [Clang: UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) — UBSan sub-check list and overhead +- [Valgrind User Manual](https://valgrind.org/docs/manual/manual.html) — DBI method and Memcheck/Helgrind, the 20-50x overhead comparison diff --git a/documents/en/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md b/documents/en/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md new file mode 100644 index 000000000..0908fd283 --- /dev/null +++ b/documents/en/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md @@ -0,0 +1,493 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: Pull apart the responsibilities of the Valgrind quintet (memcheck/callgrind/cachegrind/helgrind+drd/massif), + compile and run six classic memory errors under ASan for real, and explain the essential difference between + the "dynamic binary translation" and "compile-time shadow-memory instrumentation" routes. +difficulty: advanced +order: 4 +platform: host +prerequisites: +- Dynamic memory management (new/delete and smart pointers) +- C dynamic memory management (malloc/free and valgrind) +reading_time_minutes: 27 +related: +- The ASan tool family and memory safety (shadow memory, Heartbleed, and sanitizer selection) +- Concurrency debugging techniques (TSan / Helgrind in depth) +- Dynamic memory management +tags: +- host +- cpp-modern +- advanced +- 内存安全 +- 调试 +- 内存管理 +title: 'Valgrind vs ASan: JIT interpretation vs compile-time instrumentation' +translation: + source: documents/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md + source_hash: 4a96815f8c688c7e6e9aa1058760e641068a3bf284de6778723c5c2652cafcfe + translated_at: '2026-07-06T14:30:00+00:00' + engine: manual + token_count: 6200 +--- +# Valgrind vs ASan: JIT interpretation vs compile-time instrumentation + +> PS: This part is migrated from my college notes, and every key conclusion has been re-verified by actually compiling and running on this machine with GCC 16.1.1 + valgrind 3.25.1. If anything is still off, an Issue or PR is welcome. + +Let's start with something we've probably all done: a piece of C++ code runs fine locally, goes to production, and either crashes intermittently or has its RSS climb until the OOM Killer takes it out. You go back and read the code, the `new`/`delete` pairs all look right, the overflow is only off by a byte or two, and reading the code tells you nothing. This kind of bug has no hope of being caught by eye; you need a tool to "see" every memory access. + +What this article does is split the memory-error-catching tools into two camps by their implementation route, and run them both. One camp is **Valgrind**: the old-school JIT scheme that wraps a "virtual CPU" around your program and interprets it. The other is **AddressSanitizer (ASan)**: a scheme that inserts checking code into your program at compile time and accounts for memory with "shadow memory". The original notes only covered Valgrind and didn't mention ASan at all, which is exactly the route more commonly used in engineering today. This article fills that gap and puts the two routes side by side. + +## 1. Two classes of memory errors, and "why reading the code doesn't help" + +Before we reach for tools, let's sort out the "enemies" we're hunting. Memory errors fall roughly into two classes, and catching them is wildly different in difficulty. + +**Class one: deterministic overflow / use-after-free / double-free.** The signature of these errors is "accessed an address that shouldn't be accessed". They're dangerous, but relatively easy to catch: as long as the tool can mark "which memory is legal and which isn't", the overflow is reported the moment it happens. An off-by-one like `char buf[8]; buf[8] = 'x';`, a dangling pointer like `free(p); return *p;`, all belong here. + +**Class two: uninitialized reads / memory leaks.** These are sneakier. An uninitialized read is "the address is legal but the value is garbage"; the program doesn't crash, it just silently computes wrong. A memory leak is "the address stays legal, it just never gets returned"; the program doesn't crash either, RSS just climbs slowly. You can't catch these two with a "legal address table", you need another mechanism: Valgrind maintains a "has this value been initialized" flag for every byte, and ASan's leak detection (LSan) sweeps the heap at program exit looking for blocks that are "allocated but pointed to by no one". + +The fundamental reason reading the code doesn't work is that both classes of errors **depend on runtime memory state**, not on the literal text of the code. Looking at `*p` alone, you have no idea whether the memory `p` points to at this moment is live or dead, initialized or garbage. That's exactly why we need tools to "record" every allocation, every free, every read/write, turning runtime memory state into an auditable ledger. + +"Recording" is something Valgrind and ASan do via two completely different implementation routes. Let's put the conclusion up front and take them apart one by one. + +| Dimension | Valgrind (memcheck) | AddressSanitizer | +|------|---------------------|------------------| +| How it records | Dynamic binary translation: at runtime, translates each machine instruction into a checked version | Compile-time instrumentation: at compile time, inserts checking code before and after every memory access | +| Recompile needed? | **No**, runs on a stock binary | **Yes**, must recompile with `-fsanitize=address` | +| Runtime overhead | 20-50x slower, 2x+ memory (official wording) | ~2x slower, ~3x memory | +| Platforms | Linux/macOS (FreeBSD/Solaris), x86/ARM, etc. | GCC/Clang/MSVC, all platforms, including Windows | +| Who catches uninitialized reads | memcheck natively (V-bit) | ASan **cannot**, needs separate `-fsanitize=memory` (MSan, Clang only) | +| Catches stack overflow | Yes (needs full `-tool=memcheck`) | Catches stack/global redzones by default, `detect_stack_use_after_return` catches stack-return-then-access | + +Keep this table in mind. Next we start from "the source pain" and see how the Valgrind route works. + +## 2. Valgrind: wrap a "virtual CPU" to JIT-interpret your program + +### 2.1 What it's actually doing + +Valgrind is essentially a **dynamic binary translation (DBT) framework**. It's not an ordinary detection library; it stuffs your entire program into a "virtual CPU" and runs it there. When you type `valgrind ./myprog`, what really happens is: Valgrind intercepts every one of your machine instructions, **just-in-time translates** it into a new sequence that "does the original work + incidentally records memory state", and only then executes. So your program isn't running directly on the CPU; it's being "interpreted" inside Valgrind's core. + +That's the source of its famous side effect: **20 to 50 times slower**, and memory usage more than doubled. The official Valgrind manual says it outright: + +> Programs running under Valgrind run significantly more slowly, and use much more memory -- e.g. more than twice as much as normal under the Memcheck tool. + +Put it in perspective: a program that runs in 1 second might take half a minute under memcheck. So Valgrind isn't something you keep running during daily development; it's for "this program really has a memory bug, I'm setting aside time specifically to hunt it down". + +This JIT-interpretation architecture has one huge advantage, and it's the fundamental reason Valgrind hasn't been obsoleted yet: **no recompilation needed**. You've got a binary from ten years ago whose source you can't even find all of, you suspect it leaks, `valgrind ./old_relic` and you're running. ASan can't do this; ASan must recompile from source. That's the hardest difference between the two routes. + +### 2.2 The quintet: one framework, five tools + +The essence of Valgrind is "framework + tools". The core handles translation and scheduling; the specifics of "what to record, what to report" go to a pluggable tool. `--tool=` picks one, which is to say you pick a pair of "checking glasses". Let's have a look; the manual lists these core tools: + +**Memcheck**: the memory error detector, Valgrind's default tool, and the one most people actually mean when they say "use Valgrind to check memory". Its full catch list (quoted from manual section 4.1) is: accessing memory you shouldn't (heap overflow, stack-top overflow, use-after-free), using uninitialized values, wrong frees (double-free, mismatched `malloc` with `delete`), `memcpy` source/dest overlap, passing "suspicious" negative sizes to allocation functions, `realloc` with 0, alignment not a power of two, and memory leaks. In one sentence: memcheck nets nearly all the commonest memory errors in C/C++ programs. + +**Callgrind**: call graph + cache/branch prediction profiler. It doesn't need special compile-time options (but `-g` is recommended); at the end of the run it writes analysis data to a file, then you turn it into human-readable form with `callgrind_annotate`. Use it to find "which function gets called how many times, what the call relationships look like". + +**Cachegrind**: cache profiler. It simulates the CPU's I1/D1/L2 caches, pinpoints exactly where cache hits and misses happen in your program, and can tell you how many misses and how many instructions each line, each function, each module produced. Use it when you want to press cache performance. + +**Helgrind and DRD**: these two are both **thread error detectors**, catching data races, lock-order inconsistencies, and POSIX thread API misuse. The original notes called Helgrind "still experimental", and that claim **has been outdated for a long time**: in the 2026 official manual both Helgrind and DRD are formally listed stable tools, each with its own chapter (manual chapters 7 and 8), not experimental. Worth mentioning too: the notes only mentioned Helgrind and **missed DRD**: the two have the same goal (catching thread bugs) but different algorithms, and DRD is usually faster and handles some scenarios (lots of small objects, Boost.Thread, OpenMP) better. Thread-error hunting is covered in depth with TSan/Helgrind in vol5's [Concurrency debugging techniques](../../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md); this article won't repeat it, just remember "for thread bugs reach for helgrind/drd, or the more modern TSan". + +**Massif**: heap profiler. It measures how much memory your program actually eats on the heap, giving you the growth curve of heap blocks, heap management structures, and the stack. Use it to "slim down" a program or find the big RSS consumers. + +> **An easily missed division of labor**: memcheck catches "right or wrong" (can this memory be accessed, is it initialized), callgrind/cachegrind/massif catch "fast or slow / much or little" (performance and usage). Newcomers often conflate them and think Valgrind is for finding memory leaks, when that's only one tool's job (memcheck's). The performance-analysis tools (callgrind/cachegrind/massif) and ASan aren't even in the same race; ASan doesn't touch performance profiling. + +### 2.3 memcheck's dual-table principle: A-bit and V-bit + +How does memcheck catch so many kinds of memory errors? The key is that it maintains two "shadow tables" covering the entire process address space. Manual section 4.5 lays this out clearly. + +**Valid-Address table (A-bit).** Every byte of the process address space has 1 bit recording "can this address currently be read or written". When you `malloc` a block, A-bit marks those bytes "valid"; when you `free`, it flips back to "invalid". When an instruction is about to read or write a byte, it first checks that byte's A-bit; if it says invalid, that's an illegal access and memcheck reports it on the spot. This layer catches: overflow, use-after-free, accessing unallocated regions. + +**Valid-Value table (V-bit).** Every byte of the process address space has 8 bits; every CPU register also has a corresponding bit vector. They record "whether this value has been initialized yet". Freshly `malloc`'d memory has all V-bits "uninitialized"; once an instruction writes a defined value into it, the corresponding bytes' V-bits flip to "initialized". The key design is that **V-bits propagate with the value**: read an uninitialized value from memory into a register and the V-bit moves into the register too; do arithmetic on it and the result's V-bit is "uninitialized" as well. But memcheck doesn't report the moment it reads an uninitialized value; it only reports when that value is "used to affect program output, or used to compute an address". This delay is deliberate, to avoid a screen full of false positives. + +Putting the two tables together: A-bit governs "is the address legal", V-bit governs "is the value clean". The former catches overflow/UAF, the latter catches uninitialized reads. Double-free and alloc-dealloc mismatch are caught via a ledger memcheck itself maintains, recording "which allocator was this memory requested from". + +The cost of this "every byte accounted for" mechanism is the memory doubling mentioned earlier; A-bit and V-bit themselves take up space. + +## 3. ASan: compile-time instrumentation + shadow memory + +### 3.1 The idea is exactly reversed + +ASan's implementation route is the reverse of Valgrind's. It does **not** wrap a virtual CPU around your program; instead, **at compile time** it inserts checking code into your program. You add `-fsanitize=address`, and the compiler inserts a small piece of code before and after every memory read/write: that code consults a "shadow memory" table, decides whether this access is legal, and if not, reports an error and aborts. + +So ASan's checking is "the program checks itself", not "an outside virtual CPU checks for it". That explains the huge gap in overhead between the two routes: ASan only spends a few extra instructions on the instrumented accesses, with no "translate the whole instruction stream" cost, so it's **only about 2x slower** (Valgrind is 20-50x); the price is that you must recompile, and the checking covers only the instrumented code. Dynamically loaded third-party `.so` files not built with ASan are out of its reach (Valgrind can, because it intercepts at the instruction level across the board). + +### 3.2 Shadow memory: the 8-byte to 1-byte encoding + +ASan's core mechanism is shadow memory (a full teardown of shadow memory is in this volume's [ASan tool family](./03-asan-family-and-memory-safety.md), which also covers how it plugged Heartbleed-style over-read holes). It maps the process's entire address space into a shadow table in 8-byte groups, with every 8 application bytes corresponding to 1 shadow byte. The value of that shadow byte has a precise meaning; I'll paste the legend straight from a real run on my machine (the output that follows is real): + +```text +Shadow byte legend (one shadow byte represents 8 application bytes): + Addressable: 00 + Partially addressable: 01 02 03 04 05 06 07 + Heap left redzone: fa + Freed heap region: fd + Stack left redzone: f1 + Stack mid redzone: f2 + Stack right redzone: f3 + Stack after return: f5 + Stack use after scope: f8 + Global redzone: f9 + Global init order: f6 + Poisoned by user: f7 + Container overflow: fc + Array cookie: ac + Intra object redzone: bb + ASan internal: fe + Left alloca redzone: ca + Right alloca redzone: cb +``` + +Translating the elegance of this encoding: + +- Shadow byte `00`: all 8 bytes are addressable; +- `01`-`07`: only the first N bytes are addressable, the rest is overflow redzone. This is exactly how ASan catches off-by-one: it paves a "redzone" around every heap block, stack frame, and global variable, and the redzone's shadow bytes are marked `fa`/`f9` and friends. Step into the redzone, the instrumentation checks the shadow byte, sees it's not "addressable", and reports on the spot; +- `fd`: this memory has been `free`'d; any further access is use-after-free, caught red-handed. + +In other words, ASan takes a different path from memcheck's "byte-by-byte address legality accounting": it paves redzones around legal regions and uses redzones to define boundaries. This mechanism is extremely effective for overflow and UAF, but **it has no V-bit**, so ASan cannot catch uninitialized reads. That gap has to be filled by MSan (MemorySanitizer, `-fsanitize=memory`), and MSan is Clang-only; **GCC still doesn't support `-fsanitize=memory` as of 16.1.1** (verified on this machine: `unrecognized argument`). That's a real shortcoming of the ASan route versus memcheck. + +> **Pitfall warning**: ASan and the other sanitizers are in a "one class at a time" relationship. `-fsanitize=address` and `-fsanitize=thread` (TSan) **cannot be turned on together**: their assumptions about shadow-memory layout differ, and mixing them either errors out or behaves erratically. So turn on ASan when hunting memory errors, turn on TSan separately when hunting concurrency data races, and don't try to "one-shot" it. For how to hunt thread errors, see [vol5's concurrency debugging article](../../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md). + +## 4. Run it: six classic errors, real ASan output + +Theory alone isn't satisfying. We take the six classes of "screenshots only, no source" classic errors from the original notes, write them all out as real code, and compile and run them on this machine (GCC 16.1.1) with `g++ -std=c++20 -O0 -g -fsanitize=address,undefined`. Every output chunk below is something I **really ran**, not hand-fabricated. + +First, pack all six errors into one program: + +```cpp +// cases.cpp — six classic memory errors, each reproduced with ASan +// Build: g++ -std=c++20 -O0 -g -fsanitize=address,undefined cases.cpp -o cases +// Run: ./cases <1..6> no arg runs only the leak +#include +#include + +// 1. Using uninitialized memory (ASan can't catch this, needs MSan) +int case_uninit() { + int* p = (int*)malloc(sizeof(int)); // contents are garbage + int v = *p; // reads a garbage value, but the address is legal + free(p); + return v; +} + +// 2. use-after-free +int case_uaf() { + int* p = (int*)malloc(sizeof(int)); + *p = 42; + free(p); + return *p; // reading freed memory +} + +// 3. Heap buffer overflow (tail read/write) +int case_oob() { + int* a = (int*)malloc(4 * sizeof(int)); // only a[0..3] + a[4] = 99; // 5th element, out of bounds + int r = a[4]; + free(a); + return r; +} + +// 4. Memory leak (forgot to free) +void case_leak() { + int* p = (int*)malloc(sizeof(int)); + *p = 7; // deliberately don't free +} + +// 5. malloc paired with delete (alloc/dealloc mismatch) +void case_mismatch() { + int* p = (int*)malloc(sizeof(int)); + *p = 5; + delete p; // malloc should pair with free +} + +// 6. Double free +void case_double_free() { + int* p = (int*)malloc(sizeof(int)); + free(p); + free(p); // second free +} + +int main(int argc, char** argv) { + if (argc < 2) { case_leak(); puts("done: leak only"); return 0; } + switch (atoi(argv[1])) { + case 1: printf("uninit=%d\n", case_uninit()); break; + case 2: printf("uaf=%d\n", case_uaf()); break; + case 3: printf("oob=%d\n", case_oob()); break; + case 4: case_leak(); puts("done leak"); break; + case 5: case_mismatch(); puts("done mismatch"); break; + case 6: case_double_free(); puts("done double-free"); break; + default: puts("usage: ./cases [1..6]"); break; + } + return 0; +} +``` + +Note this build line, every case below uses it: `g++ -std=c++20 -O0 -g -fsanitize=address,undefined cases.cpp -o cases`. `-g` makes ASan reports carry line numbers; `-O0` keeps the optimizer from folding away our overflow access (at higher optimization levels, a "write-then-immediately-read" like `a[4]` may get folded; ASan still catches it, but `-O0` is cleanest for debugging). + +### 4.1 Using uninitialized memory — ASan's blind spot + +Run case 1 and watch ASan's reaction: + +```text +$ ./cases 1 +uninit=-1094795586 +``` + +**ASan says nothing**, and the program returns a garbage value (`-1094795586`) normally. That's the shortcoming mentioned earlier: this memory address is legal (it came from `malloc`), ASan's shadow memory marks it "addressable", and there's no V-bit to judge "has this value been initialized". memcheck catches this error (via V-bit), ASan doesn't; to catch it you have to switch to MSan (`-fsanitize=memory`, Clang only). That's a **substantive capability gap** between the two routes; it's not about which is stronger, it's that each minds its own patch. + +### 4.2 use-after-free — the redzone bites on the spot + +Run case 2: + +```text +$ ./cases 2 +================================================================= +==44083==ERROR: AddressSanitizer: heap-use-after-free on address 0x799329de0010 ... +READ of size 4 at 0x799329de0010 thread T0 + #0 ... in case_uaf() /tmp/asand/cases.cpp:20 + #1 ... in main /tmp/asand/cases.cpp:56 + ... + +0x799329de0010 is located 0 bytes inside of 4-byte region [0x799329de0010,0x799329de0014) +freed by thread T0 here: + #0 ... in free ... + #1 ... in case_uaf() /tmp/asand/cases.cpp:19 + ... + +previously allocated by thread T0 here: + #0 ... in malloc ... + #1 ... in case_uaf() /tmp/asand/cases.cpp:17 + ... + +SUMMARY: AddressSanitizer: heap-use-after-free /tmp/asand/cases.cpp:20 in case_uaf() +``` + +(I trimmed the build-id and other irrelevant lines above; all the key info is there.) ASan gives you three pieces: **where this illegal read happened** (line 20 of `case_uaf()`, the `return *p`), **where this memory was freed** (line 19), and **where it was originally `malloc`'d** (line 17). Put together, the whole causal chain of "allocate -> free -> access again" is in full view. That's the credit of the redzone mechanism plus "after `free` the shadow byte flips to `fd`": once freed, that memory is no longer "addressable" to ASan, and any further touch trips a report. + +### 4.3 Heap buffer overflow — the tail redzone + +Run case 3 (`a[4]` overflows; `a` only holds 4 ints): + +```text +$ ./cases 3 +================================================================= +==44191==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7288a7be0020 ... +WRITE of size 4 at 0x7288a7be0020 thread T0 + #0 ... in case_oob() /tmp/asand/cases.cpp:26 + ... + +0x7288a7be0020 is located 0 bytes after 16-byte region [0x7288a7be0010,0x7288a7be0020) +allocated by thread T0 here: + #0 ... in malloc ... + #1 ... in case_oob() /tmp/asand/cases.cpp:25 + ... +``` + +`located 0 bytes after 16-byte region`: this memory is 16 bytes (4 ints), and the access lands exactly on the **first byte after its end**, i.e., the start of the tail redzone. That's how ASan catches off-by-one: right behind the block returned by `malloc` is a ring of redzone, whose shadow bytes are `fa` (heap left redzone, really poison around the heap block); `a[4]` falls into the redzone, the instrumentation checks the shadow byte, sees it isn't `00`, and reports on the spot. + +> **A point the notes raise but is easy to misread**: the notes say "Valgrind doesn't check statically-allocated arrays". That's true for old memcheck (stack/global array overflow was historically a memcheck weak spot), but **ASan is different**: ASan paves redzones around stack arrays and global variables too (shadow bytes `f1`-`f3` are stack redzones, `f9` is the global redzone), and it catches stack array overflow cleanly. So "static array overflow can't be caught" holds for Valgrind, not for ASan. Don't conflate the two tools' limitations. + +### 4.4 Memory leak — LSan sweeps the heap at program exit + +Run case 4 (`./cases 4`, deliberately doesn't free): + +```text +$ ./cases 4 + +================================================================= +==44296==ERROR: LeakSanitizer: detected memory leaks + +Direct leak of 4 byte(s) in 1 object(s) allocated from: + #0 ... in malloc ... + #1 ... in case_leak() /tmp/asand/cases.cpp:34 + #2 ... in main /tmp/asand/cases.cpp:58 + ... + +SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s). +``` + +Note that the error is from **`LeakSanitizer`**, not ASan proper: LSan is the leak detector bundled with ASan by default, and it sweeps the entire heap **when the program exits normally**, pulling out blocks that are "allocated but pointed to by no one". What it reports is the "definitely lost" entry in the "still reachable / definitely lost" classification. This is the same leak-detection idea as memcheck (both sweep the heap at exit); LSan just happens to be part of the ASan toolchain. + +> **What about daemons?** LSan by default only sweeps when the program `exit`s; a long-running daemon/service doesn't exit on its own. You can signal it to dump mid-run: `ASAN_OPTIONS=abort_on_error=0:detect_leaks=1` combined with `kill`, or use LSan's `__lsan_do_leak_check()` API to trigger a scan from inside the code. On the Valgrind side the equivalent move is to `kill` the memcheck process from another terminal so it prints its output (the notes mentioned this trick). + +### 4.5 malloc paired with delete — alloc/dealloc mismatch + +Run case 5: + +```text +$ ./cases 5 +================================================================= +==44300==ERROR: AddressSanitizer: alloc-dealloc-mismatch (malloc vs operator delete) ... + #0 ... in operator delete(void*, unsigned long) ... + #1 ... in case_mismatch() /tmp/asand/cases.cpp:42 + ... + +0x71a3249e0010 is located 0 bytes inside of 4-byte region [0x71a3249e0010,0x71a3249e0014) +allocated by thread T0 here: + #0 ... in malloc ... + #1 ... in case_mismatch() /tmp/asand/cases.cpp:40 + ... +``` + +`alloc-dealloc-mismatch (malloc vs operator delete)`: ASan records for every allocation "who requested it", and at deallocation time compares; `malloc` paired with `delete` doesn't match, reported on the spot. memcheck catches the same class (manual 4.2.5 "freed with an inappropriate deallocation function"); the two sides are aligned. + +> **Platform difference note**: this `alloc-dealloc-mismatch` check is **off by default on Windows** (MSVC's ASan, because on Windows `delete` and `free` are often effectively equivalent). Linux/macOS turn it on by default. If you're on Windows and notice this class of error isn't caught, check `ASAN_OPTIONS=alloc_dealloc_mismatch=1`. + +### 4.6 Double free + +Run case 6: + +```text +$ ./cases 6 +================================================================= +==44193==ERROR: AddressSanitizer: attempting double-free on 0x6d0d527e0010 in thread T0: + #0 ... in free ... + #1 ... in case_double_free() /tmp/asand/cases.cpp:49 + ... + +0x6d0d527e0010 is located 0 bytes inside of 4-byte region [0x6d0d527e0010,0x6d0d527e0014) +freed by thread T0 here: + #0 ... in free ... + #1 ... in case_double_free() /tmp/asand/cases.cpp:48 + ... +``` + +`attempting double-free`: after the first `free`, the shadow byte flips to `fd`; on the second `free` of the same address, ASan sees it's already in `fd` state (freed) and rules it a double-free. It even helpfully tells you "the previous free was on line 48". + +### 4.7 Bonus: stack use-after-return + +ASan can also catch something memcheck historically had great trouble with: **a stack frame being accessed after it returns** (the function has returned, but the caller still holds a pointer to a local inside it). This has to be turned on explicitly: + +```cpp +// suar2.cpp +#include +static int* g = nullptr; +void stash() { int local = 0xc0ffee; g = &local; } // store a local's address outward +int main() { stash(); return *g; } // local died when stash returned +``` + +```text +$ g++ -std=c++20 -O0 -g -fsanitize=address suar2.cpp -o suar2 +$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./suar2 +================================================================= +==44702==ERROR: AddressSanitizer: stack-use-after-return on address 0x6da50b8f0020 ... +READ of size 4 at 0x6da50b8f0020 thread T0 + #0 ... in main /tmp/asand/suar2.cpp:4 + ... + +Address 0x6da50b8f0020 is located in stack of thread T0 at offset 32 in frame + #0 ... in stash() /tmp/asand/suar2.cpp:3 + + This frame has 1 object(s): + [32, 36) 'local' (line 3) <== Memory access at offset 32 is inside this variable +HINT: this may be a false positive if your program uses some custom stack unwind mechanism ... +SUMMARY: AddressSanitizer: stack-use-after-return /tmp/asand/suar2.cpp:4 in main +``` + +Note the address `0x6da50b8f0020`: it sits **very far forward** in the process address space (not the normal stack region), because with `detect_stack_use_after_return` on, ASan moves "locals that might be pointed to by escaping pointers" onto a dedicated "fake stack"; when the function returns, that fake-stack region is poisoned, and any further access reports `stack-use-after-return` (shadow byte `f5`). It's off by default because of some overhead and a few false positives (see that HINT). But this kind of "still using stack memory after the function returned" bug is extremely hard to track down, and it's worth knowing the trick exists. + +## 5. Using Valgrind: feed the above errors to memcheck + +With theory covered, let's feed the same `cases.cpp` from section 4 (this time built without `-fsanitize=`, plain compile) into valgrind and see how memcheck reports the same batch of errors, the two dialects face to face; only side-by-side comparison reads clearly. This machine uses valgrind 3.25.1. + +First, build a clean version with `-g` (valgrind doesn't need ASan's instrumentation, but it does need `-g` to give line numbers in the report): + +```bash +g++ -std=c++20 -g -O0 cases.cpp -o cases_plain + +# Most common: full memcheck leak check +valgrind --tool=memcheck --leak-check=full ./cases_plain 4 + +# Go harder: list still-reachable too + follow child processes +valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all --trace-children=yes ./cases_plain +``` + +A few key parameters: `--leak-check=full` does a full leak check (with line numbers); `--show-leak-kinds=all` lists even the "still reachable" blocks (blocks that still have a pointer pointing at them, that in theory could still be freed) (the older `--show-reachable=yes` is an alias, still works but is no longer recommended); `--trace-children=yes` follows child processes from `fork`/`exec`. To switch tools, change `--tool=`: `callgrind`, `cachegrind`, `helgrind`, `drd`, `massif`. + +### 5.1 The same UAF, memcheck's report + +Run case 2 (the same use-after-free from section 4): + +```text +$ valgrind --tool=memcheck --leak-check=full ./cases_plain 2 +==453796== Memcheck, a memory error detector +... +==453796== Invalid read of size 4 +==453796== at 0x40011E9: case_uaf() (cases.cpp:20) +==453796== by 0x4001377: main (cases.cpp:56) +==453796== Address 0x4ee9080 is 0 bytes inside a block of size 4 free'd +==453796== at 0x48529EF: free (vg_replace_malloc.c:989) +==453796== by 0x40011E4: case_uaf() (cases.cpp:19) +==453796== Block was alloc'd at +==453796== at 0x484F8A8: malloc (vg_replace_malloc.c:446) +==453796== by 0x40011CA: case_uaf() (cases.cpp:17) +uaf=42 +... +==453796== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) +``` + +Notice the line numbers: `cases.cpp:20` read, `:19` free, `:17` malloc, **exactly the same** as ASan reported in section 4 (ASan's side also had :20/:19/:17). The same bug, both tools locate it to the same lines; only the dialect differs: + +- ASan says `heap-use-after-free` + `located 0 bytes inside of 4-byte region`; +- memcheck says `Invalid read of size 4` + `Address ... is 0 bytes inside a block of size 4 free'd`. + +memcheck also adds `Block was alloc'd at ... :17`: its A-bit ledger records this memory's "life" (where it was allocated, where freed, now being read again), giving you the whole causal chain at once, the same idea as ASan's "allocated by / freed by" three-part report in two different wordings. + +### 5.2 Leaks: LEAK SUMMARY vs LSan + +Run case 4 (deliberately not freed): + +```text +$ valgrind --tool=memcheck --leak-check=full ./cases_plain 4 +==453446== HEAP SUMMARY: +==453446== in use at exit: 4 bytes in 1 blocks +==453446== total heap usage: 3 allocs, 2 frees, 77,828 bytes allocated +==453446== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1 +==453446== at 0x484F8A8: malloc (vg_replace_malloc.c:446) +==453446== by 0x400123D: case_leak() (cases.cpp:34) +==453446== by 0x40013B5: main (cases.cpp:58) +==453446== LEAK SUMMARY: +==453446== definitely lost: 4 bytes in 1 blocks +==453446== indirectly lost: 0 bytes in 0 blocks +==453446== possibly lost: 0 bytes in 0 blocks +==453446== still reachable: 0 bytes in 0 blocks +==453446== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) +``` + +`definitely lost: 4 bytes`, matching section 4's LSan `Direct leak of 4 byte(s)` on the ASan side. Both "sweep the heap at program exit"; memcheck just splits leaks into four tiers (`definitely lost / indirectly lost / possibly lost / still reachable`, finer-grained), while LSan by default reports only the `Direct` and `Indirect` tiers. The line number is again `:34`, matching ASan. + +> **Don't go download the source tarball and compile it by hand.** The install flow the notes give is `tar -jxvf valgrind-3.12.0.tar.bz2 && ./configure && make && sudo make install`; `3.12.0` is from 2016, **ten years ago**, and it handles modern kernels/new CPU instructions (like recent AVX) poorly, so freshly built programs tend to throw all kinds of errors. These days just use the distro package: Debian/Ubuntu `apt install valgrind`, Fedora/RHEL `dnf install valgrind`, Arch `pacman -S valgrind`; what you get is a 3.2x version (this machine has 3.25.1). + +## 6. How to choose between the two routes + +With all that said, when do you use which? Here's a field-tested decision: + +**Default to ASan.** For daily development and the memory-error detector hung in CI, ASan is the first choice: it's fast (2x slower vs 20-50x, CI can live with that), cross-platform (Windows/macOS/Linux all work, MSVC supports it too), and the reports are clean. In modern C++ projects, `-fsanitize=address,undefined` is almost the standard debug-build config. vol1's [Dynamic memory management](../../vol1-fundamentals/ch12/02-new-delete.md) covers ASan for leak hunting, and vol5's concurrency debugging covers TSan; both are tools on this same route. + +**These scenarios demand Valgrind:** + +1. **Binary only, no source**, or recompilation is too costly (a huge legacy project, say). ASan must recompile; Valgrind runs on a stock binary. +2. **You need to catch uninitialized reads, but you only have GCC**. ASan has no V-bit, and MSan is Clang-only; for a GCC-built project to catch uninitialized reads, memcheck is right there. +3. **You need performance profiling** (callgrind/cachegrind/massif). These tools have no ASan equivalent at all; cache misses, heap growth curves, and call graphs only come from the Valgrind suite. +4. **You need full coverage, including third-party libraries not built with ASan**. Valgrind intercepts at the instruction level and catches memory errors even in a sourceless `.so`; ASan only covers instrumented code. + +Conversely, **these are things Valgrind can't do, or does poorly, and need ASan**: catching stack/global array overflow (ASan's stack/global redzones are a strength), running fast (CI-friendly), the Windows platform (Valgrind basically doesn't support Windows), catching stack-use-after-return (ASan has a dedicated fake-stack mechanism). + +One-sentence summary: **ASan is the "development-phase" standard, Valgrind is the "weird bugs / performance / legacy binary" specialist.** They aren't a replacement relationship, they're complementary: many teams hang ASan in CI for daily gatekeeping, and turn to Valgrind for a second look when ASan can't pin down a weird problem. + +## 7. Back to C++: tools are the safety net, RAII is the cure + +After a whole article on tools, we have to pull the thread back: **no matter how strong these tools are, they "catch bugs after the fact", they don't "eliminate bugs".** What actually makes memory errors vanish at the root is C++'s RAII and smart pointers. + +Looking back at those six errors, you'll see they're **all built on "raw malloc/free, raw pointers"**: + +- Leaks? With `std::unique_ptr` / `std::vector`, the object frees itself when it leaves scope; there's simply no chance to forget `free`; +- use-after-free? Smart-pointer ownership semantics turn "can this memory still be used" into something the compiler can enforce; +- double-free? `unique_ptr` can't be copied, and after a move the source is nulled out, so a double is physically impossible; +- Overflow? `std::vector` with `.at()` throws an exception, `std::span` carries a bound; don't use raw `[]` with a hand-managed length. + +C-style `malloc`/`free`/raw pointers throw "when memory gets freed, who can access it" entirely onto the programmer to remember, and the human brain inevitably gets this wrong, which is exactly why "accounting tools" like Valgrind and ASan exist as a safety net. Modern C++ moves this accounting **into the type system**: a resource's lifetime is bound to an object, and the compiler guarantees release for you. That's the fundamental leap from "tools catch bugs" to "the language eliminates bugs", which vol1's [Dynamic memory management](../../vol1-fundamentals/ch12/02-new-delete.md) is entirely about. + +But this **does not** mean a Modern C++ project can do without ASan/Valgrind. As long as your code still calls C libraries, still uses `new`/`delete`, still touches third-party interfaces without RAII wrappers, memory errors still have a seam to slip through. So the right posture is: **first use RAII to eliminate 99% of memory errors at write time, then use ASan to surface the 1% that slips through during testing, and finally keep Valgrind as the safety net for the weirdest cases.** Three layers of defense, none optional. diff --git a/documents/en/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md b/documents/en/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md new file mode 100644 index 000000000..e26c9400b --- /dev/null +++ b/documents/en/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md @@ -0,0 +1,330 @@ +--- +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: Lay user-space -fsanitize=address/memory/undefined/thread and kernel-side + KASAN/KMSAN/UBSAN/KCSAN/KFENCE out in one table; make sense of the "compile-time instrumentation + vs sampling" routes and the "debug vs production" layered defense. +difficulty: advanced +order: 5 +platform: host +prerequisites: +- The ASan tool family and memory safety (shadow memory, Heartbleed, and sanitizer selection) +- Valgrind vs ASan (JIT interpretation vs compile-time instrumentation) +reading_time_minutes: 20 +related: +- The ASan tool family and memory safety (shadow memory, Heartbleed, and sanitizer selection) +- Valgrind vs ASan (JIT interpretation vs compile-time instrumentation) +- Concurrency debugging techniques (ThreadSanitizer) +- Dynamic memory management (new/delete and smart pointers) +tags: +- host +- cpp-modern +- advanced +- 内存安全 +- 调试 +- 工具链 +title: 'The sanitizer toolchain panorama: from -fsanitize to in-kernel KASAN/KFENCE' +translation: + source: documents/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md + source_hash: fe551848e8d6a94c9ba4297b7baeedb42e5ed5ee646b6182ef1b9350893bee66 + translated_at: '2026-07-06T14:45:00+00:00' + engine: manual + token_count: 5400 +--- +# The sanitizer toolchain panorama: from -fsanitize to in-kernel KASAN/KFENCE + +> PS: This part is migrated from my college notes and fact-checked; the user-space sanitizers were really run on this machine, while the kernel-side tools can't run here and rest on kernel.org's official docs. If anything is off, an Issue or PR is welcome. + +The previous two articles took user-space ASan / UBSan / MSan / TSan and Valgrind apart in fine detail: how shadow memory does its accounting, where the JIT-interpretation and compile-time-instrumentation routes differ, why the five sanitizers are mutually exclusive. But if you stop at "add a `g++ -fsanitize=address` flag", you'll miss a bigger picture: **sanitizers aren't a user-space monopoly; the kernel has a whole parallel set of tools**, and the two sides' design tradeoffs are completely different. + +What this article does is flatten the entire sanitizer toolchain and look at it as a whole. User-space `-fsanitize=*` on one side, kernel-space `CONFIG_KASAN / CONFIG_KMSAN / CONFIG_KFENCE` on the other; they hunt the same classes of bugs (overflow, use-after-free, uninitialized reads, data races), but under utterly different constraints. User-space can afford to slow a program 2-5x to catch bugs; the kernel can't, and once the kernel slows 5x the whole machine is toast. So the kernel side evolved the "sampling" route: KFENCE trades extremely low overhead for "can stay on in production all the time", coexisting in layers with the heavy "debug-only" KASAN. + +## First, tie off the user-space side + +Before walking into the kernel, let's nail down user-space sanitizers' four flags with real reports, so we can contrast them with the kernel later. The detailed shadow-memory mechanics and the Heartbleed story were taken apart in the previous article; here we just show the smallest reproducible code and real terminal output, handy for "which flag maps to which bug". + +The four flags in one sentence each: `-fsanitize=address` (ASan, overflow/UAF/leaks), `-fsanitize=undefined` (UBSan, undefined behavior), `-fsanitize=memory` (MSan, uninitialized reads), `-fsanitize=thread` (TSan, data races). + +### ASan: three errors in one pass + +Heap overflow, use-after-free, memory leaks, ASan collects all three. Let's write each as a minimal example (in one program, ASan would abort at the first error and you'd miss the other two, so we split them): + +```cpp +// uaf.cpp — use-after-free +#include +int main() { + int* p = new int(7); + delete p; + printf("*p = %d\n", *p); // p is deleted, dangling + return 0; +} +``` + +Build with `g++ -std=c++20 -O0 -g -fsanitize=address -fno-omit-frame-pointer uaf.cpp -o uaf`, run it: + +```text +================================================================= +==118313==ERROR: AddressSanitizer: heap-use-after-free on address 0x72c9e1de0010 at pc 0x5d222d6ed26f bp 0x7ffc31d299a0 sp 0x7ffc31d29990 +READ of size 4 at 0x72c9e1de0010 thread T0 + #0 0x5d222d6ed26e in main /tmp/sanit/uaf.cpp:6 + ... +SUMMARY: AddressSanitizer: heap-use-after-free /tmp/sanit/uaf.cpp:6 in main +``` + +`-g` is what lets the report carry source locations like `uaf.cpp:5`; that's the watershed for whether ASan is usable at all: without debug symbols the report is just a string of addresses, basically useless. It catches stack overflow just the same; let's switch to a cross-function stack buffer: + +```cpp +// stack_oob.cpp — stack buffer overflow +#include +void fill(char* p) { // cross-function, so detection crosses frames + for (int i = 0; i <= 8; ++i) p[i] = 'A'; // legal indices 0..7, 8 overflows +} +int main() { + char buf[8]; + fill(buf); + printf("done\n"); + return 0; +} +``` + +Build with the same flags and run: + +```text +================================================================= +==119120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x6ec9ef2f0028 at pc 0x5f38ab644200 bp 0x7fff6db78e20 sp 0x7fff6db78e10 +WRITE of size 1 at 0x6ec9ef2f0028 thread T0 + #0 0x5f38ab6411ff in fill(char*) /tmp/sanit/stack_oob.cpp:4 + #1 0x5f38ab6429d in main /tmp/sanit/stack_oob.cpp:8 + ... +Address 0x6ec9ef2f0028 is located in stack of thread T0 at offset 40 in frame + #0 0x5f38ab644220 in main /tmp/sanit/stack_oob.cpp:6 +``` + +Notice it doesn't just tell you there's an overflow; it tells you "this memory is the `buf` at offset 40 in `main`'s stack frame": the stack redzone even labels which stack array the memory belongs to. That's the power of shadow memory, taken apart in detail in the previous article; we won't re-expand here. + +Memory leaks go through ASan's bundled LeakSanitizer (LSan), which sweeps once at exit: + +```cpp +// leak.cpp — forgot to delete +#include +int main() { + int* leak = new int(99); + *leak = 100; + printf("leak = %d (deliberately not deleted)\n", *leak); + return 0; +} +``` + +```text +================================================================= +==118322==ERROR: LeakSanitizer: detected memory leaks + +Direct leak of 4 byte(s) in 1 object(s) allocated from: + #0 0x7c2b9dd2d341 in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341) + #1 0x609649f361ba in main /tmp/sanit/leak.cpp:4 +``` + +ASan's cost is real and tangible: the program runs 2-5x slower and uses 3-5x more memory. So **production builds absolutely must strip `-fsanitize=address`**; turn it on only for debug and test. This constraint sounds innocuous, but on the kernel side the same "overhead too high" directly forced a completely different tool into existence, which is where KFENCE comes from. + +### UBSan: the UB specialist + +ASan minds "can this memory be touched"; UBSan minds "is this operation itself legal". Signed integer overflow, array subscript out of bounds, null pointer dereference, misaligned shifts: these are undefined behaviors (UB) under the C++ standard, not necessarily crashing, but the results are unpredictable: + +```cpp +// ub.cpp — three flavors of UB +#include +#include +int main() { + int32_t big = 2147483647; // INT32_MAX + int32_t sum = big + 1; // (1) signed addition overflow → UB + int arr[4] = {0,1,2,3}; + int idx = 10; + int v = arr[idx]; // (2) subscript out of bounds → UBSan's bounds check + printf("sum=%d v=%d\n", sum, v); + return 0; +} +``` + +Build with `g++ -std=c++20 -O0 -g -fsanitize=undefined ub.cpp -o ub` (recover by default, prints all UB and continues): + +```text +ub.cpp:6:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' +ub.cpp:9:20: runtime error: index 10 out of bounds for type 'int [4]' +ub.cpp:9:9: runtime error: load of address 0x7fffed140f28 with insufficient space for an object of type 'int' +sum=-2147483648 v=0 +``` + +A real pitfall to flag here: **UBSan and ASan can be turned on together** (`-fsanitize=address,undefined`), and many people do, since one minds memory and the other minds arithmetic, complementary. But UBSan by default "prints and continues" (recover); if you want it to abort on the first UB (closer to production behavior), add `-fno-sanitize-recover=all`. ASan, by contrast, aborts on contact and you can't change that. + +### MSan: uninitialized reads, Clang only + +MSan catches "using an uninitialized value", a class ASan can't catch: the memory is legal, the access is legal, but the value is garbage. The catch is that **MSan is Clang-only; GCC doesn't support this flag at all**: + +```cpp +// msan.cpp — using an uninitialized variable +#include +int main() { + int x; // deliberately uninitialized + if (x) // branch on a garbage value → MSan catches + printf("x is truthy\n"); + else + printf("x is zero\n"); + return 0; +} +``` + +GCC errors out: + +```text +$ g++ -std=c++20 -fsanitize=memory msan.cpp -o msan +g++: error: unrecognized argument to '-fsanitize=' option: 'memory' +``` + +Switch to Clang and it compiles and runs (`clang++ -std=c++20 -O0 -g -fsanitize=memory -fno-omit-frame-pointer msan.cpp -o msan`): + +```text +==118932==WARNING: MemorySanitizer: use-of-uninitialized-value + #0 0x58f3129f5677 (/tmp/sanit/msan+0xd7677) + ... +SUMMARY: MemorySanitizer: use-of-uninitialized-value +``` + +> **Pitfall warning**: MSan has a hard constraint, **the entire program (including every library it links) must be compiled with MSan instrumentation**. If you just `clang++ -fsanitize=memory` and link an uninstrumented `libc++` or third-party library, you get a flood of false positives, because MSan treats every value returned by the library as uninitialized. So MSan sees little real-project use; it usually takes "rebuild the entire toolchain with MSan" to run clean. The previous article covered this; we emphasize it again here because the kernel-side KMSAN has the same "whole-chain instrumentation" requirement. + +As for TSan (data races), it's mutually exclusive with ASan, with 5-15x overhead, and is specifically for concurrency bugs; the "Concurrency debugging techniques" chapter in the concurrency volume already took it apart, so here we just mark where it sits on the panorama and don't repeat it. + +## Now the question: what about the kernel? + +With user-space's four flags memorized, next is what this article really wants to cover. **The kernel is C code too; it overflows, it use-after-frees, it has data races too; can we just slap `-fsanitize=address` onto the kernel?** + +The answer: **yes, and the kernel does exactly that, but the cost is so high you can only turn it on for debugging**. That's KASAN, the Kernel AddressSanitizer. Under the hood it's the same machinery as user-space ASan (shadow memory + compile-time instrumentation), but the kernel has its own constraints: + +1. **Shadow memory eats a big chunk of kernel virtual address space**. User-space ASan's shadow memory is "1/8 of the process address space"; the kernel just carves out a big segment of kernel VAS (`KASAN_SHADOW_START` to `KASAN_SHADOW_END`). On 64-bit kernels the address space is huge (128 TB), so it holds up; on 32-bit it's much tighter, which is why early KASAN only ran on 64-bit, until 5.11 when Linus Walleij landed a trimmed version for ARM-32. + +2. **Every memory access in the machine gets instrumented**. The kernel isn't a process; it's the substrate shared by all processes. Once KASAN is on, whole-machine performance falls off a cliff, which is exactly why `CONFIG_KASAN` is only for debug kernels; production kernels never turn it on. + +3. **It needs a specific allocator**. The kernel uses the SLAB or SLUB allocator, and KASAN has to plant redzones in the allocator and poison freed pages (`KASAN_SANITIZE_*`) to nab UAF/OOB on the spot. It's the same idea as user-space ASan intercepting `malloc/free`, just moved to `kmalloc/kfree`. + +The original notes said "KASAN applies to x86_64 and AArch64, 4.x and above"; that version number needs checking. In fact KASAN landed in the mainline at **Linux 4.0** (initially x86_64), with AArch64 following, and **5.11** added the trimmed ARM-32 version. The mechanism is right, but don't remember it as a vague "4.x". + +### What a KASAN report looks like (official style) + +What does a KASAN report look like? Following the structure of the kernel.org dev-tools/kasan example, swapping its `kmalloc_oob_right` for a virtual `buggy_driver_write` (fields and hierarchy map exactly to the official report), it looks roughly like this: + +```text +================================================================== +BUG: KASAN: slab-out-of-bounds in buggy_driver_write+0x3e/0x60 [buggy] +Write of size 1 at addr ffff888006c42185 by task cat/1234 + +CPU: 0 PID: 1234 Comm: cat Tainted: G B +Call Trace: + dump_stack_lvl+0x49/0x63 + print_report+0x171/0x486 + kasan_report+0xb1/0x130 + buggy_driver_write+0x3e/0x60 [buggy] + ... + +Allocated by task 1234: + kasan_save_stack+0x1e/0x40 + __kasan_kmalloc+0x81/0xa0 + kmalloc_trace+0x21/0x30 + buggy_driver_init+0x2a/0x60 [buggy] + ... + +The buggy address belongs to the object at ffff888006c42180 + which belongs to the cache kmalloc-8 of size 8 +The buggy address is located 5 bytes inside of + 8-byte region [ffff888006c42180, ffff88800642188) +``` + +It's almost identical in structure to a user-space ASan report: **first it says where it blew up (slab-out-of-bounds, an out-of-bounds write, in which driver function), then it gives the allocation stack (who allocated this memory, in which `kmalloc`)**. The kernel report adds kernel-allocator-specific info like "which slab cache it belongs to (`kmalloc-8`), at which byte of the object". Once you can read a user-space ASan report, you can basically read a kernel KASAN report. + +## The panorama table: user-space ↔ kernel + +Now align the two sides; this table is the heart of this article. The original notes had it as an external PNG; we redraw it in Markdown: + +| Bug hunted | User-space flag | Kernel tool | Kernel version | Production-ok? | +|---------|-----------|---------|------------|----------| +| Overflow / UAF / double-free | `-fsanitize=address` (ASan) | **KASAN** | 4.0 (x86_64) / 5.11 (ARM-32 trimmed) | No, debug only | +| Uninitialized reads | `-fsanitize=memory` (MSan, Clang only) | **KMSAN** | usable on patch branches from 5.16, fully in mainline from **6.1**, Clang 14.0.6+ only, x86_64 only | No, huge overhead | +| Undefined behavior (overflow/oob/shift) | `-fsanitize=undefined` (UBSan) | **UBSAN** | landed in 4.5 | Some sub-checks yes (see below) | +| Data races | `-fsanitize=thread` (TSan) | **KCSAN** | landed in 5.8, sampling-based | No, debug only | +| Memory leaks | ASan bundles LSan | **kmemleak** / eBPF `memleak` | kmemleak has long existed | Cautiously, has false positives | +| Sampled memory errors | (no user-space equivalent) | **KFENCE** | **5.12** | **Yes, on by default** | +| Access-pattern analysis (not bug detection) | (none) | **DAMON** | **5.15** | Yes, designed for production | + +A few correspondences in this table you must remember: + +- **ASan ↔ KASAN**: the same shadow-memory idea moved into the kernel, at the cost of whole-machine performance collapsing; debug-only. +- **MSan ↔ KMSAN**: both Clang-only, both need whole-chain instrumentation, both with huge overhead. The KMSAN docs say outright: "not intended for production use, because it drastically increases kernel memory footprint and slows the whole system down". +- **UBSan ↔ UBSAN**: kernel UBSAN landed in 4.5, and **part of its checks (like `CONFIG_UBSAN_BOUNDS`) are on by default in modern distro kernels**, because that part is very cheap to run, one of the few kernel sanitizers that can "stay resident". +- **TSan ↔ KCSAN**: note that TSan is full compile-time instrumentation, while KCSAN is different, it's **sampling-based** (watchpoints), with controllable overhead; correspondingly, it detects races by "happening to sample them", not TSan's "theoretically guaranteed to detect". Landed in 5.8 in the mainline (the google/kernel-sanitizers repo says outright "in mainline since 5.8"). + +The original notes marked KMSAN as "6.1 and above", and **that version number is correct**, don't misremember. KMSAN's patch series was maintained by Google's Alexander Potapenko for years; until the end of 2021 it was still only a branch patch, not in the mainline (the kernel.org official sample report runs on a patched `5.16.0-rc3+`, using the google/kmsan branch, not mainline); Google's official repo (google/kmsan) README states plainly "Linux 6.1+ contains a fully-working KMSAN implementation which can be used out of the box", i.e., **fully usable in mainline from 6.1 on**. So KMSAN is the last of this batch of kernel sanitizers to make it into the mainline. Be careful not to confuse "the 5.16 patch branch can run it" with "6.1 brought it into the mainline"; that's the commonest misread of this kind of version number. + +## KFENCE: the key move that puts a sanitizer into production + +KASAN's problem is too obvious: it can only be on for debugging, but what happens when your company's online kernel hits a memory bug? You can't exactly swap a production machine for a KASAN-debug kernel to reproduce it; the business would already be dead. What's really missing is a **memory-error detector with overhead low enough to leave on**. + +That's KFENCE (Kernel Electric-Fence), **landed in Linux 5.12 in the mainline**. Its idea is entirely different from KASAN's; it no longer "checks every access", it switches to **sampling**: + +- KFENCE maintains a fixed-size object pool (default `CONFIG_KFENCE_NUM_OBJECTS=255`; each object takes 2 pages, 1 page for the object and 1 as a guard page, with object pages and guard pages interleaved in the pool, so each object page has guard pages on both sides; under default config the whole pool is about 2 MiB). +- The kernel's slab allocator (`kmalloc`) is **hooked into the KFENCE pool by a sampling timer**: KFENCE has a sampling interval in milliseconds (boot param `kfence.sample_interval`, configurable via `CONFIG_KFENCE_SAMPLE_INTERVAL`), and within each sampling interval the next `kmalloc` is "hooked" and handed off to KFENCE. +- Once in the KFENCE pool, that allocation sits between two guard pages, and any out-of-bounds read/write hits a guard page, immediately triggers a page fault, and the kernel reports a precise error and allocation stack. +- After being freed, KFENCE marks that page "inaccessible"; anyone touching it again is use-after-free, reported on the spot. + +The cost of sampling is that **the vast majority of allocations never pass through KFENCE**, so it misses most bugs; you have to run long enough, with enough allocations flowing through the KFENCE pool, to have a chance at catching one. In exchange it has **extremely low overhead** (officially near zero; real production workloads barely feel it), and so it became **the first memory sanitizer that can stay on in a production kernel**. In fact, as long as the architecture supports it and SLAB or SLUB is on, KFENCE is on by default in many distros. + +The original notes said "KFENCE must run for a long time, but overhead is low enough that it can even run in production"; the mechanism is right, we just add the version (5.12) and the keyword "sampling", and emphasize the engineering significance of "on by default". It replaces the older `kmemcheck` (deleted back in 4.15 because overhead was too high and the idea clashed with KFENCE's). + +## DAMON: another "sampling" route, but not for catching bugs + +Mentioning "sampling", we should bring up DAMON (Data Access MONitor) too, because philosophically it's the same family as KFENCE: **no full tracking, just sampling representative samples**. But DAMON isn't a sanitizer; it doesn't catch bugs, it **monitors memory-access patterns**: + +- **Landed in Linux 5.15 in the mainline**, aimed at letting developers (and the kernel itself) see "how is a process actually accessing its memory", to optimize layout and guide reclaim. +- DAMON slices the target process's address space into equally-sized regions, **sampling** a handful of representative pages in each region, recording access frequency, and forming histograms. If a region is hot, it gets subdivided further; this "smart amplification" lets it run cheaply even on enormous address spaces. +- The kernel component is the "producer" (producing access patterns); user-space (or the kernel) is the "consumer". A consumer can even feed access patterns back into `madvise()` to change memory attributes, suggesting the kernel swap out regions confirmed cold. + +DAMON has three interfaces: the user-space `damo` tool (from awslabs/damo), sysfs under `/sys/kernel/mm/damon/admin/`, and a kernel API for kernel developers. The old debugfs interface is deprecated. Looking at it next to KFENCE, you can see that across the 5.12-5.15 wave the kernel systematically used "sampling" to close the "full instrumentation too expensive" gap: KFENCE catches bugs, DAMON watches patterns, and both can go to production. + +## Three-layer defense: place tools by scenario + +Putting user-space and kernel-space sanitizers together, the memory-safety toolchain is actually a **layered defense in depth**, where each layer makes a different overhead/coverage tradeoff: + +::: tip Development: full instrumentation, catch until you drop +Self-testing, CI, and fuzzing: **overhead isn't the issue, coverage is**. User-space turns on `-fsanitize=address,undefined` (and a separate round of `-fsanitize=thread`); kernel debug builds turn on `CONFIG_KASAN` + `CONFIG_KCSAN` + `CONFIG_UBSAN`. This layer assumes bugs can always be caught by full instrumentation; the price is the program or the whole machine running several times slower, borne only outside production. +::: + +::: tip Test / pre-prod: sampled instrumentation, long-running +Pre-prod, gray release, long load tests: **can't accept whole-machine collapse, but have to run long enough to surface rare bugs**. This layer uses KFENCE: sampled, low overhead, can stay on, letting tens of thousands of allocations flow through the guard-page pool to nab the "once in ten thousand runs" overflow and UAF. User-space has no real equivalent at this layer (Valgrind is too slow, ASan too heavy), which is exactly why KFENCE stands out so much on the kernel side. +::: + +::: tip Production: always-on lightweight checks + post-hoc analysis +A real online kernel **turns on only checks with negligible overhead**: KFENCE (on by default), lightweight UBSAN subsets like `CONFIG_UBSAN_BOUNDS`, plus DAMON for access-pattern analysis to guide optimization. After an incident you rely on post-hoc tools: kernel oops logs, `kdump`/`crash` analysis, and eBPF's `memleak-bpfcc` to track unreleased allocations. This layer no longer expects to "catch bugs on the spot"; it's about "leave enough evidence to investigate afterward". +::: + +This layering is why the kernel maintains both KASAN and KFENCE, two seemingly redundant tools: **the same bug (say UAF), caught by KASAN during development and by KFENCE in production**; the tools don't overlap, the scenarios don't overlap. User-space today only really has the first layer (dev-time instrumentation) working smoothly; the second and third layers aren't as mature as the kernel's, which is also why "completely nailing memory safety in C++ user-space" is harder than in the kernel: the kernel at least has KFENCE backing it up in production; when user-space hits an online UAF, you often just wait for it to crash and then go look at the core dump. + +## Side note: static analysis and post-hoc tools + +Beyond the runtime sanitizers, both kernel and user-space have another set of **tools that don't run code, they read code or read logs**; the notes mentioned them too, so let's close that thread without expanding: + +- **Static analysis**: on the kernel side there's `sparse`, `smatch`, `Coccinelle`, `checkpatch.pl`; on the user-space side there's `clang-tidy`, `cppcheck`. They don't run code and have zero overhead, but they only catch the "obviously fishy in the code pattern" class, and miss runtime-only UAF/OOB. They complement sanitizers rather than replace them: static analysis catches conventions, sanitizers catch runtime behavior. +- **Post-hoc analysis**: kernel oops/panic logs, `kdump`/`crash` for dump analysis, `[K]GDB` debugging. These are forensics for after a bug has already blown up, in a different phase from the "catch bugs early" sanitizers. + +User-space C++ post-hoc analysis appeared in the "Dynamic memory management" chapter, where `-fsanitize=address` reported a leak at exit, and in "Concurrency debugging techniques", where TSan did post-hoc concurrency-bug localization. The whole toolchain runs **dev-time sanitizers → production-time lightweight checks → post-hoc analysis** end to end; whichever link is missing, the corresponding class of bug keeps biting you in that phase. + +## References + +- [kernel.org: Kernel Address Sanitizer (KASAN)](https://www.kernel.org/doc/html/latest/dev-tools/kasan.html) — KASAN mechanism, config options, and sample reports +- [kernel.org: Kernel Memory Sanitizer (KMSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kmsan.html) — KMSAN requires Clang 14.0.6+, x86_64 only, explicitly "not for production" +- [kernel.org: Kernel Electric-Fence (KFENCE)](https://www.kernel.org/doc/html/latest/dev-tools/kfence.html) — KFENCE sampling mechanism, `CONFIG_KFENCE_NUM_OBJECTS`, production-ready positioning +- [kernel.org: UndefinedBehaviorSanitizer (UBSAN)](https://www.kernel.org/doc/html/latest/dev-tools/ubsan.html) — kernel UBSAN sub-checks and overhead +- [kernel.org: Kernel Concurrency Sanitizer (KCSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kcsan.html) — KCSAN's watchpoint-based sampling race detection +- [kernel.org: DAMON](https://www.kernel.org/doc/html/latest/admin-guide/mm/damon/usage.html) — DAMON sysfs/schemes interface and access-pattern monitoring +- [Clang: UndefinedBehaviorSanitizer](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) — user-space UBSan sub-check list +- [Clang: MemorySanitizer](https://clang.llvm.org/docs/MemorySanitizer.html) — MSan whole-chain instrumentation requirements and usage diff --git a/documents/en/vol6-performance/ch00-performance-mindset/index.md b/documents/en/vol6-performance/ch00-performance-mindset/index.md new file mode 100644 index 000000000..5fa3c4cd8 --- /dev/null +++ b/documents/en/vol6-performance/ch00-performance-mindset/index.md @@ -0,0 +1,22 @@ +--- +title: "Performance mindset and correctness first" +description: "Establishing the mindset for performance work: the difference between efficiency and performance, two iron rules, the Amdahl ceiling, and sanitizers as the correctness foundation" +--- + +# Performance mindset and correctness first + +I'd argue performance is the one area in C++ engineering where it's easiest to be confidently wrong. Microarchitecture complexity runs far ahead of human intuition — change code by feel, and nine times out of ten you're optimizing the 5% while the real bottleneck lies in the other 95%. So the first thing this volume does isn't teach you any single trick; it sets the mindset first: **correctness first, then speed; measure first, then optimize**. + +This chapter does three things. With a piece of $O(\log n)$ lookup code, it pins down **why efficiency (algorithmic complexity) and performance (real behavior on hardware) are not the same thing**. It lays down the two iron rules and the Amdahl ceiling that run through the whole volume. And it settles the sanitizer toolchain as the "correctness foundation" — any performance number without correctness backing it is not to be trusted. + +This chapter is the volume's thesis entry point. ch01's benchmark methodology picks up from here, swapping "I feel like" for "I measured it". + +## In this chapter + + + Performance mindset: efficiency and performance are not the same thing + From "correct first" to "fast next": why sanitizers are the foundation of the performance volume + The ASan family and memory safety: shadow memory and sanitizer selection + Valgrind vs ASan: JIT interpretation vs compile-time instrumentation + The sanitizer toolchain landscape: from -fsanitize to in-kernel KASAN/KFENCE + diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md b/documents/en/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md new file mode 100644 index 000000000..45a9faa24 --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md @@ -0,0 +1,115 @@ +--- +chapter: 1 +cpp_standard: +- 14 +- 17 +description: The microbenchmark is the most-used tool in performance work, and also the easiest one to lie to you. This article tears open three classic deceptions (compiler optimizes to nothing, cache is always hot, noise drowns the signal), names the uncomfortable truth that 'the very hand that makes a microbenchmark clean is the one that makes it unrealistic', and picks Google Benchmark + nanobench. +difficulty: intermediate +order: 1 +platform: host +prerequisites: +- Performance Mindset: efficiency is not performance +- From correctness to performance (why sanitizers are the foundation of the performance volume) +reading_time_minutes: 9 +related: +- How to write a credible microbenchmark +- Measurement pitfalls and environment readiness +- Statistics and reporting +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: Why microbenchmarks lie +translation: + source: documents/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md + source_hash: c9682c759425df5dc236c7f2429c56cb45a43d644dfebcf577b7503a27bc7ff2 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3300 +--- +# Why microbenchmarks lie + +## Performance is not a boolean + +A feature either works or it doesn't; it either runs or it doesn't — that's a boolean. Performance isn't. Performance is a **distribution**: the same code, the same input, run twice, you get two different numbers; run it ten times and you can plot a scatter chart. Bakhvalov makes this point right at the start of Chapter 2 of *Performance Analysis and Tuning on Modern CPUs*: unzipping a file gives you a byte-identical result every time (reproducible); but ask for "the identical performance curve" and you can't get it. + +This single fact drives the methodology of the entire volume. Since performance is a random variable, "measuring performance" is never "run it once, write down the number" — it's sampling a distribution and doing statistical inference. What we need is a statistic on average. + +This chapter is about how to do that hard thing right. It's the anchor chapter of the volume; every later performance article opens by referencing the discipline it teaches, the way vol5 runs TSan through all of concurrency correctness. + +Before that, though, you have to swallow one uncomfortable fact: the tool you reach for most readily, the microbenchmark, is also the one most likely to lie to you. This article tears its tricks open. + +## Episode 1: the compiler optimizes your benchmark into nothing, or into code you didn't intend + +The most classic, and most embarrassing, kind. You write a loop that looks like it's doing work — say you want to measure how fast the standard library string constructs and destructs, so you can compare against your own string: + +```cpp +// This "tests string creation performance" —— and measures nothing +void foo() { + for (int i = 0; i < 1000; ++i) { + std::string s("hi"); // created, but never read + } +} +``` + +`s` is created and never used. The compiler takes one look, calls it dead code, and deletes it — the whole loop plus the `string` construction get eliminated (DCE). I confirmed this on my own machine (GCC 16.1.1): at `-O2` the assembly of `foo` is a single `ret`, the entire loop has evaporated; the same code at `-O0` is 89 lines of assembly, loop and `string` construction all present. You smugly finish the run, jot down "0.3 nanoseconds", and conclude the function is fast. What you measured is "do nothing". + +> One reminder from me: when you profile, besides admiring your own perf graph, please go look at the assembly. Assembly is a direct map of the machine code; reading it tells you roughly what the machine is actually going to execute. + +We brushed past this pitfall in ch00-02 when talking about UB (the `(x+1)>x` example that got folded into a constant). Here's the more direct, performance-flavored version: **as long as the result your benchmark computes is never consumed, the compiler has every legal right to delete the whole thing.** This isn't a compiler "bug"; it's an allowed optimization. + +What do you do? Force the result to be "used". The industry calls these `DoNotOptimize`-style helpers — a bit of inline assembly pins the result to memory or a register, and both Google Benchmark and JMH (Java's `Blackhole.consume`) ship one. Its semantics and pitfalls are non-trivial (the `volatile global_sink` in ch00-01 is a hand-rolled approximation); the next article, ch01-02, takes it apart properly. + +## Episode 2: the cache is always hot, real workloads aren't + +The standard microbenchmark recipe is to run one function thousands or tens of thousands of times and take the mean. The problem lives in "thousands of times": when the same function keeps running on the same (or similar) data, that data sits in L1/L2 cache the whole time and never misses once. The 2 nanoseconds you measure is the 2 nanoseconds under this "hot cache" condition. + +In a real workload, between two calls to this function the system runs a pile of other things, and the cache has long since been replaced by someone else's data. When the function gets called again, it has to fetch from L3 or even DRAM — 2 nanoseconds becomes 50, becomes 200. That's the famous order-of-magnitude gulf between micro and macro (the 2/50/200 here is an industry rule of thumb, not something this article measured; it's meant to give you the intuition). + +Bakhvalov points out an even more insidious version at the end of Chapter 2: a microbenchmark running on an idle machine **grabs all the DRAM and cache for itself**. So when you compare two implementations, A is faster but eats more memory, B is slightly slower but saves memory. On the idle-system microbenchmark, A wins handsomely, because it can afford to eat memory. But the moment it goes to production, packed in with a bunch of neighbor processes fighting for DRAM, A's extra memory gets squeezed out to disk swap and performance falls off a cliff — the conclusion flips entirely. **What makes A look faster is exactly the microbenchmark's unrealistic premise that "nobody in the whole system is competing with me".** + +This corollary matters a lot; it's the mirror of ch00-01's "efficiency ≠ performance" at the measurement layer: don't back production performance with microbenchmark conclusions. What micro measures is "how fast can this function run under ideal conditions", not "how fast the user will actually experience it". + +## Episode 3: system noise drowns the signal + +Even if you survived the first two episodes (the result is consumed, you've accepted the cache being hot), there's a third class of deception coming from the system itself: modern CPUs and OSes have a pile of "for performance" features whose side effect is to make measurements unstable. + +- **Dynamic frequency scaling (DFS / Turbo)**: the CPU temporarily raises or lowers its frequency based on temperature and load. A "cold" processor on the first run might spike to turbo frequency; by the second run it's warmed up and dropped back to base — the same code run twice differs by a few percent to over ten percent. This is especially bad on laptops (cooling-limited). +- **Filesystem cache**: the first run reads from disk; the second time the data is all in the cache, and the second run is much faster. You think the second run shows your optimization paying off; really, the disk just didn't have to be read again. +- **Memory layout bias**: the spookiest kind. The classic Mytkowicz et al. 2009 paper proves that **the total byte count of UNIX environment variables, and the order of object files the linker reads**, both change a program's performance, in an unpredictable direction. You changed no code, only `LINK_ORDER`, and the numbers moved. +- **Even the monitoring tool itself**: you run `top` on another core to watch CPU usage, that core wakes up and re-scales frequency, and that can perturb the core running the benchmark. Bakhvalov specifically warns that even opening Task Manager can affect the measurement. + +These three tricks together point to a single conclusion: **one-off, hand-rolled measurement is essentially meaningless.** The number you measured is "the number for this code, under this compile flag, on this machine, at this temperature, this frequency, this memory layout, this cache state" — change one condition and it shifts. + +Having covered the three tricks, it's worth stepping back to look at a deeper contradiction. To make a microbenchmark produce clean, stable, comparable numbers, our instinct is to **eliminate noise**: lock the CPU frequency, pin to a core, disable hyperthreading, warm up the cache, run enough rounds and take the median. None of this is wrong, and the rest of this volume will teach you each one. + +But you have to stay clear-headed: **the process of eliminating noise is the process of pushing the measurement away from the real environment.** You locked the frequency, but the user's phone never locks; you pinned one core, but production threads get scheduled on and off; you pre-warmed the cache to optimal, but real calls hit a cache cold as ice. So Bakhvalov's advice is key: **when evaluating real performance, don't eliminate the system's nondeterminism — replicate the target environment.** Put another way, the very hand that makes a micro clean is the hand that makes it unrealistic. + +Someone's banging the table: that's a contradiction! No, it isn't — these are two different measurement scenarios, used separately. + +On one side, **microbenchmarks** do **relative comparison**: the same function, two implementations, the same machine, the same set of control conditions, how much faster is A than B. Here you do want to eliminate noise, because what you want is a "clean signal-to-noise ratio". Its output is "is this change direction right". **Production measurement / macro benchmarks**, on the other side, do **absolute judgement**: how fast the user actually feels, whether it can survive next month's traffic. Here you want to keep the noise and replicate reality, and then **use statistics** to process that noise. Its output is "can this number hold up". + +A common and disastrous mistake is to take a micro-style relative conclusion and project it onto a macro-style absolute judgement: "my function got 30% faster in micro, so the service will be 30% faster in production". Probably not — part of that 30% is "the dividend the idle system yielded", which doesn't exist in production at all. These two kinds of measurement are two languages and can't be directly converted. Production measurement and CI regression are the subject of ch01-05. + +## Don't hand-roll, use a framework + +Once you understand why it lies, tool selection gets clear: do not hand-roll a loop with `std::chrono` and start measuring (the `vector_vs_set` in ch00-01 deliberately used the most naive style to make a point — that one's the exception). A competent benchmark framework handles "result gets optimized away", "how many rounds to run", "how to compute statistics" for you, so you can focus on "what to measure". A few mainstream options in the C++ ecosystem: + +| Framework | Form | Anti-optimization mechanism | Statistical output | Positioning | +|---|---|---|---|---| +| **Google Benchmark** | static lib | `DoNotOptimize` + `ClobberMemory` | mean / median / stdev, strongest chainable API | **main workhorse this volume** | +| **ankerl::nanobench** | single header | `doNotOptimizeAway` | ns/op + err%, **built-in IPC / branch miss%** | lightweight supplement, instant feedback when teaching microarchitecture | +| Catch2 `BENCHMARK` | built into Catch2 | return value as sink | mean + 95% CI | convenient if the project already uses Catch2 | +| picobench / nonius / Hayai | single header | varies | simple | not the default, mentioned for completeness | + +This volume's pick is **Google Benchmark as the main workhorse + nanobench as a lightweight supplement**. The reason is GBench's chainable API — `BENCHMARK(f)->RangeMultiplier(2)->Range(8, 8<<10)->UseRealTime()->Repetitions(3)->ReportAggregatesOnly(true)` expresses "parameter sweep + wall-clock timing + multiple repetitions + report aggregates only" in one line, which no other library can do; and nanobench ships hardware counters (IPC, branch miss%), giving you instant feedback in the microarchitecture chapters. From the next article on, our code examples switch to GBench. + +## References + +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs*, Chapter 2 *Measuring Performance* (noise sources, micro vs production, the DCE string example). +- Google Benchmark: [user_guide](https://github.com/google/benchmark/blob/main/docs/user_guide.md) (`DoNotOptimize` / `ClobberMemory` / `Range` / `UseRealTime` / `Repetitions`). +- easyperf.net: *How to get consistent results when benchmarking on Linux*. +- Mytkowicz et al., *Producing Wrong Data Without Doing Anything Obviously Wrong*, ASPLOS 2009 (measurement bias: environment-variable size, link order). +- ankerl::nanobench: [README](https://github.com/martinus/nanobench). diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md b/documents/en/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md new file mode 100644 index 000000000..ca8191aa5 --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md @@ -0,0 +1,157 @@ +--- +chapter: 1 +cpp_standard: +- 14 +- 17 +description: From the "tricks" of ch01-01 to the countermeasures — write a not-(so)-deceptive microbenchmark with Google Benchmark, take apart the semantics and pitfalls of DoNotOptimize / ClobberMemory, parameter sweeps, repeat aggregation, UseRealTime, with a minimal runnable example and real output. +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- Why microbenchmarks lie +reading_time_minutes: 8 +related: +- Measurement pitfalls and environment readiness +- Statistics and reporting +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: How to write a credible microbenchmark +translation: + source: documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md + source_hash: a800c5e069a479290b4dc543fdf43dcdd3603f9e32cc1f270846685a0f7c39fe + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3600 +--- +# How to write a credible microbenchmark + +## The problem the previous article left behind + +ch01-01 laid out the microbenchmark's three deceptions: the compiler optimizes you into nothing, the cache is fake-hot, noise drowns the signal. The tricks are done; this article is the antidote. + +The antidote really only covers the first deception (the result gets optimized away), and along the way gets parameter sweeps, repeat aggregation, and wall-clock timing right — the three pieces of posture you'll need immediately. The third (system noise) needs the environment checklist from ch01-03, and how a distribution becomes a conclusion is the business of ch01-04; those two wait. This article first nails down "is the thing you're measuring the real thing". + +## Don't write your own timing loop + +You're probably tempted to do this: a `for` loop, `std::chrono::steady_clock` for timing, divide at the end. The `vector_vs_set` in ch00-01 was written exactly that way, but it **deliberately used the most naive style to make a point** — don't copy it. With a hand-rolled timing loop, "how many rounds to run", "how to compute statistics", "how to keep the result from being optimized away" are all on you, and each of those has pitfalls. A competent benchmark framework takes those three mechanical chores off your hands, and you only write "what to measure". This volume's main workhorse is Google Benchmark (GBench from here on). + +Here's a minimal but complete example, measuring `std::vector::push_back`: + +```cpp +// push_bench.cpp —— minimal complete GBench example +#include +#include + +static void BM_PushBack(benchmark::State& state) { + for (auto _ : state) { // timing loop: framework controls iteration count + std::vector v; + for (int i = 0; i < state.range(0); ++i) { + v.push_back(i); + benchmark::DoNotOptimize(v.data()); // prevent DCE + memory barrier + } + benchmark::ClobberMemory(); // make sure writes really hit memory + } + state.SetComplexityN(state.range(0)); // tell the framework the big-O N, auto-fit +} + +BENCHMARK(BM_PushBack) + ->RangeMultiplier(2)->Range(8, 8 << 6) // parameter sweep: 8,16,32,...,512 + ->UseRealTime() // report wall-clock, not CPU time + ->Repetitions(3) // run 3 rounds + ->ReportAggregatesOnly(true); // only report mean/median/stddev/cv + +BENCHMARK_MAIN(); +``` + +I ran it on my own machine (GCC 16.1.1, GBench v1.9.5, pulled via FetchContent); the output looks like this (a few representative rows): + +```text +Run on (14 X 3193.92 MHz CPU s) +CPU Caches: + L1 Data 32 kiB (x7) L2 Unified 512 kiB (x7) L3 Unified 16384 kiB (x1) +------------------------------------------------------------------------------------- +Benchmark Time CPU Iterations +------------------------------------------------------------------------------------- +BM_PushBack/8/repeats:3/real_time_mean 44.0 ns 44.0 ns 3 +BM_PushBack/8/repeats:3/real_time_median 44.0 ns 44.0 ns 3 +BM_PushBack/8/repeats:3/real_time_stddev 0.137 ns 0.137 ns 3 +BM_PushBack/8/repeats:3/real_time_cv 0.31 % 0.31 % 3 +BM_PushBack/64/repeats:3/real_time_mean 105 ns 105 ns 3 +BM_PushBack/64/repeats:3/real_time_median 105 ns 105 ns 3 +BM_PushBack/256/repeats:3/real_time_mean 242 ns 242 ns 3 +BM_PushBack/256/repeats:3/real_time_median 242 ns 242 ns 3 +``` + +How to read this table. `Time` is wall-clock (because we used `UseRealTime`), `CPU` is CPU time, and in the aggregate rows `Iterations` shows the repetition count (3, the one from `Repetitions(3)`), not the real per-round iteration count; the framework estimated many iterations per round, they're just hidden under `ReportAggregatesOnly` mode. `mean` / `median` / `stddev` / `cv` are statistics over those 3 rounds, and `cv` (coefficient of variation, `stddev/mean`) is the one to watch — it tells you "how scattered this group of measurements is". The cv on the 44ns row is 0.31%, very stable; the day cv spikes past 5%, don't trust this round, go hunt down the noise source first (ch01-03). + +> The first time I used GBench I just stared at `mean`. It took a few losses before I learned to glance at `cv` first. A `mean` with a big `cv` is meaningless; drawing a conclusion from a distribution where noise is bigger than the signal is just fooling yourself. + +Time scales with N (8→44ns, 64→105ns, 256→242ns) — this is what `push_back`'s "gets more expensive with scale" actually looks like. Not the empty shell that DCE deleted down to a single `ret` in ch01-01. + +## DoNotOptimize: it saves you, but not all the way + +This section is the one that most deserves to be thorough, and it's the one beginners most misuse. Put ch01-01's `foo()` next to this `BM_PushBack`: both "create/write things in a loop". `foo()` doesn't use `DoNotOptimize`, and the compiler deletes the whole thing into a single `ret`; `BM_PushBack` does, it actually runs, and time scales with N. What `DoNotOptimize` does is pin the "result" to memory or a register so the compiler can't decide it's dead code. + +But there's a big catch. I'll quote the Google Benchmark `user_guide` directly: `benchmark::DoNotOptimize(expr)` stores the result of `expr` in memory or a register, and on GNU compilers it's also a global memory read/write barrier (flushing pending writes); **but it does not prevent `expr` itself from being optimized** — if `expr`'s result can be computed at compile time, it may get computed away entirely, leaving only a constant. + +Sounds contradictory, but it's really a division of labor. `DoNotOptimize` prevents "the whole loop getting deleted because no one uses the result" (the `foo()` case); it does **not** prevent "the loop body getting punched through by constant propagation". So when writing a benchmark, the input data must be **produced at runtime** — from random numbers, from a file, from a parameter; it can't be a compile-time constant. Otherwise the compiler computes all the way through, and `DoNotOptimize` can't save you. Bakhvalov stresses this in §2.6 too: **first make sure "the scenario you want to measure" actually executes at runtime.** (That loops back to my reminder in the previous section — go look at the assembly.) + +`benchmark::ClobberMemory()` is the companion piece, forcing all pending writes to actually land in memory. `push_back` mutates the `vector`'s internal state (size, possibly a reallocation); if the compiler decides "no one looks at this `vector` later", under some boundary conditions it may skip part of the writes. `ClobberMemory` is the finisher that says "don't skip, really write". A common safe pattern: in the hot loop, `DoNotOptimize` the address every time you write the target data; at the end of the loop, `ClobberMemory` as the safety net. + +## Don't measure just one N + +The line `BENCHMARK(BM_PushBack)->RangeMultiplier(2)->Range(8, 8 << 6)` makes the framework run the same benchmark with the set `8, 16, 32, 64, 128, 256, 512` for N. Why sweep a whole set of N instead of picking one handy value? + +The true shape of complexity only shows up when you sweep a set of N. `push_back` is amortized $O(1)$, but a sweep reveals that small N gets eaten by the cache while large N triggers reallocation spikes; if you measure only one N, what you see might be a cache dividend or a reallocation penalty, depending entirely on luck. Worse, crossovers hide inside the scale: in the ch00-01 `vector` vs `set`, looking only at N=1024, `set` is actually slightly faster; sweeping up to N=65536 is where you see `vector` beat it 5×. Without sweeping the scale, you simply can't see these flips. + +Throw in `state.SetComplexityN(state.range(0))` and the framework will also auto-fit a big-O from the times you swept, adding a `Big O` column to the output so you can sanity-check against your complexity intuition. Easier than computing the slope by hand. + +## Repeat several rounds, report the median, not the single-pass mean + +ch01-01 said performance is a distribution, so a single measurement is meaningless. GBench's answer is `Repetitions(n)`: run the same benchmark n rounds (the framework estimates the inner iteration count each round), then `ReportAggregatesOnly(true)` outputs only the `mean` / `median` / `stddev` / `cv` aggregates, instead of flooding the screen with each round's raw value. + +Why stress the **median** and not just the mean: `push_back` occasionally hits a reallocation — that's a legitimate amortized cost, but relative to the mean it's an outlier; the mean gets dragged up by that long tail, while the median doesn't budge. ch01-04 covers when to use the median, when the mean, and how to report a confidence interval; for now remember one line: reporting the median + cv is far more honest than throwing out a single mean. `ReportAggregatesOnly(true)` has an invisible bonus too: when running benchmarks in CI, aggregate output is more suitable for trend comparison and regression detection (ch01-05 picks up that thread). + +One more detail to mention: `UseRealTime()`. GBench reports **CPU time** by default, which under multithreading also counts work done on other cores, and is often not "how long did this code take on the wall" that you want. `UseRealTime()` switches the report to wall-clock. This thread is continuous with the `clock()` pitfall in ch00-02: `clock()` measures CPU time and distorts under multithreading; `steady_clock` measures wall-clock. Single-threaded doesn't matter; the moment your benchmark spawns threads (or you want to compare against the latency the user feels), add `UseRealTime()`. + +## How to compile + +Two paths, pick one. + +**System has GBench installed** (Arch: `pacman -S benchmark`, macOS: `brew install google-benchmark`): + +```bash +g++ -O2 -std=c++17 push_bench.cpp -o push_bench -lbenchmark -lpthread +./push_bench +``` + +Note whether you link `benchmark` (the library) or `benchmark::benchmark_main` (with its own `main`): if the code has `BENCHMARK_MAIN()`, link `benchmark`; if you don't want to write `main` yourself, link `benchmark_main` and delete the `BENCHMARK_MAIN()` line. + +**With CMake + FetchContent** (this is what this volume's code examples use, so readers don't have to pre-install; clone the repo and run): + +```cmake +cmake_minimum_required(VERSION 3.20) +project(vol6_ch01_bench CXX) +set(CMAKE_CXX_STANDARD 17) +include(FetchContent) +FetchContent_Declare(benchmark + GIT_REPOSITORY https://github.com/google/benchmark.git + GIT_TAG v1.9.5) +set(BENCHMARK_ENABLE_TESTING OFF CACHE_BOOL "" FORCE) # turn off its own test targets +FetchContent_MakeAvailable(benchmark) +add_executable(push_bench push_bench.cpp) +target_link_libraries(push_bench PRIVATE benchmark::benchmark_main) +target_compile_options(push_bench PRIVATE -O2 -Wall -Wextra) +``` + +> ⚠️ **A pit I stepped in**: to turn off benchmark's own test targets, the flag is `BENCHMARK_ENABLE_TESTING` (not `BENCHMARK_ENABLE_TESTS`). Get the name wrong and FetchContent will go build benchmark's internal tests, blow up on missing gtest config, and even though your `push_bench` itself already compiled, `cmake --build` will return non-zero overall because a sibling target failed. Look for `Built target push_bench` in the `make` output; if it's there, your executable made it, just run `./build/push_bench`. + +## References + +- Google Benchmark: [user_guide](https://github.com/google/benchmark/blob/main/docs/user_guide.md) (the `DoNotOptimize` / `ClobberMemory` / `Range` / `UseRealTime` / `Repetitions` sections; the precise semantics of `DoNotOptimize` are authoritative from the original text here). +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs* §2.6 *Microbenchmarks* (the `foo()` DCE example, making sure the scenario actually executes at runtime). +- This volume's ch01-01 "Why microbenchmarks lie" (the three tricks; this article is the countermeasure). diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md b/documents/en/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md new file mode 100644 index 000000000..35b63f308 --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md @@ -0,0 +1,140 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +description: The concrete countermeasure to ch01-01's third trick (noise) — 16 environment pitfalls that distort performance numbers, grouped by frequency / cache / scheduling / tooling, each with "why it distorts + the command to avoid it", plus a perf-env-check.sh one-shot health-check script. +difficulty: intermediate +order: 3 +platform: host +prerequisites: +- How to write a credible microbenchmark +reading_time_minutes: 6 +related: +- Why microbenchmarks lie +- Statistics and reporting +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 'Measurement pitfalls and environment readiness: a 16-item checklist' +translation: + source: documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md + source_hash: 11d9a7e19b5b64e087271cb00f8917a25ca479236662ef634e7c0f20d65e1a25 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3000 +--- +# Measurement pitfalls and environment readiness: a 16-item checklist + +## Why you need a checklist + +ch01-01 covered the third microbenchmark deception: system noise drowning the signal. That article told you "why there's noise"; this one tells you "exactly how to turn it off". The 16 items below are the environment pitfalls most often tripped when doing credible microbenchmarking on Linux. Each is laid out as "**pitfall → why it distorts → how to avoid it**", most with a command you can copy straight. + +But before we start, repeat the most important boundary from ch01-01: **these noise-elimination techniques should only be used when doing relative A/B comparisons.** If what you're evaluating is "how fast the user actually feels", you instead **replicate** the real environment (keep the noise, keep DFS, keep the neighbor processes) and then process that noise with statistics — that's the job of ch01-05, production measurement. This page is for the microbenchmark scenario of "I want to cleanly compare two implementations". + +## The 16 pitfalls + +For memorability, grouped by nature into four buckets. + +### Group 1: frequency and power (the biggest swing source) + +| # | Pitfall | Why it distorts | Avoidance | +|---|---|---|---| +| 1 | **CPU frequency scaling (DVFS)** | governor=ondemand means frequency floats with load; GBench even prints `***WARNING*** CPU scaling enabled` on startup | `sudo cpupower frequency-set -g performance` to lock to top frequency | +| 2 | **Turbo Boost** | Single-core burst high frequency; cold start differs from steady state, drops once it heats up | Disable Turbo in BIOS; or lock the frequency; measure steady state and warm up first (that's what warmup means) | + +Leave these two alone and the same code differing 10% between two runs is normal. Especially bad on laptops (cooling-limited, Turbo constantly going in and out). + +### Group 2: cache, memory, and address translation + +| # | Pitfall | Why it distorts | Avoidance | +|---|---|---|---| +| 3 | **Cold start vs steady state** | First access misses cache (goes to DRAM), subsequent hits cache, 10–100× difference | The framework's estimation phase already pre-warms; to measure cold start, use `posix_fadvise(fd, POSIX_FADV_DONTNEED)` to drop the pages | +| 4 | **Page faults** | First touch triggers a soft page fault (microsecond-scale), inflating a single operation tens of times over | `mlockall(MCL_CURRENT \| MCL_FUTURE)` to lock pages; or touch every page first | +| 9 | **NUMA** | On multi-socket machines cross-node memory access latency doubles or quadruples; "memory bandwidth" becomes "interconnect bandwidth" | `numactl --cpunodebind=0 --membind=0 ./bench` to bind threads and memory to the same node | +| 15 | **ASLR / code layout** | Different PIE base addresses make instruction cache (icache) and branch predictor alignment jitter by 10–20%; also affects "memory layout bias" (Mytkowicz 2009) | For fine microarchitectural timing add `-no-pie`; to remove layout bias use random interleaving | + +### Group 3: scheduling and interference + +| # | Pitfall | Why it distorts | Avoidance | +|---|---|---|---| +| 5 | **Context switches / interrupts** | Getting scheduled away produces long-tail outlier samples | `taskset -c ` to pin; use the median for statistics (not the mean) | +| 8 | **CPU pinning** | Threads migrating across cores, cache cold every time | `taskset -c 3 ./bench` (pick a core, don't let the OS jiggle it) | +| 10 | **SMT / hyperthread contention** | The sibling thread on the same physical core eats execution units | Disable hyperthreading in BIOS; or taskset only physical cores (use one of every two sibling cores) | +| 11 | **Timer resolution** | `clock()` measuring nanoseconds is all noise (not enough resolution) | `std::chrono::steady_clock` (see ch00-02); or `perf stat` to look at cycles | + +### Group 4: tooling posture and statistics + +| # | Pitfall | Why it distorts | Avoidance | +|---|---|---|---| +| 6 | **Dead code gets optimized away** | Result unused → DCE deletes the loop (see ch01-01's `foo()`) | `DoNotOptimize` / `doNotOptimizeAway`; note it **does not prevent the expression itself from being computed away** (ch01-02) | +| 7 | **No release + no debug info** | `-O0` performance numbers are meaningless; pure `-O2` without `-g` can't do source annotation | Use `RelWithDebInfo` (`-O2 -g`) uniformly; add `-fno-omit-frame-pointer` for profiling (otherwise the stack breaks, flame graph explodes) | +| 12 | **Mean vs median** | Microbenchmarks are right-skewed (long tail), mean gets dragged up | Report median + IQR; GBench `Repetitions` + `ReportAggregatesOnly` (ch01-02) | +| 13 | **Too few samples** | Confidence interval so wide you can't tell A from B | ≥30 samples; report 95% CI; use the Mann-Whitney U test for A/B significance (ch01-04) | +| 14 | **Run-to-run instability** | Environment not fixed, drift across runs | Take the most stable of ≥3 runs; `perf stat -r 5` repeats for you | +| 16 | **PEBS skid** | A sampled event "skids" a few instructions before landing on the real instruction | Use events with the `:pp` / `:ppp` (precise IP) suffix, e.g. `MEM_LOAD_RETIRED.L3_MISS:ppp` | + +The 16 look like a lot, but the core is one sentence: **control everything you can (frequency, core, memory layout), really consume the result that should be consumed (`DoNotOptimize`), and treat the numbers as a distribution (median + multiple rounds).** The rest depends on the scenario — for micro, do as many as possible; for production evaluation, don't do them (replicate reality). + +## One-shot health check: `perf-env-check.sh` + +Checking all these by hand before every measurement is annoying, so we compress it into a script. It **only checks, never modifies** (things like changing governor or turning off Turbo need sudo and are left for you to decide), and prints whatever problems it finds: + +```bash +#!/usr/bin/env bash +# perf-env-check.sh —— credible microbenchmark environment health check (check only, no modify) +set -u + +ok() { printf " ✓ %s\n" "$1"; } +warn() { printf " ⚠ %s\n" "$1"; } + +echo "=== CPU governor (should=performance) ===" +g=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null) +[ "$g" = performance ] && ok "governor=performance" || warn "governor=$g (DVFS will float). Fix: sudo cpupower frequency-set -g performance" + +echo "=== Turbo Boost (Intel) ===" +if [ -f /sys/devices/system/cpu/intel_pstate/no_turbo ]; then + nt=$(cat /sys/devices/system/cpu/intel_pstate/no_turbo) + [ "$nt" = 1 ] && ok "Turbo off" || warn "Turbo on (no_turbo=$nt), cold/hot start numbers will differ" +else + echo " · Not intel_pstate or no such interface, skipping (set in BIOS)" +fi + +echo "=== perf_event_paranoid (<=1 to sample nicely) ===" +p=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null) +[ "${p:-3}" -le 1 ] && ok "perf_event_paranoid=$p" || warn "=$p (perf restricted). Fix: sudo sysctl -w kernel.perf_event_paranoid=1" + +echo "=== NUMA topology (only matters on multi-socket) ===" +command -v numactl >/dev/null && numactl --hardware 2>/dev/null | grep -E "^available|node [0-9]+ cpus" | head -4 || warn "no numactl" + +echo "=== CPU affinity (should explicitly pin one core, don't let the OS jiggle) ===" +cpu=$(grep Cpus_allowed_list /proc/self/status 2>/dev/null | awk '{print $2}') +n=$(nproc 2>/dev/null) +echo " Cpus_allowed_list=$cpu (nproc=$n) → if not pinned, taskset -c ./bench (don't pick core 0, often busy with system interrupts)" + +echo "=== ASLR (turn off for fine microarchitectural timing) ===" +aslr=$(cat /proc/sys/kernel/randomize_va_space 2>/dev/null) +echo " randomize_va_space=$aslr (2=full; for fine icache/branch timing: sudo sysctl -w kernel.randomize_va_space=0)" +``` + +Save it as `perf-env-check.sh` and run `bash perf-env-check.sh` to see what your environment is still missing. The full script also lives under `code/volumn_codes/vol6-performance/ch01/`. + +## Which scenarios call for which items + +| Scenario | Do these (eliminate noise) | Don't do these | +|---|---|---| +| **microbenchmark A/B comparison** | 1/2/4/5/8/9/10/15, as many as possible; you want a clean signal-to-noise ratio | Don't project the conclusion straight onto production | +| **Evaluating production performance** | **Almost none**, replicate the real environment (keep DFS, neighbors, ASLR) | Don't turn off noise sources, or you're not measuring what users will experience | +| **Profiling for hotspots** | 7 (`-fno-omit-frame-pointer`), 16 (`:pp`) | Finding hotspots is sampling under real load anyway | + +This table is the concrete landing of ch01-01's "the hand that makes micro clean is the hand that makes it deceptive": **the same set of techniques is medicine in the micro scenario and poison in the production scenario.** Getting this balance right matters more than memorizing the 16 commands. + +## References + +- easyperf.net: *How to get consistent results when benchmarking on Linux* (one of the direct sources for this checklist). +- Brendan Gregg: [Linux Performance](https://www.brendangregg.com/linuxperf.html) (perf / task placement / NUMA). +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs* §2.1 *Noise In Modern Systems*. +- This volume's ch01-01 (noise classification), ch01-02 (`DoNotOptimize` / `Repetitions` / `UseRealTime`). diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md b/documents/en/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md new file mode 100644 index 000000000..1106e3a3a --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md @@ -0,0 +1,95 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +description: What to do after you've got a pile of numbers — why performance data reports the median + confidence interval rather than a single mean, why it's almost never normal so Mann-Whitney beats t-test, the right posture for A/B comparison, and why you can't back macro conclusions with micro numbers. +difficulty: intermediate +order: 4 +platform: host +prerequisites: +- How to write a credible microbenchmark +- 'Measurement pitfalls and environment readiness: a 16-item checklist' +reading_time_minutes: 6 +related: +- Production measurement and CI performance regression +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 'Statistics and reporting: turning a distribution into a conclusion' +translation: + source: documents/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md + source_hash: a013bb919b59df4eba7daefd9f726144825177be853b048c5158f1bb297ddec4 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2800 +--- +# Statistics and reporting: turning a distribution into a conclusion + +## You've got a pile of numbers, now what + +ch01-01 through ch01-03 let you produce performance numbers that "aren't an empty shell, are measured right, and come from a clean environment". But what you get is never one number, it's a pile of numbers — a distribution. ch01-01 said it at the very start: performance isn't a boolean, it's a distribution. This article answers the last question: how do you turn that distribution into a credible conclusion, "A is X% faster than B, and that X% is real, not noise"? + +This is, in essence, statistical inference. It sounds intimidating, but what you actually need to master boils down to a few rules, and each one maps to a mistake you will really make. + +## What to report, what not to report + +First, hard rules: + +- **Must report**: the median, a dispersion measure (IQR interquartile range, or 95% confidence interval CI), sample count, and an environment snapshot (kernel / CPU / governor / `perf_event_paranoid`). With `Repetitions` + `ReportAggregatesOnly(true)`, GBench gives you `mean` / `median` / `stddev` / `cv` automatically — the table we got in ch01-02. +- **Forbidden**: a single-pass mean. The `mean` from one run isn't a conclusion, it's a single sample. + +Why single out the "single-pass mean" for prohibition? Because performance data is **right-skewed**: usually fast, but occasionally a reallocation, getting scheduled away, or a cache miss drags out a long tail. The mean gets pulled up by the long tail, while the median doesn't budge. Two datasets where "A's typical performance is clearly better" can have B win on the mean simply because B has fewer long tails — the conclusion flips. + +## Why the median is more reliable than the mean + +Bakhvalov has a very illustrative figure in §2.4 of *Performance Analysis and Tuning on Modern CPUs*: performance measurements of two versions A and B **plotted as distributions**, two curves that overlap heavily. A's peak (the most likely latency) sits further left than B's (faster), so A looks like the winner. But because the distributions overlap, **"A is faster than B" only holds for some probability P**: there are always samples where B is actually faster. You draw one sample and it might land in the segment where B is faster. + +This has a direct corollary: + +- Don't draw a conclusion from one or two samples. What you want is a comparison of distributions, not a comparison of point estimates. +- Use the **median** to represent "typical performance", and a **dispersion measure** (IQR / 95% CI / cv) to represent "how stable this typical is". A cv (`stddev/mean`) below 1% is stable; above 5% the group itself is untrustworthy — go back and find the noise source (ch01-03) before drawing any conclusion. +- Look at the shape of the distribution itself. If it's **bimodal** (two peaks), your benchmark has mixed two behaviors — the classic cases are cache-hit vs cache-miss paths, or lock-contention vs no-contention. Bakhvalov warns: a bimodal distribution isn't noise, it's a signal, meaning you should split the two scenarios and measure them separately, not mash them together and take the median. + +## Hypothesis testing: t-test or Mann-Whitney U + +"A is 12% faster than B — is that 12% real?" That's a statistical question, called **hypothesis testing**. The idea is to first assume "A and B are no different" (the null hypothesis), then look at how unlikely your data is under that null. If unlikely enough (p value below a threshold, usually 0.05), call it "significant" and reject the null. + +Which test to pick depends on what the data distribution looks like: + +- **Student's t-test** (parametric): assumes the data follows a **normal distribution**. Simple to compute, the default in textbooks. +- **Mann-Whitney U** (non-parametric): makes no assumption about distribution shape; it just compares the ranks (who's bigger after sorting) of the two groups. + +Here's the key point, paraphrasing Bakhvalov in §2.4: **in performance measurement data, a normal distribution almost never shows up.** Performance data is usually skewed, long-tailed, even multimodal. So the textbook formulas that assume normality (including the t-test) must be used cautiously in performance settings. He calls this out specifically because too many people reach for the t-test by default. + +Practical advice: for A/B significance judgement, **default to Mann-Whitney U** (non-parametric, robust); only use the t-test if you've first run a normality test and confirmed the data is actually close to normal. Python's `scipy.stats.mannwhitneyu` and R's `wilcox.test` both compute it directly. + +## The right posture for A/B comparison + +Putting the above together, a credible "A is faster than B" conclusion must satisfy: + +1. **Same environment**: same machine, same governor, same workload, only the one thing you're comparing changes. Don't measure A and B on two different machines. +2. **Same binary**: ideally a compile-time switch in one binary flips A/B, to avoid layout bias from different compilations. +3. **Multiple repetitions**: measure A N times, B N times (N ≥ 30 is best), each yielding a distribution. +4. **Report effect size, not just p value**: the p value only tells you "is the difference real", not "how big is the difference". A "p<0.05, 0.3% faster" conclusion is statistically significant but engineering-meaningless. Report the full form: "12% faster (95% CI [10%, 14%], p<0.01)". +5. **Run more than one round**: confirm across days and warmup states, in case this round's environment drifted. + +What this set gets automated into in CI (ch01-05) is doing this kind of A/B continuously and judging regressions automatically. + +## micro vs macro: one more time + +Because it's the most deadly, every article in this chapter is going to call it out once. + +**It is forbidden to back production performance with microbenchmark conclusions.** A function that measures IPC=2, all-cache-hit in a microbenchmark can completely become IPC=0.3 in a real workload because of cache misses. This isn't "micro is inaccurate" — micro measures "how fast can it run under ideal conditions", and production has no ideal conditions. The two are two languages and can't be directly converted. + +In Bakhvalov's §2.4 example, the "distribution comparison" is done within the same kind of scenario (both micro or both macro). When comparing across scenarios, you either restrict the micro conclusion to "the relative direction of improvement for this function", or you go do macro measurement (production telemetry / macro load benchmark). The latter is the subject of ch01-05. + +## References + +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs* §2.4 *Manual Performance Testing* (distribution comparison, bimodal, hypothesis testing, the "performance data is almost never normal" line). +- Feitelson, D. G. *Workload Modeling for Computer Systems Performance Evaluation* (Bakhvalov's recommended reference for performance statistics; modal distributions, skewness, etc.). +- Wikipedia: [Mann–Whitney U test](https://en.wikipedia.org/wiki/Mann%E2%80%93Whitney_U_test), [Student's t-test](https://en.wikipedia.org/wiki/Student%27s_t-test). +- This volume's ch01-01 (the root of micro vs macro), ch01-02 (the `mean`/`median`/`stddev`/`cv` from `ReportAggregatesOnly`). diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md b/documents/en/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md new file mode 100644 index 000000000..8ba120b77 --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md @@ -0,0 +1,96 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +description: Moving measurement from the dev machine to production and CI — how production telemetry samples real-user data at <1% overhead, why simple thresholds can't stop performance regressions, MongoDB's change-point detection thinking, and the five steps a CI performance system should automate. +difficulty: intermediate +order: 5 +platform: host +prerequisites: +- 'Statistics and reporting: turning a distribution into a conclusion' +reading_time_minutes: 5 +related: +- Why microbenchmarks lie +- Benchmark methodology reference card +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: Production measurement and CI performance regression detection +translation: + source: documents/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md + source_hash: 2747614e06c4e7c171633ca3822c2d9cd296aabecf9b15b5fe41108ba09f8e2d + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2600 +--- +# Production measurement and CI performance regression detection + +## From the dev machine to production + +ch01-01 through ch01-04 cover doing microbenchmark A/B comparisons **on the dev machine**: eliminating noise, reporting the median, running hypothesis tests. That discipline answers "is my change direction right". But there are two questions it can't answer: + +1. **Did the user actually get faster after launch?** microbenchmark conclusions can't be projected straight onto production (ch01-01 stresses this repeatedly); you have to measure in the production environment. +2. **How do you stop performance from silently degrading as versions pile up?** Large projects change fast, and performance regression bugs leak into production code at surprising speed; relying on a human to watch every time will leak sooner or later. + +This article answers those two: how to do production measurement, and how to automatically detect performance regression in CI. The material comes mostly from Bakhvalov's *Performance Analysis and Tuning on Modern CPUs* §2.2 and §2.3. + +## Production measurement: accept the noise, use statistics + +The biggest difference between the production environment and the dev machine is: **you can't eliminate the noise, and you shouldn't try.** The "lock frequency, pin core, disable Turbo" routine from ch01-03 is all poison in production: if you eliminate the noise, what you measure isn't what the user will experience. The principle of production measurement flips around: **replicate reality + process the noise with statistics**. + +A few key points: + +- **Shared-infrastructure interference.** On the public cloud your service runs on the same physical machine as someone else (virtualized / containerized), and neighbor processes affect your performance in unpredictable ways. You can't replicate this interference on the dev machine. +- **Telemetry: sample on real user devices.** The big shops increasingly instrument the client side to collect performance data. Bakhvalov cites Netflix's Icarus telemetry service, running on thousands of devices scattered around the world, helping engineers see "how real users perceive performance" — data you can't replicate in the lab. +- **Overhead must be tiny.** The principle of production profiling is "low overhead is job one". Paraphrasing Ren et al. 2010: doing continuous profiling on data-center machines running real traffic, **the acceptable total overhead is under 1%**. So you can only use lightweight methods (low-frequency sampling, only a fraction of machines, short time windows). +- **Statistical methods for quantile metrics.** What production performance cares about isn't "how fast on average", it's "what's the p90 / p99 latency" — the long tail is what kills user experience. LinkedIn's approach (Bakhvalov cites Liu et al. 2019) is to do A/B testing in the production environment with statistical methods, comparing these quantile metrics. + +In one sentence: **production measurement = keep the noise + statistical inference**, two languages apart from micro's "eliminate noise + clean comparison". + +## CI performance regression detection: why simple thresholds don't work + +Iteration is fast, and performance regressions keep leaking in. What stops them? + +**First reaction: eyeball the chart.** Don't. People lose focus fast, especially on noisy charts. In Bakhvalov's §2.3 figure, the human eye catches the dip on August 5, but the next few small regressions probably get missed. Plus this is boring daily work, not fit for humans. + +**Second reaction: set a threshold, "alert if the drop exceeds X%".** Sounds reasonable; in practice two hard flaws: + +1. **The threshold is extremely hard to pick.** Set it low and a pile of pure-noise wiggles trigger alerts; you're chasing air all day. Set it high and real regressions get filtered out. And **small regressions accumulate**: Bakhvalov's example — threshold at 2%, two regressions of 1.5% each both get filtered, two days accumulate to 3%, already over threshold but nobody noticed. +2. **Each test needs its own threshold.** Different benchmarks have different noise levels; one threshold can't be universal. Chromium's LUCI is an example of explicitly configuring thresholds per test — it runs, but the maintenance cost is high. + +## Change point analysis + +The newer approach is **change point detection**: instead of watching a single threshold, watch when the **distribution** of the whole time series changes. The MongoDB team (Daly et al. 2020) implemented one in their CI system Evergreen, using an algorithm called "E-Divisive means" to automatically find "the point where the distribution changed" in the time series, mark it on the chart, and auto-open a Jira ticket. The nice thing about this approach is that it's robust to noise (it looks for changes in distribution structure, not single jitter), and it doesn't require manually tuning a threshold per test. + +Another idea (Bakhvalov cites Alam et al. 2019's AutoPerf): use **hardware performance counters** (PMC, see ch02/ch03) to build a "performance fingerprint" for each function; if the post-change version's fingerprint deviates from the baseline, flag it as anomalous. This catches some complex performance bugs hidden inside parallel programs. + +## What a CI performance system should automate + +Regardless of whether the underlying mechanism is thresholds or change-point detection, Bakhvalov's §2.3 gives a typical five steps a CI performance system should automate, very practical: + +1. **Set up the system under test** +2. **Run the workload** +3. **Report the result** +4. **Decide whether performance has changed** +5. **Visualize** + +Plus a few requirements: support both automatic and manual submission, results must be reproducible, and when a regression is found, **open a ticket promptly** — while the code is still hot and the author hasn't moved on to the next task, regressions are easiest to fix; drag it out two weeks and the author has forgotten what they changed, and fixing it is twice the work for half the result. + +## Tying this chapter together + +That completes the ch01 loop: + +- ch01-01~04: **microbenchmark** A/B on the dev machine, eliminating noise, reporting the median, running hypothesis tests. Answers "is the change direction right". +- ch01-05 (this article): **production measurement + CI regression**. Production replicates real noise + statistical quantiles; CI uses change-point detection to catch regressions automatically. Answers "did it really get faster in production, and has it silently degraded". + +The two can't be mixed: don't back production with micro numbers, and don't use micro's "eliminate noise" routine in the production environment. They're the two ends of the measurement spectrum, and the transition in the middle is handled by macro benchmarks (representative of real load but controlled), which is the business of later chapters. + +## References + +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs* §2.2 *Measuring Performance In Production*, §2.3 *Automated Detection of Performance Regressions* (Netflix Icarus, Ren 2010, Liu 2019, MongoDB Evergreen / Daly 2020, AutoPerf / Alam 2019 all come from here). +- Chromium LUCI performance dashboard docs. +- This volume's ch01-01 (the micro vs macro boundary), ch01-04 (statistical methods). diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md b/documents/en/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md new file mode 100644 index 000000000..c06b6bb46 --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md @@ -0,0 +1,119 @@ +--- +chapter: 1 +cpp_standard: +- 11 +- 17 +description: The quick-reference page for all of ch01. Later performance articles and Labs reference it from their opening. One card condenses environment readiness, credible-microbenchmark writing, reporting and comparison, the micro vs production/CI boundary, and a perf cheat sheet. +difficulty: intermediate +order: 6 +platform: host +prerequisites: +- How to write a credible microbenchmark +reading_time_minutes: 4 +related: +- Why microbenchmarks lie +- 'Measurement pitfalls and environment readiness: a 16-item checklist' +- 'Statistics and reporting: turning a distribution into a conclusion' +- Production measurement and CI performance regression detection +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: Benchmark methodology reference card +translation: + source: documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md + source_hash: 0f7fcb6674e2c52d1d4cb29efbf97dd6b3e1fa4b6842147dcbc9f1c300a446ce + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2200 +--- +# Benchmark methodology reference card + +> This is the **quick-reference page** for all of ch01. Every later performance article and every performance Lab in this volume opens by referencing the discipline taught here, the way vol5 runs TSan through all of concurrency correctness. This isn't a tutorial (the tutorial is ch01-01 through ch01-05); it's a reference card, the kind you tape to the wall. + +## §0 Premise (one sentence) + +**Performance is a random variable, not a number.** What you measure is always a distribution; you sample + do statistical inference, you don't run once and jot down a number. (See ch01-01.) + +## §1 Before measuring: environment readiness (micro A/B scenario) + +| Must-do | Command / how | +|---|---| +| Lock CPU governor | `sudo cpupower frequency-set -g performance` | +| Disable Turbo | BIOS, or lock the frequency | +| Pin a core | `taskset -c ./bench` (don't pick 0) | +| NUMA-bind a node | `numactl --cpunodebind=0 --membind=0 ./bench` | +| perf usable | `sudo sysctl -w kernel.perf_event_paranoid=1` | +| Compile flags | `RelWithDebInfo` (`-O2 -g`); for profiling add `-fno-omit-frame-pointer` | +| Health check | `bash perf-env-check.sh` (see ch01-03, check only, no modify) | + +> ⚠️ **Only do these in the micro A/B scenario.** When evaluating production performance **do none of them**: you want to replicate reality (keep DFS, neighbors, ASLR) and process the noise with statistics. See ch01-05. + +## §2 Writing a credible microbenchmark + +| Point | How | +|---|---| +| Use a framework, don't hand-roll | Google Benchmark as main workhorse, nanobench as lightweight supplement (instant feedback when teaching microarchitecture) | +| Prevent DCE | `benchmark::DoNotOptimize(x)` pins the result to memory/register; **note: does not prevent `x` itself from being computed away by constant propagation**, so the input must be runtime data | +| Force writes to land | `benchmark::ClobberMemory()` as the safety net | +| Sweep parameters | `->RangeMultiplier(2)->Range(8, 8<<10)`; `state.SetComplexityN(...)` auto-fits big-O | +| Repeat + aggregate | `->Repetitions(3)->ReportAggregatesOnly(true)` reports mean/median/stddev/cv | +| Wall-clock | `->UseRealTime()` (mandatory for multithreading) | + +See ch01-02 (with a complete runnable example and real output). + +## §3 Reporting and comparison + +- **Must report**: median, IQR or 95% CI, cv, sample count, environment snapshot (kernel / CPU / governor / `perf_event_paranoid`). +- **Forbidden**: single-pass mean (performance data is right-skewed; the long tail biases the mean). +- **A/B**: same environment, same binary (only one thing changed), multiple repetitions (N ≥ 30); for hypothesis testing **default to Mann-Whitney U** (non-parametric; performance data is almost never normal); only use t-test with a prior normality check. +- **Report effect size**: the full form "12% faster (95% CI [10%, 14%], p<0.01)", not just a p value. Statistically significant ≠ engineering-meaningful. +- **A bimodal distribution is a signal, not noise**: you've mixed two behaviors (cache hit/miss, lock contention); split them and measure separately. + +See ch01-04. + +## §4 micro vs production/CI (boundary, do not mix) + +| Scenario | What you do | Output | +|---|---|---| +| **micro A/B** | Eliminate noise, cleanly compare two implementations | "Is the change direction right" | +| **Production measurement** | Replicate real noise, telemetry samples quantiles (p90/p99), statistical A/B | "Did the user actually get faster" | +| **CI regression** | Change-point detection (E-Divisive) / PMC fingerprint (AutoPerf), auto-open tickets | "Has it silently degraded" | + +**Forbidden to convert across scenarios**: micro's 30% improvement doesn't carry proportionally to production. See ch01-01, ch01-05. + +## §5 How vol6 articles / Labs reference this + +- Every performance article and every performance Lab declares at its opening "this article follows the ch01 measurement methodology". +- When reporting performance numbers, attach an **environment snapshot** + **statistics** (median / cv / repetition count); don't report single-pass raw values. +- For A/B, use the §3 routine (same environment + same binary + Mann-Whitney + effect size). + +## §6 perf cheat sheet + +```bash +# Basic counts (health check: look at IPC, cache miss, branch miss) +perf stat -r 5 ./bench +perf stat -e cycles,instructions,cache-misses,branch-misses ./bench + +# Sampling profile (find hotspots; must use -fno-omit-frame-pointer or dwarf for stack) +perf record -F 99 -g --call-graph dwarf -- ./bench +perf report # interactive +perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg # flame graph + +# Microarchitectural attribution (detailed in ch03) +toplev -l3 taskset -c 0 ./bench # TMAM four-bucket drill-down, needs pmu-tools +``` + +The full workflow for flame graphs, TMAM, and `toplev` is the business of ch03 (attribution methodology); this is only the entry point. + +## References (tutorial articles) + +- ch01-01 "Why microbenchmarks lie" +- ch01-02 "How to write a credible microbenchmark" +- ch01-03 "Measurement pitfalls and environment readiness: a 16-item checklist" +- ch01-04 "Statistics and reporting: turning a distribution into a conclusion" +- ch01-05 "Production measurement and CI performance regression detection" +- Bakhvalov, D. *Performance Analysis and Tuning on Modern CPUs*, Chapter 2. +- Google Benchmark [user_guide](https://github.com/google/benchmark/blob/main/docs/user_guide.md), Brendan Gregg [perf / FlameGraphs](https://www.brendangregg.com/linuxperf.html). diff --git a/documents/en/vol6-performance/ch01-benchmark-methodology/index.md b/documents/en/vol6-performance/ch01-benchmark-methodology/index.md new file mode 100644 index 000000000..d58960851 --- /dev/null +++ b/documents/en/vol6-performance/ch01-benchmark-methodology/index.md @@ -0,0 +1,23 @@ +--- +title: "Benchmark methodology" +description: "The volume's anchor chapter: starting from why microbenchmarks lie, it builds out the full methodology of measurement, statistics, production and CI regression — every later performance article points back here" +--- + +# Benchmark methodology + +This is the volume's **anchor chapter**. ch00 established the two iron rules — "correct first, then fast" and "measure first, then optimize" — but behind "measure first" hides an entire discipline. Performance isn't a boolean, it's a distribution; a one-off hand-rolled measurement is almost meaningless; and the microbenchmark, the handiest tool in the box, is also the one most likely to lie to you. + +This chapter tears "measuring accurately" down to the studs: first see through the three ways a microbenchmark deceives you, then learn how to write one that doesn't (`DoNotOptimize` semantics, parameter sweeps, repetition aggregation), then a 16-item environment-ready checklist to shut noise sources down one by one, then statistical methods to turn a distribution into a trustworthy conclusion, and finally how to move measurement into production and CI to stand guard over performance. Every performance article after this one opens by pointing back to the rules here — the way vol5 threads TSan through concurrency correctness. + +If you only read a few chapters in this volume, this one should be most of them. + +## In this chapter + + + Why microbenchmarks lie to you + How to write a credible microbenchmark + Measurement pitfalls and environment readiness: a 16-item checklist + Statistics and reporting: turning a distribution into a conclusion + Production measurement and CI performance regression detection + Benchmark methodology reference card + diff --git a/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md new file mode 100644 index 000000000..d6982430b --- /dev/null +++ b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md @@ -0,0 +1,171 @@ +--- +chapter: 2 +cpp_standard: +- 14 +- 17 +description: Between the CPU and main memory sits a 100x speed gap. The memory hierarchy uses fast-but-small-expensive layers to cache slow-but-big-cheap ones. With a memory mountain measured on the author's own machine and a pointer-chasing latency ladder, this article pins down the latency and bandwidth of L1/L2/L3/DRAM and shows why sequential access is two orders of magnitude faster than random. +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 'Performance Mindset: efficiency is not performance' +- 'Why microbenchmarks lie' +reading_time_minutes: 11 +related: +- 'Cachelines and locality: the 64-byte minimum unit' +- 'Pipeline, ILP, and branch prediction' +- 'TLB, huge pages, and a microarchitecture cheat sheet across CPU families' +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存管理 +title: 'Memory hierarchy and the latency ladder: why sequential access is 100x faster' +translation: + source: documents/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md + source_hash: 1b8ce9865f2aa15f890735fe681aa5ffb0b04d3c69991dcbec62721b3508e158 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3200 +--- +# Memory hierarchy and the latency ladder: why sequential access is 100x faster + +## An uncomfortable fact: your CPU spends most of its time waiting on memory + +Back in ch00-01 we made a claim: `std::vector` binary search (O(log n)) can beat `std::set` lookup (also O(log n)) at moderate sizes, identical complexity, performance off by an order of magnitude. The one-line reason we hand-waved back then was "vector is contiguous and cache-friendly, set nodes are scattered and cache-missy." This chapter pulls that "cache-friendly" apart. Behind it is a cold physical fact about modern CPUs: **the CPU computes blazingly fast, but the data can't get to it in time.** + +In numbers: a modern CPU pipeline retires multiple instructions per cycle, while fetching one datum from main memory (DRAM) costs hundreds of cycles. In other words, **the moment your data isn't in cache, the CPU sits idle for hundreds of cycles** and all that compute goes to waste. Brendan Gregg calls this "the CPU is a jet engine and memory is a bicycle." No matter how powerful the engine, dragged by a bicycle, it goes nowhere fast. + +The prerequisite to understanding this is a precise picture of the memory hierarchy: the closer data is to the CPU, the smaller, more expensive, and faster; the farther away, the bigger, cheaper, and slower. In this article we draw that "latency ladder" from a measurement on my own machine, then explain how it dictates every layout decision you make writing C++. + +## The memory hierarchy: each layer fast-but-small, the next slow-but-big + +Here's a "textbook plus on-machine measurement" table. Latency numbers are orders of magnitude (they move with architecture and frequency), but the **ratios** are stable, and that's what to remember: + +| Layer | Typical latency (cycles) | Measured (ns) | Capacity (this machine, AMD Ryzen 7 5800H) | +|---|---|---|---| +| Registers | 0 cycles | — | a handful of general-purpose registers, compiler-allocated | +| L1 cache (data) | ~4 cycles | **~1.2** | 32 KB, private per core | +| L2 cache | ~14 cycles (Zen 3) | **~3–9** | 512 KB, private per core | +| L3 cache | ~47 cycles (Zen 3) | **~11–60** | 16 MB, shared across cores | +| Main memory DRAM | ~200–400 cycles | **~120** | tens of GB | + +> Cycle counts come from Agner Fog's *The microarchitecture of Intel, AMD and VIA CPUs*, Chapter 23 *AMD Zen 3* (Table 23.1: L1=4, L2=14, L3=47 cycles); ns values are pointer-chasing results measured on my 5800H (see below). + +Read this table vertically and you get the single most important ratio in this volume: **L1 hit ~1 ns, DRAM fetch ~120 ns, a full 100x apart, that is, two orders of magnitude.** That's the physical root of "why sequential traversal is two orders of magnitude faster than random (in latency)": sequential access pulls subsequent data into cache ahead of time, random access cold-misses every time and has to fetch from DRAM. + +Before you take my word for it, let's measure this ladder by hand. + +## Run it yourself: measuring the latency ladder on this machine + +The cleanest way to measure latency is **pointer chasing**: lay down a circular chain in memory where "the next address is hidden inside the current datum," and let the CPU walk it. The key is that the next address depends on the result of the current load. That's a true dependency, the hardware prefetcher can't guess ahead (it doesn't know what's next), so what you measure is the **bare memory-access latency**. + +The core loop is just a few lines (full program in this chapter's code, `memory_mountain.cpp`): + +```cpp +// Build a shuffled single ring that visits every node: nxt[perm[i]] = perm[(i+1) % n] +std::vector nxt(elems); +for (long i = 0; i < elems; ++i) nxt[perm[i]] = perm[(i + 1) % elems]; + +long idx = 0; +auto t0 = std::chrono::steady_clock::now(); +for (long s = 0; s < total_steps; ++s) { + idx = nxt[idx]; // next address depends on this load → true dep, prefetcher helpless + sink = sink + g_data[idx]; // read data[idx], creating a dependency on the chain +} +auto t1 = std::chrono::steady_clock::now(); +``` + +Sweeping the working set (array size) from 4 KB to 128 MB, crossing the L1/L2/L3/DRAM boundaries, and letting it "stomp" the entire working set each time, here's what I get on my machine (`taskset -c 0` to pin to core 0 and cut noise, WSL2 environment): + +```text +===== B. Pointer-chasing random-read latency (ns/access) ===== + size elems ns/access level(inferred) + 4K 512 1.19 L1d + 8K 1024 1.18 L1d + 16K 2048 1.24 L1d + 32K 4096 2.50 L1d ← working set = L1d capacity (32K), starts spilling + 64K 8192 3.33 L2 + 128K 16384 4.04 L2 + 256K 32768 6.02 L2 + 512K 65536 8.88 L2 ← working set ≈ L2 capacity (512K), starts spilling + 1024K 131072 11.42 L3 + 2048K 262144 12.40 L3 + 4096K 524288 22.59 L3 + 8192K 1048576 59.99 L3 + 16384K 2097152 96.27 L3 ← working set = L3 capacity (16M), heavy thrashing + 32768K 4194304 135.92 DRAM + 65536K 8388608 118.59 DRAM + 131072K 16777216 122.06 DRAM +``` + +This table is worth pausing on. It tells a very clean story: + +- **4K–16K: all in L1, ~1.2 ns.** This is what "hot cache" looks like, data sitting right next to the execution units. Note that 1.2 ns ÷ 4 cycles ≈ 3.3 GHz, exactly the 5800H's base-clock operating point, lining up precisely with Agner's L1 = 4 cycles. +- **32K: starts climbing (2.5 ns).** The working set exactly equals the L1d capacity (32 KB), it doesn't fit anymore, and some accesses spill to L2. This is the "overflow" tipping point of the cache. +- **64K–512K: into L2, 3–9 ns.** A clean L2 region, but latency creeps up as the working set grows, because the closer it gets to L2 capacity, the more conflict/capacity misses. +- **1M–16M: into L3, 11–60 ns.** Note the 16M row at 96 ns, the classic L3 thrashing regime (working set = L3 capacity, frantically evicting each other). +- **32M and up: DRAM, ~120 ns.** The ladder bottoms out here. + +L1's 1.2 ns and DRAM's 120 ns are exactly **100x** apart. That's not a textbook metaphor, it's the physical reality of the machine in your hands. + +> One caveat from me: this machine runs WSL2 (a VM), the host manages the CPU frequency, and I can't read the governor, so the absolute ns values jitter by a few percent. But **the ratios between levels** (L1≈1, L2≈a few, L3≈tens, DRAM≈hundreds of ns) are fixed by the hardware and very stable. What's actually trustworthy in a performance article is the ratio, not any single absolute number. + +## The memory mountain: drawing locality as a mountain + +The latency ladder answers only one dimension, "how does access latency change with the working set." The famous **memory mountain** from CSAPP Chapter 6 adds a second dimension, **stride**, so spatial and temporal locality get laid out together on one table. The information density is much higher. + +The idea: fix a working-set size, then read the array in a circular sequential pattern at different strides and measure throughput (GB/s). Smaller stride means consecutive accesses are more likely to land in the same cacheline (spatial locality); smaller size means the data is more likely still in cache (temporal locality). Measured on this machine: + +```text +===== A. Memory mountain: read throughput (GB/s) ===== +size\stride(B) 8B 16B 32B 64B 128B 256B 512B + 1K 16.9 17.0 17.0 16.7 16.1 16.5 15.3 + 8K 16.9 16.6 17.0 17.2 17.1 17.0 17.1 + 32K 16.8 16.9 16.9 16.9 16.8 17.2 16.5 ← L1d edge + 64K 16.7 16.9 17.2 16.7 16.8 16.6 7.3 + 256K 17.2 17.2 17.2 16.6 16.6 16.7 7.6 ← L2 region + 512K 16.7 16.8 16.6 14.3 11.3 11.6 5.3 ← L2 edge + 1024K 16.7 16.6 16.2 12.9 8.7 11.1 4.5 + 4096K 16.5 16.9 16.3 12.9 8.1 10.1 4.4 ← L3 region + 16384K 13.0 10.5 7.7 3.8 3.1 3.1 2.9 ← L3 edge, dropped + 32768K 14.5 11.0 6.7 3.8 2.7 2.6 2.1 ← into DRAM +``` + +How do you read this mountain? Watch two directions: + +**Down the "left column" (stride = 8B, fully sequential):** even with a 32 MB working set (way past L3), throughput is still 14.5 GB/s, almost the same as at 1 KB. That's the **hardware prefetcher** at work: it detects "you're scanning memory at a fixed stride" and pulls subsequent cachelines into cache ahead of time. In other words, **sequential access can approach L1 throughput even on DRAM**, because the prefetcher hides the latency. This is the real mechanism behind "sequential traversal is fast," and we'll unpack it below. + +**The "bottom-right corner" (stride = 512B, working set 32 MB):** throughput collapses to 2.1 GB/s, 8x worse than the 17 GB/s in the top-left. A 512B stride means each access lands in a new cacheline, and the stride is too large for the prefetcher to keep up (it can generally only track a limited number of streams), so every access becomes a near-random DRAM fetch. **This is DRAM's true face: fast when sequential, brutally slow when random.** + +Read this mountain together with the latency ladder above and the conclusion is the same set: how fast the memory hierarchy is depends *both* on "is the data in cache" (temporal locality) *and* on "is the access pattern contiguous" (spatial locality). The former is decided by working-set size, the latter by access pattern. Get both right and you run at L1 speed; get both wrong and you drop to DRAM's scrap. + +## Back to C++: what these numbers are telling us + +Hardware knowledge isn't for showing off. It's for guiding how you write code. From this ladder we can translate directly to a few C++ layout principles, one by one: + +**1. Keep data contiguous; prefer contiguous containers.** This is the most direct beneficiary of spatial locality. `std::vector` / `std::array` pack elements tightly into one contiguous block of memory; when traversing, a single cacheline (64 bytes) holds several elements, and the prefetcher pulls ahead for you. That's the root cause of fast vector traversal. Conversely, nodes of `std::list` / `std::set` are each `new`'d and scattered across the heap, and pointer chasing over them is the slowest curve in the latency ladder above. **Same complexity, different layout, performance off by an order of magnitude.** ch00-01 measured this with vector binary search vs set lookup; here we fill in the physical basis. + +> Boundary note: the **design-level** internals of vector — three pointers, the growth strategy (2x), the element-move cost of insert/erase — belong to vol3 (it answers "why is vector designed this way"). vol6 only answers "why is contiguous layout fast when running on hardware," which is exactly this latency ladder. + +**2. Keep the hot working set inside L3.** The latency ladder shows that once the working set exceeds cache capacity, latency jumps from nanoseconds to tens or hundreds of nanoseconds. A very practical corollary: **data that gets scanned repeatedly needs to "fit in cache."** For example, a table queried at high frequency that's 20 MB on a 5800H (whose L3 is only 16 MB) thrashes on every query; if you can compress it to under 12 MB, performance might jump several times. This isn't mysticism, it's the gap between the 16M row (96 ns) and the 4M row (22 ns) in the table. + +**3. For random-access workloads, switch to a structure that cuts the number of memory accesses.** Linked lists and balanced trees jump one node at a time, and every jump is a potential cache miss (nodes are scattered). A common engineering compromise is to **flatten the tree**: replace a binary balanced tree with a B-tree / B+ tree, where each node stores dozens or hundreds of keys (cramming one or two cachelines), the height drops sharply, and so do the misses. That's why database indexes and filesystems all use B+ trees instead of red-black trees, not to save pointers, but to save cache misses. `std::set` gets walloped by a B-tree on large-data, query-heavy workloads, and this is the root cause. + +**4. `reserve` capacity up front, don't let growth shatter your layout.** This one's more of a C++ engineering practice: a `vector` repeatedly `push_back`'d triggers growth, which moves the whole block to new memory, invalidating the old cache, and the move itself is a big cost. Calling `reserve(estimated capacity)` ahead of time avoids that. The specifics of the growth mechanism (why 2x, the cost of one move) belong to vol3; here just remember the conclusion: **on a hot-path vector, reserve up front.** + +## Threads left for the next article + +In this article we drew the **latency ladder** on this machine (L1 ~1 ns, L2 ~a few ns, L3 ~tens of ns, DRAM ~120 ns, a 100x gap at each step) using pointer chasing, and used the memory mountain to see **how spatial locality (stride) and temporal locality (size) together determine throughput**. The C++ principles translated from it — use contiguous containers, control the hot working set, swap random access for a B-tree, reserve — are all direct corollaries of these numbers. + +But two details we deliberately skipped: how exactly that recurring "**64 bytes**" is derived (why is 32K the edge in the L1 table? why is stride 64B a cliff?), and the counterintuitive costs that come with the cacheline as the minimum unit of cache (for example, two unrelated variables crammed into the same line kicking each other out). That's exactly the topic of the next article. + +There's also one bigger thing left for later: this chapter only covered "where data is slow." It didn't cover "why computation stalls." Pipelining, instruction-level parallelism, branch prediction — those are the subject of ch02-03, and together with the memory hierarchy they form the hardware foundation underpinning every "optimize by bottleneck site" chapter later in vol6. + +## References + +- Agner Fog, *The microarchitecture of Intel, AMD and VIA CPUs*, §23 *AMD Zen 3*: Zen 3 cache-latency table (L1=4, L2=14, L3=47 cycles), pipeline widths, branch throughput. Local copy: `.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Bakhvalov, D., *Performance Analysis and Tuning on Modern CPUs*, Chapter 3 *CPU Microarchitecture*: an engineering view of the memory hierarchy and latency numbers +- Bryant & O'Hallaron, *Computer Systems: A Programmer's Perspective* (CSAPP), Chapter 6 *The Memory Hierarchy*: the source of the memory-mountain experiment, and the formal definitions of "spatial/temporal locality" +- Source for this article's measurements: `code/volumn_codes/vol6-performance/ch02/memory_mountain.cpp` diff --git a/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md new file mode 100644 index 000000000..9f210686d --- /dev/null +++ b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md @@ -0,0 +1,202 @@ +--- +chapter: 2 +cpp_standard: +- 17 +description: Cache doesn't move bytes one at a time, nor in whatever size you happen to access. It moves data in 64-byte cachelines, so even a 1-byte read pulls an entire 64-byte line into cache. With a stride scan we pin down this 64-byte cliff precisely, then use a 6x gap between row-major and column-major traversal to show the power of spatial locality. +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 'Memory hierarchy and the latency ladder: why sequential access is 100x faster' +reading_time_minutes: 12 +related: +- 'Pipeline, ILP, and branch prediction' +- 'Backend memory bottlenecks: cache-friendly, AoS/SoA, and prefetch' +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 内存管理 +title: 'Cachelines and locality: the 64-byte minimum unit of transfer' +translation: + source: documents/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md + source_hash: 6a6e7203adac373ba9ae435ddbceb1e218234d7285757ec965f432b9c8968917 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3400 +--- +# Cachelines and locality: the 64-byte minimum unit of transfer + +## Starting from the "cliff" in the previous mountain + +The memory mountain from the last article hid a detail we waved past at the time. Pull out the row of that mountain where the working set is 1024K (which lands in L3): + +```text +1024K 16.7 16.6 16.2 12.9 8.7 11.1 4.5 + 8B 16B 32B 64B 128B 256B 512B ← stride +``` + +Throughput barely moves from stride 8B up to 32B (around 16 GB/s); the moment it crosses **64B**, the numbers drop noticeably. That's not noise, the position of this "cliff" is stable. It corresponds to a hard physical parameter of the cache: the size of a **cacheline**. In this article we crack open that 64 bytes; it's the key to understanding every "why does layout affect performance" question. + +## The cacheline: the minimum unit of cache + +A lot of people hold an intuitive but wrong model of cache: I access an `int` (4 bytes), the hardware goes to memory and pulls 4 bytes in. **Not even close.** The reality is that transfers between cache and main memory happen in fixed-sized blocks, and this block is called a **cacheline** (also cache line / cache block). On contemporary x86 and ARM, this size is almost universally **64 bytes**. + +What this means: whether you access a 1-byte `char`, an 8-byte `double`, or just read an `int` once, **if this access misses the cache, the hardware pulls in the entire 64-byte cacheline containing that address, as a block.** You only wanted to pay for 4 bytes; you actually paid the time to move 64 (though the rest of those 64 bytes are free if you access them next — that's exactly the mechanism of spatial locality). + +The 64-byte number isn't a guess, the OS tells you directly. On this machine: + +```bash +$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size +64 +$ getconf LEVEL1_DCACHE_LINESIZE +64 +``` + +`index0` is the L1 data cache, and `coherency_line_size` is the cacheline size. L1/L2/L3 are all 64 bytes (you can check `index1/2/3` one by one; on this machine all 64). This value is fixed for a given CPU generation, so performance articles often hard-code "64B," but **the first time you tune on an unfamiliar machine, confirm with `getconf` first.** Some low-power ARM cores or older Intel parts use 32-byte lines. This reminder sounds naggy until you've actually stepped in the 32B-line pit. + +With the model clear, let's measure this 64 by behavior. + +## Run it yourself: a stride scan to locate the 64-byte cliff + +The idea is direct: pin the working set to a size "larger than L1, landing in L3" (so every miss has real cost), then vary only the stride and watch where throughput falls off a cliff. If the cliff lands exactly at stride = 64B, that's reverse-proof that "cache moves in units of 64 bytes." + +The core is still sequential circular traversal, but now we sweep the stride finely: + +```cpp +double stride_throughput(long elems, long stride_elem) { + const long ACCESSES = 64'000'000; + long mask = elems - 1; // elems is a power of 2 + int sink = 0; long idx = 0; + auto t0 = std::chrono::steady_clock::now(); + for (long i = 0; i < ACCESSES; ++i) { + sink += g_data[idx]; + idx = (idx + stride_elem) & mask; + } + do_not_optimize(sink); // defeat DCE, same semantics as Google Benchmark DoNotOptimize + auto t1 = std::chrono::steady_clock::now(); + double secs = std::chrono::duration(t1 - t0).count(); + return (double)ACCESSES / secs / 1e6; // M accesses/sec +} +``` + +Working set 2 MB (`int` array, 512K elements), `taskset -c 0` pinned, sweep the stride: + +```text +===== A. Stride scan (working set 2MB, in L3) ===== +stride(B) M accesses/sec note + 4B 2017.5 < cacheline: multiple accesses to the same line get amortized + 8B 1956.3 < cacheline: multiple accesses to the same line get amortized + 16B 1986.7 < cacheline: multiple accesses to the same line get amortized + 32B 1962.3 < cacheline: multiple accesses to the same line get amortized + 48B 1820.1 < cacheline: multiple accesses to the same line get amortized + 56B 1543.5 < cacheline: multiple accesses to the same line get amortized + 64B 1422.1 = cacheline: each access exactly changes line ← cliff + 72B 1181.8 > cacheline: every access hits a new line + 96B 1034.8 > cacheline: every access hits a new line + 128B 995.8 > cacheline: every access hits a new line + 256B 1324.2 > cacheline: every access hits a new line ← anomalous bump, see below (suspected prefetcher) + 512B 528.7 > cacheline: every access hits a new line +``` + +How to read this table: watch "**stride = 64B is the dividing line**." + +- **stride < 64B (4 through 32)**: several consecutive accesses land in the same 64-byte cacheline. The first access misses and pulls the whole line in; the following accesses all hit the line that's already been brought in, almost free. So throughput is high and flat (~2000 M/sec). The parts of this 64-byte line you didn't touch got a "free ride." +- **stride ≥ 64B (64, 72, 96…)**: every access steps into a **new** cacheline, the "free ride" is gone, and each one pays the cost of moving a line. Throughput drops accordingly to ~1000–1400 M/sec. + +This drop from ~2000 to ~1000, landing exactly at stride = 64B, is our behavioral cross-check on "a cacheline is 64 bytes." + +> I want to flag the 256B row specifically: it's actually higher than 128B (1324 vs 996), which looks "unscientific." **The most likely suspect is the hardware prefetcher**: a modern CPU's prefetcher can recognize a "fixed-stride" access stream (quite large strides are still within its learning window), pull subsequent lines in ahead of time, and partially hide the latency. **But this is mechanism-based speculation, not a measured verdict**: on this WSL2 machine I have no way to turn the prefetcher off for a control (disabling it requires writing to an MSR, which WSL2 can't reach), and there's no public documentation for the exact maximum trackable stride / stream count of the Zen prefetcher. The 256B row also catches a geometric dividend from "the same-array traversal period getting shorter, so warmup is more complete" (see `mask = elems-1` for the circular traversal in the code). At 512B the number keeps dropping, and the dominant factor there is more likely cacheline utilization (each access consumes only a small slice of a 64-byte line; see the bandwidth drop at large strides in 02-01). The takeaway: **when measuring cache behavior, the prefetcher is a recurring troublemaker variable**, and an "inexplicable bump" should make you suspect it first. But also admit that until you've run the control experiment, "suspicion" is not "proof" (this is exactly the "sounds-like-an-explanation" false-causality trap that vol6 ch00-01 keeps warning about). + +## Spatial locality: contiguous layout is a "double win" + +Translate the previous section's conclusion into a C++ design principle, and you get the one everybody has heard but maybe not thought through: **keep data contiguous.** Contiguous layout cashes in on a double dividend: + +1. **Amortizing the cacheline load**: one 64-byte line comes in, and contiguous access uses every element in those 64 bytes, nothing wasted. For the same one miss, accessing 16 `int`s (64B) versus 1 `int` costs the same to move. +2. **Feeding the prefetcher**: the prefetcher loves "sequential, fixed-stride" streams. When you traverse a `vector`, it detects the stride = 4B stream and pulls the next few cachelines into cache ahead of time, so by the time you need them they already hit, **it even saves you the misses**. + +That's why `std::vector` / `std::array` / native arrays fly when traversed, while `std::list` / `std::set` / `std::unordered_map` (chained buckets) crawl over their nodes: the latter's nodes are scattered across the heap, you can neither amortize (each node occupies a cacheline but you only read a small slice) nor can the prefetcher keep up (the next node's address is unpredictable). + +This principle has one classic pitfall that every C++ programmer should step in once with their own feet: the traversal order of a 2D array. + +## Row-major vs column-major: where does the 6x gap come from + +2D arrays in C and C++ (whether native `a[N][N]` or simulated as 1D `a[i*N+j]`) are stored in memory in **row-major** order: store the first row, then the second. So `a[i][j]` and `a[i][j+1]` are adjacent in memory (4 bytes apart), but `a[i][j]` and `a[i+1][j]` are a whole row apart (`N*4` bytes). + +What this means: when double-looping over a matrix, **the inner loop walking along a "row" is sequential access, walking along a "column" is a big-stride jump.** We measure with a 2048×2048 `int` matrix (16 MB, exactly this machine's L3 size, to amplify the gap): + +```cpp +void walk_2d(int* a, int N, bool row_major) { + volatile int sink = 0; int s = 0; + auto t0 = std::chrono::steady_clock::now(); + if (row_major) + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) s += a[i * N + j]; // along a row: sequential, stride=4B + else + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) s += a[j * N + i]; // along a column: jumping, stride=N*4B=8KB + sink = s; + auto t1 = std::chrono::steady_clock::now(); + /* print elapsed time and throughput */ +} +``` + +```text +===== B. 2D traversal (N=2048, matrix 16 MB = L3 size) ===== + row-major: 1.0 ms, 16.7 GB/s + column-major: 6.3 ms, 2.7 GB/s +``` + +**Same matrix, same amount of work, same L3 residency, column-major is more than 6x slower than row-major.** + +Breaking it down: in row-major the inner `j` advances 4 bytes each step, fully sequential, one cacheline (64B) serves 16 `int` accesses, and the prefetcher pulls data ahead, throughput hits 16.7 GB/s. In column-major, the inner `j` makes the address jump `N*4 = 8192` bytes each step, every access steps into a brand-new cacheline, and an 8 KB stride is so large the prefetcher can't keep up (recall the 512B row's misery a section ago), so it degenerates to L3's random-access throughput, leaving only 2.7 GB/s. + +That's the entire reason behind "the inner loop has to walk along the memory-contiguous direction." Anyone writing matrix multiply, image convolution, or grid simulations gets this direction backwards and gives away several times the performance for free, and the compiler **will not** flip it for you (it can't prove swapping loop order doesn't change the result; in most cases it actually doesn't, but the compiler doesn't dare). + +> As an aside: this "swap loop order" trick is called **loop interchange**, one of the compiler optimizations ch04-02 will cover. Here just hold on to its physical motivation: make the innermost loop walk along the memory-contiguous direction. + +## Struct field ordering: pack hot data into the same line + +Cachelines also directly drive one C++-specific layout decision: **how to order the fields of a struct.** Look at these two: + +```cpp +struct Bad { + int id; // hot: queried every frame + char debug_tag; // cold: only read when logging + double values[6]; // hot: computed every frame + void* parent; // cold: only used when walking the tree +}; + +struct Good { + int id; // hot + double values[6]; // hot ← hot fields clustered + char debug_tag; // cold + void* parent; // cold ← cold fields separated +}; +``` + +`Bad` interleaves hot and cold fields; every time you touch a hot field during traversal, you incidentally drag the cold fields in the same cacheline into cache too, wasting precious cache space that could have held more hot objects. `Good` concentrates the hot fields so a single cacheline holds as much "data that will actually be used" as possible. This is called **hot/cold splitting**, and at heart it's about marshaling cachelines so every slot lands on the cutting edge. + +There's a more aggressive version of this called **AoS → SoA** (Array of Structs to Struct of Arrays): when you have a ton of objects but only process one of their fields at a time (say, only updating positions in a physics sim), laying out "the same field contiguously" beats "the same object's fields adjacent" by a lot. This goes deeper and has more nuance than field ordering, so we save it for ch04-01 "backend memory bottlenecks." Here, just plant the idea. + +> Boundary note: the **alignment, padding, `#pragma pack`** machinery behind "why `sizeof` ends up bigger than you'd think" (for example, the compiler inserts padding after a `char` to align a `double` to 8 bytes) belongs to ABI / layout rules, and vol4 will touch it when covering class layout. vol6 here only cares about the performance-side meaning of "hot-field clustering is cache-friendly." + +## A thread left dangling: the cost of sharing a line (false sharing) + +There's one more face of the cacheline we've deliberately left unfolded. Since "adjacent addresses share the same cacheline," what happens when two **unrelated** variables happen to be crammed into the same 64 bytes? They kick each other out of cache, and worse, under multithreading they trigger **false sharing**: thread A writes variable x, thread B writes variable y; even though x and y are logically unrelated, they're in the same cacheline, and the hardware coherence protocol repeatedly invalidates that line back and forth between the two cores. Performance collapses. + +This doesn't show up on a single core. It's a multicore-only pitfall, and a deep one. So the full false-sharing measurement and the `alignas(64)` fix we save for ch05's multicore chapter. Here just bury the foreshadowing: the "sharing" of cachelines is a dividend on one core, and possibly a tax on many. + +## Threads left for later + +By the end of this article we've confirmed that the minimum unit of cache transfer is the **64-byte cacheline**, and seen its consequences through two measurements: the **stride scan** finds a throughput cliff exactly at stride = 64B (a behavioral proof), and **row-major vs column-major traversal differs by 6x** (the inner-loop direction decides whether your access is sequential or a big-stride jump), plus **hot-field clustering** making precious cachelines hold only data that gets used (AoS→SoA is saved for ch04-01), and the multicore dark side, **false sharing** (saved for ch05). + +But program performance isn't determined only by data movement. The way the CPU executes instructions itself (pipelining, instruction-level parallelism, branch prediction) can make the same data run several times faster or slower. The next article steps into the CPU's execution core. + +## References + +- Agner Fog, *The microarchitecture of Intel, AMD and VIA CPUs*, §22.16 *Cache and memory access*: Zen-family cache parameters (64 B lines, associativity/sets at each level), hardware-prefetch behavior. Local copy: `.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Bryant & O'Hallaron, *CSAPP*, Chapter 6 *The Memory Hierarchy*: formal definitions of cachelines, spatial/temporal locality, and the memory mountain +- Drepper, U., *What Every Programmer Should Know About Memory*: engineering details of cachelines, alignment, and prefetching (classic long-form) +- Source for this article's measurements: `code/volumn_codes/vol6-performance/ch02/cacheline_locality.cpp` diff --git a/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md new file mode 100644 index 000000000..5d699261d --- /dev/null +++ b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md @@ -0,0 +1,159 @@ +--- +chapter: 2 +cpp_standard: +- 17 +description: 'The first two articles covered data movement. This one steps into the CPU''s execution core: how the instruction pipeline overlaps instructions, how out-of-order execution mines instruction-level parallelism (ILP) from your code, and why a branch misprediction flushes the pipeline. Two measurements — dot1 vs dot4 at 2.9x and sorted vs shuffled arrays at 4.2x — make it concrete, and we introduce the three pipeline hazards (data, control, structural).' +difficulty: advanced +order: 3 +platform: host +prerequisites: +- 'Memory hierarchy and the latency ladder: why sequential access is 100x faster' +- 'Cachelines and locality: the 64-byte minimum unit of transfer' +reading_time_minutes: 11 +related: +- 'Loops and compute optimization: code motion, unrolling, and multiple accumulators' +- 'Branches: branchless and predication' +tags: +- host +- cpp-modern +- advanced +- 优化 +- atomic +title: 'Pipeline, ILP, and branch prediction: same data, several times the execution speed' +translation: + source: documents/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md + source_hash: ff71a74f8f884866fff5246e94238587c8a284ac1319cdf25076c3a34e34caa7 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3300 +--- +# Pipeline, ILP, and branch prediction: same data, several times the execution speed + +## Data can be moved — but can it be computed + +The previous two articles took the memory hierarchy apart end to end: contiguous data, row-major traversal, controlling the hot working set. Those solve "can data reach the CPU's mouth in time." But in ch04 you'll see some counterintuitive phenomena: sometimes the data layout is unchanged, and just writing a few extra accumulators in a loop, or rewriting a branch a different way, costs another several times in performance. That tells you the other half of what decides performance is **how the CPU executes instructions**, independent of data movement. + +This article walks into the CPU's execution core and covers three interrelated mechanisms: **the pipeline** overlaps instructions, **instruction-level parallelism (ILP)** makes independent instructions genuinely run at the same time, and **branch prediction** gambles on a direction when an `if` shows up. Together they decide "how fast your instruction sequence can run." We stop at the depth "enough to support judgment"; the deeper water (register renaming, the reorder buffer ROB, execution-port scheduling) we leave to Agner's microarchitecture manual, with pointers. + +## The pipeline: an assembly line for instructions + +Executing one CPU instruction isn't "done in one cycle" the way you might think. It's split into stages like a factory assembly line, typically: fetch → decode → execute → memory → write-back. Each instruction flows through these stages in turn, and **different stages of different instructions advance in parallel within the same cycle**: while instruction N is executing, N+1 is decoding, N+2 is being fetched. That's the **pipeline**. + +Ideally the pipeline retires one instruction per cycle (a classic scalar pipeline); contemporary CPUs go further with **superscalar** designs, where each stage handles multiple instructions, so multiple instructions retire per cycle. In the AMD Zen chapter, Agner gives Zen's retire width as **8 µops/cycle** (a µop is a micro-operation internal to the CPU; one x86 instruction may split into several µops). That's the concrete meaning of "the CPU computes blazingly fast": 8 micro-operations per cycle. + +But this "8/cycle" is an **upper bound**. Whether you hit it depends on two things: whether the pipeline is **fed enough** (no hazard blocking it), and whether the code has **enough independent instructions for it to parallelize** (ILP). + +## ILP: out-of-order execution mines parallelism from your code + +For that "8 µops/cycle" throughput to hold, the 8 µops must have **no data dependencies among them**. Contemporary CPUs achieve this with **out-of-order execution**: instead of running instructions one by one in the order you wrote them, the CPU dynamically scans the instruction window and simultaneously issues independent instructions to multiple execution units. The fewer the data dependencies in your code, the more instructions can run in parallel, the higher the **instruction-level parallelism (ILP)**, and the faster it goes. + +Conversely, if you write a **long dependency chain** where every instruction depends on the previous one's result, it doesn't matter how wide the CPU is, it just waits. The most common example is a "single-accumulator reduction": + +```cpp +float dot1(const float* a, const float* b) { + float acc = 0.0f; + for (int i = 0; i < N; ++i) acc += a[i] * b[i]; // each iteration depends on the previous acc + return acc; +} +``` + +`acc += a[i]*b[i]` is a **true dependency**: this iteration's add needs the previous `acc`, and the CPU can't parallelize adjacent multiply-adds. That's a long chain, ILP is essentially zero, and the execution units sit idle most of the time waiting for the next add to finish. + +The classic way to break this chain is **multiple accumulators**: use several independent accumulators, each maintaining a short chain: + +```cpp +float dot4(const float* a, const float* b) { + float a0 = 0, a1 = 0, a2 = 0, a3 = 0; + for (int i = 0; i < N; i += 4) { + a0 += a[i] * b[i]; // chain 0 + a1 += a[i + 1] * b[i + 1]; // chain 1, independent of chain 0 + a2 += a[i + 2] * b[i + 2]; // chain 2 + a3 += a[i + 3] * b[i + 3]; // chain 3 + } + return a0 + a1 + a2 + a3; +} +``` + +Four accumulators are four independent chains, and the CPU dispatches them to different execution ports to run genuinely in parallel. Let's measure (to demonstrate scalar ILP, we **deliberately disable auto-vectorization** when compiling; otherwise SIMD would do this even faster and mask the ILP effect, which itself is a foreshadowing we'll get to): + +```text +===== B. ILP (dot product of 32768 floats, scalar, no vectorization) ===== + single accumulator dot1: 23.7 us/run (one long dependency chain, CPU waits for each add) + 4 accumulators dot4: 8.1 us/run (4 independent chains, CPU fills the execution ports in parallel) + 2.92x difference +``` + +**Same number of multiply-adds, 4 accumulators is nearly 3x faster than 1.** That's direct evidence of ILP. The assembly gives it away too: + +```text +; dot1: one serial chain + mulss (%rsi,%rax), %xmm0 ; compute a[i]*b[i] → xmm0 + addss %xmm0, %xmm1 ; acc += xmm0; next iteration's add depends on xmm1 here + +; dot4: four independent chains (accumulators xmm1/xmm4/xmm3/xmm2 are mutually independent) + mulss (%rsi), %xmm0 ; addss %xmm0, %xmm1 + mulss -12(%rsi), %xmm0 ; addss %xmm0, %xmm4 ← independent of xmm1 + mulss -8(%rsi), %xmm0 ; addss %xmm0, %xmm3 ← independent of xmm1/xmm4 + mulss -4(%rsi), %xmm0 ; addss %xmm0, %xmm2 ← independent of the three above +``` + +Each `addss` in dot1 waits for the previous writeback to `%xmm1`; in dot4 the four adds write to four different registers, and the out-of-order engine fires them all at once. This lesson gets a full treatment in ch04-02 "loops and compute optimization," where it's called the **multiple-accumulator transform** / **breaking the dependency chain**, one of the easiest performance dividends to pick up in scientific computing, reductions, and dot-product-style code. + +> A note from me: you might think "I don't have to write dot4 myself, won't the compiler unroll automatically?" It will, but usually needs `-O3 -funroll-loops`, and it can't violate floating-point associativity (`-ffast-math` is required for that), so under default `-O2` an FP reduction often stays a single chain. That's why we hand-write dot4 above to reliably capture ILP. The "what the compiler *can* do vs what it *will* do, separated by optimization level and language semantics" topic gets a systematic treatment in ch04. + +## Branch prediction: guess right and it's free, guess wrong and the pipeline flushes + +The second mechanism is **branch prediction**. To keep throughput high, the pipeline doesn't stop and wait when it hits an `if`. It **guesses** which way to go and then speculatively keeps executing. If it guesses right, the speculative work is all kept, almost free; if it guesses wrong, every instruction the speculative phase stuffed into the pipeline must be **flushed**, and fetch restarts from the correct direction. The cost of a flush is having executed a dozen to twenty-some cycles of work for nothing; the deeper the pipeline, the more a wrong guess hurts. + +That leads to a counterintuitive but extremely important conclusion: **the cost of a branch doesn't depend on the branch itself, but on whether it's "easy to predict."** A branch that always goes the same way (like a loop-exit condition) has a 100% predictor hit rate and is almost free; a 50/50 random branch can only be guessed half right, and every wrong guess pays the flush cost. The classic experiment, on the same array, "if it's ≥ 128, add it up": + +```cpp +uint64_t sum_gt128(const std::vector& d) { + uint64_t s = 0; + for (int i = 0; i < N; ++i) { + if (d[i] >= 128) s += d[i]; // a two-way branch + } + return s; +} +``` + +Prepare two datasets: a **shuffled array** (each element ≥128 or not is roughly random, branch is 50/50 unpredictable) and a **sorted array** (first half all <128, second half all ≥128, branch pattern is very clear). Same code, same amount of data, only the order differs: + +```text +===== A. Branch prediction (conditional sum over 32768 elements, 3000-run average) ===== + shuffled (random branch, predictor can't hit): 0.053 ms/run + sorted (clear pattern, almost no mispredictions): 0.013 ms/run + 4.2x difference +``` + +**4.2x.** Same accumulation, same data-movement cost, the gap comes entirely from branch-prediction-miss flushes. The sorted array's branch is "first consecutive not-taken, then consecutive taken"; the predictor learns it in two or three tries and the hit rate approaches 100%. The shuffled array can't be guessed right, flushing the pipeline nearly every other iteration. That's the answer to the famous Stack Overflow question "why is processing a sorted array faster" — the root cause isn't the data, it's the **predictability of the branch**. + +This conclusion has two corollaries that run through later chapters: + +1. **A predictable branch is almost free.** If an `if` inside a loop almost always goes the same way, don't bother eliminating it. What's worth eliminating is the **data-dependent, random branch**. +2. **A data-dependent random branch can be removed with a branchless rewrite.** Rewriting the `if` as a `cmov` (conditional move) or an arithmetic trick means the CPU doesn't have to gamble, no speculation, no flush. ch04-06 "branches: branchless and predication" covers this in depth; here we just plant the motivation. + +> A trap in this experiment has to be spelled out: if the code above is compiled at **default `-O2`**, GCC will **auto-vectorize** the `if`-sum into a SIMD compare-add, or turn it into a `cmov`. Either way the "branch" is gone, and shuffled and sorted end up equally fast (I stepped in this exact pit the first time around, 1.0x). So I deliberately add `-fno-tree-vectorize -fno-tree-slp-vectorize -fno-if-conversion` here to keep the scalar branch alive (those three flags block loop vectorization, SLP vectorization, and if-conversion respectively), and only then does the 4.2x show up. The teaching point here: **the "branch cost" you see actually depends on what the compiler turned your code into**, it may have already made it branchless, or it may not have. Read the assembly and confirm, don't assume. + +## Pipeline hazards: three kinds of stalls, matching the previous two sections + +Stringing the pipeline, ILP, and branch prediction together, CSAPP Chapter 4 uses the word "**hazard**" to unify them: under certain conditions the pipeline is forced to stall. They come in three kinds, matching exactly what was covered above: + +- **Data hazard**: adjacent instructions have a true data dependency (this one needs the previous one's result), and the pipeline must wait. That's the plight of dot1 in the ILP section, the long `acc +=` dependency chain is a string of RAW (read-after-write) hazards. The fix is to break the chain and raise ILP. +- **Control hazard**: a branch makes the fetch direction uncertain. That's the subject of the branch-prediction section, and the fix is either to make the branch predictable or to use branchless to eliminate it outright. +- **Structural hazard**: multiple instructions simultaneously compete for the same execution resource. For example, Zen's integer unit has 4 ALUs but only one divider, so multiple integer divides in the same cycle have to queue; FP divide is even scarcer and has longer latency (tens of cycles). That's why ch04-03 "data types and arithmetic" will specifically cover "division is a bottleneck, replace it with multiplication or bit ops when you can." It's not that division is slow, it's that dividers are few and high-latency, easy to hit a structural hazard. + +Remember those three names and you're set. CSAPP Chapter 4 has the full hazard-detection and forwarding machinery, which belongs to a computer-architecture course and vol6 won't reproduce. What we care about is "how these three stalls translate into C++-level performance pits," and that's ch04's job. + +## A thread for the next article + +This article covered three mechanisms on the CPU's execution side: **the pipeline** overlaps instructions (a Zen-class CPU has a theoretical retire width of 8 µops/cycle, but that's an upper bound), **ILP** decides whether you can approach that bound (the long dependency chain of dot1 crushes ILP to zero, multiple accumulators in dot4 pull it back up, 2.9x measured gap), and **branch prediction** penalizes unpredictable branches hard (sorted vs shuffled, 4.2x; predictable ones are almost free; the branchless rewrite for random branches is saved for ch04-06). Add the **three hazards** (data / control / structural), and you have the hardware foundation for ch04's "optimize by bottleneck site." + +That wraps up ch02's single-core hardware foundation: the memory hierarchy (02-01), cachelines and locality (02-02), and pipeline / ILP / branches (this article). The next article adds the last piece of the puzzle, virtual-address translation and the TLB, plus a cheat sheet of microarchitecture differences across CPU families, as a desk reference for anyone tuning across platforms. + +## References + +- Agner Fog, *The microarchitecture of Intel, AMD and VIA CPUs*, §22 *AMD Ryzen*: Zen-family pipeline widths (4-wide decode, 6 µop/clock dispatch, 8 µop/clock retire), branch throughput (taken 1/2 clock, not-taken 2/clock), µop cache, execution-unit counts. Local copy: `.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Bryant & O'Hallaron, *CSAPP*, Chapter 4 *Processor Architecture* (concept-level definitions of pipeline and hazards) and Chapter 5 *Optimizing Program Performance* (the classic derivations of loop unrolling, multiple accumulators, and reassociation) +- The legendary Stack Overflow question *Why is processing a sorted array faster than processing an unsorted array?* (the source of the branch-prediction experiment, muffinista / Mysticial's classic answer) +- Source for this article's measurements: `code/volumn_codes/vol6-performance/ch02/pipeline_branch_ilp.cpp` diff --git a/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md new file mode 100644 index 000000000..82d0d95fd --- /dev/null +++ b/documents/en/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md @@ -0,0 +1,118 @@ +--- +chapter: 2 +cpp_standard: +- 17 +description: 'The ch02 finale: add the last puzzle piece — virtual-address translation. The TLB caches page-table entries, and huge pages use bigger pages to cut TLB pressure. Plus a microarchitecture-difference cheat sheet across CPU families (Intel / AMD Zen / Apple / ARM) as a desk reference for cross-platform tuning.' +difficulty: advanced +order: 4 +platform: host +prerequisites: +- 'Memory hierarchy and the latency ladder: why sequential access is 100x faster' +- 'Cachelines and locality: the 64-byte minimum unit of transfer' +reading_time_minutes: 8 +related: +- 'Backend memory bottlenecks: cache-friendly, AoS/SoA, and prefetch' +- 'Measurement traps and environment readiness: a 16-item checklist' +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存管理 +title: 'TLB, huge pages, and a microarchitecture cheat sheet across CPU families' +translation: + source: documents/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md + source_hash: ae854453ebcd64b29fda774e5b9324ee03cb69aad919460a80fccbc1a6840e54 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2800 +--- +# TLB, huge pages, and a microarchitecture cheat sheet across CPU families + +## There's one more translation gate + +The first three articles exhausted cache, but the addresses a program uses are **virtual addresses**, while cache and main memory use **physical addresses**. Between the two sits a translation: every memory access has to translate the virtual address into a physical one before it can look in cache or DRAM. If this translation had to walk the full page table every time, the cost would be staggering. That's exactly the problem the **TLB (Translation Lookaside Buffer)** solves. + +This article first lays out the TLB and huge-page mechanism, then appends a cheat sheet of microarchitecture differences across CPU families as the ch02 finale. After this, ch02's single-core hardware foundation is fully laid, and ch03 (attribution) and ch04 (optimization by bottleneck site) can build on top of it. + +## The TLB: caching page-table entries, otherwise one translation costs several DRAM accesses + +An x86-64 virtual address is 48 bits (newer CPUs support 57 bits, 5-level page tables), and the physical page size is **4 KB**. The page table is a 4-level tree (9 bits of index per level + 12 bits of page offset): to translate a virtual address, the CPU in principle has to walk 4 page-table pages in turn (one per level), each living in memory. **Worst case: one address translation = 4 DRAM accesses.** If it really worked that way, all the latency the cache saved upstream would be paid back and then some. + +The TLB is the cache for this page table: it records recently translated "virtual page → physical page" mappings, and the next access to the same page just looks in the TLB, done in a few cycles, no page-table walk. The TLB and the data cache are two independent pieces of hardware. Your data may be in cache, but whether address translation goes through the TLB or the page table is a separate question. + +The TLB is also layered: each core has an L1 dTLB (data) and an L1 iTLB (instructions), small but fast; below that sits a shared L2 TLB (AMD/Intel structures differ slightly). The L1 dTLB usually holds only tens to around a hundred entries (each governing one 4 KB page), so **when the number of pages your working set touches exceeds the dTLB capacity, TLB misses start to happen, and every miss pays a page walk, on the order of several DRAM accesses, tens to hundreds of nanoseconds**. + +> The exact dTLB entry count varies by architecture and isn't the same across CPUs. For precise numbers, check the [Wikichip page for the microarchitecture](https://en.wikichip.org/wiki/amd/microarchitectures/zen_3) (separate pages for AMD / Intel) or the TLB section of Agner's microarchitecture manual. This article only covers structure and orders of magnitude. + +So the question is: **how do you know whether your program is taking TLB-miss pain?** The cleanest way is the hardware counters (`perf stat -e dTLB-load-misses`); a high TLB-miss rate combined with a working set whose page count far exceeds the dTLB capacity means you're TLB-bound. Another common signal: **big working set + random access** programs (random index lookups in a database, big hash tables), even when the data is all in DRAM, have latency that runs higher than a pure DRAM access. That extra chunk is often the page walk. + +## Huge pages: use bigger pages to bring the TLB entry count down + +Since the TLB's capacity bottleneck is "entry count," one direct fix is to **make pages bigger** so one entry covers more memory. In addition to 4 KB pages, x86-64 also supports **2 MB** (and even 1 GB) huge pages. One 2 MB page covers as much memory as 512 4 KB pages, so the same working set needs only 1/512 as many TLB entries with 2 MB pages. + +This translates to real performance in scenarios like these: + +- **Databases** (PostgreSQL, MySQL) with random access to big indexes: working sets of tens of GB, billions of TLB entries needed under 4 KB pages, guaranteed to thrash; switching to 2 MB pages cuts latency significantly. +- **Big hash tables / JVM heaps**: the Java community has long debated whether enabling transparent huge pages (THP) speeds things up; the root cause is that JVM heaps run to several GB and put heavy pressure on the TLB. +- **Random access to big arrays in scientific computing**: same logic. + +But huge pages aren't a free lunch. Bigger pages are harder to assemble (2 MB of contiguous physical memory is harder to find than 4 KB), which invites fragmentation; and the gain is small for **sequential-access**-dominant programs (the prefetcher and cache have already done the work, the TLB isn't the bottleneck). So the rule is: **confirm the TLB is really the bottleneck first (perf counters), then enable huge pages.** Don't turn them on blind. + +### Run it yourself (and an honest negative result) + +I want to demonstrate the huge-page payoff on this machine: the same 256 MB working set doing pointer chasing, one copy on plain 4 KB pages, one using `madvise(MADV_HUGEPAGE)` to request transparent huge pages, and see whether the huge-page version is faster. Core of the code: + +```cpp +void* a4 = mmap(nullptr, SZ, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); +void* a2 = mmap(nullptr, SZ, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); +madvise(a2, SZ, MADV_HUGEPAGE); // request transparent huge pages +// run the same pointer chase on both, compare latency +``` + +Three runs: + +```text +Run 1: 4KB page 136.5 ns 2MB page 134.0 ns ratio 1.02 +Run 2: 4KB page 135.8 ns 2MB page 137.4 ns ratio 0.99 +``` + +The ratio is basically 1.0, **huge pages brought no measurable improvement**. Why? Checking `/proc/self/smaps` shows `AnonHugePages: 0 kB`: **WSL2 (the environment I'm running on) simply didn't grant transparent huge pages**, `madvise` was requested but the kernel never actually delivered. So this "no difference" is real — it's not that huge pages are useless, it's that this environment didn't give me huge pages. + +I'm writing this negative result down verbatim to drive home the ch01 measurement discipline again: **the optimization you think you turned on may not have taken effect at all.** On this WSL2 box, `perf` isn't installed, THP doesn't deliver, CPU frequency can't be read. Each one will throw off your reading of performance numbers. Move to bare-metal Linux, `echo always > /sys/.../transparent_hugepage/enabled` or pre-allocate a hugetlb pool, and this same code can measure a ten-to-forty-percent improvement on a TLB-bound workload. **Conclusions can be cited from authoritative sources, but the numbers in your hand have to come from the environment you actually ran on, and you have to confirm first that the environment really meets the preconditions.** + +## A microarchitecture cheat sheet across CPU families + +With the TLB covered, ch02's hardware foundation is complete. But the numbers in the previous three articles centered on this machine's AMD Zen 3; switch to Intel, Apple Silicon, or ARM, and the specific numbers change. The table below gives directions and orders of magnitude — **for precise numbers, consult the corresponding [Wikichip](https://en.wikichip.org) microarchitecture page or Agner's microarchitecture manual.** It's not for memorizing; it's an entry point for "which direction to look in" when tuning across platforms. + +| Dimension | Intel (recent gens) | AMD Zen (2/3/4/5) | Apple (A-series to M-series) | ARM Cortex (X / big cores) | +|---|---|---|---|---| +| **Decode width** | 6-wide (since Golden Cove) | 4-wide x86 decode, **op-cache brings it to 6-8 µops/cycle** | very wide (~8-wide), no x86 decode burden | wide (4-5+) | +| **ROB depth** | deep (~400-500+) | mid-deep (Zen3 ~256) | very deep (~600+) | moderate | +| **L1d / L2** | 48 KB / 1-2 MB | 32 KB / 512KB-1MB | 128 KB / large | 64 KB / variable | +| **LLC structure** | shared LLC (MIC / integrated) | Zen3+ single-CCD shared large L3 | system-level cache (SLC) | shared /clustered L3 | +| **TLB** | L1 dTLB + L2 TLB | L1 dTLB + L2 TLB | similar layering | similar layering | +| **Signature** | deep pipeline, strong front-end | unified CCD large cache, high frequency | ultra-wide ROB, high ILP | energy efficiency, licensable | + +How to read this table: don't stare at absolute numbers (generations evolve); stare at **structural differences**. For example, "AMD Zen relies on 4-wide x86 decode + op-cache to prop up throughput, while Apple is genuinely ultra-wide decode + ultra-deep ROB" — that's one of the reasons Apple Silicon can beat x86 on IPC (instructions per cycle), and it also explains why "front-end bottlenecks" (decode can't keep up) show up more often in x86 code than ARM (ch04-07 front-end optimization covers this). Or "Zen3 merged the L3 into a single-CCD shared structure" — that's AMD's key move to drastically cut cross-core cache latency, directly shaping the multithreaded performance curve (ch05). + +> This table strictly belongs to the "giving pointers" category. Register-renaming-table size, execution-port distribution, per-instruction latency/throughput — that **architecture-table-level** data — Agner volume 3 (microarchitecture) and volume 4 (instruction tables) are the desk authority, Wikichip is the online encyclopedia. vol6 stops at the depth that supports "understanding why things are fast or slow"; for deeper digging, look those two up. + +## ch02 wrap-up: four hardware foundations + +With that, the three layers of single-core hardware are covered: + +1. **Memory hierarchy** (02-01): the latency ladder of L1/L2/L3/DRAM, a 100x gap at each step. Sequential access can approach L1 throughput because the prefetcher is helping. +2. **Cachelines and locality** (02-02): 64 bytes is the minimum transfer unit, spatial locality decides that contiguous layout is fast, row-major vs column-major differ by 6x. +3. **Pipeline and ILP / branches** (02-03): ILP decides whether execution units are fed (multiple accumulators, 2.9x); branch prediction penalizes unpredictable branches hard (sorted vs shuffled, 4.2x). +4. **TLB and huge pages** (this article): address translation is another gate, huge pages cut TLB pressure, but you have to confirm the environment actually delivered them first. + +These are the hardware foundation for every recommendation in ch04 "optimize by bottleneck site": why use contiguous containers, why control the hot working set, why multiple accumulators, why branchless, why division is a bottleneck, why front-end PGO helps. Every one of them traces back to a number in these four chapters. ch03 then teaches us **how to measure which of these four blocks the current program's bottleneck lands in**, and ch04 prescribes accordingly. + +## References + +- Bryant & O'Hallaron, *CSAPP*, Chapter 9 *Virtual Memory*: concepts and costs of page tables, the TLB, and page-table walks +- Agner Fog, *The microarchitecture of Intel, AMD and VIA CPUs*, §22 *AMD Ryzen* and the various Intel chapters: architecture-level details of the TLB, pipeline, and execution ports. Local copy: `.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Wikichip *Microarchitectures*: precise-parameter lookup across CPU families (Intel / AMD / Apple / ARM), the desk entry point for cross-platform tuning +- Drepper, U., *What Every Programmer Should Know About Memory*: an engineering view of TLB, huge pages, and page tables +- Source for this article's measurements: `code/volumn_codes/vol6-performance/ch02/tlb_hugepage.cpp` diff --git a/documents/en/vol6-performance/ch02-cpu-microarchitecture/index.md b/documents/en/vol6-performance/ch02-cpu-microarchitecture/index.md new file mode 100644 index 000000000..f9e90a5f9 --- /dev/null +++ b/documents/en/vol6-performance/ch02-cpu-microarchitecture/index.md @@ -0,0 +1,26 @@ +--- +title: "CPU microarchitecture and the memory hierarchy" +description: "ch02 lays out the full single-core hardware foundation: the memory-hierarchy latency ladder, cachelines and locality, the pipeline / ILP / branch-prediction trio, and the TLB with huge pages. Each piece is backed by measurements run on the author's own machine, providing the hardware base for ch04's optimize-by-bottleneck-site advice." +--- + +# CPU microarchitecture and the memory hierarchy + +ch00 established "correctness first, then speed" and "measure first, then optimize." ch01 turned "measure first" into a complete methodology. But between "measure first" and "actually optimize," there's one piece of knowledge still missing: which kind of cost are you actually optimizing away? If unrolling a loop makes it 3x faster, is that because of cache, instruction-level parallelism, or branch prediction? Without the root cause, optimization is just guessing. + +This chapter splits the single core into four layers, and for each layer uses on-machine measurements to explain "what happens on the hardware that makes this fast or slow": + +- **Memory hierarchy**: the L1/L2/L3/DRAM latency ladder falls off by 100x at each step; sequential access can approach L1 throughput thanks to the prefetcher. +- **Cachelines and locality**: 64 bytes is the minimum unit of cache transfer, spatial locality is why contiguous layout is fast, and row-major vs column-major traversal differs by 6x. +- **Pipeline / ILP / branch prediction**: instruction-level parallelism decides whether execution units are fed (multiple accumulators are 3x faster), and unpredictable branches get penalized hard (sorted vs shuffled is a 4x gap). +- **TLB and huge pages**: virtual-address translation is another gate, huge pages cut TLB pressure, but you have to confirm the environment actually delivered them first. + +The numbers in this chapter (100x, 6x, 3x, 4x) are the physical basis for every recommendation in ch04's "optimize by bottleneck site" — why use contiguous containers, why control the hot working set, why multiple accumulators, why branchless, why division is a bottleneck, why front-end PGO helps. Every one of them traces back to here. On depth we stop at "enough to support judgment"; ROB / register renaming / execution-port scheduling and the deeper content get pointers to Agner's microarchitecture manual and Wikichip. + +## In this chapter + + + Memory hierarchy and the latency ladder: why sequential access is 100x faster + Cachelines and locality: the 64-byte minimum unit of transfer + Pipeline, ILP, and branch prediction + TLB, huge pages, and a microarchitecture cheat sheet across CPU families + diff --git a/documents/en/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md b/documents/en/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md new file mode 100644 index 000000000..b79c30767 --- /dev/null +++ b/documents/en/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md @@ -0,0 +1,113 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: After measuring that something is slow, you have to answer why. We learn two complementary high-level attribution frameworks first — Brendan Gregg's USE method (check utilization, saturation, and errors for every resource to rule out system-wide causes first), then the Roofline model (use arithmetic intensity to tell at a glance whether your code needs less memory traffic or more SIMD) +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Memory hierarchy and the latency ladder — why sequential access is 100x faster +- Benchmark methodology reference card +reading_time_minutes: 7 +related: +- TMAM's four buckets and hardware sampling — LBR / PEBS / Intel PT +- Flame graphs, the perf workflow, and COZ / eBPF +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: "The USE method and the Roofline model: system-wide first, then compute vs bandwidth" +translation: + source: documents/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md + source_hash: 22792ef0ead1715614163a3333dcc09a034d3e4611f44fdcf98215fba5be2031 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2800 +--- +# The USE method and the Roofline model: system-wide first, then compute vs bandwidth + +## Once you've measured "slow," don't rush to change code + +ch01 taught us how to measure performance accurately, and ch02 handed us the hardware base. But the moment you actually start optimizing a slow program, you run into two questions. First, **where exactly is it slow?** Is the CPU not computing fast enough, is the data not arriving fast enough, or is it not computing at all (waiting on a lock, on IO)? The second question is sneakier: **is this bottleneck even worth fixing?** You spend a day making some function 3x faster, but if it's only 2% of total time, the user won't feel a thing. + +This chapter (attribution methodology) answers those two questions. It doesn't speed anything up directly; instead, it gives you a localization workflow from "slow" to "where the bottleneck is and how big its share is." Get the localization right and ch04's "optimize by bottleneck site" can prescribe the right medicine; get it wrong and you're just busy for nothing. + +We split the work across three articles on three complementary tools, plus a synthesis walkthrough. This article covers two high-level frameworks (USE for the system-wide view, Roofline for compute vs bandwidth), 03-02 covers Intel's TMAM four buckets (attributing the bottleneck to a pipeline stage), 03-03 covers flame graphs (localizing down to a line of code), and 03-04 chains them into one complete workflow. The relationship between the three tools is: USE first rules out system-wide causes, Roofline tells compute vs bandwidth at a glance, TMAM drills down to a pipeline stage, and flame graphs land on the code. + +> Most of the tools in this chapter are system-level profilers like `perf`, `toplev`, and flame-graph scripts. The machine I'm writing on is WSL2, with no `perf` installed and no way to run `toplev`. So the commands and outputs in this chapter are **quoted from authoritative sources (Brendan Gregg, Bakhvalov, easyperf.net) and labeled as such**, not faked as if I ran them on this machine. The commands themselves are standard Linux performance-analysis moves and work the same on a bare-metal Linux box with perf installed. This discipline of "honest about environment limits" is itself part of ch01's measurement methodology. + +## The USE method: three checks per resource + +USE is a system-level health-check framework coined by Brendan Gregg. The name is an acronym for three checks, applied to every resource in the system: + +- **U**tilization: the fraction of time it's busy. +- **S**aturation: the queue length or wait depth, meaning work is already piling up. +- **E**rrors: error counts, hardware faults, dropped packets, retransmits, that sort of thing. + +Resources include CPU, memory, disk, network, bus, mutexes, thread pools, connection pools — anything that can become a bottleneck. USE's value is exhaustive early-stage triage: you don't have to guess where the bottleneck is, you sweep U/S/E for every resource and treat whichever one is saturated first, which avoids "staring at one spot optimizing while the real bottleneck is elsewhere." + +A few resource-to-metric mappings (full table at brendangregg.com/usemethod.html): + +| Resource | Utilization | Saturation | Errors | +|---|---|---|---| +| CPU | `%us`+`%sy` from `vmstat 1` | `vmstat` `r` column (run queue length) > core count | — | +| Memory | `free -m` / `sar -B` | `vmstat` `si`/`so` (swap in/out) > 0 | OOM in `dmesg` | +| Network | `rxkB/s` from `sar -n DEV` | `ifconfig` drops / `netstat -L` overflow | `ifconfig` errors | +| Disk | `%util` from `iostat -xz 1` | `iostat` `avgqu-sz` / `await` | `dmesg` / smart | + +One counterintuitive point to remember about USE: **low average utilization can still mean saturation.** A CPU averaging 80% over five minutes can hide second-level spikes to 100%, so saturation (queue length) surfaces problems earlier than average utilization. This same corollary showed up in ch01-03's "measurement pitfalls" (averages dragged by long tails); statistically it's the same effect. + +USE is used at the very beginning of a performance investigation. A few minutes sweeping the system rules out the obvious — "memory is paging," "disk is maxed," "network is dropping" — before you sink down to the microarchitecture level covered in ch02. It doesn't answer "which line of code is slow," but it keeps you from diving into a code dead-end right at the start. + +## The Roofline model: compute ceiling vs bandwidth ceiling + +After USE has given you the system-wide view and confirmed the bottleneck is on CPU compute, the next split to make is: **is it compute-bound (Core Bound) or is the data not being fed fast enough (Backend Memory Bound)?** The two bottlenecks call for opposite fixes. Compute-bound wants more SIMD and fewer instructions; bandwidth-bound wants less memory traffic and a different data layout. Get the call wrong and the optimization is wasted. + +The Roofline model (Williams et al., CACM 2009) gives an extremely simple criterion. Plot a program on a 2D chart: + +- **X axis**: arithmetic intensity (AI), how many operations per byte of memory traffic, in **ops/byte**. +- **Y axis**: attainable compute throughput, **ops/s** (or FLOPS/s). +- **Two rooflines**: the horizontal line is peak CPU compute; the slanted line is peak memory bandwidth (`ops/s = bytes/s × AI`, so it's a line through the origin). + +Your program lands at a point on this chart based on its arithmetic intensity, and the height of the "roof" it can reach is its theoretical peak performance. The key reading: + +- If the program point **hugs the slanted line** (the bandwidth line), it's **memory-bandwidth-bound**, and adding SIMD won't help; you need to cut memory traffic. +- If the program point **hugs the horizontal line** (the compute line), it's **compute-bound**, and cutting memory traffic won't help; you need more compute (SIMD, fewer instructions). + +The intersection of the slanted line and the horizontal line is called the **roofline point**, and the arithmetic intensity at that point is the boundary where you "saturate bandwidth and start tipping into compute." Programs with AI below the roofline point are all bandwidth-bound; only those above it can be compute-bound. + +### Two examples by hand: dot and axpy + +The nice thing about Roofline is that arithmetic intensity can be computed by hand, no profiler needed. Let's work two classic BLAS kernels: + +**Dot product `dot = Σ a[i]*b[i]`**: + +- Per iteration: 2 floating-point ops (one multiply, one add), reads 2 floats (8 bytes). +- Arithmetic intensity = `2 FLOP / 8 B = 0.25 FLOP/byte`. + +**AXPY `y[i] = α*x[i] + y[i]`**: + +- Per iteration: 2 floating-point ops (multiply, add), reads 2 floats + writes 1 float (12 bytes). +- Arithmetic intensity = `2 FLOP / 12 B ≈ 0.17 FLOP/byte`. + +For a chip in the 5800H class, the roofline-point arithmetic intensity sits around **~20-25 FLOP/byte** (peak FP32 throughput around 900 GFLOPS, peak DDR bandwidth around 30-40 GB/s, divide the two). Dot's 0.25 and axpy's 0.17 are far below the roofline point, so these two kernels are **unambiguously memory-bandwidth-bound**, riding the bandwidth slope. + +That conclusion directly sets the optimization direction: for dot and axpy, don't try to squeeze SIMD lane utilization (the compute direction), cut memory traffic instead (the bandwidth direction). For example, merge multiple arrays into SoA for one big load, use wider loads, or switch algorithms entirely to move less data. That's exactly what ch04-01 "backend memory" covers. + +Conversely, a matrix multiply `C += A·B` has much higher arithmetic intensity (each element is reused many times), with AI in the tens, landing on the compute line. So matrix-multiply optimization is about SIMD and tiling to extract compute, not about cutting traffic. Same source code, completely different optimization direction, all determined by where AI lands. + +> Magnitude note: the 5800H peak compute and bandwidth figures above are **order-of-magnitude approximations**, not pinned exact numbers, because they float with turbo frequency, AVX mode, and memory configuration; pinning them would be misleading. The pedagogical value of Roofline is in the qualitative call "does AI land on the slope or the horizontal line," not in any one machine's exact peaks. When you do need exact peaks, look up the CPU spec page and measure memory bandwidth (e.g., the STREAM benchmark). + +USE and Roofline are both high-level, fast frameworks you can pick up without a deep profiler. USE, at the very start of an investigation, exhaustively sweeps resource utilization, saturation, and errors to rule out "the problem isn't even on the CPU." Roofline, once the bottleneck is confirmed to be compute, uses arithmetic intensity to tell compute-bound (add SIMD) from bandwidth-bound (cut traffic) at a glance. + +They narrow the battlefield down to "a resource / a class of bottleneck," but they haven't drilled down to "which stage of the pipeline." That's exactly what the TMAM four buckets do, and the next article walks into the CPU pipeline to attribute the bottleneck to Frontend / Backend / Bad Speculation / Retiring. + +## References + +- Brendan Gregg, *The USE Method*, brendangregg.com/usemethod.html — the original USE framework and the full metric tables per resource. +- Williams, Waterman, Patterson, *Roofline: An Insightful Visual Performance Model for Multicore Architectures*, CACM 2009 — the original Roofline paper. +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, Chapter 6 *Analysis Approaches* — the engineering treatment of USE and Roofline. +- Ofenbeck et al., *Applying the Roofline Model* — the engineering-compute view of arithmetic intensity. diff --git a/documents/en/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md b/documents/en/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md new file mode 100644 index 000000000..b03f24995 --- /dev/null +++ b/documents/en/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md @@ -0,0 +1,124 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: TMAM (Top-Down Microarchitecture Analysis) sorts pipeline slots into four buckets — Retiring / Frontend Bound / Backend Bound / Bad Speculation — telling you which pipeline stage the bottleneck is in. This article covers how the four buckets are defined, the toplev workflow, and the hardware sampling mechanisms behind them — what data LBR / PEBS / Intel PT each give you +difficulty: advanced +order: 2 +platform: host +prerequisites: +- The USE method and the Roofline model +- Pipeline, ILP, and branch prediction +reading_time_minutes: 7 +related: +- Flame graphs, the perf workflow, and COZ / eBPF +- Frontend optimization — code layout, PGO, BOLT +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: "TMAM's four buckets and hardware sampling: LBR / PEBS / Intel PT" +translation: + source: documents/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md + source_hash: 13e8a6ee3c48422f8159f7ca845611a8be3b9913dcc2eaaf395990df9bf559c8 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2900 +--- +# TMAM's four buckets and hardware sampling: LBR / PEBS / Intel PT + +## Attributing the bottleneck to a pipeline stage + +Last article's Roofline can tell compute-bound from bandwidth-bound, but "compute" and "bandwidth" are both still too coarse. A CPU pipeline has many stages — fetch, decode, execute, memory access, branch prediction — and "not enough compute" could mean decode can't keep up (frontend), or the execution units are waiting on data (backend), or branch prediction keeps getting it wrong and flushing the pipeline (bad speculation). The fixes for those three are completely different. + +**TMAM (Top-Down Microarchitecture Analysis Method)** is the framework Intel came up with to answer this question (Yasin, *A Top-Down Method for Performance Analysis and Tuning*, 2014). It takes the slots the pipeline can allocate each cycle and sorts them **by their final fate** into four buckets. Whichever bucket's share is abnormally high tells you which pipeline stage the bottleneck is in. Andi Kleen later turned this framework into the one-command `pmu-tools/toplev`, the workhorse of modern CPU performance analysis. + +## The four buckets: four fates for a slot + +The CPU frontend tries to "allocate" some number of slots per cycle (slot count = pipeline width). Those slots have only four possible fates: + +| Bucket | Meaning | High share means | Typical fix (maps to ch04) | +|---|---|---|---| +| **Retiring** | the slot retired into a real useful instruction | **the higher the better** (ideal) | already well-structured, keep going | +| **Frontend Bound** | the slot stalled because the frontend (fetch/decode) couldn't keep up | icache miss / iTLB miss / code bloat | code layout, PGO, BOLT (ch04-07) | +| **Backend Bound** | the slot stalled at the backend — the execution unit was **waiting on data** (memory) or **waiting on a port** (core) | cache miss / data dependency / execution-port contention | cache optimization, SIMD, breaking dependency chains (ch04-01/02/03) | +| **Bad Speculation** | the slot was wasted on a **mispredicted speculative path** (branch prediction failure, flushed) | unpredictable branches | branchless, predication (ch04-06) | + +How to read the four buckets: **Retiring is the good bucket, the other three are bad buckets, and whichever has an abnormally high share is the current bottleneck.** A well-tuned numerical-compute kernel can hit 50%–70% Retiring (SIMD fully loaded); if it's only 20% with Backend Memory at 60%, you should be fixing cache, not adding SIMD. + +Backend Bound splits further into two branches: **Backend Memory Bound** (waiting on data: cache misses, bandwidth) and **Backend Core Bound** (waiting on an execution port: division, a long-latency dependency chain). That split is extremely useful — it maps directly to the two routes in ch04, "fix memory" or "fix compute." + +> Boundary note: the TMAM four buckets are an attribution framework; they tell you "which class the bottleneck lands in." How to actually fix it (vectorize, cut traffic, branchless) is ch04's job. Don't expand on optimization detail in the attribution chapter — that's ch04's home turf. + +## The toplev workflow: drill down layer by layer + +`toplev` (the Python tool in `pmu-tools` that wraps Intel's TMAM performance counters) works by drilling down level by level. A typical three steps: + +```bash +# 1. Look at the L1 four buckets first, pick the main battlefield +toplev -l1 -- ./app +# Sample output (quoted from easyPerfect.net, not run locally): +# Frontend_Bound: 12.5% ← normal +# Backend_Bound: 58.0% ← main bucket! +# Bad_Speculation: 8.3% +# Retiring: 21.2% + +# 2. Backend is the main bucket, drill to L2 to see Memory vs Core +toplev -l2 -- ./app +# Backend_Bound.Core_Bound: 15.0% +# Backend_Bound.Memory_Bound: 43.0% ← memory-bound + +# 3. Memory Bound drills further to L3, see which cache level / DRAM +toplev -l3 -- ./app +# ... L3_Bound.DRAM_Bound: 38% ← likely cache misses hitting DRAM +``` + +By L3 you know "the bottleneck is an L3 miss hitting DRAM" at that granularity. But that's still not enough — you still need to know **which instruction is missing** before you can change anything. That's what **hardware sampling events with precise addresses** are for. + +## The hardware-sampling trio: LBR / PEBS / Intel PT + +`toplev` tells you "which level is stuck," but to land on "which assembly instruction," you rely on the CPU's hardware sampling mechanisms. Modern Intel/AMD CPUs have three, each giving you a different granularity of data: + +**LBR (Last Branch Record)**: the CPU keeps a ring buffer recording the last few dozen to few hundred **branch jumps** (from/to address pairs). LBR's strength is capturing **control flow** — where branch prediction failed, the call stack (LBR rebuilds the stack without needing a frame pointer), hot loops. The cost: it only records branch jumps, not ordinary memory accesses. + +**PEBS (Precise Event-Based Sampling)**: this is what `perf` events with the `:pp` suffix lean on. Ordinary sampling is based on the **instruction pointer**, which suffers from skid (between the event happening and the interrupt being delivered, the CPU executes a few more instructions, so the sample point "slides" past the real one and localization is imprecise). PEBS makes the CPU **precisely** save the register state at the event time (including the precise instruction address) into the PEBS buffer, with almost no skid. This is essential for "which load instruction cache-missed": + +```bash +# Use an event with the :ppp (precise IP) suffix to localize the exact instruction that cache-missed +perf record -e MEM_LOAD_RETIRED.L3_MISS:ppp -- ./app +perf report # or perf annotate for assembly-level hits +``` + +> This is the flip side of item 16 in ch01-03's "measurement pitfalls" table, "PEBS skid": the `:ppp` precise event is what localizes cleanly; an ordinary event skids a few instructions and you end up patching the wrong function. + +**Intel PT (Processor Trace)**: even more aggressive — it **continuously records** the full control flow (the direction of every branch) and can **completely reconstruct the execution trace** (though it doesn't record data values). The cost is a lot of data (buffers eat memory) and parsing needs dedicated tools (`perf script`, `libipt`). Intel PT is for "I need to know exactly which path this run took and how every branch turned" type of deep analysis; ordinary profiling is fine with PEBS. + +AMD's equivalents have different names (AMD uses IBS, Instruction-Based Sampling, similar in spirit to PEBS but implemented differently; the branch-record equivalent is also called LBR). The framework (the TMAM four buckets) is portable; the underlying event names change with the vendor, which is something to watch for in cross-platform tuning — a perf command tuned on an Intel box needs its event names changed for AMD. `perf list` enumerates the events available on your machine. + +## Bottlenecks migrate: it's iterative, not one-shot + +The TMAM workflow has one extremely important property, and newcomers get burned on it regularly: **fixing one bottleneck reveals the next.** + +Say you drill down and find Backend Memory at 60% (L3 misses hitting DRAM), you put real work into fixing the cache layout, and Backend Memory drops to 20%. You re-measure thinking you're done — only to find total performance moved only a little, because now **Bad Speculation climbed to 35%** (it was hidden under the memory bottleneck; once memory got fast, branch misprediction became the new bottleneck). That's TMAM's "bottleneck migration": the short board of the pipeline changes; you fix one and the next becomes the new short board. + +So TMAM is an iterative process, not a one-shot diagnosis: + +1. `toplev -l1` to find the current biggest bucket → drill down to localize → fix → back to 1. +2. Each round handles the **current biggest** bucket, until Retiring's share is satisfactory or the remaining buckets are all too small to bother. + +This "bottlenecks migrate" property is also why ch01 keeps stressing "measure before and after with the same methodology." What you think you've fixed may just have moved the bottleneck somewhere else. + +Looking back at what TMAM gives us: four buckets (Retiring the good one / Frontend Bound / Backend Bound split into Memory + Core / Bad Speculation), and whichever bucket's share is high is where the bottleneck is; the toplev workflow is L1 to pick the main bucket, L2/L3 to drill into cache levels, then precise events (`:ppp`) to localize to a specific assembly instruction, then fix and iterate; the hardware-sampling trio divides the labor, with LBR capturing control flow and stacks, PEBS capturing precise memory events (curing skid), and Intel PT continuously reconstructing the full trace; and one running discipline: bottlenecks migrate, so each round treats the current biggest bucket, you re-measure after every change, until Retiring is satisfactory. + +TMAM answers "which class of bottleneck," but it hasn't yet answered "which stretch of code, which function." Localizing to code is the job of flame graphs, the next article. + +## References + +- Yasin, *A Top-Down Method for Performance Analysis and Tuning* (2014) — the original TMAM paper. +- Andi Kleen's `pmu-tools` (`toplev`) — github.com/andikleen/pmu-tools, the command-line implementation of TMAM. +- easyPerfect.net, *Top-Down performance analysis methodology* (2019-02-09) — an illustrated tutorial on the toplev workflow. +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, Chapter 6 *CPU Features For Performance Analysis* — the mechanism-level treatment of LBR / PEBS / Intel PT. +- Intel, *Optimization Reference Manual*, Appendix B — the official definition of TMAM and performance counters. +- `perf` documentation: `perf record` / `perf annotate` / the `:pp` precise-event suffix. diff --git a/documents/en/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md b/documents/en/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md new file mode 100644 index 000000000..9a5113e97 --- /dev/null +++ b/documents/en/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md @@ -0,0 +1,122 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: TMAM answers "which class of bottleneck," flame graphs answer "which stretch of code." This article covers the standard perf record sampling workflow, how to generate and read on-CPU and off-CPU flame graphs, plus two advanced tools — COZ (a causal profiler that tells you which function is worth optimizing most) and eBPF (the modern programmable tracing substrate) +difficulty: advanced +order: 3 +platform: host +prerequisites: +- TMAM's four buckets and hardware sampling +- Benchmark methodology reference card +reading_time_minutes: 8 +related: +- The USE method and the Roofline model +- Attribution in practice — from a slow program to the bottleneck +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: "Flame graphs, the perf workflow, and COZ / eBPF" +translation: + source: documents/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md + source_hash: f796606cd5abf9b17dbccb7becf8329a0fcf9325e2f1077d5d9f5bf4826f94d3 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3000 +--- +# Flame graphs, the perf workflow, and COZ / eBPF + +## From "which class" to "which code" + +Last article's TMAM attributes the bottleneck to a pipeline bucket (Frontend / Backend / Bad Spec), but "Backend Memory Bound 60%" still can't be pinned to a line of code. You need to know which function, which loop is producing those cache misses before you can change anything. That calls for a profiler that can **aggregate by code location**. + +The **flame graph**, invented by Brendan Gregg, is the most readable of the bunch: it draws sampled call stacks as a "stacked-boxes chart," and at a glance you can see which call chain the time is spent on. Combined with Linux's built-in `perf`, this pair is the absolute mainstay of everyday profiling. This article covers the perf workflow, how to read a flame graph, and two advanced tools (COZ causal profiling and eBPF programmable tracing). + +## The perf record workflow + +The whole flow is three steps: sample, fold the stack, draw. Sampling uses `perf record`: + +```bash +# Standard on-CPU sampling: 99Hz, DWARF call stacks (no frame pointer dependency) +perf record -F 99 --call-graph dwarf -- ./app + +# Export after sampling +perf script > out.perf + +# Fold + draw (Brendan Gregg's FlameGraph repo scripts) +./stackcollapse-perf.pl out.perf > out.folded +./flamegraph.pl out.folded > out.svg +# Open out.svg in a browser; hover/click to zoom +``` + +A few **key parameters and gotchas** (all extensions of ch01-03's "measurement pitfalls"): + +- **`-F 99` (sampling frequency 99Hz)**: why not 100Hz? Because 100 is a "round number" for many built-in timers and easily **phase-locks** with other periodic system events, producing regular sampling bias; 99 is an odd number (and doesn't divide 100), so sample points don't easily align with the system's round-number periodic events. This is a common small convention in performance analysis. **Note that 99 is not a prime** (99 = 9 × 11); it was picked simply as an odd number that doesn't divide 100, not for any prime property (Brendan Gregg's perf docs only say "99 Hertz," never explaining it as "prime"). +- **`--call-graph dwarf` (rebuild the stack from DWARF debug info)**: GCC defaults to `-fomit-frame-pointer` (omit the frame pointer, freeing up one usable register), which means `perf` can't walk frame pointers to rebuild the call stack — the stack is broken. Two fixes: **add `-fno-omit-frame-pointer` at compile time** (recommended, near-zero cost, standard for profiling), or **sample with `--call-graph dwarf`** (rebuild the stack from DWARF debug info, a bit slower but no recompile needed). For a release binary you intend to profile, always add `-fno-omit-frame-pointer`. +- **Sampling is statistical**: `perf record` is sampling, not tracing. 99Hz for 10 seconds collects only ~990 samples per core, and short functions may not get a single sample. To catch rare hotspots, raise the frequency or extend the run; to capture one-shot startup overhead, switch to a tracing tool (perf c2c / Intel PT / ftrace). + +## How to read a flame graph + +The structure of a flame graph: + +- **y axis (vertical) is call-stack depth**: the bottom is the entry point (`main`), and each layer up is a called function. One box stacked on another = "the lower one called the upper one." +- **x axis (horizontal) is sample count (not time order!)**: the wider the box, the bigger this function's share (and its children's) in the on-CPU sample. **The left-to-right order on x does not represent execution order**, it's just an alphabetical aggregation. +- **How to read it**: find **the widest box**, that's the function eating the most on-CPU time. But it's not necessarily the function you should optimize — you have to look at **what it sits on top of** (the call-stack context). + +Two common misreadings to avoid: + +1. **"The widest box must be the one to optimize"**: wrong. A "wide and flat" box (no child boxes on top) is a real hotspot worth optimizing; a "wide box with a tall stack on top" just means **the children it calls are eating time**, optimizing the box itself is useless, you optimize the widest of the children stacked above it. +2. **"The x axis is time"**: it isn't. A flame graph is an **aggregation**, not a timeline. To see "which time window was busy, in order," use a timeline / chrometrace-style tool. + +### Two important variants: on-CPU vs off-CPU + +The standard flame graph is **on-CPU** (what's running on the CPU), answering "where did compute time go." But it can't see **what you're waiting on**. If a program is slow because it's waiting on a lock, on IO, on sleep, the on-CPU flame graph will be empty (because at sample time the CPU isn't running your program at all). + +In that case use an **off-CPU flame graph**: it samples **the call stack at the moment the thread leaves the CPU**, meaning "when you were waiting, which function were you waiting in." The widest box on the off-CPU graph is the wait you most need to cut. Brendan Gregg likens on-CPU and off-CPU to two sides of a coin: on-CPU for compute, off-CPU for waiting, and only together are they complete. In production a lot of "slow" is actually waiting (database, locks, network), and off-CPU is the key. + +Generating an off-CPU graph needs bcc / bpftrace (eBPF tools, covered below), more involved than on-CPU, but irreplaceable for "stalls" type problems. + +## COZ: a causal profiler that tells you "which function is worth optimizing most" + +Ordinary profilers (flame graphs, perf) have a fundamental limit: they tell you "where time is going," but they don't tell you "where the biggest payoff is." A function takes 50% of the time; you make it 2x faster; how much does total time drop? Intuitively, 25%, but Charlie Curtsinger's team shows in the COZ paper (*COZ: Finding Code that Counts with Causal Profiling*, SOSP 2015) that this assumes "optimizing this function doesn't affect the cost of other functions," and in reality functions share resources (locks, cache) — speeding one up can slow another down. + +COZ solves this with **causal profiling**: at runtime it virtually "speeds up" some target function, but not by actually making that function faster. Instead it does the opposite — **it inserts pauses into all the other concurrently running threads, slowing them down** (Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, §11.5; Curtsinger & Berger, SOSP 2015). Slowing everyone else down is mathematically equivalent to speeding the target function up. It then measures the total speedup; because the "other threads' time" variable is controlled, the total change can be cleanly attributed to the target function, which is exactly what "causal" means. That lets it directly answer "if I speed function X up by 10%, how much faster does the whole program get," which is the real test of "is it worth optimizing." + +COZ's output is "a virtual-speedup → total-speedup curve per function," and the function with the steepest slope is the one with the biggest payoff, even if its own CPU share isn't the highest. That's a different angle from a flame graph's "find the widest box," and it's more accurate for ranking optimization priorities. COZ is open source on Linux (curtsinger.cc/coz); usage is link the COZ runtime, run the program, view the profile. + +## eBPF: the modern programmable tracing substrate + +**eBPF** (extended Berkeley Packet Filter) is the biggest change in Linux performance tooling in recent years. In short, it lets you **safely run small programs inside the kernel**, hooked onto various hook points (syscalls, kernel functions, tracepoints, USDTs…), collecting data on demand. It needs no kernel changes, no reboot, and its overhead is controllable. + +Why should performance analysis care about eBPF? Because a pile of the tools above are built on it: + +- **Off-CPU flame graphs**: bcc's `profile` or bpftrace can sample off-CPU stacks. +- **Much of `perf`**: a new generation of BPF-based tools (`bpftrace` one-liners) is more flexible. +- **System-level tracing**: `biosnoop` (block IO latency), `execsnoop` (new processes), `tcplife` (TCP connection lifecycle)… Brendan Gregg's bcc toolset has dozens, all written in eBPF. + +For C++ backend work, the most practical one is **bpftrace**, a "one-line DSL for performance analysis." For example, one line traces the latency distribution of a userspace function call: + +```bash +# Trace latency (us) of my_func in mysqld, as a histogram +bpftrace -e 'uprobe:/path/to/bin:my_func { @start[tid] = nsecs; } + uretprobe:/path/to/bin:my_func /@start[tid]/ { + @lat = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); + }' +``` + +The depth of eBPF (how to write BPF programs) is beyond vol6's scope; here I only want to plant one recognition in your head: **the substrate of modern Linux performance tools is eBPF, and off-CPU and system-level tracing are all built on it.** When you need it, brendangregg.com has the full tutorial. + +Compressing this article into a cheat sheet: the mainstay of everyday on-CPU profiling is perf record plus flame graphs, and you must use `-fno-omit-frame-pointer` or `--call-graph dwarf`, otherwise the stack breaks; reading a flame graph means finding the wide box, but distinguishing "wide itself (real hotspot)" from "wide on top (children eat time)," and the x axis is aggregation, not time; off-CPU flame graphs show "what you're waiting on," treat stall-class problems, and rely on eBPF (bcc/bpftrace); COZ is a causal profiler that directly tells you which function has the biggest total payoff, more accurate than "find the widest box"; eBPF is the substrate of modern Linux performance tooling, and off-CPU and system-level tracing are all built on top of it. + +That rounds out ch03's four tools (USE / Roofline / TMAM / flame graphs + COZ + eBPF). The next article chains them into one complete workflow: take a slow program, walk from "it's slow" all the way to "here's the bottleneck." + +## References + +- Brendan Gregg, *Flame Graphs*, brendangregg.com/FlameGraphs/cpuflamegraphs.html — the original flame-graph piece, generation scripts (the FlameGraph repo), and how to read them. +- Gregg, *Brendan Gregg's perf Examples* / *perf one-liners* — a cheat sheet for the perf workflow. +- Curtsinger et al., *COZ: Finding Code that Counts with Causal Profiling*, SOSP 2015 — causal profiling; curtsinger.cc/coz has the open-source implementation. +- Gregg, *BPF Performance Tools* (book) / bcc / bpftrace documentation — the eBPF toolset. +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, §11.5–11.6 — COZ and eBPF in CPU tuning. diff --git a/documents/en/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md b/documents/en/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md new file mode 100644 index 000000000..2b14b2140 --- /dev/null +++ b/documents/en/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md @@ -0,0 +1,175 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: Chain ch03's first three articles — USE / Roofline / TMAM / flame graphs — into one complete workflow. Using a weighted-dot-product case (working set sitting on the L3 boundary, bandwidth-bound), walk step by step with the four tools to localize "Backend Memory Bound, hugging the DRAM bandwidth slope, the optimization is AoS to SoA to drop pad traffic," and stress the iterative nature of bottleneck migration +difficulty: advanced +order: 4 +platform: host +prerequisites: +- The USE method and the Roofline model +- TMAM's four buckets and hardware sampling +- Flame graphs, the perf workflow, and COZ / eBPF +reading_time_minutes: 8 +related: +- Backend memory bottlenecks — cache-friendly, AoS/SoA, and prefetch +- Loop and compute optimization — code motion, unrolling, and multiple accumulators +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: "Attribution in practice: from a slow program to the bottleneck" +translation: + source: documents/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md + source_hash: 5740bcf58b7f119931f64030a9f1dba29192d172d043d0daf4d85369f38c4714 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3100 +--- +# Attribution in practice: from a slow program to the bottleneck + +## How the four tools chain together + +In the first three articles of ch03 we learned four tools: USE (system-wide view), Roofline (compute vs bandwidth), TMAM (which pipeline stage), and flame graphs + COZ (land on code). But when you actually get your hands on it, you don't run all four end to end — that's too slow. Their proper relationship is a funnel, filtering coarse-to-fine: + +```text +slow program + │ + ├─ 1. USE sweeps the system: is it even on the CPU? (paging? disk full? network?) + │ └─ After ruling out system-level causes, confirm the bottleneck is on CPU compute + │ + ├─ 2. Roofline qualitative call: compute-bound or bandwidth-bound? (sets the optimization direction) + │ + ├─ 3. TMAM four buckets: which pipeline stage? (Frontend/Backend Memory/Backend Core/Bad Spec) + │ └─ Drill to cache level, use precise events to localize to an assembly instruction + │ + └─ 4. Flame graph: which function is this slow code in? (confirm which line to change) + └─ COZ adds: how big is the total payoff of fixing this function? (rank optimization priorities) +``` + +This article walks the full workflow. To be clear up front: **I did not run perf/toplev on this WSL2 machine** (perf isn't installed locally). So the profiler outputs below are derived from ch02's actually-measured data (those are real local runs) plus standard cases from Bakhvalov/easyPerfect, **extrapolated** as "what you'd see if you ran them." What can be measured locally (arithmetic intensity, raw timings, cache behavior) is labeled "measured"; specific profiler outputs are labeled "extrapolated/quoted," never faked as run. + +## Scenario: a ridiculously slow dot product + +Say you wrote a particle-physics simulation whose core is a "weighted dot product": for N particles, compute `result = Σ w[i] * x[i] * y[i]`. N = 1 million (16 bytes per particle, a 16 MB working set, sitting right on the local L3 capacity boundary), and a single run takes about 0.9 ms (measured locally, order of magnitude). You want to know whether it has room to optimize and where it's stuck — **work through it with the attribution workflow**. + +The code looks like this (simplified): + +```cpp +struct Particle { float w, x, y, pad; }; // AoS: 16 bytes per particle +float weighted_dot(const std::vector& ps) { + float acc = 0.0f; + for (size_t i = 0; i < ps.size(); ++i) + acc += ps[i].w * ps[i].x * ps[i].y; // each iteration touches three fields + return acc; +} +``` + +## Step 1: USE sweep — confirm it's a CPU problem first + +Before doing anything, spend two minutes on a system sweep to rule out "the problem isn't even on the CPU": + +```bash +vmstat 1 # %us+%sy (user+system CPU), r column (run queue), si/so (paging) +free -m # has memory filled up to paging? +iostat -xz 1 # disk (this program doesn't read disk; should all be zero) +``` + +If you see `si/so > 0` (paging), the "slow" might just be the system paging because memory is short, having nothing to do with your algorithm — add memory first. If CPU utilization maxes out one core while other resources sit idle, confirm the bottleneck is on CPU compute and **move to step 2**. + +This step looks trivial, but it can save you in five minutes from the tragedy of "spent a day optimizing the algorithm and it turned out the disk was full." That's the value of USE. + +## Step 2: Roofline qualitative call — compute or bandwidth? + +Now you've confirmed a CPU compute bottleneck, but still need to split: can't compute fast enough, or can't feed data fast enough? Compute the arithmetic intensity (this is locally computable, no profiler needed): + +Per loop iteration: + +- Ops: 2 multiplies + 1 multiply + 1 add = 3 floating-point ops (strictly, `w*x*y+acc` is 2 mul + 1 add = 3 FLOP). +- Memory: reading `w`, `x`, `y` (three floats) = 12 bytes (the `pad` field comes along in the same cacheline but doesn't count as "useful traffic"; count useful for now). +- **Arithmetic intensity ≈ 3 FLOP / 12 B = 0.25 FLOP/byte**. + +From 03-01: the roofline-point AI for a chip in the 5800H class is around ~20 FLOP/byte (8-core AVX2 FMA peak ~900 GFLOPS / ~40 GB/s). 0.25 is far below the roofline point, so this kernel is **unambiguously memory-bandwidth-bound**, riding the bandwidth slope. The optimization direction is immediately clear: **cut memory traffic, don't squeeze SIMD lanes** (even with SIMD fully loaded, the bandwidth bottleneck is unchanged, just spinning). + +This step cost only a pen and already ruled out the wrong path of "add SIMD," which would have been at least half a day of wasted work. That's the leverage of Roofline. + +## Step 3: TMAM drill — which cache level is missing? + +Roofline said "bandwidth-bound," but at which level? L2 hit but L3 miss? Or L3 also blown through to DRAM? That's `toplev`'s job (the output below is extrapolated from local cache parameters plus the Bakhvalov case): + +```bash +toplev -l1 -- ./weighted_dot +# Frontend_Bound: 8.0% +# Backend_Bound: 62.0% ← main bucket, matches Roofline's "bandwidth-bound" +# Bad_Speculation: 5.0% +# Retiring: 25.0% + +toplev -l3 -- ./weighted_dot +# Backend_Bound.Memory_Bound.L3_Bound.DRAM_Bound: 45% ← even L3 is blown through to DRAM +``` + +"DRAM Bound" tells us: data didn't hit L3 (16 MB), it went to main memory. But note — **the weighted dot product is a streaming single-pass scan** (each element is visited once, with zero temporal reuse), so **there is no L3 capacity thrash here** (thrash requires repeatedly-revisited elements evicting each other; a streaming scan has no revisits). The working set of 16 MB sitting on/slightly exceeding the L3 boundary, the real cause is that it's **riding the DRAM bandwidth slope** — see ch02-01's memory mountain, where throughput falls to the low end of the bandwidth slope once the working set exceeds L3. **The root cause is bandwidth-bound** (not capacity thrash). + +Drilling down to assembly, use a precise event to confirm which load is missing: + +```bash +perf record -e MEM_LOAD_RETIRED.L3_MISS:ppp -- ./weighted_dot +perf annotate +# Highlights: the three movss reads of ps[i].w/x/y in the loop are the miss heavyweights +``` + +Localization complete: bottleneck = Backend Memory Bound, DRAM-level miss, and the misses are produced by the field loads in the loop. Now you can change it. + +## Step 4: flame-graph confirmation + the fix + +In this example the flame graph isn't actually critical (there's only one loop), but in a larger program it can tell you "is this 45% of DRAM misses spread across 5 functions or all concentrated in `weighted_dot`." If spread, you fix each; if concentrated, one fix does it. Assume the flame graph confirms everything is in `weighted_dot`, concentrated. + +How to fix? In a bandwidth-bound scenario, the key is **cutting useless traffic**. The problem is the **AoS layout**: `Particle{w,x,y,(pad)}` packs the three useful fields together with the unused `pad`, and as the scan walks the array each iteration, `pad` eats a quarter of the bandwidth for nothing. Switch to **SoA** (full mechanism in ch04-01), with the three fields each contiguous and no longer carrying `pad`: + +```cpp +struct Particles { + std::vector w, x, y; // three separate arrays +}; +float weighted_dot(const Particles& ps) { + float acc = 0.0f; + for (size_t i = 0; i < ps.w.size(); ++i) + acc += ps.w[i] * ps.x[i] * ps.y[i]; + return acc; +} +``` + +After the change, **go back to step 2 and re-measure** (iterate!), and you find the single-run time drops to ~0.7 ms (measured locally, order of magnitude; SoA drops the 25% pad traffic). Look at toplev again and Backend Memory's share is visibly down, Retiring up — much better. + +## Don't celebrate too soon: bottlenecks migrate + +This is the part of TMAM that gets newcomers. You fix Backend Memory down to 20%, **re-measure total time**, and you might find only a small speedup — because now **Bad Speculation has climbed** (it was hidden under the memory bottleneck). Or Retiring went up, but **Frontend Bound** rose because the code layout got worse. Every fix has to **go back to step 2 and look at the four buckets again**, handle the new biggest bucket, until Retiring is satisfactory or the remaining buckets are all too small to bother. + +This "bottleneck migration" property is why attribution is iterative, not "run the profiler once, fix once." It's also why ch01 keeps saying "measure before and after with the same methodology" — what you think you've fixed may just have moved the short board to a new spot. + +## Wrap-up: COZ for priorities + +If this program has other functions (not just `weighted_dot`), how do you decide **which to fix first**? The flame graph sorts by time, but "the function that takes the most time" isn't necessarily "the function with the biggest optimization payoff." That's where COZ helps: it draws a "virtual speedup → total speedup" curve for each function, and the steepest one is the most worth optimizing. When you have several candidate bottlenecks and a limited budget, COZ's priorities are more reliable than the flame graph. + +## The value of this workflow + +Walk it once and you'll find that the heart of attribution isn't "some magic tool," it's **narrowing the battlefield step by step through the funnel**: + +1. USE (2 minutes) rules out system-level causes and confirms it's CPU. +2. Roofline (a pen) decides compute vs bandwidth and sets the direction. +3. TMAM (a few toplev runs) drills down to the pipeline stage and cache level, with precise events localizing to an instruction. +4. Flame graph confirms the function; COZ ranks priorities. +5. Fix, then **iterate** — bottlenecks migrate. + +Each step costs more than the one before it (in time, tooling, and intrusiveness), but each step also narrows the battlefield for the next. Skip the early steps and jump straight to a flame graph, and you'll get lost in a 50-function program; skip Roofline and jump straight to SIMD, and you might optimize the wrong direction. This coarse-to-fine discipline is the precondition for ch04's optimize-by-bottleneck-site to prescribe the right medicine. + +With this article, ch03's attribution methodology is done. From the next article on, we officially enter **ch04, tuning by bottleneck site**, walking each of the four buckets — Backend Memory / Backend Core / Bad Speculation / Frontend — through how to treat it. + +## References + +- ch03-01 The USE method and the Roofline model (this volume) +- ch03-02 TMAM's four buckets and hardware sampling (this volume) +- ch03-03 Flame graphs, the perf workflow, and COZ / eBPF (this volume) +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs* — full case walkthroughs in ch6–11 (TMAM/CPU features/Cache/Memory/Core/Frontend/multithreading; the book has 11 chapters total). +- ch02-01 Memory hierarchy and the latency ladder (this volume; the source for the memory-mountain measurements of throughput when the working set crosses the L3 boundary). diff --git a/documents/en/vol6-performance/ch03-attribution-methodology/index.md b/documents/en/vol6-performance/ch03-attribution-methodology/index.md new file mode 100644 index 000000000..f82fe1e41 --- /dev/null +++ b/documents/en/vol6-performance/ch03-attribution-methodology/index.md @@ -0,0 +1,29 @@ +--- +title: "Attribution methodology: from measurement to bottleneck" +description: "After you've measured something is slow, you still have to answer why it's slow and where. ch03 lays out four complementary attribution frameworks: USE for the system-wide view, Roofline for compute-vs-bandwidth, the four TMAM buckets for pipeline locality, and flame graphs plus COZ plus eBPF for landing on actual code, then ties them together into a coarse-to-fine funnel workflow in a walkthrough article" +--- + +# Attribution methodology: from measurement to bottleneck + +ch01 teaches us how to measure performance accurately, and ch02 hands us the hardware base. But between "this is slow" and "I know why it's slow" sits an entire discipline. A program can be slow because the CPU can't compute fast enough, because the data can't be moved in fast enough, because it's waiting on a lock or on IO, or because it's thrashing memory pages. **The precondition for the right fix is the right diagnosis.** This chapter turns diagnosis into a reusable workflow. + +The four tools are arranged as a coarse-to-fine funnel. Each step costs more than the one before it (in time, in tooling, in intrusiveness), but each step also narrows the battlefield for the next: + +- **USE** (ch03-01): a two-minute system sweep to rule out "the problem isn't even on the CPU." +- **Roofline** (ch03-01): with a pen, compute the arithmetic intensity and decide compute-bound vs bandwidth-bound, which sets the optimization direction. +- **TMAM four buckets** (ch03-02): drill down with `toplev`, attribute the bottleneck to Frontend / Backend Memory / Backend Core / Bad Speculation on the pipeline, then sample precisely down to specific instructions. +- **Flame graphs + COZ + eBPF** (ch03-03): land on "which function is slow" and use a causal profiler to rank optimization priorities. +- **Walk-through** (ch03-04): with a real weighted-dot-product case, chain the four tools into one complete workflow. + +One discipline runs through this chapter: **bottlenecks migrate**. You fix Backend Memory and Bad Speculation surfaces underneath. Attribution is therefore iterative, with a re-measure after every change, not "run the profiler once, fix once." Once you've localized accurately, ch04's "optimize by bottleneck site" can prescribe the right medicine. + +> The tools in this chapter (`perf` / `toplev` / flame-graph scripts) are mostly system-level profilers. The articles are written on a WSL2 machine where these aren't installed, so the specific commands and outputs are **quoted from authoritative sources (Brendan Gregg, Bakhvalov, easyperf.net) and labeled as such**, not faked as if run locally. What can be measured on this machine (arithmetic intensity, raw timings, cache behavior) is labeled "measured." The commands themselves are standard Linux performance-analysis moves and work the same on a bare-metal Linux box with the tooling installed. + +## In this chapter + + + The USE method and the Roofline model + TMAM's four buckets and hardware sampling: LBR / PEBS / Intel PT + Flame graphs, the perf workflow, and COZ / eBPF + Attribution in practice: from a slow program to the bottleneck + diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md new file mode 100644 index 000000000..9f560c426 --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md @@ -0,0 +1,140 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: Backend Memory Bound is the single biggest lever in single-threaded performance. Using a measured particle system, this article works through three things — why contiguous + sequential is fast (a cache-friendly refresher), how AoS to SoA is nearly 10x faster in the "only some fields updated" scenario, and how struct alignment and padding affect cacheline utilization — plus when software prefetch helps +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Memory hierarchy and the latency ladder — why sequential access is 100x faster +- Cachelines and locality — the 64-byte minimum unit of transfer +- Attribution in practice — from a slow program to the bottleneck +reading_time_minutes: 7 +related: +- Loop and compute optimization — code motion, unrolling, and multiple accumulators +- SIMD and vectorization — auto-vectorization conditions, intrinsics, and CPU dispatch +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存管理 +title: "Backend memory bottlenecks: cache-friendly, AoS/SoA, and prefetch" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md + source_hash: 606e5c8189143c72cadc8413b47018a834c38ac97ed7933a1d755581f2eca86b + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2900 +--- +# Backend memory bottlenecks: cache-friendly, AoS/SoA, and prefetch + +## The single biggest lever in single-threaded performance + +ch03's attribution methods tell us that the overwhelming majority of C++ programs land their bottleneck in the **Backend Memory Bound** bucket — the execution units are waiting on data. That's actually good news, because "waiting on data" is the **highest-leverage** category in single-threaded optimization: a change in data layout often buys you a few-fold, even ten-fold improvement, whereas changing algorithms or adding SIMD usually only gets you tens of percent. + +This article treats Backend Memory specifically. We drop the memory-hierarchy and cacheline knowledge from ch02 onto three concrete C++ rewrites: cache-friendly (a review, to make it muscle memory), AoS to SoA (the core rewrite of data-oriented design), and struct alignment and padding (don't waste cachelines). At the end we cover when software prefetch is useful. + +## cache-friendly: three iron rules, reviewed + +ch02 covered this exhaustively; here we compress it into three memorizable iron rules that anchor every later rewrite: + +1. **Contiguous data**: `vector`/`array`/raw arrays beat `list`/`set`/chained buckets. Only contiguity lets one cacheline serve multiple accesses. +2. **Access order**: walk traversal along the direction of memory contiguity (row-major), and the prefetcher pulls upcoming data into cache ahead of time for you. +3. **Small hot dataset**: data you repeatedly sweep has to fit in cache (especially L3), or it will thrash at the capacity boundary (look back at ch02-01: when the working set equals the L3 size, latency jumps from ~12 ns to ~96 ns). + +These three are "free" — without changing the algorithm, just adjusting layout and access order, you fill the cache. But when you hit a scenario like "the object has many fields but the loop only touches a few of them," contiguity alone isn't enough, and that's where AoS to SoA comes in. + +## AoS to SoA: the core rewrite of data-oriented design + +This is the most valuable move in ch04. Look at a typical "object-oriented" particle struct: + +```cpp +// AoS: Array of Structures — every field of each particle sits together +struct Particle { float x, y, z; // position + float vx, vy, vz; }; // velocity +Particle ps[N]; // 6 floats = 24 bytes/particle +``` + +Looks natural enough — each particle is an object, fields packed tight. **The trouble shows up when you only update the position**: + +```cpp +for (int i = 0; i < N; ++i) + ps[i].x += ps[i].vx * 0.016f; // only touches x, uses vx +``` + +Each iteration only touches `x` and `vx`, but because it's AoS you pull the entire `ps[i]` (along with the unused `y`, `z`, `vy`, `vz`) into cache. A 24-byte particle where you only use 8 bytes (`x`+`vx`) means **cacheline utilization is 1/3**, with 2/3 of the bandwidth wasted. + +**SoA (Structure of Arrays)** lays all the values of a single field contiguously: + +```cpp +struct Particles { + std::vector x, y, z, vx, vy, vz; // 6 separate arrays +}; +Particles ps; +for (int i = 0; i < N; ++i) + ps.x[i] += ps.vx[i] * 0.016f; // only touches the x array and the vx array +``` + +Now you only pull the `x` array and the `vx` array into cache, and cacheline utilization approaches 100%. Measured (N = 1 million particles, only updating `x`): + +```text +===== A. AoS vs SoA (updating 1048576 particles' x, average of 20 runs) ===== + AoS: 1.74 ms/run (each row drags the not-updated y/z/vy/vz into cache; wasted bandwidth) + SoA: 0.18 ms/run (only touches the x and vx arrays; cacheline utilization near 100%) + SoA is 9.79x faster +``` + +**Close to 10x.** That's one of the most dramatic rewrites in single-threaded optimization, with no algorithm change and no SIMD — purely from rearranging the data. That's the core idea of **data-oriented design (DOD)**: **organize data by how it's accessed, not by what it "is in the real world."** Fabian's *Data-Oriented Design* and Mike Acton's talks are the source of this line of thought (the talk is saved for vol10; here we only give the conclusion). + +SoA has one more dividend: it's **naturally SIMD-friendly** (the `x` array is contiguous, so SIMD can load 8 floats at once); ch04-05 expands on that. The cost is that the code is no longer "object-oriented" — that's an engineering tradeoff, worth it on a performance hotspot, unnecessary off hotspot. + +> Boundary note: SoA is a **layout transformation**; explaining "why it's fast" is vol6's job (this article). "Why `vector` is contiguous inside" is vol3's job. Here we only care about the effect of layout on cache. + +## Struct alignment and padding: don't waste cachelines + +AoS has one more hidden cost — **alignment and padding**. Look at these two structs: + +```cpp +struct ParticleAoS { float x, y, z, vx, vy, vz; }; // 6 floats = 24 B +struct ParticleAoS8 { float x, y, z, vx, vy, vz, pad1, pad2; }; // 8 floats = 32 B +``` + +`sizeof`, measured: the first is 24 bytes, the second 32 bytes (deliberately padded to a multiple of 8). A 64-byte cacheline holds **two** ParticleAoS (2×24=48B, leaving 16B that can't fit a third — waste), or **two** ParticleAoS8 (2×32=64B exactly, no waste). + +```text +sizeof(ParticleAoS) = 24 B +sizeof(ParticleAoS8) = 32 B (padded to 32) +64B cacheline holds: AoS=2, AoS8=2 +``` + +Here AoS, because its size isn't a power of two, wastes the tail of the cacheline; padding to 32B actually packs it cleanly. The rule behind this is the compiler's **alignment padding**: inside a `struct` the compiler inserts padding according to member alignment (for example, a `char` followed by a `double` gets 7 bytes of padding so the `double` aligns to 8). `sizeof` being bigger than "the sum of the fields" you thought you had is exactly this. + +The practical corollary: **on a hot path, sort struct fields by size, descending** (big doubles/pointers first, small ints/chars after) to cut padding; or use `alignas(64)` to give a critical struct its own cacheline (this move is the protagonist in ch05-01 when treating false sharing). `#pragma pack` forces tight packing but breaks alignment and can trigger unaligned-access penalties, **so don't spray it around on performance-sensitive paths**. The mechanism of alignment/padding (why sizeof computes this way) belongs to vol4 class layout; vol6 only covers its effect on cachelines. + +## Software prefetch: usually don't + +The last move is **software prefetch**, `__builtin_prefetch`. The idea: you know you'll soon access `data[i+stride]`, so you tell the CPU in advance "pull this from memory into cache," and by the time you actually access it, it's already hit, hiding latency. + +```cpp +for (int i = 0; i < N; ++i) { + __builtin_prefetch(&data[i + PREFETCH_DIST]); // prefetch PREFETCH_DIST steps ahead + process(data[i]); +} +``` + +Sounds great, but **modern CPUs come with hardware prefetchers that are extremely good at regular access (sequential, fixed stride)** (look back at ch02-01: sequential traversal on DRAM can hit L1 throughput, courtesy of the hardware prefetcher). So regular access has no use for software prefetch; the hardware already did it. + +Software prefetch is genuinely useful for **irregular but predictable access**: linked-list traversal (you know the next node's address ahead of time), B-tree lookup, graph traversal. The hardware prefetcher can't learn these patterns, so hand prefetching can pay off. But even in these scenarios prefetch easily **makes things worse** (prefetching data you don't use, polluting cache, wasting bandwidth). **Always benchmark against a control** — that's a concrete instance of the "don't blindly hand-optimize" discipline that ch04-06 keeps stressing. + +Looking back at the cards this article dealt: Backend Memory Bound is the single biggest lever in single-threaded work, where data-layout changes buy a few-fold to ten-fold, far cheaper than adding compute; the three cache-friendly iron rules (contiguous, sequential, small hot dataset) are free; AoS to SoA is nearly 10x faster (measured 9.79x) in the "only update some fields" scenario and is naturally SIMD-friendly — the core DOD rewrite; on alignment and padding, sort hot-path struct fields by size descending to cut padding, and `alignas(64)` treats false sharing (ch05); the mechanism belongs to vol4; software prefetch doesn't help for regular access (the hardware already does it), only helps with irregular-but-predictable access, and must be benchmarked. + +The next article moves the battlefield from "data layout" to "the computation itself" — how to write loops so that ILP and the compiler can both help. + +## References + +- Fabian, R., *Data-Oriented Design* — the source of DOD thinking; local copy in `.claude/drafts/books/`. +- Agner Fog, *Optimizing software in C++*, §7 *Making containers/objects efficient* — the engineering treatment of AoS/SoA, alignment, and padding; local copy. +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, Chapter 9 *Memory Optimizations*. +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/backend_memory.cpp`. diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md new file mode 100644 index 000000000..9b011eea7 --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md @@ -0,0 +1,128 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: Loops are the main battlefield of C++ performance. This article covers the classic loop optimizations from CSAPP chapter 5 — code motion (loop-invariant hoisting), eliminating unnecessary memory references, loop unrolling, multiple accumulators to break dependency chains, and reassociation to expose ILP. The key is telling what the compiler already does for you (don't hand-write it) from what it often needs you to break manually (the FP reduction dependency chain) +difficulty: advanced +order: 2 +platform: host +prerequisites: +- Pipeline, ILP, and branch prediction +- Backend memory bottlenecks — cache-friendly, AoS/SoA, and prefetch +reading_time_minutes: 6 +related: +- Data types and arithmetic — int vs float, the division bottleneck, and jump tables +- SIMD and vectorization — auto-vectorization conditions, intrinsics, and CPU dispatch +tags: +- host +- cpp-modern +- advanced +- 优化 +title: "Loop and compute optimization: code motion, eliminating memory references, and multiple accumulators" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md + source_hash: 708427dd46583be6395bcae574d7a913723509dc7e6095cceee39ffdf7c3ff55 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2800 +--- +# Loop and compute optimization: code motion, eliminating memory references, and multiple accumulators + +## Loop optimization: what's worth hand-writing vs what the compiler already does + +Programs spend 90% of their time in loops, so loop optimization is the main battlefield of performance engineering. But there's a confusing reality here: **the modern compiler at `-O2` already does most of CSAPP chapter 5's loop optimizations (code motion, eliminating memory references, unrolling, multiple accumulators) for you.** Hand-write them and you'll often see no difference. So what's left for this article? + +Two things. First, **figure out what the compiler does and what it doesn't**, so you don't hand-write blindly (adding code complexity without any speedup). Second, the few things the compiler doesn't do well and that you have to break manually (the FP reduction dependency chain is the classic case) — that's where hand-writing pays. + +CSAPP splits loop optimization into five moves. Let's walk them. + +## 1. Code motion: hoisting loop invariants + +Hoist an expression that "computes the same thing every iteration" out of the loop. The textbook example: + +```cpp +// Bad: recompute length() every iteration +for (int i = 0; i < s.length(); ++i) process(s[i]); +// Good: hoist length() +int len = s.length(); +for (int i = 0; i < len; ++i) process(s[i]); +``` + +Modern compilers are good at const-fold + LICM (loop-invariant code motion) for pure functions like `length()`, and in most cases **hoist automatically**. But the precondition for hoisting is that the compiler can prove "this thing doesn't change": `volatile`, side effects, function calls that might modify global state — none of those will it touch. Measured (`volatile float scale`, forcing the compiler not to hoist vs an ordinary variable it can hoist): + +```text +===== A. code motion ===== + scale is volatile (loaded every time): 799.3 us + scale hoisted into an ordinary variable: 765.3 us +``` + +The gap is small (a few percent), because the bottleneck here is the multiplication itself, not the load of scale. **Hand-written code motion usually pays little under a modern compiler**, unless you've confirmed there's a real dependency the compiler won't hoist (volatile, cross-TU calls). This move is mainly a **mental model** — understanding it helps you read the code the compiler generates. + +## 2. Eliminating unnecessary memory references + +The CSAPP classic: keep the accumulator intermediate in memory (an array element) vs in a register. + +```cpp +// Bad: every iteration loads c[i], computes, stores c[i] (c[i] keeps hovering in memory) +for (int i = 0; i < N; ++i) c[i] = c[i] + a[i] * b[i] * scale; +// Good: write directly, no read-back +for (int i = 0; i < N; ++i) c[i] = a[i] * b[i] * scale; +``` + +Measured: + +```text +===== B. Eliminating unnecessary memory references ===== + Repeated read/write of c[i]: 597.8 us + Direct write of c[i]: 526.1 us +``` + +The gap is also a few percent, but **don't mistake this for "-O2 eliminates the extra memory access for you."** Look at the assembly (`g++ -O2 -S loop_opt.cpp`): the extra `c[i]` read-back in the "bad" version **isn't eliminated at -O2** (every iteration still has `addss (%rdx,%rax),%xmm0` reading `c[i]` back to accumulate), because `c[i]` is written every time and the compiler can't assume the read-back can be skipped; the "good" version simply doesn't read back. So the gap is the real cost of one extra load/store, just small relative to the multiplication itself, hence only a few percent. The CSAPP textbook "put the accumulator in a register instead of an array element" case that -O2 does often eliminate automatically is the **pure-scalar-accumulator, no write-back-to-array** situation. This move usually doesn't buy much under a modern compiler, but the cause-and-effect has to be right: don't treat "the compiler already optimized it" as a universal explanation. + +## 3. Multiple accumulators: breaking the dependency chain (the compiler often can't, worth hand-writing) + +This is the **most often hand-broken** of the five, because the compiler is constrained by language semantics and can't do it automatically. Look back at ch02-03: the single-accumulator reduction `acc += a[i]*b[i]` is one long RAW dependency chain with almost zero ILP; splitting into 4 accumulators gives 4 independent chains, and only then can the CPU fill the execution ports in parallel. Measured (ch02-03): **single accumulator 23.7 us vs 4 accumulators 8.1 us, 2.92x faster**. + +Why doesn't the compiler do this one for you? For **integer reductions** it sometimes auto-unrolls into multiple accumulators; for **FP reductions**, it **can't**, because floating-point addition is not associative (`(a+b)+c ≠ a+(b+c)`, different mantissa rounding error), and rewriting it automatically would change the result and violate standard semantics. So: + +```cpp +// FP single chain: the compiler dare not multi-accumulate (would change the FP result) +float acc = 0; for (int i = 0; i < N; ++i) acc += a[i] * b[i]; + +// Hand-written 4 accumulators: you (the programmer) take on the responsibility that +// "the association order changed, the result has a tiny difference" +float a0=0,a1=0,a2=0,a3=0; +for (int i = 0; i < N; i += 4) { a0 += a[i]*b[i]; a1 += a[i+1]*b[i+1]; ... } +return a0 + a1 + a2 + a3; +``` + +This is the **highest-value hand-written move** in ch04: dot products, reductions, inner products — FP hot loops like these get a steady 2-4x from hand-written multiple accumulators. The other fix is `-ffast-math` (loosen FP semantics, allow the compiler to reassociate), but `-ffast-math` changes NaN/Inf behavior, is a global switch, and has a high cost, **so only use it in a local context where you're sure you don't need strict FP semantics.** ch04-05's SIMD article runs into this same "FP associativity blocking the way" problem again. + +## 4. Loop unrolling + +Change `for (i) a[i]` into `for (i+=4) { a[i]; a[i+1]; a[i+2]; a[i+3]; }`. Two benefits: less loop control overhead (branches, counter increments), and more room for register allocation and ILP. Modern compilers do it at `-O3 -funroll-loops`, and the compiler picks the unroll factor more smartly than hand-writing (it can trade off register pressure), **so by default don't hand-write unrolling.** Hand-written unrolling is worth it in two cases: (a) you're doing multiple accumulators at the same time (unrolling and multi-accumulate are often written together); (b) the compiler didn't unroll but your benchmark proves unrolling helps. Blind hand-unrolling often **slows down** because the larger code footprint causes icache misses. + +## 5. Reassociation + +Rewrite the parentheses in an operation: `((a+b)+c)+d` → `(a+b)+(c+d)`. This is essentially another form of "break the dependency chain" — reparenthesizing so independent subexpressions can run in parallel. It's two ways of writing the same idea as multiple accumulators (exposing ILP). For FP it's equally constrained by associativity and needs hand-writing or `-ffast-math`. + +Compressing the five moves into a "hand-write vs compiler" cheat sheet: + +| Optimization | Does the compiler at -O2? | Hand-write value | +|---|---|---| +| Code motion (invariant hoist) | Mostly yes | Low (unless volatile/cross-TU calls) | +| Eliminate memory refs | Mostly yes | Low | +| Multiple accumulators (FP reduction) | **No** (FP associativity) | **High (2-4x)** | +| Loop unrolling | Yes at -O3 -funroll-loops | Low (unless paired with multi-accumulate) | +| Reassociation (FP) | **No** | Medium-high | + +In one sentence: **for integer loops, the compiler does most of it for you, so don't over-hand-write; for FP reduction/dot-product hot loops, hand-written multiple accumulators is a steady free lunch.** Whichever move, after hand-writing you must benchmark against a control — "the compiler already did it" and "your hand-written version is actually slower" are both far too common. + +The next article is on data-type and arithmetic selection, where there's another class of optimization the compiler can't do for you and you must choose yourself: the division bottleneck. + +## References + +- Bryant & O'Hallaron, *CSAPP*, Chapter 5 *Optimizing Program Performance* — the classic derivation of code motion / eliminating memory references / unrolling / multiple accumulators / reassociation (the source of this article's five moves). +- Agner Fog, *Optimizing software in C++*, §12 *Optimizing loops*; local copy. +- ch02-03 Pipeline, ILP, and branch prediction (this volume; the source of the dot1/dot4 2.92x multi-accumulator measurement). +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/loop_opt.cpp`. diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md new file mode 100644 index 000000000..d5a21a44f --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md @@ -0,0 +1,97 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 'Picking the right data type and arithmetic is "an optimization the compiler can''t do for you — you have to choose." With measurements, this article covers integer division as a 5x bottleneck over multiplication (power-of-two divisors reduce to shifts, constant divisions become multiply-by-reciprocal, runtime-variable division has no escape), the cost gap between int and float, and when a switch jump table is actually faster than an if-else chain (answer: not always)' +difficulty: advanced +order: 3 +platform: host +prerequisites: +- Loop and compute optimization — code motion, unrolling, and multiple accumulators +- Pipeline, ILP, and branch prediction +reading_time_minutes: 6 +related: +- SIMD and vectorization — auto-vectorization conditions, intrinsics, and CPU dispatch +- Branches — branchless, predication, and "don't go branchless blindly" +tags: +- host +- cpp-modern +- advanced +- 优化 +title: "Data types and arithmetic: int vs float, the division bottleneck, and jump tables" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md + source_hash: 3ed09d040cb9a2cc599f0b70367ba7d53d17f05e9e378b32ab42fd38352e0b5a + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2700 +--- +# Data types and arithmetic: int vs float, the division bottleneck, and jump tables + +## This is one class the compiler can't do for you + +In ch04-02's loop optimization you'll notice more than half is "the compiler at -O2 already did it." This article looks at arithmetic from a different angle: for some operations, the speed **depends on which data type you chose and which operation you wrote**, and **the choice is in your hands — the compiler is powerless**. The classic case is division. + +Division is the most "expensive" basic integer op on x86. Its latency is relatively long (on Zen 3, `idiv` is about 9-12 cycles for 32-bit and 9-17 cycles for 64-bit, against 1-3 cycles for `imul`/`lea`; data from Agner's *Instruction tables*, Zen 3 section; note that's "a dozen-ish cycles," not "tens of cycles" — the "tens of cycles" in old textbooks is a leftover from the Pentium era's long-latency divider), its throughput is low (you can only issue one every 6-12 cycles; dividers are scarce, so they queue up one after another), and it's a textbook **structural hazard** from ch02-03, with multiple divisions fighting for one divide port. Multiplication is the opposite: `imul` is 1 cycle, fully pipelined. **Avoid division when you can** is the core of this article. + +## The division bottleneck: 5x over multiplication + +Let's measure directly (local Zen 3, `taskset -c 0`, average time per element): + +```text +===== A. Integer arithmetic cost ===== + x/8 (divisor = power of 2, compiler turns into shift): 0.33 ns + x>>3 (hand-written shift) : 0.38 ns + x/7 (divisor = runtime variable) : 1.64 ns ← division bottleneck + x*3 (multiplication) : 0.33 ns + division (variable)/multiplication = 5.0x +``` + +Three things to read out of this: + +**1. Power-of-two divisor, the compiler turns it into a shift for you.** `x/8` is the same speed as `x>>3` (both 0.33-0.38 ns), because GCC sees `8 = 2^3` and compiles it to `>>3`. **So don't feel "inelegant" writing `x/8` — the compiler optimizes it for you.** This also holds for constant divisors — `x/10` constant division becomes "multiply by the reciprocal of 10" (a technique that approximates division with multiply + shift), no real division. + +**2. Runtime-variable divisor, division is unavoidable, 5x over multiplication.** When `x/d` (d is a variable), the compiler can't turn it into a shift or a reciprocal; it has to actually execute the `idiv` instruction. 1.64 ns vs multiplication's 0.33 ns, **5x**. That's the hard number of the "division bottleneck." + +**3. Practical corollary: on hot paths, replace variable division with multiplication or shifts.** Common moves: + +- Power-of-two divisor → shift (or just write `/8` and let the compiler shift). +- Divisor that isn't a power of two but is constant → trust the compiler to use a reciprocal. +- `x % m` (modulo) is as expensive as `x / m` (both are division underneath); the modulo bottleneck works the same way. +- Hash tables using "`& (size-1)`" instead of "`% size`" (with size a power of two) — that's why modern hash tables like `absl::flat_hash_map` and `folly::F14` make bucket counts powers of two: they save one division. **Note that `std::unordered_map` goes the other way**: libstdc++'s implementation takes bucket counts to **primes** (locally measured: `reserve(100)` gives 103, `reserve(1000)` gives 1031, `reserve(10000)` gives 10273 — none powers of two), preferring one real division for hash quality. That's the other side of the "hash quality vs division cost" tradeoff. + +## Int vs float: don't go by intuition + +A lot of people think "float is slower than int," and on modern CPUs that's **basically not true**. Zen 3 has independent floating-point/vector execution units, and FP add and multiply are both fully pipelined with 3-4 cycle latency, with throughput comparable to integer add (FMA even does the work of a multiply and an add in one instruction). So: + +- "Swap a `double` for an `int` to go faster" usually doesn't help and can be slower (precision loss, extra conversions). +- The floating-point ops that are genuinely slow are **division, square root, transcendental functions** (`sin`/`exp`): these have tens of cycles of latency and low throughput, the "division bottleneck" of floating point. +- **Subnormal** (denormal) floating-point ops carry an extra penalty (Agner's microarchitecture manual has the data); `-ffast-math` enables flush-to-zero to turn this off, but it also changes FP semantics. + +In short: **ordinary FP add/multiply isn't slow; what's slow is FP division and transcendental functions.** Put your optimization effort into the latter. + +## switch vs if-else: when is a jump table actually faster + +The last topic, often misexplained. Textbooks often say `switch` with many branches generates a **jump table** — an O(1) table-lookup jump that beats a long chain of `if-else` (which on average walks half the branches). We measure: + +```text +===== B. switch vs if-else chain ===== + switch: 0.48 ns + if-else: 0.43 ns + if-else/switch = 0.90x +``` + +**if-else is actually slightly faster? Hold off on explaining this as "the jump table's indirect-jump predictor drags it down" — that's exactly the "sounds like an explanation" pseudo-causality trap ch00-01 warns about.** You have to read the -O2 assembly to see what really happens: in the bundled code, `switch (x % 8)` has cases 0..7 (contiguous) and return values 100..107 (also contiguous), so GCC finds this equivalent to `return 100 + (x & 7)` and **folds it into a few arithmetic instructions — generating neither a jump table nor keeping the if-else chain** (`g++ -O2 -S` output has indirect jump `jmp *` count = 0, and jump-table data is also 0); the if-else version gets folded the same way. **The two codegens are nearly line-by-line identical**, so the 0.90x is measurement noise, not the prediction cost of a jump table. + +The real lesson is exactly the ch02-03 discipline: **the cost of a branch doesn't depend on syntax (switch/if); it depends on what the compiler actually generated and how well that thing predicts on the hardware.** Casually explaining it as "jump table vs branch tree" is walking straight into pseudo-causality. + +**When does a jump table actually appear?** When the case labels are **sparse and non-contiguous** (say `case 1: case 23: case 199: ...`), the compiler can't fold them into arithmetic and actually emits a jump table — an address table plus one indirect jump (`jmp *`). The indirect-jump target is dynamic, so the branch predictor has a harder time; with many cases, the jump table's O(1) beats the if-else chain's O(n). So the conclusion is still "switch isn't necessarily faster than if-else," but **the reason has to be read off the assembly**: few and contiguous cases (this example) fold into arithmetic, both the same; many and dense cases let the jump table win big; sparse cases still get the jump table's O(1) but the indirect jump has a prediction cost. **Always `g++ -O2 -S` before judging.** + +Compressing this article into a few lines: division is a 5x bottleneck over multiplication (runtime-variable division); power-of-two divisors reduce to shifts (the compiler often does it for you); constant divisions become multiply-by-reciprocal; hot-path variable division/modulo should be rewritten away. Int and float have comparable ordinary add/multiply; what's slow is FP division/square root/transcendental functions. switch isn't necessarily faster than if-else; in this example with few and contiguous cases the compiler folds both into arithmetic/branchless, so the difference is noise — `g++ -O2 -S` before judging. This is one class the compiler can't do for you: which data types you pick and which operations you write is the hand you're holding. + +## References + +- Agner Fog, *Optimizing software in C++*, §14 *Optimizing arithmetic* (instruction-level costs of int vs float, multiply/divide, jump tables); local copy. +- Agner Fog, *Instruction tables* — latency/throughput/µop breakdowns of each instruction, for desk reference; local copy. +- ch02-03 Pipeline, ILP, and branch prediction (this volume; structural hazards and the branch predictor). +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/arithmetic_cost.cpp`. diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md new file mode 100644 index 000000000..76c38118f --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md @@ -0,0 +1,119 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 'This article covers the real power of inline (not "saving the call overhead" but "letting the compiler optimize across function boundaries"), how devirtualization turns a virtual call back into an inlineable direct call, and a landscape view of "what -O2/-O3 actually do." The point: don''t fixate on the inline keyword — modern compilers decide based on their own cost model, and your job is "don''t block the path"' +difficulty: advanced +order: 4 +platform: host +prerequisites: +- Loop and compute optimization — code motion, unrolling, and multiple accumulators +- Pipeline, ILP, and branch prediction +reading_time_minutes: 7 +related: +- Virtual functions and devirtualization — don't rush to turn virtuals into templates +- Compiler optimization boundaries — -O levels, optimization blockers, and LTO +tags: +- host +- cpp-modern +- advanced +- 优化 +title: "inline, devirtualization, and the compiler optimization landscape" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md + source_hash: 40f39088a8de03dc61bbb03f4047761d0c0078bc7552b57ee5f5e8f4f150695d + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3000 +--- +# inline, devirtualization, and the compiler optimization landscape + +## inline's real power: not saving calls, but opening up optimization space + +A lot of people's understanding of `inline` stops at "saves the function-call push/pop overhead." That's true but only on the surface — on a modern CPU a normal function call is a few nanoseconds, and the call overhead inline saves usually isn't the bulk. **inline's real power is that it "moves the callee's code into" the call site, letting the compiler optimize across function boundaries.** + +For example: + +```cpp +int square(int x) { return x * x; } +int f(int n) { return square(n) + square(n); } +``` + +Without inlining, `f` calls `square` twice — two function calls, two `n*n`. **After inlining**, the compiler sees `return n*n + n*n`, and so: + +1. **Common subexpression elimination (CSE)**: `n*n` is computed twice → merged into `int t = n*n; return t + t;`. +2. **Strength reduction**: `t + t` → `t << 1` (add → shift; pointless here, but the compiler evaluates it). +3. **Constant propagation** (if `n` is known at compile time): the whole `f(5)` folds to the constant `50`. + +These optimizations **can't happen without inlining**, because the compiler can't see `square`'s implementation (it's in another translation unit, or the compiler doesn't dare assume across the call site). Once inlining happens, the function boundary dissolves and the optimization space opens up. That's the core value of inline. + +## The inline keyword is only a "hint" + +C++'s `inline` keyword is a **hint**, not a command. Modern compilers (GCC/Clang) decide whether to actually inline based on their own **cost model**: too-big functions aren't inlined (code bloat, icache misses cost more than they gain), recursion can't be fully inlined, virtual functions can't be inlined (you don't know who's called until runtime). Conversely, **functions not marked `inline` still get auto-inlined as long as the compiler can see the implementation** (especially under `-O2`/`-O3` + LTO). + +So your job isn't "sprinkle `inline` everywhere." It's: + +- **Put hot-function implementations in the header** (or turn on LTO), so the compiler **can see** the implementation — that's the precondition for inlining. +- **Don't block with `noinline` casually** (sometimes used for debugging or to control code size, but remember to remove it). +- When you really must force inlining, use `[[gnu::always_inline]]` (GCC/Clang) or the non-standardized `__attribute__((always_inline))`, but sparingly — the compiler's cost model is usually more accurate than yours. +- C++20's `[[gnu::flatten]]` forces inlining of a whole call chain; occasionally useful on extremely sensitive hotspots. + +**A common mistake**: treating `inline` as the "make function fast" silver bullet and sticking it everywhere. If the implementation isn't in a header (cross-TU), the `inline` keyword does nothing — the compiler still can't see the implementation. LTO (ch07-02) fixes that. + +## Devirtualization: turn a virtual back into an inlineable call + +Virtual functions are inline's natural enemy, because who's called isn't known until runtime vtable lookup, and the compiler doesn't dare inline. **But if the compiler can prove "the runtime type is fixed,"** it **devirtualizes** the virtual call into a direct call, which can then be inlined. We measure four call styles (local, average ns per call): + +```text +===== Virtual functions and devirtualization ===== + virtual (pointer, runtime polymorphism): 0.55 ns ← vtable lookup + indirect jump, blocks inlining + final class (compiler devirt): 0.54 ns + direct object (non-pointer, often devirtualized): 0.23 ns + CRTP (static polymorphism, no vtable): 0.22 ns ← inlineable + virtual/CRTP = 2.5x +``` + +Two things to read out: + +**1. A virtual call through a pointer (0.55 ns) is 2.5x slower than an inlineable CRTP/direct object (0.22-0.23 ns).** Reading the assembly, in this example the virtual call was actually **speculatively devirtualized** by GCC (at runtime it compares the vptr against a compile-time-known target address, and on a hit takes an inlined fast path), and the function body `x*3+1` has already been inlined — so the extra overhead in the 0.55 ns tier isn't "vtable lookup + indirect jump," it's the per-iteration **type guard** in the loop (load vptr, compare against known address, conditional branch); the direct-object/CRTP tier has no such guard, so it's faster. **CRTP (static polymorphism, implemented with templates)** pushes polymorphism to compile time, has no vtable, can be inlined, and is the fastest. + +**2. `final` didn't speed up the loop in this example (0.54 ≈ 0.55).** I marked `struct DerivedF final`, expecting the compiler to "know there's no further derivation" and dare to devirtualize — but 0.54 ≈ 0.55 **is not "final didn't devirtualize"!** Local `-fopt-info-all` shows that the virtual calls on the final class (`DerivedF`) and the ordinary derivation (`Derived`) were both **speculatively devirtualized** by GCC (same mechanism as point 1). The real cause of 0.54 ≈ 0.55 is "the per-iteration type-guard cost of speculative devirtualization is comparable to one virtual call, so nothing was saved." This is an **honest, important result**: **don't assume that marking `final` automatically speeds things up — whether devirtualization actually saves overhead depends on whether the assembly really turned into a direct `call`** (`-S`: a virtual call is `call [vtable+offset]` indirect jump, a full devirtualization is a direct `call func`). + +Corollary: **don't preemptively turn virtuals into CRTP/templates for "might be faster."** Measure first — the compiler may already have devirtualized (especially when you call through a direct object); only when you measure that virtual functions are the bottleneck should you reach for CRTP or `final`. ch06-01 expands this into a full discussion of "the cost of virtual functions." + +## The compiler optimization landscape: what -O2/-O3 actually do + +Place inline and devirt into the bigger picture. One table showing roughly what each `-O` level does in GCC/Clang (precise list in the official docs): + +| Level | Roughly what it does | When to use | +|---|---|---| +| `-O0` | No optimization, variables observable | Debugging (**performance numbers are meaningless**) | +| `-O1` | Basic optimizations (constant folding, simple inlining) | — | +| `-O2` | Most optimizations: CSE, LICM, register allocation, scheduling, **auto-inlining (same TU)**, basic vectorization | **release default sweet spot** | +| `-O3` | More aggressive: **loop vectorization, auto-unrolling, more aggressive inlining** | Numerical/SIMD-friendly code; occasionally backfires (icache) | +| `-Os`/`-Oz` | Optimize for **size** | Embedded with flash limits | + +Key recognitions: + +- **`-O2` is the release default sweet spot.** It already does most of ch04-02's loop optimizations, same-TU inlining, and basic scheduling. +- **`-O3` over `-O2` is mostly "more aggressive vectorization + unrolling + inlining."** Useful for numerical-compute/SIMD-friendly code; **may actually slow down** branch-heavy, irregular code (code bloat → icache misses). +- **Cross-TU inlining and optimization, `-O2`/`-O3` can't do** — that needs LTO (ch07-02). That's why large projects recommend LTO for release builds. + +## Don't block the compiler: optimization blockers + +inline and the optimizations in this section all assume "the compiler can see it and dares to optimize." A few situations **block the compiler** (optimization blockers, ch07-01 in detail): + +- **Cross-TU**: can't see the implementation → won't inline. Fix: put the implementation in a header / turn on LTO. +- **Pointer aliasing**: the compiler doesn't know whether two pointers point at the same address and doesn't dare aggressively reorder memory reads/writes. Fix: declare no alias with `__restrict` (C99 origin; a C++ extension but supported by GCC/Clang). +- **`volatile`**: forces a real memory read/write every time, effectively disabling optimization. Use only for MMIO/signals/lock-free flags, **never for performance**. + +All three are "you wrote something that blocked the compiler's path." The spirit of this article is: **the main work of inlining and compiler optimization isn't "actively do something," it's "don't block the path + let it see the implementation."** Active hand-writing (forced inline, CRTP) comes only after you've measured a bottleneck. + +Compressing this article into a few lines: inline's real value is opening up optimization space across function boundaries (CSE, constant propagation, strength reduction), not saving call overhead; the `inline` keyword is only a hint, and letting the compiler see the implementation (header / LTO) is the precondition for inlining; devirtualization can turn a virtual function back into an inlineable call, but it's not "slap on `final` and you save overhead" — in this example both the final class and the ordinary derivation were speculatively devirtualized by GCC (provable with `-fopt-info-all`), and 0.54 ≈ 0.55 is the per-iteration guard cost that didn't get saved; direct-object calls are easier to fully devirtualize; CRTP is static polymorphism and the fastest (virtual/CRTP = 2.5x); `-O2` is the release sweet spot, and `-O3` adds aggressive vectorization/unrolling that's numerical-friendly but occasionally backfires; optimization blockers (cross-TU, aliasing, volatile) are you blocking the compiler's path — the next article, ch04-05, runs into the "does the compiler dare" question again in the SIMD context. + +## References + +- Piotr Padlewski, *C++ devirtualization in clang* (CppCon 2015 Lightning) — devirtualization mechanisms (reused in vol10). +- GCC/Clang docs on `-O` levels, `-finline-*`, `__restrict`, the `always_inline` attribute. +- ch06-01 Virtual functions and devirtualization (this volume; the full discussion of virtual-function cost). +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp`. diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md new file mode 100644 index 000000000..6bef682b0 --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md @@ -0,0 +1,126 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 'SIMD lets one instruction process multiple data elements, with theoretical speedups of 4x/8x/16x. With a measured dot product, this article clarifies three things — the conditions for compiler auto-vectorization (and corrects a common misconception: modern GCC does vectorize FP reductions by default, but the single-accumulator dependency chain caps the gain), hand-written AVX2 intrinsics steadily deliver ~20x (measured), and why SSE/AVX2 is a safe sweet spot while AVX-512 needs care with frequency throttling' +difficulty: advanced +order: 5 +platform: host +prerequisites: +- Loop and compute optimization — code motion, unrolling, and multiple accumulators +- Memory hierarchy and the latency ladder +reading_time_minutes: 8 +related: +- Backend memory bottlenecks — cache-friendly, AoS/SoA, and prefetch +- Compiler optimization boundaries — -O levels, optimization blockers, and LTO +tags: +- host +- cpp-modern +- advanced +- 优化 +title: "SIMD and vectorization: auto-vectorization conditions, intrinsics, and CPU dispatch" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md + source_hash: 40f4f30e8f09ab99c5600f89adfc36322a26625e706fd420a02aec10ea8daedf + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3300 +--- +# SIMD and vectorization: auto-vectorization conditions, intrinsics, and CPU dispatch + +## One instruction, multiple data + +SIMD (Single Instruction, Multiple Data) is the contemporary CPU's main lever for more compute throughput: one instruction processes multiple data elements at once. The wider the register, the more data per instruction: + +- **SSE**: 128-bit registers, 4 floats at a time (or 2 doubles). +- **AVX/AVX2**: 256-bit, 8 floats at a time. +- **AVX-512**: 512-bit, 16 floats at a time. + +In theory, converting a scalar loop to AVX2 multiplies throughput directly by ×8 (8 floats' worth of arithmetic per instruction). We measure a dot product to see the real number: + +```text +===== SIMD dot product (4M floats = 16MB, average of 30 runs) ===== + scalar dot_scalar 958.2 us + hand-written AVX2 dot_avx2 48.1 us + AVX2/scalar = 19.9x +``` + +**Close to 20x.** This number is higher than the theoretical 8x because the hand-written version, on top of the SIMD width, also uses FMA (one instruction does the work of a multiply and an add) and multiple accumulators (breaking the dependency chain, per 04-02) — all three stacked. **First, correct one piece of intuition**: the scalar `dot_scalar` at `-O3 -mavx2` is **already auto-vectorized** (`g++ -O3 -mavx2 -fopt-info-vec-optimized` reports `loop vectorized using 32 byte vectors`), it's not "not vectorized." So why is it still 20x slower? Because its accumulation forms a dependency chain (the assembly below shows it's a scalar horizontal accumulation chain), and the hand-written `dot_avx2` has 4 parallel accumulators. **This 20x is mostly an ILP gap of "multiple accumulators vs single accumulator," not "vectorized vs not."** That's the point this article is built to make. + +## Auto-vectorization: conditions + a common misconception about FP reductions + +The compiler at `-O2`/`-O3 -ftree-vectorize` tries to auto-convert loops to SIMD. Preconditions: + +1. **Iteration count is estimable** (the compiler needs a rough idea of how many to schedule SIMD). +2. **No pointer aliasing** (the compiler has to assume the memory accessed by two loop iterations doesn't overlap; `__restrict` helps, see ch04-04). +3. **No function calls** (or the calls can be inlined away). +4. **Simple branches** (no complex control flow). + +**The fifth condition, "FP reduction," deserves an update from the old wisdom.** The core of a dot product is `acc += a[i] * b[i]`, an **FP reduction**. Vectorizing it means splitting the accumulator into parallel lanes, which **changes the association order of floating-point addition**, and FP addition **is not associative** (`(a+b)+c ≠ a+(b+c)`, different mantissa rounding errors). **Old textbooks often say "default -O3 strictly respects FP semantics, doesn't dare change the association order, so it doesn't vectorize FP reductions" — that claim is outdated on modern GCC**: GCC 10 onward uses the **ordered (order-preserving) reduction mode** to vectorize FP reductions, preserving the observable accumulation order (bit-exact result) and only using SIMD internally for the order-preserving partial sums. Verify it yourself: `g++ -O3 -mavx2 -fopt-info-vec-optimized` on this article's `dot_scalar` reports `loop vectorized using 32 byte vectors`. + +**So why is the scalar dot product still nearly 20x slower than hand-written dot_avx2?** Look at the assembly the ordered mode actually produces: at `-O3 -mavx2` the multiply in `dot_scalar` is vectorized with `vmulps` (8-way SIMD), but **the order-preserving accumulation is a chain of scalar `vaddss` (lane-by-lane horizontal adds into the same `xmm0`)** — a scalar add dependency chain. That's the cost of ordered mode: it vectorized the multiply, but couldn't parallelize the accumulation (parallel accumulation would change the FP association order). The hand-written dot_avx2's 4 vector accumulators are what break that limit and let the 4 FMAs actually run in parallel. **So this 20x is mostly the gap of "4 parallel accumulators vs scalar sequential accumulation," not "vectorized vs not."** If you want the compiler to split the lanes itself, you have to give it `-ffast-math` (or the narrower `-fassociative-math`) to loosen FP semantics. **With `-ffast-math`, GCC does rearrange `dot_scalar` into 4 vector accumulators** (assembly shows `vfmadd231ps × 4 + unroll 32`, same structure as hand-written `dot_avx2`); but on this WSL2 machine the measured time barely changes, which means the 20x gap on this simple loop isn't only about accumulator count — `dot_scalar` gets inlined into the REP outer loop, while `dot_avx2` as an independent function call sits in a different measurement structure. Integer reductions don't have the associativity problem, default to multi-lane, and auto-vectorize more aggressively. + +To know whether your loop vectorized and why it didn't get the full speedup, use GCC's diagnostics: `-fopt-info-vec-optimized` (what got vectorized) and `-fopt-info-vec-missed` (why not) — the fastest way to read the compiler's mind. + +## Hand-written intrinsics: AVX2 in practice + +When auto-vectorization can't get there (non-standard access patterns, specific instructions, or you just want to squeeze hard), hand-write intrinsics. intrinsics are compiler-builtin C functions that map to single SIMD instructions, header ``. The core of an AVX2 dot product: + +```cpp +#include +float dot_avx2(const float* a, const float* b) { + __m256 v0 = _mm256_setzero_ps(), v1 = _mm256_setzero_ps(), + v2 = _mm256_setzero_ps(), v3 = _mm256_setzero_ps(); + for (int i = 0; i < N; i += 32) { // 4 vectors × 8 floats = 32 + v0 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i), _mm256_loadu_ps(b+i), v0); + v1 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+8), _mm256_loadu_ps(b+i+8), v1); + v2 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+16), _mm256_loadu_ps(b+i+16), v2); + v3 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+24), _mm256_loadu_ps(b+i+24), v3); + } + // horizontal merge of the 4 accumulators + __m256 s = _mm256_add_ps(_mm256_add_ps(v0, v1), _mm256_add_ps(v2, v3)); + alignas(32) float tmp[8]; _mm256_store_ps(tmp, s); + float acc = 0; for (int i = 0; i < 8; ++i) acc += tmp[i]; + return acc; +} +``` + +A few notes: + +- **`__m256`** is the 256-bit vector type, holding 8 floats. +- **`_mm256_loadu_ps`**: load 8 floats from memory (`u` = unaligned, allows unaligned; the aligned version `_mm256_load_ps` requires 32-byte alignment). +- **`_mm256_fmadd_ps(a, b, c)`**: `a*b + c`, FMA does a multiply-add in one instruction (AVX2 + FMA only; compile with `-mfma`). +- **4 accumulators** `v0..v3`: break the dependency chain (04-02) so 4 FMAs can fill the execution ports in parallel. +- **Horizontal merge**: a SIMD accumulator finally has to reduce to a scalar; this step has a lot of scalar ops, so don't open too many accumulators (8 doesn't beat 4). + +This style squeezes the SIMD width + FMA + multiple accumulators dry and measures to 20x. The cost is **poor portability** (locked to AVX2+FMA; older CPUs can't run it) and **poor readability**. So the boundary for hand-written intrinsics is: **don't hand-write what auto-vectorization can do; only hand-write when auto can't and you've confirmed it's a hotspot.** + +## AVX-512: watch out for throttling + +AVX-512 sounds even more aggressive (512-bit, 16 floats at a time), but there's a **trap**: on some Intel CPUs, running AVX-512 instructions triggers **license-based frequency throttling** — the CPU decides AVX-512's power/thermal requirements are high and actively drops the **all-core** frequency a step, and the throttle **persists for a while even after the AVX-512 instructions end** (waiting to "cool down"). The result: a small stretch of AVX-512 code can slow down **the rest of the whole program**, a net loss. + +This trap has been improving across Intel generations (Ice Lake onward, lightweight AVX-512 doesn't downclock; Skylake-X was the worst), and AMD Zen 4 only introduced AVX-512 with a different policy. **In practice, SSE/AVX2 + FMA is the safe sweet spot** — the gain is already big and there's no throttle trap; AVX-512 is either for new CPUs confirmed not to throttle, or for the "lightweight" instruction subset, and **must be benchmarked**. The local 5800H is Zen 3 and only supports up to AVX2 (no AVX-512), so all SIMD examples in this volume are AVX2-level. + +## CPU dispatch: function multiversioning + +Hand-written intrinsics lock the instruction set, hurting portability. The fix is **CPU dispatch / function multiversioning**: write multiple versions of the same function (SSE, AVX2, scalar) and pick at runtime by CPU capability. GCC's built-in `__builtin_cpu_supports("avx2")` queries capability; the more elegant `__attribute__((target_clones("avx2","default")))` has the compiler auto-generate the versions plus the dispatch: + +```cpp +__attribute__((target_clones("avx2","default"))) +float dot(const float* a, const float* b) { /* one piece of generic code, compiler compiles per target */ } +``` + +The compiler produces an AVX2 version and a default version, and on first call detects the CPU and picks (the IFUNC mechanism). One piece of source, each CPU runs its optimized version. The cost: a bigger binary (multi-version code) and a tiny dispatch overhead on first call. + +## Outlook: std::simd (C++26) + +C++26 standardizes `std::simd` (in ``), providing **a portable SIMD abstraction** — you write "process with width-N SIMD" and the compiler maps it to each platform's concrete instructions. This offloads the portability problem of hand-written intrinsics to the standard library. vol6 only drops a one-line outlook here; depth waits for C++26 adoption. + +Compressing this article: SIMD is theoretically ×4/×8/×16, and the measured hand-written AVX2 dot product gets **~20x** (SIMD width + FMA + multiple accumulators stacked). The conditions for auto-vectorization are estimable iteration count, no aliasing, no complex branches; **modern GCC (GCC 10+) vectorizes FP reductions in ordered mode by default (order-preserving, bit-exact result), but the single-accumulator dependency chain caps it**, and getting the full SIMD needs **multiple accumulators** (hand-written, or `-ffast-math` so the compiler dares to split lanes) — it's not "turn on -O3/-ffast-math and you're done"; read the diagnostics with `-fopt-info-vec`. Hand-written intrinsics squeeze SIMD dry but hurt portability; only use them on hotspots where auto can't. AVX-512 has a throttle trap; SSE/AVX2+FMA is the safe sweet spot; you must benchmark. CPU dispatch (`target_clones` / `__builtin_cpu_supports`) solves portability; `std::simd` (C++26) is the future. + +## References + +- Agner Fog, *Optimizing assembly*, §5 "Vector programming" + §13 "AVX-512"; local copy. +- GCC docs on `-ftree-vectorize` / `-fopt-info-vec` / `target_clones` / `__builtin_cpu_supports`. +- Intel Intrinsics Guide (intel.com/intrinsics-guide) — intrinsic lookup. +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/simd.cpp`. diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md new file mode 100644 index 000000000..91e4318ec --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md @@ -0,0 +1,108 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 'The Bad Speculation bucket''s countermeasure is to eliminate unpredictable branches. This article covers branchless (cmov / bitwise ops to remove branches) and predication, but the core message is counterintuitive: modern CPU branch predictors are extremely strong + the compiler often auto-branchless-es (loops vectorized into SIMD masks, scalars into cmov), so "don''t go branchless blindly" — predictable branches are nearly free, and a branchless rewrite that adds instructions or data dependencies can be slower. Always benchmark' +difficulty: advanced +order: 6 +platform: host +prerequisites: +- Pipeline, ILP, and branch prediction +- Data types and arithmetic — int vs float, the division bottleneck, and jump tables +reading_time_minutes: 6 +related: +- Loop and compute optimization — code motion, unrolling, and multiple accumulators +- Frontend optimization — code layout, PGO, BOLT +tags: +- host +- cpp-modern +- advanced +- 优化 +title: "Branches: branchless, predication, and \"don't go branchless blindly\"" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md + source_hash: 7ade38dd6e5ddb37b7f7494d863b58dd57e394732e08dd0a469aca4c1858f809 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2800 +--- +# Branches: branchless, predication, and "don't go branchless blindly" + +## The Bad Speculation bucket's countermeasure + +In ch02-03 we measured: a 50/50 random branch that the predictor guesses wrong has to flush the pipeline, which is brutally expensive — sorted array (predictable) vs shuffled array (unpredictable) differs by **4.2x**. TMAM attributes that waste to the **Bad Speculation** bucket. This bucket's countermeasure has one line of thought: **eliminate unpredictable branches**. + +There are two ways to eliminate: **branchless** (turn the branch into branchless cmov/bitwise ops) and **predication** (compute both paths, then pick by condition at the end — if both are computed, "guessing wrong" doesn't matter). Both sound like silver bullets, but the core message of this article is the counterintuitive one: **don't go branchless blindly.** We use experiments to show why. + +## branchless: cmov and bitwise ops + +The most common branchless rewrite is `std::min`/`std::max`/`std::clamp` and friends, which bottom out in the **cmov** (conditional move) instruction: both paths' results are computed, then "selected" by condition, with no branch jump anywhere and therefore no misprediction. + +```cpp +// Branchy: mispredicts on unpredictable data, pipeline flush +int clamp_if(int x, int lo, int hi) { + if (x < lo) return lo; + if (x > hi) return hi; + return x; +} +// Branchless (std::min/max compiles to cmov) +int clamp_cmov(int x, int lo, int hi) { + return std::min(std::max(x, lo), hi); +} +``` + +The cost of `cmov`: both paths are computed (one extra computation), there's a data dependency (the selected result depends on the condition), but **there's no control dependency, no pipeline flush**. On an **unpredictable** branch, cmov wins big; on a **predictable** branch, cmov is actually slower (because both are computed + the data dependency chain is longer, while the branch version's predictor hits and is nearly free). + +## Run it (with an honest result) + +I measured three clamp styles on 50/50 random data: an if branch / `std::min`+`std::max` (cmov) / a bitwise trick: + +```text +===== branchless vs branch (clamp, random data 50/50) ===== + if branch: 0.27 ns/run (misprediction flush) + std::min/max: 0.25 ns/run + bitwise trick: 0.25 ns/run + if / cmov = 1.07x +``` + +**Only a 1.07x gap, not the "branchless wins big" I expected.** Why? Look at the assembly (`g++ -O2 -S branchless.cpp`) — **the cmov count in the entire loop is 0**: at default `-O2`, GCC **auto-vectorizes all three clamp loops into the same SIMD mask operation** (`-fopt-info-vec` reports `loop vectorized using 16 byte vectors`). In other words, whether you write `if`, `std::min/max`, or a bitwise trick, **after -O2 in a loop they all become the same vector code**, so all three are equally fast. **Note this isn't "if was turned into cmov"** (only a scalar single-shot clamp gets cmov) **— it's "the loop got vectorized." Don't explain it with the wrong mechanism.** + +This is an **extremely important honest result**: the `if` you wrote isn't necessarily a real branch — the compiler may have already branchless-ed it for you (in loops via SIMD vectorization, in scalar single-shots via cmov). To confirm, you have to look at the assembly (`-S`) + the vectorization diagnostics (`-fopt-info-vec`): a real branch is a `jcc` (conditional jump), a scalar branchless is `cmov`/`cset`, a loop branchless is a SIMD mask instruction. **Don't assume your if is a branch, and don't assume hand-written branchless is necessarily faster.** + +Where does the real-branch cost live then? To force a real branch out, you have to **disable the compiler's if-conversion** (just like in ch02-03, where I needed `-fno-if-conversion` to measure the 4.2x). The ch02-03 experiment (shuffled vs sorted, 4.2x) is the honest evidence of the real-branch penalty. The 1.07x in this article is the honest evidence that "the compiler already went branchless." Together they're the complete picture. + +## predication: compute both paths + +The generalization of `cmov` is **predication**: rather than branch and pick one path to compute, **compute both paths and pick by condition at the end.** GPUs and vector architectures (SSE/AVX masks) use predication heavily because they hate branches (divergence). On x86, predication shows up as cmov / cset. + +The cost of predication: total work = the sum of both paths (even though only one's result is taken). So it's **only suited to "both paths are cheap"** scenarios (`max`, `min`, `clamp`, simple assignments). If one of the two paths is expensive (a division, a function call), predication forcing both is a big loss — **a branch is better there** (the expensive path only runs when it hits). That tradeoff is the other face of "don't go branchless blindly." + +## `[[likely]]` / `[[unlikely]]`: hand the compiler a branch probability + +C++20 standardized `[[likely]]` / `[[unlikely]]` (the old way was GCC's `__builtin_expect`): give the compiler a branch-probability hint so it lays out the likely path's code together (improving icache, see ch04-07 frontend optimization) and tunes the branch-prediction assumptions. + +```cpp +if (rare_error) [[unlikely]] { handle_error(); } // tell the compiler this path is rare +``` + +Note that the **main payoff of `[[likely]]` is code layout** (cluster the hot path, push the cold path to the end of the function) for better icache hit rate — that's a Frontend optimization. It's **not** "make the branch predictor more accurate" (the hardware predictor doesn't read your source annotations; it reads runtime history). So `[[likely]]` is useful when "the branch is heavily skewed + the function is fairly large," and basically useless for "a branch inside a small loop." + +## "Don't go branchless blindly": four disciplines + +Compress this article's honest results into four disciplines: + +1. **Predictable branches are nearly free.** Loop-exit conditions, `if (ptr == nullptr)` — branches that almost always go the same way have 99%+ predictor hit rate, no need to bother eliminating them. What you should eliminate are **data-dependent, unpredictable branches**. +2. **Your `if` isn't necessarily a real branch.** The compiler often auto-branchless-es (in loops via SIMD masks, in scalar via cmov); confirm with the assembly + `-fopt-info-vec`. +3. **branchless isn't a silver bullet.** If predication computes both paths (one of them expensive), or lengthens the data dependency chain, branchless is actually slower. +4. **Always benchmark against a control.** Measure before and after a branchless change with the same methodology (ch01); the signal beats intuition. + +This "don't go blindly" discipline actually runs through all of ch04. 04-02's loop optimization, 04-04's inline, this article's branchless — the story is the same one: **the modern compiler + hardware predictor does most of it for you, so your job isn't "hand-write optimizations hard," it's "measure the real bottleneck, change precisely, verify after."** Performance optimization isn't a pile of tricks; it's measurement-driven precision surgery. + +Looking back at this article: the Bad Speculation bucket's countermeasure is eliminating unpredictable branches, with branchless (cmov/bitwise) and predication as the techniques; the measured if/cmov/bitwise-trick clamp are all basically the same speed (1.07x), because at `-O2` all three loop forms get vectorized into the same SIMD mask code (`-S` shows cmov count = 0), and the real-branch-penalty evidence is ch02-03's 4.2x; predication only suits the "both paths cheap" case — when one path is expensive, a branch wins; `[[likely]]`/`[[unlikely]]`'s main payoff is code layout (icache), not the predictor; and the bottom line is don't go branchless blindly — predictable branches are free, your if might already be a cmov, and the benchmark is the only referee. + +## References + +- Agner Fog, *The microarchitecture of Intel, AMD and VIA CPUs*, branch-prediction chapter; local copy. +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, Chapter 9 *Optimizing Bad Speculation*. +- ch02-03 Pipeline, ILP, and branch prediction (this volume; the source of the 4.2x real-branch penalty measurement). +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/branchless.cpp`. diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md new file mode 100644 index 000000000..3cefd846a --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md @@ -0,0 +1,111 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 'The Frontend Bound bucket''s countermeasure is keeping fetch/decode fed. This article covers how to treat icache/iTLB misses — controlling template and inline bloat, using PGO so the compiler lays out hot code by a real profile, and using BOLT for post-link code-layout optimization. With a PGO three-stage experiment we land an honest conclusion: PGO may have no payoff on microbenchmarks; its value is in large codebases' +difficulty: advanced +order: 7 +platform: host +prerequisites: +- TMAM's four buckets and hardware sampling +- inline, devirtualization, and the compiler optimization landscape +reading_time_minutes: 6 +related: +- Branches — branchless, predication, and "don't go branchless blindly" +- LTO, ThinLTO, and engineering PGO into the build +tags: +- host +- cpp-modern +- advanced +- 优化 +title: "Frontend optimization: code layout, PGO, and BOLT" +translation: + source: documents/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md + source_hash: bd86cf4f23f841406267c39d4e608b15e1aa3431b58faf392ad558771d85d92c + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2800 +--- +# Frontend optimization: code layout, PGO, and BOLT + +## Frontend Bound: fetch/decode can't keep up + +The first six articles of ch04 treat Backend (memory, compute) and Bad Speculation (branches). The last kind of bottleneck is **Frontend Bound** — the CPU frontend (fetch, decode) can't keep up with the backend's execution speed, and slots stall because "instructions didn't get fed in time." This bottleneck is common in large codebases and shows up as: + +- **icache miss**: code size is too big, the instruction cache can't hold it, and instructions are constantly fetched from L2/L3. +- **iTLB miss**: instruction page-table translation also misses, especially when there are many code pages. +- **Code bloat**: excessive inline / template bloat inflates the instruction count for the same logic and pressures icache. + +Frontend Bound is rare in tight numerical loops (small code, high icache hit rate) and common in **large applications with heavy templates and lots of branches**. The countermeasures all revolve around one thing: **keep hot code compact and laid out together, icache-friendly.** + +## First move: control code bloat + +The first Frontend optimization is "don't let the code grow needlessly": + +- **Avoid excessive inline**: more inline isn't better. Excessive inline bloats code size (the same inlined code unrolled in many places), icache can't hold it, and it actually slows down. 04-04 covered this — the compiler has a cost model, so don't force `always_inline` wildly. +- **Control template bloat**: a template generates one copy of code per type. If one template is instantiated for 20 types, that's 20 copies. Counter: pull type-independent logic into a non-template base class / common function (compiled once), use `extern template` (C++11) to explicitly instantiate and avoid repeated generation. +- **`-ffunction-sections -fdata-sections` + link-time `--gc-sections`**: put each function/data in its own section and reclaim unreferenced sections at link time. Strip dead code, shrink size. This is a standard size-optimization move (ch07-04) that also improves icache as a side effect. + +## Second move: PGO (lay out code by a real profile) + +**PGO (Profile-Guided Optimization)** is the headliner of Frontend optimization. The idea: run the program once to collect a profile of "which code is actually hot, how each branch goes," and the compiler relayouts code from that — clustering hot-path code physically together (improving icache), tuning branch-prediction layout, and making smarter inline decisions. + +PGO is a three-stage flow: + +```bash +# Stage 1: instrumented compile (insert counters) — note the -o name must exactly match stage 3! +g++ -O2 -fprofile-generate app.cpp -o app_pgo +# Stage 2: run a representative workload to generate a profile (.gcda file; the filename embeds "the binary that produced it") +./app_pgo +# Stage 3: recompile with the profile (-o must match stage 1's name; otherwise the .gcda filename won't match +# and the compiler warns "profile count data file not found", and the profile isn't applied) +g++ -O2 -fprofile-use app.cpp -o app_pgo +``` + +### An honest experiment: PGO has no payoff on microbenchmarks + +With a small function (`process`) that "goes the hot path 99% of the time and a cold path 1%," I ran the full three-stage PGO and compared it to a pure `-O2` baseline (3 runs each, take the stable one): + +```text +===== Three-way comparison ===== + Pure -O2 baseline: 3.57-3.82 ms + -O2 + PGO: 3.78-4.17 ms +``` + +**PGO has zero payoff** (even slightly slower, within noise). That's an **important honest result**, worth expanding on: + +- This small function has only 2 branches and a few lines of code; **the compiler at -O2 already optimizes it well** (hot path inlined, the predictor hits 99% on a 99/1 branch). +- PGO's real value is **code layout in large codebases**: physically clustering the hot path across thousands of functions for a clear icache hit-rate gain. For a few-dozen-line function, there's nothing to lay out. +- Industry PGO payoffs all come from **large projects**: Chrome, Firefox, the major databases, reporting single-digit to teen-percent speedups — on the precondition that the codebase is large enough that icache/branch layout is a real bottleneck. + +> The first time I ran this experiment, the "PGO version" looked 4x faster and I got excited. It turned out that 4x was entirely **instrumentation overhead of the instrumented binary** (stage 1's `app_pgo` carries performance counters and is much slower), not PGO's doing. The fix: use a **pure `-O2`, non-instrumented** baseline for comparison, and make sure stage 3's profile is actually applied (the compiler will warn `profile count data file not found` if it isn't). I'm writing this crash here to reinforce ch01's discipline one more time: **what you think is a PGO speedup may actually be instrumentation overhead.** The baseline must be clean. + +So the practical advice for PGO: **don't expect it to work on microbenchmarks.** Turn it on in your real large-project release build (`-fprofile-generate` → run a representative load → `-fprofile-use`), sampling with production-grade workloads, and it means something. Engineering PGO into the build (how to pick a workload, how to integrate with CI) is ch07-02's job. + +## Third move: BOLT (post-link layout optimization) + +**BOLT (Binary Optimization and Layout Tool)** is the LLVM project's tool, doing an advanced version of PGO: **code-layout optimization directly on an already-linked binary**, no recompile needed. It reads a perf-collected profile and rearranges code blocks in the binary (placing hot basic blocks contiguously, pushing cold ones to the end), with significant effects on **large binaries** (community reports single-digit to teen-percent gains). + +BOLT's advantage: **no need to recompile the whole project** (extremely valuable for big projects where a full rebuild is tens of minutes to hours); it operates only on the final binary. The cost: more build-pipeline complexity and a need for profile data. It suits the **"already using LTO + PGO and want to squeeze more"** extreme-optimization case; ordinary projects don't need it. + +## Practical priority order for Frontend optimization + +Rank the three moves by priority: + +1. **Control bloat** (don't over-inline, pull templates into common code, gc-sections) — free, low-risk, do first. +2. **PGO** (turn on for large-project release builds) — real payoff in big codebases, medium integration cost. +3. **BOLT** (extreme optimization) — for the "already doing LTO+PGO and want more" case, high integration cost. + +But **first confirm you're actually Frontend Bound** — use TMAM to see whether the Frontend bucket's share is high (ch03-02). Going to PGO/BOLT without profiling first is "hammer looking for a nail": you might sweat for half a day and find the real bottleneck is elsewhere (often Backend Memory). + +Looking back at this article: Frontend Bound is fetch/decode not keeping up, common in big codebases, and the countermeasure is keeping hot code compact and laid out together; the three moves are controlling bloat (don't over-inline, pull templates into common code, gc-sections), PGO (lay out by profile), and BOLT (post-link layout); **PGO has no payoff on microbenchmarks** (measured ~3.7 vs ~3.9 ms), and its value is in large codebases (Chrome/Firefox-class, public payoffs single-digit to teen-percent); **that 4x that showed up once was instrumentation overhead, not PGO, and the baseline must be clean**; and finally, **profile first to confirm Frontend really is the bottleneck** before PGO/BOLT — don't swing a hammer looking for a nail. + +With this article, ch04's tuning-by-bottleneck-site is complete. Each of the four buckets (Backend Memory / Backend Core / Bad Speculation / Frontend) has its countermeasures. The next article shifts perspective to multicore performance (ch05), where new bottleneck types appear (false sharing, NUMA). + +## References + +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs*, Chapter 7 *CPU Front-End Optimizations*. +- LLVM BOLT docs (github.com/llvm/llvm-project/blob/main/bolt). +- GCC PGO docs `-fprofile-generate` / `-fprofile-use`. +- ch07-02 LTO, ThinLTO, and engineering PGO into the build (this volume). +- Measured code for this article: `code/volumn_codes/vol6-performance/ch04/pgo_demo.cpp` (the three-stage PGO script is in the README). diff --git a/documents/en/vol6-performance/ch04-tuning-by-bottleneck/index.md b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/index.md new file mode 100644 index 000000000..e81b1b621 --- /dev/null +++ b/documents/en/vol6-performance/ch04-tuning-by-bottleneck/index.md @@ -0,0 +1,31 @@ +--- +title: "Tuning by bottleneck site" +description: "ch04 is the technical body of vol6, aligned to the four TMAM buckets: Backend Memory (cache-friendly, AoS/SoA, prefetch), Backend Core (loop optimization, data types and arithmetic, inline plus devirtualization, SIMD), Bad Speculation (branchless), Frontend (code layout, PGO, BOLT), each article backed by on-machine measurement" +--- + +# Tuning by bottleneck site + +ch03 teaches us to use USE / Roofline / TMAM / flame graphs to attribute the bottleneck to a pipeline bucket. This chapter is the prescription, walking each bucket through how to treat it. It is the technical body of vol6 and the longest chapter in the volume. + +The four buckets map to seven articles: + +- **Backend Memory** (04-01): cache-friendly, AoS to SoA, prefetch. The biggest single-threaded lever, with AoS to SoA measuring close to a 10x speedup. +- **Backend Core** (04-02 / 04-03 / 04-04 / 04-05): loop optimization, data types and arithmetic, inline plus devirtualization, SIMD. Division is a 5x bottleneck over multiplication, SIMD measures around 20x, virtual calls vs CRTP are 2.5x. +- **Bad Speculation** (04-06): branchless and predication, with the core message "don't go branchless blindly." +- **Frontend** (04-07): code layout, PGO, BOLT. + +The single sentence that runs through the whole chapter is this: **modern compilers plus modern hardware already do most of the optimization for you, so your job is not "pile on handwritten tricks," it's "measure the real bottleneck, change precisely, then verify with the same methodology."** Several honest results live in this chapter: switch isn't always faster than if-else, `final` doesn't automatically devirtualize, FP reduction isn't auto-vectorized the way the old textbooks describe, branchless at -O2 is the same speed as the branch version, PGO has no benefit on microbenchmarks. They are all footnotes to that sentence. Performance optimization is measurement-driven precision surgery, not a pile of tricks. + +> Boundary note: this chapter only covers "**P** — when running on hardware, how to make it faster." "**D** — why vector/string are designed this way" and "**U** — how to use containers correctly" belong to vol3; "EBO/SSO mechanisms" belong to vol4; "how to write lock-free code and synchronization primitives" belong to vol5. ch04-01 was edge-checked against vol3 before writing: it covers layout for performance and does not redo mechanism explanations. + +## In this chapter + + + Backend memory bottlenecks: cache-friendly, AoS/SoA, and prefetch + Loop and compute optimization: code motion, eliminating memory references, and multiple accumulators + Data types and arithmetic: int vs float, the division bottleneck, and jump tables + inline, devirtualization, and the compiler optimization landscape + SIMD and vectorization: auto-vectorization conditions, intrinsics, and CPU dispatch + Branches: branchless, predication, and "don't go branchless blindly" + Frontend optimization: code layout, PGO, and BOLT + diff --git a/documents/en/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md b/documents/en/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md new file mode 100644 index 000000000..9f841c6c3 --- /dev/null +++ b/documents/en/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md @@ -0,0 +1,116 @@ +--- +chapter: 5 +cpp_standard: +- 17 +description: False sharing is the most hidden and most dramatic trap in multicore performance. Two unrelated variables happen to sit on the same 64-byte cacheline; two cores write them separately and the hardware coherence protocol invalidates the other core's cacheline on every write, forcing them to run serially in effect. This article measures it with two threads incrementing counters, false sharing is about an order of magnitude slower than alignas(64) (about 18x in a single run on this machine, multiplier varies a lot between runs), and explains the MESI coherence protocol +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Cachelines and locality - the 64-byte minimum unit of transfer +- Benchmark methodology reference card +reading_time_minutes: 6 +related: +- NUMA, affinity, and the scalability curve +- Lock cost and "lock-free is not a silver bullet" +tags: +- host +- cpp-modern +- advanced +- 优化 +- 并发 +title: 'False sharing: one cacheline dragging many cores back to single-core' +translation: + source: documents/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md + source_hash: 2654b223c503fb37c157be4f33a5b46835fa269a121e4d81caccb19e2923adfd + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3200 +--- +# False sharing: one cacheline dragging many cores back to single-core + +## The foreshadowing ch02 planted, paid off here + +In ch02-02 on cachelines I planted a foreshadowing: cacheline "sharing" is a dividend on a single core (spatial locality), but on multicore it can turn into a tax. This article pays it off. + +Recall that a cacheline is the minimum unit of cache, 64 bytes, and **also the minimum unit of coherence**: the hardware guarantees that the same cacheline is consistent across the whole system. Under multicore, the price of that guarantee is **false sharing**. Two cores frequently write **different variables sitting on the same cacheline**, and to maintain coherence the hardware invalidates the other core's copy of that cacheline on every write, so the two cores are **forced to run serially in effect**: it looks parallel, but they keep kicking each other's cache. + +This is the most hidden trap in multicore performance. At the code level the two threads operate on **completely unrelated variables**, with no logical sharing, but at the performance level they're tied together by an invisible cacheline. It doesn't make the program wrong (correctness is fine), it just makes the program mysteriously slow. + +## MESI coherence: why the cacheline is the multicore battlefield + +To understand false sharing you first have to understand **the cache coherence protocol** (x86 uses MESI and its variants). Every cacheline has a state in every core's cache: + +- **M(odified)**: only my core has it, and I've modified it (dirty). +- **E(xclusive)**: only my core has it, not modified (clean). +- **S(hared)**: multiple cores have the same copy (clean). +- **I(nvalid)**: invalid, can't be used. + +When core A wants to write an S-state (shared) cacheline, it has to signal the other cores first, "I'm about to write, invalidate this line", and the others mark their copy of that cacheline as I. Next time core B wants to use that cacheline, it finds its copy is I and has to fetch it again (cache-to-cache or from memory). **This "invalidate the other side plus re-fetch" round-trip is where the cost of false sharing comes from.** + +The key point: **the granularity of coherence is the cacheline (64 bytes), not a single variable**. So even if core A writes `counter_a` and core B writes `counter_b`, as long as `counter_a` and `counter_b` sit in the same 64 bytes, the hardware treats it as "the same line being modified on both sides" and triggers the full invalidate round-trip. The variables are logically unrelated but physically on the same boat. + +## Run it: the cost of an order of magnitude + +The classic scenario: two threads each have a counter and each increments it a hundred million times. Completely independent logically. + +```cpp +// A. False sharing: two atomic packed together, same cacheline +struct BadCounters { std::atomic a{0}; std::atomic b{0}; }; // sizeof = 16B +// B. No false sharing: each alignas(64) occupies its own cacheline +struct alignas(64) PaddedCounter { std::atomic v{0}; }; +struct GoodCounters { PaddedCounter a; PaddedCounter b; }; // sizeof = 128B +``` + +Two threads each touch only their own counter (`a` and `b`), running the same number of times: + +```text +===== False sharing (2 threads each incrementing 100M times) ===== + false sharing (same cacheline): 467.0 ms + alignas(64) (own cacheline): 26.0 ms + false sharing / aligned = 18.0x + sizeof(BadCounters)=16 sizeof(GoodCounters)=128 +``` + +**Close to 20x (an order of magnitude).** Same amount of computation, same number of threads, and the only difference is whether the two counters are crammed onto the same cacheline. That spreads them apart by an order of magnitude. **The absolute multiplier varies a lot between runs**: on this machine, reproducing on the same hardware, I've seen the multiplier anywhere from 15x to 48x (the run above is 18x), and WSL2 scheduling noise amplifies the jitter; but "an order of magnitude apart" is a stable conclusion. `BadCounters` is 16 bytes (two `atomic` crammed into one 64B cacheline); `GoodCounters` uses `alignas(64)` so each counter owns its own line, 128 bytes total, and false sharing is gone. + +That's how lethal false sharing is: **it can drag a program that "looks perfectly parallel" down to nearly single-threaded speed**. The nastier part is that a correct TSan/ASan run won't catch it (because it's not a data race, not UB, logically correct); only a **performance-oriented profiler** can: + +```bash +# perf's dedicated false-sharing tool (this WSL2 machine has no perf; command from KDAB/Brendan Gregg): +perf c2c record -- ./your_app +perf c2c report +# Look at the HITM (Hit Modified) counts; the hot spots are the false-sharing disaster zones +``` + +## The fix: alignas(64) gives hot variables their own cacheline + +The fix is almost brutally direct: **make any variable that different cores will write frequently occupy its own cacheline**. In C++ that's `alignas(64)`: + +```cpp +struct alignas(64) AlignedCounter { std::atomic v{0}; }; +``` + +`alignas(64)` forces this struct's address to be 64-byte aligned, and `sizeof` is also padded to a multiple of 64, so it occupies the whole cacheline and nobody else can cram in. Common usages: + +- **Per-thread statistics counters**: thread `i` writes `counters[i]`; if `counters` is `atomic[]` you get false sharing; switch to an array of `alignas(64)` structs and you don't. +- **Per-thread data in lock-free data structures**: per-thread slots in a ring buffer. +- **Per-worker state in a thread pool**. + +Note that `alignas(64)` **wastes memory** (each counter takes 64B instead of 8B), but for hot variables this trade is worth it: the cacheline round-trips you save are worth far more than the bytes you waste. Don't sprinkle it on cold variables. + +Since C++17 there's a cleaner way: put per-thread data in `thread_local` and the compiler gives each thread its own instance, eliminating sharing at the root. But `thread_local` has its own pitfalls (initialization cost, interaction with thread pools); pick by scenario. + +> Boundary reminder: false sharing is a **performance** problem and belongs to vol6; "how to write correct multithreaded synchronization and the memory-ordering semantics of atomic operations" belongs to vol5. This article only covers "the performance cost of cachelines under multicore". + +Compress this article into one sentence: multiple cores frequently writing different variables on the same cacheline triggers MESI invalidate round-trips and effectively serializes them; on-machine measurement is an order of magnitude apart (about 18x in a single run on this machine, multiplier varies from 15x to 48x between runs; "an order of magnitude" is the stable conclusion); the fix is `alignas(64)` giving each frequently-written variable its own cacheline, or putting per-thread data in `thread_local`; to find false sharing use `perf c2c` (look at HITM counts), TSan/ASan won't catch it because it isn't a correctness problem. The **depth** of coherence protocols (MESI state machines, MOESI/MESIF variants, cache-to-cache transfer) belongs to an architecture course; vol6 only covers the "cacheline is the unit of coherence" layer, enough to guide code changes. + +The next article covers the other multicore amplifier, NUMA, and how to use the scalability curve to judge whether your parallel program "scales well". + +## References + +- CppCoreGuidelines CP.3 *false sharing* — the definition and fix for false sharing +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs* §11.7 *Detecting Coherence Issues* (written by Mark Dawson) +- Drepper, *What Every Programmer Should Know About Memory* — MESI and multicore cache coherence +- perf c2c documentation (KDAB has a detailed tutorial) +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch05/false_sharing.cpp` diff --git a/documents/en/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md b/documents/en/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md new file mode 100644 index 000000000..a6460a45d --- /dev/null +++ b/documents/en/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md @@ -0,0 +1,115 @@ +--- +chapter: 5 +cpp_standard: +- 17 +description: Multicore performance isn't just "more cores, more speed". This article covers the scalability curve (1/2/4/8 cores on the same task, measured at 1 to 2.53x sublinear, and why it's nowhere near ideal linear), Amdahl (fixed size) vs Gustafson (size scales with cores), NUMA cross-node memory-access latency doubling, thread affinity pinning, and the cost of thread creation and stack. The single-NUMA-node limit of WSL2 is marked honestly +difficulty: advanced +order: 2 +platform: host +prerequisites: +- False sharing - one cacheline dragging many cores back to single-core +- Amdahl's law - the optimization ceiling (ch00) +reading_time_minutes: 5 +related: +- Lock cost and "lock-free is not a silver bullet" +tags: +- host +- cpp-modern +- advanced +- 优化 +- 并发 +title: 'NUMA, affinity, and the scalability curve' +translation: + source: documents/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md + source_hash: cac21f66301af0929ecc4f4194755b9d2a1666352d8637487455663543992bb0 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3300 +--- +# NUMA, affinity, and the scalability curve + +## More cores does not equal linear speedup + +A lot of people intuit that "4 cores means 4x faster than 1 core", and that parallelization is just "split the work into N pieces for N cores". Once you actually try it, you find **almost no program scales linearly**. We measured with the simplest parallel task (chunked sum of a hundred million elements), the speedup at 1/2/4/8 threads: + +```text +===== Scalability curve (parallel sum of 100M elements) ===== +threads time(ms) speedup +1 29.3 1.00x +2 17.2 1.70x +4 12.6 2.33x +8 11.6 2.53x +``` + +**8 threads buys only a 2.53x speedup, far from the ideal 8x.** Why? Three reasons, each covered below: + +1. **Amdahl's law**: there's a serial section in the program (reducing results, synchronization), and no matter how small its share, it locks the speedup ceiling. ch00-01 covered `S = 1/(s + (1-s)/N)`; 10% serial locks the unlimited-core speedup at 10x. +2. **Shared-resource contention**: in this example, all cores read the same DRAM, and **memory bandwidth is shared**. Sum reduction is memory-bound (recall ch03-01: dot's arithmetic intensity is very low, bandwidth-bound); once cores multiply, bandwidth saturates first and adding cores doesn't help. **Memory-bound tasks scale poorly by nature**, and only compute-bound tasks scale well. +3. **NUMA cross-node**: on multi-socket machines, a core accessing "remote socket memory" sees latency double to quadruple. + +The scalability curve is the gold standard for diagnosing multicore programs: **run 1/2/4/8 cores once and plot it**. Ideally it's a straight 45-degree line; once it bends and flattens, you've hit one of the three bottlenecks above. Where it bends and how hard tells you how much "performance you can still buy by adding cores". + +## Amdahl vs Gustafson: strong scaling vs weak scaling + +There are two different yardsticks for scalability, don't mix them: + +- **Amdahl (strong scaling)**: **fix the problem size**, add cores, watch the speedup. The ceiling is locked by the serial section. This is what most "I want to make this program faster" scenarios care about. +- **Gustafson (weak scaling)**: **scale the problem size with the core count**, watch whether "cores double plus data double, time stays the same" holds. This is what HPC / big-data scenarios care about: data grows, add cores to keep up. + +Corollary: "this program scales poorly" is a hard flaw under Amdahl but might not matter under Gustafson. It depends on whether your question is "run this fixed size faster" or "don't crash as the data grows". Figure out which one you care about first. + +## NUMA: the hidden latency of multi-socket + +**NUMA (Non-Uniform Memory Access)** is the reality of multi-socket servers: each CPU socket has "its own" local memory, and accessing another socket's memory goes over the interconnect (QPI/UPI), **with latency doubling to quadruple**. So "memory bandwidth" effectively becomes "interconnect bandwidth": a thread runs on socket 0, but the data lives in socket 1's memory, and every memory access pays the interconnect toll. + +The fix for NUMA is to bind "the thread" and "the data it operates on" onto the same socket: + +```bash +# Bind threads and memory both to NUMA node 0 +numactl --cpunodebind=0 --membind=0 ./your_app +# Or interleave allocation (let both nodes share the load evenly, avoid one side saturating) -- but with a cross-node penalty +numactl --interleave=all ./your_app +``` + +At the program level: the thread pool is partitioned by NUMA topology (one pool per socket, handling only data in this socket's memory), and data is partitioned by socket. This is standard gear in HPC and high-performance backends. + +> **Limit of this machine**: WSL2 on a single-socket laptop (5800H, single NUMA node, `numactl --hardware` lists only node0), **can't measure the NUMA cross-node penalty**. The NUMA commands (`numactl`) and data here are drawn from Bakhvalov §11 plus multi-socket server practice. To measure NUMA yourself you need a dual-socket server. This section honestly marks "can't measure on this machine" and treats it as a measurement-environment teaching point rather than glossing over it. + +## Affinity: pin threads to cores to cut migration + +Even on a single socket, threads being **migrated** between cores by the OS costs something: after a migration, all of the thread's L1/L2 cache is cold and has to warm up again. `taskset` (ad-hoc) / `pthread_setaffinity_np` (in-program) **pins a thread to a fixed core** and eliminates the migration cost: + +```bash +# Command line: pin the process to cores 0-3 +taskset -c 0-3 ./your_app +``` + +```cpp +// In-program: pin a thread to a specific core +cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(core_id, &cpuset); +pthread_setaffinity_np(thread.native_handle(), sizeof(cpuset), &cpuset); +``` + +Pinning matters for **long-running, cache-sensitive** workloads (databases, stream processing); for short tasks it doesn't matter much (the cache never warms up in time). Pinning also composes with NUMA, keeping a thread on its own socket's cores. + +> The 5th and 8th items in ch01-03's "measurement pitfalls" (pinning, NUMA) are exactly what this section covers; here we expand on the why. Pinning is both a standard move in **performance measurement** (to avoid migration noise) and a standard move in **production deployment** (to stabilize cache behavior). + +## The cost of thread creation and stack + +One easily-overlooked multicore cost: **threads themselves aren't free**. Creating a thread allocates a stack (8MB of virtual address space by default, Linux allocates as much as touched), kernel data structures, and takes tens to a hundred-plus microseconds. So: + +- **Don't `new` threads on the hot path**: `std::thread t(...)` pays the create-plus-destroy cost every time. Reuse with a **thread pool**. +- **Stack size is tunable**: the 8MB default is too much for most threads; `pthread_attr_setstacksize` can shrink it (say to 256KB-1MB), saving virtual memory and easing TLB pressure (the stack is memory too, and eats TLB entries). Common in embedded / ultra-high-concurrency scenarios. +- **`std::async` + the default policy**: may implicitly create threads, and has pitfalls with `std::launch::async` semantics (vol5 covers this in depth). + +The thread pool is the classic "correctness belongs to vol5, cost belongs to vol6": how to write a UB-free thread pool is vol5's job; here we only cover the cost motivation for "why you should use a pool instead of raw `std::thread`". + +In one sentence: the scalability curve is just 1/2/4/8 cores on the same task, watching whether the speedup bends flat (ideal is linear; a bend means you hit Amdahl / shared resources / NUMA); memory-bound tasks scale poorly by nature because shared memory bandwidth saturates first, and only compute-bound tasks scale well; Amdahl (fixed size) vs Gustafson (size scales with cores), figure out which one you care about first; on NUMA, threads and data must be pinned to the same socket (`numactl`), and this WSL2 single-node machine can't measure it; affinity pinning cuts migration and belongs in both measurement and production; threads themselves aren't free, use a thread pool on the hot path, and the stack size is tunable. + +## References + +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs* Chapter 11 *Multithreaded Apps* (written by Mark Dawson; covers NUMA/affinity/scalability) +- Drepper, *What Every Programmer Should Know About Memory* — NUMA and cache coherence from an engineering angle +- `numactl` / `taskset` / `pthread_setaffinity_np` documentation +- ch00-01 Performance mindset (this volume; the source of Amdahl's law) +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch05/scalability.cpp` diff --git a/documents/en/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md b/documents/en/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md new file mode 100644 index 000000000..63d0fabd3 --- /dev/null +++ b/documents/en/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md @@ -0,0 +1,99 @@ +--- +chapter: 5 +cpp_standard: +- 17 +description: This article looks at concurrent synchronization through the cost lens. The fast path of an uncontended mutex is nanosecond-level, about 3.6x an atomic, and blows up under contention; lock-free is not a silver bullet (ABA, retry storms, memory reclamation headaches), and in many scenarios sharded locks are both faster and simpler than lock-free. "Whether to go lock-free" is a cost/complexity tradeoff that belongs to vol6; "how to write lock-free" belongs to vol5 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- NUMA, affinity, and the scalability curve +- False sharing - one cacheline dragging many cores back to single-core +reading_time_minutes: 6 +related: +- std::atomic and memory ordering (vol5) +tags: +- host +- cpp-modern +- advanced +- 优化 +- 并发 +- 无锁 +title: 'Lock cost and "lock-free is not a silver bullet"' +translation: + source: documents/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md + source_hash: ad43f054bd5abd94261d2b649a3ee0c505884d852335e1357b66e90f25d4154a + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3400 +--- +# Lock cost and "lock-free is not a silver bullet" + +## Synchronization from the cost lens + +The first two ch05 articles covered the physical bottlenecks of multicore (false sharing, NUMA, scalability). This one switches to the cost lens of **synchronization primitives**: `std::mutex`, `std::atomic`, lock-free data structures, how much each costs and when each is worth it. + +First, the boundary (this one matters most): **"how to write correct synchronization, atomic-operation memory ordering, and lock-free data-structure implementations" belongs to vol5**. vol6 only answers one question: **"how many nanoseconds does each of these synchronization styles cost, and which should you pick when"**. This is the cost lens, not mechanism teaching. + +## Uncontended locks: nanosecond-level, but 3-4x an atomic + +A lot of people's impression of `std::mutex` is "slow". **Uncontended, it really isn't slow**: the fast path of a modern mutex is a user-space CAS (spin a few times), and only falls into the kernel when it can't get it. We measured a single thread (completely uncontended) incrementing a hundred million times: + +```text +===== Synchronization cost (single thread incrementing 100M times, ns/op) ===== + non-atomic int (baseline): 0.00 ns + atomic relaxed (lock-free, weak ordering): 1.86 ns + atomic seq_cst (default strong ordering): 1.84 ns + mutex uncontended (lock/unlock): 6.60 ns + mutex/atomic_relaxed = 3.6x +``` + +A few things to read off: + +- **Non-atomic int baseline 0.00 ns**: the compiler optimizes `++plain` into a register self-add, no memory write; that's the limit of "no synchronization". +- **`atomic` about 1.8-1.9 ns/op**: this is the cost of a lock-free atomic operation (`fetch_add`, when the return value is discarded, is often optimized by GCC into `lock addq` (a constant atomic add); when the return value is used it's `lock xadd`; both carry the LOCK prefix, and with a cacheline-aligned target they use a cache lock rather than a bus lock). **`memory_order_relaxed` and the default `seq_cst` are nearly identical on a single thread**: their difference is in cross-thread ordering, and a single-threaded increment has no cross-thread visibility requirement, so the difference doesn't show. +- **`mutex` uncontended 6.6 ns/op**: **about 3.6x an atomic**. This multiplier is the typical cost of an uncontended mutex: the fast path is a handful of instructions (CAS to grab the lock, CAS to release), a few more than a single `lock xadd`. + +So **under no contention, mutex is 3-4x slower than atomic, but both are nanosecond-level**. If your critical section is just one atomic increment, using a mutex is indeed wasteful (a direct atomic is faster); but if the critical section has dozens of instructions, the 6-nanosecond mutex cost is negligible against the critical section itself, **and readability and correctness matter far more than that overhead**. + +## Under contention: the mutex cost blows up + +The above is **uncontended**. What `mutex` really punishes you on is **contended**: multiple threads grab at once, the fast-path CAS fails, spins, still can't get it, falls into the kernel (`futex` syscall), the thread gets suspended and woken, with context switches involved (a few microseconds each). A highly contended mutex can degenerate the program into "the kernel scheduler is switching threads and the business logic barely runs". + +That's the motivation for lock-free data structures: **avoid falling into the kernel, avoid context switches**. `std::atomic`'s `lock xadd` stays in user space and is always lock-free (never falls into the kernel), no matter the contention. For **extremely high contention plus a tiny critical section** (say a global counter, a simple queue), lock-free genuinely wins. + +But— + +## Lock-free is not a silver bullet + +"Lock-free" sounds like a silver bullet, but the pits run deep: + +1. **The ABA problem**: in a lock-free compare-and-swap (CAS) loop, the value goes A→B→A, and CAS thinks "nothing changed" and succeeds, even though it was modified in between. The classic lock-free stack `pop` CASes repeatedly, and once a node is freed and reused you're hit. The fixes (tagged pointer, hazard pointer, epoch-based reclamation) are all complex and error-prone. +2. **Retry storms**: multiple threads CAS the same variable at once, only one wins, the rest all fail and retry; under high contention the CPU burns entirely on retries, worse than a mutex (called "thundering herd" or "live-lock"). +3. **Memory reclamation is hard**: in a lock-free data structure, "can this node be freed yet" is itself a concurrent puzzle (another thread may still hold a pointer to it). This is the hardest part of lock-free programming. +4. **Writing it correctly is extremely hard**: the correctness of lock-free code relies on the careful cooperation of memory ordering (`acquire/release/seq_cst`); get one wrong and it's a data race UB, and even TSan might not catch all of it. + +**Conclusion: lock-free is a high-bar, high-complexity tool, not a synonym for "faster"**. In many scenarios, **sharded locks** are both faster and simpler than lock-free: slice a shared structure into N pieces, one lock per piece, and threads most likely operate on different pieces and don't contend. For example a sharded hash table: N bucket groups, one mutex per group, the actual contention gets diluted to nearly uncontended. This "shard plus lock" pattern routinely beats lock-free in engineering, because the uncontended mutex fast path is extremely fast (nanosecond-level, as measured earlier), sharding drops the contention to uncontended and enjoys the fast path, and the code is simple with easy correctness. + +## Decision framework: what to use when + +| Scenario | Recommendation | Reason | +|---|---|---| +| Simple counter / simple statistics | `std::atomic` | One `lock xadd`, no critical section, cheapest | +| Complex shared structure, low contention | `std::mutex` + RAII | Readable, correct, uncontended fast path is fast enough | +| Highly contended shared structure | **Sharded locks** | Dilutes contention, simple and reliable, routinely beats lock-free | +| Extreme high concurrency + tiny critical section + a team that can handle it | Lock-free data structure | Has its scenarios, but you pay the complexity tax | +| Cross-thread read-only sharing | `std::shared_ptr` / share const directly | Reads don't contend, no sync needed | + +**"Whether to go lock-free" is a cost/complexity tradeoff that belongs to vol6; "how to write correct lock-free" belongs to vol5.** First ask "is mutex plus sharding enough"; most of the time it is. Only reach for lock-free when it genuinely isn't, and pair it with rigorous TSan validation. + +In one sentence: the uncontended mutex is nanosecond-level, about 3.6x an atomic, while under contention it blows up (kernel falls, context switches); a single atomic operation stays in user space and stays lock-free, suitable for simple counters; lock-free is not a silver bullet (ABA, retry storms, memory reclamation, extremely hard to write correctly), and sharded locks routinely beat it by diluting contention plus being simple and reliable; first consider sharding, then consider lock-free; vol6 only answers "how much each costs and which to pick", and "how to write it correctly" belongs to vol5. + +That wraps ch05 multicore performance: false sharing, NUMA/scalability, and synchronization cost all collected. The next article switches to ch06, looking at "the performance cost of C++ abstractions": how much each of those C++-specific features (virtual functions, exceptions, std::function, optional/variant) costs. + +## References + +- Bakhvalov, *Performance Analysis and Tuning on Modern CPUs* Chapter 11 *Multithreaded Apps* +- Pikus, *The Art of Writing Efficient Programs* — concurrent performance, sharded-vs-lock-free tradeoffs (local) +- `std::mutex` / `std::atomic` / memory ordering: cppreference; depth belongs to vol5 +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch05/lock_cost.cpp` diff --git a/documents/en/vol6-performance/ch05-multicore-performance/index.md b/documents/en/vol6-performance/ch05-multicore-performance/index.md new file mode 100644 index 000000000..c1b9b6438 --- /dev/null +++ b/documents/en/vol6-performance/ch05-multicore-performance/index.md @@ -0,0 +1,18 @@ +--- +title: "Multicore performance" +description: "ch05 picks up where vol5 left off (the correctness of synchronization primitives and lock-free code belongs to vol5) and focuses only on the performance tax multicore brings and how to measure and fix it: false sharing (one cacheline dragging many cores back to single-core speed, measured at one order of magnitude, about 18x in a single run with large variance), NUMA and the scalability curve (1 to 8 threads measured at a sublinear 2.53x), and lock cost versus the 'lock-free is not a silver bullet' reality (uncontended mutex about 3.6x an atomic)" +--- + +# Multicore performance + +vol5 thoroughly covers **the correctness of synchronization primitives, memory ordering, and lock-free data structures**. This chapter doesn't repeat any of that mechanism; it answers one performance question: **how do you measure and fix the performance decay multicore brings, and how many nanoseconds does each synchronization style cost.** + +Three articles: + +- **05-01 False sharing**: two cores frequently writing different variables sitting on the same 64-byte cacheline trigger MESI coherence invalidate round-trips, measured at **about an order of magnitude** slower (about 18x in a single run, multiplier varies a lot between runs). The fix is `alignas(64)`. +- **05-02 NUMA and the scalability curve**: cross-socket memory access latency doubles to quadruples; the scalability curve (1 to 8 threads measured at a sublinear 2.53x) diagnoses "how much performance buying more cores actually buys you"; Amdahl vs Gustafson; thread affinity; thread creation and stack cost. +- **05-03 Lock versus lock-free cost**: uncontended mutex is nanosecond-level (about 3.6x an atomic), but blows up under contention; lock-free is not a silver bullet (ABA, retry storms, memory reclamation), and sharded locks routinely beat lock-free. + +Boundary: **"how to write correct synchronization, atomic memory ordering, and lock-free implementations" belongs to vol5**; vol6 only answers "how much each costs and which to pick when." + +> This machine is WSL2 on a single-socket single-NUMA laptop, so NUMA cross-node penalty can't be measured here (05-02 marks this honestly, content is drawn from multi-socket server practice). False sharing, scalability, and lock cost are all measured on this machine. diff --git a/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md new file mode 100644 index 000000000..97ac78d3e --- /dev/null +++ b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md @@ -0,0 +1,93 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: 'Virtual functions are the cornerstone of C++ polymorphism and the abstraction most often "assumed slow". This article measures their real cost (a virtual call through a pointer is 2.5x slower than an inlinable CRTP), but the core message is: modern compilers devirtualize, and in many scenarios your virtual function has already been optimized into a direct call or even inlined — don''t prematurely rewrite virtual functions into CRTP/templates for "maybe faster", measure first' +difficulty: advanced +order: 1 +platform: host +prerequisites: +- inline, devirtualization, and the full landscape of compiler optimization +- Pipeline, ILP, and branch prediction +reading_time_minutes: 5 +related: +- The zero-cost model of exceptions +- std::function's small buffer optimization +tags: +- host +- cpp-modern +- advanced +- 优化 +title: 'Virtual functions and devirtualization: don''t rush to rewrite virtuals as templates' +translation: + source: documents/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md + source_hash: 2c3f14b54ad7b46481a07ddb8a11f73ec0c18fdc826568ab96b7b112d9c20ce5 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3300 +--- +# Virtual functions and devirtualization: don't rush to rewrite virtuals as templates + +## "Virtual functions are slow" is one of vol6's home-court propositions + +The proposition Carruth pulls out in *There Are No Zero-Cost Abstractions*, "no zero-cost abstractions", names virtual functions most often. Their cost comes from three places: **a vtable lookup (one extra memory access, the vtable may cache miss) + an indirect jump (disrupts the pipeline, may be mispredicted by the branch predictor) + blocking inlining** (the compiler doesn't know who gets called at runtime, so it doesn't dare optimize across the call). Stack those three and a virtual call really is more expensive than a direct call. + +But the core message of this article is counter-intuitive: **"virtual functions are slow" is an upper bound, not the norm**. Modern compilers **devirtualize**, and in many scenarios can optimize a virtual call into a direct call or even inline it, dropping the cost to zero. This ch06 chapter is the home court for "the performance cost of C++ abstractions", but for virtual functions our advice is "**measure first, don't rewrite prematurely**". That stance doesn't contradict "virtual functions have a cost": the cost exists, but often doesn't trigger. + +## Run it: the real cost of four kinds of calls + +Pulling over the data measured in ch04-04 (this machine, average ns per call): + +```text +===== Virtual functions and devirtualization ===== + virtual function (pointer, runtime polymorphism): 0.55 ns ← vtable lookup + indirect jump, blocks inlining + final class (compiler devirtualization): 0.54 ns + direct object (non-pointer, often devirtualized): 0.23 ns + CRTP (static polymorphism, no vtable): 0.22 ns ← inlinable + virtual/CRTP = 2.5x +``` + +Reading this table, separate "upper bound" from "norm": + +**Upper bound: the virtual call through a pointer/reference (0.55 ns) is the most expensive**. Here the compiler can't see the runtime type, dutifully does a vtable lookup plus indirect jump, 2.5x slower than CRTP (0.22 ns). That's the hard number behind "virtual functions have a cost". + +**Norm: a lot of calls are actually devirtualized**: + +- **Direct object (non-pointer/reference) call** (0.23 ns): in `Derived d; d.foo();` the compiler can see the exact type of `d` and devirtualize into a normal call, **just as fast as CRTP**. +- **`final` class/method**: tells the compiler "nothing derives further", which can sometimes trigger devirtualization. But note, **in this example `final` is just as fast as a plain virtual function** (0.54 ≈ 0.55), and that's not "final failed to devirtualize"! `-fopt-info-all` shows that the virtual calls on both the final class (`DerivedF`) and the plain derived class (`Derived`) have been **speculatively devirtualized** by GCC (speculative devirtualization: at runtime it compares the vtable entry against the compile-time-known target address, and on a hit goes down the inlined path). The real reason 0.54 ≈ 0.55 is that "the runtime comparison cost of speculative devirtualization is about the same as one virtual call, nothing saved", not "final didn't take effect". Honest conclusion: **don't assume that tagging `final` automatically makes it fast — whether devirtualization actually saves cost depends on whether the assembly truly turned into a direct `call`** (`-S`: a virtual call is `call [vtable+offset]` an indirect jump; full devirtualization is a direct `call func`). +- **CRTP (static polymorphism)**: pushes polymorphism to compile time (templates), no vtable, inlinable, always fastest. The price is code bloat (each derived class instantiates a copy) plus losing runtime polymorphism. + +## When devirtualization actually happens + +Devirtualization happens in these scenarios **without you doing anything**: + +- A direct object (non-pointer/reference) call, with the type visible at the call site. +- A derivation hierarchy plus `final` that lets the compiler prove a unique implementation. +- LTO on, where type info across translation units becomes visible and more devirtualization opens up. +- A monomorphic hot spot: profile feedback shows some virtual call site calls the same type 99% of the time; some compilers/PGO can devirtualize on that. + +So "has my virtual function been devirtualized" **has to be checked in the assembly** (`-S`: a direct call is `call func`, a virtual call is an indirect `call [vtable+offset]`); don't go by feel. + +## Practical advice: don't CRTP-ify prematurely + +Compress the above into a workflow: + +1. **Write it cleanly with virtual functions first** (OOP expressiveness is best, easy to maintain). +2. **Profile** to find hot spots. If a virtual call isn't in a hot spot, leave it alone; it's already fast enough. +3. **If it really is in a hot spot**, check the assembly first to see whether it devirtualized. If it did, you're done. +4. **If it didn't devirtualize and it's the bottleneck**, then consider: `final`, switching to a direct-object call, PGO, and only at the end CRTP/templates. + +**The most common anti-pattern is "someone said virtual functions are slow, so the whole class hierarchy gets CRTP-ified on day one"**: code complexity explodes (template error messages, code bloat), while the original virtual call may have been devirtualized by the compiler already, or may not sit in a hot spot at all. This is the classic form of **premature optimization** in the C++ abstraction-cost field. ch04-04 made the same point about inline: your job is "don't get in the compiler's way plus measure the real bottleneck and fix it precisely", not "hand-write the faster-looking version harder". + +> Boundary reminder: the **mechanism** of virtual functions (vtable layout, virtual destructors, `override` semantics) belongs to vol4 class design; vol6 only covers "how expensive it is when running on hardware, and how to make it not expensive". + +In one sentence: the **upper bound** cost of a virtual function is 0.55 ns (via pointer), about 2.5x an inlinable CRTP (0.22 ns), sourced from vtable lookup plus indirect jump plus blocking inlining; but many virtual calls get devirtualized into direct calls or inlined (direct object, `final`, LTO, PGO, monomorphic hot spots), and in this example `final` through a pointer didn't trigger it (0.54≈0.55), honestly showing it isn't guaranteed; in practice follow "write cleanly with virtuals first → profile → check assembly, confirm not devirtualized plus is the bottleneck → only then consider `final`/CRTP", and don't CRTP-ify prematurely, that's premature optimization. + +The next article covers exceptions. Like virtual functions they're often misunderstood as "slow", and the real cost model is "the normal path is zero-cost, the exception path is expensive". + +## References + +- Piotr Padlewski, *C++ devirtualization in clang* (CppCon 2015 Lightning) — the devirtualization mechanism, reused in vol10 +- ch04-04 inline, devirtualization, and the full landscape of compiler optimization (the source of this article's virtual-function data) +- Agner Fog, *Optimizing software in C++* §7 *Virtual functions* (local) +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp` (shared with ch04-04) diff --git a/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md new file mode 100644 index 000000000..4ca4516c3 --- /dev/null +++ b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md @@ -0,0 +1,97 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: 'C++ exceptions are a table-driven zero-cost model — the normal path (no-throw) has no extra instructions, the exception path relies on EH table lookup. Verified by measurement: with the ability to throw but never throwing is just as fast as a pure function (0.25 ns, zero cost nailed down); but throwing plus catching every time reaches 857 ns (3400x). Conclusion: use exceptions only for genuinely exceptional cases, not for control flow; embedded can use -fno-exceptions' +difficulty: advanced +order: 2 +platform: host +prerequisites: +- Virtual functions and devirtualization +- Benchmark methodology reference card +reading_time_minutes: 5 +related: +- std::function's small buffer optimization +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存安全 +title: 'The zero-cost model of exceptions: free on the normal path, expensive on the exception path' +translation: + source: documents/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md + source_hash: 45496538f71b5111a30dbb13f60e9c0a12a316e2a28d853694e5f6695b528986 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3400 +--- +# The zero-cost model of exceptions: free on the normal path, expensive on the exception path + +## "Exceptions are slow" is one of the biggest misconceptions + +In C++ circles, "exceptions are slow, performance code should `-fno-exceptions`" is one of the most widely-spread misconceptions. It's partly right (the exception path really is expensive) and partly wrong (the normal path is nearly free). The real model is called **table-driven zero-cost**: + +- **Zero-cost (normal path)**: a code path that doesn't throw has **almost no extra instructions**. The cost of the exception mechanism doesn't show up as "one extra check instruction per call" (that's the cost of error codes); it's deferred to **when an exception is actually thrown**. +- **Table-driven (exception path)**: when an exception is thrown, the runtime consults the **EH table** (Exception Handling table, generated at compile time, recording "how to unwind this address's stack frame, is there a catch") to walk up the call stack looking for a catch and unwind the stack. This table lookup plus stack unwind is **microsecond-level**. + +Let's measure this model directly. + +## Run it: normal is free, exceptions are 3400x more expensive + +Four paths (this machine, average ns per op): + +```text +===== Exception cost ===== + normal path (never throws): 0.250 ns/op + error code (return value carries err): 0.247 ns/op + has throw ability but never throws (zero cost): 0.249 ns/op + throw+catch every time: 857.3 ns/op ← the exception path is expensive +``` + +This table verifies both halves of the zero-cost model: + +**1. The normal path's zero cost is nailed down**. The first three rows are nearly identical in speed (0.247-0.250 ns): "pure function", "error code (carrying one more err parameter per call)", "has throw ability but never throws" have **no measurable difference** in normal-path latency. That's the precise meaning of "zero cost": **turning on `try`/the exception mechanism adds no cost to the normal path**. + +**2. The exception path is about 3400x more expensive**. The last row is 857 ns vs 0.25 ns. Throwing plus catching one exception has to: allocate the exception object, consult the EH table, walk up the stack (destructing locals one by one), find the catch, jump. That whole sequence is microsecond-level, three orders of magnitude more expensive than a normal return. + +These two together directly drive the **usage discipline** around exceptions: + +- **Use exceptions only for genuinely exceptional cases** (rare, unexpected, needs stack unwinding). Never use exceptions as a "special return value" on the normal control-flow path: that path is 3400x more expensive. +- **Don't worry about exception overhead on the normal path**. Turning on `try`/the exception mechanism doesn't slow your normal code down (zero cost nailed down). "`try` blocks are slow" is a misunderstanding: `try` itself produces no runtime instruction; what's slow is "actually throwing". + +## Weighing exceptions against error codes + +Exceptions vs error codes isn't "which is faster", it's "which error distribution fits which": + +| Model | Normal path | Error path | Fits | +|---|---|---|---| +| **Error code** | Slightly more expensive (checks err every time) | Same as normal | Errors are **common** (checking every time isn't wasted)| +| **Exception** | Free (zero cost) | Very expensive (microsecond-level) | Errors are **rare** (the normal path isn't polluted)| + +Corollary: **the rarer the error, the better exceptions pay off**. If an API's "error" is actually the norm (say `parse` frequently hits invalid input), error codes are better; if the error is a genuine exception (say `vector::at` out-of-range, memory allocation failure, network interruption), exceptions are better: they keep the normal-path code clean (no `if (err)` everywhere), and when a real exception happens that cost doesn't matter. + +The C++ standard library makes exactly this trade: `vector::operator[]` doesn't bounds-check (fast); `vector::at` checks and throws (the normal path is still zero-cost; out-of-range is a genuine exception). + +## `-fno-exceptions`: when to turn it off + +Some scenarios `-fno-exceptions` the whole thing off: + +- **Embedded / games / real-time**: deterministic requirements are high, stack-unwind time is uncontrollable (microsecond-level jitter); or binary size is constrained (the EH table takes space). +- **Extreme size**: the EH table and unwind info take non-trivial size, turning them off saves bytes (and also lifts some code-gen constraints). +- The cost: **losing RAII error propagation**. Exceptions are C++'s "no-omissions" mechanism for propagating errors across functions (the destructor chain guarantees it); turn them off and you have to hand-write error-code pass-through, which is easy to get wrong. Some container (`vector` etc.) behaviors also degrade (for example `at` becomes `abort`). + +So `-fno-exceptions` is **a tradeoff engineering decision**, not a "for speed" silver bullet. Normal C++ code (backends, desktop, most libraries) should **keep exceptions**, because they keep the normal path clean and the normal path is zero-cost. + +## Implementation: the Itanium C++ ABI EH + +The underlying implementation of exceptions follows **the Itanium C++ ABI EH spec** (x86-64 Linux plus macOS, in general): `throw` uses `__cxa_throw`, `catch` uses a personality function to look up `.gcc_except_table`, stack unwinding uses `_Unwind_RaiseException`. This mechanism is "table-driven": at compile time it generates an EH table for every section that can throw, and at runtime **only consults it when an exception is thrown**. The depth (two-phase exception handling, handler search) is beyond vol6's scope; knowing the architectural fact "table-driven zero-cost" is enough to guide decisions. + +In one sentence: exceptions are a table-driven zero-cost model, the normal path has no extra instructions (measured 0.25 ns, same as a pure function), the exception path relies on EH table lookup plus stack unwinding (measured 857 ns, 3400x more expensive); "try is slow" is a misunderstanding, what's slow is "actually throwing", and the `try` block itself is zero-cost; the rarer the error the better exceptions pay off, and when errors are the norm use error codes; `-fno-exceptions` is an embedded/size/determinism tradeoff, not a performance silver bullet, and the cost is losing RAII error propagation. The **mechanism depth** of exceptions (Itanium ABI EH, two-phase handling) is beyond vol6; knowing the zero-cost model is enough. + +## References + +- Itanium C++ ABI *Exception Handling* (itanium-cxx-abi.github.io/cxx-abi-eh.html) — the spec for EH tables, personality functions, and stack unwinding +- CppCoreGuidelines *Errors and Exception Handling* (Stroustrup & Sutter) — exception-usage discipline +- Agner Fog, *Optimizing software in C++*, exceptions section (local) +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch06/exception_cost.cpp` diff --git a/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md new file mode 100644 index 000000000..6a02d84e5 --- /dev/null +++ b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md @@ -0,0 +1,95 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: 'std::function type-erases any callable object, and the price is call indirection plus possible heap allocation. This article measures it: a call is about 6x slower than a direct lambda (1.61 ns vs 0.25 ns), and at construction a small capture hits SBO (2.3 ns) while a large capture triggers heap allocation (19.6 ns, 8.5x). Repeatedly constructing a function plus a large capture on the hot path is what to watch for; the fixes are a template parameter (compile-time polymorphism) or a fixed-signature function pointer' +difficulty: advanced +order: 3 +platform: host +prerequisites: +- Virtual functions and devirtualization +- Cachelines and locality - the 64-byte minimum unit of transfer +reading_time_minutes: 4 +related: +- The cost cheat sheet for C++ abstractions +- The real cost of RVO, NRVO, and move +tags: +- host +- cpp-modern +- advanced +- 优化 +- std_function +title: 'std::function''s small buffer optimization: the cost of type erasure' +translation: + source: documents/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md + source_hash: 1ea24a87c0bb47090b772b47ce372dbf02b03c3a6a833ddd255cda61696f2fda + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3200 +--- +# std::function's small buffer optimization: the cost of type erasure + +## The convenience and cost of type erasure + +`std::function` is C++'s most convenient "stuffs any callable" container: function pointers, lambdas, function objects, bind expressions, as long as the signature matches you can stuff them in. Its implementation relies on **type erasure**: it doesn't pin the callable's type as a template parameter, instead it uses a uniform internal interface (usually a virtual function or a function-pointer table) to do the call. + +That convenience has a cost. Let's measure (this machine): + +```text +===== std::function SBO ===== +Calls: + call - function pointer: 0.26 ns + call - function+small lambda (SBO): 1.61 ns + call - direct lambda (control): 0.25 ns + +Construct 1000000 times: + function holding function pointer: 2.0 ns/time + function holding small lambda (SBO): 2.3 ns/time + function holding large lambda (heap alloc): 19.6 ns/time ← heap-allocation cost + +sizeof(std::function) = 32 +``` + +Two costs: + +**1. The call is indirect, about 6x slower than a direct lambda**. A direct lambda (0.25 ns) and a function pointer (0.26 ns) are equally fast (both are direct calls plus inlinable); `std::function` (1.61 ns) has to go through the type-erased indirect call (vtable/function-pointer lookup plus jump), **about 6x**. Same logic as the virtual functions in 06-01: indirect calls block inlining. + +**2. Construction may heap-allocate**. When `std::function` holds a callable, it has to store the callable's state. Most implementations have **SBO (Small Buffer Optimization)**: a small buffer is reserved inside the `std::function` object (on this machine's libstdc++ an `std::function` is 32 bytes); captures with small state (≤ the SBO threshold, usually 16-24 bytes) are stored inline without heap allocation; captures with large state (over the threshold) have to `new` a heap block. + +Measured construction cost: a small lambda (SBO hit) is 2.3 ns/time, **a large lambda (over SBO, heap allocated) is 19.6 ns/time**, **8.5x**. That gap mostly comes from the cost of one `new`/`delete` heap allocation (tens-of-nanoseconds level). + +## When these two costs bite + +**The 6x call cost**: for "occasional" callbacks it doesn't matter (the callback isn't on the hot path); for "called a million times per frame" callbacks, 6x is real money. For example an event dispatcher: if every dispatch goes through `std::function`, the call overhead can become the bottleneck; switching to a template (compile-time polymorphism) or a function pointer is much faster. + +**The construction heap-allocation cost**: this is the easier pit to fall into. Consider this code: + +```cpp +// Hot path repeatedly constructing a function plus a big capture +for (auto& item : items) { + std::function f = [item, ctx](int x) { /* big capture */ }; + dispatch(f); +} +``` + +Every loop iteration constructs a `std::function`, and if the capture is big (over SBO), **every iteration does `new`/`delete`**: heap allocation plus cache miss plus possible malloc-lock contention (multithreaded). This "repeatedly constructing a function on the hot path" is a performance black hole; a few common fixes: + +- **Use a template parameter (compile-time polymorphism)**: write the callback type as a template parameter, eliminating type erasure. The cost is that the call site has to know the type at compile time. +- **Fixed-signature function pointer**: if the callback has no capture, use `void(*)(int)` directly, zero overhead. +- **Reuse the `std::function` object**: construct once outside the loop, only mutate its state inside the loop (though mutating state may still heap-allocate). +- **Avoid unnecessary captures**: the less a lambda captures, the more likely it hits SBO. + +## SBO is the same idea as string's SSO + +SBO is the same idea as `std::string`'s **SSO (Small String Optimization)** (both reserve a small buffer inside the object; small goes inline, big heap-allocates). Both solve the contradiction "type erasure / dynamic size plus avoid hot-path heap allocation". The mechanism of SBO/SSO (why the threshold is 16-24 bytes, how it cooperates with the ABI) belongs to vol3/vol4; vol6 only covers "it affects the heap-allocation cost of hot-path construction". + +The sizeof of `std::function` varies by implementation (libstdc++ 32 bytes, libc++ 48 bytes, MSFC different again), and the SBO threshold varies with it. So "will this lambda trigger a heap allocation" needs a `sizeof` or a look at the implementation; the **general advice is: don't rely on hitting SBO on the hot path; switch big captures to templates**. + +In one sentence: `std::function` has two costs, an indirect call (about 6x slower than a direct lambda) and possible heap allocation on construction (triggered by a big capture, about 8.5x more expensive than SBO); SBO lets small captures (≤16-24B) stay inside the object without heap allocation while big captures heap-allocate; on the hot path avoid repeatedly constructing `std::function` plus a big capture, that's a heap-allocation black hole, and the fixes are a template parameter, a function pointer, reusing the object, or cutting captures; SBO is the same idea as string's SSO, and the mechanism belongs to vol3/vol4. + +## References + +- cppreference *std::function* — type-erasure semantics, SBO notes +- Stepov/Stroustrup CppCoreGuidelines *F.50* — when to use function vs template vs function pointer +- Agner Fog, *Optimizing software in C++*, object/container overhead (local) +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch06/function_sbo.cpp` diff --git a/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md new file mode 100644 index 000000000..84b9a00aa --- /dev/null +++ b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md @@ -0,0 +1,110 @@ +--- +chapter: 6 +cpp_standard: +- 20 +description: Rolls up the C++ abstraction costs measured across this chapter (the sizeof and call/construction cost of virtual functions, exceptions, std::function, optional/variant/span, and friends) into a cheat sheet, plus three supplementary entries (variable storage types, bitfields, and the zero cost of enum class), for desk reference while coding +difficulty: advanced +order: 4 +platform: host +prerequisites: +- Virtual functions and devirtualization +- The zero-cost model of exceptions +- std::function's small buffer optimization +reading_time_minutes: 5 +related: +- The real cost of RVO, NRVO, and move +- The performance cost of C++ abstractions (chapter front page) +tags: +- host +- cpp-modern +- advanced +- 优化 +- 字面量 +- enum_class +title: The cost cheat sheet for C++ abstractions +translation: + source: documents/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md + source_hash: 399bcd8bc9a5eb4d6e20ba901eed029b7a1ba3246bf4bd06050454e2215b552d + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3500 +--- +# The cost cheat sheet for C++ abstractions + +This article is ch06's reference card: it **rolls the C++ abstraction costs measured in earlier articles into a cheat sheet**, then adds three small entries that didn't get their own article (variable storage types, bitfields, enum class). When you hit "is this abstraction expensive" while coding, look it up here. + +## What was measured: the cost cheat sheet + +| Abstraction | Main cost | Measured number (this machine) | When you care | +|---|---|---|---| +| **Virtual function** (via pointer)| vtable lookup + indirect jump + blocks inlining | 0.55 ns, **2.5x** CRTP | A hot virtual call that hasn't been devirtualized | +| **Devirtualization** | Often free when the compiler can prove the type | Direct object 0.23 ns ≈ CRTP | Most of the time the compiler does it for you | +| **Exception** (normal path)| Table-driven zero cost | **0.25 ns (same as a pure function)** | Almost never | +| **Exception** (throwing path)| EH table lookup + stack unwind | **857 ns, ~3400x** | Keep the exception path out of hot loops | +| **`std::function`** call | Type-erased indirect call | 1.61 ns, **6x** a direct lambda | A million calls per frame | +| **`std::function`** construction | Small hits SBO, big heap-allocates | SBO 2.3 ns / heap 19.6 ns (**8.5x**)| Repeated construction + big capture on the hot path | +| **RVO/NRVO** | Return value constructed directly in the caller | **0 copies, 0 moves** | Don't write std::move on a return of a local | +| **`return std::move(local)`** | Disables NRVO, forces a move | 0 copies + 1 move (one extra) | **Anti-pattern, don't write it** | + +This table is the measurement roll-up of ch06-01/02/03/05; see each article for mechanism and experiment. The thesis (Carruth *No Zero-Cost Abstractions*): **every C++ abstraction maps to a hardware cost**, but "has a cost" doesn't equal "happens every time", and the compiler often eliminates it for you (devirtualization, zero-cost exceptions, RVO). **Measure first, then decide whether to hand-write around it.** + +## Supplementary entries + +### 1. Variable storage types: register / static / thread_local + +A variable's **storage type** affects where it lives and how fast it is to access (Agner vol1 §7.1): + +- **Automatic variables (stack)**: the default. Fastest access (on a stack that fits in L1), and the compiler can put it in a register. The `register` keyword is meaningless on modern compilers (the compiler allocates registers itself); it's a deprecated/removed keyword since C++17, don't use it. +- **Static variables (`static`/global)**: fixed address, fixed initialization (constant initialization is zero-cost; dynamic initialization has a startup cost). In multithreaded code, the initialization of a static local is thread-safe (magic statics), but **there's a runtime cost to thread-safe initialization** (an atomic check on first entry). +- **`thread_local`**: one per thread. Access is slightly more expensive (has to look up the thread-local storage area, usually a few extra instructions), but avoids sharing under multithreading. Useful for "per-thread context objects". + +In practice: hot-path variables should be automatic where possible (let the compiler put them in registers); `static` global constants are free; `thread_local` is for per-thread context (its initialization and destruction cost has to be counted into the thread lifecycle). + +### 2. Bitfields + +A **bitfield** packs several small fields into one integer, saving space: + +```cpp +struct Flags { unsigned a : 1; unsigned b : 1; unsigned c : 6; }; // 8 bits total +``` + +The upside: small `sizeof` (compact), cache-friendly. The cost: **bit operations** — reading and writing a bitfield member is "read the whole byte plus bitmask plus bit operation", a few more instructions than reading and writing a plain `int`. So bitfields **save memory, spend instructions**. They fit "lots of flag bits, memory is the bottleneck" (protocol headers, flag sets); they don't fit "a single field read/written at high frequency, compute is the bottleneck". Agner vol1 §7.27 has the detailed tradeoffs. + +### 3. enum class: zero overhead + +**`enum class`** (the C++11 strongly-typed enum) is "type-safe enum", and **zero overhead**: underneath it's just an `int` (or whatever underlying type you specify), as fast to access as a plain `int`, **and the type safety is at compile time, zero cost at runtime**. So: + +- Prefer `enum class` over bare `int` constants (type safety, readability, free). +- Don't worry about its performance; it's the same as `int`. +- Specifying the underlying type (`enum class Color : uint8_t`) controls sizeof and saves space. + +This is one of the few cases where "zero-cost abstraction" actually holds (an exception to Carruth's thesis: not every abstraction has a cost; `enum class`/`optional` on the normal path is nearly zero-cost). + +## sizeof cheat sheet (measured on this machine, libstdc++ C++20) + +```text +sizeof: + int = 4 + std::optional = 8 (int 4B + has-value flag + padding) + std::variant = 16 (double 8B + index + padding) + std::variant= 40 (string 32B + index + padding) + std::span = 16 (pointer + length, zero ownership) + std::string_view = 16 (pointer + length, no \0 guarantee) + std::shared_ptr = 16 (2 pointers: object + control block) + std::unique_ptr = 8 (1 pointer) + std::string = 32 (includes SSO buffer) + std::vector = 24 (3 pointers) +``` + +How to read it: the extra bytes in `optional`/`variant` are the "has-value" flag and the index; `span`/`string_view` are lightweight "pointer plus length" views (zero ownership, nearly free); `string`'s 32 bytes include the SSO small buffer (the SSO mechanism is covered in vol3). + +How to use this table: when coding, prefer zero- or near-zero-cost abstractions (`enum class`, `span`/`string_view`, `optional`/`variant` on the normal path); they make code safer and cost almost nothing in performance. What really deserves attention is virtual function calls (via pointer, not devirtualized), `std::function` with repeated construction plus a big capture, and exceptions entering a hot loop; these have real cost and often need manual optimization. Always measure before optimizing: an abstraction that "sounds expensive" may already be eliminated by the compiler, and one that "sounds free" (constructing a `std::function`) may be hiding a heap allocation. + +The next article is ch06's last, on RVO/NRVO and move. It isn't really "abstraction cost", it's "the mechanism for returning large objects under value semantics", and it's often misunderstood. + +## References + +- Agner Fog, *Optimizing software in C++* §7 *Variables / objects / containers* (variable storage types, bitfields, enum) (local) +- Carruth, *There Are No Zero-Cost Abstractions* (CppCon 2019) — the "no zero-cost abstractions" thesis +- ch06-01/02/03/05 (this volume; the measurement source for each cost) +- The sizeof program for this article: `code/volumn_codes/vol6-performance/ch06/abstraction_sizeof.cpp` diff --git a/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md new file mode 100644 index 000000000..9745db79d --- /dev/null +++ b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md @@ -0,0 +1,119 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: Since C++17, return value optimization (RVO/NRVO) makes "return a large object by value" nearly free — the return value is constructed directly in the caller's stack frame, zero copies and zero moves. This article uses a Tracked type whose copy/move constructors count (a demonstration the compiler can't punch through) to show that URVO/NRVO is 0 copies 0 moves, that return std::move(local) is an anti-pattern (it disables NRVO and adds 1 move), and that move is about an order of magnitude cheaper than copy +difficulty: advanced +order: 5 +platform: host +prerequisites: +- std::function's small buffer optimization +- Benchmark methodology reference card +reading_time_minutes: 6 +related: +- The cost cheat sheet for C++ abstractions +tags: +- host +- cpp-modern +- advanced +- 优化 +- 移动语义 +title: 'The real cost of RVO, NRVO, and move' +translation: + source: documents/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md + source_hash: df61a891aa680946e318fe0137e288b6eb627bc81fcd528ca7c1383bf422b090 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 3600 +--- +# The real cost of RVO, NRVO, and move + +## "Returning a large object by value" used to be expensive, now it's free + +In early C++, "returning a large object by value" (say `vector make()`) was repeatedly warned against: it meant copying the whole object, absurdly expensive. So for a long time C++ circles had old dogma like "to return a large object use a pointer/reference" or "use an out-parameter `void make(T& out)`". + +This dogma **is mostly obsolete in modern C++**. Since C++17, **return value optimization (RVO/NRVO) is mandatory** (URVO) or effectively standard (NRVO): the return value is **constructed directly in the caller's stack frame**, neither copied nor moved, zero cost. Let's see this clearly with a method the compiler can't punch through. + +## Seeing it with a copy/move counter (the compiler can't punch through) + +There's a pitfall in demonstrating RVO: **timing it lets the compiler's cross-iteration optimization punch through** (in my first timing version, "copy" was actually faster than "RVO", because the compiler folded the whole loop). The right approach is a type whose **copy/move constructors count**: it **directly counts** how many copies and how many moves happened, and no matter how smart the compiler is, it can't rewrite your counter: + +```cpp +struct Tracked { + int v; + static int64_t copies, moves; + Tracked(int x) : v(x) {} + Tracked(const Tracked& o) : v(o.v) { ++copies; } // count copies + Tracked(Tracked&& o) noexcept : v(o.v) { ++moves; o.v = -1; } // count moves +}; +``` + +Then measure four "return" styles, watching the copies/moves counts: + +```text +===== Copy/move counts for RVO/NRVO/move/copy ===== + URVO return Tracked(1): copies=0 moves=0 → zero copies zero moves + NRVO return t (named local): copies=0 moves=0 → zero copies zero moves + return std::move(t): copies=0 moves=1 → forced 1 move (you disabled NRVO) + return g_global (lvalue): copies=1 moves=0 → 1 copy (RVO not eligible) +``` + +This table is the gold standard for teaching RVO (it doesn't depend on timing, the compiler can't punch through): + +- **URVO (returning an unnamed temporary)**: `return Tracked(1);`. Since C++17 this is **guaranteed copy elision**: the returned prvalue is initialized directly in the caller, neither copied nor moved. **0 copies 0 moves**. +- **NRVO (returning a named local)**: `Tracked t(...); return t;`. For returning a named local, the compiler **in effect** elides it (it was common practice before C++17; C++17 guarantees more scenarios). **0 copies 0 moves**. +- **`return std::move(t)` (anti-pattern)**: your hand-written `std::move` **forces it to an rvalue**, which **disables NRVO** (NRVO requires an lvalue), and the compiler is forced into the move constructor. **0 copies 1 move**, one avoidable move. That's why "``return std::move(local)`" is a famous **anti-pattern** in C++: it can only make code slower, never faster. +- **Returning an lvalue (global/parameter)**: `return g_global;`. An lvalue isn't RVO/move-eligible (doesn't qualify), so it's copied. **1 copy 0 moves**. This is the genuinely "expensive" case: returning an externally-named object, you have to copy it. + +## Seeing RVO turned off with -fno-elide-constructors + +GCC has a flag `-fno-elide-constructors` that turns off copy elision (used to reproduce old C++ behavior or for debugging). Rebuild with it: + +```text +(with -fno-elide-constructors:) + URVO return Tracked(1): copies=0 moves=0 ← C++17 guaranteed, can't turn off + NRVO return t (named local): copies=0 moves=1 ← NRVO off, degenerates to 1 move + return std::move(t): copies=0 moves=1 + return g_global: copies=1 moves=0 +``` + +Two things to read off: + +- **URVO is mandatory in C++17, and `-fno-elide` can't turn it off** (prvalue initialization rules, not an optimization). So `return T(args)` is always zero-cost. +- **NRVO is an "in-effect optimization"; turn it off and it degenerates into one move**. Move is cheaper than copy (covered below), so even if NRVO doesn't kick in you only pay one move's cost, not a copy. This is the safety net for C++'s "value semantics is safe": the worst case is one cheap move. + +## How much cheaper is move than copy + +`std::move` itself does nothing (it's just a cast to an rvalue); the real work is done by the **move constructor**. For types that manage dynamic memory like `vector`/`string`, move is a **pointer swap** (O(1)), and copy is a **deep copy** (O(n)): + +```cpp +// vector's move constructor: O(1) pointer swap (note the parameter is vector&&, not const vector&& -- move needs to modify the source) +vector(vector&& o) noexcept : data_(o.data_), size_(o.size_) { o.data_ = nullptr; o.size_ = 0; } +// vector's copy constructor: O(n) deep copy +vector(const vector& o) : data_(new T[o.size()]) { copy o.data_ → data_; } +``` + +For a 4 KB `vector` (1000 elements), move is a few pointer assignments (nanosecond-level), and copy is allocate 4KB plus memcpy (tens to hundreds of nanoseconds, depending on the allocator). **Move is more than an order of magnitude cheaper than copy**, and the gap widens as the element count grows. + +But move isn't free; it's still "construct a new object plus destroy the gutted source". So **cheapest is RVO/NRVO (0 times), then move (1 time), most expensive copy (deep copy)**. + +## Practical rules + +Compressed into a few memorizable ones: + +1. **Return by value, write it with confidence**. Both `return Tracked(args)` (URVO, mandatory-free since C++17) and `T t(...); return t;` (NRVO, effectively free) are zero-cost. +2. **`return std::move(local)` is an anti-pattern, don't write it**. It disables NRVO and can only slow things down. The compiler will turn the return of a local into an rvalue automatically as needed; you don't have to do it. +3. **Move isn't free, but it's about an order of magnitude cheaper than copy** (for big objects). `std::vector`/`std::string` moving is O(1). +4. **Use `std::move` where you "clearly want to turn something into an rvalue"**: for example stuffing a local into a container `v.push_back(std::move(elem))`, or transferring `unique_ptr` ownership. Don't use it on a `return`. +5. **Returning an external lvalue (global, parameter, member) still copies**; in that case consider a `const&` return or an explicit `std::move` (if you really do want to transfer ownership). + +These cover "how modern C++ returns objects" completely. The old dogma "return by value is slow" basically doesn't hold under modern C++ plus RVO: **write naturally, write value semantics, and the compiler elides copies for you.** + +In one sentence: RVO/NRVO makes return-by-value zero-cost (URVO is mandatory since C++17 at 0/0, NRVO is an in-effect optimization at 0/0), and the compiler can't punch through it; you have to verify it with a copy/move counter; `return std::move(local)` is an anti-pattern that disables NRVO and adds one move (0 copies 1 move), don't write it; move is about an order of magnitude cheaper than copy (O(1) pointer swap vs O(n) deep copy), but isn't free; returning an external lvalue is the only case that still copies, and there you consider a reference or an explicit transfer. ch06 ends here: virtual functions, exceptions, std::function, the cheat sheet, RVO/move — the costs of the major C++ abstractions have all been measured. + +## References + +- cppreference *Copy elision* (the C++17 guaranteed-elision rules) +- Meyer, S. *Effective Modern C++ Item 25* (reverse: overloading on rvalue vs reference qualification) — the engineering use of move semantics +- Agner Fog, *Optimizing software in C++* §7.16 *Returning objects* (local) +- The measurement code for this article: `code/volumn_codes/vol6-performance/ch06/rvo_move.cpp` (includes the -fno-elide-constructors comparison) diff --git a/documents/en/vol6-performance/ch06-cpp-abstraction-cost/index.md b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/index.md new file mode 100644 index 000000000..c710c3156 --- /dev/null +++ b/documents/en/vol6-performance/ch06-cpp-abstraction-cost/index.md @@ -0,0 +1,20 @@ +--- +title: "The performance cost of C++ abstractions" +description: "ch06 is the performance mirror of vol3's 'why components are designed this way' — what happens on hardware once you use them. Measured one by one: virtual functions (devirtualization often makes them free), exceptions (the zero-cost model, free on the normal path / throwing costs 3400x), std::function (SBO and heap allocation), a cost cheat sheet (sizeof plus three supplements), and RVO/NRVO with move (return by value at zero cost). The thesis: there are no zero-cost abstractions, but measure first, then optimize" +--- + +# The performance cost of C++ abstractions + +This chapter is vol3's **performance mirror**. vol3 covers "why components like vector/string/function are **designed the way they are**" (design motivation); this ch06 chapter covers "**after you use them, what happens on hardware that makes things fast or slow**". The thesis is Carruth's *There Are No Zero-Cost Abstractions*: **no zero-cost abstractions**, every C++ abstraction maps to a hardware cost. + +But the chapter has a consistent contrarian spirit: "has a cost" **does not equal** "happens every time". The compiler often eliminates the cost for you: devirtualization turns virtual calls into direct calls, the zero-cost model keeps the normal path of exceptions free, and RVO makes return-by-value zero-copy. So the advice that runs through this chapter is repeatedly "**measure first, don't hand-write around it prematurely**". + +Five articles: + +- **06-01 Virtual functions and devirtualization**: a virtual call through a pointer is 0.55ns (2.5x CRTP), but the compiler often devirtualizes. Don't CRTP-ify prematurely. +- **06-02 The zero-cost model of exceptions**: the normal path is 0.25ns (zero cost nailed down), throwing is 857ns (3400x). Reserve exceptions for genuinely exceptional cases. +- **06-03 std::function's SBO**: the call is 6x slower, construction takes the SBO path when small and heap-allocates when big (8.5x). Watch out for repeated construction plus a big capture on the hot path. +- **06-04 The cost cheat sheet**: a roll-up plus variable storage types, bitfields, the zero cost of enum class, and a sizeof table. +- **06-05 RVO, NRVO, and move**: return by value is zero-copy and zero-move (verified with the copy/move counter method); `return std::move(local)` is an anti-pattern. + +> Boundary: the **design mechanism** of components (vector's three pointers, SSO implementation, EBO) belongs to vol3/vol4; vol6 only covers "the cost of running them on hardware". High-frequency Zhihu questions (are virtual functions slow / are exceptions slow / function heap allocation / the move counter-example / return std::move) feed into each article's entry point, none get a standalone article. diff --git a/documents/en/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md b/documents/en/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md new file mode 100644 index 000000000..c0070d010 --- /dev/null +++ b/documents/en/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md @@ -0,0 +1,117 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: 'This article nails two things: what each -O level (-O0/-O1/-O2/-O3/-Os/-Oz) actually + does (measured -O0→-O2 4× faster, and -O3 is sometimes slower than -O2), and the three classes + of "optimization blockers" the compiler can''t cross (cross-translation-unit, which is LTO territory; + pointer aliasing, unlocked with __restrict; and volatile, which force-disables optimization). The + takeaway is that your job is largely "don''t get in the compiler''s way"' +difficulty: advanced +order: 1 +platform: host +prerequisites: +- Inline, devirtualization, and the full compiler-optimization landscape +- Front-end optimization: code layout, PGO, and BOLT +reading_time_minutes: 6 +related: +- LTO, ThinLTO, and the engineering rollout of PGO +- Size optimization: -Os, --gc-sections, and template bloat control +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工具链 +title: '-O levels and optimization blockers: what the compiler can and can''t do' +translation: + source: documents/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md + source_hash: a17a25eef54896e1c0579d6ddd496ac78ca689e3441ab20649db3e961b5f6837 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2900 +--- +# -O levels and optimization blockers: what the compiler can and can't do + +## The compiler is your first performance teammate + +When you write C++ for performance, one of the most important things is **figuring out what the compiler will do for you, and what it can't**. It will inline automatically, vectorize automatically, eliminate dead code automatically; ch04-02/04/05 already showed all of that. But it also has three classes of hard "can't optimize" limits (blockers) that need your cooperation. This article covers both sides. + +## -O levels: what each level does + +GCC/Clang's `-O` levels control optimization effort. One table (for the precise list, check the official docs): + +| Level | Roughly what it does | When to use | +|---|---|---| +| `-O0` | No optimization, variables observable, assembly readable | Debugging (**performance numbers are meaningless**)| +| `-O1` | Basic optimization (constant folding, simple inlining)| Occasional debugging | +| `-O2` | Most optimizations: CSE, LICM, scheduling, auto-inlining (same TU), basic vectorization | **Release default sweet spot** | +| `-O3` | More aggressive: **loop vectorization, auto-unrolling, more aggressive inlining** | Numerical/SIMD wins; **occasionally hurts** | +| `-Os`/`-Oz` | Optimize for **size** (fast but smaller)| Embedded flash-constrained (ch07-04)| + +`-O2` is the default release choice. It already covers most of the loop optimizations from ch04, same-translation-unit inlining, and basic scheduling. What `-O3` adds on top of `-O2` is mainly "more aggressive vectorization + unrolling + inlining". + +### Measurement: -O0→-O2 4× faster, -O3 occasionally slower than -O2 + +We measure the same function (a loop with a `volatile` scale) at different -O levels: + +```text +===== -O levels (same loop function) ===== + -O0: 18.6 ms ← no optimization, 4× slower than -O2 + -O2: 4.9 ms ← release sweet spot + -O3: 7.4 ms ← slower than -O2! (see below: not vectorization backfiring) +``` + +Two things, one at a time. + +**First, `-O0` performance numbers are meaningless.** 18.6 ms vs 4.9 ms is a 4× gap. Never run performance tests under `-O0`; what you're measuring is "unoptimized code", not "the real performance of your code". Debug with `-O0`, but performance numbers start at `-O2`. ch01 hammered this point. + +**Second, `-O3` isn't always faster than `-O2`.** Here `-O3` (7.4 ms) is slower than `-O2` (4.9 ms). But before jumping to conclusions, let's look at the assembly. `g++ -O2 -S` vs `g++ -O3 -S` for this `scale_add_alias` function: **neither vectorizes**. A `volatile` read can't be cached into a register, and `-fopt-info-vec-missed` explicitly reports `not vectorized: volatile type`. Note this has nothing to do with alias analysis; `volatile` doesn't participate in alias reasoning. The only difference between the two levels is register allocation and instruction scheduling. So 7.4 vs 4.9 **isn't "aggressive vectorization backfiring" (there was no vectorization to begin with), it's more likely measurement noise plus scheduling differences**: a single-shot measurement on WSL2, repeated runs flip this gap, and I've seen the reverse too. + +That's an honest and important result. **"`-O3` is better than `-O2`" is a misunderstanding.** `-O3` pays off on numerical/SIMD-friendly code (real vectorization wins); on irregular, volatile-heavy, or branch-dense code it either doesn't vectorize (this example) or occasionally loses to scheduling. So release defaults to `-O2`, and you only enable `-O3` or `-ftree-vectorize` on **specific spots where you've confirmed `-O3` helps** (numerical hotspots). + +## optimization blockers: three classes the compiler can't cross + +Now the "what it can't do" part. Three blockers, each easy to trigger by accident. + +### 1. Cross-translation-unit (LTO territory, ch07-02) + +When the compiler compiles a `.cpp`, it can't see implementations in other `.cpp` files. So when `int helper(int)` is declared in a header but implemented in another `.cpp`, **the compiler doesn't dare inline it** (it doesn't know the implementation). That's the cross-TU blocker. The fix is **LTO (link-time optimization)**, which inlines across files at link time; ch07-02 measures LTO making a cross-TU call **3.9× faster**. + +### 2. Pointer aliasing + +C/C++ allows two pointers to refer to the same address (aliasing). The compiler **defaults to assuming two pointers might alias**, so it doesn't dare reorder memory reads and writes aggressively, because it's afraid "writing to `a[i]` might affect `b[i]`". This conservative assumption blocks a lot of loops that could otherwise be vectorized or reordered. + +Measurement (`scale_add`: in the loop `a[i] += b[i] * *scale`, with `volatile` scale): + +```text + -O3 aliasing version (default): 7.4 ms ← compiler won't assume a≠b + -O3 __restrict version: 5.8 ms ← you promise a/b/scale don't alias, compiler dares to optimize +``` + +> ⚠️ Note: the attribution of this teaching demo is actually **not clean**. In the accompanying code, the alias version's signature is `volatile int* scale` and the restrict version's signature is `int* __restrict scale` (scale is non-volatile), meaning the restrict version **also drops `volatile` from `scale`**. So the 7.4→5.8 gain isn't entirely alias analysis at work; part of it is that after removing `volatile`, `scale` can be cached/hoisted. A clean aliasing comparison would keep `scale`'s type identical in both versions and only toggle `__restrict` on `a`/`b`. This is a simplification of the teaching demo; in real-world comparisons, watch the "change only one variable" discipline, which echoes ch00-01. + +`__restrict` (introduced in C99, a C++ extension but supported by both GCC and Clang) is your promise to the compiler that "this pointer doesn't alias with anything else": + +```cpp +void f(int* __restrict a, int* __restrict b, int* __restrict scale, int n); +``` + +With that promise, the compiler dares to vectorize/reorder. The price: if you lie (the pointers actually do alias), **it's UB**. So use `__restrict` when you're sure there's no aliasing; a typical scenario is several independent arrays in numerical computation. Don't overuse it, but in numerical hotspots it's a steady win. + +> Note: `__restrict` also works on references (`const int& __restrict`), but the semantics are slightly subtle; look it up before using. C++ has no `restrict` keyword (that's C's); use `__restrict`. + +### 3. volatile + +`volatile` forces the compiler to **truly read or write memory every time**, with no caching into registers and no optimizations applied. It was originally meant for **MMIO (memory-mapped IO), signal handling, and lock-free flags between threads**, scenarios where every access must genuinely hit memory (no caching). But `volatile` **is the antonym of optimization**: in the test above, `volatile scale` forced every loop iteration to do a real load, directly slowing the loop down. + +In practice, **don't use `volatile` for "thread synchronization" or "performance"**. It guarantees neither atomicity nor memory ordering (that's `std::atomic`'s job, see vol5); it only guarantees "no optimization". In most performance code, `volatile` is misuse and should be replaced by `std::atomic` or dropped outright. + +## References + +- GCC manual, *Options That Control Optimization* (the list of passes enabled by `-O0`/`-O1`/`-O2`/`-O3`/`-Os`/`-Oz`) +- Agner Fog, *Optimizing Software in C++*, §8 *Different C++ compilers*. Local copy +- CSAPP Chapter 5 *Optimizing Program Performance* (the concept of optimization blockers, aliasing/memory-reference) +- Code for this article's measurements: `code/volumn_codes/vol6-performance/ch07/opt_levels_blockers.cpp` + +The one-line summary: the compiler is your performance teammate, **don't get in its way**. Let it see implementations (LTO), trust your no-alias promise (`__restrict`), and don't use `volatile` to disable its optimizations; `-O2` is the release sweet spot, `-O3` belongs on numerical hotspots, and performance numbers from `-O0` are never to be trusted. diff --git a/documents/en/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md b/documents/en/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md new file mode 100644 index 000000000..8707c1abb --- /dev/null +++ b/documents/en/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md @@ -0,0 +1,107 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: LTO (link-time optimization) lets the compiler inline across translation units, removing + the "cross-TU call" optimization blocker, measured 3.9× faster on the same cross-file call; PGO + (profile-guided optimization) pays off significantly on large codebases but does nothing on microbenchmarks + (honest null result). This article covers their mechanism, engineering rollout (how to build), and the cost +difficulty: advanced +order: 2 +platform: host +prerequisites: +- '-O levels and optimization blockers' +- 'Front-end optimization: code layout, PGO, and BOLT' +reading_time_minutes: 5 +related: +- Linking performance, multi-compiler comparison, and compile-time metaprogramming +- Size optimization: -Os, --gc-sections, and template bloat control +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工具链 +title: LTO, ThinLTO, and the engineering rollout of PGO +translation: + source: documents/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md + source_hash: 1ec0c7fda10a91b83af0e9ba5eed141f8bd5dd0b4bdca663fc4f7a2c6db82739 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2400 +--- +# LTO, ThinLTO, and the engineering rollout of PGO + +ch07-01 covered "cross-translation-unit" as one of the optimization blockers: when the compiler builds a `.cpp`, it can't see implementations in other `.cpp` files, so it doesn't dare inline across files. This article covers the fix, **LTO**, plus **PGO**, which "lays out code by the real profile". Both are release-build-level engineering rollouts, and both pay off significantly on large projects. + +## LTO: cross-file inlining at link time + +**LTO (Link-Time Optimization)** works like this: at compile time, every `.o` file carries not just machine code but also the "intermediate representation (GIMPLE/LLVM IR)", and at link time the linker merges all the IR and **redoes cross-file optimization and inlining**. Cross-TU function calls can now be inlined, and constant propagation and dead-code elimination can run across files. + +We measure the simplest cross-TU scenario: `main.cpp` calls `helper(int)` in `helper.cpp`: + +```bash +# No LTO: helper is in another TU, compiler can't see the implementation, can't inline +g++ -O2 main.cpp helper.cpp -o lto_nolto +# With LTO: merged at link time, helper gets inlined → further constant propagation +g++ -O2 -flto main.cpp helper.cpp -o lto_lto +``` + +Measurement (helper called a hundred million times): + +```text +No LTO: 178.6 ms +LTO: 46.2 ms ← 3.9× +``` + +**3.9×.** This gap is entirely from "cross-TU inlining + downstream optimization": LTO lets the compiler see `helper`'s implementation, inlines it into `main`'s loop, and then constant propagation / loop simplification compresses the whole thing to near-optimal. Without LTO, every loop iteration is a real function call (and helper has its own loop inside). + +Side benefit: LTO also does **cross-file dead-code elimination**, so the binary is often smaller (here 16136 → 16024 bytes, reclaiming unused code). + +### ThinLTO: scalable LTO + +Full LTO merges all the IR into one giant view, which on large projects is **slow to link and memory-hungry**: Chrome-scale projects under full LTO need tens of minutes and tens of GB of RAM at link time. **ThinLTO** (LLVM, `-flto=thin`) shards the work: first do lightweight summaries (import/export decisions), then optimize each module in parallel. On large projects ThinLTO **links much faster and uses less memory** than full LTO, with near-equivalent optimization. GCC has an analogous mechanism (`-flto=auto` for parallelism). + +### The cost of LTO + +- **Linking gets slower** (full LTO especially), and build memory rises. +- **Build complexity**: build systems like CMake have to correctly pass `-flto` to both compile and link, and `ar` has to use gcc-ar (to handle LTO objects). +- **Debugging**: after LTO, symbols can be shuffled around and the debugger experience degrades. + +In practice, **enable LTO/ThinLTO on release builds, not on debug builds**. Use ThinLTO on large projects. This is a "free lunch" optimization (one flag, single-digit to low-teens percent speedup), with the only cost being link time. + +## PGO: layout by the real profile + +**PGO (Profile-Guided Optimization)** was already covered in ch04-07 (three phases: instrument → run the profile → recompile with the profile). Here we cover the engineering rollout and one honest conclusion. + +### The honest conclusion: PGO has no payoff on microbenchmarks + +I ran the full three-phase PGO rigorously on ch04's `pgo_demo` (a small function with a 99/1 branch split) and compared it to a pure -O2 baseline: + +```text +Pure -O2 baseline: 3.57-3.82 ms +-O2 + PGO: 3.78-4.17 ms ← no payoff at all (even slightly slower, within noise) +``` + +**PGO has no effect on microbenchmarks.** The reason is that this small function has only two branches and a few lines; `-O2` already optimizes it well. PGO's value is in **code layout on large codebases** (physically clustering hot paths that are scattered across thousands of functions, to help icache); for a few dozen lines there's nothing to lay out. + +> The first time I ran it, the "PGO version" looked 4× faster, and I got excited for a moment, until I realized that 4× was **the counter overhead of the instrumented binary** (phase 1's build comes with performance counters built in), not a PGO win. **To measure PGO you must compare against a "pure -O2, non-instrumented" baseline**, and confirm that the phase-3 profile was actually applied (a compiler warning `profile count data file not found` means it wasn't found). I'm recording this face-plant here to echo the ch01 discipline: **what you thought was a PGO win might be instrumentation overhead**. + +### Where PGO actually pays off: large codebases + +Every public PGO win comes from big projects: **Chrome, Firefox, major databases**, reporting **single-digit to low-teens percent** speedups, on the precondition that the codebase is large enough for icache/branch layout to actually be a bottleneck. Three rules: + +- **Don't expect PGO to work on microbenchmarks or small projects.** +- **Enable it on large release builds**, sampling the profile from a **representative production workload** (not just any random run). +- Rollout (CMake): compile with `-fprofile-generate` → run the workload → recompile with `-fprofile-use`. CI integration needs to store and reuse the profile across builds. + +PGO + LTO stacked is the standard combo for large-project releases. + +## References + +- GCC docs for `-flto` / `-fprofile-generate` / `-fprofile-use` +- LLVM docs, *ThinLTO* (llvm.org/docs/ThinLTO.html) +- ch04-07 Front-end optimization: code layout, PGO, and BOLT (this volume; first discussion of PGO mechanics and "no payoff on microbenchmarks") +- Code for this article's measurements: `code/volumn_codes/vol6-performance/ch07/lto_main.cpp` + `lto_helper.cpp`; PGO reuses `ch04/pgo_demo.cpp` + `ch04/pgo.sh` + +The one-line capstone: **LTO cross-TU inlining measures 3.9×** (cross-file helper call), enable on release and use ThinLTO on large projects, with link time as the only cost; **PGO lays out by profile and has no payoff on microbenchmarks (honest null)**, with the value on large codebases (Chrome/Firefox-class, single-digit to low-teens percent), and that one-time 4× was instrumentation overhead, not PGO. **PGO + LTO** is the standard combo for large-project releases; rollout is engineering work (CMake flag-passing, profile storage), a one-time config for long-term benefit. diff --git a/documents/en/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md b/documents/en/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md new file mode 100644 index 000000000..799ce89ce --- /dev/null +++ b/documents/en/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md @@ -0,0 +1,97 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: 'This article covers three boundary topics: the runtime cost of dynamic linking/PIC + (the "symbol resolution + position independence" cost from CSAPP ch7), the optimization differences + among mainstream compilers GCC/Clang/MSVC (Agner vol1 ch8), and the performance face of compile-time + metaprogramming (templates/constexpr), which has zero runtime cost once computed at compile time but + costs compile time and binary size' +difficulty: advanced +order: 3 +platform: host +prerequisites: +- LTO, ThinLTO, and the engineering rollout of PGO +- '-O levels and optimization blockers' +reading_time_minutes: 5 +related: +- Size optimization: -Os, --gc-sections, and template bloat control +tags: +- host +- cpp-modern +- advanced +- 优化 +- 链接器 +- 工具链 +title: Linking performance, multi-compiler comparison, and compile-time metaprogramming +translation: + source: documents/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md + source_hash: 037748bacea5c92cef655da38c5d80b0162beebf433c5cea79ec39fc054101df + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2500 +--- +# Linking performance, multi-compiler comparison, and compile-time metaprogramming + +The first two ch07 articles covered `-O` levels and LTO/PGO. This one closes out the remaining three compile/link-related topics: **the runtime cost of dynamic linking, optimization differences among mainstream compilers, and the performance face of compile-time metaprogramming**. All three are "boundary topics", not the lead actors in routine performance work, but they show up in large projects, embedded systems, and extreme-optimization scenarios. + +> This article has no local measurements. Dynamic-linking mechanics come from CSAPP ch7, multi-compiler comparison from Agner vol1 §8, and compile-time metaprogramming from Agner vol1 §15; we cover only the performance side and don't reinvent the experiments. + +## Dynamic linking and PIC: the cost of position independence + +CSAPP Chapter 7, *Linking*, covers static vs dynamic linking. From a performance angle, dynamic linking (`.so`/`.dylib`/`.dll`) carries several runtime costs: + +- **Symbol resolution**: the first time you call into a dynamic library, the runtime linker (`ld.so`) has to look up the symbol table and bind the address (lazy binding) or bind everything at startup (now binding). This is a **first-call latency**. +- **PIC (position-independent code)**: dynamic-library code has to be loadable at any address, so it goes through a **GOT (Global Offset Table)** for indirect addressing; every access to a global variable or external function adds one GOT lookup. This is a **constant per-access cost** (a few extra instructions). +- **PLT (Procedure Linkage Table)**: external function calls go through a PLT indirect jump, one extra hop versus a direct call. +- **icache pressure**: dynamic-library code is scattered across different load addresses, and cross-library calls add icache misses. + +Each of these costs is **small per-call (nanoseconds)**, but in scenarios with "extremely high-frequency cross-library calls + very short functions" they show up (for example, a tight loop calling a small function in another `.so`). A few counters: + +- **Inline the hot-path cross-library calls away** (LTO works within a library, not across; or move the hot function into the main program). +- **`-Wl,-z,now`** (bind everything at startup, so first-call latency doesn't fire mid-run; suitable for long-running services). +- **Statically link hot-path libraries** (trade upgrade independence for performance). + +CSAPP ch7 has the full mechanism; vol6 only covers the performance side. For most applications these costs are negligible; **real-time games, HFT, and embedded** are where they get fined-tuned. + +## Multi-compiler comparison: GCC vs Clang vs MSVC + +The three mainstream compilers' optimization capability is **broadly comparable, with details that differ**. Agner Fog compares them in vol1 §8; a few observations (these shift with versions, check the latest): + +- **Optimization strength**: at `-O2`/`-O3`, the overall performance gap across the three is usually within **single-digit percent**, and which one wins depends on the specific code. +- **Vectorization**: GCC has historically been aggressive at vectorization; Clang/LLVM's vectorization framework (led by Nadav Rotem) is also strong and sometimes smarter; MSVC's auto-vectorization is relatively conservative (but hand-written intrinsics are the same). +- **Code generation**: Clang has the best error messages and most accurate diagnostics; GCC has the widest platform support; MSVC is the de facto standard under the Windows ABI. +- **Cross-platform**: GCC/Clang span Linux/macOS/Windows (MinGW/clang-cl); MSVC is mostly Windows. + +The practical advice is **don't switch toolchains over "I heard X compiler is faster"**: the gap is small, and shifts with versions. Choose a compiler by **platform support + team familiarity + diagnostic quality**, not by raw performance. If you really want to squeeze, **PGO + LTO is far more effective than switching compilers**. Run benchmarks periodically to confirm your compiler choice has no obvious disadvantage. + +> Note: different compilers have **incompatible ABIs** (Itanium ABI vs MSVC ABI), so mixing them (say, a GCC-built library used by MSVC) requires `extern "C"` interface isolation. That's an engineering problem, not a performance one. + +## Compile-time metaprogramming: the performance face of templates and constexpr + +C++ templates and `constexpr` can push computation to **compile time**: once compiled, it's done, and runtime cost is zero. This is "true zero-overhead abstraction" (at runtime). But the cost has two faces. + +### Runtime: zero or near zero + +- **`constexpr`/`consteval` functions**: evaluation finishes at compile time, and the runtime result is just a constant. For example, `constexpr int fib(int n)` computes `fib(10)` at compile time; at runtime it's just the literal `55`, **zero runtime cost**. +- **Template computation**: `template struct Fact { static constexpr int v = N * Fact::v; };`, computed at compile time. +- **Type-level computation** (the dispatch for `std::tuple`, `std::variant`) is generated at compile time, and at runtime it's optimized direct code (often inlined away). + +So "if it can be computed at compile time, compute it at compile time" is one of C++'s performance principles: **shifting compute from runtime to compile time is free**. + +### The cost: compile time and size + +- **Compile time**: template instantiation is one of the compiler's heaviest jobs. Heavy-template C++ projects routinely take tens of seconds to minutes to compile. This is a long-standing pain point in the C++ world (modules, since C++20, ease it). +- **Binary size**: templates generate one copy of the code per type (`vector`, `vector`, `vector` are three copies), producing **template bloat**. ch07-04 covers the counters (`extern template`, factoring out shared logic). +- **Readability/error messages**: heavy-template code is notoriously hard to read in its errors. + +Practical tradeoff: **for small computations on the hot path, push them to compile time with `constexpr`** (`constexpr int kTable[N] = ...`); **don't over-templatize for the sake of "compile time"** (template bloat + compile time + readability cost). `constexpr` is more restrained and more modern than "template metaprogramming"; prefer `constexpr`/`consteval`. + +## References + +- CSAPP Chapter 7, *Linking*: static/dynamic linking, GOT/PLT, the mechanics of symbol resolution +- Agner Fog, *Optimizing Software in C++*, §8 "Different C++ compilers" (compiler comparison) and §15 "Metaprogramming" (the size face of compile-time metaprogramming). Local copy +- GCC/Clang/MSVC docs for their respective `-O` behavior, `-fpic`/`-fPIC`, and `constexpr` support +- ch07-04 Size optimization (this volume, counters to template bloat) + +Three-part capstone: **dynamic linking has runtime cost** (PIC indirection, symbol resolution, icache), small per-call, only visible under extremely high-frequency short cross-library calls, with mechanics in CSAPP ch7; **the three compilers' performance gap is small** (single-digit percent), don't switch toolchains for "faster", PGO+LTO is more effective; **compile-time metaprogramming is zero-cost at runtime, but the cost is compile time + template size**, prefer `constexpr`/`consteval` over heavy templates. These three are supporting cast in routine optimization, but lead roles in large-project build engineering, embedded, and extreme optimization. diff --git a/documents/en/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md b/documents/en/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md new file mode 100644 index 000000000..04a4cf4ac --- /dev/null +++ b/documents/en/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md @@ -0,0 +1,130 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: 'Binary size isn''t itself performance, but it indirectly affects performance through icache/iTLB/download + size (especially on embedded). This article covers three size-optimization techniques: -Os/-Oz (size-first optimization + levels), -ffunction-sections + --gc-sections (link-time dead-code reclamation, measured), and template bloat control + (extern template, factoring out shared logic), and ends with a "size ↔ performance" tradeoff checklist' +difficulty: advanced +order: 4 +platform: host +prerequisites: +- '-O levels and optimization blockers' +- Linking performance, multi-compiler comparison, and compile-time metaprogramming +reading_time_minutes: 6 +related: +- LTO, ThinLTO, and the engineering rollout of PGO +- 'Front-end optimization: code layout, PGO, and BOLT' +tags: +- host +- cpp-modern +- advanced +- 优化 +- 链接器 +- 工具链 +title: 'Size optimization: -Os, --gc-sections, and template bloat control' +translation: + source: documents/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md + source_hash: ecb1cd0e9ecac0f7209203d546dffaaf1f248a87def29bc0135e8d8c53791870 + translated_at: '2026-07-06T00:00:00+00:00' + engine: manual + token_count: 2900 +--- +# Size optimization: -Os, --gc-sections, and template bloat control + +## Why size affects performance + +Binary size itself isn't "speed", but it **indirectly affects performance** along three main paths: + +1. **icache is finite**: more code → won't fit in icache → icache misses → Frontend Bound (covered in ch04-07). This is the main mechanism by which size hits performance. +2. **iTLB is finite**: more code pages → more iTLB entries needed → iTLB misses. +3. **Download/storage**: embedded flash constraints, mobile APK size, network transfer; size is directly cost. + +So size optimization matters for **embedded (flash-constrained)**, **mobile (APK size)**, and **large codebases (icache pressure)**. This article covers three standard techniques. + +## Technique 1: -Os / -Oz (size-first optimization levels) + +`-Os` is "optimize down to a size that doesn't bloat", and `-Oz` (Clang, also GCC) prioritizes size even more aggressively. Their difference from `-O2`/`-O3` is a **different cost model**: + +- `-O2`: cost model is "speed first, size second". +- `-Os`: cost model is "size first, but don't get noticeably slower"; it skips optimizations that would grow the code (like aggressive loop unrolling). +- `-Oz`: even more size-biased, possibly sacrificing a bit of speed. + +Measurement (`size_demo`, with dead code + multiple template instances): + +> ⚠️ Measure code size with the `size` command's text segment, not `ls -l`. A whole ELF includes headers/alignment/debug info and gets polluted, and on some compiler versions the `-Os` ELF can even come out larger than `-O2`. Below we consistently use `size `'s text segment (the code segment). + +```text + text data bss (order of magnitude, local GCC 16) +-O2: ~4144 ... ... ← baseline +-Os: ~3740 ... ... ← smaller text than -O2 +-Oz: ~3740 ... ... ← same order as -Os +--gc-sections ~4017 ... ... ← after dead-code reclamation +``` + +Absolute values shift with compiler version, but the direction is stable: `-Os`/`-Oz` text is smaller than `-O2`. + +On this small demo the difference is tiny (a few hundred bytes), because the program itself is small. **On large projects, `-Os` typically saves 5-15% over `-O2`.** The cost of `-Os` is "might be slightly slower" (it skips the bloat-inducing optimizations), so it fits scenarios where "size is a hard constraint" (embedded flash), not "speed-first" desktop/server. + +## Technique 2: -ffunction-sections + --gc-sections (dead-code reclamation) + +The idea here is to put each function/data item in its own section, and reclaim **unreferenced sections** at link time. Two steps: + +```bash +# Compile: each function in its own section +g++ -ffunction-sections -fdata-sections ... +# Link: reclaim unreferenced sections +g++ ... -Wl,--gc-sections +``` + +What it solves is **dead code**: functions that are defined but never called (very common: legacy code, old paths disabled by conditional compilation, template members instantiated but never used). Measurement above shows `--gc-sections` saving 200 bytes over `-O2` (that demo has two `[[maybe_unused]]` dead functions). + +On large projects `--gc-sections` pays off significantly: large C++ projects often have a lot of "linked in but never used" code (especially when whole third-party libraries are linked in), and `--gc-sections` can cut tens of percent. The cost is essentially nil (compile/link gets slightly slower, negligible). **Release builds should enable `-ffunction-sections -fdata-sections -Wl,--gc-sections` by default**, near-free size optimization. + +> Note: `--gc-sections` reclaims sections whose "entire function/data is unreferenced"; it can't reclaim partial code inside a function (say, an if branch that never executes). That's PGO's job, via code layout. The two are complementary. + +## Technique 3: template bloat control + +Templates instantiate one copy of the code per type, so they bloat easily. `vector`, `vector`, `vector` are three independent copies of `push_back`/`reserve`/growth code. A few control techniques: + +- **`extern template` (C++11)**: explicitly declare that "this template instantiation lives in another TU; don't regenerate it here". In large projects, centralize the commonly-used template instantiations in one `.cpp` (instantiate once), and have other TUs declare them with `extern template`, so each TU doesn't generate its own copy and dedupe at link time (deduping takes time too). + + ```cpp + // common.h + extern template class std::vector; // declaration: don't generate here + // common.cpp + template class std::vector; // instantiate once + ``` + +- **Factor out shared logic**: pull out the type-independent parts of a template into a non-template base class or shared function, compiled once. For example, `vector`'s memory management can share a non-template `vector_base`. +- **Don't over-generalize**: only instantiate for the types you actually need. If `template` is used on a function that only ever takes `int`/`double`, instantiate only those two; don't pile on unused specializations for "generality". + +Template bloat can contribute a meaningful chunk of size on large projects (especially heavy STL/Boost users). These three are the standard counters. + +## The size ↔ performance tradeoff checklist + +Putting the three techniques together with the earlier ones, here's a checklist sorted by "size optimization vs performance impact": + +| Technique | Size | Speed | When to use | +|---|---|---|---| +| `-ffunction-sections` + `--gc-sections` | ↓↓ | Almost unchanged | **release default** (free)| +| `-Os`/`-Oz` | ↓ | Possibly slightly down | Size is a hard constraint (embedded)| +| `extern template` | ↓ | Unchanged | Heavy-template large projects | +| Factor out shared logic (non-template base) | ↓ | Possibly slightly up (indirect call)| Weigh carefully | +| `-O3` (aggressive vectorization/unrolling) | ↑↑ | Usually ↑ occasionally ↓ | Speed first, size is plentiful | +| Template over-generalization | ↑↑ | n/a | Don't write this way | + +The core tradeoff is that **size optimization and speed optimization often pull in opposite directions**: `-Os` saves size but might be slower; `-O3` speeds things up but bloats. **Embedded prioritizes size, desktop/server prioritizes speed, mobile is in between**. Take `--gc-sections` first (the free size win), then choose the `-O` level by scenario, and only then consider things like `extern template` that require code changes. + +## References + +- Existing vol6 `06-evaluating-performance-and-size.md` (this article's predecessor; already present) +- Agner Fog, *Optimizing Assembly*, §10 *Code size optimization*. Local copy +- GCC/Clang docs for `-Os`/`-Oz`/`-ffunction-sections`/`-Wl,--gc-sections`/`extern template` +- CSAPP Chapter 7, *Linking* (background on the `--gc-sections` link mechanism) +- Code for this article's measurements: `code/volumn_codes/vol6-performance/ch07/size_demo.cpp` + +The one-line capstone: **the main mechanism by which size hits performance is icache/iTLB misses** (plus embedded flash and mobile downloads); the three techniques are `-Os`/`-Oz` (size-first optimization levels, measured saving a few hundred bytes to 5-15% over -O2), `--gc-sections` (dead-code reclamation, almost free, on by default in release), and template bloat control (`extern template`, factoring out shared logic); size ↔ speed often trade off in opposite directions: embedded prioritizes size, desktop prioritizes speed, and `--gc-sections` is the free size win to take first. + +This is the last article of ch07, and the capstone of vol6's eight-chapter encyclopedia. The volume started from "performance mindset + sanitizer foundation", passed through "measurement methodology", "CPU microarchitecture", "attribution methodology", "per-bottleneck optimization", "multi-core", "C++ abstraction cost", and arrives here at "compiler boundaries and size", a complete performance-engineering methodology from "correct first, measure first" to "the right medicine for the right symptom". diff --git a/documents/en/vol6-performance/ch07-compiler-and-size/index.md b/documents/en/vol6-performance/ch07-compiler-and-size/index.md new file mode 100644 index 000000000..b76bb5053 --- /dev/null +++ b/documents/en/vol6-performance/ch07-compiler-and-size/index.md @@ -0,0 +1,28 @@ +--- +title: "Compiler optimization boundaries and size evaluation" +description: "ch07 closes the volume from the compiler's perspective: -O levels and optimization blockers (cross-TU / aliasing / volatile), LTO cross-TU inlining (measured 3.9×) and PGO (null on microbenchmarks, real on large codebases), linking performance and multi-compiler comparison and compile-time metaprogramming, and size optimization (-Os / --gc-sections / template bloat). The volume's engineering capstone" +--- + +# Compiler optimization boundaries and size evaluation + +This is vol6's last chapter, closing things out from the **compiler and linker** side. The earlier chapters were about how to write "C++ code that the hardware runs fast"; this one is about **what the compiler can do for you, what it can't, and how to cooperate with it** (give it visibility, don't get in its way). + +Four articles: + +- **07-01 -O levels and blockers**: `-O2` is the release sweet spot (measured -O0→-O2 4× faster), **-O3 is occasionally slower than -O2** (honestly); three blockers (cross-TU / aliasing / volatile). +- **07-02 LTO and PGO**: LTO cross-TU inlining measured **3.9×**; PGO has no payoff on microbenchmarks (honest null, that one-time 4× was instrumentation overhead), value is on large codebases. +- **07-03 Linking, multi-compiler, metaprogramming**: dynamic-link PIC cost (CSAPP ch7), GCC/Clang/MSVC gap is small, the size face of compile-time metaprogramming. +- **07-04 Size optimization**: `-Os` / `--gc-sections` / template bloat control; the size ↔ speed tradeoff is often opposing. + +The thread running through this chapter is the same as ch04: **the compiler is your performance teammate, your job is to get out of its way**; let it see implementations (LTO), trust your no-alias promise (`__restrict`), and don't use `volatile` to disable its optimizations. release defaults to LTO + `--gc-sections`, that's free lunch; PGO is the extra course on large projects. + +> Local measurement covers 07-01/02/04; 07-03 is conceptual (linking mechanism from CSAPP ch7, multi-compiler from Agner vol1 §8), no reinvented experiments. + +## In this chapter + + + -O levels and optimization blockers + LTO, ThinLTO, and the engineering rollout of PGO + Linking performance, multi-compiler comparison, and compile-time metaprogramming + Size optimization: -Os, --gc-sections, and template bloat control + diff --git a/documents/en/vol6-performance/index.md b/documents/en/vol6-performance/index.md index c65434339..c495863b6 100644 --- a/documents/en/vol6-performance/index.md +++ b/documents/en/vol6-performance/index.md @@ -1,30 +1,35 @@ --- -title: 'Volume 6: Performance Optimization' -description: CPU cache, SIMD, assembly reading, optimization levels +title: "Volume 6: Performance Optimization" +description: "From measurement methodology to CPU microarchitecture, from optimize-by-bottleneck-site to the performance cost of C++ abstractions" platform: host tags: -- cpp-modern -- host -- intermediate + - cpp-modern + - host + - intermediate translation: source: documents/vol6-performance/index.md - source_hash: 09e50257ee43a395a7d4d5b7824a818f7bbd3d1654b2575980be65cf59c4dc23 - translated_at: '2026-05-26T11:49:42.299200+00:00' - engine: anthropic - token_count: 116 + source_hash: e38c35474b5a4c7d6a11391e72f553f2834cb602b85182564328208401ba71c2 + engine: manual + token_count: 350 --- + # Volume 6: Performance Optimization -> Status: Partial content exists (pending rewrite) +Performance is the one area in C++ engineering where it's easiest to be confidently wrong — microarchitecture complexity runs far ahead of human intuition. The spine of this volume is a single chain: **correctness first (the correctness foundation) → measure first (the benchmark methodology anchor) → attribute and optimize by bottleneck site (the four TMA buckets) → land on the performance cost of C++ abstractions**. Every topic walks the loop "cut in with C++ code → drop down to the hardware or methodology → come back to how to change the C++". -## Overview +One thesis runs through the whole volume: **efficiency (algorithmic complexity) ≠ performance (real behavior on hardware).** Don't stare at big-O — watch how the data actually flows on the hardware. -This volume covers C++ performance optimization. +> Status: the rewritten eight-chapter CN volume is now translated in full (ch00–ch07). The legacy single-file articles (02-inline / 06-evaluating / avx) have been removed; the sanitizer articles (10/11/12) have been relocated into ch00 as 03/04/05 in both CN and EN. -## Existing Articles (Pending Rewrite to Generic Content) +## Chapter navigation - Inlining and Compiler Optimization - Evaluating Performance and Size - AVX/AVX2 Deep Dive + ch00 · Performance mindset and correctness first + ch01 · Benchmark methodology (volume anchor) + ch02 · CPU microarchitecture and the memory hierarchy + ch03 · Attribution methodology: from measurement to bottleneck + ch04 · Tuning by bottleneck site (technical core) + ch05 · Multicore performance (continuing vol5) + ch06 · The performance cost of C++ abstractions + ch07 · Compiler optimization bounds and size evaluation diff --git a/documents/en/vol8-domains/embedded/core-embedded-cpp-index.md b/documents/en/vol8-domains/embedded/core-embedded-cpp-index.md index ab1ad39f3..e53193c6f 100644 --- a/documents/en/vol8-domains/embedded/core-embedded-cpp-index.md +++ b/documents/en/vol8-domains/embedded/core-embedded-cpp-index.md @@ -34,7 +34,7 @@ This is the table of contents for "Modern C++ for Embedded Systems Tutorial". Cl - [C++98 Advanced: Type Casting, Dynamic Memory, and Exception Handling](../../vol1-fundamentals/03F-cpp98-casts-memory-exceptions.md) - [When to Use C++ and Which Features to Use (Trade-offs and Disabled Features)](../../vol1-fundamentals/04-when-to-use-cpp.md) - [Language Selection Principles: The Real Trade-off Between Performance and Maintainability](../../vol1-fundamentals/05-language-choice-performance-vs-maintainability.md) -- [Does C++ Necessarily Lead to Code Bloat?](../../vol6-performance/06-evaluating-performance-and-size.md) +- [Does C++ Necessarily Lead to Code Bloat?](./01-zero-overhead-abstraction.md) ## Chapter 1 - Build Toolchain @@ -45,7 +45,7 @@ This is the table of contents for "Modern C++ for Embedded Systems Tutorial". Cl ## Chapter 2 - Zero-Overhead Abstractions - [Zero-Overhead Abstraction](./01-zero-overhead-abstraction.md) -- [Inlining and Compiler Optimization](../../vol6-performance/02-inline-and-compiler-optimization.md) +- [Inlining and Compiler Optimization](../../vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md) - [CRTP vs. Runtime Polymorphism: Did You Know?](./04-crtp-vs-runtime-polymorphism.md) ## Chapter 3 - Memory and Object Management diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md b/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md index abee15cbe..19fbff2c8 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/02-reading-assembly-and-registers-abi.md @@ -673,4 +673,4 @@ _Z9make_bigll: ## 延伸阅读 - 想看懂不同优化级别(`-O0` / `-O2` / `-O3`)下编译器到底吐出什么汇编,见 [卷七·编译器选项](../../../../vol7-engineering/02-compiler-options.md)。 -- 想深入 SIMD/AVX 如何重塑汇编输出,见 [卷六·AVX/AVX2 深入](../../../../vol6-performance/avx-avx2-deep-dive.md)。 +- 想深入 SIMD/AVX 如何重塑汇编输出,见 [卷六·AVX/AVX2 深入](../../../../vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md)。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md b/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md index 227f97945..23ce4c97c 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/03-compiler-explorer-and-ai-assisted.md @@ -469,4 +469,4 @@ C++ 中的模块化不只是"怎么写头文件和源文件",更是"怎么把 ## 延伸阅读 - Compiler Explorer 是观察编译器行为的最佳窗口,想系统梳理 GCC/Clang 各选项的效果,见 [卷七·编译器选项](../../../../vol7-engineering/02-compiler-options.md)。 -- 在 CE 里看自动向量化如何启用 AVX/AVX2,见 [卷六·AVX/AVX2 深入](../../../../vol6-performance/avx-avx2-deep-dive.md)。 +- 在 CE 里看自动向量化如何启用 AVX/AVX2,见 [卷六·AVX/AVX2 深入](../../../../vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md)。 diff --git a/documents/vol6-performance/02-inline-and-compiler-optimization.md b/documents/vol6-performance/02-inline-and-compiler-optimization.md deleted file mode 100644 index a6c1673cd..000000000 --- a/documents/vol6-performance/02-inline-and-compiler-optimization.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -chapter: 2 -cpp_standard: -- 11 -- 14 -- 17 -- 20 -description: 探讨内联函数的工作原理 -difficulty: intermediate -order: 2 -platform: host -prerequisites: -- 'Chapter 1: 构建工具链' -reading_time_minutes: 4 -tags: -- cpp-modern -- host -- intermediate -title: 内联与编译器优化 ---- -# 嵌入式现代 C++教程——内联函数与编译器优化 - -在嵌入式开发中,`inline` 这个关键字几乎是每个工程师都会用到的东西。它看起来简单直接,甚至带着一点"性能保证"的意味:函数短、调用频繁、对时序敏感,那就 `inline` 掉,似乎天经地义。 - -对,在过去,inline这个关键字的确是这个作用,但是实际上,在现代C++的今天,在编译器的优化做的非常出色的现在,**`inline` 并不是性能优化的按钮,它甚至经常什么都没做。** - -## inline 从一开始,就不是为"快"而生的 - -从语言层面来说,`inline` 的核心作用其实非常克制。现代的 C++ 标准并没有承诺"你写了 inline,编译器就一定展开函数体"。它真正保证的事情只有一件:**允许该函数在多个翻译单元中出现定义,而不违反 ODR。** - -这也是为什么大量头文件中的小函数、模板函数、traits 工具函数,天然就带着 `inline` 的气质。它解决的是"链接层面的问题",而不是"性能问题"。至于是否真的发生内联,那完全是编译器的自由裁量权。在现代编译器面前,`inline` 更像是一句"我觉得你可以考虑展开"的建议,而不是命令。 - ------- - -## 那嵌入式里,函数调用真的慢吗? - -很多关于 inline 的直觉,来自于对函数调用成本的恐惧。在 Cortex-M 这类架构上,一次函数调用确实意味着跳转、LR 保存、参数传递和返回路径恢复。你如果盯着汇编一条条看,很容易得出结论:这看起来不便宜。 - -问题在于,这个成本**是否真的落在了你的性能瓶颈路径上**。 - -在真实的嵌入式工程中,大量函数的时间消耗根本不在"调用本身",而是在外设访问、总线等待、Flash 读取、Cache miss,甚至是中断抢占上。你为一个 GPIO 读取函数纠结要不要 inline,往往是在一个完全不重要的层级上做优化。 - -更关键的是,在开启优化(哪怕只是 `-O2`)之后,这类短小、无副作用、语义明确的函数,**即使你不写 inline,编译器也几乎必然会自动内联**。 - -今天的编译器在决定是否内联一个函数时,会综合考虑函数体大小、调用点数量、寄存器压力、指令 Cache 行为,甚至在开启 LTO 后跨文件分析调用关系。它掌握的信息,远比你在写代码时看到的那一点上下文要多。这也是为什么你经常会看到这样一种情况:你显式写了 `inline`,反汇编却发现函数依然存在;而你什么都没写,函数却悄无声息地被展开了。 - ------- - -## 展开成调用的inline其实不是很安全 - -如果说 PC 开发中,inline 最大的风险是"无感",那么在嵌入式中,它真正的风险往往是**代码体积膨胀**。 - -内联的本质是复制。一个被频繁调用的小函数,如果在多个位置被展开,指令会被实实在在地复制多份。在 Flash 资源紧张的 MCU 上,这种复制是不可忽视的。更微妙的一点在于,代码变大不只是占 Flash,它还会影响指令 Cache 的局部性。即使在有 I-Cache 的内核上,过度内联也可能导致更多 Cache miss,最终表现为性能下降,而不是提升。 - ------- - -## 那 inline 什么时候才真正有价值? - -在实践中,inline 真正体现价值的场景,往往不是"为了省掉一次函数调用",而是为了**消除抽象边界的成本**。 - -例如模板函数、类型安全的寄存器访问封装、constexpr 参与的编译期计算。这些地方的 inline,使得你可以写出表达力极强的 C++ 代码,同时在生成的汇编层面,几乎和手写 C 没有区别。 - -这正是现代 C++ 在嵌入式领域最迷人的地方:**抽象不是负担,而是可以被完全优化掉的语义工具。** - -在中断服务函数或极端热路径中,inline 也可能是合理的,但前提永远只有一个:你真的看过汇编,并且确认它解决了实际问题。 - -## 在线运行 - -在线对比 C 风格函数调用与 C++ 模板零开销抽象,观察编译器内联优化效果: - - diff --git a/documents/vol6-performance/06-evaluating-performance-and-size.md b/documents/vol6-performance/06-evaluating-performance-and-size.md deleted file mode 100644 index dd8331131..000000000 --- a/documents/vol6-performance/06-evaluating-performance-and-size.md +++ /dev/null @@ -1,1290 +0,0 @@ ---- -chapter: 0 -cpp_standard: -- 11 -- 14 -- 17 -- 20 -description: 学习如何评估程序的性能和体积开销,通过实测对比C和C++在嵌入式环境中的表现 -difficulty: beginner -order: 6 -platform: host -prerequisites: [] -reading_time_minutes: 31 -related: [] -tags: -- cpp-modern -- host -- intermediate -title: 性能与体积评估 ---- -# 现代嵌入式C++教程——C++一定会使得代码膨胀嘛? - -关于性能评估和程序体积大小,我相信,各位程序员可能对前者更加有感觉,对后者还是会略微陌生一点——特别是上位机开发的朋友。我相信在大家觉得硬存越来越不值钱的今天,很少人会关心上位机程序的发布包大小了。不过在嵌入式,一点Flash跟个金子一样珍贵的行业中,还是有必要考虑下程序体积大小的。 - -这就引发了一个问题,大家知道这里是《现代嵌入式C++教程》(有时候,笔者写成了嵌入式现代C++教程),但是这个问题是一个老生常谈但又永远充满争议的话题:**C++一定会使得代码膨胀嘛?** - -## 开始之前,磨刀不误砍柴工 - -在开始我们的代码大战之前,先确保你的工具箱里有这些家伙: - -#### arm-none-eabi-gcc / arm-none-eabi-g++ - -这个是X86_64对ARM平台的交叉编译器,咱们走一下: - -```cpp - -[charliechen@Charliechen arm-linux]$ arm-none-eabi-gcc --version -arm-none-eabi-gcc (Arch Repository) 14.2.0 -Copyright (C) 2024 Free Software Foundation, Inc. -This is free software; see the source for copying conditions. There is NO -warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - -[charliechen@Charliechen arm-linux]$ arm-none-eabi-g++ --version -arm-none-eabi-g++ (Arch Repository) 14.2.0 -Copyright (C) 2024 Free Software Foundation, Inc. -This is free software; see the source for copying conditions. There is NO -warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - -``` - -如果看到版本号,恭喜你!如果看到"command not found",那你可能需要先去ARM官网下载工具链,笔者玩的Arch Linux,直接用pacman或者是yay下回来就好了。 - -> 对了下的是:gcc-arm-none-eabi,不然的话会少标准依赖,您先试试看下arm-none-eabi-gcc,demo拉不通在下标准的EABI - -```bash - -# 编译C语言代码的标准姿势 -arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -c example.c -o example.o - -# 编译C++代码的标准姿势,不要exception,也不要rtti,笔者之前就说过了 -arm-none-eabi-g++ -mcpu=cortex-m4 -mthumb -Os -fno-exceptions -fno-rtti -c example.cpp -o example.o - -# 查看你的代码到底有多"胖" -arm-none-eabi-size example.o - -# 如果你想看看编译器到底把你的代码变成了啥样 -arm-none-eabi-objdump -d example.o - -``` - -> `-fno-exceptions`和`-fno-rtti`这两个参数是在嵌入式系统中使用C++的"减肥药"。不加这两个,你的固件可能会因为异常处理机制的代码而膨胀到像吃了发酵粉的馒头一样。 - ------- - -## 先从点灯开始:GPIO驱动(点个灯而已,有多难?) - -咱们第一个事情,是先将之前的内容落到实地,先看看:不同的语言,不同的编程范式,咱们的代码看起来怎么样,实际上的表现又是如何。 - -### 任务简介 - -我们要实现一个GPIO驱动来控制LED。这是嵌入式世界的"Hello World",就像学编程时打印"Hello World"一样经典。功能包括: - -- 开灯/关灯(额。。。) -- 切换状态 -- PWM调光(装个逼) - -#### C语言版本——朴实无华 - -```c -// gpio_driver.c -#include -#include - -// 硬件寄存器定义(这是在和硬件对话的门牌号) -#define GPIO_BASE 0x40020000 -#define GPIO_ODR (*(volatile uint32_t*)(GPIO_BASE + 0x14)) -#define GPIO_BSRR (*(volatile uint32_t*)(GPIO_BASE + 0x18)) - -// GPIO句柄结构体(把状态打包带走) -typedef struct { - uint8_t pin; - bool state; - uint8_t pwm_duty; // 0-100,就像电灯的亮度旋钮 -} GPIO_Handle; - -// 初始化GPIO(给我们的LED安个家) -void gpio_init(GPIO_Handle* handle, uint8_t pin) { - handle->pin = pin; - handle->state = false; - handle->pwm_duty = 0; -} - -// 设置输出状态(开灯关灯就靠它了) -void gpio_write(GPIO_Handle* handle, bool value) { - if (value) { - GPIO_BSRR = (1 << handle->pin); // 原子操作,不怕中断捣乱 - } else { - GPIO_BSRR = (1 << (handle->pin + 16)); // 高16位是复位位 - } - handle->state = value; -} - -// 切换状态(懒得记住当前是开还是关?用这个!) -void gpio_toggle(GPIO_Handle* handle) { - gpio_write(handle, !handle->state); -} - -// 设置PWM占空比(让LED可以调亮度) -void gpio_set_pwm(GPIO_Handle* handle, uint8_t duty) { - if (duty > 100) duty = 100; // 防止有人手滑输入101 - handle->pwm_duty = duty; -} - -// 获取当前状态(查看灯现在到底是开还是关) -bool gpio_read(GPIO_Handle* handle) { - return handle->state; -} - -// 使用示例(三行代码搞定一个LED) -void example_c(void) { - GPIO_Handle led; - gpio_init(&led, 5); // 用GPIO 5号引脚 - - gpio_write(&led, true); // 开灯 - gpio_toggle(&led); // 切换(现在是关) - gpio_set_pwm(&led, 75); // 设置75%亮度 -} - -``` - -这是笔者的C语言编程风格,当然一些朋友似乎不太喜欢结构体。嗯,笔者还是推介采用结构体,但是不要传它本身触发拷贝,而是传递指针指向这个对象。 - -#### C++版本——OOP - -```cpp -// gpio_driver.hpp -#include - -class GPIO { -private: - // 硬件寄存器定义(藏在private里,外人别乱碰) - static constexpr uint32_t GPIO_BASE = 0x40020000; - static volatile uint32_t& GPIO_ODR() { - return *reinterpret_cast(GPIO_BASE + 0x14); - } - static volatile uint32_t& GPIO_BSRR() { - return *reinterpret_cast(GPIO_BASE + 0x18); - } - - uint8_t pin_; - bool state_; - uint8_t pwm_duty_; - -public: - // 构造函数(一出生就知道自己是谁) - explicit GPIO(uint8_t pin) : pin_(pin), state_(false), pwm_duty_(0) {} - - // 禁用拷贝(硬件资源不能克隆,你能复制一个LED吗?) - GPIO(const GPIO&) = delete; - GPIO& operator=(const GPIO&) = delete; - - // 写入状态 - void write(bool value) { - if (value) { - GPIO_BSRR() = (1U << pin_); - } else { - GPIO_BSRR() = (1U << (pin_ + 16)); - } - state_ = value; - } - - // 切换状态 - void toggle() { - write(!state_); - } - - // 设置PWM占空比 - void setPWM(uint8_t duty) { - pwm_duty_ = (duty > 100) ? 100 : duty; - } - - // 读取状态 - bool read() const { - return state_; - } - - // 运算符重载:让代码看起来像在和LED聊天 - GPIO& operator=(bool value) { - write(value); - return *this; - } - - operator bool() const { - return read(); - } -}; - -// 使用示例(看起来更像是在"对话"而不是"操作") -void example_cpp() { - GPIO led(5); // 创建一个GPIO对象,它自己知道初始化 - - led.write(true); - led.toggle(); - led.setPWM(75); - - // 或者使用更直观的语法(就像在说"led你给我开!") - led = true; - if (led) { // 可以直接当bool用! - led = false; - } -} - -``` - -C++中的一个经典的使用,就是采用OOP面对对象的编程思路进行编程。 - -当然一些朋友会抬杠——谁告诉你,C++是一个OOP语言的?它也是泛型编程语言。也对,我没意见,笔者自己的GPIO库就是用模板写的,不过这里,我们先考虑OOP。 - -### 战况分析:真的差很多吗? - -先不评价,先看看差异如何! - -我们将上述C代码保存为demo.c,然后使用的完整编译指令如下: - -```bash -arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -c demo.c -o demo_c.o - -``` - -哈?你说你都是IDE直接单点的?好,咱们就来讲讲这在做什么 - ------- - -#### `arm-none-eabi-gcc` - -指定使用 **ARM 裸机交叉编译器**: - -- `arm`:目标架构为 ARM -- `none`:无操作系统(bare-metal) -- `eabi`:嵌入式 ABI - -生成的代码 **不能运行在 Linux / Windows**,而是用于 MCU Flash。 - ------- - -#### `-mcpu=cortex-m4` - -指定**目标 CPU 内核型号**: - -- 生成 **针对 Cortex-M4 的指令** -- 启用 M4 特有特性(如 DSP 指令) -- 确保指令集与实际 MCU 完全匹配 - -当然,如果你说你想试着测一下M1的,也彳亍,换成cortex-m1,都可以试一试。 - ------- - -#### `-mthumb` - -强制使用 **Thumb 指令集**: - -- Cortex-M 系列 **只支持 Thumb** -- 指令更紧凑,代码密度更高 -- 是 M 系列的"默认工作模式" - -对 Cortex-M 来说,这是**必选项而不是优化项**。 - ------- - -#### `-Os` - -**以最小代码体积为目标的优化等级**: - -- 优先减少 Flash 占用 -- 在 `-O2` / `-O3` 的基础上,刻意避免代码膨胀 -- 是嵌入式中**最常用、最稳妥**的优化等级 - ------- - -#### `-c`:**只编译,不链接** - -- 输入:`demo.c` -- 输出:`demo_c.o` -- 不生成可执行文件 - -- `.o` 才能用于 `arm-none-eabi-size` -- 可以精确评估"某个源文件本身"的代码体积 - ------- - -#### `-o demo_c.o` - -指定输出文件名: - -```cpp - -demo.c → demo_c.o - -``` - -避免使用默认的 `demo.o`,在做 **多语言 / 多版本对比实验**时尤其清晰。 - ------- - -### 让我们看看战果 - -| 实现方式 | text (代码段) | data | bss | 总计 | -| -------- | ------------- | ---- | ---- | ------- | -| C版本 | 96 bytes | 0 | 0 | 96 | -| C++版本 | 24 bytes | 0 | 0 | 24 | -| 差异 | **-72 bytes** | 0 | 0 | **-72** | - -**惊不惊喜?意不意外?** - -C++版本反而**少了72个字节**,代码量减少了75%!这点减少换来的是: - -- ✅ 更好的封装性(私有成员不会被乱改) -- ✅ 自动初始化(不会忘记调用init) -- ✅ 类型安全(不会传错指针) -- ✅ 更直观的语法(`led = true`比`gpio_write(&led, true)`舒服多了) - -**关键发现**:C++的内联优化让整个`example_cpp`函数只有24字节,比C版本的多个函数加起来还小!编译器把所有操作都优化成了直接的寄存器操作。 - -### 汇编级别的真相 - -不信的话,我们来看看编译器生成的汇编代码(这是编译器的"透视眼"): - -**C版本的example_c(96字节,包含多个函数调用):** - -```asm -example_c: - push {r0, r1, r2, lr} - movs r3, #5 - strb.w r3, [sp, #4] ; 初始化pin - ldr r3, [pc, #20] - movs r2, #32 - str r2, [r3, #24] ; GPIO操作 - add r0, sp, #4 - movs r3, #1 - strb.w r3, [sp, #5] ; 设置state - bl gpio_toggle ; 函数调用 - add sp, #12 - ldr.w pc, [sp], #4 - -``` - -**C++版本的example_cpp(仅24字节,全部内联):** - -```asm -example_cpp: - ldr r3, [pc, #16] - movs r1, #32 - mov.w r2, #2097152 ; 直接计算bit mask - str r1, [r3, #24] ; GPIO操作1 - str r2, [r3, #24] ; GPIO操作2 - str r1, [r3, #24] ; GPIO操作3 - str r2, [r3, #24] ; GPIO操作4 - bx lr - nop - -``` - -**看到了吗?C++版本更简洁高效!** - -编译器把C++的类方法全部内联,消除了函数调用开销,直接生成最优化的寄存器操作。而C版本由于函数分离,需要额外的栈操作和函数跳转。 - -**结论**:C++的封装是"零开销抽象"——不仅零开销,在很多情况下反而更高效!这不是营销口号,是真的! - ------- - -## 第二回合:环形缓冲区(UART的好帮手) - -### 任务简介 - -环形缓冲区(Ring Buffer)是嵌入式系统的"瑞士军刀"。当UART数据像洪水一样涌来时,你需要一个地方暂存它们。这就是环形缓冲区的用武之地——一个首尾相连、永不浪费的数据容器。 - -想象一个寿司转台,盘子绕着圈转,你放盘子(写入),别人拿盘子(读取),只要盘子没满,转台就一直转。 - -#### C语言版本——那就朴实 - -```c -// ring_buffer.c -#include -#include -#include - -#define BUFFER_SIZE 64 // 64字节,不大不小刚刚好 - -typedef struct { - uint8_t buffer[BUFFER_SIZE]; - volatile uint16_t head; // 写指针(放盘子的位置) - volatile uint16_t tail; // 读指针(拿盘子的位置) - volatile uint16_t count; // 当前有多少盘子 -} RingBuffer; - -// 初始化(把转台清空) -void rb_init(RingBuffer* rb) { - rb->head = 0; - rb->tail = 0; - rb->count = 0; -} - -// 写入一个字节(放一个盘子上台) -bool rb_put(RingBuffer* rb, uint8_t data) { - if (rb->count >= BUFFER_SIZE) { - return false; // 转台满了,请稍后再试 - } - - rb->buffer[rb->head] = data; - rb->head = (rb->head + 1) % BUFFER_SIZE; // 绕圈圈 - rb->count++; - return true; -} - -// 读取一个字节(拿一个盘子) -bool rb_get(RingBuffer* rb, uint8_t* data) { - if (rb->count == 0) { - return false; // 转台空了,没东西可拿 - } - - *data = rb->buffer[rb->tail]; - rb->tail = (rb->tail + 1) % BUFFER_SIZE; // 绕圈圈 - rb->count--; - return true; -} - -// 查询还有多少数据(还有多少盘子) -uint16_t rb_available(RingBuffer* rb) { - return rb->count; -} - -// 查询还有多少空间(还能放多少盘子) -uint16_t rb_free_space(RingBuffer* rb) { - return BUFFER_SIZE - rb->count; -} - -// 清空缓冲区(把转台上的盘子全部拿走) -void rb_clear(RingBuffer* rb) { - rb->head = 0; - rb->tail = 0; - rb->count = 0; -} - -// 使用示例 -void example_c_rb(void) { - RingBuffer uart_rx; - rb_init(&uart_rx); - - // 写入数据(发送"Hello") - const char* msg = "Hello"; - for (int i = 0; msg[i]; i++) { - rb_put(&uart_rx, msg[i]); - } - - // 读取数据(接收并处理) - uint8_t byte; - while (rb_get(&uart_rx, &byte)) { - // 处理每个字节 - } -} - -``` - -#### C++版本——那就泛型 - -好,这里我们就写泛型——泛型有个毛病就是代码膨胀的问题 - -```cpp -// ring_buffer.hpp -#include -#include -#include - -// 模板参数:想要多大的转台,你说了算! -template -class RingBuffer { -private: - std::array buffer_; // 用std::array而不是C数组 - volatile uint16_t head_{0}; - volatile uint16_t tail_{0}; - volatile uint16_t count_{0}; - -public: - // 构造函数(转台出厂就是干净的) - RingBuffer() = default; - - // 写入一个字节 - bool put(uint8_t data) { - if (count_ >= Size) { - return false; - } - - buffer_[head_] = data; - head_ = (head_ + 1) % Size; - count_++; - return true; - } - - // 读取一个字节(注意:这里用引用返回,避免指针) - bool get(uint8_t& data) { - if (count_ == 0) { - return false; - } - - data = buffer_[tail_]; - tail_ = (tail_ + 1) % Size; - count_--; - return true; - } - - // 批量写入(一次放多个盘子) - size_t write(const uint8_t* data, size_t len) { - size_t written = 0; - for (size_t i = 0; i < len && put(data[i]); i++) { - written++; - } - return written; - } - - // 批量读取(一次拿多个盘子) - size_t read(uint8_t* data, size_t len) { - size_t read_count = 0; - for (size_t i = 0; i < len && get(data[i]); i++) { - read_count++; - } - return read_count; - } - - // 查询方法([[nodiscard]]告诉编译器:别忽略返回值!) - [[nodiscard]] uint16_t available() const { return count_; } - [[nodiscard]] uint16_t freeSpace() const { return Size - count_; } - [[nodiscard]] bool isEmpty() const { return count_ == 0; } - [[nodiscard]] bool isFull() const { return count_ >= Size; } - - // 清空 - void clear() { - head_ = 0; - tail_ = 0; - count_ = 0; - } - - // 获取容量(编译期常量,不占运行时间) - static constexpr size_t capacity() { return Size; } -}; - -// 使用示例(注意模板参数可以在编译期指定大小) -void example_cpp_rb() { - RingBuffer<64> uart_rx; // 64字节的缓冲区 - - // 写入数据 - const char* msg = "Hello"; - uart_rx.write(reinterpret_cast(msg), strlen(msg)); - - // 读取数据 - uint8_t byte; - while (uart_rx.get(byte)) { - // 处理每个字节 - } - - // 或者批量读取 - uint8_t buffer[32]; - size_t n = uart_rx.read(buffer, sizeof(buffer)); -} - -``` - ------- - -### 第一段:环形缓冲区实现对比 - -让我们看看战果: - -| 实现方式 | text (代码段) | data | bss | 总计 | -| -------- | ------------- | ---- | ---- | ------- | -| C版本 | 218 bytes | 0 | 0 | 218 | -| C++版本 | 150 bytes | 0 | 0 | 150 | -| 差异 | **-68 bytes** | 0 | 0 | **-68** | - -**惊不惊喜?意不意外?** - -C++版本反而**少了68个字节**,代码量减少了31%!这还是在实现了完整环形缓冲区功能的情况下。这点减少换来的是: - -- ✅ 更好的封装性(内部索引不会被外部修改) -- ✅ 自动构造函数初始化(不会忘记调用init) -- ✅ 类型安全(不会传错指针) -- ✅ 更直观的方法调用(`rb.put(data)`比`rb_put(&rb, data)`舒服多了) - -**关键发现**:C++通过内联优化消除了函数调用开销,同时编译器能更好地优化类方法。C版本需要多个独立函数(`rb_init`, `rb_put`, `rb_get`, `rb_available`, `rb_free_space`, `rb_clear`),而C++版本通过智能内联将这些操作融合得更紧凑。 - -### 汇编级别的真相 - -不信的话,我们来看看编译器生成的汇编代码: - -**C版本的example_c_rb(依赖多个函数):** - -```asm -example_c_rb: - push {lr} - sub sp, #84 - movs r3, #0 - ldr r2, [pc, #44] - strh.w r3, [sp, #72] ; 初始化 - strh.w r3, [sp, #74] - strh.w r3, [sp, #76] - ; ... 循环写入 - bl rb_put ; 函数调用开销 - ; ... 循环读取 - bl rb_get ; 函数调用开销 - add sp, #84 - ldr.w pc, [sp], #4 - -``` - -**C++版本的example_cpp_rb(完全内联):** - -```asm -example_cpp_rb: - sub sp, #72 - movs r3, #0 - strh.w r3, [sp, #64] ; 内联初始化 - movs r2, #5 - strh.w r3, [sp, #66] - strh.w r3, [sp, #68] - ; ... 直接操作,无函数调用 - ldrh.w r3, [sp, #68] - adds r3, #1 - strh.w r3, [sp, #68] ; 内联的put操作 - ; ... 继续内联操作 - add sp, #72 - bx lr - -``` - -**看到了吗?C++版本消除了所有函数调用!** - -编译器把所有方法内联到一起,减少了栈操作、函数跳转和寄存器保存。C版本因为函数分离,每次`rb_put`和`rb_get`都需要额外的`bl`指令和栈帧设置。 - ------- - -## 第三回合:状态机(按键消抖的艺术) - -### 任务简介 - -按键消抖是嵌入式工程师的"必修课"。机械按键在按下和释放时会产生抖动(就像弹簧来回震动),如果不处理,一次按键可能被识别成十几次。 - -我们要实现一个状态机来: - -- 检测按键按下 -- 检测按键释放 -- 检测长按(按住1秒以上) -- 消抖(50ms内的抖动忽略) - -### C语言版本:经典状态机 - -```c -// button_fsm.c -#include -#include - -// 按键状态(状态机的"房间") -typedef enum { - BTN_STATE_IDLE, // 闲置状态(没人按) - BTN_STATE_PRESSED, // 按下状态(刚按下) - BTN_STATE_RELEASED, // 释放状态(刚松开) - BTN_STATE_LONG_PRESS // 长按状态(按了很久) -} ButtonState; - -// 按键事件(状态机的"输出") -typedef enum { - BTN_EVENT_NONE, - BTN_EVENT_PRESS, // 检测到短按 - BTN_EVENT_RELEASE, // 检测到释放 - BTN_EVENT_LONG_PRESS // 检测到长按 -} ButtonEvent; - -// 按键状态机结构体 -typedef struct { - ButtonState state; - uint32_t timestamp; // 时间戳(记录何时进入当前状态) - uint8_t pin; // GPIO引脚 - bool last_reading; // 上次读数 - uint16_t debounce_time; // 消抖时间(ms) - uint16_t long_press_time; // 长按时间(ms) -} ButtonFSM; - -// 初始化 -void btn_init(ButtonFSM* btn, uint8_t pin) { - btn->state = BTN_STATE_IDLE; - btn->timestamp = 0; - btn->pin = pin; - btn->last_reading = false; - btn->debounce_time = 50; // 50ms消抖 - btn->long_press_time = 1000; // 1s长按 -} - -// 硬件接口(假设这些函数已经实现) -extern bool hw_read_pin(uint8_t pin); -extern uint32_t hw_get_tick(void); - -// 状态机更新(在主循环中不断调用) -ButtonEvent btn_update(ButtonFSM* btn) { - bool reading = hw_read_pin(btn->pin); - uint32_t now = hw_get_tick(); - ButtonEvent event = BTN_EVENT_NONE; - - // 状态机核心:根据当前状态和输入决定下一步 - switch (btn->state) { - case BTN_STATE_IDLE: - // 闲置状态:检测按下 - if (reading && !btn->last_reading) { - btn->timestamp = now; - btn->state = BTN_STATE_PRESSED; - } - break; - - case BTN_STATE_PRESSED: - // 按下状态:等待释放或长按 - if (!reading) { - // 松开了 - if ((now - btn->timestamp) >= btn->debounce_time) { - event = BTN_EVENT_PRESS; // 确认是有效按下 - btn->state = BTN_STATE_RELEASED; - btn->timestamp = now; - } else { - btn->state = BTN_STATE_IDLE; // 抖动,忽略 - } - } else if ((now - btn->timestamp) >= btn->long_press_time) { - // 按太久了! - event = BTN_EVENT_LONG_PRESS; - btn->state = BTN_STATE_LONG_PRESS; - } - break; - - case BTN_STATE_RELEASED: - // 释放状态:确认释放 - if ((now - btn->timestamp) >= btn->debounce_time) { - if (!reading) { - event = BTN_EVENT_RELEASE; - btn->state = BTN_STATE_IDLE; - } else { - btn->state = BTN_STATE_PRESSED; // 又按下了? - btn->timestamp = now; - } - } - break; - - case BTN_STATE_LONG_PRESS: - // 长按状态:等待释放 - if (!reading) { - btn->state = BTN_STATE_IDLE; - } - break; - } - - btn->last_reading = reading; - return event; -} - -// 使用示例 -void example_c_button(void) { - ButtonFSM power_button; - btn_init(&power_button, 3); // 使用GPIO 3 - - // 在主循环中调用 - while (1) { - ButtonEvent evt = btn_update(&power_button); - switch (evt) { - case BTN_EVENT_PRESS: - // 短按:切换开关 - break; - case BTN_EVENT_LONG_PRESS: - // 长按:进入设置菜单 - break; - case BTN_EVENT_RELEASE: - // 释放:什么都不做 - break; - default: - break; - } - } -} - -``` - -### C++版本:面向对象的状态机 - -```cpp -// button_fsm.hpp -#include -#include - -// 硬件接口(假设这些函数已经实现) -extern bool hw_read_pin(uint8_t pin); -extern uint32_t hw_get_tick(void); - -class Button { -public: - // 事件类型(用enum class更安全) - enum class Event { - None, - Press, - Release, - LongPress - }; - - // 回调函数类型(可以是lambda、函数指针等) - using EventCallback = std::function; - -private: - // 内部状态(外人看不到) - enum class State { - Idle, - Pressed, - Released, - LongPress - }; - - State state_{State::Idle}; - uint32_t timestamp_{0}; - uint8_t pin_; - bool last_reading_{false}; - uint16_t debounce_time_{50}; - uint16_t long_press_time_{1000}; - EventCallback callback_; // 事件回调 - - // 硬件接口(封装在内部) - bool readPin() const { - return hw_read_pin(pin_); - } - - uint32_t getTick() const { - return hw_get_tick(); - } - - // 通知事件(如果有回调的话) - void notifyEvent(Event event) { - if (callback_ && event != Event::None) { - callback_(event); - } - } - -public: - // 构造函数(可以自定义消抖和长按时间) - explicit Button(uint8_t pin, - uint16_t debounce_ms = 50, - uint16_t long_press_ms = 1000) - : pin_(pin) - , debounce_time_(debounce_ms) - , long_press_time_(long_press_ms) {} - - // 设置回调函数(支持lambda表达式) - void setCallback(EventCallback callback) { - callback_ = callback; - } - - // 状态机更新 - Event update() { - bool reading = readPin(); - uint32_t now = getTick(); - Event event = Event::None; - - switch (state_) { - case State::Idle: - if (reading && !last_reading_) { - timestamp_ = now; - state_ = State::Pressed; - } - break; - - case State::Pressed: - if (!reading) { - if ((now - timestamp_) >= debounce_time_) { - event = Event::Press; - state_ = State::Released; - timestamp_ = now; - } else { - state_ = State::Idle; - } - } else if ((now - timestamp_) >= long_press_time_) { - event = Event::LongPress; - state_ = State::LongPress; - } - break; - - case State::Released: - if ((now - timestamp_) >= debounce_time_) { - if (!reading) { - event = Event::Release; - state_ = State::Idle; - } else { - state_ = State::Pressed; - timestamp_ = now; - } - } - break; - - case State::LongPress: - if (!reading) { - state_ = State::Idle; - } - break; - } - - last_reading_ = reading; - notifyEvent(event); // 自动通知回调 - return event; - } - - // 查询方法 - [[nodiscard]] bool isPressed() const { - return state_ == State::Pressed || state_ == State::LongPress; - } -}; - -// 使用示例(看看C++的回调有多爽) -void example_cpp_button() { - Button power_button(3); - - // 使用lambda表达式作为回调 - power_button.setCallback([](Button::Event evt) { - switch (evt) { - case Button::Event::Press: - // 短按:切换开关 - break; - case Button::Event::LongPress: - // 长按:进入设置菜单 - break; - case Button::Event::Release: - // 释放:什么都不做 - break; - default: - break; - } - }); - - // 在主循环中调用 - while (true) { - power_button.update(); // 自动调用回调 - } -} - -// 如果不想用std::function(它有点重),可以用函数指针版本 -class ButtonLite { -public: - enum class Event { None, Press, Release, LongPress }; - using EventCallback = void (*)(Event); // 函数指针,更轻量 - - // ... 其他实现类似,但用函数指针代替std::function -}; - -``` - -### 战况分析:std::function的代价 - -| 实现方式 | text (代码段) | data | bss | 总计 | -| ----------------------- | -------------- | ---- | ---- | -------- | -| C版本 | 172 bytes | 0 | 0 | 172 | -| C++版本 (std::function) | 306 bytes | 0 | 0 | 306 | -| 差异 | **+134 bytes** | 0 | 0 | **+134** | - -**这次差异明显了!**C++版本增加了**78%的代码量**,这134字节的代价来自于这些地方: - -- `std::function`的类型擦除机制(需要虚函数表) -- lambda捕获的额外开销 -- 动态多态的运行时支持代码 - -所以,这个事情是想跟你说——咱们的C++不是所有抽象的都是0开销的,以**std::function为例子:它带来了显著的代码膨胀(78%增长)**,而且:**lambda捕获是存在隐藏成本的,因为每个lambda都需要额外的存储和管理代码,这一点熟悉Lambda的朋友应该知晓——他会生成一个带有`operator()`调用,存储每一个捕获对象的结构体**: - -这里代替方案也很简单: - -```cpp -// 方案1:函数指针(接近C的开销) -using callback_t = void (*)(int); -void set_callback(callback_t cb); - -// 方案2:模板回调(零开销,编译期展开) -template -void process(Callback cb) { - cb(42); // 完全内联 -} - -// 方案3:直接调用(最优) -void process() { - handle_event(42); -} - -``` - -## 说话 - -#### 代码大小对比表 - -我们回顾下: - -**案例一:GPIO操作封装** - -在GPIO操作场景中,C++类封装展现出令人惊讶的优势。C版本需要96字节来实现gpio_init、gpio_write、gpio_toggle等多个函数,而C++版本通过编译器的内联优化,将整个操作序列压缩到仅24字节,代码量减少了75%。这种巨大的差异来自于编译器能够将C++的成员函数调用完全内联,消除了函数调用开销和栈帧管理。 - -**案例二:环形缓冲区实现** - -环形缓冲区的实现进一步验证了C++的优势。C版本需要实现rb_init、rb_put、rb_get、rb_available、rb_free_space、rb_clear等六个独立函数,总计218字节。而C++版本通过类封装和方法内联,将代码量减少到150字节,节省了31%的空间。关键在于编译器能够看到完整的调用链,从而进行更激进的优化。 - -**案例三:std::function的警示** - -并非所有C++特性都适合嵌入式开发。当使用std::function实现回调时,代码从C版本的172字节膨胀到306字节,增加了78%。这是因为std::function需要类型擦除机制、虚函数表支持以及lambda捕获的管理代码。这个案例提醒我们,在资源受限的环境中,必须谨慎选择使用的C++特性。 - -| 特性 | 代码增长 | 建议 | -| ---------------- | ------------ | -------------------------------- | -| 类封装(基础) | -75% 到 -31% | 强烈推荐(实测反而更小) | -| 类封装(带模板) | +4% | 强烈推荐(几乎零开销) | -| 虚函数 | +20-40% | 谨慎使用(考虑CRTP替代) | -| 异常处理 | +50-100% | 禁用(-fno-exceptions) | -| RTTI | +30-50% | 禁用(-fno-rtti) | -| std::function | +78% | 谨慎使用(用函数指针或模板替代) | -| 模板(泛型容器) | +4% | 强烈推荐(编译期优化) | - -### 性能对比表 - -基于汇编级别的周期计数分析: - -| 类别 | C实现 | C++实现 | 差异 | -| ---------------- | ------------ | ------------ | ------- | -| GPIO单次操作 | 8-10 cycles | 8-10 cycles | 0% | -| 缓冲区读写 | 12-15 cycles | 12-15 cycles | 0% | -| 内联后的完整操作 | 需要函数调用 | 完全内联 | C++更快 | - -**关键发现**:在启用优化的情况下,C++的零开销抽象不是营销口号,而是可验证的事实。编译器生成的汇编代码显示,C++的类方法和C的函数在单个操作层面完全相同,而在复杂操作场景中,C++由于内联优化甚至更快。 - ------- - -## 最佳实践:如何在嵌入式系统中优雅地使用C++ - -### 一、编译器选项(减肥配置) - -嵌入式C++开发的黄金编译器配置如下: - -```bash - -# 基础优化 --Os # 优化代码大小而非速度 --mcpu=cortex-m4 # 指定目标处理器 --mthumb # 使用Thumb指令集(代码更紧凑) - -# C++特性控制(关键) --fno-exceptions # 禁用异常处理(节省50-100%代码) --fno-rtti # 禁用运行时类型信息(节省30-50%) --fno-threadsafe-statics # 禁用线程安全的静态初始化 - -# 代码段优化 --ffunction-sections # 每个函数放入独立段 --fdata-sections # 每个数据对象放入独立段 --Wl,--gc-sections # 链接时删除未使用的段 - -# 进一步优化 --flto # 链接时优化(Link Time Optimization) --fno-use-cxa-atexit # 禁用全局析构函数注册 - -``` - -这套配置能够确保C++代码在嵌入式环境中保持高效紧凑。实测显示,正确配置的C++代码可以达到与C相当甚至更小的体积。 - -### 二、推荐使用的C++特性 - -以下特性经过实测验证,在嵌入式系统中表现优秀: - -**类和对象(强烈推荐)** - -类封装是C++的核心优势,能够将硬件资源抽象为对象。实测显示,简单的类封装不仅不会增加代码量,反而因为编译器优化而减少代码。例如,将GPIO寄存器封装为类,可以提供类型安全和更好的接口,同时保持零开销。 - -**构造和析构函数(强烈推荐)** - -构造函数提供自动初始化,析构函数实现RAII模式,这是C++最强大的资源管理机制。在嵌入式系统中,可以用析构函数自动关闭外设、释放资源,避免资源泄漏。编译器通常能将简单的构造函数完全内联。 - -**模板(强烈推荐)** - -模板提供编译期的代码生成,完全没有运行时开销。环形缓冲区的测试显示,模板版本仅增加4%的代码量,却提供了类型安全和大小参数化。相比C的宏,模板更安全、更易调试。 - -**constexpr(强烈推荐)** - -constexpr函数在编译期计算,结果直接嵌入代码。可以用于计算配置参数、查找表生成等场景,完全零运行时开销。 - -**引用和inline函数(强烈推荐)** - -引用避免了不必要的拷贝,inline函数消除函数调用开销。在嵌入式系统中,合理使用引用可以显著提升性能,特别是在传递结构体时。 - -**运算符重载(适度推荐)** - -运算符重载可以让代码更直观,例如用`gpio = true`代替`gpio_write(&gpio, 1)`。只要不滥用,运算符重载不会带来额外开销。 - -### 三、谨慎使用的C++特性 - -以下特性有一定开销,需要根据实际情况权衡: - -**虚函数(谨慎使用)** - -虚函数引入vtable,每个对象增加4字节指针开销,每次调用需要间接跳转。如果确实需要多态,考虑使用CRTP(Curiously Recurring Template Pattern)实现编译期多态,可以避免运行时开销。 - -**std::function(谨慎使用)** - -实测显示std::function会导致78%的代码膨胀。如果需要回调机制,优先考虑函数指针(与C相同开销)或模板回调(零开销)。只有在需要捕获状态的lambda时,才考虑std::function。 - -**动态内存分配(谨慎使用)** - -new和delete在嵌入式系统中可能导致内存碎片。建议使用placement new配合静态内存池,或者使用栈上对象。如果必须使用动态内存,考虑自定义分配器。 - -**STL容器(谨慎使用)** - -标准库容器如std::vector、std::map的实现可能较大。建议先测试代码大小,或使用专门为嵌入式优化的容器库(如EASTL)。对于简单场景,手写固定大小的容器可能更合适。 - -### 四、禁止使用的C++特性 - -以下特性在嵌入式系统中应当完全避免: - -**异常处理(禁止)** - -异常处理机制会使代码膨胀50-100%,且引入不可预测的执行路径。嵌入式系统需要确定性的行为,应使用错误码或断言代替异常。务必添加-fno-exceptions编译选项。 - -**RTTI(禁止)** - -运行时类型信息会增加30-50%的代码,且在嵌入式系统中很少需要。使用-fno-rtti禁用。如果需要类型识别,可以手动实现简单的类型标记系统。 - -**iostream库(禁止)** - -std::cout和std::cin会引入巨大的代码(几十KB),远超嵌入式系统的承受能力。使用传统的printf/scanf或专门的嵌入式日志库。 - -**多重继承(禁止)** - -多重继承增加复杂度和代码量,且可能导致diamond问题。在嵌入式系统中,单一继承或组合模式就足够了。 - ------- - -## 实战建议:何时用C,何时用C++? - -### 选择C的场景 - -**极度资源受限的环境** - -当目标硬件Flash小于8KB、RAM小于1KB时,C是更安全的选择。这类系统通常是简单的传感器节点或控制器,不需要复杂的抽象。 - -**团队技术栈限制** - -如果团队成员不熟悉C++,或者项目时间紧张,强行使用C++可能得不偿失。C的学习曲线更平缓,更容易掌握。 - -**纯C代码库集成** - -当需要集成大量现有的C代码库时,使用C可以避免混合编程的麻烦。虽然C++可以调用C代码,但在某些情况下纯C项目更简单。 - -**工具链支持不足** - -某些老旧或专用的编译器对C++支持不完整,可能产生低效的代码。这时C是更可靠的选择。 - -### 选择C++的场景 - -**中等以上资源的系统** - -当Flash大于16KB、RAM大于2KB时,C++的优势开始显现。这类系统有足够空间容纳C++的抽象机制,同时能从封装和类型安全中受益。 - -**复杂的状态管理** - -实现状态机、协议栈、传感器融合等复杂逻辑时,C++的类封装可以显著降低复杂度。对象可以封装状态和行为,使代码更易维护。 - -**需要代码复用** - -当有多个相似的模块(如多个串口、多个定时器)时,C++模板比C的宏更安全、更易调试。模板提供编译期的类型检查和参数化。 - -**现代开发实践** - -如果团队熟悉现代C++(C++11及以后),能够正确使用智能指针、移动语义、lambda等特性,开发效率会显著提升。 - -### 混合使用(最佳实践) - -许多成功的嵌入式项目采用分层的混合策略: - -**底层驱动层:使用C** - -直接操作寄存器的底层驱动用C编写,保证稳定性和可移植性。这部分代码通常不复杂,C已经足够。 - -**中间抽象层:使用C++** - -将底层驱动封装为C++类,提供面向对象的接口。例如,将UART驱动封装为SerialPort类,提供更安全、更易用的API。 - -**应用逻辑层:使用C++** - -业务逻辑、状态机、数据处理等用C++实现,利用类、模板、RAII等特性简化代码。 - -**模块接口:使用extern "C"** - -模块之间的接口使用extern "C"声明,确保C和C++模块可以无缝协作。这种方式既保持了灵活性,又避免了名称修饰的问题。 - ------- - -## 在线运行 - -在线对比 C 与 C++ 的 GPIO 封装、环形缓冲区在代码行为和 sizeof 上的差异: - - - -## 练习时间:动手试试吧 - -### 练习1:实测对比 - -在你的开发板上实现上述三个示例,测量: - -1. Flash占用(用`arm-none-eabi-size`) -2. RAM占用(检查.bss和.data段) -3. 运行时间(用DWT周期计数器) - -### 练习2:优化挑战 - -尝试优化环形缓冲区: - -1. 当Size是2的幂时,用位运算替代取模(`% Size` → `& (Size-1)`) -2. 实现零拷贝的`peek()`操作 -3. 添加中断安全版本(禁用中断或用原子操作) - -### 练习3:设计决策 - -为以下场景选择C或C++: - -1. 简单UART驱动(只收发)→ **你的选择?** -2. 传感器融合算法(卡尔曼滤波)→ **你的选择?** -3. 1ms实时控制循环 → **你的选择?** -4. OTA固件升级模块 → **你的选择?** - -### 练习4:代码审查 - -找出以下C++代码的问题: - -```cpp -class Sensor { - std::vector data; // 问题1: ? -public: - virtual void read() { // 问题2: ? - throw std::runtime_error("Not implemented"); // 问题3: ? - } -}; - -``` - -**改进版本**: - -```cpp -template -class Sensor { - std::array data_; - size_t size_{0}; -public: - [[nodiscard]] bool read() { // 用bool表示成功/失败 - // 实现... - return true; - } -}; - -``` - -## 最后的最后 - -引用Bjarne Stroustrup(C++之父)的话: - -> "C++不是一门你必须全部使用的语言,而是一门你可以选择使用的语言。" - -在嵌入式系统中,我们要做聪明的选择者,而不是盲目的追随者。用C++的强大特性来提高代码质量,同时避开那些在资源受限环境中不适用的特性。 diff --git a/documents/vol6-performance/avx-avx2-deep-dive.md b/documents/vol6-performance/avx-avx2-deep-dive.md deleted file mode 100644 index 02fba33d8..000000000 --- a/documents/vol6-performance/avx-avx2-deep-dive.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -chapter: 2 -difficulty: intermediate -order: 3 -platform: host -reading_time_minutes: 6 -tags: -- cpp-modern -- host -- intermediate -title: AVX 指令集系列深度介绍:领域、意义、以及 AVX / AVX2 的基本用法与样例 -description: '' ---- -# AVX 指令集系列深度介绍:领域、意义、以及 AVX / AVX2 的基本用法与样例 - -## 前言 - -PS下,笔者不是专门做这一块的,是聊天的时候聊到这里,发现这个领域对我而言相当的陌生,打算好好的记录个笔记唠下,所以我没办法完全保证我搜集得到的内容百分百准确。看官自行评判。 - ------- - -## 为什么要关心 AVX?—— 向量化计算的领域与意义 - -我关心这个,有的时候是高清视频渲染(嗯,笔者参与的项目有涉及到这块,所以才知晓还有这个领域的),毕竟在现代计算任务中,无论是高清视频渲染、人工智能模型训练,还是复杂的科学仿真,数据量都在呈指数级增长。传统的 **SISD(单指令单数据)** 处理模式——即每次操作只处理一个数据项——已逐渐成为计算效率的瓶颈。 - -为了突破这一瓶颈,**SIMD(单指令多数据)** 概念应运而生。它允许 CPU 用一条指令同时处理一组数据,这种"成批处理"的技术被称为**向量化**。**AVX(Advanced Vector Extensions,高级向量扩展)** 正是 x86 架构中最重要的向量化指令集之一。 - -我们很自然的就要问了,那怎么优化的?在 CPU 内部,**寄存器**是数据参加运算前必须停留的"临时月台"。在早期的 SSE 技术时代,这个月台的宽度是 128 位。如果我们处理的是"单精度浮点数"(每个数据占 32 位),那么一个周期内只能并排排下 4 个数据进行计算。 - -而 AVX 技术将这个月台的宽度翻倍到了 **256 位**。这意味着 CPU 的硬件通道发生了质变:现在它可以在同一个瞬间,同时吞吐并处理 **8 个**单精度浮点数,或者 **4 个**更大、更精确的双精度浮点数。这种位宽的翻倍,本质上是为数据流动修建了更宽的高速公路,让计算的"胃口"增大了一倍。 - -在传统的计算指令中,CPU 的操作逻辑通常比较"粗糙"。比如要执行 A + B 的操作,计算结果必须强制覆盖掉原来的数据 A。这种设计被称为"两操作数"模式,它具有一定的破坏性——如果你后续还需要用到原始数据 A,就必须在计算前额外花时间把它备份到另一个地方。 - -AVX 引入了更先进的 **VEX 编码**,实现了"三操作数"模式。它允许程序下达更精细的指令:"取数据 A,取数据 B,计算结果存入 C"。这样一来,原始数据 A 和 B 都被完好地保留了下来。这种进化精简了大量的重复劳动,减少了数据在内存中反复搬运、备份的开销,使得整个程序的逻辑变得更加轻盈和高效。 - -AVX 带来的不仅仅是速度的微调,而是处理逻辑的底层进化。它将原本需要一个接一个排队执行的"串行"任务,转化为成批进行的"向量化"任务。在理想的计算密集型场景下(如科学模型计算或高画质渲染),这种转化能让 CPU 的工作效率产生数倍的飞跃。 - -这种进步意味着,在面对海量数字运算时,CPU 能够极大程度地释放其算术吞吐量。以前需要反复旋转的时钟周期,现在通过一次强有力的"向量化打击"即可完成,从而在不单纯依赖提高主频的情况下,实现了性能的跨越式提升。 - -### AVX2:整型运算与灵活性的飞跃 - -2013 年发布的 **AVX2** 进一步完善了这一体系。如果说 AVX 解决了"算得快"的问题,那么 AVX2 则解决了"算得广"的问题: - -1. **全面整型化**:AVX2 将原有的 256 位并行计算能力从浮点数扩展到了**整数**领域。这对于数据压缩、图像处理以及数据库检索等依赖整数运算的场景至关重要。 -2. **非连续数据处理(Gather/Permute)**:在实际应用中,数据往往零散地分布在内存中。AVX2 引入了"收集"(Gather)指令,允许 CPU 从不连续的内存地址批量抓取数据,显著增强了处理复杂数据结构的能力。 - ------- - -## 在代码中使用 AVX / AVX2 - -#### 编译器开关 - -- GCC/Clang: - - AVX: `-mavx` - - AVX2: `-mavx2` - - FMA(若需要): `-mfma` - - 若希望对目标 CPU 最佳化:`-march=native`(但会生成依赖当前 CPU 的代码) -- MSVC: - - `/arch:AVX` 或 `/arch:AVX2`(视 VS 版本) -- 推荐做法:编译时可以生成带 AVX/AVX2 的专门文件,或编译多版本并在运行时选择(runtime dispatch)。 - -#### Intrinsics(示例 API) - -- 浮点(AVX):`__m256`(float32 ×8)和 `__m256d`(double ×4) - - load/store: `_mm256_loadu_ps`, `_mm256_storeu_ps` (unaligned) - - add/mul: `_mm256_add_ps`, `_mm256_mul_ps` - - fused: `_mm256_fmadd_ps`(需要 FMA) -- 整数(AVX2):`__m256i` - - add: `_mm256_add_epi32` - - gather: `_mm256_i32gather_epi32`(从 int 索引收集) - - shift/and/or: `_mm256_slli_epi32`, `_mm256_and_si256` 等 - ------- - -## 基本样例(C/C++ intrinsics) - -下面这些小样例可以比较直观的体验下AVX。 - -#### 浮点数组相加(AVX) - -```cpp -#include -#include - -void add_float_arrays_avx(const float* a, const float* b, float* out, size_t n) { - size_t i = 0; - const size_t stride = 8; // 8 floats per __m256 - for (; i + stride <= n; i += stride) { - __m256 va = _mm256_loadu_ps(a + i); // unaligned load - __m256 vb = _mm256_loadu_ps(b + i); - __m256 vr = _mm256_add_ps(va, vb); - _mm256_storeu_ps(out + i, vr); - } - // tail - for (; i < n; ++i) out[i] = a[i] + b[i]; -} - -``` - -编译: - -```cpp - -g++ -O3 -mavx -std=c++17 avx_samples.cpp -o avx_samples - -``` - -#### 浮点点积(AVX + reduction) - -```cpp -#include -#include - -float dot_product_avx(const float* a, const float* b, size_t n) { - size_t i = 0; - const size_t stride = 8; - __m256 acc = _mm256_setzero_ps(); - for (; i + stride <= n; i += stride) { - __m256 va = _mm256_loadu_ps(a + i); - __m256 vb = _mm256_loadu_ps(b + i); - __m256 prod = _mm256_mul_ps(va, vb); - acc = _mm256_add_ps(acc, prod); - } - // horizontal sum of acc - __attribute__((aligned(32))) float tmp[8]; - _mm256_store_ps(tmp, acc); - float sum = tmp[0] + tmp[1] + tmp[2] + tmp[3] + tmp[4] + tmp[5] + tmp[6] + tmp[7]; - for (; i < n; ++i) sum += a[i] * b[i]; - return sum; -} - -``` - -#### 试一下:AVX2:整型并行加法与 gather 示例 - -```cpp -#include -#include - -// add 8 32-bit ints in parallel -void add_int32_avx2(const int32_t* a, const int32_t* b, int32_t* out, size_t n) { - size_t i = 0; - const size_t stride = 8; // 8 x int32 in 256 bits - for (; i + stride <= n; i += stride) { - __m256i va = _mm256_loadu_si256((const __m256i*)(a + i)); - __m256i vb = _mm256_loadu_si256((const __m256i*)(b + i)); - __m256i vr = _mm256_add_epi32(va, vb); - _mm256_storeu_si256((__m256i*)(out + i), vr); - } - for (; i < n; ++i) out[i] = a[i] + b[i]; -} - -// gather example: gather int32_t at indices array idx from base pointer base -void gather_example(const int32_t* base, const int32_t* idx, int32_t* out) { - __m256i vindex = _mm256_loadu_si256((const __m256i*)idx); // indices - __m256i gathered = _mm256_i32gather_epi32(base, vindex, 4); - _mm256_storeu_si256((__m256i*)out, gathered); -} - -``` - -编译: - -```cpp - -g++ -O3 -mavx2 -std=c++17 avx_samples.cpp -o avx2_samples - -``` diff --git a/documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md b/documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md index e1d9fafc7..05640c620 100644 --- a/documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md +++ b/documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md @@ -1,31 +1,33 @@ --- -title: "性能思维:efficiency 与 performance 不是一回事" -description: "从一段同是 O(log n) 的查找代码出发,讲透 efficiency(算法复杂度)与 performance(硬件上的真实表现)的鸿沟,立下本卷两条铁律与 Amdahl 天花板,附平台/库选型决策一页" chapter: 0 -order: 1 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 实战 +cpp_standard: +- 14 +- 17 +description: 从一段同是 O(log n) 的查找代码出发,讲透 efficiency(算法复杂度)与 performance(硬件上的真实表现)的鸿沟,立下本卷两条铁律与 + Amdahl 天花板,附平台/库选型决策一页 difficulty: intermediate +order: 1 platform: host -reading_time_minutes: 16 -cpp_standard: [14, 17] prerequisites: - - "C++ 容器与算法基础(std::vector / std::set / std::lower_bound)" +- C++ 容器与算法基础(std::vector / std::set / std::lower_bound) +reading_time_minutes: 15 related: - - "为什么 microbenchmark 会骗你" - - "存储层次与延迟阶梯" - - "ASan 工具家族与内存安全" +- 为什么 microbenchmark 会骗你 +- 存储层次与延迟阶梯 +- ASan 工具家族与内存安全 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 实战 +title: 性能思维:efficiency 与 performance 不是一回事 --- - # 性能思维:efficiency 与 performance 不是一回事 ## 一个让很多人不舒服的事实 -我们先看一段几乎人人都写过的代码:在一个集合里查一个数。最自然的两个选择——把数据放进 `std::set`,或者放进排好序的 `std::vector` 然后用 `std::lower_bound` 做二分查找。两边查一次都是 $O(\log n)$,复杂度完全一样,教科书讲到这一步通常就停了,告诉你「随便选,差不多」。 +我们先看一段几乎人人都写过的代码:在一个集合里查一个数。最自然的两个选择:把数据放进 `std::set`,或者放进排好序的 `std::vector` 然后用 `std::lower_bound` 做二分查找。两边查一次都是 $O(\log n)$,复杂度完全一样,教科书讲到这一步通常就停了,告诉你「随便选,差不多」。 但如果你真的去测,会发现事情没那么简单。下面这张表是我在自己机器上跑出来的(WSL2/Linux,2,000,000 次随机命中查询,5 次取中位数,完整代码见本章「代码示例」一节): @@ -46,14 +48,14 @@ N 超过 6 万以后,`set` 比 `vector` 慢了 3 到 5 倍;而在 N 很小(1024) 先把两个词分清楚,本卷后面所有讨论都建立在这个区分上: -- **efficiency(效率)** 是算法复杂度维度:总工作量(work)、关键路径长度(span)、big-O 记号。这是一种**数学性质**,和具体硬件无关——你说二分查找是 $O(\log n)$,不管它在 x86、ARM 还是一台纸带机上跑,这个结论都成立。 +- **efficiency(效率)** 是算法复杂度维度:总工作量(work)、关键路径长度(span)、big-O 记号。这是一种**数学性质**,和具体硬件无关。你说二分查找是 $O(\log n)$,不管它在 x86、ARM 还是一台纸带机上跑,这个结论都成立。 - **performance(性能)** 是你的数据**在真实硬件上怎么流动**:命中了哪一级缓存、有没有触发分支预测失败、有没有被向量化、有没有伪共享。这是一种**工程性质**,只能在具体的硬件上测出来,换个 CPU 型号可能结论就变了。 -问题出在:big-O 把所有「硬件相关」的效应——缓存命中与否、分支预测准不准、常量因子的实际大小——一股脑塞进一个隐含的常数 $C$ 里,然后假装它不重要。`std::set` 主流实现(libstdc++/libc++/MSVC)是红黑树,每个节点单独分配,散落在堆的各个角落;查找时每往下一层都是一次指针解引用,跳到的下一个节点位置不可预测——这是一种叫 **pointer chasing**(指针追逐)的模式,硬件预取器学不会,于是大 N 下几乎每一层都是一次 cache miss。`std::vector` 的元素是**连续存放**的,二分查找跳到的那些点至少落在一段紧凑的内存里(一个 cacheline——CPU 从内存取数据的最小单位,通常 64 字节——能装下 16 个 `int`,整段数组容易被 L2/L3 装下)。复杂度分析把这条鸿沟全部塞进了常数 $C$,于是一句「都是 $O(\log n)$」就把 5 倍的真实差距抹平了。 +问题出在:big-O 把所有「硬件相关」的效应(缓存命中与否、分支预测准不准、常量因子的实际大小)一股脑塞进一个隐含的常数 $C$ 里,然后假装它不重要。`std::set` 主流实现(libstdc++/libc++/MSVC)是红黑树,每个节点单独分配,散落在堆的各个角落;查找时每往下一层都是一次指针解引用,跳到的下一个节点位置不可预测。这是一种叫 **pointer chasing**(指针追逐)的模式,硬件预取器学不会,于是大 N 下几乎每一层都是一次 cache miss。`std::vector` 的元素是**连续存放**的,二分查找跳到的那些点至少落在一段紧凑的内存里(一个 cacheline,即 CPU 从内存取数据的最小单位,通常 64 字节,能装下 16 个 `int`,整段数组容易被 L2/L3 装下)。复杂度分析把这条鸿沟全部塞进了常数 $C$,于是一句「都是 $O(\log n)$」就把 5 倍的真实差距抹平了。 -Denis Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》第 1 章举了一个更反直觉的例子:在**小规模**输入下,InsertionSort($O(n^2)$)实测打败 QuickSort($O(n \log n)$)——因为 Big-O 无法刻画分支预测和缓存效应,它俩的差异被藏进了那个「不重要」的常数里。他在书里这段话的大意是:复杂度分析无法考虑各种算法的分支预测和缓存效应,所以只能把它们封装在一个隐含的常数 $C$ 里,而这个常数有时会对性能产生决定性的影响。 +Denis Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》第 1 章举了一个更反直觉的例子:在**小规模**输入下,InsertionSort($O(n^2)$)实测打败 QuickSort($O(n \log n)$),因为 Big-O 无法刻画分支预测和缓存效应,它俩的差异被藏进了那个「不重要」的常数里。他在书里这段话的大意是:复杂度分析无法考虑各种算法的分支预测和缓存效应,所以只能把它们封装在一个隐含的常数 $C$ 里,而这个常数有时会对性能产生决定性的影响。 -这就是本卷的总命题:**别只看 big-O,要看数据在硬件上怎么流。** 后面 ch02 会把缓存层级、cacheline、延迟阶梯这些硬件细节正式展开;但你现在就要建立一个直觉——「复杂度低 = 跑得快」是一个会在真实代码里让你出丑的错觉。同一个 $O(\log n)$,差 5 倍;同一个 $O(n)$,差几十倍都有可能(顺序遍历 vs 随机访问)。 +这就是本卷的总命题:**别只看 big-O,要看数据在硬件上怎么流。** 后面 ch02 会把缓存层级、cacheline、延迟阶梯这些硬件细节正式展开;但你现在就要建立一个直觉:「复杂度低 = 跑得快」是一个会在真实代码里让你出丑的错觉。同一个 $O(\log n)$,差 5 倍;同一个 $O(n)$,差几十倍都有可能(顺序遍历 vs 随机访问)。 ## 铁律一:先正确,再快 @@ -63,13 +65,13 @@ CS:APP 第 5 章开篇第一句就把这条钉死了(原文我们直接引,因 > > (写程序的首要目标,是让它在所有可能条件下都算对。一个跑得飞快但结果错误的程序,没有任何用处。) -听起来像废话,但放进性能优化这个场景,它有一条非常具体的、会被反复违反的推论:**带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。** UB 在 `-O2` 下不会乖乖待着——编译器会基于「这段 UB 永远不会发生」做激进优化,结果常常是:你的 benchmark 测的早就不是一个真实的函数调用,而是一个被优化得面目全非的空壳,你对着它得出一堆精美又全错的结论,还信心满满地拿去「优化」生产代码。 +听起来像废话,但放进性能优化这个场景,它有一条非常具体的、会被反复违反的推论:**带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。** UB 在 `-O2` 下不会乖乖待着,编译器会基于「这段 UB 永远不会发生」做激进优化,结果常常是:benchmark 测的对象早就面目全非,被优化成一个空壳,你对着它得出一堆精美又全错的结论,还信心满满地拿去「优化」生产代码。 -所以本卷把 sanitizer 工具链(ASan / UBSan / MSan / TSan)放在 ch00 当地基,而不是当成「调试工具混进了性能卷」。没有 sanitizer 兜底的正确性,性能数字一律不可信;并发代码没过 TSan 的数字,同样不可信。我们后面会专门讲这条链路,这里你只需要记住结论——**先正确,再快,这条没有商量余地。** +所以本卷把 sanitizer 工具链(ASan / UBSan / MSan / TSan)放在 ch00 当地基,而不是当成「调试工具混进了性能卷」。没有 sanitizer 兜底的正确性,性能数字一律不可信;并发代码没过 TSan 的数字,同样不可信。我们后面会专门讲这条链路,这里你只需要记住结论:**先正确,再快,这条没有商量余地。** ## 铁律二:先测量,再优化 -你的直觉,在微架构这个层面,经常是错的。「数据紧凑一点、少做一次分配」这种大方向的直觉当然对;但一到「该不该无分支」「循环要不要手展」「虚函数到底慢不慢」这种指令级细节,直觉就开始追不上硬件了。这不是贬低谁,这是现代 CPU 的复杂度决定的——一颗当代 CPU 里有乱序执行、分支预测、缓存层级、预取器、SIMD、微操作融合……你的「感觉」追不上这些。 +你的直觉,在微架构这个层面,经常是错的。「数据紧凑一点、少做一次分配」这种大方向的直觉当然对;但一到「该不该无分支」「循环要不要手展」「虚函数到底慢不慢」这种指令级细节,直觉就开始追不上硬件了。这不是贬低谁,这是现代 CPU 的复杂度决定的。一颗当代 CPU 里有乱序执行、分支预测、缓存层级、预取器、SIMD、微操作融合……你的「感觉」追不上这些。 举几个本卷后面会逐个用实测拆给你看的案例: @@ -79,19 +81,19 @@ CS:APP 第 5 章开篇第一句就把这条钉死了(原文我们直接引,因 这些都不是假设,是后面 ch04 / ch06 的真实内容。结论只有一句话:**优化之前先 profile。** 火焰图上最宽的那个盒子,才是值得动手的地方;凭直觉改代码,大概率你在优化那 5% 的部分,而真正的瓶颈在另外 95% 里躺着睡大觉。 -这条铁律直接引出了 ch01——Benchmark 方法论。那是本卷的**锚点章**:后面每一篇性能文章,开头都会回引它讲的那套测量规矩,就像 vol5 用 TSan 贯穿并发正确性一样。如果你时间只够读这一卷里的一篇文章,读 ch01。 +这条铁律直接引出了 ch01:Benchmark 方法论。那是本卷的**锚点章**:后面每一篇性能文章,开头都会回引它讲的那套测量规矩,就像 vol5 用 TSan 贯穿并发正确性一样。如果你时间只够读这一卷里的一篇文章,读 ch01。 ## Amdahl:优化的天花板 -在动手改任何代码之前,还得知道一条硬规矩——Amdahl 定律。它最早由 Gene Amdahl 在 1967 年提出,一句话说清加速比的上限在哪: +在动手改任何代码之前,还得知道一条硬规矩:Amdahl 定律。它最早由 Gene Amdahl 在 1967 年提出,一句话说清加速比的上限在哪: $$S = \frac{1}{(1 - p) + \dfrac{p}{N}}$$ -其中 $p$ 是「能被加速的部分」占总时间的比例,$N$ 是你给这部分施加的加速比。分母里那个孤零零的 $(1 - p)$ 就是串行部分——它不吃你的加速,原封不动留在那里。 +其中 $p$ 是「能被加速的部分」占总时间的比例,$N$ 是你给这部分施加的加速比。分母里那个孤零零的 $(1 - p)$ 就是串行部分,它不吃你的加速,原封不动留在那里。 -代几个数你就 felt 到它的残酷了:哪怕你把占 90% 的部分加速 1000 倍($p=0.9, N=1000$),总加速也只有 $1 / (0.1 + 0.0009) \approx 9.9\times$。上限在哪?让 $N \to \infty$(你把那 90% 无限加速),$p/N \to 0$,$S \to 1/(1 - p) = 1/0.1 = 10\times$——这就是「锁死在 10 倍以内」的来历:剩下那 10% 的串行代码,你拿它一点办法都没有,再怎么把并行部分往死里压榨,串行部分岿然不动。 +代几个数你就 felt 到它的残酷了:哪怕你把占 90% 的部分加速 1000 倍($p=0.9, N=1000$),总加速也只有 $1 / (0.1 + 0.0009) \approx 9.9\times$。上限在哪?让 $N \to \infty$(你把那 90% 无限加速),$p/N \to 0$,$S \to 1/(1 - p) = 1/0.1 = 10\times$,这就是「锁死在 10 倍以内」的来历:剩下那 10% 的串行代码,你拿它一点办法都没有,再怎么把并行部分往死里压榨,串行部分岿然不动。 -推论极其重要:**优化要打在「占比大的串行部分」上。** 这就是 profile 驱动优化的理论根据——不是「哪里看起来慢就改哪里」,而是「先测出哪里占比最大,再改那里」。Amdahl 定律的完整推导、它和 Gustafson 定律的对比(固定问题规模的强扩展 vs 随核数扩大问题规模的弱扩展),vol5 第 0 章已经讲透了,我们这里只取「优化天花板」这一个视角,不重复造轮子。 +推论极其重要:**优化要打在「占比大的串行部分」上。** 这就是 profile 驱动优化的理论根据:先测出哪里占比最大,再改那里,别凭「看起来慢」就动手。Amdahl 定律的完整推导、它和 Gustafson 定律的对比(固定问题规模的强扩展 vs 随核数扩大问题规模的弱扩展),vol5 第 0 章已经讲透了,我们这里只取「优化天花板」这一个视角,不重复造轮子。 ## 别忽略最大的那个杠杆:平台和库 @@ -100,10 +102,10 @@ $$S = \frac{1}{(1 - p) + \dfrac{p}{N}}$$ 我们把它压成一页,几条关键判断: - **先干掉最浪费的工具/框架。** 选了一个处处堆分配、层层虚函数的重量级框架,后面再怎么抠 cacheline、抠对齐都补不回来。Agner 引用了 Wirth's law 这条半开玩笑的格言:软件变慢的速度,比硬件变快的速度还快。这两件事同时发生的时候,用户体验就是原地踏步甚至倒退。 -- **数据结构选型 > 微优化。** 我们开头那个 `vector` vs `set` 的例子就是明证——换一个缓存友好的容器,5 倍收益到手,比你手动展开循环、抠位运算实在得多。先把这种「结构性」的收益吃掉,再谈指令级优化。 +- **数据结构选型 > 微优化。** 我们开头那个 `vector` vs `set` 的例子就是明证:换一个缓存友好的容器,5 倍收益到手,比你手动展开循环、抠位运算实在得多。先把这种「结构性」的收益吃掉,再谈指令级优化。 - **嵌入式向:host 和 MCU 的资源差好几个数量级。** 在 host 上无所谓的一次堆分配,到 STM32 上可能就是一次碎片化灾难;host 上验证过的优化,原理在 MCU 上往往同样成立,但 MCU 的可用内存小太多,host 上根本不在意的开销,在 MCU 上可能就是性能或容量的硬瓶颈。本卷代码示例以 host 为主,涉及嵌入式向的话题会单独点名,带你去 vol8 嵌入式域看完整故事。 -平台选型的完整清单 Agner 写了十几页,我们这里压成一页,因为它不是本卷的技术主体——但它常常是被忽略的、收益最大的那一刀。很多人性能优化做不动,不是微架构没吃透,是一开始工具/库就选错了。 +平台选型的完整清单 Agner 写了十几页,我们这里压成一页,因为它不是本卷的技术主体,但它常常是被忽略的、收益最大的那一刀。很多人性能优化做不动,不是微架构没吃透,是一开始工具/库就选错了。 ## 代码示例:亲手验证 vector vs set @@ -182,15 +184,15 @@ int main() { 第三,**多轮取中位数**。单次跑出来 50ns 还是 80ns,可能只是这一轮 CPU 被调度走了、或者 Turbo 频率没爬上来。跑 5 轮取中位数,把这种离群值压下去。这一条看似琐碎,但它是 ch01 整章要展开的「性能数字是随机变量」的起点——你测出来的不是一个数,是一个分布。 -第四,**用 `steady_clock`**,不用 `clock()`。`clock()` 测的是进程 CPU 时间——多线程下会把别的核的繁忙也算进来,阻塞/睡眠又不计入,根本不是我们要的「这段代码墙上跑了多久」;`steady_clock` 是单调递增的高分辨率时钟,不会像 `system_clock`(那才是真正的墙钟)那样在系统改时间时被回拨,专门用来量「两件事之间隔了多久」,正是这里要的。ch01 会把「该用哪个时钟」单独讲。 +第四,**用 `steady_clock`**,不用 `clock()`。`clock()` 测的是进程 CPU 时间,多线程下会把别的核的繁忙也算进来,阻塞/睡眠又不计入,根本不是我们要的「这段代码墙上跑了多久」;`steady_clock` 是单调递增的高分辨率时钟,不会像 `system_clock`(那才是真正的墙钟)那样在系统改时间时被回拨,专门用来量「两件事之间隔了多久」,正是这里要的。ch01 会把「该用哪个时钟」单独讲。 跑出来你会看到和本篇开头那张表一致的趋势:小 N 时 `set` 在我机器上反而略快(0.9× 左右),N 一旦超出缓存,`set` 就被 cache miss 拖着成倍变慢,而 `vector` 靠连续内存把曲线压得很平。 -小 N 那个「反常」值得多说一句,因为它正好暴露了 big-O 藏不住的第二样东西——**分支预测**。N=1024 时,`set` 的整棵红黑树和 `vector` 二分会跳到的那些元素,都还全在 L1 里,缓存这把刀根本还没发威。真正的差异在分支:`lower_bound` 每一步的比较,对随机命中的查询来说几乎是 50/50,分支预测器猜不中,猜错一次要冲刷流水线(代价十几个周期);而 `set::find` 每层除了那次 key 比较,还有几个高度可预测的操作(空指针检查、指针更新),缓存不缺位时,这点指令混合的差异足以让它反超。Bakhvalov 在书里专门讲过 binary search 这种「缓存还没发力时,反倒被分支预测拖累」的反直觉现象。注意这个「小 N set 略快」是在我这台机器 + libstdc++ 上稳定复现的,但**换编译器、换 STL 实现、换微架构就可能翻转**——所以下面警告框里那句「`set` 略快那行可能消失」指的是换环境,不是同一台机器上的随机抖动。两条都是 $O(\log n)$,小 N 谁快由分支预测和指令混合决定,大 N 谁快由缓存决定,这就是 efficiency ≠ performance。 +小 N 那个「反常」值得多说一句,因为它正好暴露了 big-O 藏不住的第二样东西:**分支预测**。N=1024 时,`set` 的整棵红黑树和 `vector` 二分会跳到的那些元素,都还全在 L1 里,缓存这把刀根本还没发威。真正的差异在分支:`lower_bound` 每一步的比较,对随机命中的查询来说几乎是 50/50,分支预测器猜不中,猜错一次要冲刷流水线(代价十几个周期);而 `set::find` 每层除了那次 key 比较,还有几个高度可预测的操作(空指针检查、指针更新),缓存不缺位时,这点指令混合的差异足以让它反超。Bakhvalov 在书里专门讲过 binary search 这种「缓存还没发力时,反倒被分支预测拖累」的反直觉现象。注意这个「小 N set 略快」是在我这台机器 + libstdc++ 上稳定复现的,但**换编译器、换 STL 实现、换微架构就可能翻转**,所以下面警告框里那句「`set` 略快那行可能消失」指的是换环境,不是同一台机器上的随机抖动。两条都是 $O(\log n)$,小 N 谁快由分支预测和指令混合决定,大 N 谁快由缓存决定,这就是 efficiency ≠ performance。 > ⚠️ **别把这张表当普适结论。** 你在不同 CPU、不同编译器、不同 libc++ 实现上跑出的绝对数字会变,`set` 略快的那一行可能消失,也可能变得更明显。我们关心的是**趋势**(连续内存 vs 节点分散)和**命题**(同复杂度差几倍),不是某个具体倍数。把别人的性能数字直接抄进自己的工程,是另一种「猜」。 -性能问题永远从「数据在硬件上怎么流」出发,不从「我觉得」出发——至于怎么把「我觉得」换成「我测过」,那是 ch01 的事。 +性能问题永远从「数据在硬件上怎么流」出发,不从「我觉得」出发。至于怎么把「我觉得」换成「我测过」,那是 ch01 的事。 ## 参考资源 diff --git a/documents/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md b/documents/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md index f8b9ecd16..395692528 100644 --- a/documents/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md +++ b/documents/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md @@ -1,34 +1,35 @@ --- -title: "从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基" -description: "承接 ch00-01 的「先正确再快」,拆开「带 UB 的性能数字为什么不可信」,讲清 sanitizer(ASan/UBSan/MSan/TSan)为什么排在性能卷 ch00 当地基而不是被当成调试工具,再把叙事交接到 sanitizer 三篇与 ch01" chapter: 0 -order: 2 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 内存安全 +cpp_standard: +- 11 +description: 承接 ch00-01 的「先正确再快」,拆开「带 UB 的性能数字为什么不可信」,讲清 sanitizer(ASan/UBSan/MSan/TSan)为什么排在性能卷 + ch00 当地基而不是被当成调试工具,再把叙事交接到 sanitizer 三篇与 ch01 difficulty: intermediate +order: 2 platform: host -reading_time_minutes: 8 -cpp_standard: [11] prerequisites: - - "性能思维:efficiency 与 performance 不是一回事" +- 性能思维:efficiency 与 performance 不是一回事 +reading_time_minutes: 7 related: - - "ASan 工具家族与内存安全" - - "为什么 microbenchmark 会骗你" +- ASan 工具家族与内存安全 +- 为什么 microbenchmark 会骗你 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 内存安全 +title: 从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基 --- - # 从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基 ## 上一篇留下的那句话 -ch00-01 立铁律一「先正确,再快」的时候,我们甩了一句很重的话:**带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。** 这一篇就把这句话拆开——不是吓唬你,是让你看清楚,UB 到底是怎么把一个性能数字变成谎话的。看懂这一步,你就能理解一件可能让你困惑的事:为什么一本讲性能优化的卷,第一章不讲 cache、不讲 SIMD,却摆了一整套 sanitizer 工具链。 +ch00-01 立铁律一「先正确,再快」的时候,我们甩了一句很重的话:**带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。** 这一篇就把这句话拆开,让你看清楚 UB 到底是怎么把一个性能数字变成谎话的。看懂这一步,你就能理解一件可能让你困惑的事:为什么一本讲性能优化的卷,第一章不讲 cache、不讲 SIMD,却摆了一整套 sanitizer 工具链。 ## UB 是怎么把性能数字变成谎话的 -C++ 标准把一类行为划为「未定义」:有符号整数溢出、越界访问、解引用空指针、读未初始化的变量、数据竞争……标准对这些程序的行为**什么都不保证**——不是说它会「出错」,是说它**什么都可能发生**,包括你最不想的那种「看起来正常跑、结果全错」。 +C++ 标准把一类行为划为「未定义」:有符号整数溢出、越界访问、解引用空指针、读未初始化的变量、数据竞争……标准对这些程序的行为**什么都不保证**:**什么都可能发生**,包括你最不想的那种「看起来正常跑、结果全错」。 可怕的地方在于:编译器是**主动利用**这条规则的。`-O2` 下,编译器会合法地假设「这段代码不会触发 UB」,然后基于这个假设做激进优化。这是标准允许的,也是现代编译器日常在做的事。落到性能测量上,后果主要有三类,每一类都够让你的数字作废: @@ -36,7 +37,7 @@ C++ 标准把一类行为划为「未定义」:有符号整数溢出、越界访 **第二类:编译器替你的循环下了结论。** 一个有符号循环变量如果可能溢出,那就是 UB;编译器可以假设它不会溢出,进而推出循环上界、把整段循环 hoist 成常量、或者干脆折叠掉。你以为测了 N 次迭代,实际一次都没跑。 -**第三类,也是最阴险的一类:你以为在测 A,其实踩在 B 的内存上。** 越界写、use-after-free、未初始化读——这些不会让程序崩,只会让你的 benchmark 读写到「别的变量」的内存。你测出来「这段代码 50 纳秒」,其实那 50 纳秒里有一半在污染隔壁的数据结构,数字毫无意义,而且程序还在「正常」跑。 +**第三类,也是最阴险的一类:你以为在测 A,其实踩在 B 的内存上。** 越界写、use-after-free、未初始化读,这些不会让程序崩,只会让你的 benchmark 读写到「别的变量」的内存。你测出来「这段代码 50 纳秒」,其实那 50 纳秒里有一半在污染隔壁的数据结构,数字毫无意义,而且程序还在「正常」跑。 举一个最小、也最经典的例子,让你有体感。下面这个函数判断「`x+1` 是否大于 `x`」: @@ -44,7 +45,7 @@ C++ 标准把一类行为划为「未定义」:有符号整数溢出、越界访 bool always_bigger(int x) { return (x + 1) > x; } // x+1 溢出 = UB ``` -`-O2` 下,gcc 会基于「有符号溢出不可能发生」把整个函数折叠成 `return true;`——汇编就一行 `movl $1, %eax; ret`,什么 `x` 都不关心。于是哪怕你传 `INT_MAX`(那个 `x+1` 真会溢出的值),它也理直气壮返回 `true`。这是 gcc `-fstrict-overflow` 文档化了的行为(`-O2` 默认开启),cppreference 的未定义行为页面也拿它当标准反例。 +`-O2` 下,gcc 会基于「有符号溢出不可能发生」把整个函数折叠成 `return true;`,汇编就一行 `movl $1, %eax; ret`,什么 `x` 都不关心。于是哪怕你传 `INT_MAX`(那个 `x+1` 真会溢出的值),它也理直气壮返回 `true`。这是 gcc `-fstrict-overflow` 文档化了的行为(`-O2` 默认开启),cppreference 的未定义行为页面也拿它当标准反例。 口说无凭,我在自己机器上(GCC 16.1.1)实跑了一遍,给函数喂同一个值 `INT_MAX`: @@ -58,9 +59,9 @@ $ g++ -O2 -fwrapv ub_fold.cpp -o ub_fold_wrap && ./ub_fold_wrap 2147483647 f(INT_MAX) = 0 # INT_MAX+1 回绕成 INT_MIN,INT_MIN > INT_MAX 为假 ``` -同一个优化级别(`-O2`)、同一份输入(`INT_MAX`),唯一差别是「有符号溢出算 UB」还是「定义成回绕」——结果一个 `1` 一个 `0`。这就是 UB 在性能测量里最致命的地方:**你测的对象本身是不稳定的,编译选项一动,测的就不是同一个东西了。** 顺带一个坑:你可能想用 `-O0` 当对照(指望它老实做加法、回绕出 `0`),但当代 GCC 的中间端即便 `-O0` 也会识别 `(x+1)>x` 这个惯用法、照样折叠,所以这个对照最好用 `-fwrapv` 来做,比换优化级别更干净。 +同一个优化级别(`-O2`)、同一份输入(`INT_MAX`),唯一差别是「有符号溢出算 UB」还是「定义成回绕」,结果一个 `1` 一个 `0`。这就是 UB 在性能测量里最致命的地方:**你测的对象本身是不稳定的,编译选项一动,测的就不是同一个东西了。** 顺带一个坑:你可能想用 `-O0` 当对照(指望它老实做加法、回绕出 `0`),但当代 GCC 的中间端即便 `-O0` 也会识别 `(x+1)>x` 这个惯用法、照样折叠,所以这个对照最好用 `-fwrapv` 来做,比换优化级别更干净。 -所以一个带 UB 的 benchmark,在 `-O2` 下测出来的「快了 30%」,很可能只是「编译器基于 UB 假设把你的循环删掉了一半」省出来的 30%——这 30% 到了生产环境(不同的编译选项、不同的输入分布)会瞬间蒸发,甚至变成「慢了」。你拿着这个假数字去做架构决策,就是在沙子上盖楼。 +所以一个带 UB 的 benchmark,在 `-O2` 下测出来的「快了 30%」,很可能只是「编译器基于 UB 假设把你的循环删掉了一半」省出来的 30%。这 30% 到了生产环境(不同的编译选项、不同的输入分布)会瞬间蒸发,甚至变成「慢了」。你拿着这个假数字去做架构决策,就是在沙子上盖楼。 ## 所以 sanitizer 不是「调试工具」,是性能测量的可信度地基 @@ -69,17 +70,17 @@ f(INT_MAX) = 0 # INT_MAX+1 回绕成 INT_MIN,INT_MIN > INT_MAX 为假 - **ASan**(AddressSanitizer)管内存错——越界、use-after-free、重复释放。这些正是上面「第三类阴险情况」的元凶,它们让你的 benchmark 偷偷踩到别人的内存。 - **UBSan**(UndefinedBehaviorSanitizer)管语言层 UB——有符号溢出、空指针解引用、错类型 cast、非法 shift。这些正是上面「第一类、第二类」的元凶,它们让编译器有理由把你测的代码改写成空壳。 - **MSan**(MemorySanitizer)管未初始化读——读到的是「随机值」,你测的本质是个随机数发生器。 -- **TSan**(ThreadSanitizer)管并发数据竞争——而数据竞争本身就是 UB。这一条在并发性能测量里特别要命:你的多线程 benchmark 没过 TSan,那串漂亮的吞吐数字毫无意义。TSan 的机制 vol5 已经讲透了,我们这里只取「它为什么是性能地基」这一个视角——并发性能数字的可信度,前提是没有数据竞争。 +- **TSan**(ThreadSanitizer)管并发数据竞争——而数据竞争本身就是 UB。这一条在并发性能测量里特别要命:你的多线程 benchmark 没过 TSan,那串漂亮的吞吐数字毫无意义。TSan 的机制 vol5 已经讲透了,我们这里只取「它为什么是性能地基」这一个视角:并发性能数字的可信度,前提是没有数据竞争。 -把这四条放在一起,结论非常硬:**一个没过 sanitizer 的性能数字,你不知道它测的是什么。** 不是「不够精确」,是「连前提都不成立」。所以本卷的规矩是——任何进入性能对比的代码,先在 sanitizer 下跑干净,再谈数字。 +把这四条放在一起,结论非常硬:**一个没过 sanitizer 的性能数字,你不知道它测的是什么。** 前提都不成立,谈不上精确不精确。所以本卷的规矩是:任何进入性能对比的代码,先在 sanitizer 下跑干净,再谈数字。 ## 这章的 sanitizer 三篇 具体怎么开、怎么读它的报错、怎么和 `-O2` 共处、调试版和上生产的取舍——这些是 sanitizer 三篇的活,这篇只是路口牌,指一下它们各讲什么: -- **「ASan 工具家族与内存安全」**——从 Heartbleed 这个真实灾难说起,拆开 shadow memory 这套机制,实测越界、UAF、全局越界,理清 ASan / LSan / MSan / TSan / UBSan 五兄弟各自的职责和为什么它们大多互斥(不能同时开)。 -- **「Valgrind 与 ASan 对照」**——把 Valgrind 的动态二进制翻译路线和 ASan 的编译期插桩路线摆在一起,讲透两条路在性能、能抓的错、使用门槛上的本质差别。 -- **「Sanitizer 工具链全景」**——从用户态 `-fsanitize=` 一路讲到内核态的 KASAN / KMSAN / UBSAN / KCSAN / KFENCE,讲清「编译期插桩 vs 采样」两条线,以及「调试时开满」和「常驻生产」的分层防御。 +- **「ASan 工具家族与内存安全」**:从 Heartbleed 这个真实灾难说起,拆开 shadow memory 这套机制,实测越界、UAF、全局越界,理清 ASan / LSan / MSan / TSan / UBSan 五兄弟各自的职责和为什么它们大多互斥(不能同时开)。 +- **「Valgrind 与 ASan 对照」**:把 Valgrind 的动态二进制翻译路线和 ASan 的编译期插桩路线摆在一起,讲透两条路在性能、能抓的错、使用门槛上的本质差别。 +- **「Sanitizer 工具链全景」**:从用户态 `-fsanitize=` 一路讲到内核态的 KASAN / KMSAN / UBSAN / KCSAN / KFENCE,讲清「编译期插桩 vs 采样」两条线,以及「调试时开满」和「常驻生产」的分层防御。 读完这三篇,你就具备了「让性能数字可信」的完整工具链。它们是性能卷的地基,不是配角。 diff --git a/documents/vol6-performance/10-asan-family-and-memory-safety.md b/documents/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md similarity index 66% rename from documents/vol6-performance/10-asan-family-and-memory-safety.md rename to documents/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md index 9c149752c..ff72e74c8 100644 --- a/documents/vol6-performance/10-asan-family-and-memory-safety.md +++ b/documents/vol6-performance/ch00-performance-mindset/03-asan-family-and-memory-safety.md @@ -1,45 +1,49 @@ --- -title: "ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型" -description: "从 Heartbleed 说起,拆解 AddressSanitizer 的 shadow memory 三件套,实测 OOB/UAF/全局越界与 UBSan,理清 ASan/LSan/MSan/TSan/UBSan 五兄弟的职责与互斥关系" -chapter: 6 -order: 10 -platform: host +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: 从 Heartbleed 说起,拆解 AddressSanitizer 的 shadow memory 三件套,实测 OOB/UAF/全局越界与 + UBSan,理清 ASan/LSan/MSan/TSan/UBSan 五兄弟的职责与互斥关系 difficulty: advanced -cpp_standard: [11, 14, 17, 20] -reading_time_minutes: 22 +order: 3 +platform: host prerequisites: - - "动态内存管理(new/delete 与智能指针)" - - "并发程序调试技巧(ThreadSanitizer)" +- 动态内存管理(new/delete 与智能指针) +- 并发程序调试技巧(ThreadSanitizer) +reading_time_minutes: 24 related: - - "动态内存管理(new/delete 与智能指针)" - - "并发程序调试技巧(ThreadSanitizer)" - - "C 语言动态内存管理(malloc/free 与 valgrind)" +- 动态内存管理(new/delete 与智能指针) +- 并发程序调试技巧(ThreadSanitizer) +- C 语言动态内存管理(malloc/free 与 valgrind) tags: - - host - - cpp-modern - - advanced - - 内存安全 - - 调试 - - 内存管理 +- host +- cpp-modern +- advanced +- 内存安全 +- 调试 +- 内存管理 +title: ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 --- - # ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 > PS: 这部分内容是笔者读大学期间迁移的笔记,仅经过有限的搜索验证,如果发现存在技术断言不严谨的问题甚至时错误,欢迎报告 Issue! 或者是直接的修复PR! -写过一段时间 C/C++,你大概率被这几类问题反复折磨过:数组多读了一位、释放完的指针被别人又用了一次、同一个 `delete` 调了两遍。这些错误有个共同的恶心之处——它们是**未定义行为**(大名鼎鼎的Undefined Behavior),不是"一定会崩",而是"在 Debug 构建里跑得好好的,到了 Release 或者换了台机器就随机爆炸"。更糟的是,崩的地方往往离真正出错的代码十万八千里,堆栈指向的可能是某个无辜的库函数。 +写过一段时间 C/C++,你大概率被这几类问题反复折磨过:数组多读了一位、释放完的指针被别人又用了一次、同一个 `delete` 调了两遍。这些错误有个共同的恶心之处:它们是**未定义行为**(大名鼎鼎的Undefined Behavior)。不一定崩,在 Debug 构建里跑得好好的,到了 Release 或者换了台机器就随机爆炸。更糟的是,崩的地方往往离真正出错的代码十万八千里,堆栈指向的可能是某个无辜的库函数。 为什么会这样?因为这类 bug 破坏的是内存管理器自己的元数据,等到下一次 `malloc`/`free` 走到那个被踩坏的位置才发作。本卷前面讲性能,这一篇我们换到另一个维度:怎么在 bug 还没酿成线上事故之前,就用工具把它揪出来。主角是 AddressSanitizer(ASan)和它背后那一整套 sanitizer 工具家族。 -先别急着把 ASan 当成一个"加个 flag 就完事"的小工具。它背后的设计——shadow memory、编译期插桩——其实是过去十几年 C/C++ 内存安全领域最重要的工程进展之一,而且它最初被发明出来,就是为了堵住一个让整个互联网心惊肉跳的洞。我们从那个洞讲起。 +先别急着把 ASan 当成一个"加个 flag 就完事"的小工具。它背后的设计(shadow memory、编译期插桩)其实是过去十几年 C/C++ 内存安全领域最重要的工程进展之一,而且它最初被发明出来,就是为了堵住一个让整个互联网心惊肉跳的洞。我们从那个洞讲起。 ## 一切的起点:Heartbleed 与 buffer over-read -2014 年 4 月,CVE-2014-0160 被披露,代号 Heartbleed。这是 OpenSSL 实现里一个听起来人畜无害的特性——TLS 心跳扩展(heartbeat)——里藏的洞。协议很简单:客户端发来一段任意数据,告诉服务器"这段数据有 N 字节,请原样读回来",用来验证连接还活着。 +2014 年 4 月,CVE-2014-0160 被披露,代号 Heartbleed。这是 OpenSSL 实现里一个听起来人畜无害的特性(TLS 心跳扩展,heartbeat)里藏的洞。协议很简单:客户端发来一段任意数据,告诉服务器"这段数据有 N 字节,请原样读回来",用来验证连接还活着。 -漏洞在于,服务器**信任了客户端报上来的长度 N,但没有校验 N 是否真的不超过自己手里那段数据的实际长度**。于是攻击者只要把 N 报得很大(比如 64KB),服务器就会从自己进程内存里"读回" 64KB 给攻击者。读到的可能是别的会话的 TLS 私钥、用户密码、session token——进程内存里挨着那块缓冲区的一切,统统泄漏。 +漏洞在于,服务器**信任了客户端报上来的长度 N,但没有校验 N 是否真的不超过自己手里那段数据的实际长度**。于是攻击者只要把 N 报得很大(比如 64KB),服务器就会从自己进程内存里"读回" 64KB 给攻击者。读到的可能是别的会话的 TLS 私钥、用户密码、session token,进程内存里挨着那块缓冲区的一切,统统泄漏。 -这个 bug 的本质不是越界**写**,而是越界**读**(buffer over-read / over-read)。写越界好歹会破坏数据、容易暴露;读越界安静得多,进程自己不会崩,数据就这么悄无声息地流出去了。ASan 当年被反复拎出来讲,正是因为它属于少数能**稳定检测 over-read**的工具——只要那段越界内存碰到了 ASan 埋下的 redzone,读一下就立刻报错。 +这个 bug 的本质是越界**读**(buffer over-read / over-read),不是越界**写**。写越界好歹会破坏数据、容易暴露;读越界安静得多,进程自己不会崩,数据就这么悄无声息地流出去了。ASan 当年被反复拎出来讲,正是因为它属于少数能**稳定检测 over-read**的工具:只要那段越界内存碰到了 ASan 埋下的 redzone,读一下就立刻报错。 我们用几十行 Modern C++ 复刻一个 Heartbleed 形状的 bug,然后让 ASan 抓现行。这是本篇的王牌演示,后面要反复用到。 @@ -86,22 +90,22 @@ READ of size 64 at 0x72175e1f0028 thread T0 SUMMARY: AddressSanitizer: stack-buffer-overflow oob_read.cpp:11 in read_back ``` -注意两个细节。第一,报错类型是 `stack-buffer-overflow`,发生在 `read_back` 第 11 行——也就是 `return std::string(buf.data(), n);` 那一行,精确到源码位置,这正是为什么编译时必须带 `-g`。第二,ASan 甚至告诉我们栈帧里有两个对象,`buf` 占 `[32, 40)`、`leaked` 占 `[64, 96)`,越界读的位置(offset 40)正好落在两者之间。这种级别的现场信息,是 ASan 区别于"加个断言慢慢找"的根本所在。 +注意两个细节。第一,报错类型是 `stack-buffer-overflow`,发生在 `read_back` 第 11 行,也就是 `return std::string(buf.data(), n);` 那一行,精确到源码位置,这正是为什么编译时必须带 `-g`。第二,ASan 甚至告诉我们栈帧里有两个对象,`buf` 占 `[32, 40)`、`leaked` 占 `[64, 96)`,越界读的位置(offset 40)正好落在两者之间。这种级别的现场信息,是 ASan 区别于"加个断言慢慢找"的根本所在。 ## 所以,ASan 到底做了什么才能这样的呢? ### 第一件:编译期插桩(CTI) -ASan 不是事后分析的 profiler,而是**编译期就改写你的代码**。当你加 `-fsanitize=address`,编译器(GCC 或 Clang 都行)会在每一次内存访问——每一个 `*p`、每一次数组下标、每一次 `memcpy`——前后插入额外的检查指令。这种技术叫**编译期插桩**(compile-time instrumentation,CTI),也叫静态插桩。 +ASan 在**编译期就改写你的代码**,不是事后才分析的 profiler。当你加 `-fsanitize=address`,编译器(GCC 或 Clang 都行)会在每一次内存访问(每一个 `*p`、每一次数组下标、每一次 `memcpy`)前后插入额外的检查指令。这种技术叫**编译期插桩**(compile-time instrumentation,CTI),也叫静态插桩。 -这里先验证一下它真的"动了你的代码"。把上面的 `oob_read.cpp` 不带 ASan 编译一份,带 ASan 编译一份,对比两者的**代码段(.text)大小**——也就是真正塞进二进制里的机器指令: +这里先验证一下它真的"动了你的代码"。把上面的 `oob_read.cpp` 不带 ASan 编译一份,带 ASan 编译一份,对比两者的**代码段(.text)大小**,也就是真正塞进二进制里的机器指令: ```text 普通构建 .text: 2792 字节 ASan 构建 .text: 5736 字节 (+105%) ``` -(本机 GCC 16.1.1,`g++ -std=c++20 -O1 -g`,用 `size` 看 `.text` 段。) 多出来的那一倍,就是编译器在每次内存访问前后塞进去的检查指令。注意这里有个坑:**别拿整个二进制文件的大小来比**——ASan 的运行时库 `libasan.so.8` 是**动态链接**的(`ldd` 能看到它),并没有被打进可执行文件,所以整个文件大小其实只涨了 5% 左右;真正反映插桩量的是 `.text` 代码段,那才是翻倍增长的地方。代价是体积变大、运行变慢——但比起它能抓到的 bug,这点开销在开发阶段几乎可以忽略。CTI 是**编译期**决定的事,所以你必须**编译时**就带上 `-fsanitize=address`,而且**链接时也要带**。如果你只编译主程序时加了、链接某个第三方 `.a` 库时没加,那库内部的内存访问就没被插桩,ASan 对那部分代码就是瞎的。完整流程是: +(本机 GCC 16.1.1,`g++ -std=c++20 -O1 -g`,用 `size` 看 `.text` 段。) 多出来的那一倍,就是编译器在每次内存访问前后塞进去的检查指令。注意这里有个坑:**别拿整个二进制文件的大小来比**:ASan 的运行时库 `libasan.so.8` 是**动态链接**的(`ldd` 能看到它),并没有被打进可执行文件,所以整个文件大小其实只涨了 5% 左右;真正反映插桩量的是 `.text` 代码段,那才是翻倍增长的地方。代价是体积变大、运行变慢,但比起它能抓到的 bug,这点开销在开发阶段几乎可以忽略。CTI 是**编译期**决定的事,所以你必须**编译时**就带上 `-fsanitize=address`,而且**链接时也要带**。如果你只编译主程序时加了、链接某个第三方 `.a` 库时没加,那库内部的内存访问就没被插桩,ASan 对那部分代码就是瞎的。完整流程是: ```bash g++ -std=c++20 -O1 -fsanitize=address -g -c a.cpp -o a.o # 编译带 @@ -128,7 +132,7 @@ Shadow byte legend (one shadow byte represents 8 application bytes): Stack mid redzone: f2 ``` -这就是 1:8 映射的全部语义。`00` 表示这 8 字节都可以访问;`01`–`07` 表示只有前几个字节合法(比如 `03` 表示前 3 字节能访问、后 5 字节不能,用于对齐尾部的部分可访问区域);`fa` 是堆分配周围的 redzone——ASan 在你 `new` 出来的每块内存四周都偷偷塞了一圈"禁入区",你一旦读到 `fa`,就是堆越界;`fd` 是已经 `free` 掉的内存,你一碰就是 use-after-free;`f1`/`f2` 是栈对象的 redzone。 +这就是 1:8 映射的全部语义。`00` 表示这 8 字节都可以访问;`01`–`07` 表示只有前几个字节合法(比如 `03` 表示前 3 字节能访问、后 5 字节不能,用于对齐尾部的部分可访问区域);`fa` 是堆分配周围的 redzone:ASan 在你 `new` 出来的每块内存四周都偷偷塞了一圈"禁入区",你一旦读到 `fa`,就是堆越界;`fd` 是已经 `free` 掉的内存,你一碰就是 use-after-free;`f1`/`f2` 是栈对象的 redzone。 回头看上面那段报错的 shadow dump: @@ -136,26 +140,26 @@ Shadow byte legend (one shadow byte represents 8 application bytes): =>0x72175e1f0000: f1 f1 f1 f1 00[f2]f2 f2 00 00 00 00 f3 f3 f3 f3 ``` -`00` 是 `buf` 本体(8 字节,1 个影子字节),紧跟着的 `[f2]` 就是栈对象之间的 mid redzone。我们越界读的地址恰好落在这个 `f2` 上——ASan 一眼就看出来了。这是 shadow memory 机制能精确到字节级的原因。 +`00` 是 `buf` 本体(8 字节,1 个影子字节),紧跟着的 `[f2]` 就是栈对象之间的 mid redzone。我们越界读的地址恰好落在这个 `f2` 上,ASan 一眼就看出来了。这是 shadow memory 机制能精确到字节级的原因。 ### 第三件:运行时库 + quarantine -光有插桩和影子区还不够,还得有人**填这个账本**。`new`/`delete`、`malloc`/`free` 这些函数,ASan 运行时库(`libasan`)会把它们整个换掉——换成自己的版本。每分配一块内存,运行时就给它在影子区里画上 redzone;每释放一块,就把对应影子区标记成 `fd`。 +光有插桩和影子区还不够,还得有人**填这个账本**。`new`/`delete`、`malloc`/`free` 这些函数,ASan 运行时库(`libasan`)会把它们整个换掉,换成自己的版本。每分配一块内存,运行时就给它在影子区里画上 redzone;每释放一块,就把对应影子区标记成 `fd`。 这里还有个关键设计叫 **quarantine(隔离区)**。`free` 掉的内存,ASan 不会立刻归还给系统重新分配,而是先扔进一个隔离队列里晾着。为什么?因为 use-after-free 这种 bug,如果你 `free` 完马上又分配出去给别人用了,那块内存的影子状态就变回 `00` 了,后面误读就抓不到了。隔离一段时间,保证"已释放"的状态能被后续的误访问撞上。 -不过 quarantine 不是无限的——队列有上限,满了之后旧的已释放内存会按 FIFO 被真正回收。所以 ASan 对 use-after-free 的检测也不是 100%——如果隔离窗口已经滑过去了、内存已经被重新分配,那次误读就抓不到。但配合足够的测试覆盖,绝大多数 UAF 都能被逮住。 +不过 quarantine 不是无限的,队列有上限,满了之后旧的已释放内存会按 FIFO 被真正回收。所以 ASan 对 use-after-free 的检测也不是 100%:如果隔离窗口已经滑过去了、内存已经被重新分配,那次误读就抓不到。但配合足够的测试覆盖,绝大多数 UAF 都能被逮住。 ### 代价:2-4 倍开销,为什么还是值得 三件套加起来,ASan 的典型开销是 **2-4 倍的运行时间减速、3-5 倍的内存开销**(影子区占 1/8,加上 redzone 和 quarantine)。听起来不少,但这是跟谁比的问题。 -传统的内存检测工具 Valgrind(Memcheck)用的是**动态二进制插桩**(DBI)——它不重新编译你的程序,而是在运行时把每一条机器指令翻译成自己的一套中间表示、逐条分析再执行。精度高、不用重新编译,但代价是 20-50 倍的减速。跑一个原本 1 秒的测试,Valgrind 要等半分钟,很多时候根本没法纳入日常 CI。 +传统的内存检测工具 Valgrind(Memcheck)用的是**动态二进制插桩**(DBI):它不重新编译你的程序,而是在运行时把每一条机器指令翻译成自己的一套中间表示、逐条分析再执行。精度高、不用重新编译,但代价是 20-50 倍的减速。跑一个原本 1 秒的测试,Valgrind 要等半分钟,很多时候根本没法纳入日常 CI。 -ASan 把分析成本**前移到了编译期**(CTI),运行时只做查表,所以能把开销压到 2-4 倍。这个量级意味着你可以在开发和 CI 里**常驻**开着 ASan 跑完整测试套件,而不是偶尔想起来才手动跑一次 Valgrind。这是 ASan 相对 Valgrind 最根本的优势——不是更准,而是**用得起**。 +ASan 把分析成本**前移到了编译期**(CTI),运行时只做查表,所以能把开销压到 2-4 倍。这个量级意味着你可以在开发和 CI 里**常驻**开着 ASan 跑完整测试套件,而不是偶尔想起来才手动跑一次 Valgrind。这是 ASan 相对 Valgrind 最根本的优势:**用得起**。 ::: warning 本机没装 Valgrind -这篇的所有 ASan/UBSan 输出都是本机真跑出来的(GCC 16.1.1 / Clang 22,WSL2)。Valgrind 在本机环境里没装(`which valgrind` → not found),所以本篇不贴 Valgrind 实测输出。需要 Valgrind 的同学在 Debian/Ubuntu 上 `apt install valgrind` 即可,用法见 vol1 的 [C 语言动态内存管理](../vol1-fundamentals/c_tutorials/14-dynamic-memory.md) 一文的 valgrind 段。两条命令的本质区别记牢:**ASan 是编译期 `-fsanitize=address`(CTI),Valgrind 是运行时 `valgrind ./prog`(DBI)**。 +这篇的所有 ASan/UBSan 输出都是本机真跑出来的(GCC 16.1.1 / Clang 22,WSL2)。Valgrind 在本机环境里没装(`which valgrind` → not found),所以本篇不贴 Valgrind 实测输出。需要 Valgrind 的同学在 Debian/Ubuntu 上 `apt install valgrind` 即可,用法见 vol1 的 [C 语言动态内存管理](../../vol1-fundamentals/c_tutorials/14-dynamic-memory.md) 一文的 valgrind 段。两条命令的本质区别记牢:**ASan 是编译期 `-fsanitize=address`(CTI),Valgrind 是运行时 `valgrind ./prog`(DBI)**。 ::: ## 工具家族:五个 sanitizer 各管一类 @@ -170,7 +174,7 @@ ASan 其实只是一个家族的成员。这套工具最早是 Google 的工程 | **TSan**(ThreadSanitizer) | `-fsanitize=thread` | 数据竞争(data race)、死锁 | 5-15x 减速 | | **UBSan**(UndefinedBehaviorSanitizer) | `-fsanitize=undefined` | 未定义行为(有符号溢出、空指针解引用、位移越界等) | 可配置,多数子项开销很小 | -五兄弟里,ASan 是主力,日常开发几乎必备;LSan 默认会随 ASan 一起启用(GCC/Clang 在支持的环境下);MSan 只在 Clang 上完整可用、且必须**全程序**都编译成 MSan 版本(连 libc 都要 MSan 版,否则误报满天飞);TSan 专门盯并发,我们在 vol5 的 [并发程序调试技巧](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md) 里专门讲过;UBSan 是个"补刀手",开销小、可以和别的组合。 +五兄弟里,ASan 是主力,日常开发几乎必备;LSan 默认会随 ASan 一起启用(GCC/Clang 在支持的环境下);MSan 只在 Clang 上完整可用、且必须**全程序**都编译成 MSan 版本(连 libc 都要 MSan 版,否则误报满天飞);TSan 专门盯并发,我们在 vol5 的 [并发程序调试技巧](../../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md) 里专门讲过;UBSan 是个"补刀手",开销小、可以和别的组合。 ### ASan 和 TSan 互斥:一条铁律 @@ -181,9 +185,9 @@ $ g++ -std=c++20 -fsanitize=address,thread -g conflict.cpp -o conflict cc1plus: error: '-fsanitize=thread' is incompatible with '-fsanitize=address' ``` -报错直白。这条约束的工程后果是:一个项目的 CI 里,内存错误检测和数据竞争检测得分**两套独立的构建**——一套开 ASan、一套开 TSan,各自跑一遍测试。vol5 的 TSan 篇专门讲过这个"双构建"实践,这里我们记住结论就好。 +报错直白。这条约束的工程后果是:一个项目的 CI 里,内存错误检测和数据竞争检测得分**两套独立的构建**:一套开 ASan、一套开 TSan,各自跑一遍测试。vol5 的 TSan 篇专门讲过这个"双构建"实践,这里我们记住结论就好。 -至于 MSan,它跟 ASan/TSan 都不兼容(它要求所有代码都"干净"地走自己的未初始化追踪),而且只支持 Clang,所以实际项目里用得最少。LSan 和 UBSan 是两个"百搭"——LSan 几乎零开销可以常驻,UBSan 的多数子项也能跟 ASan 一起开。 +至于 MSan,它跟 ASan/TSan 都不兼容(它要求所有代码都"干净"地走自己的未初始化追踪),而且只支持 Clang,所以实际项目里用得最少。LSan 和 UBSan 是两个"百搭":LSan 几乎零开销可以常驻,UBSan 的多数子项也能跟 ASan 一起开。 ## 实测:ASan 抓三类典型错误 @@ -191,7 +195,7 @@ cc1plus: error: '-fsanitize=thread' is incompatible with '-fsanitize=address' ### 堆 use-after-free -智能指针能挡住大部分 UAF,但只要项目里还有裸指针、还有 C 风格 API,这个洞就堵不完。看一个最小例子——把一个 `unique_ptr` 释放后,还拿着它先前吐出来的裸指针去读: +智能指针能挡住大部分 UAF,但只要项目里还有裸指针、还有 C 风格 API,这个洞就堵不完。看一个最小例子:把一个 `unique_ptr` 释放后,还拿着它先前吐出来的裸指针去读: ```cpp // uaf.cpp —— use-after-free @@ -231,7 +235,7 @@ previously allocated by thread T0 here: SUMMARY: AddressSanitizer: heap-use-after-free uaf.cpp:12 in main ``` -这份报告是 ASan 最有价值的地方。它不仅告诉你"第 12 行那次读是 use-after-free",还同时给出**这条内存的两段历史**:在 `uaf.cpp:9` 由 `make_unique` 分配、在 `uaf.cpp:11` 由 `reset` 释放。盯着这两行,bug 的因果链就完整了——这正是 quarantine + redzone 机制的价值:已释放的内存被标记成 `fd` 而不是立刻回收,所以后续误读能撞上。 +这份报告是 ASan 最有价值的地方。它不仅告诉你"第 12 行那次读是 use-after-free",还同时给出**这条内存的两段历史**:在 `uaf.cpp:9` 由 `make_unique` 分配、在 `uaf.cpp:11` 由 `reset` 释放。盯着这两行,bug 的因果链就完整了,这正是 quarantine + redzone 机制的价值:已释放的内存被标记成 `fd` 而不是立刻回收,所以后续误读能撞上。 shadow dump 里那个 `[fd]` 就是铁证: @@ -261,12 +265,12 @@ SUMMARY: AddressSanitizer: global-buffer-overflow global_oob.cpp:5 in main 报错类型明确标成 `global-buffer-overflow`。ASan 把栈、堆、全局三类区域用不同的 redzone 编码区分开(`f1`/`f2` 栈、`fa` 堆、`f9` 全局),所以你能一眼看出越界发生在哪类存储上。 ::: warning 关于「Clang 11 才支持全局 OOB」这个说法 -有些老资料会说"ASan 检测全局变量越界需要 Clang 11 以上"。这个说法的历史背景是:早期 ASan 对全局变量的 redzone 支持不完整,Clang 11 引入了 ODR 指示器(`-fsanitize-address-use-odr-indicator`)等改进才把全局检测做扎实。但**今天**——GCC 8.3+ / Clang 主流版本——对全局越界的检测都是默认开、开箱即用的,本机 GCC 16.1.1 上面这个例子就是默认配置一把抓到的。所以这条"版本门槛"对现在的工具链已经过时,别被老资料带歪。 +有些老资料会说"ASan 检测全局变量越界需要 Clang 11 以上"。这个说法的历史背景是:早期 ASan 对全局变量的 redzone 支持不完整,Clang 11 引入了 ODR 指示器(`-fsanitize-address-use-odr-indicator`)等改进才把全局检测做扎实。但**今天**(GCC 8.3+ / Clang 主流版本)对全局越界的检测都是默认开、开箱即用的,本机 GCC 16.1.1 上面这个例子就是默认配置一把抓到的。所以这条"版本门槛"对现在的工具链已经过时,别被老资料带歪。 ::: ### 泄漏:LSan 在退出时收尾 -最后看内存泄漏。LSan 的工作方式和前几个不一样——它不在运行中报错,而是在 `main` 返回、程序即将退出时,扫描所有还"活着"的堆分配,把没人引用的、没有对应释放的全标出来。我们写一个故意泄漏的最小例子: +最后看内存泄漏。LSan 的工作方式和前几个不一样:它等到 `main` 返回、程序即将退出时,才扫描所有还"活着"的堆分配,把没人引用的、没有对应释放的全标出来。我们写一个故意泄漏的最小例子: ```cpp // leak.cpp —— 故意泄漏 @@ -298,17 +302,17 @@ Direct leak of 16 byte(s) in 1 object(s) allocated from: SUMMARY: AddressSanitizer: 16 byte(s) leaked in 1 allocation(s). ``` -注意:报告是在 `main` 返回**之后**才打出来的——这正是 LSan "退出时收尾"的工作方式。vol1 的 [动态内存管理](../vol1-fundamentals/ch12/02-new-delete.md) 也给过一个等价示例,可对照。 +注意:报告是在 `main` 返回**之后**才打出来的,这正是 LSan "退出时收尾"的工作方式。vol1 的 [动态内存管理](../../vol1-fundamentals/ch12/02-new-delete.md) 也给过一个等价示例,可对照。 ::: warning LSan 的"静默退出"陷阱 -LSan 在主流 Linux 环境(GCC 16.1.1 / Clang 22)下默认随 ASan 启用,上面这个例子本机能稳定抓到。但要小心一个真实陷阱:**泄漏只有在进程正常退出时才会被扫描**。如果你的程序是被 `SIGKILL` 干掉的、或调了 `_exit` 绕过 `atexit` 钩子、或在某些容器/沙箱里 LSan 的退出钩子没跑起来,泄漏报告就会**静默消失**——程序看起来"没报错",但其实是 LSan 压根没机会扫描。 +LSan 在主流 Linux 环境(GCC 16.1.1 / Clang 22)下默认随 ASan 启用,上面这个例子本机能稳定抓到。但要小心一个真实陷阱:**泄漏只有在进程正常退出时才会被扫描**。如果你的程序是被 `SIGKILL` 干掉的、或调了 `_exit` 绕过 `atexit` 钩子、或在某些容器/沙箱里 LSan 的退出钩子没跑起来,泄漏报告就会**静默消失**:程序看起来"没报错",但其实是 LSan 压根没机会扫描。 排查办法:确认进程是正常 return 退出的;需要时显式 `ASAN_OPTIONS=detect_leaks=1` 强制开;长期运行的服务(根本不退出)用不了 LSan 这种"退出时收尾"的模型,得改用 Valgrind massif 或堆采样。别假设 LSan "没报就是没漏"。 ::: ## UBSan:把"未定义行为"从沉默变成报错 -讲完 ASan 家族的主力,我们看补刀手 UBSan。C/C++ 有个让人血压拉满的特性:**未定义行为(UB)**。编译器对 UB 的态度是"既然标准没规定会发生什么,我就当它不会发生,放心优化"。后果是,有符号整数溢出、位移越界、空指针解引用这些错误,程序**经常看起来跑得好好的**——直到某天开了 `-O2`、换了编译器版本,优化器基于"这段不会溢出"的假设做了激进变换,程序突然算出离谱的结果。 +讲完 ASan 家族的主力,我们看补刀手 UBSan。C/C++ 有个让人血压拉满的特性:**未定义行为(UB)**。编译器对 UB 的态度是"既然标准没规定会发生什么,我就当它不会发生,放心优化"。后果是,有符号整数溢出、位移越界、空指针解引用这些错误,程序**经常看起来跑得好好的**,直到某天开了 `-O2`、换了编译器版本,优化器基于"这段不会溢出"的假设做了激进变换,程序突然算出离谱的结果。 UBSan 的思路是:在每个可能产生 UB 的操作旁边插一个运行时检查,一旦真的发生 UB,立刻打印 `runtime error: ...` 报告(默认不中止程序,可以配置成中止)。开销很小,很多子项可以和 ASan 一起常驻。 @@ -349,13 +353,13 @@ ubsan.cpp:17:42: runtime error: shift exponent 32 is too large for 32-bit type ' - **算术类**:有符号整数溢出/下溢、除以零; - **位移类**:位移量为负或大于等于位宽、左移把符号位改掉; - **内存/指针类**:空指针解引用、对齐错误的内存访问、对象大小不匹配(通过错误类型的指针访问); -- **数组类**:下标越界(`-fsanitize=bounds`,这个和 ASan 的越界检测有重叠但侧重不同——ASan 看 redzone,UBSan 看编译期已知的数组尺寸)。 +- **数组类**:下标越界(`-fsanitize=bounds`,这个和 ASan 的越界检测有重叠但侧重不同:ASan 看 redzone,UBSan 看编译期已知的数组尺寸)。 UBSan 的开销取决于你开了哪些子项。`-fsanitize=undefined` 是一组默认子项的集合,多数都很轻;真正贵的是 `-fsanitize=integer`(无符号溢出也算,开销大、误报多,生产慎用)。日常建议:`-fsanitize=undefined` 跟 ASan 一起开,成本低、收益高。 ## 选型:面对一个内存 bug,该上哪个工具 -到这里五兄弟都亮相过了。问题来了——当你真坐到一个诡异的 bug 面前,该按什么顺序选工具?我们按"症状"来定位: +到这里五兄弟都亮相过了。问题来了:当你真坐到一个诡异的 bug 面前,该按什么顺序选工具?我们按"症状"来定位: - **一跑就崩 / 段错误 / 偶发崩溃**:先开 ASan 跑复现测试。越界、UAF、double-free 这三类是段错误最常见的原因,ASan 一把抓。 - **结果偶发性错误 / 跨函数的诡异值**:怀疑 UAF 或数据竞争。先 ASan 排除 UAF,ASan 没报再单独构建一份 TSan 版本查数据竞争(别忘了两者互斥,不能同时开)。 @@ -363,31 +367,21 @@ UBSan 的开销取决于你开了哪些子项。`-fsanitize=undefined` 是一组 - **读了"看起来正常"的垃圾值、行为依赖未初始化**:MSan(注意只 Clang、要全程序编译)。 - **进程吃内存越来越多 / 怀疑泄漏**:LSan(退出时报告),或长期运行的服务用 Valgrind massif / 堆采样的方式。 -一个工程化的实践是:**CI 里常驻两套构建**——`ASan+UBSan` 一套、`TSan` 一套,每次提交都跑。开销可以接受(ASan+UBSan 在 2-4 倍量级),换来的是把"上线后偶发崩溃"这类最贵的 bug,在还没出门的时候摁住。 +一个工程化的实践是:**CI 里常驻两套构建**:`ASan+UBSan` 一套、`TSan` 一套,每次提交都跑。开销可以接受(ASan+UBSan 在 2-4 倍量级),换来的是把"上线后偶发崩溃"这类最贵的 bug,在还没出门的时候摁住。 ::: warning ASan 不是银弹 ASan 很强,但它有几个绕不开的限制,必须心里有数。 -第一,**它只能抓"实际执行到"的路径**。CTI 是运行时检测,代码没跑到就不会触发检查。如果你的测试覆盖率不够、某条越界路径从来没被触发过,ASan 抓不到——这正是为什么 ASan 要配合好的测试用例、甚至 fuzzing(模糊测试)一起用,fuzzing 负责把罕见路径跑出来,ASan 负责在这些路径上一旦出错就报。 +第一,**它只能抓"实际执行到"的路径**。CTI 是运行时检测,代码没跑到就不会触发检查。如果你的测试覆盖率不够、某条越界路径从来没被触发过,ASan 抓不到,这正是为什么 ASan 要配合好的测试用例、甚至 fuzzing(模糊测试)一起用,fuzzing 负责把罕见路径跑出来,ASan 负责在这些路径上一旦出错就报。 -第二,**只抓内存类错误**。逻辑错误(算错了)、并发错误(数据竞争)、整数溢出这种 UB,ASan 不管——后者归 UBSan,前者归 TSan。别指望一个 flag 解决所有问题。 +第二,**只抓内存类错误**。逻辑错误(算错了)、并发错误(数据竞争)、整数溢出这种 UB,ASan 不管:后者归 UBSan,前者归 TSan。别指望一个 flag 解决所有问题。 第三,**生产环境别开**。2-4 倍减速和额外内存,在生产负载下是灾难。ASan/UBSan/TSan 都是**开发/测试/CI 阶段**的工具,发布构建里一定要去掉这些 flag。 第四,**它有假阳性边界**。某些自定义的栈展开机制(`swapcontext`、`vfork`)会让 ASan 的影子区判断出错,报假阳性。报告里那句 `HINT: this may be a false positive if your program uses some custom stack unwind mechanism` 就是在提醒这个。 ::: -## 小结 - -我们这一篇从 Heartbleed 那个让全世界心惊的 over-read 洞出发,把 ASan 工具家族拆了个底朝天。几个关键结论收一下: - -- **ASan 三件套**:编译期插桩(CTI)改写每次内存访问、shadow memory 用 1:8 影子字节记录每 8 字节内存的可访问状态、运行时库替换 `new`/`delete` 并用 quarantine 隔离已释放内存。开销 2-4 倍减速,远低于 Valgrind 的 20-50 倍,所以能在 CI 里常驻。 -- **shadow memory 的编码**:`00` 可访问、`01`-`07` 部分可访问、`fa` 堆 redzone、`fd` 已释放、`f1`/`f2` 栈 redzone、`f9` 全局 redzone。ASan 报告末尾的 shadow dump 就是这套编码的直观呈现。 -- **工具家族**:ASan(越界/UAF)、LSan(泄漏)、MSan(未初始化读,Clang only)、TSan(数据竞争)、UBSan(UB)。**ASan 和 TSan 互斥**,CI 里分两套构建。 -- **UBSan 是补刀手**:开销小、可和 ASan 共存,把有符号溢出、位移越界、空指针解引用这些"沉默的 UB"变成显式 `runtime error`。 -- **选型口诀**:段错误先 ASan,偶发错误 ASan 排 UAF 再 TSan 查竞争,算术离谱上 UBSan,内存增长查 LSan。生产环境一律关闭。 - -真正的内存安全不是靠工具抓出来的,是靠 RAII、智能指针、`std::span`、范围 `for` 这些**从语法层面就让你写不出越界和悬空**的手段守住的——那些是 vol1 和 vol3 的主题。ASan 这套工具的价值在于:在你还没把所有裸指针都替换掉、在第三方 C 库还没被现代封装包住的过渡期,它是那道"最后一道防线",让那些潜伏的内存 bug 在开发阶段就显形,而不是拖到线上凌晨三点炸给你看。 +真正的内存安全要靠 RAII、智能指针、`std::span`、范围 `for` 这些**从语法层面就让你写不出越界和悬空**的手段守住,那些是 vol1 和 vol3 的主题;ASan 这套工具的价值在于过渡期:在你还没把所有裸指针都替换掉、第三方 C 库还没被现代封装包住的时候,它是那道"最后一道防线",让潜伏的内存 bug 在开发阶段就显形,别拖到线上凌晨三点炸给你看。 ## 参考资源 diff --git a/documents/vol6-performance/11-memory-safety-asan-valgrind.md b/documents/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md similarity index 64% rename from documents/vol6-performance/11-memory-safety-asan-valgrind.md rename to documents/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md index 92cc6708e..1023e3819 100644 --- a/documents/vol6-performance/11-memory-safety-asan-valgrind.md +++ b/documents/vol6-performance/ch00-performance-mindset/04-memory-safety-asan-valgrind.md @@ -1,49 +1,49 @@ --- -title: Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 -description: 把 Valgrind 五件套(memcheck/callgrind/cachegrind/helgrind+drd/massif)的职责拆开,用 ASan 真实编译运行六类经典内存错误,讲透「动态二进制翻译」与「编译期影子内存插桩」两条路线的本质差别 -chapter: 6 -order: 11 -platform: host -difficulty: advanced +chapter: 0 cpp_standard: - - 11 - - 14 - - 17 - - 20 -tags: - - host - - cpp-modern - - advanced - - 内存安全 - - 调试 - - 内存管理 -reading_time_minutes: 28 +- 11 +- 14 +- 17 +- 20 +description: 把 Valgrind 五件套(memcheck/callgrind/cachegrind/helgrind+drd/massif)的职责拆开,用 + ASan 真实编译运行六类经典内存错误,讲透「动态二进制翻译」与「编译期影子内存插桩」两条路线的本质差别 +difficulty: advanced +order: 4 +platform: host prerequisites: - - 动态内存管理(new/delete 与智能指针) - - C 语言动态内存(malloc/free 与 valgrind 速览) +- 动态内存管理(new/delete 与智能指针) +- C 语言动态内存(malloc/free 与 valgrind 速览) +reading_time_minutes: 27 related: - - ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 - - 并发程序调试技巧(TSan / Helgrind 深入) - - 动态内存管理 +- ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 +- 并发程序调试技巧(TSan / Helgrind 深入) +- 动态内存管理 +tags: +- host +- cpp-modern +- advanced +- 内存安全 +- 调试 +- 内存管理 +title: Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 --- - # Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 > PS: 这部分内容由笔者大学期间的笔记迁移而来,关键结论均已用本机 GCC 16.1.1 + valgrind 3.25.1 实编实跑核对过;若仍有疏漏,欢迎 Issue 或 PR。 -先说一个我们大概都干过的事:一段 C++ 代码本地跑得好好的,一上线就偶发崩溃,或者内存 RSS 一路涨到被 OOM Killer 收掉。你回头去翻代码,`new` 配 `delete` 看着都对,越界也就差那么一两个字节——光读代码根本读不出问题。这种 bug,肉眼 debug 是没希望的,必须靠工具去「看见」内存的每一次访问。 +先说一个我们大概都干过的事:一段 C++ 代码本地跑得好好的,一上线就偶发崩溃,或者内存 RSS 一路涨到被 OOM Killer 收掉。你回头去翻代码,`new` 配 `delete` 看着都对,越界也就差那么一两个字节,光读代码根本读不出问题。这种 bug,肉眼 debug 是没希望的,必须靠工具去「看见」内存的每一次访问。 -这篇我们要做的,是把抓内存错误的工具按「实现路线」分成两大派,把它们拆开跑一遍。一派是 **Valgrind**——老牌的、在程序外面套一层「虚拟 CPU」去解释执行的 JIT 方案;另一派是 **AddressSanitizer(ASan)**——编译期就把检查代码插进你程序里、靠「影子内存」记账的方案。源头的旧笔记只讲了 Valgrind,对 ASan 一字未提,但这恰恰是现在工程里更常用的那条路。这篇就把这个缺口补上,并把两条路线摆在一起对照。 +这篇我们要做的,是把抓内存错误的工具按「实现路线」分成两大派,把它们拆开跑一遍。一派是 **Valgrind**:老牌的、在程序外面套一层「虚拟 CPU」去解释执行的 JIT 方案;另一派是 **AddressSanitizer(ASan)**:编译期就把检查代码插进你程序里、靠「影子内存」记账的方案。源头的旧笔记只讲了 Valgrind,对 ASan 一字未提,但这恰恰是现在工程里更常用的那条路。这篇就把这个缺口补上,并把两条路线摆在一起对照。 ## 一、两类内存错误,和「为什么读代码读不出来」 在动手用工具之前,先把要抓的「敌人」分清楚。内存错误大致两类,抓它们的难度天差地别: -**第一类:确定性的越界 / use-after-free / double-free。** 这类错误的特征是「访问了一块不该访问的地址」。它危险,但相对好抓——只要工具能标记「哪块内存是合法的、哪块不是」,越界那一刻就能当场报出来。`char buf[8]; buf[8] = 'x';` 这种 off-by-one、`free(p); return *p;` 这种悬垂指针,都属于这一类。 +**第一类:确定性的越界 / use-after-free / double-free。** 这类错误的特征是「访问了一块不该访问的地址」。它危险,但相对好抓:只要工具能标记「哪块内存是合法的、哪块不是」,越界那一刻就能当场报出来。`char buf[8]; buf[8] = 'x';` 这种 off-by-one、`free(p); return *p;` 这种悬垂指针,都属于这一类。 -**第二类:未初始化读取 / 内存泄漏。** 这类更阴险。未初始化读取是「地址合法、但值是垃圾」——程序不会崩,只是悄悄算错;内存泄漏是「地址一直合法、只是永远不归还」——程序也不崩,只是 RSS 慢慢涨。这俩你不能靠「合法地址表」抓,得靠另一套机制:Valgrind 给每个字节维护「这个值是不是已经初始化过」的标记,ASan 的泄漏检测(LSan)则在程序退出时扫一遍堆,看还有没有「分配了但没人指着」的块。 +**第二类:未初始化读取 / 内存泄漏。** 这类更阴险。未初始化读取是「地址合法、但值是垃圾」,程序不会崩,只是悄悄算错;内存泄漏是「地址一直合法、只是永远不归还」,程序也不崩,只是 RSS 慢慢涨。这俩你不能靠「合法地址表」抓,得靠另一套机制:Valgrind 给每个字节维护「这个值是不是已经初始化过」的标记,ASan 的泄漏检测(LSan)则在程序退出时扫一遍堆,看还有没有「分配了但没人指着」的块。 -读代码读不出来的根本原因,是这两类错误都**取决于运行时的内存状态**,而不取决于代码的字面写法。你光看 `*p`,根本不知道这一刻 `p` 指的内存是活的还是死的、是初始化过的还是垃圾。这正是为什么我们需要工具去「记录」每一次分配、每一次释放、每一次读写——把运行时的内存状态变成一份事后可查的账本。 +读代码读不出来的根本原因,是这两类错误都**取决于运行时的内存状态**,而不取决于代码的字面写法。你光看 `*p`,根本不知道这一刻 `p` 指的内存是活的还是死的、是初始化过的还是垃圾。这正是为什么我们需要工具去「记录」每一次分配、每一次释放、每一次读写,把运行时的内存状态变成一份事后可查的账本。 那「记录」这件事,Valgrind 和 ASan 是两条完全不同的实现路线。先把结论摆前面,后面再逐个拆。 @@ -64,41 +64,41 @@ related: Valgrind 本质上是一个**动态二进制翻译(dynamic binary translation, DBT)框架**。它不是个普通的检测库,而是把你的程序整个塞进一个「虚拟 CPU」里跑。你敲 `valgrind ./myprog`,真实发生的事是:Valgrind 拦截你的每一条机器指令,把它**即时翻译**成「干原来的活 + 顺带记录内存状态」的一串新指令,然后才执行。所以你的程序不是直接在 CPU 上跑的,而是在 Valgrind 的核心(core)里被「解释」着跑。 -这就是它那句著名的副作用的来源——**慢 20 到 50 倍**,内存占用翻倍以上。Valgrind 官方手册原话就写着: +这就是它那句著名的副作用的来源:**慢 20 到 50 倍**,内存占用翻倍以上。Valgrind 官方手册原话就写着: > Programs running under Valgrind run significantly more slowly, and use much more memory -- e.g. more than twice as much as normal under the Memcheck tool. 换算一下:一个跑 1 秒的程序,塞进 memcheck 可能要跑半分钟。所以 Valgrind 不是给你做日常开发时挂着的,是给你「这程序真的有内存 bug,我专门拿一段时间来揪它」用的。 -这个 JIT 解释的架构有个巨大的好处,也是 Valgrind 至今没被淘汰的根本原因:**不用重新编译**。你手头有一个十年前的、连源码都找不全的二进制,怀疑它泄漏——`valgrind ./老古董` 一敲就能跑。ASan 做不到这点,ASan 必须从源码重新编一遍。这是两条路线最硬的差别。 +这个 JIT 解释的架构有个巨大的好处,也是 Valgrind 至今没被淘汰的根本原因:**不用重新编译**。你手头有一个十年前的、连源码都找不全的二进制,怀疑它泄漏,`valgrind ./老古董` 一敲就能跑。ASan 做不到这点,ASan 必须从源码重新编一遍。这是两条路线最硬的差别。 ### 2.2 五件套:一个框架,五个工具 Valgrind 的精髓是「框架 + 工具」。core 负责翻译和调度,具体「记什么、报什么」交给可插拔的 tool。`--tool=` 选哪个,就是选哪副「检查眼镜」。让我们瞧瞧,可以看到手册里列的核心工具有这些: -**Memcheck**——内存错误检测器,Valgrind 的默认工具,也是绝大多数人说的「用 Valgrind 查内存」时实际用的那个。它抓的全集是(引自手册 4.1):访问不该访问的内存(堆块越界、栈顶越界、释放后访问)、使用未初始化的值、错误的释放(double-free、`malloc` 配 `delete` 这类不匹配)、`memcpy` 源目的重叠、传给分配函数「可疑」的负数 size、`realloc` 传 0、对齐值不是 2 的幂、以及内存泄漏。一句话:memcheck 把 C/C++ 程序里最常见的内存错误几乎一网打尽。 +**Memcheck**:内存错误检测器,Valgrind 的默认工具,也是绝大多数人说的「用 Valgrind 查内存」时实际用的那个。它抓的全集是(引自手册 4.1):访问不该访问的内存(堆块越界、栈顶越界、释放后访问)、使用未初始化的值、错误的释放(double-free、`malloc` 配 `delete` 这类不匹配)、`memcpy` 源目的重叠、传给分配函数「可疑」的负数 size、`realloc` 传 0、对齐值不是 2 的幂、以及内存泄漏。一句话:memcheck 把 C/C++ 程序里最常见的内存错误几乎一网打尽。 -**Callgrind**——调用图 + 缓存/分支预测 profiler。它不需要你在编译时加特殊选项(但推荐 `-g`),运行结束时把分析数据写进一个文件,再用 `callgrind_annotate` 转成人能读的格式。定位「哪个函数被调了多少次、调用关系长啥样」用。 +**Callgrind**:调用图 + 缓存/分支预测 profiler。它不需要你在编译时加特殊选项(但推荐 `-g`),运行结束时把分析数据写进一个文件,再用 `callgrind_annotate` 转成人能读的格式。定位「哪个函数被调了多少次、调用关系长啥样」用。 -**Cachegrind**——缓存 profiler。它模拟 CPU 的 I1/D1/L2 缓存,精确指出程序里 cache miss 和命中的位置,能给你每行代码、每个函数、每个模块产生了多少次 miss、多少条指令。想压缓存性能用它。 +**Cachegrind**:缓存 profiler。它模拟 CPU 的 I1/D1/L2 缓存,精确指出程序里 cache miss 和命中的位置,能给你每行代码、每个函数、每个模块产生了多少次 miss、多少条指令。想压缓存性能用它。 -**Helgrind 和 DRD**——这俩都是**线程错误检测器**,抓数据竞争、锁顺序不一致、POSIX 线程 API 误用。源笔记把 Helgrind 写成「仍然处于实验阶段」,这个说法**早就过时了**——2026 年的官方手册里 Helgrind 和 DRD 都是正式列出的稳定工具,各有独立的章节(手册第 7、8 章),不是实验功能。顺带提一句:源笔记只提了 Helgrind,**漏了 DRD**——它俩目的相同(抓线程 bug)但算法不同,DRD 通常更快、对某些场景(比如大量小对象、Boost.Thread、OpenMP)支持更好。线程错误这块我在卷五的[并发程序调试技巧](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md)里专门讲过 TSan/Helgrind 的实战,这篇不重复,记住「线程类 bug 找 helgrind/drd、或更现代的 TSan」就行。 +**Helgrind 和 DRD**:这俩都是**线程错误检测器**,抓数据竞争、锁顺序不一致、POSIX 线程 API 误用。源笔记把 Helgrind 写成「仍然处于实验阶段」,这个说法**早就过时了**,2026 年的官方手册里 Helgrind 和 DRD 都是正式列出的稳定工具,各有独立的章节(手册第 7、8 章),不是实验功能。顺带提一句:源笔记只提了 Helgrind,**漏了 DRD**,它俩目的相同(抓线程 bug)但算法不同,DRD 通常更快、对某些场景(比如大量小对象、Boost.Thread、OpenMP)支持更好。线程错误这块我在卷五的[并发程序调试技巧](../../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md)里专门讲过 TSan/Helgrind 的实战,这篇不重复,记住「线程类 bug 找 helgrind/drd、或更现代的 TSan」就行。 -**Massif**——堆 profiler。测程序在堆上到底吃了多少内存,给你堆块、堆管理结构、栈的增长曲线。想给程序「瘦身」、找 RSS 大户用它。 +**Massif**:堆 profiler。测程序在堆上到底吃了多少内存,给你堆块、堆管理结构、栈的增长曲线。想给程序「瘦身」、找 RSS 大户用它。 -> **一个容易被忽略的分工点**:memcheck 抓「对错」(这块内存能不能访问、有没有初始化),callgrind/cachegrind/massif 抓「快慢/多少」(性能和用量)。新人常把它们混为一谈,以为 Valgrind 就是查内存泄漏的——其实那只是 memcheck 一个工具的活。性能分析那几个工具(callgrind/cachegrind/massif)和 ASan 完全不在一个赛道,ASan 不碰性能 profiling。 +> **一个容易被忽略的分工点**:memcheck 抓「对错」(这块内存能不能访问、有没有初始化),callgrind/cachegrind/massif 抓「快慢/多少」(性能和用量)。新人常把它们混为一谈,以为 Valgrind 就是查内存泄漏的,其实那只是 memcheck 一个工具的活。性能分析那几个工具(callgrind/cachegrind/massif)和 ASan 完全不在一个赛道,ASan 不碰性能 profiling。 ### 2.3 memcheck 的双表原理:A-bit 与 V-bit memcheck 凭什么能抓住那么多种内存错误?关键就在它维护了两张覆盖整个进程地址空间的「影子表」。手册 4.5 节讲得很清楚: -**Valid-Address 表(A-bit)。** 进程地址空间的每一个字节,都对应 1 个 bit,记录「这个地址当前能不能被读写」。malloc 出来一块、A-bit 就把那几个字节标记成「有效」;free 掉、标记翻回「无效」。当指令要去读写某个字节时,先查它的 A-bit——如果显示无效,就是非法访问,memcheck 当场报错。这层抓住了:越界、use-after-free、访问未分配区域。 +**Valid-Address 表(A-bit)。** 进程地址空间的每一个字节,都对应 1 个 bit,记录「这个地址当前能不能被读写」。malloc 出来一块、A-bit 就把那几个字节标记成「有效」;free 掉、标记翻回「无效」。当指令要去读写某个字节时,先查它的 A-bit,如果显示无效,就是非法访问,memcheck 当场报错。这层抓住了:越界、use-after-free、访问未分配区域。 -**Valid-Value 表(V-bit)。** 进程地址空间的每一个字节,对应 8 个 bit;CPU 的每个寄存器也对应一个 bit 向量。它们记录「这个值是不是已经被初始化过了」。malloc 出来的内存,V-bit 全是「未初始化」;一旦有指令往里写了确定的值,对应字节的 V-bit 翻成「已初始化」。关键设计是:**V-bit 会跟着值「传播」**——你把一个未初始化的值从内存读进寄存器,V-bit 也跟着搬到寄存器里;你拿它做运算,结果的 V-bit 也是「未初始化」。但 memcheck 不会一读到未初始化值就报,它只在「这个值被拿去影响程序输出、或被用来生成地址」的那一刻才报。这个延迟是有意为之的——避免满屏误报。 +**Valid-Value 表(V-bit)。** 进程地址空间的每一个字节,对应 8 个 bit;CPU 的每个寄存器也对应一个 bit 向量。它们记录「这个值是不是已经被初始化过了」。malloc 出来的内存,V-bit 全是「未初始化」;一旦有指令往里写了确定的值,对应字节的 V-bit 翻成「已初始化」。关键设计是:**V-bit 会跟着值「传播」**,你把一个未初始化的值从内存读进寄存器,V-bit 也跟着搬到寄存器里;你拿它做运算,结果的 V-bit 也是「未初始化」。但 memcheck 不会一读到未初始化值就报,它只在「这个值被拿去影响程序输出、或被用来生成地址」的那一刻才报。这个延迟是有意为之的,避免满屏误报。 把两张表合起来看就明白了:A-bit 管「地址合不合法」,V-bit 管「值干不干净」。前者抓越界/UAF,后者抓未初始化读取。double-free 和 alloc-dealloc 不匹配则靠 memcheck 自己维护的「这块内存是用什么分配器申请的」账本去比对。 -这套「每字节都记账」的机制,代价就是前面说的内存翻倍——A-bit 和 V-bit 本身就要占地方。 +这套「每字节都记账」的机制,代价就是前面说的内存翻倍,A-bit 和 V-bit 本身就要占地方。 ## 三、ASan:编译期插桩 + 影子内存 @@ -106,11 +106,11 @@ memcheck 凭什么能抓住那么多种内存错误?关键就在它维护了 ASan 的实现路线和 Valgrind 正好反过来。它**不**在程序外面套虚拟 CPU,而是**在编译的时候**就把检查代码插进你的程序里。你加 `-fsanitize=address`,编译器就会在你每一次读/写内存的前后,插一小段代码:这段代码会查一张「影子内存(shadow memory)」表,判断这次访问合不合法,不合法就报错并 abort。 -所以 ASan 的检查是「程序自己查自己」,而不是「外面的虚拟 CPU 替它查」。这就解释了两条路线开销的巨大差距:ASan 只在被插桩的那几次访存上多花几条指令,没有「翻译整条指令流」的成本,所以**只慢约 2 倍**(Valgrind 是 20~50 倍);代价是必须重新编译,且检查只覆盖被插桩的代码——动态加载的、没带 ASan 编译的第三方 .so,它管不到(Valgrind 能,因为它在指令层全盘拦截)。 +所以 ASan 的检查是「程序自己查自己」,而不是「外面的虚拟 CPU 替它查」。这就解释了两条路线开销的巨大差距:ASan 只在被插桩的那几次访存上多花几条指令,没有「翻译整条指令流」的成本,所以**只慢约 2 倍**(Valgrind 是 20~50 倍);代价是必须重新编译,且检查只覆盖被插桩的代码,动态加载的、没带 ASan 编译的第三方 .so,它管不到(Valgrind 能,因为它在指令层全盘拦截)。 ### 3.2 影子内存:8 字节 → 1 字节的编码 -ASan 的核心机制是影子内存(shadow memory 的完整拆解见本卷[ASan 工具家族](./10-asan-family-and-memory-safety.md),那里还讲了它当年怎么堵 Heartbleed 这种越界读漏洞)。它把进程的整个地址空间按 8 字节一组映射到一张影子表里——每 8 个应用字节对应 1 个影子字节。那个影子字节的值有明确含义,我直接把本机跑出来的图例贴给你(后面那段输出是真实的): +ASan 的核心机制是影子内存(shadow memory 的完整拆解见本卷[ASan 工具家族](./03-asan-family-and-memory-safety.md),那里还讲了它当年怎么堵 Heartbleed 这种越界读漏洞)。它把进程的整个地址空间按 8 字节一组映射到一张影子表里,每 8 个应用字节对应 1 个影子字节。那个影子字节的值有明确含义,我直接把本机跑出来的图例贴给你(后面那段输出是真实的): ```text Shadow byte legend (one shadow byte represents 8 application bytes): @@ -137,12 +137,12 @@ Shadow byte legend (one shadow byte represents 8 application bytes): 翻译一下这套编码的精妙之处: - 影子字节是 `00`:这 8 字节全部可访问; -- 是 `01`~`07`:只有前 N 字节可访问,剩下的是越界红区——这正是 ASan 抓 off-by-one 的原理,它在每个堆块、栈帧、全局变量周围都铺了一圈「红区」,红区的影子字节标记成 `fa`/`f9` 等,你一踩进红区,插桩代码查影子字节发现不是「可访问」,立刻报错; -- 是 `fd`:这块内存已经 free 了——再访问就是 use-after-free,当场抓住。 +- 是 `01`~`07`:只有前 N 字节可访问,剩下的是越界红区。这正是 ASan 抓 off-by-one 的原理,它在每个堆块、栈帧、全局变量周围都铺了一圈「红区」,红区的影子字节标记成 `fa`/`f9` 等,你一踩进红区,插桩代码查影子字节发现不是「可访问」,立刻报错; +- 是 `fd`:这块内存已经 free 了,再访问就是 use-after-free,当场抓住。 -也就是说,ASan 不是像 memcheck 那样「逐字节记账地址合法性」,而是「在合法区域外围铺红区,用红区来界定边界」。这套机制对越界和 UAF 极其有效,但**它没有 V-bit**——所以 ASan 抓不了未初始化读取。这块缺口得用 MSan(MemorySanitizer,`-fsanitize=memory`)补,而 MSan 只有 Clang 实现,**GCC 到 16.1.1 都不支持 `-fsanitize=memory`**(本机实测 `unrecognized argument`)。这是 ASan 路线相对 memcheck 的一个真实短板。 +也就是说,ASan 走的是另一条路:memcheck 逐字节记账地址合法性,ASan 则在合法区域外围铺红区,靠红区来界定边界。这套机制对越界和 UAF 极其有效,但**它没有 V-bit**,所以 ASan 抓不了未初始化读取。这块缺口得用 MSan(MemorySanitizer,`-fsanitize=memory`)补,而 MSan 只有 Clang 实现,**GCC 到 16.1.1 都不支持 `-fsanitize=memory`**(本机实测 `unrecognized argument`)。这是 ASan 路线相对 memcheck 的一个真实短板。 -> **踩坑预警**:ASan 和别的 sanitizer 是「一次只能开一类」的关系。`-fsanitize=address` 和 `-fsanitize=thread`(TSan)**不能同时开**——它们对影子内存的布局假设不同,混用会直接报错或行为异常。所以抓内存错误时开 ASan,抓并发数据竞争时单独开 TSan,别想着「一把梭」。线程错误该怎么查,见[卷五的并发调试篇](../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md)。 +> **踩坑预警**:ASan 和别的 sanitizer 是「一次只能开一类」的关系。`-fsanitize=address` 和 `-fsanitize=thread`(TSan)**不能同时开**:它们对影子内存的布局假设不同,混用会直接报错或行为异常。所以抓内存错误时开 ASan,抓并发数据竞争时单独开 TSan,别想着「一把梭」。线程错误该怎么查,见[卷五的并发调试篇](../../vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md)。 ## 四、上手跑一跑:六类经典错误,ASan 真实输出 @@ -228,7 +228,7 @@ $ ./cases 1 uninit=-1094795586 ``` -**ASan 一声不吭**,程序正常返回了一个垃圾值(`-1094795586`)。这就是前面说的短板:这块内存地址是合法的(malloc 来的),ASan 的影子内存里它标成「可访问」,没有 V-bit 去判断「这个值有没有被初始化过」。这块错误 memcheck 能抓(靠 V-bit),ASan 抓不到——要抓得换 MSan(`-fsanitize=memory`,Clang 专属)。这是两条路线一个**实质性的能力差异**,不是谁更强,是各管一摊。 +**ASan 一声不吭**,程序正常返回了一个垃圾值(`-1094795586`)。这就是前面说的短板:这块内存地址是合法的(malloc 来的),ASan 的影子内存里它标成「可访问」,没有 V-bit 去判断「这个值有没有被初始化过」。这块错误 memcheck 能抓(靠 V-bit),ASan 抓不到,要抓得换 MSan(`-fsanitize=memory`,Clang 专属)。这是两条路线一个**实质性的能力差异**,不是谁更强,是各管一摊。 ### 4.2 use-after-free —— 红区当场咬住 @@ -257,7 +257,7 @@ previously allocated by thread T0 here: SUMMARY: AddressSanitizer: heap-use-after-free /tmp/asand/cases.cpp:20 in case_uaf() ``` -(上面把 build-id 等无关行省略了,关键信息全在。)你看 ASan 给了三段信息:**这次非法读发生在哪**(`case_uaf()` 第 20 行的 `return *p`)、**这块内存是哪里 free 的**(第 19 行)、**它最初是哪里 malloc 的**(第 17 行)。三段凑一起,整条「申请→释放→又访问」的因果链一目了然。这就是红区机制加上「free 后影子字节翻成 `fd`」的功劳——`free` 之后那块内存对 ASan 来说不再是「可访问」,再碰就报。 +(上面把 build-id 等无关行省略了,关键信息全在。)你看 ASan 给了三段信息:**这次非法读发生在哪**(`case_uaf()` 第 20 行的 `return *p`)、**这块内存是哪里 free 的**(第 19 行)、**它最初是哪里 malloc 的**(第 17 行)。三段凑一起,整条「申请→释放→又访问」的因果链一目了然。这就是红区机制加上「free 后影子字节翻成 `fd`」的功劳:`free` 之后那块内存对 ASan 来说不再是「可访问」,再碰就报。 ### 4.3 堆缓冲区越界 —— 尾部红区 @@ -278,9 +278,9 @@ allocated by thread T0 here: ... ``` -`located 0 bytes after 16-byte region`——这块内存是 16 字节(4 个 int),访问点正好踩在它**结尾之后的第一个字节**,也就是尾部红区的起点。这就是 ASan 抓 off-by-one 的原理:malloc 返回的块后面紧跟一圈红区,红区的影子字节是 `fa`(heap left redzone,其实是堆块周围的 poison),`a[4]` 落进红区,插桩代码一查影子字节不是 `00`,当场报错。 +`located 0 bytes after 16-byte region`,这块内存是 16 字节(4 个 int),访问点正好踩在它**结尾之后的第一个字节**,也就是尾部红区的起点。这就是 ASan 抓 off-by-one 的原理:malloc 返回的块后面紧跟一圈红区,红区的影子字节是 `fa`(heap left redzone,其实是堆块周围的 poison),`a[4]` 落进红区,插桩代码一查影子字节不是 `00`,当场报错。 -> **一个源笔记提到、但容易误解的点**:源笔记说「Valgrind 不检查静态分配数组」。这点对老 memcheck 是真的(栈/全局数组越界历史上是 memcheck 的弱项),但 **ASan 不是这样**——ASan 对栈数组、全局变量都铺红区(影子字节 `f1`~`f3` 是栈红区、`f9` 是全局红区),栈上数组越界它抓得很利索。所以「静态数组越界查不到」这条结论,只对 Valgrind 成立,对 ASan 不成立。别把两个工具的局限混为一谈。 +> **一个源笔记提到、但容易误解的点**:源笔记说「Valgrind 不检查静态分配数组」。这点对老 memcheck 是真的(栈/全局数组越界历史上是 memcheck 的弱项),但 **ASan 不是这样**:ASan 对栈数组、全局变量都铺红区(影子字节 `f1`~`f3` 是栈红区、`f9` 是全局红区),栈上数组越界它抓得很利索。所以「静态数组越界查不到」这条结论,只对 Valgrind 成立,对 ASan 不成立。别把两个工具的局限混为一谈。 ### 4.4 内存泄漏 —— LSan 在程序退出时扫堆 @@ -301,7 +301,7 @@ Direct leak of 4 byte(s) in 1 object(s) allocated from: SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s). ``` -注意这个报错是 **`LeakSanitizer`**,不是 ASan 本体——LSan 是 ASan 默认捆绑的泄漏检测器,在**程序正常退出时**扫一遍整个堆,把「分配了但没有任何指针指向」的块揪出来。它报告的是「still reachable / definitely lost」这套分类里的「definitely lost」。这和 memcheck 的泄漏检测思路一致(都是退出时扫堆),只是 LSan 是 ASan 工具链的一部分。 +注意这个报错是 **`LeakSanitizer`**,不是 ASan 本体:LSan 是 ASan 默认捆绑的泄漏检测器,在**程序正常退出时**扫一遍整个堆,把「分配了但没有任何指针指向」的块揪出来。它报告的是「still reachable / definitely lost」这套分类里的「definitely lost」。这和 memcheck 的泄漏检测思路一致(都是退出时扫堆),只是 LSan 是 ASan 工具链的一部分。 > **守护进程怎么办?** LSan 默认在程序 `exit` 时才扫,长跑的 daemon/服务进程不会自己退出。这时可以发信号让它中途 dump:`ASAN_OPTIONS=abort_on_error=0:detect_leaks=1` 配合 `kill`,或用 LSan 的 `__lsan_do_leak_check()` API 在代码里主动触发一次扫描。Valgrind 那边对应的办法是另一个终端 `kill` 掉 memcheck 进程让它输出(源笔记提过这招)。 @@ -324,7 +324,7 @@ allocated by thread T0 here: ... ``` -`alloc-dealloc-mismatch (malloc vs operator delete)`——ASan 替每个分配记下了「是用谁申请的」,释放时一比对,`malloc` 配 `delete` 不匹配,当场报。memcheck 抓的是同一类(手册 4.2.5「freed with an inappropriate deallocation function」),两边能力对齐。 +`alloc-dealloc-mismatch (malloc vs operator delete)`:ASan 替每个分配记下了「是用谁申请的」,释放时一比对,`malloc` 配 `delete` 不匹配,当场报。memcheck 抓的是同一类(手册 4.2.5「freed with an inappropriate deallocation function」),两边能力对齐。 > **平台差异提醒**:这个 `alloc-dealloc-mismatch` 检查在 Windows 上**默认是关的**(MSVC 的 ASan,因为 Windows 上 `delete` 和 `free` 经常实际等价)。Linux/macOS 默认开。如果你在 Windows 上发现这类错误没被抓,查一下 `ASAN_OPTIONS=alloc_dealloc_mismatch=1`。 @@ -347,11 +347,11 @@ freed by thread T0 here: ... ``` -`attempting double-free`——第一次 `free` 之后影子字节翻成 `fd`,第二次再 `free` 同一地址,ASan 发现它已经是 `fd` 状态(已释放),直接判定为 double-free。还贴心地告诉你「上次是在第 48 行 free 的」。 +`attempting double-free`:第一次 `free` 之后影子字节翻成 `fd`,第二次再 `free` 同一地址,ASan 发现它已经是 `fd` 状态(已释放),直接判定为 double-free。还贴心地告诉你「上次是在第 48 行 free 的」。 ### 4.7 额外彩蛋:栈上 use-after-return -ASan 还能抓一个 memcheck 历史上很难抓的东西——**栈帧返回后被访问**(函数返回了,调用方却还持有指向函数内局部变量的指针)。这个要显式开: +ASan 还能抓一个 memcheck 历史上很难抓的东西,**栈帧返回后被访问**(函数返回了,调用方却还持有指向函数内局部变量的指针)。这个要显式开: ```cpp // suar2.cpp @@ -379,11 +379,11 @@ HINT: this may be a false positive if your program uses some custom stack unwind SUMMARY: AddressSanitizer: stack-use-after-return /tmp/asand/suar2.cpp:4 in main ``` -注意那个地址 `0x6da50b8f0020`——它在进程地址空间里**很靠前**(不是普通栈区),因为开了 `detect_stack_use_after_return` 后,ASan 会把「可能被逃逸指针指向的局部变量」挪到一块专门的「假栈(fake stack)」上,函数返回时把那块假栈标成毒,再访问就报 `stack-use-after-return`(影子字节 `f5`)。默认它是关的,因为有一定开销和少量误报(看那个 HINT)。但这种「函数返回后还在用栈内存」的 bug 极其难查,值得知道有这招。 +注意那个地址 `0x6da50b8f0020`,它在进程地址空间里**很靠前**(不是普通栈区),因为开了 `detect_stack_use_after_return` 后,ASan 会把「可能被逃逸指针指向的局部变量」挪到一块专门的「假栈(fake stack)」上,函数返回时把那块假栈标成毒,再访问就报 `stack-use-after-return`(影子字节 `f5`)。默认它是关的,因为有一定开销和少量误报(看那个 HINT)。但这种「函数返回后还在用栈内存」的 bug 极其难查,值得知道有这招。 ## 五、Valgrind 怎么用:把上面那些错误喂给 memcheck -讲完原理,我们把第四节那同一个 `cases.cpp`(这次不带 `-fsanitize=`,普通编译)塞进 valgrind 跑一遍,看 memcheck 对同一批错误是怎么报的——两套话术正面相对,对照才看得清。本机用的是 valgrind 3.25.1。 +讲完原理,我们把第四节那同一个 `cases.cpp`(这次不带 `-fsanitize=`,普通编译)塞进 valgrind 跑一遍,看 memcheck 对同一批错误是怎么报的,两套话术正面相对,对照才看得清。本机用的是 valgrind 3.25.1。 先用 `-g` 编一个干净版(valgrind 不需要 ASan 那套插桩,但要 `-g` 才能在报告里给出行号): @@ -421,12 +421,12 @@ uaf=42 ==453796== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) ``` -注意行号——`cases.cpp:20` 读、`:19` free、`:17` malloc,和第四节 ASan 报的**一模一样**(ASan 那边也是 :20/:19/:17)。同一个 bug,两个工具各自定位到同一行,只是话术不同: +注意行号,`cases.cpp:20` 读、`:19` free、`:17` malloc,和第四节 ASan 报的**一模一样**(ASan 那边也是 :20/:19/:17)。同一个 bug,两个工具各自定位到同一行,只是话术不同: - ASan 说 `heap-use-after-free` + `located 0 bytes inside of 4-byte region`; - memcheck 说 `Invalid read of size 4` + `Address ... is 0 bytes inside a block of size 4 free'd`。 -memcheck 还多了句 `Block was alloc'd at ... :17`——它靠 A-bit 账本记下了这块内存的「一生」(在哪申请、在哪释放、现在又被读),整条因果链一次给全,和 ASan 的「allocated by / freed by」三段式是同一个思路、两套措辞。 +memcheck 还多了句 `Block was alloc'd at ... :17`:它靠 A-bit 账本记下了这块内存的「一生」(在哪申请、在哪释放、现在又被读),整条因果链一次给全,和 ASan 的「allocated by / freed by」三段式是同一个思路、两套措辞。 ### 5.2 泄漏:LEAK SUMMARY 对位 LSan @@ -449,26 +449,26 @@ $ valgrind --tool=memcheck --leak-check=full ./cases_plain 4 ==453446== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0) ``` -`definitely lost: 4 bytes`——对位第四节 ASan 那边 LSan 的 `Direct leak of 4 byte(s)`。两边都是「程序退出时扫一遍堆」,只是 memcheck 把泄漏分成 `definitely lost / indirectly lost / possibly lost / still reachable` 四档(更细),LSan 默认只报 `Direct` 和 `Indirect` 两档。行号同样是 `:34`,和 ASan 一致。 +`definitely lost: 4 bytes`,对位第四节 ASan 那边 LSan 的 `Direct leak of 4 byte(s)`。两边都是「程序退出时扫一遍堆」,只是 memcheck 把泄漏分成 `definitely lost / indirectly lost / possibly lost / still reachable` 四档(更细),LSan 默认只报 `Direct` 和 `Indirect` 两档。行号同样是 `:34`,和 ASan 一致。 -> **别再去下源码包手动编译。** 源笔记给的安装流程是 `tar -jxvf valgrind-3.12.0.tar.bz2 && ./configure && make && sudo make install`——`3.12.0` 是 2016 年的版本,**十年前了**,而且对现代内核/新 CPU 指令(比如较新的 AVX)支持差,跑新编的程序容易各种报错。现在直接用发行版包:Debian/Ubuntu 是 `apt install valgrind`、Fedora/RHEL 是 `dnf install valgrind`、Arch 是 `pacman -S valgrind`,装上就是 3.2x 版本(本机 3.25.1)。 +> **别再去下源码包手动编译。** 源笔记给的安装流程是 `tar -jxvf valgrind-3.12.0.tar.bz2 && ./configure && make && sudo make install`,`3.12.0` 是 2016 年的版本,**十年前了**,而且对现代内核/新 CPU 指令(比如较新的 AVX)支持差,跑新编的程序容易各种报错。现在直接用发行版包:Debian/Ubuntu 是 `apt install valgrind`、Fedora/RHEL 是 `dnf install valgrind`、Arch 是 `pacman -S valgrind`,装上就是 3.2x 版本(本机 3.25.1)。 ## 六、两条路线怎么选 说了这么多,到底什么时候用哪个?给你一套实战决策: -**默认用 ASan。** 日常开发、CI 里挂着的内存错误检测,首选 ASan——它快(慢 2 倍 vs 20~50 倍,CI 能忍),跨平台(Windows/macOS/Linux 通吃,MSVC 也支持),报告干净。现代 C++ 项目里,`-fsanitize=address,undefined` 几乎是调试构建的标配。我们卷一的[动态内存管理](../vol1-fundamentals/ch12/02-new-delete.md)里讲 ASan 抓泄漏、卷五的并发调试里讲 TSan,都是这条路上的工具。 +**默认用 ASan。** 日常开发、CI 里挂着的内存错误检测,首选 ASan:它快(慢 2 倍 vs 20~50 倍,CI 能忍),跨平台(Windows/macOS/Linux 通吃,MSVC 也支持),报告干净。现代 C++ 项目里,`-fsanitize=address,undefined` 几乎是调试构建的标配。我们卷一的[动态内存管理](../../vol1-fundamentals/ch12/02-new-delete.md)里讲 ASan 抓泄漏、卷五的并发调试里讲 TSan,都是这条路上的工具。 **这几类场景,必须上 Valgrind:** 1. **只有二进制、没源码**,或者重新编译成本太高(比如巨大的遗留项目)。ASan 必须重新编,Valgrind 拿现成二进制就能跑。 -2. **要抓未初始化读取,但只有 GCC**。ASan 没 V-bit,MSan 又只有 Clang——用 GCC 编的项目要抓未初始化,memcheck 是现成的。 +2. **要抓未初始化读取,但只有 GCC**。ASan 没 V-bit,MSan 又只有 Clang,用 GCC 编的项目要抓未初始化,memcheck 是现成的。 3. **要性能 profiling**(callgrind/cachegrind/massif)。这几个工具 ASan 完全没有对应物,想看 cache miss、堆增长曲线、调用图,只有 Valgrind 这一套。 4. **要全盘覆盖,包括没带 ASan 编译的第三方库**。Valgrind 在指令层拦截,连没源码的 .so 里的内存错误也能抓;ASan 只覆盖被插桩的代码。 反过来,**这几类 Valgrind 干不了、或干不好,得用 ASan**:抓栈数组/全局数组越界(ASan 的栈/全局红区是强项)、跑得快(CI 友好)、Windows 平台(Valgrind 基本不支持 Windows)、抓 stack-use-after-return(ASan 有专门的 fake stack 机制)。 -一句话收口:**ASan 是「开发期」的标配,Valgrind 是「疑难杂症 / 性能 / 遗留二进制」的专科」。** 它们不是替代关系,是互补关系——很多团队是 CI 里挂 ASan 做日常守门,遇到 ASan 抓不到的怪问题再上 Valgrind 复查。 +一句话收口:**ASan 是「开发期」的标配,Valgrind 是「疑难杂症 / 性能 / 遗留二进制」的专科」。** 它们不是替代关系,是互补关系,很多团队是 CI 里挂 ASan 做日常守门,遇到 ASan 抓不到的怪问题再上 Valgrind 复查。 ## 七、回到 C++:工具是兜底,RAII 才是治本 @@ -481,6 +481,6 @@ $ valgrind --tool=memcheck --leak-check=full ./cases_plain 4 - double-free?`unique_ptr` 不能拷贝、移动后原指针置空,物理上就 double 不了; - 越界?`std::vector` 配 `.at()` 会抛异常、`std::span` 带边界,别用裸 `[]` 配手工长度。 -C 风格的 `malloc`/`free`/裸指针把「内存什么时候释放、谁能访问」全部丢给程序员记——人脑记这些必然出错,所以才需要 Valgrind 和 ASan 这种「记账工具」来兜底。Modern C++ 的思路是把这套记账**搬到类型系统里**:资源的生命周期绑死在对象上,编译器替你保证释放。这是从「工具抓 bug」到「语言消灭 bug」的根本跃迁,我们卷一的[动态内存管理](../vol1-fundamentals/ch12/02-new-delete.md)整篇就在讲这个。 +C 风格的 `malloc`/`free`/裸指针把「内存什么时候释放、谁能访问」全部丢给程序员记,人脑记这些必然出错,所以才需要 Valgrind 和 ASan 这种「记账工具」来兜底。Modern C++ 的思路是把这套记账**搬到类型系统里**:资源的生命周期绑死在对象上,编译器替你保证释放。这是从「工具抓 bug」到「语言消灭 bug」的根本跃迁,我们卷一的[动态内存管理](../../vol1-fundamentals/ch12/02-new-delete.md)整篇就在讲这个。 但这**不**意味着 Modern C++ 项目就不需要 ASan/Valgrind 了。只要你的代码还会调 C 库、还会用 `new`/`delete`、还会碰第三方没有 RAII 包装的接口,内存错误就还有缝可钻。所以正确的姿势是:**先用 RAII 把 99% 的内存错误在写代码时就消灭,再用 ASan 把漏网的那 1% 在测试期抓出来,最后拿 Valgrind 兜底那些最古怪的疑难杂症。** 三层防线,缺一不可。 diff --git a/documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md b/documents/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md similarity index 65% rename from documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md rename to documents/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md index 925d0ef4b..5ad741838 100644 --- a/documents/vol6-performance/12-sanitizer-toolchain-and-memory-safety.md +++ b/documents/vol6-performance/ch00-performance-mindset/05-sanitizer-toolchain-and-memory-safety.md @@ -1,36 +1,40 @@ --- -title: "Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE" -description: "把用户态 -fsanitize=address/memory/undefined/thread 和内核态 KASAN/KMSAN/UBSAN/KCSAN/KFENCE 摆在同一张表里对照,讲透『编译期插桩 vs 采样』两条路线和『调试 vs 上生产』的分层防御" -chapter: 6 -order: 12 -platform: host +chapter: 0 +cpp_standard: +- 11 +- 14 +- 17 +- 20 +description: 把用户态 -fsanitize=address/memory/undefined/thread 和内核态 KASAN/KMSAN/UBSAN/KCSAN/KFENCE + 摆在同一张表里对照,讲透『编译期插桩 vs 采样』两条路线和『调试 vs 上生产』的分层防御 difficulty: advanced -cpp_standard: [11, 14, 17, 20] -reading_time_minutes: 22 +order: 5 +platform: host prerequisites: - - "ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型" - - "Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩" +- ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 +- Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 +reading_time_minutes: 20 related: - - "ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型" - - "Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩" - - "并发程序调试技巧(ThreadSanitizer)" - - "动态内存管理(new/delete 与智能指针)" +- ASan 工具家族与内存安全:shadow memory、Heartbleed 与 sanitizer 选型 +- Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 +- 并发程序调试技巧(ThreadSanitizer) +- 动态内存管理(new/delete 与智能指针) tags: - - host - - cpp-modern - - advanced - - 内存安全 - - 调试 - - 工具链 +- host +- cpp-modern +- advanced +- 内存安全 +- 调试 +- 工具链 +title: Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE --- - # Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE > PS: 这部分内容由大学期间的笔记迁移而来、并经查证核对;用户态 sanitizer 本机实跑,内核态工具本机无法运行、以 kernel.org 官方文档为准。若有疏漏,欢迎 Issue 或 PR。 -前面两篇我们已经把用户态的 ASan / UBSan / MSan / TSan 和 Valgrind 拆得很细了——shadow memory 怎么记账、JIT 解释和编译期插桩两条路线差在哪、五种 sanitizer 之间为什么互斥。但如果你只把目光停在「`g++ -fsanitize=address` 加个 flag」上,会漏掉一个更大的图景:**sanitizer 不是用户态的专利,内核里也有一整套对应的工具**,而且两者的设计取舍完全不一样**。 +前面两篇我们已经把用户态的 ASan / UBSan / MSan / TSan 和 Valgrind 拆得很细了:shadow memory 怎么记账、JIT 解释和编译期插桩两条路线差在哪、五种 sanitizer 之间为什么互斥。但如果你只把目光停在「`g++ -fsanitize=address` 加个 flag」上,会漏掉一个更大的图景:**sanitizer 不是用户态的专利,内核里也有一整套对应的工具**,而且两者的设计取舍完全不一样**。 -这一篇我们要做的,是把整张 sanitizer 工具链拉平了看。用户态的 `-fsanitize=*` 一边,内核态的 `CONFIG_KASAN / CONFIG_KMSAN / CONFIG_KFENCE` 一边——它们抓的是同一类 bug(越界、释放后使用、未初始化、数据竞争),但站在完全不同的约束下:用户态可以为了抓 bug 把程序拖慢 2~5 倍,内核态不行,内核一旦拖慢 5 倍整台机器就废了。所以内核这边演化出了「采样」这条路——KFENCE 用极低开销换「能在生产环境一直开着」,和 KASAN 那种「只能调试期开」的重型工具分层共存。 +这一篇我们要做的,是把整张 sanitizer 工具链拉平了看。用户态的 `-fsanitize=*` 一边,内核态的 `CONFIG_KASAN / CONFIG_KMSAN / CONFIG_KFENCE` 一边,它们抓的是同一类 bug(越界、释放后使用、未初始化、数据竞争),但站在完全不同的约束下:用户态可以为了抓 bug 把程序拖慢 2~5 倍,内核态不行,内核一旦拖慢 5 倍整台机器就废了。所以内核这边演化出了「采样」这条路:KFENCE 用极低开销换「能在生产环境一直开着」,和 KASAN 那种「只能调试期开」的重型工具分层共存。 ## 先把用户态这一侧收个口 @@ -64,7 +68,7 @@ READ of size 4 at 0x72c9e1de0010 thread T0 SUMMARY: AddressSanitizer: heap-use-after-free /tmp/sanit/uaf.cpp:6 in main ``` -`-g` 让报告带上 `uaf.cpp:5` 这种源码定位,这是 ASan 能不能用的分水岭——没有调试符号,报告只剩一串地址,等于白报。栈上的越界它一样抓,换一个跨函数的栈缓冲: +`-g` 让报告带上 `uaf.cpp:5` 这种源码定位,这是 ASan 能不能用的分水岭:没有调试符号,报告只剩一串地址,等于白报。栈上的越界它一样抓,换一个跨函数的栈缓冲: ```cpp // stack_oob.cpp —— 栈缓冲越界 @@ -93,7 +97,7 @@ Address 0x6ec9ef2f0028 is located in stack of thread T0 at offset 40 in frame #0 0x5f38ab644220 in main /tmp/sanit/stack_oob.cpp:6 ``` -注意它不光告诉你越界,还告诉你「这块内存是 `main` 栈帧里 offset 40 的那个 `buf`」——栈红区(redzone)连栈上数组的归属都标出来了。这就是 shadow memory 的威力,上一篇详细拆过,这里不再展开。 +注意它不光告诉你越界,还告诉你「这块内存是 `main` 栈帧里 offset 40 的那个 `buf`」:栈红区(redzone)连栈上数组的归属都标出来了。这就是 shadow memory 的威力,上一篇详细拆过,这里不再展开。 内存泄漏走的是 ASan 自带的 LeakSanitizer(LSan),程序退出时扫一次: @@ -117,7 +121,7 @@ Direct leak of 4 byte(s) in 1 object(s) allocated from: #1 0x609649f361ba in main /tmp/sanit/leak.cpp:4 ``` -ASan 的代价是实打实的:程序慢 2~5 倍、内存多 3~5 倍。所以**生产构建一定要摘掉 `-fsanitize=address`**,只在调试和测试期开。这条约束听起来无所谓,但到了内核那边,同样的「开销太大」直接催生出了完全不同的工具——这就是后面 KFENCE 的来历。 +ASan 的代价是实打实的:程序慢 2~5 倍、内存多 3~5 倍。所以**生产构建一定要摘掉 `-fsanitize=address`**,只在调试和测试期开。这条约束听起来无所谓,但到了内核那边,同样的「开销太大」直接催生出了完全不同的工具,这就是后面 KFENCE 的来历。 ### UBSan:专治未定义行为 @@ -151,7 +155,7 @@ sum=-2147483648 v=0 ### MSan:未初始化读,只有 Clang 有 -MSan 抓的是「用了没初始化的值」,这是 ASan 抓不到的一类——内存合法、访问也合法,但值是垃圾。坑在于:**MSan 只有 Clang 实现,GCC 压根不支持这个 flag**: +MSan 抓的是「用了没初始化的值」,这是 ASan 抓不到的一类:内存合法、访问也合法,但值是垃圾。坑在于:**MSan 只有 Clang 实现,GCC 压根不支持这个 flag**: ```cpp // msan.cpp —— 用了没初始化的变量 @@ -182,7 +186,7 @@ g++: error: unrecognized argument to '-fsanitize=' option: 'memory' SUMMARY: MemorySanitizer: use-of-uninitialized-value ``` -> **踩坑预警**:MSan 有个硬限制——**整个程序(包括它链接的所有库)都必须用 MSan 插桩编译**。你直接 `clang++ -fsanitize=memory` 链一个没插桩的 `libc++` 或第三方库,会爆出一堆假阳性,因为 MSan 把库返回的值都当未初始化。所以 MSan 在实际项目里很少用,通常要配合「用 MSan 重新构建整个 toolchain」才能跑干净。这一点上一篇讲过,这里强调一下,因为内核那边的 KMSAN 也有类似的「全链路插桩」要求。 +> **踩坑预警**:MSan 有个硬限制,**整个程序(包括它链接的所有库)都必须用 MSan 插桩编译**。你直接 `clang++ -fsanitize=memory` 链一个没插桩的 `libc++` 或第三方库,会爆出一堆假阳性,因为 MSan 把库返回的值都当未初始化。所以 MSan 在实际项目里很少用,通常要配合「用 MSan 重新构建整个 toolchain」才能跑干净。这一点上一篇讲过,这里强调一下,因为内核那边的 KMSAN 也有类似的「全链路插桩」要求。 至于 TSan(数据竞争),它和 ASan 互斥、开销 5~15 倍,专门抓并发 bug,讲并发那一卷的「并发程序调试技巧」已经拆透了,这里只标一下它在全景图里的位置,不重复。 @@ -190,11 +194,11 @@ SUMMARY: MemorySanitizer: use-of-uninitialized-value 把用户态的四个 flag 记牢之后,接下来才是这一篇真正想讲的东西。**内核也是 C 代码,也会越界、也会 UAF、也会有数据竞争,能不能直接把 `-fsanitize=address` 套到内核上?** -答案是:**能,而且内核确实这么干了,但代价大到你只能在调试时开**。这就是 KASAN——Kernel AddressSanitizer。它的底层和用户态 ASan 是同一套(shadow memory + 编译期插桩),但内核有自己的约束: +答案是:**能,而且内核确实这么干了,但代价大到你只能在调试时开**。这就是 KASAN,Kernel AddressSanitizer。它的底层和用户态 ASan 是同一套(shadow memory + 编译期插桩),但内核有自己的约束: 1. **影子内存要占内核虚拟地址空间的一大块**。用户态 ASan 的影子内存是「进程地址空间的 1/8」,内核这边直接划走内核 VAS 的一大段(`KASAN_SHADOW_START` 到 `KASAN_SHADOW_END`)。在 64 位内核上地址空间够大(128 TB),还能撑住;32 位就紧张得多,所以早期 KASAN 只能跑在 64 位上,直到 5.11 才有 Linus Walleij 给 ARM-32 做的精简版。 -2. **整机的每一处内存访问都被插桩**。内核不是一个进程,是所有进程共享的底层,一旦开 KASAN,整机性能直接塌方——这就是为什么 `CONFIG_KASAN` 只用于调试内核,生产内核绝对不开。 +2. **整机的每一处内存访问都被插桩**。内核不是一个进程,是所有进程共享的底层,一旦开 KASAN,整机性能直接塌方,这就是为什么 `CONFIG_KASAN` 只用于调试内核,生产内核绝对不开。 3. **它要配特定的内存分配器**。内核用 SLAB 或 SLUB 分配器,KASAN 要在分配器里埋红区、给释放的页打「投毒」标记(`KASAN_SANITIZE_*`),才能在 UAF/OOB 时立刻逮到。这和用户态 ASan 拦截 `malloc/free` 是同一思路,只是换到了 `kmalloc/kfree`。 @@ -234,7 +238,7 @@ The buggy address is located 5 bytes inside of ## 全景对照表:用户态 ↔ 内核态 -到这里把两边对齐,这张表是这一篇的核心——源笔记原是一张外部 PNG,我们用 Markdown 自己画: +到这里把两边对齐,这张表是这一篇的核心:源笔记原是一张外部 PNG,我们用 Markdown 自己画。 | 抓的 bug | 用户态 flag | 内核工具 | 内核合入版本 | 能否上生产 | |---------|-----------|---------|------------|----------| @@ -250,35 +254,35 @@ The buggy address is located 5 bytes inside of - **ASan ↔ KASAN**:同一个 shadow memory 思路搬到内核,代价是整机性能塌方,只能调试开。 - **MSan ↔ KMSAN**:都只认 Clang,都要全链路插桩,都开销巨大。KMSAN 官方文档明说「not intended for production use, because it drastically increases kernel memory footprint and slows the whole system down」。 -- **UBSan ↔ UBSAN**:内核 UBSAN 在 4.5 合入,而且**它的一部分检查(比如 `CONFIG_UBSAN_BOUNDS`)在现代发行版内核里默认开启**,因为这部分开销很低——这是内核 sanitizer 里少数能「常驻」的。 -- **TSan ↔ KCSAN**:注意 TSan 是编译期全插桩,KCSAN 不一样——它基于**采样**(watchpoint),开销可控,但相应的,它检测数据竞争靠的是「碰巧采到」,不是 TSan 那种「理论上一定检测到」。5.8 合入主线(google/kernel-sanitizers 仓库明说「in mainline since 5.8」)。 +- **UBSan ↔ UBSAN**:内核 UBSAN 在 4.5 合入,而且**它的一部分检查(比如 `CONFIG_UBSAN_BOUNDS`)在现代发行版内核里默认开启**,因为这部分开销很低,这是内核 sanitizer 里少数能「常驻」的。 +- **TSan ↔ KCSAN**:注意 TSan 是编译期全插桩,KCSAN 不一样,它基于**采样**(watchpoint),开销可控,但相应的,它检测数据竞争靠的是「碰巧采到」,不是 TSan 那种「理论上一定检测到」。5.8 合入主线(google/kernel-sanitizers 仓库明说「in mainline since 5.8」)。 -源笔记里把 KMSAN 标成「6.1 及以上版本」——**这个版本号是对的**,别记错。KMSAN 的补丁系列由 Google 的 Alexander Potapenko 维护了多年,直到 2021 年底都还只是分支补丁、未进主线(kernel.org 官方示例报告跑在打了补丁的 `5.16.0-rc3+` 上,用的就是 google/kmsan 分支,不是主干);Google 官方仓库 (google/kmsan) 的 README 明确写着「Linux 6.1+ contains a fully-working KMSAN implementation which can be used out of the box」,即 **6.1 起主线完整可用**。所以 KMSAN 是这一批内核 sanitizer 里进主线最晚的。注意别把「5.16 补丁分支能跑」和「6.1 进主线」搞混——这是这类版本号最常见的误读。 +源笔记里把 KMSAN 标成「6.1 及以上版本」,**这个版本号是对的**,别记错。KMSAN 的补丁系列由 Google 的 Alexander Potapenko 维护了多年,直到 2021 年底都还只是分支补丁、未进主线(kernel.org 官方示例报告跑在打了补丁的 `5.16.0-rc3+` 上,用的就是 google/kmsan 分支,不是主干);Google 官方仓库 (google/kmsan) 的 README 明确写着「Linux 6.1+ contains a fully-working KMSAN implementation which can be used out of the box」,即 **6.1 起主线完整可用**。所以 KMSAN 是这一批内核 sanitizer 里进主线最晚的。注意别把「5.16 补丁分支能跑」和「6.1 进主线」搞混,这是这类版本号最常见的误读。 ## KFENCE:把 sanitizer 搬上生产的关键一招 -KASAN 的问题太明显了——只能调试开,但你公司线上跑的内核出了内存 bug 怎么办?你总不能拿一台生产机器换成开了 KASAN 的调试内核去复现,那样业务早就挂了。真正缺的是一个**开销低到能一直开着的内存错误检测器**。 +KASAN 的问题太明显了:只能调试开,但你公司线上跑的内核出了内存 bug 怎么办?你总不能拿一台生产机器换成开了 KASAN 的调试内核去复现,那样业务早就挂了。真正缺的是一个**开销低到能一直开着的内存错误检测器**。 这就是 KFENCE(Kernel Electric-Fence),**Linux 5.12 合入主线**。它的思路和 KASAN 完全不同,不再「检测每一次访问」,而是改成**采样**: -- KFENCE 维护一个固定大小的对象池(默认 `CONFIG_KFENCE_NUM_OBJECTS=255`,每个对象占 2 页——1 页放对象、1 页当守卫页 guard page,池里对象页和守卫页交错排布,所以每个对象页两边都是守卫页;默认配置下整个池约 2 MiB)。 +- KFENCE 维护一个固定大小的对象池(默认 `CONFIG_KFENCE_NUM_OBJECTS=255`,每个对象占 2 页:1 页放对象、1 页当守卫页 guard page,池里对象页和守卫页交错排布,所以每个对象页两边都是守卫页;默认配置下整个池约 2 MiB)。 - 内核的 slab 分配器(`kmalloc`)会**被一个采样定时器钓进 KFENCE 池**:KFENCE 有个以毫秒为单位的采样间隔(启动参数 `kfence.sample_interval`,默认可由 `CONFIG_KFENCE_SAMPLE_INTERVAL` 配),每个采样间隔里下一次 `kmalloc` 分配就被「钓」交给 KFENCE 来管。 -- 一旦进了 KFENCE 池,这次分配就被放在两个守卫页之间——任何越界读写都会踩到守卫页,立刻触发 page fault,内核报出精确的错误和分配栈。 +- 一旦进了 KFENCE 池,这次分配就被放在两个守卫页之间,任何越界读写都会踩到守卫页,立刻触发 page fault,内核报出精确的错误和分配栈。 - 释放后,KFENCE 把这页标记成「不可访问」,再有人碰它就是 use-after-free,同样立刻报。 -采样的代价是:**绝大多数分配根本不经过 KFENCE**,所以大部分 bug 它抓不到——你得跑足够长时间、让足够多的分配流经 KFENCE 池,才有机会逮到。但换来的是**极低的开销**(官方说接近零,实际生产负载几乎感知不到),于是它成了**第一个能一直开在生产内核上的内存 sanitizer**。事实上,只要架构支持、且开了 SLAB 或 SLUB,KFENCE 在很多发行版里默认就是开的。 +采样的代价是:**绝大多数分配根本不经过 KFENCE**,所以大部分 bug 它抓不到,你得跑足够长时间、让足够多的分配流经 KFENCE 池,才有机会逮到。但换来的是**极低的开销**(官方说接近零,实际生产负载几乎感知不到),于是它成了**第一个能一直开在生产内核上的内存 sanitizer**。事实上,只要架构支持、且开了 SLAB 或 SLUB,KFENCE 在很多发行版里默认就是开的。 -源笔记原话「KFENCE 必须运行长时间,但开销足够低,甚至可以在生产环境中运行」——机制描述没错,我们补上版本号(5.12)和「采样」这个关键词,再强调一下「默认常开」这个工程意义。它取代的是更老的 `kmemcheck`(那个在 4.15 就被删了,因为开销太大、和 KFENCE 思路冲突)。 +源笔记原话「KFENCE 必须运行长时间,但开销足够低,甚至可以在生产环境中运行」,机制描述没错,我们补上版本号(5.12)和「采样」这个关键词,再强调一下「默认常开」这个工程意义。它取代的是更老的 `kmemcheck`(那个在 4.15 就被删了,因为开销太大、和 KFENCE 思路冲突)。 ## DAMON:另一条「采样」路线,但目标不是抓 bug -提到「采样」,顺带要把 DAMON(Data Access MONitor)讲一下,因为它和 KFENCE 在哲学上是同一类——**不去全量跟踪,而是采样代表性样本**。但 DAMON 不是 sanitizer,它不抓 bug,它**监控内存访问模式**: +提到「采样」,顺带要把 DAMON(Data Access MONitor)讲一下,因为它和 KFENCE 在哲学上是同一类,**不去全量跟踪,而是采样代表性样本**。但 DAMON 不是 sanitizer,它不抓 bug,它**监控内存访问模式**: - **Linux 5.15 合入主线**,目的是帮开发者(和内核自己)看清「进程到底在怎么访问内存」,从而优化布局、指导回收。 -- DAMON 把目标进程的地址空间切成等大的区域,**采样**每个区域里的若干代表页,记录访问频次,形成直方图。区域如果是热点,就再细分——这种「智能放大」让它在超大地址空间上也能低成本运行。 -- 内核组件是「生产者」(产出访问模式),用户态(或内核)是「消费者」。消费者甚至能根据访问模式反过来调 `madvise()` 改内存属性——比如把确认冷的数据区建议内核换出。 +- DAMON 把目标进程的地址空间切成等大的区域,**采样**每个区域里的若干代表页,记录访问频次,形成直方图。区域如果是热点,就再细分,这种「智能放大」让它在超大地址空间上也能低成本运行。 +- 内核组件是「生产者」(产出访问模式),用户态(或内核)是「消费者」。消费者甚至能根据访问模式反过来调 `madvise()` 改内存属性,比如把确认冷的数据区建议内核换出。 -DAMON 的接口有三个:用户态的 `damo` 工具(来自 awslabs/damo)、`/sys/kernel/mm/damon/admin/` 下的 sysfs、以及给内核开发者的内核 API。旧的 debugfs 接口已废弃。它和 KFENCE 放在一起看,你会发现内核在 5.12~5.15 这一波,系统性地用「采样」补上了「全量插桩太贵」这个口子——KFENCE 抓 bug,DAMON 看模式,都能上生产。 +DAMON 的接口有三个:用户态的 `damo` 工具(来自 awslabs/damo)、`/sys/kernel/mm/damon/admin/` 下的 sysfs、以及给内核开发者的内核 API。旧的 debugfs 接口已废弃。它和 KFENCE 放在一起看,你会发现内核在 5.12~5.15 这一波,系统性地用「采样」补上了「全量插桩太贵」这个口子:KFENCE 抓 bug,DAMON 看模式,都能上生产。 ## 三层防御:把工具按场景摆好 @@ -289,39 +293,28 @@ DAMON 的接口有三个:用户态的 `damo` 工具(来自 awslabs/damo)、`/sys ::: ::: tip 测试/准生产:采样插桩,长期运行 -预发、灰度、长时间负载测试,**不能接受整机塌方,但要跑足够久才能暴露罕见 bug**。这一层用 KFENCE——采样、低开销、能一直开着,让成千上万的分配流经守卫页池,逮到那些「跑一万次才出现一次」的越界和 UAF。用户态这一层目前没有完全对等的东西(Valgrind 太慢、ASan 太重),所以内核这边 KFENCE 的工程价值特别突出。 +预发、灰度、长时间负载测试,**不能接受整机塌方,但要跑足够久才能暴露罕见 bug**。这一层用 KFENCE:采样、低开销、能一直开着,让成千上万的分配流经守卫页池,逮到那些「跑一万次才出现一次」的越界和 UAF。用户态这一层目前没有完全对等的东西(Valgrind 太慢、ASan 太重),所以内核这边 KFENCE 的工程价值特别突出。 ::: ::: tip 生产:默认开启的轻量检查 + 事后分析 -真正的线上内核,**只开开销可忽略的检查**:KFENCE(默认常开)、`CONFIG_UBSAN_BOUNDS` 这类轻量 UBSAN 子集、再加上 DAMON 做访问模式分析指导优化。出了事故靠事后工具——内核 oops 日志、kdump/crash 分析、eBPF 的 `memleak-bpfcc` 跟踪未释放的分配。这一层不再指望「当场抓 bug」,而是「留够证据,事后能查」。 +真正的线上内核,**只开开销可忽略的检查**:KFENCE(默认常开)、`CONFIG_UBSAN_BOUNDS` 这类轻量 UBSAN 子集、再加上 DAMON 做访问模式分析指导优化。出了事故靠事后工具:内核 oops 日志、kdump/crash 分析、eBPF 的 `memleak-bpfcc` 跟踪未释放的分配。这一层不再指望「当场抓 bug」,而是「留够证据,事后能查」。 ::: -这套分层就是为什么内核要同时养 KASAN 和 KFENCE 两个看似重复的工具——**同一个 bug(比如 UAF),开发期用 KASAN 抓,生产期用 KFENCE 抓**,工具不重复,场景不重叠。用户态目前只有第一层(开发期插桩)用得顺手,第二、第三层还没有 kernel 那么成熟的工具,这也是为什么「在 C++ 用户态里彻底搞定内存安全」比内核还难——内核好歹有 KFENCE 能兜底生产,用户态出了线上 UAF,经常只能等它崩了再去看 core dump。 +这套分层就是为什么内核要同时养 KASAN 和 KFENCE 两个看似重复的工具,**同一个 bug(比如 UAF),开发期用 KASAN 抓,生产期用 KFENCE 抓**,工具不重复,场景不重叠。用户态目前只有第一层(开发期插桩)用得顺手,第二、第三层还没有 kernel 那么成熟的工具,这也是为什么「在 C++ 用户态里彻底搞定内存安全」比内核还难:内核好歹有 KFENCE 能兜底生产,用户态出了线上 UAF,经常只能等它崩了再去看 core dump。 ## 顺带一提:静态分析和事后工具 除了上面这些运行时 sanitizer,内核和用户态都还有一组**不靠运行、靠看代码或看日志**的工具,源笔记里也提到了,这里收个尾,不展开: -- **静态分析**:内核侧有 `sparse`、`smatch`、`Coccinelle`、`checkpatch.pl`,用户态有 `clang-tidy`、`cppcheck`。它们不跑代码、开销为零,但只能抓「代码模式上明显有问题」的那类,抓不到运行时才暴露的 UAF/OOB。和 sanitizer 是互补不是替代——静态分析抓规范、sanitizer 抓运行时。 +- **静态分析**:内核侧有 `sparse`、`smatch`、`Coccinelle`、`checkpatch.pl`,用户态有 `clang-tidy`、`cppcheck`。它们不跑代码、开销为零,但只能抓「代码模式上明显有问题」的那类,抓不到运行时才暴露的 UAF/OOB。和 sanitizer 是互补不是替代,静态分析抓规范、sanitizer 抓运行时。 - **事后分析**:内核 oops/panic 日志、`kdump`/`crash` 工具分析 dump、`[K]GDB` 调试。这些是 bug 已经炸了之后的取证手段,和「提前抓 bug」的 sanitizer 不在一个阶段。 C++ 用户态的事后分析,在「动态内存管理」那一章我们用 `-fsanitize=address` 在退出时报泄漏见过一次,在「并发程序调试技巧」用 TSan 见过并发 bug 的事后定位。整个工具链是**开发期 sanitizer → 生产期轻量检查 → 事后分析**一条龙,缺哪一环,对应的那类 bug 就会在那个阶段反复咬你。 -## 小结 - -这一篇把 sanitizer 工具链从用户态拉到内核态,几个关键结论收一下: - -- 用户态四个 flag 分工明确:ASan(越界/UAF/泄漏)、UBSan(未定义行为)、MSan(未初始化读,仅 Clang)、TSan(数据竞争)。它们之间大多互斥(ASan/MSan/TSan 不能两两同开),UBSan 能和 ASan 叠加。本机 GCC 16.1.1 / Clang 22 全部真跑出了报告。 -- 内核态有完全对应的工具:KASAN(↔ASan,4.0)、KMSAN(↔MSan,6.1 起主线完整可用)、UBSAN(↔UBSan,4.5)、KCSAN(↔TSan,5.8)。机制同源,但受内核约束,大多只能调试开。 -- **KFENCE(5.12)是分水岭**:用「采样 + 守卫页」把内存错误检测的开销压到能上生产,默认常开,填补了 KASAN 留下的生产空白。 -- **DAMON(5.15)**走同一条采样路线,但不抓 bug,监控访问模式,指导内存优化。 -- 整套工具链是三层防御:开发期全量插桩(KASAN/ASan)→ 准生产采样(KFENCE)→ 生产轻量检查 + 事后分析(UBSAN 子集/kdump)。 -- 源笔记两处版本号已核正:KFENCE 版本 = 5.12(源没标,补上);KMSAN 源写「6.1 及以上」其实是对的,核过 Google 官方仓库 README 确认 6.1 起主线完整可用——补丁在 5.16 分支已能跑,但进主线是 6.1。 - -下一篇我们继续在性能与正确性这条线上走,去看编译器优化怎么在不改变语义的前提下把代码变快——以及它在什么时候会「偷偷」改变语义,让你精心写的并发代码跑出和你预期不一样的结果。 - ## 参考资源 +下面这些资料,是Linux的,笔者因为目前工作主力机是Linux,也对Linux的了解更好一些。请Windows的朋友见谅!笔者后续会慢慢补充的! + - [kernel.org: Kernel Address Sanitizer (KASAN)](https://www.kernel.org/doc/html/latest/dev-tools/kasan.html) —— KASAN 机制、配置项与示例报告 - [kernel.org: Kernel Memory Sanitizer (KMSAN)](https://www.kernel.org/doc/html/latest/dev-tools/kmsan.html) —— KMSAN 要求 Clang 14.0.6+,仅 x86_64,明确「not for production」 - [kernel.org: Kernel Electric-Fence (KFENCE)](https://www.kernel.org/doc/html/latest/dev-tools/kfence.html) —— KFENCE 采样机制、`CONFIG_KFENCE_NUM_OBJECTS`、生产可用定位 diff --git a/documents/vol6-performance/ch00-performance-mindset/index.md b/documents/vol6-performance/ch00-performance-mindset/index.md index 4325c89df..5468a42a4 100644 --- a/documents/vol6-performance/ch00-performance-mindset/index.md +++ b/documents/vol6-performance/ch00-performance-mindset/index.md @@ -16,4 +16,7 @@ description: "建立性能优化的思维方式:efficiency 与 performance 的 性能思维:efficiency 与 performance 不是一回事 从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基 + ASan 工具家族与内存安全:shadow memory 与 sanitizer 选型 + Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 + Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE diff --git a/documents/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md b/documents/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md index 0bddbfd56..9219765e4 100644 --- a/documents/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md +++ b/documents/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md @@ -1,42 +1,44 @@ --- -title: "为什么 microbenchmark 会骗你" -description: "microbenchmark 是性能优化最常用的工具,也是最容易骗人的——拆开三类典型欺骗(编译器优化成空、缓存假热、噪声淹没信号),点破「让微基准干净的正是不让它真实的那只手」,给出 Google Benchmark + nanobench 的选型" chapter: 1 -order: 1 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 测试 +cpp_standard: +- 14 +- 17 +description: microbenchmark 是性能优化最常用的工具,也是最容易骗人的——拆开三类典型欺骗(编译器优化成空、缓存假热、噪声淹没信号),点破「让微基准干净的正是不让它真实的那只手」,给出 + Google Benchmark + nanobench 的选型 difficulty: intermediate +order: 1 platform: host -reading_time_minutes: 10 -cpp_standard: [14, 17] prerequisites: - - "性能思维:efficiency 与 performance 不是一回事" - - "从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基" +- 性能思维:efficiency 与 performance 不是一回事 +- 从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基 +reading_time_minutes: 9 related: - - "怎么写一个可信的 microbenchmark" - - "测量陷阱与环境就绪" - - "统计与报告" +- 怎么写一个可信的 microbenchmark +- 测量陷阱与环境就绪 +- 统计与报告 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 为什么 microbenchmark 会骗你 --- - # 为什么 microbenchmark 会骗你 ## 性能不是一个布尔量 -功能做对了就是对了,跑通了就是跑通了——这是布尔量。但是很遗憾——**性能不是**。性能是一个**分布**:同一段代码、同一份输入,你跑两遍,数字会不一样;跑十遍,你能画出一张散点图。Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》第 2 章开篇就点明这件事:解压一个 zip 文件,你每次得到的结果一模一样(可复现);但要你「复现一模一样的性能曲线」,做不到。 +功能做对了就是对了,跑通了就是跑通了,这是布尔量。性能不是。性能是一个**分布**:同一段代码、同一份输入,你跑两遍数字就不一样;跑十遍,你能画出一张散点图。Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》第 2 章开篇就点明这件事:解压一个 zip 文件,你每次得到的结果一模一样(可复现);但要你「复现一模一样的性能曲线」,做不到。 -这件事决定了本卷的整个方法论走向:既然性能是随机变量,那「测性能」就不是「跑一次记个数」,而是「采样一个分布、做统计推断」。(换而言之,我们需要的是平均的统计量) +这件事决定了本卷的整个方法论走向。既然性能是随机变量,「测性能」就不是「跑一次记个数」,而是采样一个分布、做统计推断,我们要的是平均意义上的统计量。 -这一章尝试做的事情,就是讲怎么把这件难事做对,它是全卷的锚点章——后面每一篇性能文章,开头都会回引它讲的那套规矩,就像 vol5 用 TSan 贯穿并发正确性一样。 +这一章讲的就是怎么把这件难事做对,它是全卷的锚点章。后面每一篇性能文章,开头都会回引它讲的那套规矩,就像 vol5 用 TSan 贯穿并发正确性一样。 -但在这之前,我们得先认清一个让人不舒服的事实:你手上最趁手的那个工具——microbenchmark(微基准)——恰恰是最容易骗你的。这一篇就把它的骗术拆开。 +但在这之前,得先认清一个让人不舒服的事实:你手上最趁手的那个工具 microbenchmark(微基准),恰恰是最容易骗你的。这一篇就把它的骗术拆开。 -## 第一集,是编译器把你的 benchmark 优化成空,或者是出乎您对比意料的代码 +## 第一集:编译器把你的 benchmark 优化成空,或者优化出你没想到的代码 -最经典也最尴尬的一种。你写了一段看上去在干活的循环,举个例子,您说您要度量标准库字符串构造和析构的速度如何,从而推广您自己写的字符串的时候—— +最经典也最尴尬的一种。你写了一段看上去在干活的循环,比如说,你想度量标准库字符串构造和析构的速度,好跟你自己写的字符串对比: ```cpp // 这段「测试 string 创建性能」——其实什么都没测 @@ -47,44 +49,44 @@ void foo() { } ``` -`s` 创建出来,谁也没用。编译器一看,这是死代码,删了——整个循环连同 `string` 的构造,全部消除(DCE)。我在自己机器上(GCC 16.1.1)实跑确认:这段代码 `-O2` 下 `foo` 的汇编就一条 `ret`,整个循环蒸发;同一段代码 `-O0` 下是 89 行汇编,循环和 `string` 构造全在。你美滋滋地跑完,记下「0.3 纳秒」,心想这函数真快。你测的是「什么都不做」。 +`s` 创建出来,谁也没用。编译器一看,这是死代码,删了,整个循环连同 `string` 的构造全部消除(DCE)。我在自己机器上(GCC 16.1.1)实跑确认:这段代码 `-O2` 下 `foo` 的汇编就一条 `ret`,整个循环蒸发;同一段代码 `-O0` 下是 89 行汇编,循环和 `string` 构造全在。你美滋滋地跑完,记下「0.3 纳秒」,心想这函数真快。你测的是「什么都不做」。 -> 笔者这里也是提醒您,做profile,除了一顿对自己的perf图高兴之外,请务必看看汇编,汇编是机器码的一个直接映射,读它大致就知道你的机器将会被执行什么。 +> 笔者这里也要提醒一句:做 profile,除了一顿对自己的 perf 图高兴之外,请务必看看汇编。汇编是机器码的直接映射,读它大致就知道你的机器真正会执行什么。 这个坑我们在 ch00-02 讲 UB 的时候侧面碰过(那个 `(x+1)>x` 被折叠成常量的例子),这里换一个更直接的性能版本:**只要你的 benchmark 算出来的结果没人消费,编译器就有合法理由把它整段删掉。** 这不是编译器「bug」,这是允许的优化。 -怎么办?强制让结果「被用到」——业内叫 `DoNotOptimize` 一类的辅助函数(底层用一点内联汇编把结果钉到内存或寄存器上),Google Benchmark 和 JMH(Java 的 `Blackhole.consume`)都内置了。它的语义和坑不少(ch00-01 那个 `volatile global_sink` 是手写版的近似),下一篇 ch01-02 会专门拆。 +怎么办?强制让结果「被用到」。业内叫 `DoNotOptimize` 一类的辅助函数,底层用一点内联汇编把结果钉到内存或寄存器上,Google Benchmark 和 JMH(Java 的 `Blackhole.consume`)都内置了。它的语义和坑不少(ch00-01 那个 `volatile global_sink` 是手写版的近似),下一篇 ch01-02 会专门拆。 -## 第二集:性能测量,缓存总是热的,真实负载不是 +## 第二集:缓存总是热的,真实负载不是 -microbenchmark 的标准做法是:把一个函数反复跑成千上万次,取平均。问题就出在「反复」上——同一个函数反复跑同一份(或相似的)数据,那些数据从头到尾都赖在 L1/L2 cache 里,一次都不 miss。你测出来 2 纳秒,是这个「热缓存」条件下的 2 纳秒。 +microbenchmark 的标准做法是把一个函数反复跑成千上万次,取平均。问题就出在「反复」上:同一个函数反复跑同一份(或相似的)数据,那些数据从头到尾都赖在 L1/L2 cache 里,一次都不 miss。你测出来 2 纳秒,是这个「热缓存」条件下的 2 纳秒。 -而真实负载里,这个函数被调用一次的间隔里,系统跑了一堆别的东西,cache 早被换成别人的数据了。它再被调用时,要从 L3 甚至 DRAM 现取——2 纳秒变 50、变 200。这就是 micro 和 macro 那道著名的数量级鸿沟。 +而真实负载里,这个函数两次调用之间,系统跑了一堆别的东西,cache 早被换成别人的数据了。它再被调用时,要从 L3 甚至 DRAM 现取,2 纳秒变 50、变 200。这就是 micro 和 macro 那道著名的数量级鸿沟(这里的 2/50/200 是业界量级说法,不是本文实测,意在给一个直觉)。 -更阴险的版本,Bakhvalov 在第 2 章末尾点出来:一个在空闲机器上跑的 microbenchmark,**把全部 DRAM 和 cache 都据为己有**。于是你对比两个实现,A 更快但更吃内存,B 稍慢但省内存——在空闲系统的 microbenchmark 里,A 赢得很漂亮,因为它有吃内存的资本。可一旦上了生产,旁边挤着一堆邻居进程抢 DRAM,A 那些多占的内存被挤到磁盘 swap,性能断崖式下跌,结论整个翻转。**让 A 看起来更快的,正是 microbenchmark 那个不真实的「全场没人跟我抢」的前提。** +更阴险的版本,Bakhvalov 在第 2 章末尾点出来:一个在空闲机器上跑的 microbenchmark,**把全部 DRAM 和 cache 都据为己有**。于是你对比两个实现,A 更快但更吃内存,B 稍慢但省内存。在空闲系统的 microbenchmark 里,A 赢得很漂亮,因为它有吃内存的资本。可一旦上了生产,旁边挤着一堆邻居进程抢 DRAM,A 多占的那部分内存被挤到磁盘 swap,性能断崖式下跌,结论整个翻转。**让 A 看起来更快的,正是 microbenchmark 那个不真实的「全场没人跟我抢」的前提。** 这条推论极其重要,它是 ch00-01「efficiency ≠ performance」在测量层的镜像:别用 microbenchmark 的结论去给生产性能背书。micro 测的是「这个函数在理想条件下能跑多快」,不是「用户实际会体验到多快」。 -## 第三集:系统噪声把信号淹了 +## 第三集:系统噪声把信号淹了 -哪怕你躲过了前两集(结果也消费了、缓存也认了它热), 还有一类骗术来自系统本身:现代 CPU 和 OS 有一堆「为了提升性能」的特性,它们的副作用是让测量结果不稳定。 +哪怕你躲过了前两集(结果也消费了、缓存也认了它热),还有一类骗术来自系统本身:现代 CPU 和 OS 有一堆「为了提升性能」的特性,它们的副作用是让测量结果不稳定。 -- **动态调频(DFS / Turbo)**:CPU 会根据温度和负载临时提频或降频。「冷」处理器跑第一轮时可能飙到 turbo 频率,跑第二轮时已经热了、降回基频——同一份代码两次跑,差出百分之几到十几。笔记本上尤其严重(散热有限)。 +- **动态调频(DFS / Turbo)**:CPU 会根据温度和负载临时提频或降频。「冷」处理器跑第一轮时可能飙到 turbo 频率,跑第二轮时已经热了、降回基频,同一份代码两次跑差出百分之几到十几。笔记本上尤其严重(散热有限)。 - **文件系统缓存**:第一次跑要读盘,第二次数据都在缓存里了,第二次快得多。你以为第二次的优化见效了,其实只是盘不用再读了。 - **内存布局偏置**:这是最 spooky 的一种。Mytkowicz 等人 2009 年那篇经典论文证明,**UNIX 环境变量的总字节数、链接器输入的目标文件顺序**,都会改变程序的性能,而且方向不可预测。你什么代码都没改,只改了 `LINK_ORDER`,数字就动了。 -- **甚至你用来监控的工具本身**:你在另一个核跑个 `top` 看 CPU 占用,那个核被激活、调频,可能连带干扰到跑 benchmark 的那个核。Bakhvalov 特意提醒:连开个任务管理器都能影响测量。 +- **甚至你用来监控的工具本身**:你在另一个核跑个 `top` 看 CPU 占用,那个核被激活、调频,可能连带干扰到跑 benchmark 的那个核。Bakhvalov 特意提醒,连开个任务管理器都能影响测量。 这三骗加起来,指向同一个结论:**一次性的、手搓的测量,几乎没有意义。** 你测到的那个数,是「这段代码在这个编译选项下在这台机器在这个温度这个频率这个内存布局这个缓存状态下」的数,换一个条件就变。 -完事了?骗你的没完事,**让 micro 干净的, 正是让它骗人的**。讲完三骗, 值得退一步看一个更深的矛盾。为了让 microbenchmark 给出干净、稳定、可对比的数字,我们本能地会去**消除噪声**:锁死 CPU 频率、绑核、关超线程、预热缓存、跑够多轮取中位数。这些都没错,本卷后面会逐个教你怎么做。 +讲完三骗,值得退一步看一个更深的矛盾。为了让 microbenchmark 给出干净、稳定、可对比的数字,我们本能地会去**消除噪声**:锁死 CPU 频率、绑核、关超线程、预热缓存、跑够多轮取中位数。这些都没错,本卷后面会逐个教你怎么做。 -但你要清醒地知道:**消除噪声的过程,就是让测量偏离真实环境的过程。** 你锁死了频率,可用户的手机从来不锁频;你绑死了一个核,可生产环境线程被调度来调度去;你把缓存预热到最佳,可真实调用 cache 冷得像冰。所以 Bakhvalov 那句忠告很关键:**评估真实性能时,不要去消除系统的非确定性,而要复刻目标环境。** +但你要清醒地知道:**消除噪声的过程,就是让测量偏离真实环境的过程。** 你锁死了频率,可用户的手机从来不锁频;你绑死了一个核,可生产环境线程被调度来调度去;你把缓存预热到最佳,可真实调用 cache 冷得像冰。所以 Bakhvalov 那句忠告很关键:**评估真实性能时,不要去消除系统的非确定性,而要复刻目标环境。** 换个说法,让 micro 干净的那只手,正是让它不真实的那只手。 -有人拍桌子了:你这是自相矛盾!不是,这是两种不同的测量场景,要分开用: +有人拍桌子了:你这是自相矛盾!不是,这是两种不同的测量场景,要分开用。 -一方面,**microbenchmark**:做**相对比较**——同一个函数、两种实现、同一台机器、同一套控制条件,A 比 B 快多少。这时你要消除噪声,因为你要的是「干净的信噪比」。它的产出是「这个改动方向对不对」。而**生产测量 / 宏观 benchmark**:做**绝对判断**——用户实际感受到多快、能不能扛过下个月的流量。这时你反而要保留噪声、复刻真实,然后**用统计方法**处理那个噪声。它的产出是「这个数字扛不扛得住」。 +一方面,**microbenchmark** 做**相对比较**:同一个函数、两种实现、同一台机器、同一套控制条件,A 比 B 快多少。这时你要消除噪声,因为你要的是「干净的信噪比」。它的产出是「这个改动方向对不对」。而**生产测量 / 宏观 benchmark** 做**绝对判断**:用户实际感受到多快、能不能扛过下个月的流量。这时你反而要保留噪声、复刻真实,然后**用统计方法**处理那个噪声。它的产出是「这个数字扛不扛得住」。 -一个常见的、灾难性的错误,是拿 micro 的相对结论去推 macro 的绝对判断:「我这个函数在 micro 里快了 30%,所以上线后服务会快 30%」。大概率不会——那 30% 里有一部分是「空闲系统让出来的红利」,在生产里根本不存在。这两种测量是两套语言,不能直接换算。生产测量和 CI 回归的事,ch01-05 专门讲。 +一个常见的、灾难性的错误,是拿 micro 的相对结论去推 macro 的绝对判断:「我这个函数在 micro 里快了 30%,所以上线后服务会快 30%」。大概率不会,那 30% 里有一部分是「空闲系统让出来的红利」,在生产里根本不存在。这两种测量是两套语言,不能直接换算。生产测量和 CI 回归的事,ch01-05 专门讲。 ## 别手搓,用框架 @@ -97,7 +99,7 @@ microbenchmark 的标准做法是:把一个函数反复跑成千上万次,取平 | Catch2 `BENCHMARK` | Catch2 内置 | return 值当 sink | mean + 95% CI | 已用 Catch2 的项目顺手 | | picobench / nonius / Hayai | 单头 | 各异 | 简单 | 不默认用,提一句 | -本卷的选型:**Google Benchmark 主力 + nanobench 轻量补充**。理由是 GBench 那条链式 API——`BENCHMARK(f)->RangeMultiplier(2)->Range(8, 8<<10)->UseRealTime()->Repetitions(3)->ReportAggregatesOnly(true)` 一行就能表达「参数扫描 + 墙钟计时 + 多次重复 + 只报聚合」,其他库做不到这种组合;而 nanobench 自带硬件计数器(IPC、branch miss%),讲到微架构那几章时能给你即时反馈,很方便。从下一篇起,我们的代码示例会切到 GBench。 +本卷的选型是 **Google Benchmark 主力 + nanobench 轻量补充**。理由是 GBench 那条链式 API,`BENCHMARK(f)->RangeMultiplier(2)->Range(8, 8<<10)->UseRealTime()->Repetitions(3)->ReportAggregatesOnly(true)` 一行就能表达「参数扫描 + 墙钟计时 + 多次重复 + 只报聚合」,其他库做不到这种组合;而 nanobench 自带硬件计数器(IPC、branch miss%),讲到微架构那几章时能给你即时反馈,很方便。从下一篇起,我们的代码示例会切到 GBench。 ## 参考资源 diff --git a/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md b/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md index 7f4b507c0..e5a0a17e2 100644 --- a/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md +++ b/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md @@ -1,36 +1,38 @@ --- -title: "怎么写一个可信的 microbenchmark" -description: "从 ch01-01 的「骗术」走到「对策」:用 Google Benchmark 写一个不(那么)骗人的微基准,拆透 DoNotOptimize / ClobberMemory 的语义与坑、参数扫描、重复聚合、UseRealTime,配最小可运行例子和真实输出" chapter: 1 -order: 2 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 测试 +cpp_standard: +- 14 +- 17 +description: 从 ch01-01 的「骗术」走到「对策」:用 Google Benchmark 写一个不(那么)骗人的微基准,拆透 DoNotOptimize + / ClobberMemory 的语义与坑、参数扫描、重复聚合、UseRealTime,配最小可运行例子和真实输出 difficulty: intermediate +order: 2 platform: host -reading_time_minutes: 10 -cpp_standard: [14, 17] prerequisites: - - "为什么 microbenchmark 会骗你" +- 为什么 microbenchmark 会骗你 +reading_time_minutes: 8 related: - - "测量陷阱与环境就绪" - - "统计与报告" +- 测量陷阱与环境就绪 +- 统计与报告 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 怎么写一个可信的 microbenchmark --- - # 怎么写一个可信的 microbenchmark ## 上一篇甩下的问题 -ch01-01 把 microbenchmark 那三套骗术摆完了——编译器把你优化成空、缓存假热、噪声淹信号。骗术讲完了,这一篇给解药。 +ch01-01 把 microbenchmark 那三套骗术摆完了:编译器把你优化成空、缓存假热、噪声淹信号。骗术讲完了,这一篇给解药。 -解药其实只管第一套骗术(结果被优化掉),顺手把参数扫描、重复聚合、墙钟计时这几件马上要用的姿势做对。第三套(系统噪声)得靠 ch01-03 那份环境 checklist,分布怎么变成结论是 ch01-04 的事——这两件先放着,这一篇先把「测的对象是不是真东西」立住。 +解药其实只管第一套骗术(结果被优化掉),顺手把参数扫描、重复聚合、墙钟计时这几件马上要用的姿势做对。第三套(系统噪声)得靠 ch01-03 那份环境 checklist,分布怎么变成结论是 ch01-04 的事,这两件先放着。这一篇先把「测的对象是不是真东西」立住。 ## 别自己写计时循环 -你大概想这么干:一个 `for` 循环、`std::chrono::steady_clock` 掐表、跑完除一下。ch00-01 那个 `vector_vs_set` 就是这么写的——但那是**为了讲命题故意用最朴素写法**,别学。自己搓的计时循环,「该跑多少轮」「怎么算统计」「结果怎么不被优化掉」全得你自己管,而这几件事每一件都有坑。一个合格的 benchmark 框架替你把这三件机械活包了,你只管写「测什么」。本卷主力是 Google Benchmark(下面简称 GBench)。 +你大概想这么干:一个 `for` 循环、`std::chrono::steady_clock` 掐表、跑完除一下。ch00-01 那个 `vector_vs_set` 就是这么写的,但那是**为了讲命题故意用最朴素写法**,别学。自己搓的计时循环,「该跑多少轮」「怎么算统计」「结果怎么不被优化掉」全得你自己管,而这几件事每一件都有坑。一个合格的 benchmark 框架替你把这三件机械活包了,你只管写「测什么」。本卷主力是 Google Benchmark(下面简称 GBench)。 看一个最小、但完整的例子,测 `std::vector::push_back`: @@ -79,9 +81,9 @@ BM_PushBack/256/repeats:3/real_time_mean 242 ns 242 ns BM_PushBack/256/repeats:3/real_time_median 242 ns 242 ns 3 ``` -这张表怎么读。`Time` 是墙钟(因为用了 `UseRealTime`),`CPU` 是 CPU 时间,`Iterations` 在聚合行里显示的是重复次数(3,即 `Repetitions(3)` 那个 3),不是每轮真实迭代数——每轮框架估了很多次,只是在 `ReportAggregatesOnly` 模式下被聚合藏起来了。`mean` / `median` / `stddev` / `cv` 是对这 3 轮做的统计,其中 `cv`(coefficient of variation,`stddev/mean`)是最该盯的——它告诉你「这一组测量有多散」。44ns 那行 cv 是 0.31%,很稳;哪天 cv 飙到 5% 以上,这轮就别信了,先去查噪声源(ch01-03)。 +这张表怎么读。`Time` 是墙钟(因为用了 `UseRealTime`),`CPU` 是 CPU 时间,`Iterations` 在聚合行里显示的是重复次数(3,即 `Repetitions(3)` 那个 3),不是每轮真实迭代数;每轮框架估了很多次,只是在 `ReportAggregatesOnly` 模式下被聚合藏起来了。`mean` / `median` / `stddev` / `cv` 是对这 3 轮做的统计,其中 `cv`(coefficient of variation,`stddev/mean`)是最该盯的,它告诉你「这一组测量有多散」。44ns 那行 cv 是 0.31%,很稳;哪天 cv 飙到 5% 以上,这轮就别信了,先去查噪声源(ch01-03)。 -> 笔者第一次用 GBench 的时候,光盯着 `mean` 看,后来吃了几次亏才学会先扫一眼 `cv`——`cv` 大的 `mean` 没有意义,你对着一个噪声比信号还大的分布下结论,纯属自欺。 +> 笔者第一次用 GBench 的时候,光盯着 `mean` 看,后来吃了几次亏才学会先扫一眼 `cv`。`cv` 大的 `mean` 没有意义,你对着一个噪声比信号还大的分布下结论,纯属自欺。 时间随 N 涨(8→44ns,64→105ns,256→242ns),这才是 `push_back`「随规模变贵」的真实样子。不是 ch01-01 那个被 DCE 删成一条 `ret` 的空壳。 @@ -89,9 +91,9 @@ BM_PushBack/256/repeats:3/real_time_median 242 ns 242 ns 这一节是整篇最该讲透的,也是新手最爱用错的地方。把 ch01-01 的 `foo()` 和这里的 `BM_PushBack` 摆一起:同样是「循环里创建/写入东西」,`foo()` 没用 `DoNotOptimize`,整段被编译器删成一条 `ret`;`BM_PushBack` 用了,真跑了、时间随 N 缩放。`DoNotOptimize` 干的事,就是把「结果」钉到内存或寄存器,让编译器没法判定它是死代码。 -但是有个大问题,我先直接引 Google Benchmark `user_guide` 原文:`benchmark::DoNotOptimize(expr)` 把 `expr` 的结果存到 memory 或 register,对 GNU 编译器它还是个全局内存的 read/write barrier(冲刷 pending 写);**但它不阻止 `expr` 本身被优化**——`expr` 的结果要是编译期就能算出来,它可能被整个算掉,只剩一个常量。 +但是有个大问题。我先直接引 Google Benchmark `user_guide` 原文:`benchmark::DoNotOptimize(expr)` 把 `expr` 的结果存到 memory 或 register,对 GNU 编译器它还是个全局内存的 read/write barrier(冲刷 pending 写);**但它不阻止 `expr` 本身被优化**:`expr` 的结果要是编译期就能算出来,它可能被整个算掉,只剩一个常量。 -听着矛盾,其实是分工:`DoNotOptimize` 防的是「整段循环因为结果没人用而被删」(`foo()` 那种);它**不防**「循环体内部被常量传播算穿」。所以写 benchmark 的时候,输入数据必须是**运行期产生**的——从随机数、从文件、从参数,不能是编译期常量。否则编译器一路算穿,`DoNotOptimize` 也救不了你。Bakhvalov 在 §2.6 也强调了这一句: **先确保「你想测的场景」在运行期真被执行了。**(这就回到了我上一节的提示了,麻烦看看汇编) +听着矛盾,其实是分工。`DoNotOptimize` 防的是「整段循环因为结果没人用而被删」(`foo()` 那种);它**不防**「循环体内部被常量传播算穿」。所以写 benchmark 的时候,输入数据必须是**运行期产生**的:从随机数、从文件、从参数,不能是编译期常量。否则编译器一路算穿,`DoNotOptimize` 也救不了你。Bakhvalov 在 §2.6 也强调了这一句: **先确保「你想测的场景」在运行期真被执行了。** (这就回到了笔者上一节的提示了,麻烦看看汇编) `benchmark::ClobberMemory()` 是配套的另一件,强制把所有 pending 写真正落回内存。`push_back` 改了 `vector` 的内部状态(大小、可能的扩容),编译器要是判定「这个 `vector` 后面没人看」,某些边界条件下可能省掉一部分写。`ClobberMemory` 就是兜底那句「别省,真写」。常见的安全写法:热循环里每次写完目标数据 `DoNotOptimize` 钉一下地址,循环结束 `ClobberMemory` 兜底。 @@ -99,7 +101,7 @@ BM_PushBack/256/repeats:3/real_time_median 242 ns 242 ns `BENCHMARK(BM_PushBack)->RangeMultiplier(2)->Range(8, 8 << 6)` 这行,让框架自动用 `8, 16, 32, 64, 128, 256, 512` 这一组 N 跑同一个 benchmark。为什么要扫一整组 N,而不是挑一个顺手测? -复杂度的真实形状,扫一组 N 才看得出来。`push_back` 均摊是 $O(1)$,但你扫一遍会发现小 N 时被缓存吃掉、大 N 时触发扩容的尖刺;只测一个 N,你看到的可能是缓存红利,也可能是扩容惩罚,完全取决于你手气。更狠的是 crossover 藏在尺度里——ch00-01 那个 `vector` vs `set`,只在 N=1024 看,`set` 反而略快;扫到 N=65536 才看到它被 `vector` 打到 5 倍。不扫尺度,这种翻转你根本看不见。 +复杂度的真实形状,扫一组 N 才看得出来。`push_back` 均摊是 $O(1)$,但你扫一遍会发现小 N 时被缓存吃掉、大 N 时触发扩容的尖刺;只测一个 N,你看到的可能是缓存红利,也可能是扩容惩罚,完全取决于你手气。更狠的是 crossover 藏在尺度里:ch00-01 那个 `vector` vs `set`,只在 N=1024 看,`set` 反而略快;扫到 N=65536 才看到它被 `vector` 打到 5 倍。不扫尺度,这种翻转你根本看不见。 顺手 `state.SetComplexityN(state.range(0))`,框架还能根据你扫出来的时间自动拟合一个 big-O,输出里多一栏 `Big O`,让你对着复杂度直觉核一遍。比手算斜率省事。 @@ -107,9 +109,9 @@ BM_PushBack/256/repeats:3/real_time_median 242 ns 242 ns ch01-01 讲过性能是分布,一次测量没意义。GBench 的对策是 `Repetitions(n)`:同一个 benchmark 跑 n 轮(每轮内部迭代数框架自己估),然后 `ReportAggregatesOnly(true)` 只输出 `mean` / `median` / `stddev` / `cv` 这几个聚合,不把每轮原始值刷满屏。 -为什么强调**中位数**而不只看均值:`push_back` 偶尔会撞上一次扩容——那是合法的均摊成本,但相对均值它是个离群值,均值被这种长尾拉高,中位数岿然不动。ch01-04 会专门讲什么时候用中位数、什么时候用均值、怎么报置信区间,这里你先记住一句:报中位数 + cv,比只甩一个均值诚实得多。`ReportAggregatesOnly(true)` 还有个隐形好处——CI 里跑 benchmark 时,聚合输出更适合做趋势对比和回归检测(ch01-05 接这条线)。 +为什么强调**中位数**而不只看均值:`push_back` 偶尔会撞上一次扩容,那是合法的均摊成本,但相对均值它是个离群值,均值被这种长尾拉高,中位数岿然不动。ch01-04 会专门讲什么时候用中位数、什么时候用均值、怎么报置信区间,这里你先记住一句:报中位数 + cv,比只甩一个均值诚实得多。`ReportAggregatesOnly(true)` 还有个隐形好处:CI 里跑 benchmark 时,聚合输出更适合做趋势对比和回归检测(ch01-05 接这条线)。 -还有个细节得提一句:`UseRealTime()`。GBench 默认报的是 **CPU 时间**,多线程场景下会把别的核上跑的也算进来,往往不是你要的「这段代码墙上跑了多久」。`UseRealTime()` 把报告改成墙钟。这条跟 ch00-02 讲的 `clock()` 陷阱是一脉相承的——`clock()` 测 CPU 时间多线程失真,`steady_clock` 测墙钟。单线程测无所谓,一旦你的 benchmark 起了多线程(或者你想对标用户感受到的延迟),就加 `UseRealTime()`。 +还有个细节得提一句:`UseRealTime()`。GBench 默认报的是 **CPU 时间**,多线程场景下会把别的核上跑的也算进来,往往不是你要的「这段代码墙上跑了多久」。`UseRealTime()` 把报告改成墙钟。这条跟 ch00-02 讲的 `clock()` 陷阱是一脉相承的,`clock()` 测 CPU 时间多线程失真,`steady_clock` 测墙钟。单线程测无所谓,一旦你的 benchmark 起了多线程(或者你想对标用户感受到的延迟),就加 `UseRealTime()`。 ## 怎么编译 @@ -141,7 +143,7 @@ target_link_libraries(push_bench PRIVATE benchmark::benchmark_main) target_compile_options(push_bench PRIVATE -O2 -Wall -Wextra) ``` -> ⚠️ **一个我踩过的坑**:关 benchmark 自己的测试目标,flag 是 `BENCHMARK_ENABLE_TESTING`(不是 `BENCHMARK_ENABLE_TESTS`)。写错名字的话,FetchContent 会去 build benchmark 的内部测试,缺 gtest 配置就炸,即便你的 `push_bench` 本身已经编过了,`cmake --build` 也会因为兄弟目标失败而整体返回非零。看 `make` 输出里有没有 `Built target push_bench` —— 有就说明你的可执行文件成了,直接 `./build/push_bench` 跑就行。 +> ⚠️ **一个笔者踩过的坑**:关 benchmark 自己的测试目标,flag 是 `BENCHMARK_ENABLE_TESTING`(不是 `BENCHMARK_ENABLE_TESTS`)。写错名字的话,FetchContent 会去 build benchmark 的内部测试,缺 gtest 配置就炸,即便你的 `push_bench` 本身已经编过了,`cmake --build` 也会因为兄弟目标失败而整体返回非零。看 `make` 输出里有没有 `Built target push_bench`,有就说明你的可执行文件成了,直接 `./build/push_bench` 跑就行。 ## 参考资源 diff --git a/documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md b/documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md index bb12813e3..94f53b2ba 100644 --- a/documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md +++ b/documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md @@ -1,32 +1,34 @@ --- -title: "测量陷阱与环境就绪:16 条 checklist" -description: "ch01-01 骗术三(噪声)的具体对策——把 16 个会让性能数字失真的环境陷阱按频率/缓存/调度/工具分组,逐条给「失真原因 + 规避命令」,附 perf-env-check.sh 一键体检脚本" chapter: 1 -order: 3 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 测试 +cpp_standard: +- 11 +- 17 +description: ch01-01 骗术三(噪声)的具体对策——把 16 个会让性能数字失真的环境陷阱按频率/缓存/调度/工具分组,逐条给「失真原因 + 规避命令」,附 + perf-env-check.sh 一键体检脚本 difficulty: intermediate +order: 3 platform: host -reading_time_minutes: 8 -cpp_standard: [11, 17] prerequisites: - - "怎么写一个可信的 microbenchmark" +- 怎么写一个可信的 microbenchmark +reading_time_minutes: 6 related: - - "为什么 microbenchmark 会骗你" - - "统计与报告" +- 为什么 microbenchmark 会骗你 +- 统计与报告 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 测量陷阱与环境就绪:16 条 checklist --- - # 测量陷阱与环境就绪:16 条 checklist ## 为什么需要一份 checklist -ch01-01 讲了 microbenchmark 第三类欺骗——系统噪声淹没信号。那篇给的是「为什么会噪声」,这一篇给「具体怎么关」。下面 16 条是 Linux 上做可信 microbenchmark 最常踩的环境陷阱,每条都按「**陷阱 → 为什么失真 → 怎么规避**」给出,大多还配一条可以直接抄的命令。 +ch01-01 讲了 microbenchmark 第三类欺骗:系统噪声淹没信号。那篇给的是「为什么会噪声」,这一篇给「具体怎么关」。下面 16 条是 Linux 上做可信 microbenchmark 最常踩的环境陷阱,每条都按「**陷阱 → 为什么失真 → 怎么规避**」给出,大多还配一条可以直接抄的命令。 -但开讲之前,先重复一遍 ch01-01 那条最重要的边界:**这些消除噪声的手段,只在「做相对 A/B 比较」时该用。** 如果你要评估的是「用户实际感受到多快」,反而要**复刻**真实环境(保留噪声、保留 DFS、保留邻居进程),然后用统计方法处理它——那是 ch01-05 生产测量的活。这一页是给「我想干净地比较两个实现」的 microbenchmark 场景用的。 +但开讲之前,先重复一遍 ch01-01 那条最重要的边界:**这些消除噪声的手段,只在「做相对 A/B 比较」时该用。** 如果你要评估的是「用户实际感受到多快」,反而要**复刻**真实环境(保留噪声、保留 DFS、保留邻居进程),然后用统计方法处理它,那是 ch01-05 生产测量的活。这一页是给「我想干净地比较两个实现」的 microbenchmark 场景用的。 ## 16 条陷阱 @@ -70,7 +72,7 @@ ch01-01 讲了 microbenchmark 第三类欺骗——系统噪声淹没信号。 | 14 | **多次运行不稳** | 环境没固定,跨次运行漂移 | ≥3 次取最稳;`perf stat -r 5` 自带重复 | | 16 | **PEBS 滑步(skid)** | 采样事件会「滑」几条指令才落到真正的指令上 | 用带 `:pp` / `:ppp`(精确 IP)后缀的事件,如 `MEM_LOAD_RETIRED.L3_MISS:ppp` | -16 条看着多,核心就一句:**把能控的全控住(频率、核、内存布局),把结果该消费的真消费(`DoNotOptimize`),把数字当分布看(中位数 + 多轮重复)。** 剩下的看场景——做 micro 就尽量全做,做生产评估就别做(复刻真实)。 +16 条看着多,核心就一句:**把能控的全控住(频率、核、内存布局),把结果该消费的真消费(`DoNotOptimize`),把数字当分布看(中位数 + 多轮重复)。** 剩下的看场景:做 micro 就尽量全做,做生产评估就别做(复刻真实)。 ## 一键体检:`perf-env-check.sh` @@ -119,8 +121,8 @@ echo " randomize_va_space=$aslr(2=全开;精细 icache/分支测时可 sudo sys | 场景 | 该做的(消除噪声) | 不该做的 | |---|---|---| -| **microbenchmark 做 A/B 比较** | 1/2/4/5/8/9/10/15——尽量全做,你要的是干净信噪比 | 别把结论直接推给生产 | -| **评估生产性能** | **几乎都不做**——复刻真实环境(保留 DFS、邻居、ASLR) | 别关噪声源,否则测的不是用户会经历的 | +| **microbenchmark 做 A/B 比较** | 1/2/4/5/8/9/10/15,尽量全做,你要的是干净信噪比 | 别把结论直接推给生产 | +| **评估生产性能** | **几乎都不做**,复刻真实环境(保留 DFS、邻居、ASLR) | 别关噪声源,否则测的不是用户会经历的 | | **profiling 找热点** | 7(`-fno-omit-frame-pointer`)、16(`:pp`) | 找热点本来就是在真实负载下采 | 这张表是 ch01-01 那条「让 micro 干净的,正是让它骗人的」的具体落地:**同一组手段,在 micro 场景是解药,在生产场景是毒药。** 拿捏这个分寸,比记住 16 条命令更重要。 diff --git a/documents/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md b/documents/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md index 530d234c5..e0c896b78 100644 --- a/documents/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md +++ b/documents/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md @@ -1,30 +1,32 @@ --- -title: "统计与报告:把分布变成结论" -description: "测出来一组数之后怎么办——为什么性能数据报中位数+置信区间而非单均值、为什么几乎不正态所以 Mann-Whitney 比 t-test 靠谱、A/B 比较的正确姿势,以及为什么不能用 micro 结论给 macro 背书" chapter: 1 -order: 4 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 测试 +cpp_standard: +- 11 +- 17 +description: 测出来一组数之后怎么办——为什么性能数据报中位数+置信区间而非单均值、为什么几乎不正态所以 Mann-Whitney 比 t-test 靠谱、A/B + 比较的正确姿势,以及为什么不能用 micro 结论给 macro 背书 difficulty: intermediate +order: 4 platform: host -reading_time_minutes: 7 -cpp_standard: [11, 17] prerequisites: - - "怎么写一个可信的 microbenchmark" - - "测量陷阱与环境就绪:16 条 checklist" +- 怎么写一个可信的 microbenchmark +- 测量陷阱与环境就绪:16 条 checklist +reading_time_minutes: 6 related: - - "生产测量与 CI 性能回归" +- 生产测量与 CI 性能回归 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 统计与报告:把分布变成结论 --- - # 统计与报告:把分布变成结论 ## 测出来一堆数,然后呢 -ch01-01 到 ch01-03 让你能拿到一组「不是空壳、姿势对、环境干净」的性能数字。但你拿到的从来不是一个数,而是一组数——一个分布。ch01-01 开篇就说了:性能不是布尔量,是分布。这一篇就回答最后一个问题:这组分布,怎么变成一个可信的结论——「A 比 B 快 X%,而且这个快是真的、不是噪声」? +ch01-01 到 ch01-03 让你能拿到一组「不是空壳、姿势对、环境干净」的性能数字。但你拿到的从来不是一个数,而是一组数,一个分布。ch01-01 开篇就说了:性能不是布尔量,是分布。这一篇就回答最后一个问题:这组分布,怎么变成一个可信的结论,「A 比 B 快 X%,而且这个快是真的、不是噪声」? 这件事的本质是统计推断。听起来吓人,但你要掌握的其实就几条,而且每条都对应一个你会真实犯的错误。 @@ -35,28 +37,28 @@ ch01-01 到 ch01-03 让你能拿到一组「不是空壳、姿势对、环境干 - **必报**:中位数(median)、离散度(IQR 四分位距,或 95% 置信区间 CI)、样本数、环境快照(内核 / CPU / governor / `perf_event_paranoid`)。GBench 用 `Repetitions` + `ReportAggregatesOnly(true)` 会自动给你 `mean` / `median` / `stddev` / `cv`,我们在 ch01-02 跑出来的那张表就是。 - **禁报**:单次均值。一次跑出来的 `mean` 不是结论,是一个样本。 -为什么把「单次均值」单拎出来禁?因为性能数据是**右偏**的:正常的快,偶尔一次扩容、一次被调度走、一次 cache miss,就拖出一个长尾。均值被长尾往上拉,中位数岿然不动。两组数据明明「A 的典型表现更好」,却因为 B 的长尾更少,均值上反而 B 占优——结论就反了。 +为什么把「单次均值」单拎出来禁?因为性能数据是**右偏**的:正常的快,偶尔一次扩容、一次被调度走、一次 cache miss,就拖出一个长尾。均值被长尾往上拉,中位数岿然不动。两组数据明明「A 的典型表现更好」,却因为 B 的长尾更少,均值上反而 B 占优,结论就反了。 ## 为什么中位数比均值靠谱 -Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》§2.4 有一张很说明问题的图:两个版本 A 和 B 的性能测量**画成分布**,两条曲线高度重叠。A 的峰值(最可能出现的耗时)比 B 更靠左(更快),看起来 A 赢。但因为分布有重叠,**「A 比 B 快」只对某个概率 P 成立**——总有一些样本里 B 反而快。你采一个样本,有可能正好落在 B 快的那一段。 +Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》§2.4 有一张很说明问题的图:两个版本 A 和 B 的性能测量**画成分布**,两条曲线高度重叠。A 的峰值(最可能出现的耗时)比 B 更靠左(更快),看起来 A 赢。但因为分布有重叠,**「A 比 B 快」只对某个概率 P 成立**:总有一些样本里 B 反而快。你采一个样本,有可能正好落在 B 快的那一段。 这件事的直接推论是: - 不要凭一两个样本下结论。你要的是分布的对比,不是点估计的对比。 - 用**中位数**代表「典型表现」,用**离散度**(IQR / 95% CI / cv)代表「这个典型有多稳」。cv(`stddev/mean`)< 1% 很稳;> 5% 这组数本身不可信,先回去查噪声源(ch01-03),别急着下结论。 -- 看分布形状本身。如果分布是**双峰**(两个峰),说明你的 benchmark 里混了两种行为——典型的比如缓存命中与未命中两条路,或者锁竞争与不竞争。Bakhvalov 提醒:双峰分布不是噪声,是信号,说明你该把两种场景拆开分别测,而不是糊在一起取中位数。 +- 看分布形状本身。如果分布是**双峰**(两个峰),说明你的 benchmark 里混了两种行为,典型的比如缓存命中与未命中两条路,或者锁竞争与不竞争。Bakhvalov 提醒:双峰分布不是噪声,是信号,说明你该把两种场景拆开分别测,而不是糊在一起取中位数。 ## 假设检验:t-test 还是 Mann-Whitney U -「A 比 B 快 12%,这个 12% 是真的吗?」——这是个统计问题,叫**假设检验**(hypothesis testing)。思路是:先假设「A 和 B 没差别」(零假设),然后看手里这组数据在零假设下有多不可能。如果足够不可能(p 值低于阈值,通常 0.05),就说「差别显著」,拒绝零假设。 +「A 比 B 快 12%,这个 12% 是真的吗?」这是个统计问题,叫**假设检验**(hypothesis testing)。思路是:先假设「A 和 B 没差别」(零假设),然后看手里这组数据在零假设下有多不可能。如果足够不可能(p 值低于阈值,通常 0.05),就说「差别显著」,拒绝零假设。 选哪个检验,取决于数据分布长什么样: - **Student's t-test**(参数检验):假设数据服从**正态分布**。算简单,教科书默认教这个。 - **Mann-Whitney U**(非参数检验):不假设分布形状,只比两组数据的秩(排序后谁大谁小)。 -关键点来了,直接引 Bakhvalov 在 §2.4 的原话大意:**性能测量数据里,正态分布几乎从不出现**。性能数据通常是偏态的、有长尾的、甚至多峰的。所以那套假设正态的教科书公式(包括 t-test)在性能场景下要谨慎用——他特意点了这句,因为太多人默认套 t-test。 +关键点来了,直接引 Bakhvalov 在 §2.4 的原话大意:**性能测量数据里,正态分布几乎从不出现**。性能数据通常是偏态的、有长尾的、甚至多峰的。所以那套假设正态的教科书公式(包括 t-test)在性能场景下要谨慎用。他特意点了这句,因为太多人默认套 t-test。 实践建议:做 A/B 显著性判断,**默认用 Mann-Whitney U**(非参数,稳);只有当你先做了正态性检验、确认数据确实近似正态时,才用 t-test。Python 的 `scipy.stats.mannwhitneyu` / R 的 `wilcox.test` 都能直接算。 @@ -76,7 +78,7 @@ Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》§2.4 有一张 因为它最致命,值得这一章每一篇都拎出来讲一次。 -**禁止用 microbenchmark 的结论去给生产性能背书。** 一个 microbenchmark 里测出 IPC=2、cache 全命中的函数,在真实负载里完全可能因为 cache miss 变成 IPC=0.3。这不是「micro 不准」,是 micro 测的本来就是「理想条件下能跑多快」,而生产里没有理想条件——这两件事是两种语言,不能直接换算。 +**禁止用 microbenchmark 的结论去给生产性能背书。** 一个 microbenchmark 里测出 IPC=2、cache 全命中的函数,在真实负载里完全可能因为 cache miss 变成 IPC=0.3。这不是「micro 不准」,是 micro 测的本来就是「理想条件下能跑多快」,而生产里没有理想条件。这两件事是两种语言,不能直接换算。 Bakhvalov §2.4 的例子里,「分布对比」是在同一类场景下做的(都 micro 或都 macro)。跨场景对比时,你要么把 micro 的结论限定在「这个函数的相对改进方向」上,要么干脆去做宏观测量(生产 telemetry / 宏观负载 benchmark)。后者是 ch01-05 的事。 diff --git a/documents/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md b/documents/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md index 2ec3f481b..9c51dec35 100644 --- a/documents/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md +++ b/documents/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md @@ -1,46 +1,48 @@ --- -title: "生产测量与 CI 性能回归检测" -description: "把测量从开发机搬到生产和 CI——生产 telemetry 如何在 <1% 开销下采真实用户数据、为什么简单阈值挡不住性能回归、MongoDB 的变点检测思路,以及一个 CI 性能系统该自动化的五步" chapter: 1 -order: 5 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 测试 +cpp_standard: +- 11 +- 17 +description: 把测量从开发机搬到生产和 CI——生产 telemetry 如何在 <1% 开销下采真实用户数据、为什么简单阈值挡不住性能回归、MongoDB + 的变点检测思路,以及一个 CI 性能系统该自动化的五步 difficulty: intermediate +order: 5 platform: host -reading_time_minutes: 6 -cpp_standard: [11, 17] prerequisites: - - "统计与报告:把分布变成结论" +- 统计与报告:把分布变成结论 +reading_time_minutes: 5 related: - - "为什么 microbenchmark 会骗你" - - "Benchmark 方法论参考卡" +- 为什么 microbenchmark 会骗你 +- Benchmark 方法论参考卡 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: 生产测量与 CI 性能回归检测 --- - # 生产测量与 CI 性能回归检测 ## 从开发机到生产 -ch01-01 到 ch01-04 讲的是**开发机上**做 microbenchmark A/B 比较——消除噪声、报中位数、做假设检验。那套规矩回答的是「我这个改动方向对不对」。但还有两个问题它答不了: +ch01-01 到 ch01-04 讲的是**开发机上**做 microbenchmark A/B 比较:消除噪声、报中位数、做假设检验。那套规矩回答的是「我这个改动方向对不对」。但还有两个问题它答不了: 1. **上线后用户到底快了没有?** microbenchmark 的结论不能直接推给生产(ch01-01 反复强调),你得在生产环境里测。 -2. **怎么防止性能随着版本累积悄悄退化?** 大项目变更飞快,性能回归 bug 会以惊人的速度漏进生产代码——光靠人每次盯,迟早漏。 +2. **怎么防止性能随着版本累积悄悄退化?** 大项目变更飞快,性能回归 bug 会以惊人的速度漏进生产代码,光靠人每次盯,迟早漏。 这一篇就回答这两个:生产测量怎么做、CI 里怎么自动检测性能回归。素材主要来自 Bakhvalov《Performance Analysis and Tuning on Modern CPUs》§2.2 和 §2.3。 ## 生产测量:接受噪声,用统计 -生产环境和开发机最大的差别是:**你不能消除噪声,也不该试**。ch01-03 那套「锁频、绑核、关 Turbo」的活,在生产里全是毒药——你消除了噪声,测的就不是用户会经历的东西。生产测量的原则正好反过来:**复刻真实 + 用统计方法处理噪声**。 +生产环境和开发机最大的差别是:**你不能消除噪声,也不该试**。ch01-03 那套「锁频、绑核、关 Turbo」的活,在生产里全是毒药:你消除了噪声,测的就不是用户会经历的东西。生产测量的原则正好反过来:**复刻真实 + 用统计方法处理噪声**。 几个要点: - **共享基础设施的干扰**。公有云上,你的服务跑在和别人共享的同一台物理机上(虚拟化 / 容器),邻居进程会以不可预测的方式影响你的性能。你在开发机上复刻不出这种干扰。 -- **telemetry:在真实用户设备上采样**。大厂越来越流行在用户端埋点采集性能数据。Bakhvalov 举了 Netflix 的 Icarus telemetry 服务——跑在散布全球的几千台设备上,帮工程师看到「真实用户怎么感知性能」,这种数据在实验室里复刻不出来。 +- **telemetry:在真实用户设备上采样**。大厂越来越流行在用户端埋点采集性能数据。Bakhvalov 举了 Netflix 的 Icarus telemetry 服务,它跑在散布全球的几千台设备上,帮工程师看到「真实用户怎么感知性能」,这种数据在实验室里复刻不出来。 - **开销必须极低**。生产 profiling 的原则是「极低开销是首要」。Ren 等人 2010 年那篇论文的原话大意:在跑真实流量的数据中心机器上做持续 profiling,**可接受的总开销在 1% 以下**。所以只能用 lightweight 方法(低频采样、只抽一部分机器、短时间窗)。 -- **统计方法处理分位数指标**。生产性能看的不是「平均多快」,而是「p90 / p99 延迟多少」——长尾才是用户体验的杀手。LinkedIn 的做法(Bakhvalov 引 Liu 等人 2019)是用统计方法在生产环境做 A/B 测试,比较这些分位数指标。 +- **统计方法处理分位数指标**。生产性能看的不是「平均多快」,而是「p90 / p99 延迟多少」,长尾才是用户体验的杀手。LinkedIn 的做法(Bakhvalov 引 Liu 等人 2019)是用统计方法在生产环境做 A/B 测试,比较这些分位数指标。 一句话:**生产测量 = 保留噪声 + 统计推断**,和 micro 的「消除噪声 + 干净对比」是两套语言。 @@ -48,16 +50,16 @@ ch01-01 到 ch01-04 讲的是**开发机上**做 microbenchmark A/B 比较—— 产品迭代飞快,性能回归会持续漏进来。靠什么挡? -**第一反应:人眼看图。** 别。人会迅速失去焦点,尤其在噪声大的图上——Bakhvalov §2.3 那张图里,8 月 5 号的那次回落人眼能抓到,但后续几个小回归大概率被漏。而且这是每天都要做的、无聊的活,不适合人。 +**第一反应:人眼看图。** 别。人会迅速失去焦点,尤其在噪声大的图上。Bakhvalov §2.3 那张图里,8 月 5 号的那次回落人眼能抓到,但后续几个小回归大概率被漏。而且这是每天都要做的、无聊的活,不适合人。 **第二反应:定一个阈值,「跌幅超过 X% 就报警」。** 听起来合理,实际两个硬伤: -1. **阈值极难选**。设低了,一堆纯噪声的小波动触发报警,你天天查空气;设高了,真回归被滤掉。而且**小回归会累积**——Bakhvalov 举的例子:阈值设 2%,两次各 1.5% 的回归都被滤掉,两天累积成 3%,已经超阈值却没人管。 -2. **每个 test 要单独调阈值**。不同 benchmark 的噪声水平不同,一个阈值不可能通用。Chromium 项目的 LUCI 就是每个 test 显式配阈值的例子——能跑,但维护成本高。 +1. **阈值极难选**。设低了,一堆纯噪声的小波动触发报警,你天天查空气;设高了,真回归被滤掉。而且**小回归会累积**:Bakhvalov 举的例子,阈值设 2%,两次各 1.5% 的回归都被滤掉,两天累积成 3%,已经超阈值却没人管。 +2. **每个 test 要单独调阈值**。不同 benchmark 的噪声水平不同,一个阈值不可能通用。Chromium 项目的 LUCI 就是每个 test 显式配阈值的例子,能跑,但维护成本高。 ## 变点检测(change point analysis) -更新的做法是**变点检测**:不盯单一阈值,而是盯整条时间序列的**分布**什么时候变了。MongoDB 团队(Daly 等人 2020)在他们的 CI 系统 Evergreen 里实现了一套——用一个叫「E-Divisive means」的算法,在时间序列里自动找「分布发生变化的点」,在图上标出来,自动开 Jira ticket。这种做法的好处是它对噪声鲁棒(找的是分布结构变化,不是单次抖动),而且不需要每个 test 手动调阈值。 +更新的做法是**变点检测**:不盯单一阈值,而是盯整条时间序列的**分布**什么时候变了。MongoDB 团队(Daly 等人 2020)在他们的 CI 系统 Evergreen 里实现了一套,用一个叫「E-Divisive means」的算法,在时间序列里自动找「分布发生变化的点」,在图上标出来,自动开 Jira ticket。这种做法的好处是它对噪声鲁棒(找的是分布结构变化,不是单次抖动),而且不需要每个 test 手动调阈值。 另一个思路(Bakhvalov 引 Alam 等人 2019 的 AutoPerf):用**硬件性能计数器**(PMC,见 ch02/ch03)给每个函数建一个「性能指纹」,改动后的版本如果指纹偏离了基线,就标记异常。这种法子能抓到一些藏在并行程序里的复杂性能 bug。 @@ -71,7 +73,7 @@ ch01-01 到 ch01-04 讲的是**开发机上**做 microbenchmark A/B 比较—— 4. **判断性能有没有变**(Decide if performance has changed) 5. **可视化**(Visualize) -外加几条要求:支持自动和人工两种提交方式、结果可重复、发现回归要**及时**开 ticket——趁代码还热、作者还没切到下一个任务,回归最容易被修;拖两周,作者都忘了改过啥,修起来事倍功半。 +外加几条要求:支持自动和人工两种提交方式、结果可重复、发现回归要**及时**开 ticket。趁代码还热、作者还没切到下一个任务,回归最容易被修;拖两周,作者都忘了改过啥,修起来事倍功半。 ## 把这一章串起来 diff --git a/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md b/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md index 24a35c8ec..55d03556f 100644 --- a/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md +++ b/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md @@ -1,30 +1,32 @@ --- -title: "Benchmark 方法论参考卡" -description: "ch01 全章的速查页——后续每篇性能文章和 Lab 开头引用它。一张卡浓缩:环境就绪、可信 microbenchmark 写法、报告与比较、micro vs 生产/CI 的边界、perf 速查" chapter: 1 -order: 6 -tags: - - host - - cpp-modern - - intermediate - - 优化 - - 测试 +cpp_standard: +- 11 +- 17 +description: ch01 全章的速查页——后续每篇性能文章和 Lab 开头引用它。一张卡浓缩:环境就绪、可信 microbenchmark 写法、报告与比较、micro + vs 生产/CI 的边界、perf 速查 difficulty: intermediate +order: 6 platform: host -reading_time_minutes: 6 -cpp_standard: [11, 17] prerequisites: - - "怎么写一个可信的 microbenchmark" +- 怎么写一个可信的 microbenchmark +reading_time_minutes: 4 related: - - "为什么 microbenchmark 会骗你" - - "测量陷阱与环境就绪:16 条 checklist" - - "统计与报告:把分布变成结论" - - "生产测量与 CI 性能回归检测" +- 为什么 microbenchmark 会骗你 +- 测量陷阱与环境就绪:16 条 checklist +- 统计与报告:把分布变成结论 +- 生产测量与 CI 性能回归检测 +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 测试 +title: Benchmark 方法论参考卡 --- - # Benchmark 方法论参考卡 -> 这是 ch01 全章的**速查页**。本卷后续每一篇性能文章和性能 Lab,开头都会回引这一页讲的那套规矩——就像 vol5 用 TSan 贯穿并发正确性。这不是教程(教程是 ch01-01 到 ch01-05),是 reference card,贴墙上看的那种。 +> 这是 ch01 全章的**速查页**。本卷后续每一篇性能文章和性能 Lab,开头都会回引这一页讲的那套规矩,就像 vol5 用 TSan 贯穿并发正确性。这不是教程(教程是 ch01-01 到 ch01-05),是 reference card,贴墙上看的那种。 ## §0 前提(一句话) @@ -42,7 +44,7 @@ related: | 编译选项 | `RelWithDebInfo`(`-O2 -g`)+ profiling 加 `-fno-omit-frame-pointer` | | 体检 | `bash perf-env-check.sh`(见 ch01-03,只查不改) | -> ⚠️ **这些只在 micro A/B 场景做**。评估生产性能时**全部不做**——要复刻真实(保留 DFS、邻居、ASLR),用统计处理噪声。见 ch01-05。 +> ⚠️ **这些只在 micro A/B 场景做**。评估生产性能时**全部不做**:要复刻真实(保留 DFS、邻居、ASLR),用统计处理噪声。见 ch01-05。 ## §2 写可信 microbenchmark diff --git a/documents/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md b/documents/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md new file mode 100644 index 000000000..299c78c87 --- /dev/null +++ b/documents/vol6-performance/ch02-cpu-microarchitecture/02-01-memory-hierarchy.md @@ -0,0 +1,166 @@ +--- +chapter: 2 +cpp_standard: +- 14 +- 17 +description: CPU 和主存之间隔着 100 倍的速度鸿沟,存储层次就是用快但小贵的层缓存慢但大便宜的层。用本机实测的 memory mountain + 和指针追逐延迟阶梯,看清 L1/L2/L3/DRAM 各自的延迟与带宽,理解「为什么顺序访问比随机访问快两个数量级」 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 性能思维:efficiency 与 performance 不是一回事 +- 为什么 microbenchmark 会骗你 +reading_time_minutes: 11 +related: +- 缓存行与局部性:64 字节的最小单位 +- 流水线、ILP 与分支预测 +- TLB、huge page 与各 CPU 族微架构速查 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存管理 +title: 存储层次与延迟阶梯:为什么顺序访问快 100 倍 +--- +# 存储层次与延迟阶梯:为什么顺序访问快 100 倍 + +## 一个让人不舒服的事实:你的 CPU 大部分时间在等内存 + +我们在 ch00-01 立下过那个命题:`std::vector` 的二分查找(O(log n))能在中等规模下打败 `std::set` 的查找(也是 O(log n)),复杂度相同,性能差一个数量级。当时一句话带过的原因是「vector 连续缓存友好、set 节点分散 cache miss」。这一章我们把这个「缓存友好」彻底拆开,它背后是现代 CPU 一个冰冷的物理事实:**CPU 算得飞快,但数据搬不过来。** + +具体到数字,一颗当代 CPU 流水线每周期可以退休多条指令,而跑到主存(DRAM)取一个数据要花上百个周期。也就是说,**只要你的数据不在 cache 里,CPU 就得干等上百个周期**,算力根本用不上。Brendan Gregg 把这种现象叫做「CPU 是喷气式发动机,内存是自行车」,发动机再猛,被自行车拖着也快不起来。 + +理解这件事的前提,是先建立存储层次(hierarchy)的精确图像:数据离 CPU 越近,越小越贵越快;越远,越大越便宜越慢。这一篇我们就用本机实测把这张「延迟阶梯」画出来,然后讲清楚它怎么决定你写 C++ 时的每一个布局决策。 + +## 存储层次:一层快但小,下一层慢但大 + +先上一张「教科书 + 本机实测」对照表。延迟数字给量级(随架构和频率变化),但**比例关系**是稳定的,这才是要记的: + +| 层 | 典型延迟(周期)| 本机实测(ns)| 容量(本机 AMD Ryzen 7 5800H)| +|---|---|---|---| +| 寄存器 | 0 周期 | — | 通用寄存器若干,编译器分配 | +| L1 cache(数据)| ~4 周期 | **~1.2** | 32 KB,每核私有 | +| L2 cache | ~14 周期(Zen3) | **~3–9** | 512 KB,每核私有 | +| L3 cache | ~47 周期(Zen3) | **~11–60** | 16 MB,全核共享 | +| 主存 DRAM | ~200–400 周期 | **~120** | 几十 GB | + +> 周期数取自 Agner Fog《The microarchitecture of Intel, AMD and VIA CPUs》第 23 章 *AMD Zen 3*(Table 23.1:L1=4、L2=14、L3=47 周期);ns 为本机 5800H 实测的指针追逐结果(见下文)。 + +把这张表竖着看,你会得到本卷最重要的一组比例:**L1 命中约 1 纳秒,DRAM 取数约 120 纳秒,差了整整 100 倍,也就是两个数量级。** 这是「为什么顺序遍历比随机快两个数量级(延迟上)」的物理根基:顺序访问把后续数据提前拉进 cache,随机访问每次都 cold miss,只能去 DRAM 现取。 + +讲到这里,先别急着信我的话,我们把这张阶梯亲手测出来。 + +## 上手跑一跑:本机实测延迟阶梯 + +测延迟最干净的办法是**指针追逐(pointer chasing)**:在内存里铺一条「下一个地址藏在当前数据里」的环形链,让 CPU 走完它。关键在于下一个地址依赖当前这次 load 的结果,这是真依赖,硬件预取器没法提前猜(它不知道下一个要访问哪),所以测出来的就是**裸访存延迟**。 + +核心循环就这么几行(完整程序见本章代码 `memory_mountain.cpp`): + +```cpp +// 构造一条贯穿全部节点的置乱单环:nxt[perm[i]] = perm[(i+1) % n] +std::vector nxt(elems); +for (long i = 0; i < elems; ++i) nxt[perm[i]] = perm[(i + 1) % elems]; + +long idx = 0; +auto t0 = std::chrono::steady_clock::now(); +for (long s = 0; s < total_steps; ++s) { + idx = nxt[idx]; // 下一个地址依赖本次 load → 真依赖,预取器束手无策 + sink = sink + g_data[idx]; // 读 data[idx],制造链上的依赖 +} +auto t1 = std::chrono::steady_clock::now(); +``` + +把工作集(数组大小)从 4 KB 扫到 128 MB,跨越 L1/L2/L3/DRAM 各级边界,每次都让它把整个工作集「踩」一遍,我本机(`taskset -c 0` 绑核 0 降噪,WSL2 环境)跑出来的结果是: + +```text +===== B. 指针追逐随机读延迟 (ns/访问) ===== + size elems ns/access level(推断) + 4K 512 1.19 L1d + 8K 1024 1.18 L1d + 16K 2048 1.24 L1d + 32K 4096 2.50 L1d ← 工作集 = L1d 容量(32K),开始溢出 + 64K 8192 3.33 L2 + 128K 16384 4.04 L2 + 256K 32768 6.02 L2 + 512K 65536 8.88 L2 ← 工作集 ≈ L2 容量(512K),开始溢出 + 1024K 131072 11.42 L3 + 2048K 262144 12.40 L3 + 4096K 524288 22.59 L3 + 8192K 1048576 59.99 L3 + 16384K 2097152 96.27 L3 ← 工作集 = L3 容量(16M),剧烈颠簸 + 32768K 4194304 135.92 DRAM + 65536K 8388608 118.59 DRAM + 131072K 16777216 122.06 DRAM +``` + +这张表值得停下来读一会儿。它讲了一个非常清晰的故事: + +- **4K–16K:全在 L1,~1.2 ns。** 这就是「热缓存」的样子,数据就贴着计算单元。注意 1.2 ns ÷ 4 周期 ≈ 3.3 GHz,正好是 5800H 的基频运行点,跟 Agner 给的 L1 = 4 周期严丝合缝对上了。 +- **32K:开始抬头(2.5 ns)。** 工作集正好等于 L1d 容量(32 KB),装不下了,部分访问被挤到 L2。这就是 cache「溢出」的临界点。 +- **64K–512K:进 L2,3–9 ns。** 干净的 L2 区,但延迟随工作集增大往上爬,因为工作集越接近 L2 容量,冲突/容量 miss 越多。 +- **1M–16M:进 L3,11–60 ns。** 注意 16M 那一行 96 ns,L3 颠簸区的典型表现(工作集 = L3 容量,疯狂互相驱逐)。 +- **32M 以上:DRAM,~120 ns。** 阶梯到此彻底跌到底。 + +L1 的 1.2 ns 和 DRAM 的 120 ns,正好差 **100 倍**。这不是教科书上的比喻,是你手上这台机器的物理事实。 + +> 笔者这里要提醒一句:我跑这台机器是 WSL2(虚拟机),CPU 频率被宿主管着,读不到 governor,绝对 ns 会有百分之几的抖动。但**各级之间的比例关系**(L1≈1、L2≈几个、L3≈几十、DRAM≈上百 ns)是硬件决定的,稳得很。性能文章里真正可信的是比例,不是某个绝对数。 + +## memory mountain:把局部性画成一座山 + +延迟阶梯只回答了「访问延迟随工作集怎么变」这一个维度。CSAPP 第 6 章那个著名的**memory mountain(内存山)**多加了一个维度,**步长(stride)**,于是空间局部性和时间局部性被同时摊在一张表上,信息密度更高。 + +它的做法是:固定一个工作集 size,以不同 stride 顺序环形读数组,测吞吐(GB/s)。stride 越小,相邻两次访问越可能落在同一缓存行(空间局部性);size 越小,数据越可能还在 cache 里(时间局部性)。本机实测: + +```text +===== A. memory mountain: 读吞吐 (GB/s) ===== +size\stride(B) 8B 16B 32B 64B 128B 256B 512B + 1K 16.9 17.0 17.0 16.7 16.1 16.5 15.3 + 8K 16.9 16.6 17.0 17.2 17.1 17.0 17.1 + 32K 16.8 16.9 16.9 16.9 16.8 17.2 16.5 ← L1d 边界 + 64K 16.7 16.9 17.2 16.7 16.8 16.6 7.3 + 256K 17.2 17.2 17.2 16.6 16.6 16.7 7.6 ← L2 区 + 512K 16.7 16.8 16.6 14.3 11.3 11.6 5.3 ← L2 边界 + 1024K 16.7 16.6 16.2 12.9 8.7 11.1 4.5 + 4096K 16.5 16.9 16.3 12.9 8.1 10.1 4.4 ← L3 区 + 16384K 13.0 10.5 7.7 3.8 3.1 3.1 2.9 ← L3 边界,跌了 + 32768K 14.5 11.0 6.7 3.8 2.7 2.6 2.1 ← 进 DRAM +``` + +怎么读这张山?盯住两个方向: + +**沿「左列」往下看(stride = 8B,顺序连续访问)**:哪怕工作集已经 32 MB(远超 L3),吞吐还有 14.5 GB/s,跟 1 KB 时几乎一样。这是**硬件预取器(prefetcher)**的功劳:它检测到「你在按固定步长顺序扫内存」,就提前把后面的缓存行拉进 cache。换句话说,**顺序访问哪怕在 DRAM 上也能逼近 L1 的吞吐**,因为预取器把延迟藏起来了。这是「顺序遍历快」的真正机制,下面会展开。 + +**沿「右下角」看(stride = 512B,工作集 32 MB)**:吞吐崩到 2.1 GB/s,比左上角的 17 GB/s 差了 8 倍。stride 512B 意味着每次访问踩一个新缓存行、而且步长太大预取器追不上(它一般只能预取有限条流),于是每次访问都是一次近乎随机的 DRAM 取数。**这才是 DRAM 的真面目:顺序起来很快,随机起来极慢。** + +把这张山和前面的延迟阶梯合起来看,结论是同一组:存储层次的快慢**同时**取决于「数据在不在 cache 里」(时间局部性)和「访问是不是连续的」(空间局部性)。前者由工作集大小决定,后者由访问模式决定。两者都好,跑出 L1 的速度;两者都差,跌到 DRAM 的零头。 + +## 回到 C++:这些数字在告诉我们什么 + +硬件知识不为炫技,为的是指导写代码。从这张阶梯里能直接翻译出几条 C++ 布局原则,我们一条条对应: + +**1. 数据连续存放,优先用连续容器。** 这是最直接受益于空间局部性的一条。`std::vector` / `std::array` 把元素紧凑排在一块连续内存,遍历时一个缓存行(64 字节)能装好几个元素,预取器还帮你提前拉,这就是 vector 遍历快的根因。反过来,`std::list` / `std::set` 的节点各自 `new` 出来、散落在堆各处,指针追逐就是上面延迟阶梯里那条最慢的曲线。**复杂度相同,布局不同,性能差一个数量级。** 这条 ch00-01 已经用 vector 二分 vs set 查找实测过,这里补上了它的物理根据。 + +> 边界提醒:vector 内部「三指针、扩容策略(2 倍增长)、insert/erase 的元素搬移成本」这些**设计层面**的内容归 vol3 讲(它回答「vector 为什么这么设计」)。vol6 只回答「在硬件上跑时,连续布局为什么快」,就是我们这张延迟阶梯。 + +**2. 把热数据集控制在 L3 以内。** 延迟阶梯显示,工作集一旦超过 cache 容量,延迟就从纳秒级跳到几十上百纳秒。一个很实际的推论:**经常被反复扫描的数据,要让它「装得进 cache」**。比如一个被高频查询的表,如果 20 MB,5800H 的 L3 只有 16 MB,每次查询都在颠簸;如果能压到 12 MB 以内,性能可能直接翻几倍。这不是玄学,是表上 16M 那一行(96 ns)和 4M 那一行(22 ns)的差距。 + +**3. 随机访问场景,换能降低访存次数的结构。** 链表/平衡树每次跳一个节点就是一次潜在 cache miss(节点分散)。工程上常见的折中是**把树拍扁**:用 B-tree / B+ tree 替代二叉平衡树,每个节点存几十上百个键(塞满一两个缓存行),树高骤降,miss 次数随之骤降。这就是为什么数据库索引、文件系统全用 B+ 树而不是红黑树,不是为了省指针,是为了省 cache miss。`std::set` 在数据量大、查询密集的场景被 B-tree 花式吊打,根因就在这。 + +**4. 容量先 reserve,别让扩容打散布局。** 这条偏 C++ 工程实践:`vector` 反复 `push_back` 触发扩容,会把整块数据搬到新内存,旧 cache 全失效,而且搬本身就是一次大开销。提前 `reserve(估计容量)` 能避免这件事。具体的扩容机制(为什么是 2 倍、搬一次的成本)归 vol3,这里只记结论:**热路径上的 vector,提前 reserve**。 + +## 留给下一篇的线头 + +这一篇我们用指针追逐画出本机的**延迟阶梯**(L1 ~1 ns、L2 ~几个 ns、L3 ~几十 ns、DRAM ~120 ns,逐级 100 倍鸿沟),用 memory mountain 看清**空间局部性(stride)和时间局部性(size)怎么共同决定吞吐**。由此翻译出的 C++ 原则,用连续容器、控热数据集、随机访问换 B-tree、reserve,都是这套数字的直接推论。 + +但有两个细节我们刻意绕过去了:那个反复出现的「**64 字节**」到底怎么算出来的(为什么 L1 表里 32K 是边界?为什么 stride 64B 是个坎?),以及「缓存行」作为 cache 最小单位带来的那些反直觉代价(比如两个不相关的变量挤在同一行会互相踢)。这正是下一篇的主题。 + +还有一件大事留在更后面:这一章只讲了「数据在哪里慢」,没讲「计算为什么会停」。流水线、指令级并行、分支预测,这些是 ch02-03 的内容,它们和存储层次一起,构成了 vol6 后面所有「按瓶颈部位优化」的硬件底座。 + +## 参考资源 + +- Agner Fog《The microarchitecture of Intel, AMD and VIA CPUs》§23 *AMD Zen 3*:Zen 3 的缓存延迟表(L1=4、L2=14、L3=47 周期)、流水线宽度、分支吞吐。本地:`.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Bakhvalov, D.《Performance Analysis and Tuning on Modern CPUs》第 3 章 *CPU Microarchitecture*:存储层次与延迟数字的工程视角 +- Bryant & O'Hallaron《Computer Systems: A Programmer's Perspective》(CSAPP)第 6 章 *The Memory Hierarchy*:memory mountain 实验的出处,以及「空间/时间局部性」的形式化定义 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch02/memory_mountain.cpp` diff --git a/documents/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md b/documents/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md new file mode 100644 index 000000000..e0aae5eab --- /dev/null +++ b/documents/vol6-performance/ch02-cpu-microarchitecture/02-02-cacheline-and-locality.md @@ -0,0 +1,195 @@ +--- +chapter: 2 +cpp_standard: +- 17 +description: cache 不是按字节、也不是按你访问的数据大小工作的,而是以 64 字节的缓存行(cacheline)为最小搬运单位——哪怕只读 1 字节,硬件也会把整个 + 64 字节拉进 cache。用步长扫描精确定位这 64 字节断崖,再用行优先 vs 列优先遍历 6 倍差距看清空间局部性的力量 +difficulty: intermediate +order: 2 +platform: host +prerequisites: +- 存储层次与延迟阶梯:为什么顺序访问快 100 倍 +reading_time_minutes: 12 +related: +- 流水线、ILP 与分支预测 +- 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch +tags: +- host +- cpp-modern +- intermediate +- 优化 +- 内存管理 +title: 缓存行与局部性:64 字节的最小搬运单位 +--- +# 缓存行与局部性:64 字节的最小搬运单位 + +## 从上一张山的「坎」说起 + +上一篇的 memory mountain 里藏着一个细节,我们当时一笔带过了。把那张山的工作集 = 1024K(落在 L3)那一行单独拎出来: + +```text +1024K 16.7 16.6 16.2 12.9 8.7 11.1 4.5 + 8B 16B 32B 64B 128B 256B 512B ← stride +``` + +stride 从 8B 涨到 32B,吞吐几乎不动(16 GB/s 上下);可一旦跨过 **64B**,数字就明显往下掉。这不是噪声,这个「坎」的位置是稳定的,它对应着 cache 一个硬邦邦的物理参数:**缓存行(cacheline)的大小**。这一篇我们就把这个 64 字节掰开讲清楚,它是理解一切「布局为什么影响性能」的钥匙。 + +## 缓存行:cache 的最小单位 + +很多人对 cache 有个直觉但错误的模型:我访问 `int`(4 字节),硬件就去内存搬 4 字节进来。**完全不是。** 真实情况是:cache 和主存之间的搬运,以一个固定大小的块为单位,这个块叫**缓存行**(也叫 cache line / cache block)。当代 x86 和 ARM 上,这个大小几乎清一色是 **64 字节**。 + +含义是这样的:无论你访问一个 1 字节的 `char`、一个 8 字节的 `double`,还是一次只读 `int`,**只要这次访问 miss 了 cache,硬件就会把「那个地址所在的整条 64 字节缓存行」整块拉进来。** 你只想花 4 字节的成本,实际花了搬运 64 字节的时间(当然,这 64 字节里后续的部分如果你接下来访问,就免费了,这正是空间局部性的机制)。 + +```bash +$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size +64 +$ getconf LEVEL1_DCACHE_LINESIZE +64 +``` + +`index0` 是 L1 数据 cache,`coherency_line_size` 就是缓存行大小。L1/L2/L3 全是 64 字节(可以挨个 `index1/2/3` 查,本机都是 64)。这个值在同代 CPU 上是固定的,所以性能文章里经常直接写死「64B」,但**第一次在一台陌生机器上调优时,先 `getconf` 确认一下**,有些低功耗 ARM 核或者老 Intel 是 32 字节。这条提醒看似啰嗦,真踩过 32B 行的坑就记住了。 + +讲清楚模型,接下来我们用行为实验亲手把这个 64 测出来。 + +## 上手跑一跑:步长扫描定位 64 字节断崖 + +思路很直接:把工作集固定在一个「大于 L1、落在 L3」的尺寸(这样每次 miss 有真实的成本),然后只变 stride,看吞吐在哪个步长出现断崖。如果断崖正好出现在 stride = 64B,那就反证了「cache 以 64 字节为单位」。 + +核心还是顺序环形遍历,但这次我们精细扫步长: + +```cpp +double stride_throughput(long elems, long stride_elem) { + const long ACCESSES = 64'000'000; + long mask = elems - 1; // elems 是 2 的幂 + int sink = 0; long idx = 0; + auto t0 = std::chrono::steady_clock::now(); + for (long i = 0; i < ACCESSES; ++i) { + sink += g_data[idx]; + idx = (idx + stride_elem) & mask; + } + do_not_optimize(sink); // 防 DCE,语义同 Google Benchmark DoNotOptimize + auto t1 = std::chrono::steady_clock::now(); + double secs = std::chrono::duration(t1 - t0).count(); + return (double)ACCESSES / secs / 1e6; // M 访问/秒 +} +``` + +工作集 2 MB(`int` 数组,512K 元素),`taskset -c 0` 绑核,扫一遍步长: + +```text +===== A. 步长扫描(工作集 2MB,落 L3)===== +stride(B) M访问/秒 说明 + 4B 2017.5 < cacheline:同行的多次访问被摊薄 + 8B 1956.3 < cacheline:同行的多次访问被摊薄 + 16B 1986.7 < cacheline:同行的多次访问被摊薄 + 32B 1962.3 < cacheline:同行的多次访问被摊薄 + 48B 1820.1 < cacheline:同行的多次访问被摊薄 + 56B 1543.5 < cacheline:同行的多次访问被摊薄 + 64B 1422.1 = cacheline:每次访问正好换一行 ← 断崖 + 72B 1181.8 > cacheline:每次访问都是新行 + 96B 1034.8 > cacheline:每次访问都是新行 + 128B 995.8 > cacheline:每次访问都是新行 + 256B 1324.2 > cacheline:每次访问都是新行 ← 反常凸点,见下文(疑似预取器) + 512B 528.7 > cacheline:每次访问都是新行 +``` + +读这张表的姿势:盯住「**stride = 64B 是分水岭**」。 + +- **stride < 64B(4 到 32)**:连续几次访问落在同一条 64 字节缓存行里。第一次访问 miss,把整行拉进来;之后几次访问全都命中这条已经搬来的行,几乎免费。所以吞吐高且平坦(~2000 M/秒),这条 64 字节里没被你访问到的部分,被你「免费搭车」了。 +- **stride ≥ 64B(64、72、96…)**:每一次访问都踩进一条**新的**缓存行,「搭车」福利消失,每次都要付一次搬行的成本。吞吐应声跌到 ~1000–1400 M/秒。 + +这个从 ~2000 跌到 ~1000 的坎,精确出现在 stride = 64B,就是我们用行为给「缓存行 = 64 字节」做的交叉验证。 + +> 笔者要特意点一下 256B 那一行:它反而比 128B 高(1324 vs 996),看起来「不科学」。**最可能的嫌疑是硬件预取器**,当代 CPU 的预取器能识别「固定等步长」的访问流(相当大的步长也在它可学习的窗口内),提前把后面的行拉进来,部分藏住延迟。**但这是基于机制的推测,不是实测定论**:本机 WSL2 没条件关掉预取器做对照(关预取要写 MSR,WSL2 拿不到),且 Zen 预取器的精确可跟踪步长上限/流数没有公开文档;256B 那一行同时也吃了「同数组遍历周期变短、预热更充分」的几何红利(见代码里 `mask = elems-1` 的环形遍历)。512B 时数字继续往下掉,主导因素更可能是 cacheline 利用率(每次访问只消费 64B 行里的一小部分,见 02-01 stride 大步长带宽下降)。这件事提醒我们:**测 cache 行为时,预取器是个经常出来捣乱的变量**,看到不合常理的「凸点」先怀疑它,但也要承认,没做对照实验之前,「怀疑」不等于「证明」(这正是 vol6 ch00-01 反复警告的「听起来像解释」的伪因果陷阱)。 + +## 空间局部性:连续布局是「双赢」 + +把上一节的结论翻译成 C++ 设计原则,就是大家都听过但未必想透的那条:**数据要连续**。连续布局吃到的是双重红利: + +1. **摊薄缓存行加载**:一条 64 字节搬进来,连续访问能把这 64 字节里的每个元素都用上,不浪费。同样一次 miss,你访问 16 个 `int`(64B)和访问 1 个 `int`,搬的成本一样。 +2. **喂饱预取器**:预取器最爱「顺序等步长」的流。你连续遍历 `vector`,它检测到 stride = 4B 的流,提前把后面几条缓存行拉进 cache,等你用到时早已命中,**连 miss 都帮你省了**。 + +这就是为什么 `std::vector` / `std::array` / 原生数组遍历起来飞快,而 `std::list` / `std::set` / `std::unordered_map`(链式桶)的节点遍历慢:后者的节点分散在堆各处,既不能摊薄(每个节点占用一条缓存行但只读一小部分),预取器也跟不上(下一个节点的地址不可预测)。 + +这条原理有一个特别经典、每个 C++ 程序员都该亲手踩一次的坑:二维数组的遍历顺序。 + +## 行优先 vs 列优先:6 倍差距从哪来 + +C 和 C++ 的二维数组(无论原生 `a[N][N]` 还是用一维模拟的 `a[i*N+j]`)在内存里都是**行优先(row-major)**存储的,第一行存完存第二行。也就是说,`a[i][j]` 和 `a[i][j+1]` 在内存里挨着(差 4 字节),但 `a[i][j]` 和 `a[i+1][j]` 隔了一整行(差 `N*4` 字节)。 + +这意味着:双层循环遍历矩阵时,**内层循环沿「行」走是顺序访问,沿「列」走是大步长跳跃**。我们用 2048×2048 的 `int` 矩阵(16 MB,正好 = 本机 L3 大小,放大差异)实测: + +```cpp +void walk_2d(int* a, int N, bool row_major) { + volatile int sink = 0; int s = 0; + auto t0 = std::chrono::steady_clock::now(); + if (row_major) + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) s += a[i * N + j]; // 沿行:顺序,stride=4B + else + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) s += a[j * N + i]; // 沿列:跳跃,stride=N*4B=8KB + sink = s; + auto t1 = std::chrono::steady_clock::now(); + /* 打印耗时和吞吐 */ +} +``` + +```text +===== B. 2D 遍历(N=2048,矩阵 16 MB = L3 大小)===== + 行优先 row-major: 1.0 ms, 16.7 GB/s + 列优先 col-major: 6.3 ms, 2.7 GB/s +``` + +**同样的矩阵、同样的计算量、同样的命中 L3,列优先比行优先慢 6 倍多。** + +拆开看:行优先时内层 `j` 每次前进 4 字节,完全顺序,一条缓存行(64B)能服务 16 次 `int` 访问,预取器还提前拉数据,吞吐 16.7 GB/s 跑满。列优先时内层 `j` 让地址每次跳 `N*4 = 8192` 字节,每一次访问都踩进一条全新的缓存行,而且步长 8 KB 大到预取器追不上(参考上一节 512B 那行的惨状),于是退化成 L3 的随机访问吞吐,只剩 2.7 GB/s。 + +这就是「内层循环必须沿内存连续方向」的全部理由。写矩阵乘法、图像卷积、网格类模拟的朋友,这个方向写反了就是白送几倍性能,而且编译器**不会**帮你自动翻转(它不能证明交换循环次序不影响结果,大多数情况其实不影响,但编译器不敢)。 + +> 顺带一提:这个「交换循环次序」叫做 **loop interchange**,是后面 ch04-02 循环优化会专门讲的编译器优化之一。这里你先记住它的物理动机:让最内层循环沿着内存连续方向走。 + +## 结构体字段排序:让热数据挤进同一条行 + +缓存行还直接决定了一件 C++ 特有的布局决策:**结构体字段该怎么排**。看这两个结构体: + +```cpp +struct Bad { + int id; // 热:每帧都要查 + char debug_tag; // 冷:只有打日志才读 + double values[6]; // 热:每帧都算 + void* parent; // 冷:只有遍历树时才用 +}; + +struct Good { + int id; // 热 + double values[6]; // 热 ← 热字段聚一起 + char debug_tag; // 冷 + void* parent; // 冷 ← 冷字段分开 +}; +``` + +`Bad` 里冷热字段交错,遍历时每碰一下热字段,顺带把同一条缓存行里的冷字段也搬进了 cache,白白占掉宝贵的 cache 空间,本来能多缓存几个热对象的。`Good` 把热字段集中,一条缓存行尽可能多的装「真的会被用」的数据。这叫**热/冷字段分离(hot/cold splitting)**,本质就是给缓存行排兵布阵,让它每一格都花在刀刃上。 + +这条经验有个更激进的版本叫 **AoS → SoA**(Array of Structs 改 Struct of Arrays),当你有一大堆对象、但每次只处理它们的某个字段时(比如物理模拟里只更新位置),把「同一字段连续排」比「同一对象字段挨着排」快得多。这件事比字段排序更深、更有讲究,我们留到 ch04-01「后端内存瓶颈」专章讲,这里只种下这个念头。 + +> 边界提醒:结构体**对齐、padding、`#pragma pack`** 这些「为什么 `sizeof` 会比你以为的大」的机制(比如 `char` 后面编译器会插填充让 `double` 对齐到 8 字节),属于 ABI / 布局规则,vol4 讲类布局时会涉及。vol6 这里只关心「热字段聚类对 cache 友好」这层性能含义。 + +## 一个伏笔:同行的代价(false sharing) + +缓存行还有一面我们故意还没展开。既然「相邻地址共享同一条缓存行」,那两个**不相关**的变量如果碰巧挤在同一条 64 字节里,会发生什么?会互相把对方踢出 cache,甚至更糟,在多线程下引发**伪共享(false sharing)**:线程 A 写变量 x、线程 B 写变量 y,虽然 x、y 逻辑上无关,但它们在同一条缓存行,硬件的一致性协议会反复让这条行在两个核之间来回 invalidate,性能塌方。 + +这件事单核上看不出来,是多核专属的坑,而且坑极深。所以完整的 false sharing 实测和 `alignas(64)` 解法,我们留到 ch05 多核性能那章专门拆,这里先埋个伏笔:缓存行的「共享」在单核是红利,在多核可能变成税。 + +## 留给后面的线头 + +到这一篇为止,我们确认了 cache 的最小搬运单位是 **64 字节的缓存行**,并用两个实测看清它的后果:**步长扫描**精确在 stride = 64B 处看到吞吐断崖(行为级证明),**行优先 vs 列优先遍历差 6 倍**(内层循环方向决定了你访问是顺序还是大步长跳跃),再加上**热字段聚类**让宝贵的缓存行只装会被用到的数据(AoS→SoA 留到 ch04-01),以及多核下的暗面 **false sharing**(留到 ch05)。 + +但程序的性能不只取决于数据搬运,CPU 算指令的方式本身(流水线、指令级并行、分支预测)也能让同样的数据跑出几倍的差距。下一篇我们就走进 CPU 的执行核心。 + +## 参考资源 + +- Agner Fog《The microarchitecture of Intel, AMD and VIA CPUs》§22.16 *Cache and memory access*:Zen 族的缓存参数(64 B 行、各级路数/组数)、硬件预取行为。本地:`.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Bryant & O'Hallaron《CSAPP》第 6 章 *The Memory Hierarchy*:缓存行、空间/时间局部性的形式化定义与 memory mountain +- Drepper, U.《What Every Programmer Should Know About Memory》:缓存行、对齐、预取的工程细节(经典长文) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch02/cacheline_locality.cpp` diff --git a/documents/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md b/documents/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md new file mode 100644 index 000000000..f7f7c4f58 --- /dev/null +++ b/documents/vol6-performance/ch02-cpu-microarchitecture/02-03-pipeline-ilp-branch.md @@ -0,0 +1,154 @@ +--- +chapter: 2 +cpp_standard: +- 17 +description: 前两篇讲清了数据搬运,这一篇走进 CPU 的执行核心:指令流水线怎么把多条指令重叠推进、乱序执行怎么从你的代码里挖指令级并行(ILP)、分支预测猜错为什么要冲刷流水线。用 + dot1 vs dot4 的 2.9 倍和已排序 vs 打乱数组的 4.2 倍两个实测看清,并引入数据/控制/结构三类流水线冒险 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- 存储层次与延迟阶梯:为什么顺序访问快 100 倍 +- 缓存行与局部性:64 字节的最小搬运单位 +reading_time_minutes: 11 +related: +- 循环与计算优化:code motion、展开与多累加器 +- 分支:branchless 与 predication +tags: +- host +- cpp-modern +- advanced +- 优化 +- atomic +title: 流水线、ILP 与分支预测:同样的数据,执行方式差几倍 +--- +# 流水线、ILP 与分支预测:同样的数据,执行方式差几倍 + +## 数据搬得动了,接下来算得动吗 + +前两篇我们把存储层次彻底拆过了一遍:数据连续存放、行优先遍历、控热数据集,这些解决的是「数据能不能及时送到 CPU 嘴边」。但你会在 ch04 看到一些反直觉的现象:有时候数据布局没变,只是把循环里多写几个累加器、或者把一个分支换个写法,性能又差好几倍。这说明性能的另一半决定因素在 **CPU 怎么执行指令**,跟数据搬运无关。 + +这一篇就走进 CPU 的执行核心,讲三个互相关联的机制:**流水线**让指令重叠推进、**指令级并行(ILP)**让无依赖的指令真正同时跑、**分支预测**在遇到 `if` 时赌一把方向。三者共同决定了「你的指令序列能跑多快」。讲到「够支撑判断」的深度就停,更深的水(寄存器重命名、重排序缓冲 ROB、执行端口调度)留给 Agner 的微架构手册,我们给指针。 + +## 流水线:指令的工厂装配线 + +CPU 执行一条指令不是「一个周期干完」那么简单,而是像工厂装配线一样分成多个阶段,典型地:取指(fetch)→ 译码(decode)→ 执行(execute)→ 访存(memory)→ 写回(write-back)。每条指令依次流过这些阶段,**不同指令的不同阶段在同一周期里并行推进**:第 N 条在执行时,第 N+1 条在译码,第 N+2 条在取指。这就是**流水线(pipeline)**。 + +理想情况下,流水线每个周期退休一条指令(经典标量流水线);当代 CPU 更进一步做**超标量(superscalar)**,每个阶段处理多条,于是每周期能退休多条指令。Agner 在 AMD Zen 章给出 Zen 的退休宽度是 **8 条 µop/周期**(µop 是 CPU 内部的微操作,一条 x86 指令可能拆成多条 µop)。这是「CPU 算得飞快」的具体含义:一个周期能完成 8 个微操作。 + +但这个「8/周期」是**上限**,能不能达到,取决于两件事:流水线**喂不喂得饱**(有没有冒险阻塞),以及代码里**有没有足够的独立指令供它并行**(ILP)。 + +## ILP:乱序执行从你代码里挖并行 + +「8 条 µop/周期」这个吞吐要成立,前提是这 8 条 µop 之间**没有数据依赖**。当代 CPU 用**乱序执行(out-of-order execution)**做到这一点:它不按你写的顺序一条条执行,而是动态扫描指令窗口,把互相不依赖的指令同时发射到多个执行单元。你的代码里数据依赖越少,能并行的指令越多,**指令级并行度(ILP)越高**,跑得越快。 + +反过来,如果你写了一条**长依赖链(dependency chain)**,每条指令都依赖上一条的结果,CPU 再宽也没用,只能乖乖等。最常见的例子就是「单累加器归约」: + +```cpp +float dot1(const float* a, const float* b) { + float acc = 0.0f; + for (int i = 0; i < N; ++i) acc += a[i] * b[i]; // 每次循环都依赖上次的 acc + return acc; +} +``` + +`acc += a[i]*b[i]` 是一个**真依赖**:本次加法要用上一次 `acc` 的值,CPU 没法把相邻几次乘加并行起来。这就是一条长链,ILP 几乎为零,执行单元大部分时间在干等下一次加法完成。 + +打破这条链的经典办法是**多累加器**:用几个独立的累加器,各自维护一条短链: + +```cpp +float dot4(const float* a, const float* b) { + float a0 = 0, a1 = 0, a2 = 0, a3 = 0; + for (int i = 0; i < N; i += 4) { + a0 += a[i] * b[i]; // 链 0 + a1 += a[i + 1] * b[i + 1]; // 链 1,与链 0 互不依赖 + a2 += a[i + 2] * b[i + 2]; // 链 2 + a3 += a[i + 3] * b[i + 3]; // 链 3 + } + return a0 + a1 + a2 + a3; +} +``` + +四个累加器是四条独立链,CPU 把它们分发到不同的执行端口真正并行执行。我们实测一下(为了演示标量 ILP,这里编译时**故意关掉自动向量化**,否则 SIMD 会把这道题做得更快,把 ILP 的效果盖住,这件事本身就是个伏笔,下面会讲): + +```text +===== B. ILP(点积 32768 float,标量无向量化)===== + 单累加器 dot1: 23.7 us/次 (一条长依赖链,CPU 只能等上次加完) + 4 累加器 dot4: 8.1 us/次 (4 条独立链,CPU 并行填满执行端口) + 差 2.92x +``` + +**同样的乘加次数,4 个累加器比 1 个快接近 3 倍。** 这是 ILP 的直接证据。汇编也能看出来端倪: + +```text +; dot1:一条串行链 + mulss (%rsi,%rax), %xmm0 ; 算 a[i]*b[i] → xmm0 + addss %xmm0, %xmm1 ; acc += xmm0,下次循环的 add 依赖这里的 xmm1 + +; dot4:四条独立链(累加器 xmm1/xmm4/xmm3/xmm2 互不依赖) + mulss (%rsi), %xmm0 ; addss %xmm0, %xmm1 + mulss -12(%rsi), %xmm0 ; addss %xmm0, %xmm4 ← 独立于 xmm1 + mulss -8(%rsi), %xmm0 ; addss %xmm0, %xmm3 ← 独立于 xmm1/xmm4 + mulss -4(%rsi), %xmm0 ; addss %xmm0, %xmm2 ← 独立于上面三条 +``` + +dot1 的每次 `addss` 都要等上一次写回 `%xmm1`;dot4 的四次加法写进四个不同寄存器,乱序引擎一把全发射出去。这条经验在 ch04-02「循环与计算优化」会专门讲,叫做**多累加器变换(multiple accumulators)** / **打破依赖链**,是科学计算、归约、点积类代码最容易捡到的性能红利之一。 + +> 笔者插一句:你可能会想「我自己不用写 dot4,编译器不会自动展开吗?」会,但通常要 `-O3 -funroll-loops`,而且它不能违反浮点的结合律(`-ffast-math` 才行),所以默认 `-O2` 下 FP 归约常常保持单链。这也是为什么上面要手动写 dot4 才能稳定吃到 ILP。这个「编译器能做和会做之间隔着优化等级与语言语义」的话题,ch04 会系统讲。 + +## 分支预测:赌对方向就免费,赌错冲刷流水线 + +第二个机制是**分支预测**。流水线为了维持高吞吐,遇到 `if` 时不会停下来等条件算出来,它**猜**走哪条路,然后投机(speculate)地往下执行。猜对了,投机的工作全保留,几乎免费;猜错了,投机阶段塞进流水线的指令全要**冲刷(flush)**掉,再从正确方向重新取指。冲刷的代价就是白白执行了十几到二十来个周期的工作,pipeline 越深,赌错越疼。 + +这就引出一个反直觉但极其重要的结论:**分支的开销不取决于分支本身,而取决于它「好不好预测」。** 一个永远走同一方向的分支(比如循环结束条件),预测器命中率 100%,几乎免费;一个 50/50 随机的分支,预测器只能猜对一半,每次猜错都付冲刷代价。我们用最经典的实验来看这件事,对同一个数组做「大于 128 就累加」: + +```cpp +uint64_t sum_gt128(const std::vector& d) { + uint64_t s = 0; + for (int i = 0; i < N; ++i) { + if (d[i] >= 128) s += d[i]; // 一个二分分支 + } + return s; +} +``` + +准备两份数据:**打乱的数组**(每个元素 ≥128 与否近似随机,分支 50/50 不可预测)和**排序后的数组**(前一半全 <128、后一半全 ≥128,分支模式极清晰)。同样的代码、同样的数据量,只是顺序不同: + +```text +===== A. 分支预测(条件累加 32768 元素,3000 次平均)===== + 打乱(随机分支,预测器猜不中): 0.053 ms/次 + 排序(模式清晰,几乎不预测失败): 0.013 ms/次 + 差 4.2x +``` + +**4.2 倍。** 同样的累加、同样的数据搬运成本,差距全来自分支预测失败的冲刷。排序数组的分支是「先连续不走、再连续走」,预测器两三次就学会,命中率接近 100%;打乱数组每次都赌不准,几乎每两次就冲刷一次流水线。这就是 Stack Overflow 上那个著名的「为什么处理排序数组更快」的答案,根因不在数据,在**分支的可预测性**。 + +这条结论有两个推论贯穿后面几章: + +1. **可预测的分支几乎免费**。循环里的 `if` 如果绝大多数走同一方向,你不用费心去消除它。该花力气消除的是**数据相关的、随机的分支**。 +2. **数据相关的随机分支可以用 branchless(无分支)写法消除**。把 `if` 改写成 `cmov`(条件传送)或算术 trick,让 CPU 不用赌,没有投机就没有冲刷。这件事 ch04-06「分支:branchless 与 predication」会专章讲,这里只先种下动机。 + +> 这个实验有个坑必须说清楚:上面代码如果用**默认 `-O2`** 编译,GCC 会把 `if` 累加**自动向量化**成 SIMD 比较-加法,或者转成 `cmov`,无论哪种,「分支」都没了,打乱和排序就一样快了(我第一遍跑就是这么翻车的,1.0x)。所以我这里特意加了 `-fno-tree-vectorize -fno-tree-slp-vectorize -fno-if-conversion` 把标量分支保留住(分别挡住循环级向量化、SLP 向量化、if-conversion 三条通路),才测出 4.2 倍。这件事的教学意义在于:**你看到的「分支开销」其实取决于编译器把你的代码编译成了什么**,它可能已经帮你无分支化了,也可能没有。读汇编确认,别空口假设。 + +## 流水线冒险:三类阻塞,正好对应前两节 + +把流水线、ILP、分支预测串起来,CSAPP 第 4 章用「**冒险(hazard)**」这个词统一它们:某些情况下流水线会被迫停顿(stall),分三类,正好对上前面讲的: + +- **数据冒险(data hazard)**:相邻指令有真数据依赖(本条要用上一条的结果),流水线必须等。这就是 ILP 一节里 dot1 的困境,`acc +=` 的长依赖链是一连串 RAW(写后读)冒险。破法是打破依赖链、提升 ILP。 +- **控制冒险(control hazard)**:遇到分支,取指方向不确定。这就是分支预测一节的主题,破法要么让分支好预测,要么用 branchless 干脆消除分支。 +- **结构冒险(structural hazard)**:多条指令同时争抢同一个执行资源。比如 Zen 整数单元有 4 个 ALU,但除法器只有一个,同一周期多条整数除法就要排队;FP 除法更稀缺,延迟也长(几十周期)。这就是为什么 ch04-03「数据类型与算术」会专门讲「除法是瓶颈、能用乘法或位运算替代就替代」,不是除法慢,是除法器少且延迟高,容易撞结构冒险。 + +记住这三个名字就够用了。CSAPP 第 4 章有完整的冒险检测与转发(forwarding)机制,属于计算机体系结构课的内容,vol6 不复刻,我们关心的是「这三类阻塞怎么翻译成 C++ 代码层面的性能坑」,那是 ch04 的事。 + +## 留给下一篇的线头 + +这一篇讲清了 CPU 执行端的三个机制:**流水线**把指令重叠推进(Zen 类 CPU 理论退休宽度每周期 8 条 µop,但这是上限)、**ILP** 决定能不能逼近这个上限(长依赖链 dot1 把 ILP 压到零,多累加器 dot4 把它拉起来,实测差 2.9 倍)、**分支预测**对不可预测的分支罚得很重(排序 vs 打乱 4.2 倍,可预测的几乎免费,消除随机分支的 branchless 写法留到 ch04-06)。再加上**三类冒险**(数据/控制/结构),就是 ch04 按瓶颈部位优化的硬件底座。 + +到这一篇为止,ch02 的单核硬件地基就铺完了:存储层次(02-01)、缓存行与局部性(02-02)、流水线与 ILP/分支(本篇)。下一篇我们补上最后一块拼图,虚拟地址翻译与 TLB,并附一张各 CPU 族微架构差异的速查表,给需要跨平台调优的同学一个案头参考。 + +## 参考资源 + +- Agner Fog《The microarchitecture of Intel, AMD and VIA CPUs》§22 *AMD Ryzen*:Zen 族流水线宽度(4-wide 译码、6 µop/clock 派发、8 µop/clock 退休)、分支吞吐(taken 1/2 clock、not-taken 2/clock)、µop cache、执行单元数量。本地:`.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Bryant & O'Hallaron《CSAPP》第 4 章 *Processor Architecture*(流水线、冒险的概念级定义)与第 5 章 *Optimizing Program Performance*(循环展开、多累加器、reassociation 的经典推导) +- Stack Overflow 传奇问题 *Why is processing a sorted array faster than processing an unsorted array?*(分支预测实验的出处,muffinista / Mysticial 的经典回答) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch02/pipeline_branch_ilp.cpp` diff --git a/documents/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md b/documents/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md new file mode 100644 index 000000000..5eafec537 --- /dev/null +++ b/documents/vol6-performance/ch02-cpu-microarchitecture/02-04-tlb-hugepage-and-cpu-families.md @@ -0,0 +1,113 @@ +--- +chapter: 2 +cpp_standard: +- 17 +description: ch02 收尾篇:补上虚拟地址翻译这最后一块拼图——TLB 缓存页表项,huge page 用更大页降低 TLB 压力;并附一张各 CPU + 族(Intel / AMD Zen / Apple / ARM)微架构差异速查表,给需要跨平台调优的同学一个案头入口 +difficulty: advanced +order: 4 +platform: host +prerequisites: +- 存储层次与延迟阶梯:为什么顺序访问快 100 倍 +- 缓存行与局部性:64 字节的最小搬运单位 +reading_time_minutes: 8 +related: +- 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch +- 测量陷阱与环境就绪:16 条 checklist +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存管理 +title: TLB、huge page 与各 CPU 族微架构速查 +--- +# TLB、huge page 与各 CPU 族微架构速查 + +## 还有一道翻译的关卡 + +前三篇我们把 cache 讲透了,但程序里用的都是**虚拟地址**(virtual address),而 cache 和主存用的是**物理地址**(physical address)。两者之间隔着一道翻译:CPU 每次访存,都要先把虚拟地址翻译成物理地址,才能去查 cache 或 DRAM。这道翻译如果每次都走完整的页表(page table),代价是惊人的,这正是**TLB(Translation Lookaside Buffer,旁路翻译缓冲)**要解决的问题。 + +这一篇我们先讲清 TLB 和 huge page 的机制,然后附一张各 CPU 族微架构差异的速查表,作为 ch02 的收尾。讲完这一篇,ch02 单核硬件地基就全部铺完,后面的 ch03(归因)和 ch04(按部位优化)就能在它上面展开了。 + +## TLB:缓存页表项,否则一次翻译顶好几次 DRAM 访问 + +x86-64 的虚拟地址是 48 位(新一些的 CPU 支持 57 位,5 级页表),物理页大小 **4 KB**。页表是一棵 4 层的树(每层 9 位索引 + 12 位页内偏移):要翻译一个虚拟地址,CPU 理论上要依次访问 4 个页表页(每层一个),每个都在内存里。**最坏情况:一次地址翻译 = 4 次 DRAM 访问。** 如果真这么干,前面 cache 省下来的延迟全赔回去还倒贴。 + +TLB 就是这棵页表的缓存:它把最近翻译过的「虚拟页 → 物理页」映射记下来,下次同一个页的访问直接查 TLB,几个周期搞定,不用走页表。TLB 和数据 cache 是两套独立的硬件,你的数据可能在 cache 里,但地址翻译该走 TLB 还是页表,是另一码事。 + +TLB 也分层次:每核一个 L1 dTLB(数据)和 L1 iTLB(指令),容量小但快;再往下还有一个共享的 L2 TLB(AMD/Intel 结构略有不同)。L1 dTLB 通常只有几十到上百项(每项管一个 4 KB 页),所以**当你的工作集涉及的页数超过 dTLB 容量,TLB miss 就开始发生,每次 miss 都要付一次页表漫游(page walk)的代价,几个 DRAM 访问级别,几十到上百纳秒**。 + +> 精确的 dTLB 条目数随架构变,且不是所有 CPU 都一样。需要精确数字时,查 [Wikichip 对应微架构页](https://en.wikichip.org/wiki/amd/microarchitectures/zen_3)(amd / intel 各有专页)或 Agner 微架构手册的 TLB 小节,本篇只讲结构和量级。 + +那么问题来了:**怎么知道你的程序是不是吃了 TLB miss 的亏?** 最干净的判断方法是看硬件计数器(`perf stat -e dTLB-load-misses`),TLB miss 率高、且工作集涉及的页数远超 dTLB 容量,就是 TLB 受限。另一个常见信号是:**大工作集 + 随机访问**的程序(比如数据库的随机索引查询、大哈希表),即使数据全在 DRAM,延迟也比纯 DRAM 访问还高出一截,那一截往往就是 page walk。 + +## huge page:用更大的页,把 TLB 项数需求降下来 + +既然 TLB 容量瓶颈是「项数」,一个直接的解法就是**把页变大**,让一项覆盖更多内存。x86-64 除 4 KB 页外,还支持 **2 MB**(甚至 1 GB)的 huge page。一个 2 MB 页覆盖的内存相当于 512 个 4 KB 页,同样大小的工作集,用 2 MB 页只需要 1/512 的 TLB 项。 + +这在以下场景里能换到实打实的性能: + +- **数据库**(PostgreSQL、MySQL)随机访问大索引:工作集几十 GB,4 KB 页下 TLB 项需求上千万,必然 thrash;换 2 MB 页能显著降延迟。 +- **大哈希表 / JVM 堆**:Java 社区长期有「开 transparent huge page(THP)能不能提速」的讨论,根因就是 JVM 堆动辄几个 GB,TLB 压力大。 +- **科学计算的大数组随机访问**:同理。 + +但 huge page 不是免费午餐,大页更难凑齐(2 MB 连续物理内存比 4 KB 难找),容易引发碎片;而且对**顺序访问**为主的程序收益小(预取器和 cache 已经把活干好了,TLB 不是瓶颈)。所以原则是:**先确认 TLB 真是瓶颈(perf 计数器),再上 huge page**,别盲目开。 + +### 上手跑一跑(以及一个诚实的否定结果) + +我想在本机演示一下 huge page 的收益:同样的 256 MB 工作集做指针追逐,一份用普通 4 KB 页,一份用 `madvise(MADV_HUGEPAGE)` 请求透明大页,看大页版本是否更快。代码核心: + +```cpp +void* a4 = mmap(nullptr, SZ, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); +void* a2 = mmap(nullptr, SZ, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); +madvise(a2, SZ, MADV_HUGEPAGE); // 请求透明大页 +// 对两份做同样的指针追逐,比延迟 +``` + +跑三次的结果: + +```text +第 1 次: 4KB 页 136.5 ns 2MB 页 134.0 ns 比值 1.02 +第 2 次: 4KB 页 135.8 ns 2MB 页 137.4 ns 比值 0.99 +``` + +比值基本是 1.0,**大页没带来任何可测的提升**。为什么?去查 `/proc/self/smaps`,发现 `AnonHugePages: 0 kB`:**WSL2(我跑的环境)压根没给透明大页**,`madvise` 请求了但内核没实际兑现。所以这个「没差别」是真实的,它不是 huge page 没用,而是这个环境没给我 huge page。 + +我把这个否定结果原样写在这里,是想再次强调 ch01 那条测量纪律:**你以为开了的优化,可能根本没生效。** 在这台 WSL2 上,`perf` 没装、THP 没兑现、CPU 频率读不到,每个都会让你对性能数字的解读偏离真实。换到裸机 Linux 上、`echo always > /sys/.../transparent_hugepage/enabled` 或预分配 hugetlb 池后,同样这段代码对 TLB 受限负载是能测出百分之十几到几十提升的。**结论可以引自权威资料,但你手上的数字必须来自你实际跑的环境,而且你得先确认环境真的满足前提。** + +## 各 CPU 族微架构速查表 + +讲完 TLB,ch02 的硬件地基就齐了。但前面三篇的数字主要围绕本机的 AMD Zen 3,你换到 Intel、Apple Silicon 或 ARM 上,具体数字会变。下面这张表给方向和量级,**精确数字请查 [Wikichip](https://en.wikichip.org) 对应微架构页或 Agner 微架构手册**,它不是用来背的,是给你跨平台调优时一个「该往哪个方向查」的入口。 + +| 维度 | Intel(近几代)| AMD Zen(2/3/4/5)| Apple(A 系到 M 系)| ARM Cortex(X / 大核)| +|---|---|---|---|---| +| **译码宽度** | 6-wide(Golden Cove 起) | 4-wide x86 译码,**靠 op-cache 补到 6-8 µop/周期** | 极宽(~8-wide),且无 x86 译码负担 | 宽(4-5+)| +| **ROB 深度** | 深(~400-500+) | 中深(Zen3 ~256) | 极深(~600+) | 中等 | +| **L1d / L2** | 48 KB / 1-2 MB | 32 KB / 512KB-1MB | 128 KB / 大 | 64 KB / 可变 | +| **LLC 结构** | 共享 LLC(MIC / 内置)| Zen3+ 单 CCD 共享大 L3 | 系统级缓存(SLC) | 共享 /簇 L3 | +| **TLB** | L1 dTLB + L2 TLB | L1 dTLB + L2 TLB | 类似分层 | 类似分层 | +| **特色** | 深流水线、强前端 | 统一 CCD 大缓存、高频率 | 超宽 ROB、高 ILP | 能效比、可授权 | + +读这张表的姿势:别盯绝对数(代际在演进),盯**结构性差异**。比如「AMD Zen 靠 4-wide x86 译码 + op-cache 补吞吐,而 Apple 是真·超宽译码 + 超深 ROB」,这是为什么 Apple Silicon 在 IPC(每指令周期数)上能打 x86 的根因之一,也解释了为什么 x86 代码的「前端瓶颈」(译码跟不上)比 ARM 更常见(ch04-07 前端优化会讲)。再比如「Zen3 把 L3 整合成单 CCD 共享」,这是 AMD 大幅缩小跨核缓存延迟的关键,直接影响多线程性能曲线(ch05)。 + +> 这张表严格意义上属于「给指针」的范畴,寄存器重命名表大小、执行端口分布、各指令延迟/吞吐这些**架构表录级**的数据,Agner 卷3(微架构)、卷4(指令表)是案头权威,Wikichip 是在线百科。vol6 讲到够支撑你「理解快慢」就停,深挖查这两处。 + +## ch02 收尾:四块硬件底座 + +到这里,单核硬件的三个层面都讲完了: + +1. **存储层次**(02-01):L1/L2/L3/DRAM 的延迟阶梯,逐级差出 100 倍。顺序访问能逼近 L1 吞吐是预取器在帮忙。 +2. **缓存行与局部性**(02-02):64 字节是最小搬运单位,空间局部性决定连续布局快,行优先 vs 列优先差 6 倍。 +3. **流水线与 ILP / 分支**(02-03):ILP 决定执行单元饱不饱(多累加器 2.9×),分支预测对不可预测分支罚得重(排序 vs 打乱 4.2×)。 +4. **TLB 与 huge page**(本篇):地址翻译是另一道关,huge page 降 TLB 压力,但得先确认环境真给了。 + +这些就是 ch04「按瓶颈部位优化」全部建议的硬件底座,为什么用连续容器、为什么控热数据集、为什么多累加器、为什么 branchless、为什么除法是瓶颈、为什么前端 PGO 有用,每一条都能回溯到这四章里的某个数字。后面 ch03 会教我们**怎么测出当前程序的瓶颈落在这四块的哪一块**,然后 ch04 对症下药。 + +## 参考资源 + +- Bryant & O'Hallaron《CSAPP》第 9 章 *Virtual Memory*:页表、TLB、页表漫游的概念与代价 +- Agner Fog《The microarchitecture of Intel, AMD and VIA CPUs》§22 *AMD Ryzen* 与各 Intel 章:TLB、流水线、执行端口的架构级细节。本地:`.claude/drafts/books/optimazation_in_cpp/microarchitecture.md` +- Wikichip *Microarchitectures*:各 CPU 族(Intel / AMD / Apple / ARM)精确参数速查,跨平台调优的案头入口 +- Drepper, U.《What Every Programmer Should Know About Memory》:TLB、huge page、页表的工程视角 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch02/tlb_hugepage.cpp` diff --git a/documents/vol6-performance/ch02-cpu-microarchitecture/index.md b/documents/vol6-performance/ch02-cpu-microarchitecture/index.md new file mode 100644 index 000000000..d5e4a6cf4 --- /dev/null +++ b/documents/vol6-performance/ch02-cpu-microarchitecture/index.md @@ -0,0 +1,26 @@ +--- +title: "CPU 微架构与存储层次" +description: "ch02 把单核硬件地基铺满:存储层次的延迟阶梯、缓存行与局部性、流水线与 ILP 和分支预测、TLB 与 huge page,每篇都配本机实测数据佐证,为 ch04 按瓶颈部位优化提供硬件底座" +--- + +# CPU 微架构与存储层次 + +ch00 立下了「先正确、再快」和「先测量、再优化」,ch01 把「先测量」做成了完整方法论。但「先测量」之后、「动手优化」之前,还差一块知识:你优化掉的到底是哪一种成本?把循环展开快了 3 倍,是因为缓存、还是指令级并行、还是分支预测?不知道根因,优化就是瞎碰。 + +这一章把单核硬件拆成四个层面,每个层面都用本机实测数据讲清「它在硬件上跑时发生了什么导致快/慢」: + +- **存储层次**:L1/L2/L3/DRAM 的延迟阶梯逐级差出 100 倍,顺序访问能逼近 L1 吞吐靠的是预取器。 +- **缓存行与局部性**:64 字节是 cache 的最小搬运单位,空间局部性决定连续布局快,行优先 vs 列优先遍历差 6 倍。 +- **流水线与 ILP / 分支预测**:指令级并行决定执行单元饱不饱(多累加器快 3 倍),不可预测的分支被罚得很重(排序 vs 打乱差 4 倍)。 +- **TLB 与 huge page**:虚拟地址翻译是另一道关,huge page 降 TLB 压力,但得先确认环境真给了。 + +这一章的数字(100 倍、6 倍、3 倍、4 倍)就是 ch04「按瓶颈部位优化」全部建议的物理根据——为什么用连续容器、为什么控热数据集、为什么多累加器、为什么 branchless、为什么除法是瓶颈、为什么前端 PGO 有用……每一条都能回溯到这里。深度上我们讲到「够支撑判断」就停,ROB / 寄存器重命名 / 执行端口调度这些更深的内容,给 Agner 微架构手册和 Wikichip 的指针。 + +## 本章内容 + + + 存储层次与延迟阶梯:为什么顺序访问快 100 倍 + 缓存行与局部性:64 字节的最小搬运单位 + 流水线、ILP 与分支预测 + TLB、huge page 与各 CPU 族微架构速查 + diff --git a/documents/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md b/documents/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md new file mode 100644 index 000000000..d6f896c6a --- /dev/null +++ b/documents/vol6-performance/ch03-attribution-methodology/03-01-use-and-roofline.md @@ -0,0 +1,108 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: 测出慢之后要回答为什么慢。先学两套互补的高层归因框架:Brendan Gregg 的 USE method(对每个资源查利用率/饱和度/错误,先看全局排除系统性瓶颈),和 + Roofline 模型(用算术强度一眼判断你的代码是该减访存还是该加 SIMD) +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 存储层次与延迟阶梯:为什么顺序访问快 100 倍 +- Benchmark 方法论参考卡 +reading_time_minutes: 7 +related: +- TMAM 四桶与硬件采样:LBR / PEBS / Intel PT +- 火焰图、perf 工作流与 COZ / eBPF +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: USE 方法与 Roofline 模型:先看全局,再判算力还是带宽 +--- +# USE 方法与 Roofline 模型:先看全局,再判算力还是带宽 + +## 测出「慢」之后,先别急着改代码 + +ch01 教我们把性能测准,ch02 给了硬件底座。但真上手优化一个「慢」的程序,你会立刻撞上两个问题。第一,**它到底慢在哪?** 是 CPU 算不过来,还是数据搬不过来,还是根本没在算(在等锁、等 IO)?第二个问题更阴险:**这瓶颈值不值得改?** 你花一天把某个函数提速 3 倍,可它要是只占总耗时 2%,用户根本感知不到。 + +这一章(归因方法论)就是回答这两个问题的。它本身不提速,而是给一条从「慢」到「瓶颈在哪儿、占多大比例」的定位流程。定位准了,ch04 的「按部位优化」才能对症下药;定位错了,就是瞎忙。 + +我们分三篇讲三套互补的工具,再加一篇综合实战。本篇讲两套高层框架(USE 看系统全局、Roofline 判算力 vs 带宽),03-02 讲 Intel 的 TMAM 四桶(把瓶颈归到流水线哪一段),03-03 讲火焰图(定位到具体代码行),03-04 把它们串成一个完整工作流。三套工具的关系是:USE 先排除系统性问题,Roofline 一眼判算力还是带宽,TMAM 下钻到流水线部位,火焰图定位到代码。 + +> 本章的工具大多是 `perf` / `toplev` / 火焰图脚本这类系统级 profiler。我写文章这台机器是 WSL2,`perf` 没装、`toplev` 也跑不了。所以本章的命令和输出我**引自权威资料(Brendan Gregg、Bakhvalov、easyperf.net)并标注**,而不是假装我在这台机器上跑过。命令本身是 Linux 性能分析的标准动作,换到一台装好 perf 的裸机 Linux 上照样能用。这种「环境制约下老实交代」的纪律,本身也是 ch01 测量方法论的一部分。 + +## USE 方法:对每个资源查三件事 + +USE 是 Brendan Gregg 提出的一套系统级体检框架,名字是三件事的缩写,对系统里每一个资源,分别检查: + +- **U**tilization(利用率):忙的时间占比。 +- **S**aturation(饱和度):排队/等待的长度,也就是已经有活干不完。 +- **E**rrors(错误):错误计数,硬件错、丢包、重传之类。 + +资源包括 CPU、内存、磁盘、网络、总线、互斥锁、线程池、连接池,任何可能成为瓶颈的东西。USE 的好处是穷举式早期排查:你不用猜瓶颈在哪,把每个资源的 U/S/E 都扫一遍,哪个饱和先治哪个,避免「盯着一处优化,真瓶颈在别处」。 + +举几个资源到指标的对应(完整表见 brendangregg.com/usemethod.html): + +| 资源 | 利用率 | 饱和度 | 错误 | +|---|---|---|---| +| CPU | `vmstat 1` 的 `%us`+`%sy` | `vmstat` 的 `r` 列(运行队列长度)> 核数 | — | +| 内存 | `free -m` / `sar -B` | `vmstat` 的 `si`/`so`(换入换出)> 0 | `dmesg` 的 OOM | +| 网络 | `sar -n DEV` 的 `rxkB/s` | `ifconfig` 的 drops / `netstat -L` 溢出 | `ifconfig` 的 errors | +| 磁盘 | `iostat -xz 1` 的 `%util` | `iostat` 的 `avgqu-sz` / `await` | `dmesg` / smart | + +USE 有个反直觉的点要特意记住:**平均利用率低也可能饱和**。5 分钟均值 80% 的 CPU,可能藏着秒级 100% 的尖刺;所以看饱和度(队列长度)比看平均利用率更早暴露问题。这条推论在 ch01-03「测量陷阱」里也出现过(均值被长尾拉偏),统计上是一回事。 + +USE 在性能调查的最早期用,几分钟扫一遍系统,排除掉「内存换页了」「磁盘打满」「网络丢包」这类明显问题,再下沉到 ch02 讲的微架构层面。它不回答「哪行代码慢」,但能让你别一开始就钻进代码的死胡同。 + +## Roofline 模型:算力屋顶 vs 带宽屋顶 + +USE 看完系统全局、确认瓶颈在 CPU 计算上之后,下一步要分清:**是算力不够(Core Bound),还是数据喂不上(Backend Memory Bound)?** 这两种瓶颈的对策完全相反。算力受限要加 SIMD、减指令;带宽受限要减访存、改数据布局。判断反了,优化白做。 + +Roofline 模型(Williams et al., CACM 2009)给了一个极简的判据。把程序画在一张二维图上: + +- **横轴**:算术强度(arithmetic intensity,AI),每字节内存流量做了多少次运算,**ops/byte**。 +- **纵轴**:可达算力,**ops/s**(或 FLOPS/s)。 +- **两条屋顶线**:水平线是 CPU 峰值算力;斜线是峰值内存带宽(`ops/s = bytes/s × AI`,所以是一条过原点的斜线)。 + +你的程序按它的算术强度落在图上某个点,这个点能到达的「屋顶」高度,就是它的理论峰值性能。关键判读: + +- 若程序点**贴着斜线**(带宽线),它是**内存带宽受限**,加再多 SIMD 也没用,得减访存。 +- 若程序点**贴着水平线**(算力线),它是**算力受限**,减访存没用,得加算力(SIMD、更少指令)。 + +斜线和水平线的交点叫**脊点(roofline point)**,对应的算术强度是「刚好吃满带宽又能转向算力」的分界。AI 低于脊点的程序全是带宽受限,高于的才可能算力受限。 + +### 手算两个例子:dot 和 axpy + +Roofline 的好处是算术强度可以手算,不需要任何 profiler。我们拿两个经典 BLAS 内核算一下: + +**点积 `dot = Σ a[i]*b[i]`**: + +- 每次迭代:2 次浮点运算(一次乘、一次加),读 2 个 float(8 字节)。 +- 算术强度 = `2 FLOP / 8 B = 0.25 FLOP/byte`。 + +**AXPY `y[i] = α*x[i] + y[i]`**: + +- 每次迭代:2 次浮点运算(乘、加),读 2 个 float + 写 1 个 float(12 字节)。 +- 算术强度 = `2 FLOP / 12 B ≈ 0.17 FLOP/byte`。 + +5800H 这类芯片的脊点算术强度大概在 **~20-25 FLOP/byte** 量级(峰值 FP32 算力约 900 GFLOPS,峰值 DDR 带宽约三四十 GB/s,相除)。dot 的 0.25 和 axpy 的 0.17 远低于脊点,所以这两个内核**铁定是内存带宽受限**,贴着带宽斜线跑。 + +这个结论直接指导优化方向:对 dot 和 axpy,别去抠 SIMD 通道利用率(算力方向),要去减访存(带宽方向),比如把多个数组合成 SoA 一次加载、用更宽的 load,或者根本换算法减少数据移动。这就是 ch04-01「后端内存」要讲的事。 + +反过来,一个矩阵乘 `C += A·B` 的算术强度高得多(每个元素被复用很多次),AI 能到几十,落在算力线上。所以矩阵乘优化的重心是 SIMD / 分块榨算力,不是减访存。同一份代码,优化方向完全不同,全看 AI 落在哪。 + +> 量级说明:上面 5800H 的峰值算力和带宽我给的是**量级近似**,不写死精确数,因为它们随 turbo 频率、AVX 模式、内存条配置浮动,写死了反而误导。Roofline 的教学价值在「AI 落在斜线还是水平线」这个定性判断,不在于某台机器的精确峰值。需要精确峰值时,查 CPU 规格页 + 实测内存带宽(如 STREAM benchmark)。 + +USE 和 Roofline 都是高层、快速、不需要深 profiler 就能上手的框架。USE 在调查最早期穷举系统资源的利用率/饱和度/错误,排除「问题根本不在 CPU」;Roofline 在确认瓶颈在计算后,用算术强度一眼区分算力受限(加 SIMD)还是带宽受限(减访存)。 + +它们能把战场缩小到「某个资源 / 某类瓶颈」,但还没下钻到「流水线的哪一段」。这正是 TMAM 四桶要做的事,下一篇我们走进 CPU 流水线,把瓶颈归到 Frontend / Backend / Bad Speculation / Retiring 四个桶里。 + +## 参考资源 + +- Brendan Gregg, *The USE Method*, brendangregg.com/usemethod.html——USE 框架原文与各资源的完整指标表 +- Williams, Waterman, Patterson, *Roofline: An Insightful Visual Performance Model for Multicore Architectures*, CACM 2009——Roofline 模型原始论文 +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 6 章 *Analysis Approaches*——USE 与 Roofline 的工程化讲法 +- Ofenbeck et al. *Applying the Roofline Model》——算术强度的工程计算视角 diff --git a/documents/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md b/documents/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md new file mode 100644 index 000000000..4c1843363 --- /dev/null +++ b/documents/vol6-performance/ch03-attribution-methodology/03-02-tmam-and-hw-sampling.md @@ -0,0 +1,120 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: TMAM(Top-Down Microarchitecture Analysis)把流水线 slot 分成 Retiring / Frontend + Bound / Backend Bound / Bad Speculation 四个桶,告诉你瓶颈在流水线的哪一段;本篇讲四桶怎么分、toplev 工作流,以及支撑它的硬件采样机制 + LBR / PEBS / Intel PT 各自能拿到什么数据 +difficulty: advanced +order: 2 +platform: host +prerequisites: +- USE 方法与 Roofline 模型 +- 流水线、ILP 与分支预测 +reading_time_minutes: 7 +related: +- 火焰图、perf 工作流与 COZ / eBPF +- 前端优化:代码布局、PGO、BOLT +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: TMAM 四桶与硬件采样:LBR / PEBS / Intel PT +--- +# TMAM 四桶与硬件采样:LBR / PEBS / Intel PT + +## 把瓶颈归到流水线的哪一段 + +上一篇的 Roofline 能区分「算力受限」还是「带宽受限」,但「算力」和「带宽」都还是太粗。CPU 流水线有取指、译码、执行、访存、分支预测好多段,「算力不够」到底是译码跟不上(前端),还是执行单元等数据(后端),还是分支预测老错白白冲刷(投错机)?这三种情况的对策天差地别。 + +**TMAM(Top-Down Microarchitecture Analysis Method)** 是 Intel 提出来回答这个问题的框架(Yasin, *A Top-Down Method for Performance Analysis and Tuning*, 2014)。它把流水线每周期能进入的 slot,按它们最终的命运分成四个桶。看哪个桶占比异常高,就知道瓶颈在流水线的哪一段。这个框架后来被 Andi Kleen 的 `pmu-tools/toplev` 工具实现成一键命令,是现代 CPU 性能分析的主力。 + +## 四个桶:slot 的四种命运 + +CPU 前端每周期会尝试「分配」一定数量的 slot(slot 数 = 流水线宽度)。这些 slot 最后只有四种归宿: + +| 桶 | 含义 | 占比高说明 | 典型对策(对应 ch04)| +|---|---|---|---| +| **Retiring** | slot 真的退休成了一条有效指令 | **越高越好**(理想) | 结构良好,继续 | +| **Frontend Bound** | slot 因为前端(取指/译码)跟不上而空转 | icache miss / iTLB miss / 代码膨胀 | 代码布局、PGO、BOLT(ch04-07)| +| **Backend Bound** | slot 卡在后端,执行单元在**等数据**(memory)或**等端口**(core) | cache miss / 数据依赖 / 执行端口冲突 | cache 优化、SIMD、打破依赖链(ch04-01/02/03)| +| **Bad Speculation** | slot 浪费在**投机的错误路径**上(分支预测失败被冲刷) | 不可预测分支 | branchless、predication(ch04-06)| + +读这四桶的姿势:**Retiring 是好桶,其余三个是坏桶,谁的占比异常高谁就是当前瓶颈**。一个调得好的数值计算内核,Retiring 能到 50%–70%(SIMD 满载);如果它只有 20% 而 Backend Memory 占 60%,那你该去治 cache,不是加 SIMD。 + +Backend Bound 还能再往下分两支,**Backend Memory Bound**(等数据:cache miss、带宽)和 **Backend Core Bound**(等执行端口:除法、长延迟依赖链)。这个细分极其有用,它直接对应 ch04 里「治内存」还是「治计算」两条路。 + +> 边界提醒:TMAM 四桶是归因框架,讲「瓶颈落在哪类」;具体怎么改(向量化、减访存、branchless)是 ch04 的事。别在归因章里展开优化细节,那是 ch04 的主场。 + +## toplev 工作流:一层层下钻 + +`toplev`(`pmu-tools` 里的 Python 工具,包装了 Intel TMAM 的性能计数器)的工作流是逐层下钻,典型三步: + +```bash +# 1. 先看 L1 四桶,定主战场 +toplev -l1 -- ./app +# 输出示例(引自 easyperf.net 示例,非本机): +# Frontend_Bound: 12.5% ← 正常 +# Backend_Bound: 58.0% ← 主桶! +# Bad_Speculation: 8.3% +# Retiring: 21.2% + +# 2. Backend 是主桶,下钻 L2 看是 Memory 还是 Core +toplev -l2 -- ./app +# Backend_Bound.Core_Bound: 15.0% +# Backend_Bound.Memory_Bound: 43.0% ← 内存受限 + +# 3. Memory Bound 再下钻 L3,看卡在哪一级 cache / DRAM +toplev -l3 -- ./app +# ... L3_Bound.DRAM_Bound: 38% ← 大概率 cache miss 打到 DRAM +``` + +下钻到 L3,你就知道「瓶颈是 L3 miss 打到 DRAM」这种粒度。但这还不够,你还得知道**是哪条指令在 miss**,才能动手改。这就要靠带精确地址的硬件采样事件。 + +## 硬件采样三件套:LBR / PEBS / Intel PT + +`toplev` 告诉你「卡在哪一级」,但要落到「哪条汇编指令」,得靠 CPU 的硬件采样机制。现代 Intel/AMD CPU 有三套机制,各自能拿到不同粒度的数据: + +**LBR(Last Branch Record)**:CPU 维护一个环形 buffer,记录最近几十到几百条**分支跳转**(from/to 地址对)。LBR 的强项是抓**控制流**,分支预测失败在哪、调用栈(用 LBR 重建栈,不需要 frame pointer)、热循环。代价是只记录分支跳转,不记录普通访存。 + +**PEBS(Precise Event-Based Sampling)**:`perf` 的「带 `:pp` 后缀」事件就靠它。普通采样是基于**指令指针**的,有 skid(事件发生后,中断交付前 CPU 还会跑几条指令,采样点「滑」过去,定位不准);PEBS 让 CPU 在事件发生时**精确地**把当时的寄存器状态(包括精确指令地址)存进 PEBS buffer,几乎无 skid。这对定位「哪条 load 指令 cache miss 了」是刚需: + +```bash +# 用带 :ppp(精确 IP)后缀的事件定位 cache miss 的具体指令 +perf record -e MEM_LOAD_RETIRED.L3_MISS:ppp -- ./app +perf report # 或 perf annotate 看汇编级命中 +``` + +> 这就是 ch01-03「测量陷阱」表里第 16 条「PEBS skid」的反面:用 `:ppp` 精确事件才能准确定位;用普通事件会滑几条指令,改错了函数。 + +**Intel PT(Processor Trace)**:更猛,它**连续记录**完整的控制流(每个分支的方向),可以**完整重建执行轨迹**(虽然不记数据值)。代价是产生大量数据(buffer 占内存)、解析需要专门工具(`perf script`、`libipt`)。Intel PT 用于「我要精确知道这次运行走了哪条路径、每个分支怎么转的」这类深度分析,日常 profiling 用 PEBS 就够。 + +AMD 这边对应机制名字不同(AMD 用 IBS,Instruction-Based Sampling,和 PEBS 思路类似但实现不同;分支记录对应也叫 LBR)。框架(TMAM 四桶)是通用的,底层事件名随厂商变,这是跨平台调优要注意的,在一台 Intel 上调好的 perf 命令,换到 AMD 上事件名得改。`perf list` 能列出本机可用事件。 + +## 瓶颈会迁移:迭代,不是一次性 + +TMAM 工作流有一个极其重要的性质,新手容易栽在这里:**修好一个瓶颈,会露出下一个**。 + +假设你下钻发现 Backend Memory 60%(L3 miss 打到 DRAM),你花力气优化了 cache 布局,Backend Memory 降到 20%。你重测,以为大功告成,结果发现总性能只提升了一点点,因为现在 **Bad Speculation 升到 35%**(原来被内存瓶颈掩盖,内存一快,分支预测失败成了新瓶颈)。这就是 TMAM 的「瓶颈迁移」:流水线的短板是会变的,你修好一块,下一块就成了新的短板。 + +所以 TMAM 是迭代流程,不是一次性诊断: + +1. `toplev -l1` 找当前最大桶 → 下钻定位 → 改 → 回到 1。 +2. 每轮处理**当前最大**的桶,直到 Retiring 占比满意或剩下的桶都不大了。 + +这条「瓶颈会迁移」的性质,也是为什么 ch01 反复强调「优化前后都用同一套方法论测一遍」。你以为搞定的事,可能只是把瓶颈挪了个地方。 + +回过头看 TMAM 给我们的东西:四桶(Retiring 好桶 / Frontend Bound / Backend Bound 拆 Memory + Core / Bad Speculation),看哪桶占比高就是瓶颈所在;toplev 工作流是 L1 定主桶,L2/L3 下钻到 cache 层级,再用精确事件(`:ppp`)定位到具体汇编指令,然后改、再迭代;硬件采样三件套分工,LBR 抓控制流和栈、PEBS 抓精确访存事件(治 skid)、Intel PT 连续重建完整轨迹;还有一条贯穿性的纪律,瓶颈会迁移,每轮治当前最大桶,改完重测,直到 Retiring 满意。 + +TMAM 答「哪类瓶颈」,但还没答「哪段代码、哪个函数」。定位到代码段,要靠火焰图,下一篇。 + +## 参考资源 + +- Yasin, *A Top-Down Method for Performance Analysis and Tuning*(2014)——TMAM 原始论文 +- Andi Kleen `pmu-tools`(`toplev`)——github.com/andikleen/pmu-tools,TMAM 的命令行实现 +- easyperf.net *Top-Down performance analysis methodology*(2019-02-09)——toplev 工作流的图文教程 +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 6 章 *CPU Features For Performance Analysis*——LBR / PEBS / Intel PT 的机制讲法 +- Intel《Optimization Reference Manual》附录 B——TMAM 与性能计数器的官方定义 +- `perf` 文档:`perf record` / `perf annotate` / `:pp` 精确事件后缀 diff --git a/documents/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md b/documents/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md new file mode 100644 index 000000000..d23d4711e --- /dev/null +++ b/documents/vol6-performance/ch03-attribution-methodology/03-03-flamegraph-perf.md @@ -0,0 +1,117 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: TMAM 答的是「哪类瓶颈」,火焰图答的是「哪段代码」。本篇讲 perf record 的标准采样工作流、怎么生成并读懂 on-CPU / + off-CPU 火焰图,以及两个进阶工具——COZ(因果 profiler,告诉你优化哪个函数最值)和 eBPF(现代可编程 tracing) +difficulty: advanced +order: 3 +platform: host +prerequisites: +- TMAM 四桶与硬件采样 +- Benchmark 方法论参考卡 +reading_time_minutes: 8 +related: +- USE 方法与 Roofline 模型 +- 归因实战:从一个慢程序到定位瓶颈 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: 火焰图、perf 工作流与 COZ / eBPF +--- +# 火焰图、perf 工作流与 COZ / eBPF + +## 从「哪类瓶颈」到「哪段代码」 + +上一篇的 TMAM 把瓶颈归到流水线的某一桶(Frontend / Backend / Bad Spec),但「Backend Memory Bound 60%」这个结论还落不到代码行上,你得知道是哪个函数、哪段循环在制造那些 cache miss,才能动手。这就需要能按代码位置聚合的 profiler。 + +Brendan Gregg 发明的**火焰图(Flame Graph)**是这类工具里最好读的:它把采样的调用栈画成一张「层层叠叠的盒子图」,一眼就能看出时间花在哪条调用链上。配合 Linux 自带的 `perf`,这套组合是日常 profiling 的绝对主力。本篇讲 perf 工作流 + 火焰图读法,再介绍两个进阶工具(COZ 因果 profiling、eBPF 可编程 tracing)。 + +## perf record 工作流 + +整个流程是「采样 → 折叠栈 → 画图」三步。采样用 `perf record`: + +```bash +# 标准 on-CPU 采样:99Hz、用 DWARF 调用栈(不依赖 frame pointer) +perf record -F 99 --call-graph dwarf -- ./app + +# 采样完后导出 +perf script > out.perf + +# 折叠 + 画图(Brendan Gregg 的 FlameGraph 仓库脚本) +./stackcollapse-perf.pl out.perf > out.folded +./flamegraph.pl out.folded > out.svg +# 用浏览器打开 out.svg,鼠标可悬停/点击放大 +``` + +几个**关键参数和坑**(都是 ch01-03「测量陷阱」的延伸): + +- **`-F 99`(采样频率 99Hz)**:为什么不是 100Hz?因为 100 是很多内置计时器的「整点」,容易和系统其它周期性事件锁相共振,采样出现规律性偏差;99 是个奇数(而且不整除 100),采样点不容易和系统整点周期事件对齐。这是性能分析里一个常见的小约定。**注意 99 不是素数**(99 = 9 × 11),选它只是为了取一个不与 100 整除的奇数,不是因为它有素数性质(Brendan Gregg 的 perf 文档里只说「99 Hertz」,从未解释成「素数」)。 +- **`--call-graph dwarf`(用 DWARF 调试信息重建栈)**:GCC 默认 `-fomit-frame-pointer`(省掉帧指针,多一点可用寄存器),这会让 `perf` 没法用「回溯帧指针」的方式重建调用栈,栈是断的。两个解法:**编译时加 `-fno-omit-frame-pointer`**(推荐,几乎零成本,profiling 时的标准做法),或者**采样时用 `--call-graph dwarf`**(靠 DWARF 调试信息重建栈,慢一点但不用改编译)。release 二进制如果要 profile,务必加 `-fno-omit-frame-pointer`。 +- **采样是统计性的**:`perf record` 是采样,不是 trace。99Hz 跑 10 秒只采 ~990 个样本/核,短函数可能一个样本都没有。要看稀有热点,提高频率或延长运行;要看一次性的启动开销,改用 trace 工具(perf c2c / Intel PT / ftrace)。 + +## 怎么读火焰图 + +火焰图的结构: + +- **y 轴(垂直)是调用栈深度**:底部是入口(`main`),上面每一层是被调用的函数。一个盒子架在另一个盒子上 = 「下面那个调用了上面那个」。 +- **x 轴(水平)是样本数(不是时间顺序!)**:盒子越宽,这个函数(及其子调用)在 on-CPU 采样里占比越大。**x 轴左右顺序不代表执行先后**,只是按字母序排的聚合。 +- **读法**:找**最宽的盒子**,那是最耗 on-CPU 时间的函数。但它不一定是该优化的函数,要看它架在谁上面(调用栈上下文)。 + +两个常见误读要避开: + +1. **「最宽的盒子就一定要优化」**:不对。一个「宽而平」的盒子(没有上面的子盒子)是真热点,值得优化;一个「宽但上面摞着一大摞」的盒子,只是因为它调用的子函数耗时,优化它自己没用,要优化它顶上那些子盒子里最宽的。 +2. **「x 轴是时间」**:不是。火焰图是聚合,不是 timeline。想看「按时间顺序哪段时间忙」,要用 timeline / chrometrace 那类工具。 + +### 两个重要变种:on-CPU vs off-CPU + +标准火焰图是 **on-CPU**(CPU 上在跑什么),回答「算的时间花在哪」。但它看不到「**在等什么**」。如果程序慢是因为在等锁、等 IO、等 sleep,on-CPU 火焰图会是空的(因为采样时 CPU 根本没在跑你的程序)。 + +这种情况下用 **off-CPU 火焰图**:它采样的是「**线程离开 CPU 那一刻的调用栈**」,即「在等的时候,你等在哪个函数」。off-CPU 图上最宽的盒子,就是你最该减少的等待。Brendan Gregg 把 on-CPU 和 off-CPU 比作硬币两面,on-CPU 看算、off-CPU 看等,两者一起才完整。生产环境里很多「慢」其实是等(等数据库、等锁、等网络),off-CPU 才是关键。 + +生成 off-CPU 图需要 bcc / bpftrace(eBPF 工具,下面讲),比 on-CPU 麻烦,但治「卡顿」类问题不可替代。 + +## COZ:因果 profiler,告诉你「优化哪个函数最值」 + +普通 profiler(火焰图、perf)有个根本局限:它告诉你「时间花在哪」,但不告诉你「优化哪里收益最大」。一个函数占 50% 时间,你把它快 2 倍,总时间能减多少?直觉是减 25%,但 Charlie Curtsinger 团队的 COZ 论文(*COZ: Finding Code that Counts with Causal Profiling*, SOSP 2015)指出,这假设了「优化这个函数不影响其它函数的耗时」,而实际上函数之间有共享资源(锁、cache),优化一处可能让别处变慢。 + +COZ 用**因果 profiling**解决这个问题:它在程序运行时虚拟地「提速」某个目标函数,但做法不是真的让那个函数变快,而是反过来,**给所有其它并发运行的线程插入 pause,把它们拖慢**(Bakhvalov《Performance Analysis and Tuning on Modern CPUs》§11.5;Curtsinger & Berger, SOSP 2015)。把别人拖慢,在数学上就等效于把目标函数被提速了。然后它测量整体提速多少,因为控制住了「其它线程的时间」这个变量,测到的整体变化才能干净地归因到目标函数,这正是「因果」二字的含义。这样它能直接回答「如果我把函数 X 提速 10%,整个程序快多少」,这才是「值不值得优化」的真正判据。 + +COZ 的输出是「每个函数的『虚拟提速 → 整体提速』曲线」,斜率最高的函数就是收益最大的优化目标,哪怕它自己占的 CPU 时间不是最多。这跟火焰图「找最宽盒子」是不同的视角,对优化优先级更准。COZ 在 Linux 上开源(curtsinger.cc/coz),用法是链接 COZ 运行时、跑程序、看 profile。 + +## eBPF:现代可编程 tracing 底座 + +**eBPF**(extended Berkeley Packet Filter)是近年 Linux 性能工具的最大变量。简单说,它让你在内核里安全地跑小程序,挂到各种 hook 点(系统调用、内核函数、tracepoint、USDT……),按需采集数据。它不需要改内核、不需要重启、开销可控。 + +为什么性能分析要关心 eBPF?因为前面一堆工具都建立在它上面: + +- **off-CPU 火焰图**:bcc 的 `profile` 或 bpftrace 能采 off-CPU 栈。 +- **`perf` 的很多功能**:新一代基于 BPF 的工具(`bpftrace` 一行命令)更灵活。 +- **系统级追踪**:`biosnoop`(块 IO 延迟)、`execsnoop`(新进程)、`tcplife`(TCP 连接生命周期)……Brendan Gregg 的 bcc 工具集几十个,全是 eBPF 写的。 + +对 C++ 后端开发,最实用的是 **bpftrace**,一种「性能分析专用的一行 DSL」,比如一行命令追某个用户态函数被调用时的延迟分布: + +```bash +# 追踪 mysqld 的某函数延迟(us),直方图 +bpftrace -e 'uprobe:/path/to/bin:my_func { @start[tid] = nsecs; } + uretprobe:/path/to/bin:my_func /@start[tid]/ { + @lat = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); + }' +``` + +eBPF 的深度(怎么写 BPF 程序)超出 vol6 范围,这里只让你建立一个认知:**现代 Linux 性能工具的底座是 eBPF,off-CPU 和系统级 tracing 都靠它**。需要时,brendangregg.com 有完整教程。 + +把这一篇压成一张速查:日常 on-CPU profiling 的主力是 perf record + 火焰图,务必 `-fno-omit-frame-pointer` 或 `--call-graph dwarf`,否则栈断;读火焰图找宽盒子,但要区分「自己宽(真热点)」和「上面摞的宽(子调用耗时)」,x 轴是聚合不是时间;off-CPU 火焰图看「在等什么」,治卡顿类问题,靠 eBPF(bcc/bpftrace);COZ 是因果 profiler,直接告诉你优化哪个函数整体收益最大,比「找最宽盒子」更准;eBPF 是现代 Linux 性能工具的底座,off-CPU、系统级 tracing 都建立在它上面。 + +到这里,ch03 的四套工具(USE / Roofline / TMAM / 火焰图+COZ+eBPF)就讲完了。下一篇我们用它们串一个完整工作流:拿到一个慢程序,从「它慢」一路定位到「这里就是瓶颈」。 + +## 参考资源 + +- Brendan Gregg, *Flame Graphs*, brendangregg.com/FlameGraphs/cpuflamegraphs.html——火焰图原文、生成脚本(FlameGraph 仓库)、读法 +- Gregg, *Brendan Gregg's perf Examples* / *perf one-liners*——perf 工作流速查 +- Curtsinger et al. *COZ: Finding Code that Counts with Causal Profiling*, SOSP 2015——因果 profiling,curtsinger.cc/coz 开源实现 +- Gregg, *BPF Performance Tools*(书)/ bcc / bpftrace 文档——eBPF 工具集 +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 11.5–11.6 节——COZ 与 eBPF 在 CPU 调优里的应用 diff --git a/documents/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md b/documents/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md new file mode 100644 index 000000000..68eef05e0 --- /dev/null +++ b/documents/vol6-performance/ch03-attribution-methodology/03-04-walkthrough.md @@ -0,0 +1,170 @@ +--- +chapter: 3 +cpp_standard: +- 17 +description: 把 ch03 前三篇的 USE / Roofline / TMAM / 火焰图串成一个完整工作流。用一个加权点积案例(工作集卡在 L3 边界、带宽受限),演示一步步用四套工具定位到「它是 + Backend Memory Bound、贴着 DRAM 带宽斜线,优化点是 AoS→SoA 省 pad 流量」,并强调瓶颈迁移的迭代性质 +difficulty: advanced +order: 4 +platform: host +prerequisites: +- USE 方法与 Roofline 模型 +- TMAM 四桶与硬件采样 +- 火焰图、perf 工作流与 COZ / eBPF +reading_time_minutes: 8 +related: +- 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch +- 循环与计算优化:code motion、展开与多累加器 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工程实践 +title: 归因实战:从一个慢程序到定位瓶颈 +--- +# 归因实战:从一个慢程序到定位瓶颈 + +## 四套工具怎么串起来 + +ch03 前三篇我们学了四套工具:USE(看系统全局)、Roofline(判算力 vs 带宽)、TMAM(归到流水线哪段)、火焰图 + COZ(落到代码)。但真上手时,你不会四套全跑一遍,那太慢。它们的正确关系是一条漏斗,从粗到细逐层过滤: + +```text +慢程序 + │ + ├─ 1. USE 扫系统:是不是根本不在 CPU?(内存换页?磁盘满?网络?) + │ └─ 排除系统性问题后,确认瓶颈在 CPU 计算 + │ + ├─ 2. Roofline 定性:算力受限 还是 带宽受限?(决定优化大方向) + │ + ├─ 3. TMAM 四桶:瓶颈在流水线哪段?(Frontend/Backend Memory/Backend Core/Bad Spec) + │ └─ 下钻到 cache 层级,用精确事件定位到汇编指令 + │ + └─ 4. 火焰图:这段慢的代码,在哪个函数?(确认改哪行) + └─ COZ 补充:改这个函数整体收益多大?(排优化优先级) +``` + +这一篇我们走一遍完整流程。先讲清楚:**我没有在写文章这台 WSL2 机器上跑 perf/toplev**(本机没装 perf)。所以下面涉及的 profiler 输出,是从 ch02 已实测的数据(那些是本机真跑的)+ Bakhvalov/easyperf 的标准案例推演出来的「如果跑了会看到什么」。能本机实测的部分(算术强度、原始耗时、cache 行为)我都标了「实测」;profiler 的具体输出标了「推演/引自」,绝不假装跑过。 + +## 场景:一个慢得离谱的点积 + +假设你写了个粒子物理模拟,核心是一个「加权点积」:对 N 个粒子,算 `result = Σ w[i] * x[i] * y[i]`。N = 100 万(每粒子 16 字节,工作集 16 MB,正好卡在本机 L3 容量边界),跑出来单次约 0.9 ms(本机实测量级)。你想知道它有没有优化空间、卡在哪,用归因工作流查一遍。 + +代码长这样(简化): + +```cpp +struct Particle { float w, x, y, pad; }; // AoS:每粒子 16 字节 +float weighted_dot(const std::vector& ps) { + float acc = 0.0f; + for (size_t i = 0; i < ps.size(); ++i) + acc += ps[i].w * ps[i].x * ps[i].y; // 每次访问三个字段 + return acc; +} +``` + +## 第 1 步:USE 扫系统——先确认是 CPU 问题 + +动手前先花两分钟扫一眼系统,排除「问题根本不在 CPU」: + +```bash +vmstat 1 # 看 %us+%sy(用户+系统 CPU)、r 列(运行队列)、si/so(换页) +free -m # 看内存有没有吃满到换页 +iostat -xz 1 # 看磁盘(这程序不读盘,应该全空) +``` + +如果你看到 `si/so > 0`(在换页),那「慢」可能根本是内存不够换页造成的,跟你的算法无关,先加内存。如果 CPU 利用率打满一个核但其它资源空闲,确认瓶颈在 CPU 计算,**进入第 2 步**。 + +这一步看起来 trivial,但它能在 5 分钟内救你于「花一天优化算法,结果是磁盘满了」这种悲剧。USE 的价值就在这儿。 + +## 第 2 步:Roofline 定性——算力还是带宽? + +现在确认是 CPU 计算瓶颈,但还要分清:是算不过来,还是数据喂不上?算一下算术强度(这个本机可算,无需 profiler): + +每次循环迭代: + +- 运算:2 次乘 + 1 次乘 + 1 次加 = 算 3 次浮点运算(严格说 `w*x*y+acc` 是 2 乘 1 加 = 3 FLOP)。 +- 访存:读 `w`、`x`、`y` 三个 float = 12 字节(`pad` 字段也被同一 cacheline 带进来,但不算「有用流量」,先按有用算)。 +- **算术强度 ≈ 3 FLOP / 12 B = 0.25 FLOP/byte**。 + +参考 03-01:5800H 这类芯片的脊点 AI 在 ~20 FLOP/byte 量级(8 核 AVX2 FMA 峰值 ~900 GFLOPS / ~40 GB/s)。0.25 远低于脊点,所以这个内核**铁定是内存带宽受限**,贴着带宽斜线跑。优化方向立刻明确:**减访存,别去抠 SIMD 通道**(就算 SIMD 满载,带宽瓶颈不变,等于空转)。 + +这一步只花了一支笔的功夫,但已经帮你排除了「加 SIMD」这条至少半天工作量的错路。Roofline 的杠杆就在这里。 + +## 第 3 步:TMAM 下钻——是哪一级 cache miss? + +Roofline 说了「带宽受限」,但带宽卡在哪一级?L2 命中但 L3 miss?还是连 L3 都打穿了到 DRAM?这就得 `toplev` 了(以下输出为按本机 cache 参数 + Bakhvalov 案例推演): + +```bash +toplev -l1 -- ./weighted_dot +# Frontend_Bound: 8.0% +# Backend_Bound: 62.0% ← 主桶,符合 Roofline 的「带宽受限」 +# Bad_Speculation: 5.0% +# Retiring: 25.0% + +toplev -l3 -- ./weighted_dot +# Backend_Bound.Memory_Bound.L3_Bound.DRAM_Bound: 45% ← 连 L3 都打穿到 DRAM +``` + +「DRAM Bound」告诉我们:数据没命中 L3(16 MB),去了主存。但要注意,**加权点积是流式单遍扫描**(每元素只访问一次、零时间复用),所以**这里没有 L3 容量颠簸**(颠簸要被反复重访的元素互相驱逐才会发生,流式扫描没有重访)。工作集 16 MB 卡在/略超 L3 边界,真因是它**贴着 DRAM 带宽斜线跑**,参考 ch02-01 的 memory mountain,工作集超过 L3 后吞吐落在带宽斜线低端。**根因是带宽受限**(不是容量颠簸)。 + +下钻到汇编,用精确事件确认是哪条 load 在 miss: + +```bash +perf record -e MEM_LOAD_RETIRED.L3_MISS:ppp -- ./weighted_dot +perf annotate +# 高亮显示:循环里那三条读 ps[i].w/x/y 的 movss 是 miss 重灾区 +``` + +定位完成:瓶颈 = Backend Memory Bound,DRAM 级 miss,制造 miss 的是循环里的字段 load。现在可以动手改了。 + +## 第 4 步:火焰图确认 + 改 + +火焰图在这个例子里其实不关键(因为就一个循环),但它在大程序里能告诉你「这 45% 的 DRAM miss 是分布在 5 个函数里、还是全在 `weighted_dot` 一个函数里」。如果分散,你得逐个改;如果集中,改一个就行。这里假设火焰图确认全在 `weighted_dot`,集中。 + +怎么改?带宽受限场景下,关键是**降低无用流量**。问题在 **AoS 布局**:`Particle{w,x,y,(pad)}` 把三个要用的字段和用不到的 `pad` 挤一起,每次循环沿数组扫,`pad` 白白占 1/4 带宽。改成 **SoA**(详细机制见 ch04-01),三个字段各自连续、不再带 `pad`: + +```cpp +struct Particles { + std::vector w, x, y; // 三个数组分开 +}; +float weighted_dot(const Particles& ps) { + float acc = 0.0f; + for (size_t i = 0; i < ps.w.size(); ++i) + acc += ps.w[i] * ps.x[i] * ps.y[i]; + return acc; +} +``` + +改完**先回第 2 步重测**(迭代!),发现单次降到 ~0.7 ms(本机实测量级,SoA 省掉了 pad 的 25% 流量)。再看 toplev,Backend Memory 占比明显下降,Retiring 上升,好多了。 + +## 别高兴太早:瓶颈会迁移 + +这是 TMAM 最坑新手的一点。你 Backend Memory 修到 20%,**重测总时间**,可能发现只快了一点点,因为现在 **Bad Speculation 升上来了**(原来被内存瓶颈掩盖)。或者 Retiring 上去了,但 **Frontend Bound** 因为代码布局变差而抬头。每次修完都要**回第 2 步重新看四桶**,处理新的最大桶,直到 Retiring 满意或剩下的桶都小到不值得。 + +这一条「瓶颈迁移」决定了归因是迭代的,不是「跑一次 profiler 改一次」。这也是为什么 ch01 反复说「优化前后都同一套方法论测一遍」,你以为修好了,可能只是把短板挪了个位置。 + +## 收尾:COZ 排优先级 + +如果这个程序还有别的函数(不止 `weighted_dot`),你怎么决定**先改哪个**?火焰图按耗时排序,但「耗时最多的函数」未必是「优化收益最大的函数」。这时 COZ 能帮你:它对每个函数画「虚拟提速 → 整体提速」曲线,斜率最陡的就是最值得优化的。在你有多个候选瓶颈、预算有限时,COZ 给的优先级比火焰图更可靠。 + +## 这套工作流的价值 + +走完一遍你会发现,归因的核心不是「某个工具多神」,而是按漏斗一步步缩小战场: + +1. USE(2 分钟)排除系统性问题,确认是 CPU。 +2. Roofline(一支笔)判算力 vs 带宽,定大方向。 +3. TMAM(几次 toplev)下钻到流水线段 + cache 层级,精确事件定位到指令。 +4. 火焰图确认函数,COZ 排优先级。 +5. 改,然后迭代,瓶颈会迁移。 + +每一步都比它前一步贵(花的时间、需要的工具、侵入性都递增),但每一步都缩小了下一步的战场。跳过前几步直接上火焰图,你会在一个 50 函数的程序里迷失;跳过 Roofline 直接改 SIMD,你可能改错方向。这套「由粗到细」的纪律,是 ch04 按部位优化能对症下药的前提。 + +到这一篇,ch03 归因方法论就讲完了。从下一篇开始,我们正式进入 **ch04 按瓶颈部位优化**,对着 Backend Memory / Backend Core / Bad Speculation / Frontend 四个桶,逐个讲怎么治。 + +## 参考资源 + +- ch03-01 USE 方法与 Roofline 模型(本卷) +- ch03-02 TMAM 四桶与硬件采样(本卷) +- ch03-03 火焰图、perf 工作流与 COZ / eBPF(本卷) +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》——完整案例走查在 ch6–11(TMAM/CPU features/Cache/Memory/Core/Frontend/多线程,全书共 11 章) +- ch02-01 存储层次延迟阶梯(本卷,memory mountain 工作集跨 L3 边界的吞吐实测出处) diff --git a/documents/vol6-performance/ch03-attribution-methodology/index.md b/documents/vol6-performance/ch03-attribution-methodology/index.md new file mode 100644 index 000000000..9c7124786 --- /dev/null +++ b/documents/vol6-performance/ch03-attribution-methodology/index.md @@ -0,0 +1,29 @@ +--- +title: "归因方法论:从测量到瓶颈" +description: "测出慢之后要回答为什么慢、慢在哪。ch03 给四套互补的归因框架:USE 看系统全局、Roofline 判算力 vs 带宽、TMAM 四桶归到流水线哪段、火焰图+COZ+eBPF 落到代码,再用一篇实战把它们串成由粗到细的漏斗工作流" +--- + +# 归因方法论:从测量到瓶颈 + +ch01 教我们怎么把性能测准,ch02 给了硬件底座。但「测出慢」和「知道为什么慢」之间还隔着一整门学问:一个程序慢,可能是 CPU 算不过来,可能是数据搬不过来,可能在等锁、等 IO,可能根本是内存换页,**对症下药的前提是先确诊**。这一章就是把「确诊」做成一套可复用的流程。 + +四套工具按**由粗到细的漏斗**排列,每一步都比前一步贵(花的时间、需要的工具、侵入性都递增),但每一步都缩小了下一步的战场: + +- **USE**(ch03-01):两分钟扫系统,排除「问题根本不在 CPU」。 +- **Roofline**(ch03-01):一支笔算算术强度,判算力受限还是带宽受限,定优化大方向。 +- **TMAM 四桶**(ch03-02):`toplev` 下钻,把瓶颈归到流水线的 Frontend / Backend Memory / Backend Core / Bad Speculation 哪一段,再精确采样到具体指令。 +- **火焰图 + COZ + eBPF**(ch03-03):落到「哪个函数慢」,并用因果 profiler 排优化优先级。 +- **实战 walk-through**(ch03-04):用一个加权点积的真实案例,把四套工具串成一遍完整流程。 + +归因这一章有个贯穿性的纪律:**瓶颈会迁移**。你修好 Backend Memory,可能露出 Bad Speculation;所以归因是迭代的,每改一处都要回测,而不是「跑一次 profiler 改一次」。定位准了,后面的 ch04「按瓶颈部位优化」才能对症下药。 + +> 本章的工具(`perf` / `toplev` / 火焰图脚本)大多是系统级 profiler。文章写于 WSL2 环境,本机没装这些,所以 profiler 的具体命令和输出**引自权威资料(Brendan Gregg、Bakhvalov、easyperf.net)并标注**,不假装在本机跑过;能本机实测的(算术强度、原始耗时、cache 行为)都标「实测」。命令本身是 Linux 性能分析的标准动作,换到装好工具的裸机上照样能用。 + +## 本章内容 + + + USE 方法与 Roofline 模型 + TMAM 四桶与硬件采样:LBR / PEBS / Intel PT + 火焰图、perf 工作流与 COZ / eBPF + 归因实战:从一个慢程序到定位瓶颈 + diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md new file mode 100644 index 000000000..632d31e5f --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-01-backend-memory.md @@ -0,0 +1,136 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: Backend Memory Bound 是单线程性能最大的杠杆。本篇用粒子系统的实测讲透三件事:为什么连续+顺序就是快(cache-friendly + 回顾)、AoS 改 SoA 在「只更新部分字段」场景下快近 10 倍、结构体对齐/padding 如何影响 cacheline 利用率,以及软件 prefetch + 何时有用 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 存储层次与延迟阶梯:为什么顺序访问快 100 倍 +- 缓存行与局部性:64 字节的最小搬运单位 +- 归因实战:从一个慢程序到定位瓶颈 +reading_time_minutes: 7 +related: +- 循环与计算优化:code motion、展开与多累加器 +- SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存管理 +title: 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch +--- +# 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch + +## 单线程性能最大的杠杆 + +ch03 的归因方法告诉我们:绝大多数 C++ 程序的瓶颈落在 **Backend Memory Bound** 这个桶,执行单元在等数据。这其实是个好消息,因为「等数据」这一类是单线程优化里**杠杆最大**的:数据布局改一改,经常能换来几倍甚至十几倍的提升,而改算法/加 SIMD 通常只能换百分之几十。 + +这一篇专治 Backend Memory。我们把 ch02 讲过的存储层次和缓存行知识,落到三个具体的 C++ 改写手法上:cache-friendly(复习,把它变成肌肉记忆)、AoS → SoA(数据导向设计的核心改写)、结构体对齐与 padding(让 cacheline 不浪费),最后讲软件 prefetch 什么时候有用。 + +## cache-friendly:三条铁律复习 + +ch02 已经讲透了,这里压成三条可背诵的铁律,作为后续所有改写的出发点: + +1. **数据连续**:`vector`/`array`/原生数组 > `list`/`set`/链式桶。连续才能让一个 cacheline 服务多次访问。 +2. **访问顺序**:遍历沿内存连续方向走(行优先),预取器帮你把后续数据提前拉进 cache。 +3. **热数据集小**:反复扫的数据要装得进 cache(尤其 L3),否则在容量边界颠簸(回看 ch02-01:工作集 = L3 大小时延迟从 ~12 ns 飙到 ~96 ns)。 + +这三条是「免费」的,不改变算法、只调整布局和访问顺序,就能吃满 cache。但当你有一个「对象包含很多字段、但每次循环只用其中几个」的场景时,光连续还不够,这时就要上 AoS → SoA。 + +## AoS → SoA:数据导向设计的核心改写 + +这是 ch04 里最值钱的一招。看一个典型的「面向对象」粒子结构: + +```cpp +// AoS:Array of Structures —— 每个粒子的所有字段挨在一起 +struct Particle { float x, y, z; // 位置 + float vx, vy, vz; }; // 速度 +Particle ps[N]; // 6 个 float = 24 字节/粒子 +``` + +看上去很自然,每个粒子是一个对象,字段紧凑。**问题出在你只更新位置时**: + +```cpp +for (int i = 0; i < N; ++i) + ps[i].x += ps[i].vx * 0.016f; // 只动 x,用 vx +``` + +每次循环只碰 `x` 和 `vx`,但因为是 AoS,你把 `ps[i]` 整条(连同没用的 `y`、`z`、`vy`、`vz`)都拉进了 cache。一个 24 字节的粒子,你只用了 8 字节(`x`+`vx`),**cacheline 利用率只有 1/3**,带宽白浪费 2/3。 + +**SoA(Structure of Arrays)** 把同一个字段的所有值连续排: + +```cpp +struct Particles { + std::vector x, y, z, vx, vy, vz; // 6 个独立数组 +}; +Particles ps; +for (int i = 0; i < N; ++i) + ps.x[i] += ps.vx[i] * 0.016f; // 只碰 x 数组和 vx 数组 +``` + +现在你只把 `x` 数组和 `vx` 数组拉进 cache,cacheline 利用率接近 100%。我们实测(N = 100 万粒子,只更新 `x`): + +```text +===== A. AoS vs SoA(更新 1048576 粒子的 x,20 次平均)===== + AoS: 1.74 ms/次(每行把不更新的 y/z/vy/vz 也拉进 cache,带宽浪费) + SoA: 0.18 ms/次(只碰 x、vx 数组,cacheline 利用率近 100%) + SoA 快 9.79x +``` + +**接近 10 倍。** 这是单线程优化里最戏剧性的改写之一,而且没动算法、没加 SIMD,纯粹是把数据重新摆了摆。这就是**数据导向设计(DOD,Data-Oriented Design)** 的核心思想:**按「怎么被访问」组织数据,而不是按「现实世界是什么」组织**。Fabian 的《Data-Oriented Design》和 Mike Acton 的演讲是这条思路的源头(演讲留 vol10,这里只讲结论)。 + +SoA 还有一个红利:它**天然适合 SIMD 向量化**(`x` 数组连续,SIMD 一次 load 8 个 float),ch04-05 会展开。代价是代码不再「面向对象」,这是工程权衡,在性能热点上值得,在非热点上不必。 + +> 边界提醒:SoA 是**布局变换**,讲「为什么快」是 vol6 的事(就是这篇);「vector 内部为什么连续」是 vol3 的事。这里只关心布局对 cache 的影响。 + +## 结构体对齐与 padding:cacheline 别浪费 + +AoS 还有一个隐形成本,**对齐和 padding**。看这两个结构: + +```cpp +struct ParticleAoS { float x, y, z, vx, vy, vz; }; // 6 float = 24 B +struct ParticleAoS8 { float x, y, z, vx, vy, vz, pad1, pad2; }; // 8 float = 32 B +``` + +`sizeof` 实测:第一种 24 字节,第二种 32 字节(故意填到 8 的倍数)。一个 64 字节的 cacheline 能装:**两个** ParticleAoS(2×24=48B,剩 16B 装不下第三个,浪费),或**两个** ParticleAoS8(2×32=64B 正好,无浪费)。 + +```text +sizeof(ParticleAoS) = 24 B +sizeof(ParticleAoS8) = 32 B(pad 到 32) +64B cacheline 能装:AoS=2 个,AoS8=2 个 +``` + +这里 AoS 因为不是 2 的幂大小,cacheline 末尾会浪费;填成 32B 反而能整数装满。这背后的规则是编译器的**对齐 padding**:`struct` 里编译器会按成员的对齐要求插入填充(比如 `char` 后跟 `double`,中间补 7 字节让 `double` 对齐到 8)。`sizeof` 比你以为的「字段之和」大,就是这个原因。 + +实用推论:**热路径上的结构体,字段按大小降序排**(大的 double/指针在前,小的 int/char在后),能减少 padding;或用 `alignas(64)` 让关键结构独占 cacheline(这个手法在 ch05-01 治伪共享时是主角)。`#pragma pack` 能强制紧凑,但会破坏对齐、可能触发未对齐访问惩罚,**别在性能敏感路径乱用**。对齐/padding 的机制(为什么 sizeof 这么算)归 vol4 类布局,vol6 只讲它对 cacheline 的影响。 + +## 软件 prefetch:大多数时候不用 + +最后一个手法是**软件预取** `__builtin_prefetch`。它的想法是:你知道过一会儿要访问 `data[i+stride]`,就提前告诉 CPU「帮我从内存拉进 cache」,等真访问时已经命中,掩盖延迟。 + +```cpp +for (int i = 0; i < N; ++i) { + __builtin_prefetch(&data[i + PREFETCH_DIST]); // 提前 PREFETCH_DIST 步预取 + process(data[i]); +} +``` + +听起来很美,但**现代 CPU 自带硬件预取器,对规律访问(顺序、固定步长)极其在行**(回看 ch02-01:顺序遍历在 DRAM 上都能跑出 L1 的吞吐,就是硬件预取的功劳)。所以规律访问根本不用软件 prefetch,硬件已经做了。 + +软件 prefetch 真正有用的场景是**不规则但可预测的访问**:比如链表遍历(下一个节点的地址你提前知道)、B-tree 查找、图遍历。硬件预取器学不会这种模式,你手工 prefetch 能拿到收益。但即使在这些场景,prefetch 也很容易帮倒忙(预取了不用的数据,污染 cache、浪费带宽)。**永远 benchmark 对照**,这是 ch04-06 会反复强调的「别盲目手优化」纪律的一个具体实例。 + +回头看这一篇的几张牌:Backend Memory Bound 是单线程最大杠杆,改数据布局换来的是几倍十几倍,比加算力划算;cache-friendly 三铁律(连续、顺序、热数据集小)是免费的;AoS → SoA 在「只更新部分字段」场景下快近 10 倍(实测 9.79×),且天然适合 SIMD,是 DOD 的核心改写;对齐与 padding 上,热路径结构体按大小降序排能减少 padding,`alignas(64)` 治伪共享(ch05),机制归 vol4;软件 prefetch 对规律访问没用(硬件已做),不规则但可预测的访问才用,且必须 benchmark。 + +下一篇我们把战场从「数据布局」转到「计算本身」,看循环怎么写能让 ILP 和编译器都帮上忙。 + +## 参考资源 + +- Fabian, R.《Data-Oriented Design》——DOD 思想源头,本地 `.claude/drafts/books/` +- Agner Fog《Optimizing software in C++》§7 *Making containers/objects efficient*——AoS/SoA、对齐、padding 的工程讲法。本地 +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 9 章 *Memory Optimizations》 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/backend_memory.cpp` diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md new file mode 100644 index 000000000..36aa70838 --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-02-loop-and-compute.md @@ -0,0 +1,122 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 循环是 C++ 性能的主战场。本篇讲 CSAPP 第 5 章那套经典循环优化:code motion(循环不变量外提)、消除不必要的内存引用、循环展开、多累加器打破依赖链、重结合变换提升 + ILP。重点是分清哪些编译器会替你做(别手写)、哪些常需要你手动点破(FP 归约的依赖链) +difficulty: advanced +order: 2 +platform: host +prerequisites: +- 流水线、ILP 与分支预测 +- 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch +reading_time_minutes: 6 +related: +- 数据类型与算术:整数/浮点、乘除与跳转表 +- SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发 +tags: +- host +- cpp-modern +- advanced +- 优化 +title: 循环与计算优化:code motion、消除内存引用与多累加器 +--- +# 循环与计算优化:code motion、消除内存引用与多累加器 + +## 循环优化:哪些值得手写,哪些编译器替你做了 + +程序里 90% 的时间花在循环里,所以循环优化是性能工程的主战场。但这里有个让人困惑的现状:**CSAPP 第 5 章那套循环优化(code motion、消除内存引用、展开、多累加器),现代编译器在 `-O2` 已经自动做了大半**。你手写一遍,经常发现没区别。那这一篇还讲什么? + +讲两件事:第一,**搞清楚哪些是编译器会做的、哪些不会**,这样你才不会瞎手写(徒增代码复杂度还不提速);第二,少数几个编译器做不好、必须手动点破的(FP 归约的依赖链是最典型的一个),这些才是手写的价值所在。 + +CSAPP 把循环优化拆成五招,我们逐个看。 + +## 1. code motion:循环不变量外提 + +把「每次循环算一样结果」的表达式提到循环外。教科书例子: + +```cpp +// 烂:每次循环都重算 length() +for (int i = 0; i < s.length(); ++i) process(s[i]); +// 好:length() 外提 +int len = s.length(); +for (int i = 0; i < len; ++i) process(s[i]); +``` + +现代编译器对纯函数(`length()` 这种)const-fold + LICM(循环不变量外提)做得很好,大多数情况**自动外提**。但编译器外提的前提是它能证明「这玩意不变」:`volatile`、有副作用、可能修改全局状态的函数调用,它都不敢动。我们实测(`volatile float scale`,迫使编译器不能外提 vs 普通变量能外提): + +```text +===== A. code motion ===== + scale 是 volatile(每次 load):799.3 us + scale 外提到普通变量: 765.3 us +``` + +差距很小(几个百分点),因为这里瓶颈在乘法本身不在 load scale。**手写 code motion 在现代编译器下收益通常微薄**,除非你确认有编译器不敢外提的真依赖(volatile、跨翻译单元调用)。这一招主要是个**心智模型**,理解它有助于你看懂编译器生成的代码。 + +## 2. 消除不必要的内存引用 + +CSAPP 经典例子:把累加中间值存在内存(数组元素)里 vs 存在寄存器里。 + +```cpp +// 烂:每次循环 load c[i]、算、store c[i](c[i] 反复盘桓在内存) +for (int i = 0; i < N; ++i) c[i] = c[i] + a[i] * b[i] * scale; +// 好:直接写,不读回 +for (int i = 0; i < N; ++i) c[i] = a[i] * b[i] * scale; +``` + +实测: + +```text +===== B. 消除不必要的内存引用 ===== + 反复读写 c[i]:597.8 us + 直接写 c[i]: 526.1 us +``` + +差距也是几个百分点,但**别误以为这是「-O2 帮你消掉了多余访存」**。看汇编(`g++ -O2 -S loop_opt.cpp`):`.坏` 版那个多余的 `c[i]` 读回 **-O2 并没消掉**(每次迭代仍有 `addss (%rdx,%rax),%xmm0` 把 `c[i]` 读回累加),因为 `c[i]` 每次都被写、编译器不敢假设读回可省;`.好` 版干脆不读回。所以差距是真实的多一次 load/store 带来的,只是相对乘法本身占比小,所以只几个百分点。CSAPP 教科书式「把累加器放寄存器而非数组元素」在 -O2 才常被编译器自动做掉的是**纯标量累加、不写回数组**的情形。这一招在现代编译器下收益通常不大,但因果要讲对:别把「编译器已优化」当万能解释。 + +## 3. 多累加器:打破依赖链(编译器常做不好,值得手写) + +这是五招里**最常需要手动点破**的一招,因为编译器受语言语义约束不能自动做。回看 ch02-03:单累加器归约 `acc += a[i]*b[i]` 是一条长 RAW 依赖链,ILP 几乎为零;用 4 个累加器分成 4 条独立链,CPU 才能并行填满执行端口。实测(ch02-03):**单累加器 23.7 us vs 4 累加器 8.1 us,快 2.92×**。 + +为什么这一招编译器不替你做?对**整数归约**它有时会自动展开多累加器;但对 **FP 归约**,它**不能**,因为浮点加法不满足结合律(`(a+b)+c ≠ a+(b+c)`,浮点误差),自动改写会改变结果,违反标准语义。所以: + +```cpp +// FP 单链:编译器不敢自动多累加器化(会改变浮点结果) +float acc = 0; for (int i = 0; i < N; ++i) acc += a[i] * b[i]; + +// 手写 4 累加器:你(程序员)承担了「结合顺序变了、结果有微小差异」的责任 +float a0=0,a1=0,a2=0,a3=0; +for (int i = 0; i < N; i += 4) { a0 += a[i]*b[i]; a1 += a[i+1]*b[i+1]; ... } +return a0 + a1 + a2 + a3; +``` + +这是 ch04 里**手写价值最高**的一招:点积、归约、内积这类 FP 热点循环,手写多累加器稳定 2-4×。另一个解法是 `-ffast-math`(放宽 FP 语义,允许编译器重结合),但 `-ffast-math` 会改变 NaN/Inf 行为,是全局开关、代价大,**只在确信不需要严格 FP 语义的局部用**。ch04-05 的 SIMD 那篇还会再撞到这个「FP 结合律挡路」的问题。 + +## 4. 循环展开(unrolling) + +把 `for (i) a[i]` 改成 `for (i+=4) { a[i]; a[i+1]; a[i+2]; a[i+3]; }`。两方面的好处:减少循环控制开销(分支、计数器自增)、给寄存器分配和 ILP 更多余地。现代编译器 `-O3 -funroll-loops` 会做,而且编译器选展开因子比手写聪明(能权衡寄存器压力),**默认情况别手写展开**。手写展开只在两种情况值得:(a) 你同时要做「多累加器」(展开+多累加器常一起写);(b) 编译器没展开但你 benchmark 证明展开有用。盲手展开经常因为增加代码体积、icache miss 反而变慢。 + +## 5. 重结合变换(reassociation) + +改写运算的括号:`((a+b)+c)+d` → `(a+b)+(c+d)`。这本质是「打破依赖链」的另一种形式,重新加括号让独立的子表达式能并行。和多累加器是同一个思想(提升 ILP)的两种写法。对 FP 同样受结合律约束,需要手写或 `-ffast-math`。 + +把五招压成一张「手写 vs 编译器」清单: + +| 优化 | 编译器 -O2 会做吗 | 手写价值 | +|---|---|---| +| code motion(不变量外提)| 大多数会 | 低(除非 volatile/跨 TU 调用)| +| 消除内存引用 | 大多数会 | 低 | +| 多累加器(FP 归约)| **不会**(FP 结合律)| **高(2-4×)** | +| 循环展开 | -O3 -funroll-loops 会 | 低(除非配多累加器)| +| 重结合(FP)| **不会** | 中高 | + +一句话:**整数循环的优化,编译器替你做了大半,别过度手写;FP 归约/点积的热点循环,手写多累加器是稳定的免费午餐**。无论哪一招,手写完都要 benchmark 对照,「编译器已经做了」和「你的手写反而更慢」都太常见。 + +下一篇讲数据类型与算术选型,里面还有一类「编译器替不了你、必须你手选」的优化:除法瓶颈。 + +## 参考资源 + +- Bryant & O'Hallaron《CSAPP》第 5 章 *Optimizing Program Performance*——code motion / 消除内存引用 / 展开 / 多累加器 / reassociation 的经典推导(本篇五招的出处) +- Agner Fog《Optimizing software in C++》§12 *Optimizing loops》。本地 +- ch02-03 流水线、ILP 与分支预测(本卷,dot1/dot4 多累加器 2.92× 的实测出处) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/loop_opt.cpp` diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md new file mode 100644 index 000000000..a8727c726 --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-03-types-and-arithmetic.md @@ -0,0 +1,92 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 选对数据类型和算术运算是「编译器替不了你、必须你手选」的优化。本篇用实测讲透:整数除法是 5 倍于乘法的瓶颈(除数是 2 的幂能换位移、常量除法编译器换乘法逆元、运行期变量除法无解)、整数 + vs 浮点的成本差异,以及 switch 跳转表什么时候真比 if-else 链快(答案:不一定) +difficulty: advanced +order: 3 +platform: host +prerequisites: +- 循环与计算优化:code motion、展开与多累加器 +- 流水线、ILP 与分支预测 +reading_time_minutes: 6 +related: +- SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发 +- 分支:branchless 与 predication +tags: +- host +- cpp-modern +- advanced +- 优化 +title: 数据类型与算术:整数/浮点、除法瓶颈与跳转表 +--- +# 数据类型与算术:整数/浮点、除法瓶颈与跳转表 + +## 这一类优化编译器替不了你 + +ch04-02 讲循环优化时,你会发现一大半是「编译器 -O2 替你做了」。这一篇换个角度看算术:有些运算的快慢,**取决于你选了什么数据类型、写了什么运算**,而**选择权在你手里,编译器无能为力**,最典型的就是除法。 + +除法是 x86 上最「贵」的基本整数运算:它的执行延迟相对长(Zen 3 上 `idiv` 32 位约 9-12 周期、64 位可达 9-17 周期,对比 `imul`/`lea` 的 1-3 周期,数据见 Agner《Instruction tables》Zen 3 节;注意是「十几周期」而不是「几十周期」,旧教材里「几十周期」是 Pentium 漫长除法器时代的数字)、吞吐低(每 6-12 周期才能发射一条,除法器稀缺,一条接一条排队),是典型的 ch02-03 讲的**结构冒险**(structural hazard),多个除法抢同一个除法端口。乘法相反,`imul` 1 周期、全流水线。**能避开除法就避开**,是这一篇的核心。 + +## 除法瓶颈:5 倍于乘法 + +我们直接测(本机 Zen 3,`taskset -c 0`,每元素平均耗时): + +```text +===== A. 整数算术成本 ===== + x/8 (除数=2 的幂,编译器换位移): 0.33 ns + x>>3 (手写位移) : 0.38 ns + x/7 (除数=运行期变量) : 1.64 ns ← 除法瓶颈 + x*3 (乘法) : 0.33 ns + 除法(变量)/乘法 = 5.0x +``` + +三件事读出来: + +**1. 除数是 2 的幂,编译器替你换位移**。`x/8` 和 `x>>3` 一样快(都 0.33-0.38 ns),因为 GCC 看出 `8 = 2^3`,自动编译成 `>>3`。**所以你写 `x/8` 不用觉得「不优雅」,编译器替你优化了**。这条对常量除数也成立,`x/10` 这种常量除法,编译器会换成「乘以 10 的逆元」(一种用乘法+位移近似除法的技术),不真的做除法。 + +**2. 除数是运行期变量,除法躲不掉,5 倍于乘法**。`x/d`(d 是变量)时编译器没法换成位移或逆元,只能真的执行 `idiv` 指令。1.64 ns vs 乘法 0.33 ns,**5 倍**。这就是「除法瓶颈」的硬数字。 + +**3. 实战推论**:**热路径上,把变量除法换成乘法或位移**。常见手法: + +- 除数是 2 的幂 → 用位移(或干脆写 `/8` 让编译器换)。 +- 除数虽不是 2 的幂但是常量 → 信任编译器换逆元。 +- `x % m`(取模)和 `x / m` 一样贵(底层都是除法),取模瓶颈同理。 +- 哈希表用「`& (size-1)`」替代「`% size`」(前提 size 是 2 的幂),这正是 `absl::flat_hash_map`、`folly::F14` 这类现代哈希表把桶数取 2 的幂的原因,省掉一次除法。**注意 `std::unordered_map` 反其道**:libstdc++ 实现把桶数取**素数**(本机实测 `reserve(100)` 给 103、`reserve(1000)` 给 1031、`reserve(10000)` 给 10273,都非 2 的幂),为散列质量宁可付一次真除法,是「散列质量 vs 除法成本」另一面的权衡。 + +## 整数 vs 浮点:别凭直觉 + +很多人以为「浮点比整数慢」,这条在现代 CPU 上**基本不成立**。Zen 3 有独立的浮点/vector 执行单元,FP 加法和乘法都是全流水线、3-4 周期延迟,和整数加法吞吐相当(甚至 FMA 一条指令做乘加两条的活)。所以: + +- 「为了快点把 `double` 换成 `int`」通常没用,甚至更慢(精度损失、额外转换)。 +- 真正慢的浮点运算是**除法、平方根、超越函数**(`sin`/`exp`):这些延迟几十周期、吞吐低,是浮点里的「除法瓶颈」。 +- **subnormal(次正规数)**浮点运算有额外惩罚(Agner 微架构手册有载);`-ffast-math` 能开 flush-to-zero 关掉这个惩罚,但同样改 FP 语义。 + +一句话:**普通 FP 加乘不慢,慢的是 FP 除法和超越函数**。优化重点放在后者。 + +## switch vs if-else:跳转表什么时候真快 + +最后一个常被误讲的话题。教科书常说 `switch` 在分支多时会生成**跳转表(jump table)**,O(1) 的查表跳转,比一长串 `if-else`(平均要走一半分支)快。我们测: + +```text +===== B. switch vs if-else 链 ===== + switch: 0.48 ns + if-else: 0.43 ns + if-else/switch = 0.90x +``` + +**if-else 反而略快?先别急着用「跳转表的间接跳转预测器拖后腿」去解释,这正好是 ch00-01 警告的「听起来像解释」的伪因果陷阱。** 看 -O2 汇编才知道真发生了什么:配套代码里 `switch (x % 8)` 的 case 是 0..7(连续)、return 值是 100..107(也连续),GCC 发现这等价于 `return 100 + (x & 7)`,**直接折成了几条算术指令,既没生成跳转表、也没保留 if-else 链**(`g++ -O2 -S` 输出里间接跳转 `jmp *` 数 = 0,跳转表数据也为 0);if-else 版被同样折叠。两段 codegen 几乎逐行相同,所以 0.90× 是测量噪声,不是跳转表的预测代价。 + +真正的教训正是 ch02-03 那条纪律:**分支的开销不取决于语法(switch/if),取决于编译器实际生成了什么、那东西在硬件上好不好预测**。想当然地用「跳转表 vs 分支树」解释,正好掉进伪因果。 + +**跳转表什么时候才真出现?** 当 case 标号**稀疏不连续**时(例如 `case 1: case 23: case 199: ...`),编译器没法折成算术,才会真上跳转表,一张地址表 + 一次间接跳转(`jmp *`)。间接跳转的目标地址动态,分支预测器确实更难猜;case 数量又多时,跳转表 O(1) 才大胜 if-else 链的 O(n)。所以结论仍是「switch 不一定比 if-else 快」,但**原因要落到汇编上看**:case 少且连续(本例)折成算术,两者一样;case 多且密集时跳转表大胜;case 稀疏时跳转表仍 O(1) 但间接跳转有预测代价。**判断前先 `g++ -O2 -S` 看一眼**。 + +把这一篇压成几句口诀:除法是 5 倍于乘法的瓶颈(运行期变量除法),除数 2 的幂走位移(编译器常替你做),常量除法编译器换乘法逆元,热路径的变量除法/取模要重写掉;整数和浮点的普通加乘差不多,慢的是 FP 除法/平方根/超越函数;switch 不一定比 if-else 快,本例 case 少且连续被编译器折成算术/branchless,差异是噪声,判断前 `-S` 看一眼实际 codegen。这一类优化编译器替不了你,选什么数据类型、写什么运算,是你手里的牌。 + +## 参考资源 + +- Agner Fog《Optimizing software in C++》§14 *Optimizing arithmetic》(整数/浮点、乘除、跳转表的指令级成本)。本地 +- Agner Fog《Instruction tables》——各指令的延迟/吞吐/µop 分解,案头查证用。本地 +- ch02-03 流水线、ILP 与分支预测(本卷,结构冒险与分支预测器) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/arithmetic_cost.cpp` diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md new file mode 100644 index 000000000..993326fb5 --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md @@ -0,0 +1,114 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: 本篇讲 inline 的真正威力(不是「省了函数调用开销」,而是「让编译器跨函数边界优化」)、去虚拟化怎么把虚函数变回可内联的直接调用、以及一张「-O2/-O3 + 编译器都在做什么」的全景图。重点:别只盯着 inline 关键字,现代编译器按自己的代价模型决定,你的工作是「别挡它的路」 +difficulty: advanced +order: 4 +platform: host +prerequisites: +- 循环与计算优化:code motion、展开与多累加器 +- 流水线、ILP 与分支预测 +reading_time_minutes: 7 +related: +- 虚函数与去虚拟化:别急着把虚函数改模板 +- 编译器优化边界:-O 级别、optimization blockers 与 LTO +tags: +- host +- cpp-modern +- advanced +- 优化 +title: inline、去虚拟化与编译器优化全景 +--- +# inline、去虚拟化与编译器优化全景 + +## inline 的真正威力:不是省调用,是打开优化空间 + +很多人对 `inline` 的理解停在「省了函数调用的压栈/出栈开销」。这话对,但只说到了表面,当代 CPU 上一次常规函数调用也就几个纳秒,inline 省下的调用开销通常不是大头。**inline 真正的威力在于:它把被调函数的代码「搬进」调用点,让编译器能跨函数边界做优化。** + +举个例子: + +```cpp +int square(int x) { return x * x; } +int f(int n) { return square(n) + square(n); } +``` + +不 inline,`f` 调了两次 `square`,两次函数调用、两次 `n*n`。**inline 后**,编译器看到的是 `return n*n + n*n`,于是: + +1. **公共子表达式消除(CSE)**:`n*n` 算了两次 → 合并成 `int t = n*n; return t + t;`。 +2. **强度折减**:`t + t` → `t << 1`(加法换位移,虽然这里没意义,但编译器会评估)。 +3. **常量传播**(如果 `n` 是编译期已知):整个 `f(5)` 折叠成常量 `50`。 + +这些优化**不 inline 就做不了**,因为编译器看不见 `square` 的实现(它在另一个翻译单元,或者编译器不敢跨调用点假设)。inline 一旦发生,函数边界消失,优化空间打开。这才是 inline 的核心价值。 + +## inline 关键字只是「提示」 + +C++ 的 `inline` 关键字是个**提示**,不是命令。现代编译器(GCC/Clang)按自己的**代价模型**决定是否真内联:函数太大不内联(代码膨胀 icache miss 得不偿失)、递归不能完全内联、虚函数不能内联(运行时才知道调谁)。反过来,**没标 `inline` 的函数,编译器只要看得见实现,也会自动内联**(尤其在 `-O2`/`-O3` + LTO 下)。 + +所以你的工作不是「到处标 `inline`」,而是: + +- **把热函数的实现放在头文件里**(或开 LTO),让编译器**看得见**实现,这是内联的前提。 +- **别用 `noinline` 随便挡**(有时为了调试或控制代码体积会用,但记得去掉)。 +- 真的必须强制内联时用 `[[gnu::always_inline]]`(GCC/Clang)或 C++ 没标准化的 `__attribute__((always_inline))`,但慎用,编译器的代价模型通常比你准。 +- C++20 的 `[[gnu::flatten]]` 能强制内联整个调用链,极度敏感的热点偶尔用。 + +**一个常见误区**:把 `inline` 当「让函数变快」的银弹到处贴。如果函数实现不在头文件(跨翻译单元),`inline` 关键字没用,编译器还是看不见实现。LTO(ch07-02)解决这个。 + +## 去虚拟化:把虚函数变回可内联 + +虚函数是 inline 的天敌,因为调谁要等到运行时查 vtable 才知道,编译器不敢内联。但**如果编译器能证明「运行时类型是确定的」**,它会把虚调用**去虚拟化(devirtualization)**成直接调用,然后就能内联了。我们测四种调用方式(本机,平均每次 ns): + +```text +===== 虚函数与去虚拟化 ===== + 虚函数(指针,运行时多态): 0.55 ns ← 查 vtable + 间接跳转,阻碍内联 + final 类(编译器去虚化): 0.54 ns + 直接对象(非指针,常去虚化): 0.23 ns + CRTP(静态多态,无虚表): 0.22 ns ← 可内联 + 虚函数/CRTP = 2.5x +``` + +读出两件事: + +**1. 虚函数通过指针调用(0.55 ns)比可内联的 CRTP/直接对象(0.22-0.23 ns)慢 2.5 倍。** 看汇编,在本例里虚调用其实被 GCC **推测性去虚化**了(运行时比对 vptr 与编译期已知目标地址,猜中走内联快速路径),函数体 `x*3+1` 已被内联,所以 0.55 ns 这一档多出来的开销不是「vtable 查表 + 间接跳转」,而是循环里 per-iteration 的**类型守卫**(加载 vptr、与已知地址比较、条件分支);直接对象/CRTP 那一档连守卫都没有,所以快。**CRTP(静态多态,模板实现)** 把多态推到编译期,没有虚表、能内联,是最快的。 + +**2. `final` 在这个例子里没让循环变快(0.54 ≈ 0.55)。** 我标了 `struct DerivedF final`,期望编译器「知道没有更派生类」就敢去虚化,但 0.54 ≈ 0.55 **不是「final 没去虚化」**!本机 `-fopt-info-all` 显示 final 类(`DerivedF`)和普通派生(`Derived`)的虚调用都被 GCC **推测性去虚化**了(和上面第 1 点同一种机制)。0.54 ≈ 0.55 的真因是「推测去虚化的 per-iteration 类型守卫开销和一次虚调用相当,没省下来」。这是个**诚实、重要的结果**:**别以为标了 `final` 就自动快,去虚化能不能省下开销,要看汇编里是不是真变成了直接 `call`**(`-S`:虚调用是 `call [vtable+offset]` 间接跳转,完全去虚化是直接 `call func`)。 + +推论:**别为了「可能快点」提前把虚函数改成 CRTP/模板**。先测,编译器可能已经去虚拟化了(尤其你用直接对象调用时);真测出虚函数是瓶颈,再考虑 CRTP 或 `final`。这条 ch06-01 会展开成完整的「虚函数成本」讨论。 + +## 编译器优化全景:-O2/-O3 都在做什么 + +把 inline、devirt 放进更大的图景。一张表看 GCC/Clang 各 `-O` 级别大致做什么(精确清单查官方文档): + +| 级别 | 大致做什么 | 何时用 | +|---|---|---| +| `-O0` | 不优化,变量可观察 | 调试(**性能数字无意义**)| +| `-O1` | 基本优化(常量折叠、简单内联)| — | +| `-O2` | 大多数优化:CSE、 LICM、寄存器分配、调度、**自动内联(同 TU)**、基础向量化 | **release 默认甜点** | +| `-O3` | 更激进:**循环向量化、自动展开、更激进内联** | 数值/SIMD 受益;偶尔反伤(icache)| +| `-Os`/`-Oz` | 优化**体积** | 嵌入式 flash 受限 | + +关键认知: + +- **`-O2` 是 release 的默认甜点**。它已经做了 04-02 讲的循环优化大半、同翻译单元内联、基础调度。 +- **`-O3` 比 `-O2` 多的主要是「更激进的向量化 + 展开 + 内联」**。对数值计算/SIMD 友好的代码有用;对分支密集、不规则代码**可能反而变慢**(代码膨胀 → icache miss)。 +- **跨翻译单元的内联和优化,`-O2`/`-O3` 都做不了**,要靠 LTO(ch07-02)。这就是为什么大项目 release 推荐开 LTO。 + +## 别挡编译器的路:optimization blockers + +inline 和这一节的各种优化,前提都是「编译器看得见、敢优化」。有几种情况会**挡住编译器**(optimization blockers,ch07-01 详讲): + +- **跨翻译单元**:看不见实现 → 不敢内联。解法:头文件放实现 / 开 LTO。 +- **指针别名**:编译器不知道两个指针是否指向同址,不敢激进重排内存读写。解法:`__restrict` 声明无别名(C99 引入,C++ 是扩展但 GCC/Clang 都支持)。 +- **`volatile`**:强制每次真读写内存,等于禁用优化。只在 MMIO/信号/无锁标志用,**别用于性能**。 + +这三条都是「你写了别的东西,挡了编译器的路」。本篇的核心精神就是:**内联和编译器优化的主要工作不是「主动做什么」,而是「别挡路 + 让它看得见实现」**。主动手写(强制内联、CRTP)只在测出瓶颈后做。 + +把这一篇压成几句:inline 的真正价值是打开跨函数边界的优化空间(CSE、常量传播、强度折减),不是省调用开销;`inline` 关键字只是提示,让编译器看得见实现(头文件 / LTO)才是内联的前提;去虚拟化能把虚函数变回可内联,但不是 `final` 一标就省开销,本例 final 类和普通派生都被 GCC 推测性去虚化了(`-fopt-info-all` 可证),0.54 ≈ 0.55 是去虚化的 per-iteration 守卫开销没省下来,直接对象调用更易完全去虚化,CRTP 是静态多态、最快(virtual/CRTP = 2.5×);`-O2` 是 release 甜点,`-O3` 多的是激进向量化/展开,数值友好但偶尔反伤;optimization blockers(跨 TU、别名、volatile)是你挡了编译器的路,下一篇 ch04-05 会在 SIMD 上下文里再撞一次「编译器敢不敢」的问题。 + +## 参考资源 + +- Piotr Padlewski *C++ devirtualization in clang*(CppCon 2015 Lightning)——去虚拟化机制(vol10 复用) +- GCC/Clang 文档 `-O` levels、`-finline-*`、`__restrict`、`always_inline` 属性 +- ch06-01 虚函数与去虚拟化(本卷,virtual 成本的完整讨论) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp` diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md new file mode 100644 index 000000000..5634a07c0 --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-05-simd.md @@ -0,0 +1,122 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: SIMD 一条指令处理多个数据,理论加速 4×/8×/16×。本篇用点积实测讲清三件事:编译器自动向量化的条件(并纠正一个常见误解——现代 + GCC 默认会向量化 FP 归约,但单累加器依赖链限死收益)、手写 AVX2 intrinsics 能稳定拿到 ~20×(实测)、以及为什么 SSE/AVX2 + 是安全甜点而 AVX-512 要小心降频 +difficulty: advanced +order: 5 +platform: host +prerequisites: +- 循环与计算优化:code motion、展开与多累加器 +- 存储层次与延迟阶梯 +reading_time_minutes: 8 +related: +- 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch +- 编译器优化边界:-O 级别、optimization blockers 与 LTO +tags: +- host +- cpp-modern +- advanced +- 优化 +title: SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发 +--- +# SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发 + +## 一条指令处理多个数据 + +SIMD(Single Instruction, Multiple Data)是当代 CPU 提升算力的主力手段:一条指令同时处理多个数据。寄存器宽度越宽,同时处理的数据越多: + +- **SSE**:128 位寄存器,一次处理 4 个 float(或 2 个 double)。 +- **AVX/AVX2**:256 位,一次 8 个 float。 +- **AVX-512**:512 位,一次 16 个 float。 + +理论上,把标量循环改成 AVX2,吞吐能直接 ×8(每条指令做 8 个 float 的运算)。我们用点积实测看真实数字: + +```text +===== SIMD 点积(4M float = 16MB,30 次平均)===== + 标量 dot_scalar 958.2 us + 手写 AVX2 dot_avx2 48.1 us + AVX2/标量 = 19.9x +``` + +**近 20 倍。** 这个数字比理论 8× 高,因为手写版除了 SIMD 宽度,还用了 FMA(一条指令做乘加两条的活)+ 多累加器(打破依赖链,04-02 讲过),三者叠加。**先纠正一个直觉**:标量版 `dot_scalar` 在 `-O3 -mavx2` 下其实**已经被自动向量化了**(`g++ -O3 -mavx2 -fopt-info-vec-optimized` 会报 `loop vectorized using 32 byte vectors`),并不是「没向量化」。那它为什么还是慢 20 倍?因为它的累加形成依赖链(下面看汇编知道是标量水平累加链),手写 `dot_avx2` 有 4 个累加器并行。**这 20× 主要是「多累加器 vs 单累加器」的 ILP 差距,不是「向量化与否」。** 这正是本篇要讲的重点。 + +## 自动向量化:条件 + 一个关于 FP 归约的常见误解 + +编译器在 `-O2`/`-O3 -ftree-vectorize` 下会尝试把循环自动改成 SIMD。前提条件: + +1. **循环次数可估**(编译器要知道大概多少次,才能安排 SIMD)。 +2. **无指针别名**(编译器要敢假设两次循环迭代访问的内存不重叠;`__restrict` 帮它,见 ch04-04)。 +3. **无函数调用**(或调用可内联消除)。 +4. **分支简单**(无复杂控制流)。 + +**第 5 条「FP 归约」值得更新一下旧认知。** 点积的核心是 `acc += a[i] * b[i]`,这是一个 **FP 归约(reduction)**。SIMD 化它要把累加拆成多个并行通道,这**改变了浮点加法的结合顺序**,而浮点加法**不满足结合律**(`(a+b)+c ≠ a+(b+c)`,尾数舍入误差不同)。**旧教材常说「默认 -O3 严格遵守 FP 语义、不敢改结合顺序,所以不向量化 FP 归约」,这个说法在现代 GCC 上过时了**:GCC 10 起会用 **ordered(顺序保持)归约模式**向量化 FP 归约,它保持可观察的累加顺序(结果位精确一致),只在内部用 SIMD 做保持顺序的部分和。亲手验证:`g++ -O3 -mavx2 -fopt-info-vec-optimized` 编译本篇的 `dot_scalar`,会看到 `loop vectorized using 32 byte vectors`。 + +**那为什么标量点积还是比手写 dot_avx2 慢近 20 倍?** 看 ordered 模式实际生成的汇编:`dot_scalar` 在 `-O3 -mavx2` 下乘法用 `vmulps`(8 路 SIMD)向量化了,但**保序累加是一串标量 `vaddss`(逐 lane 水平加进同一个 `xmm0`)**,形成一条标量加法依赖链。这是 ordered 模式的代价:它向量化了乘法,却没法让累加并行(并行累加会改变 FP 结合顺序)。手写 dot_avx2 的 4 个向量累加器才打破这个限制、让 4 条 FMA 真正并行。**所以这 20× 主要是「4 累加器并行 vs 标量顺序累加」的差距,不是「向量化 vs 没向量化」。** 想让编译器自己拆多通道,要给 `-ffast-math`(或更窄的 `-fassociative-math`)放宽 FP 语义。**开了 `-ffast-math` 后 GCC 确实会把 `dot_scalar` 重排成 4 个向量累加器**(汇编可见 `vfmadd231ps × 4 + unroll 32`,和手写 `dot_avx2` 同款结构);但在本机 WSL2 上实测耗时几乎不变,说明这个简单循环的 20× 差距主因不只是累加器数,还有 `dot_scalar` 被内联进 REP 外层循环、和作为独立函数调用的 `dot_avx2` 处于不同的测量结构。整数归约没有结合律问题,默认就能多通道,自动向量化更积极。 + +想知道你的循环向量化没有、为什么没拿满,用 GCC 诊断:`-fopt-info-vec-optimized`(哪些被向量化)、`-fopt-info-vec-missed`(为什么没),这是读编译器心思的最快办法。 + +## 手写 intrinsics:AVX2 实战 + +当自动向量化做不到(非标准访问模式、特定指令、或就是要榨干)时,手写 intrinsics。intrinsics 是编译器内置的、对应单条 SIMD 指令的 C 函数,头文件 ``。AVX2 点积的核心: + +```cpp +#include +float dot_avx2(const float* a, const float* b) { + __m256 v0 = _mm256_setzero_ps(), v1 = _mm256_setzero_ps(), + v2 = _mm256_setzero_ps(), v3 = _mm256_setzero_ps(); + for (int i = 0; i < N; i += 32) { // 4 条向量 × 8 float = 32 + v0 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i), _mm256_loadu_ps(b+i), v0); + v1 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+8), _mm256_loadu_ps(b+i+8), v1); + v2 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+16), _mm256_loadu_ps(b+i+16), v2); + v3 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+24), _mm256_loadu_ps(b+i+24), v3); + } + // horizontal 合并 4 个累加器 + __m256 s = _mm256_add_ps(_mm256_add_ps(v0, v1), _mm256_add_ps(v2, v3)); + alignas(32) float tmp[8]; _mm256_store_ps(tmp, s); + float acc = 0; for (int i = 0; i < 8; ++i) acc += tmp[i]; + return acc; +} +``` + +几个要点: + +- **`__m256`** 是 256 位向量类型,装 8 个 float。 +- **`_mm256_loadu_ps`**:从内存 load 8 个 float(`u` = unaligned,允许未对齐;对齐版本 `_mm256_load_ps` 要求数据 32 字节对齐)。 +- **`_mm256_fmadd_ps(a, b, c)`**:`a*b + c`,FMA 一条指令做乘加(AVX2 + FMA 才有,编译要加 `-mfma`)。 +- **4 个累加器** `v0..v3`:打破依赖链(04-02),让 4 条 FMA 并行填满执行端口。 +- **horizontal 合并**:SIMD 累加器最后要归约成一个标量,这一步标量运算较多,所以累加器也别开太多(开 8 个收益就不如 4 个了)。 + +这套写法吃透了 SIMD 宽度 + FMA + 多累加器,实测拿到 20×。但代价是**可移植性差**(绑死 AVX2+FMA,老 CPU 跑不了)和**可读性差**。所以手写 intrinsics 的边界:**自动向量化能做到就别手写,手写只在「自动做不到 + 确认是热点」时上**。 + +## AVX-512:小心降频 + +AVX-512 听起来更猛(512 位,一次 16 个 float),但有个**坑**:在某些 Intel CPU 上,运行 AVX-512 指令会触发**许可证级降频(license-based frequency)**,CPU 认为 AVX-512 的功耗/散热要求高,主动把**全核**频率降一档,而且降频会**延续到 AVX-512 指令结束后一段时间**(等「冷却」)。结果:一小段 AVX-512 代码可能拖慢**整个程序**的其它部分,得不偿失。 + +这条坑随 Intel 代际在改善(Ice Lake 起轻量 AVX-512 不降频;Skylake-X 那代最严重),AMD Zen 4 才引入 AVX-512 且策略不同。**实践上,SSE/AVX2 + FMA 是安全甜点**,收益已经很大、没有降频坑;AVX-512 要么在确认不降频的新 CPU 上、要么用「轻量」指令子集,并且**必须 benchmark**。本机 5800H 是 Zen 3,只支持到 AVX2(无 AVX-512),所以本卷的 SIMD 例子都是 AVX2 级。 + +## CPU 分发:function multiversioning + +手写 intrinsics 绑死指令集,可移植性差。解法是 **CPU 分发 / function multiversioning**:同一函数写多个版本(SSE 版、AVX2 版、标量版),运行时按 CPU 能力选。GCC 内建 `__builtin_cpu_supports("avx2")` 查能力;更优雅的是 `__attribute__((target_clones("avx2","default")))` 让编译器自动生成多版本 + 分发: + +```cpp +__attribute__((target_clones("avx2","default"))) +float dot(const float* a, const float* b) { /* 一份通用代码,编译器按 target 各编一遍 */ } +``` + +编译器会生成 AVX2 版和默认版,运行时第一次调用时检测 CPU 选版本(IFUNC 机制)。这样一份源码,在不同 CPU 上跑各自的优化版。代价:二进制变大(多版本代码)、首次调用有微小分发开销。 + +## 展望:std::simd(C++26) + +C++26 起标准化 `std::simd`(在 ``),提供一个**可移植的 SIMD 抽象**,你写「用宽度为 N 的 SIMD 处理」,编译器映射到各平台的具体指令。这能把手写 intrinsics 的可移植性问题交给标准库。vol6 这里只提一笔展望,深入等 C++26 普及。 + +把这一篇压成几句:SIMD 理论 ×4/×8/×16,实测手写 AVX2 点积拿 **~20×**(SIMD 宽度 + FMA + 多累加器叠加);自动向量化的条件是循环次数可估、无别名、无复杂分支,**现代 GCC(GCC 10+)默认会用 ordered 模式向量化 FP 归约(保持顺序、结果位精确),但单累加器限死依赖链**,拿满 SIMD 要靠**多累加器**(手写,或 `-ffast-math` 让编译器敢拆通道),不是「开 -O3/-ffast-math」就行,用 `-fopt-info-vec` 读诊断;手写 intrinsics 榨干 SIMD,但可移植性差、只在热点 + 自动做不到时上;AVX-512 有降频坑,SSE/AVX2+FMA 是安全甜点,必须 benchmark;CPU 分发(`target_clones` / `__builtin_cpu_supports`)解决可移植性,`std::simd`(C++26)是未来。 + +## 参考资源 + +- Agner Fog《Optimizing assembly》§5「Vector programming」+ §13「AVX-512」。本地 +- GCC 文档 `-ftree-vectorize` / `-fopt-info-vec` / `target_clones` / `__builtin_cpu_supports` +- Intel Intrinsics Guide(intel.com/intrinsics-guide)——intrinsics 速查 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/simd.cpp` diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md new file mode 100644 index 000000000..72bb73ce3 --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-06-branch-branchless.md @@ -0,0 +1,104 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: Bad Speculation 桶的对策是消除不可预测的分支。本篇讲 branchless(用 cmov/位运算消除分支)、predication + 的原理,但核心信息是反直觉的:现代 CPU 分支预测器极强 + 编译器常自动无分支化(循环向量化成 SIMD 掩码、标量转 cmov),「别盲目 branchless」——可预测分支几乎免费,branchless + 改写若增加指令或数据依赖反而更慢。永远 benchmark +difficulty: advanced +order: 6 +platform: host +prerequisites: +- 流水线、ILP 与分支预测 +- 数据类型与算术:整数/浮点、乘除与跳转表 +reading_time_minutes: 6 +related: +- 循环与计算优化:code motion、展开与多累加器 +- 前端优化:代码布局、PGO、BOLT +tags: +- host +- cpp-modern +- advanced +- 优化 +title: 分支:branchless、predication 与「别盲目无分支」 +--- +# 分支:branchless、predication 与「别盲目无分支」 + +## Bad Speculation 桶的对策 + +ch02-03 我们测过:一个 50/50 随机的分支,被预测器猜错要冲刷流水线,代价惨重,排序数组(可预测)vs 打乱数组(不可预测)差 **4.2 倍**。TMAM 把这种浪费归到 **Bad Speculation** 桶。这一桶的对策只有一条思路:**消除不可预测的分支**。 + +消除有两种做法:**branchless**(把分支改成无分支的 cmov/位运算)和 **predication**(让两条路径都算、最后按条件选,反正都算就无所谓「猜错」)。听起来都是银弹,但本篇的核心信息是反直觉的:**别盲目 branchless**。我们用实验说明为什么。 + +## branchless:cmov 与位运算 + +最常见的 branchless 改写是 `std::min`/`std::max`/`std::clamp` 这类,它们底层用 **cmov**(条件传送)指令:两条路径的结果都算出来,然后按条件「选」一个,全程没有分支跳转,自然没有预测失败。 + +```cpp +// 有分支:不可预测时预测失败冲刷 +int clamp_if(int x, int lo, int hi) { + if (x < lo) return lo; + if (x > hi) return hi; + return x; +} +// 无分支(std::min/max 编译成 cmov) +int clamp_cmov(int x, int lo, int hi) { + return std::min(std::max(x, lo), hi); +} +``` + +`cmov` 的代价:两条路径都算了(多算一次)、有数据依赖(选的结果依赖条件),但**没有控制依赖、不冲刷流水线**。对**不可预测**的分支,cmov 完胜;对**可预测**的分支,cmov 反而更慢(因为多算了 + 数据依赖链拉长,而有分支的版本预测器命中、几乎免费)。 + +## 上手跑一跑(以及一个诚实的结果) + +我在 50/50 随机数据上测 clamp 的三种写法:if 分支 / `std::min`+`std::max`(cmov)/ 位运算 trick: + +```text +===== branchless vs 分支(clamp,随机数据 50/50)===== + if 分支: 0.27 ns/次(预测失败冲刷) + std::min/max: 0.25 ns/次 + 位 trick: 0.25 ns/次 + if / cmov = 1.07x +``` + +**差距只有 1.07 倍,不是我预期的「branchless 大胜」。** 为什么?看汇编(`g++ -O2 -S branchless.cpp`)——**整个循环里 `cmov` 指令数 = 0**:默认 `-O2` 下 GCC 把三种 clamp 的循环都**自动向量化成了相同的 SIMD 掩码操作**(`-fopt-info-vec` 报 `loop vectorized using 16 byte vectors`)。也就是说,你写 `if`、写 `std::min/max`、写位 trick,在循环里经 -O2 编译后**变成了同一份向量代码**,所以三者一样快。**注意不是「if 被转成 cmov」**(标量单次 clamp 编译器才会用 cmov)**而是「循环被向量化」,别用错机制解释**。 + +这是个**极重要的诚实结果**:你写的 `if` 不一定是真分支,编译器可能早就替你 branchless 了(循环里常靠 SIMD 向量化、标量单次常靠 cmov)。要确认,得看汇编(`-S`)+ 向量化诊断(`-fopt-info-vec`):真分支是 `jcc`(条件跳转),标量无分支是 `cmov`/`cset`,循环无分支是 SIMD 掩码指令。**别假设你的 if 是分支,别假设手写 branchless 一定更快**。 + +那真分支的代价在哪?要逼出真分支,得**禁用编译器的 if-conversion**(就像 ch02-03 我用 `-fno-if-conversion` 才测出 4.2 倍)。ch02-03 的那个实验(打乱 vs 排序,4.2×)才是真分支惩罚的诚实证据。本篇这个 1.07× 是「编译器已 branchless」的诚实证据。两个合起来才是完整图景。 + +## predication:两条路都算 + +`cmov` 的思想推广就是 **predication**:与其分支选一条路算,不如**两条路都算,最后按条件选**。GPU 和向量体系结构(SSE/AVX 的 mask)大量用 predication,因为它们讨厌分支(发散)。x86 上 predication 体现为 cmov / cset。 + +predication 的代价:总计算量 = 两条路径之和(即使只取一条的结果)。所以它**只适合「两条路径都很便宜」**的场景(`max`、`min`、`clamp`、简单赋值)。如果两条路径之一很贵(比如一次除法、一次函数调用),predication 强行都算就亏大了,这时**有分支更好**(贵的路径只在命中时才算)。这条权衡是「别盲目 branchless」的另一面。 + +## `[[likely]]` / `[[unlikely]]`:给编译器分支概率 + +C++20 标准化了 `[[likely]]` / `[[unlikely]]`(老办法是 GCC 的 `__builtin_expect`):给编译器一个分支概率提示,让它把 likely 路径的代码布局到一起(改善 icache,详见 ch04-07 前端优化)、调整分支预测假设。 + +```cpp +if (rare_error) [[unlikely]] { handle_error(); } // 告诉编译器这条路很少走 +``` + +注意 `[[likely]]` 的**主要收益是代码布局**(把热路径聚一起、冷路径扔到函数末尾),让 icache 命中率更高,这是 Frontend 优化。它**不是**「让分支预测器更准」(硬件预测器不看你的源码注解,它看运行时历史)。所以 `[[likely]]` 在「分支非常倾斜 + 函数较大」时有用,在「小循环里的分支」基本没用。 + +## 「别盲目 branchless」:四条纪律 + +把这一篇的诚实结果压成四条纪律: + +1. **可预测的分支几乎免费**。循环退出条件、`if (ptr == nullptr)` 这种绝大多数走同一方向的分支,预测器命中率 99%+,不用费心消除。该消除的是**数据相关的、不可预测的分支**。 +2. **你的 `if` 不一定是真分支**。编译器常自动无分支化(循环里向量化成 SIMD 掩码、标量里转 cmov),看汇编 + `-fopt-info-vec` 确认。 +3. **branchless 不是银弹**。如果 predication 让两条路径都算(其中一条很贵),或者增加数据依赖链,branchless 反而更慢。 +4. **永远 benchmark 对照**。改 branchless 前后用同一套方法论测一遍(ch01),信号比直觉可靠。 + +这条「别盲目」的纪律其实贯穿 ch04。04-02 的循环优化、04-04 的 inline、这一篇的 branchless,故事都是同一个:**现代编译器 + 硬件预测器替你做了大半,你的工作不是「使劲手写优化」,而是「测出真瓶颈、精准改、改完验证」**。性能优化不是堆 trick,是测量驱动的精准手术。 + +回头看这一篇:Bad Speculation 桶的对策是消除不可预测的分支,手法是 branchless(cmov/位运算)和 predication;实测 clamp 的 if/cmov/位 trick 三者基本一样快(1.07×),因为 `-O2` 下循环里的三种写法都被向量化成相同 SIMD 掩码代码(`-S` 下 cmov 数 = 0),真分支惩罚的证据是 ch02-03 的 4.2×;predication 只适合两条路径都便宜的场景,一条贵时反不如有分支;`[[likely]]`/`[[unlikely]]` 主要收益是代码布局(icache),不是预测器;归根结底是别盲目 branchless,可预测分支免费、你的 if 可能已是 cmov、benchmark 是唯一裁判。 + +## 参考资源 + +- Agner Fog《The microarchitecture of Intel, AMD and VIA CPUs》分支预测章节。本地 +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 9 章 *Optimizing Bad Speculation* +- ch02-03 流水线、ILP 与分支预测(本卷,4.2× 真分支惩罚的实测出处) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/branchless.cpp` diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md new file mode 100644 index 000000000..2d282a8dc --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/04-07-frontend-pgo.md @@ -0,0 +1,106 @@ +--- +chapter: 4 +cpp_standard: +- 17 +description: Frontend Bound 桶的对策:让取指/译码跟得上。本篇讲怎么治 icache/iTLB miss——控制模板与 inline 膨胀、用 + PGO 让编译器按真实运行剖面布局热代码、用 BOLT 在链接后做代码布局优化。并用一个 PGO 三阶段实验给诚实的结论:PGO 对微基准可能无收益,价值在大型代码库 +difficulty: advanced +order: 7 +platform: host +prerequisites: +- TMAM 四桶与硬件采样 +- inline、去虚拟化与编译器优化全景 +reading_time_minutes: 6 +related: +- 分支:branchless、predication 与「别盲目无分支」 +- LTO、ThinLTO 与 PGO 的工程接入 +tags: +- host +- cpp-modern +- advanced +- 优化 +title: 前端优化:代码布局、PGO 与 BOLT +--- +# 前端优化:代码布局、PGO 与 BOLT + +## Frontend Bound:取指/译码跟不上 + +ch04 前面六篇都在治 Backend(内存、计算)和 Bad Speculation(分支)。最后一种瓶颈是 **Frontend Bound**:CPU 前端(取指、译码)跟不上后端的执行速度,slot 因为「指令没及时喂进来」而空转。这种瓶颈在大代码库里很常见,表现为: + +- **icache miss**:代码体积太大,指令 cache 装不下,频繁去 L2/L3 取指令。 +- **iTLB miss**:指令页表翻译也miss,尤其代码页很多时。 +- **代码膨胀**:过度 inline / 模板膨胀,导致同一个逻辑的指令数变多、icache 压力大。 + +Frontend Bound 在数值密集型小循环里很少见(代码小、icache 命中率高),在**大型应用、模板重、分支多的代码**里常见。对策都是围绕一件事:**让热代码紧凑、布局到一起,icache 友好**。 + +## 第一招:控制代码膨胀 + +Frontend 优化的第一招是「别让代码无谓变大」: + +- **避免过度 inline**:inline 不是越多越好。过度 inline 会让代码体积膨胀(同一份内联代码在多处展开),icache 装不下反而变慢。04-04 讲过,编译器有代价模型,别用 `always_inline` 乱强制。 +- **控制模板膨胀**:模板对不同类型各生成一份代码。如果同一个模板对 20 个类型实例化,就有 20 份代码。对策:把类型无关的逻辑抽到非模板基类/公共函数里(只编一份)、用 `extern template`(C++11)显式实例化避免重复生成。 +- **`-ffunction-sections -fdata-sections` + 链接 `--gc-sections`**:把每个函数/数据放独立段,链接期回收未被引用的段。去掉死代码,减小体积。这是体积优化(ch07-04)的标准动作,顺带改善 icache。 + +## 第二招:PGO(按真实剖面布局代码) + +**PGO(Profile-Guided Optimization)** 是 Frontend 优化的重头戏。思路:先跑一遍程序收集「哪些代码真的热、哪些分支怎么走」的剖面(profile),编译器据此重新布局,把热路径的代码物理上聚到一起(改善 icache)、优化分支预测布局、做更聪明的 inline 决策。 + +PGO 是三阶段流程: + +```bash +# 阶段 1:仪器化编译(插计数器)— 注意 -o 的名字要和阶段 3 完全一致! +g++ -O2 -fprofile-generate app.cpp -o app_pgo +# 阶段 2:跑代表性 workload,生成剖面(.gcda 文件,文件名里含「生成它的二进制名」) +./app_pgo +# 阶段 3:用剖面重编(-o 必须和阶段 1 同名;否则 .gcda 文件名对不上, +# 编译器报 warning: profile count data file not found,profile 不会被应用) +g++ -O2 -fprofile-use app.cpp -o app_pgo +``` + +### 一个诚实的实验:PGO 对微基准无收益 + +我用一个「99% 走热路径、1% 走冷路径」的小函数(`process`),严格跑完三阶段 PGO,和纯 `-O2` 基线对比(各跑 3 次取稳): + +```text +===== 三方对比 ===== + 纯 -O2 基线: 3.57-3.82 ms + -O2 + PGO: 3.78-4.17 ms +``` + +**PGO 没有任何收益**(甚至略慢,在噪声内)。这是个**重要的诚实结果**,值得展开为什么: + +- 这个小函数只有 2 个分支、几行代码,**编译器 -O2 已经把它优化得很好**(hot path 内联、分支预测器对 99/1 的分支命中 99%)。 +- PGO 的真正价值是**大型代码库的代码布局**:把分散在几千个函数里的热路径物理聚到一起、让 icache 命中率显著提升。对一个几十行的小函数,没什么可布局的。 +- 业界 PGO 的公开收益都来自**大项目**:Chrome、Firefox、各大数据库,报告个位数到十几个百分点的提速,前提是代码库大到 icache/分支布局真的成为瓶颈。 + +> 我第一遍跑这个实验时,「PGO 版」看起来快了 4 倍,激动了一下。后来发现那个 4 倍全是**仪器化二进制的计数器开销**(阶段 1 的 `app_pgo` 自带性能计数器,慢得多),不是 PGO 的功劳。修正方法:用**纯 `-O2` 无仪器化**的基线对照,且确保阶段 3 的 profile 真的被应用(编译器会 warning `profile count data file not found` 如果没找到)。这个翻车经历我写在这里,是想再次强调 ch01 的纪律:**你以为测的是 PGO 收益,可能测的是仪器化开销**。基线必须干净。 + +所以 PGO 的实战建议:**别期望它在微基准上有效**;在你的真实大型项目 release 构建里开(`-fprofile-generate` → 跑代表性负载 → `-fprofile-use`),用生产级的 workload 采样,才有意义。PGO 的工程接入(怎么选 workload、CI 怎么集成)是 ch07-02 的事。 + +## 第三招:BOLT(链接后布局优化) + +**BOLT(Binary Optimization and Layout Tool)** 是 LLVM 项目里的工具,做的是 PGO 的进阶版:**直接在已经链接好的二进制上做代码布局优化**,不需要重新编译。它读 perf 采集的 profile,重新排列二进制里的代码块(把热基本块连续摆、冷块扔到末尾),对**大型二进制**效果显著(社区报告个位数到十几百分点)。 + +BOLT 的优势:**不需要重新编译整个项目**(这对大项目极有价值,重编一次几十分钟到几小时),只在最终二进制上操作。代价:构建流程复杂度上升、需要 profile 数据。适合**已经用了 LTO + PGO 还想再榨一点**的极致优化场景,普通项目不必上。 + +## Frontend 优化的实战优先级 + +把三招排个优先级: + +1. **控制膨胀**(别过度 inline、模板抽公共、gc-sections),免费、低风险,先做。 +2. **PGO**(大项目 release 构建开),大代码库有实打实收益,接入成本中等。 +3. **BOLT**(极致优化),已用 LTO+PGO 还想再榨的场景,接入成本高。 + +但**先确认你真的是 Frontend Bound**,用 TMAM 看 Frontend 桶占比高不高(ch03-02)。不先 profile 就上 PGO/BOLT 是「拿着锤子找钉子」,可能辛苦半天,真瓶颈在别处(往往在 Backend Memory)。 + +回头看这一篇:Frontend Bound 就是取指/译码跟不上,大代码库常见,对策是让热代码紧凑、布局到一起;三招分别是控制膨胀(别过度 inline、模板抽公共、gc-sections)、PGO(按剖面布局)、BOLT(链接后布局);**PGO 对微基准无收益**(实测 ~3.7 vs ~3.9 ms),价值在大型代码库(Chrome/Firefox 级,公开收益个位数到十几百分点),**那个一度出现的 4× 是仪器化开销,不是 PGO,基线必须干净**;最后,**先 profile 确认 Frontend 真是瓶颈**,再上 PGO/BOLT,别拿锤子找钉子。 + +到这一篇,ch04 按瓶颈部位优化就讲完了。四个桶(Backend Memory / Backend Core / Bad Speculation / Frontend)各有对策。下一篇我们换个视角:多核性能(ch05),那里有新的瓶颈类型(伪共享、NUMA)。 + +## 参考资源 + +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 7 章 *CPU Front-End Optimizations* +- LLVM BOLT 文档(github.com/llvm/llvm-project/blob/main/bolt) +- GCC PGO 文档 `-fprofile-generate` / `-fprofile-use` +- ch07-02 LTO、ThinLTO 与 PGO 的工程接入(本卷) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/pgo_demo.cpp`(三阶段 PGO 脚本在 README) diff --git a/documents/vol6-performance/ch04-tuning-by-bottleneck/index.md b/documents/vol6-performance/ch04-tuning-by-bottleneck/index.md new file mode 100644 index 000000000..89a4ae26c --- /dev/null +++ b/documents/vol6-performance/ch04-tuning-by-bottleneck/index.md @@ -0,0 +1,31 @@ +--- +title: "按瓶颈部位优化" +description: "ch04 是 vol6 的技术主体,对齐 TMAM 四桶:Backend Memory(cache-friendly、AoS/SoA、prefetch)、Backend Core(循环优化、数据类型与算术、inline+去虚拟化、SIMD)、Bad Speculation(branchless)、Frontend(代码布局、PGO、BOLT),每篇都配本机实测" +--- + +# 按瓶颈部位优化 + +ch03 教我们用 USE / Roofline / TMAM / 火焰图把瓶颈归到流水线的某个桶。这一章是对症下药:对着四个桶,逐个讲怎么治。这是 vol6 的**技术主体**,也是整卷最长的一章。 + +四桶对应七篇: + +- **Backend Memory**(04-01):cache-friendly、AoS→SoA、prefetch。单线程最大杠杆,AoS→SoA 实测快近 10 倍。 +- **Backend Core**(04-02 / 04-03 / 04-04 / 04-05):循环优化、数据类型与算术、inline+去虚拟化、SIMD。除法是 5 倍于乘法的瓶颈、SIMD 实测 ~20×、虚函数 vs CRTP 2.5×。 +- **Bad Speculation**(04-06):branchless 与 predication,核心是「别盲目无分支」。 +- **Frontend**(04-07):代码布局、PGO、BOLT。 + +贯穿全章的精神只有一句:**现代编译器 + 硬件替你做了大半优化,你的工作不是「使劲手写 trick」,而是「测出真瓶颈、精准改、改完用同一套方法论验证」**。这一章里有好几个诚实的结果:switch 不总比 if-else 快、final 没自动去虚化、FP 归约不自动向量化、branchless 在 -O2 下和 if 一样快、PGO 对微基准无收益。它们都是这句话的注脚。性能优化是测量驱动的精准手术,不是堆 trick。 + +> 边界提醒:本章只讲「**P**——在硬件上跑时怎么改更快」。「**D**——为什么 vector/string 这么设计」「**U**——怎么用对容器」归 vol3;「EBO/SSO 机制」归 vol4;「怎么写无锁/同步原语」归 vol5。ch04-01 动笔前已与 vol3 切边,讲布局性能不重复造机制。 + +## 本章内容 + + + 后端内存瓶颈:cache-friendly、AoS/SoA 与 prefetch + 循环与计算优化:code motion、消除内存引用与多累加器 + 数据类型与算术:整数/浮点、除法瓶颈与跳转表 + inline、去虚拟化与编译器优化全景 + SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发 + 分支:branchless、predication 与「别盲目无分支」 + 前端优化:代码布局、PGO 与 BOLT + diff --git a/documents/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md b/documents/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md new file mode 100644 index 000000000..b88c62329 --- /dev/null +++ b/documents/vol6-performance/ch05-multicore-performance/05-01-false-sharing.md @@ -0,0 +1,112 @@ +--- +chapter: 5 +cpp_standard: +- 17 +description: 伪共享(false sharing)是多核性能最隐蔽也最戏剧性的坑——两个不相关的变量碰巧挤在同一条 64 字节缓存行,两个核分别写,硬件一致性协议每次都让对方 + cacheline 失效,实质串行。本篇用两个线程自增计数器实测:伪共享比 alignas(64) 慢一个数量级(本机单次约 18×,倍数随运行浮动大),并讲清 + MESI 一致性协议的机制 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- 缓存行与局部性:64 字节的最小搬运单位 +- Benchmark 方法论参考卡 +reading_time_minutes: 6 +related: +- NUMA、affinity 与扩展性曲线 +- 锁的开销与「无锁不是银弹」 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 并发 +title: 伪共享:同一缓存行把多核拖回单核 +--- +# 伪共享:同一缓存行把多核拖回单核 + +## ch02 埋的伏笔,这里兑现 + +ch02-02 讲缓存行时埋了一个伏笔:缓存行的「共享」在单核是红利(空间局部性),在多核可能变成税。这一篇就兑现它。 + +回忆一下,缓存行是 cache 的最小单位,64 字节,而且**也是一致性(coherence)的最小单位**:硬件保证同一条缓存行在整个系统里是一致的。多核场景下,这个保证的代价就是**伪共享(false sharing)**。两个核分别频繁写**同一条缓存行上的不同变量**,硬件为了维持一致性,每次写都让对方核持有的这条 cacheline 失效,于是两个核**实质上被迫串行**,「看起来并行,实则互相踢 cache」。 + +这是多核性能最隐蔽的坑。代码层面两个线程操作的是**完全不相关的变量**,逻辑上没有共享,但性能上被一条看不见的缓存行绑在一起。它不会让程序出错(正确性没问题),只会让程序莫名其妙地慢。 + +## MESI 一致性:为什么 cacheline 是多核战场 + +要理解伪共享,得先理解**缓存一致性协议**(x86 用 MESI 及其变种)。每个 cacheline 在每个核的 cache 里都有一个状态: + +- **M(odified)**:只有我这个核有,且我改过(脏)。 +- **E(xclusive)**:只有我这个核有,没改过(干净)。 +- **S(hared)**:多个核都有同一份(干净)。 +- **I(nvalid)**:失效,不能用。 + +当核 A 要写一条 S 状态(共享)的 cacheline 时,它得先发一个「我要写了,你们把这条作废」的信号给其它核,其它核把这条 cacheline 标记为 I。下次核 B 要用这条 cacheline 时,发现自己那份是 I,得重新去拿(Cache-to-cache 或从内存)。**「让对方失效 + 重新获取」这一来一回,就是伪共享的开销来源。** + +关键在于:**一致性粒度是 cacheline(64 字节),不是单个变量**。所以哪怕核 A 写 `counter_a`、核 B 写 `counter_b`,只要 `counter_a` 和 `counter_b` 在同一条 64 字节里,硬件就当成「同一条在两边被改」,触发完整的 invalidate 往返。变量逻辑上无关,物理上同船。 + +## 上手跑一跑:一个数量级的代价 + +经典场景:两个线程各有一个计数器,各自自增一亿次。逻辑上完全独立。 + +```cpp +// A. 伪共享:两个 atomic 紧挨着,同一条 cacheline +struct BadCounters { std::atomic a{0}; std::atomic b{0}; }; // sizeof = 16B +// B. 无伪共享:每个 alignas(64) 独占 cacheline +struct alignas(64) PaddedCounter { std::atomic v{0}; }; +struct GoodCounters { PaddedCounter a; PaddedCounter b; }; // sizeof = 128B +``` + +两个线程分别只动自己的计数器(`a` 和 `b`),跑同样次数: + +```text +===== 伪共享(2 线程各自自增 1 亿次)===== + 伪共享(同 cacheline): 467.0 ms + alignas(64)(独占 cacheline): 26.0 ms + 伪共享/对齐 = 18.0x + sizeof(BadCounters)=16 sizeof(GoodCounters)=128 +``` + +**接近 20 倍(量级)。** 同样的计算量、同样的线程数,只因为两个计数器有没有挤在同一条 cacheline,差出一个数量级。**绝对倍数随运行浮动很大**:本机同硬件多次复现,倍数在 15×–48× 之间都见过(上面这次是 18×),WSL2 的调度噪声会放大抖动;但「差一个数量级」这个结论是稳定的。`BadCounters` 是 16 字节(两个 `atomic` 挤一条 64B cacheline);`GoodCounters` 用 `alignas(64)` 让每个计数器独占一条,共 128 字节,伪共享消失。 + +这就是伪共享的杀伤力:**它能让一个「看起来完美并行」的程序,慢到接近单线程**。更阴险的是,正确的 TSan/ASan 都查不出它(因为不是数据竞争、不是 UB,逻辑正确),只有**面向性能的 profiler** 能抓: + +```bash +# perf 的伪共享专用工具(本机 WSL2 无 perf,命令引自 KDAB/Brendan Gregg): +perf c2c record -- ./your_app +perf c2c report +# 看 HITM(Hit Modified)计数,高的地方就是伪共享重灾区 +``` + +## 对策:alignas(64) 让热变量独占 cacheline + +解法直接得几乎粗暴:**让会被不同核频繁写的变量,各自独占一条 cacheline**。C++ 里用 `alignas(64)`: + +```cpp +struct alignas(64) AlignedCounter { std::atomic v{0}; }; +``` + +`alignas(64)` 强制这个结构的地址 64 字节对齐,且 `sizeof` 也补到 64 的倍数,于是它独占整条 cacheline,别人挤不进来。常见用法: + +- **每线程统计计数器**:线程 `i` 写 `counters[i]`,如果 `counters` 是 `atomic[]` 就伪共享;改成 `alignas(64)` 的结构数组就不。 +- **无锁数据结构的 per-thread 数据**:ring buffer 的每线程槽位。 +- **线程池的 per-worker 状态**。 + +注意 `alignas(64)` 会**浪费内存**(每个计数器占 64B 而不是 8B),但对热变量这笔买卖划算:省下的 cacheline 往返远比浪费的内存值钱。别对冷变量乱用。 + +C++17 起还有个更优雅的写法,把 per-thread 数据放到 `thread_local`,编译器自然给每个线程独立实例,从根上避免共享。但 `thread_local` 有它自己的坑(初始化开销、和线程池配合),具体取舍看场景。 + +> 边界提醒:伪共享是**性能**问题,归 vol6;「怎么写正确的多线程同步、原子操作的内存序语义」归 vol5。本篇只讲「多核下缓存行的性能代价」。 + +把这一篇压成一句话:多个核频繁写同一条 cacheline 上的不同变量,触发 MESI invalidate 往返,实质串行;实测差一个数量级(本机单次约 18×,倍数随运行浮动 15×–48×,「一个数量级」是稳定结论);对策是 `alignas(64)` 让每核频繁写的变量独占 cacheline,或 per-thread 数据用 `thread_local`;查伪共享用 `perf c2c`(看 HITM 计数),TSan/ASan 查不出,因为它不是正确性问题。一致性协议的**深度**(MESI 状态机、MOESI/MESIF 变种、cache-to-cache transfer)归体系结构课,vol6 只讲「cacheline 是一致性单位」这一层,足以指导改代码。 + +下一篇讲多核的另一个放大器 NUMA,以及怎么用扩展性曲线判断你的并行程序「扩展得好不好」。 + +## 参考资源 + +- CppCoreGuidelines CP.3 *false sharing*——伪共享的定义与对策 +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》§11.7 *Detecting Coherence Issues》(Mark Dawson 撰写) +- Drepper《What Every Programmer Should Know About Memory》——MESI 与多核 cache 一致性 +- perf c2c 文档(KDAB 有详细教程) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch05/false_sharing.cpp` diff --git a/documents/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md b/documents/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md new file mode 100644 index 000000000..43b73f57f --- /dev/null +++ b/documents/vol6-performance/ch05-multicore-performance/05-02-numa-scaling.md @@ -0,0 +1,110 @@ +--- +chapter: 5 +cpp_standard: +- 17 +description: 多核性能不只是「加核就快」。本篇讲扩展性曲线(1/2/4/8 核跑同一任务,实测 1→2.53× 亚线性,为什么远不到理想线性)、Amdahl(固定规模)vs + Gustafson(规模随核扩大)、NUMA 跨节点访存延迟翻倍、线程绑核 affinity,以及线程创建/栈的成本。WSL2 单 NUMA 节点的限制如实标注 +difficulty: advanced +order: 2 +platform: host +prerequisites: +- 伪共享:同一缓存行把多核拖回单核 +- Amdahl 定律:优化的天花板(ch00) +reading_time_minutes: 5 +related: +- 锁的开销与「无锁不是银弹」 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 并发 +title: NUMA、affinity 与扩展性曲线 +--- +# NUMA、affinity 与扩展性曲线 + +## 加核不等于线性加速 + +很多人的直觉是「4 核就比 1 核快 4 倍」,并行化的工作就是「把任务分成 N 份给 N 个核」。真上手会发现:**几乎没有程序能线性加速**。我们用一个最简单的并行任务(分块累加一亿元素)实测,1/2/4/8 线程的加速比: + +```text +===== 扩展性曲线(并行累加 1 亿元素)===== +线程数 耗时(ms) 加速比 +1 29.3 1.00x +2 17.2 1.70x +4 12.6 2.33x +8 11.6 2.53x +``` + +**8 线程只换来 2.53 倍加速,远不是理想的 8 倍。** 为什么?三个原因,每一个都是后面要讲的: + +1. **Amdahl 定律**:程序里有串行段(汇总结果、同步),它占比再小也锁死了加速上限。ch00-01 讲过 `S = 1/(s + (1-s)/N)`,10% 串行就把无限核加速锁死在 10×。 +2. **共享资源争用**:这个例子里,所有核读同一份 DRAM,**内存带宽是共享的**。累加是 memory-bound(回看 ch03-01:dot 的算术强度极低,带宽受限),核一多,带宽先打满,加核没用。**memory-bound 任务扩展性天然差**,compute-bound 任务才好扩展。 +3. **NUMA 跨节点**:多 socket 机器上,核访问「远端 socket 的内存」延迟翻 2-4 倍。 + +扩展性曲线是诊断多核程序的金标准:**跑一遍 1/2/4/8 核,画出来**。理想是一条 45° 斜率的直线;弯下去拐平,就说明撞上了上面三个瓶颈之一。拐点在哪、拐得多狠,告诉你还有多少「加核能买到的性能」。 + +## Amdahl vs Gustafson:强扩展 vs 弱扩展 + +扩展性有两个不同的衡量口径,别混: + +- **Amdahl(强扩展,strong scaling)**:**固定问题规模**,加核看加速比。上限被串行段锁死。这是大多数「我要把这个程序跑快」场景关心的。 +- **Gustafson(弱扩展,weak scaling)**:**问题规模随核数等比扩大**,看「核数翻倍 + 数据翻倍,时间不变」能不能做到。这是 HPC / 大数据场景关心的,数据涨了,加核顶住。 + +推论:「这个程序扩展性不好」在 Amdahl 语境下是硬伤,在 Gustafson 语境下可能无所谓。取决于你的问题是「固定大小跑快」还是「数据在涨别崩」,先想清楚你关心哪个。 + +## NUMA:多 socket 的隐藏延迟 + +**NUMA(Non-Uniform Memory Access)** 是多 socket 服务器的现实:每个 CPU socket 有「自己的」本地内存,访问别的 socket 的内存要走互联总线(QPI/UPI),**延迟翻 2-4 倍**。于是「内存带宽」测成了「互联带宽」,线程跑在 socket 0,数据却在 socket 1 的内存,每次访存都交互联罚金。 + +NUMA 的对策是把「线程」和「它操作的数据」绑在同一个 socket: + +```bash +# 把线程和内存都绑到 NUMA 节点 0 +numactl --cpunodebind=0 --membind=0 ./your_app +# 或交织分配(让两节点平均承担,避免一边打满)——但有跨节点惩罚 +numactl --interleave=all ./your_app +``` + +程序层面:线程池按 NUMA 拓扑分组(每 socket 一个池,只处理本 socket 内存上的数据)、数据按 socket 分区。这些是 HPC 和高性能后端的标配。 + +> **本机局限**:WSL2 在一台单 socket 笔记本上(5800H 单 NUMA 节点,`numactl --hardware` 只列出 node0),**测不了 NUMA 跨节点惩罚**。NUMA 的命令(`numactl`)和数据引自 Bakhvalov §11 + 多 socket 服务器实践。你要测 NUMA,得找一台双路服务器。本节诚实标注「本机测不了」,把它当测量环境的教学点,而不是含糊带过。 + +## affinity:线程绑核,减少迁移 + +哪怕单 socket,线程被 OS 在核间**迁移**也有成本:迁移后它的 L1/L2 cache 全冷,要重新预热。`taskset`(临时)/ `pthread_setaffinity_np`(程序内)把线程**绑到固定核**,消除迁移开销: + +```bash +# 命令行:把进程绑到核 0-3 +taskset -c 0-3 ./your_app +``` + +```cpp +// 程序内:把线程绑到特定核 +cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(core_id, &cpuset); +pthread_setaffinity_np(thread.native_handle(), sizeof(cpuset), &cpuset); +``` + +绑核对**长运行、cache 敏感**的负载(数据库、流处理)很重要;对短任务意义不大(本来就来不及暖 cache)。绑核还能配合 NUMA,让线程只在本 socket 的核上跑。 + +> ch01-03「测量陷阱」里第 5/8 条(绑核、NUMA)就是这一节的内容,这里展开讲它们的原理。绑核既是**性能测量**的标准动作(避免迁移噪声),也是**生产部署**的标准动作(稳定 cache 行为)。 + +## 线程创建与栈的成本 + +一个容易被忽略的多核成本:**线程本身不免费**。创建一个线程要分配栈(默认 8MB 虚拟地址空间,Linux 触碰多少分配多少)、内核数据结构,耗时几十到上百微秒。所以: + +- **别在热路径 `new` 线程**:`std::thread t(...)` 每次都付创建+销毁成本。用**线程池**复用。 +- **栈大小可调**:8MB 默认对大多数线程过多,`pthread_attr_setstacksize` 调小(比如 256KB-1MB)能省虚拟内存、改善 TLB 压力(栈也是内存,占 TLB 项)。嵌入式/超高并发场景常见。 +- **`std::async` + 默认策略**:可能有隐式线程创建,且和 `std::launch::async` 的语义有坑(vol5 详讲)。 + +线程池是「正确性归 vol5、成本归 vol6」的典型:怎么写一个无 UB 的线程池是 vol5 的事,这里只讲「为什么你应该用池而不是裸 `std::thread`」的成本动机。 + +一句话收口:扩展性曲线就是 1/2/4/8 核跑同一任务,看加速比拐不拐平(理想线性,弯了说明撞了 Amdahl / 共享资源 / NUMA);memory-bound 任务因共享内存带宽先打满,扩展性天然差,compute-bound 才好扩展;Amdahl(固定规模)vs Gustafson(规模随核扩大),先想清你关心哪个;NUMA 上线程和数据要绑同 socket(`numactl`),本机 WSL2 单节点测不了;affinity 绑核减少迁移,测量和生产都该用;线程本身不免费,热路径用线程池,栈大小可调。 + +## 参考资源 + +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 11 章 *Multithreaded Apps》(Mark Dawson 撰写,含 NUMA/affinity/扩展性) +- Drepper《What Every Programmer Should About Memory》——NUMA、cache 一致性的工程视角 +- `numactl` / `taskset` / `pthread_setaffinity_np` 文档 +- ch00-01 性能思维(本卷,Amdahl 定律的出处) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch05/scalability.cpp` diff --git a/documents/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md b/documents/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md new file mode 100644 index 000000000..f9cd527ad --- /dev/null +++ b/documents/vol6-performance/ch05-multicore-performance/05-03-locks-vs-lockfree.md @@ -0,0 +1,94 @@ +--- +chapter: 5 +cpp_standard: +- 17 +description: 本篇从成本视角看并发同步:无竞争 mutex 的 fast path 是纳秒级、约 3.6 倍于 atomic,有竞争时暴涨;无锁不是银弹(ABA、重试风暴、内存回收难题),很多场景分片锁比无锁更快更简单。「该不该用无锁」是成本/复杂度权衡,归 + vol6;「怎么写无锁」归 vol5 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- NUMA、affinity 与扩展性曲线 +- 伪共享:同一缓存行把多核拖回单核 +reading_time_minutes: 6 +related: +- std::atomic 与内存序(vol5) +tags: +- host +- cpp-modern +- advanced +- 优化 +- 并发 +- 无锁 +title: 锁的开销与「无锁不是银弹」 +--- +# 锁的开销与「无锁不是银弹」 + +## 从成本视角看同步 + +ch05 前两篇讲了多核的物理瓶颈(伪共享、NUMA、扩展性)。这一篇换到**同步原语**的成本视角:`std::mutex`、`std::atomic`、无锁数据结构,各贵多少、什么时候值。 + +先划边界(这条最重要):**「怎么写正确的同步、原子操作的内存序语义、无锁数据结构的实现」归 vol5**。vol6 只回答一个问题:**「这些同步方式各贵多少纳秒、什么场景该选哪个」**。这是成本视角,不是机制教学。 + +## 无竞争锁:纳秒级,但有 3-4 倍于 atomic 的开销 + +很多人对 `std::mutex` 的印象是「慢」。**无竞争(uncontended)时其实不慢**,现代 mutex 的 fast path 是用户态 CAS(自旋几下),拿不到才陷入内核。我们测单线程(完全无竞争)自增一亿次: + +```text +===== 同步开销(单线程自增 1 亿次,ns/op)===== + 非原子 int(基线): 0.00 ns + atomic relaxed(无锁,弱序): 1.86 ns + atomic seq_cst(默认强序): 1.84 ns + mutex 无竞争(加解锁): 6.60 ns + mutex/atomic_relaxed = 3.6x +``` + +读出几件事: + +- **非原子 int 基线 0.00 ns**:编译器把 `++plain` 优化成寄存器自加,无内存写入,这是「无同步」的极限。 +- **`atomic` 约 1.8-1.9 ns/op**:这是无锁原子操作的成本(`fetch_add` 在返回值被丢弃时常被 GCC 优化成 `lock addq`(常量原子加),返回值被使用时才是 `lock xadd`;都带 LOCK 前缀,缓存行对齐时走 cache lock 而非总线锁)。**`memory_order_relaxed` 和默认 `seq_cst` 在单线程下几乎一样**,它们的差别在跨线程 ordering,单线程自增没有跨线程可见性需求,差别显现不出来。 +- **`mutex` 无竞争 6.6 ns/op**:**约 3.6 倍于 atomic**。这个倍数是无竞争 mutex 的典型开销,fast path 几条指令(CAS 拿锁、CAS 还锁),比单条 `lock xadd` 多几条。 + +所以**无竞争场景下,mutex 比 atomic 慢 3-4 倍,但都是纳秒级**。如果你的临界区只有一次原子自增,用 mutex 确实浪费(直接 atomic 更快);但如果临界区有几十条指令,mutex 那 6 纳秒开销相对临界区本身微不足道,**可读性和正确性远比这点开销重要**。 + +## 有竞争:mutex 代价暴涨 + +上面是**无竞争**。`mutex` 真正贵的是**有竞争(contended)**时:多个线程同时抢,fast path CAS 失败,自旋,还拿不到,陷入内核(`futex` 系统调用),线程被挂起、唤醒,涉及上下文切换(每个几微秒)。一个高度竞争的 mutex 可能让程序退化成「内核调度器在切换线程,业务几乎没跑」。 + +这就是无锁数据结构的动机:**避免陷入内核、避免上下文切换**。`std::atomic` 的 `lock xadd` 始终在用户态、始终无锁(不陷入内核),无论多竞争。对**极高竞争 + 极小临界区**(比如全局计数器、简单队列)的场景,无锁确实赢。 + +但—— + +## 无锁不是银弹 + +「无锁」听起来像银弹,实际坑极深: + +1. **ABA 问题**:无锁的「比较-交换(CAS)」循环里,值从 A→B→A,CAS 以为「没变过」而成功,实际中间被改过。经典无锁栈 `pop` 反复 CAS,节点被释放又重用就中招。解法(tagged pointer、hazard pointer、epoch-based reclamation)都复杂且易错。 +2. **重试风暴**:多个线程同时 CAS 同一个变量,只有一个赢,其它全失败重试,高竞争下 CPU 全耗在重试上,比 mutex 还惨(叫「thundering herd」或「live-lock」)。 +3. **内存回收难**:无锁数据结构里,「这个节点还能不能释放」本身就是个并发难题(别的线程可能还拿着指针)。这是无锁编程最硬核的部分。 +4. **写对极难**:无锁代码的正确性靠内存序(`acquire/release/seq_cst`)精细配合,错一个就是数据竞争 UB,且 TSan 都不一定抓得全。 + +**结论:无锁是高门槛、高复杂度的工具,不是「更快」的同义词**。很多场景,**分片锁(sharded locks)** 比无锁更快更简单:把一个共享结构切成 N 片,每片一把锁,线程大概率各操作不同片、不竞争。比如分片哈希表,N 个桶组、每组一把 mutex,实际竞争被摊薄到接近无竞争。这种「分片 + 锁」在工程上经常打败无锁,因为无竞争 mutex fast path 极快(纳秒级,前面实测),分片把竞争降到无竞争享受 fast path,而代码简单、正确性容易保证。 + +## 决策框架:什么时候用什么 + +| 场景 | 推荐 | 理由 | +|---|---|---| +| 单纯计数器 / 简单统计 | `std::atomic` | 一条 `lock xadd`,无临界区,最便宜 | +| 复杂共享结构,竞争不强 | `std::mutex` + RAII | 可读、正确、无竞争 fast path 够快 | +| 高竞争共享结构 | **分片锁** | 摊薄竞争,简单可靠,常胜无锁 | +| 极端高并发 + 极小临界区 + 团队能驾驭 | 无锁数据结构 | 有它的场景,但要付复杂度税 | +| 跨线程只读共享 | `std::shared_ptr` / 直接共享 const | 读不竞争,无需同步 | + +**「该不该用无锁」是成本/复杂度权衡,归 vol6;「怎么写正确的无锁」归 vol5。** 先问「mutex + 分片够不够」,大多数时候够了;真不够再上无锁,且配合严谨的 TSan 验证。 + +压成一句话:无竞争 mutex 纳秒级、约 3.6 倍于 atomic,有竞争则暴涨(陷入内核、上下文切换);atomic 单条操作始终用户态、始终无锁,适合简单计数器;无锁不是银弹(ABA、重试风暴、内存回收、写对极难),分片锁经常靠摊薄竞争 + 简单可靠打败无锁,先考虑分片,再考虑无锁;vol6 只答「各贵多少、选哪个」,「怎么写对」归 vol5。 + +ch05 多核性能到这里就讲完了,伪共享、NUMA/扩展性、同步成本三块收齐。下一篇我们换到 ch06,从「C++ 抽象的性能成本」角度,看那些 C++ 特有特性(虚函数、异常、std::function、optional/variant)各贵多少。 + +## 参考资源 + +- Bakhvalov《Performance Analysis and Tuning on Modern CPUs》第 11 章 *Multithreaded Apps》 +- Pikus《The Art of Writing Efficient Programs》——并发性能、分片 vs 无锁的权衡(本地) +- `std::mutex` / `std::atomic` / 内存序:cppreference;深度归 vol5 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch05/lock_cost.cpp` diff --git a/documents/vol6-performance/ch05-multicore-performance/index.md b/documents/vol6-performance/ch05-multicore-performance/index.md new file mode 100644 index 000000000..e99a9938e --- /dev/null +++ b/documents/vol6-performance/ch05-multicore-performance/index.md @@ -0,0 +1,26 @@ +--- +title: "多核性能" +description: "ch05 承接 vol5(同步原语/无锁的正确性归 vol5),只讲多核带来的性能衰减与成本:伪共享(同 cacheline 把多核拖回单核,实测一个数量级,单次约 18× 浮动大)、NUMA 与扩展性曲线(1→8 线程亚线性 2.53×)、锁的开销与「无锁不是银弹」(无竞争 mutex 3.6 倍于 atomic)" +--- + +# 多核性能 + +vol5 把**同步原语、内存序、无锁数据结构的正确性**讲透了。这一章不重复那些机制,只回答一个性能问题:**多核带来的性能衰减怎么测、怎么改,各种同步方式各贵多少纳秒。** + +三篇: + +- **05-01 伪共享**:两个核频繁写同一条 64 字节 cacheline 上的不同变量,触发 MESI 一致性 invalidate 往返,实测慢**一个数量级**(单次约 18×,倍数随运行浮动大)。对策是 `alignas(64)`。 +- **05-02 NUMA 与扩展性曲线**:多 socket 跨节点访存延迟翻 2-4 倍;扩展性曲线(1→8 线程实测 2.53×,亚线性)诊断「加核能买到多少性能」;Amdahl vs Gustafson;线程绑核 affinity;线程创建/栈成本。 +- **05-03 锁 vs 无锁成本**:无竞争 mutex 纳秒级(约 3.6 倍于 atomic),有竞争暴涨;无锁不是银弹(ABA、重试风暴、内存回收),分片锁常胜无锁。 + +边界:**「怎么写正确的同步、原子操作内存序、无锁实现」归 vol5**;vol6 只答「各贵多少、什么场景选哪个」。 + +> 本机 WSL2 单 socket 单 NUMA 节点,NUMA 跨节点惩罚测不了(05-02 如实标注,内容引自多 socket 服务器实践)。伪共享、扩展性、锁开销都是本机实测。 + +## 本章内容 + + + 伪共享:同一缓存行把多核拖回单核 + NUMA、affinity 与扩展性曲线 + 锁的开销与「无锁不是银弹」 + diff --git a/documents/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md new file mode 100644 index 000000000..9de53a28f --- /dev/null +++ b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-01-virtual-devirtualization.md @@ -0,0 +1,88 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: 虚函数是 C++ 多态的基石,也是「会被认为慢」的抽象。本篇用实测看清它的真实成本(通过指针的虚调用比可内联的 CRTP 慢 2.5 倍),但核心信息是:现代编译器会去虚拟化(devirtualization),很多场景你的虚函数早已被优化成直接调用甚至内联——别为了「可能快点」提前把虚函数改成 + CRTP/模板,先测 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- inline、去虚拟化与编译器优化全景 +- 流水线、ILP 与分支预测 +reading_time_minutes: 5 +related: +- 异常的零成本模型 +- std::function 的小缓冲区优化 +tags: +- host +- cpp-modern +- advanced +- 优化 +title: 虚函数与去虚拟化:别急着把虚函数改模板 +--- +# 虚函数与去虚拟化:别急着把虚函数改模板 + +## 「虚函数慢」是 vol6 的主场命题之一 + +Carruth 在 *There Are No Zero-Cost Abstractions* 里拎出来的命题「没有零开销抽象」,虚函数是最常被点名的那个。它的开销来自三处:**查 vtable(一次额外内存访问,vtable 可能 cache miss)+ 间接跳转(阻碍流水线,可能被分支预测器猜错)+ 阻碍内联**(编译器不知道运行时调谁,不敢跨函数优化)。三条叠起来,一个虚调用确实比直接调用贵。 + +但本篇的核心信息是反直觉的:**「虚函数慢」是上限,不是常态**。现代编译器会**去虚拟化(devirtualization)**,很多场景下能把虚调用优化成直接调用甚至内联,开销归零。ch06 这一章是「C++ 抽象的性能成本」的主场,但对虚函数,我们的建议是「**先测,别提前改**」。这个态度和「虚函数有成本」并不矛盾,成本是有的,但经常不发生。 + +## 上手跑一跑:四种调用的真实成本 + +把 ch04-04 测过的数据拿过来(本机,平均每次 ns): + +```text +===== 虚函数与去虚拟化 ===== + 虚函数(指针,运行时多态): 0.55 ns ← 查 vtable + 间接跳转,阻碍内联 + final 类(编译器去虚化): 0.54 ns + 直接对象(非指针,常去虚化): 0.23 ns + CRTP(静态多态,无虚表): 0.22 ns ← 可内联 + 虚函数/CRTP = 2.5x +``` + +读这张表要分清「上限」和「常态」: + +**上限:通过指针/引用的虚调用(0.55 ns)是最贵的**。这时编译器看不见运行时类型,老老实实查 vtable + 间接跳转,比 CRTP(0.22 ns)慢 2.5 倍。这就是「虚函数有成本」的硬数字。 + +**常态:很多调用其实被去虚拟化了**: + +- **直接对象(非指针/引用)调用**(0.23 ns):编译器在 `Derived d; d.foo();` 这种场景能看见 `d` 的确切类型,直接去虚化成普通调用,**和 CRTP 一样快**。 +- **`final` 类/方法**:告诉编译器「没有更派生了」,有时能触发去虚化。但注意,**我这个例子里 `final` 和普通虚函数一样快**(0.54 ≈ 0.55),不是「final 没去虚化」!`-fopt-info-all` 显示 final 类(`DerivedF`)和普通派生(`Derived`)的虚调用都被 GCC **推测性去虚化**了(speculative devirtualization:运行时比对 vtable 项与编译期已知的目标地址,猜中走内联路径)。0.54 ≈ 0.55 的真因是「推测去虚化的运行时比对开销和一次虚调用相当,没省下来」,而不是「final 没生效」。诚实结论:**别以为标了 `final` 就自动快,去虚化能不能省下开销,要看汇编里是不是真变成了直接 `call`**(`-S`:虚调用是 `call [vtable+offset]` 间接跳转,完全去虚化是直接 `call func`)。 +- **CRTP(静态多态)**:把多态推到编译期(模板),没有虚表、能内联,总是最快。但代价是代码膨胀(每个派生类实例化一份)+ 失去运行时多态。 + +## 什么时候真要去虚拟化 + +去虚拟化在以下场景**编译器自己会做**(你不用动): + +- 直接对象(非指针/引用)调用,且类型在调用点可见。 +- 派生层级 + `final` 让编译器能证明唯一实现。 +- LTO 开启时,跨翻译单元的类型信息变可见,更多去虚化机会。 +- 单态(monomorphic)热点:profile 反馈显示某个虚调用点 99% 调同一个类型,有些编译器/PGO 能据此去虚化。 + +所以「我的虚函数有没有被去虚化」**要查汇编看**(`-S`:直接调用是 `call func`,虚调用是 `call [vtable+offset]` 的间接跳转),别凭猜。 + +## 实战建议:别提前 CRTP 化 + +把上面这些压成一条工作流: + +1. **先用虚函数写清楚**(OOP 表达力最好,易维护)。 +2. **profile** 找热点。如果某个虚调用不在热点,别管它,已经够快。 +3. **真在热点**,先查汇编看去虚化了没。去虚化了就完事。 +4. **没去虚化、且是瓶颈**,再考虑:`final`、改直接对象调用、PGO,最后才是 CRTP/模板。 + +**最常见的反模式是「听人说虚函数慢,一上来就把整个类层级 CRTP 化」**:代码复杂度暴涨(模板错误信息、代码膨胀),而原本的虚调用可能编译器早就去虚化了,或者根本不在热点。这是「**过早优化**」在 C++ 抽象成本领域的典型表现。ch04-04 讲 inline 时说过同一个道理:你的工作是「别挡编译器的路 + 测出真瓶颈再精准改」,不是「使劲手写更快的写法」。 + +> 边界提醒:虚函数**机制**(vtable 布局、虚析构、`override` 语义)归 vol4 类设计;vol6 只讲「它在硬件上跑时贵不贵、怎么不贵」。 + +一句话收口:虚函数**上限**成本是 0.55 ns(via 指针),约 2.5 倍于可内联的 CRTP(0.22 ns),来源是 vtable 查表 + 间接跳转 + 阻碍内联;但很多虚调用会被去虚化成直接调用/内联(直接对象、`final`、LTO、PGO、单态热点),本例 `final` 通过指针没触发(0.54≈0.55),诚实说明它不保证;实战按「先虚函数写清楚 → profile → 查汇编确认没去虚化 + 是瓶颈 → 才考虑 `final`/CRTP」走,别提前 CRTP 化,那是过早优化。 + +下一篇讲异常。它和虚函数一样常被误解为「慢」,而真实的成本模型是「正常路径零成本、异常路径很贵」。 + +## 参考资源 + +- Piotr Padlewski *C++ devirtualization in clang*(CppCon 2015 Lightning)——去虚拟化机制,vol10 复用 +- ch04-04 inline、去虚拟化与编译器优化全景(本篇的虚函数数据出处) +- Agner Fog《Optimizing software in C++》§7 *Virtual functions》。本地 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp`(与 ch04-04 共用) diff --git a/documents/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md new file mode 100644 index 000000000..54d473c51 --- /dev/null +++ b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-02-exceptions-zero-cost.md @@ -0,0 +1,92 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: C++ 异常是表驱动零成本模型——正常路径(no-throw)无额外指令,异常路径靠 EH 表查找。实测验证:有 throw 能力但从不抛,和纯函数一样快(0.25 + ns,零成本坐实);但每次都 throw+catch 高达 857 ns(3400 倍)。结论:异常只用于真正的异常情况,别当控制流;嵌入式可 -fno-exceptions +difficulty: advanced +order: 2 +platform: host +prerequisites: +- 虚函数与去虚拟化 +- Benchmark 方法论参考卡 +reading_time_minutes: 5 +related: +- std::function 的小缓冲区优化 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 内存安全 +title: 异常的零成本模型:正常路径免费,异常路径很贵 +--- +# 异常的零成本模型:正常路径免费,异常路径很贵 + +## 「异常很慢」是最大的误解之一 + +C++ 圈子里「异常很慢、性能代码要 `-fno-exceptions`」是流传最广的误解之一。它部分对(异常路径确实贵),部分错(正常路径几乎免费)。真实模型叫**表驱动零成本(table-driven zero-cost)**: + +- **零成本(正常路径)**:不抛异常的代码路径,几乎**没有额外指令**。异常机制的代价不体现在「每次调用多一条检查指令」(那是 error code 的代价),而是挪到了**异常真的抛出时**。 +- **表驱动(异常路径)**:抛异常时,运行时查 **EH 表**(Exception Handling table,编译期生成,记录「这个地址的栈帧怎么展开、有没有 catch」)来沿调用栈往上找 catch,并展开栈。这个查表 + 栈展开是**微秒级**的。 + +我们直接测这个模型。 + +## 上手跑一跑:正常免费,异常贵 3400 倍 + +四种路径(本机,平均每次 ns): + +```text +===== 异常成本 ===== + 正常路径(从不抛): 0.250 ns/op + 错误码(返回值传 err): 0.247 ns/op + 有 throw 能力但从不抛(零成本): 0.249 ns/op + 每次都 throw+catch: 857.3 ns/op ← 异常路径很贵 +``` + +这张表验证了零成本模型的两半: + +**1. 正常路径零成本坐实**。前三行几乎一样快(0.247-0.250 ns):「纯函数」、「错误码(每次多传一个 err 参数)」、「有 throw 能力但从不抛」三者的正常路径耗时**没有可测差别**。这就是「零成本」的精确含义:**开 `try`/异常机制,不给正常路径加任何成本**。 + +**2. 异常路径贵 ~3400 倍**。最后一行 857 ns vs 0.25 ns。抛+捕获一次异常要:分配异常对象、查 EH 表、沿栈展开(逐个析构局部对象)、找到 catch、跳转。这一套是微秒级,比正常返回贵三个数量级。 + +这两条合起来,直接指导异常的**使用纪律**: + +- **异常只用于真正的异常情况**(罕见、意外、需要栈展开的路径)。正常控制流千万别用异常当「特殊的返回值」,那条路径贵 3400 倍。 +- **正常路径不用担心异常开销**。开了 `try`/异常机制不会让你的正常代码变慢(零成本坐实)。「`try` 块慢」是误解,`try` 本身不产生运行时指令,慢的是「真的 throw」。 + +## 和错误码的取舍 + +异常 vs 错误码不是「哪个快」,是「哪种错误分布适合哪个」: + +| 模型 | 正常路径 | 错误路径 | 适合 | +|---|---|---|---| +| **错误码** | 略贵(每次检查 err) | 和正常一样 | 错误**常见**(每次检查不浪费)| +| **异常** | 免费(零成本) | 很贵(微秒级) | 错误**罕见**(正常路径不被污染)| + +推论:**错误越罕见,异常越划算**。如果一个 API 的「错误」其实是常态(比如 `parse` 经常遇到不合法输入),错误码更好;如果错误是真异常(比如 `vector::at` 越界、内存分配失败、网络中断),异常更好,它让正常路径代码干净(没 `if (err)` 满天飞),真异常时那点开销也无所谓。 + +C++ 标准库就是这个取舍:`vector::operator[]` 不检查越界(快),`vector::at` 检查并抛异常(正常路径仍零成本,越界是真异常)。 + +## `-fno-exceptions`:什么时候关 + +有些场景会 `-fno-exceptions` 整体关掉异常: + +- **嵌入式 / 游戏 / 实时**:确定性要求高,栈展开时间不可控(微秒级抖动);或二进制体积受限(EH 表占空间)。 +- **极致体积**:EH 表和 unwind 信息占不少体积,关掉能省(顺带省一些代码生成约束)。 +- 代价:**失去 RAII 错误传播**。异常是 C++ 跨函数传播错误的「无遗漏」机制(析构链保证),关掉后要手写错误码透传,容易漏。容器(`vector` 等)的某些行为也会退化(比如 `at` 改为 `abort`)。 + +所以 `-fno-exceptions` 是**有取舍的工程决策**,不是「为了快」的银弹。正常 C++ 代码(后端、桌面、大多数库)应该**留着异常**,因为它让正常路径干净、且正常路径零成本。 + +## 实现:Itanium C++ ABI EH + +异常的底层实现遵循 **Itanium C++ ABI 的 EH 规范**(x86-64 Linux + macOS 通用):`throw` 用 `__cxa_throw`、`catch` 用 personality function 查 `.gcc_except_table`、栈展开用 `_Unwind_RaiseException`。这套机制是「表驱动」的,编译期为每段能抛异常的代码生成 EH 表,运行时**只在抛出时**才查。深度(两阶段异常处理、handler 搜索)超出 vol6 范围,知道「表驱动零成本」这个架构事实就够指导决策了。 + +一句话收口:异常是表驱动零成本模型,正常路径无额外指令(实测 0.25 ns,和纯函数一样),异常路径靠 EH 表查 + 栈展开(实测 857 ns,贵 3400 倍);「try 慢」是误解,慢的是「真的 throw」,`try` 块本身零成本;错误越罕见异常越划算,错误是常态则用错误码;`-fno-exceptions` 是嵌入式/体积/确定性的取舍,不是性能银弹,代价是失去 RAII 错误传播。异常**机制深度**(Itanium ABI EH、两阶段处理)超出 vol6,知道零成本模型即可。 + +## 参考资源 + +- Itanium C++ ABI *Exception Handling*(itanium-cxx-abi.github.io/cxx-abi-eh.html)——EH 表、personality function、栈展开的规范 +- CppCoreGuidelines *Errors and Exception Handling*(Stroustrup & Sutter)——异常使用纪律 +- Agner Fog《Optimizing software in C++》异常小节。本地 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch06/exception_cost.cpp` diff --git a/documents/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md new file mode 100644 index 000000000..8505cf3c9 --- /dev/null +++ b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-03-std-function-sbo.md @@ -0,0 +1,90 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: std::function 类型擦除,能装任何可调用对象,代价是调用间接 + 可能堆分配。本篇实测:调用比直接 lambda 慢约 6 倍(1.61 + ns vs 0.25 ns),构造时小捕获走 SBO(2.3 ns)而大捕获触发堆分配(19.6 ns,8.5 倍)。热路径上反复构造 function + 大捕获要当心,对策是模板参数(编译期多态)或固定签名函数指针 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- 虚函数与去虚拟化 +- 缓存行与局部性:64 字节的最小搬运单位 +reading_time_minutes: 4 +related: +- C++ 抽象的成本速查表 +- RVO、NRVO 与 move 的真实成本 +tags: +- host +- cpp-modern +- advanced +- 优化 +- std_function +title: std::function 的小缓冲区优化:类型擦除的代价 +--- +# std::function 的小缓冲区优化:类型擦除的代价 + +## 类型擦除的便利与代价 + +`std::function` 是 C++ 里最方便的「装任何可调用对象」的容器:函数指针、lambda、函数对象、bind 表达式,只要签名匹配都能塞进去。它的实现靠**类型擦除(type erasure)**,不在模板参数里写死可调用对象的类型,而是在内部用一套统一的接口(通常是一个虚函数或函数指针表)来调用。 + +这份便利有代价。我们测一下(本机): + +```text +===== std::function SBO ===== +调用: + 调用 - 函数指针: 0.26 ns + 调用 - function+小lambda(SBO): 1.61 ns + 调用 - 直接 lambda(对照): 0.25 ns + +构造 1000000 次: + function 装函数指针: 2.0 ns/次 + function 装小lambda(SBO): 2.3 ns/次 + function 装大lambda(堆分配): 19.6 ns/次 ← 堆分配开销 + +sizeof(std::function) = 32 +``` + +两个代价: + +**1. 调用是间接的,比直接 lambda 慢约 6 倍**。直接 lambda(0.25 ns)和函数指针(0.26 ns)一样快(都是直接调用 + 可内联);`std::function`(1.61 ns)要走类型擦除的间接调用(查 vtable/函数指针 + 跳转),**约 6 倍**。和 06-01 的虚函数一个道理,间接调用阻碍内联。 + +**2. 构造可能堆分配**。`std::function` 装一个可调用对象时,要把对象的状态存起来。多数实现有 **SBO(Small Buffer Optimization,小缓冲区优化)**:在 `std::function` 对象内部预留一小块缓冲(本机 libstdc++ 的 `std::function` 是 32 字节),捕获状态小的(≤ SBO 阈值,通常 16-24 字节)直接存内部,不堆分配;捕获状态大的(超阈值)就只能 `new` 一块堆。 + +实测构造代价:小 lambda(SBO 命中)2.3 ns/次,**大 lambda(超 SBO,堆分配)19.6 ns/次**,**8.5 倍**。这个差距主要来自 `new`/`delete` 一次堆分配的成本(几十纳秒级)。 + +## 这两个代价什么时候咬人 + +**调用的 6 倍代价**:对「偶尔调用」的回调无所谓(回调不在热路径);对「每帧调用百万次」的回调,6 倍就是真金白银。比如一个事件分发器,如果每次分发走 `std::function`,调用开销可能成为瓶颈,换成模板(编译期多态)或函数指针就快得多。 + +**构造的堆分配代价**:这是更容易踩的坑。考虑这种代码: + +```cpp +// 热路径里反复构造 function + 大捕获 +for (auto& item : items) { + std::function f = [item, ctx](int x) { /* 大捕获 */ }; + dispatch(f); +} +``` + +每次循环都构造一个 `std::function`,如果捕获大(超 SBO),**每次都 `new`/`delete`**:堆分配 + cache miss + 可能触发 malloc 锁竞争(多线程下)。这种「热路径反复构造 function」是性能黑洞,几个常见对策: + +- **用模板参数(编译期多态)**:把回调类型写成模板参数,消除类型擦除。代价是调用点要编译期知道类型。 +- **固定签名函数指针**:如果回调没捕获,直接用 `void(*)(int)`,零开销。 +- **复用 `std::function` 对象**:在循环外构造一次,循环内只改它的状态(但改状态可能还是堆分配)。 +- **避免不必要的捕获**:lambda 捕获越少越可能命中 SBO。 + +## SBO 与 string 的 SSO 是一回事 + +SBO 的思想和 `std::string` 的 **SSO(Small String Optimization)** 是一回事(都在对象内部留小缓冲,小就走内联、大才堆分配)。两者都解决「类型擦除/动态大小 + 避免热路径堆分配」的矛盾。SSO/SSO 的机制(为什么阈值是 16-24 字节、怎么和 ABI 配合)归 vol3/vol4;vol6 只讲「它影响热路径构造的堆分配成本」这层。 + +`std::function` 的 sizeof 因实现而异(libstdc++ 32 字节、libc++ 48 字节、MSFC 又不同),SBO 阈值也随之不同。所以「我这个 lambda 会不会触发堆分配」要 `sizeof` 或看实现,但**通用建议是:别在热路径依赖 SBO 命中,大捕获该换模板**。 + +一句话收口:`std::function` 有两个代价,调用间接(比直接 lambda 慢 ~6 倍)、构造可能堆分配(大捕获触发,比 SBO 贵 ~8.5 倍);SBO 让小捕获(≤16-24B)存对象内部不堆分配,大捕获堆分配;热路径上避免反复构造 `std::function` + 大捕获,这是堆分配黑洞,对策是模板参数、函数指针、复用对象、减少捕获;SBO 与 string SSO 同思想,机制归 vol3/vol4。 + +## 参考资源 + +- cppreference *std::function*——类型擦除语义、SBO 说明 +- Stepov/Stroustrup CppCoreGuidelines *F.50*——什么时候用 function vs 模板 vs 函数指针 +- Agner Fog《Optimizing software in C++》对象/容器开销。本地 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch06/function_sbo.cpp` diff --git a/documents/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md new file mode 100644 index 000000000..aff68371b --- /dev/null +++ b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-04-abstraction-cost-cheatsheet.md @@ -0,0 +1,106 @@ +--- +chapter: 6 +cpp_standard: +- 20 +description: 汇总 vol6 这一章测过的 C++ 抽象成本(虚函数、异常、std::function、optional/variant/span 等的 + sizeof 与调用/构造开销)成一张速查表,加上变量存储类型(register/static/thread_local)、位域 bitfields、enum + class 零开销三个补充条目,供写代码时案头查阅 +difficulty: advanced +order: 4 +platform: host +prerequisites: +- 虚函数与去虚拟化 +- 异常的零成本模型 +- std::function 的小缓冲区优化 +reading_time_minutes: 5 +related: +- RVO、NRVO 与 move 的真实成本 +- C++ 抽象的性能成本(章首页) +tags: +- host +- cpp-modern +- advanced +- 优化 +- 字面量 +- enum_class +title: C++ 抽象的成本速查表 +--- +# C++ 抽象的成本速查表 + +这一篇是 ch06 的参考卡:把前面几篇测过的 C++ 抽象成本**汇总成一张速查表**,再补三个前面没单独立篇的小条目(变量存储类型、位域、enum class)。写代码时遇到「这个抽象贵不贵」可以这里查。 + +## 前面测过的:成本速查表 + +| 抽象 | 主要成本 | 实测数字(本机) | 何时在意 | +|---|---|---|---| +| **虚函数**(via 指针)| 查 vtable + 间接跳转 + 阻碍内联 | 0.55 ns,**2.5×** 于 CRTP | 热点虚调用且未被去虚化时 | +| **去虚拟化** | 编译器能证类型时常免费 | 直接对象 0.23 ns ≈ CRTP | 多数情况编译器替你做了 | +| **异常**(正常路径)| 表驱动零成本 | **0.25 ns(和纯函数一样)** | 几乎不用在意 | +| **异常**(抛出路径)| EH 表查 + 栈展开 | **857 ns,~3400×** | 异常路径别进热循环 | +| **`std::function`** 调用 | 类型擦除间接调用 | 1.61 ns,**6×** 于直接 lambda | 每帧百万次调用时 | +| **`std::function`** 构造 | 小走 SBO、大堆分配 | SBO 2.3 ns / 堆 19.6 ns(**8.5×**)| 热路径反复构造 + 大捕获 | +| **RVO/NRVO** | 返回值直接在调用方构造 | **0 次拷贝、0 次移动** | return 局部变量别写 std::move | +| **`return std::move(局部)`** | 禁用 NRVO,强制 move | 0 拷贝 + 1 move(多一次) | **反模式,别写** | + +这张表是 ch06-01/02/03/05 的实测汇总,具体机制和实验见各篇。总命题(Carruth *No Zero-Cost Abstractions*):**每个 C++ 抽象都对应一个硬件成本**,但「有成本」不等于「每次都发生」,编译器常替你消除(去虚化、零成本异常、RVO)。**先测,再决定要不要手写绕开**。 + +## 补充条目 + +### 1. 变量存储类型:register / static / thread_local + +变量的**存储类型**影响它在哪、访问多快(Agner 卷1 §7.1): + +- **自动变量(栈)**:默认。访问最快(在 L1 命中的栈上),编译器还能放进寄存器。`register` 关键字在现代编译器已无意义(编译器自己分配寄存器),是 C++17 起的 deprecated/removed 关键字,别用。 +- **静态变量(`static`/全局)**:固定地址,有固定初始化(常量初始化零开销;动态初始化有启动成本)。多线程下静态局部变量的初始化是线程安全的(magic statics),但**有线程安全初始化的运行时开销**(首次进入时的原子检查)。 +- **`thread_local`**:每线程一份。访问稍贵(要查 TLS 的线程局部存储区,通常几条额外指令),但在多线程下避免共享。对「每线程的上下文对象」有用。 + +实战:热路径变量尽量是自动变量(让编译器放寄存器);`static` 全局常量免费;`thread_local` 用于 per-thread 上下文(它的初始化与销毁成本要算进线程生命周期)。 + +### 2. 位域 bitfields + +**位域(bitfield)**把多个小字段压进一个整数里,省空间: + +```cpp +struct Flags { unsigned a : 1; unsigned b : 1; unsigned c : 6; }; // 共 8 bit +``` + +好处:`sizeof` 小(紧凑),cache 友好。代价是**位运算**:读写位域成员是「读整字节 + 位掩码 + 位操作」,比读写普通 `int` 多几条指令。所以位域**省内存、费指令**。适合「有大量标志位、内存是瓶颈」(协议头、标志集合);不适合「单个字段被高频读写、算力是瓶颈」。Agner 卷1 §7.27 有详细权衡。 + +### 3. enum class:零开销 + +**`enum class`**(C++11 强类型枚举)是「带类型安全」的枚举,且**零开销**:底层就是一个 `int`(或你指定的 underlying type),访问和普通 `int` 一样快,**类型安全是编译期的,运行时零成本**。所以: + +- 优先用 `enum class` 而非裸 `int` 常量(类型安全、可读性,免费)。 +- 别担心它的性能,和 `int` 一样。 +- 指定 underlying type(`enum class Color : uint8_t`)能控制 sizeof,省空间。 + +这是「零开销抽象」真正成立的少数案例之一(Carruth 命题的例外:不是所有抽象都有成本,`enum class`/`optional` 正常路径接近零成本)。 + +## sizeof 速查(本机实测,libstdc++ C++20) + +```text +sizeof: + int = 4 + std::optional = 8 (int 4B + 有无值标记 + padding) + std::variant = 16 (double 8B + index + padding) + std::variant= 40 (string 32B + index + padding) + std::span = 16 (指针 + 长度,零所有权) + std::string_view = 16 (指针 + 长度,不保证 \0) + std::shared_ptr = 16 (2 指针:对象 + 控制块) + std::unique_ptr = 8 (1 指针) + std::string = 32 (含 SSO 缓冲) + std::vector = 24 (3 指针) +``` + +读法:`optional`/`variant` 多出来的字节是「有无值」标记和 index;`span`/`string_view` 是「指针+长度」的轻量视图(零所有权,几乎免费);`string` 32 字节里有 SSO 小缓冲(SSO 机制归 vol3)。 + +怎么用这张表:写代码时优先选零开销或近零开销的抽象(`enum class`、`span`/`string_view`、`optional`/`variant` 正常路径),它们让代码更安全且几乎不费性能;真正要关注的是虚函数调用(via 指针,未被去虚化)、`std::function` 反复构造 + 大捕获、异常进热循环这几个,真有成本、常需要手动优化;永远测了再优化,「听起来贵」的抽象可能编译器早消除了,「听起来免费」的(`std::function` 构造)可能正埋着堆分配。 + +下一篇是 ch06 最后一篇,讲 RVO/NRVO 与 move。它不是「抽象成本」,而是「值语义下返回大对象」的机制,常被误解。 + +## 参考资源 + +- Agner Fog《Optimizing software in C++》§7 *Variables / objects / containers》(变量存储类型、位域、enum)。本地 +- Carruth *There Are No Zero-Cost Abstractions*(CppCon 2019)——「没有零开销抽象」命题 +- ch06-01/02/03/05(本卷,各成本的实测出处) +- 本篇 sizeof 程序:`code/volumn_codes/vol6-performance/ch06/abstraction_sizeof.cpp` diff --git a/documents/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md new file mode 100644 index 000000000..6e5b3c9b9 --- /dev/null +++ b/documents/vol6-performance/ch06-cpp-abstraction-cost/06-05-rvo-move.md @@ -0,0 +1,115 @@ +--- +chapter: 6 +cpp_standard: +- 17 +description: C++17 起,返回值优化(RVO/NRVO)让「按值返回大对象」几乎免费——返回值直接在调用方栈上构造,零拷贝零移动。本篇用一个 copy/move + 构造函数会计数的 Tracked 类型(编译器打不穿的演示)看清:URVO/NRVO 是 0 拷贝 0 移动,return std::move(局部) 是反模式(禁用 + NRVO 多 1 次 move),move 比 copy 便宜一个数量级 +difficulty: advanced +order: 5 +platform: host +prerequisites: +- std::function 的小缓冲区优化 +- Benchmark 方法论参考卡 +reading_time_minutes: 6 +related: +- C++ 抽象的成本速查表 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 移动语义 +title: RVO、NRVO 与 move 的真实成本 +--- +# RVO、NRVO 与 move 的真实成本 + +## 「按值返回大对象」曾经很贵,现在免费 + +C++ 早期,「按值返回大对象」(比如 `vector make()`)是被反复劝阻的,它意味着拷贝整个对象,贵得离谱。所以 C++ 圈长期流传着「返回大对象要用指针/引用」「用输出参数 `void make(T& out)`」之类的旧教条。 + +这些教条**在现代 C++ 里基本过时了**。C++17 起,**返回值优化(RVO / NRVO)是强制的**(URVO)或事实上的(NRVO):返回值**直接在调用方的栈上构造**,既不拷贝也不移动,零成本。我们用一个编译器打不穿的方法看清这件事。 + +## 用 copy/move 计数器看清(编译器打不穿) + +RVO 的演示有个坑:**用计时测,编译器跨迭代优化会打穿**(我第一版计时程序里,「拷贝」居然比「RVO」还快,因为编译器把整个循环折叠了)。正确做法是用一个 **copy/move 构造函数会计数的类型**,它**直接数**发生了几次拷贝、几次移动,编译器再聪明也改不了你的计数器: + +```cpp +struct Tracked { + int v; + static int64_t copies, moves; + Tracked(int x) : v(x) {} + Tracked(const Tracked& o) : v(o.v) { ++copies; } // 计拷贝 + Tracked(Tracked&& o) noexcept : v(o.v) { ++moves; o.v = -1; } // 计移动 +}; +``` + +然后测四种「返回」写法,各看 copies/moves 计数: + +```text +===== RVO/NRVO/move/copy 的拷贝/移动计数 ===== + URVO return Tracked(1): copies=0 moves=0 → 零拷贝零移动 + NRVO return t(有名局部): copies=0 moves=0 → 零拷贝零移动 + return std::move(t): copies=0 moves=1 → 被强制 1 次 move(NRVO 被你禁了) + return g_global(lvalue): copies=1 moves=0 → 1 次拷贝(不能 RVO) +``` + +这张表是 RVO 教学的金标准(不依赖计时,编译器打不穿): + +- **URVO(返回无名临时)**:`return Tracked(1);`。C++17 起是**强制拷贝消除(guaranteed copy elision)**,返回的 prvalue 直接在调用方初始化,既不拷贝也不移动。**0 拷贝 0 移动**。 +- **NRVO(返回有名局部)**:`Tracked t(...); return t;`。命名局部变量的返回,编译器**事实上**会消除(C++17 前就普遍做了,C++17 起更多场景保证)。**0 拷贝 0 移动**。 +- **`return std::move(t)`(反模式)**:你手写的 `std::move` **强制转成右值**,这**禁用了 NRVO**(NRVO 要求左值),编译器被迫走 move 构造。**0 拷贝 1 移动**,多了一次本可避免的 move。所以「`return std::move(局部)`」是 C++ 里著名的**反模式**,它只能让代码变慢,永远不会变快。 +- **返回 lvalue(全局/参数)**:`return g_global;`。lvalue 不能 RVO/move(没资格),走拷贝。**1 拷贝 0 移动**。这是真正「贵」的情形:返回一个外部命名对象,必须拷贝它。 + +## 用 -fno-elide-constructors 看 RVO 关掉的样子 + +GCC 有个 flag `-fno-elide-constructors` 关掉拷贝消除(用来复现老 C++ 行为或调试)。加上它重编: + +```text +(加 -fno-elide-constructors:) + URVO return Tracked(1): copies=0 moves=0 ← C++17 guaranteed,关不掉 + NRVO return t(有名局部): copies=0 moves=1 ← NRVO 被关,退化成 1 次 move + return std::move(t): copies=0 moves=1 + return g_global: copies=1 moves=0 +``` + +读出两件事: + +- **URVO 在 C++17 是强制的,`-fno-elide` 都关不掉**(prvalue 初始化规则,不是优化)。所以 `return T(args)` 永远零成本。 +- **NRVO 是「事实优化」,关掉后退化为一次 move**。move 比 copy 便宜(下面讲),所以即使 NRVO 没生效,你也就付一次 move 的成本,不是 copy。这是 C++「值语义安全」的兜底,最坏情况是一次便宜 move。 + +## move 比 copy 便宜多少 + +`std::move` 本身什么也不做(就是个右值转换);真正干活的是**移动构造函数**。对 `vector`/`string` 这种管理动态内存的类型,move 是**指针交换**(O(1)),copy 是**深拷贝**(O(n)): + +```cpp +// vector 的 move 构造:O(1) 指针交换(注意参数是 vector&&,不是 const vector&&——move 要改源) +vector(vector&& o) noexcept : data_(o.data_), size_(o.size_) { o.data_ = nullptr; o.size_ = 0; } +// vector 的 copy 构造:O(n) 深拷贝 +vector(const vector& o) : data_(new T[o.size()]) { copy o.data_ → data_; } +``` + +对一个 4 KB 的 `vector`(1000 个元素),move 是几次指针赋值(纳秒级),copy 是分配 4KB + memcpy(几十到几百纳秒,取决于分配器)。**move 比 copy 便宜一个数量级以上**,元素越多差距越大。 + +但 move 不是免费,它仍是「构造一个新对象 + 析构被掏空的源对象」。所以**最便宜的是 RVO/NRVO(0 次),其次 move(1 次),最贵 copy(深拷贝)**。 + +## 实战规则 + +压成几条可背诵的: + +1. **按值返回,放心写**。`return Tracked(args)`(URVO,C++17 强制免费)和 `T t(...); return t;`(NRVO,事实上免费)都零成本。 +2. **`return std::move(局部)` 是反模式,别写**。它禁用 NRVO,只会变慢。编译器会在「返回局部变量」时自动按需转右值,你不用操心。 +3. **move 不是免费,但比 copy 便宜一个数量级**(对大对象)。`std::vector`/`std::string` 的移动是 O(1)。 +4. **`std::move` 用在「明确要转右值」的地方**:比如把局部变量塞进容器 `v.push_back(std::move(elem))`、转移 `unique_ptr` 所有权。别在 `return` 上用。 +5. **返回外部 lvalue(全局、参数、成员)仍会拷贝**,那种情况考虑 `const&` 返回或显式 `std::move`(如果确实要转移所有权)。 + +这几条把「现代 C++ 怎么返回对象」讲全了。旧教条「按值返回慢」在现代 C++ + RVO 下基本不成立,**写得自然、写得值语义,编译器替你消除拷贝**。 + +一句话收口:RVO/NRVO 让按值返回零成本(URVO 是 C++17 强制 0/0,NRVO 是事实优化 0/0),编译器再聪明也打不穿,得用 copy/move 计数器验证;`return std::move(局部)` 是反模式,禁用 NRVO 多一次 move(0 拷贝 1 move),别写;move 比 copy 便宜一个数量级(O(1) 指针交换 vs O(n) 深拷贝),但不是免费;返回外部 lvalue 才会拷贝,那种情况考虑引用或显式转移。ch06 到此完结:虚函数、异常、std::function、速查表、RVO/move,C++ 主要抽象的成本都测过了。 + +## 参考资源 + +- cppreference *Copy elision*(C++17 guaranteed elision 规则) +- Meyer, S. *Effective Modern C++ Item 25*(reverse:对右值重载 vs 引用限定)——move 语义的工程用法 +- Agner Fog《Optimizing software in C++》§7.16 *Returning objects》。本地 +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch06/rvo_move.cpp`(含 -fno-elide-constructors 对照) diff --git a/documents/vol6-performance/ch06-cpp-abstraction-cost/index.md b/documents/vol6-performance/ch06-cpp-abstraction-cost/index.md new file mode 100644 index 000000000..2ccb1cdca --- /dev/null +++ b/documents/vol6-performance/ch06-cpp-abstraction-cost/index.md @@ -0,0 +1,30 @@ +--- +title: "C++ 抽象的性能成本" +description: "ch06 是 vol3「组件为什么这么设计」的性能镜像——用了之后硬件上发生了什么。逐个测:虚函数(去虚化常免费)、异常(零成本模型,正常免费/抛出贵 3400×)、std::function(SBO 与堆分配)、成本速查表(sizeof + 三补充)、RVO/NRVO 与 move(按值返回零成本)。总命题:没有零开销抽象,但先测再优化" +--- + +# C++ 抽象的性能成本 + +这一章是 vol3 的**性能镜像**。vol3 讲「vector/string/function 这些组件**为什么这么设计**」(设计动机),vol6 这一章讲「**用了之后,硬件上发生了什么导致快/慢**」。总命题是 Carruth 的 *There Are No Zero-Cost Abstractions*:**没有零开销抽象**,每个 C++ 抽象都对应一个硬件成本。 + +但这一章有个贯穿的反直觉精神:「有成本」**不等于**「每次都发生」。编译器常替你消除成本,去虚拟化让虚函数变直接调用、零成本模型让异常正常路径无开销、RVO 让按值返回零拷贝。所以这一章的建议反复是「**先测,别提前手写绕开**」。 + +五篇: + +- **06-01 虚函数与去虚拟化**:via 指针的虚调用 0.55ns(2.5× CRTP),但编译器常去虚化。别提前 CRTP 化。 +- **06-02 异常的零成本模型**:正常路径 0.25ns(零成本坐实),抛出 857ns(3400×)。异常只用于真异常。 +- **06-03 std::function 的 SBO**:调用慢 6×,构造小走 SBO/大堆分配(8.5×)。热路径反复构造 + 大捕获要当心。 +- **06-04 成本速查表**:汇总 + 变量存储类型 + 位域 + enum class 零开销 + sizeof。 +- **06-05 RVO、NRVO 与 move**:按值返回零拷贝零移动(用 copy/move 计数法验证);`return std::move(局部)` 是反模式。 + +> 边界:组件**设计机制**(vector 三指针、SSO 实现、EBO)归 vol3/vol4;vol6 只讲「在硬件上跑的成本」。知乎高频问题(虚函数慢吗/异常慢吗/function 堆分配/move 反例/return std::move)融入各篇切入点,不单独立篇。 + +## 本章内容 + + + 虚函数与去虚拟化:别急着把虚函数改模板 + 异常的零成本模型:正常路径免费,异常路径很贵 + std::function 的小缓冲区优化:类型擦除的代价 + C++ 抽象的成本速查表 + RVO、NRVO 与 move 的真实成本 + diff --git a/documents/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md b/documents/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md new file mode 100644 index 000000000..eef6e9ab9 --- /dev/null +++ b/documents/vol6-performance/ch07-compiler-and-size/07-01-opt-levels-and-blockers.md @@ -0,0 +1,108 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: 本篇讲清两件事:-O0/-O1/-O2/-O3/-Os/-Oz 各级编译器做什么(实测 -O0→-O2 快 4 倍,且 -O3 有时反比 + -O2 慢),以及三类编译器优化不了的「blocker」——跨翻译单元(归 LTO)、指针别名(用 __restrict 解锁)、volatile(强禁优化)。核心:你的工作很大程度是「别挡编译器的路」 +difficulty: advanced +order: 1 +platform: host +prerequisites: +- inline、去虚拟化与编译器优化全景 +- 前端优化:代码布局、PGO 与 BOLT +reading_time_minutes: 6 +related: +- LTO、ThinLTO 与 PGO 的工程接入 +- 体积优化:-Os、--gc-sections 与模板膨胀控制 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工具链 +title: -O 级别与 optimization blockers:编译器能做什么、做不了什么 +--- +# -O 级别与 optimization blockers:编译器能做什么、做不了什么 + +## 编译器是你的第一个性能队友 + +写 C++ 性能代码,最重要的事之一是**搞清楚编译器会替你做什么、做不了什么**。它会自动内联、自动向量化、自动消除死代码,这些 ch04-02/04/05 都见过;但它也有三类「优化不了」的硬限制(blocker),需要你配合。本篇把两边都讲清。 + +## -O 级别:各级编译器做什么 + +GCC/Clang 的 `-O` 级别控制优化力度。一张表(精确清单查官方文档): + +| 级别 | 大致做什么 | 何时用 | +|---|---|---| +| `-O0` | 不优化,变量可观察、汇编可读 | 调试(**性能数字无意义**)| +| `-O1` | 基本优化(常量折叠、简单内联)| 偶尔调试用 | +| `-O2` | 大多数优化:CSE、LICM、调度、自动内联(同 TU)、基础向量化 | **release 默认甜点** | +| `-O3` | 更激进:**循环向量化、自动展开、更激进内联** | 数值/SIMD 受益;**偶尔反伤** | +| `-Os`/`-Oz` | 优化**体积**(快但更小)| 嵌入式 flash 受限(ch07-04)| + +`-O2` 是 release 的默认选择,它已经做了 ch04 讲的循环优化大半、同翻译单元内联、基础调度。`-O3` 比 `-O2` 多的主要是「更激进的向量化 + 展开 + 内联」。 + +### 实测:-O0→-O2 快 4 倍,-O3 偶尔反比 -O2 慢 + +我们测同一个函数(带 `volatile` scale 的循环)在不同 -O 级别下: + +```text +===== -O 级别(同一个循环函数)===== + -O0: 18.6 ms ← 不优化,慢 4 倍于 -O2 + -O2: 4.9 ms ← release 甜点 + -O3: 7.4 ms ← 比 -O2 还慢!(见下文:不是向量化反伤) +``` + +两件事,各说一句。 + +**第一件,`-O0` 性能数字无意义。** 18.6 ms vs 4.9 ms,4 倍差距。性能测试绝不能用 `-O0`,你测的是「没优化的代码」,不是「你的代码的真实性能」。调试用 `-O0`,性能数字一律 `-O2` 起。这条 ch01 反复强调过。 + +**第二件,`-O3` 不总比 `-O2` 快。** 这里 `-O3`(7.4 ms)比 `-O2`(4.9 ms)慢。但先别急着下结论,我们看汇编。`g++ -O2 -S` vs `g++ -O3 -S` 这个 `scale_add_alias` 函数,**两个都没向量化**:`volatile` 读不能缓存进寄存器,`-fopt-info-vec-missed` 明确报 `not vectorized: volatile type`。注意这跟别名分析无关,`volatile` 不参与别名判断。两个级别之间只是寄存器分配/指令调度略有差异。所以 7.4 vs 4.9 **不是「激进向量化反伤」(压根没向量化),更可能是测量噪声叠加调度差异**:单次测量 + WSL2 噪声,多次复现这个差值会变,反过来的结果笔者也见过。 + +这是个诚实且重要的结果。**「`-O3` 比 `-O2` 更优」是误解**:`-O3` 在数值/SIMD 友好的代码上有用(真向量化拿收益),在不规则、volatile、分支密集的代码上要么没向量化(本例),要么偶尔调度反伤。所以 release 默认 `-O2`,只在**确认 `-O3` 有收益**的局部(数值热点)开 `-O3` 或 `-ftree-vectorize`。 + +## optimization blockers:三类编译器优化不了的 + +讲完「编译器能做的」,讲它「做不了的」。三类 blocker,每一类你都可能无意中触发。 + +### 1. 跨翻译单元(归 LTO,ch07-02) + +编译器编译一个 `.cpp` 时,看不见别的 `.cpp` 里的实现。所以 `int helper(int)` 声明在头文件、实现在另一个 `.cpp` 时,**编译器不敢内联它**(不知道实现)。这是跨 TU blocker。解法是 **LTO(链接期优化)**,链接时跨文件内联,ch07-02 实测 LTO 让一个跨 TU 调用快 **3.9 倍**。 + +### 2. 指针别名(aliasing) + +C/C++ 允许两个指针指向同一地址(别名 aliasing)。编译器**默认假设两个指针可能别名**,于是不敢做激进的内存读写重排,因为它怕「写 `a[i]` 影响了 `b[i]`」。这个保守假设让很多本可向量化/重排的循环卡住。 + +实测(`scale_add`:循环里 `a[i] += b[i] * *scale`,`volatile` scale): + +```text + -O3 别名版(默认): 7.4 ms ← 编译器不敢假设 a≠b + -O3 __restrict 版: 5.8 ms ← 你保证 a/b/scale 不别名,编译器敢优化 +``` + +> ⚠️ 注:这个教学 demo 的归因其实**不干净**。配套代码里 alias 版签名是 `volatile int* scale`、restrict 版签名是 `int* __restrict scale`(scale 非 volatile),也就是 restrict 版**同时去掉了 `scale` 的 `volatile`**。所以 7.4→5.8 不全是别名分析的功劳,部分是去掉 `volatile` 后 `scale` 能被缓存/外提。干净的别名对照应保持两版 `scale` 类型一致、只动 `a`/`b` 上有没有 `__restrict`。这是教学 demo 的简化,实战对照要注意「只改一个变量」,呼应 ch00-01 的纪律。 + +`__restrict`(C99 引入,C++ 是扩展但 GCC/Clang 都支持)是你向编译器承诺「这个指针不和别人别名」: + +```cpp +void f(int* __restrict a, int* __restrict b, int* __restrict scale, int n); +``` + +承诺了,编译器就敢向量化/重排。代价:如果你撒谎(指针其实别名),**是 UB**。所以 `__restrict` 要在「你确信无别名」时用,典型场景是数值计算里独立的多个数组。别滥用,但数值热点里它是稳定收益。 + +> 注:`__restrict` 对引用也行(`const int& __restrict`),但语义稍微妙,查清楚再用。C++ 没有 `restrict` 关键字(那是 C 的),用 `__restrict`。 + +### 3. volatile + +`volatile` 强制编译器**每次都真正读写内存**,不缓存到寄存器、不做任何优化。它本来是给 **MMIO(内存映射 IO)、信号处理、线程间无锁标志**用的,这些场景每次访问都必须真碰到内存(不能缓存)。但 `volatile` **是优化的反义词**:上面的测试里 `volatile scale` 迫使每次循环真 load,直接拖慢了循环。 + +实战上,**别用 `volatile` 做「线程同步」或「性能」**。它不保证原子性、不保证内存序(那是 `std::atomic` 的事,vol5 讲),只保证「不优化」。多数性能代码里 `volatile` 是误用,该换成 `std::atomic` 或干脆去掉。 + +## 参考资源 + +- GCC 手册 *Options That Control Optimization*(`-O0`/`-O1`/`-O2`/`-O3`/`-Os`/`-Oz` 各启用的 pass 清单) +- Agner Fog《Optimizing software in C++》§8 *Different C++ compilers》。本地 +- CSAPP 第 5 章 *Optimizing Program Performance》(optimization blockers 的概念定义,别名/内存引用那套) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch07/opt_levels_blockers.cpp` + +小结成一句:编译器是你的性能队友,**别挡它的路**。让它看得见实现(LTO)、信你的无别名承诺(`__restrict`)、别用 `volatile` 禁它的优化;`-O2` 是 release 甜点,`-O3` 留给数值热点,`-O0` 的性能数字一律不信。 diff --git a/documents/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md b/documents/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md new file mode 100644 index 000000000..0055ba16f --- /dev/null +++ b/documents/vol6-performance/ch07-compiler-and-size/07-02-lto-pgo.md @@ -0,0 +1,99 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: LTO(链接期优化)让编译器跨翻译单元内联,消除「跨 TU 调用」这个 optimization blocker——实测同一个跨文件调用快 + 3.9 倍;PGO(剖面引导优化)在大型代码库上收益显著但对微基准无效果(诚实 null 结果)。本篇讲两者的机制、工程接入(怎么构建)、以及代价 +difficulty: advanced +order: 2 +platform: host +prerequisites: +- -O 级别与 optimization blockers +- 前端优化:代码布局、PGO 与 BOLT +reading_time_minutes: 5 +related: +- 链接性能、多编译器对比与编译期元编程 +- 体积优化:-Os、--gc-sections 与模板膨胀控制 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 工具链 +title: LTO、ThinLTO 与 PGO 的工程接入 +--- +# LTO、ThinLTO 与 PGO 的工程接入 + +ch07-01 讲了「跨翻译单元」是 optimization blocker 之一:编译器编译一个 `.cpp` 时看不见别的 `.cpp` 里的实现,不敢跨文件内联。本篇讲它的解法 **LTO**,加上「按真实剖面优化代码布局」的 **PGO**。两者都是 release 构建级的工程接入,在大项目上收益显著。 + +## LTO:链接期跨文件内联 + +**LTO(Link-Time Optimization)** 的思路是这样的:编译时给每个 `.o` 文件带上「中间表示(GIMPLE/LLVM IR)」而不只是机器码,链接时链接器把所有 IR 合并,**重新做一遍跨文件的优化和内联**。于是跨 TU 的函数调用能被内联,常量传播、死代码消除都能跨文件进行。 + +我们测一个最简单的跨 TU 场景:`main.cpp` 调 `helper.cpp` 里的 `helper(int)`: + +```bash +# 无 LTO:helper 在另一个 TU,编译器看不见实现,不能内联 +g++ -O2 main.cpp helper.cpp -o lto_nolto +# 有 LTO:链接期合并,helper 能被内联 → 进一步常量传播 +g++ -O2 -flto main.cpp helper.cpp -o lto_lto +``` + +实测(helper 被调一亿次): + +```text +无 LTO: 178.6 ms +有 LTO: 46.2 ms ← 3.9 倍 +``` + +**3.9 倍。** 这个差距全部来自「跨 TU 内联 + 后续优化」:LTO 让编译器看见 `helper` 的实现,把它内联进 `main` 的循环,然后常量传播/循环简化把整段代码压到接近最优。无 LTO 时,每次循环是一次真函数调用(且 helper 内部有自己的循环)。 + +附带好处:LTO 还做**跨文件死代码消除**,二进制往往更小(本例 16136 → 16024 字节,回收了未用代码)。 + +### ThinLTO:可扩展的 LTO + +全 LTO 把所有 IR 合并成一个巨视图,大项目上**链接慢、内存吃得多**:Chrome 级项目全 LTO 链接要几十分钟、几十 GB 内存。**ThinLTO**(LLVM,`-flto=thin`)把工作分片,先做轻量摘要(import/export 决策),再并行优化每个模块。大项目上 ThinLTO 比 full LTO **链接快得多、内存省**,优化效果接近。GCC 也有类似机制(`-flto=auto` 并行)。 + +### LTO 的代价 + +- **链接变慢**(全 LTO 尤其),构建内存上升。 +- **构建复杂度**:CMake 等构建系统要正确传 `-flto` 给编译和链接、ar 要用 gcc-ar(处理 LTO 对象)。 +- **调试**:LTO 后符号可能被打乱,调试器体验下降。 + +实战上,**release 构建开 LTO/ThinLTO,debug 构建不开**。大项目用 ThinLTO。这是「免费午餐」级的优化(一行 flag、几个百分点到十几百分点提速),代价只是链接时间。 + +## PGO:按真实剖面布局代码 + +**PGO(Profile-Guided Optimization)** 在 ch04-07 讲过原理(三阶段:仪器化 → 跑剖面 → 用剖面重编),这里讲工程接入和一个诚实结论。 + +### 诚实的结论:PGO 对微基准无收益 + +笔者用 ch04 的 `pgo_demo`(99/1 分支的小函数)严格跑完三阶段 PGO,和纯 -O2 基线对比: + +```text +纯 -O2 基线: 3.57-3.82 ms +-O2 + PGO: 3.78-4.17 ms ← 没有任何收益(甚至略慢,噪声内) +``` + +**PGO 对微基准无效果。** 原因是这个小函数就两个分支、几行代码,`-O2` 已经把它优化得很好;PGO 的价值是**大代码库的代码布局**(把分散在几千个函数里的热路径物理聚到一起,改善 icache),对几十行的小函数没什么可布局的。 + +> 笔者第一遍跑时「PGO 版」看起来快了 4 倍,激动了一下,后来发现那个 4 倍是**仪器化二进制的计数器开销**(阶段 1 的 build 自带性能计数器),不是 PGO 收益。**测 PGO 必须用「纯 -O2 无仪器化」基线对照**,且确认阶段 3 的 profile 真被应用(编译器 warning `profile count data file not found` 说明没找到)。这个翻车经历记在这里,呼应 ch01 纪律:**你以为测的 PGO 收益,可能测的是仪器化开销**。 + +### PGO 真正的价值:大代码库 + +PGO 的公开收益都来自大项目:**Chrome、Firefox、各大数据库**,报告**个位数到十几个百分点**提速,前提是代码库大到 icache/分支布局真的成为瓶颈。所以三条: + +- **别期望 PGO 在微基准或小项目上有效。** +- **在大型 release 构建里开**,用**代表性生产 workload** 采样剖面(不是随便跑跑)。 +- 接入流程(CMake):`-fprofile-generate` 编译 → 跑 workload → `-fprofile-use` 重编。CI 集成要保存剖面、跨构建复用。 + +PGO + LTO 叠加是大项目 release 的标配组合。 + +## 参考资源 + +- GCC 文档 `-flto` / `-fprofile-generate` / `-fprofile-use` +- LLVM 文档 *ThinLTO*(llvm.org/docs/ThinLTO.html) +- ch04-07 前端优化:代码布局、PGO 与 BOLT(本卷,PGO 原理与「对微基准无收益」的首次讨论) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch07/lto_main.cpp` + `lto_helper.cpp`;PGO 复用 `ch04/pgo_demo.cpp` + `ch04/pgo.sh` + +一句话收口:**LTO 跨 TU 内联实测 3.9×**(跨文件 helper 调用),release 开、大项目用 ThinLTO,代价只是链接变慢;**PGO 按剖面布局,对微基准无收益(诚实 null)**,价值在大型代码库(Chrome/Firefox 级,个位数到十几百分点),那个一度看到的 4× 是仪器化开销不是 PGO。**PGO + LTO** 是大项目 release 标配,接入是工程活(CMake 传 flag、保存剖面),一次性配置长期受益。 diff --git a/documents/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md b/documents/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md new file mode 100644 index 000000000..3ef182dc2 --- /dev/null +++ b/documents/vol6-performance/ch07-compiler-and-size/07-03-linking-and-compilers.md @@ -0,0 +1,88 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: 本篇讲三个边界话题:动态链接/PIC 的运行时开销(CSAPP ch7 的「符号解析 + 位置无关」代价)、主流编译器 GCC/Clang/MSVC + 的优化差异(Agner 卷1 ch8)、以及编译期元编程(模板/constexpr)的性能面——它在编译期算完后零运行期成本,但代价是编译时间和二进制体积 +difficulty: advanced +order: 3 +platform: host +prerequisites: +- LTO、ThinLTO 与 PGO 的工程接入 +- -O 级别与 optimization blockers +reading_time_minutes: 5 +related: +- 体积优化:-Os、--gc-sections 与模板膨胀控制 +tags: +- host +- cpp-modern +- advanced +- 优化 +- 链接器 +- 工具链 +title: 链接性能、多编译器对比与编译期元编程 +--- +# 链接性能、多编译器对比与编译期元编程 + +ch07 前两篇讲了 `-O` 级别和 LTO/PGO。本篇把剩下三个编译/链接相关的话题收尾:**动态链接的运行时开销、主流编译器的优化差异、编译期元编程的性能面**。三个都是「边界话题」,在常规性能优化里它们不是主角,但在大型项目、嵌入式、极致优化场景会冒出来。 + +> 本篇无本机实测。动态链接的机制讲 CSAPP ch7,多编译器对比讲 Agner 卷1 §8,编译期元编程讲 Agner 卷1 §15,只讲性能面、不重复造实验。 + +## 动态链接与 PIC:位置无关的代价 + +CSAPP 第 7 章 *Linking* 讲了静态链接 vs 动态链接。性能视角看,动态链接(`.so`/`.dylib`/`.dll`)有几个运行时开销: + +- **符号解析**:第一次调用动态库的函数时,运行时链接器(`ld.so`)要查符号表、绑定地址(lazy binding)或启动时全绑定(now binding)。这是**首次调用的延迟**。 +- **PIC(位置无关代码)**:动态库的代码要能在任意地址加载,所以走 **GOT(全局偏移表)**间接寻址,每次访问全局变量/外部函数多一次 GOT 表查表。这是**每次访问的常数开销**(几条额外指令)。 +- **PLT(过程链接表)**:外部函数调用走 PLT 间接跳转,比直接调用多一跳。 +- **icache 压力**:动态库的代码散落在不同加载地址,跨库调用增加 icache miss。 + +这些开销**单次很小(纳秒级)**,但在「极高频跨库调用 + 极短函数」的场景会显现(比如一个 tight 循环里调用另一个 .so 的小函数)。对策几条: + +- **把热路径的跨库调用内联掉**(LTO 在库内有效,跨库无效;或者把热函数挪进主程序)。 +- **`-Wl,-z,now`**(启动时全绑定,避免首次延迟在运行中发生,适合长运行服务)。 +- **静态链接热路径库**(牺牲升级独立性换性能)。 + +CSAPP ch7 有完整机制讲解,vol6 只讲性能面。大多数应用这些开销可忽略;**实时游戏、HFT、嵌入式**才会精细处理。 + +## 多编译器对比:GCC vs Clang vs MSVC + +三大主流编译器的优化能力**大体相当,细节有差异**。Agner Fog 在卷1 §8 做过对比,几个观察(随版本变,查最新): + +- **优化强度**:三者 `-O2`/`-O3` 整体性能差距通常在**个位数百分点**内,谁更强取决于具体代码。 +- **向量化**:GCC 历来激进向量化;Clang/LLVM 的向量化框架(由 Nadav Rotem 主导)也很强,有时更聪明;MSVC 的自动向量化相对保守(但手写 intrinsics 一样)。 +- **代码生成**:Clang 的错误信息最好、诊断最准;GCC 平台支持最广;MSVC 在 Windows ABI 下是事实标准。 +- **跨平台**:GCC/Clang 跨 Linux/macOS/Windows(MinGW/clang-cl);MSVC 主要 Windows。 + +实战建议是**别为了「听说 X 编译器更快」换工具链**:差距小,且随版本变。选编译器的依据是**平台支持 + 团队熟悉度 + 诊断质量**,性能差距不是主要因素。真要榨干,**用 PGO + LTO 比换编译器有效得多**。定期跑 benchmark 确认你的编译器选择没明显劣势即可。 + +> 注意:不同编译器的 **ABI 不兼容**(Itanium ABI vs MSVC ABI),所以混用(比如 GCC 编的库给 MSVC 用)要 `extern "C"` 接口隔离。这是工程问题,不是性能问题。 + +## 编译期元编程:模板与 constexpr 的性能面 + +C++ 的模板和 `constexpr` 能把计算推到**编译期**,编译完算完,运行期零成本。这是「真正的零开销抽象」(运行期)。但代价分两面。 + +### 运行期:零或接近零 + +- **`constexpr`/`consteval` 函数**:编译期求值完,运行期结果就是个常量。比如 `constexpr int fib(int n)` 在编译期算 `fib(10)`,运行期就是个 `55` 字面量,**零运行期成本**。 +- **模板计算**:`template struct Fact { static constexpr int v = N * Fact::v; };`,编译期算完。 +- **类型计算**(`std::tuple`、`std::variant` 的调度)在编译期生成,运行期是优化的直接代码(常被内联消除)。 + +所以「编译期能算的就编译期算」是 C++ 性能的一个原则:**算力从运行期挪到编译期,免费**。 + +### 代价:编译时间和体积 + +- **编译时间**:模板实例化是编译器最重的活之一。大量模板的 C++ 项目编译动辄几十秒到几分钟。这是 C++ 圈长期痛点(模块 C++20 起缓解)。 +- **二进制体积**:模板对不同类型各生成一份代码(`vector`、`vector`、`vector` 是三份),**模板膨胀(template bloat)**。ch07-04 讲对策(`extern template`、抽公共逻辑)。 +- **可读性/错误信息**:重模板代码的错误信息出了名的难读。 + +实战权衡:**热路径的小计算,用 `constexpr` 推到编译期**(`constexpr int kTable[N] = ...`);**别为了「编译期」过度模板化**(模板膨胀 + 编译时间 + 可读性代价)。`constexpr` 比「模板元编程」更克制、更现代,优先用 `constexpr`/`consteval`。 + +## 参考资源 + +- CSAPP 第 7 章 *Linking*——静态/动态链接、GOT/PLT、符号解析的机制 +- Agner Fog《Optimizing software in C++》§8「Different C++ compilers」(编译器对比)+ §15「Metaprogramming」(编译期元编程的体积面)。本地 +- GCC/Clang/MSVC 文档各自的 `-O` 行为、`-fpic`/`-fpic`、`constexpr` 支持 +- ch07-04 体积优化(本卷,模板膨胀的对策) + +三条收尾:**动态链接有运行时开销**(PIC 间接寻址、符号解析、icache),单次小,极高频跨库短调用才显现,机制讲 CSAPP ch7;**三大编译器性能差距小**(个位数百分点),别为「更快」换工具链,用 PGO+LTO 更有效;**编译期元编程运行期零成本,但代价是编译时间 + 模板体积**,优先用 `constexpr`/`consteval` 而非重模板。这三条在常规优化里是配角,但在大项目构建工程、嵌入式、极致优化里会成主角。 diff --git a/documents/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md b/documents/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md new file mode 100644 index 000000000..0341b65d5 --- /dev/null +++ b/documents/vol6-performance/ch07-compiler-and-size/07-04-size-optimization.md @@ -0,0 +1,122 @@ +--- +chapter: 7 +cpp_standard: +- 17 +description: 二进制体积本身不是性能,但它通过 icache/iTLB/下载量间接影响性能(尤其嵌入式)。本篇讲体积优化三招:-Os/-Oz(体积优先的优化级别)、-ffunction-sections + + --gc-sections(链接期回收死代码,实测省死代码)、模板膨胀控制(extern template、抽公共逻辑)。最后给一张「体积↔性能」的取舍清单 +difficulty: advanced +order: 4 +platform: host +prerequisites: +- -O 级别与 optimization blockers +- 链接性能、多编译器对比与编译期元编程 +reading_time_minutes: 6 +related: +- LTO、ThinLTO 与 PGO 的工程接入 +- 前端优化:代码布局、PGO 与 BOLT +tags: +- host +- cpp-modern +- advanced +- 优化 +- 链接器 +- 工具链 +title: 体积优化:-Os、--gc-sections 与模板膨胀控制 +--- +# 体积优化:-Os、--gc-sections 与模板膨胀控制 + +## 体积为什么影响性能 + +二进制体积本身不是「速度」,但它**间接影响性能**,主要三条路径: + +1. **icache 容量有限**:代码体积大 → icache 装不下 → icache miss → Frontend Bound(ch04-07 讲过)。这是体积影响性能的主要机制。 +2. **iTLB 有限**:代码页多 → iTLB 项需求多 → iTLB miss。 +3. **下载/存储**:嵌入式 flash 受限、移动端 APK 大小、网络传输,体积直接是成本。 + +所以体积优化对**嵌入式(Flash 受限)**、**移动端(APK 大小)**、**大代码库(icache 压力)**有实际意义。本篇讲三招标准手法。 + +## 第一招:-Os / -Oz(体积优先的优化级别) + +`-Os` 是「优化到不膨胀的大小」,`-Oz`(Clang, GCC 也有)更激进地优先体积。它们和 `-O2`/`-O3` 的区别是**代价模型不同**: + +- `-O2`:代价模型是「速度优先,体积其次」。 +- `-Os`:代价模型是「体积优先,但别明显变慢」,它不开那些会让代码变大的优化(如激进循环展开)。 +- `-Oz`:更偏体积,可能略微牺牲速度。 + +实测(`size_demo`,含死代码 + 模板多实例): + +> ⚠️ 量代码体积用 `size` 命令的 text 段,别用 `ls -l`。整个 ELF 含头部/对齐/调试信息,会被污染,且 `-Os` 的 ELF 在某些编译器版本上可能反比 `-O2` 大。下面一律用 `size ` 的 text 段(代码段)度量。 + +```text + text data bss (本机 GCC 16 量级) +-O2: ~4144 ... ... ← 基线 +-Os: ~3740 ... ... ← 比 -O2 text 小 +-Oz: ~3740 ... ... ← 与 -Os 同量级 +--gc-sections ~4017 ... ... ← 死代码回收后 +``` + +绝对值随编译器版本变,但方向稳定:`-Os`/`-Oz` 的 text 比 `-O2` 小。 + +在这个小 demo 上差异很小(几百字节),因为程序本身就小。**在大项目上,`-Os` 比 `-O2` 通常省 5-15% 体积**。`-Os` 的代价是「可能略微变慢」(没做那些会膨胀的优化),所以适合「体积是硬约束」的场景(嵌入式 flash),不适合「速度优先」的桌面/服务器。 + +## 第二招:-ffunction-sections + --gc-sections(死代码回收) + +这一招的思路是把每个函数/数据放独立段(section),链接期回收**没被引用的段**。两步: + +```bash +# 编译:每个函数独立段 +g++ -ffunction-sections -fdata-sections ... +# 链接:回收未引用段 +g++ ... -Wl,--gc-sections +``` + +它解决的是**死代码**:那些定义了但从没被调用的函数(很常见:遗留代码、被条件编译禁用的旧路径、模板实例化但没用到的成员)。实测上面的 `--gc-sections` 比 `-O2` 省 200 字节(那个 demo 里有两个 `[[maybe_unused]]` 的死函数)。 + +大项目上 `--gc-sections` 收益显著:大型 C++ 项目常常有大量「链接进来但没用」的代码(尤其第三方库整库链接),`--gc-sections` 能砍掉几十个百分点。代价几乎没有(编译/链接略慢,可忽略)。**release 构建默认应该开 `-ffunction-sections -fdata-sections -Wl,--gc-sections`**,几乎免费的体积优化。 + +> 注意:`--gc-sections` 回收的是「整个函数/数据未被引用」的段;函数内部的部分代码(比如某个 if 分支没被执行)它回收不了,那要靠 PGO 的代码布局。两者互补。 + +## 第三招:模板膨胀控制 + +模板对不同类型各实例化一份代码,容易膨胀。`vector`、`vector`、`vector` 是三份独立的 `push_back`/`reserve`/扩容等代码。控制手法几条: + +- **`extern template`(C++11)**:显式声明「这个模板实例在别的 TU 实例化,这里不重复生成」。大项目里把常用模板实例集中在一个 `.cpp` 里实例化一次,其它 TU 用 `extern template` 声明,避免每个 TU 都生成、链接期去重(去重也要时间)。 + + ```cpp + // common.h + extern template class std::vector; // 声明:别在这生成 + // common.cpp + template class std::vector; // 实例化一次 + ``` + +- **抽公共逻辑**:模板类型无关的部分抽到非模板基类/公共函数,只编一份。比如 `vector` 的内存管理可以共享一个非模板的 `vector_base`。 +- **别过度泛化**:只对真需要的类型实例化。`template` 用在一个只对 `int`/`double` 用的函数上,就只实例化这两个,别为了「通用」加一堆用不到的特化。 + +模板膨胀在大项目(尤其重用 STL/Boost)上能贡献可观体积。这三条是标准对策。 + +## 体积 ↔ 性能 的取舍清单 + +把三招和前面的放一起,按「体积优化 vs 性能影响」排个清单: + +| 手法 | 体积 | 速度 | 何时用 | +|---|---|---|---| +| `-ffunction-sections` + `--gc-sections` | ↓↓ | 几乎不变 | **release 默认开**(免费)| +| `-Os`/`-Oz` | ↓ | 可能略降 | 体积硬约束(嵌入式)| +| `extern template` | ↓ | 不变 | 大项目重模板 | +| 抽公共逻辑(非模板基类) | ↓ | 可能略升(间接调用)| 谨慎权衡 | +| `-O3`(激进向量化/展开) | ↑↑ | 通常↑ 偶尔↓ | 速度优先,体积够用 | +| 模板过度泛化 | ↑↑ | — | 别这么写 | + +核心取舍是**体积优化和速度优化经常反向**:`-Os` 省体积但可能慢、`-O3` 提速但膨胀。**嵌入式优先体积、桌面/服务器优先速度、移动端居中**。先用 `--gc-sections`(免费的体积红利),再按场景选 `-O` 级别,最后才考虑 `extern template` 这类需要改代码的。 + +## 参考资源 + +- 现有 vol6 `06-evaluating-performance-and-size.md`(本篇是其扩写版的前置,已存在) +- Agner Fog《Optimizing assembly》§10 *Code size optimization》。本地 +- GCC/Clang 文档 `-Os`/`-Oz`/`-ffunction-sections`/`-Wl,--gc-sections`/`extern template` +- CSAPP 第 7 章 *Linking*(`--gc-sections` 的链接机制背景) +- 本篇实测代码:`code/volumn_codes/vol6-performance/ch07/size_demo.cpp` + +收口一句:**体积影响性能的主要机制是 icache/iTLB miss**(还有嵌入式 flash、移动端下载);三招是 `-Os`/`-Oz`(体积优先优化级别,实测比 -O2 省几百字节到 5-15%)、`--gc-sections`(死代码回收,几乎免费,release 默认开)、模板膨胀控制(`extern template`、抽公共);体积 ↔ 速度常反向取舍,嵌入式优先体积、桌面优先速度,`--gc-sections` 是免费的体积红利先拿。 + +本篇是 ch07 的最后一篇,也是 vol6 八章百科的收尾。整卷从「性能思维 + sanitizer 地基」开始,经过「测量方法论」「CPU 微架构」「归因方法论」「按瓶颈部位优化」「多核」「C++ 抽象成本」,到这里「编译器边界与体积」,一套从「先正确、先测量」到「对症下药」的完整性能工程方法论。 diff --git a/documents/vol6-performance/ch07-compiler-and-size/index.md b/documents/vol6-performance/ch07-compiler-and-size/index.md new file mode 100644 index 000000000..94564aeb0 --- /dev/null +++ b/documents/vol6-performance/ch07-compiler-and-size/index.md @@ -0,0 +1,28 @@ +--- +title: "编译器优化边界与体积评估" +description: "ch07 收尾编译器视角:-O 级别与 optimization blockers(跨 TU/别名/volatile)、LTO 跨 TU 内联(实测 3.9×)与 PGO(微基准无收益、大代码库有)、链接性能与多编译器对比与编译期元编程、体积优化(-Os/--gc-sections/模板膨胀)。整卷的工程收口" +--- + +# 编译器优化边界与体积评估 + +这是 vol6 的最后一章,从**编译器和链接器**的视角收尾。前面几章我们讲了怎么写「硬件跑得快的 C++ 代码」,这一章讲**编译器能替你做什么、做不了什么**,以及怎么配合它(让出视野、别挡路)。 + +四篇: + +- **07-01 -O 级别与 blockers**:`-O2` 是 release 甜点(实测 -O0→-O2 快 4 倍),**-O3 偶尔反比 -O2 慢**(诚实);三类 blocker(跨 TU / 别名 / volatile)。 +- **07-02 LTO 与 PGO**:LTO 跨 TU 内联实测 **3.9×**;PGO 对微基准无收益(诚实 null,那个一度的 4× 是仪器化开销),价值在大代码库。 +- **07-03 链接、多编译器、元编程**:动态链接 PIC 代价(CSAPP ch7)、GCC/Clang/MSVC 差距小、编译期元编程的体积面。 +- **07-04 体积优化**:`-Os`/`--gc-sections`/模板膨胀控制;体积↔速度常反向取舍。 + +贯穿全章的精神和 ch04 一致:**编译器是性能队友,你的工作是「别挡路」**:让它看得见实现(LTO)、信你的无别名承诺(`__restrict`)、别用 `volatile` 禁它的优化。release 默认开 LTO + `--gc-sections`,这是免费午餐;PGO 在大项目上加餐。 + +> 本机实测覆盖 07-01/02/04;07-03 偏概念(链接机制讲 CSAPP ch7,多编译器讲 Agner 卷1 §8),不重复造实验。 + +## 本章内容 + + + -O 级别与 optimization blockers + LTO、ThinLTO 与 PGO 的工程接入 + 链接性能、多编译器对比与编译期元编程 + 体积优化:-Os、--gc-sections 与模板膨胀控制 + diff --git a/documents/vol6-performance/index.md b/documents/vol6-performance/index.md index 938ff32b2..4d5535ada 100644 --- a/documents/vol6-performance/index.md +++ b/documents/vol6-performance/index.md @@ -14,17 +14,17 @@ tags: 一句话总命题贯穿全卷:**efficiency(算法复杂度)≠ performance(硬件上的真实表现)。** 别只看 big-O,要看数据在硬件上怎么流。 -> 本卷正在按这套骨架系统化重写,部分历史散篇(内联、AVX、体积评估、sanitizer 三篇)在归位中,暂列在下方导航。 +> 本卷已按八章结构稳定下来(2026-07-03:历史散篇 02-inline / avx 删、06-evaluating 属嵌入式已移、sanitizer 三篇归位 ch00-03/04/05)。 ## 章节导航 ch00 · 性能思维与正确性前置 ch01 · Benchmark 方法论【全卷锚点】 - 内联与编译器优化 - AVX/AVX2 深度介绍 - 性能与体积评估 - ASan 工具家族与内存安全 - Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 - Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE + ch02 · CPU 微架构与存储层次 + ch03 · 归因方法论:从测量到瓶颈 + ch04 · 按瓶颈部位优化【技术主体】 + ch05 · 多核性能(承接 vol5) + ch06 · C++ 抽象的性能成本 + ch07 · 编译器优化边界与体积评估 diff --git a/documents/vol8-domains/embedded/core-embedded-cpp-index.md b/documents/vol8-domains/embedded/core-embedded-cpp-index.md index 5fa2e3812..11f1a7191 100644 --- a/documents/vol8-domains/embedded/core-embedded-cpp-index.md +++ b/documents/vol8-domains/embedded/core-embedded-cpp-index.md @@ -28,7 +28,7 @@ description: '' - [C++98进阶:类型转换、动态内存与异常处理](../../vol1-fundamentals/03F-cpp98-casts-memory-exceptions.md) - [何时用 C++、用哪些 C++ 特性(折中与禁用项)](../../vol1-fundamentals/04-when-to-use-cpp.md) - [语言选择原则:性能 vs 可维护性的真实取舍](../../vol1-fundamentals/05-language-choice-performance-vs-maintainability.md) -- [C++一定导致代码膨胀嘛?](../../vol6-performance/06-evaluating-performance-and-size.md) +- [C++一定导致代码膨胀嘛?](./01-zero-overhead-abstraction.md) ## Chapter 1 - 构建工具链 @@ -39,7 +39,7 @@ description: '' ## Chapter 2 - 零开销抽象 - [零开销抽象](./01-zero-overhead-abstraction.md) -- [内联与编译器优化](../../vol6-performance/02-inline-and-compiler-optimization.md) +- [内联与编译器优化](../../vol6-performance/ch04-tuning-by-bottleneck/04-04-inline-devirt-compiler.md) - [CRTP VS 运行时多态,你们知道吗?](./04-crtp-vs-runtime-polymorphism.md) ## Chapter 3 - 内存与对象管理 diff --git a/scripts/tags.py b/scripts/tags.py index 8f8088365..831b69bce 100644 --- a/scripts/tags.py +++ b/scripts/tags.py @@ -39,7 +39,7 @@ '函数对象', 'std_function', 'std_invoke', 'Ranges', # Concurrency - 'atomic', 'memory_order', 'mutex', '无锁', + 'atomic', 'memory_order', 'mutex', '无锁', '并发', # Embedded specific '嵌入式', '单片机', '外设管理', '寄存器', '链接器',