[sglang] Experimental dense tensor-parallel recipe (NCCL all-reduce over VMM-IPC)#2
Conversation
Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
SAVE->SAVE->LOAD validated on 2x H200 (no IMEX): no MMU/Xid/CUDA fault, per-rank final_alloc_offset reproducible across passes, LOAD ~52s vs ~172s cold SAVE, and temperature-0 LOAD-engine tokens match the warmed SAVE engine exactly. scratch_space_size=1024MB sufficient. Document the required pre-#26735 sglang base for the foundry hook. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…C (#26735) sglang PR #26735 removed init_forward_metadata_capture_cuda_graph in favor of init_forward_metadata_out_graph(forward_batch, in_capture) + _in_graph. Foundry's FlashInfer reuse-prepass shim and the LOAD-side metadata pre-pass called the removed method, so SAVE aborted with AttributeError on any post-rename sglang (all modes, not just TP). Detect the API by hasattr and drive the new out_graph(in_capture=True) path (building a ForwardBatch-like view for the pre-pass); the capture-time reuse shim now intercepts out_graph, sets forward_metadata for the pre-allocated wrappers, and re-plans via out_graph(in_capture=False) with no re-allocation. Falls back to the legacy method on pre-rename sglang, so no version pin is required. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
| return SimpleNamespace( | ||
| batch_size=bs, | ||
| forward_mode=cuda_graph_runner.capture_forward_mode, | ||
| req_pool_indices=buffers.req_pool_indices[:bs], | ||
| seq_lens=seq_lens, | ||
| seq_lens_cpu=buffers.seq_lens_cpu[:bs], | ||
| seq_lens_sum=int(seq_lens.sum().item()), | ||
| encoder_lens=encoder_lens, | ||
| spec_info=cuda_graph_runner.get_spec_info(num_tokens), | ||
| positions=buffers.positions[:num_tokens], | ||
| ) |
There was a problem hiding this comment.
🔍 build_capture_fb_view accesses buffers.seq_lens_cpu not used by the legacy path
build_capture_fb_view (python/foundry/integration/sglang/graph_ops.py:271) reads buffers.seq_lens_cpu[:bs], an attribute the legacy pre-pass never accessed (the legacy path derived CPU seq lens via seq_lens.cpu() inside reuse_pre_pass_init at hooks.py:565). If cuda_graph_runner.buffers in the targeted sglang build does not expose seq_lens_cpu as an attribute, this raises AttributeError during the pre-pass on both SAVE and LOAD. sglang is not vendored in this repo so I could not verify the attribute exists on the buffers namespace; the other accessed fields (seq_lens, req_pool_indices, positions, encoder_lens) are confirmed used by existing legacy code, but seq_lens_cpu is new. Worth confirming against the pinned sglang HEAD build.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
buffers.seq_lens_cpu does exist on post-#26735 sglang. In cuda_graph_runner.py, DecodeInputBuffers declares seq_lens_cpu and the runner allocates + fills it (self.seq_lens_cpu.fill_(seq_len_fill_value) / self.seq_lens_cpu[:raw_bs].copy_(forward_batch.seq_lens_cpu)), and sglang's own build_replay_fb_view reads buffers.seq_lens_cpu[:bs] — this port uses the same field the upstream replay view does.
Also now GPU-validated: I re-ran the dense TP recipe (Qwen3-8B, TP=2, 2×H200) against the sglang foundry HEAD (the new init_forward_metadata_out_graph API, which is what exercises build_capture_fb_view). All 4 phases healthy, errors=[], no AttributeError in the pre-pass. So this path is exercised, not just the pre-rename base.
| def reuse_out_graph(forward_batch, in_capture=False): | ||
| # Only the capture path (in_capture=True) must avoid | ||
| # re-allocating the per-bs wrappers the pre-pass already built; | ||
| # replay / eager (in_capture=False) pass straight through. | ||
| if not in_capture: | ||
| return real_out_graph(forward_batch, in_capture=in_capture) | ||
|
|
||
| from sglang.srt.layers.attention.flashinfer_backend import ( | ||
| DecodeMetadata, | ||
| PrefillMetadata, | ||
| ) | ||
|
|
||
| bs = forward_batch.batch_size | ||
| forward_mode = forward_batch.forward_mode | ||
| if forward_mode.is_decode_or_idle(): | ||
| wrappers = attn_backend.decode_cuda_graph_metadata.get(bs) | ||
| if wrappers is None: | ||
| return real_out_graph(forward_batch, in_capture=True) | ||
| attn_backend.forward_metadata = DecodeMetadata(wrappers) | ||
| elif ( | ||
| forward_mode.is_target_verify() | ||
| or forward_mode.is_draft_extend() | ||
| or forward_mode.is_dllm_extend() | ||
| ): | ||
| wrappers = attn_backend.prefill_cuda_graph_metadata.get(bs) | ||
| if wrappers is None: | ||
| return real_out_graph(forward_batch, in_capture=True) | ||
| attn_backend.forward_metadata = PrefillMetadata( | ||
| wrappers, forward_mode.is_dllm_extend(), False | ||
| ) | ||
| else: | ||
| return real_out_graph(forward_batch, in_capture=True) | ||
| # Re-run the planner against the pre-allocated wrappers with | ||
| # in_capture=False: no ``_prepare_cuda_graph_metadata`` (so no | ||
| # second torch.empty for ``_int_workspace_buffer``) and no | ||
| # begin_forward re-install (the pre-pass did that). We set | ||
| # forward_metadata above since that path skips it. | ||
| real_out_graph(forward_batch, in_capture=False) |
There was a problem hiding this comment.
📝 Info: reuse_out_graph re-planning semantics differ from the legacy reuse_pre_pass_init
The new-API shim reuse_out_graph (python/foundry/integration/sglang/hooks.py:635-672) sets fi_backend.forward_metadata from the pre-allocated wrappers and then calls real_out_graph(forward_batch, in_capture=False) to re-run the planner without re-allocating, whereas the legacy reuse_pre_pass_init (hooks.py:477-574) explicitly calls indices_updater_decode.update(...) / indices_updater_prefill.update(...). The correctness of the new path hinges on the assumption (stated in comments) that init_forward_metadata_out_graph(..., in_capture=False) re-plans against the existing wrappers, does NOT allocate a second _int_workspace_buffer, and does NOT overwrite forward_metadata. This is an assumption about sglang internals that can't be verified from this repo; it is reportedly GPU-validated. If a future sglang changes in_capture=False to re-set forward_metadata or re-allocate, the VMM cursor determinism (or the manually-set forward_metadata) would silently break.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Both assumptions check out against the real post-#26735 sglang, and the path is now GPU-validated.
- Return value ignored by the capture loop.
init_forward_metadata_out_graphis avoidmethod upstream (base_attn_backend.py), and the CUDA graph capture path calls it purely for side effects —attn_backend.init_forward_metadata_out_graph(forward_batch, in_capture=True)— without consuming a return. Soreuse_out_graphreturningNoneon the reuse-success path is fine; I only forward the real method's return on fallback for symmetry. in_capture=Falsedoes not allocate. Inflashinfer_backend.py::init_forward_metadata_out_graph, the only allocator (_prepare_cuda_graph_metadata, which does thetorch.emptyfor_int_workspace_bufferand creates the wrappers) is guarded byif in_capture:. Within_capture=Falseit just re-runsindices_updater_*.update(...)against the already-allocated*_cuda_graph_metadata[bs]wrappers. That's exactly why the reuse shim delegates toin_capture=False— no fresh graph memory. It also skips theforward_metadataassignment (only_prepare_*sets it), which is why the shim setsforward_metadataexplicitly before delegating.
GPU evidence (new-API path): Qwen3-8B, TP=2, 2×H200 on sglang foundry HEAD, baseline→SAVE→SAVE→LOAD: per-rank final_alloc_offset=121831948288 identical across both SAVE passes and identical to the pre-rename-base run, LOAD healthy in 79s vs 250s cold SAVE, and temperature-0 SAVE-engine==LOAD-engine exact-token match on all 16/16 prompts, errors=[]. So the VMM cursor does not diverge SAVE-vs-LOAD on the ported path.
There was a problem hiding this comment.
Following up now that the new-API path has been exercised much harder than the 8B check:
- 8B / TP=2 on renamed sglang HEAD (foundry-org/sglang
foundry, post-#26735, theinit_forward_metadata_out_graphABC): baseline→SAVE→SAVE→LOAD,errors=[], per-rankfinal_alloc_offsetreproducible across both SAVE passes, 16/16 exact temperature-0 SAVE-engine==LOAD-engine token match. - The
in_capture=False"no second_int_workspace_bufferalloc" assumption is directly falsifiable and holds: if the replan re-allocated the workspace, the SAVEfinal_alloc_offsetwould drift and LOAD would land at a different cursor. It doesn't — SAVEfinal_alloc_offsetis byte-identical across passes and LOAD's post-load cursor matches it exactly. - Now also exercised on a hybrid MoE (Qwen3.5-122B-A10B, TP=4) where FlashInfer is nested under
HybridLinearAttnBackend.full_attn_backend: the pre-pass runs cleanly and SAVE/LOAD reach byte-identical VMM offsets (save_after_pre_init== LOADafter_pre_init; SAVEfinal_alloc_offset== LOADafter_load_all_graphs).
So the new-API branch is validated on the renamed build, not just the pre-rename base — the README claim is backed by GPU evidence. (122B still has a separate, non-FlashInfer decode-graph replay issue unrelated to this shim; tracked separately.)
There was a problem hiding this comment.
Correct that this hinges on sglang's in_capture=False semantics (re-plan against existing wrappers, no second _int_workspace_buffer alloc, no forward_metadata overwrite). That's why the shim sets forward_metadata explicitly before the re-plan call and why we assert byte-identical VMM offsets between SAVE and LOAD as the guardrail: save_after_pre_init == LOAD after_pre_init and SAVE final_alloc_offset == LOAD after_load_all_graphs (validated equal on 122B). If a future sglang changed in_capture=False to re-allocate or re-set metadata, those offsets would diverge and the run would fault/mismatch rather than silently drift — so the determinism check is the tripwire. The legacy indices_updater_*.update(...) path remains for pre-#26735 sglang. Not making a code change here; flagging that the offset-equality assertion is the intended protection against exactly this drift.
Foundry skips sglang kernel_warmup and suppresses the pre-capture warmup forwards in SAVE for VMM-cursor determinism, and previously only ran its own neutered pre-capture warmup (_run_warmup_pass) for the DeepEP/EP path. On the dense path the model's first forward therefore ran *inside* the CUDA-graph capture; on Qwen3.5 that first forward lazily triggers torch.compile/inductor (vocab-parallel get_masked_input_and_mask -> inductor SFDP lazy_init), whose CPU<->CUDA copy is illegal mid-capture and aborts capture with 'Graph contains no nodes'. Run the warmup on all SAVE so lazy inductor/JIT init happens outside capture; LOAD still preallocates absolute offsets and never warms up, so the cursor stays symmetric. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This reverts commit c95648a.
…orted Update the dense-TP README: the hook is ported to sglang's new init_forward_metadata 3-method ABC and validated on foundry HEAD (8B/TP=2 reproduces bit-for-bit), so the version-pin note is removed. Add a section documenting that Qwen3.5-122B-A10B (hybrid Mamba/GatedDeltaNet + MoE + multimodal) is not supported by this dense recipe, with the exact blockers (SAVE capture 'Graph contains no nodes' from lazy torch.compile inside capture; FlashInfer AllReduce Fusion bypassing the NCCL/VMM-IPC path; LOAD OOM from Mamba SSM state). Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…brid MoE) Run one real forward per capture_bs before graph capture on the dense/DP SAVE path, with the allocation region STOPPED and the caching allocator emptied afterwards, so lazy torch.compile/inductor + per-shape GEMM JIT happen outside the captured stream while the VMM cursor entering the FlashInfer pre-pass is byte-for-byte what LOAD sees. Fixes 'Graph contains no nodes' on models that defer compilation to first forward without the SAVE-only warmup drift that corrupts LOAD. Gated by FOUNDRY_SGLANG_SAVE_WARMUP (default on); EP path unchanged. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
| if not _ep_lazy_init_needed() and _dense_save_warmup_enabled(): | ||
| _run_dense_save_warmup(self) |
There was a problem hiding this comment.
📝 Info: Dense warmup now also affects the previously-untouched single-GPU / DP dense paths
Before this PR, only the EP path ran a pre-capture warmup; the comment at python/foundry/integration/sglang/hooks.py:474-475 still claims 'the validated dense / single-GPU / DP paths are untouched'. That comment refers only to the EP-gated block, but _dense_save_warmup_enabled() defaults on and gates the new _run_dense_warmup for ALL non-EP paths (SAVE hooks.py:599, LOAD hooks.py:525-528), including plain single-GPU and DP. So those paths now DO run a warmup + torch.cuda.empty_cache() and shift their VMM cursor trajectory relative to prior behavior. This appears intentional (the feature targets lazy-init hybrid models) and remains symmetric SAVE↔LOAD, but any previously-saved single-GPU/DP archive produced before this change would be incompatible with a LOAD after it (different cursor trajectory). Re-capture is required after upgrading.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good call-out — the neutrality invariant is exactly what needs to hold, so I re-validated the dense path with the warmup default-ON (H200:2, Qwen3-8B, --tp-size 2, baseline → SAVE → SAVE → LOAD):
[save ] errors=[] offsets=['120439439360', '120439439360']
[save2 ] errors=[] offsets=['120439439360', '120439439360']
[load ] errors=[]
PRIMARY temp-0 tokens SAVE-engine == LOAD-engine : True (16/16 prompts)
SAVE final_alloc_offset reproducible : True
So with the warmup running, SAVE still produces a per-rank final_alloc_offset identical across both passes, and the restored LOAD engine reproduces the warmed SAVE engine token-for-token on all 16 prompts. The neutrality holds because the warmup runs entirely under stop_allocation_region() (activations go through the ordinary CUDA allocator, VMM cursor frozen), the caching allocator is emptied afterwards, and the per-bs FlashInfer wrappers the warmup populates are dropped so the real pre-pass re-allocates them in-region — the cursor entering that pre-pass is byte-for-byte what LOAD sees.
Scope is intentional: I kept it default-on for all non-EP paths (with FOUNDRY_SGLANG_SAVE_WARMUP=0 to opt out) rather than special-casing dense-TP, since the same lazy-init-inside-capture failure affects any model that defers torch.compile/JIT to first forward. final_alloc_offset here (120439439360) differs from the earlier run only because this harness lowered --cuda-graph-max-bs 256→8; determinism and SAVE==LOAD are independent of that.
There was a problem hiding this comment.
Agreed the old comment was misleading — fixed in a3fe80f. It now reads that the EP block is EP-only and that dense/single-GPU/DP get their own in-region warmup via _run_dense_warmup on both SAVE and LOAD. Your compatibility note is correct and intended: this shifts the dense/DP VMM cursor trajectory, so an archive captured before this change is not LOAD-compatible after it (re-capture required after upgrading). I'll add that to the README's warmup-restore section alongside the default-on note.
Qwen3.5-style hybrid models nest FlashInfer inside a HybridLinearAttnBackend (FlashInfer full-attn layers + mamba/linear-attn layers). Foundry detected FlashInfer by indices_updater_decode on the top-level attn_backend, which the hybrid wrapper does not expose, so the per-bs FlashInfer wrapper prepass was skipped: SAVE allocated wrappers interleaved with graph memory during capture while LOAD allocated them after load_all_graphs, diverging the VMM offsets and making the decode graph replay hit an illegal memory access on LOAD. Add _flashinfer_backend() to resolve the FlashInfer backend through the hybrid wrapper's full_attn_backend, and install the reuse shim / run the prepass on it. The mamba backend keeps all its graph buffers in init_cuda_graph_state (pre-capture, already SAVE/LOAD-symmetric), so no extra handling is needed there. Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…h replay Models with lazy-init persistent buffers (Qwen3.5 MoE + mamba) need the warmup to run with the VMM allocation region ACTIVE so those buffers land at deterministic in-region addresses. The previous cursor-neutral approach (region stopped during warmup) put them out-of-region, which worked for SAVE (same process) but failed on LOAD: the graph replay referenced stale out-of-region addresses from a different process. Run the same in-region warmup on both SAVE and LOAD so the VMM cursor trajectory matches, then clear FlashInfer metadata and empty the caching allocator cache before the prepass/capture proceeds. This makes all lazy-init model buffers (torch.compile codegen, Triton workspace, etc.) available at the same in-region addresses on LOAD as on SAVE. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
16/16 exact SAVE==LOAD temperature-0 token match, zero faults, byte-identical SAVE/LOAD VMM offset trajectory, SAVE offsets reproducible across passes on all 4 ranks. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…of re-running forwards The dense warmup on LOAD re-runs model forwards (~28s on Qwen3.5-122B) purely to place lazy-init persistent buffers in-region at the addresses the decode graphs reference. When those buffers are write-before-read scratch within the captured graph, LOAD can skip the forwards and simply reserve the SAVE-side warmup allocation gap (recorded in warmup_alloc_offset.json), leaving it mapped but uninitialized. Gated behind FOUNDRY_SGLANG_SKIP_LOAD_WARMUP=1 (off by default) since correctness depends on the scratch-only assumption — must be validated with exact SAVE==LOAD token equality per model. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…n LOAD Instead of re-running the dense warmup forwards on LOAD, dump the exact post-warmup VMM gap bytes during SAVE and cudaMemcpy them back into the same addresses on LOAD (opt-in via FOUNDRY_SGLANG_WARMUP_RESTORE=1). The decode graphs replay by address and read these restored persistent buffers; decode is never run eagerly on LOAD, so nothing re-triggers the lazy init. Off by default; the validated re-run-warmup path is unchanged. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…mp/restore The VMM region is driver-mapped (cuMemMap on CUdeviceptr); the CUDA runtime cudaMemcpy returns cudaErrorInvalidValue for it. Copy the warmup gap bytes with the driver API instead, operating on CUdeviceptr integers in the current context. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
foundry unmaps freed allocations (cuMemFree -> cuMemUnmap), so after the dense warmup + empty_cache the [before, after) gap is fragmented: persistent buffers stay mapped, freed transients are holes. A single cuMemcpyDtoH over the whole span hit unmapped VA (CUDA_ERROR_INVALID_VALUE). Probe each 2MiB page with cuPointerGetAttribute(CU_POINTER_ATTRIBUTE_MAPPED), dump each mapped run, and record intervals so LOAD restores exactly those bytes into the (contiguously remapped) region. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…empty_cache The gap is only contiguously mapped BEFORE empty_cache (the caching allocator still holds every block the warmup touched). After empty_cache the freed blocks are returned to foundry and unmapped, so the probe found 0 mapped bytes. Move the dump into the finally block before empty_cache and anchor the LOAD cursor on the post-empty_cache warmup offset the validated path uses. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The pointer-attribute probe used the wrong CU_POINTER_ATTRIBUTE value and found 0 mapped bytes. Replace it with trial cuMemcpyDtoH copies: one contiguous copy first (succeeds when the gap is fully mapped, i.e. before empty_cache), falling back to per-2MiB-page copies that keep only cleanly-copied runs. No dependence on pointer-attribute semantics. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
All TP ranks read workspace_root/warmup_state.json at end of capture while rank 0 rewrites it in place; a reader could hit the truncated file mid-write and die with JSONDecodeError. Write to a pid-tmp file and os.replace so readers always see a complete file. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
| buf = ctypes.create_string_buffer(size) | ||
| rc = lib.cuMemcpyDtoH_v2(ctypes.cast(buf, ctypes.c_void_p), base_ptr, size) | ||
| if rc == 0: | ||
| return [(0, size)], buf.raw | ||
|
|
||
| intervals: list[tuple[int, int]] = [] | ||
| parts: list[bytes] = [] | ||
| run_start = None | ||
| run_parts: list[bytes] = [] | ||
| off = 0 | ||
| while off < size: | ||
| length = min(_VMM_GRANULARITY, size - off) | ||
| page = ctypes.create_string_buffer(length) | ||
| prc = lib.cuMemcpyDtoH_v2(ctypes.cast(page, ctypes.c_void_p), base_ptr + off, length) | ||
| if prc == 0: | ||
| if run_start is None: | ||
| run_start = off | ||
| run_parts = [] | ||
| run_parts.append(page.raw) | ||
| elif run_start is not None: | ||
| intervals.append((run_start, off - run_start)) | ||
| parts.append(b"".join(run_parts)) | ||
| run_start = None | ||
| off += length | ||
| if run_start is not None: | ||
| intervals.append((run_start, size - run_start)) | ||
| parts.append(b"".join(run_parts)) | ||
| return intervals, b"".join(parts) |
There was a problem hiding this comment.
🔍 Fragmented-gap fallback assumes failed cuMemcpyDtoH leaves context healthy
_dump_mapped (python/foundry/integration/sglang/runtime.py:262-302) first tries one contiguous cuMemcpyDtoH_v2 and, on failure, probes per-2MiB page keeping only cleanly-copied runs. The correctness of the probe relies on a failed copy over a partially-unmapped span being a synchronous argument-validation error that leaves the CUDA context usable (as the docstring claims). If instead an unmapped page in the middle produces an asynchronous fault, it could leave a sticky error on the context. This fallback path is not exercised by the validated runs (which report a single contiguous interval), so it is effectively untested. Worth validating before relying on the fragmented path.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Correct that the validated runs only exercised the single-contiguous path (one 144 MiB interval), so the per-page fallback is defensive. On the context-health assumption: cuMemcpyDtoH_v2 validates the CUdeviceptr range against the driver's VA mappings synchronously and returns CUDA_ERROR_INVALID_VALUE (1) before issuing any copy for an unmapped/partially-unmapped span — it is an argument-validation error, not an asynchronous kernel fault, so it does not leave a sticky error on the context. We actually observed exactly this earlier in development: the pre-fix whole-span copy over an unmapped gap failed with cuda driver error 1 (invalid argument) and the process stayed healthy (it raised cleanly through _cu_check), which is why the fix moved the dump before empty_cache. The primary guarantee is that the dump runs while the gap is fully mapped (caching allocator still holds every block), so the contiguous copy succeeds and the fallback is belt-and-suspenders for a hypothetical mid-warmup unmap. I'd rather keep it than silently produce a short dump; happy to add a fragmented-path GPU test if you want it exercised explicitly.
The dump end is the pre-empty_cache peak, which can exceed final_alloc_offset for models whose warmup transients peak above the final captured offset. preallocate_for_load_mode only maps [.., final_alloc_offset), so restoring those higher bytes would hit unmapped VA. Those bytes are transient (never referenced by the replayed graphs), so clamp each interval to the mapped region. No-op on the validated path (peak == after_warmup < final). Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
FOUNDRY_SGLANG_WARMUP_RESTORE now defaults to 1: SAVE dumps the warmup gap, LOAD restores it. LOAD only takes the restore path when the archive actually carries the dump (warmup_region_available); otherwise it falls back to re-running the deterministic warmup forwards, so archives produced before this change (or with restore disabled) still load. Set the flag to 0 to force the re-run path on both save and load. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…amp, frag test, upgrade note Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Summary
Dense tensor-parallel SGLang SAVE/LOAD recipe — routes the per-layer TP all-reduce through NCCL over Foundry's VMM-IPC bridge. GPU-validated on both Qwen3-8B (dense, TP=2, H200:2) and Qwen3.5-122B-A10B (hybrid Mamba/MoE, TP=4, H200:4), no IMEX/fabric.
The core fix for hybrid models (Qwen3.5): run the pre-capture warmup with the VMM region active on both SAVE and LOAD, so lazy-init persistent buffers (torch.compile codegen, Triton workspace, MoE/mamba state) land at deterministic in-region VMM addresses. The prior cursor-neutral approach (region stopped) put them out-of-region — fine for SAVE (same process), fatal for LOAD (different process → stale addresses →
illegal memory accesson decode graph replay).Key changes in
hooks.py:Also:
_flashinfer_backend()sees throughHybridLinearAttnBackend.full_attn_backend(Qwen3.5), and the SGLang hook is ported to the post-#26735init_forward_metadata_out_graph3-method ABC (legacy fallback kept).LOAD warmup-skip via byte restore (
FOUNDRY_SGLANG_WARMUP_RESTORE, default on)The in-region warmup re-runs real forwards on LOAD (~20 s on 122B) only to reproduce the deterministic persistent buffers it wrote on SAVE. Instead, SAVE dumps the warmup allocation gap's bytes and LOAD restores them:
This is on by default. LOAD only restores when the archive carries the dump; otherwise it falls back to re-running the warmup, so archives produced before this change still load.
FOUNDRY_SGLANG_WARMUP_RESTORE=0forces the re-run path on both.Robustness:
final_alloc_offset(the LOAD-mapped ceiling). Warmup transients that peak above the final captured offset are dropped (never referenced by the replayed graphs), so a larger-transient model can't drive an H2D copy into unmapped VA.empty_cache: afterwards foundry (cuMemFree→cuMemUnmap) releases freed blocks, so a whole-span copy would hit unmapped VA (CUDA_ERROR_INVALID_VALUE). The fragmented fallback (_dump_mappedper-2MiB trial copies over a holed range) is GPU-tested via a dedicated harness entrypoint that maps two VMM blocks around an unmapped hole and asserts the returned intervals/bytes + a healthy post-probe context.Distinct from
FOUNDRY_SGLANG_SKIP_LOAD_WARMUP=1, which only reserves the gap without restoring contents — GPU-proven incorrect (coherent but divergent LOAD tokens; the gap holds read-before-write state); that flag stays off by default.Also fixes a latent race: the shared
workspace_root/warmup_state.jsonwas truncated in place by rank 0 while other TP ranks read it at end of capture (JSONDecodeError). Now written atomically (temp +os.replace).Upgrade note: the dense/single-GPU/DP paths now run an in-region warmup (they previously did not), which shifts their VMM cursor trajectory. An archive captured before this change is not LOAD-compatible with a build after it — re-capture (re-run SAVE) after upgrading.
Validation
120517033984(reproducible)120376524800(all 4 ranks)Zero faults (MMU/Xid/CUDA error) on both. Byte-identical SAVE/LOAD VMM offset trajectory at every checkpoint. For 122B with warmup-restore on by default, LOAD cold-start ~94 s (all 4 ranks restore 144 MiB as one contiguous interval) vs ~127 s re-running warmup vs ~249 s cold SAVE, still 16/16 exact SAVE==LOAD and base==LOAD. The fragmented
_dump_mappedfallback passes its dedicated single-GPU test.122B-specific flags:
--enforce-disable-flashinfer-allreduce-fusion,--max-mamba-cache-size 32,--disable-radix-cache.Link to Devin session: https://modal.devinenterprise.com/sessions/625ef9aabbff4b318f291f968d259cfc
Requested by: @rchalamala