From 4521a0085884ff9f0eab0c53622e1e964ab2791c Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 2 Oct 2026 16:35:36 +0200 Subject: [PATCH 1/4] feat(memory): harvest perf maps and JIT unwind data for memtrack Python and Node write runtime symbols to /tmp/perf-.map, and Python JIT dumps carry the unwind data needed to walk through interpreter trampolines. Collect both for the benchmark processes before saving the memtrack metadata, reusing the walltime artifact pipeline, so offline allocation stacks keep their runtime frames. --- src/executor/memory/executor.rs | 35 ++++++++++-------- src/executor/memory/module_artifacts.rs | 40 ++++++++++++++------- src/executor/wall_time/profiler/perf/mod.rs | 2 +- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/executor/memory/executor.rs b/src/executor/memory/executor.rs index d54a0d675..e021f482f 100644 --- a/src/executor/memory/executor.rs +++ b/src/executor/memory/executor.rs @@ -181,14 +181,13 @@ impl Executor for MemoryExecutor { debug!("cmd: {cmd:?}"); let runner_fifo = RunnerFifo::new()?; - let integration = Rc::new(RefCell::new(None)); + let fifo_data = Rc::new(RefCell::new(None)); let on_process_started = { - let integration = integration.clone(); + let fifo_data_cell = fifo_data.clone(); |mut child: std::process::Child| async move { - let (marker_result, fifo_data, exit_status) = + let (marker_result, data, exit_status) = Self::handle_fifo(runner_fifo, ipc, &mut child).await?; - *integration.borrow_mut() = fifo_data.integration; - + *fifo_data_cell.borrow_mut() = Some(data); marker_result.save_to(&results_folder).unwrap(); Ok(exit_status) @@ -202,16 +201,22 @@ impl Executor for MemoryExecutor { bail!("failed to execute memory tracker process: {status}"); } - if let Some(integration) = integration.borrow_mut().take() { - let results_folder = execution_context.profile_folder.join("results"); - if let Err(e) = save_module_artifacts( - &execution_context.profile_folder, - &results_folder, - integration, - ) { - // The memory results are complete without them; only offline - // stack attribution is lost. - error!("Failed to save memtrack module artifacts: {e:#}"); + let data = fifo_data.borrow_mut().take(); + if let Some(data) = data { + if let Some(integration) = data.integration { + let results_folder = execution_context.profile_folder.join("results"); + if let Err(e) = save_module_artifacts( + &execution_context.profile_folder, + &results_folder, + integration, + &data.bench_pids, + ) + .await + { + // The memory results are complete without them; only offline + // stack attribution is lost. + error!("Failed to save memtrack module artifacts: {e:#}"); + } } } diff --git a/src/executor/memory/module_artifacts.rs b/src/executor/memory/module_artifacts.rs index 8bd0596c1..046f5c439 100644 --- a/src/executor/memory/module_artifacts.rs +++ b/src/executor/memory/module_artifacts.rs @@ -1,13 +1,15 @@ +use crate::executor::helpers::harvest_perf_maps_for_pids::harvest_perf_maps_for_pids; use crate::executor::shared::module_artifacts::loaded_module::LoadedModule; use crate::executor::shared::module_artifacts::module_symbols::ModuleSymbols; use crate::executor::shared::module_artifacts::save_artifacts::save_artifacts; use crate::executor::shared::module_artifacts::unwind_data::unwind_data_from_elf; +use crate::executor::wall_time::profiler::perf::jit_dump::save_symbols_and_harvest_unwind_data_for_pids; use crate::prelude::*; use libc::pid_t; use runner_shared::artifacts::{ArtifactExt, MemtrackArtifact, MemtrackEventKind}; use runner_shared::metadata::MemtrackMetadata; use runner_shared::unwind_data::ProcessUnwindData; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; @@ -33,23 +35,33 @@ struct ProcessMapping { timestamp: u64, } -/// Turn the mappings memtrack recorded into the artifacts an offline unwinder -/// needs: the deduplicated `unwind_data`/`symbols.map` files, plus the -/// `memtrack.metadata` referencing them per pid. -/// -/// `results_folder` is where memtrack wrote its artifacts; the keyed files and -/// the metadata land in `profile_folder`, next to walltime's equivalents. -pub fn save_module_artifacts( +/// Save the native modules memtrack mapped, per-process perf maps, and JIT +/// unwind data needed for offline stack attribution. The keyed native and JIT +/// files and their `memtrack.metadata` land in `profile_folder`; memtrack's +/// event streams are read from `results_folder`. +pub async fn save_module_artifacts( profile_folder: &Path, results_folder: &Path, integration: (String, String), + bench_pids: &HashSet, ) -> Result<()> { let mappings = read_mappings(results_folder)?; - if mappings.is_empty() { - debug!("No module mappings recorded, skipping memtrack module artifacts"); + let pids = mappings + .iter() + .map(|mapping| mapping.pid) + .chain(bench_pids.iter().copied()) + .collect::>(); + if pids.is_empty() { + debug!("No processes recorded, skipping memtrack module artifacts"); return Ok(()); } + // Python and Node emit perf maps in /tmp; Python JIT dumps also contain + // unwind data needed to walk through interpreter trampolines. + harvest_perf_maps_for_pids(profile_folder, &pids).await?; + let jit_unwind_data = + save_symbols_and_harvest_unwind_data_for_pids(profile_folder, &pids).await?; + let loaded_modules = loaded_modules_from_mappings(&mappings); debug!( "Extracting artifacts for {} modules from {} mappings", @@ -57,7 +69,7 @@ pub fn save_module_artifacts( mappings.len() ); - let saved = save_artifacts(profile_folder, &loaded_modules, &HashMap::new()); + let saved = save_artifacts(profile_folder, &loaded_modules, &jit_unwind_data); MemtrackMetadata::new(integration, saved.artifacts).save_to(profile_folder) } @@ -481,8 +493,8 @@ mod tests { ); } - #[test] - fn writes_keyed_artifacts_and_metadata_for_a_streamed_mapping() { + #[tokio::test] + async fn writes_keyed_artifacts_and_metadata_for_a_streamed_mapping() { const MODULE: &str = "testdata/perf_map/the_algorithms.bin"; let profile = tempfile::tempdir().unwrap(); @@ -512,7 +524,9 @@ mod tests { profile.path(), &results, ("codspeed-rust".to_string(), "4.2.0".to_string()), + &HashSet::new(), ) + .await .unwrap(); let metadata = MemtrackMetadata::from_reader( diff --git a/src/executor/wall_time/profiler/perf/mod.rs b/src/executor/wall_time/profiler/perf/mod.rs index 7f31921e2..dbc0e464a 100644 --- a/src/executor/wall_time/profiler/perf/mod.rs +++ b/src/executor/wall_time/profiler/perf/mod.rs @@ -30,7 +30,7 @@ use runner_shared::metadata::WalltimeMetadata; use std::path::Path; use std::path::PathBuf; -mod jit_dump; +pub(crate) mod jit_dump; mod parse_perf_file; pub(crate) mod setup; From 2caa48e939dbe6821ba37be648dee07c857b8069 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 2 Oct 2026 16:36:43 +0200 Subject: [PATCH 2/4] feat(exec-harness): emit Python and Node perf maps in memory mode Memory stacks can only name Python and Node frames from the runtime perf maps. Set PYTHONPERFSUPPORT and the Node perf options per benchmark command, as simulation mode already does. Memory mode also passes --interpreted-frames-native-stack, since interpreted JS frames otherwise all resolve to the shared V8 interpreter trampoline. --- README.md | 7 +++++-- crates/exec-harness/src/analysis/mod.rs | 5 ++++- crates/exec-harness/src/node.rs | 12 ++++++++---- crates/exec-harness/src/walltime/benchmark_loop.rs | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 811132aca..2a33f31e1 100644 --- a/README.md +++ b/README.md @@ -160,11 +160,14 @@ codspeed exec --mode simulation -- ./my-binary ### Memory -Tracks heap allocations (peak usage, count, allocation size) with eBPF profiling. +Tracks native heap allocations (peak usage, count, allocation size) with eBPF profiling. **Best for:** Memory optimization, leak detection, constrained environments -**Supported:** Rust, C/C++ with libc, jemalloc, mimalloc +**Supported:** Rust, C/C++ with libc, jemalloc, mimalloc; Python and Node.js +frames in allocation stacks (`codspeed exec` enables their perf maps). Only +allocations made through tracked native allocators are measured, not all +Python objects or V8 heap allocations. ```bash codspeed exec --mode memory -- ./my-binary diff --git a/crates/exec-harness/src/analysis/mod.rs b/crates/exec-harness/src/analysis/mod.rs index 8bb4eaf44..537e6d496 100644 --- a/crates/exec-harness/src/analysis/mod.rs +++ b/crates/exec-harness/src/analysis/mod.rs @@ -20,6 +20,9 @@ pub fn perform(commands: Vec) -> Result<()> { let mut cmd = Command::new(&benchmark_cmd.command[0]); cmd.args(&benchmark_cmd.command[1..]); + // Python and Node frames are only symbolized from runtime perf maps. + cmd.env("PYTHONPERFSUPPORT", "1"); + crate::node::set_node_options(&mut cmd, crate::node::MEMORY_NODE_OPTIONS); hooks.start_benchmark().unwrap(); let status = cmd.status(); hooks.stop_benchmark().unwrap(); @@ -58,7 +61,7 @@ pub fn perform_with_valgrind(commands: Vec) -> Result<()> { cmd.env("PYTHONPERFSUPPORT", "1"); cmd.env(constants::URI_ENV, &name_and_uri.uri); - crate::node::set_node_options(&mut cmd); + crate::node::set_node_options(&mut cmd, &[]); let mut child = cmd.spawn().context("Failed to spawn command")?; diff --git a/crates/exec-harness/src/node.rs b/crates/exec-harness/src/node.rs index d2c6c4771..e097c8863 100644 --- a/crates/exec-harness/src/node.rs +++ b/crates/exec-harness/src/node.rs @@ -2,13 +2,17 @@ use std::process::Command; const NODE_OPTIONS_TO_ADD: &[&str] = &["--perf-basic-prof"]; -/// Appends CodSpeed-required Node.js options to `NODE_OPTIONS` on a [`Command`], -/// preserving any existing value from the environment. -pub fn set_node_options(cmd: &mut Command) { +/// Without this flag, all interpreted JS frames share V8's interpreter +/// trampoline, so memory stacks cannot name the allocating JS function. +pub const MEMORY_NODE_OPTIONS: &[&str] = &["--interpreted-frames-native-stack"]; + +/// Appends CodSpeed-required Node.js options, plus `extra`, to `NODE_OPTIONS` +/// on a [`Command`], preserving any existing value from the environment. +pub fn set_node_options(cmd: &mut Command, extra: &[&str]) { let existing = std::env::var("NODE_OPTIONS").unwrap_or_default(); let mut parts: Vec<&str> = existing.split_whitespace().collect(); - for opt in NODE_OPTIONS_TO_ADD { + for opt in NODE_OPTIONS_TO_ADD.iter().chain(extra) { if !parts.contains(opt) { parts.push(opt); } diff --git a/crates/exec-harness/src/walltime/benchmark_loop.rs b/crates/exec-harness/src/walltime/benchmark_loop.rs index a66b075fe..411172a7a 100644 --- a/crates/exec-harness/src/walltime/benchmark_loop.rs +++ b/crates/exec-harness/src/walltime/benchmark_loop.rs @@ -17,7 +17,7 @@ pub fn run_rounds( let do_one_round = || -> Result<(u64, u64)> { let mut cmd = Command::new(&command[0]); cmd.args(&command[1..]); - crate::node::set_node_options(&mut cmd); + crate::node::set_node_options(&mut cmd, &[]); let mut child = cmd.spawn().context("Failed to execute command")?; let bench_round_start_ts_ns = InstrumentHooks::current_timestamp(); let status = child From 62fd7de1dde8c7bcde0f459d462804c4bb9db8de Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 2 Oct 2026 16:33:21 +0200 Subject: [PATCH 3/4] test(memtrack): cover Python and Node allocations with JIT symbols Track a native allocation made from a Python function (perf trampoline) and a Node function (V8 perf-basic-prof). Assert the allocation carries a captured stack and that the runtime perf map names the allocating function, which offline attribution needs. --- crates/memtrack/testdata/node_alloc.js | 12 +++ crates/memtrack/testdata/python_alloc.py | 19 +++++ crates/memtrack/tests/interpreter_tests.rs | 86 ++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 crates/memtrack/testdata/node_alloc.js create mode 100644 crates/memtrack/testdata/python_alloc.py create mode 100644 crates/memtrack/tests/interpreter_tests.rs diff --git a/crates/memtrack/testdata/node_alloc.js b/crates/memtrack/testdata/node_alloc.js new file mode 100644 index 000000000..b08a16e7c --- /dev/null +++ b/crates/memtrack/testdata/node_alloc.js @@ -0,0 +1,12 @@ +// Run with `node --perf-basic-prof --interpreted-frames-native-stack`: V8 then +// gives `allocation` a `JS:~allocation` entry in /tmp/perf-.map, so the +// native ArrayBuffer allocation below can be attributed to this JS function. +const ALLOCATION_SIZE = 2_000_001; + +function allocation() { + // `allocUnsafeSlow` skips the Buffer pool, so V8 asks the allocator for + // exactly ALLOCATION_SIZE bytes. + return Buffer.allocUnsafeSlow(ALLOCATION_SIZE); +} + +allocation(); diff --git a/crates/memtrack/testdata/python_alloc.py b/crates/memtrack/testdata/python_alloc.py new file mode 100644 index 000000000..ab44e8bd1 --- /dev/null +++ b/crates/memtrack/testdata/python_alloc.py @@ -0,0 +1,19 @@ +# Run with `python3 -X perf`: the perf trampoline gives `allocation` a +# `py::allocation:` entry in /tmp/perf-.map, so the native malloc +# below can be attributed to this Python function offline. +import ctypes + +libc = ctypes.CDLL(None) +libc.malloc.restype = ctypes.c_void_p +libc.malloc.argtypes = [ctypes.c_size_t] +libc.free.argtypes = [ctypes.c_void_p] + +ALLOCATION_SIZE = 2_000_001 + + +def allocation(): + ptr = libc.malloc(ALLOCATION_SIZE) + libc.free(ptr) + + +allocation() diff --git a/crates/memtrack/tests/interpreter_tests.rs b/crates/memtrack/tests/interpreter_tests.rs new file mode 100644 index 000000000..ec9add681 --- /dev/null +++ b/crates/memtrack/tests/interpreter_tests.rs @@ -0,0 +1,86 @@ +#[macro_use] +mod shared; + +use memtrack::TrackerOptions; +use runner_shared::artifacts::{MemtrackEvent, MemtrackEventKind}; +use std::path::Path; +use std::process::Command; + +const ALLOCATION_SIZE: u64 = 2_000_001; + +/// Find the fixture's allocation and return the pid that made it. The stack +/// must be captured, since offline attribution walks it through the JIT frame. +fn allocation_pid(events: &[MemtrackEvent]) -> libc::pid_t { + let (pid, stack_hash) = events + .iter() + .find_map(|event| match event.kind { + MemtrackEventKind::Malloc { size, stack_hash } + | MemtrackEventKind::Calloc { size, stack_hash } + | MemtrackEventKind::AlignedAlloc { size, stack_hash } + if size == ALLOCATION_SIZE => + { + Some((event.pid, stack_hash)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("no {ALLOCATION_SIZE}-byte allocation was tracked")); + assert_ne!(stack_hash, 0, "allocation has no captured stack"); + + pid +} + +/// The runtime wrote a perf map for the allocating process naming the +/// fixture's `allocation` function, which the runner harvests from /tmp. +fn assert_perf_map_symbol(pid: libc::pid_t, symbol: &str) { + let path = format!("/tmp/perf-{pid}.map"); + let perf_map = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); + let _ = std::fs::remove_file(&path); + + assert!( + perf_map.lines().any(|line| line.contains(symbol)), + "{path} has no `{symbol}` entry" + ); +} + +fn fixture(name: &str) -> String { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(name) + .to_string_lossy() + .into_owned() +} + +fn stack_capture() -> TrackerOptions { + TrackerOptions::builder().stack_capture(true).build() +} + +#[test_with::env(GITHUB_ACTIONS)] +#[test_log::test] +fn test_python_allocation_has_jit_symbol() -> anyhow::Result<()> { + let mut command = Command::new("python3"); + command.args(["-X", "perf", &fixture("python_alloc.py")]); + let (events, thread_handle) = shared::track_command(command, stack_capture())?; + + assert_perf_map_symbol(allocation_pid(&events), "py::allocation:"); + + thread_handle.join().unwrap(); + Ok(()) +} + +#[test_with::env(GITHUB_ACTIONS)] +#[test_log::test] +fn test_node_allocation_has_jit_symbol() -> anyhow::Result<()> { + let mut command = Command::new("node"); + command.args([ + "--perf-basic-prof", + "--interpreted-frames-native-stack", + &fixture("node_alloc.js"), + ]); + let (events, thread_handle) = shared::track_command(command, stack_capture())?; + + assert_perf_map_symbol(allocation_pid(&events), "JS:~allocation "); + + thread_handle.join().unwrap(); + Ok(()) +} From 9108e6c342c5000d2ac9f231e59f2f1775e6d8c2 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 2 Oct 2026 17:23:47 +0200 Subject: [PATCH 4/4] fix(memory): keep every placement of a module mapped more than once A process can map the same file at several addresses at once. V8 remaps its embedded builtins out of the node binary into its code range, so node runs from both its original text and that copy. Each placement has its own load bias, but only the last mapping per (path, pid) was kept, so frames in every earlier placement lost their symbols and unwind data. In a Node memory profile, all native node frames showed up as unresolved addresses. Record every distinct load bias and the unwind data of every executable mapping for each process, and emit them all in the artifact metadata. The walltime perf path shares this bookkeeping and gets the same fix. Add a sample that maps its own text a second time and allocates through both copies. The test feeds that process's real mappings into the artifact pipeline and checks that both copies resolve. Refs COD-1377 --- src/executor/memory/module_artifacts.rs | 135 +++++++++++++++++- .../shared/module_artifacts/loaded_module.rs | 24 +++- .../shared/module_artifacts/save_artifacts.rs | 8 +- .../profiler/perf/parse_perf_file.rs | 39 +++-- testdata/memory/remapped_text.c | 90 ++++++++++++ 5 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 testdata/memory/remapped_text.c diff --git a/src/executor/memory/module_artifacts.rs b/src/executor/memory/module_artifacts.rs index 046f5c439..b3bee9a9f 100644 --- a/src/executor/memory/module_artifacts.rs +++ b/src/executor/memory/module_artifacts.rs @@ -230,17 +230,16 @@ fn loaded_modules_from_mappings(mappings: &[ProcessMapping]) -> HashMap/maps` line into the mapping event memtrack records + /// for it. + fn mapping_event_from_maps_line(pid: pid_t, timestamp: u64, line: &str) -> MemtrackEvent { + let fields = line.split_whitespace().collect::>(); + let (start, end) = fields[0].split_once('-').unwrap(); + let (start, end) = ( + u64::from_str_radix(start, 16).unwrap(), + u64::from_str_radix(end, 16).unwrap(), + ); + let (major, minor) = fields[3].split_once(':').unwrap(); + let dev = + u64::from_str_radix(major, 16).unwrap() << 20 | u64::from_str_radix(minor, 16).unwrap(); + + MemtrackEvent { + pid, + tid: pid, + timestamp, + addr: start, + kind: MemtrackEventKind::Mapping { + path: fields[5].to_string(), + dev, + ino: fields[4].parse().unwrap(), + file_offset: u64::from_str_radix(fields[2], 16).unwrap(), + len: end - start, + }, + } + } + + /// A process can map its own binary a second time, as V8 does with its + /// embedded builtins. Each placement has its own load bias, and frames in + /// either copy must resolve. + #[tokio::test] + async fn keeps_every_placement_of_a_module_mapped_twice() { + let build = tempfile::tempdir().unwrap(); + let binary = build.path().join("remapped_text"); + let status = std::process::Command::new("gcc") + .args(["-O0", "-o"]) + .arg(&binary) + .arg("testdata/memory/remapped_text.c") + .status() + .unwrap(); + assert!(status.success(), "failed to compile remapped_text.c"); + + let output = std::process::Command::new(&binary).output().unwrap(); + assert!(output.status.success(), "remapped_text failed"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let (maps_lines, allocate_line) = stdout.trim_end().rsplit_once('\n').unwrap(); + let runtime_addrs = allocate_line + .strip_prefix("allocate ") + .unwrap() + .split(' ') + .map(|addr| u64::from_str_radix(addr, 16).unwrap()) + .collect::>(); + + const PID: pid_t = 4321; + let profile = tempfile::tempdir().unwrap(); + let results = profile.path().join("results"); + std::fs::create_dir_all(&results).unwrap(); + MemtrackArtifact { + events: maps_lines + .lines() + .zip(1..) + .map(|(line, timestamp)| mapping_event_from_maps_line(PID, timestamp, line)) + .collect(), + } + .save_with_pid_to(&results, PID) + .unwrap(); + + save_module_artifacts( + profile.path(), + &results, + ("exec-harness".to_string(), "1.0.0".to_string()), + &HashSet::new(), + ) + .await + .unwrap(); + + let metadata = MemtrackMetadata::from_reader( + std::fs::File::open(profile.path().join("memtrack.metadata")).unwrap(), + ) + .unwrap(); + let artifacts = &metadata.artifacts; + let key = artifacts + .path_key_to_path + .iter() + .find_map(|(key, path)| (path == &binary).then_some(key)) + .unwrap(); + + let symbols = + std::fs::read_to_string(profile.path().join(format!("{key}.symbols.map"))).unwrap(); + let allocate_svma = symbols + .lines() + .find_map(|line| { + let fields = line.split(' ').collect::>(); + (fields[2] == "allocate").then(|| u64::from_str_radix(fields[0], 16).unwrap()) + }) + .unwrap(); + let load_biases = artifacts.mapped_process_module_symbols[&PID] + .iter() + .filter(|mapped| &mapped.perf_map_key == key) + .map(|mapped| mapped.load_bias) + .collect::>(); + let unwind_ranges = artifacts.mapped_process_unwind_data_by_pid[&PID] + .iter() + .filter(|mapped| &mapped.unwind_data_key == key) + .map(|mapped| mapped.inner.avma_range.clone()) + .collect::>(); + + for addr in runtime_addrs { + assert!( + load_biases + .iter() + .any(|bias| addr.wrapping_sub(*bias) == allocate_svma), + "no load bias resolves {addr:#x} to `allocate`, got {load_biases:x?}" + ); + assert!( + unwind_ranges.iter().any(|range| range.contains(&addr)), + "no unwind data covers {addr:#x}, got {unwind_ranges:x?}" + ); + } + } + fn mapping_event(pid: pid_t, timestamp: u64, addr: u64, path: &str) -> MemtrackEvent { MemtrackEvent { pid, diff --git a/src/executor/shared/module_artifacts/loaded_module.rs b/src/executor/shared/module_artifacts/loaded_module.rs index 2c9bd88e6..cd7da7d9c 100644 --- a/src/executor/shared/module_artifacts/loaded_module.rs +++ b/src/executor/shared/module_artifacts/loaded_module.rs @@ -18,13 +18,25 @@ pub struct LoadedModule { pub process_loaded_modules: HashMap, } -#[derive(Default)] +/// Every placement of a module in one process. A process can map the same file +/// more than once at different addresses, and each placement has its own load +/// bias. +#[derive(Default, Clone)] pub struct ProcessLoadedModule { - /// Load bias used to adjust declared elf addresses to their actual runtime addresses - /// The bias is the difference between where the segment *actually* is in memory versus where the ELF file *preferred* it to be - pub symbols_load_bias: Option, - /// Unwind data specific to the process mounting, derived from both load bias and the actual unwind data - pub process_unwind_data: Option, + /// Load biases used to adjust declared elf addresses to their actual runtime addresses, one + /// per distinct placement. A bias is the difference between where the segment *actually* is in + /// memory versus where the ELF file *preferred* it to be + pub symbols_load_biases: Vec, + /// Unwind data of each executable mapping, derived from both load bias and the actual unwind data + pub process_unwind_data: Vec, +} + +impl ProcessLoadedModule { + pub fn add_load_bias(&mut self, load_bias: u64) { + if !self.symbols_load_biases.contains(&load_bias) { + self.symbols_load_biases.push(load_bias); + } + } } impl LoadedModule { diff --git a/src/executor/shared/module_artifacts/save_artifacts.rs b/src/executor/shared/module_artifacts/save_artifacts.rs index 3e8903ed9..386b76734 100644 --- a/src/executor/shared/module_artifacts/save_artifacts.rs +++ b/src/executor/shared/module_artifacts/save_artifacts.rs @@ -108,7 +108,7 @@ fn save_symbols( } let key = &path_to_key[path]; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(load_bias) = pm.symbols_load_bias { + for &load_bias in &pm.symbols_load_biases { mappings_by_pid .entry(pid) .or_default() @@ -158,7 +158,7 @@ fn save_debug_info( continue; }; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(load_bias) = pm.symbols_load_bias { + for &load_bias in &pm.symbols_load_biases { mappings_by_pid .entry(pid) .or_default() @@ -204,7 +204,7 @@ fn save_unwind_data( } let key = &path_to_key[path]; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(ref pud) = pm.process_unwind_data { + for pud in &pm.process_unwind_data { mappings_by_pid .entry(pid) .or_default() @@ -281,7 +281,7 @@ fn collect_ignored_modules( }; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(load_bias) = pm.symbols_load_bias { + for &load_bias in &pm.symbols_load_biases { by_pid.entry(pid).or_default().push(( path_str.to_string(), elf_start + load_bias, diff --git a/src/executor/wall_time/profiler/perf/parse_perf_file.rs b/src/executor/wall_time/profiler/perf/parse_perf_file.rs index 1d1033b38..a3a8505a7 100644 --- a/src/executor/wall_time/profiler/perf/parse_perf_file.rs +++ b/src/executor/wall_time/profiler/perf/parse_perf_file.rs @@ -1,4 +1,4 @@ -use crate::executor::shared::module_artifacts::loaded_module::{LoadedModule, ProcessLoadedModule}; +use crate::executor::shared::module_artifacts::loaded_module::LoadedModule; use crate::executor::shared::module_artifacts::module_symbols::ModuleSymbols; use crate::executor::shared::module_artifacts::unwind_data::unwind_data_from_elf; use crate::prelude::*; @@ -182,14 +182,7 @@ fn inherit_parent_mappings( use std::collections::hash_map::Entry; for loaded_module in loaded_modules_by_path.values_mut() { - let inherited = - loaded_module - .process_loaded_modules - .get(&ppid) - .map(|p| ProcessLoadedModule { - symbols_load_bias: p.symbols_load_bias, - process_unwind_data: p.process_unwind_data.clone(), - }); + let inherited = loaded_module.process_loaded_modules.get(&ppid).cloned(); let Some(inherited) = inherited else { continue; }; @@ -277,8 +270,7 @@ fn process_mmap2_record( } } - // Store load bias for this process mounting - process_loaded_module.symbols_load_bias = Some(load_bias); + process_loaded_module.add_load_bias(load_bias); // Extract unwind_data match unwind_data_from_elf( @@ -290,7 +282,9 @@ fn process_mmap2_record( ) { Ok((unwind_data, process_unwind_data)) => { loaded_module.unwind_data = Some(unwind_data); - process_loaded_module.process_unwind_data = Some(process_unwind_data); + process_loaded_module + .process_unwind_data + .push(process_unwind_data); } Err(error) => { debug!("Failed to load unwind data for module {record_path_string}: {error}"); @@ -301,14 +295,15 @@ fn process_mmap2_record( #[cfg(test)] mod tests { use super::*; + use crate::executor::shared::module_artifacts::loaded_module::ProcessLoadedModule; fn make_module_with_parent(ppid: pid_t, load_bias: u64) -> LoadedModule { let mut m = LoadedModule::default(); m.process_loaded_modules.insert( ppid, ProcessLoadedModule { - symbols_load_bias: Some(load_bias), - process_unwind_data: None, + symbols_load_biases: vec![load_bias], + process_unwind_data: vec![], }, ); m @@ -326,7 +321,7 @@ mod tests { let m = &modules[&PathBuf::from("/lib/libpython.so")]; let child = m.process_loaded_modules.get(&200).unwrap(); - assert_eq!(child.symbols_load_bias, Some(0xdead)); + assert_eq!(child.symbols_load_biases, vec![0xdead]); } #[test] @@ -337,8 +332,8 @@ mod tests { m.process_loaded_modules.insert( 200, ProcessLoadedModule { - symbols_load_bias: Some(0xcafe), - process_unwind_data: None, + symbols_load_biases: vec![0xcafe], + process_unwind_data: vec![], }, ); modules.insert(PathBuf::from("/lib/libpython.so"), m); @@ -349,7 +344,7 @@ mod tests { .process_loaded_modules .get(&200) .unwrap(); - assert_eq!(child.symbols_load_bias, Some(0xcafe)); + assert_eq!(child.symbols_load_biases, vec![0xcafe]); } #[test] @@ -363,8 +358,8 @@ mod tests { bash.process_loaded_modules.insert( 200, ProcessLoadedModule { - symbols_load_bias: Some(0xaaaaaaaa0000), - process_unwind_data: None, + symbols_load_biases: vec![0xaaaaaaaa0000], + process_unwind_data: vec![], }, ); modules.insert(PathBuf::from("/usr/bin/bash"), bash); @@ -394,8 +389,8 @@ mod tests { .process_loaded_modules .get(&100) .unwrap() - .symbols_load_bias, - Some(0xaaaaaaaa0000) + .symbols_load_biases, + vec![0xaaaaaaaa0000] ); } } diff --git a/testdata/memory/remapped_text.c b/testdata/memory/remapped_text.c new file mode 100644 index 000000000..613b66c27 --- /dev/null +++ b/testdata/memory/remapped_text.c @@ -0,0 +1,90 @@ +// Maps the executable segment holding `allocate` a second time at another +// address, the way V8 remaps its embedded builtins, and allocates through both +// copies. The process then holds two placements of its own binary, each with a +// different load bias. +// +// Prints the executable mappings of the binary, as /proc/self/maps lines, +// followed by `allocate `. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +typedef void *(*alloc_fn)(size_t); +typedef void *(*allocate_fn)(alloc_fn, size_t); + +// Only touches its arguments, so it runs unchanged from either copy. +__attribute__((noinline, optimize("no-optimize-sibling-calls"))) void * +allocate(alloc_fn alloc, size_t size) { + return alloc(size); +} + +int main(void) { + char exe[PATH_MAX]; + ssize_t exe_len = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (exe_len < 0) { + return 1; + } + exe[exe_len] = '\0'; + + FILE *maps = fopen("/proc/self/maps", "r"); + if (!maps) { + return 1; + } + + uintptr_t target = (uintptr_t)&allocate; + uintptr_t start = 0, end = 0; + unsigned long long offset = 0; + char line[PATH_MAX + 128]; + while (fgets(line, sizeof(line), maps)) { + uintptr_t s, e; + char perms[5]; + unsigned long long off; + if (sscanf(line, "%lx-%lx %4s %llx", &s, &e, perms, &off) == 4 && + perms[2] == 'x' && s <= target && target < e) { + start = s; + end = e; + offset = off; + break; + } + } + fclose(maps); + if (!start) { + return 1; + } + + int fd = open(exe, O_RDONLY); + if (fd < 0) { + return 1; + } + uint8_t *copy = mmap(NULL, end - start, PROT_READ | PROT_EXEC, MAP_PRIVATE, + fd, (off_t)offset); + close(fd); + if (copy == MAP_FAILED) { + return 1; + } + + allocate_fn remapped = (allocate_fn)(void *)(copy + (target - start)); + free(allocate(malloc, 1111)); + free(remapped(malloc, 2222)); + maps = fopen("/proc/self/maps", "r"); + if (!maps) { + return 1; + } + while (fgets(line, sizeof(line), maps)) { + char perms[5]; + if (sscanf(line, "%*x-%*x %4s", perms) == 1 && perms[2] == 'x' && + strstr(line, exe)) { + fputs(line, stdout); + } + } + fclose(maps); + + printf("allocate %lx %lx\n", target, (uintptr_t)remapped); + return 0; +}