Skip to content

ci(memtrack): benchmark memtrack's own tracking overhead - #512

Draft
not-matthias wants to merge 5 commits into
mainfrom
memtrack-walltime-benchmarks
Draft

ci(memtrack): benchmark memtrack's own tracking overhead#512
not-matthias wants to merge 5 commits into
mainfrom
memtrack-walltime-benchmarks

Conversation

@not-matthias

Copy link
Copy Markdown
Member

Why

codspeed-memtrack track pays a fixed cost per invocation — BPF program load plus uprobe/uretprobe attaches — on top of running the tracked command. Nothing measured that overhead, so wall-clock regressions in it went unnoticed.

What

  • crates/memtrack/codspeed.yml: walltime benchmarks driving codspeed-memtrack track over three workloads:
    • ls -la /usr/lib/x86_64-linux-gnu — read-only, low allocation
    • tar -cf ... /usr/lib/x86_64-linux-gnu — allocation- and I/O-heavy (many small reads)
    • dd if=/dev/zero of=... bs=1M count=64 — I/O-heavy, minimal allocation
  • .github/workflows/ci.yml: memtrack-benchmarks job that builds both the CLI and memtrack from source, grants memtrack the file capabilities (same sequence as the tests job), and runs the config with codspeed run -m walltime. Added to check's needs.

warmup-time: 5s / max-time: 60s are deliberately generous: the per-invocation attach cost alone is ~1.1s, so the walltime defaults tuned for near-instant commands would yield no usable rounds.

Verification

Ran locally on Linux (NixOS, so the exec paths were substituted for local equivalents; everything else unmodified):

codspeed --config crates/memtrack/codspeed.yml run -m walltime --skip-upload
→ exit 0, "Found 3 benchmark(s)", memtrack artifacts written to the output dir

Measured medians over 3 rounds: ls 1.15s, tar 1.96s, dd 1.30s.

`codspeed-memtrack track` pays a fixed cost per invocation (BPF program
load plus uprobe/uretprobe attaches) on top of the tracked command, and
nothing measured it so far, so wall-clock regressions in that overhead
went unnoticed.

Add a walltime config with three exec targets covering distinct
workloads (read-only, allocation-heavy, I/O-heavy) and a CI job that
runs them with the CLI and memtrack built from source.
@codspeed-hq

codspeed-hq Bot commented Aug 21, 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.

✅ 17 untouched benchmarks
🆕 3 new benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
🆕 WallTime memtrack track dd N/A 1.4 s N/A
🆕 WallTime memtrack track ls N/A 1.4 s N/A
🆕 WallTime memtrack track tar N/A 5.2 s N/A

Comparing memtrack-walltime-benchmarks (8201908) with main (1dcc738)

Open in CodSpeed

The listing and dd's stderr were captured into the runner log once per
round, which made the uploaded log 4.2 MB of noise. The tracked command
string is run through `bash -c`, so a redirect inside it works.
Allocator entry points share addresses through aliases: `free`, `cfree`
and `__libc_free` are one symbol in glibc, and the standard-probe sweep
attaches all of the names it finds. Attaching `uprobe_free` twice at one
address does not double the trap, since the kernel keeps a single uprobe
per address with a list of consumers, but it does run the program twice
per call and emit a duplicate free event: 607k events for 200k
malloc/free pairs, 406k after this change.

Measured on a malloc/free latency harness (p50 per pair, glibc): 1272 ns
to 1162 ns, and one fewer link to attach and detach per aliased symbol.
The uprobe/uretprobe argument hand-off kept a hash map keyed by tid for
every instrumented function, costing an update on entry and a lookup
plus delete on return, and every hook re-resolved is_tracked() through
further hashed lookups of the pid and its ancestors.

Both now live in task-local storage, reached by a pointer chase off the
task_struct instead of a hashed, bucket-locked lookup. One slot per
entry point rather than a single shared one, since allocators call each
other (glibc realloc reaches malloc) and nested calls on a thread must
not clobber each other's saved arguments. A `valid` bitmask keeps a zero
argument distinguishable from an absent one, so a return probe firing
without its entry probe is still ignored.

The tracked flag is only memoized when positive: pids are added to
tracked_pids and never removed, so a tracked task stays tracked, while
an untracked one may be registered later and must keep re-resolving.

Measured on a malloc/free latency harness (p50 per pair, glibc):
2204 ns to 2064 ns.
Every submit called bpf_ringbuf_query(BPF_RB_AVAIL_DATA) to decide
whether to force a consumer wakeup. That reads the consumer position, a
cache line the polling thread on another CPU writes continuously, so
each event paid a cross-CPU miss for a decision that only changes once
per watermark.

Count submitted bytes per CPU instead and force a wakeup whenever the
watermark is crossed. Events are fixed size, so this is the same
cadence the query approximated, decided entirely on the producer side
with no shared cache line involved. A missing counter forces the wakeup
rather than risking a stalled consumer.

Measured on a malloc/free latency harness (p50 per pair, glibc):
2064 ns to 1102 ns, the largest of the three hot-path wins. Verified at
10M malloc/free pairs (20,006,217 events, ~800 MB through the 256 MB
ring buffer) with the dropped-event counter still at zero, so batched
wakeups keep up with a sustained high event rate.
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.

1 participant