Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cc83502
[sglang] add experimental dense tensor-parallel recipe
devin-ai-integration[bot] Jul 23, 2026
ee7d61a
[sglang] record dense-TP GPU validation results (Qwen3-8B TP=2, 2x H200)
devin-ai-integration[bot] Jul 23, 2026
4279785
[sglang] port graph-capture hook to init_forward_metadata 3-method AB…
devin-ai-integration[bot] Jul 23, 2026
c95648a
[sglang] Run pre-capture warmup on all SAVE, not just EP
devin-ai-integration[bot] Jul 23, 2026
0dfe8ef
Revert "[sglang] Run pre-capture warmup on all SAVE, not just EP"
devin-ai-integration[bot] Jul 23, 2026
d36f647
[sglang] docs: port done + document Qwen3.5-122B hybrid MoE as unsupp…
devin-ai-integration[bot] Jul 23, 2026
2807b6b
[sglang] Cursor-neutral pre-capture warmup for dense SAVE (Qwen3.5 hy…
devin-ai-integration[bot] Jul 23, 2026
2b6f25d
[sglang] Route FlashInfer prepass through hybrid linear-attn wrapper
devin-ai-integration[bot] Jul 23, 2026
7403994
[sglang] In-region warmup on both SAVE and LOAD for hybrid model grap…
devin-ai-integration[bot] Jul 23, 2026
dfdddad
[sglang] Update README: Qwen3.5-122B-A10B GPU-validated on H200:4 TP=4
devin-ai-integration[bot] Jul 23, 2026
e2dbdf2
[sglang] Optional LOAD warmup skip: reserve warmup alloc gap instead …
devin-ai-integration[bot] Jul 23, 2026
76b8a84
[sglang] Optional warmup serialize+restore: memcpy dumped gap bytes o…
devin-ai-integration[bot] Jul 23, 2026
dcbece3
[sglang] Use CUDA driver API (cuMemcpyDtoH/HtoD) for warmup region du…
devin-ai-integration[bot] Jul 23, 2026
8fd119f
[sglang] Dump only mapped sub-intervals of the warmup gap
devin-ai-integration[bot] Jul 23, 2026
7bfe52b
[sglang] Dump warmup gap before empty_cache; anchor LOAD cursor post-…
devin-ai-integration[bot] Jul 23, 2026
90b5f3f
[sglang] Dump warmup gap via ground-truth trial copies
devin-ai-integration[bot] Jul 23, 2026
00fb57c
[sglang] Write shared warmup_state.json atomically to avoid torn reads
devin-ai-integration[bot] Jul 23, 2026
114d4cd
[sglang] Document validated opt-in FOUNDRY_SGLANG_WARMUP_RESTORE path
devin-ai-integration[bot] Jul 23, 2026
15b969b
[sglang] Clamp warmup restore to final_alloc_offset (LOAD-mapped region)
devin-ai-integration[bot] Jul 24, 2026
11f1a96
[sglang] Default warmup-restore on, with safe fallback to re-run warmup
devin-ai-integration[bot] Jul 24, 2026
a3fe80f
[sglang] Clarify EP-gate comment now that dense/DP run their own warmup
devin-ai-integration[bot] Jul 24, 2026
4769c0f
[sglang] README: warmup-restore now default-on; document fallback, cl…
devin-ai-integration[bot] Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion python/foundry/integration/sglang/graph_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,34 @@ def bootstrap_deepep_buffer(cuda_graph_runner) -> bool:
return False


def build_capture_fb_view(cuda_graph_runner, bs: int):
"""Build a ForwardBatch-like view for backend capture-side metadata init.

Mirrors the padded per-bs inputs sglang's own capture loop feeds the
attention backend (``cuda_graph_runner.py::capture_one_batch_size``), as a
lightweight ``SimpleNamespace`` since the pre-pass runs before a real
``ForwardBatch`` exists. Used to drive the post-#26735 3-method init ABC
(``init_forward_metadata_out_graph``).
"""
from types import SimpleNamespace

buffers = cuda_graph_runner.buffers
num_tokens = bs * cuda_graph_runner.num_tokens_per_bs
encoder_lens = buffers.encoder_lens[:bs] if cuda_graph_runner.is_encoder_decoder else None
seq_lens = buffers.seq_lens[:bs]
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],
)
Comment on lines +266 to +276

@devin-ai-integration devin-ai-integration Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 initialize_attention_metadata_for_bs(cuda_graph_runner, bs: int) -> None:
"""Populate ``decode_cuda_graph_metadata[bs]`` for runtime replay.

Expand All @@ -257,11 +285,21 @@ def initialize_attention_metadata_for_bs(cuda_graph_runner, bs: int) -> None:
re-run the same call before runtime replay so the wrappers exist
at deterministic VMM addresses.
"""
attn_backend = cuda_graph_runner.attn_backend
# sglang #26735 replaced the single ``init_forward_metadata_capture_cuda_graph``
# with a 3-method ABC; the capture-side allocation now lives in
# ``init_forward_metadata_out_graph(fb, in_capture=True)``. Prefer the new
# API, fall back to the legacy method for pre-rename sglang.
if hasattr(attn_backend, "init_forward_metadata_out_graph"):
fb_view = build_capture_fb_view(cuda_graph_runner, bs)
attn_backend.init_forward_metadata_out_graph(fb_view, in_capture=True)
return

buffers = cuda_graph_runner.buffers
num_tokens = bs * cuda_graph_runner.num_tokens_per_bs
encoder_lens = buffers.encoder_lens[:bs] if cuda_graph_runner.is_encoder_decoder else None
spec_info = cuda_graph_runner.get_spec_info(num_tokens)
cuda_graph_runner.attn_backend.init_forward_metadata_capture_cuda_graph(
attn_backend.init_forward_metadata_capture_cuda_graph(
bs,
num_tokens,
buffers.req_pool_indices[:bs],
Expand Down
309 changes: 284 additions & 25 deletions python/foundry/integration/sglang/hooks.py

Large diffs are not rendered by default.

292 changes: 283 additions & 9 deletions python/foundry/integration/sglang/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,13 @@ def save_warmup_state(state: WarmupState) -> None:
if ext_state is not None and ext_state.rank != 0 and os.path.exists(path):
return
os.makedirs(workspace_root, exist_ok=True)
with open(path, "w") as f:
# warmup_state.json lives at the shared workspace_root; every TP rank calls
# load_warmup_state() at end of capture. Write atomically (temp + replace)
# so a concurrent reader never observes the truncated/empty file.
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w") as f:
json.dump(asdict(state), f, indent=2)
os.replace(tmp, path)
logger.info("[Foundry] Saved SGLang warmup state to %s", path)


Expand Down Expand Up @@ -180,18 +185,287 @@ def capture_final_alloc_offset() -> int:
return _final_alloc_offset


def save_warmup_alloc_offset() -> None:
"""Persist the VMM cursor immediately after the dense warmup so LOAD can
reserve the same gap without re-running the warmup forwards."""
cfg = get_config()
if cfg is None or cfg.mode != CUDAGraphExtensionMode.SAVE or cfg.workspace_dir is None:
return
offset = cge.get_current_alloc_offset()
path = os.path.join(cfg.workspace_dir, "warmup_alloc_offset.json")
with open(path, "w") as f:
json.dump({"warmup_alloc_offset": offset}, f)


def load_warmup_alloc_offset() -> int:
"""Return the SAVE-side post-warmup cursor, or 0 if not recorded."""
cfg = get_config()
if cfg is None or cfg.workspace_dir is None:
return 0
path = os.path.join(cfg.workspace_dir, "warmup_alloc_offset.json")
if not os.path.exists(path):
return 0
with open(path) as f:
return json.load(f).get("warmup_alloc_offset", 0)


_WARMUP_REGION_BIN = "warmup_region.bin"
_WARMUP_REGION_JSON = "warmup_region.json"
_libcuda_cache = None


def _libcuda():
"""Return a ctypes handle to the CUDA *driver* library (libcuda).

The VMM region is driver-mapped (``cuMemMap`` on a ``CUdeviceptr``), which
the CUDA *runtime* (``cudaMemcpy``) cannot address — it returns
``cudaErrorInvalidValue`` for pointers not in its allocation tables. So copy
the gap bytes with the driver API (``cuMemcpyDtoH_v2`` / ``cuMemcpyHtoD_v2``),
which operate directly on ``CUdeviceptr`` integers in the current context (the
same context torch/foundry mapped the region in). Resolve libcuda from
``/proc/self/maps`` (torch has it loaded) rather than the loader path."""
global _libcuda_cache
if _libcuda_cache is not None:
return _libcuda_cache
import ctypes
import re

lib_path = None
try:
with open("/proc/self/maps") as f:
for line in f:
m = re.search(r"(\S*/libcuda\.so[^\s]*)", line)
if m:
lib_path = m.group(1)
break
except OSError:
lib_path = None
lib = ctypes.CDLL(lib_path) if lib_path else ctypes.CDLL("libcuda.so.1")
# CUdeviceptr is a 64-bit integer; host pointers are void*.
lib.cuMemcpyDtoH_v2.restype = ctypes.c_int
lib.cuMemcpyDtoH_v2.argtypes = [ctypes.c_void_p, ctypes.c_ulonglong, ctypes.c_size_t]
lib.cuMemcpyHtoD_v2.restype = ctypes.c_int
lib.cuMemcpyHtoD_v2.argtypes = [ctypes.c_ulonglong, ctypes.c_void_p, ctypes.c_size_t]
lib.cuCtxSynchronize.restype = ctypes.c_int
lib.cuGetErrorString.restype = ctypes.c_int
lib.cuGetErrorString.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_char_p)]
_libcuda_cache = lib
return lib


# VMM minimum allocation granularity (2 MiB on all current NVIDIA GPUs, which is
# also what foundry aligns allocations to), used as the probe step when the gap
# turns out to be fragmented.
_VMM_GRANULARITY = 2 * 1024 * 1024


def _dump_mapped(lib, base_ptr: int, size: int) -> tuple[list[tuple[int, int]], bytes]:
"""Copy the physically-backed bytes of ``[base_ptr, base_ptr + size)`` to host
and return ``([(rel_offset, length), ...], concatenated_bytes)``.

Ground truth via trial copies rather than pointer-attribute queries: try one
contiguous copy first (the gap is contiguously mapped as long as the caching
allocator still holds every block, i.e. before ``empty_cache``); if that
fails (foundry unmaps freed blocks, so a hole may exist mid-warmup), fall
back to per-granularity copies and keep only the pages that copy cleanly. A
failed ``cuMemcpyDtoH`` is a synchronous argument-validation error that
leaves the context healthy, so probing this way is safe."""
import ctypes

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)
Comment on lines +275 to +302

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.



def _cu_check(lib, rc: int, what: str) -> None:
if rc != 0:
import ctypes

msg = ctypes.c_char_p()
lib.cuGetErrorString(rc, ctypes.byref(msg))
detail = msg.value.decode() if msg.value else "unknown"
raise RuntimeError(f"[Foundry] {what} failed: cuda driver error {rc} ({detail})")


def dump_warmup_region(before_offset: int, after_offset: int) -> None:
"""Dump the VMM bytes the dense warmup produced (``[before, after)``) so LOAD
can restore them instead of re-running the warmup forwards.

The decode graphs replay by absolute address and read persistent buffers the
warmup wrote in this gap (torch.compile constants, Triton/MoE/mamba workspace,
RoPE caches). Because the in-region warmup is deterministic, SAVE's post-warmup
gap bytes equal what LOAD's warmup would produce — restoring them is equivalent
to (but far cheaper than) re-running the warmup."""
cfg = get_config()
if cfg is None or cfg.mode != CUDAGraphExtensionMode.SAVE or cfg.workspace_dir is None:
return
size = after_offset - before_offset
if size <= 0:
return
lib = _libcuda()
base_ptr = cfg.base_addr + before_offset
_cu_check(lib, lib.cuCtxSynchronize(), "cuCtxSynchronize(pre-dump)")
intervals, data = _dump_mapped(lib, base_ptr, size)
total = sum(length for _, length in intervals)
bin_path = os.path.join(cfg.workspace_dir, _WARMUP_REGION_BIN)
with open(bin_path, "wb") as f:
f.write(data)
json_path = os.path.join(cfg.workspace_dir, _WARMUP_REGION_JSON)
with open(json_path, "w") as f:
json.dump(
{
"before_offset": before_offset,
"after_offset": after_offset,
"intervals": intervals,
},
f,
)
logger.info(
"[Foundry] SGLang dumped warmup region [%d, %d): %d mapped bytes across "
"%d intervals (of %.1f MB span) to %s",
before_offset,
after_offset,
total,
len(intervals),
size / (1024 * 1024),
bin_path,
)


def warmup_region_available() -> bool:
"""Whether the archive contains a serialized warmup region to restore.

Lets LOAD fall back to re-running the deterministic warmup forwards when an
archive was produced before warmup-restore existed (or with it disabled),
instead of failing."""
cfg = get_config()
if cfg is None or cfg.workspace_dir is None:
return False
return os.path.exists(os.path.join(cfg.workspace_dir, _WARMUP_REGION_JSON)) and os.path.exists(
os.path.join(cfg.workspace_dir, _WARMUP_REGION_BIN)
)


def _load_final_alloc_offset() -> int:
"""Return the SAVE-side final alloc offset (the top of the LOAD-mapped
region), or 0 if not recorded."""
cfg = get_config()
if cfg is None or cfg.workspace_dir is None:
return 0
path = os.path.join(cfg.workspace_dir, "final_alloc_offset.json")
if os.path.exists(path):
with open(path) as f:
final = json.load(f).get("final_alloc_offset", 0)
if final > 0:
return final
return load_warmup_state().final_alloc_offset


def restore_warmup_region() -> bool:
"""Restore the SAVE-side warmup gap bytes into the mapped VMM region and set
the cursor to the post-warmup offset. Returns False if no dump is present.

Must be called AFTER ``preallocate_for_load_mode`` has physically mapped the
region (including this gap), so the H2D copy targets backed memory."""
import ctypes

cfg = get_config()
if cfg is None or cfg.mode != CUDAGraphExtensionMode.LOAD or cfg.workspace_dir is None:
return False
json_path = os.path.join(cfg.workspace_dir, _WARMUP_REGION_JSON)
bin_path = os.path.join(cfg.workspace_dir, _WARMUP_REGION_BIN)
if not (os.path.exists(json_path) and os.path.exists(bin_path)):
return False
with open(json_path) as f:
meta = json.load(f)
before_offset = meta["before_offset"]
after_offset = meta["after_offset"]
intervals = [(int(rel), int(length)) for rel, length in meta["intervals"]]
total = sum(length for _, length in intervals)
with open(bin_path, "rb") as f:
data = f.read()
if len(data) != total:
raise RuntimeError(
f"[Foundry] warmup region dump size mismatch: file={len(data)} expected={total}"
)
# preallocate_for_load_mode only maps [.., final_alloc_offset); the dump end
# is the pre-empty_cache peak, which can exceed it for models whose warmup
# transients peak above the final captured offset. Bytes at/above final are
# transient (never referenced by the replayed graphs), so clamp the restore
# to the mapped region — writing past it would hit unmapped VA.
final = _load_final_alloc_offset()
lib = _libcuda()
base_ptr = cfg.base_addr + before_offset
restored = 0
pos = 0
for rel, length in intervals:
copy_len = length
if final > 0:
abs_end = before_offset + rel + length
if before_offset + rel >= final:
copy_len = 0
elif abs_end > final:
copy_len = final - (before_offset + rel)
if copy_len > 0:
buf = (ctypes.c_char * copy_len).from_buffer_copy(data[pos : pos + copy_len])
_cu_check(
lib,
lib.cuMemcpyHtoD_v2(base_ptr + rel, ctypes.cast(buf, ctypes.c_void_p), copy_len),
"cuMemcpyHtoD(warmup region)",
)
restored += copy_len
pos += length
_cu_check(lib, lib.cuCtxSynchronize(), "cuCtxSynchronize(post-restore)")
# The dump interval end is the pre-empty_cache cursor; the validated
# trajectory anchors on the post-empty_cache cursor (warmup_alloc_offset.json).
# Set the cursor to that so the subsequent FlashInfer pre-pass / graph load
# allocates from the exact SAVE offset.
cursor = load_warmup_alloc_offset() or after_offset
cge.set_current_alloc_offset(cursor)
logger.info(
"[Foundry] SGLang restored warmup region [%d, %d): %d of %d bytes across "
"%d intervals (clamped to final=%d); cursor=%d",
before_offset,
after_offset,
restored,
total,
len(intervals),
final,
cursor,
)
return True


def preallocate_for_load_mode() -> None:
cfg = get_config()
if cfg is None or cfg.mode != CUDAGraphExtensionMode.LOAD:
return
final = 0
if cfg.workspace_dir is not None:
path = os.path.join(cfg.workspace_dir, "final_alloc_offset.json")
if os.path.exists(path):
with open(path) as f:
final = json.load(f).get("final_alloc_offset", 0)
if final <= 0:
final = load_warmup_state().final_alloc_offset
final = _load_final_alloc_offset()
remaining = final - cge.get_current_alloc_offset()
if remaining > 0:
cge.preallocate_region(remaining)
Expand Down
Loading