Skip to content

Commit fdf45ba

Browse files
committed
feat(walltime): backfill benchmark code mappings from /proc/<pid>/maps
perf only emits MMAP2/COMM while its event is enabled, so benchmark processes that exec()'d while sampling was disabled (every one after the first in a run) are missing their code mappings in the perf data, leaving their samples unsymbolized and without flamegraphs. Snapshot each benchmark pid's executable file mappings from /proc/<pid>/maps the first time it announces itself over the FIFO (while still alive), then backfill any mappings perf missed before symbolizing.
1 parent eb2a8e7 commit fdf45ba

4 files changed

Lines changed: 185 additions & 3 deletions

File tree

src/executor/shared/fifo.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::executor::shared::proc_maps::{read_proc_maps, ProcMapping};
12
use crate::prelude::*;
23
use anyhow::Context;
34
use futures::StreamExt;
@@ -7,7 +8,10 @@ use runner_shared::fifo::{RUNNER_ACK_FIFO, RUNNER_CTL_FIFO};
78
use std::cmp::Ordering;
89
use std::os::unix::fs::OpenOptionsExt;
910
use std::path::{Path, PathBuf};
10-
use std::{collections::HashSet, time::Duration};
11+
use std::{
12+
collections::{HashMap, HashSet},
13+
time::Duration,
14+
};
1115
use tokio::io::AsyncWriteExt;
1216
use tokio::net::unix::pid_t;
1317
use tokio::net::unix::pipe::Receiver as TokioPipeReader;
@@ -69,6 +73,11 @@ pub struct FifoBenchmarkData {
6973
/// Name and version of the integration
7074
pub integration: Option<(String, String)>,
7175
pub bench_pids: HashSet<pid_t>,
76+
/// Executable file mappings snapshotted from `/proc/<pid>/maps` the first time
77+
/// each benchmark pid announced itself over the FIFO (while it was still alive).
78+
/// Backfills code mappings that perf's `MMAP2` records miss for processes that
79+
/// exec()'d while sampling was disabled.
80+
pub maps_by_pid: HashMap<pid_t, Vec<ProcMapping>>,
7281
}
7382

7483
impl FifoBenchmarkData {
@@ -171,6 +180,7 @@ impl RunnerFifo {
171180
)> {
172181
let mut bench_order_by_timestamp = Vec::<(u64, String)>::new();
173182
let mut bench_pids = HashSet::<pid_t>::new();
183+
let mut maps_by_pid = HashMap::<pid_t, Vec<ProcMapping>>::new();
174184
let mut markers = Vec::<MarkerType>::new();
175185

176186
let mut integration = None;
@@ -207,7 +217,15 @@ impl RunnerFifo {
207217
match &cmd {
208218
FifoCommand::CurrentBenchmark { pid, uri } => {
209219
bench_order_by_timestamp.push((get_current_time(), uri.to_string()));
210-
bench_pids.insert(*pid);
220+
// Snapshot the process's code mappings the first time we see it,
221+
// while it is still alive, to recover mappings perf drops for
222+
// processes that exec()'d while sampling was disabled.
223+
if bench_pids.insert(*pid) {
224+
let mappings = read_proc_maps(*pid);
225+
if !mappings.is_empty() {
226+
maps_by_pid.insert(*pid, mappings);
227+
}
228+
}
211229
self.send_cmd(FifoCommand::Ack).await?;
212230
}
213231
FifoCommand::StartProfiler => {
@@ -278,6 +296,7 @@ impl RunnerFifo {
278296
let fifo_data = FifoBenchmarkData {
279297
integration,
280298
bench_pids,
299+
maps_by_pid,
281300
};
282301
return Ok((marker_result, fifo_data, exit_status));
283302
}

src/executor/shared/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pub mod fifo;
2+
pub mod proc_maps;

src/executor/shared/proc_maps.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
use crate::prelude::*;
2+
use std::path::PathBuf;
3+
use tokio::net::unix::pid_t;
4+
5+
/// One executable file mapping of a live process, read from `/proc/<pid>/maps`.
6+
#[derive(Debug, Clone)]
7+
pub struct ProcMapping {
8+
/// Absolute path of the mapped ELF file.
9+
pub path: PathBuf,
10+
/// Runtime start address of the mapping.
11+
pub start: u64,
12+
/// Runtime end address of the mapping.
13+
pub end: u64,
14+
/// File offset of the mapping (matches perf's `MMAP2` page offset).
15+
pub offset: u64,
16+
}
17+
18+
/// Best-effort snapshot of a live process's executable file mappings.
19+
///
20+
/// perf only emits `MMAP2`/`COMM` while its event is enabled, so any benchmark
21+
/// process that exec()'d while sampling was disabled (every one after the first in
22+
/// a run) is missing its code mappings in the perf data. Reading `/proc/<pid>/maps`
23+
/// while the process is alive recovers those load addresses so its samples can be
24+
/// symbolized. Returns empty on any error rather than failing the run.
25+
pub fn read_proc_maps(pid: pid_t) -> Vec<ProcMapping> {
26+
if !cfg!(target_os = "linux") {
27+
return Vec::new();
28+
}
29+
30+
let path = format!("/proc/{pid}/maps");
31+
match std::fs::read_to_string(&path) {
32+
Ok(content) => parse_proc_maps(&content),
33+
Err(e) => {
34+
warn!("Failed to read {path} for benchmark pid {pid}: {e}");
35+
Vec::new()
36+
}
37+
}
38+
}
39+
40+
/// Parse the executable file mappings out of `/proc/<pid>/maps` content.
41+
///
42+
/// Each line is `start-end perms offset dev inode pathname`. We keep only
43+
/// executable (`x`) file-backed mappings; anonymous/special (`[vdso]`, `[heap]`, …)
44+
/// and deleted files are skipped. The numeric fields never contain `/` or `[`, so
45+
/// the pathname is taken from the first such character, which tolerates spaces in
46+
/// the path.
47+
fn parse_proc_maps(content: &str) -> Vec<ProcMapping> {
48+
let mut mappings = Vec::new();
49+
50+
for line in content.lines() {
51+
let mut fields = line.split_whitespace();
52+
let (Some(range), Some(perms), Some(offset)) =
53+
(fields.next(), fields.next(), fields.next())
54+
else {
55+
continue;
56+
};
57+
58+
if !perms.contains('x') {
59+
continue;
60+
}
61+
62+
let Some(path_start) = line.find(['/', '[']) else {
63+
continue;
64+
};
65+
let pathname = line[path_start..].trim();
66+
if pathname.starts_with('[') {
67+
continue;
68+
}
69+
let pathname = pathname.strip_suffix(" (deleted)").unwrap_or(pathname);
70+
71+
let Some((start, end)) = range.split_once('-') else {
72+
continue;
73+
};
74+
let (Ok(start), Ok(end), Ok(offset)) = (
75+
u64::from_str_radix(start, 16),
76+
u64::from_str_radix(end, 16),
77+
u64::from_str_radix(offset, 16),
78+
) else {
79+
continue;
80+
};
81+
82+
mappings.push(ProcMapping {
83+
path: PathBuf::from(pathname),
84+
start,
85+
end,
86+
offset,
87+
});
88+
}
89+
90+
mappings
91+
}
92+
93+
#[cfg(all(test, target_os = "linux"))]
94+
mod tests {
95+
use super::*;
96+
97+
#[test]
98+
fn parse_proc_maps_keeps_only_executable_file_mappings() {
99+
let content = "\
100+
55a0f0000000-55a0f0001000 r--p 00000000 fe:01 100 /bin/bench
101+
55a0f0001000-55a0f0100000 r-xp 00001000 fe:01 100 /bin/bench
102+
55a0f0100000-55a0f0200000 r--p 00100000 fe:01 100 /bin/bench
103+
7f00aa000000-7f00aa100000 r-xp 00002000 fe:01 200 /usr/lib/libc.so.6
104+
7f00bb000000-7f00bb001000 rw-p 00000000 00:00 0
105+
7ffd00000000-7ffd00021000 rw-p 00000000 00:00 0 [stack]
106+
7ffd00021000-7ffd00023000 r-xp 00000000 00:00 0 [vdso]
107+
7f00cc000000-7f00cc010000 r-xp 00000000 fe:01 300 /tmp/old.so (deleted)
108+
";
109+
let maps = parse_proc_maps(content);
110+
111+
// Only the two live executable file mappings (bench .text, libc .text) and the
112+
// deleted one (path stripped) are kept; non-exec, anon, [stack], [vdso] dropped.
113+
let paths: Vec<_> = maps
114+
.iter()
115+
.map(|m| m.path.to_string_lossy().into_owned())
116+
.collect();
117+
assert_eq!(paths, vec!["/bin/bench", "/usr/lib/libc.so.6", "/tmp/old.so"]);
118+
119+
let bench = &maps[0];
120+
assert_eq!(bench.start, 0x55a0f0001000);
121+
assert_eq!(bench.end, 0x55a0f0100000);
122+
assert_eq!(bench.offset, 0x1000);
123+
}
124+
125+
#[test]
126+
fn parse_proc_maps_handles_paths_with_spaces() {
127+
let content =
128+
"400000-401000 r-xp 00000000 fe:01 100 /home/user/my bench/target/deps/thing\n";
129+
let maps = parse_proc_maps(content);
130+
assert_eq!(maps.len(), 1);
131+
assert_eq!(
132+
maps[0].path.to_string_lossy(),
133+
"/home/user/my bench/target/deps/thing"
134+
);
135+
}
136+
}

src/executor/wall_time/profiler/perf/mod.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,13 +268,39 @@ impl BenchmarkData<'_> {
268268
debug!("Pid filter for perf file parsing: {pid_filter:?}");
269269
debug!("Reading perf data from file for mmap extraction");
270270
let MemmapRecordsOutput {
271-
loaded_modules_by_path,
271+
mut loaded_modules_by_path,
272272
tracked_pids,
273273
} = parse_perf_file::parse_for_memmap2(perf_file_path, pid_filter).map_err(|e| {
274274
error!("Failed to parse perf file: {e}");
275275
BenchmarkDataSaveError::FailedToParsePerfFile
276276
})?;
277277

278+
// perf only emits MMAP2 while its event is enabled, so benchmark processes
279+
// that exec()'d while sampling was disabled (every one after the first) are
280+
// missing their executable mappings above. Backfill them from the
281+
// /proc/<pid>/maps snapshots taken live when each pid announced itself.
282+
for (pid, mappings) in &self.fifo_data.maps_by_pid {
283+
for mapping in mappings {
284+
let already_mapped = loaded_modules_by_path
285+
.get(&mapping.path)
286+
.is_some_and(|module| module.process_loaded_modules.contains_key(pid));
287+
if already_mapped {
288+
continue;
289+
}
290+
291+
let path_string = mapping.path.to_string_lossy();
292+
parse_perf_file::add_module_mapping(
293+
&mut loaded_modules_by_path,
294+
&mapping.path,
295+
&path_string,
296+
mapping.start,
297+
mapping.end,
298+
mapping.offset,
299+
*pid,
300+
);
301+
}
302+
}
303+
278304
// Harvest the perf maps generated by python. This will copy the perf
279305
// maps from /tmp to the profile folder. We have to write our own perf
280306
// maps to these files AFTERWARDS, otherwise it'll be overwritten!

0 commit comments

Comments
 (0)