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_CALLCHAIN 且 exclude_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),使用 TrackEvent 的 name 字段(非 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 还是走 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 次提交
- docs: add cprof cpp profiling and dynamic batching docsxiaocheng··
862cca0