Plan: collect profiles, identify roots, mitigate with code/data changes.
Collecting memory profiles:
- Use tracemalloc (a built-in module that records which line of code allocated each block of memory, and lets you snapshot total/peak Python-level allocations at any point) for Python allocations
- Use memory_profiler (@profile) (a decorator-driven tool that reports memory usage line by line for a decorated function, run via
python -m memory_profiler script.py) to trace per-line memory
- Use psutil / top for process-level peaks (
psutil: a library that reads OS-level process stats, including RSS, resident set size, the actual physical RAM a process is currently using, directly from the operating system)
- For NumPy, use objgraph (a tool for visualizing and counting live Python object references, useful for finding what is unexpectedly still holding a reference to something large) / np.ndarray.nbytes sampling
- Take periodic heap dumps and track peak RSS
Which to reach for first: tracemalloc is the default starting point for almost any Python-level memory investigation, cheap to turn on, built in, and line-level. psutil/RSS is the next check when the growth seems to be happening outside pure Python objects entirely (a C extension, or a numpy allocation that tracemalloc's Python-object view does not fully capture). memory_profiler and objgraph are for harder follow-up cases once the first two have narrowed down roughly where the growth is, not where most investigations should start.
A worked trace with tracemalloc, verified on CPython 3.12 with numpy: creating a float64 array and then converting it with .astype(np.float32) (a common, easy-to-miss source of an avoidable extra allocation) shows up as two distinct, measurable jumps:
python
import tracemalloc
import numpy as np
tracemalloc.start()
baseline, _ = tracemalloc.get_traced_memory()
a = np.zeros(1_000_000, dtype=np.float64)
after_a, _ = tracemalloc.get_traced_memory()
b = a.astype(np.float32) # a full, avoidable copy if a float32 array was all that was ever needed
after_b, _ = tracemalloc.get_traced_memory()
print("a allocation bytes:", after_a - baseline)
print("b (astype copy) allocation bytes:", after_b - after_a)
Output:
a allocation bytes: 8000496
b (astype copy) allocation bytes: 4000096
The dominant numbers, 8,000,000 and 4,000,000, are exactly 1_000_000 * 8 bytes (float64) and 1_000_000 * 4 bytes (float32); the small remainder (496 and 96 bytes) is bookkeeping overhead from numpy's own array object and can vary slightly by numpy version, the part worth remembering is the dtype-size arithmetic, not the exact overhead constant. If a was only ever needed as float32, allocating it directly as np.zeros(1_000_000, dtype=np.float32) avoids the second, redundant 4,000,000-byte allocation entirely, this is exactly the kind of concrete before/after number a real profiling pass surfaces that a purely descriptive plan does not.
Common causes:
- Creating many temporaries (slicing / copying)
- Unnecessary copies from dtype promotion (dtype promotion: when an operation combines two arrays of different dtypes, e.g.
float32 and float64, numpy silently produces a result in the wider of the two dtypes, float64, which can quietly double the memory of a computation that was only ever intended to stay in float32)
- Holding references to large arrays (accumulators, list append)
- Parallel workers duplicating memory
Mitigations:
- Use in-place operations (out=...) and views (a view: a new array object that points at the same underlying memory as another array, e.g.
a[::2], versus a copy, which allocates and fills an entirely separate block of memory) where safe
- Use appropriate dtypes (float32 vs float64)
- Process data in chunks / streaming
- Reuse preallocated arrays and buffer pools
- Avoid building large Python lists; use arrays or write to disk-backed arrays (memmap)
- Release references and call gc.collect when necessary for deterministic release
Validation: write microbenchmarks for peak RSS (RSS: resident set size, the actual physical memory a process currently occupies, as reported by the OS), compare before/after; run under production-like data sizes.