开了 `-O3`、加了线程,程序为什么还是不快?高性能 C++ 学习导读
时间:2026/05/08
性能优化最常见的起点是“感觉这里慢”:把编译选项改成 -O3,把循环拆给更多线程,再换一个内存池。结果可能没有提升,甚至因线程调度、缓存竞争和额外分配变得更慢。
原因是性能不是某个语法特性,而是一条因果链:算法做了多少工作,数据怎样进入缓存,编译器能否消除或向量化指令,任务能否并行,线程是否争用同一资源,硬件带宽是否已经饱和。优化其中一层,瓶颈可能仍在另一层。
这组笔记按“先保证正确和可测量,再优化单线程数据路径,最后扩大并行层级”的顺序组织。目标不是收集性能技巧,而是建立一套可以重复验证的过程:测量、提出假设、改变一个变量、验证正确性与性能、保留或回滚。
1. 为什么“更快”必须先变成可测量目标?
“接口响应太慢”至少要继续拆成:
- 哪个输入规模和真实负载?
- 吞吐量、平均延迟还是 P99?
- CPU 时间、墙钟时间、内存峰值还是能耗?
- 单线程慢,还是并发扩展性差?
- 允许增加多少内存和代码复杂度?
同一种改动可能提高吞吐,却恶化尾延迟;缓存结果可能节省 CPU,却增加内存。没有目标与预算,就无法判断优化是否成功。
一个最小优化闭环是:
1 | 固定输入与环境 |
不要从“哪种技术更高级”开始。火焰图显示时间在 I/O 时,SIMD 不会解决问题;内存带宽已经饱和时,增加线程只会争抢同一瓶颈。
2. 为什么正确性必须排在性能之前?
编译器和 CPU 只需要保证符合语言规则的程序。数据竞争、越界、有符号溢出和悬空指针属于未定义行为,Benchmark 跑出更快数字可能只是错误路径被优化掉。
并行归约还可能改变浮点加法顺序:
1 | // 数学上像同一个求和,浮点舍入顺序却不同 |
因此每项优化都要定义可接受的结果误差、确定性和失败语义。性能测试不能只计时,还要验证输出与基线一致。
优化构建也不能丢掉诊断流程。测试阶段使用警告、ASan/UBSan/TSan 等工具,性能测量使用接近发布的 Release/RelWithDebInfo 构建;两类构建提供不同证据。
3. 推荐怎样阅读这 19 个主题?
第一阶段:先建立对象、内存和泛型基础
- C 指针与内存模型
- RAII 与智能指针
- 左右值、完美转发与引用折叠
- 模板与元编程
- 虚函数、多态与动态派发
这一阶段回答“数据在哪里、谁拥有、怎样传递、调用在编译期还是运行期确定”。若对象生命周期本身错误,后面的并行只会让缺陷更难复现。
第二阶段:优化单线程数据路径
vector容器优化std::pmr与内存池- 编译器优化与汇编视角
- 访存优化
- SIMD 与自动向量化
- Benchmark 与性能分析方法
常见有效顺序是:先减少算法工作量和分配,再改善连续访问与数据布局,最后帮助编译器向量化。汇编是验证生成代码的证据,不是要求所有业务都手写汇编。
第三阶段:从线程正确性走向扩展性
- C++11 多线程编程
- C++20/23 并发工具
- 原子操作、内存序与无锁基础
- 标准库并行算法与执行策略
- TBB 并行编程
- NUMA 与多路 CPU 访存
先掌握线程生命周期、互斥和条件变量,再进入 atomics 与无锁。无锁不等于无等待,也不天然比锁快;任务粒度、false sharing、内存带宽和 NUMA 远端访问都会决定扩展性。
第四阶段:专用硬件与完整案例
- CUDA 开启的 GPU 编程
- 流体仿真实战
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 服务或计算程序,先读:
- C 指针与内存模型;
- RAII 与智能指针;
vector容器优化;- 编译器优化与汇编视角;
- 访存优化;
- SIMD 与自动向量化;
- Benchmark 与性能分析;
- C++11 多线程。
这条路线先建立正确性和测量,再进入并行。原子、TBB、标准并行算法按实际工作负载继续;CUDA 和 NUMA 只有目标硬件/部署确实需要时再深入。
6. 为什么线程数增加后加速会变差?
并行程序的时间通常包含串行部分、并行计算、同步、调度和数据搬运:
1 | 总时间 = 串行工作 + 并行工作 / 有效并行度 |
Amdahl 定律提醒我们,固定问题规模下串行比例会限制理论加速;真实系统还会更早碰到内存带宽、锁、NUMA 或任务粒度。线程数等于 CPU 核心数不是万能答案,硬件并发数也只是提示。
测扩展性时应记录 1、2、4、…线程的吞吐/耗时,而不只比较单线程与“全部线程”。如果 4 线程后平台期出现,要寻找共享瓶颈,而不是继续增加 worker。
7. 怎样写一个不会欺骗自己的 Benchmark?
最小要求包括:
- 使用 Release/RelWithDebInfo 并记录编译器、选项和 CPU;
- 固定输入数据与规模,包含预热;
- 重复多次并报告分布,不只取一次最小值;
- 防止编译器消除未使用结果;
- 把数据生成、I/O 和目标算法计时边界分清;
- 控制线程数、CPU 频率、系统负载和亲和性;
- 先验证优化前后输出一致;
- 同时观察 CPU、cache miss、带宽等 profile 证据。
下面这种一次计时只能作为粗略探针,不是可靠微基准:
1 | const auto start = std::chrono::steady_clock::now(); |
成熟项目优先使用已有 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。
- 先定义指标、输入和正确性,再谈优化;
- 单线程的数据布局与访存往往决定并行上限;
- 编译器报告、profile、硬件计数器和扩展曲线提供不同证据;
- 原子、无锁、SIMD、NUMA 和 GPU 都有硬件与负载适用边界;
- 每次只验证一个假设,同时记录收益、复杂度和回归门槛。
建议从下一篇的指针与内存模型开始,同时为自己最关心的一段代码建立可重复基线。只有能稳定测出“现在多慢”,后面的优化才有方向。