Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion crates/exec-harness/src/analysis/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub fn perform(commands: Vec<BenchmarkCommand>) -> 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();
Expand Down Expand Up @@ -58,7 +61,7 @@ pub fn perform_with_valgrind(commands: Vec<BenchmarkCommand>) -> 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")?;

Expand Down
12 changes: 8 additions & 4 deletions crates/exec-harness/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/exec-harness/src/walltime/benchmark_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions crates/memtrack/testdata/node_alloc.js
Original file line number Diff line number Diff line change
@@ -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-<pid>.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();
19 changes: 19 additions & 0 deletions crates/memtrack/testdata/python_alloc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Run with `python3 -X perf`: the perf trampoline gives `allocation` a
# `py::allocation:<file>` entry in /tmp/perf-<pid>.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()
86 changes: 86 additions & 0 deletions crates/memtrack/tests/interpreter_tests.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
35 changes: 20 additions & 15 deletions src/executor/memory/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:#}");
}
}
}

Expand Down
Loading
Loading