diff --git a/README.md b/README.md index cd880aa4f..17c0e322b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-99%25-green.svg) 494/500 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-89%25-green.svg) 494/552 docs translated ## 这是什么项目 diff --git a/code/volumn_codes/vol6-performance/ch00/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch00/CMakeLists.txt new file mode 100644 index 000000000..be8a1e495 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch00/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch00_performance_mindset LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ch00-01 命题验证:vector+二分 vs set+find,同为 O(log n) 的缓存效应对比 +add_executable(vector_vs_set vector_vs_set.cpp) +target_compile_options(vector_vs_set PRIVATE -Wall -Wextra -Wpedantic -O2) diff --git a/code/volumn_codes/vol6-performance/ch00/README.md b/code/volumn_codes/vol6-performance/ch00/README.md new file mode 100644 index 000000000..af1cb45b9 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch00/README.md @@ -0,0 +1,24 @@ +# vol6 ch00 · 性能思维 — 代码示例 + +对应文章:`documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md` + +## vector_vs_set + +验证本卷开篇命题 **efficiency ≠ performance**:`std::vector` + `std::lower_bound`(二分查找)与 `std::set::find` 都是 $O(\log n)$,但在真实硬件上,当 N 超出缓存后,连续内存的 `vector` 把节点分散的 `set` 甩开好几倍。 + +### 构建 + +```bash +# 直接编译(最快) +g++ -O2 -std=c++17 vector_vs_set.cpp -o vector_vs_set +./vector_vs_set + +# 或用 CMake +cmake -B build && cmake --build build && ./build/vector_vs_set +``` + +### 怎么读结果 + +关心**趋势**(N 增大后 `set/vector` 比值上升)和**命题**(同复杂度差几倍),不要把某个具体倍数当普适结论。绝对数字随 CPU / 编译器 / libc++ 实现而变。 + +代码里几处防失真细节(`volatile global_sink` 防死代码消除、全部命中消除偏差、多轮取中位数压离群值)是 vol6 ch01 *Benchmark 方法论* 的伏笔,文章里逐条有讲。 diff --git a/code/volumn_codes/vol6-performance/ch00/vector_vs_set.cpp b/code/volumn_codes/vol6-performance/ch00/vector_vs_set.cpp new file mode 100644 index 000000000..cb3424c6b --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch00/vector_vs_set.cpp @@ -0,0 +1,69 @@ +// vector_vs_set.cpp —— vol6 ch00-01 命题验证 +// 同为 O(log n) 的查找,缓存效应能差多少? +// +// 编译:g++ -O2 -std=c++17 vector_vs_set.cpp -o vector_vs_set +// 或: cmake -B build && cmake --build build && ./build/vector_vs_set +#include +#include +#include +#include +#include +#include +#include + +using Clock = std::chrono::steady_clock; + +/// 多轮取中位数,把离群值压下去(ch01 测量方法论的伏笔) +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; // 每个 N 查 200 万次,摊薄单次噪声 + constexpr int trials = 5; // 跑 5 轮取中位数 + volatile std::int64_t global_sink = 0; // 防止整段循环被死代码消除(DCE) + + printf("%-10s %18s %18s %10s\n", "N", "vector(ns/q)", "set(ns/q)", "set/vector"); + printf("------------------------------------------------------------\n"); + 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; // 偶数、稀疏 + std::vector sorted = keys; + std::sort(sorted.begin(), sorted.end()); // vector 二分用 + std::set sset(keys.begin(), keys.end()); // set 红黑树 + + // 全部命中(查存在的 key),消除「找不到」走不同路径的偏差 + std::vector toFind(queries); + 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; + } + const 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 (防死代码消除)\n", (long long)global_sink); +} diff --git a/code/volumn_codes/vol6-performance/ch01/CMakeLists.txt b/code/volumn_codes/vol6-performance/ch01/CMakeLists.txt new file mode 100644 index 000000000..c63ba9046 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch01/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.20) +project(vol6_ch01_benchmark_methodology CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# FetchContent 拉 Google Benchmark —— reader 不用预装,clone 仓库就能跑 +include(FetchContent) +FetchContent_Declare(benchmark + GIT_REPOSITORY https://github.com/google/benchmark.git + GIT_TAG v1.9.5) +# 关掉 benchmark 自己的测试目标(注意 flag 名是 BENCHMARK_ENABLE_TESTING, +# 不是 BENCHMARK_ENABLE_TESTS——写错会去 build 它的内部测试、缺 gtest 就炸) +set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE) +set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(benchmark) + +# ch01-02 最小完整例子:push_back + DoNotOptimize/ClobberMemory + 参数扫描 + 重复聚合 +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 -Wpedantic) diff --git a/code/volumn_codes/vol6-performance/ch01/README.md b/code/volumn_codes/vol6-performance/ch01/README.md new file mode 100644 index 000000000..92d910c56 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch01/README.md @@ -0,0 +1,28 @@ +# vol6 ch01 · Benchmark 方法论 — 代码示例 + +对应文章:`documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md` + +## push_bench + +ch01-02 的最小完整 GBench 例子:测 `std::vector::push_back`,演示四件套——`DoNotOptimize`/`ClobberMemory` 防 DCE、`Range` 参数扫描、`Repetitions`+`ReportAggregatesOnly` 重复聚合、`UseRealTime` 墙钟计时。 + +### 构建(任选其一) + +```bash +# 1) 系统装了 GBench(Arch: pacman -S benchmark;macOS: brew install google-benchmark) +g++ -O2 -std=c++17 push_bench.cpp -o push_bench -lbenchmark -lpthread +./push_bench + +# 2) CMake + FetchContent(免预装,首次会 clone + build benchmark,几分钟) +cmake -B build && cmake --build build && ./build/push_bench +``` + +### 怎么读输出 + +- `Time` 是墙钟(`UseRealTime`)、`CPU` 是 CPU 时间;聚合行的 `Iterations` 列显示的是重复次数(3),不是每轮真实迭代数(被聚合隐藏了)。 +- 盯 `cv`(coefficient of variation = `stddev/mean`):<1% 很稳;>5% 这轮测得不可信,查噪声源(ch01-03)。 +- 时间随 N 涨才是 `push_back` 真实的样子;如果你测出来不随 N 变,多半是被 DCE 删成空壳了(缺 `DoNotOptimize`)。 + +### 一个会踩的坑 + +`BENCHMARK_ENABLE_TESTING OFF`(关 benchmark 自己的测试目标)flag 名是 `BENCHMARK_ENABLE_TESTING`,不是 `BENCHMARK_ENABLE_TESTS`。写错的话 `cmake --build` 会因为 benchmark 内部测试目标失败而整体非零退出,即便你的 `push_bench` 已经编过了——看输出里有没有 `Built target push_bench`,有就直接 `./build/push_bench` 跑。 diff --git a/code/volumn_codes/vol6-performance/ch01/perf-env-check.sh b/code/volumn_codes/vol6-performance/ch01/perf-env-check.sh new file mode 100644 index 000000000..227f0221f --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch01/perf-env-check.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# perf-env-check.sh —— vol6 ch01-03 可信 microbenchmark 环境体检(只查不改) +# +# 用法:bash perf-env-check.sh +# 它不修改任何东西(改 governor / 关 Turbo 要 sudo,留给你自己决定),只把发现的问题打印出来。 +# 对应文章:documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md +set -u + +ok() { printf " ✓ %s\n" "$1"; } +warn() { printf " ⚠ %s — %s\n" "$1" "$2"; } + +echo "=== CPU governor(应=performance;否则 DVFS 让数字浮动)===" +if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then + g=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor) + [ "$g" = performance ] && ok "governor=performance" \ + || warn "governor=$g" "sudo cpupower frequency-set -g performance" +else + echo " · 无 cpufreq 接口(可能已锁频或虚拟化屏蔽),跳过" +fi + +echo "=== Turbo Boost(Intel pstate)===" +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 已关" \ + || warn "Turbo 开着(no_turbo=$nt)" "冷热启动数字会差,BIOS 或这里关" +else + echo " · 非 intel_pstate,跳过(可在 BIOS 设)" +fi + +echo "=== perf_event_paranoid(<=1 才好采样)===" +p=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null || echo 3) +[ "$p" -le 1 ] 2>/dev/null && ok "perf_event_paranoid=$p" \ + || warn "perf_event_paranoid=$p" "sudo sysctl -w kernel.perf_event_paranoid=1" + +echo "=== NUMA 拓扑(多 socket 才在意)===" +if command -v numactl >/dev/null 2>&1; then + numactl --hardware 2>/dev/null | grep -E "^available|node [0-9]+ (cpus|size)" | head -6 +else + warn "无 numactl" "apt install numactl / pacman -S numactl;多 socket 机器必装" +fi + +echo "=== CPU 亲和性(应明确绑一个核,别让 OS 晃)===" +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)" +echo " → 想绑核:taskset -c <某个核> ./bench (别挑 0 号核,常被系统中断占用)" + +echo "=== ASLR(微架构精细测时应关)===" +aslr=$(cat /proc/sys/kernel/randomize_va_space 2>/dev/null || echo "?") +echo " randomize_va_space=$aslr (2=全开;精细 icache/分支测时: sudo sysctl -w kernel.randomize_va_space=0)" + +echo "" +echo "体检完毕。microbenchmark A/B 场景:把上面 ⚠ 尽量清掉;" +echo "评估生产性能时:这些噪声源反而要保留(复刻真实),见 ch01-05。" diff --git a/code/volumn_codes/vol6-performance/ch01/push_bench.cpp b/code/volumn_codes/vol6-performance/ch01/push_bench.cpp new file mode 100644 index 000000000..f9ecb6643 --- /dev/null +++ b/code/volumn_codes/vol6-performance/ch01/push_bench.cpp @@ -0,0 +1,32 @@ +// push_bench.cpp —— vol6 ch01-02 最小完整 GBench 例子 +// 测 std::vector::push_back,演示 DoNotOptimize/ClobberMemory + 参数扫描 + 重复聚合 +// +// 构建(任选其一): +// 1) 系统装了 GBench(Arch: pacman -S benchmark): +// g++ -O2 -std=c++17 push_bench.cpp -o push_bench -lbenchmark -lpthread +// 2) CMake + FetchContent(reader 免预装,见同目录 CMakeLists.txt): +// cmake -B build && cmake --build build && ./build/push_bench +#include +#include + +// push_back 带 DoNotOptimize+ClobberMemory:防 DCE + 强制写落内存 +static void BM_PushBack(benchmark::State& state) { + for (auto _ : state) { // 计时循环:框架控制迭代次数 + std::vector v; + for (int i = 0; i < state.range(0); ++i) { + v.push_back(i); + benchmark::DoNotOptimize(v.data()); // 防 DCE + 内存 barrier + } + benchmark::ClobberMemory(); // 确保写真正落内存 + } + state.SetComplexityN(state.range(0)); // 告诉框架 big-O 的 N,自动拟合 +} + +BENCHMARK(BM_PushBack) + ->RangeMultiplier(2) + ->Range(8, 8 << 6) // 参数扫描:8,16,32,...,512 + ->UseRealTime() // 报墙钟时间,不是 CPU 时间 + ->Repetitions(3) // 跑 3 轮 + ->ReportAggregatesOnly(true); // 只报 mean/median/stddev/cv + +BENCHMARK_MAIN(); 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 new file mode 100644 index 000000000..e1d9fafc7 --- /dev/null +++ b/documents/vol6-performance/ch00-performance-mindset/01-efficiency-vs-performance.md @@ -0,0 +1,201 @@ +--- +title: "性能思维:efficiency 与 performance 不是一回事" +description: "从一段同是 O(log n) 的查找代码出发,讲透 efficiency(算法复杂度)与 performance(硬件上的真实表现)的鸿沟,立下本卷两条铁律与 Amdahl 天花板,附平台/库选型决策一页" +chapter: 0 +order: 1 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 实战 +difficulty: intermediate +platform: host +reading_time_minutes: 16 +cpp_standard: [14, 17] +prerequisites: + - "C++ 容器与算法基础(std::vector / std::set / std::lower_bound)" +related: + - "为什么 microbenchmark 会骗你" + - "存储层次与延迟阶梯" + - "ASan 工具家族与内存安全" +--- + +# 性能思维:efficiency 与 performance 不是一回事 + +## 一个让很多人不舒服的事实 + +我们先看一段几乎人人都写过的代码:在一个集合里查一个数。最自然的两个选择——把数据放进 `std::set`,或者放进排好序的 `std::vector` 然后用 `std::lower_bound` 做二分查找。两边查一次都是 $O(\log n)$,复杂度完全一样,教科书讲到这一步通常就停了,告诉你「随便选,差不多」。 + +但如果你真的去测,会发现事情没那么简单。下面这张表是我在自己机器上跑出来的(WSL2/Linux,2,000,000 次随机命中查询,5 次取中位数,完整代码见本章「代码示例」一节): + +| 元素数 N | `vector`+二分 (ns/次) | `set`.find (ns/次) | `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×** | + +N 超过 6 万以后,`set` 比 `vector` 慢了 3 到 5 倍;而在 N 很小(1024)的时候,`set` 反而还略快一点点。两边复杂度一模一样,凭什么差这么多?更尴尬的是,如果你的面试题答案是「二者等价」,你在真实代码里就会莫名其妙地交出一个慢几倍的服务。 + +答案就是这一整卷要反复回到的命题:**efficiency(效率)和 performance(性能)不是一回事。** + +## efficiency 与 performance,到底差在哪 + +先把两个词分清楚,本卷后面所有讨论都建立在这个区分上: + +- **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 倍的真实差距抹平了。 + +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 随机访问)。 + +## 铁律一:先正确,再快 + +CS:APP 第 5 章开篇第一句就把这条钉死了(原文我们直接引,因为它没法说得更准): + +> 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. +> +> (写程序的首要目标,是让它在所有可能条件下都算对。一个跑得飞快但结果错误的程序,没有任何用处。) + +听起来像废话,但放进性能优化这个场景,它有一条非常具体的、会被反复违反的推论:**带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。** UB 在 `-O2` 下不会乖乖待着——编译器会基于「这段 UB 永远不会发生」做激进优化,结果常常是:你的 benchmark 测的早就不是一个真实的函数调用,而是一个被优化得面目全非的空壳,你对着它得出一堆精美又全错的结论,还信心满满地拿去「优化」生产代码。 + +所以本卷把 sanitizer 工具链(ASan / UBSan / MSan / TSan)放在 ch00 当地基,而不是当成「调试工具混进了性能卷」。没有 sanitizer 兜底的正确性,性能数字一律不可信;并发代码没过 TSan 的数字,同样不可信。我们后面会专门讲这条链路,这里你只需要记住结论——**先正确,再快,这条没有商量余地。** + +## 铁律二:先测量,再优化 + +你的直觉,在微架构这个层面,经常是错的。「数据紧凑一点、少做一次分配」这种大方向的直觉当然对;但一到「该不该无分支」「循环要不要手展」「虚函数到底慢不慢」这种指令级细节,直觉就开始追不上硬件了。这不是贬低谁,这是现代 CPU 的复杂度决定的——一颗当代 CPU 里有乱序执行、分支预测、缓存层级、预取器、SIMD、微操作融合……你的「感觉」追不上这些。 + +举几个本卷后面会逐个用实测拆给你看的案例: + +- 你以为无分支(branchless)更快,结果现代分支预测器把**可预测的**分支处理得几乎免费,你的 branchless 改写反而多塞了几条指令、引入了数据依赖,更慢; +- 你以为手写循环展开能提速,结果编译器在 `-O2` 早就展开过了,你再写一通只让代码更难读、icache 更差; +- 你以为虚函数调用很慢,结果编译器早就根据类型层次把它去虚拟化(devirtualize)成了直接调用,甚至内联了。 + +这些都不是假设,是后面 ch04 / ch06 的真实内容。结论只有一句话:**优化之前先 profile。** 火焰图上最宽的那个盒子,才是值得动手的地方;凭直觉改代码,大概率你在优化那 5% 的部分,而真正的瓶颈在另外 95% 里躺着睡大觉。 + +这条铁律直接引出了 ch01——Benchmark 方法论。那是本卷的**锚点章**:后面每一篇性能文章,开头都会回引它讲的那套测量规矩,就像 vol5 用 TSan 贯穿并发正确性一样。如果你时间只够读这一卷里的一篇文章,读 ch01。 + +## Amdahl:优化的天花板 + +在动手改任何代码之前,还得知道一条硬规矩——Amdahl 定律。它最早由 Gene Amdahl 在 1967 年提出,一句话说清加速比的上限在哪: + +$$S = \frac{1}{(1 - p) + \dfrac{p}{N}}$$ + +其中 $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% 的串行代码,你拿它一点办法都没有,再怎么把并行部分往死里压榨,串行部分岿然不动。 + +推论极其重要:**优化要打在「占比大的串行部分」上。** 这就是 profile 驱动优化的理论根据——不是「哪里看起来慢就改哪里」,而是「先测出哪里占比最大,再改那里」。Amdahl 定律的完整推导、它和 Gustafson 定律的对比(固定问题规模的强扩展 vs 随核数扩大问题规模的弱扩展),vol5 第 0 章已经讲透了,我们这里只取「优化天花板」这一个视角,不重复造轮子。 + +## 别忽略最大的那个杠杆:平台和库 + +讲完两条铁律和一条天花板,在进入微观优化之前,先退一步看宏观。Agner Fog 在他的优化手册第 1 卷里专门用了一整章(ch2 *Choosing the optimal platform*)讲「选择最优平台」,顺序是这样的:硬件平台 → 处理器型号 → 操作系统 → 编程语言 → 编译器 → 函数库 → UI 框架。他的态度很直白:**这些高层决策对性能的影响,通常比你后面抠的任何一条微优化都大。** + +我们把它压成一页,几条关键判断: + +- **先干掉最浪费的工具/框架。** 选了一个处处堆分配、层层虚函数的重量级框架,后面再怎么抠 cacheline、抠对齐都补不回来。Agner 引用了 Wirth's law 这条半开玩笑的格言:软件变慢的速度,比硬件变快的速度还快。这两件事同时发生的时候,用户体验就是原地踏步甚至倒退。 +- **数据结构选型 > 微优化。** 我们开头那个 `vector` vs `set` 的例子就是明证——换一个缓存友好的容器,5 倍收益到手,比你手动展开循环、抠位运算实在得多。先把这种「结构性」的收益吃掉,再谈指令级优化。 +- **嵌入式向:host 和 MCU 的资源差好几个数量级。** 在 host 上无所谓的一次堆分配,到 STM32 上可能就是一次碎片化灾难;host 上验证过的优化,原理在 MCU 上往往同样成立,但 MCU 的可用内存小太多,host 上根本不在意的开销,在 MCU 上可能就是性能或容量的硬瓶颈。本卷代码示例以 host 为主,涉及嵌入式向的话题会单独点名,带你去 vol8 嵌入式域看完整故事。 + +平台选型的完整清单 Agner 写了十几页,我们这里压成一页,因为它不是本卷的技术主体——但它常常是被忽略的、收益最大的那一刀。很多人性能优化做不动,不是微架构没吃透,是一开始工具/库就选错了。 + +## 代码示例:亲手验证 vector vs set + +口说无凭,我们把开头那张表的代码摆出来。这是一个**自包含**的基准测试,不依赖任何外部库,标准 C++17 就能编(后面 ch01 会正式引入 Google Benchmark 那一套工业级方法论,这里先用最朴素的 `std::chrono`,免得在本卷第一篇就堆概念)。 + +```cpp +// vector_vs_set.cpp —— 同为 O(log n) 的查找,缓存效应能差多少? +// 编译: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; // 每个 N 查 200 万次,摊薄单次噪声 + constexpr int trials = 5; // 跑 5 轮取中位数,后续 ch01 会讲为什么 + volatile std::int64_t global_sink = 0; // 防止整段循环被死代码消除 + + 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; // 偶数、稀疏 + std::vector sorted = keys; + std::sort(sorted.begin(), sorted.end()); // vector 二分用 + std::set sset(keys.begin(), keys.end()); // set 红黑树 + + std::vector toFind(queries); // 全部命中,消除「找不到」偏差 + 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 (防死代码消除)\n", (long long)global_sink); +} +``` + +我们挑几处关键的讲一下为什么这么写,因为这里的每一个细节都是后面 ch01 测量方法论的伏笔。 + +第一,**结果必须被消费**。`acc` 累加命中数,最后喂给 `volatile global_sink`。没有这一步,编译器会发现「这个循环算出来的结果没人用」,直接把整段循环删掉(DCE),你测的是一个空程序。`volatile` 强制每次真的写内存,堵死这条优化路径。 + +第二,**全部查命中**。`toFind` 里的数都是集合里真实存在的 key。如果不控制,「查得到」和「查不到」走的路径长短不一样,会污染结果。我们要比的是纯查找成本,不是命中率。 + +第三,**多轮取中位数**。单次跑出来 50ns 还是 80ns,可能只是这一轮 CPU 被调度走了、或者 Turbo 频率没爬上来。跑 5 轮取中位数,把这种离群值压下去。这一条看似琐碎,但它是 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。 + +> ⚠️ **别把这张表当普适结论。** 你在不同 CPU、不同编译器、不同 libc++ 实现上跑出的绝对数字会变,`set` 略快的那一行可能消失,也可能变得更明显。我们关心的是**趋势**(连续内存 vs 节点分散)和**命题**(同复杂度差几倍),不是某个具体倍数。把别人的性能数字直接抄进自己的工程,是另一种「猜」。 + +性能问题永远从「数据在硬件上怎么流」出发,不从「我觉得」出发——至于怎么把「我觉得」换成「我测过」,那是 ch01 的事。 + +## 参考资源 + +- Bryant, R. E., O'Hallaron, D. R. 《Computer Systems: A Programmer's Perspective》第 5 章 *Optimizing Program Performance*(「先正确再快」、优化分层、Amdahl 视角) +- Bakhvalov, D. 《Performance Analysis and Tuning on Modern CPUs》第 1 章 *Introduction*(complexity analysis 的局限、InsertionSort vs QuickSort 案例) +- Fog, A. 《Optimizing Software in C++》第 1–2 章(Why software is often slow / Choosing the optimal platform) +- cppreference:[`std::lower_bound`](https://zh.cppreference.com/w/cpp/algorithm/lower_bound)、[`std::set::find`](https://zh.cppreference.com/w/cpp/container/set/find) +- Amdahl 定律原始出处:Gene Amdahl, *Validity of the single processor approach to achieving large scale computing capabilities*, AFIPS 1967 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 new file mode 100644 index 000000000..f8b9ecd16 --- /dev/null +++ b/documents/vol6-performance/ch00-performance-mindset/02-from-correctness-to-performance.md @@ -0,0 +1,97 @@ +--- +title: "从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基" +description: "承接 ch00-01 的「先正确再快」,拆开「带 UB 的性能数字为什么不可信」,讲清 sanitizer(ASan/UBSan/MSan/TSan)为什么排在性能卷 ch00 当地基而不是被当成调试工具,再把叙事交接到 sanitizer 三篇与 ch01" +chapter: 0 +order: 2 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 内存安全 +difficulty: intermediate +platform: host +reading_time_minutes: 8 +cpp_standard: [11] +prerequisites: + - "性能思维:efficiency 与 performance 不是一回事" +related: + - "ASan 工具家族与内存安全" + - "为什么 microbenchmark 会骗你" +--- + +# 从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基 + +## 上一篇留下的那句话 + +ch00-01 立铁律一「先正确,再快」的时候,我们甩了一句很重的话:**带着未定义行为(UB)去谈性能数字,等于在地基没打牢的工地上盖楼。** 这一篇就把这句话拆开——不是吓唬你,是让你看清楚,UB 到底是怎么把一个性能数字变成谎话的。看懂这一步,你就能理解一件可能让你困惑的事:为什么一本讲性能优化的卷,第一章不讲 cache、不讲 SIMD,却摆了一整套 sanitizer 工具链。 + +## UB 是怎么把性能数字变成谎话的 + +C++ 标准把一类行为划为「未定义」:有符号整数溢出、越界访问、解引用空指针、读未初始化的变量、数据竞争……标准对这些程序的行为**什么都不保证**——不是说它会「出错」,是说它**什么都可能发生**,包括你最不想的那种「看起来正常跑、结果全错」。 + +可怕的地方在于:编译器是**主动利用**这条规则的。`-O2` 下,编译器会合法地假设「这段代码不会触发 UB」,然后基于这个假设做激进优化。这是标准允许的,也是现代编译器日常在做的事。落到性能测量上,后果主要有三类,每一类都够让你的数字作废: + +**第一类:你要测的代码整段被删掉。** 你的 benchmark 算出一个结果却没人用,编译器判定它是死代码,直接消除(DCE),你美滋滋地测出一个 0.3 纳秒——测的是空。这是 ch00-01 那个 benchmark 里 `volatile global_sink` 为什么必须存在的原因。 + +**第二类:编译器替你的循环下了结论。** 一个有符号循环变量如果可能溢出,那就是 UB;编译器可以假设它不会溢出,进而推出循环上界、把整段循环 hoist 成常量、或者干脆折叠掉。你以为测了 N 次迭代,实际一次都没跑。 + +**第三类,也是最阴险的一类:你以为在测 A,其实踩在 B 的内存上。** 越界写、use-after-free、未初始化读——这些不会让程序崩,只会让你的 benchmark 读写到「别的变量」的内存。你测出来「这段代码 50 纳秒」,其实那 50 纳秒里有一半在污染隔壁的数据结构,数字毫无意义,而且程序还在「正常」跑。 + +举一个最小、也最经典的例子,让你有体感。下面这个函数判断「`x+1` 是否大于 `x`」: + +```cpp +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 的未定义行为页面也拿它当标准反例。 + +口说无凭,我在自己机器上(GCC 16.1.1)实跑了一遍,给函数喂同一个值 `INT_MAX`: + +```text +# -O2(允许 UB 假设:整段折叠成 return true) +$ g++ -O2 ub_fold.cpp -o ub_fold && ./ub_fold 2147483647 +f(INT_MAX) = 1 + +# -O2 -fwrapv(强制有符号溢出回绕成定义行为:老实算) +$ 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` 来做,比换优化级别更干净。 + +所以一个带 UB 的 benchmark,在 `-O2` 下测出来的「快了 30%」,很可能只是「编译器基于 UB 假设把你的循环删掉了一半」省出来的 30%——这 30% 到了生产环境(不同的编译选项、不同的输入分布)会瞬间蒸发,甚至变成「慢了」。你拿着这个假数字去做架构决策,就是在沙子上盖楼。 + +## 所以 sanitizer 不是「调试工具」,是性能测量的可信度地基 + +理清了上面这一层,你就明白为什么这卷把 sanitizer 排在 ch00,而不是像别处那样把它们归到「调试技巧」里顺手提一句。四兄弟各管一类「会让性能数字作废」的 UB: + +- **ASan**(AddressSanitizer)管内存错——越界、use-after-free、重复释放。这些正是上面「第三类阴险情况」的元凶,它们让你的 benchmark 偷偷踩到别人的内存。 +- **UBSan**(UndefinedBehaviorSanitizer)管语言层 UB——有符号溢出、空指针解引用、错类型 cast、非法 shift。这些正是上面「第一类、第二类」的元凶,它们让编译器有理由把你测的代码改写成空壳。 +- **MSan**(MemorySanitizer)管未初始化读——读到的是「随机值」,你测的本质是个随机数发生器。 +- **TSan**(ThreadSanitizer)管并发数据竞争——而数据竞争本身就是 UB。这一条在并发性能测量里特别要命:你的多线程 benchmark 没过 TSan,那串漂亮的吞吐数字毫无意义。TSan 的机制 vol5 已经讲透了,我们这里只取「它为什么是性能地基」这一个视角——并发性能数字的可信度,前提是没有数据竞争。 + +把这四条放在一起,结论非常硬:**一个没过 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 采样」两条线,以及「调试时开满」和「常驻生产」的分层防御。 + +读完这三篇,你就具备了「让性能数字可信」的完整工具链。它们是性能卷的地基,不是配角。 + +## 然后呢:从「测的是真东西」到「测得准」 + +sanitizer 解决的是**前提**——保证你测的确实是你要测的那段逻辑,而不是一个被 UB 改写过的空壳、或踩在别人内存上的噪声。但「测的是真东西」只是第一步,一个真实的性能数字本身**还是一个随机变量**:CPU 频率在飘、线程被调度走、缓存冷热在变、页表在按需建……同一个函数跑两遍,数字会不一样。 + +「测的是真东西」加上「数字是随机变量」这两条加起来,就引出了 ch01——Benchmark 方法论。那一章是本卷的锚点,专门讲怎么把一个随机变量测成一个可信的结论、怎么对比两个数字才算数。换句话说:ch00 给你一把「让数字不作假」的尺子,sanitizer 三篇是这把尺子的校准源;ch01 接过去讲,怎么用这把校准过的尺子量出真东西。 + +## 参考资源 + +- cppreference:[未定义行为](https://zh.cppreference.com/w/cpp/language/ub) +- GCC 文档:`-fstrict-overflow` / `-fwrapv`(`man gcc` 或 gcc.gnu.org/onlinedocs/) +- 本卷 sanitizer 三篇(见上「这章的 sanitizer 三篇」一节) +- Bryant, R. E., O'Hallaron, D. R. 《Computer Systems: A Programmer's Perspective》第 5 章(「先正确再快」的前提) diff --git a/documents/vol6-performance/ch00-performance-mindset/index.md b/documents/vol6-performance/ch00-performance-mindset/index.md new file mode 100644 index 000000000..4325c89df --- /dev/null +++ b/documents/vol6-performance/ch00-performance-mindset/index.md @@ -0,0 +1,19 @@ +--- +title: "性能思维与正确性前置" +description: "建立性能优化的思维方式:efficiency 与 performance 的区别、两条铁律、Amdahl 天花板,以及 sanitizer 作为正确性地基" +--- + +# 性能思维与正确性前置 + +笔者认为,性能优化是 C++ 工程能力里最容易「自信地犯错」的一块。微架构的复杂度远远跑在人的直觉前面,凭感觉改代码,十有八九是在优化那 5% 的部分,而真正的瓶颈在另外 95% 里躺着。所以本卷的第一件事不是教你任何一条优化技巧,而是先把思维方式立起来——**先正确,再快;先测量,再优化**。 + +这一章我们做三件事:用一段同是 $O(\log n)$ 的查找代码,讲透 **efficiency(算法复杂度)和 performance(硬件上的真实表现)为什么不是一回事**;立下贯穿全卷的两条铁律和 Amdahl 天花板;并把 sanitizer 工具链安顿成「正确性地基」——没有正确性兜底的性能数字,一律不可信。 + +本章是整卷的命题入口,ch01 的 Benchmark 方法论会从这里接过去,把「我觉得」换成「我测过」。 + +## 本章内容 + + + 性能思维:efficiency 与 performance 不是一回事 + 从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基 + 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 new file mode 100644 index 000000000..0bddbfd56 --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/01-why-microbenchmarks-lie.md @@ -0,0 +1,108 @@ +--- +title: "为什么 microbenchmark 会骗你" +description: "microbenchmark 是性能优化最常用的工具,也是最容易骗人的——拆开三类典型欺骗(编译器优化成空、缓存假热、噪声淹没信号),点破「让微基准干净的正是不让它真实的那只手」,给出 Google Benchmark + nanobench 的选型" +chapter: 1 +order: 1 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 测试 +difficulty: intermediate +platform: host +reading_time_minutes: 10 +cpp_standard: [14, 17] +prerequisites: + - "性能思维:efficiency 与 performance 不是一回事" + - "从「先正确」到「再快」:为什么 sanitizer 是性能卷的地基" +related: + - "怎么写一个可信的 microbenchmark" + - "测量陷阱与环境就绪" + - "统计与报告" +--- + +# 为什么 microbenchmark 会骗你 + +## 性能不是一个布尔量 + +功能做对了就是对了,跑通了就是跑通了——这是布尔量。但是很遗憾——**性能不是**。性能是一个**分布**:同一段代码、同一份输入,你跑两遍,数字会不一样;跑十遍,你能画出一张散点图。Bakhvalov 在《Performance Analysis and Tuning on Modern CPUs》第 2 章开篇就点明这件事:解压一个 zip 文件,你每次得到的结果一模一样(可复现);但要你「复现一模一样的性能曲线」,做不到。 + +这件事决定了本卷的整个方法论走向:既然性能是随机变量,那「测性能」就不是「跑一次记个数」,而是「采样一个分布、做统计推断」。(换而言之,我们需要的是平均的统计量) + +这一章尝试做的事情,就是讲怎么把这件难事做对,它是全卷的锚点章——后面每一篇性能文章,开头都会回引它讲的那套规矩,就像 vol5 用 TSan 贯穿并发正确性一样。 + +但在这之前,我们得先认清一个让人不舒服的事实:你手上最趁手的那个工具——microbenchmark(微基准)——恰恰是最容易骗你的。这一篇就把它的骗术拆开。 + +## 第一集,是编译器把你的 benchmark 优化成空,或者是出乎您对比意料的代码 + +最经典也最尴尬的一种。你写了一段看上去在干活的循环,举个例子,您说您要度量标准库字符串构造和析构的速度如何,从而推广您自己写的字符串的时候—— + +```cpp +// 这段「测试 string 创建性能」——其实什么都没测 +void foo() { + for (int i = 0; i < 1000; ++i) { + std::string s("hi"); // 创建了,但从来没被读过 + } +} +``` + +`s` 创建出来,谁也没用。编译器一看,这是死代码,删了——整个循环连同 `string` 的构造,全部消除(DCE)。我在自己机器上(GCC 16.1.1)实跑确认:这段代码 `-O2` 下 `foo` 的汇编就一条 `ret`,整个循环蒸发;同一段代码 `-O0` 下是 89 行汇编,循环和 `string` 构造全在。你美滋滋地跑完,记下「0.3 纳秒」,心想这函数真快。你测的是「什么都不做」。 + +> 笔者这里也是提醒您,做profile,除了一顿对自己的perf图高兴之外,请务必看看汇编,汇编是机器码的一个直接映射,读它大致就知道你的机器将会被执行什么。 + +这个坑我们在 ch00-02 讲 UB 的时候侧面碰过(那个 `(x+1)>x` 被折叠成常量的例子),这里换一个更直接的性能版本:**只要你的 benchmark 算出来的结果没人消费,编译器就有合法理由把它整段删掉。** 这不是编译器「bug」,这是允许的优化。 + +怎么办?强制让结果「被用到」——业内叫 `DoNotOptimize` 一类的辅助函数(底层用一点内联汇编把结果钉到内存或寄存器上),Google Benchmark 和 JMH(Java 的 `Blackhole.consume`)都内置了。它的语义和坑不少(ch00-01 那个 `volatile global_sink` 是手写版的近似),下一篇 ch01-02 会专门拆。 + +## 第二集:性能测量,缓存总是热的,真实负载不是 + +microbenchmark 的标准做法是:把一个函数反复跑成千上万次,取平均。问题就出在「反复」上——同一个函数反复跑同一份(或相似的)数据,那些数据从头到尾都赖在 L1/L2 cache 里,一次都不 miss。你测出来 2 纳秒,是这个「热缓存」条件下的 2 纳秒。 + +而真实负载里,这个函数被调用一次的间隔里,系统跑了一堆别的东西,cache 早被换成别人的数据了。它再被调用时,要从 L3 甚至 DRAM 现取——2 纳秒变 50、变 200。这就是 micro 和 macro 那道著名的数量级鸿沟。 + +更阴险的版本,Bakhvalov 在第 2 章末尾点出来:一个在空闲机器上跑的 microbenchmark,**把全部 DRAM 和 cache 都据为己有**。于是你对比两个实现,A 更快但更吃内存,B 稍慢但省内存——在空闲系统的 microbenchmark 里,A 赢得很漂亮,因为它有吃内存的资本。可一旦上了生产,旁边挤着一堆邻居进程抢 DRAM,A 那些多占的内存被挤到磁盘 swap,性能断崖式下跌,结论整个翻转。**让 A 看起来更快的,正是 microbenchmark 那个不真实的「全场没人跟我抢」的前提。** + +这条推论极其重要,它是 ch00-01「efficiency ≠ performance」在测量层的镜像:别用 microbenchmark 的结论去给生产性能背书。micro 测的是「这个函数在理想条件下能跑多快」,不是「用户实际会体验到多快」。 + +## 第三集:系统噪声把信号淹了 + +哪怕你躲过了前两集(结果也消费了、缓存也认了它热), 还有一类骗术来自系统本身:现代 CPU 和 OS 有一堆「为了提升性能」的特性,它们的副作用是让测量结果不稳定。 + +- **动态调频(DFS / Turbo)**:CPU 会根据温度和负载临时提频或降频。「冷」处理器跑第一轮时可能飙到 turbo 频率,跑第二轮时已经热了、降回基频——同一份代码两次跑,差出百分之几到十几。笔记本上尤其严重(散热有限)。 +- **文件系统缓存**:第一次跑要读盘,第二次数据都在缓存里了,第二次快得多。你以为第二次的优化见效了,其实只是盘不用再读了。 +- **内存布局偏置**:这是最 spooky 的一种。Mytkowicz 等人 2009 年那篇经典论文证明,**UNIX 环境变量的总字节数、链接器输入的目标文件顺序**,都会改变程序的性能,而且方向不可预测。你什么代码都没改,只改了 `LINK_ORDER`,数字就动了。 +- **甚至你用来监控的工具本身**:你在另一个核跑个 `top` 看 CPU 占用,那个核被激活、调频,可能连带干扰到跑 benchmark 的那个核。Bakhvalov 特意提醒:连开个任务管理器都能影响测量。 + +这三骗加起来,指向同一个结论:**一次性的、手搓的测量,几乎没有意义。** 你测到的那个数,是「这段代码在这个编译选项下在这台机器在这个温度这个频率这个内存布局这个缓存状态下」的数,换一个条件就变。 + +完事了?骗你的没完事,**让 micro 干净的, 正是让它骗人的**。讲完三骗, 值得退一步看一个更深的矛盾。为了让 microbenchmark 给出干净、稳定、可对比的数字,我们本能地会去**消除噪声**:锁死 CPU 频率、绑核、关超线程、预热缓存、跑够多轮取中位数。这些都没错,本卷后面会逐个教你怎么做。 + +但你要清醒地知道:**消除噪声的过程,就是让测量偏离真实环境的过程。** 你锁死了频率,可用户的手机从来不锁频;你绑死了一个核,可生产环境线程被调度来调度去;你把缓存预热到最佳,可真实调用 cache 冷得像冰。所以 Bakhvalov 那句忠告很关键:**评估真实性能时,不要去消除系统的非确定性,而要复刻目标环境。** + +有人拍桌子了:你这是自相矛盾!不是,这是两种不同的测量场景,要分开用: + +一方面,**microbenchmark**:做**相对比较**——同一个函数、两种实现、同一台机器、同一套控制条件,A 比 B 快多少。这时你要消除噪声,因为你要的是「干净的信噪比」。它的产出是「这个改动方向对不对」。而**生产测量 / 宏观 benchmark**:做**绝对判断**——用户实际感受到多快、能不能扛过下个月的流量。这时你反而要保留噪声、复刻真实,然后**用统计方法**处理那个噪声。它的产出是「这个数字扛不扛得住」。 + +一个常见的、灾难性的错误,是拿 micro 的相对结论去推 macro 的绝对判断:「我这个函数在 micro 里快了 30%,所以上线后服务会快 30%」。大概率不会——那 30% 里有一部分是「空闲系统让出来的红利」,在生产里根本不存在。这两种测量是两套语言,不能直接换算。生产测量和 CI 回归的事,ch01-05 专门讲。 + +## 别手搓,用框架 + +理解了为什么会骗,工具选型就很清楚了:千万别拿 `std::chrono` 手搓一个循环就开测(本卷 ch00-01 的那个 `vector_vs_set` 是为了讲命题故意用最朴素写法,那个例外)。一个合格的 benchmark 框架要替你处理掉「结果被优化掉」「跑多少轮」「怎么统计」这些机械活,让你专注写「测什么」。C++ 生态里几个主流选项: + +| 框架 | 形态 | 防优化机制 | 统计输出 | 定位 | +|---|---|---|---|---| +| **Google Benchmark** | 静态库 | `DoNotOptimize` + `ClobberMemory` | mean / median / stdev,链式 API 最强 | **本卷主力** | +| **ankerl::nanobench** | 单头文件 | `doNotOptimizeAway` | ns/op + err%,**自带 IPC / branch miss%** | 轻量补充,讲微架构时即时反馈 | +| 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。 + +## 参考资源 + +- Bakhvalov, D. 《Performance Analysis and Tuning on Modern CPUs》第 2 章 *Measuring Performance*(噪声源、micro vs production、DCE 的 string 例子) +- 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(测量偏置:环境变量大小、链接顺序) +- ankerl::nanobench:[README](https://github.com/martinus/nanobench) diff --git a/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md b/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md new file mode 100644 index 000000000..7f4b507c0 --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/02-credible-microbenchmark.md @@ -0,0 +1,150 @@ +--- +title: "怎么写一个可信的 microbenchmark" +description: "从 ch01-01 的「骗术」走到「对策」:用 Google Benchmark 写一个不(那么)骗人的微基准,拆透 DoNotOptimize / ClobberMemory 的语义与坑、参数扫描、重复聚合、UseRealTime,配最小可运行例子和真实输出" +chapter: 1 +order: 2 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 测试 +difficulty: intermediate +platform: host +reading_time_minutes: 10 +cpp_standard: [14, 17] +prerequisites: + - "为什么 microbenchmark 会骗你" +related: + - "测量陷阱与环境就绪" + - "统计与报告" +--- + +# 怎么写一个可信的 microbenchmark + +## 上一篇甩下的问题 + +ch01-01 把 microbenchmark 那三套骗术摆完了——编译器把你优化成空、缓存假热、噪声淹信号。骗术讲完了,这一篇给解药。 + +解药其实只管第一套骗术(结果被优化掉),顺手把参数扫描、重复聚合、墙钟计时这几件马上要用的姿势做对。第三套(系统噪声)得靠 ch01-03 那份环境 checklist,分布怎么变成结论是 ch01-04 的事——这两件先放着,这一篇先把「测的对象是不是真东西」立住。 + +## 别自己写计时循环 + +你大概想这么干:一个 `for` 循环、`std::chrono::steady_clock` 掐表、跑完除一下。ch00-01 那个 `vector_vs_set` 就是这么写的——但那是**为了讲命题故意用最朴素写法**,别学。自己搓的计时循环,「该跑多少轮」「怎么算统计」「结果怎么不被优化掉」全得你自己管,而这几件事每一件都有坑。一个合格的 benchmark 框架替你把这三件机械活包了,你只管写「测什么」。本卷主力是 Google Benchmark(下面简称 GBench)。 + +看一个最小、但完整的例子,测 `std::vector::push_back`: + +```cpp +// push_bench.cpp —— GBench 最小完整例子 +#include +#include + +static void BM_PushBack(benchmark::State& state) { + for (auto _ : state) { // 计时循环:框架控制迭代次数 + std::vector v; + for (int i = 0; i < state.range(0); ++i) { + v.push_back(i); + benchmark::DoNotOptimize(v.data()); // 防 DCE + 内存 barrier + } + benchmark::ClobberMemory(); // 确保写真正落内存 + } + state.SetComplexityN(state.range(0)); // 告诉框架 big-O 的 N,自动拟合 +} + +BENCHMARK(BM_PushBack) + ->RangeMultiplier(2)->Range(8, 8 << 6) // 参数扫描:8,16,32,...,512 + ->UseRealTime() // 报墙钟时间,不是 CPU 时间 + ->Repetitions(3) // 跑 3 轮 + ->ReportAggregatesOnly(true); // 只报 mean/median/stddev/cv + +BENCHMARK_MAIN(); +``` + +我在自己机器上(GCC 16.1.1,GBench v1.9.5,FetchContent 拉的)实跑了一遍,输出长这样(截几行代表): + +```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 +``` + +这张表怎么读。`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` 没有意义,你对着一个噪声比信号还大的分布下结论,纯属自欺。 + +时间随 N 涨(8→44ns,64→105ns,256→242ns),这才是 `push_back`「随规模变贵」的真实样子。不是 ch01-01 那个被 DCE 删成一条 `ret` 的空壳。 + +## DoNotOptimize: 它救你,但救不到底 + +这一节是整篇最该讲透的,也是新手最爱用错的地方。把 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` 的结果要是编译期就能算出来,它可能被整个算掉,只剩一个常量。 + +听着矛盾,其实是分工:`DoNotOptimize` 防的是「整段循环因为结果没人用而被删」(`foo()` 那种);它**不防**「循环体内部被常量传播算穿」。所以写 benchmark 的时候,输入数据必须是**运行期产生**的——从随机数、从文件、从参数,不能是编译期常量。否则编译器一路算穿,`DoNotOptimize` 也救不了你。Bakhvalov 在 §2.6 也强调了这一句: **先确保「你想测的场景」在运行期真被执行了。**(这就回到了我上一节的提示了,麻烦看看汇编) + +`benchmark::ClobberMemory()` 是配套的另一件,强制把所有 pending 写真正落回内存。`push_back` 改了 `vector` 的内部状态(大小、可能的扩容),编译器要是判定「这个 `vector` 后面没人看」,某些边界条件下可能省掉一部分写。`ClobberMemory` 就是兜底那句「别省,真写」。常见的安全写法:热循环里每次写完目标数据 `DoNotOptimize` 钉一下地址,循环结束 `ClobberMemory` 兜底。 + +## 别只测一个 N + +`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 倍。不扫尺度,这种翻转你根本看不见。 + +顺手 `state.SetComplexityN(state.range(0))`,框架还能根据你扫出来的时间自动拟合一个 big-O,输出里多一栏 `Big O`,让你对着复杂度直觉核一遍。比手算斜率省事。 + +## 重复几轮,报中位数别报单次均值 + +ch01-01 讲过性能是分布,一次测量没意义。GBench 的对策是 `Repetitions(n)`:同一个 benchmark 跑 n 轮(每轮内部迭代数框架自己估),然后 `ReportAggregatesOnly(true)` 只输出 `mean` / `median` / `stddev` / `cv` 这几个聚合,不把每轮原始值刷满屏。 + +为什么强调**中位数**而不只看均值:`push_back` 偶尔会撞上一次扩容——那是合法的均摊成本,但相对均值它是个离群值,均值被这种长尾拉高,中位数岿然不动。ch01-04 会专门讲什么时候用中位数、什么时候用均值、怎么报置信区间,这里你先记住一句:报中位数 + cv,比只甩一个均值诚实得多。`ReportAggregatesOnly(true)` 还有个隐形好处——CI 里跑 benchmark 时,聚合输出更适合做趋势对比和回归检测(ch01-05 接这条线)。 + +还有个细节得提一句:`UseRealTime()`。GBench 默认报的是 **CPU 时间**,多线程场景下会把别的核上跑的也算进来,往往不是你要的「这段代码墙上跑了多久」。`UseRealTime()` 把报告改成墙钟。这条跟 ch00-02 讲的 `clock()` 陷阱是一脉相承的——`clock()` 测 CPU 时间多线程失真,`steady_clock` 测墙钟。单线程测无所谓,一旦你的 benchmark 起了多线程(或者你想对标用户感受到的延迟),就加 `UseRealTime()`。 + +## 怎么编译 + +两条路,挑一条。 + +**系统装了 GBench**(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 +``` + +注意链 `benchmark`(库)还是 `benchmark::benchmark_main`(自带 `main`):代码里写了 `BENCHMARK_MAIN()` 就链 `benchmark`;不想自己写 `main` 就链 `benchmark_main` 并删掉那行 `BENCHMARK_MAIN()`。 + +**用 CMake + FetchContent**(本卷代码示例走这条,reader 不用预装,clone 仓库就能跑): + +```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) # 关掉它自己的测试目标 +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) +``` + +> ⚠️ **一个我踩过的坑**:关 benchmark 自己的测试目标,flag 是 `BENCHMARK_ENABLE_TESTING`(不是 `BENCHMARK_ENABLE_TESTS`)。写错名字的话,FetchContent 会去 build benchmark 的内部测试,缺 gtest 配置就炸,即便你的 `push_bench` 本身已经编过了,`cmake --build` 也会因为兄弟目标失败而整体返回非零。看 `make` 输出里有没有 `Built target push_bench` —— 有就说明你的可执行文件成了,直接 `./build/push_bench` 跑就行。 + +## 参考资源 + +- Google Benchmark:[user_guide](https://github.com/google/benchmark/blob/main/docs/user_guide.md)(`DoNotOptimize` / `ClobberMemory` / `Range` / `UseRealTime` / `Repetitions` 各节,`DoNotOptimize` 的精确语义以这里的原文为准) +- Bakhvalov, D. 《Performance Analysis and Tuning on Modern CPUs》§2.6 *Microbenchmarks*(`foo()` 被 DCE 的例子、确保场景在运行期执行) +- 本卷 ch01-01「为什么 microbenchmark 会骗你」(三骗,本文是它的对策) 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 new file mode 100644 index 000000000..bb12813e3 --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/03-pitfalls-and-env.md @@ -0,0 +1,133 @@ +--- +title: "测量陷阱与环境就绪:16 条 checklist" +description: "ch01-01 骗术三(噪声)的具体对策——把 16 个会让性能数字失真的环境陷阱按频率/缓存/调度/工具分组,逐条给「失真原因 + 规避命令」,附 perf-env-check.sh 一键体检脚本" +chapter: 1 +order: 3 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 测试 +difficulty: intermediate +platform: host +reading_time_minutes: 8 +cpp_standard: [11, 17] +prerequisites: + - "怎么写一个可信的 microbenchmark" +related: + - "为什么 microbenchmark 会骗你" + - "统计与报告" +--- + +# 测量陷阱与环境就绪:16 条 checklist + +## 为什么需要一份 checklist + +ch01-01 讲了 microbenchmark 第三类欺骗——系统噪声淹没信号。那篇给的是「为什么会噪声」,这一篇给「具体怎么关」。下面 16 条是 Linux 上做可信 microbenchmark 最常踩的环境陷阱,每条都按「**陷阱 → 为什么失真 → 怎么规避**」给出,大多还配一条可以直接抄的命令。 + +但开讲之前,先重复一遍 ch01-01 那条最重要的边界:**这些消除噪声的手段,只在「做相对 A/B 比较」时该用。** 如果你要评估的是「用户实际感受到多快」,反而要**复刻**真实环境(保留噪声、保留 DFS、保留邻居进程),然后用统计方法处理它——那是 ch01-05 生产测量的活。这一页是给「我想干净地比较两个实现」的 microbenchmark 场景用的。 + +## 16 条陷阱 + +为了好记,按性质分四组。 + +### 第一组:频率与功耗(数字的最大波动源) + +| # | 陷阱 | 为什么失真 | 规避 | +|---|---|---|---| +| 1 | **CPU 调频(DVFS)** | governor=ondemand 时频率随风浮动,GBench 启动还会打 `***WARNING*** CPU scaling enabled` | `sudo cpupower frequency-set -g performance` 锁到最高频 | +| 2 | **Turbo Boost** | 单核突发高频,冷启动和稳态不同,温度一上来就降 | BIOS 关 Turbo;或锁频;测稳态先热够(warmup说的是这个) | + +这两条不解决,同一份代码两次跑差 10% 都正常。笔记本上尤其严重(散热有限,Turbo 频繁进出)。 + +### 第二组:缓存、内存与地址翻译 + +| # | 陷阱 | 为什么失真 | 规避 | +|---|---|---|---| +| 3 | **冷启动与稳态** | 首次访问未命中缓存(走 DRAM),之后命中缓存,差 10–100 倍 | 框架的估测阶段已经预热过;想测冷启动,用 `posix_fadvise(fd, POSIX_FADV_DONTNEED)` 把页丢弃 | +| 4 | **缺页(page fault)** | 首次碰页触发软缺页(微秒级),放大单次操作几十倍 | `mlockall(MCL_CURRENT \| MCL_FUTURE)` 锁页;或先把每一页都触碰一遍 | +| 9 | **NUMA** | 多 socket 机器跨节点访存延迟翻 2–4 倍,「内存带宽」测成了「互联带宽」 | `numactl --cpunodebind=0 --membind=0 ./bench` 把线程和内存绑同一节点 | +| 15 | **ASLR / 代码布局** | PIE 基址不同,指令缓存(icache)和分支预测器对齐抖动 10–20%;还影响「内存布局偏置」(Mytkowicz 2009) | 微架构精细测时加 `-no-pie`;想消除布局偏置用随机交错(random interleaving) | + +### 第三组:调度与干扰 + +| # | 陷阱 | 为什么失真 | 规避 | +|---|---|---|---| +| 5 | **上下文切换 / 中断** | 被调度走,样本长尾离群 | `taskset -c <核>` 绑核;统计用中位数(别用均值) | +| 8 | **绑核(CPU pinning)** | 线程跨核迁移,缓存每次冷掉 | `taskset -c 3 ./bench`(挑一个核,别让 OS 晃) | +| 10 | **SMT / 超线程争用** | 同物理核另一线程吃执行单元 | BIOS 关超线程;或 taskset 只绑物理核(每两个兄弟核用一个) | +| 11 | **定时器分辨率** | `clock()` 测纳秒级全是噪声(分辨率不够) | `std::chrono::steady_clock`(见 ch00-02);或 `perf stat` 看 cycle | + +### 第四组:工具姿势与统计 + +| # | 陷阱 | 为什么失真 | 规避 | +|---|---|---|---| +| 6 | **死代码被优化掉** | 结果没用 → DCE 消掉循环(见 ch01-01 的 `foo()`) | `DoNotOptimize` / `doNotOptimizeAway`;注意它**不防表达式自身被算掉**(ch01-02) | +| 7 | **没开 release + 调试信息** | `-O0` 的性能数字无意义;纯 `-O2` 没 `-g` 又做不了源码标注 | 统一用 `RelWithDebInfo`(`-O2 -g`);profiling 再加 `-fno-omit-frame-pointer`(否则栈断,火焰图炸) | +| 12 | **均值与中位数** | 微基准右偏(长尾),均值被拉高 | 报中位数 + IQR;GBench `Repetitions` + `ReportAggregatesOnly`(ch01-02) | +| 13 | **样本太少** | 置信区间宽到分不清 A/B | ≥30 个样本;报 95% CI;A/B 用 Mann-Whitney U 检验判显著性(ch01-04) | +| 14 | **多次运行不稳** | 环境没固定,跨次运行漂移 | ≥3 次取最稳;`perf stat -r 5` 自带重复 | +| 16 | **PEBS 滑步(skid)** | 采样事件会「滑」几条指令才落到真正的指令上 | 用带 `:pp` / `:ppp`(精确 IP)后缀的事件,如 `MEM_LOAD_RETIRED.L3_MISS:ppp` | + +16 条看着多,核心就一句:**把能控的全控住(频率、核、内存布局),把结果该消费的真消费(`DoNotOptimize`),把数字当分布看(中位数 + 多轮重复)。** 剩下的看场景——做 micro 就尽量全做,做生产评估就别做(复刻真实)。 + +## 一键体检:`perf-env-check.sh` + +每次开测前手动查一遍这些项很烦,我们把它压成一个脚本。它**只检查、不修改**(改 governor、关 Turbo 那种要 sudo 的操作,留给你自己决定),把发现的问题打印出来: + +```bash +#!/usr/bin/env bash +# perf-env-check.sh —— 可信 microbenchmark 环境体检(只查不改) +set -u + +ok() { printf " ✓ %s\n" "$1"; } +warn() { printf " ⚠ %s\n" "$1"; } + +echo "=== CPU governor(应=performance)===" +g=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null) +[ "$g" = performance ] && ok "governor=performance" || warn "governor=$g(DVFS 会浮动)。修: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 已关" || warn "Turbo 开着(no_turbo=$nt),冷热启动数字会差" +else + echo " · 非 intel_pstate 或无该接口,跳过(可在 BIOS 设)" +fi + +echo "=== perf_event_paranoid(<=1 才好采样)===" +p=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null) +[ "${p:-3}" -le 1 ] && ok "perf_event_paranoid=$p" || warn "=$p(perf 受限)。修:sudo sysctl -w kernel.perf_event_paranoid=1" + +echo "=== NUMA 拓扑(多 socket 才在意)===" +command -v numactl >/dev/null && numactl --hardware 2>/dev/null | grep -E "^available|node [0-9]+ cpus" | head -4 || warn "无 numactl" + +echo "=== CPU 亲和性(应明确绑一个核,别让 OS 晃)===" +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) → 没绑核就 taskset -c <某个核> ./bench(别挑 0 号核,常被系统中断占用)" + +echo "=== ASLR(微架构精细测时应关)===" +aslr=$(cat /proc/sys/kernel/randomize_va_space 2>/dev/null) +echo " randomize_va_space=$aslr(2=全开;精细 icache/分支测时可 sudo sysctl -w kernel.randomize_va_space=0)" +``` + +把它存成 `perf-env-check.sh`,跑一下 `bash perf-env-check.sh` 就知道环境还差什么。完整的脚本也在 `code/volumn_codes/vol6-performance/ch01/` 下。 + +## 哪些场景该做哪些 + +| 场景 | 该做的(消除噪声) | 不该做的 | +|---|---|---| +| **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 条命令更重要。 + +## 参考资源 + +- easyperf.net:*How to get consistent results when benchmarking on Linux*(这份 checklist 的直接来源之一) +- Brendan Gregg:[Linux Performance](https://www.brendangregg.com/linuxperf.html)(perf / 任务放置 / NUMA) +- Bakhvalov, D. 《Performance Analysis and Tuning on Modern CPUs》§2.1 *Noise In Modern Systems* +- 本卷 ch01-01(噪声的分类)、ch01-02(`DoNotOptimize` / `Repetitions` / `UseRealTime`) 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 new file mode 100644 index 000000000..530d234c5 --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/04-statistics-and-reporting.md @@ -0,0 +1,88 @@ +--- +title: "统计与报告:把分布变成结论" +description: "测出来一组数之后怎么办——为什么性能数据报中位数+置信区间而非单均值、为什么几乎不正态所以 Mann-Whitney 比 t-test 靠谱、A/B 比较的正确姿势,以及为什么不能用 micro 结论给 macro 背书" +chapter: 1 +order: 4 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 测试 +difficulty: intermediate +platform: host +reading_time_minutes: 7 +cpp_standard: [11, 17] +prerequisites: + - "怎么写一个可信的 microbenchmark" + - "测量陷阱与环境就绪:16 条 checklist" +related: + - "生产测量与 CI 性能回归" +--- + +# 统计与报告:把分布变成结论 + +## 测出来一堆数,然后呢 + +ch01-01 到 ch01-03 让你能拿到一组「不是空壳、姿势对、环境干净」的性能数字。但你拿到的从来不是一个数,而是一组数——一个分布。ch01-01 开篇就说了:性能不是布尔量,是分布。这一篇就回答最后一个问题:这组分布,怎么变成一个可信的结论——「A 比 B 快 X%,而且这个快是真的、不是噪声」? + +这件事的本质是统计推断。听起来吓人,但你要掌握的其实就几条,而且每条都对应一个你会真实犯的错误。 + +## 报什么、不报什么 + +先立死规矩: + +- **必报**:中位数(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 占优——结论就反了。 + +## 为什么中位数比均值靠谱 + +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 提醒:双峰分布不是噪声,是信号,说明你该把两种场景拆开分别测,而不是糊在一起取中位数。 + +## 假设检验:t-test 还是 Mann-Whitney U + +「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。 + +实践建议:做 A/B 显著性判断,**默认用 Mann-Whitney U**(非参数,稳);只有当你先做了正态性检验、确认数据确实近似正态时,才用 t-test。Python 的 `scipy.stats.mannwhitneyu` / R 的 `wilcox.test` 都能直接算。 + +## A/B 比较的正确姿势 + +把上面几条拼起来,一个可信的「A 比 B 快」结论,要满足: + +1. **同一环境**:同一台机器、同一 governor、同一负载,只改你要比的那一处。别在两台机器上分别测 A 和 B。 +2. **同一二进制**:理想情况是一个二进制里编译期开关切换 A/B,避免不同编译带来的布局偏置。 +3. **多次重复**:A 测 N 次,B 测 N 次(N ≥ 30 最好),各得一个分布。 +4. **报效应量,不只 p 值**:p 值只告诉你「差别是不是真的」,不告诉你「差别多大」。一个「p<0.05,快了 0.3%」的结论,统计显著但工程上没意义。要报「快 12%(95% CI [10%, 14%], p<0.01)」这种完整的说法。 +5. **跑不止一轮**:跨天、跨 warmup 状态再跑一轮确认,防止这一轮环境漂了。 + +这一套在 CI 里自动化的事(ch01-05 讲),就是持续地做这种 A/B,自动判回归。 + +## micro vs macro:最后再说一遍 + +因为它最致命,值得这一章每一篇都拎出来讲一次。 + +**禁止用 microbenchmark 的结论去给生产性能背书。** 一个 microbenchmark 里测出 IPC=2、cache 全命中的函数,在真实负载里完全可能因为 cache miss 变成 IPC=0.3。这不是「micro 不准」,是 micro 测的本来就是「理想条件下能跑多快」,而生产里没有理想条件——这两件事是两种语言,不能直接换算。 + +Bakhvalov §2.4 的例子里,「分布对比」是在同一类场景下做的(都 micro 或都 macro)。跨场景对比时,你要么把 micro 的结论限定在「这个函数的相对改进方向」上,要么干脆去做宏观测量(生产 telemetry / 宏观负载 benchmark)。后者是 ch01-05 的事。 + +## 参考资源 + +- Bakhvalov, D. 《Performance Analysis and Tuning on Modern CPUs》§2.4 *Manual Performance Testing*(分布对比、双峰、假设检验、「性能数据几乎不正态」的原话) +- Feitelson, D. G. 《Workload Modeling for Computer Systems Performance Evaluation》(Bakhvalov 推荐的性能统计专门参考,模态分布、偏度等) +- 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) +- 本卷 ch01-01(micro vs macro 的根)、ch01-02(`ReportAggregatesOnly` 的 `mean`/`median`/`stddev`/`cv`) 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 new file mode 100644 index 000000000..2ec3f481b --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/05-production-and-ci.md @@ -0,0 +1,89 @@ +--- +title: "生产测量与 CI 性能回归检测" +description: "把测量从开发机搬到生产和 CI——生产 telemetry 如何在 <1% 开销下采真实用户数据、为什么简单阈值挡不住性能回归、MongoDB 的变点检测思路,以及一个 CI 性能系统该自动化的五步" +chapter: 1 +order: 5 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 测试 +difficulty: intermediate +platform: host +reading_time_minutes: 6 +cpp_standard: [11, 17] +prerequisites: + - "统计与报告:把分布变成结论" +related: + - "为什么 microbenchmark 会骗你" + - "Benchmark 方法论参考卡" +--- + +# 生产测量与 CI 性能回归检测 + +## 从开发机到生产 + +ch01-01 到 ch01-04 讲的是**开发机上**做 microbenchmark A/B 比较——消除噪声、报中位数、做假设检验。那套规矩回答的是「我这个改动方向对不对」。但还有两个问题它答不了: + +1. **上线后用户到底快了没有?** microbenchmark 的结论不能直接推给生产(ch01-01 反复强调),你得在生产环境里测。 +2. **怎么防止性能随着版本累积悄悄退化?** 大项目变更飞快,性能回归 bug 会以惊人的速度漏进生产代码——光靠人每次盯,迟早漏。 + +这一篇就回答这两个:生产测量怎么做、CI 里怎么自动检测性能回归。素材主要来自 Bakhvalov《Performance Analysis and Tuning on Modern CPUs》§2.2 和 §2.3。 + +## 生产测量:接受噪声,用统计 + +生产环境和开发机最大的差别是:**你不能消除噪声,也不该试**。ch01-03 那套「锁频、绑核、关 Turbo」的活,在生产里全是毒药——你消除了噪声,测的就不是用户会经历的东西。生产测量的原则正好反过来:**复刻真实 + 用统计方法处理噪声**。 + +几个要点: + +- **共享基础设施的干扰**。公有云上,你的服务跑在和别人共享的同一台物理机上(虚拟化 / 容器),邻居进程会以不可预测的方式影响你的性能。你在开发机上复刻不出这种干扰。 +- **telemetry:在真实用户设备上采样**。大厂越来越流行在用户端埋点采集性能数据。Bakhvalov 举了 Netflix 的 Icarus telemetry 服务——跑在散布全球的几千台设备上,帮工程师看到「真实用户怎么感知性能」,这种数据在实验室里复刻不出来。 +- **开销必须极低**。生产 profiling 的原则是「极低开销是首要」。Ren 等人 2010 年那篇论文的原话大意:在跑真实流量的数据中心机器上做持续 profiling,**可接受的总开销在 1% 以下**。所以只能用 lightweight 方法(低频采样、只抽一部分机器、短时间窗)。 +- **统计方法处理分位数指标**。生产性能看的不是「平均多快」,而是「p90 / p99 延迟多少」——长尾才是用户体验的杀手。LinkedIn 的做法(Bakhvalov 引 Liu 等人 2019)是用统计方法在生产环境做 A/B 测试,比较这些分位数指标。 + +一句话:**生产测量 = 保留噪声 + 统计推断**,和 micro 的「消除噪声 + 干净对比」是两套语言。 + +## CI 性能回归检测:为什么简单阈值不灵 + +产品迭代飞快,性能回归会持续漏进来。靠什么挡? + +**第一反应:人眼看图。** 别。人会迅速失去焦点,尤其在噪声大的图上——Bakhvalov §2.3 那张图里,8 月 5 号的那次回落人眼能抓到,但后续几个小回归大概率被漏。而且这是每天都要做的、无聊的活,不适合人。 + +**第二反应:定一个阈值,「跌幅超过 X% 就报警」。** 听起来合理,实际两个硬伤: + +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 手动调阈值。 + +另一个思路(Bakhvalov 引 Alam 等人 2019 的 AutoPerf):用**硬件性能计数器**(PMC,见 ch02/ch03)给每个函数建一个「性能指纹」,改动后的版本如果指纹偏离了基线,就标记异常。这种法子能抓到一些藏在并行程序里的复杂性能 bug。 + +## 一个 CI 性能系统该自动化的事 + +不管底层用阈值还是变点检测,Bakhvalov §2.3 给了一个典型 CI 性能系统该自动化的五步,很实在: + +1. **搭建被测系统**(Setup system under test) +2. **跑负载**(Run workload) +3. **报结果**(Report) +4. **判断性能有没有变**(Decide if performance has changed) +5. **可视化**(Visualize) + +外加几条要求:支持自动和人工两种提交方式、结果可重复、发现回归要**及时**开 ticket——趁代码还热、作者还没切到下一个任务,回归最容易被修;拖两周,作者都忘了改过啥,修起来事倍功半。 + +## 把这一章串起来 + +到这里,ch01 的闭环就齐了: + +- ch01-01~04:**microbenchmark** 在开发机上做 A/B,消除噪声、报中位数、做假设检验。回答「改动方向对不对」。 +- ch01-05(本文):**生产测量 + CI 回归**。生产复刻真实噪声 + 统计分位数;CI 用变点检测自动抓回归。回答「上线后真的快了吗、有没有悄悄退化」。 + +两套不能混:别拿 micro 的数字给生产背书,也别在生产环境里用 micro 的「消除噪声」那套。它们是测量光谱的两端,中间的过渡靠 macro benchmark(代表真实负载但受控),那是更后面章节的事。 + +## 参考资源 + +- 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 均出于此) +- Chromium LUCI 性能 dashboard 文档 +- 本卷 ch01-01(micro vs macro 边界)、ch01-04(统计方法) diff --git a/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md b/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md new file mode 100644 index 000000000..24a35c8ec --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/06-methodology-reference.md @@ -0,0 +1,112 @@ +--- +title: "Benchmark 方法论参考卡" +description: "ch01 全章的速查页——后续每篇性能文章和 Lab 开头引用它。一张卡浓缩:环境就绪、可信 microbenchmark 写法、报告与比较、micro vs 生产/CI 的边界、perf 速查" +chapter: 1 +order: 6 +tags: + - host + - cpp-modern + - intermediate + - 优化 + - 测试 +difficulty: intermediate +platform: host +reading_time_minutes: 6 +cpp_standard: [11, 17] +prerequisites: + - "怎么写一个可信的 microbenchmark" +related: + - "为什么 microbenchmark 会骗你" + - "测量陷阱与环境就绪:16 条 checklist" + - "统计与报告:把分布变成结论" + - "生产测量与 CI 性能回归检测" +--- + +# Benchmark 方法论参考卡 + +> 这是 ch01 全章的**速查页**。本卷后续每一篇性能文章和性能 Lab,开头都会回引这一页讲的那套规矩——就像 vol5 用 TSan 贯穿并发正确性。这不是教程(教程是 ch01-01 到 ch01-05),是 reference card,贴墙上看的那种。 + +## §0 前提(一句话) + +**性能是随机变量,不是数。** 你测出来的一定是一个分布,要采样 + 统计推断,不能跑一次记个数。(详见 ch01-01) + +## §1 测量前:环境就绪(micro A/B 场景) + +| 必做 | 命令 / 做法 | +|---|---| +| 锁 CPU governor | `sudo cpupower frequency-set -g performance` | +| 关 Turbo | BIOS,或锁频 | +| 绑核 | `taskset -c <某个核> ./bench`(别挑 0 号) | +| NUMA 绑节点 | `numactl --cpunodebind=0 --membind=0 ./bench` | +| perf 可用 | `sudo sysctl -w kernel.perf_event_paranoid=1` | +| 编译选项 | `RelWithDebInfo`(`-O2 -g`)+ profiling 加 `-fno-omit-frame-pointer` | +| 体检 | `bash perf-env-check.sh`(见 ch01-03,只查不改) | + +> ⚠️ **这些只在 micro A/B 场景做**。评估生产性能时**全部不做**——要复刻真实(保留 DFS、邻居、ASLR),用统计处理噪声。见 ch01-05。 + +## §2 写可信 microbenchmark + +| 要点 | 做法 | +|---|---| +| 用框架,别手搓 | Google Benchmark 主力、nanobench 轻量补(讲微架构时即时反馈) | +| 防 DCE | `benchmark::DoNotOptimize(x)` 钉结果到内存/寄存器;**注意:不防 `x` 自身被常量传播算掉**,所以输入必须是运行期数据 | +| 强制写落内存 | `benchmark::ClobberMemory()` 兜底 | +| 扫参数 | `->RangeMultiplier(2)->Range(8, 8<<10)`;`state.SetComplexityN(...)` 自动拟合 big-O | +| 重复聚合 | `->Repetitions(3)->ReportAggregatesOnly(true)` 报 mean/median/stddev/cv | +| 墙钟 | `->UseRealTime()`(多线程必加) | + +详见 ch01-02(含完整可运行例子和真实输出)。 + +## §3 报告与比较 + +- **必报**:中位数、IQR 或 95% CI、cv、样本数、环境快照(内核 / CPU / governor / `perf_event_paranoid`)。 +- **禁报**:单次均值(性能数据右偏,长尾拉偏均值)。 +- **A/B**:同环境、同二进制(只改一处)、多次重复(N ≥ 30);假设检验**默认 Mann-Whitney U**(非参数,性能数据几乎不正态),先验正态才用 t-test。 +- **报效应量**:「快 12%(95% CI [10%, 14%], p<0.01)」这种完整说法,不只甩一个 p 值。统计显著 ≠ 工程有意义。 +- **双峰分布是信号不是噪声**:混了两种行为(cache hit/miss、锁竞争),拆开分别测。 + +详见 ch01-04。 + +## §4 micro vs 生产/CI(边界,不许混) + +| 场景 | 做什么 | 产出 | +|---|---|---| +| **micro A/B** | 消除噪声,干净对比两个实现 | 「改动方向对不对」 | +| **生产测量** | 复刻真实噪声,telemetry 采分位数(p90/p99),统计 A/B | 「用户实际快了吗」 | +| **CI 回归** | 变点检测(E-Divisive)/ PMC 指纹(AutoPerf),自动开 ticket | 「有没有悄悄退化」 | + +**禁跨场景换算**:micro 的 30% 提升不会等比例带到生产。见 ch01-01、ch01-05。 + +## §5 vol6 文章 / Lab 引用规范 + +- 每篇性能文章、每个性能 Lab,开头声明「本文遵循 ch01 测量方法论」。 +- 报性能数字时,附**环境快照** + **统计量**(中位数 / cv / 重复次数),不报单次裸值。 +- 涉及 A/B 时,用 §3 那套(同环境同二进制 + Mann-Whitney + 效应量)。 + +## §6 perf 速查 + +```bash +# 基础计数(体检:看 IPC、cache miss、branch miss) +perf stat -r 5 ./bench +perf stat -e cycles,instructions,cache-misses,branch-misses ./bench + +# 采样 profile(找热点;务必 -fno-omit-frame-pointer 或用 dwarf 解栈) +perf record -F 99 -g --call-graph dwarf -- ./bench +perf report # 交互式看 +perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg # 火焰图 + +# 微架构归因(ch03 详谈) +toplev -l3 taskset -c 0 ./bench # TMAM 四桶下钻,需 pmu-tools +``` + +火焰图、TMAM、`toplev` 的完整工作流是 ch03(归因方法论)的活,这里只给入口。 + +## 参考资源(教程正文) + +- ch01-01「为什么 microbenchmark 会骗你」 +- ch01-02「怎么写一个可信的 microbenchmark」 +- ch01-03「测量陷阱与环境就绪:16 条 checklist」 +- ch01-04「统计与报告:把分布变成结论」 +- ch01-05「生产测量与 CI 性能回归检测」 +- Bakhvalov, D. 《Performance Analysis and Tuning on Modern CPUs》第 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/vol6-performance/ch01-benchmark-methodology/index.md b/documents/vol6-performance/ch01-benchmark-methodology/index.md new file mode 100644 index 000000000..938a0afea --- /dev/null +++ b/documents/vol6-performance/ch01-benchmark-methodology/index.md @@ -0,0 +1,23 @@ +--- +title: "Benchmark 方法论" +description: "vol6 全卷锚点章:从「microbenchmark 为什么会骗你」出发,建立测量、统计、生产与 CI 回归的完整方法论,后续每篇性能文章都回引这一章" +--- + +# Benchmark 方法论 + +这是整卷的**锚点章**。ch00 立下了「先正确、再快」和「先测量、再优化」两条铁律,但「先测量」这三个字背后藏着一整门学问:性能不是一个布尔量,而是一个分布;一次性的手搓测量几乎没有意义;microbenchmark 这个最趁手的工具,恰恰也最会骗人。 + +这一章把「测得准」这件事彻底拆开:先认清 microbenchmark 的三类欺骗,再学怎么写一个不骗人的微基准(`DoNotOptimize` 的语义、参数扫描、重复聚合),然后一份 16 条环境就绪 checklist 把噪声源逐个关掉,接着用统计方法把分布变成可信的结论,最后讲怎么把测量搬进生产和 CI 里持续守护性能。后面每一篇性能文章,开头都会回引这一章的规矩——就像 vol5 用 TSan 贯穿并发正确性一样。 + +如果你只读这一卷的少数几篇,这一章应该占大半。 + +## 本章内容 + + + 为什么 microbenchmark 会骗你 + 怎么写一个可信的 microbenchmark + 测量陷阱与环境就绪:16 条 checklist + 统计与报告:把分布变成结论 + 生产测量与 CI 性能回归检测 + Benchmark 方法论参考卡 + diff --git a/documents/vol6-performance/index.md b/documents/vol6-performance/index.md index 207b4b9ad..938ff32b2 100644 --- a/documents/vol6-performance/index.md +++ b/documents/vol6-performance/index.md @@ -1,6 +1,6 @@ --- title: "卷六:性能优化" -description: "CPU 缓存、SIMD、汇编阅读、优化模式" +description: "从测量方法论到 CPU 微架构,从按瓶颈部位优化到 C++ 抽象的性能成本" platform: host tags: - cpp-modern @@ -10,19 +10,21 @@ tags: # 卷六:性能优化 -> 状态:部分内容已有(待重写) +性能优化是 C++ 工程能力里最容易「自信地犯错」的一块——微架构的复杂度远远跑在人的直觉前面。本卷的脊柱是一条:**先正确(正确性地基)→ 先测量(Benchmark 方法论锚点)→ 按瓶颈部位归因与优化(TMA 四桶)→ 落到 C++ 抽象的性能成本**。每个主题都走「C++ 代码切入 → 下沉硬件/方法论 → 回到 C++ 怎么改」的环路。 -## 概述 +一句话总命题贯穿全卷:**efficiency(算法复杂度)≠ performance(硬件上的真实表现)。** 别只看 big-O,要看数据在硬件上怎么流。 -本卷覆盖 C++ 性能优化。 +> 本卷正在按这套骨架系统化重写,部分历史散篇(内联、AVX、体积评估、sanitizer 三篇)在归位中,暂列在下方导航。 -## 现有文章(待重写为通用内容) +## 章节导航 + ch00 · 性能思维与正确性前置 + ch01 · Benchmark 方法论【全卷锚点】 内联与编译器优化 - 性能与大小评估 - AVX/AVX2 深入 + AVX/AVX2 深度介绍 + 性能与体积评估 ASan 工具家族与内存安全 - Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 - Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE + Valgrind 与 ASan 对照:JIT 解释 vs 编译期插桩 + Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE