Draft

This documentation is in draft and under active review. Figures for the Fall 2026 GPU reservation system are not yet published, and some pages describe behaviour that has not been verified against a primary source. Check with us before relying on anything here.

Checkpointing & Logging Long Runs

Between the runtime limit, idle culling, reservation windows and the occasional maintenance closure, there are several ways for a container to stop that have nothing to do with faulty code. A job that cannot resume is restarted from zero, and a job with no log of its own cannot be debugged after the fact.

Contents

What Can End a Run


Cause Warning Where it is documented
The runtime limit — 6 hours by default, 12 if set at launch None; the pod reports DeadlineExceeded The Runtime Limit
Idle culling, once the GPU stops being used Yes; a warning comes first, and the status is recorded on the pod Idle Culling
Preemption, once a booking needs the capacity Yes; minutes of notice, readable by the job itself What Ends a Session
Research Cluster maintenance, quarterly at the term break About 7 days' notice; all running jobs are terminated The Runtime Limit

None of these is a fault, and none of them is avoidable by asking. Instructional maintenance, on Tuesday mornings, generally leaves running jobs alone.

Two of the four announce themselves on the pod, where a running program can read them. The reservation controller marks a session it may need to stop, minutes before it stops it; the idle culler records a status and a projected deadline in the same way. The rest of this page is in two halves: writing a checkpoint worth resuming from, and using that notice when it arrives.

Checkpointing


A checkpoint carries enough state to resume, written on a chosen schedule. For a training run that usually means the model weights, the optimizer state, and the epoch or step counter.

Home is the place to write it. Home persists between containers, and the container and the login node share a filesystem, so a checkpoint written by a job is readable from the login node after the pod is gone. A path inside the container that is not a mounted directory dies with the pod — which is the event being checkpointed against. → Directories, Quotas & Cleaning Up

Checkpointing on wall-clock time rather than only on epochs bounds what an interruption costs: a limit of 6 hours does not know what an epoch is, and an epoch that takes 90 minutes loses 90 minutes each time. Something written every 15 or 30 minutes costs very little.

A script that looks for the newest checkpoint and continues from it survives an interruption nobody was present for; a --resume flag has to be remembered.

Checkpoints are large and home directories are quota'd, so the number kept is worth bounding — the last two and the best one is usually enough. The old one is deleted after the new one is complete, not before. Pruning eagerly is what turns one badly timed interruption into total loss. → Directories, Quotas & Cleaning Up

What a Resumable Checkpoint Contains


A checkpoint holding only model weights resumes a different run. Everything below is state the optimizer or the data pipeline carries, and omitting any of it shows up as a jump in the loss curve at the point of resume.

Component Call Notes
Model weights model.state_dict() Under torch.compile the keys gain an _orig_mod. prefix. Saving model._orig_mod.state_dict(), or stripping the prefix on load, keeps the checkpoint loadable by an uncompiled model
Optimizer optimizer.state_dict() The large one: Adam and AdamW carry two fp32 moments, so optimizer state is commonly 2-3× the model in bytes
Learning-rate scheduler scheduler.state_dict() Cheap, and the one most often forgotten. Without it a warmup or cosine schedule restarts from the beginning
Mixed-precision scaler scaler.state_dict() torch.amp.GradScaler holds an adaptive loss scale; a resume without it re-converges through a few skipped steps
Step and epoch counters Kept by the training script The anchor a resume is expressed in
Data position StatefulDataLoader.state_dict() torchdata's drop-in DataLoader replacement, which supports mid-epoch resume without replaying batches. It requires the same num_workers on load as on save
Random number generator state torch.get_rng_state(), torch.cuda.get_rng_state_all(), random.getstate(), numpy.random.get_state() Needed for a bit-comparable resume — dropout, augmentation, sampling. Skipping it is reasonable where reproducibility does not matter, and worth skipping deliberately rather than by accident
Averaged weights and metric state Kept by the training script Exponential moving averages, best-so-far metrics, early-stopping counters

The run's configuration belongs next to the weights. A checkpoint that cannot be identified six weeks later is half a checkpoint.

Writing One an Interruption Cannot Corrupt


The failure mode these interruptions create is precisely a process disappearing mid-write. Two rules cover it.

A single-file save is written to a temporary path, flushed, then renamed. os.replace is atomic within one filesystem, so the visible path is always either the complete old checkpoint or the complete new one, and never a truncated file:

import os, torch

def save_atomic(state: dict, path: str) -> None:
    tmp = f"{path}.tmp"
    with open(tmp, "wb") as fh:
        torch.save(state, fh)
        fh.flush()
        os.fsync(fh.fileno())      # the data is durable before the rename
    os.replace(tmp, path)          # atomic, within one filesystem only
    dfd = os.open(os.path.dirname(path) or ".", os.O_RDONLY)
    try:
        os.fsync(dfd)              # and the rename itself is durable
    finally:
        os.close(dfd)

Skipping this is how a run ends up with RuntimeError: unexpected EOF from the only checkpoint it had.

A sharded save is completed, then published. A distributed checkpoint is a directory of per-rank shards, and there is no atomic rename covering "all of them landed". The equivalents are to write into a scratch directory and rename the directory once every rank has finished, or to write an explicit DONE marker last and have the resume path ignore any directory without one. With torch.distributed.checkpoint, the .metadata file is written after the shards, so its absence is a reasonable signal that a directory is incomplete — but an explicit marker is the thing to depend on.

How Often to Write One


The classical answer is the Young/Daly interval: checkpoint every sqrt(2 · C · MTBF), where C is the wall-clock cost of one checkpoint and MTBF is the mean time between interruptions. It balances saving so often that the saving dominates against saving so rarely that each interruption costs hours.

Under a reservation system that announces its interruptions, the cadence can be set from C alone. A common target is checkpoint overhead below about 5% of step time, which for a synchronous save means an interval of roughly 20 × C. That covers the interruptions nothing warns about — a node failure, an out-of-memory kill, a collective that times out. The termination warning covers the announced ones, and is what turns "lose up to one interval" into "lose up to one step".

C is worth measuring once on the storage actually being written to, and logging. Every figure in this section is expressed in terms of it, and the assumed value is consistently optimistic.

Where C is large, asynchronous checkpointing is the answer before a longer interval is. torch.distributed.checkpoint offers dcp.async_save, which stages tensors into CPU buffers and writes them from a background thread while training continues, dropping the blocking part of the save to roughly the staging copy. Two costs come with it: host memory on the order of one checkpoint per rank, and the requirement to keep one outstanding save at a time — the previous future is waited on before the next is issued, or the memory multiplies. An asynchronous save that was never flushed is not a checkpoint, so the outstanding future is waited on before exiting on a preemption.

The Termination Warning


A session the controller may need to stop is marked as such before it is stopped. The mark is a set of annotations written onto the pod, and it appears only while the session is genuinely at risk — a booking is coming due for capacity this session is holding, and this session is one of the candidates that could supply it.

A session inside its runtime guarantee is never stopped, however short the cluster is. The mark can still appear while the guarantee is running, when the booking that wants the capacity falls after the guarantee ends — it then says that the reclaim is queued for the moment the protection lapses. That is the most useful warning of the lot to a job that checkpoints, because it arrives while there is still plenty of time to act on it. → What Ends a Session

Four of the annotations matter to a long-running job:

Annotation Value What it says
galends/guaranteed-until UTC instant, YYYY-MM-DDTHH:MM:SSZ The end of the protected period. It can move later while the session runs, when an abutting follow-on window is booked
galends/termination-warning-at UTC instant, same format The earliest moment the session could be stopped. Never earlier than guaranteed-until. Present only while the session is at risk
galends/termination-warning-risk Decimal between 0 and 1, two places, e.g. 0.33 The share of the candidates that has to be stopped. 1.00 means all of them
galends/termination-warning-message A sentence The same thing in prose, in the cluster's local timezone. Written to be displayed as it stands, and not to be parsed

termination-warning-at is the earliest possible stop, not a scheduled one. The shortfall it was computed from may be gone before it arrives, in which case nothing happens and the annotations are removed again. A job that counts down to it and declares itself dead is wrong; a job that treats the moment as the start of "may be stopped at any time" is right.

Extending or re-booking the window cancels a pending termination, and does so right up until the pod is deleted. The controller re-checks every session's live guarantee before it selects anything, so a reservation that lands first always wins. → Continue, Extend & Adopt

figure not published

Reading the Warning From Inside a Container


Annotations are not visible to the container unless the pod spec projects them. A downward-API volume does that, and it is refreshed as the values change:

spec:
  volumes:
    - name: podinfo
      downwardAPI:
        items:
          - path: guaranteed-until
            fieldRef:
              fieldPath: metadata.annotations['galends/guaranteed-until']
          - path: termination-warning-at
            fieldRef:
              fieldPath: metadata.annotations['galends/termination-warning-at']
          - path: termination-warning-risk
            fieldRef:
              fieldPath: metadata.annotations['galends/termination-warning-risk']
          - path: termination-warning-message
            fieldRef:
              fieldPath: metadata.annotations['galends/termination-warning-message']
  containers:
    - name: notebook
      volumeMounts:
        - name: podinfo
          mountPath: /etc/podinfo
          readOnly: true

Each file then holds one value, raw — no quoting, no escaping, no trailing newline, so $(cat …) is the value:

$ cat /etc/podinfo/termination-warning-at; echo
2026-08-21T17:30:16Z
$ cat /etc/podinfo/termination-warning-risk; echo
0.33

An absent annotation is an empty file, not a missing one. All four files are created when the pod starts, and the three warning files stay empty for as long as the session is not at risk — which is most of most runs. A test for one of them uses -s, "exists and is not empty"; -f and -e are true from the pod's first second, so a loop guarded on either bails immediately, every time, and does so silently.

Environment variables are the wrong mechanism here. A downward-API environment variable is resolved once when the container starts, and every value on this page is written after that and some of them change during the run. Only the volume is refreshed.

The refresh is not instant. The kubelet updates the files on its own sync loop, which adds to the controller's own cadence — so the warning reaches the container some way into the notice period, not at the start of it. Polling every 15 to 30 seconds is ample; watching the files with inotify needs the directory watched rather than the file, since the whole set is swapped atomically behind a symlink.

figure not published

Projecting the whole annotation map into a single file is also possible, with fieldPath: metadata.annotations and no subscript. That file carries every annotation the session has — the reservation behind it, its GPU class, when it was admitted — one per line as key="value", with quotes and escapes that have to be undone. The per-key files above avoid the parser and are enough for checkpointing.

The idle culler publishes its own status the same way. dsmlp/idle-gpu-status and dsmlp/idle-gpu-cull-deadline are annotations on the same pod, and an additional fieldRef for each projects them alongside the four above. A job whose GPU goes quiet between phases is at risk from the culler on a timetable of its own, entirely separately from any reservation. → Idle Culling

Acting on the Warning


A batch job that processes work in units — shards, epochs, sweeps — usually needs nothing more than to stop starting new ones. The check goes between units, never inside one:

PODINFO=${PODINFO:-/etc/podinfo}

# -s is the operator that matters here: the file exists and is empty until the
# controller warns this pod, so -f and -e would bail on the first shard.
warned() { [[ -s "$PODINFO/termination-warning-at" ]]; }

for shard in "${SHARDS[@]}"; do
    if warned; then
        cat "$PODINFO/termination-warning-message" >&2
        printf 'stopped before %s; rerun to pick up the rest\n' "$shard" >&2
        exit 75                 # EX_TEMPFAIL, for whatever submitted the job
    fi
    process_shard "$shard"      # each shard commits its own output
done

The test is re-read every time round the loop, and no result is remembered. Warnings are withdrawn — the window gets extended, the incoming booking never claims its capacity, the session is re-linked to another reservation — and the file goes empty again. A job that recorded the first warning it saw would stop for one that had already been called off. Where a job would rather carry on than hand back its remaining units, the same test is worth making and logging rather than acting on; only the response changes.

A training loop checkpoints at a step boundary and not from a signal handler. Saving mid-backward captures inconsistent state, and under distributed training a save is a collective operation every rank has to enter in the same iteration:

from datetime import datetime, timezone
from pathlib import Path

PODINFO = Path("/etc/podinfo")

def _read(name):
    try:
        raw = (PODINFO / name).read_text().strip()
    except OSError:
        return None
    return raw or None

def _instant(name):
    raw = _read(name)
    try:
        return datetime.fromisoformat(raw) if raw else None
    except ValueError:
        return None            # anything unparseable is ignored, not raised on

def stop_expected(lead_seconds):
    """True when a stop is close enough that a checkpoint should be written."""
    now = datetime.now(timezone.utc)
    for key in ("termination-warning-at", "guaranteed-until"):
        instant = _instant(key)
        if instant is not None and (instant - now).total_seconds() <= lead_seconds:
            return True
    return False
warned = False

for step, batch in enumerate(loader, start=resume_step):
    train_step(batch)

    urgent = stop_expected(lead_seconds=2 * CHECKPOINT_SECONDS + 60)
    if dist.is_initialized():
        urgent = any_rank(urgent)          # see below

    if step % CHECKPOINT_EVERY == 0 or (urgent and not warned):
        save_atomic(state(step), CHECKPOINT_PATH)
    warned = urgent                        # re-arms if the warning is withdrawn

Four things in that loop earn their place:

  • The lead time is derived from the measured cost of a checkpoint, not guessed. A save that starts too late to finish is worth no more than one that never started.
  • any_rank makes the ranks agree. Each pod carries its own annotations, so in a multi-pod job only some members may be marked — but losing any one member ends the job, and a collective save that only some ranks enter deadlocks. The flag is OR-reduced across ranks and acted on as an aggregate.
  • warned debounces. Tracking the current value rather than latching makes the urgent save fire once per episode, and fire again if the warning clears and returns.
  • The guarantee ending is a cue in its own right. Past guaranteed-until a session is preemptible, and entering that state on an hour-old checkpoint is a self-inflicted loss.

Stopping voluntarily is a legitimate response, and for a batch job usually the better one. Checkpointing, exiting cleanly and letting a submission system resubmit hands back the capacity the incoming booking wanted, picks the stopping point, and skips the scramble in the grace period entirely. For an interactive session the opposite holds: the risk figure is not a certainty, the warning is frequently withdrawn, and a notebook that checkpoints and carries on will usually still be there afterwards.

The risk figure is a band, not a probability. It describes what share of the candidate sessions has to be stopped, on the assumption that the choice among them is arbitrary; where selection follows a policy instead, the number is only an indication. Thresholding it coarsely — "possible", "likely" — is the sound use of it. Where checkpoints are cheap, ignoring it and saving on any warning at all is sounder still.

Resuming


The save path gets the attention; the resume path is where the defects are.

  • The newest complete checkpoint is the one to load, which is not always the newest path. The marker written in the save path is what distinguishes them, and falling back to the previous checkpoint on any load error is the entire reason for keeping more than one.
  • Everything that was saved has to be restored — in particular the scheduler and the data position. A resume that restores only weights is visible in the loss curve.
  • torch.load defaults to weights_only=True from PyTorch 2.6. A checkpoint holding anything beyond plain tensors and containers needs those types allowlisted through torch.serialization.safe_globals. That is the thing to reach for rather than weights_only=False, which permits arbitrary code execution on load. Where only weights are needed, safetensors avoids the question — though it stores tensors only, so optimizer state still goes through torch.save.
  • The resume path is tested deliberately. A run killed at a random step, restarted, and checked for a continuous loss curve establishes that it works. Preemption will run this test eventually; better that it is not the first time.

Checkpointing by Job Shape


Job Checkpoint size What follows from it
A LoRA or other adapter fine-tune Tens of MB — the adapter only The cost of a save is seconds, so it can be written often and on any warning. Resuming still needs the optimizer, scheduler and step count alongside the adapter; the base model is referenced by name rather than copied into the checkpoint
A full fine-tune on one node Model, plus 2-3× that in optimizer state Saved from one rank where the replicas are identical. This is the case where the warning pays for itself most clearly
From-scratch or large-model training across nodes Hundreds of GB, sharded torch.distributed.checkpoint handles sharded model and optimizer state together. Asynchronous saves suit the periodic cadence; on a warning, one synchronous save at a step boundary beats racing an asynchronous one that may not get flushed
A run spanning several reservations Any Designed for restart rather than continuity. A run that resumes cleanly from disk can be scheduled across several windows and interrupted between them at almost no cost
Inference or a service pod Nothing to checkpoint The warning is used to drain: stop accepting work, finish what is in flight, exit

Logging


An unattended job can only be understood from its logs. Output from print statements in a background run goes nowhere anyone can read it, and a failed trial costs hours rather than seconds. The crude version is a redirect, and it is much better than nothing:

python run.py all > log.txt 2>&1 &

The 2>&1 matters> alone captures standard output, and tracebacks arrive on standard error. python -u is worth adding too, since Python buffers output to a file and an interrupted job can otherwise lose its last several minutes of log.

The better version is the logging module. It timestamps every line and records which file and function produced it, which is the difference between a log that can be searched and a wall of text:

# randdiv.py
import logging
import numpy as np

logging.basicConfig(filename='log.txt',
                    filemode='a',
                    level=logging.INFO,
                    datefmt='%H:%M:%S',
                    format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s')

def myfunc():
    a = np.random.randint(-5, 5)
    b = np.random.randint(-5, 5)
    if b == 0:
        logging.info('zerodivisionerror preempted')
    else:
        return a / b

print([myfunc() for _ in range(100)])

A line in log.txt then reads 2022-10-30 23:37:11,815,815 root INFO zerodivisionerror preempted — naming when it happened and where it came from. filemode='a' appends, so a resumed run adds to the record instead of erasing it.

Four things are worth correlating with an interruption: wall-clock time, the epoch or step, the path of the last checkpoint written, and whether a termination warning was present. When a run ends unexpectedly, those four lines locate the restart point and say whether the ending was announced.

Logs That Outlive the Pod


kubectl logs <pod-name> reads from the pod, so it stops working the moment the pod is deleted — including when the pod deletes itself at the end of a batch job, and including a preemption. It is for looking at something now, not for keeping a record.

A file in a home directory outlives everything. For unattended work, please redirect into one:

launch-scipy-ml.sh -g 1 -B -- bash -c 'python -u ./train.py > run.log 2>&1'

run.log is then readable from the login node while the job is still running, and after it is gone. → Interactive, Background & Batch Modes · Watching a Running Job

Caveats & Limitations


A deadline gives no warning at all. The runtime limit stops the container at its appointed second with nothing beforehand and nothing to catch, and the Research Cluster maintenance window terminates every running job that is in it. Neither is announced on the pod, so neither can be prepared for in the moment: whatever matters has to be on disk before either arrives. → The Runtime Limit

The grace period is not a checkpointing window. A preempted pod is deleted the ordinary way — SIGTERM, then a short interval before the container is stopped — which is long enough to flush something already staged in memory and nowhere near long enough to write an optimizer state out. The warning is what buys the time; the grace period is what is left after the decision has been made. A strategy resting on the signal handler is a strategy resting on the wrong one of the two.

figure not published

The guarantee is not a deadline either. Past guaranteed-until a job is not stopped — it keeps running until somebody else's reservation needs the capacity. Exiting at the guarantee gives up time that was there for the taking. Tightening the checkpoint cadence at that point is the response, since from that instant the job is stoppable on notice it does not control.

Every one of these annotations is optional and best-effort. Any of them can be absent at any moment, the warning trio disappears when the risk clears, and none of them is read back by the controller to decide anything — the decision is recomputed from live reservation state each time. Code that reads them handles each one missing, ignores a value that does not parse rather than failing, and never caches at startup what it can re-read.

Please do not checkpoint into a shared or course-wide directory unintentionally — it is quota'd for the class, not for one run. → Directories, Quotas & Cleaning Up


If you still have questions or need additional assistance, email us at datahub@ucsd.edu or submit a ticket to the ITS Service Desk.