culpert — 具备 span 归属能力和回归检测工具的 Rust 堆分配分析器
摘要
culpert 是一个实验性的 Rust 堆分配分析器,能将采样到的分配归属到 span,导出 pprof 兼容报告并在 CI 中检测分配回归,由 Cloudflare 开发并已在生产前捕获多个内存泄漏问题。
查看缓存全文
缓存时间: 2026/10/11 01:16
rupert648/culpert
来源:https://github.com/rupert648/culpert
culpert
Culpert 仍处于高度实验和开发阶段,请自行承担在生产环境中使用的风险。
简介
面向 Rust 服务的按 span 划分的堆分配分析器。
Culpert 是一个针对 Rust 库和服务的采样式堆分配分析器。它将内存分配归因到现有的 span 上,并导出兼容 pprof 的 profile 以及适合 CI 使用的 diff,帮助在发布前捕获内存分配回退。它可与 tracing、Cloudflare Foundations 或其自带的 #[culpert::span_fn] 宏集成使用。
Culpert 已经在 cloudflare 捕获了多个真实的内存分配回退,包括一个内存泄漏——全部在它们进入生产环境之前!
它是什么?
一个 #[global_allocator] 包装器,将每次采样到的内存分配归因到其所在的 span,导出 pprof 格式的 profile(使现有的工具生态——原生 pprof、Speedscope、Pyroscope、Polar Signals——保持可用),并附带一个 CLI,提供无偏的按 span 报告以及适用于 CI/PR 工作流的 diff 子命令。
几何采样配合 Bernstein 校正,提供无偏的内存分配估算值,同时保持较低的分析开销。提供三种集成方式——选择与你的服务匹配的即可:
| 你的服务使用… | 适配器 | 成本 |
|---|---|---|
foundations (https://github.com/cloudflare/foundations) | culpert 的 foundations feature | 零侵入。已有的 #[span_fn] 标注直接成为归因键。 |
tracing (https://crates.io/crates/tracing) crate | culpert 的 tracing feature | 将 tracing_subscriber::Layer 组合到你的 subscriber 栈中。已有的 #[tracing::instrument] 标注直接成为归因键。 |
| 两者都没有,或希望归因与 trace 采样独立 | culpert::scope + #[culpert::span_fn] | 在需要归因的函数上加一个宏即可。无需外部 tracer。 |
安装
库:
cargo add culpert # 独立使用
cargo add culpert --features foundations # 配合 foundations
cargo add culpert --features tracing # 配合 tracing
culpert-macros 由 culpert 重新导出(请勿直接依赖它)。
CLI:
cargo install --locked culpert-cli # → 将 `culpert` 二进制安装到 ~/.cargo/bin
culpert --help
# 可选 feature:用于与部署在 Cloudflare Access 之后的
# culpert-archive 实例通信——为 `upload` / `pull` 添加
# --cf-access-client-id / --cf-access-client-secret 标志
# (支持 CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET 环境变量作为回退):
cargo install --locked --features cloudflare-access culpert-cli
快速上手——独立使用(#[culpert::span_fn])
最小可用配置。无需外部 tracer:
[dependencies]
culpert = "0.2.1"
use culpert::{Config, LocalSpanContext, TrackingAllocator};
use std::alloc::System;
#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator::new(System);
fn main() {
let _profiler = culpert::install(LocalSpanContext::new(), Config::default());
handle_request();
let profile = culpert::snapshot();
let bytes = culpert::pprof::encode_gzipped(&profile).unwrap();
std::fs::write("/tmp/prof.pb.gz", &bytes).unwrap();
}
#[culpert::span_fn("handle_request")]
fn handle_request() {
// 这里产生的所有采样到的内存分配(包括传递调用的),
// 都会归因到 span_name = "handle_request"。
}
每种安装方式都会返回一个 ProfilerGuard。在需要记录内存分配期间,请保持该 guard 的存活;drop 它可以在应用和 thread-local 清理之前安全地停止分析。
可运行版本请参见 examples/macros。
快速上手——配合 foundations
[dependencies]
culpert = { version = "0.2.1", features = ["foundations"] }
foundations = { version = "5", default-features = false, features = ["tracing", "telemetry-server"] }
default-features = false 是 必需的——foundations 默认的 jemalloc feature 会声明它自己的 #[global_allocator],与 culpert 的 TrackingAllocator 冲突,导致链接失败。
#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator::new(System);
#[tokio::main]
async fn main() {
let driver = foundations::telemetry::init(TelemetryConfig {
service_info: &service_info!(),
settings: &settings.telemetry,
custom_server_routes: vec![
culpert::foundations::pprof_route("/debug/alloc/profile"),
],
}).unwrap();
let _profiler = culpert::foundations::install();
// ... 你现有的应用,照常使用 #[span_fn] 标注。
}
已有的 #[foundations::telemetry::tracing::span_fn] 标注会自动成为归因键,无需额外操作。
参见 examples/foundations(最小示例)和 examples/mock-axum(完整的 HTTP 服务,通过 pprof_route 提供 profile)。
快速上手——配合 tracing crate
[dependencies]
culpert = { version = "0.2.1", features = ["tracing"] }
tracing = "0.1"
tracing-subscriber = "0.3"
use tracing_subscriber::prelude::*;
#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator::new(System);
fn main() {
tracing_subscriber::registry()
.with(culpert::tracing::layer())
.with(/* 你自己的其他 layer——fmt、OTLP 等 */)
.init();
let _profiler = culpert::tracing::install();
// ... 你现有的应用,照常使用 #[tracing::instrument] 标注。
}
已有的 #[tracing::instrument] 标注会直接成为归因键。
可运行版本请参见 examples/tracing。
你会得到什么
一个 profile(*.pb.gz),你可以将其输入原生 pprof,也可以用自带的 CLI 查看。
以下输出来自 examples/mock-axum 服务在负载下的运行结果;同样的输出格式适用于所有三种集成方式。
树状报告——culpert report <pb.gz>
默认输出:层级化分解,每个子 span 嵌套在其父级之下。由已安装的 SpanContext 发出的 span_parent_id 标签构建。
$ culpert report /tmp/mock-axum.pb.gz
层级化 span 报告(143179 个样本,采样率 4.00 KB/alloc):
树状视图展示 span_name 按父级分组。`bytes` 是经 Bernstein 校正的无偏分配字节估算值
(参见 CHANGELOG:几何采样)。使用 --flat 查看简单的排序表格。
vec 1.30 GB 70.48% (self 1.30 GB)
(no span) 246.45 MB 13.07% (self 246.45 MB)
json 180.92 MB 9.59% (self 180.92 MB)
nested 3.55 MB 0.19% (self 170.62 KB)
├─ parse_payload 59.77 MB 3.17% (self 59.77 MB)
├─ validate_payload 59.57 MB 3.16% (self 59.57 MB)
└─ build_response 604.00 KB 0.03% (self 604.00 KB)
strings 5.87 MB 0.31% (self 5.87 MB)
--flat 切换为按字节排序的表格视图,供偏爱此形式的用户使用。
bytes 列是每个 span 下分配总字节数的无偏估算值——每个底层样本按 1 / (1 − exp(−bytes/rate)) 加权(即几何采样的 Bernstein 校正)。不显示原始列:在几何采样下,校正值是唯一有意义的数字。
深入查看某个 span——--span <name>
$ culpert report /tmp/mock-axum.pb.gz --span json --top 4
span "json" 内的 top 调用点(采样率 4.00 KB/alloc):
callsite samples bytes bytes %
------------------------------------------------------------ --------- ---------- -------
alloc::vec::Vec::from_iter::SpecFromIter 23000 89.84 MB 50.00%
alloc::fmt::format::{closure} 23000 89.84 MB 50.00%
深入查看未归因的部分——--no-span
适用于回答“这是我代码的问题,还是运行时的?“。展示在任何 span 之外触发的内存分配的 top 调用点——即 tokio 运行时的工作、框架内部实现、foundations 或 tracing 自身的 reporter,以及你尚未标注的代码路径。
$ culpert report /tmp/mock-axum.pb.gz --no-span --top 4
未归因样本中的 top 调用点——在任何 span 之外:
callsite samples bytes bytes %
------------------------------------------------------------ --------- ---------- -------
cf_rustracing_jaeger::Tag as Clone>::clone 8720 34.06 MB 33.74%
alloc::vec::Vec::append_elements 6678 26.09 MB 25.84%
alloc::boxed::Box::new_uninit 6551 25.59 MB 25.34%
bytes::bytes_mut::BytesMut::reserve_inner 2307 16.00 MB 15.85%
对比两个 profile——culpert diff
适用于 CI 工作流:按 span_name 对“前“和“后“两个 profile 做 diff,支持绝对值阈值(--threshold-bytes)和相对值阈值(--threshold-pct)双重门槛。--format markdown 生成的输出可直接管道传入 $GITHUB_STEP_SUMMARY:
$ culpert diff before.pb.gz after.pb.gz --format markdown
### culpert: 内存分配 diff
- **before:** `before.pb.gz` — 总计 2.70 MB(估算值)
- **after:** `after.pb.gz` — 总计 5.75 MB(估算值)
- **净变化 Δ:** +3.05 MB (+113.21%)
#### 回退项
| Span | Before | After | Δ | Δ% |
|------|-------:|------:|--:|---:|
| `encode_response` | 1.53 MB | 4.58 MB | +3.05 MB | +200.00% |
若要过滤掉在两个 profile 中分配量都少于 20 MiB 的 span,可添加 --min-span-bytes 20971520(默认:0,即禁用)。在任一侧恰好达到 20 MiB 的 span 仍然保留,包括新增和消失的 span。已有的差值和百分比阈值门槛仍然生效;全 profile 总量包含所有 span。JSON 输出会将被过滤的行标记为 quiet。在 --tree 输出中,最小值适用于显示的子树总量(包括子级)。
culpert diff before.pb.gz after.pb.gz --format markdown \
--min-span-bytes 20971520 --threshold-bytes 1048576 --threshold-pct 10
CI 可以设置 CULPERT_MIN_SPAN_BYTES=20971520 来替代传入该标志。显式的 --min-span-bytes 优先于环境变量,包括 0 用于显式禁用。CI 需要安装包含此选项的 CLI 版本。
原生 pprof 同样可用
磁盘上的格式是标准 pprof,因此整个生态的所有工具都能读取:
pprof -tags /tmp/mock-axum.pb.gz # 按 span_name + span_id 分组的总量
pprof -tagfocus="span_name:json" -text /tmp/mock-axum.pb.gz
pprof -http=:8090 /tmp/mock-axum.pb.gz # 交互式火焰图 + 源码视图
为 CI 持久化 profile——culpert upload / culpert pull
在 CI 工作流中,你通常需要与上周的 profile 进行对比。配套的 culpert-archive(https://github.com/rupert648/culpert-archive)Cloudflare Worker 以 commit SHA 为键存储 .pb.gz 文件;culpert-cli 的 upload / pull 子命令是其官方客户端。端点和令牌从环境变量(CULPERT_ARCHIVE / CULPERT_TOKEN)读取,commit SHA / 分支从 GITHUB_SHA / GITHUB_REF_NAME 读取,因此 GitHub Actions 步骤内容非常简洁:
# 将刚采集的 profile 推送到该 commit 下
culpert upload /tmp/profile.pb.gz
# 拉取上一次构建的 main 分支 profile 作为基线。
# --allow-missing 在 404 时以退出码 0 退出(不写任何内容),
# 这样 main 分支的第一次运行不会导致构建失败。
culpert pull --latest-of main -o /tmp/baseline.pb.gz --allow-missing
# 进行 diff。Markdown 输出到 $GITHUB_STEP_SUMMARY,另加一次带失败门槛的文本运行。
culpert diff /tmp/baseline.pb.gz /tmp/profile.pb.gz \
--format markdown >> "$GITHUB_STEP_SUMMARY"
culpert diff /tmp/baseline.pb.gz /tmp/profile.pb.gz \
--threshold-bytes 1048576 --threshold-pct 10 # 发现回退时 exit 1
为了开箱即用的 CI 集成,本仓库提供了一个可复用的 composite action,封装了整个流程:
# 在你的 workflow 中,采集到 profile 之后:
- uses: rupert648/culpert/.github/actions/culpert-diff@main
with:
archive-url: ${{ vars.CULPERT_ARCHIVE }}
archive-token: ${{ secrets.CULPERT_TOKEN }}
profile: /tmp/my-service-profile.pb.gz
culpert-cli: ./target/release/culpert # 二进制路径
这一个代码块完成了:culpert info(在运行日志中做健康检查)→ culpert pull --latest-of main --allow-missing → culpert diff --format markdown(发布到 $GITHUB_STEP_SUMMARY,并在 pull_request 事件中作为置顶 PR 评论)→ culpert upload 作为新的基线。可通过 action 的输入项覆盖默认值——baseline-branch、threshold-bytes、threshold-pct、fail-on-regression 等。
完整的输入模式请参见 .github/actions/culpert-diff/action.yml。
Culpert 自身的 rust.yml 会对 example-macros 进行分析并调用同一个 action——这就是实际的参考示例。目前仅做警告(fail-on-regression: "false"),直到 main 分支积累了足够多的运行记录,使门槛判定真正有意义。
Worker 从不解析 pprof 字节——它是纯存储。采样归因、Bernstein 校正、阈值逻辑全部在本 CLI 中运行。部署方法和 HTTP 接口请参见 culpert-archive 的 README(https://github.com/rupert648/culpert-archive)。
示例
examples/ 下有四个独立的示例,展示每种集成方式:
| 示例 | 展示内容 | 关键接线 |
|---|---|---|
mock-axum | foundations + axum 服务,带 /debug/alloc/profile HTTP 端点,附带负载测试脚本 | foundations::telemetry::init + culpert::foundations::pprof_route + #[foundations::span_fn] |
foundations | 无 HTTP 服务器的最小 foundations 接线——仅初始化、执行、快照、写入 | culpert::foundations::install + #[foundations::span_fn] |
tracing | 通过 tracing_subscriber::Layer 集成 tracing crate | culpert::tracing::layer() 组合到 Registry + #[tracing::instrument] |
macros | 无需外部 tracer 的与采样无关的归因;也可作为 BT vs FP 计时演示(设置 CULPERT_FP=1) | culpert::LocalSpanContext + #[culpert::span_fn] |
每个示例都会将 profile 打印到 /tmp/example-*.pb.gz。可使用 cargo run -p culpert-cli --bin culpert -- report <pb.gz> 或 pprof -tags <pb.gz> 查看。
许可证
采用 MIT 和 Apache-2.0 双重许可。
相似文章
性能去哪了?理解并修复 C 到 Rust 自动翻译中的性能回退问题
该论文研究了 C2Rust 自动 C 到 Rust 翻译中的性能回退问题,分析出九类常见性能问题,并提出基于 LLM 规则引导的 PerfR 方法,在多个项目上恢复甚至超越原始 C 性能。
下一代借用检查器:Rust 借用检查的即插即用替代方案
介绍下一代 Rust 借用检查器,作为现有借用检查的可直接替换方案,旨在支持更灵活的所有权分析并减少误报。
rust-lang/rust
Rust 主源代码仓库,包含编译器、标准库和文档,强调性能、可靠性与生产力,配套 Cargo、rustfmt、Clippy 和 rust-analyzer 等工具链。
@cevenif: GitHub上挖到个宝藏:TaskExplorer——专业级进程分析工具。随便点一个进程,线程堆栈、内存、文件句柄、网络Socket全部摊开给你看,一屏全搞定。 磁盘读写位置、网络速率实时刷新,还能直接注入或卸载DLL模块。这功能密度,任…
TaskExplorer 是一个开源的进程分析工具,提供线程堆栈、内存编辑、文件句柄、网络Socket等专业功能,支持DLL注入/卸载,基于Qt和SystemInformer驱动,适用于Windows系统监控与性能排查。
Valen 的内存安全:一种新型借用检查
Valen 的新文章介绍了一种更灵活的借用检查器(group borrowing),编译器会记住引用指向的位置,以实现比 Rust 更简单灵活的内存安全方案,并已被 Google Carbon 等语言采纳。