Skip to content

feat(memtrack): attach allocator probes on demand#445

Open
not-matthias wants to merge 8 commits into
mainfrom
cod-1801-only-attach-to-used-libraries-in-memtrack
Open

feat(memtrack): attach allocator probes on demand#445
not-matthias wants to merge 8 commits into
mainfrom
cod-1801-only-attach-to-used-libraries-in-memtrack

Conversation

@not-matthias

Copy link
Copy Markdown
Member

Attach allocator uprobes only to libraries the tracked process tree actually maps, instead of pre-attaching every allocator discovered on the system at startup.

A BPF fentry on security_mmap_file signals the first mapping of each unknown executable inode. A background worker SIGSTOPs the mapping processes, classifies the file by its allocator symbols, attaches probes, and resumes them. This:

  • covers dlopen'd allocators and statically linked allocators in spawned children (previously missed entirely)
  • skips libraries the benchmark never loads
  • removes the startup discovery scan (system-wide library glob + build-dir walk), which was slow on nix-heavy systems

The eager discovery path and CODSPEED_MEMTRACK_BINARIES are removed since the watcher supersedes them (breaking: the env var is no longer read).

The crate API is now Tracker (owns the attach worker) and Session (owns the spawned child and its event pipeline); ring buffers are polled through a generic RingBufferPoller with caller-supplied parsing, replacing the keepalive forwarding thread.

Review focus: the stop-the-world attach in attach_worker.rs (fixpoint stop + synchronous ring buffer drain) — its correctness argument is that a full consume() after all producers are stopped cannot miss requests.

Fixes COD-1801

@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

✅ 7 untouched benchmarks


Comparing cod-1801-only-attach-to-used-libraries-in-memtrack (a0a194d) with main (58d994a)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the eager startup library scan with an on-demand BPF-driven attach mechanism. A new fentry/security_mmap_file probe (watch_exec_mmap) fires when a tracked process maps an executable file it hasn't seen before, sends SIGSTOP to the process, and enqueues an attach request. A background AttachWorker processes the queue: it stops every producing PID to a fixpoint, does a synchronous consume() of the ring buffer, classifies each unique (dev, ino) via ELF symbol inspection, attaches uprobes, then resumes all stopped processes via ContGuard. The public API is simplified to Tracker (owns the attach worker) and Session (owns the child + event pipeline).

  • Removes the system-wide glob scan and CODSPEED_MEMTRACK_BINARIES env var; adds dlopen and threaded-dlopen integration tests that directly exercise the on-demand path.
  • Introduces RingBufferPoller — a generic ring-buffer-to-mpsc bridge supporting both synchronous drain (for the stop-the-world attach) and timed polling (for event delivery).

Confidence Score: 5/5

The on-demand attach mechanism is safe to merge. The fixpoint stop-drain-attach loop is correctly ordered and the ContGuard covers every exit path.

The core stop-the-world attach logic is sound: BPF submits the ring buffer entry before sending SIGSTOP, so a full consume() after all producers are stopped cannot miss requests. The fixpoint loop correctly discovers new child pids from each drain. ContGuard guarantees SIGCONT on every exit path including errors and panics. The only finding is that a libbpf-level ring buffer consume failure in the poll thread is silently swallowed, which does not affect normal operation.

crates/memtrack/src/ebpf/poller.rs — the let _ = ringbuf.consume() in the drain handler discards consume errors

Important Files Changed

Filename Overview
crates/memtrack/src/ebpf/attach_worker.rs New file: implements the stop-the-world fixpoint attach loop. Correctness argument holds — ring buffer entry is submitted before SIGSTOP is sent, so a full consume() after all producers are stopped cannot miss requests. ContGuard SIGCONT on drop covers all exit paths including panics.
crates/memtrack/src/ebpf/poller.rs New file: generic ring-buffer-to-mpsc bridge. The drain path silently discards consume() errors, meaning a failed consume reports success to the caller.
crates/memtrack/src/ebpf/c/attach.bpf.h New BPF program: fentry on security_mmap_file. Correctly checks PROT_EXEC, consults known_inodes before signalling, submits ring buffer entry before bpf_send_signal (preserving the drain guarantee).
crates/memtrack/src/ebpf/proc_fs.rs New file: wait_all_stopped correctly treats D-state threads as effectively stopped at deadline, and resolve_mapping correctly decodes the kernel dev_t encoding (major << 20
crates/memtrack/src/ebpf/tracker.rs Refactored to own the AttachWorker. Spawn ordering is correct: add_tracked_pid and set_root_pid execute while the child is stopped, before resume().
crates/memtrack/src/allocators/static_linked.rs Simplified to from_path_static only. Added mallocx to jemalloc detection symbols; AllocatorKind::all() orders non-default allocators first to avoid libc false matches.
crates/memtrack/tests/dlopen_tests.rs New integration tests covering single and concurrent threaded dlopen, verifying all 100 allocations per allocator are captured end-to-end.
crates/memtrack/src/ebpf/spawn.rs Shell-stub approach sidesteps the pre_exec SIGSTOP deadlock. wrap_stopped preserves explicit env overrides; env_clear() limitation is consistent with the documented uid/gid limitation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant P as TrackedProcess
    participant BPF as BPF_watch_exec_mmap
    participant RB as RingBuf_attach_requests
    participant Poll as RingBufferPoller
    participant Ch as mpsc_channel
    participant W as AttachWorker
    participant M as MemtrackBpf

    P->>BPF: mmap lib.so PROT_EXEC
    BPF->>RB: bpf_ringbuf_submit pid dev ino
    BPF->>P: bpf_send_signal SIGSTOP
    Poll->>RB: poll every 10ms
    RB-->>Ch: AttachRequest via tx.send
    W->>Ch: recv_timeout 100ms
    Ch-->>W: AttachRequest
    loop fixpoint stop all new pids
        W->>P: wait_all_stopped 1s deadline
        W->>Poll: drain synchronous consume
        Poll-->>W: ack
        W->>Ch: try_iter new entries
    end
    W->>M: attach_allocator_probes kind path
    W->>M: insert_known_inode dev ino
    W->>P: ContGuard drop SIGCONT resumes process
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant P as TrackedProcess
    participant BPF as BPF_watch_exec_mmap
    participant RB as RingBuf_attach_requests
    participant Poll as RingBufferPoller
    participant Ch as mpsc_channel
    participant W as AttachWorker
    participant M as MemtrackBpf

    P->>BPF: mmap lib.so PROT_EXEC
    BPF->>RB: bpf_ringbuf_submit pid dev ino
    BPF->>P: bpf_send_signal SIGSTOP
    Poll->>RB: poll every 10ms
    RB-->>Ch: AttachRequest via tx.send
    W->>Ch: recv_timeout 100ms
    Ch-->>W: AttachRequest
    loop fixpoint stop all new pids
        W->>P: wait_all_stopped 1s deadline
        W->>Poll: drain synchronous consume
        Poll-->>W: ack
        W->>Ch: try_iter new entries
    end
    W->>M: attach_allocator_probes kind path
    W->>M: insert_known_inode dev ino
    W->>P: ContGuard drop SIGCONT resumes process
Loading

Reviews (5): Last reviewed commit: "fixup! feat(memtrack): add exec-mapping ..." | Re-trigger Greptile

Comment thread crates/memtrack/src/ebpf/attach_worker.rs Outdated
@not-matthias not-matthias force-pushed the cod-1801-only-attach-to-used-libraries-in-memtrack branch from 8bce092 to 16ffc38 Compare July 9, 2026 16:28
@not-matthias not-matthias marked this pull request as ready for review July 9, 2026 16:29
…tcher

Instead of pre-attaching every allocator found on the system, a BPF
fentry on security_mmap_file signals the first mapping of each unknown
executable inode. A background worker stops the mapping processes,
classifies the file by its symbols, attaches probes, and resumes them.
This covers dlopen'd and statically linked allocators in spawned
children, and skips libraries the benchmark never loads.

The public API shrinks to Tracker (owns the attach worker) and Session
(owns the spawned child and its event pipeline); ring buffers are
polled through a generic RingBufferPoller with caller-supplied parsing.

Fixes COD-1801
On-demand attach supersedes the startup scan: delete the system-wide
library glob, the build-dir walk, and the CODSPEED_MEMTRACK_BINARIES
env var (the runner no longer resolves exec target binaries for it).

BREAKING CHANGE: CODSPEED_MEMTRACK_BINARIES is no longer read

Refs COD-1801
Distro libjemalloc is built without the je_ symbol prefix, so symbol
classification missed it and fell through to libc++ (jemalloc exports
operator new), leaving plain malloc/aligned_alloc unprobed. Match on
mallocx, which keeps its name in unprefixed builds and is unique to
jemalloc.
@not-matthias not-matthias force-pushed the cod-1801-only-attach-to-used-libraries-in-memtrack branch from 16ffc38 to 5b76788 Compare July 9, 2026 16:50
Comment thread crates/memtrack/src/ebpf/attach_worker.rs

@GuillaumeLagrange GuillaumeLagrange left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stopping here becuase I want us to dig the TRACEME thing before leaning in the stop wrapper

Comment thread crates/memtrack/src/ebpf/c/attach.bpf.h Outdated
return 0;
}

#include "attach.bpf.h"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's slightly weird to attach.bpf.h like this no?
is this to keep a single .bpf.c entry to easy build.rs compilation?

Maybe we could have a main.bpf.c or sthing that just includes sibling module.bpf.c files to keep things a bit tidier?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal swas to separate the eBPF tracepoints into multiple files since it's getting a bit messy imo. The vision I had in mind, was to put different tracepoint categories into their own file: memory tracking, fork/attach tracking, rss tracking. etc

I used a .h since it's very easy to just include it, but I'll check if we can have multiple .c source files.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the initial goal. It's also possible to include .c files btw, although not common.

But the main thing that bothers me is that we have one main file that defines a bunch of eBPF tracepoints, then includes another file, the hierarchy is a bit weird, we may want to simply create two sibling included files and keep a clean main that only includes stuff

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, I get what you mean. I'll see what I can do 👍

Comment thread crates/memtrack/src/ebpf/c/memtrack.bpf.c Outdated
Comment thread crates/memtrack/src/ebpf/events.rs
Comment thread crates/memtrack/src/ebpf/tracker.rs Outdated
Comment thread crates/memtrack/src/ebpf/tracker.rs Outdated
/// `uid_gid` drops the child's privileges (a `Command`'s uid/gid cannot be
/// read back, so it cannot be preserved through the wrap).
pub fn spawn(&self, cmd: &Command, uid_gid: Option<(u32, u32)>) -> Result<Session> {
let mut wrapped = wrap_stopped(cmd);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to have the command self-stopped ??
Isnt it precisely the role of the bpf exec mapper that we've attached??

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we're only doing the fork/exec tracking for tracked_pids which we'll have to setup at some point. So there's a small race condition window at the start that drops allocations.

But maybe we can use pre_exec for this

Comment on lines +14 to +22
pub fn stopped_command(program: impl AsRef<OsStr>, args: &[OsString]) -> Command {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(r#"kill -STOP "$$"; exec "$@""#)
.arg("sh")
.arg(program.as_ref())
.args(args);
cmd
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am 99% sure that we'll get both simpler code and much more reliable results by adding a ptrace(PTRACE_TRACEME) syscall, WDYT?

Did you already try it and discard it? Because while I see why we dont do a self SIGSTOP, ptrace seems more tried and tested, as well as saving us from yet another shell wrapper

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason we need this is to not have to manually parse the /proc/maps mappings to find the static/dynamic allocators after the execve if we use ptrace.

So basically what we're doing right here is:

  1. Spawn sh, stop it
  2. Track this pid in eBPF
  3. Exec the actual target program -> eBPF can now use the same logic to detect new mappings/forks/execs

If we wanted to use ptrace, we'd still do sh -c 'exec "$@"' and then use PTRACE_TRACEME to stop it in pre_exec. I don't like this as much since it separates the logic and makes it harder to understand for (almost) no benefit.

Wdyt?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants