Skip to content

Commit e2cad6c

Browse files
RobotSailclaude
andauthored
Add on-demand full-state checkpointing for OpenShift AI / KubeFlow preemption (#686)
* Add on-demand full-state checkpointing for OpenShift AI / KubeFlow preemption Implements signal-driven checkpoint-and-exit for distributed training jobs running in OpenShift AI as KubeFlow training jobs or multi-node bare metal. When `on_demand_checkpointing=True` is set in TrainingArgs: - Parent process (run_training) installs handlers for SIGTERM, SIGINT, SIGUSR1, SIGUSR2, SIGXCPU, and SIGHUP — covering all signals Kubernetes/OpenShift sends before the hard SIGKILL. - On signal receipt, a trigger file is atomically written to /dev/shm (tmpfs, shared within the pod, zero disk I/O). - Worker processes check for the trigger file after each optimizer step via an all_reduce(MAX) collective, ensuring global consensus across all ranks on all nodes. - When any rank detects the trigger, all ranks collectively save a full-state distributed checkpoint (model + optimizer + LR scheduler) then exit gracefully. - Parent waits up to 300s for workers to complete the checkpoint before proceeding with normal shutdown. https://claude.ai/code/session_01HSxsk7SnMULJxy7uafe7t3 * Address review feedback for on-demand checkpointing - Fix mypy error: properly type _original_handlers dict with _SignalHandler type alias instead of bare object - Fix ruff/isort: remove duplicate comment, fix import ordering - Namespace trigger file with rdzv_id as job_id so concurrent jobs sharing /dev/shm don't interfere with each other - Recompute subprocess failure status after forced termination to avoid stale exit code - Gate consensus log message to rank 0 to reduce log noise on large jobs * Check for on-demand checkpoint after each minibatch backward Move the checkpoint request check from after the full optimizer step to after each minibatch's backward pass inside BatchLossManager.process_batch. This ensures the system responds within one fwd+bwd cycle (~1-2s) even when gradient accumulation spans many minibatches, giving more time to save before Kubernetes sends SIGKILL after the grace period. The check is passed as an optional interrupt_check callback to keep checkpoint-specific logic out of BatchLossManager. When triggered, the batch loop breaks early and the training loop saves the checkpoint immediately, skipping the optimizer step to preserve the pre-step model state for exact resumption. * Add diagnostic note when on-demand checkpoint fails to save in time When the training subprocess fails after an on-demand checkpoint signal was received, the error message now includes guidance to increase terminationGracePeriodSeconds or reduce fwd/bwd pass time so the checkpoint check fires before SIGKILL arrives. * Fix help text: checkpoint check happens after each minibatch backward Update --on_demand_checkpointing help text and TrainingArgs description to accurately state that workers check for the trigger file after each minibatch backward pass, not after each training step. * Add checkpoint checks at 5 synchronization points per training step Expand on-demand checkpointing to check for a trigger at five points: 1. Before each minibatch forward pass 2. Before each minibatch backward pass 3. After each minibatch backward pass (existing) 4. Before the optimizer step 5. After the optimizer step This minimizes the latency between a termination signal arriving and the checkpoint being saved, which is critical when the SIGKILL grace period is short (e.g. 30s on OpenShift/Kubernetes). Also cleans up the save-and-exit logic in train() by extracting a _save_and_exit() helper to eliminate three nearly identical blocks, and fixes _compute_average_loss to handle the case where the minibatch loop is interrupted before any forward pass completes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add unit tests for on-demand checkpointing 24 tests covering: - Trigger file helpers (path generation, write, exists, remove) - ParentSignalHandler (install, handle, idempotency, uninstall, real signal) - check_checkpoint_requested (trigger detection, cleanup, all_reduce consensus) - BatchLossManager interrupt handling (all 3 check points, early exit, float loss) * fix: correct help text for on_demand_checkpointing sync point count * Add stale trigger cleanup and exact mid-epoch resume Two fixes to the on-demand checkpointing feature: 1. Stale trigger file cleanup: ParentSignalHandler.install() now checks for and removes any existing trigger file before installing signal handlers. If the file exists before handlers are installed, it's from a previous run that was killed before workers could clean it up. Prevents a new training job from immediately checkpointing and exiting. 2. Exact mid-epoch resume: save_on_demand_checkpoint() now persists global_step in the checkpoint metadata alongside current_epoch and samples_seen. On resume, load_latest_full_state() detects the global_step field and sets last_step accordingly, so the training loop fast-forwards to the exact step within the epoch. Without this, mid-epoch checkpoints would skip to the next epoch on resume, losing remaining steps. Tested with Qwen2-1.5B-Instruct on 2 GPUs: interrupted at step 19/25, checkpoint saved with global_step=19, resumed and completed steps 20-25. * Fix ruff formatting in utils.py * Add documentation for on-demand checkpointing feature * Document manual trigger file creation for on-demand checkpointing * Improve manual trigger docs: clarify job ID requirement * Simplify trigger file: remove job_id namespacing Drop the job_id suffix from the trigger file path. The file is now always /dev/shm/instructlab_checkpoint_requested with no suffix. The namespacing was defensive against concurrent jobs sharing /dev/shm, but in practice Kubernetes pods each get their own /dev/shm. This makes manual triggering trivial: touch /dev/shm/instructlab_checkpoint_requested * Fix pylint: remove unnecessary lambda wrapper * fix: update unit tests for simplified trigger file API (no job_id) * fix: add compat shim for Nemotron's is_flash_attn_greater_or_equal_2_10 The function was renamed to is_flash_attn_greater_or_equal in transformers 5.x, but Nemotron's HF Hub remote code still imports the old name. Inject a compatibility wrapper before model loading. * fix: make unit tests work without CUDA - Patch torch.tensor in check_checkpoint_requested tests to avoid CUDA device creation (CI runs without GPUs) - Override BatchLossManager.torch_device to CPU in fixture so _prepare_model_inputs doesn't trigger CUDA init --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 091c21e commit e2cad6c

8 files changed

Lines changed: 1080 additions & 34 deletions

File tree

docs/on_demand_checkpointing.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# On-Demand Checkpointing
2+
3+
On-demand checkpointing enables graceful checkpoint-and-exit when termination
4+
signals are received during training. It is designed for environments like
5+
OpenShift AI and KubeFlow where training jobs can be preempted at any time.
6+
7+
## How It Works
8+
9+
When enabled, the system installs signal handlers in the parent (launcher)
10+
process that catch termination signals before the hard SIGKILL. When a signal
11+
arrives:
12+
13+
1. The parent writes a trigger file to `/dev/shm` (a fast, node-local tmpfs).
14+
2. Worker processes check for the trigger file at multiple synchronization
15+
points during each training step.
16+
3. Workers coordinate via `all_reduce` so that if any rank on any node
17+
detects the trigger, all ranks agree to save.
18+
4. A full-state checkpoint (model + optimizer + LR scheduler) is saved.
19+
5. Workers exit cleanly.
20+
21+
On resume, the training loop detects the mid-epoch checkpoint, restores the
22+
full training state, and fast-forwards to the exact step where training was
23+
interrupted.
24+
25+
## Signals Handled
26+
27+
The following signals are intercepted (SIGKILL cannot be caught):
28+
29+
| Signal | Source |
30+
|--------|--------|
31+
| SIGTERM | Kubernetes graceful shutdown (default) |
32+
| SIGINT | Ctrl-C / some job controllers |
33+
| SIGUSR1 | Custom preemption controllers |
34+
| SIGUSR2 | Custom preemption controllers |
35+
| SIGXCPU | CPU time limit exceeded (resource quotas) |
36+
| SIGHUP | Terminal disconnect / some eviction paths |
37+
38+
## Usage
39+
40+
### Python API
41+
42+
```python
43+
from instructlab.training.config import TorchrunArgs, TrainingArgs
44+
from instructlab.training import run_training
45+
46+
torch_args = TorchrunArgs(
47+
nproc_per_node=8,
48+
nnodes=1,
49+
node_rank=0,
50+
rdzv_id=12345,
51+
rdzv_endpoint="127.0.0.1:29500",
52+
)
53+
54+
train_args = TrainingArgs(
55+
model_path="Qwen/Qwen2-1.5B-Instruct",
56+
data_path="./data.jsonl",
57+
data_output_dir="./processed",
58+
ckpt_output_dir="./checkpoints",
59+
num_epochs=3,
60+
on_demand_checkpointing=True, # Enable the feature
61+
# ... other training args
62+
)
63+
64+
run_training(torch_args, train_args)
65+
```
66+
67+
### CLI
68+
69+
```bash
70+
torchrun --nproc-per-node=8 \
71+
instructlab/training/main_ds.py \
72+
--model_name_or_path Qwen/Qwen2-1.5B-Instruct \
73+
--data_path ./data.jsonl \
74+
--output_dir ./checkpoints \
75+
--on_demand_checkpointing \
76+
...
77+
```
78+
79+
## Resume Behavior
80+
81+
When a checkpoint saved by on-demand checkpointing is found in the output
82+
directory, the training loop automatically:
83+
84+
1. Loads the full optimizer and LR scheduler state from the checkpoint.
85+
2. Reads `global_step` from the checkpoint metadata to determine where
86+
training was interrupted.
87+
3. Resumes at the **same epoch** and fast-forwards to the exact step,
88+
skipping already-completed batches.
89+
90+
This differs from epoch-boundary checkpoints, which resume at the start of
91+
the next epoch.
92+
93+
### Checkpoint Metadata
94+
95+
On-demand checkpoints store additional metadata compared to epoch-boundary
96+
checkpoints:
97+
98+
```json
99+
{
100+
"current_epoch": 0,
101+
"samples_seen": 144,
102+
"global_step": 19
103+
}
104+
```
105+
106+
The `global_step` field is what distinguishes an on-demand checkpoint from an
107+
epoch-boundary one. When present, the resume logic keeps `current_epoch`
108+
unchanged and sets `last_step = global_step` to enable fast-forwarding.
109+
110+
## Multi-Node Training
111+
112+
The trigger file mechanism works correctly across multiple nodes:
113+
114+
- The trigger file lives on `/dev/shm`, which is node-local. Each node's
115+
parent process writes its own trigger file when it receives a signal.
116+
- Workers use `all_reduce(MAX)` to synchronize: if any rank on any node
117+
detects a trigger, all ranks on all nodes agree to save.
118+
- The checkpoint itself is saved to the shared filesystem (the configured
119+
`ckpt_output_dir`), accessible by all nodes on resume.
120+
121+
## Stale Trigger Files
122+
123+
If a previous training run was killed before workers could clean up the
124+
trigger file, the new run's `ParentSignalHandler` detects and removes it
125+
during initialization. This prevents a new job from immediately
126+
checkpointing and exiting due to a leftover trigger from a prior run.
127+
128+
## Manually Triggering a Checkpoint
129+
130+
You can trigger a checkpoint-and-exit without sending a signal by writing
131+
the trigger file directly. This is useful for debugging, testing, or
132+
integration with custom orchestration that doesn't use Unix signals.
133+
134+
The trigger file is always at a fixed path. To trigger a checkpoint
135+
(e.g. via `kubectl exec` into the training pod):
136+
137+
```bash
138+
touch /dev/shm/instructlab_checkpoint_requested
139+
```
140+
141+
Workers check for the trigger file at each synchronization point in the
142+
training loop (multiple times per step). Once any rank on any node detects
143+
it, all ranks coordinate via `all_reduce` to save a checkpoint and exit.
144+
145+
You only need to write the file on **one node** — the `all_reduce` ensures
146+
all nodes participate even if they don't see the file locally.
147+
148+
From Python:
149+
150+
```python
151+
from instructlab.training.on_demand_checkpoint import write_trigger_file
152+
153+
write_trigger_file()
154+
```
155+
156+
## Kubernetes / OpenShift Configuration
157+
158+
To give workers enough time to save a checkpoint before the hard SIGKILL,
159+
increase `terminationGracePeriodSeconds` in your pod spec:
160+
161+
```yaml
162+
spec:
163+
terminationGracePeriodSeconds: 300 # 5 minutes
164+
```
165+
166+
The default of 30 seconds may not be enough for large models. The checkpoint
167+
save time depends on model size, number of GPUs, and filesystem speed.

src/instructlab/training/batch_loss_manager.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"""
88

99
# Standard
10-
from dataclasses import dataclass
10+
from collections.abc import Callable
11+
from dataclasses import dataclass, field
1112
import logging
1213

1314
# Third Party
@@ -33,6 +34,7 @@ class BatchMetrics:
3334
accumulated_aux_loss: torch.Tensor | None
3435
grad_accum_steps: int
3536
num_minibatches: int
37+
interrupted: bool = field(default=False)
3638

3739

3840
class BatchLossManager:
@@ -62,12 +64,22 @@ def __init__(self, model, accelerator, world_size: int, local_rank: int):
6264
self.local_rank: int = local_rank
6365
self.torch_device = torch.device("cuda", local_rank)
6466

65-
def process_batch(self, batch: list[CollatedItem]) -> tuple[BatchMetrics, float]:
67+
def process_batch(
68+
self,
69+
batch: list[CollatedItem],
70+
interrupt_check: Callable[[], bool] | None = None,
71+
) -> tuple[BatchMetrics, float]:
6672
"""
6773
Process a batch of minibatches, computing losses and accumulating gradients.
6874
6975
Args:
7076
batch: List of minibatches to process
77+
interrupt_check: Optional callback invoked at three points per
78+
minibatch: before forward, before backward, and after
79+
backward. If it returns ``True`` at any point, processing
80+
stops early and ``BatchMetrics.interrupted`` is set. Used by
81+
on-demand checkpointing to react as quickly as possible
82+
instead of waiting for the full optimizer step.
7183
7284
Returns:
7385
tuple: (BatchMetrics, average_loss_across_ranks)
@@ -82,9 +94,15 @@ def process_batch(self, batch: list[CollatedItem]) -> tuple[BatchMetrics, float]
8294
accumulated_loss = 0.0
8395
accumulated_aux_loss = 0.0
8496
grad_accum_steps = 0
97+
interrupted = False
8598

8699
# process each minibatch
87100
for mb in batch:
101+
# Check for on-demand checkpoint before forward
102+
if interrupt_check is not None and interrupt_check():
103+
interrupted = True
104+
break
105+
88106
# extract minibatch-specific info
89107
micro_batch_size = mb["num_samples"]
90108
total_length = mb["total_length"]
@@ -96,10 +114,16 @@ def process_batch(self, batch: list[CollatedItem]) -> tuple[BatchMetrics, float]
96114
# prepare model inputs
97115
model_inputs = self._prepare_model_inputs(mb)
98116

99-
# compute loss and backward pass
117+
# compute loss (forward pass)
100118
scaled_loss, raw_losses = self.model.compute_loss(
101119
model_inputs, self.world_size, batch_num_loss_counted_tokens
102120
)
121+
122+
# Check for on-demand checkpoint before backward
123+
if interrupt_check is not None and interrupt_check():
124+
interrupted = True
125+
break
126+
103127
self.accelerator.backward(scaled_loss)
104128

105129
# accumulate losses
@@ -108,6 +132,11 @@ def process_batch(self, batch: list[CollatedItem]) -> tuple[BatchMetrics, float]
108132
if raw_losses.aux_loss is not None:
109133
accumulated_aux_loss += raw_losses.aux_loss
110134

135+
# Check for on-demand checkpoint after backward
136+
if interrupt_check is not None and interrupt_check():
137+
interrupted = True
138+
break
139+
111140
# reduce metrics across ranks
112141
batch_total_samples, batch_total_length = self._reduce_metrics(
113142
batch_total_samples, batch_total_length
@@ -127,6 +156,7 @@ def process_batch(self, batch: list[CollatedItem]) -> tuple[BatchMetrics, float]
127156
accumulated_aux_loss=accumulated_aux_loss,
128157
grad_accum_steps=grad_accum_steps,
129158
num_minibatches=num_minibatches,
159+
interrupted=interrupted,
130160
)
131161

132162
return metrics, avg_loss_across_ranks
@@ -165,8 +195,8 @@ def _reduce_metrics(
165195

166196
def _compute_average_loss(
167197
self,
168-
accumulated_loss: torch.Tensor,
169-
accumulated_aux_loss: torch.Tensor | None,
198+
accumulated_loss: torch.Tensor | float,
199+
accumulated_aux_loss: torch.Tensor | float | None,
170200
batch_num_loss_counted_tokens: int,
171201
) -> float:
172202
"""Compute average loss across all ranks for metrics logging."""
@@ -177,11 +207,16 @@ def _compute_average_loss(
177207
if accumulated_aux_loss is not None:
178208
total_batch_loss += accumulated_aux_loss
179209

210+
# Extract scalar value — accumulated_loss may be a plain float if the
211+
# minibatch loop was interrupted before any forward pass completed.
212+
if isinstance(total_batch_loss, torch.Tensor):
213+
loss_value = total_batch_loss.detach().item()
214+
else:
215+
loss_value = float(total_batch_loss)
216+
180217
# reduce across ranks
181218
avg_loss_across_ranks = self.accelerator.reduce(
182-
torch.tensor(
183-
total_batch_loss.detach().item(), device=self.accelerator.device
184-
),
219+
torch.tensor(loss_value, device=self.accelerator.device),
185220
reduction="mean",
186221
).item()
187222

src/instructlab/training/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,19 @@ class TrainingArgs(BaseModel):
361361
description="How often to evaluate validation loss (in training steps). Required when validation_split > 0.",
362362
)
363363

364+
on_demand_checkpointing: bool = Field(
365+
default=False,
366+
description=(
367+
"Enable on-demand full-state checkpointing triggered by Unix signals. "
368+
"When enabled, the parent process intercepts termination signals "
369+
"(SIGTERM, SIGINT, SIGUSR1, SIGUSR2, SIGXCPU, SIGHUP) and writes a "
370+
"trigger file to /dev/shm. Worker processes check for this trigger "
371+
"after each minibatch backward pass and collectively save a distributed "
372+
"checkpoint before exiting gracefully. Designed for OpenShift AI / "
373+
"KubeFlow training jobs where preemption signals must be handled."
374+
),
375+
)
376+
364377
@model_validator(mode="after")
365378
def validate_validation_config(self):
366379
if not 0.0 <= self.validation_split < 1.0:

0 commit comments

Comments
 (0)