C++ 性能分析全景指南:从工具链到方法论

不要凭直觉猜瓶颈——人的直觉在性能问题上错误率极高。先量测,再优化。 —— 这一共识来自 Brendan Gregg《Systems Performance》与多位 CppCon 讲者的反复强调


写在前面

性能优化是 C++ 程序员的核心竞争力之一。但"性能优化"这四个字太大了——从微架构级的 cache line 对齐,到宏观的算法复杂度选择,中间跨越了多个抽象层次。

这篇文章不是某个工具的使用教程,而是试图建立一套完整的性能分析知识框架:遇到性能问题时,你该用什么工具、看什么指标、按什么思路排查。全文分为十二个部分:

  1. 核心思维
  2. CPU Profiling
  3. 内存分析
  4. 编译优化分析
  5. Benchmark 编写
  6. 并发与锁分析
  7. Sanitizer 全家桶
  8. 优化决策方法论
  9. 工具选择与学习路线
  10. Windows 平台工具链
  11. Lua 层性能分析
  12. 游戏服务器专项性能分析

说明:前九节以 Linux 工具为例讲解通用原理(这些知识跨平台有效),第十节为 Windows/MSVC 专项,第十一、十二节针对本项目的 Lua 层与游戏服务器特性。


一、核心思维

1.1 性能问题的三种类型

所有性能问题,本质上只有三类:

类型表现典型原因
CPU-boundCPU 利用率高,但吞吐上不去算法复杂度高、分支预测失败、指令级并行度低
Memory-boundCPU 利用率不高(在等数据),IPC 低缓存未命中、TLB miss、false sharing、频繁堆分配
I/O-boundCPU 几乎空闲,程序却很慢磁盘读写、网络等待、锁竞争(广义 I/O)

判断当前程序属于哪一类,是性能分析的第一步。用错了工具,你会在错误的方向上浪费大量时间。

1.2 Amdahl 定律的启示

优化一个占总耗时 5% 的函数,即使你把它优化到 0,整体也只快 5%。但优化一个占 60% 的函数,哪怕只快 20%,整体就快 12%。

永远先找大头。这就是为什么 profiling 必须走在优化前面。

1.3 量测的四条铁律

  1. 在接近生产环境的条件下量测——Debug 模式的热点分布和 Release 完全不同
  2. 量测时关闭无关进程——CPU 频率调节(turbo boost / power saving)会干扰结果
  3. 多次量测取统计值——单次运行的噪声太大,至少跑 3 次取中位数
  4. 量测前后只改一个变量——否则你不知道是哪个改动起了作用

二、CPU Profiling

CPU 剖析是性能分析的基础。根据实现方式不同,分为采样式插桩式两大类。

2.1 采样式剖析(Sampling Profiler)

原理:以固定频率(通常 99Hz 或 997Hz)中断目标程序,记录当时的调用栈。运行结束后统计每个函数出现在栈顶(或栈中)的次数,得出热点分布。

优势:开销极低(通常 < 2%),可用于生产环境。 劣势:统计精度取决于采样次数,短函数可能被"漏掉"。

主流采样式工具

工具平台特点
perfLinux内核级,开销最低,支持硬件 PMU 事件
Intel VTune全平台硬件计数器支持最好,GUI 丰富
Visual Studio ProfilerWindowsIDE 集成,零配置上手
gperftools (pprof)全平台Google 出品,LD_PRELOAD 注入,输出格式通用
Tracy全平台游戏行业常用,纳秒级精度,实时可视化
InstrumentsmacOSXcode 自带 Time Profiler

perf 实战流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 第一步:编译时保留符号(Release 级优化 + 调试信息)
cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo

# 第二步:采样记录
# -F 97:采样频率 97Hz(用 97 而非 100,避免与系统时钟谐振)
# -g --call-graph dwarf:采集完整调用栈(dwarf 比 fp 更准确)
perf record -F 99 -g --call-graph dwarf ./build/my_server

# 第三步:在 TUI 中查看报告
perf report --no-children
# --no-children:只看函数自身 CPU 占比(self%),不含子函数
# 默认的 children% 可能误导你以为 main() 是热点

为什么用 97Hz 而不是 100Hz? 如果采样频率恰好是系统定时器周期(通常 100Hz / 250Hz / 1000Hz)的整数倍,会反复命中同一个代码位置(lockstep 效应),导致结果偏斜。 正确原则是让采样频率与系统时钟互质(没有大于 1 的公因数),例如 97Hz、997Hz。顺带说明:99 不是质数(3×3×11),它只是恰好与 100 互质所以也能用——“质数频率"的说法并不严谨。

火焰图(Flame Graph)

火焰图是 perf 数据最直观的可视化方式,由 Brendan Gregg 在 2011 年前后提出并推广。

1
2
# 从 perf 数据生成火焰图
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

读图方法

1
2
3
4
5
6
         ┌───────────────── func_A() ──────────────────┐
         │ ┌─── func_B() ───┐ ┌────── func_C() ──────┐│
         │ │ ┌─ func_D() ─┐ │ │ ┌─── func_E() ───┐  ││
         │ │ └─────────────┘ │ │ └─────────────────┘  ││
         │ └─────────────────┘ └──────────────────────┘│
         └─────────────────────────────────────────────┘
  • X 轴:函数在采样中出现的比例。不是时间线,字母排序只是为了视觉稳定
  • Y 轴:调用栈深度,底部是调用者,顶部是被调用者
  • 看宽度:越宽 = 采样越多 = 越热 = 越可能是瓶颈
  • 看平顶:顶部宽的函数,说明自身耗时大(self time 高)
  • 看底部:底部宽说明整条调用路径累计耗时大

实际例子

如果你看到火焰图顶部有一大块 __memcpy_avx_unaligned,说明程序在大量拷贝内存。如果紧挨着的还有 std::string::_M_create,那很可能是频繁创建临时字符串导致的。

2.2 插桩式剖析(Instrumentation Profiler)

原理:在函数入口和出口插入计时代码,精确记录每次调用的耗时。

优势:精确到单次调用,不会漏掉短函数。 劣势:开销大(10%-100%),会改变程序行为(探针效应 / Heisenbug)。

手动 RAII 计时器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <chrono>
#include <cstdio>

class ScopedTimer
{
public:
    explicit ScopedTimer(const char* name)
        : m_name(name)
        , m_start(std::chrono::steady_clock::now())
    {
    }

    ~ScopedTimer()
    {
        auto elapsed = std::chrono::steady_clock::now() - m_start;
        auto us = std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
        printf("[%s] %lld us\n", m_name, static_cast<long long>(us));
    }

    ScopedTimer(const ScopedTimer&) = delete;
    ScopedTimer& operator=(const ScopedTimer&) = delete;

private:
    const char* m_name;
    std::chrono::steady_clock::time_point m_start;
};

// 使用:作用域结束时自动打印耗时
void handleRequest()
{
    ScopedTimer timer("handleRequest");
    // ... 业务逻辑 ...
}

注意:用 steady_clock 而非 high_resolution_clock。后者在某些平台可能不是单调的(被 NTP 调整),而性能量测需要单调时钟。

编译器自动插桩

1
2
3
4
5
6
7
# GCC/Clang 提供 -finstrument-functions 选项
# 每个函数入口/出口会自动调用:
#   __cyg_profile_func_enter(void *this_fn, void *call_site)
#   __cyg_profile_func_exit(void *this_fn, void *call_site)
g++ -finstrument-functions -g my_code.cpp -o my_program

# 你需要自己实现这两个函数来记录数据

这种方式全自动,但会插桩所有函数(包括 getter/setter),开销很大。可以用 __attribute__((no_instrument_function)) 豁免特定函数。

Tracy Profiler(游戏行业标配)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <tracy/Tracy.hpp>

void handleRequest()
{
    ZoneScoped;  // 自动记录当前作用域
    // ... 业务逻辑 ...

    {
        ZoneScopedN("parse_headers");  // 命名子区域
        parseHeaders();
    }
}

// main 入口
int main()
{
    while (running)
    {
        FrameMark;  // 标记帧边界
        update();
        render();
    }
}

Tracy 的强项是实时可视化:连接到正在运行的程序,看到纳秒级的时间线、内存分配追踪、锁等待分析,全部在一个 GUI 里。开销大约 1-5%,适合开发阶段常驻。

2.3 硬件性能计数器(PMU / Hardware Counters)

现代 CPU 内置了几十到几百个性能计数器(Performance Monitoring Unit),可以统计微架构级别的事件。这是判断 CPU-bound vs Memory-bound 的核心手段。

perf stat:快速总览

1
perf stat ./build/my_server <<< "quick_test_input"

典型输出:

1
2
3
4
5
6
7
8
9
 Performance counter stats for './build/my_server':

     1,234,567,890      instructions      #    1.23  insn per cycle
     1,002,345,678      cycles
        12,345,678      cache-misses      #    3.2 % of all cache refs
       385,432,100      cache-references
         5,678,901      branch-misses     #    0.5 % of all branches
     1,135,780,200      branches
             2.34 seconds time elapsed

关键指标速查

指标含义健康值异常说明
IPC (Instructions/Cycle)每个时钟周期执行的指令数> 2.0 好< 1.0 说明 CPU 严重 stall
L1 cache miss rate一级数据缓存未命中率< 5%高则说明数据局部性差
LLC (Last Level Cache) miss rate最后一级缓存未命中率< 1%高则每次 miss 要到内存取数据(~100ns)
Branch miss rate分支预测失败率< 2%高则可考虑 branchless 写法
TLB miss页表缓存未命中极少出现出现说明内存布局极度分散或大页未启用

定向分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 缓存分析
perf stat -e cache-references,cache-misses,\
            L1-dcache-loads,L1-dcache-load-misses,\
            LLC-loads,LLC-load-misses \
         ./build/my_program

# 分支预测分析
perf stat -e branches,branch-misses ./build/my_program

# 指令级分析(查看哪些指令类型最多)
perf stat -e instructions,cycles,\
            stalled-cycles-frontend,stalled-cycles-backend \
         ./build/my_program

IPC 诊断流程

1
2
3
4
5
6
7
IPC < 1.0?
├── stalled-cycles-backend 高 → Memory-bound
│   ├── LLC-miss 高 → 数据不在缓存,查数据局部性
│   └── LLC-miss 低 → L1/L2 miss 或 store buffer 满
└── stalled-cycles-frontend 高 → 指令获取慢
    ├── I-cache miss 高 → 代码体积太大(模板膨胀?)
    └── branch-miss 高 → 分支预测失败,查 if/switch 逻辑

三、内存分析

3.1 为什么内存是 C++ 性能的关键

两个事实:

  1. 内存延迟远大于 CPU 速度:L1 缓存 ~1ns,主内存 ~100ns。一次 cache miss 浪费的时间,够 CPU 执行 100-300 条指令
  2. 堆分配有隐性开销:每次 new/malloc 可能触发系统调用、分配器锁竞争、内存碎片

C++ 程序性能差,内存问题的概率比你想象的高得多。

3.2 内存分配剖析

Heaptrack(推荐,Linux)

1
2
3
4
5
# 记录所有 malloc/free 调用
heaptrack ./build/my_server

# 用 GUI 分析
heaptrack_gui heaptrack.my_server.*.gz

Heaptrack 输出的关键信息

  • 总分配次数 / 总分配字节——一个 HTTP 请求分配了多少次?
  • 分配热点函数——哪个函数分配最多?(按次数和字节分别排序)
  • 峰值内存使用——有没有内存暴涨?
  • 临时分配——alloc 后很快 free 的(< 1ms),这些是优化重点。每次临时分配都意味着白白跑了一趟分配器

Massif(Valgrind 组件)

1
2
3
4
5
# 堆使用快照
valgrind --tool=massif --pages-as-heap=no ./build/my_program

# 文本报告
ms_print massif.out.12345

Massif 会生成堆使用的时间线图(ASCII art),能看出内存是平稳的、缓慢增长的还是锯齿形的。

3.3 内存泄漏检测

AddressSanitizer(ASan)——编译时方案,推荐

1
2
3
4
cmake -B build -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer"
cmake --build build
./build/my_program  # 泄漏在程序退出时报告

ASan 报告示例:

1
2
3
4
5
6
7
8
==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 1024 byte(s) in 1 object(s) allocated from:
    #0 0x7f... in operator new(unsigned long) (/usr/lib/...)
    #1 0x40... in MyClass::init() (src/my_class.cpp:42)
    #2 0x40... in main (src/main.cpp:10)

SUMMARY: AddressSanitizer: 1024 byte(s) leaked in 1 allocation(s).

ASan 的开销约 2x,远小于 Valgrind(10-50x),而且检测范围更广:越界访问、use-after-free、double-free、stack buffer overflow 都能抓到。

Valgrind memcheck——运行时方案

1
valgrind --leak-check=full --show-leak-kinds=all ./build/my_program

优点是不需要重新编译,但速度慢 10-50 倍,只适合离线检测。

3.4 缓存友好性分析

数据布局:AoS vs SoA

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ========================================
// Array of Structs (AoS)
// ========================================
struct Entity
{
    float x, y, z;        // 12 bytes
    float health;         // 4 bytes
    int   id;             // 4 bytes
    char  name[64];       // 64 bytes
};
// sizeof(Entity) = 88 bytes

std::vector<Entity> entities(10000);

// 遍历所有实体的 health:
for (auto& e : entities)
{
    if (e.health < 50.0f) heal(e);
}
// 每个 Entity 88B,跨 2 个 cache line(64B),读一个 health 字段要搬 2 行共 128B
// 但你只需要 health 字段(4 bytes)
// 缓存利用率:4/128 ≈ 3.1%

// ========================================
// Struct of Arrays (SoA)
// ========================================
struct Entities
{
    std::vector<float> x, y, z;
    std::vector<float> health;
    std::vector<int>   id;
    std::vector<std::array<char, 64>> name;  // 固定长度数组,保持连续
    // 注意:不要用 std::vector<std::string> 存 name——
    // std::string 是堆分配、数据不连续,会破坏 SoA 的缓存友好性
};

Entities entities;
// entities.health 是连续的 float 数组
// 遍历时每个 cache line 装 16 个 health 值
// 缓存利用率:100%

什么时候用 SoA:当你频繁遍历某一个字段而不是整个 struct 时。游戏中的 ECS(Entity Component System)架构就是基于这个原理。

什么时候 AoS 更好:当你总是同时访问一个对象的多个字段时(比如渲染管线中同时需要位置+法线+UV),AoS 保证了单个对象的数据局部性。

Cachegrind——缓存行为模拟

1
2
valgrind --tool=cachegrind ./build/my_program
cg_annotate cachegrind.out.12345

Cachegrind 会模拟 CPU 缓存,报告每一行代码的 cache miss 次数。精度高但速度极慢(50-100x),适合小程序或单元测试。

perf c2c——False Sharing 检测

1
2
perf c2c record -- ./build/my_server
perf c2c report

False Sharing(伪共享) 是多线程程序的隐形杀手:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// BAD:两个线程频繁写同一 cache line 的不同变量
struct Counters
{
    std::atomic<int> threadACounter;  // 偏移 0
    std::atomic<int> threadBCounter;  // 偏移 4
    // 同一个 64 字节 cache line!
    // 线程 A 写 threadACounter 时,线程 B 的缓存行被 invalidate
    // 反之亦然——两个线程在争抢一条缓存行的所有权
};

// GOOD:对齐到不同 cache line
struct Counters
{
    alignas(64) std::atomic<int> threadACounter;
    alignas(64) std::atomic<int> threadBCounter;
};

// C++17 也可以用 std::hardware_destructive_interference_size
// (MSVC 2022 / GCC 12+ / Clang 15+ 均已实现)
// 跨平台代码仍建议保留 alignas(64) 作为后备

如何发现 false sharing

  1. perf c2c 报告中寻找 “Shared Data Cache Line Table”
  2. 如果某个 cache line 上有多个线程的 store 操作,且 HITM(命中已修改行)次数高,就是 false sharing
  3. pahole 工具查看 struct 成员的偏移量,确认热成员是否落在同一 cache line

3.5 减少堆分配的常用手法

场景手法
短生命周期对象大量分配std::pmr::monotonic_buffer_resource(请求级内存池)
固定数量的同类型对象对象池(slab allocator)
std::string 大量创建std::string_view(只读场景)、SSO(小字符串优化:MSVC/GCC <= 15 字节不分配堆,Clang libc++ <= 22)
std::vector 频繁增长reserve() 预分配
函数返回大对象依赖 NRVO(Named Return Value Optimization),不要手动 std::move 返回值
std::map / std::set改用 std::unordered_map(减少节点分配),或 flat_map(C++23,先确认编译器实现质量)
临时 buffer栈上 std::arrayalloca,避免堆分配

四、编译优化分析

4.1 优化级别

级别含义典型用途
-O0无优化,变量保留在内存中调试(断点/单步最准确)
-O1基础优化,不增加编译时间的优化调试 + 可接受性能
-O2标准优化,几乎所有不增加代码体积的优化生产环境推荐
-O3激进优化,含自动向量化、循环展开计算密集型场景
-Os优化代码体积(有时反而因 icache 友好而更快)嵌入式 / 缓存敏感
-Ofast-O3 + -ffast-math(放宽浮点语义)科学计算(注意 NaN/Inf 行为变化)

-O2 vs -O3 的选择:对大多数服务器程序,-O2 就够了。-O3 增加的向量化和循环展开会膨胀代码体积,可能导致 icache miss 增加。实测后再决定。

4.2 查看编译器优化结果

1
2
3
4
5
6
# 生成汇编(Intel 语法,更易读)
g++ -O2 -S -masm=intel my_code.cpp -o my_code.s

# 只看某个函数的汇编
g++ -O2 -S -masm=intel my_code.cpp -o /dev/stdout | \
    sed -n '/^handleRequest/,/^[^.]/p'

更方便的方式是使用 Compiler Explorergodbolt.org):在线对比不同编译器、不同优化级别的汇编输出,还能高亮源代码和汇编的对应关系。

4.3 Profile-Guided Optimization (PGO)

PGO 用实际运行数据指导编译器做出更好的决策:哪些分支更常走、哪些函数值得内联、哪些循环值得展开。效果通常在 5%-30% 之间,对分支密集型代码效果尤其显著。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# ==========================================
# 步骤 1:插桩编译
# ==========================================
cmake -B build-pgo-gen -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_CXX_FLAGS="-fprofile-generate=/tmp/pgo-data"
cmake --build build-pgo-gen

# ==========================================
# 步骤 2:用典型负载运行(生成 .gcda / .profraw 文件)
# ==========================================
./build-pgo-gen/my_server &
SERVER_PID=$!

# 发送真实或模拟的请求(覆盖主要路径)
wrk -t4 -c100 -d30s http://localhost:8080/
wrk -t4 -c100 -d30s http://localhost:8080/api/users
# ... 其他关键路径 ...

kill $SERVER_PID
# /tmp/pgo-data/ 下生成了 profile 数据

# ==========================================
# 步骤 3:用 profile 数据重新编译
# ==========================================
cmake -B build-pgo-use -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_CXX_FLAGS="-fprofile-use=/tmp/pgo-data"
cmake --build build-pgo-use

PGO 的注意事项

  • Profile 数据要覆盖真实使用场景,不能只跑 Hello World
  • 代码改动后 profile 数据会部分失效(编译器会 fallback,不会出错)
  • Clang 的 PGO 实现(-fprofile-instr-generate/use)和 GCC 的(-fprofile-generate/use)语法略有不同
  • 可以写进 CI pipeline:定期用 benchmark 生成 profile → 重新编译 → 发布

传统编译模式下,每个 .cpp 独立编译为 .o,编译器看不到跨翻译单元的优化机会。LTO 把优化推迟到链接阶段,此时编译器能看到全部代码:

1
2
3
# CMake 一行开启
cmake -B build -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON

LTO 能做什么

  • 跨文件函数内联(最大收益)
  • 跨文件死代码消除
  • 跨文件的常量传播和折叠
  • 更准确的别名分析

代价:链接时间显著增加(2x-10x),内存占用也增大。大型项目可以用 ThinLTO(-flto=thin)折中。

4.5 编译时间分析

当模板大量使用时,编译本身也可能成为瓶颈:

1
2
3
4
5
6
7
8
9
# Clang 编译时间追踪(生成 JSON 文件)
clang++ -ftime-trace -c heavy_template.cpp
# 生成 heavy_template.json
# 用 chrome://tracing 或 https://ui.perfetto.dev 打开

# 能看到:
# - 每个头文件的 include 耗时
# - 每个模板实例化的耗时
# - 每个函数的代码生成耗时

减少编译时间的常用手法

  • 前向声明:在头文件中用 class Foo; 代替 #include "Foo.h"
  • Pimpl 模式:隔离实现细节,减少头文件依赖
  • extern templateextern template class std::vector<MyType>; 避免在多个翻译单元重复实例化
  • PCH / 模块(C++20 Modules):预编译常用头文件

五、Benchmark 编写

5.1 为什么需要 Micro Benchmark

Profiling 告诉你"哪里慢”,Benchmark 告诉你"改了之后是不是真的快了"。没有 Benchmark,你的优化就是在盲飞。

5.2 Google Benchmark

Google Benchmark 是 C++ 微基准测试的事实标准:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <benchmark/benchmark.h>
#include <vector>
#include <algorithm>
#include <random>

// ==========================================
// 基础用法
// ==========================================
static void BM_VectorPushBack(benchmark::State& state)
{
    for (auto _ : state)
    {
        std::vector<int> v;
        for (int i = 0; i < state.range(0); ++i)
        {
            v.push_back(i);
        }
        benchmark::DoNotOptimize(v.data());
    }
    state.SetComplexityN(state.range(0));
}
BENCHMARK(BM_VectorPushBack)->Range(8, 1 << 20)->Complexity();

// ==========================================
// 对比版本:预分配
// ==========================================
static void BM_VectorReserved(benchmark::State& state)
{
    for (auto _ : state)
    {
        std::vector<int> v;
        v.reserve(state.range(0));
        for (int i = 0; i < state.range(0); ++i)
        {
            v.push_back(i);
        }
        benchmark::DoNotOptimize(v.data());
    }
    state.SetComplexityN(state.range(0));
}
BENCHMARK(BM_VectorReserved)->Range(8, 1 << 20)->Complexity();

BENCHMARK_MAIN();

输出类似:

1
2
3
4
5
6
7
8
9
-----------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-----------------------------------------------------------
BM_VectorPushBack/8        45.2 ns         45.0 ns   15534262
BM_VectorPushBack/1024     8234 ns         8215 ns      85147
BM_VectorPushBack/1048576  12.3 ms         12.2 ms         57
BM_VectorReserved/8        32.1 ns         32.0 ns   21847523
BM_VectorReserved/1024     2156 ns         2150 ns     325478
BM_VectorReserved/1048576  3.45 ms         3.44 ms        203

5.3 Benchmark 编写的关键陷阱

陷阱 1:编译器把你的代码优化没了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// BAD:编译器发现 result 没有被使用,直接消除整个计算
static void BM_Bad(benchmark::State& state)
{
    for (auto _ : state)
    {
        int result = expensiveComputation();
        // result 未使用 → 编译器优化掉 → 测出来 0ns
    }
}

// GOOD:用 DoNotOptimize 告诉编译器"这个值有副作用"
static void BM_Good(benchmark::State& state)
{
    for (auto _ : state)
    {
        int result = expensiveComputation();
        benchmark::DoNotOptimize(result);
    }
}

// 如果是修改了内存中的数据结构:
static void BM_InPlace(benchmark::State& state)
{
    std::vector<int> data(1024);
    for (auto _ : state)
    {
        modifyInPlace(data);
        benchmark::ClobberMemory();  // 告诉编译器"所有内存都可能被修改了"
    }
}

陷阱 2:Setup 时间混入量测

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
static void BM_Sort(benchmark::State& state)
{
    std::mt19937 rng(42);
    std::uniform_int_distribution<int> dist(0, 1000000);

    for (auto _ : state)
    {
        // 暂停计时:生成随机数据不是我们要测的
        state.PauseTiming();
        std::vector<int> data(state.range(0));
        std::generate(data.begin(), data.end(), [&] { return dist(rng); });
        state.ResumeTiming();

        // 只测排序
        std::sort(data.begin(), data.end());
        benchmark::DoNotOptimize(data.data());
    }
}
BENCHMARK(BM_Sort)->Range(1024, 1 << 20);

注意PauseTiming()/ResumeTiming() 本身有开销(涉及锁与状态同步,约数百 ns ~ 数 us)。如果被测代码耗时在 us 量级,pause/resume 的噪声会淹没信号。此时应把 setup 移到循环外。

陷阱 3:没有报告吞吐量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static void BM_ParseJson(benchmark::State& state)
{
    std::string input = loadTestJson();  // 4KB JSON

    for (auto _ : state)
    {
        auto result = parseJson(input);
        benchmark::DoNotOptimize(result);
    }

    // 报告字节吞吐量——比纯 ns/op 更有意义
    state.SetBytesProcessed(
        static_cast<int64_t>(state.iterations()) * input.size()
    );
}
// 输出会多一列:xxx MB/s

5.4 nanobench(轻量替代)

如果觉得 Google Benchmark 太重(需要编译链接库),可以用 nanobench——单头文件,拖进项目就能用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define ANKERL_NANOBENCH_IMPLEMENT
#include <nanobench.h>

int main()
{
    std::vector<int> data(10000);
    std::iota(data.begin(), data.end(), 0);

    ankerl::nanobench::Bench()
        .title("sorting algorithms")
        .relative(true)  // 第一个测试为基准,后续显示相对值
        .run("std::sort", [&]
        {
            auto copy = data;
            std::sort(copy.begin(), copy.end());
            ankerl::nanobench::doNotOptimizeAway(copy.data());
        })
        .run("std::stable_sort", [&]
        {
            auto copy = data;
            std::stable_sort(copy.begin(), copy.end());
            ankerl::nanobench::doNotOptimizeAway(copy.data());
        });
}

nanobench 还会自动检测量测稳定性,如果 variance 太大会警告你。

5.5 在线 Benchmark 工具

  • Quick C++ Benchmarkquick-bench.com):在线跑 Google Benchmark,支持对比多个实现,生成柱状图。适合快速验证想法
  • Compiler Explorergodbolt.org):虽然主要看汇编,但也能辅助判断编译器是否做了你期望的优化

六、并发与锁分析

6.1 锁竞争分析

锁竞争是服务器程序最常见的扩展性杀手。4 核时性能线性增长,16 核时反而更慢——通常就是锁竞争。

perf lock

1
2
3
4
5
perf lock record -- ./build/my_server &
# 发送负载...
kill %1

perf lock report

输出会列出每个锁的等待次数、等待时间、持有时间,帮你找到竞争最激烈的锁。

Mutrace(Linux)

1
2
# 无需重新编译,LD_PRELOAD 注入
LD_PRELOAD=/usr/lib/libmutrace.so ./build/my_server

Mutrace 在程序退出时打印 mutex 统计报告:

1
2
3
4
5
6
7
mutrace: 3 mutexes used.

Mutex #0 (0x7f...) first used at src/session.cpp:42
  Locked 1,234,567 times
  Contended 23,456 times (1.9%)
  Avg wait time: 12.3 us
  Max wait time: 456.7 us

contended 比例超过 5% 就值得优化了。

6.2 常见并发性能问题及优化

问题 1:锁粒度太粗

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// BAD:整个函数加锁
std::mutex m_mutex;

void processRequest(Request& req)
{
    std::lock_guard lock(m_mutex);
    auto headers = parseHeaders(req);     // 不需要锁
    auto auth = validateAuth(headers);    // 不需要锁
    updateSessionStore(auth);             // 只有这里需要锁
    auto response = buildResponse(auth);  // 不需要锁
    sendResponse(response);              // 不需要锁
}

// GOOD:最小化临界区
void processRequest(Request& req)
{
    auto headers = parseHeaders(req);
    auto auth = validateAuth(headers);
    {
        std::lock_guard lock(m_mutex);
        updateSessionStore(auth);  // 临界区只包含必须互斥的操作
    }
    auto response = buildResponse(auth);
    sendResponse(response);
}

问题 2:读多写少场景使用互斥锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// BAD:用 mutex 保护一个 99% 时间在读的缓存
std::mutex m_mutex;
std::unordered_map<std::string, CacheEntry> m_cache;

CacheEntry get(const std::string& key)
{
    std::lock_guard lock(m_mutex);  // 读操作也要互斥等待
    return m_cache[key];
}

// GOOD:用 shared_mutex(读写锁)
std::shared_mutex m_rwMutex;

CacheEntry get(const std::string& key)
{
    std::shared_lock lock(m_rwMutex);  // 多个读者可以同时进入
    auto it = m_cache.find(key);
    return it != m_cache.end() ? it->second : CacheEntry{};
}

void set(const std::string& key, CacheEntry value)
{
    std::unique_lock lock(m_rwMutex);  // 写者独占
    m_cache[key] = std::move(value);
}

> **注意**:`shared_mutex` 内部状态比 `mutex` 复杂,`shared_lock` 的获取开销也更高。当临界区很小(< 100ns 的简单操作)时,读写锁往往**** `std::mutex` 更慢。用之前务必用 Benchmark 实测,不要盲信"读写锁一定更快"

问题 3:原子操作的隐性开销

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// BAD:每次请求都原子递增全局计数器
std::atomic<uint64_t> g_totalRequests{0};

void handleRequest()
{
    g_totalRequests.fetch_add(1, std::memory_order_seq_cst);
    // seq_cst 是最强的内存序,会产生内存屏障(mfence)
}

// GOOD:用宽松内存序(如果只是统计,不需要和其他操作同步)
void handleRequest()
{
    g_totalRequests.fetch_add(1, std::memory_order_relaxed);
    // relaxed 只要求编译器不额外插入屏障指令;
    // 但 x86 的原子 RMW 指令(lock add)本身隐含硬件级的内存排序
    // 真正"无同步"的原子操作只有 relaxed 的 load/store
}

// BETTER:线程局部累积 + 定期汇总(完全无竞争)
thread_local uint64_t tLocalCount = 0;

void handleRequest()
{
    ++tLocalCount;  // 无原子操作,纯寄存器操作
}

// 定期汇总(比如每秒一次)
uint64_t collectTotal()
{
    // 遍历所有线程的 thread_local 累加
}

6.3 ThreadSanitizer(TSan)

TSan 不是性能工具,而是正确性工具——但数据竞争往往导致间歇性性能问题(CPU 缓存一致性协议疲于奔命),所以放在这里一并介绍。

1
2
3
4
cmake -B build -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_CXX_FLAGS="-fsanitize=thread"
cmake --build build
./build/my_program

TSan 报告示例:

1
2
3
4
5
6
WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 4 at 0x7f... by thread T2:
    #0 MyClass::update() src/my_class.cpp:67

  Previous read of size 4 at 0x7f... by thread T1:
    #0 MyClass::getValue() src/my_class.cpp:23

注意:TSan 和 ASan 不能同时启用,需要分别编译运行。TSan 的运行时开销约 5-15x。


七、Sanitizer 全家桶

Sanitizer 是现代 C++ 开发的安全网。虽然不全是"性能"工具,但它们能捕获导致性能问题的 bug(未定义行为可能让编译器生成意外代码)。

7.1 四大 Sanitizer 一览

Sanitizer编译标志检测内容运行时开销
ASan-fsanitize=address越界访问、use-after-free、double-free、内存泄漏~2x
MSan-fsanitize=memory使用未初始化的内存~3x
TSan-fsanitize=thread数据竞争、死锁检测~5-15x
UBSan-fsanitize=undefined整数溢出、空指针解引用、移位越界等未定义行为< 1.5x

7.2 组合使用规则

1
2
3
4
5
6
7
8
9
# ASan + UBSan:可以同时开启(推荐组合)
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"

# TSan:必须单独使用(和 ASan 冲突)
cmake -DCMAKE_CXX_FLAGS="-fsanitize=thread"

# MSan:必须单独使用,且所有依赖库也要用 MSan 编译
# (实际操作中很难满足,通常只在特定场景使用)
cmake -DCMAKE_CXX_FLAGS="-fsanitize=memory"

7.3 CI 集成建议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 典型的 CI 矩阵
jobs:
  build-release:
    # 正常 Release 编译 + 测试
    cmake -DCMAKE_BUILD_TYPE=Release

  build-asan:
    # ASan + UBSan 检测内存错误和未定义行为
    cmake -DCMAKE_BUILD_TYPE=Debug \
          -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"

  build-tsan:
    # TSan 检测数据竞争(单独一个 job)
    cmake -DCMAKE_BUILD_TYPE=Debug \
          -DCMAKE_CXX_FLAGS="-fsanitize=thread"

每次 push 都跑 Sanitizer,能在 bug 引入的第一时间抓到,而不是等到线上出 core dump。


八、优化决策方法论

工具和技巧再多,如果没有正确的决策框架,也只是在做布朗运动。

8.1 优化前的 Checklist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 1. 有量测数据证明这是瓶颈吗?
     如果没有  不要优化。最常见的浪费就是优化不是瓶颈的代码

 2. 算法复杂度对吗?
     O(N²)  O(N log N) 的收益,比任何微优化大 100 
     先看大 O,再看常数因子

 3. 有没有不必要的内存分配?
     string 拷贝、临时对象、容器 realloc
      Heaptrack 看一个请求分配了多少次

 4. 数据布局是否缓存友好?
     热数据 / 冷数据分离了吗?遍历模式和内存布局匹配吗?
     IPC < 1.0  cache-miss   改数据布局

 5. 有没有不必要的同步?
     锁粒度是否最小?原子操作的内存序是否过强?
     能否用 thread_local 消除竞争?

 6. 编译器能帮你吗?
     PGO 试了吗?LTO 开了吗?-O2 了吗?
     这些是免费的性能(只需要改构建配置)

 7. I/O 模式对吗?
     批量 vs 逐条?异步 vs 同步?
     批量写:POSIX `writev` / Windows `WSASend`scatter/gather
     零拷贝:POSIX `sendfile`/`splice` / Windows `TransmitFile`

 8. DB / 存储访问对吗?
     循环里逐条 SELECT 是不是 ODB lazy loading 触发的 N+1
     查询能否批量 / 合并?连接池是否过小导致排队等待?
     慢查询有没有走 MySQL slow query log 定位?

8.2 高频优化手法速查表

场景原因手法
std::string 大量拷贝堆分配 + memcpystd::string_view(只读)、move 语义、SSO
容器频繁 realloc指数增长策略导致多次拷贝reserve() 预分配
短生命周期对象分配器锁竞争、碎片化PMR monotonic buffer、栈分配
虚函数热路径调用间接调用无法内联CRTP 静态多态、if constexpr
频繁 dynamic_castRTTI 查表 + 字符串比较enum type tag + static_cast
std::map 查找慢红黑树 O(log N) + 节点分散std::unordered_map O(1) / flat_map(C++23)
std::shared_ptr 开销原子引用计数确认是否真需要共享,否则 unique_ptr
std::ostringstream 格式化内部多次堆分配std::format(C++20,VS2019 16.10+)/ std::to_chars(C++17)/ 自研栈上 FixedBuffer
系统调用频繁用户态 ↔ 内核态切换批量化(writev)、用户态缓冲
小对象大量 new/delete分配器开销对象池 / pmr::unsynchronized_pool_resource

8.3 优化的层次模型

按收益从大到小排序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌──────────────────────────────────────────────┐
  1. 架构级(最大收益,最早做)                    
     - 同步  异步                                
     - 单线程  多线程(或反过来减少锁)            
     - 网络往返减少(批量/缓存/CDN               
├──────────────────────────────────────────────┤
  2. 算法级                                      
     - O(N²)  O(N log N)  O(1)               
     - 选择更合适的数据结构                        
├──────────────────────────────────────────────┤
  3. 数据级                                      
     - 缓存友好的内存布局(SoA、热冷分离)          
     - 减少堆分配(poolstack alloc             
     - 零拷贝                                     
├──────────────────────────────────────────────┤
  4. 编译器级(免费的午餐)                        
     - 优化级别(-O2/-O3                        
     - PGO / LTO                                 
     - constexpr 计算下推到编译期                  
├──────────────────────────────────────────────┤
  5. 微优化级(最后才做,收益最小)                 
     - branchless 写法                           
     - SIMD 手写向量化                            
     - cache line 对齐                           
     - 汇编级调优                                 
└──────────────────────────────────────────────┘

原则:从上往下优化。先把架构和算法搞对,再考虑缓存和分配,最后才动微优化。在错误的架构上做微优化,就像在沙滩上打地基——再精细也撑不起高楼。


九、工具选择与学习路线

9.1 工具选择决策树

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
需要分析什么?(Linux / Windows 双平台)
├── CPU 热点在哪?
│   ├── Linux     → perf record + 火焰图
│   ├── Windows   → Visual Studio Profiler / ETW + WPA / VTune
│   └── 跨平台    → Tracy(游戏行业标配,实时可视化)
├── 内存分配合理吗?
│   ├── Linux     → Heaptrack(分配热点)/ Massif(峰值快照)
│   ├── Windows   → UMDH(堆分配追踪)/ WPA Heap Analysis / VS 内存快照
│   └── 泄漏检测  → ASan(推荐,双平台)/ Valgrind memcheck(Linux 备选)
├── 缓存命中率如何?
│   ├── 真实硬件  → perf stat / VTune(Linux)| ETW PMC + WPA(Windows)
│   └── 模拟分析  → Cachegrind(双平台,极慢,适合小程序)
├── 有数据竞争吗?
│   ├── Linux     → TSan(唯一靠谱的运行时检测方案)
│   └── Windows   → Intel Inspector / Dr. Memory(MSVC 不支持 TSan)
├── 锁竞争严重吗?
│   ├── Linux     → perf lock / Mutrace
│   └── Windows   → ETW + WPA(Contention Analysis)/ Intel Inspector
├── 有未定义行为吗?
│   ├── Linux     → UBSan(推荐)
│   ├── Windows   → MSVC `/RTC1` 部分替代 / Clang-cl + UBSan
│   └── 通用      → MSVC `/analyze` 静态分析
├── 编译太慢?
│   ├── Clang     → `-ftime-trace`(每个模板实例化耗时一目了然)
│   └── MSVC      → `/d1reportTime`(头文件 include 耗时)/ `/d2cgsummary`(后端优化耗时)/ `/Bt+`(各函数编译耗时)
├── Lua 性能问题?
│   └── 通用      → debug.sethook 采样 / lua_setallocf 分配跟踪 / GC 停顿分析(详见第十一章)
└── 生产环境持续监控?
    ├── Linux     → eBPF / bcc / bpftrace
    └── Windows   → ETW Kernel Trace(WPR + WPA,轻量可常驻)

9.2 推荐学习路线

第一阶段:建立安全网

  • 学会用 Sanitizer(ASan + UBSan + TSan)
  • 在 CI 中集成 Sanitizer
  • 学会写基本的 Google Benchmark
  • 会写 debug.sethook 采样脚本,能定位 Lua 层热点(详见第十一章)

目标:能发现问题、能量化改进。

第二阶段:掌握核心工具

  • perf stat 看硬件计数器,判断 CPU-bound / Memory-bound
  • perf record + 火焰图定位 CPU 热点
  • Heaptrack 分析内存分配
  • 理解缓存层次和 cache line 的影响

目标:能独立完成一次完整的性能分析和优化。

第三阶段:深入微架构

  • 理解 CPU 流水线、乱序执行、分支预测
  • 学会读汇编,理解编译器的优化决策
  • PGO / LTO 调优
  • SIMD 向量化(自动或手动)
  • eBPF 生产环境观测

目标:能做到"知其然且知其所以然"。

9.3 推荐资源

书籍

  • Performance Analysis and Tuning on Modern CPUs — Denis Bakhvalov(免费在线,从硬件原理讲起,强烈推荐)
  • Systems Performance: Enterprise and the Cloud — Brendan Gregg(系统性能分析的"圣经")
  • Computer Systems: A Programmer’s Perspective (CSAPP) — 第五、六章讲存储器层次和优化

演讲(CppCon)

  • Want fast C++? Know your hardware! — Timur Doumler
  • There Are No Zero-cost Abstractions — Chandler Carruth
  • Efficiency with Algorithms, Performance with Data Structures — Chandler Carruth
  • Performance Matters — Emery Berger

在线工具

博客

  • Brendan Gregg 的博客 — 火焰图发明者,perf / eBPF 权威
  • Daniel Lemire 的博客 — 数据密集型计算优化
  • Agner Fog 的优化手册 — x86 微架构最详尽的参考资料

十、Windows 平台工具链

大量 C++ 服务器团队使用 Windows + MSVC 开发。本节补齐 Windows 生态的核心工具,覆盖开发期与生产期。

10.1 Windows 性能工具全景

工具用途特点是否常驻生产
Visual Studio 诊断工具CPU 热区 / 内存快照IDE 内置,调试时零配置启动
ETW + WPA (Windows Performance Kit)全系统性能追踪(CPU/磁盘/网络/内存)内核级 Event Tracing,开销极低,可常驻生产
WPR (Windows Performance Recorder)ETW 录制器GUI 和 CLI 双模式,支持自定义 Profile
PerfView.NET + 原生混合分析Microsoft 出品,GUI 丰富,ETL 文件分析
UMDH (User-Mode Dump Heap)堆分配追踪捕获每个分配的调用栈,定位分配热点
Intel VTuneCPU / 内存 / 线程全栈分析硬件 PMU 支持最强,GUI 强大,跨平台
VS Concurrency Visualizer线程调度 / 锁竞争 / GPUIDE 扩展,可视化线程状态切换
Dr. Memory内存错误检测(TSan Windows 替代)Windows 上替代 TSan,检测未初始化 / 越界 / 泄漏
Intel Inspector内存 + 线程错误检测GUI 工具,检测数据竞争和内存错误,TSan 的替代方案

10.2 ETW + WPA:Windows 最强大的性能框架

ETW(Event Tracing for Windows)是 Windows 的内核级事件追踪系统,现代 Windows 的性能调优几乎离不开它。

核心优势

  • 开销极低——事件在内核缓冲区中暂存,异步写出日志文件。纯 CPU 追踪时开销 < 2%
  • 可常驻生产——不需要 stop-the-world,不影响业务
  • 覆盖面广——CPU Sampling、磁盘 IO、网络、虚拟内存、线程调度、DLL 加载,全部在一个 trace 里

典型使用流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 1. 用 WPR UI 或 CLI 录制(建议 WPR UI 选择 "CPU usage" + "File I/O" Profile)
wpr -start cpu -start fileio

# 2. 运行负载(如游戏压测)
.\ZoneServer_c.exe

# 3. 停止录制
wpr -stop perf_trace.etl

# 4. 用 WPA 打开 .etl 文件分析
wpa.exe perf_trace.etl

WPA 中的关键 Graph

Graph 名称分析什么
CPU Usage (Sampled)调用栈采样,等同 perf report,看热点函数
Service and Region CPU Usage按进程/模块/函数累计 CPU 占用
VirtualAlloc / Heap Usage每次 VirtualAlloc/HeapAlloc 的调用栈与大小
File I/O每次文件读写操作的耗时、大小、文件路径
Hard Faults缺页中断(page fault)热路径,指示内存不足或访问模式差
Thread线程状态切换(Running/Ready/Wait),定位锁竞争和调度延迟
DPC/ISR驱动级别的中断处理耗时(硬件频繁中断会拖累用户态程序)

10.3 VS 诊断工具(开发阶段首选)

Visual Studio 内置的诊断工具在调试模式下即可实时查看 CPU 使用率和内存快照,无需额外配置:

  1. 调试 → Windows → 显示诊断工具(或 Ctrl+Alt+F2)
  2. 断点触发后点击"截取快照"对比堆内存变化

使用场景:开发时怀疑某段代码有性能问题,先挂 VS 诊断看一眼,判断是否值得开更深的分析。

CPU 使用率:报告函数级 CPU 占比(含 Inclusive / Exclusive 两种视图),双击热点函数即可跳转到代码行。

内存快照对比:在操作前后各打一个快照,VS 会自动计算差值,列出新增的堆对象及其分配调用栈。

10.4 UMDH:堆分配热点定位

UMDH(User-Mode Dump Heap)适用于定位"哪些函数分配了最多堆内存":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
REM 启用栈回溯(需要管理员权限)
gflags /i ZoneServer_c.exe +ust

REM 运行服务器,取两个快照(前后对比)
umdh -p:<PID> -f:snapshot1.txt
REM ...执行一段业务逻辑...
umdh -p:<PID> -f:snapshot2.txt

REM 对比两个快照,看增量分配
umdh snapshot1.txt snapshot2.txt > diff.txt

输出会列出每个分配调用栈的增量分配次数增量字节数,让你一眼看出哪个函数在频繁分配。

10.5 Windows 上 Sanitizer 支持的现状

MSVC 和 Windows 生态的 Sanitizer 支持不如 GCC/Clang 完整:

SanitizerMSVC 支持情况替代方案
ASan✅ MSVC 16.9+,/fsanitize=address原生支持,推荐使用;但运行时开销高于 Linux(约 3x-5x,需额外拦截 Windows 堆 API)
UBSan❌ MSVC 不支持(Clang-cl 可部分使用)/RTC1(检测部分栈/未初始化),/analyze 静态分析
TSan❌ MSVC 完全不支持Dr. Memory / Intel Inspector
MSan❌ 不可用别无选择

建议:至少把 ASan 跑在 CI 中。数据竞争可以用代码审查 + 压力测试 + Intel Inspector 兜底。

10.6 Windows 上的 IOCP 性能诊断

游戏服务器大量使用 IOCP(IO Completion Ports),可以通过 ETW 精准分析:

1
2
3
4
5
6
7
8
9
REM 用 xperf(Windows 7/8 SDK)捕获 IOCP 事件
xperf -start IoctlSession -on PROC_THREAD+LOADER+DISK_IO+NETWORK -f iocp.etl
REM 运行负载...
xperf -stop IoctlSession

REM 用 WPA 打开 iocp.etl,关注:
REM - "Thread" Graph:看工作线程是 Running 还是 Waiting
REM - "DPC/ISR":网络中断是否过于频繁
REM - "Disk I/O":DB 写入是否变成了瓶颈

IOCP 常见性能陷阱

  1. 工作线程数不对——经验值 2 * CPU 核心数,但也要看是否阻塞(DB 回调、Lua 执行)
  2. PostQueuedCompletionStatus 频繁调用——每次调用都会产生一次内核 APC 中断,批量优于逐条
  3. GetQueuedCompletionStatusEx 批量取包——单次取多个完成包(可设 64),减少内核↔用户态切换
  4. IOCP Socket 句柄泄漏——句柄未关闭会导致完成端口逐步积累无用条目,增加调度开销

网络性能分析的其他维度

  • TCP 参数调优——TCP_NODELAY(禁用 Nagle,降低小包延迟)、send/recv buffer 大小与延迟/吞吐的权衡
  • 序列化开销——Protobuf 编解码的 CPU 占比用 CPU 采样即可看到;热路径(如高频广播)建议缓存编码结果或复用 buffer
  • 发包策略——合并小包、控制广播频率、用 WSASend 批量发送;观察每个连接的平均包大小
  • 压测基线——用 iPerf / ntttcp 打满带宽作为网络基准,再对比业务负载,区分"网络层慢"还是"业务层慢"

10.7 Windows 独有编译优化选项

项目使用 MSVC,需要了解 MSVC 特有的优化开关:

选项等价于说明
/O2-O2生产环境推荐,最大化速度
/Ox最大优化(含 /GT fiber-safe TLS)/O2 是交集而非超集:/O2 = /Og /Oi /Ot /Oy /Ob2 /GF /Gy/Ox = /Ob2 /Oi /Ot /Oy /GT/O2/GF+/Gy,死代码消除更好,通常作为生产首选
/GLLTO(Whole Program Optimization)跨文件内联、死代码消除,链接变慢
/LTCG链接时代码生成需配合 /GL 使用,跨文件优化不依赖 PGO;变体 /LTCG:PGINSTRUMENT / /LTCG:PGOPTIMIZE 用于 PGO
/arch:AVX2生成 AVX2 指令配合 /Qpar 让 MSVC 自动向量化
/Qpar自动循环并行化对简单 for 循环有效,需 /Qpar-report 确认
/Ob2内联函数展开默认包含在 /O2
/Oy-禁止帧指针省略调试时建议开启,方便栈回溯
/Zo优化后调试信息增强让 Release 版的栈回溯更完整
/d2cgsummary查看编译流水线各阶段耗时查编译瓶颈时用,配合 /Bt+ 加详细

MSVC PGO 流程(与 GCC 思想一致,语法不同):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Step 1: 插桩编译
cmake -B build-pgo -DCMAKE_BUILD_TYPE=Release `
    -DCMAKE_CXX_FLAGS="/GL /LTCG:PGINSTRUMENT"

# Step 2: 运行典型负载,产生 .pgc 文件
./build-pgo/ZoneServer_c.exe

# Step 3: 用 Profile 数据优化编译
cmake -B build-pgo-opt -DCMAKE_BUILD_TYPE=Release `
    -DCMAKE_CXX_FLAGS="/GL /LTCG:PGOPTIMIZE /USEPROFILE"

10.8 生产环境持续监控:ETW Kernel Trace

对于需要长期运行的服务器,可以常驻一个轻量 ETW 会话,定时 dump trace:

1
2
3
4
5
6
7
8
9
# 启动最小开销的内核级 CPU Profile(~1-2% 开销)
wpr -start CPU -start DiskIO -start FileIO

# 运行服务器,正常处理业务...

# 积累半小时后 dump
wpr -stop weekly_trace_$(Get-Date -Format yyyyMMdd_HHmm).etl

# 下次继续

这些 .etl 文件可以在出问题时回放分析(比如服务器突然变慢了,去对比高峰低谷两个 trace 的差异)。

10.9 移植提醒:在 Linux 上线前用 perf 验证

即使日常开发在 Windows,在线上 Linux 环境发布前,用 perf stat 做一个快速健康检查:

1
2
3
4
5
# 在 Linux 上检查整体指标
perf stat -p $(pgrep ZoneServer) -- sleep 30

# 如果 IPC < 1.0 或 LLC miss > 5%,说明 Windows 上没暴露出来的
# 数据局部性问题在 Linux 大核上被放大了

十一、Lua 层性能分析

本项目是 C++ 底层框架 + Lua 业务逻辑 架构,日常性能问题的根因大多在 Lua 层。而 Lua 层的剖析与 C++ 完全不同——没有硬件计数器,热点分析靠解释器自身的能力。

11.1 定位策略:先判断问题在哪一层

跨 C++/Lua 边界的调用有固定成本(LuaBridge 参数转换、栈操作),但真正的热点通常是这两类之一:

  • 纯 Lua 计算——算法低效、表遍历过多、字符串拼接
  • 跨边界调用过频——频繁进出 C++(例如每个实体每 tick 都回调一次 Lua)

第一板斧永远是先量测。用下面的采样器跑一段真实负载,先确认热点在 Lua 还是在 C++,再决定深入方向。

11.2 debug.sethook 采样 Profiler(零依赖)

不需要装任何库,标准库即可实现一个最简采样器:用 debug.sethook 挂一个按行触发(或按指令计数)的 hook,定期记录调用栈。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- 最简单的按行采样器(示例骨架)
local samples = {}
local depth = 0

local function hook()
    depth = depth + 1
    if depth % 100 == 0 then            -- 每 100 行采一次样
        local info = debug.getinfo(2, "Sln")
        if info then
            local key = (info.source or "?") .. ":" .. (info.currentline or "?")
            samples[key] = (samples[key] or 0) + 1
        end
    end
end

debug.sethook(hook, "l")               -- "l" = 每执行一行调用一次

-- 运行被测业务 ...
runRealWorkload()

debug.sethook()                          -- 结束采样

注意:按行 hook 开销巨大(可放大执行时间 5-20 倍),只能用于离线压测,不可用于线上。线上想观测,优先用 C 侧采样(perf / ETW)——它能把 Lua 字节码执行的 C 函数(luaV_executeluaD_call 等)也采到。

解读:采样结果中如果是 luaV_execute 占大头,说明是纯 Lua 计算热;如果某个自定义 C 函数(如 Player::OnTick 的绑定)占大头,说明是跨边界调用热,此时要看:能否减少调用频率、能否把多次小调用合并成一次大调用。

11.3 内存分配跟踪:lua_setallocf

Lua 的内存分配全部经过 lua_State 的分配器。注入自己的分配函数,就能统计每笔分配的字节数,定位"谁在疯狂分配":

1
2
3
4
5
6
7
8
9
// C++ 侧注入分配器(示意)
static void* my_alloc(void* ud, void* ptr, size_t osize, size_t nsize)
{
    if (nsize == 0) { /* free */ }
    else if (ptr == nullptr) { /* malloc,累计 bytes_allocated += nsize */ }
    else { /* realloc,累计 bytes_allocated += (nsize - osize) */ }
    return realloc(ptr, nsize);   // 示例:实际应转发给真实分配器
}
lua_setallocf(L, my_alloc, nullptr);

配合按函数采样,可以回答"这个功能一次调用的总分配量是多少"。Lua 里最常见的性能问题往往不是算法,而是大量小表的临时创建——它们不仅慢,还会加剧 GC 压力。

11.4 GC 停顿分析

Lua 5.4 的 GC 是分步的,但单次 step 仍可能阻塞主线程。监控两个指标:

1
2
print(collectgarbage("count"))          -- 当前 KB 数,看内存水位
print(collectgarbage("incremental", "step", 1024 * 1024))  -- 强制单步,观察耗时

排查套路

  1. 内存水位是否持续增长——如果 collectgarbage("count") 长期不回落,查是否有全局表/闭包泄漏(引用未释放)
  2. GC 是否频繁"卡顿"——如果某些时刻有可见的停顿,尝试调大 collectgarbage("incremental", "stepmul", 200) 或分帧调用,把一次大 GC 拆成多次小 GC
  3. 临时分配是不是元凶——GC 停顿根因往往是短期对象太多。用 11.3 的分配跟踪确认,然后修根因(复用表、缓存结果),而不是只调 GC 参数

热更新下的特殊注意:Lua 热更期间会替换整段代码与闭包,产生大量待回收对象。热更后建议主动触发一次完整 GC(在非玩家高峰时段),避免积压的垃圾在下一个业务高峰集中回收造成尖峰。

11.5 C++ ↔ Lua 跨边界开销

每一次 LuaBridge 调用都有参数转换与表查找成本。量测方式:

1
2
3
4
5
6
-- 用 os.clock 粗测
local t0 = os.clock()
for i = 1, 1000000 do
    obj:GetLevel()      -- 被测的跨边界调用
end
print(os.clock() - t0)

把单次跨边界调用的成本×调用频率,就能算出它对帧预算的占用。典型优化方向

  • 高频只读访问(如 GetLevel())——把数据缓存到 Lua 侧副本,或按帧批量同步
  • 高频小调用——改为一次返回数组/表,减少往返次数
  • 表字段访问——能局部变量化就局部变量化,避免反复 obj.field

11.6 与 C++ 剖析协同

C++ 采样器(perf / ETW)能看到 lua_pcallluaV_execute 的占比;Lua 采样器能看到具体脚本行。两者的组合才是完整图景

  1. 先用 C++ 侧采样,确认 Lua 相关调用(lua_pcall / luaV_execute)占多少 CPU
  2. 若占比高,再上 Lua 侧 hook 采样定位到脚本函数
  3. 修完用 Benchmark(第五章)验证——注意 Lua 侧测速用 os.clock()(Lua 里是 CPU 时间,比 os.time() 精确得多)

十二、游戏服务器专项性能分析

通用工具解决"这段代码慢",本节解决"这个服务器为什么卡"。游戏服务器有自己特有的性能维度:帧耗时、实体规模、内存长跑、压测基线。

12.1 帧耗时 / Tick 剖析

本项目的服务器主循环以 16ms 一拍(约 62.5Hz) 运行,入口在 CLogicThread::run()Server/ZoneServer/Src/LogicThread.cpp:111),通过 WaitForSingleObject(hNetEventHandle, 16) 实现定时唤醒。性能问题的第一现场就是 每 tick 的耗时分布

  • 单帧超时——某一次 tick 超过 16ms 的帧预算,会造成全服卡顿(所有玩家同感)
  • 平均帧时 vs 峰值帧时——平均正常但峰值极高,往往是"尖刺",要抓的是尖刺而不是平均
  • 预算告警——当前已预留 CAPABILITY_ALARM(32ms)阈值,单 tick 任何系统超 32ms 时自动打印 WarningLn

主循环内每 tick 执行的系统(按 OnDo_Ex 调度顺序):

系统函数 / 文件说明
网络引擎CNetworkTask::OnDo_Ex @ ZoneServer/Src/LogicThread.cppIOCP 网络事件分发,含消息解包 + dispatchMessage
时间轴CTimerAxisTask::OnDo_Ex @ ZoneServer/Src/LogicThread.cppTimeAxis->CheckTimer,遍历所有注册定时器
场景服控制器CZoneServerControl::OnDo_Ex @ ZoneServer/Src/ZoneServerControl.cpp运维状态机、统计导出触发

量测做法:本项目已内置两套性能量测基础设施,编译宏控制开关,内网调试打开、外网默认关闭:

  1. PerfStat 模块Server/ZoneServer/Src/PerfStat.h/.cpp)——专为每 tick 逐系统耗时统计设计,提供 Enter(szName)/Exit(szName) 对包裹各系统的执行区间,自动累加平均/峰值/次数,每 5 秒按耗时降序输出到日志(EmphasisLn 流式宏)。开关:#define OPEN_PERFSTATBase/Base/Include/Config.h,默认注释)。
  2. IProfile 机制PP_BY_NAME 宏 + CProfiler)——类似 Tracy 的树状热点统计,已有 BroadcastOptimizeDataBroadcastGreetBroadcastFindPath 等热点打点,支持 WriteLog2CsvFile 导出 CSV。开关:OPEN_PROFILE(已默认开启)+ OPEN_BVTTEST(默认注释,内网调试时打开)。

采集流程:在主循环里用 CPerfStat 包裹各系统,运行业务负载几分钟后,通过 IProfile 的 WriteLog2CsvFile 导出 CSV(触发条件:EZoneServer_StatType_CPU),按耗时排序定位"尖刺"来源。

12.2 AOI 与实体规模

实体数量上去后,最常先撞墙的是这两类。以下基于本项目(远征 Online)源码分析。

AOI(Area of Interest)计算

本项目的 AOI 实现是 九宫格分块索引,复杂度 O(K) 而非 O(N²)

  • 玩家移动触发 CGameZone::MoveEntity()Server/ZoneManager/Src/GameZone.cpp:993),核心在 CompareTwoNineGrid()Server/ZoneManager/Src/Theodolite.cpp:137),只比较新旧九宫格的差异宫格,不遍历全场景实体
  • 网格定位 TileToGrid 是 O(1) 位移操作
  • 已有 Add9GridPersonQty 增量维护九宫格玩家计数
  • 广播链路:CMapGrid::GreetBroadcast() / EchoBroadcast() / DataBroadcast()Server/ZoneManager/Src/MapGrid.cpp),已在 PP_BY_NAME 打点中可观测

属性广播

本项目的属性变更是 定时器合并 + diff 广播,不是每次变化全量广播:

  • 入口 CPersonPropBank::SyncForSetNumPropSith()Server/EntityServer/Src/PersonPropBank.cpp:2770
  • CheckWaitSyncPropBroadcast():2684)实现合并窗口——常规 400ms / 人群密集时 1000ms 内多次 SetNumProp 合并为一次广播
  • 广播前做 diff(:2772),只发变化的属性;BroadcastOptimize()Server/ZoneManager/Src/GameZone.cpp:2003)高密度时有 BO 限流
  • 已有 PP_BY_NAME("CGameZone::BroadcastOptimize") 等热点打点(GameZone.cpp:2004

量测入口(本项目的实际量测手段):

  1. 打开 OPEN_BVTTESTBase/Base/Include/Config.h,默认注释),上述热点函数的 PP_BY_NAME 打点生效,打包到 IProfile 树
  2. 在主循环中启用 CPerfStatOPEN_PERFSTAT),包裹 MoveEntity / SyncForSetNumPropSith 等,导出 CSV(WriteLog2CsvFile,触发条件 EZoneServer_StatType_CPU
  3. 分层压测(100→1000 人在线),看 AOI 广播耗时是否随人数线性增长(O(K) 预期)而非平方级增长(O(N²) 排除项),看属性广播合并率(合并前后广播次数比值)

12.3 Lua 热更新下的性能

热更本身不是性能问题,但热更的时机与后果是:

  • 热更会替换闭包,旧闭包成为待回收垃圾——若在高峰热更,GC 尖峰可能与业务尖峰叠加
  • 热更后新增代码如果分配模式差(大量临时表),会把垃圾问题带进新版本

建议:热更安排在低峰期;热更后主动触发一次 GC(见 11.4);上线前先用 11.3 的分配跟踪过一遍热更新增代码。

12.4 内存长跑:碎片与泄漏

服务器连续运行数周,内存问题不会立刻暴露,而是慢慢恶化

  • 缓慢泄漏——每次请求漏几 KB,跑一个月后 OOM。用 ASan 或 Valgrind 在测试环境抓泄漏点
  • 碎片化——大量小对象分配/释放后,空闲内存虽多但无连续大块。监控:进程内存持续增长但业务对象并未同步增长
  • 池预热——用 PMR / 对象池的模块,冷启动后池是空的,业务高峰时边分配边竞争。建议启动时预热,或用固定大小池减少碎片

监控指标:进程常驻内存(Working Set)的时间序列。若平台内存与业务对象总数不成比例地增长,优先怀疑泄漏或碎片,而不是"玩家变多了"。

12.5 数据库访问(ODB + MySQL)

DB 是本项目最容易出性能事故的地方,因为慢查询的延迟以 ms 计,而内存操作是 ns 计

  • N+1 查询——ODB 的 lazy loading 在循环里逐条 SELECT。用日志或 MySQL general log 看请求期间实际发了多少条 SQL
  • 连接池过小——高并发时连接排队等待。看 DB 连接等待时间
  • 慢查询——开 MySQL slow query log(long_query_time=0.1),定期扫一遍
  • 批量化——多条 INSERT/UPDATE 合并成一条批量语句;读多写少的数据考虑缓存

量测入口:WPA 的 Disk I/O Graph 看 DB 写入是否成瓶颈;DB 侧看 SHOW GLOBAL STATUS LIKE 'Threads_running' 判断连接竞争。

12.6 压测与容量规划

性能分析的最后一步是把结论固化成数字,压测就是干这个的:

  • 定义容量指标——“单服同时在线 X 人,tick 100ms,CPU 占用 < 60%,DB 慢查询 < 1%”
  • 分阶段加压——从 100 人压到 1000 人,每档记录 CPU / 内存 / 网络 / DB 指标,找到拐点
  • 工具——模拟客户端压测脚本(本项目 Bin/Zone/Data/Lua/Test/ 下的测试客户端)、iPerf 测网络基准、wrk 测 HTTP 入口(如有)

压测发现的瓶颈,按第八章的层次模型从架构级往下修;修完重新压测对比,把收益数字记录进文档,形成团队的知识沉淀。



总结

性能优化不是一种"技巧",而是一种思维方式

  1. 先量测——没有数据就没有优化
  2. 找大头——Amdahl 定律告诉你,优化 5% 的热点不如优化 60% 的热点
  3. 从上到下——架构 > 算法 > 数据布局 > 编译器 > 微优化
  4. 验证改进——用 Benchmark 证明你的改动确实有效
  5. 持续监控——性能是回归的,今天的快可能是明天的慢

工具只是手段,关键是建立正确的分析思路。当你能快速判断"这是 CPU-bound 还是 Memory-bound"、“该用 perf 还是 Heaptrack”、“该改算法还是改数据布局"时,你就已经掌握了 C++ 性能分析的核心能力。

对本项目而言,还多一条判断要熟练:问题在 C++ 层还是 Lua 层——跨过 C++/Lua 边界之前先想清楚,常常能省下大量无用功(详见第十一、十二章)。