Skip to content
Open
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
123 changes: 123 additions & 0 deletions src/executor/helpers/linux_sysctl.rs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to gate this whole mod behind the feature flag but not every single line, because now we end up gating almost every single line with #[cfg(target_os = "linux")] 😅

Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use crate::prelude::*;

#[cfg(target_os = "linux")]
use crate::executor::helpers::run_with_sudo::run_with_sudo;
#[cfg(any(test, target_os = "linux"))]
use anyhow::Context;
#[cfg(target_os = "linux")]
use std::process::Command;

/// Restores a sysctl to its initial value when dropped.
#[derive(Debug)]
#[must_use = "the sysctl is restored when this guard is dropped"]
pub(crate) struct LinuxSysctl {
#[cfg(target_os = "linux")]
name: &'static str,
#[cfg(target_os = "linux")]
previous: Option<i64>,
}

#[cfg(target_os = "linux")]
impl LinuxSysctl {
pub(crate) fn set(name: &'static str, target_value: i64) -> Result<Self> {
let previous = ensure_sysctl(name, target_value)?;

Ok(Self { name, previous })
}

pub(crate) fn is_changed(&self) -> bool {
self.previous.is_some()
}
}

impl Drop for LinuxSysctl {
fn drop(&mut self) {
#[cfg(target_os = "linux")]
{
let Some(value) = self.previous else {
return;
};

if let Err(error) = ensure_sysctl(self.name, value) {
warn!("Failed to restore {}={value}: {error}", self.name);
}
}
}
}

#[cfg(target_os = "linux")]
pub fn ensure_linux_profiling_sysctls() -> Result<Vec<LinuxSysctl>> {
let mut sysctls = Vec::new();

for (name, target_value) in [
("kernel.kptr_restrict", 0),
("kernel.perf_event_paranoid", -1),
] {
let sysctl = LinuxSysctl::set(name, target_value)?;
if sysctl.is_changed() {
sysctls.push(sysctl);
}
}

Ok(sysctls)
}

#[cfg(not(target_os = "linux"))]
pub fn ensure_linux_profiling_sysctls() -> Result<Vec<LinuxSysctl>> {
Ok(Vec::new())
}

/// Sets a sysctl, returning the value it held before, or `None` when it was
/// already at `target_value` and nothing was written.
#[cfg(target_os = "linux")]
pub(crate) fn ensure_sysctl(name: &str, target_value: i64) -> Result<Option<i64>> {
let current_value = sysctl_read(name)?;
if current_value == target_value {
return Ok(None);
}

let assignment = format!("{name}={target_value}");
run_with_sudo("sysctl", ["-w", assignment.as_str()])?;

Ok(Some(current_value))
}

#[cfg(target_os = "linux")]
fn sysctl_read(name: &str) -> Result<i64> {
let output = Command::new("sysctl").arg(name).output()?;
let output = String::from_utf8(output.stdout)?;

parse_sysctl_value(&output)
}

#[cfg(any(test, target_os = "linux"))]
fn parse_sysctl_value(output: &str) -> Result<i64> {
let (_, value) = output
.split_once('=')
.context("Couldn't find the value in sysctl output")?;

Ok(value.trim().parse::<i64>()?)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_sysctl_value() {
assert_eq!(parse_sysctl_value("kernel.kptr_restrict = 0\n").unwrap(), 0);
}

#[test]
fn parses_negative_sysctl_value() {
assert_eq!(
parse_sysctl_value("kernel.perf_event_paranoid = -1\n").unwrap(),
-1
);
}

#[test]
fn rejects_sysctl_output_without_value_separator() {
assert!(parse_sysctl_value("kernel.kptr_restrict 0\n").is_err());
}
}
1 change: 1 addition & 0 deletions src/executor/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod harvest_perf_maps_for_pids;
pub mod homebrew;
pub mod introspected_golang;
pub mod introspected_nodejs;
pub mod linux_sysctl;
pub mod profile_folder;
pub mod run_command_with_log_pipe;
pub mod run_with_env;
Expand Down
3 changes: 3 additions & 0 deletions src/executor/memory/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::executor::helpers::get_bench_command::get_bench_command;
use crate::executor::helpers::run_command_with_log_pipe::run_command_with_log_pipe_and_callback;
use crate::executor::helpers::run_with_env::prefix_command_with_env;
use crate::executor::helpers::run_with_sudo::is_root_user;
use crate::executor::memory::tunables::MemoryTunables;
use crate::executor::shared::fifo::RunnerFifo;
use crate::executor::{ExecutionContext, Executor};
use crate::instruments::mongo_tracer::MongoTracer;
Expand Down Expand Up @@ -159,6 +160,8 @@ impl Executor for MemoryExecutor {
execution_context: &ExecutionContext,
_mongo_tracer: &Option<MongoTracer>,
) -> Result<()> {
let _tunables = MemoryTunables::apply();

// Create the results/ directory inside the profile folder to avoid having memtrack create it with wrong permissions
std::fs::create_dir_all(execution_context.profile_folder.join("results"))?;

Expand Down
1 change: 1 addition & 0 deletions src/executor/memory/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod executor;
pub(crate) mod setup;
pub(crate) mod tunables;
138 changes: 138 additions & 0 deletions src/executor/memory/tunables.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//! Kernel controls that stabilise memory measurements:
//!
//! - [transparent huge pages](https://docs.kernel.org/admin-guide/mm/transhuge.html)
//! can allocate a 2 MiB page when a benchmark touches a small part of a
//! mapping, making its RSS depend on page-promotion timing.
//! - [`vm.drop_caches`](https://docs.kernel.org/admin-guide/sysctl/vm.html)
//! clears clean page cache and reclaimable slab objects, giving each run the
//! same cache state.
//!
//! [`MemoryTunables`] captures the previous THP setting and restores it on
//! drop, so a host that only looks like CI — `CI=true` inside a container
//! sharing the host's non-namespaced knobs, say — is left as it was.

use crate::executor::helpers::run_with_sudo::{can_elevate_without_prompt, run_with_sudo};
use crate::prelude::*;
use std::fs::read_to_string;

/// Guard holding the previous THP setting when it was changed.
/// Empty when THP was not changed, making [`Drop`] a no-op.
#[derive(Debug)]
#[must_use = "the knobs are restored as soon as the guard is dropped"]
pub struct MemoryTunables {
/// THP knob path -> the mode it held before.
thp: Vec<(String, String)>,
}

impl MemoryTunables {
/// Applies the controls on a best-effort basis: a control that cannot be
/// set is warned about, never fatal.
pub fn apply() -> Option<Self> {
// Blocking the run on an interactive password prompt would be worse than
// measuring without the knobs.
if !can_elevate_without_prompt() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Local runs mutate host memory

When a developer runs memory mode locally as root or with non-interactive sudo, MemoryTunables::apply treats privilege elevation as sufficient authorization and changes THP before irreversibly dropping the host page cache, affecting unrelated local workloads despite the CI-only contract.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/executor/memory/tunables.rs
Line: 33

Comment:
**Local runs mutate host memory**

When a developer runs memory mode locally as root or with non-interactive sudo, `MemoryTunables::apply` treats privilege elevation as sufficient authorization and changes THP before irreversibly dropping the host page cache, affecting unrelated local workloads despite the CI-only contract.

**Knowledge Base Used:**
- [Benchmark execution engine](https://app.greptile.com/codspeed/-/custom-context/knowledge-base/codspeedhq/codspeed/-/docs/benchmark-execution.md)
- [Run environment detection](https://app.greptile.com/codspeed/-/custom-context/knowledge-base/codspeedhq/codspeed/-/docs/run-environment-detection.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

warn!(
"Cannot elevate privileges without a password prompt, skipping kernel memory tunables"
);
return None;
}

start_group!("Applying kernel memory tunables");
let tunables = Self {
thp: Self::set_thp_enabled("never"),
};
Comment thread
GuillaumeLagrange marked this conversation as resolved.
Comment on lines +40 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Swap remains enabled during measurement

If a CI host has enabled swap, MemoryTunables::apply changes only THP and drops the page cache without disabling or recording swap entries, so paging can continue perturbing memory results even though the runner reports that kernel memory tunables were applied.

Knowledge Base Used: Memory benchmarking

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/executor/memory/tunables.rs
Line: 40-43

Comment:
**Swap remains enabled during measurement**

If a CI host has enabled swap, `MemoryTunables::apply` changes only THP and drops the page cache without disabling or recording swap entries, so paging can continue perturbing memory results even though the runner reports that kernel memory tunables were applied.

**Knowledge Base Used:** [Memory benchmarking](https://app.greptile.com/codspeed/-/custom-context/knowledge-base/codspeedhq/codspeed/-/docs/memory-benchmarking.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Self::drop_page_cache();
end_group!();

Some(tunables)
}

/// Drops the page cache. Nothing to restore: the node is a write-only
/// trigger and the kernel refills the cache on demand.
fn drop_page_cache() {
// drop_caches only reclaims clean objects; flush dirty buffers first.
nix::unistd::sync();
Comment thread
not-matthias marked this conversation as resolved.
if let Err(error) = write_root_file("/proc/sys/vm/drop_caches", "3") {
warn!("Failed to drop the page cache: {error}");
}
}

/// Sets the THP default mode, returning the prior mode when it changed.
fn set_thp_enabled(value: &str) -> Vec<(String, String)> {
let mut previous = Vec::new();

let path = "/sys/kernel/mm/transparent_hugepage/enabled";
let Some(active) = read_thp_mode(path) else {
debug!("{path} is missing or has no active mode, skipping");
return previous;
};
if active == value {
return previous;
}

match write_root_file(path, value) {
Ok(()) => previous.push((path.to_string(), active)),
Err(error) => warn!("Failed to set transparent huge pages ({path}): {error}"),
}

previous
}
}

impl Drop for MemoryTunables {
fn drop(&mut self) {
if self.thp.is_empty() {
return;
}

start_group!("Restoring kernel memory tunables");
for (path, value) in &self.thp {
if let Err(error) = write_root_file(path, value) {
warn!("Failed to restore transparent huge pages ({path}) to {value}: {error}");
}
}
end_group!();
}
}

/// The active mode of a THP knob, whose value reads as `always [madvise] never`.
fn read_thp_mode(path: &str) -> Option<String> {
let content = read_to_string(path).ok()?;
let mode = content
.split_whitespace()
.find_map(|token| token.strip_prefix('[')?.strip_suffix(']'))?;

Some(mode.to_string())
}

/// Write to a root-owned /proc or /sys node. `run_with_sudo` cannot pipe stdin,
/// so the redirect happens inside a shell instead of `sudo tee`.
fn write_root_file(path: &str, value: &str) -> Result<()> {
run_with_sudo("sh", ["-c", &format!("printf '%s' {value} > {path}")])
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn reads_the_active_thp_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("enabled");
std::fs::write(&path, "always [madvise] never\n").unwrap();

assert_eq!(
read_thp_mode(path.to_str().unwrap()),
Some("madvise".to_string())
);
}

#[test]
fn reports_no_thp_mode_when_none_is_active() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("enabled");
std::fs::write(&path, "always madvise never\n").unwrap();

assert_eq!(read_thp_mode(path.to_str().unwrap()), None);
}
}
15 changes: 5 additions & 10 deletions src/executor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,25 +256,20 @@ mod walltime {
use crate::executor::wall_time::executor::WallTimeExecutor;

async fn get_walltime_executor() -> (SemaphorePermit<'static>, WallTimeExecutor) {
static WALLTIME_INIT: OnceCell<()> = OnceCell::const_new();
static WALLTIME_SEMAPHORE: OnceCell<Semaphore> = OnceCell::const_new();

WALLTIME_INIT
.get_or_init(|| async {
let executor = WallTimeExecutor::new(None);
let system_info = SystemInfo::new().unwrap();
executor.setup(&system_info, None).await.unwrap();
})
.await;

// We can't execute multiple walltime executors in parallel because perf isn't thread-safe (yet). We have to
// use a semaphore to limit concurrent access.
let semaphore = WALLTIME_SEMAPHORE
.get_or_init(|| async { Semaphore::new(1) })
.await;
let permit = semaphore.acquire().await.unwrap();

(permit, WallTimeExecutor::new(None))
let executor = WallTimeExecutor::new(None);
let system_info = SystemInfo::new().unwrap();
executor.setup(&system_info, None).await.unwrap();

(permit, executor)
}

fn walltime_config(command: &str, enable_profiler: bool) -> ExecutorConfig {
Expand Down
17 changes: 15 additions & 2 deletions src/executor/wall_time/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::executor::config::WalltimeProfiler;
use crate::executor::helpers::command::CommandBuilder;
use crate::executor::helpers::env::{build_path_env, get_base_injected_env};
use crate::executor::helpers::get_bench_command::get_bench_command;
use crate::executor::helpers::linux_sysctl::{LinuxSysctl, ensure_linux_profiling_sysctls};
use crate::executor::helpers::run_command_with_log_pipe::run_command_with_log_pipe;
use crate::executor::helpers::run_command_with_log_pipe::run_command_with_log_pipe_and_callback;
use crate::executor::helpers::run_with_env::wrap_with_env;
Expand Down Expand Up @@ -37,6 +38,8 @@ pub struct WallTimeExecutor {
/// Stashed by [`Executor::run`] and consumed by [`Executor::teardown`] to
/// hand the run's outputs to [`Profiler::finalize`].
benchmark_state: OnceCell<(FifoBenchmarkData, ExecutionTimestamps)>,

profiling_sysctls: OnceCell<Vec<LinuxSysctl>>,
}

fn select_profiler(profiler_override: Option<WalltimeProfiler>) -> Option<Box<dyn Profiler>> {
Expand All @@ -52,6 +55,7 @@ impl WallTimeExecutor {
Self {
profiler: select_profiler(profiler_override),
benchmark_state: OnceCell::new(),
profiling_sysctls: OnceCell::new(),
}
}

Expand Down Expand Up @@ -107,8 +111,16 @@ impl Executor for WallTimeExecutor {
}

async fn setup(&self, system_info: &SystemInfo, setup_cache_dir: Option<&Path>) -> Result<()> {
if let Some(profiler) = &self.profiler {
profiler.setup(system_info, setup_cache_dir).await?;
let Some(profiler) = &self.profiler else {
return Ok(());
};

profiler.setup(system_info, setup_cache_dir).await?;
if self.profiling_sysctls.get().is_none() {
let sysctls = ensure_linux_profiling_sysctls()?;
self.profiling_sysctls
.set(sysctls)
.map_err(|_| anyhow!("profiling sysctls were initialized concurrently"))?;
}
Ok(())
}
Expand All @@ -134,6 +146,7 @@ impl Executor for WallTimeExecutor {
let Self {
profiler,
benchmark_state,
..
} = self;

let status = match profiler.as_mut() {
Expand Down
Loading
Loading