feat(optimizer): NVMe optimizer-state streaming as a miles plugin - #1793
Open
yueming-yuan wants to merge 3 commits into
Open
feat(optimizer): NVMe optimizer-state streaming as a miles plugin#1793yueming-yuan wants to merge 3 commits into
yueming-yuan wants to merge 3 commits into
Conversation
yueming-yuan
requested review from
Shi-Dong,
Zhichenzzz,
fzyzcjy,
guapisolo,
jybsuper,
maocheng23 and
yushengsu-thu
as code owners
July 25, 2026 00:30
Contributor
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
yueming-yuan
force-pushed
the
yueming/nvme-stream-on-1575
branch
from
July 25, 2026 00:30
dfe4f46 to
a9fb4f6
Compare
yueming-yuan
added a commit
to radixark/Megatron-LM
that referenced
this pull request
Jul 25, 2026
…two hooks The store and its wiring move to miles_plugins/optimizers/nvme_stream.py (radixark/miles#1793), which binds the five entry points that used to be `if self._nvme_state_store is not None` branches here onto each DistributedOptimizer instance. The --optimizer-state-nvme-* args and their OptimizerConfig fields go with it; miles owns the flags now and passes them to the constructor. What is left in Megatron carries no NVMe logic and does not import miles: - _copy_main_params_to_model_params_for, extracted out of _copy_main_params_to_model_params. Pure refactor; reimplementing that range-map walk in miles would fail silently when gbuf_world_in_bucket semantics change instead of conflicting. - The two checkpoint hooks, duck-typed through the _nvme_state_store attribute. They have to be inside save_checkpoint/load_checkpoint to cover every call path: miles has two save call sites and a missed one shows up as a checkpoint with no optimizer state that silently resumes from zero. save_to/load_from now take the checkpoint base and derive their own per-rank subdirectory from the same layout the live scratch directory uses, so _nvme_state_checkpoint_dir goes away and Megatron no longer reads store.dist_opt or store.uid. The duck-typed contract is four methods. The "this checkpoint has no streamed state" case moves into load_from, where the policy belongs.
yueming-yuan
force-pushed
the
yueming/nvme-stream-on-1575
branch
2 times, most recently
from
July 25, 2026 03:37
e5127c5 to
5e18019
Compare
yueming-yuan
added a commit
to radixark/Megatron-LM
that referenced
this pull request
Jul 25, 2026
The store and its wiring move to miles_plugins/optimizers/nvme_stream.py (radixark/miles#1793), which binds the five entry points that used to be `if self._nvme_state_store is not None` branches in distrib_optimizer.py onto each DistributedOptimizer instance. The --optimizer-state-nvme-* args and their OptimizerConfig fields go with it; miles owns the flags now and passes them to the constructor. distrib_optimizer.py is back to untouched. The earlier _copy_main_params_to_model_params_for extraction is reverted too: its only live caller under streaming was the store, and it handed out a partial operation, since the fp8 branch depends on quantize_param_shard() having run over the whole fp8 param set -- a DP collective that cannot be split per bucket. miles adapts the copy instead, with the source pinned by commit and line range, and rejects fp8 params up front. What is left here is the two checkpoint hooks, duck-typed through the _nvme_state_store attribute and importing nothing from miles. They have to be inside save_checkpoint/load_checkpoint to cover every call path: miles has two save call sites and a missed one shows up as a checkpoint with no optimizer state that silently resumes from zero. save_to/load_from now take the checkpoint base and derive their own per-rank subdirectory from the same layout the live scratch directory uses, so _nvme_state_checkpoint_dir goes away and nothing here reads store.dist_opt or store.uid. The duck-typed contract is four methods. The "this checkpoint has no streamed state" case moves into load_from, where the policy belongs.
yueming-yuan
force-pushed
the
yueming/nvme-stream-on-1575
branch
5 times, most recently
from
July 25, 2026 04:11
1c55e95 to
6b23983
Compare
Move the store out of Megatron into miles_plugins/optimizers/nvme_stream.py, and turn the five DistributedOptimizer entry points it overrides into instance bindings here instead of `if self._nvme_state_store is not None` branches there. Pairs with radixark/Megatron-LM#63, whose optimizer diff is now empty: the only thing left on that side is the two checkpoint hooks, which have to sit inside save_checkpoint/load_checkpoint to cover every call path. The per-bucket copy of mains into the param buffer is adapted here from _copy_main_params_to_model_params rather than extracted there, with the source pinned by commit and line range. Extracting it would have handed out a partial operation across a repo boundary: the fp8 branch depends on quantize_param_shard() having run over the whole fp8 param set, which is a DP collective and cannot be split per bucket, so a subset-taking method silently skips fp8 updates unless the caller knows to compensate. Keeping the copy next to its only caller makes that precondition local, and __init__ now rejects fp8 params (and the MXFP8 param all-gather) outright. --stream-optimizer-state-to-disk is the switch, replacing Megatron's --optimizer-state-nvme-dir where "enable" and "where" were the same knob so there could be no default location. It requires --offload-train-target=disk: both mechanisms answer the same pressure and are only deployed as a pair, so that is the only combination covered. --offload-train-disk-dir and --offload-train-disk-chunk-mb are therefore reused as-is, with each mechanism taking its own subdirectory of the shared root, and their validation is untouched. --stream-optimizer-state-moment-dtype carries the on-disk dtype, which is a constructor argument now, so the silent getattr fallback on the config field is gone. It is a serialization format, not a compute precision, which is what distinguishes it from --exp-avg-dtype: the step still hands fp32 to FusedAdam, and those flags require the precision-aware optimizer that streaming excludes. Streaming also asserts against the paths that read the main params or the master optimizer's state directly (--reset-optimizer-states, --save-local-weight-checksum, --enable-witness) instead of letting them return emptiness.
yueming-yuan
force-pushed
the
yueming/nvme-stream-on-1575
branch
from
July 25, 2026 04:14
6b23983 to
10739e4
Compare
--offload-train-target=disk sets TMS_INIT_ENABLE_CPU_BACKUP=0 alongside TMS_INIT_ENABLE_DISK_BACKUP=1. A torch_memory_saver built before fzyzcjy/torch_memory_saver#80 ignores the disk variable but still honours the cpu one, so pause() frees the actor with no backup anywhere and resume() hands back uninitialized memory. Nothing fails at that point: the weights are garbage, and it surfaces a rollout later as nan ref_log_probs/log_probs and then "found NaN in local grad norm for bucket #0", which points at the model, not at the offload path. Hit on radixark/miles:dev, which ships 0.0.9.post1 (no disk backend). Diagnosing it cost a 57G checkpoint reconversion chasing the wrong suspect. Checked against the binary that actually gets LD_PRELOADed, since that is what reads the env vars -- a Python-level probe would not cover a stale .so. Verified both ways on 8xH200: the pinned build has the symbol, all three preload .so in the 0.0.9.post1 wheel do not.
yueming-yuan
force-pushed
the
yueming/nvme-stream-on-1575
branch
from
July 25, 2026 09:54
53a7e5d to
46e5cdc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1575 (base will retarget to
mainwhen that merges). Pairs with radixark/Megatron-LM#63, which is updated alongside this.Why
--offload-train-target=disk(#1575) spills the paused actor at phase boundaries. That cannot help when the optimizer state does not fit the GPU while the step runs — by the time the Adam kernel launches, everything is resident again. Streaming solves that instead: the fp32 main params and Adam moments live in per-bucket files, and each step brings in one bucket, updates it, and writes it back.The mechanism was written and validated in Megatron-LM#63 (GLM-5.2 744B on 8xGB300, Qwen3.5-35B-A3B on 8xH200). This PR moves the implementation into miles and shrinks what Megatron has to carry.
What moves
megatron/core/optimizer/nvme_state_store.py->miles_plugins/optimizers/nvme_stream.py, plus the wiring. The fiveif self._nvme_state_store is not Nonebranches insideDistributedOptimizerbecome instance bindings in_bind():step_with_ready_gradsstore.step()+ the param all-gather tailreload_model_paramsstore.refresh_main_from_model_paramsstate_dict/load_state_dict/sharded_state_dictsave_to/load_fromcarry the real bytesMegatron keeps one thing: the two checkpoint hooks in
checkpointing.py, which have to be insidesave_checkpoint/load_checkpointbecause miles has two save call sites and a missed one shows up as a checkpoint with no optimizer state that silently resumes from zero. Its optimizer diff is empty — #63 is down to one file, +19 lines.The per-bucket copy is adapted here, not extracted there
step()has to get each bucket's updated mains into the param buffer beforeflush()releases their storage, so it needs a subset version of_copy_main_params_to_model_params. The first version of this work extracted one in Megatron and called it. That was wrong on two counts:step_with_ready_gradsis bound tostore.step(), Megatron's own_copy_main_params_to_model_paramsis never reached, so the extracted method existed there purely to serve an out-of-repo caller.quantize_param_shard()having already run over the whole fp8 param set, and that is a DP collective which cannot be hoisted into a per-subset method —plan_bucketscuts on each rank's own shard sizes, so bucket counts differ across ranks and the calls would not line up. A caller taking only the subset method therefore skips fp8 weight updates with no error. The# quantized in the above quantize_param_shard functioncomment also lost its referent the moment "above" became a different method.So
_copy_main_to_model_paramslives here, pinned by commit and line range in a comment (4716f754#L2469-L2519), following the same convention asmiles/utils/seqlen_balancing.pyand themiles_plugins/models/*kernels. The precondition it cannot satisfy is enforced next to it: the constructor rejects fp8 params and the MXFP8 param all-gather. Drift on a Megatron bump is the cost — the pinned link makes it auditable, and theassert world_range.size == shard_main_param.nelement()is the tripwire.miles' own coupling is two lazy imports;
grep -rn nvme_stream miles/lists all of it.Arg surface: one new switch, nothing renamed
Streaming requires
--offload-train-target=disk, asserted at startup. Both mechanisms answer the same pressure — not enough HBM for the model — and are only ever deployed as a pair, so that is the only combination covered. Streaming on its own does run (#63's matrix has a passing row for it) and it is the one case where offload has nothing to do, because nothing else wants the training GPUs during rollout; but nothing validates it, and failing at startup beats someone discovering that at 3am. Since the assert makes streaming imply disk offload,--offload-train-disk-dirand--offload-train-disk-chunk-mbare reused unchanged with each mechanism taking its own subdirectory of the shared root, and #1575's validation block is untouched — the diff against it is two help strings.The one thing that does not carry over is Megatron's
--optimizer-state-nvme-dir, where "enable" and "where" were the same knob, so there could be no default location. It also read too close to the three flags miles already inherits (--offload-optimizer-states,--optimizer-cpu-offload,--rl-offload-optimizer-during-inference), two of which are mutually exclusive with this.streamencodes the actual distinction: offload happens at phase boundaries, streaming happens insideoptimizer.step().Moments default to
fp32, so enabling streaming does not change results — it is bit-identical to GPU-resident state and only trades step time for memory.bf16stays an explicit perf opt-in. The fp8 options are kept (#63 measured fp8e4m3 at ~1.4x bf16's deviation, 6/6 steps NORMAL) but documented as not recommended. The dtype is a constructor argument now, sogetattr(config, "optimizer_state_nvme_moment_dtype", "fp32")and its silent fallback are gone.Guards
Three miles-side readers go through the main params or the master optimizer's per-param state, both of which the store owns — mains have 0-sized storage between steps and the master Adam never holds state. They were unguarded and would have returned emptiness rather than failing:
--reset-optimizer-states(walkschained_optimizer.optimizer.state.values()) — the reset would silently do nothing--save-local-weight-checksum(readsparam.main_param)--enable-witness(writesmain_param.data[local_idx])The disk-offload requirement above also subsumes the
--colocatewithout--offload-traincase, which #63 hit as an sglangresume_memory_occupationOOM mid-run. Plus the usual exclusions (--optimizer-cpu-offload,--offload-optimizer-states, precision-aware optimizer, multi-LoRA, non-Adam, non-distributed optimizer)._bindalso assertsreuse_grad_buf_for_mxfp8_param_agis off: the boundstep_with_ready_gradsdrops Megatron's branch to_copy_main_params_to_param_buffer.Checkpointing
save_to/load_fromnow take the checkpoint base and derive their own per-rank subdirectory from the samerelative_dirthe live scratch directory uses, so the layout is spelled once instead of once per repo (Megatron hadrank0000_opt0_0flat while the store hadrank0/opt0_0nested). Megatron no longer readsstore.dist_optorstore.uid; the duck-typed contract is four methods. The "this checkpoint has no streamed state" case moves intoload_from, which is a policy decision — it keeps pre-streaming checkpoints loadable — and now lives in one place.Known limits, documented in
docs/advanced/disk-offload.mdtorch_distpath for runs that enable streaming; fixing it means emitting per-paramShardedTensors, whichsharded_state_dict's build-the-whole-plan-up-front shape does not currently allow.--async-saveblock in Megatron, so the state copy blocks. Byte volume is unchanged from today (dist-ckpt writes the same 12 B/param, andbf16moments write less) — what is lost is the async parallel writer. Separate PR.Guard added
--offload-train-target=disksetsTMS_INIT_ENABLE_CPU_BACKUP=0alongsideTMS_INIT_ENABLE_DISK_BACKUP=1. A torch_memory_saver built before fzyzcjy/torch_memory_saver#80 ignores the disk variable but still honours the cpu one, sopause()frees the actor with no backup anywhere andresume()returns uninitialized memory. Nothing fails at that point — the weights are garbage and it surfaces a rollout later asnanlog-probs and thenfound NaN in local grad norm for bucket #0, which reads like a model bug.actor_factorynow asserts the LD_PRELOADed binary knows the env var (checked against the binary, not the Python API, since the binary is what reads it).radixark/miles:devcurrently ships 0.0.9.post1 and trips it.Testing
Qwen3-30B-A3B RL on 8xH200 (h200-sci-k8s devbox), colocate, TP4/EP8/DP8, bf16,
--num-rollout 3, dapo-math-17k, eval off. Both arms identical except for streaming. Because streaming is mutually exclusive with--optimizer-cpu-offload/--use-precision-aware-optimizer, which the launcher's("H100", 1)preset adds, those three flags were dropped from both arms — so the baseline holds the whole optimizer state on GPU, which is the intended contrast.3/3 train steps
outcome=NORMAL valid_step=trueon all 8 ranks in both arms. Weights round-trip correctly:train_rollout_logprob_abs_diff0.01803 / 0.01864 (baseline) vs 0.01804 / 0.01875 / 0.01896 (streaming).Peak is
torch.cuda.max_memory_allocated()bracketing theactor_traintimer, max over the 8 ranks; steps 1-2 only, since step 0 is lower until Adam creates the moments.actor_trainBoth numbers check out against what the store reports, so they are not coincidence:
The docs' "trades step time for memory" is too pessimistic for the colocated pairing. With the state already on disk the paused actor has ~43 GB/rank less to spill, so sleep/wake collapse and the cycle gets faster:
The lazy-moments path is exercised rather than assumed: the expert store reads 13.5 GB on the first step (= 27.0 x 4/8, mains only) and 27.0 GB from the second on. Both ChainedOptimizer members get their own store (
opt0_0dense /opt0_1expert), confirming the per-process uid disambiguation.Not covered: fp32 moments (should be bit-identical, worth running as the correctness control); checkpoint save/load, since
--save-interval 20never fires in 3 steps, so the synchronous copy noted above is still unmeasured. One streaming attempt lost its rollout engine at step 3 to sglang's owncouldn't get a response from detokenizer for last 20 sliveness check; it did not recur on rerun and host RAM was never under pressure (103 of 2009 GB), so it is not attributed to streaming, but not ruled out either.Second arm: fp32 moments, 4xB300, fp8 rollout
Closes the fp32-moments control the arm above lists as not covered, on different hardware, topology and rollout precision: 4xB300, TP4/PP1/CP1/EP4, colocate,
--rollout-fp8with bf16 training,--offload-train-target disk,--num-rollout 3, dapo-math-17k, 8192 response length, eval off. (This arm also dropped--use-fault-tolerancefrom the launcher, which turned out to be unnecessary:_FT_DEFAULT_COMPONENTSis["rollout"], so"train" not in ft_componentsand the witness / weight-checksum auto-enables that streaming rejects never fire. The H200 arm above kept FT on and confirms it:ft_components ['rollout'],enable_witness False,save_local_weight_checksum False, no assert. Streaming is only incompatible with an explicit--ft-components train, where refusing is the intended behaviour. Both arms here were symmetric, so the comparison is unaffected.)Three 3-step runs,
perf 0/1/2in each, no NaN, no traceback:train_rollout_logprob_abs_diff(steps 0/1/2)Agreement step by step across all three arms, so the fp32 path behaves as documented.
Metric here is the train actor's
allocated_GBfrom miles' ownprint_memoryat the end of the train phase, plus a 2 snvidia-smisampler windowed toactor_train start..end; steps 1-2 only.allocated_GBactor_trainBoth deltas check out against what the store reports:
Two things this arm needed that the H200 arm did not. torch_memory_saver at #80 —
radixark/miles:devships 0.0.9.post1 and tripped exactly the silent-corruption failure described under Guards, surfacing asfound NaN in local grad normin step 0's backward until the matching build was installed. And a one-line sglang fix (sgl-project/sglang#32385) for an unrelated uninitialised-scale bug that makes fp8 rollout unusable for any model withmoe_intermediate / 128 % 4 != 0, Qwen3-30B-A3B included.Measurement note: the device-level peak taken over the whole run is ~197 GiB in both arms and shows nothing, because
--sglang-mem-fraction-static 0.7dominates it. The numbers above window the sampler to the train phases, where the colocated engine is offloaded.Checkpoint save/load is covered separately, below.
Checkpoint save and resume
Run on the 8xH200 box, same arms as above,
--save-interval 1over 2 rollouts to make the hook fire, then a resume from the produced checkpoint. This is the first exercise ofsave_to/load_fromand therefore of the twocheckpointing.pyhooks in radixark/Megatron-LM#63.Save. Both iterations got their state, and the layout is what
relative_dirintends:16 manifests, i.e. 8 ranks x 2 chained members, no collisions — the point of moving the subdirectory naming out of Megatron and into the store, where the live scratch directory already spelled it. Per rank the store is 1 bucket / 1.5 GB (dense) plus 18 buckets / 27.0 GB (expert) at 8 B/param, so 228 GB of optimizer state across the 8 ranks; the whole
iter_0000001directory is 285 GB. Manifest contents check out:dtypes{main: float32, exp_avg: bfloat16, exp_avg_sq: bfloat16}, per-group Adamsteps [2, 2], andnumel 197,499,904for the dense bucket, just under the 200M cap.Cost of the synchronous copy, previously unmeasured:
Timer save_model57.0 s and 51.8 s. That covers the model dist-ckpt plus the 228 GBshutil.copyfileof the store, so it is an upper bound on the streaming part rather than an isolated figure.Resume. Both members load, and the manifest dtype and layout asserts pass:
The restored state is actually used, not just copied back. On a cold start the first step reads only the mains — 4/8 of the total with bf16 moments — because Adam has not created the moments yet. After a resume
allocate_moments()has pre-created them andload_fromhas filled them, so the very first step streams all three segments:iter_0000001Training continues normally from there: 2 x
outcome=NORMAL,train_rollout_logprob_abs_diff0.0186, in the same band as the arms above, job exits succeeded with no asserts and no NaN.