feat(sglang): support checkpoint-engine refit - #3519
tianyi-zhang-02 wants to merge 19 commits into
Conversation
35156f7 to
52d06f9
Compare
GPU follow-upI don't have an environment where SetupSingle node, 2 GPUs, Topology mirrors what the PR introduces:
To exercise ResultEvery weight arrived bit-exact on both ranks, exactly once, per-rank content distinct, across genuinely different transport boundaries (5 vs 6 batches). Caveats
One proposed change: drop an unreachable branchIn if any(finished[rank] and not queue for rank, queue in enumerate(pending)):
raise RuntimeError(
"Checkpoint-engine streams ended with different weights across "
"SGLang ranks."
)
- if any(not queue for queue in pending):
- continue
aligned = [[] for _queue in pending]The reason to delete rather than keep it as a defensive guard: if the invariant ever did break, Environment notes, in case they save someone time
Also happy to pushA property-test module for the alignment logic: randomized per-rank bucket layouts asserting global weight order is preserved, plus a receiver that poisons its recycled buffer to pin down the double-buffer contract in One thing worth confirming, since I could not verify it without a real server: SGLang 0.5.12.post1 asserts |
|
Pushed the property tests I mentioned, plus four more gaps. Each one was verified by mutation — the test fails when the behaviour is broken and passes when it is not — so none of them are assertions that cannot fail:
The buffer one is the reason I wanted this in the tree. The payload-index one is the property with the worst failure mode: SGLang indexes 149 unit tests passing across |
957601d to
c6be7b3
Compare
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Three gaps found reviewing this branch: - tests/functional/grpo_sglang_nixl_non_colocated.sh was referenced by nothing, so it had never run. The CI guard only checks that L1_Functional*.sh shards appear in the workflow matrix, not that leaf scripts are called. Register it with L1_Functional_Tests_SGLang.sh, which is already in all three matrices. - The only test of _aligned_checkpoint_engine_batches asserted weight names only. The aligner itself enforces name equality across ranks, so that assertion is invariant under any rank permutation. Mutating aligned[rank] -> aligned[len(pending)-1-rank] delivered every shard to the wrong TP rank and the suite stayed green. Assert the tensors too; the mutant now fails. - docs/design-docs/checkpoint-engines.md and docs/guides/checkpoint-engine-refit.md both still said SGLang has no checkpoint-engine refit. Update both, record the actual limits (one node per logical engine, no shard_expert_weights), and generalize the 'Adding Another Backend' timing-line step, which named the vLLM line only. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Five properties that had no coverage; each was verified by mutation, i.e. the test fails when the corresponding behaviour is broken and passes when it is not. - Recycled receive buffers. nixl.py hands out views into a rotating buffer pool, so a fake engine that allocates fresh tensors every batch makes that entire bug class invisible. _RecyclingEngine poisons a buffer once it is recycled. The aligner is correct today because it only advances a rank whose deque is empty; making it prefetch turns the yielded tensors into NaN. - Multi-dtype batches. NIXL packs buckets by bytes, not dtype, so a mixed bf16/fp32 batch is the normal production shape, but nothing drove more than one dtype group through the update path. - weight_version across refits. Its semantics were pinned only for the first refit, so bumping per POST instead of per refit went undetected. - base_gpu_id remapping. _to_local_gpu_id was stubbed to identity with base_gpu_id=0, which is exactly the case where remapping and doing nothing are indistinguishable. - Payload index to SGLang rank. SGLang indexes serialized_named_tensors by its own TP rank, so a transposed list loads every shard onto the wrong GPU and still reports success. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
refit.md still stated flatly that non-colocated SGLang generation is not supported, which now contradicts checkpoint-engine-refit.md on the same click (refit.md links to it). The statement is still true for every transport other than checkpoint-engine refit, which factory.py:107-110 rejects, so narrow it rather than delete it, and add SGLang to the NIXL full-weights row. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Two follow-ups on this PR's own changes.
The dp_size=1 guard (weight_sync/factory.py:90) was documented only in
checkpoint-engine-refit.md. design-docs/checkpoint-engines.md still said
'Both are rejected' for what is now three constraints, and refit.md's
constraint table did not mention it at all.
MetricSetupTiming.vllm_checkpoint_engine_init_time_s is no longer written
by anything: grpo.py:1504 moved to extras[f'{backend}_checkpoint_engine_
init_time_s']. For vLLM that formats to the same string, and to_dict()
merges extras over the typed fields, so the emitted metric name is
unchanged -- the field is just dead state now.
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The factory guarded dp_size and shard_expert_weights but not pp_size. The mixin creates one receiver per engine GPU, while SGLang indexes serialized_named_tensors by TP rank -- and sglang_worker.py:386 asserts tp_size == num_gpus_per_engine // pp_size, so with pp_size>1 the payload list is pp_size times longer than the engine expects. Fail loudly at setup instead. Recorded in all three docs alongside the other two limits. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Mirrors the dp_size case immediately above it. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
c162a24 to
29e6c3d
Compare
Kh4L
left a comment
There was a problem hiding this comment.
Reviewed at 29e6c3d, following up on @yuki-97's ping.
The direction is right: reusing CheckpointEngineWeightSynchronizer with per-rank NIXL receivers and name-aligned rank-local streams is the correct shape for this, the envelope guards fail at setup as they should, and the mutation-verified unit tests (especially the recycled-buffer one) are genuinely good.
Two blockers, both invisible to the current tests (CI has only run DCO here, so the new functional test has never executed against a real engine):
- The transfer never opens a
begin_weight_update/end_weight_updatesession — the pinned SGLang scheduler hard-asserts on the first bucket, and skippingend_weight_updateskips quantized-layout finalization (details inline). asyncio.run()inside the worker: the mixin's inherited coroutine turnsSGLangGenerationWorkerinto an asyncio Ray actor, so the entry point raisesRuntimeError: asyncio.run() cannot be called from a running event loop— reproduced on the locked ray 2.56.1 (details + one-line fix inline).
Also inline: two test-coverage suggestions, two config-conventions items, a doc/comment accuracy fix for what refit_transport: null means when non-colocated, one piece of dead code, and a forward-compat note on the #3613 fault-tolerance interplay.
One nit with no diff line to attach to: the exemplar comment above refit_transport: null in examples/configs/grpo_math_1B.yaml still says nixl is vLLM-only ("Non-colocated vLLM also supports vllm_s3_sparse, vllm_zmq_sparse, and nixl.") — worth updating, and grpo_math_1B_sglang.yaml could carry a short documented refit_transport note since the keys are new on SGLangConfig.
On the overlap question from #3288: #3612 has merged, so this PR is the right vehicle for the SGLang checkpoint-engine path — keeping it standalone on top of the merged seam is the right call. The quantized follow-ups (#3614/#3615) will stack cleanly once the session envelope is in.
|
|
||
| try: | ||
| with context: | ||
| if self._is_sglang(): |
There was a problem hiding this comment.
Blocker: the SGLang path never opens a weight-update session — the first bucket fails on a scheduler assert at the pinned sglang rev.
The sglang rev this repo pins (3003d70f, unchanged by this PR) gates tensor updates in scheduler_components/weight_updater.py:254-256:
assert (
self._weight_update_in_progress
), "update_weights_from_tensor requires an open begin_weight_update session"The flag is set only by begin_weight_update, and this path never calls it: prepare_for_generation(tags=[...]) is memory-onload (resume_memory_occupation), not session bracketing, and the mixin then POSTs /update_weights_from_tensor directly. Skipping end_weight_update is independently wrong too — it's what runs postprocess_weight to finalize quantized/MoE kernel layouts after the last bucket (the seam the quantized-refit follow-ups to #3612 rely on).
Fix: wrap the SGLang branch in the same envelope as the sibling _SGLangWeightSynchronizer._refit (sglang_weight_synchronizer.py:150-180): pause_generation → invalidate_kv_cache → begin_weight_update → transfer → end_weight_update in a finally → continue_generation in an outer finally. Both hooks already exist on SGLangGeneration and the worker. The pause also makes the multi-bucket update atomic against in-flight requests: each update_weights_from_tensor takes the server's model_update_lock per call, so requests admitted between buckets would otherwise run on a mixed old/new model, and invalidate_kv_cache()'s /flush_cache 400s while requests are pending.
(The unit tests stub the worker's update_weights_from_tensor, which is why this never surfaced — see the test-file comment.)
There was a problem hiding this comment.
You're right, there was no session here at all. Fixed in fbf667f: the SGLang branch now runs the same sequence as _SGLangWeightSynchronizer._refit, pause then flush then begin, transfer, end_weight_update in a finally, continue_generation in an outer one. c963c83 moves the kv_cache re-onload into that same finally, otherwise a failed refit resumes with the pool still released.
Dropped the worker's post-transfer invalidate_kv_cache() since the flush happens before the buckets now. Also took the in_place rejection from sglang_weight_synchronizer.py:80-83, this path pauses through the same hook and had no guard.
| return True | ||
|
|
||
| def update_weights_from_checkpoint_engine(self) -> bool: | ||
| return asyncio.run(self._update_weights_from_checkpoint_engine_async()) |
There was a problem hiding this comment.
Blocker: this asyncio.run() raises RuntimeError: asyncio.run() cannot be called from a running event loop on the first refit.
Mixing this class into the @ray.remote SGLangGenerationWorker makes it an asyncio actor: Ray's has_async_methods uses inspect.getmembers(cls, predicate=is_async_func), which counts the inherited private _update_weights_from_checkpoint_engine_async (ray/actor.py:1806 at the locked ray 2.56.1). Asyncio actors run sync methods on the actor's event loop, so the first update_weights_from_checkpoint_engine.remote() crashes. Reproduced with a minimal actor of exactly this shape on ray 2.56.1 (a pure-sync control actor works).
The unit tests miss it because they asyncio.run the private coroutine on a non-actor object; tests/functional/grpo_sglang_nixl_non_colocated.sh will hit it at the step-1 refit.
Fix — make the entry point async and let Ray await it natively:
async def update_weights_from_checkpoint_engine(self) -> bool:
return await self._update_weights_from_checkpoint_engine_async()or keep the receive loop off the actor class entirely, like the vLLM analog (its asyncio.run runs inside vLLM's internal worker via collective_rpc, never as a Ray actor method). Either way, worth a visible note: any coroutine on this class flips all pre-existing worker RPCs (init/shutdown/colocated refit) from threaded to asyncio-actor semantics.
Minor, same function: if every receiver yields zero batches, this still bumps _checkpoint_engine_weight_version, invalidates the KV cache, prints "Loaded 0 rank-local tensors", and returns True. Consider raising when loaded_tensors == 0 so a no-op sender can't report a successful refit.
There was a problem hiding this comment.
Reproduced it, same error text. Fixed in 2034cb9, but with your second option rather than the one-liner: making the entry point async fixes the crash and keeps the flip, so every other worker RPC still gets asyncio-actor semantics out of a change that has nothing to do with them. The receive loop is a module-level coroutine now, so the mixin has no coroutine member and the actor stays as it was.
Took the minor point too, zero tensors raises instead of reporting a refit.
| def _to_local_gpu_id(self, gpu_id): | ||
| return gpu_id | ||
|
|
||
| def update_weights_from_tensor(self, **kwargs): |
There was a problem hiding this comment.
The suite stubs exactly the two contracts that are broken in production, so it stays green while the feature's only entry point fails:
_Workeroverridesupdate_weights_from_tensorto return{"success": True}, hiding the server-side session assert at the pinned sglang rev ("update_weights_from_tensor requires an open begin_weight_update session").- Every test drives
asyncio.run(worker._update_weights_from_checkpoint_engine_async())on a plain object; nothing calls the publicupdate_weights_from_checkpoint_enginethrough a Ray actor, which is where the asyncio-actor crash lives — that wrapper is the one mixin method with zero coverage.
Suggestions once the fixes are in: exercise update_weights_from_checkpoint_engine through a real @ray.remote actor (or at minimum switch one existing test to call the public wrapper), and assert the begin_weight_update → updates → end_weight_update ordering at the synchronizer level.
There was a problem hiding this comment.
Both right. The _Worker double now asserts an open session using the pinned rev's own message instead of returning success unconditionally, and the synchronizer tests pin begin/update/end ordering plus the failure paths: transfer raises, kv flush fails, begin_weight_update itself raises, pause_generation itself raises.
My first version of the no-asyncio-actor guard was wrong in a way worth flagging. I wrote iscoroutinefunction over the mixin; Ray uses iscoroutinefunction or isasyncgenfunction over the actor class. So an async generator moved back onto the mixin left it green, which is exactly the shape at hand since the receive loop is one. c963c83 calls Ray's has_async_methods instead.
Couldn't do the real @ray.remote actor in this file though. pytest names the module sglang.test_checkpoint_engine, so the directory shadows the real package and the actor fails to import in the worker. Can put it elsewhere if you want it.
| policy.generation.colocated.resources.gpus_per_node=1 \ | ||
| policy.generation.refit_transport=nixl \ | ||
| policy.generation.use_async_rollouts=false \ | ||
| policy.generation.sglang_cfg.tp_size=1 \ |
There was a problem hiding this comment.
Non-blocking: with tp_size=1 and a single one-GPU engine, the machinery that differentiates this PR degenerates to the trivial case — one stream (nothing to align), _validate_rank_batches compares nothing, and update_weights_from_tensor gets a one-payload list, so the payload-i → TP-rank-i scatter contract never runs against a real server. As test_checkpoint_engine_payload_index_matches_sglang_rank's own docstring notes, a transposed payload list "would load every shard onto the wrong GPU while still succeeding".
Suggest a tp_size=2 variant (e.g. cluster.gpus_per_node=3, generation on 2 GPUs) — there's precedent for 3-GPU functional tests (grpo_sc_gym_router_failover.sh), and the existing token_mult_prob_error < 1.05 gate would catch shard misplacement.
There was a problem hiding this comment.
Agreed, tp_size=1 doesn't exercise the scatter at all. Added grpo_sglang_nixl_non_colocated_tp2.sh in 6b83532 (gpus_per_node=3, engine on 2, trainer on 1), registered as full-mode in L1_Functional_Tests_SGLang.sh with the same token_mult_prob_error gate.
Worth saying plainly: I haven't run either functional test, so CI is the first real execution of both.
| checkpoint_engine_config["backend"] | ||
| ] | ||
| if generation_backend == SGLANG_BACKEND: | ||
| sglang_cfg = generation.cfg.get("sglang_cfg", {}) |
There was a problem hiding this comment.
Call-site config defaults (config-conventions forbids these for TypedDict configs — defaults live only in the exemplar YAML):
generation.cfg.get("sglang_cfg", {})—sglang_cfgis a required key ofSGLangConfig, and this branch already knows the backend is SGLang; the{}fallback lets a malformed config silently pass the dp/pp guards below instead of failing loudly.sglang_cfg.get("dp_size", 1)/.get("pp_size", 1)— non-None defaults at the access site; sibling code reads these keys bare (sglang_worker.py:449/475), and their defaults already live ingrpo_math_1B_sglang.yaml:18-19.
sglang_cfg = generation.cfg["sglang_cfg"]
if sglang_cfg["dp_size"] != 1: ...
if sglang_cfg["pp_size"] != 1: ...There was a problem hiding this comment.
Done in fbf667f, indexed the way you wrote it. shard_expert_weights lost its False literal too. Three fixtures were each missing a key the guards now need, so there's a _sglang_refit_cfg() helper.
| # Null uses the colocated CUDA-IPC path. ``nixl`` and custom checkpoint | ||
| # engines are supported for non-colocated generation. | ||
| refit_transport: NotRequired[str | None] | ||
| refit_cfg: NotRequired[Any] |
There was a problem hiding this comment.
Non-blocking: refit_cfg: NotRequired[Any] types a known-field block as Any (config-conventions: avoid Any for known-field configs). It's normalized by the same schema as vLLM's — checkpoint_engine_refit_config runs normalize_vllm_refit_config and writes the validated VllmRefitConfig back into config["refit_cfg"]. Mirror the vLLM analog (nemo_rl/models/generation/vllm/config.py:175):
refit_cfg: NotRequired[VllmRefitConfig | None]vllm/config.py imports only typing/pydantic/interfaces, so this adds no import cycle and no vllm dependency to the SGLang venv (verified). If the vllm-named import feels off here, hoisting the schema to a shared module works too.
There was a problem hiding this comment.
Done in c3a1687. Checked the import cost, it's free: the vLLM config chain was already being pulled in before this PR.
If the vllm-named import here bothers you later I'm happy to hoist the three refit models into a shared module. weight_sync/checkpoint_engine_config.py already imports all of them.
|
|
||
| sglang_cfg: SglangSpecificArgs | ||
| sglang_kwargs: NotRequired[dict[str, Any]] | ||
| # Null uses the colocated CUDA-IPC path. ``nixl`` and custom checkpoint |
There was a problem hiding this comment.
This comment is inaccurate for non-colocated SGLang: with colocated.enabled: false, refit_transport: null selects SGLangDisaggregatedWeightSynchronizer (SGLang's NCCL weight-update group with a Megatron policy), not CUDA-IPC — exactly what this PR's own docs/guides/refit.md table says. Suggest:
# Null selects the default refit path: Ray CUDA-IPC when colocated,
# SGLang's NCCL weight-update group when non-colocated. ``nixl`` and
# custom checkpoint engines are supported for non-colocated generation.Same wording nit in the new NotImplementedError in grpo.py ("or null for colocated CUDA-IPC refit") — that raise can fire in non-colocated runs where null means the NCCL path, steering users toward nixl as their only option.
There was a problem hiding this comment.
Fixed here and in the grpo.py raise (c3a1687), with one qualifier on your wording. Non-colocated null only reaches the NCCL group with a Megatron policy, factory.py:165 raises otherwise, so a DTensor user following that message hits a second NotImplementedError. Both now say it requires Megatron today. Drop it if you'd rather keep them short.
Also put the envelope and the in_place restriction in docs/guides/checkpoint-engine-refit.md.
| for j in range(len(self.engines)) | ||
| ] | ||
|
|
||
| def get_rollout_engine_urls(self) -> list[str]: |
There was a problem hiding this comment.
Non-blocking: get_rollout_engine_urls() has zero callers anywhere in the tree — it looks like a leftover from the earlier HTTP-transport design. The checkpoint-engine path dispatches through run_checkpoint_engine_method() actor RPCs and never resolves engine URLs. Suggest dropping it here and landing it with whichever follow-up actually consumes it (the worker-side get_base_url() it wraps already exists on main).
There was a problem hiding this comment.
Gone in c3a1687. Checked for string dispatch too (getattr, run_checkpoint_engine_method, collective_rpc), nothing reaches it. Left get_base_url alone.
| "SGLang checkpoint-engine refit does not support " | ||
| "shard_expert_weights=true; use full-weight MoE refit instead." | ||
| ) | ||
| if getattr(self, "checkpoint_engines", None) is not None: |
There was a problem hiding this comment.
Forward-compat note (non-blocking), from the #3613 side of the stack: this early return makes the receiver set permanent for the actor's lifetime, and nothing can invalidate checkpoint_engines / _checkpoint_engine_target_devices / the NIXL process groups. #3613 adds engine fault tolerance where a dead SGLang engine is restarted and rejoined mid-run — after such a restart these cached receivers would hold stale NIXL registrations and rank state. No action needed in this PR beyond awareness; when both land we'll need a reset hook here (invalidate + re-init on recovery) rather than the early return. Flagging so the constraint is on record — happy to coordinate.
The SGLang branch of sync_weights only called prepare_for_generation, which is resume_memory_occupation and nothing else, and then POSTed update_weights_from_tensor directly. At the pinned sglang rev that is rejected: the scheduler asserts on a session opened by begin_weight_update. end_weight_update is also what rebuilds quantized kernel layouts after the last bucket. Wrap the transfer in the same envelope the sibling SGLang synchronizer uses, so both transports drive the engine through one contract. The pause is load-bearing too: the buckets arrive as several update_weights_from_tensor calls, each taking the server's model update lock on its own, so a request admitted between buckets would run against a half-updated model. Also port the in_place pause rejection, which this path lacked, and drop the call-site config defaults the dp/pp guards were reading through. The existing tests could not see any of this -- they stub the worker's update_weights_from_tensor -- so add ordering and both failure paths. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Ray decides between a threaded and an asyncio actor with has_async_methods, which is inspect.getmembers over the whole MRO. The private coroutine on this mixin therefore turned SGLangGenerationWorker into an asyncio actor, and Ray runs an asyncio actor's sync methods on that event loop -- so the asyncio.run in the refit entry point raised 'asyncio.run() cannot be called from a running event loop' on the first refit. Making the entry point async would fix the crash but leave the flip in place, changing the concurrency semantics of every pre-existing worker RPC. Move the loop to a module-level coroutine instead: the mixin defines no coroutine members, so the actor stays threaded. The tests missed it because they drove the private coroutine on a plain object; cover the public wrapper, and assert the no-coroutine-member invariant, which is the thing that actually regresses. While here: a refit that received zero tensors now raises instead of bumping the weight version and reporting success, and the post-transfer invalidate_kv_cache is dropped -- the synchronizer now flushes before the buckets land. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…ad code refit_cfg was NotRequired[Any] for a block normalized by exactly the vLLM schema, so give it the same type. The refit_transport comments and the grpo.py raise claimed null always means colocated CUDA-IPC, which is wrong for non-colocated SGLang, where it selects the NCCL weight-update group. get_rollout_engine_urls has no callers -- a leftover from the earlier HTTP transport design. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
With one one-GPU engine the payload-index -> TP-rank scatter never runs: one stream has nothing to align, _validate_rank_batches compares nothing, and update_weights_from_tensor gets a one-payload list. A transposed payload list would load every shard onto the wrong GPU while still succeeding, and only tp_size>1 can catch that. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Self-audit of the review fixes turned up four gaps. The no-asyncio-actor guard restated Ray's rule instead of asking Ray. Ray flips on iscoroutinefunction OR isasyncgenfunction, over the ACTOR class; the test checked only iscoroutinefunction, only on the mixin. An async generator moved back onto the mixin -- the exact shape at hand, since the receive loop is one -- left it green. It now imports Ray's own has_async_methods and asks about SGLangGenerationWorker. The KV pool was re-acquired on the success path while the pause was undone on every path, so a failed refit resumed the engines with the pool still released. Both now live in the same finally, KV first so requests are not readmitted before the memory is back. Nothing tested the two invariants the comments assert: that end_weight_update never closes a session begin_weight_update failed to open, and that a pause which itself raises still resumes. Both are covered now, as are the engine-reported-failure branch and the fact that the in_place guard is SGLang-only. The transport wording promised more than the code delivers: SGLang's NCCL weight-update group is Megatron-policy only (factory.py:165), so a DTensor user following the message would hit a second NotImplementedError. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
|
Thanks — this is a great review, and the framing was the useful part: both blockers were invisible to the tests I had, and that was the actual defect. I checked each point myself before changing anything; all nine hold. Fixes pushed, one section per comment. Blocker 1 — no weight-update session. Confirmed. The SGLang branch of Fixed by mirroring it: While porting I also took the Blocker 2 — I took your second option rather than the one-liner. Making the entry point On the vLLM twin you flagged: it's safe, and for the reason you'd expect — Took the minor point too: zero tensors received now raises instead of bumping the weight version and returning True. Test blind spot. Both halves were right, and this was the part worth fixing properly.
All the new synchronizer tests are red on the previous head and green now. I also ran a mutation matrix over the envelope — begin moved after the transfer, Worth reporting honestly: my first version of the asyncio guard was wrong in a way that would have mattered. It restated Ray's rule as One thing I could not do as suggested: a real
Also wrote the envelope and the
#3613 / receiver reset. Understood, and thanks for putting it on record here rather than after the fact. I left the constraint as a comment on the early return so whoever adds the reset hook finds it at the site. Ping me when #3613 is close and I'll rebase around it rather than the other way round. |
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
|
Real-server follow-up on final SHA
cc @Kh4L — this is the real server path behind the two blockers from your review. |
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
ff253a3 to
9725291
Compare
What does this PR do?
Adds checkpoint-engine refit for non-colocated SGLang:
pause→ KV invalidation →begin_weight_update→ transfer →end_weight_update→ resume);dp_size=1,pp_size=1, no expert-weight sharding, and noin_placepause mode;tp_size=1andtp_size=2functional coverage.The receive loop stays outside the actor class, so this does not turn the existing synchronous SGLang worker into an asyncio Ray actor.
The legacy refit-metadata gather is skipped for SGLang: this path does not use it, and gathering every DTensor shard previously OOMed the TP2 test before the server could receive a weight. The functional test below then reached both refits and completed training.
This covers the SGLang half of #3288. It has no unmerged dependency; the Megatron-generation half remains out of scope.
Validation
Current head:
97252916e1aa6c9082cb2716d2d82bb05837a5a1, includingmainatfd7112c374af23b3bcc392637fb737495f8a3f31.On 2026-09-21, both changed checkpoint-engine unit-test files passed on Runpod Secure Cloud: 50 passed, 0 failed/skipped. Environment: 1× H100 80 GB,
nvcr.io/nvidia/nemo-rl:v0.7.0, driver 580.126.09, CUDA 13.0, PyTorch 2.11.0+cu130. The current-main test fixture requiredpytest==9.0.3and pinnednemo-lenscommitb85578f, installed only in the temporary Pod. The refresh also updates one factory-routing test for main's newly supported non-colocated Megatron route.Earlier, at head
5925bcf5b07fd438e9f385fb2bd1e1d21531ce33,tests/functional/grpo_sglang_nixl_non_colocated_tp2.shpassed on a Runpod 4×H100 host with 2 SGLang TP ranks and 2 DTensor policy ranks. That run usedNCCL_NVLS_ENABLE=0, matching the repository's H100 recipe workaround.train/token_mult_prob_errorBoth steps completed without OOM, traceback, or NCCL error; the metric stayed below the functional threshold of
1.05. The 4-GPU functional test was not rerun after the main-only refresh and test expectation change; this session provides 1-GPU unit coverage only.