跳转到主要内容

Cprof C++ Profiling 核心技术

1. 用户态 DWARF 栈回溯 — MemoryOverlay 复合内存

perf_event_open 配置 PERF_SAMPLE_STACK_USER | PERF_SAMPLE_REGS_USER,内核在采样时捕获一份栈快照(从 SP 开始的 ~64KB)和全部寄存器。但 libunwindstack 默认用 CreateProcessMemory(pid) 读活进程内存,采样到回溯之间栈已经被覆盖了,导致只能展开 1-2 帧。

解决方案是自定义 MemoryOverlay 类(继承 unwindstack::Memory):对 [SP, SP+stack_size) 范围内的地址从采样快照读取,其余地址(ELF header、.eh_frame.debug_frame 等 DWARF 数据)回退到 /proc/<pid>/mem。这样既用了时间点一致的栈数据,又能访问 DWARF unwind 信息。

另外用 dyn_size(内核实际写入量)而非 stack_size(buffer 大小),避免读到未初始化数据。

2. 全寄存器采集 — 18 个 x86_64 通用寄存器

perf record --call-graph dwarf 采集所有通用寄存器,因为 DWARF CFI 规则可以引用任意寄存器(尤其是 callee-saved 的 RBX、R12-R15)来计算 CFA 和恢复调用者状态。只采 BP/SP/IP 会导致大量回溯失败。

arch_defs.h 定义了 X86_64_FULL_REGS_MASK(18 个寄存器的 bitmask),内核按 bit 位顺序传递寄存器值,C++ 侧按索引映射到 libunwindstack 的寄存器对象。

3. 跨 mount namespace 符号解析 — /proc/<pid>/root/ 路径重写

容器化进程的 /proc/<pid>/maps 中的路径(如 /lib/x86_64-linux-gnu/libc.so.6)在宿主机上不存在。libunwindstack 打开 ELF 失败后回退到进程内存,但 section headers 通常不在映射范围内,导致 .dynsym/.symtab 不可用。

ProcRootRemoteMaps 类(继承 RemoteMaps)在 Parse() 后遍历所有 map entry,对不可访问的绝对路径尝试 /proc/<pid>/root/ 前缀(通过 procfs 穿透目标进程的 mount namespace)。stack_unwinder_add_map 对 MMAP2 事件也做同样的重写。

4. 内核栈采集 — PERF_SAMPLE_CALLCHAIN + kallsyms 二分查找

perf_event_attr 设置 PERF_SAMPLE_CALLCHAINexclude_kernel=0,内核在采样时附带完整调用链。C++ 层解析 callchain,用 PERF_CONTEXT_KERNEL/PERF_CONTEXT_USER 标记分离内核帧和用户帧。

Go 层启动时用 sync.Once 加载 /proc/kallsyms(只保留 t/T/W/w 类型的代码符号),构建排序数组。解析时对内核 IP 做二分查找(sort.Search),找到最大的 addr <= ip 的符号。缓存用 sync.Map 实现无锁并发读写。

内核帧反转后追加到用户栈根部,形成 user_leaf → ... → user_root → entry_SYSCALL_64 → do_syscall_64 → ... → schedule 的完整调用链。

5. 实时 maps/线程更新 — MMAP2 + FORK/EXIT 事件驱动

perf_event_attr 设置 mmap2=1, task=1,内核在 dlopen/mmap/fork/exit 时生成 PERF_RECORD_MMAP2/PERF_RECORD_FORK/PERF_RECORD_EXIT 事件。

C++ 层从 ring buffer 解析这些事件,Go 层定期 drain:MMAP2 事件通过 stack_unwinder_add_map 增量更新 maps(批量添加后一次 Sort()),FORK/EXIT 事件触发线程列表刷新。无 MMAP2 事件时回退到全量 /proc/<pid>/maps 重解析。

6. Stackdiff 时间线算法 — 无间隙、基于持续时间的切片

采样是离散的时间点,但 Perfetto UI 需要连续的 slice。processPartitionToNativeTimeline 实现了 stackdiff 算法:

  • 每个采样的函数被认为活跃到下一个采样到达
  • 相邻采样做 diff:找公共前缀,关闭消失的函数(从深到浅),开启新出现的函数
  • 最后一个采样的函数延伸一个采样间隔(1s/freq
  • 未符号化的帧用 rel_pc - function_offset 作为稳定标识符,避免同一函数不同偏移导致的虚假 exit/entry

7. On/Off-CPU 联合采样

--with-offcpu 同时运行 perf 采样和 eBPF sched 追踪。onoffcpu.SchedMonitor 通过 eBPF 挂载 sched_switch tracepoint,回调中用状态机追踪每个线程的 off-CPU 起止时间。线程回到 on-CPU 时计算 duration,过滤 <1µs 的噪声。

off-CPU 事件按等待类型分类(futex/poll/sleep/io/network/lock/memory/sched),最终通过 MergeOffCPUEvents 合并到同一线程 track 上,填充 cpu-clock 采样的空白区间。

8. Perfetto 原生 protobuf 输出

直接生成 Perfetto Trace protobuf(不走 JSON),使用 TrackEventname 字段(非 name_iid interning),避免了增量状态管理的复杂性。不设置 SEQ_NEEDS_INCREMENTAL_STATE 标志(否则需要配套 SEQ_INCREMENTAL_STATE_CLEARED,缺失会导致 trace processor 丢弃所有 packet)。

Track 层级:Process track(ProcessDescriptor + pid)→ Thread track(ThreadDescriptor + pid/tid),Perfetto UI 自动按 pid/tid 分组。

9. C++ 符号 demangle

使用 Pyroscope 的 demangle 库(github.com/grafana/pyroscope/ebpf/cpp/demangle),支持 Itanium ABI(_Z 前缀)的 C++ mangled 符号。结果缓存在 sync.Map 中。

10. 性能优化

  • 批量读取perf_sampler_read_batch 一次 CGO 调用读取最多 256 个样本
  • 自适应休眠:空闲时从采样间隔逐步退避到 20ms,有数据时立即重置
  • 缓存进程内存CreateProcessMemoryCached 避免重复读取 ELF/DWARF 数据
  • MMAP2 批量排序stack_unwinder_add_map 不逐次排序,stack_unwinder_sort_maps 批量排序一次
  • 栈数据 malloc 拷贝:从 ring buffer 拷贝出来后立即推进 tail,避免被覆盖

11. 全静态编译

make build-static 通过 -static-libgcc -static-libstdc++-extldflags "-static" 生成无运行时依赖的静态二进制,适合 scp 部署到各种宿主机环境。

12. unwind

unwind 就是”栈回溯”——从当前执行位置往回追溯整条调用链。

具体过程:程序执行到某个函数时,CPU 的栈大概长这样:

栈顶 (SP寄存器指向这里)
┌─────────────────────┐
│ spdlog::log          │  ← 当前正在执行的函数
├─────────────────────┤
│ HuaTuoSdk::WriteLog  │  ← 调用了 spdlog::log
├─────────────────────┤
│ StatsTrace::compute   │  ← 调用了 WriteLog
├─────────────────────┤
│ AdTableTvfOp::Process │  ← 调用了 compute
├─────────────────────┤
│ ExecutorState::Process│  ← 调用了 AdTableTvfOp
├─────────────────────┤
│ WorkerLoop            │  ← 调用了 ExecutorState
└─────────────────────┘
栈底

perf 采样触发时,内核把当前的寄存器状态和栈内存拷贝出来。但这只是一堆原始字节,不知道每个函数的栈帧边界在哪。

unwind 就是从这堆原始字节里还原出上面这条调用链的过程。cprof 用 libunwindstack 做这件事,它读取 ELF 文件里的 DWARF 信息(.eh_frame 段),里面记录了每个函数的栈帧大小、返回地址存在哪个偏移位置。按照这些信息一层一层往回跳,就能还原出完整的调用栈。

在 cprof 里的位置 内核 perf 事件触发 → 拷贝寄存器 + 栈内存 (perf_sample_t) → cprof 读出 raw sample (ReadBatch) → libunwindstack 做 DWARF unwind (UnwindSample) → 得到 [“WorkerLoop”, “ExecutorState::Process”, …, “spdlog::log”] → 存入 partition (AddSampleEvent) unwind 是整个采样流程里最重的一步,也是最容易出错的一步——如果 DWARF 信息不完整、栈被截断、或者遇到 JIT 代码没有 unwind 信息,就会 unwind 失败,得到一个不完整的栈或空栈。

13. 火焰图生成技术对比

pftrace 火焰图(duration 加权):这个函数占了多少 wall-clock 时间?

collapsed 火焰图(sample count):这个函数占了多少 CPU 时间?

对于纯 CPU 密集型的函数,两者几乎一样。差异出在有等待的场景:

funcA: on-CPU 50ms, 然后 off-CPU 等锁 200ms, 再 on-CPU 50ms
  • pftrace 火焰图:funcA 宽度 ∝ 300ms(wall-clock,包含等锁)
  • collapsed 火焰图:funcA 宽度 ∝ 10 个 sample(只有 on-CPU 时被采到)
  • perf 火焰图:和 collapsed 一样,∝ 10 个 sample

pftrace 火焰图说的是”funcA 从开始到结束花了 300ms”,这个信息本身是对的。但业务拿它和 perf 火焰图比的时候,看到的是 funcA 在 pftrace 里占比大、在 perf 里占比小,就觉得”不一样”。

什么时候 pftrace 火焰图更有用?

其实 pftrace 的 duration 视角在某些场景下反而更有价值——比如排查延迟问题。一个请求端到端耗时 500ms,你想知道时间花在哪了,duration 加权的火焰图直接告诉你答案,而 sample count 火焰图会把等锁/等 IO 的时间”藏”起来。

所以不是哪个更准确,是看你要回答什么问题:

  • 优化 CPU 使用率 → 用 collapsed(sample count)
  • 排查延迟瓶颈 → 用 pftrace(duration)

collapsed stacks 输出原理

之前只有一条路径,sample 必须经过 stackdiff 变成 slice:

原始 sample → stackdiff → slice (begin/end) → pftrace → 火焰图工具按 duration 聚合

                          wall-clock 拉长问题在这里引入

现在加了一条直通路径,从原始 sample 直接聚合:

原始 sample → stackdiff → slice → pftrace (timeline 视图)

     └──→ 直接按栈聚合 → .collapsed (火焰图)

perf 的 perf script 输出也是一堆离散的 sample,FlameGraph 工具对它做的事情完全一样——把相同栈路径的 sample 计数合并。

两边的语义都是:一个函数被采到的次数越多,说明它占 CPU 时间越多,火焰图里就越宽。不涉及任何时间差计算,自然不存在 wall-clock 拉长的问题。

和 pftrace 的关系

两个输出互不影响:

  • .pftrace 还是走 stackdiff,给 Perfetto UI 看时间线用
  • .collapsed 直接从原始 sample 聚合,给火焰图工具用

架构概览

graph TD
    CLI["main.go<br/><i>perf / uprobe / cpustat 子命令</i>"]

    CLI --> Profiler["profiler.go<br/><i>事件循环 · 批量读取 · 自适应休眠</i>"]

    Profiler --> Adapter["native_adapter.go<br/><i>CGo 桥接 · kallsyms 解析<br/>MMAP2/FORK/EXIT drain</i>"]
    Profiler --> Builder["trace_builder.go<br/><i>Stackdiff 时间线算法<br/>Perfetto protobuf 输出<br/>C++ demangle · Python 过滤</i>"]
    Profiler --> SchedMon["sched_monitor.go<br/><i>eBPF sched_switch tracepoint<br/>等待类型分类状态机</i>"]

    Adapter --> Sampler["perf_sampler.cpp<br/><i>perf_event_open · ring buffer 解析<br/>CALLCHAIN / MMAP2 / FORK / EXIT</i>"]
    Adapter --> Unwinder["stack_unwinder.cpp<br/><i>MemoryOverlay 复合内存<br/>ProcRootRemoteMaps 路径重写<br/>libunwindstack DWARF 回溯</i>"]

    SchedMon --> |off-CPU 事件| Builder

    Builder --> Output["profile.pftrace<br/><i>Perfetto UI 可视化</i>"]

    style CLI fill:#4a90d9,color:#fff
    style Profiler fill:#5ba55b,color:#fff
    style Adapter fill:#e8a838,color:#fff
    style Builder fill:#e8a838,color:#fff
    style SchedMon fill:#c75050,color:#fff
    style Sampler fill:#7b68ae,color:#fff
    style Unwinder fill:#7b68ae,color:#fff
    style Output fill:#888,color:#fff
修改历史1 次提交