Appearance
性能是设计出来的:局部性、少拷贝与摊还
面试里问「怎么优化性能」,很多人第一反应是「换更快的算法」。但真实系统里,性能往往在架构和数据布局阶段就被决定了:同样的算法,内存访问模式差一点,可能就差一个数量级。这一页把「设计层面」的性能思想串成一条线——局部性、少拷贝、惰性、摊还、无共享、先测量。
1. 数据局部性 / 缓存友好
一句话:让程序访问的内存尽量落在已加载的缓存行里,往往比把算法常数从 O(n log n) 降到 O(n) 更有效。
展开:现代 CPU 与内存之间有几十到上百倍的延迟差,L1 命中约 4 周期、主存约 100ns,差距足以淹没「算法优化」。痛点是:算法复杂度描述的是「操作次数」,却完全忽略了每次操作要等多久内存。做法是按访问顺序组织数据(顺序扫描优于随机跳转)、把热数据聚在一起、减少指针追逐。边界在于:局部性只对「访存受限」的程序有决定性作用,纯计算密集(已在寄存器/缓存内)则更看指令数与并行度。
小例子
cpp
// 同样遍历 4096x4096 矩阵,仅因访问顺序不同,性能可差数倍
constexpr int N = 4096;
// A:行优先顺序访问,缓存友好(预取器能预测)
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; ++j)
sum += m[i][j]; // 内存地址连续递增
// B:列优先跳着访问,每步跨 N 个元素,每次都换缓存行
for (int j = 0; j < N; ++j)
for (int i = 0; i < N; ++i)
sum += m[i][j]; // 地址跳跃,缓存行利用率极低
// 两者"操作次数"完全相同,但 B 的每访问成本高得多静态代码看起来「一样多」,但 B 因跨缓存行访问导致大量 cache miss,实测可慢一个量级。
2. 数据导向设计(DOD)
一句话:按「数据怎么被访问」来组织内存,而不是按「概念上属于哪个对象」来组织。
展开:面向对象习惯 AoS(Array of Structs,struct 数组),让「一个对象的字段」聚在一起;但当你只需要遍历所有对象的某个字段时,AoS 会把无关字段也拖进缓存。DOD 主张 SoA(Struct of Arrays,把同一字段的所有值放进独立数组),让每一次缓存行加载都是「有用的」。痛点是混合 AoS 在高频热路径上浪费带宽与缓存容量。代价是代码不再「面向对象」,索引管理更繁琐,适合性能敏感、批量处理的场景(物理引擎、ECS、数值计算)。
小例子
cpp
// AoS:遍历只更新 position,却把 velocity/health/name 一并载入
struct Entity { float x, y, z; float vx, vy, vz; int hp; char name[16]; };
// SoA:同一字段连续存放,热路径只触碰 x/y/z
struct Entities {
std::vector<float> x, y, z; // 位置,连续
std::vector<float> vx, vy, vz; // 速度
std::vector<int> hp;
};
void integrate_positions(Entities& e, float dt) {
for (size_t i = 0; i < e.x.size(); ++i) { // 只访问 3 个连续数组
e.x[i] += e.vx[i] * dt;
e.y[i] += e.vy[i] * dt;
e.z[i] += e.vz[i] * dt;
}
}同一个循环,SoA 每次缓存行几乎 100% 命中有效数据,AoS 则大量字段被「顺带加载却用不到」。
3. 少拷贝
一句话:能传引用就别传值,能用视图就别复制,能移动就别拷贝——拷贝是性能问题里最便宜、收益最确定的修复点。
展开:一次深拷贝既花 CPU(复制字节)又花内存(额外分配),在大对象、热路径、容器元素上代价被放大。做法:函数参数用 const T& 或视图;只读字符串用 std::string_view,只读序列用 std::span(C++20);用移动语义把「搬走」变成「偷指针」。边界是:视图不拥有数据、生命周期必须长于使用者,误用会变成悬垂;小对象(如 int、指针)传值反而更快,别过度引用化。
小例子
cpp
#include <string>
#include <string_view>
#include <vector>
std::string bad(std::string s) { // 按值:调用即拷贝一次
return "hello " + s; // 返回又可能再搬一次
}
std::string_view slice_head(std::string_view sv, size_t n) {
return sv.substr(0, n); // 零拷贝,只是"看"一段
}
void consume(const std::vector<int>& v); // 只读用 const&,不复制整块
int main() {
std::string s = "world";
auto view = slice_head("hello, world", 5); // 不分配、不复制
}同一个「截取字符串」的需求,substr 会分配新串,string_view 只是记录指针与长度。
回链:std::string、移动语义
4. 惰性求值
一句话:把「构建中间结果」推迟到真正需要时,或用表达式模板取消中间对象,从而省掉整轮分配与遍历。
展开:a = b + c + d 若每步都造一个临时容器,就会产生多个中间结果,既分配又遍历。C++20 ranges 用惰性 view 把「过滤 → 转换 → 取前 N」串成一条管道,元素按需流过,不落地中间容器;表达式模板(Eigen 风格)则在编译期把表达式拼成一棵树,一次循环算完。边界:惰性链太深会拖慢编译、错误信息难读,且对「需要多次遍历」的数据反而更慢。
小例子
cpp
#include <ranges>
#include <vector>
#include <iostream>
auto cheap = std::vector<int>{1,2,3,4,5,6,7,8,9,10}
| std::views::filter([](int x){ return x % 2 == 0; })
| std::views::transform([](int x){ return x * x; })
| std::views::take(3);
// 没有中间 vector:每个元素只在被读到时才算一次
for (int v : cheap) std::cout << v << ' '; // 4 16 36管道中的 filter/transform 都是惰性的,元素逐个「流过」,不构建中间容器。
5. 摊还分析
一句话:单次操作可以很贵,但只要摊到每次平均就便宜,就是划算的设计——vector 扩容正是典范。
展开:若 vector 每次插入都精确扩容 1 个元素,就退化为 O(n²);改成容量不足时按几何倍数(通常 2×)增长,虽然单次扩容要搬 n 个元素,但摊到每次 push_back 平均是 O(1)。痛点是「我该 reserve 吗」——预估容量能消除扩容期搬家。边界是:2× 会短暂占用约 1.5~3 倍内存(旧新并存),且对超大容器需要权衡内存与拷贝次数。
小例子
cpp
#include <vector>
std::vector<int> v;
// 容量按几何增长:1,2,4,8,16,... 共 n 次 push_back
// 总搬家次数 = 1+2+4+...+n/2 < n → 摊还 O(1)/次
for (int i = 0; i < 1000000; ++i) v.push_back(i);
// 若已知道规模,reserve 可消除全部扩容搬迁
std::vector<int> w;
w.reserve(1000000);
for (int i = 0; i < 1000000; ++i) w.push_back(i); // 无 realloc几何增长让「偶发的大搬家」被海量「平凡插入」摊薄;reserve 则直接消灭那笔大开销。
6. 无共享 / 不可变优先
一句话:能不共享状态就不共享;必须共享时优先不可变,用「不写」消除加锁的必要。
展开:多线程性能的敌人常常不是 CPU 不够,而是锁竞争与缓存一致性流量。若数据只读,多线程可以无锁并发访问同一份内存;若数据结构不可变,读写天然分离,省掉互斥与内存序的复杂度。做法:让线程持有各自的数据副本再合并(map-reduce 模式)、用 const/不可变对象表达「不变量」。边界是:副本化会增加内存与合并开销,适合「写入稀疏、读取密集」的场景。
小例子
cpp
#include <vector>
#include <thread>
// 反模式:所有线程抢同一把锁累加,锁竞争吃掉并行收益
// 正模式:每个线程写私有分片,最后串行合并(无锁竞争)
long parallel_sum(const std::vector<int>& data, int nthreads) {
std::vector<long> partial(nthreads, 0);
std::vector<std::thread> pool;
size_t chunk = data.size() / nthreads;
for (int t = 0; t < nthreads; ++t) {
pool.emplace_back([&, t] {
size_t lo = t * chunk;
size_t hi = (t == nthreads - 1) ? data.size() : lo + chunk;
long s = 0;
for (size_t i = lo; i < hi; ++i) s += data[i]; // 私有变量,无共享
partial[t] = s;
});
}
long total = 0;
for (auto& th : pool) th.join();
for (long p : partial) total += p; // 合并阶段才写"共享"
return total;
}每个线程只写自己的 partial[t],全程无锁,合并阶段一次搞定。
7. 假共享(false sharing)与缓存行
一句话:两个线程写的是不同变量,但它们恰好落在同一条缓存行里,缓存一致性协议仍会把这一行来回搬运。
展开:缓存以 64 字节缓存行为单位同步。即使变量逻辑上「无关」,只要物理相邻,一个核写它就会让另一个核持有的同一行失效,触发反复的缓存行迁移。痛点是「明明没有共享变量,多线程却越跑越慢」。做法:让高竞争的共享变量各占一条缓存行(对齐 + padding,alignas(64)),或干脆让每个线程用私有的局部变量最后汇总。边界是 padding 会浪费内存,只值得用在热点计数器上。
小例子
cpp
#include <atomic>
#include <thread>
#include <cstddef>
// 反模式:两个 atomic 相邻,落在同一缓存行 → false sharing
struct Bad { std::atomic<long> a; std::atomic<long> b; };
// 正模式:各自 64 字节对齐并填充,独占缓存行
struct alignas(64) Padded {
std::atomic<long> v;
char pad[64 - sizeof(std::atomic<long>)]; // 填满整条缓存行
};
Padded c1, c2; // 两个计数器分属不同缓存行
void count(std::atomic<long>& x) {
for (int i = 0; i < 1000000; ++i) x.fetch_add(1, std::memory_order_relaxed);
}
// 线程1 累加 c1.v,线程2 累加 c2.v:不再互相失效alignas(64) 让两个计数器分属不同缓存行,消除「写不同变量却互相拖慢」的抖动。
8. 先测量再优化
一句话:不要凭直觉猜瓶颈,用 profiler 或精确计时先定位,再动手改,改完再验证。
展开:程序员对「哪里慢」的直觉普遍不准,且优化往往引入复杂度与 bug。正确流程是:先建立可复现的基准 → 用 perf/采样 profiler 找出真正的热点 → 只改热点 → 用同一基准验证收益,防止「优化了非瓶颈」。边界是:基准要避免编译器把代码优化掉(用 DoNotOptimize/volatile 兜底)、避免把测量本身的噪声当收益。没有测量支撑的「优化」常是无用功甚至退步。
小例子
cpp
#include <chrono>
#include <iostream>
// 最朴素的计时:用 steady_clock(单调、不受系统时间调整影响)
template <class F>
double time_ms(F&& f) {
auto t0 = std::chrono::steady_clock::now();
f();
auto t1 = std::chrono::steady_clock::now();
return std::chrono::duration<double, std::milli>(t1 - t0).count();
}
int main() {
double a = time_ms([]{ /* 版本 A 的核心逻辑 */ });
double b = time_ms([]{ /* 版本 B 的核心逻辑 */ });
std::cout << "A=" << a << "ms B=" << b << "ms\n";
// 结论以实测为准,而非"我觉得 B 更快"
}先用可复现的计时跑通两个版本,再决定改不改、改哪个——否则优化方向可能完全错。