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::array 或 alloca,避免堆分配

四、编译优化分析

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 Explorer(godbolt.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 template:extern 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++ Benchmark(quick-bench.com):在线跑 Google Benchmark,支持对比多个实现,生成柱状图。适合快速验证想法
  • Compiler Explorer(godbolt.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、热冷分离)          │
│     - 减少堆分配(pool、stack 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_execute、luaD_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_pcall、luaV_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_PERFSTAT(Base/Base/Include/Config.h,默认注释)。
  2. IProfile 机制(PP_BY_NAME 宏 + CProfiler)——类似 Tracy 的树状热点统计,已有 BroadcastOptimize、DataBroadcast、GreetBroadcast、FindPath 等热点打点,支持 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_BVTTEST(Base/Base/Include/Config.h,默认注释),上述热点函数的 PP_BY_NAME 打点生效,打包到 IProfile 树
  2. 在主循环中启用 CPerfStat(OPEN_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 边界之前先想清楚,常常能省下大量无用功(详见第十一、十二章)。