feat(memtrack): attach allocator probes on demand#445
Conversation
Merging this PR will not alter performance
|
Greptile SummaryThis PR replaces memtrack's eager startup discovery (system-wide library glob + build-dir walk +
Confidence Score: 4/5The attach worker fixpoint drain and ContGuard-before-resume ordering are correct; one shutdown-path drain failure is silently discarded instead of being surfaced as a fatal error. If the ring buffer poller thread dies unexpectedly during the shutdown drain, the worker exits cleanly, AttachWorker::finish() returns Ok(()), and the run proceeds with potentially incomplete allocator coverage and no diagnostic. All other correctness properties of the stop-the-world attach are sound. crates/memtrack/src/ebpf/attach_worker.rs — the shutdown drain error path at the top of the shutdown branch in Worker::run. Important Files Changed
|
8bce092 to
16ffc38
Compare
…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.
16ffc38 to
5b76788
Compare
| impl Worker { | ||
| fn run(self) { | ||
| let mut known: HashSet<(u64, u64)> = HashSet::new(); | ||
|
|
There was a problem hiding this comment.
Drain failure during shutdown silently ignored
If the RingBufferPoller's poll thread dies unexpectedly (e.g., an unhandled libbpf-rs panic inside ringbuf.consume() or ringbuf.poll()), drain() returns Err (the ack never arrives or the ctl send fails). The worker thread then returns early without calling record_fatal, so AttachWorker::finish() sees a clean thread join, fatal = None, and attach_request_dropped_count == 0 — returning Ok(()) despite potentially unprocessed attach requests remaining in the ring buffer. The benchmark would proceed with incomplete allocator coverage and no error reported.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/memtrack/src/ebpf/attach_worker.rs
Line: 119-122
Comment:
**Drain failure during shutdown silently ignored**
If the `RingBufferPoller`'s poll thread dies unexpectedly (e.g., an unhandled `libbpf-rs` panic inside `ringbuf.consume()` or `ringbuf.poll()`), `drain()` returns `Err` (the ack never arrives or the ctl send fails). The worker thread then returns early without calling `record_fatal`, so `AttachWorker::finish()` sees a clean thread join, `fatal = None`, and `attach_request_dropped_count == 0` — returning `Ok(())` despite potentially unprocessed attach requests remaining in the ring buffer. The benchmark would proceed with incomplete allocator coverage and no error reported.
How can I resolve this? If you propose a fix, please make it concise.
GuillaumeLagrange
left a comment
There was a problem hiding this comment.
Stopping here becuase I want us to dig the TRACEME thing before leaning in the stop wrapper
| @@ -0,0 +1,54 @@ | |||
| /* == Exec-mapping watcher: on-demand allocator attach == */ | |||
There was a problem hiding this comment.
Slightly misleading: this is not really on demand allocator attach, this is a mmap watcher that stops the program during memmap and queues the request in a ring buffer for further processing
| return 0; | ||
| } | ||
|
|
||
| #include "attach.bpf.h" |
There was a problem hiding this comment.
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?
| /* Default to enabled if map not initialized */ | ||
| if (!enabled) { | ||
| return 1; | ||
| } |
There was a problem hiding this comment.
completely unrelated to your code, but is this really the default behavior we want? To me, if map not initialized, the whoel thing is just not enabled
| #[derive(Debug, Clone, Copy)] | ||
| pub struct AttachRequest { | ||
| pub pid: u32, | ||
| pub dev: u64, |
There was a problem hiding this comment.
we dropped the tid here, is this on purpose?
There was a problem hiding this comment.
If we don't need it do we need to emit it from the bpf script?
| /// Create a new tracker with on-demand allocator attach. | ||
| /// | ||
| /// This will: | ||
| /// - Initialize the BPF subsystem | ||
| /// - Bump memlock limits | ||
| /// - Attach uprobes to all libc instances | ||
| /// - Attach tracepoints for fork tracking | ||
| /// No allocators are pre-attached: the exec-mapping watcher discovers and | ||
| /// attaches them as the tracked tree maps executable files. |
There was a problem hiding this comment.
Overfitted comment that focuses on the difference between previous impl and this one, maybe a good candidate for the deslop skill?
| Self::bump_memlock_rlimit()?; | ||
|
|
||
| let mut bpf = MemtrackBpf::new()?; | ||
| bpf.attach_tracepoints()?; |
There was a problem hiding this comment.
would be clearer to get read of the attach_tracepoints indirection in favor of a direct attach_sched_fork call.
| /// `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); |
There was a problem hiding this comment.
Why do we need to have the command self-stopped ??
Isnt it precisely the role of the bpf exec mapper that we've attached??
| 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 | ||
| } |
There was a problem hiding this comment.
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
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_filesignals 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:dlopen'd allocators and statically linked allocators in spawned children (previously missed entirely)The eager discovery path and
CODSPEED_MEMTRACK_BINARIESare removed since the watcher supersedes them (breaking: the env var is no longer read).The crate API is now
Tracker(owns the attach worker) andSession(owns the spawned child and its event pipeline); ring buffers are polled through a genericRingBufferPollerwith 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 fullconsume()after all producers are stopped cannot miss requests.Fixes COD-1801