性能优化思路:从测量到代码的五层递进方法论

优化不靠直觉,靠数据。这篇文章是我在给 Hical 做性能优化时沉淀下来的一套方法论:从"用 profiler 找到真正的瓶颈",到架构选型、系统间交互、数据结构、再到最后的代码级微优化,一共五层,层层递进。每一层都有对应的工具、实战案例和"什么时候别优化"的提醒。看完你会发现,大部分性能问题根本轮不到写代码,先想清楚在哪一层出手,比埋头优化重要得多。


目录


第零层:测量——找到真正的瓶颈

优化不是凭直觉猜,是数据驱动的工程决策。没有 profiling 数据就动手,相当于在黑屋子里开枪——打中算运气,打不中浪费子弹。我在 Hical 上踩过的第一个坑就是猜错了方向:光凭代码感觉觉得是协程帧太多,结果 perf 一跑,14.5% 的 CPU 其实耗在调度器上。所以,先量,再动

工具链:每种工具回答一个问题

工具回答什么问题Hical 实战
perf record + 火焰图CPU 时间花在哪了perf record -g -F 99 → docker 内 10s wrk
strace -c系统调用频次和时间占比epoll_ctl 31351 次占 25.92%
GTest 性能用例函数级性能断言test_router_perf.cpp,100K 次 dispatch 计时
wrk / wrk2端到端吞吐和延迟分布wrk -c100 -t4 -d30s
heaptrack / valgrind massif内存分配热点找意外的大分配
perf stat -dIPC、分支预测率、cache miss看整体健康度

火焰图怎么看

拿 Hical 实际火焰图数据来说(docker 内 wrk -c10000 -d10sperf record -g -F 99):

  • 宽度 = CPU 占比。一个函数条越宽,占用 CPU 越多。
  • 高度 = 调用栈深度。从下往上读:最底层是 __libc_start_mainmainio_context::run → … → 最顶层是叶子函数。
  • 同色块连续出现 = 同一函数在栈的不同位置被调用。
  • 只看"自我时间"(self time):函数条本身的宽度,减去它所有子调用条的宽度,才是它自己消耗的 CPU。别把子调用的账算在自己头上。

常见误判:看到一个宽条函数就说是瓶颈——它可能只是调了一个更宽的子函数。真正的热点是最上层宽条里那些自己干活(self time 高)的叶子。

💡 小技巧:perf record-g 抓栈深度,配 -F 99 是采样频率 99Hz,对短请求足够;输出直接 perf report 或导出 SVG 火焰图都行。跑之前记得把采样时长拉够(Hical 是 10s),数据太少看啥都像热点。

优化闭环

1
测量 → 找热点 → 假设根因 → 改代码 → 再测量对比 → 确认收益(或回退)

缺最后一步等于白做——没数据对比,你不知道改完之后是变快了还是变慢了。每次优化至少要有两个数字:改前 xx,改后 yy,提升 zz%

什么时候不该优化

在动手之前先问自己三个问题:

  1. 这代码被调用了多少次? 启动阶段跑一次的初始化函数,优化了也感知不到。
  2. 优化收益能覆盖复杂度代价吗? 为了省 5ns 把三行代码变成三十行,除非是框架最热路径,否则不值得。
  3. 有更简单的方案吗? 加缓存、调大超时、减少重试次数——很多性能问题不需要改代码,调个参数就解决了。

这三问不是客套,是真能拦住冲动。Hical 早期我在一个启动时只跑一次的函数上纠结了半小时,后来发现它连 0.1% 的 CPU 都占不到——纯浪费。


性能优化五层模型

把性能优化拆成五个层次来思考:

  1. 测量 — 用 profiling 工具找到真正的瓶颈在哪。不测量不动手。这一层决定了你花的时间有没有价值。
  2. 架构级 — 选什么模型、怎么分层、内存怎么管、并发怎么调度。这一层的决定影响全局,后期纠正代价极大。
  3. (子)系统间 — 模块之间怎么交互:I/O 模型、跨核通信、内核态开销、批处理粒度。单模块再快,交互方式不对也白搭。
  4. 数据结构与算法 — 用什么容器、什么算法策略。同一个功能,选对数据结构和选错可能差几个数量级。
  5. 代码级 — 缓存友好、零拷贝、分支预测、编译器指令。这是"最后一公里",在前面四层都做对的前提下榨出剩余性能。

五层不是互相替代,而是层层递进。架构选型错了,代码层优化再多也是杯水车薪。跳过测量直接优化,连是不是杯水车薪都不知道。

我个人的体会是:大部分项目栽在第 1 层和第 4 层。要么架构就定错了,改不动;要么明明瓶颈在系统调用上,还在拼命扣代码级的几个 ns。先定位自己现在站在哪一层,再决定使多大力气。

场景速查:我该从哪一层入手?

症状决定了最可能出手的层次。下面的速查表是经验总结,不是硬规则——有时候一个问题涉及多个层次:

症状优先看哪层具体章节
QPS 上不去,CPU 没跑满架构级 → 系统间并发模型、绑核、多 acceptor
QPS 上不去,CPU 跑满了测量 → 代码级火焰图找热点、数据结构选型
P99 延迟抖动大系统间 → 代码级协程同步效应、false sharing、内存分配抖动
内存占用过高架构级 → 代码级三级内存池、ReadBufferPool、避免拷贝
连接数上去后吞吐暴跌系统间IdleScanner 集中扫描、epoll_ctl 次数
长尾请求慢系统间 → 数据结构数据库连接池 acquire 延迟、路由查找
编译慢 / 二进制体积大架构级编译隔离、extern template、LTO
不确定瓶颈在哪第零层火焰图 + strace 双管齐下

架构级性能优化

架构级优化的核心问题是:选什么模型、怎么分配资源、并发怎么组织。改架构的成本最高,但收益也最大——做对了,下面的层次自然有发挥空间。

这一层最容易被忽略,因为它要动的是"设计"而不是"代码"。但反过来说,这一层也是最不该省的:等框架写完再回头改并发模型,基本等于重写。

零开销抽象:不用就别付出代价

C++ 的"不为不用的部分付费"原则,在框架层面体现为编译期隔离:

  • Hical 的数据库中间件、OpenAPI 模块通过 CMake option(HICAL_WITH_DATABASEHICAL_WITH_OPENAPI)控制编译
  • 编译期 #ifdef HICAL_HAS_XXX 把所有相关代码物理排除在二进制之外
  • 用户不用数据库,就一个字节的数据库代码都不会编译进去

这和运行时 if 判断完全不同——运行时判断虽然不执行分支,但代码还在 icache 里占地方,分支预测器还要猜。编译期隔离是真的零开销。

协程模型 vs 回调模型

协程和回调对比,不仅是代码好不好写的问题,更直接影响性能:

  • 回调模型:状态分散在多个闭包里,每次状态转移都是一次堆分配 + 函数指针调用。一个长链路(读 → 解析 → 路由 → handler → 写)可能涉及 5-6 次回调/分配。
  • 协程模型:所有状态集中在栈帧里,一次分配覆盖整个链路。Hical 用 Asio 的 awaitable<T> + use_awaitable,协程帧一次分配,所有 co_await 都不需要额外堆分配。

更值得提的是 Hical 的同步快速路径——如果一个 handler 没有 co_await(比如直接返回 res.ok("hello")),dispatchSync() 完全跳过协程帧分配,直接函数调用返回,省 ~40-130ns。

并发模型选型:绑核 + 多 acceptor

高并发场景下,并发模型的选择直接影响吞吐天花板。几个关键决策:

1:1 线程绑核:每个 worker 线程绑定一个 CPU 核(pthread_setaffinity_np),减少线程在核间迁移导致的 TLB flush 和跨核 IPI 中断。Hical 火焰图分析中 IPI 占了 11%,但如果线程不绑核,这个数字会更难看。

SO_REUSEPORT 多 acceptor:每个 worker 各自 listen 同一个端口,内核把新连接直接分发给对应 worker。这样 accept 和后续 I/O 都在同一线程,零跨核调度。Windows 不支持就降级为单 acceptor 模式。

最少连接数调度:新连接分配给当前连接数最少的 worker(AsioEventLoopconnectionCount_ 原子变量),避免某个核被大量连接压死而其他核闲着。

内存策略:三级池 + 请求级隔离

为什么 Hical 设计三级内存池(全局 → 线程 → 请求)?

  • 全局 synchronized_pool_resource:进程级共享,适合长生命周期对象,有锁但分配频率低
  • 线程 unsynchronized_pool_resource:每个线程独立,无锁,适合中间生命周期对象
  • 请求 monotonic_buffer_resource:只增不减,请求结束时整块回收,零 free 开销

核心约束:请求级单调缓冲区的对象不能逃逸到请求之外(比如缓存到全局 map 里),否则是 use-after-free。这是人为约束换取零 free 性能

打个比方:搬家时你用一个大箱子把所有东西一次搬走(单调缓冲区),比一个个小袋子来回跑(逐次 malloc/free)效率高得多。代价是如果你把钥匙忘在箱子里,箱子扔了就找不到了——这就是"对象不能逃逸出请求生命周期"的由来。

集中式扫描 vs 分散式定时器

Hical 在 v2.6.4 版本把空闲连接检测从"每个连接一个 steady_timer 协程"改为"一个 IdleScanner 管理一个 io_context 上所有连接"。

原方案的问题:10000 个空闲连接 = 10000 个 timer 协程帧 = 每次 timer 触发就是 10000 次独立调度。改成集中扫描后,一个 timer 扫一圈只做一次调度。帧数降了几个数量级。

这类"集中式扫描 vs 分散式定时器"的思路在游戏服务器里也很常见——玩家心跳检测、离线判定,用一个大 tick 扫全量,比每个人物挂一个 timer 省得多。


(子)系统间性能优化

架构定了以后,模块怎么交互就成了瓶颈。系统间优化的核心问题是:调用链路有多长、跨了多少边界、边界开销能不能合并

如果说架构级是"选对车",那系统间就是"怎么开"——同样的车,路线规划得好不好,差距能拉得很大。

I/O 模型:减少系统调用次数

每次 epoll_ctlsendtorecvfrom 都要从用户态切到内核态再切回来。切换本身就几百 ns,再加上内核内部的锁和队列——累积起来是很大的开销。

Hical strace 数据(docker 内 wrk -c10000 -d10s)显示:10 秒内 epoll_ctl 调了 31351 次,占总时间的 25.92%。在源码层面这是一个"看不见"的开销——perf 采样几乎抓不到短平快的 syscall,但 strace 把问题暴露得很清楚。

Hical 做的优化:

  • 256B 栈缓冲 speculative read(v2.6.5):空闲连接不再先 async_wait 再读,而是直接用 256B 栈缓冲区尝试读,读到数据就不退还给 reactor。这把 epoll_ctl(MOD) 调用从 ~32K 次降到 ~9K 次,时间占比从 26.51% 降到 5.57%。
  • 响应前缀模板:连接级预构建 Server/Connection/Date 头部 wire bytes(~90B),keep-alive 请求直接用 memcpy,省了 3 次 HeaderMap::insert + 序列化循环。
  • Optimistic sync write:小响应(head+body ≤512B)先试同步 write_some(),大概率一次写完不挂起协程也不进 reactor 队列。只有 would_block 才 fallback 到 async_write

内核态 vs 用户态的时间分配

火焰图数据很直观:Kernel TCP send 38% + TCP recv 18% + Socket wakeup 17% + IPI 11% = 84% 时间在内核里。Hical 应用层只占 7%。

这个比例说明了什么?在当前架构下,进一步优化应用层代码对吞吐提升极其有限——因为 93% 的时间不在你手上。这也是 Hical 后期我基本停手的原因:瓶颈已经不在我写的代码里了。真正的杠杆在:

  • 换 I/O 模型(io_uring 批量化提交/完成,省掉半数以上 syscall 边界开销)
  • 减少系统调用(上面说的 speculative read、optimistic write、TCP_CORK 合并帧)
  • DPDK/XDP(bypass 内核协议栈,但这已经跨入另一层架构选型了)

批量合并 I/O

一次大 write 比十次小 write 快得多,这不是 syscall 次数的问题,还涉及 TCP 分段策略和 Nagle 算法。

Hical 的做法:

  • Scatter-gather I/O:响应头 + 文件体用 std::array<const_buffer, 2> 一次 async_write 完成,两个分散的内存块合进一次 syscall。SSE 推送场景用 std::array<const_buffer, 4>(chunk-size + CRLF + body + CRLF)。
  • TcpCorkGuard RAII(Linux TCP_CORK / macOS TCP_NOPUSH):文件响应场景先把头部塞进 TCP 缓冲区(CORK 模式不触发立即发送),然后发第一个 64KB 文件块时才 flush——头部和第一批数据合并成一段 TCP segment。
  • MpscQueue 批量 drain:写队列每次最多 drain 256 个节点(kMaxDrainBatch),控制单轮耗时不被突发队列拉长。

协程间数据传递:用视图不要用拷贝

一个 HTTP 请求从解析到响应,在中间件链里穿过 N 层,每一层都可能需要读 headers / body / query params。如果每层都拷一份,N 次拷贝=N 次 malloc+memcpy。

Hical 的做法是全程 string_view 引用连接级 readBuf

  • NativeRequeststring_view target + 栈分配 array<Entry, 64> headers,全指向 readBuf
  • 中间件和 handler 都只读视图,不持有拷贝
  • 只在最终响应需要持久化 body 时才做拷贝

数据库交互:连接池延迟模型

数据库是系统间交互里开销最大的环节(网络往返 + SQL 解析 + 磁盘 I/O)。Hical 的连接池设计体现了几点系统间优化思路:

  • LIFO 复用:最近归还的连接复用概率最高(缓存局部性)
  • pingGracePeriod:上次 ping 还在 N 秒内的连接跳过健康检查,避免重复 ping
  • acquire 预检查 isAlive():拿连接时在锁内先判断存活,死连接当场丢弃重新分配,不拖到业务层报错
  • 自动事务回滚:如果 handler 异常退出且连接还在事务中,归还时自动 rollback,防止未提交事务泄漏

锁优化:从互斥到无锁的阶梯

共享数据的并发访问是服务端程序的常规战场。锁的选择直接影响吞吐天花板。

锁的代价阶梯(从慢到快):

锁类型内核参与适用临界区说明
std::mutex(有竞争)是(futex)>10μs竞争时走内核 futex,开销大
std::mutex(无竞争)1-10μs纯用户态 CAS,很快
std::shared_mutex读多写少读共享、写独占,比 exclusive mutex 并发度高
spinlock<500ns忙等不 sleep,省上下文切换但费 CPU
Lock-free (CAS loop)<200ns无等待,但 ABA 问题和内存序复杂
RCU读极多写极少读完全不阻塞,写延迟释放旧数据

选锁的决策树:

  1. 临界区极短(<500ns)→ spinlock
  2. 读多写少(读:写 > 10:1)→ shared_mutex
  3. 写也不少 → mutex
  4. 锁已经是性能瓶颈且 profiler 证实(而非猜测)→ 考虑无锁方案

锁分片(lock sharding): 一把大锁拆成 N 把小锁,并发度提 N 倍:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Hash map 锁分片:每个桶独立一把锁
template <typename K, typename V, size_t ShardCount = 64>
class ShardedMap 
{
    struct Shard { std::mutex mtx; std::unordered_map<K, V> map; };
    std::array<Shard, ShardCount> shards_;

    Shard& getShard(const K& key) 
    {
        return shards_[std::hash<K>{}(key) % ShardCount];
    }
    // get/put 只锁一个 shard,其他 63 个不受影响
};

无锁的陷阱:

Hical 的 MpscQueue 用了无锁设计,但这不代表无锁总是正确选择:

  • ABA 问题:线程 A 读到指针 P,线程 B 把 P 删了又分配一个新对象恰好等于 P,线程 A 的 CAS 通过了但对象早已变了
  • 内存序地狱memory_order_acquire / release / relaxed 用错一个就出诡异 bug,而且 bug 可能只在特定 CPU 上复现
  • 调试困难:竞态条件在无锁代码里更难复现和定位

一句话:不要因为无锁"酷"就用无锁。先用 mutex,实测瓶颈再优化。 Hical 的 MpscQueue 选无锁是正确决策——它的 tail 是 producer 唯一写入、head 是 consumer 唯一读取,天然只有两个方向的竞争点,设计上简单可控。如果是随便一把共享队列,老老实实上 mutex

C++20 std::atomic_ref

对已有变量做原子操作,不需要额外定义 std::atomic<T>。这对存量的 POD 结构体升级到原子访问特别有用。注意:被 atomic_ref 引用的变量必须满足 atomic<T>::required_alignment 要求(通常需要 alignas 显式对齐):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct Config 
{
    alignas(std::atomic_ref<int>::required_alignment) int maxConns;
    int timeout;
    // ...
};
Config g_config;

// 不用改 Config 的其他使用者,只在对 maxConns 做原子访问时用 atomic_ref
void updateMaxConns(int n) 
{
    std::atomic_ref<int>(g_config.maxConns).store(n, std::memory_order_release);
}
int readMaxConns() 
{
    return std::atomic_ref<int>(g_config.maxConns).load(std::memory_order_acquire);
}

NUMA 感知

多路服务器(双 socket 或更多)上,每个 CPU socket 有自己的本地内存。访问本 socket 的内存(local)和跨 socket 访问(remote)延迟差 1.5-2×,带宽差距更大。

Hical 的 NUMA 优化不是"以后再做",而是通过编译选项 HICAL_WITH_NUMA(自动检测 libnuma)控制的一套完整方案,由三个组件协同工作:

1. NumaTopology:拓扑检测

NumaTopology 单例在启动时调用 detect()——先通过 numa_available() 判断当前系统是否真正的 NUMA(numa_nodes > 1),然后用 numa_max_node() + numa_num_configured_cpus() + numa_node_of_cpu() 把每个 CPU 归入对应节点,剔除空节点(sparse node ID),最终缓存为 vector<NumaNode>。非 NUMA 环境(Windows 或无 libnuma)自动退化为单节点 UMA,所有查询仍然可用。

1
2
3
4
5
// 启动阶段调一次,之后全部只读查询
auto& topo = NumaTopology::instance();
topo.detect();
bool numa = topo.isNuma();  // true = 多节点 NUMA,false = UMA 退化
int nodeOfCpu5 = topo.nodeOfCpu(5);  // CPU 5 在哪个节点

2. EventLoopPool:NUMA 感知绑核

EventLoopPool::start() 里的线程绑核不只是简单的 pthread_setaffinity_np 轮询——它会先把线程均匀分布到各 NUMA 节点,再在节点内轮询绑 CPU

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// EventLoopPool.cpp - NUMA 感知绑核逻辑
if (numa) 
{
    // 线程均匀分到各 NUMA 节点,节点内轮询绑 CPU
    // 比如 2 节点各 8 核,8 线程 → 每节点 4 线程,
    // 分别绑 node0 CPU 0-3、node1 CPU 0-3
    const auto& nodes = NumaTopology::instance().nodes();
    size_t nodeCount = nodes.size();
    size_t nodeIdx = i % nodeCount;
    const auto& node = nodes[nodeIdx];
    if (node.cpuCount_ > 0) 
    {
        int cpuIdx = static_cast<int>((i / nodeCount) % static_cast<size_t>(node.cpuCount_));
        CPU_SET(node.cpuList_[cpuIdx], &cpuset);
    }
}
else 
{
    // 非 NUMA:简单轮询
    CPU_SET(i % std::thread::hardware_concurrency(), &cpuset);
}
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);

这样一来,每个 worker 线程的栈、thread_local 数据、以及它处理的连接 I/O buffer,都在自己所属 NUMA 节点的本地内存里。

3. MemoryPool:NUMA 感知内存分配

MemoryPool::getOrCreateThreadPool() 在创建 thread-local unsynchronized_pool_resource 时,通过 sched_getcpu() 获取当前 CPU,numa_node_of_cpu() 找到所属节点,然后 numa_set_preferred(node)——之后所有 PMR 分配(包括请求级 monotonic_buffer_resource)都优先从本地 NUMA 节点拿内存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// MemoryPool.cpp - thread-local 池创建时设置 NUMA 亲和
#ifdef HICAL_HAS_NUMA
int cpu = sched_getcpu();
int node = numa_node_of_cpu(cpu);
if (node >= 0) 
{
    numa_set_preferred(node);  // 后续 PMR 分配优先走本地内存
}
#endif
auto pool = std::make_unique<std::pmr::unsynchronized_pool_resource>(pool_options, &globalPool_);

这比 “每个 NUMA 节点一套独立 PMR 池” 方案更轻量——不拆分池结构,只通过 numa_set_preferred 改变底层 mmap 的策略,内核自动把物理页分配到最近的 DIMM。

还可以做什么

  • 连接分发优 NUMAgetNextLoop() 当前用最少连接数策略,可以加上 NUMA 权重——同一 NUMA 节点的 accept 连接优先分给同节点的 worker,减少跨 socket 数据搬移。不过,SO_REUSEPORT 的多 acceptor 模式本身已经保证了 accept 和后续 I/O 在同一线程,这个优化的收益有限。

为什么没有更进一步

火焰图显示 Hical 瓶颈在内核 TCP 栈(55%),应用层内存访问开销占比较小。当前这套"绑核 + 内存亲和"方案已经把该做的做了——线程不跨节点、分配不走远程 DIMM。更激进的 NUMA 优化(比如 numa_alloc_onnode 手动分配热路径对象、完全拆分为 per-NUMA-node 的独立 PMR 池)在没有把内核开销降下来之前,收益很难测量到。等 io_uring 或 DPDK 把内核开销降下来之后,NUMA 优化的占比自然会浮出水面。


数据结构与算法性能优化

前两层说完了架构和交互,从这一层开始进入纯代码层面。这里要记一条铁律:数据结构的收益是数量级的,代码级的收益是百分比的。先选对结构,再去抠微优化。

数据结构选择

数据结构选对,算法复杂度从 O(n²) 变 O(log n) 甚至 O(1),这是代码层面收益最大的优化。

容器内部结构适合场景不适合场景
std::vector连续内存随机访问、尾部追加、有序遍历中间插入/删除
std::unordered_map哈希表快速键查找 (O(1))有序遍历、内存敏感场景
std::map红黑树有序遍历、按范围查询纯查找(比 hash 慢)
std::deque分段数组两端插入/删除随机访问(略慢于 vector)
std::list双向链表中间频繁插入删除几乎所有其他场景(cache 极不友好)

几个实际选择案例:

  • HeaderMap:HTTP 头部通常 <20 个,用了 vector<pair<string,string>> 做 case-insensitive 线性查找。对于小 N,L1 cache 命中的连续遍历比 hash map 的散列访问快。
  • Router 静态路由:用 unordered_map + 透明哈希(RouteKeyView/is_transparent),支持 string_view 零分配查找。
  • 连接列表IdleScanner 用侵入式双向链表而不是 std::list——Entry 嵌在协程栈上(零堆分配),链表操作只改指针不需要分配 node。

小数据用简单算法

标准库的 std::sort 是 introsort(快排 + 堆排保底),常数因子相对较大。如果数据量只有几十个,插入排序反而更快——每次比较的指令数少,cache miss 概率低,分支预测友好。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void sort_data(std::vector<int>& data) 
{
    if (data.size() < 50) 
    {
        // 小数据用插入排序,常数因子小
        for (size_t i = 1; i < data.size(); ++i) 
        {
            int key = data[i];
            int j = static_cast<int>(i) - 1;
            while (j >= 0 && data[j] > key) 
            {
                data[j + 1] = data[j];
                --j;
            }
            data[j + 1] = key;
        }
    } 
    else 
    {
        // 大数据交给标准库
        std::sort(data.begin(), data.end());
    }
}

缓存友好的数据布局

CPU 访问内存时,一次拉一个 cache line(64 字节)进 L1/L2/L3。如果把热路径数据分散在不同 cache line 里,每次访问都可能触发一次新的缓存加载。

实践中需要注意:

  • 把热路径字段放在一起(比如 struct 前几个字段经常被一起访问,就不要在中间插冷路径字段)
  • 不要用 std::list 存热数据——每个 node 都 malloc 出来的,遍历时指针跳得到处都是
  • AoS(Array of Structs)vs SoA(Struct of Arrays):如果只访问结构体里某几个字段,SoA 更 cache 友好,因为不加载用不到的字段

代码级性能优化

最后一层是编码层面,属于"微优化"范畴。但积少成多——热路径上省 10ns,150K QPS 下就是每秒省 1.5ms CPU。

不过先说清楚:这一层只有在前面四层都做对了才有意义。架构错了、交互有瓶颈,代码级抠出来的几个 ns 根本看不到效果。这也是我把"测量"放在第零层的原因——先用数据确认自己该不该在这一层使劲。

行优先访问

二维数组在内存里是按行连续排列的。行优先遍历时,每次访问的下一个元素就在隔壁缓存行里;列优先遍历时,每次访问都要跳到 N 行之外,基本每次都是 cache miss。

1
2
3
4
5
6
7
8
9
// 反例 —— 列优先,每次都跳到下一行
for (int i = 0; i < N; i++)
    for (int j = 0; j < M; j++)
        sum += array[j][i];

// 正例 —— 行优先,顺序吃满缓存行
for (int i = 0; i < M; i++)
    for (int j = 0; j < N; j++)
        sum += array[i][j];

循环展开

适用于循环体极短的场景。每次迭代的 i < Ni++、跳转都是开销。一次处理 4 个元素,这些开销除以 4。现代编译器在 -O2 以上会自己做,手动展开主要是为了可读性地标注"这里的关键路径"。

循环中的间接调用:提虚函数和函数指针

这个点比较容易被忽视,但现代编译器对普通函数(非虚)的调用在循环内是直接 call,不需要"每次查表"——编译器在编译期就知道地址。真正需要提出来的是两种情况:

  1. 虚函数调用:每次迭代都要查虚表(vtable lookup)
  2. 函数指针调用:每次解引用间接跳转
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 传统虚函数——每个 Derived 类型都需要 vtable 间接调用
class IProcessor 
{
public:
    virtual ~IProcessor() = default;
    virtual void process() = 0;  // 每次调用都是间接 call
};

// CRTP——编译期多态,零虚表开销
template <typename Derived>
class ProcessorBase 
{
public:
    void process() {
        static_cast<Derived*>(this)->impl();  // 编译期绑定,直接 call
    }
};

class FastProcessor : public ProcessorBase<FastProcessor> 
{
public:
    void impl() { /* 实际逻辑 */ }
};

把函数指针提到循环外面绑定,省掉重复的间接跳转:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 优化前:循环内函数指针间接调用
for (auto& item : container) 
{
    result += test_function(item);  // 每次间接跳转
}

// 优化后:函数指针在循环前确定,跳转目标缓存更稳定
auto func = test_function;
for (auto& item : container) 
{
    result += func(item);
}

对于现代 x86 分支预测器,间接跳转的 BHB(Branch History Buffer)预测准确率很高,所以提出来不一定有明显加速。真正值得做的是 消除虚调用本身,而不是优化它的查找开销。

内联函数

函数调用不是免费的:压栈、传参、跳转、返回、出栈。对于只有一两行的函数,内联把这个开销完全消除。

1
2
3
4
inline int square(int x) { return x * x; }

// 编译器可能不听 inline 的建议,要强制内联用 constexpr
constexpr int cube(int x) { return x * x * x; }

不是所有函数都该内联。函数体太大,内联后 icache 被撑爆,反而变慢。合适的候选者:getter/setter、简单的数学计算、类型转换。

避免不必要的拷贝

这是 C++ 重灾区。一个看似无害的传参背后可能有一整棵对象的深拷贝。主要武器:

武器省了什么注意事项
const T&值传递的拷贝不适用于基础类型(int/指针,传引用反而多一次解引用)
std::move转移深拷贝移动后原对象处于"已移走"状态,只能析构或重新赋值
RVO/NRVO返回值的拷贝编译器自动做,不需要手写 std::move 在 return 语句上
std::forward<T>保持值类别只在模板的 T&& 参数里用
string_view子串/临时 string不持有内存,原字符串释放后是悬空引用
span容器切片同上,不管理生命周期
emplace_back临时对象 + 移动构造参数直接转发给容器内对象的构造函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ❌ 传值——vector 可能包含成千上万个元素
void process(std::vector<int> data);

// ✓ 常量引用——仅传 8 字节指针
void process(const std::vector<int>& data);

// ❌ 两步——先构造临时对象,再 push_back 时移动
vec.push_back(std::string("hello"));

// ✓ 一步——直接在 vector 内部构造
vec.emplace_back("hello");

分支预测

现代 CPU 有分支预测器,每个分支都能猜对 95% 以上。但一旦猜错,流水线要清空,代价 15-20 个周期。

优化思路:把最常见的情况放在 if 分支,让预测器的默认策略(“not taken” 或学习模式)更大概率命中。配合 [[likely]] / [[unlikely]](C++20)/ __builtin_expect 给编译器提示。

1
2
3
4
5
6
7
8
9
// 大多数请求是 keep-alive,把 keep-alive 放前面
if (isKeepAlive) 
{
    // 大概率走这里
} 
else 
{
    // 关闭连接,很少走
}

短路径求值与提前退出

逻辑运算符有短路特性:A && B 在 A 为 false 时不计算 B。所以计算便宜的、更可能失败的检查放前面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ptr 判空几乎是免费的,isValid() 可能涉及多次内存访问
if (ptr != nullptr && ptr->isValid()) { /* ... */ }

// 提前退出——发现不可能满足条件就立刻返回
for (auto& item : items) 
{
    if (item.isExpired()) continue;   // 跳过不用处理
    if (item.isProcessed()) return;   // 无需继续
    doWork(item);
}

预先计算与查找表

用空间换时间。一些计算密集型函数可以预先算好结果存数组里,运行时直接查表。

经典案例:1993 年 id Software 做 DOOM 时,John Carmack 把正弦值预先算好放在 lookup table 里。当年的 CPU 做一次浮点 sin 可能几十个周期,查表只要几次数组访问。今天同样思路适用于复杂 hash / 编码转换等场景。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class SinTable 
{
    static constexpr int kSize = 3600;  // 0.1° 精度
    float table_[kSize];

public:
    SinTable() 
    {
        for (int i = 0; i < kSize; ++i)
            table_[i] = std::sin(i * M_PI / 1800.0);
    }

    float fast_sin(float degrees) 
    {
        int idx = static_cast<int>(degrees * 10) % kSize;
        if (idx < 0) idx += kSize;
        return table_[idx];
    }
};

这个思路在现代 C++ 里依然适用。比如 HTTP 状态码到 Reason Phrase 的映射——状态码天然是离散的小整数(100-599),用 unordered_map 需要散列计算 + 字符串键查找,但换成跳转表直接 O(1):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// HttpRequest.cpp - Hical 实际的 httpStatusCodeToString 实现
const char* httpStatusCodeToString(HttpStatusCode code)
{
    switch (code) {
    case HttpStatusCode::hOk:                    return "OK";
    case HttpStatusCode::hCreated:               return "Created";
    case HttpStatusCode::hNotFound:              return "Not Found";
    case HttpStatusCode::hInternalServerError:   return "Internal Server Error";
    // ...共 23 个状态码
    }
    return "Unknown";
}

switch-case 在密集枚举值(如 100-599 范围)且 case 数量较多时,编译器会编译为跳转表(jump table)——本质上是一个存代码地址的静态数组,code 直接做下标索引跳到对应的返回语句。和 DOOM 查正弦值一个思路,只不过这里是"查代码地址"而非"查数据值",性能接近静态数组查表。

内存管理:池化而非频繁分配

malloc/free 的隐藏成本:

  • 分配器内部有锁(多线程场景)
  • 碎片累积导致分配路径越来越长
  • 每次 free 可能触发 brk/mmap 归还 OS

池化策略(Hical 实际使用的几种):

  • ReadBufferPool:每请求 8KB buffer,用完归还 thread_local 池。空闲连接不占 buffer。
  • MpscNodePool:无锁队列节点,thread_local 空闲链表(max 128 nodes),new/delete 换成池分配/归还。
  • 三级 PMR:本质上是一个更通用的池化方案,覆盖不同生命周期的对象。

编译器优化:从编译期到二进制

标准编译选项:

  • -O2:平衡优化和编译时间,生产环境基线
  • -O3:更激进的优化(循环展开、内联扩展等),部分场景可能有收益
  • -march=native:针对本机 CPU 指令集(AVX2、BMI2 等),但牺牲跨机器兼容性

编译器指令:

1
2
3
4
5
6
7
8
// 强制内联——省掉函数调用开销(关键路径上)
__attribute__((always_inline)) void critical_path_fn();

// 热路径——告诉编译器此函数频繁调用,优化布局减少取指延迟
__attribute__((hot)) void hot_fn();

// 冷路径——告诉编译器此函数很少调用,放在冷代码段
__attribute__((cold)) void error_handler();

这还不是全部。标准编译选项只能做 “无上下文” 的静态优化——编译器不知道哪些分支热、哪些路径频繁。下面三层递进,每一步都给编译器更多的"上下文信息":

第一层:LTO(链接时优化)

-flto 让编译器推迟到链接阶段再做优化。常规编译是每个 .cpp 各自优化完就不再碰了——函数调用跨了翻译单元边界,编译器看不到调用方的上下文,没法做内联和去重。

LTO 把中间表示(IR)保留到链接阶段,让编译器看到全貌:

  • .cpp 的 getter/setter 内联——Hical 框架层大量小函数受益
  • 未使用的函数/WPO(whole-program optimization)裁剪——减二进制体积
  • 常量传播跨翻译单元——比如 kMaxPathSegments 在多个 .cpp 中使用,LTO 能统一优化
1
2
3
4
# CMake 中开启 LTO
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)  # 对所有 target 生效
# 或单个 target:
set_target_properties(hical_core PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON)

第二层:PGO(性能引导优化)

编译器不知道你的程序实际怎么跑——哪些分支大概率走哪个方向、哪些函数调用频率最高。PGO 给编译器补上这些信息:

1
2
3
4
5
6
7
8
# 第一步:编译 instrumented 版本(仅 Linux;Windows 用 MSVC 的 /GL /LTCG + PogoSafeMode)
g++ -fprofile-generate -o myapp ...

# 第二步:跑真实负载(用 prod 流量或 wrk 模拟)
./myapp  # 退出时生成 .gcda 文件

# 第三步:用采集到的 profile 数据重编译
g++ -fprofile-use -o myapp ...

注意-fprofile-generate / -fprofile-use 是 GCC/Linux 下的方案。Hical 本地开发在 Windows/MinGW 上,PGO 目前只能在 CI 的 Linux job 里做。

PGO 的收益来源:

  • 分支预测布局优化:把 hot 分支放前面(fallthrough),cold 分支跳出去——少一次 jump
  • 函数布局重排:高频函数放一起,减少 icache miss 和 TLB miss
  • 内联决策更准:只内联真正 hot 的调用点,不在大函数上浪费 icache

对 Hical 这种 HTTP 框架来说,dispatchSync()resolveRoute()processRequest() 这些热路径在 PGO 下分支布局改善 5-15% 是合理预期。

第三层:BOLT(二进制优化和布局工具)

BOLT 在 PGO 之后再跑一次,对已经编译好的二进制做采样驱动的布局重排。PGO 在编译期优化,BOLT 在链接后优化——两者互补:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 1. perf 采样二进制运行时的分支行为
perf record -e cycles:u -j any -o perf.data -- ./myapp

# 2. 把采样数据转成 BOLT 能读的格式
perf2bolt -p perf.data -o perf.fdata ./myapp

# 3. BOLT 重排二进制
llvm-bolt ./myapp -o ./myapp.bolt -data perf.fdata \
    -reorder-blocks=ext-tsp -reorder-functions=hfsort \
    -split-functions -split-all-cold

这属于进阶优化,对大型二进制文件效果更显著。Hical 目前的热路径相对集中,BOLT 的额外收益有限,但架构上值得知道这一层存在。

注意llvm-bolt 是 LLVM 的工具,但可以优化任何编译器(GCC/Clang)产出的 ELF 二进制。Hical 用 GCC 编译的二进制完全兼容。

内存对齐与 False Sharing

False sharing 是一个隐蔽但杀伤力大的性能问题。场景:两个线程各操作不同的变量,但这俩变量在同一个 cache line(64 字节)里。线程 A 写了自己的变量,缓存一致性协议必须把整个 cache line 标记为 dirty,通知线程 B 的 CPU 核刷新——线程 B 的读写就被拖慢了,哪怕它根本没碰 A 的变量。

解法:把每个线程的变量对齐到独立的 cache line。

1
2
3
4
5
struct ThreadData 
{
    alignas(64) int counter_;  // 独占一个 cache line
    // 其他数据...
};

Hical 中 MpscQueuetail_ 指针也用了 alignas(64) 来避免 producer 线程之间的 false sharing。

SIMD 向量化

现代 CPU 一条 SIMD 指令同时处理 16/32/64 字节数据——SSE 16B、AVX2 32B、AVX-512 64B。编译器在 -O2 以上能做一定程度的自动向量化,但热点路径手动介入收益更大。

适合 SIMD 的场景:

  • 字符串扫描:找 \r\n、匹配 “Content-Length:” 前缀
  • URL 解码(%20 → 空格)
  • Base64 编解码
  • Hash 计算批量处理
  • JSON 数字解析(一次校验 16 个字符是不是数字)

编译器辅助向量化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 告诉编译器循环没有迭代间依赖,可以安全向量化
#pragma GCC ivdep
for (int i = 0; i < n; ++i) 
{
    data[i] = data[i] * 2;
}

// 告诉编译器忽略潜在的别名(指针重叠)风险
void copy(float* restrict dst, const float* restrict src, int n) 
{
    for (int i = 0; i < n; ++i) dst[i] = src[i];  // 编译器可放心向量化
}

手动 SIMD 示例(SSE2 找换行符):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <emmintrin.h>

// 在 16 字节内找 '\r' 或 '\n',返回第一个位置(-1 表示没找到)
int find_crlf_sse2(const char* p) 
{
    __m128i data = _mm_loadu_si128(reinterpret_cast<const __m128i*>(p));
    __m128i cr   = _mm_set1_epi8('\r');
    __m128i lf   = _mm_set1_epi8('\n');
    __m128i mask = _mm_or_si128(_mm_cmpeq_epi8(data, cr), _mm_cmpeq_epi8(data, lf));
    int bits = _mm_movemask_epi8(mask);
    return bits ? __builtin_ctz(bits) : -1;  // count trailing zeros = 第一个匹配位
}

Hical 目前没有大面积使用 SIMD,因为性能瓶颈不在字符串解析上(火焰图显示内核 TCP 占 55%,应用层仅 7%)。但在需要做大量 HTTP 头部扫描或 URL 解码的场景下,这是值得考虑的武器。

未来方向:C++26 引入 std::simd,提供可移植的向量化编程接口,不再需要手写平台相关 intrinsics。等项目迁移到 C++26 后可以考虑用 std::simd 改写解析路径。

代码级优化决策顺序

不是说所有技术都用一遍,而是按收益从大到小排优先级:

  1. 数据结构选型(O(n²)→O(log n)→O(1))—— 收益可跨数量级,一劳永逸
  2. 消除不必要拷贝(传引用、move、emplace、string_view)—— 收益可能 2-5×
  3. 缓存友好布局 + False sharing 修复 —— 收益 10-30%
  4. 分支预测提示[[likely]] / [[unlikely]])—— 收益 5-15%,热路径有效
  5. 内联 / 循环展开 / SIMD —— 收益 <5%(编译器已经在做大部分,手动介入只攻最关键路径)

性能回归检测:优化完了怎么守住

你花两周把一个热点从 800ns 优化到 200ns。两个月后同事重构了路由模块,那行代码又回到了 800ns——没有人恶意,没人知道这里有过优化。性能退化的可怕之处不是它"会不会发生",而是它发生了你也不知道。

防范退化靠的不是文档里一段注释,是自动化检测。Hical 最近搭了一套两层的回归检测体系:micro benchmark 抓细粒度退化,wrk 端到端堵住粗粒度的坑。

第一层:Google Benchmark 微基准 + CI 自动对比

之前 Hical 有一份 test_router_perf.cpp,用 GTest 的 EXPECT_LT 手动掐表计时。问题是 CI runner 的 CPU 波动让断言不稳定——同一份代码换一台机器,耗时差 20% 很正常。所以之前的主 CI 直接 -E RouterPerfTest 把性能测试全跳过了。

现在换成了 Google Benchmark,不再用硬编码的阈值做断言,而是每次 push main 的时候跑 benchmark,跟上次存在 artifact 里的基线数据对比,退化超过 5% 就在 CI summary 里标 warning。

实际落地的 benchmark 有两个文件:

  • bench_router.cpp —— 路由分发微基准:覆盖了 dispatchSync(同步快速路径)、dispatch(协程完整路径)、中间件链不同长度的耗时对比
  • bench_perfect_hash.cpp —— 完美哈希路由器 dispatchSync 性能对比:分别用 unordered_map 和完美哈希做大路由表的查找耗时

CI workflow 的逻辑写在 benchmark.yml 里,流程大致是这样:

  1. benchmark job:cmake 配 HICAL_WITH_BENCHMARK=ON → 编译 bench_routerbench_perfect_hash → 分别跑,输出 JSON → 用 Python 把两个 JSON 合并成一份 results.json
  2. download-artifact 把上次存的 benchmark-baseline artifact 下载下来
  3. Python 脚本按 benchmark name 逐个对比 real_time,超过 5% 的标出来
  4. 最后把本次的 results.json 上传为新的 baseline artifact(保留 90 天)

首次跑的时候没有基线文件,download-artifact 配了 continue-on-error: true,直接跳过对比,把这次的结果当成基线存下来。下次 push 开始就自动对比了。

第二层:wrk 端到端吞吐冒烟

Google Benchmark 抓微观点位非常精准,但有个盲区:它测的是函数级的单点,不经过真实网络栈。如果有人在连接管理或者事件循环里引入了退化,微基准可能看不出来。

所以补了一层 wrk 冒烟测试,在同一个 benchmark.yml 里跑。拉 HttpArena 服务器起来,用 wrk 砸 10 秒,跟之前的吞吐基线对比。为了避免服务器启动太慢导致 wrk 连不上,用 curl 轮询 /baseline11 端点(30 次,每次 1 秒),确认就绪了再开始压测:

1
2
3
4
5
6
7
8
9
# 轮询等待服务器就绪
for i in $(seq 1 30); do
  if curl -sf http://localhost:8080/baseline11 > /dev/null 2>&1; then
    echo "服务器就绪 (尝试 $i 次)"
    break
  fi
  sleep 1
done
wrk -t4 -c100 -d10s http://localhost:8080/baseline11 > wrk_result.txt

退化阈值设得比 micro benchmark 宽松一些——micro benchmark 是 5%,wrk 给 10%。原因是 CI runner 之间的 CPU 波动在端到端场景下放大得更厉害,太紧容易出 false alarm。

这一层不能替代 micro benchmark——毕竟 CI 机器的负载波动让单次 wrk 结果不够精确——但它能拦住"不小心在主循环里加了把锁"这种级别的退化。

方案三:火焰图定期对比

不是 CI,是手册操作。每发一个大版本之前,拿 perf record 跑一遍典型负载,和上个版本的火焰图并排看:

  • 有没有新的宽条出现在不该出现的地方?
  • 热路径函数的 self time 有没有变大?
  • strace -c 的 syscall 分布有没有异常?

这是一个"人工回归检测"——自动化做不到的时候,人眼是最靠谱的后备。

选取哪些基准

不用把所有函数都进 benchmark CI——成本太高,结果噪声也太大会产生 false alarm。优先选这几类:

  1. QPS 的必经路径resolveRoutedispatchSyncprocessRequest(前两个已经迁移到 Google Benchmark:bench_router.cppbench_perfect_hash.cpp
  2. P99 的可能瓶颈IdleScanner::scanflushWriteQueue、数据库连接池 acquire(目前没有覆盖)
  3. 曾经出过性能问题的:之前优化过的函数最容易被重构回去

总结:优化完不等于事情做完了。没有回归检测的优化,等于没优化。


Hical — 基于 C++20/26 的现代高性能 Web 框架 | GitHub