Skip to content

Commit f5aca1e

Browse files
emerybergerclaude
andauthored
Shrink live stats memory + harden profiler self-exclusion (#1043)
Two in-process RAM reductions and two correctness tightens identified while auditing the overhead of the stitched-stacks timeline: - __slots__ on PyFrameKey, NativeFrameKey, CombinedStackRun, StackFrame, StackStats, and _ResolvedNativeFrame. These types are instantiated per CPU / malloc sample and aggregated into long-lived dicts and the timeline list. Dropping the per-instance __dict__ takes PyFrameKey from 664 → 192 bytes and NativeFrameKey from 424 → 72 bytes. Manual slots tuples rather than slots=True because 3.8 is still supported. - Per-process frame-key intern caches in scalene_utility (add_stack / add_combined_stack / add_async_await_run) and scalene_memory_profiler (malloc-sample StackFrame construction). Before this, each sample constructed fresh dataclass instances that compared equal; only the combined_stacks dict de-duplicated them, and the timeline retained all the redundant objects for the lifetime of the run. Caches are cleared in Scalene.start() so repeated magic-cell profiling sessions don't carry state across runs. - TraceConfig now accepts the canonical path of the installed scalene package (from os.path.realpath(os.path.dirname(scalene.__file__))) and uses it as an absolute-path prefix check in should_trace, in addition to the legacy "immediate parent directory literally named scalene" heuristic. The prefix check is robust to vendoring, editable installs under symlinks, and any packaging layout that doesn't name the directory "scalene". The parent-name check stays as a defensive fallback. register_files_to_profile / setup_trace_config take the path as an optional 4th arg to preserve the old call signature. - Scalene.stop() now always flips p_scalene_done = true, not only when args.memory is set, so any allocation made while we serialize the profile to JSON (or via an atypical shutdown path such as term_signal_handler) is guaranteed not to land in the profile. set_scalene_done_{true,false} in pywhere.cpp become no-ops when libscalene isn't preloaded (CPU-only runs), so the unconditional flip is safe regardless of mode. Measured on big_workload.py (10s, 3 threads, --memory --stacks): ScaleneStatistics total 1616 KB -> 215 KB -86.7% combined_stacks_timeline 1544 KB -> 123 KB -92.0% combined_stacks 243 KB -> 23 KB -90.5% avg CombinedStackRun 4688 B -> 1920 B -59.0% Tests: 397 pytest passed + 19 skipped (full suite), ruff clean, mypy clean on all 63 source files. tests/test_coverup_78.py updated for the new 4-arg register_files_to_profile signature. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d4d44d commit f5aca1e

8 files changed

Lines changed: 262 additions & 47 deletions

File tree

scalene/scalene_json.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class _ResolvedNativeFrame:
6565
pure cost here.
6666
"""
6767

68+
__slots__ = ("module", "symbol", "ip", "offset")
6869
module: str
6970
symbol: str
7071
ip: int

scalene/scalene_memory_profiler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
ScaleneStatistics,
2525
StackFrame,
2626
)
27+
from scalene.scalene_utility import intern_stack_frame
2728

2829

2930
class ScaleneMemoryProfiler:
@@ -158,7 +159,7 @@ def process_malloc_free_samples(
158159
except ValueError:
159160
continue
160161
frames.append(
161-
StackFrame(
162+
intern_stack_frame(
162163
filename=fn,
163164
function_name="",
164165
line_number=ln,

scalene/scalene_profiler.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
from scalene.scalene_tracing import ScaleneTracing
119119
from scalene.scalene_utility import (
120120
add_stack,
121+
clear_intern_caches,
121122
compute_frames_to_record,
122123
drain_native_stacks,
123124
enter_function_meta,
@@ -1203,6 +1204,11 @@ def start() -> None:
12031204
file=sys.stderr,
12041205
)
12051206
sys.exit(1)
1207+
# Drop any frame-key intern cache carried over from a prior session
1208+
# (Jupyter magic cells, repeated profile runs in the same process,
1209+
# etc.). Must happen before we start sampling so the cache reflects
1210+
# only the current run.
1211+
clear_intern_caches()
12061212
Scalene.__stats.start_clock()
12071213
Scalene.enable_signals()
12081214
Scalene.__start_time = time.monotonic_ns()
@@ -1230,10 +1236,20 @@ def stop() -> None:
12301236
if Scalene.__args.async_profile:
12311237
ScaleneAsync.disable()
12321238

1233-
if Scalene.__args.memory:
1239+
# Flip p_scalene_done back to true so the allocator interposer
1240+
# stops recording — in particular, allocations made while we
1241+
# serialize the profile to JSON must not land in the profile
1242+
# itself. Called unconditionally (not gated on --memory) so the
1243+
# invariant "no allocations are tracked after stop()" holds even
1244+
# on code paths that exit through term_signal_handler or other
1245+
# non-standard shutdown routes. When libscalene isn't preloaded
1246+
# (CPU-only mode), set_scalene_done_true is a no-op by design.
1247+
try:
12341248
from scalene import pywhere # type: ignore
12351249

12361250
pywhere.set_scalene_done_true()
1251+
except ImportError:
1252+
pass
12371253

12381254
Scalene._disable_signals()
12391255
Scalene.__stats.stop_clock()
@@ -1698,14 +1714,31 @@ def main() -> None:
16981714
@staticmethod
16991715
def _register_files_to_profile() -> None:
17001716
"""Tells the pywhere module, which tracks memory, which files to profile."""
1717+
import scalene as _scalene_pkg
17011718
from scalene import pywhere # type: ignore
17021719

17031720
profile_only_list = Scalene.__args.profile_only.split(",")
17041721

1722+
# Pass the canonical path of the installed scalene package so the C++
1723+
# TraceConfig can exclude Scalene-internal frames via an absolute-path
1724+
# prefix match, not just a parent-directory-name heuristic. The
1725+
# fallback "scalene" parent-directory check stays in place; this is
1726+
# additive belt-and-suspenders.
1727+
scalene_pkg_path: str | None = None
1728+
try:
1729+
scalene_pkg_file = getattr(_scalene_pkg, "__file__", None)
1730+
if scalene_pkg_file:
1731+
scalene_pkg_path = os.path.realpath(
1732+
os.path.dirname(scalene_pkg_file)
1733+
)
1734+
except (TypeError, ValueError, OSError):
1735+
scalene_pkg_path = None
1736+
17051737
pywhere.register_files_to_profile(
17061738
list(Scalene.__files_to_profile) + profile_only_list,
17071739
Scalene.__program_path,
17081740
Scalene.__args.profile_all,
1741+
scalene_pkg_path,
17091742
)
17101743

17111744
@staticmethod

scalene/scalene_statistics.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ class PyFrameKey:
4242
Frozen so instances are hashable and usable as parts of dict keys
4343
(specifically inside CombinedStackKey tuples used to key
4444
``ScaleneStatistics.combined_stacks``).
45+
46+
__slots__ keeps each instance at ~200 bytes instead of ~650 bytes
47+
(no per-instance __dict__). At the scale of the stitched-stacks
48+
timeline this is the single biggest in-process memory win. Manual
49+
slots tuple rather than ``slots=True`` because Scalene supports
50+
Python 3.8.
4551
"""
4652

53+
__slots__ = ("filename", "function", "line")
4754
filename: str
4855
function: str
4956
line: int
@@ -56,6 +63,7 @@ class NativeFrameKey:
5663
in the signal handler. Frozen for the same reason as PyFrameKey.
5764
"""
5865

66+
__slots__ = ("ip",)
5967
ip: int
6068

6169

@@ -84,8 +92,14 @@ class CombinedStackRun:
8492
stack_key: The stitched stack — same shape as the keys of
8593
``ScaleneStatistics.combined_stacks``.
8694
count: Number of consecutive CPU samples in this run.
95+
96+
Non-frozen so ``count`` can be incremented in place when the next
97+
sample hits the same stack. __slots__ drops the per-run overhead
98+
from ~590 bytes to ~150 bytes, which matters because the timeline
99+
can hold up to ``combined_stacks_timeline_max_runs`` (100K) entries.
87100
"""
88101

102+
__slots__ = ("timestamp", "stack_key", "count")
89103
timestamp: float
90104
stack_key: CombinedStackKey
91105
count: int
@@ -152,7 +166,16 @@ def __eq__(self, other: object) -> bool:
152166

153167

154168
class StackFrame:
155-
"""Represents a single frame in the stack."""
169+
"""Represents a single frame in the stack.
170+
171+
__slots__ drops each instance from ~660 bytes to ~80 bytes by
172+
eliminating the per-instance __dict__. StackFrame is the element
173+
type of ``ScaleneStatistics.memory_stacks`` keys, where every
174+
malloc sample that flows through ``process_malloc_free_samples``
175+
constructs fresh frames before the dict dedupes by equality.
176+
"""
177+
178+
__slots__ = ("filename", "function_name", "line_number")
156179

157180
def __init__(self, filename: str, function_name: str, line_number: int) -> None:
158181
self.filename = filename
@@ -178,6 +201,8 @@ def __eq__(self, other: Any) -> bool:
178201
class StackStats:
179202
"""Represents statistics for a stack."""
180203

204+
__slots__ = ("count", "python_time", "c_time", "cpu_samples")
205+
181206
def __init__(
182207
self, count: int, python_time: float, c_time: float, cpu_samples: float
183208
) -> None:

scalene/scalene_utility.py

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,91 @@
6262
_native_unwind_available = False
6363

6464

65+
# Per-process intern caches for stitched-stack frame keys.
66+
#
67+
# Every CPU sample that captures a stitched stack constructs a fresh tuple of
68+
# PyFrameKey / NativeFrameKey instances. Without interning, two consecutive
69+
# samples on the same line allocate two distinct frame objects that compare
70+
# equal, and only the `combined_stacks` dict de-duplicates them (the one fed
71+
# into the timeline is retained for the lifetime of the profile). At high
72+
# sample rates the timeline list holds millions of these redundant objects.
73+
#
74+
# These caches ensure that equal frames share a single instance, so a tuple
75+
# captured twice at the same point is represented by the same Python objects
76+
# throughout. Combined with __slots__ on the frame dataclasses, this collapses
77+
# the timeline's per-run memory cost by roughly an order of magnitude.
78+
#
79+
# The caches are module-level because they belong to the profiler process (one
80+
# profile per process) and because add_combined_stack / add_async_await_run
81+
# are called from the CPU-sample sigqueue thread on a hot path — a single
82+
# dict lookup is cheaper than constructing a fresh dataclass instance.
83+
# Cleared on each ``clear_intern_caches()`` call (invoked from Scalene.start()
84+
# so that multiprocess runs / repeated magic-cell invocations don't leak state
85+
# across profiling sessions).
86+
_py_frame_intern: Dict[Tuple[str, str, int], PyFrameKey] = {}
87+
_native_frame_intern: Dict[int, NativeFrameKey] = {}
88+
89+
90+
def _intern_py_frame(filename: str, function: str, line: int) -> PyFrameKey:
91+
"""Return the shared PyFrameKey for (filename, function, line),
92+
constructing and caching a new one on first sight. Not thread-safe by
93+
design: callers are single-threaded (the sigqueue worker and the
94+
CPU-sample Python handler, which are serialized by the GIL + sigqueue
95+
lock). A racy double-insert would still be correctness-safe (both keys
96+
hash-equal) but waste a slot.
97+
"""
98+
key = (filename, function, line)
99+
cached = _py_frame_intern.get(key)
100+
if cached is None:
101+
cached = PyFrameKey(filename=filename, function=function, line=line)
102+
_py_frame_intern[key] = cached
103+
return cached
104+
105+
106+
def _intern_native_frame(ip: int) -> NativeFrameKey:
107+
"""Return the shared NativeFrameKey for ``ip``, constructing one on
108+
first sight. Same thread-safety caveat as _intern_py_frame."""
109+
cached = _native_frame_intern.get(ip)
110+
if cached is None:
111+
cached = NativeFrameKey(ip=ip)
112+
_native_frame_intern[ip] = cached
113+
return cached
114+
115+
116+
def clear_intern_caches() -> None:
117+
"""Drop all cached frame-key instances. Safe to call any time the
118+
profiler is between sessions. After a clear, subsequent samples
119+
repopulate the caches from scratch."""
120+
_py_frame_intern.clear()
121+
_native_frame_intern.clear()
122+
_stack_frame_intern.clear()
123+
124+
125+
# StackFrame intern cache for the memory-stacks path (parallel rationale to
126+
# the PyFrameKey cache above, but for the memory_stacks dict). Keyed by
127+
# (filename, function_name, line_number). Populated by ``intern_stack_frame``;
128+
# callers outside this module (notably scalene_memory_profiler) reuse it to
129+
# avoid reconstructing equal StackFrame objects for every malloc sample.
130+
_stack_frame_intern: Dict[Tuple[str, str, int], StackFrame] = {}
131+
132+
133+
def intern_stack_frame(
134+
filename: str, function_name: str, line_number: int
135+
) -> StackFrame:
136+
"""Return a shared StackFrame for (filename, function_name, line_number).
137+
Constructs and caches a new one on first sight."""
138+
key = (filename, function_name, line_number)
139+
cached = _stack_frame_intern.get(key)
140+
if cached is None:
141+
cached = StackFrame(
142+
filename=filename,
143+
function_name=function_name,
144+
line_number=line_number,
145+
)
146+
_stack_frame_intern[key] = cached
147+
return cached
148+
149+
65150
def enter_function_meta(
66151
frame: FrameType,
67152
should_trace: Callable[[Filename, str], bool],
@@ -190,14 +275,12 @@ def add_stack(
190275
if should_trace(Filename(f.f_code.co_filename), f.f_code.co_name):
191276
stk.insert(
192277
0,
193-
StackFrame(
194-
filename=str(f.f_code.co_filename),
195-
function_name=str(get_fully_qualified_name(f)),
196-
line_number=(
197-
int(f.f_lineno)
198-
if f.f_lineno is not None
199-
else int(f.f_code.co_firstlineno)
200-
),
278+
intern_stack_frame(
279+
str(f.f_code.co_filename),
280+
str(get_fully_qualified_name(f)),
281+
int(f.f_lineno)
282+
if f.f_lineno is not None
283+
else int(f.f_code.co_firstlineno),
201284
),
202285
)
203286
f = f.f_back
@@ -297,10 +380,10 @@ def add_combined_stack(
297380
)
298381
py_chain.insert(
299382
0,
300-
PyFrameKey(
301-
filename=str(f.f_code.co_filename),
302-
function=str(get_fully_qualified_name(f)),
303-
line=line,
383+
_intern_py_frame(
384+
str(f.f_code.co_filename),
385+
str(get_fully_qualified_name(f)),
386+
line,
304387
),
305388
)
306389
f = f.f_back
@@ -312,7 +395,7 @@ def add_combined_stack(
312395
# native-entry -> native-leaf, which means reversing the
313396
# leaf-first tuple from the unwinder.
314397
native_segment: Tuple[NativeFrameKey, ...] = tuple(
315-
NativeFrameKey(ip=ip) for ip in reversed(native_stk)
398+
_intern_native_frame(ip) for ip in reversed(native_stk)
316399
)
317400
key: CombinedStackKey = py_chain_tuple + native_segment
318401
combined_stacks[key] += 1
@@ -398,10 +481,10 @@ def add_async_await_run(
398481
else (cfunc or "<coroutine>")
399482
)
400483
py_frames.append(
401-
PyFrameKey(
402-
filename=str(cf_filename),
403-
function=func_label,
404-
line=int(cl),
484+
_intern_py_frame(
485+
str(cf_filename),
486+
func_label,
487+
int(cl),
405488
)
406489
)
407490

@@ -414,10 +497,10 @@ def add_async_await_run(
414497
continue
415498
func_name: str = task.func_name or "<await>"
416499
py_frames.append(
417-
PyFrameKey(
418-
filename=str(filename),
419-
function=f"[await] {func_name}{task_suffix}",
420-
line=int(task.lineno),
500+
_intern_py_frame(
501+
str(filename),
502+
f"[await] {func_name}{task_suffix}",
503+
int(task.lineno),
421504
)
422505
)
423506

0 commit comments

Comments
 (0)