Skip to content

Commit b79da84

Browse files
committed
Merge main into amitw/miles-nixl-upstream-pr
Resolves the serialized-args snapshots against radixark#4143's new optimizer arguments, keeping this branch's p2p_transport and NIXL seed flag.
2 parents da40568 + 3f63277 commit b79da84

14 files changed

Lines changed: 475 additions & 39 deletions

File tree

‎docs/advanced/disk-offload.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,25 @@ each fp32 main shard's storage after creating its tensor handle, then Miles fill
7474
runtime bucket at a time directly in the final files. There is no temporary state file. Peak
7575
initialization HBM is the largest individual shard during construction and one main-only bucket
7676
afterward (normally at most about 200M fp32 elements, except for an oversized entry). Each
77-
initialized range is synchronized and evicted from page cache before continuing.
77+
initialized bucket is evicted from the page cache once its writeback completes.
78+
79+
The step's writes are buffered, so by default the host page cache keeps whatever of the state
80+
it has room for. That is the fastest setting when host RAM can hold the whole state: the next
81+
step reads it from memory, and the kernel finishes the writeback during rollout. A colocated
82+
run pays for it elsewhere, since that cache is reclaimed lazily and races every pinned
83+
allocation at the rollout/train handoff. Cap it per rank:
84+
85+
```bash
86+
--stream-optimizer-state-page-cache-mb 8192
87+
```
88+
89+
A writer thread then writes back and drops each flushed bucket in order, so a bucket's
90+
writeback overlaps the buckets after it instead of stalling its own flush. A flush waits only
91+
while more than the cap is still waiting to be written back; the bucket being read comes on top
92+
of it. The step finishes its writeback before it returns, so none of the store's files stay
93+
cached past a step and each step reads its state back from disk. Checkpoint save and resume
94+
put both ends of each bucket's copy, the store's file and the checkpoint's, through the same
95+
cap, so one bucket's writeback overlaps the next copy, and likewise finish it before returning.
7896

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

‎miles/rollout/filter_hub/sample_filters.py‎

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,23 @@ def mask_truncated_zero_reward(args: Namespace, data: list[list[Sample]]) -> Non
3636
"""
3737
global _warned_dict_reward
3838
total = masked = 0
39-
for sample in iter_samples(data):
40-
total += 1
41-
if not args.reward_key and isinstance(sample.reward, dict):
42-
# No scalar to compare without --reward-key; leave the sample in the loss.
43-
if not _warned_dict_reward:
44-
logger.warning(
45-
"mask_truncated_zero_reward: rewards are dicts and --reward-key is not set, "
46-
"so those samples are never masked"
47-
)
48-
_warned_dict_reward = True
49-
continue
50-
reward = sample.get_reward_value(args) if sample.reward is not None else None
51-
if is_cut_off(sample) and (reward is None or reward <= 0):
52-
sample.remove_sample = True
53-
masked += 1
39+
# ``data`` is a list of groups. With session server v2, a group entry is itself a list of
40+
# samples (one per session branch), so open each group with ``iter_samples``.
41+
for group in data:
42+
for sample in iter_samples(group):
43+
total += 1
44+
if not args.reward_key and isinstance(sample.reward, dict):
45+
# No scalar to compare without --reward-key; leave the sample in the loss.
46+
if not _warned_dict_reward:
47+
logger.warning(
48+
"mask_truncated_zero_reward: rewards are dicts and --reward-key is not set, "
49+
"so those samples are never masked"
50+
)
51+
_warned_dict_reward = True
52+
continue
53+
reward = sample.get_reward_value(args) if sample.reward is not None else None
54+
if is_cut_off(sample) and (reward is None or reward <= 0):
55+
sample.remove_sample = True
56+
masked += 1
5457
if masked:
5558
logger.info(f"mask_truncated_zero_reward: left {masked}/{total} cut-off zero-reward samples out of the loss")

‎miles/utils/args/configs/cluster.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,21 @@ class ClusterConfig(BaseConfig):
221221
),
222222
),
223223
] = "fp32"
224+
stream_optimizer_state_page_cache_mb: A[
225+
int | None,
226+
Arg(
227+
help=(
228+
"Per-rank cap (MiB) on the streamed Adam state that may sit in the host page cache "
229+
"waiting to be written back. With a cap, a writer thread writes back and drops each "
230+
"flushed bucket off the step's critical path, a flush waits only while more than the "
231+
"cap is still waiting, and the step finishes its writeback before returning, so none "
232+
"of the store's files stay cached past a step and the next step reads them from disk. "
233+
"Unset leaves the cache to the kernel, which is fastest when host RAM holds the whole "
234+
"state, but a colocated run then races that cache at every pinned allocation. "
235+
"Initialization never leaves its writes cached either way."
236+
),
237+
),
238+
] = None
224239
offload_train_disk_dir: A[
225240
str | None,
226241
Arg(

‎miles/utils/arguments.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1405,7 +1405,14 @@ def miles_validate_args(args):
14051405
"offloader; pass --chunked-optimizer-state-offload and a non-zero "
14061406
"--optimizer-state-offload-fraction"
14071407
)
1408+
assert args.stream_optimizer_state_page_cache_mb is None, (
1409+
"--stream-optimizer-state-page-cache-mb caps the Adam bucket store; Muon's disk state is "
1410+
"file-backed mappings the chunked offloader owns"
1411+
)
14081412
else:
1413+
assert (
1414+
args.stream_optimizer_state_page_cache_mb is None or args.stream_optimizer_state_page_cache_mb >= 0
1415+
), "--stream-optimizer-state-page-cache-mb must be >= 0"
14091416
assert (
14101417
args.use_distributed_optimizer
14111418
), "--stream-optimizer-state-to-disk requires the distributed optimizer"
@@ -1448,9 +1455,16 @@ def miles_validate_args(args):
14481455
assert (
14491456
not args.enable_witness
14501457
), "--enable-witness reads the master optimizer's per-param state, which the NVMe store owns"
1458+
page_cache_mb = args.stream_optimizer_state_page_cache_mb
14511459
logger.info(
14521460
f"Streaming optimizer state to disk, dir={args.offload_train_disk_dir}, "
1453-
f"chunk={args.offload_train_disk_chunk_mb}MB, moments={args.stream_optimizer_state_moment_dtype}"
1461+
f"chunk={args.offload_train_disk_chunk_mb}MB, moments={args.stream_optimizer_state_moment_dtype}, "
1462+
f"page cache {'left to the kernel' if page_cache_mb is None else f'capped at {page_cache_mb}MB'}"
1463+
)
1464+
else:
1465+
assert args.stream_optimizer_state_page_cache_mb is None, (
1466+
"--stream-optimizer-state-page-cache-mb caps the Megatron Adam bucket store; it needs "
1467+
"--stream-optimizer-state-to-disk and is not supported on other backends"
14541468
)
14551469

14561470
if args.async_max_concurrent_samples is not None:

‎miles_plugins/optimizers/nvme_stream.py‎

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import os
3939
import shutil
4040
import time
41+
from collections import deque
42+
from concurrent.futures import Future, ThreadPoolExecutor
4143
from types import MethodType
4244
from 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+
104155
def 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

Comments
 (0)