C++ 写出高效代码的关键不在语法糖,而在理解内存层级与数据布局。一篇 2013 年的 C++ 性能文章给出的核心判断是,写 C++ 本身不保证快,快来自对硬件工作方式的理解。里面有一条反直觉的结论值得先记住,把 std::string 的构造挪出循环、复用同一个对象,在两百万次迭代里把执行时间从 0.36 秒降到 0.27 秒,收益接近四分之一。省下来的是内存分配的那一部分。那篇文章写于 2013 年,具体的微秒数字已经和今天的机器对不上,但它建立的层级判断和排查顺序至今成立。
内存层级决定了优化的天花板
文章列了一张很直白的表,用一台 3 GHz 处理器做参照。寄存器一个周期,L1 三个周期,L2 十四个周期,内存两百五十个周期,硬盘四千五百万个周期,跨网络两亿四千万个周期。
从内存读一次数据的代价,大约等于从 L2 读二十次。这个比例决定了优化的优先级。把一次内存访问变成一次缓存命中,收益远大于在其他环节做微调。
一个缓存行宽 64 字节。访问结构体里的一个字段,实际上会把整个缓存行拉进 L1。这也是数据布局比算法更能解释性能差距的原因。
数据布局怎么改
文章给出的第一个可操作手段是连续内存。用原生数组或 std::vector,数据在内存里挨着放,顺序遍历几乎不浪费缓存行。反过来,大量小对象分散在堆上,每次访问都可能是一次缓存未命中。
链表、树、图这类靠指针跳转的结构要特别小心。它们的渐近复杂度好看,常数因子差。std::set 和 std::map 每个元素一次堆分配,节点之间靠指针相连,遍历时内存在地址空间里跳来跳去。替代思路是先按需要组织数据,定型时把内容搬进一个数组,然后排序加二分查找。
第二个手段是结构体拆开,也就是 AOS 转 SOA。假设一个结构体同时装位置、速度、颜色,而你只更新位置。按对象数组存放,每个对象都拖着颜色一起进缓存,用不到的颜色占了三分之二的缓存行。把四个字段拆成四个数组,每帧只碰位置数组。
循环里最容易犯的三个错
第一个是在循环里构造和析构对象。放在循环外声明一次,每次迭代 clear() 后复用,就能省掉反复分配和释放。前提是你用的实现 clear() 不缩容,这一点不可当成通用保证。
第二个是指针别名。函数同时拿到两个指针参数,编译器无法判断它们是否指向同一块内存,每次写入后都要重新读上一次算出的值,优化直接被打断。三种解法是显式拷贝到局部变量、按值传递参数、或者用 __restrict 告诉编译器这两个指针不会重叠。
第三个是编译器选项没开对。文章列了 MSVC 的四个选项,/O2 全优选项、/Oi 启用内联函数、/arch:SSE2 开启 SSE2 指令集、/fp:fast 宽松浮点模型。发布配置一定确认这几个是打开的。
什么时候该自己动手
文章对优化时机给了一个务实的看法。Knuth 那句被引用滥了的话,原意是在 1974 年的论文里举了个用 goto 把循环提速 12% 的个案,主张的是写代码全程都想性能,不是等 profile 出热点再动手。写代码时顺手做对数据布局,成本几乎为零,等代码定型再改布局,代价是重写一批代码。
文章也反驳了另一个极端说法,编译器永远比你懂。在已知具体约束的场景下,自己写一个池化分配器可以明确胜过通用实现,文中举的例子是对象大小已知、数量有上界。
反向的边界也要认。文中也说明那张内存延迟表只是示意,禁用异常和 RTTI 争议更大,收益取决于具体负载。
常见问题
今天还有必要读这类 2013 年的文章? 结论和排序值得学,具体数字不要引用。今天桌面 CPU 的 L1 仍在几十 KB 量级,内存延迟也还在几十到一百纳秒,但周期数已经因为主频变化而不同。
自动向量化能替代手写 SIMD 吗? 结构规整的热点循环可以,gather、scatter、有别名、有函数调用的循环通常不行,要看生成的汇编。