Every value your program makes has to sit somewhere in the computer’s memory. A running program splits its memory into a few areas with different rules. The two you hear about most are the stack, a small, very fast scratch pad that each function call borrows and hands back when it finishes, and the heap, a big shared pool for anything that has to stick around or whose size nobody knew in advance.
This is where three everyday surprises come from: a Python list of 50 million numbers taking five times the RAM you budgeted, a recursive function that crashes the whole program instead of raising an error, and a long-running service (say, the ranking server you’ll eventually deploy) that slowly eats memory until something kills it. Each one is easy to explain once you can picture where the bytes are.
It builds on How slow is slow?, which showed that memory close to the CPU is about a hundred times faster than main RAM. It sets up Processes, threads and the GIL (each thread gets its own scratch pad, all of them share the pool) and Why is Python slow?. Along the way you’ll compile and run a few short C programs, given to you whole, to see raw memory addresses. You read them, you don’t write them; C for Python people has more if you want it.
Today
- Sketch how a running program’s memory is laid out: where its instructions, its long-lived variables, the shared pool and the per-call scratch space sit, and which way each one grows.
- Explain what calling a function physically does to memory, why that makes it so cheap, and why there’s a hard size limit that very deep recursion runs into.
- Explain what the shared pool is for, who is responsible for handing memory back in C, Python and Rust, and how memory can be free yet unusable.
- Predict, within 20%, how much RAM a Python list, dict, or NumPy array of N numbers will take, and say where a PyTorch tensor’s numbers actually live.
- Find a memory leak in a Python program by measuring which lines are holding on to memory, with Python’s built-in memory tracer, instead of guessing.
Prerequisites: How slow is slow? (you need the gap between the CPU’s own fast memory and RAM). A C compiler to build the given C programs (cc is already on macOS and the Spark), Python 3.12+, numpy. About 3 hours with the builds.
The Problem
In the diagnostic you said: “Stack is first in, first out. Heap is like this tree, which is sorted.”
Both halves are wrong, and in a very normal way. The words are doing it to you. “Stack” sounds like a line of things waiting, and “heap” is also the name of a tree you studied in an algorithms course. So let’s clear both up before anything else, because everything later in this track sits on top of them.
The stack is last in, first out. Think of a stack of plates. You put a plate on top, and the plate you take off is the one you put on most recently. Nobody pulls the bottom plate out. First-in-first-out is a queue: the line at a coffee shop, where whoever arrived first gets served first.
The memory heap has nothing to do with the heap data structure. The tree you are thinking of, where every parent is smaller than its children, is a binary heapA tree stored in an array where each parent is smaller (or larger) than its children, so the smallest item is always at the root. Used for priority queues and heapsort.. The “heap” in “stack and heap” is just a big region of memory where a program asks for chunks at runtime. It is not sorted. It is not a tree. Inside, it is closer to a parking lot with gaps in it.
The shared name is an accident of history. J. W. J. Williams named the tree “heap” in 1964 when he published heapsort. Around the same time, language designers started calling the pool of runtime memory “the heap” (ALGOL 68 even had a HEAP keyword for it), in the everyday sense of “a pile of stuff”. As far as anyone can tell, nobody chose the clash on purpose. Two communities picked the same ordinary English word.
That fixes the vocabulary. The real question is the one under it: when your code says x = 5, or scores = [0.0] * 50_000_000, or t = torch.zeros(4096, 4096), where in the machine do those bytes go, who decides, and who cleans up? You need this for three very practical things: why a list “should” be 400 MB but is 2 GB, why deep recursion dies, and why a long-running process slowly eats all your RAM.
The Idea
A process’s memory, drawn as one long strip
When you run a program, the operating system gives it a processA running program plus everything the OS keeps for it: its memory, open files, and at least one thread executing its code. Lesson 3 is all about these.. Each process gets its own address spaceThe range of memory addresses a process can use, numbered from 0 up to a very large number. Each process has its own, so address 0x1000 in one program is unrelated to 0x1000 in another.: one long strip of numbered bytes. On a 64-bit machine the numbers go up to about 247 or 248 (hundreds of terabytes), far more than the RAM you have. That is fine, because the addresses are not real RAM locations. They are virtual addressesAddresses your program sees. The CPU and OS translate them to real RAM locations behind the scenes, page by page. A later lesson on virtual memory covers how., and only the parts actually in use get backed by real memory. For today, treat the strip as real and don’t worry about the translation.
The strip is divided into regions with different jobs. This is the classic picture, from low addresses at the bottom to high at the top:
Bottom to top:
- Code (often called the text segmentThe region holding the program’s machine instructions. Read-only and executable, so a bug can’t overwrite the code.): the compiled instructions. Read-only.
- Globals: variables that exist for the whole run, like
int g = 42;at the top of a C file. Their size is known before the program starts, so the loader sets aside exactly that much. - Heap: memory the program asks for while it runs, in sizes nobody knew in advance. It sits just above the globals and can grow upward.
- Memory-mapped region: where the OS places shared libraries (
libc,libpython, numpy’s compiled code), files you map into memory, and, on Linux, any single allocation bigger than about 128 KB (glibc’s default threshold). Technically this is heap memory too (you asked for it at runtime), it just comes from a different place. - Stack: starts near the top and grows down, toward lower addresses. One block per function call that hasn’t returned yet.
- Address 0 and the area around it is deliberately left unmapped, so following a null pointerA pointer whose value is 0, used to mean “points at nothing”. Reading through it crashes immediately because nothing is mapped at address 0. That crash is on purpose. crashes loudly instead of silently reading garbage.
Two warnings about this picture. First, it is a model. Real layouts vary: on macOS on Apple Silicon you will see the stack sitting below the big heap region, and on ARM64 Linux (your Spark) the numbers start with 0xaaaa... and 0xffff... instead of 0x55... and 0x7f.... The roles are the same. Second, the base addresses change every run, on purpose. That is ASLRAddress Space Layout Randomization: the OS shifts where the stack, heap, libraries, and code land each time a program starts, so an attacker can’t hardcode addresses. On by default on Linux and macOS.. You will see it in the build.
The stack: one frame per unfinished call
Here is the stack’s whole job. When a function is called, it needs somewhere to keep its local variables, and the CPU needs to remember where to jump back to when it returns. That bundle is called a stack frameThe block of stack memory for one function call: its local variables, the return address, and a few saved registers. Created when the call starts, thrown away when it returns..
Calling a function pushes a frame on top. Returning pops it. Because a function always finishes before the function that called it does, the most recent frame is always the first to go. That is exactly last-in-first-out, and it is why the plates are the right picture.
A frame typically holds:
- the function’s local variables (when they don’t fit in registers),
- the return addressThe address of the instruction to jump back to when this function returns: the spot in the caller right after the call. On ARM64 it arrives in a register (x30, the link register) and is saved to the frame if the function calls anything itself.,
- the caller’s frame pointer, so the chain of frames can be walked (that walk is literally what a stack trace is),
- any registers the function needs to borrow and restore.
The CPU keeps one register, the stack pointerA CPU register (rsp on x86-64, sp on ARM64) holding the address of the current top of the stack. Pushing a frame moves it down; popping moves it back up., pointing at the current top. Step through it:
The figure below runs sum(3), which calls sum(2), which calls sum(1), which calls sum(0). At the deepest point, how many frames are on the stack, counting main? And when sum(1) finishes, which frame is on top?
Reveal
Five: main, sum(3), sum(2), sum(1), sum(0). The deepest point is right when sum(0) is running. When sum(1) returns, its frame is popped and sum(2) is on top again, resuming exactly where it left off, with its own n = 2 untouched. Each call has its own copy of n because each call has its own frame.
Notice the drawing direction. The “top” of the stack, the newest frame, is drawn at the bottom because it is at the lowest address. Plates stacked upside down from the ceiling. Every diagram you’ll see in a systems book does this, and it trips everyone up once.
Why the stack is fast
Allocating a frame is one instruction: subtract the frame size from the stack pointer. On ARM64 that is literally sub sp, sp, #32. Freeing it is one instruction: add it back. There is no searching for free space, no bookkeeping, no lock. The compiler knew every function’s frame size when it compiled the program.
The second reason is the one from How slow is slow?. The top of the stack is the memory your program touched a few nanoseconds ago. It is almost always sitting in the L1 cacheThe smallest, fastest memory on the CPU core, around 32-128 KB per core, readable in about 1 ns. Main RAM is roughly 100x slower to reach.. Calling a function reuses the same few hundred bytes of stack over and over, so locals cost about 1 ns to touch instead of the ~100 ns of a trip to RAM. Stack memory is the hottest memory you have.
Why the stack is small
The price for that speed is rigidity. The stack’s size is reserved up front when the thread starts, and a frame’s size must be known at compile time.
On Linux, the main thread’s stack is 8 MB by default. Check yours:
ulimit -s # prints 8192 on most Linux boxes (KB), 8176 on macOS
8 MB is plenty for normal code: a typical frame is tens to hundreds of bytes, so you could nest calls tens of thousands deep. It is nothing for data. A C function with a local double big[2000000]; asks for 16 MB of stack, and the program dies.
When the stack pointer walks past the end of the reserved region, it hits a guard pageAn unmapped page placed just past the end of the stack. Touching it causes an immediate crash instead of letting the stack silently overwrite whatever memory sits next to it., and the OS kills the program with a segmentation faultThe crash you get when a program touches memory it has no right to. The OS sends signal SIGSEGV (number 11), and the shell shows exit code 139 = 128 + 11.. That is a stack overflowRunning out of stack space, almost always from recursion that goes too deep or a huge local array. The name of the website comes from this..
One more thing to file away for lesson 3. Every thread gets its own stack. That has to be true: each thread is running its own chain of function calls, so each needs its own pile of plates. On Linux, extra threads get the same 8 MB default; on macOS, extra threads get only 512 KB. But all threads in a process share one heap. That single fact explains most of what goes wrong with threads, which is where Processes, threads and the GIL picks up.
The heap: memory that outlives the function
The stack can’t handle two common needs:
- Data whose size you only learn at runtime. How many rows are in this file? How long is this user’s query?
- Data that must outlive the function that made it. A function that loads a model and returns it can’t keep the weights in its own frame, because the frame is gone the moment it returns.
For both, you ask the heap. In C that means malloc:
float *scores = malloc(n * sizeof(float)); // "give me n*4 bytes, somewhere"
// ... use scores[0..n-1], return it, pass it around ...
free(scores); // "I'm done, take it back"
malloc is a library function (an allocatorThe code that manages the heap: hands out chunks on request, takes them back, and tracks which parts are free. glibc’s malloc, jemalloc, and mimalloc are common ones. CPython and PyTorch have their own on top.) that keeps a map of which parts of the heap are in use. When you ask for 400 bytes, it finds a free gap of at least 400 bytes, marks it used, and hands you its address. When the heap runs out of gaps, the allocator asks the OS for more: it either extends the heap upward or, for big requests, maps a fresh region. Both are system callsRequests from your program to the operating system kernel, like “give me more memory” or “read this file”. Each costs from a few hundred nanoseconds to many microseconds, far more than a normal function call..
This is why the heap is slower than the stack. A malloc on the fast path is roughly tens of nanoseconds, not one instruction, and much more when it has to go to the OS. Worse, heap objects end up wherever there was a gap, so related data is often scattered, which means cache misses when you walk through it. Neither cost is a disaster once. Both are a disaster a hundred million times in a loop.
Fragmentation: free but useless
Because chunks are freed in random order, the heap develops holes.
FragmentationFree memory broken into many small holes, so a large request can’t be served even though the total free space is big enough. The process’s memory use grows even though it isn’t “using” more. is why a long-running server’s memory can creep up even when it isn’t leaking: it frees as much as it allocates, but into holes too small to reuse. Allocators fight this by grouping chunks into size classes (all 32-byte objects together, all 64-byte objects together), which is exactly what CPython’s allocator does.
Who frees the heap?
Stack memory cleans itself up: the pop is the cleanup. Heap memory has no such moment. Someone has to decide “nobody needs this anymore”. Languages differ mainly in who that someone is.
- C: you. Every
mallocneeds exactly onefree. Forget it and you have a memory leakMemory that’s still allocated but that nothing will ever use again, so it can never be reused. The process grows until something kills it.. Free it twice, or use it after freeing, and you corrupt the allocator’s map. A large fraction of real-world security bugs are exactly this. - CPython: counting, plus a sweeper for loops. Every object carries a count of how many references point at it. When the count drops to zero, the object is freed right then. A separate cycle collectorCPython’s garbage collector. It runs every so often, finds groups of objects that only point at each other (a cycle) with nothing outside pointing in, and frees them. Reference counting alone can never free a cycle. handles the case counting can’t: two objects that point at each other.
- Rust: the compiler. Every heap value has exactly one owner, and the compiler inserts the free at the point the owner goes out of scope. No counting at runtime, no collector, and use-after-free won’t compile.
Python: everything is on the heap
Now the part that changes how you read Python code. In CPython, every value is an object on the heap. Variables are not boxes that hold values. They are names that point at objects.
When you write x = 1000, CPython allocates an int object on the heap, then makes the name x point at it. y = x doesn’t copy anything; it makes a second name point at the same object. id(x) gives you that object’s identity, which in CPython is literally its memory address.
Every CPython object starts with the same header: a reference countA number stored inside every CPython object counting how many references point at it. Assignment adds one; a name going away subtracts one; at zero, the object is freed immediately. (8 bytes) and a pointer to its type (8 bytes). A small int adds 8 bytes of size info and one 4-byte “digit” (CPython stores big ints as a list of 30-bit chunks). That’s 28 bytes to store the number 1000, which as a raw machine integer fits in 8.
So a Python list of a million ints is really two things: one array of a million 8-byte pointers (8 MB) and a million separate int objects, each 28 bytes. Naive math: 8 + 28 = 36 MB. Measured with tracemalloc on CPython 3.12+, 64-bit, you’ll see about 40 MB, because CPython’s small-object allocator hands out memory in 16-byte steps and rounds each 28-byte int up to 32. A numpy int64 array of the same million numbers is 8 MB plus a ~100-byte header, all in one block. Five times smaller, and the numbers sit next to each other, so the CPU’s cache and prefetcher can stream through them instead of chasing a pointer per element.
(Exact sizes change across CPython versions: in 3.10, sys.getsizeof(0) was 24; from 3.11 on it’s 28. Treat every number here as “CPython 3.12+, 64-bit” and check your own with the build below.)
Two subtleties you’ll trip on:
- CPython keeps one shared object for each int from -5 to 256, made at startup.
[0] * 1_000_000is a million pointers to the same zero, so it costs 8 MB, not 40. Only distinct, larger ints each get their own object. sys.getsizeof(a_list)reports the list object and its pointer array only (8 MB for a million items), not the objects the pointers point at. It’s a shallow number.tracemallocsees everything that was allocated.
The slider at the bottom of Figure 005 lets you feel the scale. The bytes per element are tracemalloc measurements on CPython 3.12+, 64-bit (dark: the pointer; light: the object it points at). A list built with append() can be up to about 12% bigger, because lists over-allocate so that appends stay cheap.
The CPython frame stack vs the C stack
Python has a call stack too: you see it every time you read a traceback. But it isn’t simply the C stack from the address-space diagram.
CPython is a C program. When it runs your Python function, it keeps a small record for that call: which function, which line, the local variable slots (pointers to objects, of course). Since CPython 3.11, those records live in a chunk of memory the interpreter allocates on the heap, and a Python function calling another Python function no longer makes the C code recurse. The C stack stays flat.
But plenty of things still recurse in C. repr of a deeply nested list, json.dumps, pickle, comparisons, and every C extension that calls back into Python each push real C frames. Those can still overflow the real 8 MB stack and segfault the whole interpreter.
That’s why CPython has a recursion limitA cap on how deep Python calls can nest (default 1000; see sys.getrecursionlimit). Exceeding it raises RecursionError instead of crashing the process.. Historically, every Python call used real C stack, and 1,000 calls deep was comfortably under 8 MB, so the limit turned a crash into a catchable RecursionError. Today it’s mostly a guard against runaway recursion, and newer versions (3.12+) add a separate check for C-level depth. You can raise it with sys.setrecursionlimit, but on an old Python that just lets you segfault instead of getting an error. You’ll do exactly that in the build.
Reference counting and del
del x does not delete an object. It deletes the name x, which drops the object’s reference count by one. If that was the last reference, the object is freed immediately. If anything else still points at it (another name, a list, a cache, a closure, a traceback you stored), it stays alive.
This is most “Python memory leaks” in practice. Not a bug in Python. Some long-lived container (a module-level dict used as a cache, a list of results you meant to clear, a logger holding references) keeps growing, and every object it points at is pinned in memory forever.
The other source is cycles. If a.other = b and b.other = a, both counts stay at 1 even after you delete every name, and only the cycle collector can free them. Normally it runs on its own. Some teams turn it off for speed (Instagram famously did, in 2017), and then cycles never get freed.
Where a PyTorch tensor lives
A tensor is the same story with one more layer.
t = torch.zeros(1000, 1000) creates a small Python object (the tensor), which holds the shape, the stridesHow many elements to skip to move one step along each dimension. Changing strides and offset lets a view reshape or slice data without copying it., the dtype, the device, and a pointer to a storage. The storage owns the actual 4 MB buffer of floats. On CPU, that buffer is a heap allocation, like numpy’s. With device="cuda", it lives in GPU memory, allocated through PyTorch’s own CUDA allocator.
Slices and .view() make a new small tensor object pointing into the same storage with a different offset or strides. That’s why they are free, and why modifying a view modifies the original.
The CUDA side has its own version of “the heap”. PyTorch’s caching allocatorPyTorch’s GPU-memory manager. It grabs big blocks from the CUDA driver and reuses them for new tensors instead of returning them, because asking the driver is slow. Freed memory stays reserved for PyTorch. keeps freed GPU blocks for reuse instead of giving them back to the driver. So after del big_tensor, nvidia-smi doesn’t go down. torch.cuda.memory_allocated() (live tensors) does; torch.cuda.memory_reserved() (what PyTorch is holding) doesn’t. Same idea as malloc keeping freed chunks for later.
On your DGX Spark there’s a twist: the CPU and GPU share one physical pool of memory (128 GB of LPDDR5x, per the spec sheet) instead of the GPU having its own separate memory chips. CPU-heap tensors and CUDA tensors still come from different allocators, and .to("cuda") still makes a second copy in a different allocation. What changes is that there’s no PCIe bus between them, and both draw from the same 128 GB. When you budget memory on the Spark, CPU-side data and GPU-side tensors come out of one shared number. (Exactly how NVIDIA exposes shared or managed memory on GB10 is worth checking in their docs before you rely on zero-copy tricks.)
Build It
Make a working folder, e.g. ~/workbench/stack-and-heap/. The first two tasks are C programs that are given to you whole: you compile them, run them, and read what they print. Python hides stack addresses and only reaches the real stack indirectly (task t3 does that), which is the only reason these two are in C. Run them on both your Mac and the Spark if you can; the differences are part of the lesson.
Save this as where.c. It prints the address of one thing from each region in Figure 002: the code, a global, the heap (a small and a big request), and locals on the stack, including one per level of a function that calls itself three times.
#include <stdio.h> // printf
#include <stdlib.h> // malloc, free
int g = 42; // a global: lives for the whole run
void deeper(int depth) {
int x = depth; // a local, in this call's frame
printf("depth %d local %p\n", depth, (void *)&x); // &x = "the address of x"
if (depth < 3) {
deeper(depth + 1); // call ourselves: a new frame, below this one
}
}
int main(void) {
int local = 7; // a local, in main's frame
char *small = malloc(64); // 64 bytes from the heap
char *big = malloc(1 << 20); // 1 MB from the heap (1 << 20 = 1,048,576)
printf("code (main) %p\n", (void *)main); // where main's machine code sits
printf("global %p\n", (void *)&g);
printf("heap small %p\n", (void *)small);
printf("heap 1MB %p\n", (void *)big);
printf("local main %p\n", (void *)&local);
deeper(1);
free(small); // hand the heap memory back
free(big);
return 0;
}What the lines do, in Python terms:
int g = 42;outside any function is a global, like a module-level variable, except C has to be told its type (int, a 4-byte whole number) and it never changes type.int x = depth;inside a function is a local. It gets a slot in that call’s stack frame, which is exactly what you want to see.&xmeans “the address ofx”: not its value, but the number of the byte where it starts in the address space. A variable that holds an address is a pointerA variable whose value is a memory address. In C,char *smalldeclares one: “small holds the address of some bytes”. Every Python name is secretly one of these, pointing at a heap object., andchar *smalldeclares one.malloc(64)is C’s “give me 64 bytes from the heap”. It returns a pointer to the first byte.free(small)hands them back. Python does the equivalent for you every time it makes an object.printfis C’sprintwith a template:"%p"in the format stringThe template passed to printf. Each%code is a slot filled by the next argument:%dprints a whole number,%pprints a pointer as a hex address. Like a Python f-string with the values listed after it. means “print this address in hex”. The(void *)in front of each argument is a castAn instruction to treat a value as a different type.(void *)means “treat this as a plain address, never mind what it points at”, which is what%pexpects..(void *)mainwith no parentheses aftermaindoesn’t call it. It’s the function’s address: where its machine code was loaded.
Compile and run it three times:
cc -O0 where.c -o where
./where; ./where; ./wherecc is the C compiler, -o where names the output program, and -O0 is a compiler flagAn option that changes how the compiler translates your code. -O0 means “no optimization”: translate every line literally. -O2 and -O3 let it rearrange, merge, and delete work to run faster. asking for no optimization. With optimization on, the compiler is allowed to keep x only in a CPU register, and a variable in a register has no memory address to print.
On a Mac (Apple Silicon), you should see something shaped like this (your exact numbers will differ, and change every run):
code (main) 0x100a50560
global 0x100a58000
heap small 0x101175180
heap 1MB 0x7c8b000000
local main 0x16f3ae3e8
depth 1 local 0x16f3ae3a8
depth 2 local 0x16f3ae378
depth 3 local 0x16f3ae348If you want to read more C than this, C for Python people walks through it slowly.
Before you run it: which local will have the lower address, depth 1 or depth 3? By about how much? And will the numbers be the same on the second run?
Reveal
depth 3 is lower, because each call’s frame is placed below its caller’s: the stack grows down. The gap is one frame, typically tens of bytes (48 bytes, 0x30, in the run above). The numbers change every run because of ASLR, but the gaps between them stay the same, since the layout inside each region is fixed by the compiler.
Save this as boom.c. It’s a function that calls itself forever, and each call carries a 1 KB local, so every frame is a bit over 1 KB. It prints how deep it is every 1,000 calls.
#include <stdio.h>
int f(int n) {
char pad[1024]; // 1 KB array, a local: it lives in this call's frame
pad[n % 1024] = (char)n; // write into it, so the compiler can't delete it
if (n % 1000 == 0) {
printf("depth %d\n", n);
fflush(stdout); // push the text out now, before the crash eats it
}
return f(n + 1) + pad[0]; // recurse forever; using pad after the call keeps
} // every frame alive (no loop shortcut)
int main(void) {
return f(1);
}What the lines do:
char pad[1024];is an array (C)A fixed number of values of one type, packed side by side in memory.char pad[1024]is 1,024 one-byte slots. Declared inside a function, the whole thing sits in that call’s stack frame. Closest Python relative: a fixed-sizebytearray, minus the object header. of 1,024 one-bytechars. Because it’s declared insidef, all 1,024 bytes go inf’s frame, on the stack. That’s the point: it makes each frame big, so the stack fills fast.pad[n % 1024] = (char)n;writes one byte into it. Without a write, the compiler could notice the array is never used and quietly drop it.printfbuffers its output, holding text in memory until it has a chunk worth writing.fflush(stdout)forces it out immediately. Without it, the last lines would still be sitting in the buffer when the program dies, and you’d never see them.return f(n + 1) + pad[0];is the recursion. Addingpad[0]after the inner call returns means this frame still has work to do, so it has to stay on the stack. If the call were the very last thing, a compiler could reuse the frame and turn the recursion into a plain loop, and nothing would ever overflow.
Before running it, write down how deep you think it gets, using ulimit -s (the stack size in KB) and “a bit over 1 KB per frame”.
Then:
cc -O0 boom.c -o boom
./boom; echo "exit code $?"
ulimit -s # note it
ulimit -s 16384 # double it, this shell only (macOS caps this at 65520)
./boom; echo "exit code $?"On this Mac it printed depth 7000 and died with exit code 139; with the doubled stack it reached depth 15000. Read the error. Look up why the exit code is 139 (the segmentation fault note in The Idea has the answer).
In Python, write a recursive depth(n) that returns 0 if n == 0 else 1 + depth(n - 1).
- Call
depth(5000)with the default limit. Read theRecursionErrorand the traceback length. sys.setrecursionlimit(10**6)and calldepth(500_000). On 3.11+, this should succeed: pure Python frames live on the heap. Check peak memory withresource.getrusage(resource.RUSAGE_SELF).ru_maxrss(bytes on macOS, kilobytes on Linux).- Now make the C stack recurse instead. Keep the high limit, build a deeply nested list (
x = [], then a million timesx = [x]), and callrepr(x).
If you have an older Python around (uv python install 3.10 works), run step 2 and 3 there too.
Write weigh.py that answers each question with a measured number. Use sys.getsizeof for single objects and tracemalloc for totals:
import sys, tracemalloc, array
import numpy as np
def traced(make):
"""Return bytes allocated by make(), keeping the result alive."""
tracemalloc.start()
obj = make()
cur, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
return cur, objsys.getsizeofof0,1,2**30,10**20,1.0,"","a",[],(),{}. Explain the jump at2**30.- Total bytes for one million distinct ints as a
list, asarray.array("q"), and asnp.arange(..., dtype=np.int64). Comparesys.getsizeof(the_list)to the traced number and explain the difference. [0] * 1_000_000vslist(range(1_000_000)). Why different?a = 256; b = 256; a is band the same forint("1000")twice. Why?dictmapping a million ints to floats. Bytes per entry?
On the Spark (any machine with PyTorch works for the CPU half), check each claim in the tensor section:
t = torch.zeros(1000, 1000): comparet.element_size() * t.nelement()witht.untyped_storage().nbytes().v = t[10:]: confirmv.untyped_storage().data_ptr() == t.untyped_storage().data_ptr(), and thatv.data_ptr() - t.data_ptr()is10 * 1000 * 4. Thenv[0, 0] = 7and look att[10, 0].- On CUDA: allocate a 1 GB tensor, then
delit. Printtorch.cuda.memory_allocated()andtorch.cuda.memory_reserved()before and after, and watchnvidia-smiin another terminal. Then calltorch.cuda.empty_cache().
If any method name has changed in your PyTorch version, the docs for torch.Tensor and torch.cuda memory management will have the current one.
Here is a small fake reranking service with two leaks in it. The code is given (the bugs are the point); the hunting is yours.
# leaky.py
import gc, tracemalloc
_score_cache = {}
class Request:
def __init__(self, query, docs):
self.query = query
self.docs = docs
self.on_done = lambda: self.finish() # refers back to self
self.buf = bytearray(4096) # some per-request scratch space
def finish(self):
pass
def score(query, doc):
key = (query, doc)
if key not in _score_cache:
_score_cache[key] = [float(len(doc))] * 64 # "expensive" score features
return _score_cache[key][0]
def handle(i):
req = Request(f"q{i}", [f"doc{i}-{j}" for j in range(20)])
return sorted(req.docs, key=lambda d: score(req.query, d))
if __name__ == "__main__":
gc.disable() # "for speed"
for i in range(20_000):
handle(i)Your job, without reading the code for the answer first:
- Add
tracemalloc.start(10), take a snapshot after 1,000 requests and another after 20,000, and print the top 5 lines ofsnap2.compare_to(snap1, "lineno"). - For each top line, explain who still holds a reference to that memory and why the count never reaches zero.
- Fix each leak minimally (one is a cache with no bound, one is a cycle that only the collector could free). Re-run and show the growth is gone.
- You’ve seen the stack grow downward in your own output, on at least one machine, and seen ASLR move the base.
- You’ve crashed C with a stack overflow, read exit code 139, and changed how deep it got with
ulimit -s. - You’ve seen both a clean
RecursionErrorand a hard crash or “stack overflow” error from C-level recursion in Python. - Your
weigh.pyprints list vs array vs numpy totals, and you can explain each number to within a few bytes per element. - Your leak hunt shows two leaks found by
tracemalloc, both fixed, with before/after numbers.
Measure
Fill this in from your own runs:
| Measurement | Mac | Spark | Expected |
|---|---|---|---|
ulimit -s | 8176 (macOS) / 8192 (Linux) | ||
Gap between depth locals | tens of bytes | ||
Max depth, boom.c, default stack | ~7,000-8,000 | ||
Max depth, boom.c, doubled stack | about 2x | ||
depth(500_000) peak RSS | ~100 MB on 3.14 | ||
1M ints, list (traced) | ~40 MB | ||
1M ints, np.int64 | 8.0 MB | ||
1M int→float dict (traced) | ~100 MB | ||
| Leak growth per 1,000 requests, before fix | your number |
Why those numbers:
boom.cdepth. Each frame is a bit over 1 KB (the array plus return address, saved registers, alignment). 8 MB ÷ ~1.1 KB ≈ 7,000-ish. On my Mac it printed 7,000 and died before 8,000. Double the stack, double the depth: the stack is a fixed-size region, nothing more.- Python recursion. At 500,000 deep, each Python frame plus its int objects costs on the order of 100-200 bytes of heap. ~100 MB of peak RSS on CPython 3.14 for half a million frames. That memory is the “stack”, it just isn’t the stack.
- List vs numpy. 8 B pointer + 32 B object vs 8 B raw. Five times. For floats it’s 8 + 24 = 32 vs 8, four times.
- Dict. About 100 bytes per int→float pair: the hash table (hash, key pointer, value pointer, an index, and spare room so the table never gets too full) is roughly 40 bytes, then a 32-byte int and a 24-byte float (rounded to 32).
If any number is off by more than ~20%, figure out why before moving on. The likely suspects: a different Python version, small-int caching, or a list built with append (over-allocated).
Break It
The first two are C again, given whole: compile with cc -O0 file.c -o file and run.
Return a pointer to a local. make() hands back the address of its own local x, then returns, so its frame is popped. *p means “the value at the address in p” (the reverse of &).
#include <stdio.h>
int *make(void) {
int x = 1234; // x lives in make()'s frame
return &x; // BUG: hand back the address of a local
}
int other(void) {
int a = 1, b = 2, c = 3; // a few locals that land on the same stack bytes
return a + b + c;
}
int main(void) {
int *p = make(); // make() has returned: its frame is gone
printf("first: %d\n", *p);
other(); // a new call reuses those bytes
printf("second: %d\n", *p);
return 0;
}
The compiler warns you (address of stack memory associated with local variable 'x' returned). The first print may even work; the second shows garbage (on my Mac, 1234 then 1). The frame was popped, and the next call reused the same bytes. This is the clearest possible demonstration that stack memory has no life beyond its call, and why the heap exists.
Double free. Hand the same heap block back twice:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *p = malloc(32); // ask the heap for 32 bytes
p[0] = 'x';
free(p); // give them back
free(p); // BUG: give them back again
printf("still alive?\n");
return 0;
}
The allocator notices its own map is corrupt and kills the process: on Linux glibc prints free(): double free detected in tcache 2 and aborts; on my Mac it died with Trace/BPT trap: 5 and exit code 133, and never printed “still alive?”. Then compile with cc -O0 -g -fsanitize=address and run again: AddressSanitizerA checker built into clang and gcc (turned on with -fsanitize=address, “ASan” for short). It adds bookkeeping around every memory access and stops the program at the first bad one, with a report of where the memory was allocated and freed. names the exact line of each free (here, lines 7 and 8). Reach for it any time you build a C extension.
del that frees nothing. Make a 500 MB numpy array, put it in a list too, then del the name and check RSS. Nothing changes: the list still holds a reference. Clear the list and it drops instantly. Do the same with a tensor captured in a lambda, or stored on self in a training loop, and you’ve reproduced the most common “my notebook ran out of memory” bug.
Recursion limit as a crash. On Python 3.10, sys.setrecursionlimit(10**6) and run depth(200_000). No RecursionError. Just a segfault (exit 139) with no Python traceback at all, because back then each Python call used real C stack. Raising the limit removed the guardrail without making the road wider.
Explain It Back
Answer out loud, in plain words, as if in an interview.
- What is a stack frame, what’s in it, and why is allocating one so much cheaper than calling
malloc?
Model answer
A frame is the block of memory for one function call that hasn’t returned yet: its locals, the address to return to, and saved registers. The compiler knows its size ahead of time, so creating it is one instruction that moves the stack pointer, and destroying it is moving it back. It’s also always in L1 cache because it’s the memory you just used. malloc has to search for a free chunk of the right size, update bookkeeping, sometimes take a lock, and occasionally ask the OS for more memory; and the chunk it returns may be anywhere, so touching it may be a cache miss.
- Why can’t you just put everything on the stack?
Model answer
Three reasons. Size: the stack is a fixed region, 8 MB by default, so large data won’t fit. Lifetime: a frame disappears when its function returns, so anything that must outlive the call (a loaded model, a result you return, a cache) needs to live elsewhere. Unknown size: a frame’s size is fixed at compile time, but plenty of data’s size is only known at runtime. The heap handles all three, at the cost of speed and of someone having to free it.
- In CPython, what actually happens with
a = [1000, 2000]; b = a; del a?
Model answer
Two int objects and a list object are created on the heap. The list holds two pointers to the ints. The name a points at the list (refcount 1). b = a makes a second name point at the same list (refcount 2); nothing is copied. del a removes the name a and drops the count to 1. The list and the ints stay alive because b still points at them. Only when the last reference goes does the count hit zero and CPython free it immediately, which in turn drops the ints’ counts.
- You load 50 million
(id, score)pairs into a Python dict. Roughly how much RAM, and how would you shrink it?
Model answer
About 100 bytes per pair on 64-bit CPython: roughly 40 for the hash-table slot and index, 32 for the int key object, 24-32 for the float. 50M × 100 B ≈ 5 GB, and the dict briefly needs more while it resizes (it doubles its table). The numbers themselves are only 12 bytes of information (8-byte id, 4-byte float32 score). Shrink it by storing two numpy arrays: ids as int64, sorted, and scores as float32, aligned with them. That’s 50M × 12 B = 600 MB, about 8x smaller, and lookups become np.searchsorted (binary search, still fast) or vectorized over a whole batch. If ids are dense integers 0..N, skip the ids entirely and index scores[id]: 200 MB.
- Lesson 3 preview: two threads in one process each call a recursive function. Which memory is shared and which isn’t?
Model answer
Each thread has its own stack, so each has its own frames, locals, and return addresses; one thread’s recursion can’t stomp on the other’s. Both threads share the code, the globals, and the heap. So two threads appending to the same list, or updating the same dict, are touching the same heap objects at the same time, which is the root of every thread-safety problem (and part of why CPython has the GIL).
Go Deeper
- Randal Bryant and David O’Hallaron, Computer Systems: A Programmer’s Perspective (CS:APP), §3.7 “Procedures” and §9.9 “Dynamic Memory Allocation”. The canonical treatment of stack frames at the instruction level and of how
mallocreally works, including writing your own. If you read one thing, read this. - Remzi and Andrea Arpaci-Dusseau, Operating Systems: Three Easy Pieces, chapters “The Abstraction: Address Spaces”, “Interlude: Memory API”, and “Free-Space Management”. Free online at pages.cs.wisc.edu/~remzi/OSTEP. Short, funny, and exactly at the level of this lesson; the free-space chapter is the best plain explanation of fragmentation.
- Ned Batchelder, “Facts and Myths about Python Names and Values” (PyCon 2015). nedbatchelder.com/text/names1.html. The definitive explanation of names pointing at objects. Watch the talk once and you’ll never be confused by aliasing again.
- Python docs, “Memory Management” (C API) and the
tracemallocmodule. docs.python.org/3/c-api/memory.html and docs.python.org/3/library/tracemalloc.html. The first explains pymalloc and the raw/object allocator layers; the second has worked leak-hunting recipes. - Ulrich Drepper, “What Every Programmer Should Know About Memory” (2007). Long and dense, but sections 3 and 6 explain why contiguous data (numpy) beats pointer-chasing (lists) in hardware terms. Pairs with Why is NumPy 100x faster?.
- PyTorch docs, “CUDA semantics”, memory management section. pytorch.org/docs/stable/notes/cuda.html. How the caching allocator works,
memory_allocatedvsmemory_reserved, and the knobs for fragmentation on the GPU. - Edsger W. Dijkstra, “Recursive Programming”, Numerische Mathematik 2 (1960). The paper from the Origin box. Six pages, readable, and you get to watch the stack being invented.
- Instagram Engineering, “Dismissing Python Garbage Collection at Instagram” (2017). A real production story about refcounting, the cycle collector, and copy-on-write memory; a good bridge into lesson 3.
Connections
- Builds on How slow is slow?: the stack is fast because it lives in L1; scattered heap objects are slow because they don’t.
- Unlocks Processes, threads and the GIL: one stack per thread, one shared heap per process.
- The address-space picture gets its real mechanism in Virtual memory and the OOM killer, and the list-vs-numpy gap gets its full explanation in Why is NumPy 100x faster? and Why is Python slow?.
- The bytes-per-element habit you built here is the same one How much memory does a model need? uses for weights and KV caches.