From 83069521273fdefdc57db19b1361bde9ad191237 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 9 Oct 2026 11:38:10 +0200 Subject: [PATCH 1/3] refactor(runner-shared): move the module event filter next to the decoder The runner filtered the full memtrack event stream down to the Mapping, Fork and Exec events inside read_mappings_from_artifact. Moving that filter into MemtrackArtifact::decode_module_events puts it next to the decoder, so it can be benchmarked and optimized in one place without changing the runner's fork/exec replay. --- .../runner-shared/src/artifacts/memtrack/mod.rs | 16 ++++++++++++++++ src/executor/memory/module_artifacts.rs | 11 +---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/runner-shared/src/artifacts/memtrack/mod.rs b/crates/runner-shared/src/artifacts/memtrack/mod.rs index 226d57504..05339fc78 100644 --- a/crates/runner-shared/src/artifacts/memtrack/mod.rs +++ b/crates/runner-shared/src/artifacts/memtrack/mod.rs @@ -36,6 +36,22 @@ impl MemtrackArtifact { }) } + /// Stream only the events that place modules in processes: executable + /// mappings, and the forks and execs that inherit or drop them. Offline + /// stack attribution needs these few events out of the whole artifact. + pub fn decode_module_events( + reader: R, + ) -> anyhow::Result> { + Ok(Self::decode_streamed(reader)?.filter(|event| { + matches!( + event.kind, + MemtrackEventKind::Mapping { .. } + | MemtrackEventKind::Fork { .. } + | MemtrackEventKind::Exec + ) + })) + } + pub fn is_empty(reader: R) -> bool { let Ok(mut stream) = MemtrackArtifact::decode_streamed(BufReader::new(reader)) else { return true; diff --git a/src/executor/memory/module_artifacts.rs b/src/executor/memory/module_artifacts.rs index de6d72cd1..0e80d1831 100644 --- a/src/executor/memory/module_artifacts.rs +++ b/src/executor/memory/module_artifacts.rs @@ -103,16 +103,7 @@ fn read_mappings(results_folder: &Path) -> Result> { /// Reconstruct mappings across forks because inherited perf events do not /// synthesize mappings that already existed when a child was forked. fn read_mappings_from_artifact(reader: R) -> Result> { - let mut timeline = MemtrackArtifact::decode_streamed(reader)? - .filter(|event| { - matches!( - &event.kind, - MemtrackEventKind::Exec - | MemtrackEventKind::Mapping { .. } - | MemtrackEventKind::Fork { .. } - ) - }) - .collect::>(); + let mut timeline = MemtrackArtifact::decode_module_events(reader)?.collect::>(); // Ties break so exec purges before mapping, while fork inherits that mapping. timeline.sort_by_key(|event| { From 02b1261b18eaf36f865c076412d6d360b1d676da Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 9 Oct 2026 11:38:22 +0200 Subject: [PATCH 2/3] test(runner-shared): benchmark finding module events in a memtrack artifact Since 5.4.0 the memory executor decodes every event of the memtrack artifact after the run, only to keep the few Mapping, Fork and Exec events it needs for module artifacts. On large artifacts this takes minutes between memtrack's last log line and the upload. The benchmark builds an artifact laid out like memtrack writes it: allocation events with forks and execs spread through the stream and the mapping suffix at the end. It runs without stacks and with an 8 KiB stack record every 400 events, at 1M events, and checks the expected number of module events is found. Larger artifacts take minutes per case with the streamed decoder under the simulation instrument. Events are generated as the encoder consumes them and the artifact is written to a file under the target directory and mapped, so larger sizes never have to fit in memory. --- Cargo.lock | 2 + crates/runner-shared/Cargo.toml | 6 + .../runner-shared/benches/memtrack_reader.rs | 137 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 crates/runner-shared/benches/memtrack_reader.rs diff --git a/Cargo.lock b/Cargo.lock index e90f46190..d7bdaa3cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3659,6 +3659,7 @@ dependencies = [ "libc", "linux-perf-event-reader 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", "log", + "memmap2", "rand 0.8.6", "rayon", "rmp", @@ -3666,6 +3667,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "tempfile", "zstd", ] diff --git a/crates/runner-shared/Cargo.toml b/crates/runner-shared/Cargo.toml index e1bf06845..594d9cab8 100644 --- a/crates/runner-shared/Cargo.toml +++ b/crates/runner-shared/Cargo.toml @@ -27,8 +27,14 @@ zstd = "0.13.3" [dev-dependencies] divan = { version = "4.2.0", package = "codspeed-divan-compat" } +memmap2 = "0.9.10" rand = "0.8" +tempfile = { workspace = true } [[bench]] name = "memtrack_writer" harness = false + +[[bench]] +name = "memtrack_reader" +harness = false diff --git a/crates/runner-shared/benches/memtrack_reader.rs b/crates/runner-shared/benches/memtrack_reader.rs new file mode 100644 index 000000000..c8b1e47f9 --- /dev/null +++ b/crates/runner-shared/benches/memtrack_reader.rs @@ -0,0 +1,137 @@ +//! Finding the module events (mappings, forks, execs) the runner needs for +//! offline stack attribution inside a full memtrack artifact. These are a +//! handful of records among millions of allocation events, so the cost is +//! dominated by everything the search has to read past. + +use divan::Bencher; +use divan::counter::{BytesCount, ItemsCount}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use runner_shared::artifacts::{ + MemtrackArtifact, MemtrackEvent, MemtrackEventKind, StackRecord, encode_events, +}; +use std::io::Write; + +fn main() { + divan::main(); +} + +/// Artifact sizes in events. CI-sized artifacts take minutes per case with the +/// streamed decoder under the simulation instrument. +const SIZES: &[usize] = &[1_000_000]; + +#[derive(Clone, Copy)] +enum Payload { + Allocations, + AllocationsAndStacks, +} + +/// An artifact without stack capture. +#[divan::bench(args = SIZES, max_time = 10.0)] +fn find_module_events(bencher: Bencher, n: usize) { + bench(bencher, n, Payload::Allocations); +} + +/// An artifact with captured stacks, which make up most of its bytes. +#[divan::bench(args = SIZES, max_time = 10.0)] +fn find_module_events_with_stacks(bencher: Bencher, n: usize) { + bench(bencher, n, Payload::AllocationsAndStacks); +} + +fn bench(bencher: Bencher, n: usize, payload: Payload) { + // The largest artifacts do not fit in memory, so they are written to disk + // and mapped: mapped pages stay reclaimable. `/tmp` may be a tmpfs, hence + // the target directory. + let file = tempfile::tempfile_in(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let module_events = write_artifact(n, payload, &file); + // SAFETY: the file is unlinked and only this process holds it. + let artifact = unsafe { memmap2::Mmap::map(&file) }.unwrap(); + + let find = || { + MemtrackArtifact::decode_module_events(&artifact[..]) + .unwrap() + .count() + }; + assert_eq!(find(), module_events); + + bencher + .counter(ItemsCount::new(n)) + .counter(BytesCount::new(artifact.len())) + .bench_local(find); +} + +/// Write an encoded artifact of `n` events laid out like memtrack writes it, +/// and return the number of module events in it: allocations with a fork or +/// exec every so often, then the mapping suffix. Events are generated as the +/// encoder consumes them, so they are never all in memory at once. +fn write_artifact(n: usize, payload: Payload, out: impl Write) -> usize { + // Executable mappings memtrack appends after the event rings drain. + const MAPPINGS: usize = 64; + // Forks and execs come from BPF tracepoints, interleaved with allocations. + const LIFECYCLE_INTERVAL: usize = 16 * 1024; + // Keeps a 100M-event artifact with stacks at about 5 GB on disk. + const STACK_INTERVAL: usize = 400; + const ENCODE_WORKERS: usize = 4; + const PID: i32 = 4242; + + let first_mapping = n - MAPPINGS; + let mut rng = StdRng::seed_from_u64(42); + let events = (0..n).map(|i| { + let kind = if i >= first_mapping { + MemtrackEventKind::Mapping { + path: format!("/usr/lib/libmodule{i}.so"), + dev: 0x0800_0001, + ino: i as u64, + file_offset: 0x1000, + len: 0x8_0000, + } + } else if i % LIFECYCLE_INTERVAL == 0 { + if (i / LIFECYCLE_INTERVAL) % 2 == 0 { + MemtrackEventKind::Fork { parent_pid: PID } + } else { + MemtrackEventKind::Exec + } + } else if matches!(payload, Payload::AllocationsAndStacks) && i % STACK_INTERVAL == 0 { + stack_event(&mut rng) + } else if i % 2 == 0 { + MemtrackEventKind::Malloc { + size: rng.gen_range(8..8192), + stack_hash: rng.r#gen(), + } + } else { + MemtrackEventKind::Free + }; + + MemtrackEvent { + pid: PID, + tid: PID, + timestamp: i as u64, + addr: rng.r#gen(), + kind, + } + }); + + encode_events(events, out, ENCODE_WORKERS).unwrap(); + MAPPINGS + first_mapping.div_ceil(LIFECYCLE_INTERVAL) +} + +/// A captured stack sized like memtrack's: its default stack copy budget of +/// incompressible bytes, plus the x86_64 register set. +fn stack_event(rng: &mut StdRng) -> MemtrackEventKind { + const STACK_BYTES: usize = 8192; + const REGS: usize = 33; + const FP_FRAMES: usize = 16; + + let mut bytes = vec![0; STACK_BYTES]; + rng.fill(&mut bytes[..]); + MemtrackEventKind::Stack { + record: Box::new(StackRecord { + hash: rng.r#gen(), + sp: rng.r#gen(), + regs: (0..REGS).map(|_| rng.r#gen()).collect(), + bytes, + fp_chain: (0..FP_FRAMES).map(|_| rng.r#gen()).collect(), + truncated: true, + }), + } +} From 83c5faaef4708782d0da0829537efb37c5071e41 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 9 Oct 2026 16:07:12 +0200 Subject: [PATCH 3/3] test(runner-shared): keep instrumented module event benches to 1M events Generating and searching artifacts much larger than 1M events outlasts the CI job under the simulation and memory instruments. Pick the sizes from CODSPEED_RUNNER_MODE so these modes only search the smallest artifact, leaving walltime and plain divan runs free to cover larger ones. --- .../runner-shared/benches/memtrack_reader.rs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/runner-shared/benches/memtrack_reader.rs b/crates/runner-shared/benches/memtrack_reader.rs index c8b1e47f9..c04f2c6b8 100644 --- a/crates/runner-shared/benches/memtrack_reader.rs +++ b/crates/runner-shared/benches/memtrack_reader.rs @@ -3,6 +3,7 @@ //! handful of records among millions of allocation events, so the cost is //! dominated by everything the search has to read past. +use clap::ValueEnum; use divan::Bencher; use divan::counter::{BytesCount, ItemsCount}; use rand::rngs::StdRng; @@ -10,15 +11,31 @@ use rand::{Rng, SeedableRng}; use runner_shared::artifacts::{ MemtrackArtifact, MemtrackEvent, MemtrackEventKind, StackRecord, encode_events, }; +use runner_shared::measurement_mode::MeasurementMode; +use runner_shared::runtime_env::RUNNER_MODE_ENV; use std::io::Write; fn main() { divan::main(); } -/// Artifact sizes in events. CI-sized artifacts take minutes per case with the -/// streamed decoder under the simulation instrument. -const SIZES: &[usize] = &[1_000_000]; +/// Artifact sizes in events. +const WALLTIME_MODE_SIZES: &[usize] = &[1_000_000]; + +/// Under the simulation and memory instruments, generating and searching +/// larger artifacts outlasts the CI job, so these modes only search the +/// smallest artifact. +const ANALYSIS_MODE_SIZES: &[usize] = &[1_000_000]; + +fn sizes() -> &'static [usize] { + let mode = std::env::var(RUNNER_MODE_ENV) + .ok() + .and_then(|mode| MeasurementMode::from_str(&mode, true).ok()); + match mode { + Some(MeasurementMode::Simulation | MeasurementMode::Memory) => ANALYSIS_MODE_SIZES, + Some(MeasurementMode::Walltime) | None => WALLTIME_MODE_SIZES, + } +} #[derive(Clone, Copy)] enum Payload { @@ -27,13 +44,13 @@ enum Payload { } /// An artifact without stack capture. -#[divan::bench(args = SIZES, max_time = 10.0)] +#[divan::bench(args = sizes(), max_time = 10.0)] fn find_module_events(bencher: Bencher, n: usize) { bench(bencher, n, Payload::Allocations); } /// An artifact with captured stacks, which make up most of its bytes. -#[divan::bench(args = SIZES, max_time = 10.0)] +#[divan::bench(args = sizes(), max_time = 10.0)] fn find_module_events_with_stacks(bencher: Bencher, n: usize) { bench(bencher, n, Payload::AllocationsAndStacks); }