This 50–60 minute exercise uses phase-by-phase measurements to distinguish
activation pressure, persistent optimizer state, and an accidentally retained
training graph. The cases are labelled alpha, beta, and gamma so the
diagnosis must come from evidence rather than the option name.
memory_diagnostics.py creates a synthetic transformer
and GPU-resident inputs locally. It downloads no model, weights, tokenizer, or
dataset. One GPU is sufficient. An optional DDP extension compares ranks on a
multi-GPU node.
After the exercise, participants should be able to:
- distinguish live tensor allocation from memory reserved by PyTorch's caching allocator;
- identify whether a memory increase occurs during forward propagation, on the first optimizer step, or cumulatively between training steps;
- test a diagnosis by changing one workload dimension at a time;
- choose a targeted intervention and reject an unrelated one.
The exercise is complete when the participant correctly diagnoses all three cases, cites a measured phase transition or scaling trend for each diagnosis, and proposes a matching response. Producing the JSON files alone is not the success criterion.
Required prerequisites are familiarity with a PyTorch training cycle and a
CUDA-enabled PyTorch environment with at least one GPU. The repository's
environment_gpu.yml provides the intended
software environment. DDP knowledge and a second GPU are optional.
| File | Purpose |
|---|---|
memory_diagnostics.py |
Runs an anonymous workload, records memory at model, input, forward, backward, update, and gradient-clearing boundaries, and writes JSON. |
compare_memory_reports.py |
Produces a compact comparison table from one or more JSON reports without naming the diagnosis. |
SOLUTION.md |
Complete reference procedure, expected diagnoses, reasoning, and limitations. Consult it after forming a hypothesis. |
Every phase reports four related but non-interchangeable quantities:
| Metric | Interpretation |
|---|---|
allocated_mib |
Live tensor storage known to PyTorch's CUDA allocator. Use this first when reasoning about tensor lifetimes. |
reserved_mib |
Blocks retained by the caching allocator. Released tensors can reduce allocated memory without immediately reducing this value. |
peak_allocated_mib |
Largest live PyTorch allocation since the peak was reset at the start of the current step. |
device_used_mib |
Device-wide consumption derived from free memory. It includes CUDA libraries and other processes, so an exclusive Slurm allocation is preferable. |
The report also derives three diagnosis aids:
first_step_persistent_change_mib: allocation remaining after the first step and gradient clearing, relative to the pre-training baseline;between_step_growth_mib: change in the post-cleanup baseline from the first to the final step;largest_forward_increase_mib: the largest forward allocation above the preceding post-cleanup baseline.
These indicators are evidence, not universal thresholds. Absolute numbers vary with GPU, precision, PyTorch version, kernel selection, and model shape.
Before running, sketch the expected live-allocation curve for one training step. Mark model construction, forward propagation, backward propagation, the first optimizer update, and gradient clearing.
Run all three cases with the same configuration:
python memory_diagnostics.py --case alpha --output alpha.json
python memory_diagnostics.py --case beta --output beta.json
python memory_diagnostics.py --case gamma --output gamma.json
python compare_memory_reports.py --show-phases alpha.json beta.json gamma.jsonFor each case, answer these questions before looking at the implementation:
- Is its main change transient within a step, persistent after the first step, or cumulative across steps?
- Which phase provides the strongest evidence?
- Is the gap between allocated and reserved memory itself evidence of a leak?
Do not interpret a large reserved_mib value alone as live memory pressure.
A diagnosis needs a perturbation, not just a plausible story. Change one variable at a time.
Predict which indicator will respond most strongly, then run:
python memory_diagnostics.py --case alpha --batch-size-per-device 1 \
--output alpha-batch1.json
python memory_diagnostics.py --case alpha --batch-size-per-device 4 \
--output alpha-batch4.json
python compare_memory_reports.py alpha-batch1.json alpha-batch4.jsonPredict whether transient and persistent allocations will change in the same proportion:
python memory_diagnostics.py --case beta --preset small \
--output beta-small.json
python memory_diagnostics.py --case beta --preset medium \
--batch-size-per-device 1 --output beta-medium.json
python compare_memory_reports.py beta-small.json beta-medium.jsonRun the suspected cumulative case for longer. Compare the post-cleanup baseline, not only its largest peak:
python memory_diagnostics.py --case gamma --steps 10 \
--output gamma-steps10.jsonIf medium exceeds the assigned GPU or takes too long, omit that run. An OOM is
capacity evidence, not a required outcome and not a valid performance result.
For every case, choose one primary response and explain why at least one other response is poorly targeted. Candidate responses include:
- reduce the local microbatch;
- use activation checkpointing;
- shard model state with FSDP;
- change optimizer or optimizer-state precision;
- stop retaining training graphs and store detached CPU summaries instead.
The question is not “which technique saves memory?” but “which technique acts on the measured source of pressure?”
Counters should answer the first question. Once one run is suspicious, collect a detailed allocator history:
python memory_diagnostics.py --case gamma --steps 6 \
--snapshot gamma-snapshot.pickle \
--output gamma-with-snapshot.jsonThe generated file is rank-qualified, for example
gamma-snapshot-rank0.pickle. Copy it from the compute node and open it in the
PyTorch memory visualizer. Recording histories
adds overhead and can create large files, which is why it is optional and
bounded by --snapshot-max-entries.
PyTorch allocator snapshots cannot see allocations made directly by NCCL and
some other CUDA libraries. Compare snapshot evidence with device_used_mib
rather than assuming the snapshot accounts for the entire device.
Use two GPUs first with equal local batches, then deliberately give rank zero a larger batch:
torchrun --standalone --nproc-per-node=2 memory_diagnostics.py \
--case alpha --output alpha-ddp-balanced.json
torchrun --standalone --nproc-per-node=2 memory_diagnostics.py \
--case alpha --rank-zero-extra-samples 2 \
--output alpha-ddp-imbalanced.jsonInspect every entry in rank_reports. Rank-zero-only logging would hide a
problem on another rank in a real workload. The artificial imbalance also
changes the effective weighting of samples in ordinary DDP gradient averaging;
it is a diagnostic demonstration, not a recommended training configuration.
#!/usr/bin/env bash
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --gpus-per-node=2
#SBATCH --time=00:10:00
set -euo pipefail
srun torchrun --standalone --nproc-per-node=2 memory_diagnostics.py \
--case alpha \
--output "alpha-ddp-${SLURM_JOB_ID}.json"Adapt the GPU directive, partition, account, and environment activation to the
local cluster. One Slurm task launches torchrun; torchrun creates one worker
per assigned GPU.
- Many optimizers create state lazily during their first
step(), not when the optimizer object is constructed. - A healthy steady-state training loop normally reuses memory rather than increasing live allocation on every iteration.
empty_cache()does not release live tensors. Do not add it to conceal an unexplained growth trend.detach()breaks an autograd connection but does not move or discard the underlying GPU tensor. For logging, retain a scalar or transfer the required result to CPU.
Function and class names are more durable than line numbers if the file changes.
| Area | Function or class and line | What to inspect |
|---|---|---|
| Options and validation | parse_arguments() — line 103 |
Defines the case, workload, precision, output, snapshot, and imbalance options. |
| CUDA/DDP setup | initialize_runtime() — line 184 |
Selects one GPU and initializes NCCL only when launched by torchrun. |
| Model blocks | TransformerBlock — line 236 |
Defines the attention and feed-forward unit. |
| Complete model | SyntheticTransformer — line 267 |
Creates the transformer used by every case. |
| Model/DDP creation | make_model() — line 294 |
Constructs an identical local model and optionally wraps it in DDP. |
| Input handling | make_batch() — line 322 |
Creates rank-specific synthetic token batches directly on the GPU. |
| Memory sampling | sample_memory() — line 347 |
Records allocated, reserved, peak, and device-wide memory at a phase boundary. |
| Training cycle | training_step() — line 362 |
Measures forward, backward, optimizer, and cleanup phases. |
| Evidence derivation | derive_indicators() — line 425 |
Converts raw samples into three hypothesis-testing indicators. |
| Orchestration | run() — line 467 |
Builds the workload, collects evidence, optionally dumps snapshots, and gathers ranks. |
- Synchronizing at every phase makes the measurement boundaries clear but distorts step timing; this is a memory experiment, not a throughput benchmark.
- Synthetic reused input excludes data-loader and host-to-device transfer memory. Diagnose those separately in a real application.
- Model-size changes affect both parameters and activations. The small-batch medium run reduces, but does not eliminate, that confounder.
- DDP may create persistent gradient-bucket storage during its first backward pass. Diagnose the three core cases on one GPU first; in the extension, compare otherwise identical DDP runs rather than applying single-GPU thresholds blindly.
- The anonymous cases isolate one dominant cause each. Real workloads can have several simultaneous sources of pressure.
- Allocator internals used for optional snapshots have underscored names and are more version-sensitive than the ordinary counter API.