Skip to content

Commit 5e4dfb8

Browse files
emerybergerclaude
andauthored
Add memory allocation attribution tests for single- and multithreaded contexts (#1049)
Covers four regression surfaces not asserted on before: two-allocator byte proportionality, Python-vs-C allocator split, pure-Python multithreaded per-thread attribution, and issue #857 (GIL-releasing worker allocations smearing onto main-thread idle frames). New fixtures in test/line_attribution_tests/ (single-threaded and multithreaded), six test files in tests/, and a shared subprocess helper factored out of the existing _run_scalene duplication. Windows skips on tests that read memory_stacks (whereInPythonWithStack not implemented in libscalene_windows.cpp); byte-attribution tests run on Windows. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 503287a commit 5e4dfb8

11 files changed

Lines changed: 897 additions & 0 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Multithreaded fixture: worker allocates under a released GIL (numpy).
2+
3+
Used by ``tests/test_memory_multithreaded_gil_release.py``. This is the
4+
asserting form of ``test/test-native-thread-alloc.py`` and pins issue
5+
#857: when OpenBLAS/MKL drop the GIL inside a numpy allocation, the
6+
sampled bytes must still be attributed to the worker thread's Python
7+
frame, not to ``time.sleep`` on the main thread where the GIL happens
8+
to be held.
9+
10+
Line numbers are load-bearing: WORKER_ALLOC_LINE, MAIN_SLEEP_LINE are
11+
referenced as integer constants. If numpy is unavailable, exit cleanly
12+
so the test skips.
13+
"""
14+
import sys
15+
import threading
16+
import time
17+
18+
try:
19+
import numpy as np
20+
except ImportError:
21+
sys.exit(0)
22+
23+
24+
def worker():
25+
for _ in range(40):
26+
a = np.zeros((1024, 1024), dtype=np.float64) # line 26 — ~8 MB via libc malloc, GIL often released (WORKER_ALLOC_LINE)
27+
a += 1.0
28+
del a
29+
30+
31+
def main():
32+
t = threading.Thread(target=worker)
33+
t.start()
34+
time.sleep(2.0) # line 34 — main-thread idle; must NOT be charged with worker bytes (MAIN_SLEEP_LINE)
35+
t.join()
36+
37+
38+
if __name__ == "__main__":
39+
main()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Multithreaded fixture: two worker threads, each with its own allocator line.
2+
3+
Used by ``tests/test_memory_multithreaded.py`` and
4+
``tests/test_memory_multithreaded_stacks.py``. Both workers allocate pure
5+
Python ``array.array`` objects under the GIL — no numpy, no GIL release
6+
— so any attribution gap across threads reflects the stack-walk path in
7+
``whereInPythonWithStack``, not a GIL-release confounder.
8+
9+
Per-worker allocator lines get compared directly against each other
10+
(same iteration count; allocation sizes differ by 4x). The main thread's
11+
``sleep`` and ``join`` lines must NOT be charged with worker bytes — that
12+
would mean Scalene is walking the wrong thread's Python stack when a
13+
sample fires inside a worker.
14+
15+
Line numbers are load-bearing: WORKER_A_ALLOC_LINE, WORKER_B_ALLOC_LINE,
16+
MAIN_SLEEP_LINE, MAIN_JOIN_LINE are referenced as integer constants.
17+
"""
18+
import array
19+
import threading
20+
import time
21+
22+
23+
def spin_a_bit():
24+
deadline = time.time() + 0.05
25+
acc = 0
26+
while time.time() < deadline:
27+
acc += 1
28+
return acc
29+
30+
31+
def worker_a():
32+
held = []
33+
for _ in range(50):
34+
held.append(array.array("d", [0]) * 200_000) # line 34 — ~1.6 MB (WORKER_A_ALLOC_LINE)
35+
spin_a_bit()
36+
return len(held)
37+
38+
39+
def worker_b():
40+
held = []
41+
for _ in range(50):
42+
held.append(array.array("d", [0]) * 800_000) # line 42 — ~6.4 MB (WORKER_B_ALLOC_LINE)
43+
spin_a_bit()
44+
return len(held)
45+
46+
47+
def main():
48+
t_a = threading.Thread(target=worker_a)
49+
t_b = threading.Thread(target=worker_b)
50+
t_a.start()
51+
t_b.start()
52+
time.sleep(2.5) # line 52 — main-thread idle (MAIN_SLEEP_LINE)
53+
t_a.join() # line 53 — main-thread idle (MAIN_JOIN_LINE)
54+
t_b.join()
55+
56+
57+
if __name__ == "__main__":
58+
main()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Single-threaded fixture: Python-allocator vs C-allocator byte split.
2+
3+
Used by ``tests/test_memory_python_vs_c.py``. The CPython bytes allocator
4+
counts toward the interposer's ``_pythonCount`` (see
5+
``src/include/sampleheap.hpp``); numpy's ``np.zeros`` goes through libc
6+
``malloc`` and counts toward ``_cCount``. The Python-fraction field in
7+
the JSON output (``n_python_fraction``) should reflect this split
8+
per-line.
9+
10+
Line numbers are load-bearing: PYTHON_LINE and C_LINE are asserted on
11+
directly. If numpy is unavailable, exit cleanly so the test skips
12+
instead of failing spuriously.
13+
"""
14+
import sys
15+
import time
16+
17+
try:
18+
import numpy as np
19+
except ImportError:
20+
# Fixture degrades cleanly: no samples attributed to this file,
21+
# the test recognizes the missing fixture and skips.
22+
sys.exit(0)
23+
24+
25+
def spin_a_bit():
26+
deadline = time.time() + 0.05
27+
acc = 0
28+
while time.time() < deadline:
29+
acc += 1
30+
return acc
31+
32+
33+
def python_alloc():
34+
return bytes(2 * 10_485_767) # line 34 — ~20 MB via CPython allocator (PYTHON_LINE)
35+
36+
37+
def c_alloc():
38+
return np.zeros(2_000_000, dtype=np.float64) # line 38 — ~16 MB via libc malloc (C_LINE)
39+
40+
41+
def run():
42+
held = []
43+
for _ in range(30):
44+
held.append(python_alloc())
45+
held.append(c_alloc())
46+
spin_a_bit()
47+
return len(held)
48+
49+
50+
if __name__ == "__main__":
51+
print(run())
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Single-threaded fixture: two allocator functions with different byte sizes.
2+
3+
Used by ``tests/test_memory_two_allocators.py`` and
4+
``tests/test_memory_stack_leaf_singlethread.py``. Both allocator functions
5+
run the same number of times, so per-line byte attribution should be
6+
proportional to each allocator's per-call size (~4x spread here, tests
7+
only assert a loose 2x to absorb sampling noise).
8+
9+
Line numbers are load-bearing: the tests reference SMALL_LINE and BIG_LINE
10+
by integer constants. Do not reformat without updating those constants.
11+
"""
12+
import array
13+
import time
14+
15+
16+
def spin_a_bit():
17+
# Burn CPU so Scalene takes multiple sampling intervals before the
18+
# program exits. Without this, the loop can complete before a single
19+
# SIGVTALRM fires, and Scalene emits an empty profile.
20+
deadline = time.time() + 0.05
21+
acc = 0
22+
while time.time() < deadline:
23+
acc += 1
24+
return acc
25+
26+
27+
def small_alloc():
28+
return array.array("d", [0]) * 150_000 # line 28 — ~1.2 MB (SMALL_LINE)
29+
30+
31+
def big_alloc():
32+
return array.array("d", [0]) * 600_000 # line 32 — ~4.8 MB (BIG_LINE)
33+
34+
35+
def run():
36+
held = []
37+
for _ in range(40):
38+
held.append(small_alloc())
39+
held.append(big_alloc())
40+
spin_a_bit()
41+
return len(held)
42+
43+
44+
if __name__ == "__main__":
45+
print(run())

tests/_scalene_subprocess.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Shared helper for end-to-end memory-attribution tests.
2+
3+
Runs Scalene as a subprocess against a fixture, retries on empty output,
4+
and skips cleanly if no usable profile ever comes back. Factored out of
5+
``tests/test_line_attribution_nested.py`` and ``tests/test_memory_stacks_bigmem.py``
6+
once a third family of tests started needing the same logic.
7+
8+
Not a test module — pytest ignores files whose basename starts with ``_``.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import subprocess
15+
import sys
16+
from pathlib import Path
17+
from typing import List, Optional
18+
19+
import pytest
20+
21+
22+
def run_scalene_memory_profile(
23+
tmp_path: Path,
24+
fixture: Path,
25+
*,
26+
extra_args: Optional[List[str]] = None,
27+
timeout: int = 120,
28+
attempts: int = 3,
29+
require_memory_stacks: bool = False,
30+
) -> dict:
31+
"""Profile *fixture* with ``--memory``; return the parsed JSON profile.
32+
33+
Retries on two known flake modes:
34+
- Scalene startup wedges (``DYLD_INSERT_LIBRARIES`` / ``sys.monitoring``
35+
init under CI contention): caught via ``TimeoutExpired``.
36+
- Scalene runs but writes no samples for the fixture (program finished
37+
before a sampling interval): detected by the fixture path not being
38+
present in ``profile["files"]``.
39+
40+
If ``require_memory_stacks`` is set, an otherwise-valid profile whose
41+
``memory_stacks`` is empty is also treated as a failed attempt. Used by
42+
tests that assert on the synchronous stack-capture output.
43+
"""
44+
extra = list(extra_args or [])
45+
fixture_name = fixture.name
46+
last: Optional[subprocess.CompletedProcess] = None
47+
for attempt in range(1, attempts + 1):
48+
out = tmp_path / f"{fixture.stem}_{attempt}.json"
49+
cmd = [
50+
sys.executable,
51+
"-m",
52+
"scalene",
53+
"run",
54+
"--memory",
55+
"--no-browser",
56+
"-o",
57+
str(out),
58+
*extra,
59+
str(fixture),
60+
]
61+
try:
62+
last = subprocess.run(
63+
cmd,
64+
check=False,
65+
capture_output=True,
66+
text=True,
67+
timeout=timeout,
68+
)
69+
except subprocess.TimeoutExpired:
70+
continue
71+
if not (out.exists() and out.stat().st_size > 0):
72+
continue
73+
with open(out) as f:
74+
profile = json.load(f)
75+
files = profile.get("files", {})
76+
if not any(fname.endswith(fixture_name) for fname in files):
77+
continue
78+
if require_memory_stacks and not profile.get("memory_stacks"):
79+
continue
80+
return profile
81+
pytest.skip(
82+
f"Scalene produced no usable profile for {fixture_name} after "
83+
f"{attempts} attempts (suspected subprocess startup flake). "
84+
f"last returncode={last.returncode if last else 'timeout'}, "
85+
f"last stderr={(last.stderr[-400:] if last else '')!r}"
86+
)
87+
88+
89+
def fixture_lines(profile: dict, fixture_basename: str) -> List[dict]:
90+
"""Return the per-line records for the file whose path ends in *fixture_basename*.
91+
92+
``run_scalene_memory_profile`` only returns profiles where the fixture is
93+
present, so the guard here is for misuse (wrong basename) rather than
94+
flake.
95+
"""
96+
files = profile.get("files", {})
97+
matches = [
98+
fdata for fname, fdata in files.items() if fname.endswith(fixture_basename)
99+
]
100+
assert matches, (
101+
f"Fixture {fixture_basename!r} missing from profile: "
102+
f"files={list(files.keys())}"
103+
)
104+
return matches[0]["lines"]
105+
106+
107+
def leaf_line(frames: List[dict]) -> Optional[int]:
108+
"""Line number of the innermost (leaf) frame in a ``memory_stacks`` entry."""
109+
return frames[-1]["line"] if frames else None
110+
111+
112+
def leaf_filename(frames: List[dict]) -> Optional[str]:
113+
"""Filename of the innermost frame."""
114+
return frames[-1].get("filename_or_module") if frames else None

0 commit comments

Comments
 (0)