Skip to content

Commit 487a5b3

Browse files
emerybergerclaude
andcommitted
Make memory profiling work in pytest-xdist workers
The previous commit rejected --scalene-memory with -n because worker allocations came back as literally nothing: max_footprint_mb == 0, an empty memory timeline, 0 MB on every line, for a workload reporting 43 MB serially. That turned out to be a fixable bug, not an inherent limit, so this replaces the rejection with support. Bisecting the path with probes showed the interposer was in place and simply never recorded anything: in a worker, libscalene was loaded (dlsym finds its symbols), DYLD_INSERT_LIBRARIES was set, args.memory was True, the SIGXCPU handler was installed and the mapfiles existed -- but the interposer's write cursor was 0 bytes, against 8302 in the controller. So nothing was being dropped on the Python side; nothing was ever written. The cause is pywhere's native TraceConfig. sampleheap.hpp only writes an allocation record when where(...) returns non-zero, and whereInPython returns 0 immediately when TraceConfig is null. Scalene registers that config while preparing to run a program file; an interpreter launched as `python -c ...` -- which is how xdist's execnet starts its workers -- never reaches that code, so the config stayed null and every allocation went unrecorded. Fix it where the asymmetry actually is: Scalene.set_program_path() now re-registers the files to profile when memory profiling is on. That is the natural home for it, since pywhere holds its own copy of the program path, and it fixes any programmatic entry point that repoints the path rather than just this plugin. Confirmed by hand first -- calling _register_files_to_profile() in a worker took it from 0 bytes/0 samples to 101752 bytes/100 samples. `pytest --scalene-memory -n 2` now reports 43.0 MB on the allocating line, matching the serial run exactly. All six combinations of {--scalene, --scalene-memory} x {serial, -n 0, -n 2} verified; CPU percentages are lower under -n 2 only because elapsed time is wall-clock across parallel workers, which is the correct denominator. Full suite passes on 3.12 (438) and 3.14 (450). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a3474fe commit 487a5b3

5 files changed

Lines changed: 64 additions & 45 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,18 @@ python3 -m pytest tests/test_coverup_83.py -v
105105
samples summed, `elapsed_time` maxed, which is correct for parallel workers.
106106
`-n 0` is *not* distributed (xdist leaves `dist == "no"`) and stays
107107
in-process. `--cpu-only` is passed on this re-exec unless the user asked for
108-
memory/GPU, so `--scalene` keeps meaning CPU. Memory profiling does **not**
109-
reach xdist workers (verified: `max_footprint_mb == 0` and 0 MB per line for
110-
a workload reporting 43 MB serially), so `--scalene-memory` plus `-n` is
111-
rejected with a `UsageError` rather than emitting an all-zero profile.
108+
memory/GPU, so `--scalene` keeps meaning CPU.
109+
- **Memory profiling in `-c`-launched processes:** `Scalene.set_program_path()`
110+
re-registers pywhere's native `TraceConfig` (via `_register_files_to_profile`)
111+
when memory profiling is on. This matters far more than it looks:
112+
`whereInPython` returns 0 outright when that config is missing, and
113+
`sampleheap.hpp` only writes an allocation record when `where(...)` returns
114+
non-zero — so a missing TraceConfig means libscalene records **nothing**, not
115+
merely mis-attributed lines. Scalene's normal startup registers it while
116+
preparing to run a program file; an interpreter started as `python -c ...`
117+
(how xdist's execnet launches workers) never takes that path. Symptom when
118+
broken: `max_footprint_mb == 0`, empty memory timeline, and 0 MB on every
119+
line, for a workload that reports tens of MB serially.
112120

113121
### Replacement Modules (`replacement_*.py`)
114122

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -543,20 +543,21 @@ interpreter starts. Equivalently, you can drive it the other way around:
543543
scalene run -m pytest your_test.py
544544
```
545545

546-
**Using `pytest-xdist`:** CPU profiling works with distributed runs
547-
(`-n 4`, `--dist`). Because the tests execute in worker subprocesses, Scalene
546+
**Using `pytest-xdist`:** distributed runs (`-n 4`, `--dist`) are supported for
547+
both CPU and memory. Because the tests execute in worker subprocesses, Scalene
548548
re-runs the session under `scalene run` so that each worker is profiled as a
549549
child process; their statistics are then merged into the single
550550
`scalene-profile.json`:
551551

552552
```console
553-
pytest --scalene -n 4 # profiles every worker, merged
553+
pytest --scalene -n 4 # CPU, every worker, merged
554+
pytest --scalene-memory -n 4 # CPU + memory, every worker, merged
554555
```
555556

556-
Memory profiling is the exception: allocations in xdist workers are not
557-
tracked, so `--scalene-memory` combined with `-n` reports an error rather than
558-
handing back a memory profile that reads as all zeros. Profile memory without
559-
`-n` (or with `-n 0`, which keeps the tests in the current process).
557+
Note that reported CPU percentages are relative to wall-clock time, which for
558+
a parallel run is the elapsed time of the whole session — so per-line
559+
percentages are lower than in a serial run even though the ranking is the
560+
same.
560561

561562
</details>
562563

@@ -664,10 +665,9 @@ You can also drive it the other way around, which is equivalent to
664665
scalene run -m pytest your_test.py
665666
```
666667

667-
CPU profiling works with distributed `pytest-xdist` runs (`pytest --scalene -n
668-
4`): Scalene profiles each worker and merges the results. Memory profiling
669-
does not — `--scalene-memory` together with `-n` reports an error instead of
670-
producing an all-zero memory profile.
668+
Distributed `pytest-xdist` runs work too (`pytest --scalene -n 4`, with or
669+
without `--scalene-memory`): Scalene profiles each worker and merges the
670+
results into one profile.
671671

672672
See [Scalene with pytest](#scalene-with-pytest) for the full set of options.
673673

scalene/pytest_scalene.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -105,24 +105,6 @@ def pytest_configure(config: pytest.Config) -> None:
105105
if not _scalene_requested(config):
106106
return
107107

108-
# CPU sampling aggregates across xdist workers, but memory tracking does
109-
# not: a distributed `--scalene-memory` run comes back with
110-
# max_footprint_mb == 0, an empty memory timeline and 0 MB on every line,
111-
# even for a workload that reports 43 MB when run serially. Rather than
112-
# hand back a memory profile that is uniformly zero, say what's wrong and
113-
# what to do about it. Checked before the re-exec so we fail immediately
114-
# instead of relaunching the whole session first.
115-
if config.getoption("--scalene-memory") and _is_distributed(config):
116-
import pytest
117-
118-
raise pytest.UsageError(
119-
"--scalene-memory cannot be combined with a distributed "
120-
"pytest-xdist run: allocations in the workers are not tracked, so "
121-
"the memory profile would be all zeros. Use --scalene (CPU "
122-
"profiling does aggregate across workers), or drop -n/--dist "
123-
"(or use -n 0) to profile memory."
124-
)
125-
126108
# Memory/GPU profiling needs libscalene preloaded, and a distributed
127109
# xdist run needs Scalene's python alias in place so the workers are
128110
# profiled (see _needs_preload). Both are only possible by launching the

scalene/scalene_profiler.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,16 @@ def set_program_path(path: str) -> None:
17681768
Scalene.__program_path = Filename(os.path.abspath(os.path.expanduser(path)))
17691769
if hasattr(Scalene, "_Scalene__tracing"):
17701770
Scalene.__tracing.set_program_path(Scalene.__program_path)
1771+
# Refresh the native side too. pywhere keeps its own TraceConfig, and
1772+
# `whereInPython` returns 0 outright when that config is missing --
1773+
# which makes libscalene record *no* allocations at all, not merely
1774+
# mis-attributed ones. Scalene's usual startup registers it while
1775+
# setting up to run a program file; an interpreter launched as
1776+
# `python -c ...` (how pytest-xdist's execnet starts its workers)
1777+
# never goes through that path, so without this the workers report
1778+
# zero bytes. Same guard as the @profile decorator path above.
1779+
if Scalene.__initialized and getattr(Scalene.__args, "memory", False):
1780+
Scalene._register_files_to_profile()
17711781

17721782
@staticmethod
17731783
def main() -> None:

tests/test_pytest_plugin.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -367,27 +367,46 @@ def test_burn_two():
367367

368368

369369
@requires_native
370-
def test_memory_with_xdist_is_rejected(pytester: pytest.Pytester) -> None:
371-
"""`--scalene-memory -n 2` must refuse rather than report zero bytes.
372-
373-
CPU aggregates across workers, but allocation tracking does not: the run
374-
comes back with max_footprint_mb == 0 and 0 MB on every line for a
375-
workload that reports tens of MB serially. A memory profile that is
376-
uniformly zero looks like "your tests allocate nothing," so this
377-
combination is rejected outright.
370+
def test_memory_profiling_under_xdist(pytester: pytest.Pytester) -> None:
371+
"""`--scalene-memory -n 2` attributes allocations made inside workers.
372+
373+
This needs pywhere's native TraceConfig to exist in the worker: without
374+
it `whereInPython` returns 0 and libscalene records *no* allocations at
375+
all -- the whole run came back with max_footprint_mb == 0 and 0 MB on
376+
every line. Scalene's usual startup registers that config while preparing
377+
to run a program file, which an xdist worker (`python -c ...`) never does;
378+
Scalene.set_program_path() now re-registers it.
379+
380+
The workload allocates ~40 MB in one test, so a threshold well above
381+
incidental interpreter traffic still isn't sampling-sensitive.
378382
"""
379383
pytest.importorskip("xdist")
380384
pytester.makepyfile(
381385
test_alloc="""
382386
def test_alloc():
383387
data = [[j for j in range(3000)] for _ in range(400)]
384388
assert len(data) == 400
389+
390+
def test_quiet():
391+
assert 1 + 1 == 2
385392
"""
386393
)
387-
result = _run(pytester, "--scalene-memory", "-n", "2", "test_alloc.py")
388-
assert result.ret != 0, "--scalene-memory with -n should not succeed"
389-
result.stderr.fnmatch_lines(["*cannot be combined with a distributed*"])
390-
assert not (pytester.path / "scalene-profile.json").exists()
394+
result = _run(pytester, "--scalene-memory", "-n", "2", "-q")
395+
result.assert_outcomes(passed=2)
396+
397+
profile = pytester.path / "scalene-profile.json"
398+
assert profile.exists(), "distributed memory run wrote no profile"
399+
data = json.loads(profile.read_text())
400+
total_mb = sum(
401+
line.get("n_malloc_mb", 0)
402+
for fdata in data["files"].values()
403+
for line in fdata["lines"]
404+
)
405+
assert total_mb > 5.0, (
406+
f"only {total_mb:.1f} MB attributed across xdist workers; the "
407+
"worker's allocations were probably not tracked at all "
408+
f"(max_footprint_mb={data.get('max_footprint_mb')})"
409+
)
391410

392411

393412
@requires_native

0 commit comments

Comments
 (0)