Skip to content

Commit 83d030a

Browse files
committed
test(callgrind-utils): add Python fixture with runtime obj-skip
Profile a Python workload (recursion.py) live under the in-repo Callgrind, mirroring pytest-codspeed: a ctypes-loaded shim (clgctl.c) fires CALLGRIND_START/STOP and adds libpython + the python executable to the obj-skip list at runtime via CALLGRIND_ADD_OBJ_SKIP. Callgrind never names Python-level frames, so the test asserts structure rather than a golden snapshot: the START shim is captured and the Python runtime is folded out.
1 parent 11a0bc1 commit 83d030a

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

callgrind-utils/testdata/clgctl.c

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Callgrind client-request shim for the Python fixture (`recursion.py`).
2+
//
3+
// The CALLGRIND_* client requests are inline-asm sequences, so they can't be
4+
// issued from pure Python. The Python fixture loads this shared library via
5+
// `ctypes` and calls these entry points to drive instrumentation, mirroring
6+
// what pytest-codspeed's instrument-hooks does: skip the Python runtime objects
7+
// at runtime, then START/ZERO around the measured region and STOP after.
8+
//
9+
// Build (shared, against the in-repo client-request headers):
10+
// cc -g -O0 -shared -fPIC -I callgrind -I include ...
11+
12+
#include <callgrind.h>
13+
14+
// Add an object file to Callgrind's obj-skip list at runtime. Matching is exact
15+
// against the mapped object path, so the caller passes a realpath (same as
16+
// instrument-hooks' `callgrind_add_obj_skip`).
17+
void clg_add_obj_skip(const char *path) {
18+
CALLGRIND_ADD_OBJ_SKIP(path);
19+
}
20+
21+
void clg_start(void) {
22+
CALLGRIND_START_INSTRUMENTATION;
23+
CALLGRIND_ZERO_STATS;
24+
}
25+
26+
void clg_stop(void) {
27+
CALLGRIND_STOP_INSTRUMENTATION;
28+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Python counterpart to recursion.c: the same fib/square/compute shape, driven
2+
# the way CodSpeed drives a benchmark. Instrumentation is off at startup (run
3+
# with --instr-atstart=no) and turned on around the measured region via the
4+
# clgctl shim, whose compiled path is passed as argv[1].
5+
#
6+
# Before starting, we skip the Python runtime objects (libpython + the python
7+
# executable) from Callgrind at runtime, exactly as pytest-codspeed's
8+
# instrument-hooks does in _callgrind_skip_python_runtime: the interpreter's own
9+
# C frames are folded into their callers so they don't obfuscate the graph.
10+
# Matching is by exact realpath, since Callgrind keys obj-skip on the mapped
11+
# object path.
12+
import ctypes
13+
import os
14+
import sys
15+
import sysconfig
16+
17+
clgctl = ctypes.CDLL(sys.argv[1])
18+
19+
20+
def skip_python_runtime():
21+
ldlibrary = sysconfig.get_config_var("LDLIBRARY")
22+
libdir = sysconfig.get_config_var("LIBDIR")
23+
libpython = next(
24+
(
25+
p
26+
for p in (
27+
os.path.join(libdir, ldlibrary) if ldlibrary and libdir else None,
28+
os.path.join(sys.prefix, "lib", ldlibrary) if ldlibrary else None,
29+
)
30+
if p and os.path.exists(p)
31+
),
32+
None,
33+
)
34+
for path in (libpython, sys.executable):
35+
if path:
36+
clgctl.clg_add_obj_skip(os.path.realpath(path).encode())
37+
38+
39+
def fib(n):
40+
if n < 2:
41+
return n
42+
return fib(n - 1) + fib(n - 2)
43+
44+
45+
def square(n):
46+
return n * n
47+
48+
49+
def compute(n):
50+
return fib(n) + square(n)
51+
52+
53+
skip_python_runtime()
54+
55+
clgctl.clg_start()
56+
sink = compute(8)
57+
clgctl.clg_stop()
58+
59+
assert sink == 85, sink
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//! Structural test over the Python fixture (`testdata/recursion.py`).
2+
//!
3+
//! Unlike the C snapshot tests, a live Python run can't be a golden snapshot:
4+
//! Callgrind sees the CPython interpreter's C frames (version- and
5+
//! platform-specific), never the Python functions. The fixture drives Callgrind
6+
//! the way pytest-codspeed does: at runtime it adds the Python runtime objects
7+
//! (libpython + the python executable) to the obj-skip list via the
8+
//! `CALLGRIND_ADD_OBJ_SKIP` client request, so the interpreter's own frames are
9+
//! folded into their callers and don't obfuscate the graph. This test profiles
10+
//! the fixture live and asserts structural properties: instrumentation started
11+
//! at the shim, and the runtime obj-skip removed the interpreter.
12+
//!
13+
//! Requires a built `./vg-in-place` at the repo root and `cc`. Silently skips
14+
//! when `python3` is not on PATH (mirrors the `.vgtest` `prereq` guards).
15+
use std::io::Cursor;
16+
use std::path::{Path, PathBuf};
17+
use std::process::Command;
18+
19+
use callgrind_utils::model::{CallGraph, ParseOptions};
20+
21+
/// Repo root: this crate lives at `<repo>/callgrind-utils`.
22+
fn repo_root() -> PathBuf {
23+
Path::new(env!("CARGO_MANIFEST_DIR"))
24+
.parent()
25+
.expect("crate has a parent directory")
26+
.to_path_buf()
27+
}
28+
29+
fn vg_in_place() -> PathBuf {
30+
let path = repo_root().join("vg-in-place");
31+
assert!(
32+
path.is_file(),
33+
"vg-in-place not found at {} - build Valgrind in place first",
34+
path.display()
35+
);
36+
path
37+
}
38+
39+
fn have_python3() -> bool {
40+
Command::new("python3")
41+
.arg("--version")
42+
.output()
43+
.map(|o| o.status.success())
44+
.unwrap_or(false)
45+
}
46+
47+
/// The basenames of the objects `recursion.py` adds to the obj-skip list:
48+
/// libpython and the python executable, resolved exactly as the fixture does
49+
/// (realpath, then basename, matching Callgrind's normalized object names).
50+
fn skipped_runtime_objects() -> Vec<String> {
51+
let script = "\
52+
import os, sys, sysconfig
53+
ld = sysconfig.get_config_var('LDLIBRARY')
54+
libdir = sysconfig.get_config_var('LIBDIR')
55+
cands = [os.path.join(libdir, ld) if ld and libdir else None,
56+
os.path.join(sys.prefix, 'lib', ld) if ld else None]
57+
lp = next((p for p in cands if p and os.path.exists(p)), None)
58+
for p in (lp, sys.executable):
59+
if p:
60+
print(os.path.basename(os.path.realpath(p)))
61+
";
62+
let out = Command::new("python3")
63+
.arg("-c")
64+
.arg(script)
65+
.output()
66+
.expect("run python3 to resolve runtime objects");
67+
assert!(out.status.success(), "python3 obj resolution failed");
68+
String::from_utf8(out.stdout)
69+
.expect("utf8")
70+
.lines()
71+
.map(str::to_owned)
72+
.collect()
73+
}
74+
75+
/// Compile the Callgrind client-request shim the Python fixture loads via
76+
/// `ctypes`, as a shared library against the in-repo `callgrind.h`.
77+
fn compile_clgctl() -> PathBuf {
78+
let repo = repo_root();
79+
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/clgctl.c");
80+
let lib = Path::new(env!("CARGO_TARGET_TMPDIR")).join("libclgctl.so");
81+
82+
let status = Command::new("cc")
83+
.args(["-g", "-O0", "-shared", "-fPIC"])
84+
.arg("-I")
85+
.arg(repo.join("callgrind"))
86+
.arg("-I")
87+
.arg(repo.join("include"))
88+
.arg("-o")
89+
.arg(&lib)
90+
.arg(&src)
91+
.status()
92+
.unwrap_or_else(|e| panic!("failed to spawn cc for clgctl: {e}"));
93+
assert!(
94+
status.success(),
95+
"cc failed for {} ({status})",
96+
src.display()
97+
);
98+
lib
99+
}
100+
101+
/// Profile `testdata/recursion.py` with the in-repo Callgrind and return the
102+
/// `.out` contents. `--instr-atstart=no` pairs with the shim's client requests
103+
/// so only the measured region is profiled; the fixture adds the obj-skips
104+
/// itself, so no `--obj-skip` is passed on the command line.
105+
fn run_python(clgctl: &Path) -> String {
106+
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/recursion.py");
107+
let out_file = Path::new(env!("CARGO_TARGET_TMPDIR")).join("python.callgrind.out");
108+
109+
let status = Command::new(vg_in_place())
110+
.arg("--tool=callgrind")
111+
.arg("--instr-atstart=no")
112+
.arg(format!("--callgrind-out-file={}", out_file.display()))
113+
.arg("python3")
114+
.arg(&script)
115+
.arg(clgctl)
116+
.status()
117+
.unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}"));
118+
assert!(status.success(), "vg-in-place exited with {status}");
119+
std::fs::read_to_string(&out_file).unwrap_or_else(|e| panic!("read {}: {e}", out_file.display()))
120+
}
121+
122+
#[test]
123+
fn python_runtime_is_obj_skipped() {
124+
if !have_python3() {
125+
eprintln!("skipping python_runtime_is_obj_skipped: python3 not on PATH");
126+
return;
127+
}
128+
129+
let skipped = skipped_runtime_objects();
130+
assert!(
131+
!skipped.is_empty(),
132+
"expected at least the python executable to be skipped"
133+
);
134+
135+
let clgctl = compile_clgctl();
136+
let raw = run_python(&clgctl);
137+
let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default())
138+
.expect("parse python callgrind output");
139+
140+
assert!(!graph.nodes().is_empty(), "expected a non-empty node set");
141+
assert!(!graph.edges().is_empty(), "expected a non-empty edge set");
142+
143+
// The shim that fired START is captured, so instrumentation began exactly
144+
// where the fixture asked, and it is wired into the graph (not dropped as
145+
// an orphan root): the seeder reconstructed the native stack at the OFF->ON
146+
// transition.
147+
assert!(
148+
graph.nodes().iter().any(|n| n.function == "clg_start"),
149+
"clg_start (instrumentation shim) missing from graph"
150+
);
151+
assert!(
152+
graph
153+
.edges()
154+
.iter()
155+
.any(|e| e.caller.function == "clg_start" || e.callee.function == "clg_start"),
156+
"clg_start has no edges - START frame was not captured"
157+
);
158+
159+
// The runtime obj-skip folded the Python runtime out: no node belongs to
160+
// libpython or the python executable, and the interpreter loop is gone.
161+
for obj in &skipped {
162+
assert!(
163+
graph.nodes().iter().all(|n| &n.object != obj),
164+
"obj-skip failed: {obj} still present as a node object"
165+
);
166+
}
167+
assert!(
168+
!graph
169+
.nodes()
170+
.iter()
171+
.any(|n| n.function.starts_with("_PyEval_EvalFrameDefault")),
172+
"interpreter loop _PyEval_EvalFrameDefault should have been obj-skipped"
173+
);
174+
}

0 commit comments

Comments
 (0)