性能优化思路:从测量到代码的五层递进方法论
优化不靠直觉,靠数据。这篇文章是我在给 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 -d | IPC、分支预测率、cache miss | 看整体健康度 |
火焰图怎么看
拿 Hical 实际火焰图数据来说(docker 内 wrk -c10000 -d10s,perf record -g -F 99):
- 宽度 = CPU 占比。一个函数条越宽,占用 CPU 越多。
- 高度 = 调用栈深度。从下往上读:最底层是
__libc_start_main→main→io_context::run→ … → 最顶层是叶子函数。 - 同色块连续出现 = 同一函数在栈的不同位置被调用。
- 只看"自我时间"(self time):函数条本身的宽度,减去它所有子调用条的宽度,才是它自己消耗的 CPU。别把子调用的账算在自己头上。
常见误判:看到一个宽条函数就说是瓶颈——它可能只是调了一个更宽的子函数。真正的热点是最上层宽条里那些自己干活(self time 高)的叶子。
💡 小技巧:
perf record配-g抓栈深度,配-F 99是采样频率 99Hz,对短请求足够;输出直接perf report或导出 SVG 火焰图都行。跑之前记得把采样时长拉够(Hical 是 10s),数据太少看啥都像热点。
优化闭环
| |
缺最后一步等于白做——没数据对比,你不知道改完之后是变快了还是变慢了。每次优化至少要有两个数字:改前 xx,改后 yy,提升 zz%。
什么时候不该优化
在动手之前先问自己三个问题:
- 这代码被调用了多少次? 启动阶段跑一次的初始化函数,优化了也感知不到。
- 优化收益能覆盖复杂度代价吗? 为了省 5ns 把三行代码变成三十行,除非是框架最热路径,否则不值得。
- 有更简单的方案吗? 加缓存、调大超时、减少重试次数——很多性能问题不需要改代码,调个参数就解决了。
这三问不是客套,是真能拦住冲动。Hical 早期我在一个启动时只跑一次的函数上纠结了半小时,后来发现它连 0.1% 的 CPU 都占不到——纯浪费。
性能优化五层模型
把性能优化拆成五个层次来思考:
- 测量 — 用 profiling 工具找到真正的瓶颈在哪。不测量不动手。这一层决定了你花的时间有没有价值。
- 架构级 — 选什么模型、怎么分层、内存怎么管、并发怎么调度。这一层的决定影响全局,后期纠正代价极大。
- (子)系统间 — 模块之间怎么交互:I/O 模型、跨核通信、内核态开销、批处理粒度。单模块再快,交互方式不对也白搭。
- 数据结构与算法 — 用什么容器、什么算法策略。同一个功能,选对数据结构和选错可能差几个数量级。
- 代码级 — 缓存友好、零拷贝、分支预测、编译器指令。这是"最后一公里",在前面四层都做对的前提下榨出剩余性能。
五层不是互相替代,而是层层递进。架构选型错了,代码层优化再多也是杯水车薪。跳过测量直接优化,连是不是杯水车薪都不知道。
我个人的体会是:大部分项目栽在第 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_DATABASE、HICAL_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(AsioEventLoop 的 connectionCount_ 原子变量),避免某个核被大量连接压死而其他核闲着。
内存策略:三级池 + 请求级隔离
为什么 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_ctl、sendto、recvfrom 都要从用户态切到内核态再切回来。切换本身就几百 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)。 TcpCorkGuardRAII(LinuxTCP_CORK/ macOSTCP_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:
NativeRequest存string_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 | 否 | 读极多写极少 | 读完全不阻塞,写延迟释放旧数据 |
选锁的决策树:
- 临界区极短(<500ns)→ spinlock
- 读多写少(读:写 > 10:1)→
shared_mutex - 写也不少 →
mutex - 锁已经是性能瓶颈且 profiler 证实(而非猜测)→ 考虑无锁方案
锁分片(lock sharding): 一把大锁拆成 N 把小锁,并发度提 N 倍:
| |
无锁的陷阱:
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 显式对齐):
| |
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,所有查询仍然可用。
| |
2. EventLoopPool:NUMA 感知绑核
EventLoopPool::start() 里的线程绑核不只是简单的 pthread_setaffinity_np 轮询——它会先把线程均匀分布到各 NUMA 节点,再在节点内轮询绑 CPU:
| |
这样一来,每个 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 节点拿内存:
| |
这比 “每个 NUMA 节点一套独立 PMR 池” 方案更轻量——不拆分池结构,只通过 numa_set_preferred 改变底层 mmap 的策略,内核自动把物理页分配到最近的 DIMM。
还可以做什么
- 连接分发优 NUMA:
getNextLoop()当前用最少连接数策略,可以加上 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 概率低,分支预测友好。
| |
缓存友好的数据布局
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。
| |
循环展开
适用于循环体极短的场景。每次迭代的 i < N、i++、跳转都是开销。一次处理 4 个元素,这些开销除以 4。现代编译器在 -O2 以上会自己做,手动展开主要是为了可读性地标注"这里的关键路径"。
循环中的间接调用:提虚函数和函数指针
这个点比较容易被忽视,但现代编译器对普通函数(非虚)的调用在循环内是直接 call,不需要"每次查表"——编译器在编译期就知道地址。真正需要提出来的是两种情况:
- 虚函数调用:每次迭代都要查虚表(vtable lookup)
- 函数指针调用:每次解引用间接跳转
| |
把函数指针提到循环外面绑定,省掉重复的间接跳转:
| |
对于现代 x86 分支预测器,间接跳转的 BHB(Branch History Buffer)预测准确率很高,所以提出来不一定有明显加速。真正值得做的是 消除虚调用本身,而不是优化它的查找开销。
内联函数
函数调用不是免费的:压栈、传参、跳转、返回、出栈。对于只有一两行的函数,内联把这个开销完全消除。
| |
不是所有函数都该内联。函数体太大,内联后 icache 被撑爆,反而变慢。合适的候选者:getter/setter、简单的数学计算、类型转换。
避免不必要的拷贝
这是 C++ 重灾区。一个看似无害的传参背后可能有一整棵对象的深拷贝。主要武器:
| 武器 | 省了什么 | 注意事项 |
|---|---|---|
const T& | 值传递的拷贝 | 不适用于基础类型(int/指针,传引用反而多一次解引用) |
std::move | 转移深拷贝 | 移动后原对象处于"已移走"状态,只能析构或重新赋值 |
| RVO/NRVO | 返回值的拷贝 | 编译器自动做,不需要手写 std::move 在 return 语句上 |
std::forward<T> | 保持值类别 | 只在模板的 T&& 参数里用 |
string_view | 子串/临时 string | 不持有内存,原字符串释放后是悬空引用 |
span | 容器切片 | 同上,不管理生命周期 |
emplace_back | 临时对象 + 移动 | 构造参数直接转发给容器内对象的构造函数 |
| |
分支预测
现代 CPU 有分支预测器,每个分支都能猜对 95% 以上。但一旦猜错,流水线要清空,代价 15-20 个周期。
优化思路:把最常见的情况放在 if 分支,让预测器的默认策略(“not taken” 或学习模式)更大概率命中。配合 [[likely]] / [[unlikely]](C++20)/ __builtin_expect 给编译器提示。
| |
短路径求值与提前退出
逻辑运算符有短路特性:A && B 在 A 为 false 时不计算 B。所以计算便宜的、更可能失败的检查放前面:
| |
预先计算与查找表
用空间换时间。一些计算密集型函数可以预先算好结果存数组里,运行时直接查表。
经典案例:1993 年 id Software 做 DOOM 时,John Carmack 把正弦值预先算好放在 lookup table 里。当年的 CPU 做一次浮点 sin 可能几十个周期,查表只要几次数组访问。今天同样思路适用于复杂 hash / 编码转换等场景。
| |
这个思路在现代 C++ 里依然适用。比如 HTTP 状态码到 Reason Phrase 的映射——状态码天然是离散的小整数(100-599),用 unordered_map 需要散列计算 + 字符串键查找,但换成跳转表直接 O(1):
| |
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 等),但牺牲跨机器兼容性
编译器指令:
| |
这还不是全部。标准编译选项只能做 “无上下文” 的静态优化——编译器不知道哪些分支热、哪些路径频繁。下面三层递进,每一步都给编译器更多的"上下文信息":
第一层:LTO(链接时优化)
-flto 让编译器推迟到链接阶段再做优化。常规编译是每个 .cpp 各自优化完就不再碰了——函数调用跨了翻译单元边界,编译器看不到调用方的上下文,没法做内联和去重。
LTO 把中间表示(IR)保留到链接阶段,让编译器看到全貌:
- 跨
.cpp的 getter/setter 内联——Hical 框架层大量小函数受益 - 未使用的函数/WPO(whole-program optimization)裁剪——减二进制体积
- 常量传播跨翻译单元——比如
kMaxPathSegments在多个.cpp中使用,LTO 能统一优化
| |
第二层:PGO(性能引导优化)
编译器不知道你的程序实际怎么跑——哪些分支大概率走哪个方向、哪些函数调用频率最高。PGO 给编译器补上这些信息:
| |
注意:
-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 在链接后优化——两者互补:
| |
这属于进阶优化,对大型二进制文件效果更显著。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。
| |
Hical 中 MpscQueue 的 tail_ 指针也用了 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 个字符是不是数字)
编译器辅助向量化:
| |
手动 SIMD 示例(SSE2 找换行符):
| |
Hical 目前没有大面积使用 SIMD,因为性能瓶颈不在字符串解析上(火焰图显示内核 TCP 占 55%,应用层仅 7%)。但在需要做大量 HTTP 头部扫描或 URL 解码的场景下,这是值得考虑的武器。
未来方向:C++26 引入
std::simd,提供可移植的向量化编程接口,不再需要手写平台相关 intrinsics。等项目迁移到 C++26 后可以考虑用std::simd改写解析路径。
代码级优化决策顺序
不是说所有技术都用一遍,而是按收益从大到小排优先级:
- 数据结构选型(O(n²)→O(log n)→O(1))—— 收益可跨数量级,一劳永逸
- 消除不必要拷贝(传引用、move、emplace、string_view)—— 收益可能 2-5×
- 缓存友好布局 + False sharing 修复 —— 收益 10-30%
- 分支预测提示(
[[likely]]/[[unlikely]])—— 收益 5-15%,热路径有效 - 内联 / 循环展开 / 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 里,流程大致是这样:
benchmarkjob:cmake 配HICAL_WITH_BENCHMARK=ON→ 编译bench_router、bench_perfect_hash→ 分别跑,输出 JSON → 用 Python 把两个 JSON 合并成一份results.json- 用
download-artifact把上次存的benchmark-baselineartifact 下载下来 - Python 脚本按 benchmark name 逐个对比 real_time,超过 5% 的标出来
- 最后把本次的
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 秒),确认就绪了再开始压测:
| |
退化阈值设得比 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。优先选这几类:
- QPS 的必经路径:
resolveRoute、dispatchSync、processRequest(前两个已经迁移到 Google Benchmark:bench_router.cpp、bench_perfect_hash.cpp) - P99 的可能瓶颈:
IdleScanner::scan、flushWriteQueue、数据库连接池acquire(目前没有覆盖) - 曾经出过性能问题的:之前优化过的函数最容易被重构回去
总结:优化完不等于事情做完了。没有回归检测的优化,等于没优化。
Hical — 基于 C++20/26 的现代高性能 Web 框架 | GitHub