24. Profiling a Forward Pass for a Memory Bottleneck
ai-labs-pytorch.) Confirmed: 1point3acres' crowdsourced Anthropic MLE interview question database (103 Anthropic entries, 29 MLE-tagged) lists "einsum optimization," "PyTorch," and "KV-cache/batching/GPU-utilization system design" among reported topics for Anthropic's loop -- i.e. reasoning about where a given implementation spends memory or compute is a reported theme, though the specific exercise here (a forward-hook activation profiler) is written for this prep set, not a literal transcribed question.Problem
Two implementations of the "same" block can have identical outputs and wildly different activation memory footprints -- which matters a lot when deciding which one to actually ship. Rather than guessing, the standard technique is to register a forward hook on every leaf submodule, run one real forward pass, and record how many bytes each submodule's output tensor occupies; whichever submodule produced the single largest intermediate tensor is that implementation's memory bottleneck.
Implement a profiler that takes two candidate nn.Module
implementations and a shared input, runs a forward pass through each with forward
hooks attached to every leaf submodule, and reports each implementation's peak
single-layer activation footprint in bytes plus which of the two has the larger
one.
Source: src/24_profile_forward_bottleneck.py
def profile_forward_bottleneck(
impl_a: torch.nn.Module, impl_b: torch.nn.Module, x: torch.Tensor,
) -> dict: ...
>>> import torch.nn as nn
>>> wide = nn.Sequential(nn.Linear(8, 64), nn.ReLU(), nn.Linear(64, 8))
>>> narrow = nn.Sequential(nn.Linear(8, 16), nn.ReLU(), nn.Linear(16, 8))
>>> report = profile_forward_bottleneck(wide, narrow, torch.randn(4, 8))
>>> report["bottleneck"]
'a'
Step-by-Step Approach
- Write a helper that profiles one model: walk
model.named_modules()and keep only the leaves (modules with no children) -- container modules likenn.Sequentialitself would double-count their children's output. - Register a forward hook on each leaf that records
output.numel() * output.element_size()into a{name: bytes}dict, keyed by that hook's captured module name. - Run one forward pass, wrapped in
torch.no_grad()since this is pure profiling, not training. - Remove every hook in a
finallyblock, so a raised exception during the forward pass can't leave stale hooks attached for the next profiling call on the same model. - Find the entry with the largest byte count using
max(..., key=...); because dicts preserve insertion order andmaxkeeps the first maximal item under a strict "greater than" comparison, this naturally implements the "first submodule wins ties" rule. - Run the helper on both implementations, then compare the two peak byte counts to decide which one is the bottleneck.
The key insight is that activation memory is dominated by whichever single intermediate tensor is largest, not by the sum of all intermediates -- PyTorch frees most intermediates as soon as backward no longer needs them, so profiling per-layer peak size (not total) is what actually predicts which implementation will run out of memory first.
Reference solution
def _peak_activation(model: nn.Module, x: torch.Tensor) -> tuple[str, int]:
sizes: dict[str, int] = {}
def make_hook(name):
def hook(_module, _inputs, output):
# numel() * element_size() = exact byte footprint of this one
# activation tensor, independent of dtype (fp32/fp16/bf16 alike).
sizes[name] = output.numel() * output.element_size()
return hook
handles = [
module.register_forward_hook(make_hook(name))
for name, module in model.named_modules()
if len(list(module.children())) == 0 # leaf modules only
]
try:
with torch.no_grad():
model(x)
finally:
# hooks must come off even if the forward pass raises
for handle in handles:
handle.remove()
# keeps the FIRST maximal entry -- matches the tie-break rule
peak_name, peak_bytes = max(sizes.items(), key=lambda kv: kv[1])
return peak_name, peak_bytes
def profile_forward_bottleneck(
impl_a: nn.Module, impl_b: nn.Module, x: torch.Tensor,
) -> dict:
a_layer, a_bytes = _peak_activation(impl_a, x)
b_layer, b_bytes = _peak_activation(impl_b, x)
return {
"a_peak_bytes": a_bytes, "a_peak_layer": a_layer,
"b_peak_bytes": b_bytes, "b_peak_layer": b_layer,
"bottleneck": "b" if b_bytes > a_bytes else "a",
}
Key Functions & Tricks
module.register_forward_hook(fn)— attachesfn(module, inputs, output)to run every time that module'sforwardcompletes, without modifying the module's code.model.named_modules()— recursively yields every submodule with its dotted name; filtering to leaves avoids double-counting container modules.output.numel() * output.element_size()— the exact byte size of a tensor, correct for any dtype (a common bug is hardcoding* 4for float32 and silently breaking under mixed precision).handle.remove()— every hook returned byregister_forward_hookmust be explicitly removed, or it keeps firing (and accumulating) on every future forward pass through that model.try/finallyaround the forward pass — guarantees hook cleanup even when profiling a model that might raise.
How to Recognize This Pattern
Signal words: "find the memory bottleneck," "profile a forward pass," "compare
two implementations," "which layer uses the most activation memory." The tell is
comparing implementations by actual measured resource usage rather than by
reading the code and guessing -- the standard tool for that in PyTorch, short of
the full torch.profiler API, is forward/backward hooks that record
shapes or byte sizes as a real forward pass runs. Common variations: profiling
backward-pass memory with register_full_backward_hook instead of
forward hooks; using torch.cuda.max_memory_allocated() around each
implementation for a true GPU peak-memory number instead of a per-layer proxy;
or profiling wall-clock compute time per layer instead of memory, which needs
torch.cuda.synchronize() calls around each hook to get accurate GPU
timings. A common pitfall is forgetting that hooks fire on every forward call,
so re-running profiling on the same model without removing old hooks silently
corrupts the byte counts with leftover entries from a previous run.