|
1 | | -//! Structural test over the Python fixture (`testdata/recursion.py`). |
| 1 | +//! Topology-only snapshot of the Python fixture's call graph. |
2 | 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. |
| 3 | +//! Mirrors `snapshot.rs` for the C fixtures: profile `testdata/recursion.py` |
| 4 | +//! live under the in-repo Callgrind, parse, and snapshot a topology-only |
| 5 | +//! view (nodes + caller/callee indices, no `call_count`; see below). |
| 6 | +//! |
| 7 | +//! Callgrind records the CPython interpreter's C frames, not the Python |
| 8 | +//! functions: the interpreter loop is obj-skipped at runtime via the `clgctl` |
| 9 | +//! shim's `CALLGRIND_ADD_OBJ_SKIP`, so what remains is the ctypes/libffi/libc |
| 10 | +//! C-residual around the `clg_start`/`clg_stop` shim. The graph shape |
| 11 | +//! (nodes + caller/callee indices) is stable after `CallGraph::redact()`: libc/ld |
| 12 | +//! debug-derived fields collapse to `???`, and CPython extension / libffi object |
| 13 | +//! suffixes are normalized. `call_count` on the seed-reconstructed residual edges |
| 14 | +//! drifts run-to-run (loader/PLT timing), so it is stripped and the snapshot is |
| 15 | +//! topology-only. |
12 | 16 | //! |
13 | 17 | //! Requires a built `./vg-in-place` at the repo root and `cc`. Silently skips |
14 | 18 | //! when `python3` is not on PATH (mirrors the `.vgtest` `prereq` guards). |
15 | 19 | use std::io::Cursor; |
16 | 20 | use std::path::{Path, PathBuf}; |
17 | 21 | use std::process::Command; |
18 | 22 |
|
19 | | -use callgrind_utils::model::{CallGraph, ParseOptions}; |
| 23 | +use callgrind_utils::model::{CallGraph, Node, ParseOptions}; |
20 | 24 |
|
21 | 25 | /// Repo root: this crate lives at `<repo>/callgrind-utils`. |
22 | 26 | fn repo_root() -> PathBuf { |
@@ -44,34 +48,6 @@ fn have_python3() -> bool { |
44 | 48 | .unwrap_or(false) |
45 | 49 | } |
46 | 50 |
|
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 | 51 | /// Compile the Callgrind client-request shim the Python fixture loads via |
76 | 52 | /// `ctypes`, as a shared library against the in-repo `callgrind.h`. |
77 | 53 | fn compile_clgctl() -> PathBuf { |
@@ -116,59 +92,54 @@ fn run_python(clgctl: &Path) -> String { |
116 | 92 | .status() |
117 | 93 | .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); |
118 | 94 | 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())) |
| 95 | + std::fs::read_to_string(&out_file) |
| 96 | + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) |
| 97 | +} |
| 98 | + |
| 99 | +/// Topology-only JSON view: `nodes` then `edges` by index, no `call_count`. |
| 100 | +#[derive(serde::Serialize)] |
| 101 | +struct TopologyEdge { |
| 102 | + caller: usize, |
| 103 | + callee: usize, |
| 104 | +} |
| 105 | + |
| 106 | +#[derive(serde::Serialize)] |
| 107 | +struct TopologyGraph<'a> { |
| 108 | + nodes: &'a [Node], |
| 109 | + edges: Vec<TopologyEdge>, |
120 | 110 | } |
121 | 111 |
|
122 | 112 | #[test] |
123 | | -fn python_runtime_is_obj_skipped() { |
| 113 | +fn python_topology_json() { |
124 | 114 | if !have_python3() { |
125 | | - eprintln!("skipping python_runtime_is_obj_skipped: python3 not on PATH"); |
| 115 | + eprintln!("skipping python_topology_json: python3 not on PATH"); |
126 | 116 | return; |
127 | 117 | } |
128 | 118 |
|
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 | 119 | let clgctl = compile_clgctl(); |
136 | 120 | let raw = run_python(&clgctl); |
137 | 121 | 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 |
| 122 | + .unwrap_or_else(|e| panic!("parse python callgrind output: {e:?}")) |
| 123 | + .redact(); |
| 124 | + let nodes = graph.nodes(); |
| 125 | + let topology = TopologyGraph { |
| 126 | + nodes, |
| 127 | + edges: graph |
153 | 128 | .edges() |
154 | 129 | .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 | | - ); |
| 130 | + .map(|e| TopologyEdge { |
| 131 | + caller: nodes |
| 132 | + .iter() |
| 133 | + .position(|x| x == &e.caller) |
| 134 | + .expect("caller node present"), |
| 135 | + callee: nodes |
| 136 | + .iter() |
| 137 | + .position(|x| x == &e.callee) |
| 138 | + .expect("callee node present"), |
| 139 | + }) |
| 140 | + .collect(), |
| 141 | + }; |
| 142 | + let json = serde_json::to_string_pretty(&topology).expect("serialize"); |
158 | 143 |
|
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 | | - ); |
| 144 | + insta::assert_snapshot!("recursion_py__topology_json", json); |
174 | 145 | } |
0 commit comments