Memory Management › Code Implementation

Michael BrenndoerferJanuary 16, 202616 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 221Memory Management › Code Implementation
Reading section 4 of 10 · Code Implementation · 16 minSection 4 of 10 · 16 min

Book contents

Section 4 of 10 · 16 min read

Memory Management › Code ImplementationOne section · Sections in this chapter4 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement a memory estimator that computes these values programmatically and validates them against PyTorch's reported memory usage. The goal is a tool you can run before any training job to predict GPU requirements and identify which component is the bottleneck.

Setup and Model Architecture

We start by defining the model configuration and implementing functions to count parameters and estimate memory:

In[3]:
Code
# Model configuration: small GPT-style transformer
config = {
    "vocab_size": 50257,
    "hidden_dim": 512,
    "num_layers": 6,
    "num_heads": 8,
    "ff_dim": 2048,
    "max_seq_len": 512,
}


def count_parameters(cfg):
    """Count total trainable parameters for a GPT-style transformer."""
    V = cfg["vocab_size"]
    d = cfg["hidden_dim"]
    L = cfg["num_layers"]
    ff = cfg["ff_dim"]

    # Token embeddings + position embeddings
    embedding_params = V * d + cfg["max_seq_len"] * d

    # Per-layer: 4 attention projection matrices (Q, K, V, O) + 2 FF layers + biases + layer norms
    attn_params = 4 * d * d + 4 * d  # projections + biases
    ff_params = d * ff + ff + ff * d + d  # two linear layers with biases
    ln_params = 2 * 2 * d  # two layer norms per layer (scale + bias each)
    per_layer = attn_params + ff_params + ln_params

    # Final layer norm + output projection (often tied with embedding, but counted separately here)
    head_params = d * V + 2 * d

    total = embedding_params + L * per_layer + head_params
    return total


total_params = count_parameters(config)
Out[4]:
Console
Total parameters: 70,640,640
Total parameters: 70.6M

This gives us the parameter count for our toy model. The parameter range in the tens of millions is appropriate for single-GPU experimentation, where we can validate our estimation formulas against actual PyTorch measurements without requiring expensive hardware.

Memory Breakdown Estimator

Now we implement the full memory estimator using the formulas derived above:

In[5]:
Code
def estimate_memory(
    cfg,
    batch_size,
    seq_len,
    use_checkpointing=False,
    optimizer="adam",
    param_dtype_bytes=2,  # FP16 parameters
    grad_dtype_bytes=4,  # FP32 gradients
    optim_dtype_bytes=4,
):  # FP32 optimizer states
    """
    Estimate GPU memory requirements in bytes for transformer training.
    Returns a breakdown dict with each memory component.
    """
    P = count_parameters(cfg)
    d = cfg["hidden_dim"]
    L = cfg["num_layers"]
    h = cfg["num_heads"]

    # Static memory (independent of batch size)
    param_mem = P * param_dtype_bytes
    grad_mem = P * grad_dtype_bytes

    # Optimizer state memory
    optim_multipliers = {
        "adam": 2,  # first and second moment
        "lion": 1,  # only momentum
        "sgd": 1,  # momentum buffer
        "adafactor": 0.1,  # approximate (factored)
    }
    optim_states_per_param = optim_multipliers.get(optimizer, 2)
    optim_mem = P * optim_states_per_param * optim_dtype_bytes

    # Activation memory (scales with batch_size * seq_len)
    # Approximate formula per layer: BxTx(34d + 5*h*T) bytes (FP16, 2 bytes each)
    # Factor of 2 bytes already included in constants assuming FP16 activations
    act_per_layer = batch_size * seq_len * (34 * d + 5 * h * seq_len)

    if use_checkpointing:
        # Only one layer's activations needed at a time (plus boundary states)
        act_mem = act_per_layer  # approximately one layer
    else:
        act_mem = L * act_per_layer

    # CUDA/framework overhead (rough estimate)
    overhead_mem = 512 * 1024 * 1024  # 512 MB baseline

    total_mem = param_mem + grad_mem + optim_mem + act_mem + overhead_mem

    return {
        "parameters_bytes": param_mem,
        "gradients_bytes": grad_mem,
        "optimizer_states_bytes": optim_mem,
        "activations_bytes": act_mem,
        "overhead_bytes": overhead_mem,
        "total_bytes": total_mem,
        "total_gb": total_mem / (1024**3),
    }


# Estimate for batch_size=4, seq_len=512, no checkpointing
estimate_no_ckpt = estimate_memory(
    config, batch_size=4, seq_len=512, use_checkpointing=False, optimizer="adam"
)

estimate_with_ckpt = estimate_memory(
    config, batch_size=4, seq_len=512, use_checkpointing=True, optimizer="adam"
)
Out[6]:
Console
Component                             No Checkpointing   With Checkpointing
---------------------------------------------------------------------------
Parameters (FP16)                            134.74 MB            134.74 MB
Gradients (FP32)                             269.47 MB            269.47 MB
Optimizer states (Adam FP32)                 538.95 MB            538.95 MB
Activations                                  444.00 MB             74.00 MB
Framework overhead                           512.00 MB            512.00 MB
TOTAL                                          1.85 GB              1.49 GB

Gradient checkpointing saves 83% of activation memory.
Total memory reduction: 1.85 GB → 1.49 GB

The table shows how the optimizer state is the dominant consumer for this model configuration, a pattern that holds across all transformer scales. Gradient checkpointing has no effect on the static components (parameters, gradients, optimizer states), but it dramatically reduces the activation term. Activations become comparably large relative to static memory at longer sequence lengths, where the T2T^2 attention term dominates.

Validating Against PyTorch

Let us build an actual small model and verify that our estimates align with what PyTorch reports:

In[7]:
Code
import torch
import torch.nn as nn


class SimpleTransformerLayer(nn.Module):
    def __init__(self, d_model, nhead, dim_ff):
        super().__init__()
        self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
        self.ff = nn.Sequential(
            nn.Linear(d_model, dim_ff), nn.GELU(), nn.Linear(dim_ff, d_model)
        )
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

    def forward(self, x):
        attn_out, _ = self.self_attn(x, x, x)
        x = self.norm1(x + attn_out)
        x = self.norm2(x + self.ff(x))
        return x


class SimpleTransformer(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        d = cfg["hidden_dim"]
        self.embed = nn.Embedding(cfg["vocab_size"], d)
        self.pos_embed = nn.Embedding(cfg["max_seq_len"], d)
        self.layers = nn.ModuleList(
            [
                SimpleTransformerLayer(d, cfg["num_heads"], cfg["ff_dim"])
                for _ in range(cfg["num_layers"])
            ]
        )
        self.ln_f = nn.LayerNorm(d)
        self.head = nn.Linear(d, cfg["vocab_size"], bias=False)

    def forward(self, input_ids):
        B, T = input_ids.shape
        positions = torch.arange(T, device=input_ids.device).unsqueeze(0)
        x = self.embed(input_ids) + self.pos_embed(positions)
        for layer in self.layers:
            x = layer(x)
        x = self.ln_f(x)
        return self.head(x)


# Count actual parameters
model = SimpleTransformer(config)
actual_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
Out[8]:
Console
Estimated parameters: 70,640,640 (70.6M)
Actual parameters:    70,640,640 (70.6M)
Estimation error: 0.0%

Our analytical estimate closely matches the ground truth parameter count from PyTorch. The small discrepancy comes from minor architectural differences between our counting formula and the exact layer implementations, particularly in how nn.MultiheadAttention organizes its projection weight tensors.

Measuring GPU Memory Usage

We can instrument actual GPU memory consumption if a CUDA device is available. Even on CPU, we can measure the parameter storage footprint:

In[9]:
Code
def get_model_memory_footprint(model):
    """Calculate actual memory footprint of model parameters in bytes."""
    total_bytes = 0
    param_info = {}

    for name, param in model.named_parameters():
        param_bytes = param.numel() * param.element_size()
        total_bytes += param_bytes
        # Group by component type
        component = name.split(".")[0]
        param_info[component] = param_info.get(component, 0) + param_bytes

    return total_bytes, param_info


fp32_model = SimpleTransformer(config)
fp16_model = SimpleTransformer(config).half()

fp32_bytes, fp32_components = get_model_memory_footprint(fp32_model)
fp16_bytes, fp16_components = get_model_memory_footprint(fp16_model)
Out[10]:
Console
Model parameter memory by precision:
  FP32 (float32): 269.5 MB
  FP16 (float16): 134.7 MB
  Ratio: 2.0x

FP32 memory breakdown by component:
  embed          : 98.16 MB (36.4%)
  head           : 98.16 MB (36.4%)
  layers         : 72.15 MB (26.8%)
  pos_embed      : 1.00 MB (0.4%)
  ln_f           : 0.00 MB (0.0%)

Estimated FP16 parameter memory: 134.7 MB
Actual FP16 parameter memory:    134.7 MB

The match between estimated and actual parameter memory confirms our formula is accurate. In a real training run, you would observe three to seven times this parameter footprint once gradients and Adam states are added.

Scaling Analysis

Let us compute memory requirements across different model sizes to build intuition for how memory scales:

In[11]:
Code
# Standard model configurations
model_configs = {
    "GPT-2 Small (117M)": {
        "vocab_size": 50257,
        "hidden_dim": 768,
        "num_layers": 12,
        "num_heads": 12,
        "ff_dim": 3072,
        "max_seq_len": 1024,
        "params": 117e6,
    },
    "GPT-2 Large (774M)": {
        "vocab_size": 50257,
        "hidden_dim": 1280,
        "num_layers": 36,
        "num_heads": 20,
        "ff_dim": 5120,
        "max_seq_len": 1024,
        "params": 774e6,
    },
    "LLaMA-7B": {
        "vocab_size": 32000,
        "hidden_dim": 4096,
        "num_layers": 32,
        "num_heads": 32,
        "ff_dim": 11008,
        "max_seq_len": 2048,
        "params": 7e9,
    },
    "LLaMA-13B": {
        "vocab_size": 32000,
        "hidden_dim": 5120,
        "num_layers": 40,
        "num_heads": 40,
        "ff_dim": 13824,
        "max_seq_len": 2048,
        "params": 13e9,
    },
    "LLaMA-70B": {
        "vocab_size": 32000,
        "hidden_dim": 8192,
        "num_layers": 80,
        "num_heads": 64,
        "ff_dim": 28672,
        "max_seq_len": 2048,
        "params": 70e9,
    },
}

scaling_results = {}
for model_name, cfg in model_configs.items():
    P = cfg["params"]
    # Use the 14x rule for static memory (FP16 params + FP32 grads + FP32 Adam)
    static_gb = 14 * P / 1024**3

    # Activation memory for batch_size=4, seq_len=2048 (approximate)
    d = cfg["hidden_dim"]
    L = cfg["num_layers"]
    h = cfg["num_heads"]
    T = 2048
    B = 4
    act_bytes = L * B * T * (34 * d + 5 * h * T)
    act_gb = act_bytes / 1024**3

    act_ckpt_gb = B * T * (34 * d + 5 * h * T) / 1024**3  # one layer only

    scaling_results[model_name] = {
        "params_b": P / 1e9,
        "static_gb": static_gb,
        "act_gb": act_gb,
        "act_ckpt_gb": act_ckpt_gb,
        "total_gb": static_gb + act_gb,
        "total_ckpt_gb": static_gb + act_ckpt_gb,
    }
Out[12]:
Console
Model                   Params    Static      Act   Act+Ckpt     Total   Total+Ckpt
-----------------------------------------------------------------------------------
GPT-2 Small (117M)          0B      1.5G    13.6G       1.1G     15.2G         2.7G
GPT-2 Large (774M)          1B     10.1G    68.2G       1.9G     78.3G        12.0G
LLaMA-7B                    7B     91.3G   114.0G       3.6G    205.3G        94.8G
LLaMA-13B                  13B    169.5G   178.1G       4.5G    347.6G       174.0G
LLaMA-70B                  70B    912.7G   570.0G       7.1G   1482.7G       919.8G

Notes: batch_size=4, seq_len=2048, mixed precision + Adam
Static = FP16 params + FP32 gradients + FP32 Adam states (14P rule)
Act = activation memory without gradient checkpointing
Act+Ckpt = activation memory with full gradient checkpointing

This table reveals why distributed training is not optional for models above a few billion parameters. A 7B model requires over 100 GB of static memory alone, far exceeding any single GPU's capacity. The 70B model requires over a terabyte of static memory, requiring many GPUs even before considering activations. Gradient checkpointing helps with activations but does nothing for the static component, which is why memory-efficient optimizers and sharding strategies are both essential tools at large scale.

Visualizations

Out[13]:
Visualization
Horizontal bar chart showing four static-memory components: two Adam moment buffers at 26 GiB each, FP32 gradients at 26 GiB, and FP16 parameters at 13 GiB.
GPU memory breakdown for a 7B parameter model trained with mixed precision and Adam optimizer. Optimizer states (first and second moments combined) consume the largest share at about 57%, followed by FP32 gradients at about 29% and FP16 parameters at about 14%. Activations (not shown) add variable overhead depending on batch size and sequence length.
Out[14]:
Visualization
Line plot showing GPU memory in GB versus model parameter count for training with and without gradient checkpointing.
Total GPU memory requirements for transformer models ranging from 117M to 70B parameters, shown with and without gradient checkpointing. Static memory (parameters plus gradients plus optimizer states) grows linearly with model size, while activation memory grows with both model size and sequence length. Gradient checkpointing reduces activation memory to approximately one layer's worth, yielding substantial savings for large sequence lengths.
Out[15]:
Visualization
Line plot comparing activation memory growth with and without gradient checkpointing across sequence lengths from 512 to 8192.
Activation memory as a function of sequence length for a 7B-scale transformer (32 layers, hidden_dim=4096, 32 heads) at batch_size=4. Without gradient checkpointing, activation memory grows quadratically due to the attention matrix and quickly dominates at long sequences. With gradient checkpointing, activation memory is reduced to approximately one layer, enabling much longer context windows within the same GPU budget.
Out[16]:
Visualization
Bar chart comparing optimizer-state bytes per parameter for Adam or AdamW, Lion, SGD with momentum, 8-bit Adam, and AdaFactor, with a correctly converted secondary GiB axis for a 7B model.
Approximate optimizer-state memory requirements for five common optimizers used in large language model training. Adam and AdamW require 8 bytes per parameter for two FP32 moments, making them the most memory-intensive options shown. Lion, SGD with momentum, 8-bit Adam, and AdaFactor offer lower state-memory costs with different tradeoffs in training stability and convergence. The secondary axis converts each per-parameter cost to GiB for a 7B-parameter model.
Out[17]:
Visualization
Horizontal stacked bar chart comparing total GPU memory across six training configurations from full FP32 to optimized mixed precision with checkpointing.
Total estimated GPU memory for a 7B parameter model across six training configurations, illustrating the cumulative effect of memory reduction techniques. Full FP32 training with Adam is the baseline at about 220 GiB in this estimate. Switching to mixed precision cuts parameter and gradient memory substantially. Adding gradient checkpointing reduces the large activation term. Switching to memory-efficient optimizers (8-bit Adam, Lion, AdaFactor) progressively reduces optimizer state costs. Reference lines show A100 80GB and 40GB GPU capacities.

The stacked comparison shows why practitioners typically combine multiple techniques rather than relying on any single one. Mixed precision alone gets you to the 14P static baseline. Adding gradient checkpointing addresses the activation term for long sequences. Memory-efficient optimizers then chip away at the dominant static component. The full combination can make a 7B model trainable on hardware that would otherwise require tensor parallelism.