Skip to content

Commit 77bbece

Browse files
committed
fix(optimizer): drop the checkpoint's copy of the capped state from the cache
With a cap set, save_to and load_from already release the store's own bucket files to the window, but the other end of each copy, the checkpoint's bucket file, still stayed in the page cache: a resume read the whole saved state into it and a save wrote the whole state into it, right before the rollout handoff the cap is meant to protect. Under a cap, each checkpoint-side copy is now written back and dropped as soon as it is copied, through a short-lived fd since the window needs one that outlives the call. The store test checks that the checkpoint's bucket files are uncached after both a save and a load; it fails without this change.
1 parent 8fcaafb commit 77bbece

3 files changed

Lines changed: 29 additions & 4 deletions

File tree

‎docs/advanced/disk-offload.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,7 @@ writeback overlaps the buckets after it instead of stalling its own flush. A flu
9191
while more than the cap is still waiting to be written back; the bucket being read comes on top
9292
of it. The step finishes its writeback before it returns, so none of the store's files stay
9393
cached past a step and each step reads its state back from disk. Checkpoint save and resume
94-
copy the store's files through the same cap; the checkpoint's own copy is cached like any other
95-
checkpoint file.
94+
copy the store's files through the same cap and drop the checkpoint's copy from the cache too.
9695

9796
With `fp32` storage the state round-trips bit for bit and only the grad norm's summation
9897
order differs from keeping it on GPU, so turning this on changes results by rounding at

‎miles_plugins/optimizers/nvme_stream.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ def _drop_from_page_cache(fd: int) -> None:
154154
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
155155

156156

157+
def _drop_path_from_page_cache(path: str) -> None:
158+
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
159+
try:
160+
_drop_from_page_cache(fd)
161+
finally:
162+
os.close(fd)
163+
164+
157165
class _PageCacheWindow:
158166
"""Caps how much of the store's files the host page cache holds, without a flush waiting on its own writeback.
159167
@@ -588,9 +596,13 @@ def save_to(self, base_dir: str) -> None:
588596
],
589597
}
590598
for bucket in self.buckets:
591-
shutil.copyfile(bucket.path, os.path.join(dirpath, os.path.basename(bucket.path)))
599+
copy = os.path.join(dirpath, os.path.basename(bucket.path))
600+
shutil.copyfile(bucket.path, copy)
592601
if self._page_cache is not None:
602+
# Both ends of the copy are a full bucket in the cache: the store's file through the window, the
603+
# checkpoint's at once, since its fd does not outlive this loop.
593604
self._page_cache.release(bucket.fd, nbytes=bucket.nbytes)
605+
_drop_path_from_page_cache(copy)
594606
if self._fp32_adam is not None:
595607
torch.save(self._fp32_adam.state_dict(), os.path.join(dirpath, "fp32_resident_optimizer.pt"))
596608
with open(os.path.join(dirpath, "manifest.json"), "w") as f:
@@ -631,9 +643,11 @@ def load_from(self, base_dir: str) -> bool:
631643
for bucket, meta in zip(self.buckets, manifest["buckets"], strict=True):
632644
assert meta["numel"] == bucket.numel
633645
assert meta["entry_numels"] == [e.main_param.numel() for e in bucket.entries]
634-
shutil.copyfile(os.path.join(dirpath, meta["file"]), bucket.path)
646+
source = os.path.join(dirpath, meta["file"])
647+
shutil.copyfile(source, bucket.path)
635648
if self._page_cache is not None:
636649
self._page_cache.release(bucket.fd, nbytes=bucket.nbytes)
650+
_drop_path_from_page_cache(source)
637651
for group, step in zip(bucket.adam.param_groups, meta["steps"], strict=True):
638652
if step:
639653
group["step"] = step

‎tests/fast-gpu/test_nvme_optimizer_streamed_grads.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ def test_capped_store_leaves_no_page_cache_after_a_step_save_or_load(tmp_path):
190190
def resident() -> list[int]:
191191
return [_resident_pages(bucket.fd) for bucket in store.buckets]
192192

193+
def checkpoint_resident() -> int:
194+
total = 0
195+
for path in (tmp_path / "ckpt").rglob("bucket*.bin"):
196+
fd = os.open(path, os.O_RDWR)
197+
try:
198+
total += _resident_pages(fd)
199+
finally:
200+
os.close(fd)
201+
return total
202+
193203
try:
194204
(norm,) = store.grads_for_norm()
195205
store.record_grad_norm(norm if multi_tensor_scale_tensor_impl is not None else float(norm))
@@ -200,12 +210,14 @@ def resident() -> list[int]:
200210
store.save_to(str(tmp_path / "ckpt"))
201211
store._page_cache.drain()
202212
assert resident() == [0, 0]
213+
assert checkpoint_resident() == 0, "the checkpoint's copy is dropped too"
203214

204215
for bucket in store.buckets:
205216
os.pwrite(bucket.fd, bytes(bucket.nbytes), 0)
206217
assert store.load_from(str(tmp_path / "ckpt"))
207218
store._page_cache.drain()
208219
assert resident() == [0, 0]
220+
assert checkpoint_resident() == 0, "reading the checkpoint leaves it uncached"
209221
for bucket, before in zip(store.buckets, stepped, strict=True):
210222
torch.testing.assert_close(_read_main(bucket, 0, before.numel()), before, atol=0, rtol=0)
211223
finally:

0 commit comments

Comments
 (0)