3838import os
3939import shutil
4040import time
41+ from collections import deque
42+ from concurrent .futures import Future , ThreadPoolExecutor
4143from types import MethodType
4244from typing import TYPE_CHECKING , NamedTuple
4345
@@ -101,6 +103,55 @@ def _rw_full(op, fd: int, offset: int, buf) -> None:
101103 done += n
102104
103105
106+ def _drop_from_page_cache (fd : int ) -> None :
107+ """Write the file back and drop it from the host page cache; DONTNEED drops only clean pages."""
108+ os .fdatasync (fd )
109+ os .posix_fadvise (fd , 0 , 0 , os .POSIX_FADV_DONTNEED )
110+
111+
112+ def _drop_path_from_page_cache (path : str ) -> None :
113+ fd = os .open (path , os .O_RDONLY | os .O_CLOEXEC )
114+ try :
115+ _drop_from_page_cache (fd )
116+ finally :
117+ os .close (fd )
118+
119+
120+ class _PageCacheWindow :
121+ """Caps the page cache of the store's files and their checkpoint copies; no flush waits on its own writeback.
122+
123+ A writer thread drops each released file in release order; both syscalls release the GIL, so a file's
124+ writeback overlaps the buckets after it. A release waits only while more than the cap is still waiting to be
125+ dropped, and only for the oldest files. The bucket being read for the next step comes on top of the cap.
126+ """
127+
128+ def __init__ (self , cap_bytes : int ):
129+ self ._cap_bytes = cap_bytes
130+ self ._writer = ThreadPoolExecutor (max_workers = 1 , thread_name_prefix = "nvme-page-cache" )
131+ self ._waiting : deque [tuple [int , Future ]] = deque ()
132+ self ._waiting_bytes = 0
133+
134+ def release (self , file : int | str , nbytes : int ) -> None :
135+ """Queue a file just written or read, ``nbytes`` of it now in the page cache, to be dropped.
136+
137+ ``file`` is a store file's fd, or the path of a checkpoint copy, which the writer opens and closes itself.
138+ """
139+ drop = _drop_path_from_page_cache if isinstance (file , str ) else _drop_from_page_cache
140+ self ._waiting .append ((nbytes , self ._writer .submit (drop , file )))
141+ self ._waiting_bytes += nbytes
142+ while self ._waiting and (self ._waiting_bytes > self ._cap_bytes or self ._waiting [0 ][1 ].done ()):
143+ self ._retire_oldest ()
144+
145+ def drain (self ) -> None :
146+ while self ._waiting :
147+ self ._retire_oldest ()
148+
149+ def _retire_oldest (self ) -> None :
150+ nbytes , dropped = self ._waiting .popleft ()
151+ self ._waiting_bytes -= nbytes
152+ dropped .result () # a failed writeback surfaces here, on the training thread
153+
154+
104155def plan_buckets (entries_by_ddp_bucket : dict , limit : int = BUCKET_NUMEL_LIMIT ) -> list [list [_Entry ]]:
105156 planned , current , numel = [], [], 0
106157 for _ , entries in sorted (entries_by_ddp_bucket .items (), key = lambda kv : kv [0 ]):
@@ -169,12 +220,15 @@ def __init__(self, path: str, entries: list[_Entry], adam, stager: _Stager, dtyp
169220 self .numel = sum (e .main_param .numel () for e in entries )
170221
171222 self .offsets : dict [str , list [int ]] = {}
223+ self .segment_nbytes : dict [str , int ] = {} # page-aligned, which is what a segment occupies in the cache
172224 at = 0
173225 for segment in SEGMENTS :
174226 self .offsets [segment ] = []
227+ start = at
175228 for entry in entries :
176229 self .offsets [segment ].append (at )
177230 at += _align (entry .main_param .numel () * dtypes [segment ].itemsize )
231+ self .segment_nbytes [segment ] = at - start
178232 self .nbytes = at
179233 self .fd = _allocate_file (path , at )
180234 self .moments_ready = False
@@ -198,8 +252,10 @@ def _move(self, segments, *, to_disk: bool) -> int:
198252 def fetch (self ) -> int :
199253 return self ._move (SEGMENTS if self .moments_ready else SEGMENTS [:1 ], to_disk = False )
200254
201- def flush (self , segments = SEGMENTS ) -> int :
255+ def flush (self , segments = SEGMENTS , * , page_cache : _PageCacheWindow | None ) -> int :
202256 moved = self ._move (segments , to_disk = True )
257+ if page_cache is not None :
258+ page_cache .release (self .fd , nbytes = sum (self .segment_nbytes [segment ] for segment in segments ))
203259 self .moments_ready = self .moments_ready or tuple (segments ) == SEGMENTS
204260 return moved
205261
@@ -227,9 +283,11 @@ def __init__(
227283 chunk_mb : int ,
228284 moment_dtype : str = "fp32" ,
229285 allow_fresh_state : bool = False ,
286+ page_cache : _PageCacheWindow | None = None ,
230287 ):
231288 self .dist_opt = distrib_optimizer
232289 self ._allow_fresh_state = allow_fresh_state
290+ self ._page_cache = page_cache
233291 self .uid = NVMeOptimizerStateStore ._next_uid
234292 NVMeOptimizerStateStore ._next_uid += 1
235293 config = distrib_optimizer .config
@@ -444,10 +502,11 @@ def step(self) -> bool:
444502 bucket .adam .step ()
445503 self ._detach_grads (bucket .entries )
446504 self ._copy_main_to_model_params (bucket .entries )
447- written += bucket .flush ()
505+ written += bucket .flush (page_cache = self . _page_cache )
448506 if self ._fp32_adam is not None :
449507 self ._sync_lr_wd (self ._fp32_adam , self ._fp32_group_indices )
450508 self ._fp32_adam .step ()
509+ self ._drain_page_cache_window ()
451510 logger .info (
452511 f"NVMe streaming step: { len (self .buckets )} buckets, read { read / 1024 ** 3 :.1f} GB, "
453512 f"wrote { written / 1024 ** 3 :.1f} GB in { time .monotonic () - started :.1f} s"
@@ -465,9 +524,12 @@ def initialize_main_from_model_params(self) -> int:
465524 assert param_range .size == entry .main_param .nelement ()
466525 source_shard = entry .model_param .view (- 1 )[param_range .start : param_range .end ]
467526 entry .main_param .copy_ (source_shard )
468- written += bucket .flush (segments = ("main" ,))
469- os .fdatasync (bucket .fd )
470- os .posix_fadvise (bucket .fd , 0 , bucket .offsets ["exp_avg" ][0 ], os .POSIX_FADV_DONTNEED )
527+ written += bucket .flush (segments = ("main" ,), page_cache = self ._page_cache )
528+ if self ._page_cache is None :
529+ # Initialization writes the whole main state in one pass, just before the checkpoint load and
530+ # the engines' pinned allocations, so even with no cap it leaves none of that cached.
531+ _drop_from_page_cache (bucket .fd )
532+ self ._drain_page_cache_window ()
471533 return written
472534
473535 @torch .no_grad ()
@@ -476,7 +538,14 @@ def refresh_main_from_model_params(self, copy_fn) -> None:
476538 bucket .materialize_main ()
477539 copy_fn ()
478540 for bucket in self .buckets :
479- bucket .flush (segments = ("main" ,))
541+ bucket .flush (segments = ("main" ,), page_cache = self ._page_cache )
542+ self ._drain_page_cache_window ()
543+
544+ def _drain_page_cache_window (self ) -> None :
545+ # Every operation on the store's files drains the window before it returns, so no file stays cached past it
546+ # and a failed writeback fails that operation rather than a later one, or none after the last.
547+ if self ._page_cache is not None :
548+ self ._page_cache .drain ()
480549
481550 @torch .no_grad ()
482551 def save_to (self , base_dir : str ) -> None :
@@ -495,7 +564,14 @@ def save_to(self, base_dir: str) -> None:
495564 ],
496565 }
497566 for bucket in self .buckets :
498- shutil .copyfile (bucket .path , os .path .join (dirpath , os .path .basename (bucket .path )))
567+ copy = os .path .join (dirpath , os .path .basename (bucket .path ))
568+ shutil .copyfile (bucket .path , copy )
569+ if self ._page_cache is not None :
570+ # Both ends of the copy are a full bucket in the cache; both go through the window, so one bucket's
571+ # writeback overlaps the next bucket's copy.
572+ self ._page_cache .release (bucket .fd , nbytes = bucket .nbytes )
573+ self ._page_cache .release (copy , nbytes = bucket .nbytes )
574+ self ._drain_page_cache_window ()
499575 if self ._fp32_adam is not None :
500576 torch .save (self ._fp32_adam .state_dict (), os .path .join (dirpath , "fp32_resident_optimizer.pt" ))
501577 with open (os .path .join (dirpath , "manifest.json" ), "w" ) as f :
@@ -536,11 +612,16 @@ def load_from(self, base_dir: str) -> bool:
536612 for bucket , meta in zip (self .buckets , manifest ["buckets" ], strict = True ):
537613 assert meta ["numel" ] == bucket .numel
538614 assert meta ["entry_numels" ] == [e .main_param .numel () for e in bucket .entries ]
539- shutil .copyfile (os .path .join (dirpath , meta ["file" ]), bucket .path )
615+ source = os .path .join (dirpath , meta ["file" ])
616+ shutil .copyfile (source , bucket .path )
617+ if self ._page_cache is not None :
618+ self ._page_cache .release (bucket .fd , nbytes = bucket .nbytes )
619+ self ._page_cache .release (source , nbytes = bucket .nbytes )
540620 for group , step in zip (bucket .adam .param_groups , meta ["steps" ], strict = True ):
541621 if step :
542622 group ["step" ] = step
543623 bucket .allocate_moments ()
624+ self ._drain_page_cache_window ()
544625 fp32_state = os .path .join (dirpath , "fp32_resident_optimizer.pt" )
545626 if self ._fp32_adam is not None and os .path .isfile (fp32_state ):
546627 self ._fp32_adam .load_state_dict (torch .load (fp32_state ))
@@ -559,6 +640,10 @@ def setup_optimizer_state_streaming(args, optimizer) -> None:
559640
560641 dir_root = optimizer_state_dir_root (args )
561642 purge_rank_dir (dir_root )
643+ # One window per rank: the chained dense and expert stores share this process's page cache.
644+ page_cache = None
645+ if args .stream_optimizer_state_page_cache_mb is not None :
646+ page_cache = _PageCacheWindow (cap_bytes = args .stream_optimizer_state_page_cache_mb * 1024 * 1024 )
562647 stores = []
563648 for dist_opt in optimizer .chained_optimizers :
564649 assert isinstance (
@@ -576,6 +661,7 @@ def setup_optimizer_state_streaming(args, optimizer) -> None:
576661 args .offload_train_disk_chunk_mb ,
577662 args .stream_optimizer_state_moment_dtype ,
578663 allow_fresh_state = args .backend .no_load_optim ,
664+ page_cache = page_cache ,
579665 )
580666 written = store .initialize_main_from_model_params ()
581667 logger .info (
0 commit comments