|
| 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