开了 `-O3`、加了线程,程序为什么还是不快?高性能 C++ 学习导读

时间:2026/05/08

性能优化最常见的起点是“感觉这里慢”:把编译选项改成 -O3,把循环拆给更多线程,再换一个内存池。结果可能没有提升,甚至因线程调度、缓存竞争和额外分配变得更慢。

原因是性能不是某个语法特性,而是一条因果链:算法做了多少工作,数据怎样进入缓存,编译器能否消除或向量化指令,任务能否并行,线程是否争用同一资源,硬件带宽是否已经饱和。优化其中一层,瓶颈可能仍在另一层。

这组笔记按“先保证正确和可测量,再优化单线程数据路径,最后扩大并行层级”的顺序组织。目标不是收集性能技巧,而是建立一套可以重复验证的过程:测量、提出假设、改变一个变量、验证正确性与性能、保留或回滚。


1. 为什么“更快”必须先变成可测量目标?

“接口响应太慢”至少要继续拆成:

  • 哪个输入规模和真实负载?
  • 吞吐量、平均延迟还是 P99?
  • CPU 时间、墙钟时间、内存峰值还是能耗?
  • 单线程慢,还是并发扩展性差?
  • 允许增加多少内存和代码复杂度?

同一种改动可能提高吞吐,却恶化尾延迟;缓存结果可能节省 CPU,却增加内存。没有目标与预算,就无法判断优化是否成功。

一个最小优化闭环是:

1
2
3
4
5
6
7
8
9
10
11
12
13
固定输入与环境

建立正确性测试和性能基线

profile 找到主要成本

提出一个可证伪假设

只修改一个关键变量

重复测量 + 验证结果一致

记录收益、代价和适用范围

不要从“哪种技术更高级”开始。火焰图显示时间在 I/O 时,SIMD 不会解决问题;内存带宽已经饱和时,增加线程只会争抢同一瓶颈。

2. 为什么正确性必须排在性能之前?

编译器和 CPU 只需要保证符合语言规则的程序。数据竞争、越界、有符号溢出和悬空指针属于未定义行为,Benchmark 跑出更快数字可能只是错误路径被优化掉。

并行归约还可能改变浮点加法顺序:

1
2
3
// 数学上像同一个求和,浮点舍入顺序却不同
(a + b) + c
a + (b + c)

因此每项优化都要定义可接受的结果误差、确定性和失败语义。性能测试不能只计时,还要验证输出与基线一致。

优化构建也不能丢掉诊断流程。测试阶段使用警告、ASan/UBSan/TSan 等工具,性能测量使用接近发布的 Release/RelWithDebInfo 构建;两类构建提供不同证据。

3. 推荐怎样阅读这 19 个主题?

第一阶段:先建立对象、内存和泛型基础

  1. C 指针与内存模型
  2. RAII 与智能指针
  3. 左右值、完美转发与引用折叠
  4. 模板与元编程
  5. 虚函数、多态与动态派发

这一阶段回答“数据在哪里、谁拥有、怎样传递、调用在编译期还是运行期确定”。若对象生命周期本身错误,后面的并行只会让缺陷更难复现。

第二阶段:优化单线程数据路径

  1. vector 容器优化
  2. std::pmr 与内存池
  3. 编译器优化与汇编视角
  4. 访存优化
  5. SIMD 与自动向量化
  6. Benchmark 与性能分析方法

常见有效顺序是:先减少算法工作量和分配,再改善连续访问与数据布局,最后帮助编译器向量化。汇编是验证生成代码的证据,不是要求所有业务都手写汇编。

第三阶段:从线程正确性走向扩展性

  1. C++11 多线程编程
  2. C++20/23 并发工具
  3. 原子操作、内存序与无锁基础
  4. 标准库并行算法与执行策略
  5. TBB 并行编程
  6. NUMA 与多路 CPU 访存

先掌握线程生命周期、互斥和条件变量,再进入 atomics 与无锁。无锁不等于无等待,也不天然比锁快;任务粒度、false sharing、内存带宽和 NUMA 远端访问都会决定扩展性。

第四阶段:专用硬件与完整案例

  1. CUDA 开启的 GPU 编程
  2. 流体仿真实战

GPU 适合高并行度、计算结构规则且能摊薄数据传输的工作负载。流体案例把数值迭代、ping-pong buffer、访存和 kernel 调度放进同一数据流,用于练习“整个系统”而非孤立 API。

4. 每篇笔记解决哪个核心问题?

编号 主题 主要问题
[01] C 指针与内存模型 地址、对象生命周期、数组退化与别名如何影响正确性和优化
[02] RAII 与智能指针 资源所有权怎样自动结束,避免异常路径泄漏
[03] 左右值与完美转发 move/forward 怎样保留值类别,何时真的减少复制
[04] 模板与元编程 怎样生成类型安全的零/低开销抽象,不让实例化失控
[05] vector 优化 扩容、连续内存、失效和并行分区怎样影响性能
[06] 编译器与汇编 优化器实际生成了什么,哪些假设阻碍消除与向量化
[07] 访存优化 cache line、局部性、AoS/SoA、blocking 与 false sharing
[08] C++11 多线程 线程、锁、条件变量、future 和任务队列的正确生命周期
[09] 原子与内存序 原子性与可见性如何建立 happens-before,无锁有哪些陷阱
[10] TBB 怎样用任务、并行循环、归约和流水线表达 CPU 并行
[11] CUDA kernel、线程层级、显存、shared memory 和 stream 数据流
[12] 流体仿真 怎样把数值模型映射为可验证的 CPU/GPU 迭代程序
[13] std::pmr 分配是否真是瓶颈,资源生命周期怎样匹配 arena/pool
[14] 动态派发 虚函数成本来自哪里,何时 variant/模板/组合更合适
[15] SIMD 循环依赖、别名、对齐和数据布局怎样影响自动向量化
[16] Benchmark/Profiling 如何避免错误基准,并用 profile 和硬件计数器定位瓶颈
[17] NUMA 线程与内存页如何分布,first touch 和亲和性为何影响扩展
[18] C++20/23 并发工具 jthread、停止令牌、barrier、latch、semaphore 如何收紧协议
[19] 标准并行算法 execution policy 的调用约束、归约顺序和后端支持边界

5. 时间有限时应该先抓哪条主干?

如果目标是改善普通 CPU 服务或计算程序,先读:

  1. C 指针与内存模型;
  2. RAII 与智能指针;
  3. vector 容器优化;
  4. 编译器优化与汇编视角;
  5. 访存优化;
  6. SIMD 与自动向量化;
  7. Benchmark 与性能分析;
  8. C++11 多线程。

这条路线先建立正确性和测量,再进入并行。原子、TBB、标准并行算法按实际工作负载继续;CUDA 和 NUMA 只有目标硬件/部署确实需要时再深入。

6. 为什么线程数增加后加速会变差?

并行程序的时间通常包含串行部分、并行计算、同步、调度和数据搬运:

1
2
总时间 = 串行工作 + 并行工作 / 有效并行度
+ 调度/同步 + 缓存与内存代价

Amdahl 定律提醒我们,固定问题规模下串行比例会限制理论加速;真实系统还会更早碰到内存带宽、锁、NUMA 或任务粒度。线程数等于 CPU 核心数不是万能答案,硬件并发数也只是提示。

测扩展性时应记录 1、2、4、…线程的吞吐/耗时,而不只比较单线程与“全部线程”。如果 4 线程后平台期出现,要寻找共享瓶颈,而不是继续增加 worker。

7. 怎样写一个不会欺骗自己的 Benchmark?

最小要求包括:

  • 使用 Release/RelWithDebInfo 并记录编译器、选项和 CPU;
  • 固定输入数据与规模,包含预热;
  • 重复多次并报告分布,不只取一次最小值;
  • 防止编译器消除未使用结果;
  • 把数据生成、I/O 和目标算法计时边界分清;
  • 控制线程数、CPU 频率、系统负载和亲和性;
  • 先验证优化前后输出一致;
  • 同时观察 CPU、cache miss、带宽等 profile 证据。

下面这种一次计时只能作为粗略探针,不是可靠微基准:

1
2
3
4
const auto start = std::chrono::steady_clock::now();
const auto result = compute(input);
const auto elapsed = std::chrono::steady_clock::now() - start;
consume(result); // 确保结果可观察,但仍需重复、预热与环境控制

成熟项目优先使用已有 Benchmark 框架和 profiler,不要每篇优化都重新手写不一致的计时器。

8. 平台与工具链边界需要提前知道什么?

  • Linux 常用 perf、NUMA 工具和多 socket 服务器;macOS 对应 Instruments、sample 等工具,命令与计数器能力不同;
  • NUMA 结论必须在真实多节点硬件验证,普通单 socket 笔记本无法证明远端内存收益;
  • CUDA 需要兼容的 NVIDIA GPU、驱动与 toolkit,macOS 环境通常不能直接运行现代 CUDA;
  • TBB 和标准并行算法后端支持取决于项目依赖、编译器和标准库;
  • SIMD 指令集随 x86/Arm 和编译目标不同,不能硬编码一套 intrinsic 当作跨平台方案。

无法在当前机器运行的示例应标明限制,并把验证移到对应 CI/远程硬件;不能用“代码能编译”代替性能结果。

9. 哪些优化最容易适得其反?

过早并行

任务太小,调度和同步成本超过计算;先优化算法与数据布局。

盲目内存池

分配不是热点时,pool 只增加状态、峰值内存和生命周期复杂度。

手写 SIMD

可能锁定指令集、阻碍编译器优化,并遗漏尾部/对齐。先读取向量化报告。

默认无锁更快

CAS 重试、cache line 争用和内存回收可能比 mutex 更昂贵,正确性也更难证明。

只看平均值

平均吞吐可能掩盖 P99、抖动和热降频。指标要匹配用户目标。

10. 怎样把每篇笔记读成可验证能力?

主题 最小实验 应记录的证据
vector 有/无 reserve、不同遍历布局 分配次数、耗时、结果一致性
cache/AoS/SoA 同算法不同布局 cache miss、带宽、耗时分布
SIMD 可向量化与有依赖循环 编译器报告、汇编、吞吐
并发 不同线程数分区求和 加速曲线、CPU、同步开销
false sharing 相邻/填充分线程计数 cache coherence 指标和耗时
NUMA first touch 与跨节点访问 节点分配、远端访问、扩展性
CUDA 包含/不包含传输的 kernel kernel 时间、传输时间、正确性误差

每个实验只改变一个核心因素,保留原始数据和构建命令。没有可复现记录的“快了 30%”很难进入工程决策。

11. 这套笔记不试图做什么?

它不是所有硬件和框架的 API 手册,也不承诺示例数字能在另一台机器复现。OpenMP、MPI、进阶无锁内存回收、PGO/LTO 和 sender/receiver 等主题值得后续按需求扩展,但不会为了“知识面完整”提前堆入主线。

核心目标是让已有 19 个主题形成同一方法:从对象与数据出发,用工具证明瓶颈,再选择最低复杂度的优化。

12. 总结

-O3 和增加线程仍不快,通常不是还缺一条神奇编译参数,而是没有确认成本位于算法、计算、内存、同步还是 I/O。

  1. 先定义指标、输入和正确性,再谈优化;
  2. 单线程的数据布局与访存往往决定并行上限;
  3. 编译器报告、profile、硬件计数器和扩展曲线提供不同证据;
  4. 原子、无锁、SIMD、NUMA 和 GPU 都有硬件与负载适用边界;
  5. 每次只验证一个假设,同时记录收益、复杂度和回归门槛。

建议从下一篇的指针与内存模型开始,同时为自己最关心的一段代码建立可重复基线。只有能稳定测出“现在多慢”,后面的优化才有方向。