Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 40 additions & 27 deletions docs/checkpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def save_checkpoint(

| Parameter | Description |
|-----------|-------------|
| `checkpoint_dir` | Directory to write the checkpoint. Created if it does not exist. If a checkpoint already exists at this path it is replaced (best-effort; see [Known Limitations §3](#3-replacing-an-existing-checkpoint-is-not-fully-atomic)). |
| `checkpoint_dir` | Directory to write the checkpoint. Created if it does not exist. If a checkpoint already exists at this path, it is atomically replaced using a `.tmp` + `.old` mechanism (see [Atomic Checkpoint Replacement](#atomic-checkpoint-replacement)). |
| `include_storage` | Whether to save storage unit data. For `SimpleStorage` (in-memory), this is forced to `True` regardless of the value passed — skipping storage would cause complete data loss on restart. For persistent external backends, `False` is valid. |
| `metadata` | Optional user-defined key-value pairs written into `metadata.json`. Useful for recording step number, timestamp, etc. |

Expand Down Expand Up @@ -99,6 +99,43 @@ checkpoint_dir/
]
```

## Atomic Checkpoint Replacement

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc description can be more consise


To ensure atomic checkpoint replacement, `save_checkpoint` uses a three-step process:

1. Write the new checkpoint to `<checkpoint_dir>.tmp`
2. If `<checkpoint_dir>` exists, rename it to `<checkpoint_dir>.old`
3. Rename `<checkpoint_dir>.tmp` to `<checkpoint_dir>`
4. Delete `<checkpoint_dir>.old`

**Example:**
```
# Before save
/shared/checkpoints/experiment_1/ (old checkpoint)

# During save
/shared/checkpoints/experiment_1.tmp/ (new checkpoint being written)
/shared/checkpoints/experiment_1/ (old checkpoint, still intact)

# After step 2
/shared/checkpoints/experiment_1.tmp/ (new checkpoint complete)
/shared/checkpoints/experiment_1.old/ (old checkpoint moved aside)

# After step 3
/shared/checkpoints/experiment_1/ (new checkpoint in place)
/shared/checkpoints/experiment_1.old/ (old checkpoint, about to be deleted)

# Final state
/shared/checkpoints/experiment_1/ (new checkpoint)
```

**Benefits:**
- **Atomic replacement**: The old checkpoint is moved aside before the new one is moved into place. If step 3 fails, the `.old` version can be manually recovered.
- **Backward compatible**: Directory structure unchanged; users see the same `checkpoint_dir` path.
- **Automatic rollback**: If the rename fails after moving the old checkpoint aside, the exception handler automatically restores it.

**Disk overhead**: During the save operation, disk usage is temporarily `2 × checkpoint_size` (both `.tmp` and `.old` exist briefly), but returns to `1 × checkpoint_size` after completion.

## Architecture

```
Expand Down Expand Up @@ -139,39 +176,15 @@ On load, the number of storage units in the checkpoint must exactly match the ru

## Known Limitations

### 1. Controller request thread can hang on checkpoint I/O failure

The controller's request loop currently has no error handling around the checkpoint branches. If `save_checkpoint` or `load_checkpoint` raises (e.g., the path is not writable, or the pickle file is corrupt), the exception propagates up and kills the request thread. The client's `recv_multipart` call will block indefinitely.

**Workaround**: Ensure the checkpoint path is writable and the target file is not corrupt before calling. Verify disk space and file system permissions ahead of time.

---

### 2. Save consistency is not guaranteed under concurrent clears
### 1. Save consistency is not guaranteed under concurrent clears

See [Save Order and Consistency](#save-order-and-consistency) above. Concurrent `clear_partition` or `clear_samples` during `save_checkpoint` can produce a checkpoint whose controller view references storage entries that no longer exist.

**Workaround**: Do not issue `clear_partition` or `clear_samples` while `save_checkpoint` is running. This is naturally satisfied when checkpointing at training step boundaries.

---

### 3. Replacing an existing checkpoint is not fully atomic

The current save sequence is:

```python
if checkpoint_dir.exists():
shutil.rmtree(checkpoint_dir) # (1) old directory deleted
tmp_dir.rename(checkpoint_dir) # (2) new directory moved into place
```

If step (2) fails after step (1) (e.g., cross-device rename, disk full), the old checkpoint has already been deleted and the new one is also cleaned up by the exception handler — both copies are lost.

**Workaround**: Maintain an additional copy of the previous checkpoint (e.g., save to `step_N` while keeping `step_N-1`) so a failure at step N leaves `step_N-1` intact.

---

### 4. Load is not transactional — partial restore has no rollback
### 2. Load is not transactional — partial restore has no rollback

The load sequence is:

Expand Down
23 changes: 21 additions & 2 deletions transfer_queue/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,8 @@ def save_checkpoint(
raise RuntimeError("TransferQueue is not initialized. Call tq.init() first.")

checkpoint_dir = Path(checkpoint_dir)
tmp_dir = checkpoint_dir.parent / (checkpoint_dir.name + ".tmp")
tmp_dir = checkpoint_dir.parent / f"{checkpoint_dir.name}.tmp"
old_dir = checkpoint_dir.parent / f"{checkpoint_dir.name}.old"

if tmp_dir.exists():
shutil.rmtree(tmp_dir)
Expand Down Expand Up @@ -1039,15 +1040,25 @@ def save_checkpoint(
with open(tmp_dir / _METADATA_FILE, "w") as f:
json.dump(meta_content, f, indent=2)

# Atomic replacement: move old aside, move new in place, delete old
if checkpoint_dir.exists():
shutil.rmtree(checkpoint_dir)
if old_dir.exists():
shutil.rmtree(old_dir)
checkpoint_dir.rename(old_dir)

tmp_dir.rename(checkpoint_dir)
Comment on lines +1047 to 1049

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the checkpoint path present throughout replacement

When replacing an existing checkpoint, checkpoint_dir.rename(old_dir) removes the supported load path before the following rename installs the new version. A concurrent load_checkpoint(checkpoint_dir) can therefore observe a missing directory, and process termination between these statements leaves a restart unable to load the checkpoint without manually discovering .old. This two-rename sequence does not provide the documented atomic replacement or automatic rollback; use an atomic indirection/exchange mechanism or add startup/load recovery for .old.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.


if old_dir.exists():
shutil.rmtree(old_dir)

logger.info(f"Checkpoint saved to {checkpoint_dir}")

except Exception:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
if old_dir.exists() and not checkpoint_dir.exists():
# Restore old checkpoint if new one failed
old_dir.rename(checkpoint_dir)
raise


Expand All @@ -1072,6 +1083,14 @@ def load_checkpoint(
raise RuntimeError("TransferQueue is not initialized. Call tq.init() first.")

checkpoint_dir = Path(checkpoint_dir)
old_dir = checkpoint_dir.parent / f"{checkpoint_dir.name}.old"

if not checkpoint_dir.exists() and old_dir.exists():
# A prior save_checkpoint crashed between renaming checkpoint_dir aside
# and installing the new version; roll back to the pre-crash checkpoint.
logger.warning(f"Found interrupted checkpoint replacement; restoring {old_dir} to {checkpoint_dir}")
old_dir.rename(checkpoint_dir)

if not checkpoint_dir.exists():
raise FileNotFoundError(f"Checkpoint directory not found: {checkpoint_dir}")

Expand Down
Loading