mem_profile — CUDA memory profiling + peak attribution for the CP workflow
Finds the largest problem size a CP workflow can afford and which modules/lines
hold the memory at each peak. Two pieces, both model-agnostic:
- a torchrun driver that records the allocator history around the real forward
and dumps a per-rank snapshot pickle —
scripts/mem_profile.py;
- a pure-stdlib analyzer that turns a snapshot into a ranked, clickable markdown
report —
scripts/mem_profile_analysis.py.
The skeletons are complete except for two model hooks (build_model /
run_forward) in the driver. The four load-bearing steps below are where people
get stuck; follow them in order.
Preconditions
- A CP (or single-GPU) workflow that runs end-to-end, verified by
/fold-cp:test;
docs/cp_infra.md for topology + world_size.
nvidia-smi available; for CP, a multi-GPU node (square grid). Before claiming GPUs, probe
occupancy (nvidia-smi --query-compute-apps=pid,used_memory --format=csv) and HOLD if the
target devices are busy — never oversubscribe (one job per slot); the manage_gpu plugin
automates this claim/hygiene if installed.
- Know the real inference entry point (e.g.
model.infer_protein(seq)) and how to
build/load + CP-wrap the model — the same wiring the nsys driver uses.
Step 1 — Record allocation history around the forward (the profiler mechanism)
PyTorch's CUDA caching allocator can log every block alloc/free with the Python+C++
stack that requested it. This is exactly what Boltz2's CUDAMemoryProfile
(pl.Callback) does; reproduce it without Lightning around one forward:
torch.cuda.memory._record_memory_history(max_entries=400_000) # start (ring buffer)
run_forward(model, args) # the REAL workflow
torch.cuda.synchronize(device)
torch.cuda.memory._dump_snapshot(path) # write the .pickle
torch.cuda.memory._record_memory_history(enabled=None) # stop + free buffer
What matters (these bite):
- Warm up first, then
reset_peak_memory_stats, then start recording. The
first call allocates cuBLAS/cuDNN workspaces and grows the allocator pool;
without a warmup the snapshot is full of one-off allocations and the peak is
wrong. One warmup forward under torch.no_grad() is enough.
max_entries is a ring buffer. A deep workflow (recycles × diffusion steps)
emits millions of events; if it's too small you silently lose the early
timeline. Start at 200_000–400_000 and bump if the analyzer's event count
looks truncated.
- Record-around-forward vs record-from-start. Recording only the forward keeps
the pickle small but the timeline peak excludes the resident baseline (params
/buffers allocated before recording) — it will read ~param-bytes below
max_memory_allocated. That gap is usually negligible (note it in the report);
if you need the timeline peak to match max_memory_allocated exactly, start
recording before model build (--record-from-start, larger pickle).
- Inference → wrap in
torch.no_grad(). Otherwise saved-for-backward tensors
inflate the peak and mislead the attribution.
- Also report the allocator's own headline numbers per rank
(
max_memory_allocated / max_memory_reserved, reduced MAX across ranks); the
reserved−allocated gap is fragmentation. The .pickle opens at
https://pytorch.org/memory_viz for an interactive timeline + the flame graph
of stacks live at the peak — the analyzer in Step 3 automates the same
attribution headlessly.
Step 2 — Launch under torchrun (one snapshot per rank)
Use one process per CP rank with torchrun (not mp.spawn) — torchrun owns the
rendezvous and gives a clean process tree:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
torchrun --standalone --nnodes=1 --nproc_per_node=<world> \
-m your.mem_profile --n-token <N> --cp0 <a> --cp1 <a>
- The driver reads
RANK/LOCAL_RANK/WORLD_SIZE/MASTER_* from torchrun, sets
the device from LOCAL_RANK, inits the process group, builds + CP-wraps the
model, and dumps …_rank{0..world-1}.pickle. Fail fast if RANK is unset
(Rule 12) — no silent single-GPU fallback.
expandable_segments:True is usually required at the ceiling — it fights the
fragmentation that makes reserved ≫ allocated and triggers cudaMalloc
retries / spurious OOMs.
- fold-cp Rule 7: seed every rank identically and broadcast control-flow counts
(recycles, sampling steps) so all ranks issue the same collectives in the same
order — divergent control flow deadlocks NCCL. Per-rank snapshots will differ
slightly; profile/attribute rank 0 and spot-check another rank.
- Find the ceiling first. Peak memory of a diffusion sampler is per-step, so a
short run (1 recycle, 2 sampling steps, small sample count) is representative for
the ceiling search. Sweep
N_token upward (or bisect) with a short-step probe
until it OOMs, then take the full-default snapshot at the largest N that fits.
Memory typically scales O(samples·N²), so the affordable N drops fast.
The driver skeleton (scripts/mem_profile.py) has the
torchrun harness, warmup, record/dump, and the per-rank MAX reduction wired up —
fill in build_model(device, args) (construct/load + .eval() + CP-wrap) and
run_forward(model, args) (one real, no_grad forward).
Step 3 — Implement the analyzer (mem_profile_analysis.py)
The snapshot is a pickle of {"device_traces": [...per device...], "segments": ...}.
Each device_traces[d] is a time-ordered list of allocator events; the ones
you need are action ∈ {"alloc", "free_requested", "free_completed"}, each with
size, addr, time_us, and frames — the captured stack innermost→outermost,
each frame {filename, line, name}. The analyzer is pure stdlib (no torch
import) so it runs anywhere the pickle lands, in four stages:
- Replay → timeline. Walk the events keeping a running
allocated total:
alloc adds size and records the live addr; the first free of a live
addr subtracts it. The running total is the allocated-bytes curve and its
max equals torch.cuda.max_memory_allocated. (Pick the busiest device trace.)
- Find distinct peaks. Take local maxima of the curve, sort by size, and keep
the top-N after deduping by level — drop a candidate within
--dedup-pct
(default 3%) of an already-selected peak so the N peaks are different
plateaus/phases, not adjacent samples of one peak. (A naive "top-N samples"
returns the same peak N times — the bug to avoid.)
- Attribute the live set at each peak. Re-walk events up to the peak index to
rebuild the set of live allocations there. For each, pick the deepest frame
under
--project-root (your code, not torch/site-packages) as the blame site;
fall back to the deepest .py frame. Resolve the enclosing Class.method from
the source with ast (cache per file) so sites are readable. Aggregate bytes +
tensor count per (file, line).
- Emit markdown. Peaks sorted by size; within each peak, contributors sorted
by bytes, with a
% of peak, tensor count, the Class.method label, and a
short call chain. Render each site as a clickable link — default
vscode://file{abs}:{line} (also cursor://…, GitHub blob/<sha>#Lline, or
file://) so the reader jumps straight to the line.
CLI contract (keep these flags — they're what makes the report usable):
snapshot positional; --top-n 6, --top-contributors 15, --dedup-pct 3.0,
--min-sep-ms 0.0, --project-root <repo root> (frames under it are "project"),
--link-style vscode|cursor|github|file|none, --repo-url/--commit (for github
links; commit defaults to git rev-parse HEAD), --out. Defaults are
project-agnostic (--project-root = cwd, --repo-url = none).
Step 4 — Analyze the results
python scripts/mem_profile_analysis.py <snapshot>_rank0.pickle \
--project-root <repo-root> --top-n 6 --link-style vscode
# writes MEM_PEAK_ANALYSIS_<snapshot>.md next to the pickle
Read the report top-down:
- Global peak first. The tagged peak is the OOM ceiling. Its top contributors —
by
% of peak — name the module/line holding the most memory. A single
Class.method dominating (e.g. a confidence head or a diffusion pair-bias at
[samples, heads, N, N]) is the bottleneck to attack.
- Compare peaks across phases. Distinct peaks usually map to workflow stages
(trunk recycle vs diffusion sampling vs confidence). If the largest peaks are all
one module, optimizing it moves the ceiling; if they're spread, the budget is
structural.
- Cross-check the headline numbers. Timeline peak should track
max_memory_allocated (minus the baseline gap if you recorded around the
forward). A large reserved − allocated means fragmentation → confirm
expandable_segments:True and consider it part of the ceiling.
- For CP specifically: if per-rank peak does not drop as you grow the grid,
something is gathering a full tensor (e.g. the trunk gathering the full pair at
CP boundaries, or a serial/replicated diffusion+confidence running at full N on
every rank). That's the cross-cutting limit to note — the fix is sharding that
stage, not more GPUs.
- Write a short
MEM_PROFILE_SUMMARY.md: the max-affordable N table (N → peak →
OK/OOM), the dominant module per peak (with the clickable links), the
reserved/fragmentation note, and the concrete next optimization.
Discipline
timeout-wrap every run; set a process-group timeout; tee logs under /tmp/$USER/.
- The big snapshot
.pickles (tens of MB each × ranks) are regenerable —
gitignore them; commit the analyzer, the driver, and the markdown report.
- Warmup +
reset_peak_memory_stats before recording, or the peak is wrong.
- The profiling hooks must not change the GPU work — sanity-check the peak matches a
prior plain run.
Output contract
scripts/mem_profile.py filled in for the model + per-rank …_rank*.pickle
(gitignored), with peak_alloc/peak_reserved reported.
MEM_PEAK_ANALYSIS_<snapshot>.md (peaks → contributors, clickable links) and a
MEM_PROFILE_SUMMARY.md (max-affordable N, the bottleneck module, the next
optimization).
- A one-paragraph readout: largest N that fits, the dominant module at the global
peak, fragmentation/headroom, and whether per-rank memory scales with the CP grid.
1---2name: mem-profile3description: Memory-profile a context-parallel (CP) inference (or training) workflow with the PyTorch CUDA caching-allocator history, then attribute the top-N memory peaks to specific modules and lines of code. Wraps the end-to-end forward in torch.cuda.memory._record_memory_history() + _dump_snapshot() (the same mechanism as Boltz2's CUDAMemoryProfile Lightning callback) under a torchrun launcher that writes one snapshot per rank, then runs a stdlib analyzer (mem_profile_analysis.py) that replays the allocation timeline, finds the distinct peaks, and emits a markdown report with clickable file:line links to the call sites holding memory at each peak — sorted by peak, then by contribution. Use once a CP workflow runs end-to-end and you need to find the largest token count that fits and which module is the memory bottleneck.4---56# mem_profile — CUDA memory profiling + peak attribution for the CP workflow78Finds the largest problem size a CP workflow can afford and **which modules/lines9hold the memory at each peak**. Two pieces, both model-agnostic:10111. a torchrun driver that records the allocator history around the real forward12 and dumps a per-rank snapshot pickle —13 [`scripts/mem_profile.py`](scripts/mem_profile.py);142. a pure-stdlib analyzer that turns a snapshot into a ranked, clickable markdown15 report — [`scripts/mem_profile_analysis.py`](scripts/mem_profile_analysis.py).1617The skeletons are complete except for two model hooks (`build_model` /18`run_forward`) in the driver. The four load-bearing steps below are where people19get stuck; follow them in order.2021## Preconditions22- A CP (or single-GPU) workflow that runs end-to-end, verified by `/fold-cp:test`;23 `docs/cp_infra.md` for topology + `world_size`.24- `nvidia-smi` available; for CP, a multi-GPU node (square grid). **Before claiming GPUs, probe25 occupancy** (`nvidia-smi --query-compute-apps=pid,used_memory --format=csv`) and **HOLD if the26 target devices are busy — never oversubscribe (one job per slot)**; the `manage_gpu` plugin27 automates this claim/hygiene if installed.28- Know the real inference entry point (e.g. `model.infer_protein(seq)`) and how to29 build/load + CP-wrap the model — the same wiring the nsys driver uses.3031## Step 1 — Record allocation history around the forward (the profiler mechanism)32PyTorch's CUDA caching allocator can log every block alloc/free with the Python+C++33stack that requested it. This is exactly what Boltz2's `CUDAMemoryProfile`34(`pl.Callback`) does; reproduce it without Lightning around one forward:35```python36torch.cuda.memory._record_memory_history(max_entries=400_000) # start (ring buffer)37run_forward(model, args) # the REAL workflow38torch.cuda.synchronize(device)39torch.cuda.memory._dump_snapshot(path) # write the .pickle40torch.cuda.memory._record_memory_history(enabled=None) # stop + free buffer41```42What matters (these bite):43- **Warm up first, then `reset_peak_memory_stats`, *then* start recording.** The44 first call allocates cuBLAS/cuDNN workspaces and grows the allocator pool;45 without a warmup the snapshot is full of one-off allocations and the peak is46 wrong. One warmup forward under `torch.no_grad()` is enough.47- **`max_entries` is a ring buffer.** A deep workflow (recycles × diffusion steps)48 emits *millions* of events; if it's too small you silently lose the early49 timeline. Start at `200_000–400_000` and bump if the analyzer's event count50 looks truncated.51- **Record-around-forward vs record-from-start.** Recording only the forward keeps52 the pickle small but the timeline peak *excludes the resident baseline* (params53 /buffers allocated before recording) — it will read ~param-bytes below54 `max_memory_allocated`. That gap is usually negligible (note it in the report);55 if you need the timeline peak to match `max_memory_allocated` exactly, start56 recording before model build (`--record-from-start`, larger pickle).57- **Inference → wrap in `torch.no_grad()`.** Otherwise saved-for-backward tensors58 inflate the peak and mislead the attribution.59- Also report the allocator's own headline numbers per rank60 (`max_memory_allocated` / `max_memory_reserved`, reduced MAX across ranks); the61 reserved−allocated gap is fragmentation. The `.pickle` opens at62 **https://pytorch.org/memory_viz** for an interactive timeline + the flame graph63 of stacks live at the peak — the analyzer in Step 3 automates the same64 attribution headlessly.6566## Step 2 — Launch under torchrun (one snapshot per rank)67Use **one process per CP rank with torchrun** (not `mp.spawn`) — torchrun owns the68rendezvous and gives a clean process tree:69```bash70PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \71torchrun --standalone --nnodes=1 --nproc_per_node=<world> \72 -m your.mem_profile --n-token <N> --cp0 <a> --cp1 <a>73```74- The driver reads `RANK`/`LOCAL_RANK`/`WORLD_SIZE`/`MASTER_*` from torchrun, sets75 the device from `LOCAL_RANK`, inits the process group, builds + CP-wraps the76 model, and dumps `…_rank{0..world-1}.pickle`. **Fail fast** if `RANK` is unset77 (Rule 12) — no silent single-GPU fallback.78- **`expandable_segments:True` is usually required at the ceiling** — it fights the79 fragmentation that makes `reserved ≫ allocated` and triggers `cudaMalloc`80 retries / spurious OOMs.81- fold-cp Rule 7: **seed every rank identically** and broadcast control-flow counts82 (recycles, sampling steps) so all ranks issue the same collectives in the same83 order — divergent control flow deadlocks NCCL. Per-rank snapshots will differ84 slightly; profile/attribute **rank 0** and spot-check another rank.85- **Find the ceiling first.** Peak memory of a diffusion sampler is per-step, so a86 short run (1 recycle, 2 sampling steps, small sample count) is representative for87 the *ceiling search*. Sweep `N_token` upward (or bisect) with a short-step probe88 until it OOMs, then take the **full-default** snapshot at the largest N that fits.89 Memory typically scales `O(samples·N²)`, so the affordable N drops fast.9091The driver skeleton ([`scripts/mem_profile.py`](scripts/mem_profile.py)) has the92torchrun harness, warmup, record/dump, and the per-rank MAX reduction wired up —93fill in `build_model(device, args)` (construct/load + `.eval()` + CP-wrap) and94`run_forward(model, args)` (one real, `no_grad` forward).9596## Step 3 — Implement the analyzer (mem_profile_analysis.py)97The snapshot is a pickle of `{"device_traces": [...per device...], "segments": ...}`.98Each `device_traces[d]` is a **time-ordered** list of allocator events; the ones99you need are `action ∈ {"alloc", "free_requested", "free_completed"}`, each with100`size`, `addr`, `time_us`, and `frames` — the captured stack innermost→outermost,101each frame `{filename, line, name}`. The analyzer is **pure stdlib (no torch102import)** so it runs anywhere the pickle lands, in four stages:1031041. **Replay → timeline.** Walk the events keeping a running `allocated` total:105 `alloc` adds `size` and records the live `addr`; the *first* free of a live106 `addr` subtracts it. The running total is the *allocated-bytes* curve and its107 max equals `torch.cuda.max_memory_allocated`. (Pick the busiest device trace.)1082. **Find distinct peaks.** Take local maxima of the curve, sort by size, and keep109 the top-N **after deduping by level** — drop a candidate within `--dedup-pct`110 (default 3%) of an already-selected peak so the N peaks are *different111 plateaus/phases*, not adjacent samples of one peak. (A naive "top-N samples"112 returns the same peak N times — the bug to avoid.)1133. **Attribute the live set at each peak.** Re-walk events up to the peak index to114 rebuild the set of *live* allocations there. For each, pick the **deepest frame115 under `--project-root`** (your code, not torch/site-packages) as the blame site;116 fall back to the deepest `.py` frame. Resolve the enclosing `Class.method` from117 the source with `ast` (cache per file) so sites are readable. Aggregate bytes +118 tensor count per `(file, line)`.1194. **Emit markdown.** Peaks sorted by size; within each peak, contributors sorted120 by bytes, with a `% of peak`, tensor count, the `Class.method` label, and a121 short call chain. Render each site as a **clickable link** — default122 `vscode://file{abs}:{line}` (also `cursor://…`, GitHub `blob/<sha>#Lline`, or123 `file://`) so the reader jumps straight to the line.124125CLI contract (keep these flags — they're what makes the report usable):126`snapshot` positional; `--top-n 6`, `--top-contributors 15`, `--dedup-pct 3.0`,127`--min-sep-ms 0.0`, `--project-root <repo root>` (frames under it are "project"),128`--link-style vscode|cursor|github|file|none`, `--repo-url`/`--commit` (for github129links; commit defaults to `git rev-parse HEAD`), `--out`. Defaults are130project-agnostic (`--project-root` = cwd, `--repo-url` = none).131132## Step 4 — Analyze the results133```bash134python scripts/mem_profile_analysis.py <snapshot>_rank0.pickle \135 --project-root <repo-root> --top-n 6 --link-style vscode136# writes MEM_PEAK_ANALYSIS_<snapshot>.md next to the pickle137```138Read the report top-down:139- **Global peak first.** The tagged peak is the OOM ceiling. Its top contributors —140 by `% of peak` — name the module/line holding the most memory. A single141 `Class.method` dominating (e.g. a confidence head or a diffusion pair-bias at142 `[samples, heads, N, N]`) is the bottleneck to attack.143- **Compare peaks across phases.** Distinct peaks usually map to workflow stages144 (trunk recycle vs diffusion sampling vs confidence). If the largest peaks are all145 one module, optimizing it moves the ceiling; if they're spread, the budget is146 structural.147- **Cross-check the headline numbers.** Timeline peak should track148 `max_memory_allocated` (minus the baseline gap if you recorded around the149 forward). A large `reserved − allocated` means fragmentation → confirm150 `expandable_segments:True` and consider it part of the ceiling.151- **For CP specifically:** if per-rank peak does *not* drop as you grow the grid,152 something is gathering a full tensor (e.g. the trunk gathering the full pair at153 CP boundaries, or a serial/replicated diffusion+confidence running at full N on154 every rank). That's the cross-cutting limit to note — the fix is sharding that155 stage, not more GPUs.156- Write a short `MEM_PROFILE_SUMMARY.md`: the max-affordable N table (N → peak →157 OK/OOM), the dominant module per peak (with the clickable links), the158 reserved/fragmentation note, and the concrete next optimization.159160## Discipline161- `timeout`-wrap every run; set a process-group timeout; tee logs under `/tmp/$USER/`.162- The big snapshot `.pickle`s (tens of MB each × ranks) are regenerable —163 **gitignore them**; commit the analyzer, the driver, and the markdown report.164- Warmup + `reset_peak_memory_stats` before recording, or the peak is wrong.165- The profiling hooks must not change the GPU work — sanity-check the peak matches a166 prior plain run.167168## Output contract169- `scripts/mem_profile.py` filled in for the model + per-rank `…_rank*.pickle`170 (gitignored), with `peak_alloc`/`peak_reserved` reported.171- `MEM_PEAK_ANALYSIS_<snapshot>.md` (peaks → contributors, clickable links) and a172 `MEM_PROFILE_SUMMARY.md` (max-affordable N, the bottleneck module, the next173 optimization).174- A one-paragraph readout: largest N that fits, the dominant module at the global175 peak, fragmentation/headroom, and whether per-rank memory scales with the CP grid.