From 90ea2e2e6117150ab48eef4030de9af56de25b86 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Wed, 16 Sep 2026 15:53:06 +0200 Subject: [PATCH 1/2] fix: detect free-threaded Python from venv and uv before setting PYTHONMALLOC Free-threaded CPython (3.13t+) refuses to start with PYTHONMALLOC=malloc, so the valgrind executor skips that override when the interpreter is free-threaded. The detection only probed `python` on the PATH, which misses the interpreter the benchmark actually runs under when it lives in a virtual environment or is selected by uv. Probe every interpreter the command could resolve to and skip the override if any of them is free-threaded: - `python` / `python3` on the PATH - `$VIRTUAL_ENV/bin/python` - `.venv/bin/python` in the benchmark working directory - the interpreter `uv python find $UV_PYTHON` resolves to; when uv has not downloaded it yet, classify the request string (`3.13t`, `+freethreaded`) directly The probe reads `sys.abiflags` instead of `sysconfig`, since `sysconfig` fails to import when `_PYTHON_SYSCONFIGDATA_NAME` is set for a different interpreter, which turned the old check into a false negative for exactly the free-threaded venvs it needs to detect. --- src/executor/valgrind/helpers/python.rs | 114 ++++++++++++++++++++---- src/executor/valgrind/measure.rs | 7 +- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/executor/valgrind/helpers/python.rs b/src/executor/valgrind/helpers/python.rs index 62d93fb93..5c0d848fa 100644 --- a/src/executor/valgrind/helpers/python.rs +++ b/src/executor/valgrind/helpers/python.rs @@ -1,21 +1,103 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use std::process::Command; -/// Checks if the Python interpreter supports free-threaded mode. -/// Returns true if Python is free-threaded (GIL disabled), false otherwise. -pub fn is_free_threaded_python() -> bool { - // Use sysconfig.get_config_var("Py_GIL_DISABLED") as recommended by Python docs at https://docs.python.org/3/howto/free-threading-python.html#identifying-free-threaded-python - let output = Command::new("python") - .args([ - "-c", - "import sysconfig; print(sysconfig.get_config_var('Py_GIL_DISABLED') or 0)", - ]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.trim() == "1" +use crate::prelude::*; + +/// Free-threaded builds carry the `t` ABI flag. `sys.abiflags` needs no `sysconfig` +/// import, which fails when `_PYTHON_SYSCONFIGDATA_NAME` names a module the +/// interpreter does not ship. +const GIL_DISABLED_PROBE: &str = "import sys; print(int('t' in sys.abiflags))"; + +/// Returns true if any Python interpreter the benchmark command could resolve to is +/// free-threaded: PATH `python`/`python3`, `$VIRTUAL_ENV`, the working directory's +/// `.venv`, and the interpreter `uv` selects for `$UV_PYTHON`. +/// +/// `uv` downloads a requested interpreter lazily, so when `$UV_PYTHON` is not installed +/// yet the request string itself decides. +pub fn is_free_threaded_python(working_directory: Option<&Path>) -> bool { + let cwd = working_directory.unwrap_or(Path::new(".")); + + let mut candidates: Vec = vec![PathBuf::from("python"), PathBuf::from("python3")]; + if let Some(venv) = std::env::var_os("VIRTUAL_ENV") { + candidates.push(Path::new(&venv).join("bin/python")); + } + candidates.push(cwd.join(".venv/bin/python")); + if let Some(request) = std::env::var_os("UV_PYTHON") { + match uv_python_find(&request, cwd) { + Some(python) => candidates.push(python), + None if is_free_threaded_request(&request.to_string_lossy()) => { + debug!("free-threaded Python requested via UV_PYTHON={request:?}"); + return true; + } + None => {} } - _ => false, // If Python is not available or command fails, assume not free-threaded + } + + candidates.into_iter().any(|python| { + let free_threaded = is_gil_disabled(&python); + if free_threaded { + debug!("detected free-threaded Python: {}", python.display()); + } + free_threaded + }) +} + +fn is_gil_disabled(python: &Path) -> bool { + let Ok(output) = Command::new(python) + .args(["-c", GIL_DISABLED_PROBE]) + .output() + else { + return false; + }; + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "1" +} + +fn uv_python_find(request: &OsStr, cwd: &Path) -> Option { + let output = Command::new("uv") + .args(["python", "find"]) + .arg(request) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +/// Matches a full key variant (`cpython-3.13.0+freethreaded-linux-x86_64-gnu`) or a +/// version with the `t` suffix (`3.13t`, `cpython@3.13.1t`). +fn is_free_threaded_request(request: &str) -> bool { + if request.contains("+freethreaded") { + return true; + } + request.split(['-', '@']).any(|segment| { + let Some(version) = segment.strip_suffix('t') else { + return false; + }; + version.ends_with(|c: char| c.is_ascii_digit()) + }) +} + +#[cfg(test)] +mod tests { + use super::is_free_threaded_request; + use rstest::rstest; + + #[rstest] + #[case("3.13t", true)] + #[case("3.13.1t", true)] + #[case("cpython@3.14t", true)] + #[case("cpython-3.13t-linux-x86_64-gnu", true)] + #[case("cpython-3.13.0+freethreaded-linux-x86_64-gnu", true)] + #[case("3.13", false)] + #[case("cpython@3.13", false)] + #[case("cpython-3.13.0-linux-x86_64-gnu", false)] + #[case("pypy@3.10", false)] + #[case("graalpy", false)] + fn classifies_uv_python_request(#[case] request: &str, #[case] expected: bool) { + assert_eq!(is_free_threaded_request(request), expected); } } diff --git a/src/executor/valgrind/measure.rs b/src/executor/valgrind/measure.rs index 62807b925..9d1ff0094 100644 --- a/src/executor/valgrind/measure.rs +++ b/src/executor/valgrind/measure.rs @@ -138,10 +138,9 @@ pub async fn measure( config, )); - // Only set PYTHONMALLOC=malloc for non-free-threaded Python builds. - // Free-threaded Python (with GIL disabled) manages memory differently and - // should not have PYTHONMALLOC overridden. - if !is_free_threaded_python() { + // Free-threaded Python (GIL disabled) does not support PYTHONMALLOC=malloc + // and refuses to start with it set. + if !is_free_threaded_python(config.working_directory.as_deref().map(Path::new)) { cmd.env("PYTHONMALLOC", "malloc"); } From 51f891810969090e52743e66f5707bbd9b511942 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Wed, 16 Sep 2026 19:09:35 +0200 Subject: [PATCH 2/2] feat(simulation): add Python allocator opt-out Add an experimental flag that unsets PYTHONMALLOC for simulation runs, allowing integrations to validate workloads without the forced malloc allocator before it becomes the default behavior. --- src/cli/exec/mod.rs | 1 + src/cli/experimental.rs | 12 ++++++++++++ src/cli/run/mod.rs | 2 ++ src/executor/config.rs | 6 ++++++ src/executor/valgrind/measure.rs | 4 +++- 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs index a844cc6b5..6fc01ab23 100644 --- a/src/cli/exec/mod.rs +++ b/src/cli/exec/mod.rs @@ -94,6 +94,7 @@ fn build_orchestrator_config( exclude_allocations: args.shared.exclude_allocations, simulation_track_subprocess: args.shared.simulation_track_subprocess, memory_track_physical: args.shared.experimental.experimental_memory_track_physical, + disable_pythonmalloc: args.shared.experimental.experimental_disable_pythonmalloc, }) } diff --git a/src/cli/experimental.rs b/src/cli/experimental.rs index 336093b34..eb9b192d2 100644 --- a/src/cli/experimental.rs +++ b/src/cli/experimental.rs @@ -27,6 +27,15 @@ pub struct ExperimentalArgs { )] pub experimental_memory_track_physical: bool, + /// Do not set PYTHONMALLOC for simulation runs. + #[arg( + long, + default_value_t = false, + help_heading = "Experimental", + env = "CODSPEED_EXPERIMENTAL_DISABLE_PYTHONMALLOC" + )] + pub experimental_disable_pythonmalloc: bool, + /// Deprecated: cycle estimation is enabled by default and this flag has no effect. #[arg(long, hide = true, env = "CODSPEED_EXPERIMENTAL_CYCLE_ESTIMATION")] pub experimental_cycle_estimation: bool, @@ -46,6 +55,9 @@ impl ExperimentalArgs { if self.experimental_memory_track_physical { flags.push("--experimental-memory-track-physical"); } + if self.experimental_disable_pythonmalloc { + flags.push("--experimental-disable-pythonmalloc"); + } flags } diff --git a/src/cli/run/mod.rs b/src/cli/run/mod.rs index 414e04f8c..afd1c157a 100644 --- a/src/cli/run/mod.rs +++ b/src/cli/run/mod.rs @@ -84,6 +84,7 @@ impl RunArgs { experimental_cycle_estimation: false, experimental_exclude_allocations: false, experimental_memory_track_physical: false, + experimental_disable_pythonmalloc: false, }, }, instruments: vec![], @@ -137,6 +138,7 @@ fn build_orchestrator_config( exclude_allocations: args.shared.exclude_allocations, simulation_track_subprocess: args.shared.simulation_track_subprocess, memory_track_physical: args.shared.experimental.experimental_memory_track_physical, + disable_pythonmalloc: args.shared.experimental.experimental_disable_pythonmalloc, }) } diff --git a/src/executor/config.rs b/src/executor/config.rs index 39f958510..c682132a2 100644 --- a/src/executor/config.rs +++ b/src/executor/config.rs @@ -100,6 +100,8 @@ pub struct OrchestratorConfig { pub simulation_track_subprocess: bool, /// Enable physical (resident) memory tracking in memory mode. pub memory_track_physical: bool, + /// Do not set PYTHONMALLOC for simulation runs. + pub disable_pythonmalloc: bool, } /// Per-execution configuration passed to executors. @@ -145,6 +147,8 @@ pub struct ExecutorConfig { /// Only read by the memory executor, which is Linux-only. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub memory_track_physical: bool, + /// Do not set PYTHONMALLOC for simulation runs. + pub disable_pythonmalloc: bool, } #[derive(Debug, Clone, PartialEq)] @@ -218,6 +222,7 @@ impl OrchestratorConfig { exclude_allocations: self.exclude_allocations, simulation_track_subprocess: self.simulation_track_subprocess, memory_track_physical: self.memory_track_physical, + disable_pythonmalloc: self.disable_pythonmalloc, } } } @@ -254,6 +259,7 @@ impl OrchestratorConfig { exclude_allocations: false, simulation_track_subprocess: false, memory_track_physical: false, + disable_pythonmalloc: false, } } } diff --git a/src/executor/valgrind/measure.rs b/src/executor/valgrind/measure.rs index 9d1ff0094..d5db64194 100644 --- a/src/executor/valgrind/measure.rs +++ b/src/executor/valgrind/measure.rs @@ -140,7 +140,9 @@ pub async fn measure( // Free-threaded Python (GIL disabled) does not support PYTHONMALLOC=malloc // and refuses to start with it set. - if !is_free_threaded_python(config.working_directory.as_deref().map(Path::new)) { + if !config.disable_pythonmalloc + && !is_free_threaded_python(config.working_directory.as_deref().map(Path::new)) + { cmd.env("PYTHONMALLOC", "malloc"); }