WORKBENCH

Where do my variables live?

What are the stack and the heap, and where does a Python object actually sit in memory?

Listen · 14 min0:00 / 14:17

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

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.

FIG_001[ LIFO VS FIFO ]
1 · main()2 · sum(3)3 · sum(2)4 · sum(1) newestPUSHPOPSTACK · LAST IN, FIRST OUT1234INOUToldestnewestQUEUE · FIRST IN, FIRST OUT
The stack is the plates. The queue is the coffee line. A function call stack only ever touches its top plate.

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:

FIG_002[ ADDRESS SPACE ]
HIGH ADDRESSESKERNEL · OFF LIMITSSTACKframes, one per callgrows downMEMORY-MAPPEDlibs, big mallocs, filesgrows upHEAPmalloc, Python objectsGLOBALSCODE0x0 · unmapped (NULL)LOW ADDRESSESlocals, return addressesfree (not backed by RAM)libc, libpython, numpy .sofreememory you ask for at runtimeint g = 42;machine code for main()0xffff8000… (kernel)0x7ffd8a3c1e40 local0x7f3a9c000000 libc0x55d4c5a132a0 malloc0x55d4c3a04010 global0x55d4c3a01149 main
One process's memory. The stack starts high and grows down; the heap starts low and grows up; shared libraries and big allocations land in the mapped region between. Example addresses are a typical Linux x86-64 run.

Bottom to top:

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 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:

Predict firstwrite it down

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.

FIG_003[ CALL STACK, STEP BY STEP ]
Code
High addresses
Low addresses · stack grows down ↓

step 0frames 1SP -stack used -
Each call pushes a frame (shown below the previous one, because the stack grows toward lower addresses). Each return pops one. Watch the stack pointer: that one number is the entire bookkeeping. Frame size of 32 bytes is illustrative.

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:

  1. Data whose size you only learn at runtime. How many rows are in this file? How long is this user’s query?
  2. 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.

FIG_004[ FRAGMENTATION ]
HEAP (16 BLOCKS)hole 2hole 2hole 1hole 2malloc(4 blocks)no hole fits · 7 free, largest is 2usedfree
Seven blocks are free, but the largest hole is two blocks. A request for four contiguous blocks can't be served from here, so the allocator has to grow the heap.

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.

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.

FIG_005[ NAMES AND OBJECTS ]
NAMES (IN A FRAME)OBJECTS (ON THE HEAP)xyint object · 28 Brefcount 2 · type → intsize 1 · digit 1000x = 1000; y = xLIST OF INTlist objptrptrptrptr8 B eachint 7int 3int 9int 18 B pointer + ~32 B object, scatteredNUMPY INT64ndarray obj · ~112 Bshape, dtype, strides, data*73918 B each, side by sideraw numbers, one contiguous buffer

Top: two names, one object. Middle: a list is an array of pointers to objects scattered across the heap; a numpy array is one contiguous run of raw numbers. Bottom: what that costs at scale.

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:

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.

FIG_006[ TENSOR ANATOMY ]
tvTENSOR (PY OBJ)small, fixed sizeTENSOR (PY OBJ)v = t[10:]shape (1000, 1000)offset 0shape (990, 1000)offset 10000STORAGE4,000,000 B · 2 usersDATA BUFFERCPU heap or GPU memoryfloat32, 1000 x 1000= 4 MB of numbers, once
The Python Tensor object is small. The numbers live in a Storage, on the CPU heap or in GPU memory. A view like t[10:] is a new small object pointing into the same storage.

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.

Map your own address space in CAgent: fine

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.
  • &x means “the address of x”: 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 *small declares one: “small holds the address of some bytes”. Every Python name is secretly one of these, pointing at a heap object., and char *small declares 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.
  • printf is C’s print with a template: "%p" in the format stringThe template passed to printf. Each % code is a slot filled by the next argument: %d prints a whole number, %p prints 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 %p expects..
  • (void *)main with no parentheses after main doesn’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; ./where

cc 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   0x16f3ae348

If you want to read more C than this, C for Python people walks through it slowly.

Predict firstwrite it down

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.

Blow the C stack on purposeAgent: fine

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-size bytearray, minus the object header. of 1,024 one-byte chars. Because it’s declared inside f, all 1,024 bytes go in f’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.
  • printf buffers 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. Adding pad[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).

Find where Python's recursion really breaksHands only

In Python, write a recursive depth(n) that returns 0 if n == 0 else 1 + depth(n - 1).

  1. Call depth(5000) with the default limit. Read the RecursionError and the traceback length.
  2. sys.setrecursionlimit(10**6) and call depth(500_000). On 3.11+, this should succeed: pure Python frames live on the heap. Check peak memory with resource.getrusage(resource.RUSAGE_SELF).ru_maxrss (bytes on macOS, kilobytes on Linux).
  3. Now make the C stack recurse instead. Keep the high limit, build a deeply nested list (x = [], then a million times x = [x]), and call repr(x).

If you have an older Python around (uv python install 3.10 works), run step 2 and 3 there too.

Weigh Python objectsHands only

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, obj
  1. sys.getsizeof of 0, 1, 2**30, 10**20, 1.0, "", "a", [], (), {}. Explain the jump at 2**30.
  2. Total bytes for one million distinct ints as a list, as array.array("q"), and as np.arange(..., dtype=np.int64). Compare sys.getsizeof(the_list) to the traced number and explain the difference.
  3. [0] * 1_000_000 vs list(range(1_000_000)). Why different?
  4. a = 256; b = 256; a is b and the same for int("1000") twice. Why?
  5. dict mapping a million ints to floats. Bytes per entry?
Look inside a tensor on the SparkAgent: explain only

On the Spark (any machine with PyTorch works for the CPU half), check each claim in the tensor section:

  • t = torch.zeros(1000, 1000): compare t.element_size() * t.nelement() with t.untyped_storage().nbytes().
  • v = t[10:]: confirm v.untyped_storage().data_ptr() == t.untyped_storage().data_ptr(), and that v.data_ptr() - t.data_ptr() is 10 * 1000 * 4. Then v[0, 0] = 7 and look at t[10, 0].
  • On CUDA: allocate a 1 GB tensor, then del it. Print torch.cuda.memory_allocated() and torch.cuda.memory_reserved() before and after, and watch nvidia-smi in another terminal. Then call torch.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.

Hunt a leak with tracemallocHands only

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:

  1. Add tracemalloc.start(10), take a snapshot after 1,000 requests and another after 20,000, and print the top 5 lines of snap2.compare_to(snap1, "lineno").
  2. For each top line, explain who still holds a reference to that memory and why the count never reaches zero.
  3. 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.
Done when
  • 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 RecursionError and a hard crash or “stack overflow” error from C-level recursion in Python.
  • Your weigh.py prints 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:

MeasurementMacSparkExpected
ulimit -s8176 (macOS) / 8192 (Linux)
Gap between depth localstens of bytes
Max depth, boom.c, default stack~7,000-8,000
Max depth, boom.c, doubled stackabout 2x
depth(500_000) peak RSS~100 MB on 3.14
1M ints, list (traced)~40 MB
1M ints, np.int648.0 MB
1M int→float dict (traced)~100 MB
Leak growth per 1,000 requests, before fixyour number

Why those numbers:

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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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

Connections