Drop C-side smear from per-line memory attribution and the flame view - #1051
Merged
Conversation
Bytecode opcode check at sample-processing time: if the leaf user-frame captured by ``whereInPython`` lands on a line whose bytecode cannot have caused the allocation (no CALL / BUILD_ / LIST_ / SET_ / MAP_ / DICT_ / IMPORT_ / MAKE_ / FORMAT_ prefix, and not one of the exact opnames in the table — GET_ITER / FOR_ITER / BINARY_SUBSCR / LOAD_ATTR / …), the sample is dropped from every per-line accumulator *and* from ``stats.memory_stacks``. The bytes still flow into the running global footprint (so the global peak / sparkline are accurate), but no line, function, or flame-chart path takes the blame. Pre-fix, ``z = z * z`` on two floats could show hundreds of MB of malloc traffic and gigabyte-scale peak / sparkline columns — CPython internals (arena resizes, GC, scalene's own bookkeeping under the GIL) fire raw mallocs whose leaf frame is whichever line the eval loop happens to be on, and the existing leaf-attribution model credited them to that line. An earlier draft redirected those bytes up the captured stack to the nearest CALL-bearing caller; that turned out to mis-attribute too — the caller is just incidentally active, not actually responsible (the real heap pressure usually comes from a prior frame that has already returned and isn't on the stack anymore). The same reasoning applies to the flame view: the captured stack records which user frames were *on the stack at the moment of the malloc*, but none of them is what caused it. Crediting the path in ``memory_stacks`` smears the same way the per-line table does — only on the flame chart instead. So we drop these samples from ``memory_stacks`` as well. Frees go through the same check. ``process_free`` in ``src/include/sampleheap.hpp`` doesn't capture a stack, so we can't redirect frees anyway — but skipping them on smear-prone leaves keeps the per-line current-footprint and sparkline columns consistent with the dropped mallocs. The bytecode scan compiles the source once per file via ``linecache.getlines`` + ``compile()`` + ``dis.get_instructions`` and caches the resulting set of "allocator-capable lines" in ``_file_alloc_lines_cache``. Subsequent lookups are O(1) set membership. Handles Python 3.13's ``Instruction.line_number`` change (int|None per-instruction) and the older ``starts_line`` (int|None first-instr-only) the way other tooling in this codebase does — propagating last-seen line forward through the instruction stream. Sources that can't be read or compiled cache as ``None`` and answer ``True`` (trust the leaf). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| z = 0.1 | ||
| i = 0 | ||
| while i < 100_000: | ||
| z = z * z # line 24 |
| _SCALENE_ATTEMPTS = 3 | ||
|
|
||
|
|
||
| def _run_scalene(tmp_path: Path) -> dict: |
5 tasks
emeryberger
added a commit
that referenced
this pull request
May 11, 2026
Previously the tests / smoketests / codeql workflows set ``fail-fast: false`` to let every matrix entry run to completion even when one entry failed. The intent was to surface every failure in one run — but in practice CI now burns 30+ minutes after a known-flaky combination (Windows spawn-pool tests in particular) has already failed, with no information gained: the cancellation context in the prior PR (#1051 / #1052 follow-ups) made the cost concrete. Flipping to ``fail-fast: true`` is the standard policy: as soon as one matrix entry fails, cancel the rest. The ``continue-on-error`` carve-outs already in tests.yml (free-threaded Python steps) still protect intentional non-blocking failures. Affected workflows: - ``tests.yml`` (8 Python x 2 OS = 16 entries) - ``test-smoketests.yml`` (6 Python x 3 OS = 18 entries) - ``codeql.yml`` (2 languages) ``build-and-upload.yml`` only runs on releases / manual dispatch and is left alone. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This change should have been part of #1050 but didn't make it into the squash merge. Restoring it now as a follow-up.
A pure-arithmetic hot loop like
z = z * zon floats was consistently showing hundreds of MB of malloc traffic, gigabyte-scale peak / sparkline values, a sizable wedge in the memory-activity pie, and a fat path in the--stacksmemory-flame chart — even though the line's bytecode (LOAD_FAST / BINARY_OP / STORE_FAST) had no way to invoke a C-side allocator.The mechanism: the C++ heap interposer stamps every malloc with the leaf user frame (and the captured Python stack) from
whereInPython, but CPython internals (arena resizes, GC, scalene's own bookkeeping under the GIL) emit rawmalloccalls whose stamped leaf is whichever line the eval loop happens to be on. The bytes are real but the attribution is wrong — they smear onto whatever's hot, both per-line and along the captured call chain.Fix
New helper
line_has_alloc_opcode(filename, lineno)inscalene_utility.pycompiles each user source once vialinecache.getlines + compile + dis.get_instructions, recording the set of source lines whose bytecode contains at least one allocation-capable opcode. Set membership:startswith(\"CALL\" / \"BUILD_\" / \"LIST_\" / \"SET_\" / \"MAP_\" / \"DICT_\" / \"IMPORT_\" / \"MAKE_\" / \"FORMAT_\")plus exact opnames coveringGET_ITER,FOR_ITER,SEND,YIELD_VALUE,BINARY_SUBSCR,STORE_SUBSCR,LOAD_ATTR,STORE_ATTR,LOAD_BUILD_CLASS,RAISE_VARARGS,BEFORE_WITH,WITH_EXCEPT_START, etc. Handles Python 3.13'sInstruction.line_numberchange and the olderstarts_linesemantics by propagating last-seen line forward through the instruction stream. Result is cached per file; subsequent lookups are O(1).process_malloc_free_samplesconsults the helper for every sample. When the leaf line is not allocation-capable, the sample is dropped from every per-line accumulator —memory_malloc_samples,memory_python_samples,memory_max_footprint,memory_current_footprint,memory_aggregate_footprint,per_line_footprint_samples,memory_free_samples,memory_free_count,bytei_map— and fromstats.memory_stacks. The bytes still flow into the global running footprint so the global peak and sparkline stay accurate, but no line, function, or flame-chart path takes the blame.The reasoning for dropping
memory_stackstoo: the captured stack records which user frames were active at the moment of the malloc, but the actual cause is heap pressure set up by a prior frame that's already returned (e.g.,doit1ran, allocated, returned; later GC fires duringdoit2's arithmetic loop and the captured stack showsdoit2, notdoit1). Crediting the captured chain in the flame view smears the same way the per-line table did, just along a different axis. Two earlier drafts tried lighter touches — redirect-up-the-stack, and keep-flame-view-intact — and both failed for the same reason: there's no honest way to attribute these bytes from the synchronously-captured stack alone.Frees use the same check. The C++
process_freepath doesn't capture a stack so they couldn't be redirected anyway, but skipping them on smear-prone leaves keeps the per-line current-footprint and sparkline columns consistent with the dropped mallocs.Verification
On
test/testme.py(the canonical reproducer):Pre-fix:
doit2's body lines showed ~1.2 GB of phantom malloc traffic with a ~810 MB peak attributed to the inner `z = z * z` line.Post-fix:
doit2andstuff()'s call sites is zero.doit2is zero,doit1carries its full ~1.9 GB.memory_stackscontains only the three legitimate paths throughdoit1's list comprehensions (`stuff:58 → doit1:47 → lines 13/14/15`).Tests
tests/test_memory_arithmetic_smear.py(new, four tests) with fixturetest/line_attribution_tests/arithmetic_smear.py:test_arithmetic_lines_have_minimal_malloc_traffic— per-line `n_malloc_mb` on arithmetic lines stays bounded.test_allocator_lines_still_dominate— guards against over-suppression: the legitimate list-comprehension lines keep their bytes.test_function_rollup_does_not_smear_onto_arith_function— function-level rollup row for the arithmetic function is zero across every memory column.test_memory_stacks_preserve_full_paths—memory_stackscontains legitimate alloc-leaf paths and no entries whose leaf is a pure-arithmetic line.All existing memory-attribution / memory-stacks tests still pass (
test_line_attribution_nested.py× 3,test_memory_stacks_bigmem.py× 3).Test plan
🤖 Generated with Claude Code