feat(optimizer): NVMe streaming of DistributedOptimizer state#63
Open
yueming-yuan wants to merge 6 commits into
Open
feat(optimizer): NVMe streaming of DistributedOptimizer state#63yueming-yuan wants to merge 6 commits into
yueming-yuan wants to merge 6 commits into
Conversation
fp32 main params and Adam moments live in per-bucket NVMe files and are streamed through the GPU bucket-by-bucket during the optimizer step via per-bucket FusedAdam instances. Bounds step-time GPU residency to one bucket. Checkpointing optimizer state is guarded (not supported yet).
Zhichenzzz
approved these changes
Jul 8, 2026
- Evict fp32 main params to NVMe at store init: with lazy eviction they stay GPU-resident through the entire first rollout + forward/backward and OOM tight configs before the first step can flush them. - Route _copy_model_params_to_main_params through the store (materialize, copy, flush, release): checkpoint load refreshes main params, which otherwise writes into evicted storage (illegal memory access). - Chunk state specs to a fixed 200M-numel budget: DDP bucketing leaves an entire expert buffer as a single bucket when expert-DP is 1, so bucket-granular streaming materializes ~90 GiB at once on those ranks. Validated on GLM-5.2 744B, 8x GB300 (TP8/PP4/DP1/EP8): two full RL steps, flat 142 GiB watermark through the 120-bucket step loop, actor_train 287s/216s (16-node baseline ~170s), train_rollout_logprob_abs_diff in the reference band.
Zhichenzzz
added a commit
to radixark/miles
that referenced
this pull request
Jul 21, 2026
Replace the shared-directory + pid-liveness-check reclaim (fragile: pid reuse after a dead process could make an orphaned file look alive forever) with a per-rank/per-cell exclusive subdirectory, the same convention as Megatron-LM's NVMeOptimizerStateStore (radixark/Megatron-LM#63): wipe-then-recreate on startup, plain rmtree on exit, no cross-process coordination needed since directories are never shared. actor_factory.py now computes TMS_DISK_BACKUP_DIR per rank (cell{i}_rank{j}) via a per-actor runtime_env override instead of one shared path for every actor; actor.py's reclaim reads back that same env var so the Python-side cleanup always targets exactly what the C++ hook wrote to.
Route them through a small always-resident Adam instead of hard-rejecting any model that has them; NVMe-stream only the bf16/fp16 buckets, which is where the actual memory pressure is.
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.
Why
Step-time GPU residency of optimizer state (fp32 main + Adam moments, 12 B/param) is the binding constraint for training GLM-5.2 744B on 8 GB300 nodes: ~279 GB/rank against 276.6 GB of HBM, before params/grads/activations. Sleep-window offload (miles #1575 + torch_memory_saver #80) cannot help — the state must be resident when the FusedAdam kernel runs.
How
--optimizer-state-nvme-dir DIRmoves the state's home to per-bucket files on node-local NVMe ([main | exp_avg | exp_avg_sq], laid out per param shard in group order).step()processes buckets one at a time: materialize (untyped_storage().resize_) -> load -> per-bucket FusedAdam step -> copy updated mains into the param buffer -> write back -> release. GPU residency is bounded by one bucket.stepcounters exact, no kernel math duplicated.reload_model_paramsand the first colocate sleep behave exactly as today); moments are lazily created by FusedAdam at step 1 and spill with the mains.--optimizer-state-nvme-chunk-mb, default 256). Startup wipe + atexit reclaim bound disk usage across runs.state_dict/sharded_state_dictraise (checkpointing optimizer state lands in a follow-up).Testing
Qwen3-30B-A3B RL (miles colocate, 8xH200, TP1/PP1/DP8/EP8, fp32 state): two full rollout->train cycles, zero errors.
allocated_GBdrops 43.1 -> 31.8 after the first step — exactly bf16 params + fp32 grads; main+moments fully off-GPU in steady state.🤖 Generated with Claude Code
Update (2026-07-09): hardened for large-model DP1, validated on GLM-5.2 744B @ 8x GB300
Commit
13a21d94afixes three issues found bringing this up at scale (TP8/PP4/DP1/EP8, 23.25B params/rank, 279 GB state/rank), none reproducible on the 8x H200 Qwen3-30B A/B (TP1/DP8: small buckets, wide margins):step()(lazy eviction). The first rollout + forward/backward runs before any step, so tight configs OOM before the store ever flushes. Now evicted at store init.load_checkpoint -> reload_model_params -> _copy_model_params_to_main_paramswrites into the evicted storage. The refresh now routes through the store (materialize -> copy -> flush -> release), which also keeps main semantics correct (post-load weights are the truth).Validation (GLM-5.2 744B, 8x GB300, full miles RL steps):
actor_train287s (step 0, warm-up) / 216s (step 1, full main+m/v reads) vs ~170s 16-node baseline: NVMe exposure ~46s, mostly absorbed by page cachetrain_rollout_logprob_abs_diff0.0584 / 0.0548, in the 16-node reference bandUpdate (2026-07-09): checkpoint save/load (same-topology resume)
Commits
ff23ee27a+34d73a3c2replace the v1state_dict/sharded_state_dictraise with a minimal resume path — the flat bucket files on scratch are the state, so checkpointing is a per-rank file copy:store.save_to()copies each bucket file into<ckpt>/nvme_opt_state/rank%04d_opt{instance}_{uid}/plus amanifest.json(per-bucket numel, entry numels, Adam step counts). Hooked intosave_checkpointafter the regular optimizer path; dist-ckpt keeps handling model weights untouched (state_dictreturns a marker,sharded_state_dictreturns{}).store.load_from()asserts the manifest layout matches the freshly built specs (same-topology resume only), copies files back, restores Adam step counts, and pre-creates zero-storage moment tensors so the first step streams everything in. Skipped for--finetune/--no-load-optim; a checkpoint withoutnvme_opt_statelogs a notice and starts fresh (old checkpoints stay loadable).distributed_optimizer_instance_id, so both stores landed in the same directory, each wiping the other's files. Runtime survived by POSIX unlink-fd semantics (each store still held fds to its unlinked files) — any path-based access (like save) hit corrupted state. Fixed with a per-process construction counter in the directory name (opt0_0/opt0_1), deterministic across runs.Validation (GLM-5.2 744B, 8x GB300, TP8/PP4/DP1/EP8):
train_rollout_logprob_abs_diff0.0547 (in band)