culpert — 具备 span 归属能力和回归检测工具的 Rust 堆分配分析器

Lobsters Hottest 工具

摘要

culpert 是一个实验性的 Rust 堆分配分析器,能将采样到的分配归属到 span,导出 pprof 兼容报告并在 CI 中检测分配回归,由 Cloudflare 开发并已在生产前捕获多个内存泄漏问题。

<p><a href="https://lobste.rs/s/a1ser9/culpert_rust_heap_profiling_with_span">评论</a></p>
查看原文
查看缓存全文

缓存时间: 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) crateculpert 的 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-axumfoundations + 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 crateculpert::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 双重许可。

相似文章

rust-lang/rust

GitHub Trending (daily)

Rust 主源代码仓库,包含编译器、标准库和文档,强调性能、可靠性与生产力,配套 Cargo、rustfmt、Clippy 和 rust-analyzer 等工具链。

@cevenif: GitHub上挖到个宝藏:TaskExplorer——专业级进程分析工具。随便点一个进程,线程堆栈、内存、文件句柄、网络Socket全部摊开给你看,一屏全搞定。 磁盘读写位置、网络速率实时刷新,还能直接注入或卸载DLL模块。这功能密度,任…

X AI KOLs Timeline

TaskExplorer 是一个开源的进程分析工具,提供线程堆栈、内存编辑、文件句柄、网络Socket等专业功能,支持DLL注入/卸载,基于Qt和SystemInformer驱动,适用于Windows系统监控与性能排查。

Valen 的内存安全:一种新型借用检查

Hacker News Top

Valen 的新文章介绍了一种更灵活的借用检查器(group borrowing),编译器会记住引用指向的位置,以实现比 Rust 更简单灵活的内存安全方案,并已被 Google Carbon 等语言采纳。