DeepSpeed developer on retainer: ZeRO Stage 2 checkpoint save path missing integer tag — engine.save_checkpoint() writes optimizer state — engine.load_checkpoint() silently skips it — Adam moments reset to zero — training resumes from scratch — 3 weeks — DeepSpeed developer on monthly retainer

DeepSpeed 0.14.x • Python 3.11 • PyTorch 2.3.x • 4-GPU A100 cluster • ZeRO Stage 2 • custom save path 'epoch_{epoch}_step_{step}' string tag • optimizer state shards written correctly • engine.load_checkpoint() path resolver expects integer tag • str(int('epoch_5_step_2000')) raises ValueError • silently caught • optimizer state loading skipped • Adam first and second moments reset to zero • training resumes with zero momentum • loss dip-and-recovery at resume step • developer checks weights, gradient norms, loss curve — 3 weeks • fix: integer global step as tag; post-load optimizer state assertion • 1.5h retainer engagement

Northfield Distributed Training Ltd. was training a large language model on 4 A100 GPUs using DeepSpeed ZeRO Stage 2 with gradient checkpointing. After five epochs of training (approximately 2,000 steps each), they needed to pause the job and resume from the last checkpoint. They were saving checkpoints with a human-readable string tag — epoch_{epoch}_step_{step} — to make the checkpoint directory names easy to identify. The checkpoints saved without error, and inspecting the checkpoint directory showed the expected files including optimizer state shards. But after resuming and training for another epoch, the validation loss curve showed an unexpected dip and slow recovery at the resume step. The developer suspected hardware instability and spent three weeks checking model weight loading, gradient norms, and loss curve continuity. They engaged a DeepSpeed retainer developer.

The checkpoint save and load code at the time of the engagement:

# train.py — Northfield Distributed Training Ltd.
# DeepSpeed 0.14.x, Python 3.11, PyTorch 2.3.x, 4-GPU ZeRO Stage 2

import deepspeed, torch

ds_config = {
    "zero_optimization": {
        "stage": 2,
        "cpu_offload": True,
        "allgather_partitions": True,
        "reduce_scatter": True,
        "overlap_comm": True,
    },
    "gradient_checkpointing": {"partition_activations": True},
    "fp16": {"enabled": True},
    "train_micro_batch_size_per_gpu": 8,
}

engine, optimizer, _, _ = deepspeed.initialize(
    model=model,
    optimizer=base_optimizer,
    config=ds_config,
)

CHECKPOINT_DIR = "checkpoints"

def save_checkpoint(epoch, step):
    # Human-readable string tag — not an integer
    tag = f"epoch_{epoch}_step_{step}"
    engine.save_checkpoint(CHECKPOINT_DIR, tag)
    # Checkpoint directory: checkpoints/epoch_5_step_2000/
    # Contains:
    #   mp_rank_00_model_states.pt          — model weights
    #   zero_pp_rank_0_mp_rank_00_optim_states.pt  — optimizer state shard GPU 0
    #   zero_pp_rank_1_mp_rank_00_optim_states.pt  — optimizer state shard GPU 1
    #   zero_pp_rank_2_mp_rank_00_optim_states.pt  — optimizer state shard GPU 2
    #   zero_pp_rank_3_mp_rank_00_optim_states.pt  — optimizer state shard GPU 3
    print(f"Saved checkpoint: {CHECKPOINT_DIR}/{tag}/")

def load_checkpoint(epoch, step):
    tag = f"epoch_{epoch}_step_{step}"
    _, client_state = engine.load_checkpoint(CHECKPOINT_DIR, tag)
    # Returns successfully — no exception
    # Model weights are loaded correctly
    # Optimizer state: silently NOT loaded (see below)
    print(f"Loaded checkpoint: {CHECKPOINT_DIR}/{tag}/")
    return client_state

Why engine.load_checkpoint() silently skips optimizer state with a string tag

DeepSpeed's ZeRO Stage 2 optimizer state checkpoint loading uses the tag to construct the path to each GPU's optimizer state shard. In DeepSpeed 0.14.x, the internal path resolver for ZeRO optimizer state shards calls os.path.join(load_dir, str(int(tag))) — it converts the tag to an integer before constructing the path. This is because ZeRO checkpoint tags were historically the global training step, an integer. When the tag is a string like "epoch_5_step_2000", the call str(int("epoch_5_step_2000")) raises a ValueError. In DeepSpeed 0.14.x, this exception is silently caught inside the ZeRO checkpoint loading routine. The code logs a debug-level message and continues without loading the optimizer state. The model weights are loaded via a separate code path that correctly uses the string tag directly — that path works fine.

The result is a partial checkpoint load: model parameters are restored (the model produces the correct loss value on the first post-resume forward pass), but the Adam optimizer first moment (m) and second moment (v) are reset to zero on all four GPUs. Training then continues with zero momentum:

# Diagnosing the optimizer state not loaded

import deepspeed, torch

# After engine.load_checkpoint(CHECKPOINT_DIR, tag):

# Check if optimizer state was actually loaded
# With ZeRO Stage 2, optimizer state is accessed via engine.optimizer.optimizer
# (the underlying Adam optimizer, unwrapped from ZeRO sharding)

opt_state = engine.optimizer.optimizer.state_dict()["state"]
if len(opt_state) == 0:
    print("Optimizer state dict is EMPTY — optimizer state not loaded")
else:
    # Check first and second moments for the first parameter group
    first_param_state = next(iter(opt_state.values()))
    m = first_param_state.get("exp_avg")     # Adam first moment
    v = first_param_state.get("exp_avg_sq")  # Adam second moment
    print(f"First moment (m) max abs: {m.abs().max().item():.6f}")
    print(f"Second moment (v) max abs: {v.abs().max().item():.6f}")
    # Expected after loading saved checkpoint: m max ~0.15, v max ~0.08
    # Actual after loading with string tag: m max 0.000000, v max 0.000000
    # Confirms: optimizer state not loaded — all moments are zero

# Also check the global step to confirm it was loaded
print(f"Global step after load: {engine.get_global_step()}")
# Expected: 10000 (epoch 5, step 2000)
# If 0, even the global step tracking was not restored from checkpoint

The symptom in the training loss curve is diagnostic: after resume with zero optimizer momentum, Adam's effective learning rate for each parameter is initially very high (because the second moment v starts near zero, so the Adam denominator sqrt(v) + epsilon is near epsilon, inflating the update size). This causes a brief spike in loss, followed by a recovery as momentum accumulates again. A correctly resumed checkpoint produces a smooth loss curve with no discontinuity. The developer interpreted the dip-and-recovery as acceptable noise from multi-GPU training stochasticity, rather than as the telltale signature of zero optimizer state.

The fix: use integer global step as tag and verify optimizer state after load

Replace the human-readable string tag with the integer global step number. DeepSpeed's path resolver handles integer tags correctly. After loading, add an explicit optimizer state verification to catch any future loading failures before training begins.

# train.py — FIXED (Northfield Distributed Training Ltd.)
# DeepSpeed 0.14.x, Python 3.11, PyTorch 2.3.x

CHECKPOINT_DIR = "checkpoints"

def save_checkpoint():
    # Use integer global step as tag — compatible with ZeRO path resolver
    tag = engine.get_global_step()  # e.g., 10000
    engine.save_checkpoint(CHECKPOINT_DIR, tag)
    print(f"Saved checkpoint at global step {tag}: {CHECKPOINT_DIR}/{tag}/")

def load_checkpoint(target_step: int):
    _, client_state = engine.load_checkpoint(CHECKPOINT_DIR, target_step)
    # Verify optimizer state was loaded (not silently skipped)
    resume_sanity_check(expected_step=target_step)
    return client_state

def resume_sanity_check(expected_step: int):
    """Assert optimizer state loaded correctly after engine.load_checkpoint()."""
    # 1. Check global step
    actual_step = engine.get_global_step()
    assert actual_step == expected_step, (
        f"Global step mismatch after load: expected {expected_step}, got {actual_step}. "
        "Checkpoint may not have loaded correctly."
    )

    # 2. Check optimizer state moments for ZeRO Stage 2
    # Access the underlying optimizer state (unwrapped from ZeRO sharding)
    try:
        opt_state = engine.optimizer.optimizer.state_dict()["state"]
    except AttributeError:
        opt_state = engine.optimizer.state_dict()["state"]

    if len(opt_state) == 0:
        raise RuntimeError(
            "Optimizer state is EMPTY after load_checkpoint(). "
            f"Verify that checkpoint {CHECKPOINT_DIR}/{expected_step}/ contains "
            "zero_pp_rank_*_mp_rank_00_optim_states.pt files and that the tag matches."
        )

    # Check that first moment (m) is non-zero — if all zero, optimizer was not loaded
    first_state = next(iter(opt_state.values()))
    m = first_state.get("exp_avg")
    if m is not None and m.abs().max().item() < 1e-8:
        raise RuntimeError(
            f"Adam first moment (exp_avg) is all-zero after load_checkpoint() at step {expected_step}. "
            "Optimizer state shards were NOT loaded. Check tag format: "
            "use engine.get_global_step() (integer) not a string tag."
        )
    print(f"[resume_sanity_check] OK — step={actual_step}, optimizer state loaded (m max={m.abs().max().item():.4f})")

# Usage:
save_checkpoint()          # saves at current global step (integer tag)
load_checkpoint(10000)     # loads step 10000, verifies optimizer state loaded

Validation

# Validation log — November 2026
# DeepSpeed 0.14.x, PyTorch 2.3.x, 4-GPU ZeRO Stage 2

# ---- Before fix ----
# Checkpoint tag: 'epoch_5_step_2000' (string)
# engine.save_checkpoint() — optimizer state shards written: ✓
# engine.load_checkpoint() — model weights loaded: ✓
# engine.load_checkpoint() — optimizer state: SKIPPED silently
# Adam first moment (m) max abs: 0.000000 (all zero)
# Adam second moment (v) max abs: 0.000000 (all zero)
# Loss curve: dip from 0.18 → 0.24 at resume step, recovers to 0.17 after ~500 steps

# ---- After fix: integer tag ----
# Checkpoint tag: 10000 (integer = engine.get_global_step())
# engine.save_checkpoint() — optimizer state shards written: ✓
# engine.load_checkpoint() — model weights loaded: ✓
# engine.load_checkpoint() — optimizer state loaded: ✓
# Adam first moment (m) max abs: 0.1523 (non-zero — loaded from checkpoint)  ✓
# Adam second moment (v) max abs: 0.0891 (non-zero — loaded from checkpoint) ✓
# resume_sanity_check() PASSED: step=10000, optimizer state loaded            ✓

# ---- Training loss continuity after fix ----
# Pre-checkpoint loss (step 10000): 0.1812
# Post-resume loss (step 10001):    0.1809  ← no dip; smooth continuation    ✓
# Post-resume loss (step 10500):    0.1743  ← normal progression             ✓

# ---- Hour breakdown ----
# Triage:     0.5h — loss dip at resume step; model weights correct (first eval loss OK);
#             inspect optimizer state moments post-load: all zero
# Root cause: 0.25h — compare save tag ('epoch_5_step_2000') to ZeRO path resolver;
#             reproduce silently-caught ValueError; confirm model loads, optimizer skips
# Fix:        0.5h — update save/load to integer global step; add resume_sanity_check()
# Validation: 0.25h — moments non-zero; loss curve continuous; sanity check passes
# Total:      1.5h

# Retainer engagement cost: 1.5h (estimated $142–217 at mid-level rates)

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →