Skip to content

Commit 7044982

Browse files
committed
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, from 262k events up to 100M, closer to the tens of millions a CI memory run writes, and checks the expected number of module events is found. Events are generated as the encoder consumes them and the artifact is written to a file under the target directory and mapped, so even the 100M-event artifact with stacks (about 5 GB) never has to fit in memory.
1 parent bfe5aee commit 7044982

3 files changed

Lines changed: 146 additions & 0 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/runner-shared/Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,14 @@ zstd = "0.13.3"
2727

2828
[dev-dependencies]
2929
divan = { version = "4.2.0", package = "codspeed-divan-compat" }
30+
memmap2 = "0.9.10"
3031
rand = "0.8"
32+
tempfile = { workspace = true }
3133

3234
[[bench]]
3335
name = "memtrack_writer"
3436
harness = false
37+
38+
[[bench]]
39+
name = "memtrack_reader"
40+
harness = false
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Finding the module events (mappings, forks, execs) the runner needs for
2+
//! offline stack attribution inside a full memtrack artifact. These are a
3+
//! handful of records among millions of allocation events, so the cost is
4+
//! dominated by everything the search has to read past.
5+
6+
use divan::Bencher;
7+
use divan::counter::{BytesCount, ItemsCount};
8+
use rand::rngs::StdRng;
9+
use rand::{Rng, SeedableRng};
10+
use runner_shared::artifacts::{
11+
MemtrackArtifact, MemtrackEvent, MemtrackEventKind, StackRecord, encode_events,
12+
};
13+
use std::io::Write;
14+
15+
fn main() {
16+
divan::main();
17+
}
18+
19+
/// Artifact sizes in events: from four encoder frames, small enough for the
20+
/// simulation instrument, up to a CI-sized artifact with enough frames to show
21+
/// how the parallel frame scan scales.
22+
const SIZES: [usize; 4] = [4 * 64 * 1024, 1_000_000, 10_000_000, 100_000_000];
23+
24+
#[derive(Clone, Copy)]
25+
enum Payload {
26+
Allocations,
27+
AllocationsAndStacks,
28+
}
29+
30+
/// An artifact without stack capture.
31+
#[divan::bench(args = SIZES, max_time = 10.0)]
32+
fn find_module_events(bencher: Bencher, n: usize) {
33+
bench(bencher, n, Payload::Allocations);
34+
}
35+
36+
/// An artifact with captured stacks, which make up most of its bytes.
37+
#[divan::bench(args = SIZES, max_time = 10.0)]
38+
fn find_module_events_with_stacks(bencher: Bencher, n: usize) {
39+
bench(bencher, n, Payload::AllocationsAndStacks);
40+
}
41+
42+
fn bench(bencher: Bencher, n: usize, payload: Payload) {
43+
// The largest artifacts do not fit in memory, so they are written to disk
44+
// and mapped: mapped pages stay reclaimable. `/tmp` may be a tmpfs, hence
45+
// the target directory.
46+
let file = tempfile::tempfile_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
47+
let module_events = write_artifact(n, payload, &file);
48+
// SAFETY: the file is unlinked and only this process holds it.
49+
let artifact = unsafe { memmap2::Mmap::map(&file) }.unwrap();
50+
51+
let find = || {
52+
MemtrackArtifact::decode_module_events(&artifact[..])
53+
.unwrap()
54+
.count()
55+
};
56+
assert_eq!(find(), module_events);
57+
58+
bencher
59+
.counter(ItemsCount::new(n))
60+
.counter(BytesCount::new(artifact.len()))
61+
.bench_local(find);
62+
}
63+
64+
/// Write an encoded artifact of `n` events laid out like memtrack writes it,
65+
/// and return the number of module events in it: allocations with a fork or
66+
/// exec every so often, then the mapping suffix. Events are generated as the
67+
/// encoder consumes them, so they are never all in memory at once.
68+
fn write_artifact(n: usize, payload: Payload, out: impl Write) -> usize {
69+
// Executable mappings memtrack appends after the event rings drain.
70+
const MAPPINGS: usize = 64;
71+
// Forks and execs come from BPF tracepoints, interleaved with allocations.
72+
const LIFECYCLE_INTERVAL: usize = 16 * 1024;
73+
// Keeps the largest artifact with stacks at about 5 GB on disk.
74+
const STACK_INTERVAL: usize = 400;
75+
const ENCODE_WORKERS: usize = 4;
76+
const PID: i32 = 4242;
77+
78+
let first_mapping = n - MAPPINGS;
79+
let mut rng = StdRng::seed_from_u64(42);
80+
let events = (0..n).map(|i| {
81+
let kind = if i >= first_mapping {
82+
MemtrackEventKind::Mapping {
83+
path: format!("/usr/lib/libmodule{i}.so"),
84+
dev: 0x0800_0001,
85+
ino: i as u64,
86+
file_offset: 0x1000,
87+
len: 0x8_0000,
88+
}
89+
} else if i % LIFECYCLE_INTERVAL == 0 {
90+
if (i / LIFECYCLE_INTERVAL) % 2 == 0 {
91+
MemtrackEventKind::Fork { parent_pid: PID }
92+
} else {
93+
MemtrackEventKind::Exec
94+
}
95+
} else if matches!(payload, Payload::AllocationsAndStacks) && i % STACK_INTERVAL == 0 {
96+
stack_event(&mut rng)
97+
} else if i % 2 == 0 {
98+
MemtrackEventKind::Malloc {
99+
size: rng.gen_range(8..8192),
100+
stack_hash: rng.r#gen(),
101+
}
102+
} else {
103+
MemtrackEventKind::Free
104+
};
105+
106+
MemtrackEvent {
107+
pid: PID,
108+
tid: PID,
109+
timestamp: i as u64,
110+
addr: rng.r#gen(),
111+
kind,
112+
}
113+
});
114+
115+
encode_events(events, out, ENCODE_WORKERS).unwrap();
116+
MAPPINGS + first_mapping.div_ceil(LIFECYCLE_INTERVAL)
117+
}
118+
119+
/// A captured stack sized like memtrack's: its default stack copy budget of
120+
/// incompressible bytes, plus the x86_64 register set.
121+
fn stack_event(rng: &mut StdRng) -> MemtrackEventKind {
122+
const STACK_BYTES: usize = 8192;
123+
const REGS: usize = 33;
124+
const FP_FRAMES: usize = 16;
125+
126+
let mut bytes = vec![0; STACK_BYTES];
127+
rng.fill(&mut bytes[..]);
128+
MemtrackEventKind::Stack {
129+
record: Box::new(StackRecord {
130+
hash: rng.r#gen(),
131+
sp: rng.r#gen(),
132+
regs: (0..REGS).map(|_| rng.r#gen()).collect(),
133+
bytes,
134+
fp_chain: (0..FP_FRAMES).map(|_| rng.r#gen()).collect(),
135+
truncated: true,
136+
}),
137+
}
138+
}

0 commit comments

Comments
 (0)