Skip to content

fix(profile): stop the per-batch-size graph-capture exporter taking the server down - #1399

Open
fengshaoyi-amd wants to merge 1 commit into
mainfrom
fix/shafeng/profile-capture-tracelens-can-read
Open

fix(profile): stop the per-batch-size graph-capture exporter taking the server down#1399
fengshaoyi-amd wants to merge 1 commit into
mainfrom
fix/shafeng/profile-capture-tracelens-can-read

Conversation

@fengshaoyi-amd

@fengshaoyi-amd fengshaoyi-amd commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Why

profile_sglang.yaml selects SGLang's per-batch-size graph-capture exporter.
That exporter cannot survive a DSA dual-graph model: it names each trace after a
batch size, tracked by one index advanced once per profiler callback.

self._profile_bs_list = list(reversed(self.capture_bs))
self._profile_bs_idx = 0

def on_trace_ready(prof):
    bs = self._profile_bs_list[self._profile_bs_idx]

But capture() fires one callback per (bs, lora, dsa) combination. A model
that captures a dense and a sparse graph for each batch size therefore raises
the index past the end of the list, and the server goes down during graph
capture — before it is ready to serve a single request.

Switching to the single-trace exporter takes that path out of play. Upstream
gates the per-bs branch on the single-trace flag being unset
(decode_cuda_graph_runner.py:765-766), so setting it is enough; the buggy
indexing is never reached.

Behaviour change and blast radius

Breaking changes: no. This is a profile-time capture setting; nothing in the
serving or baseline path reads it.

Graph-capture trace layout changes. One combined trace instead of one file
per (batch size, rank). _capture_shapes.py classifies capture sidecars by
shape rather than by an exact name, and matches graph_capture anywhere in a
filename, so the combined trace is still recognised as a capture rather than
mistaken for the workload trace.

What this does not fix

Stated so the PR is not read as closing more than it does. A profile round can
still come back with an empty hot-kernel table, for reasons this change does not
touch:

  • the TraceLens 0.5.18 patch set being short three annotation patches — a
    TraceLens issue, tracked there;
  • per-kernel device time folding into hipGraphLaunch under cuda-graph capture
    (CUDA-graph capture folds kernels into hipGraphLaunch → trace_analyze hot_kernels=0 (#210) #431), which is what actually empties the steady-state window in the captures
    looked at here;
  • the AgentX _AGENTX_PROFILE_MAX_ITERS = 8 capture cap, which lives in
    _workload_envs.py and carries its own host-RAM rationale.

Dropped from the first revision of this PR

The first revision also set SGLANG_PROFILE_WITH_STACK to false, on the
argument that callstacks crowd GPU kernels out of the steady-state window. That
argument does not hold and the change has been reverted.

python_function events carry no correlation id — 0 of 641782 in the capture
that was quoted — and the splitter counts a chunk's GPU events solely by
collecting correlation ids from the CPU events inside a step window and looking
up the GPU events they map to. Callstacks never enter that count; removing them
changes it by zero.

A closer control says the same. Qwen3-0.6B 20260826T072156Z-edacec52 profiled
at 97.33% python_function, higher than the 94.62% capture quoted as
evidence, under the same concurrency, the same two profiler env vars and the
same 128 steps — and did not hit steady_state_chunk_empty. Both captures
collapse the same way instead: 128 hipGraphLaunch, exactly one of which
carries GPU events, leaving 127 of 128 steps with zero. Which chunk the splitter
lands on decides whether a run survives.

The blast-radius check in that revision was also wrong to conclude nothing reads
a callstack. It searched for the env var name, which does appear once. But
_trace_launcher_resolver reads the python_function chain rather than the env
var, and runs unconditionally from tracelens_analysis.py:5064 to recover a
Python launcher frame for the kernels TraceLens files as (Synthetic Op) with
launcher_path = "Not found" — hand-written Triton and AITER kernels, the ones
kernel optimisation is most likely to want. Without callstacks it falls back to
name-grep, which yields no line number and can match a test file or a CPU
implementation. TraceLens' own mla_decode_pseudo_ops matches python_function
names too.

Verification

The four suites that read this config: 381 passed.

pytest src/hyperloom/inference_optimizer/tests/test_workload_envs.py \
       src/hyperloom/inference_optimizer/tests/test_workload_envs_branches_unit.py \
       src/hyperloom/inference_optimizer/tests/test_profile_and_kernel_handlers.py \
       src/hyperloom/inference_optimizer/tests/test_aiperf_client_sh.py

The config parses and materialises the intended values, and no reference to the
replaced env var remains anywhere in the tree.

@fengshaoyi-amd
fengshaoyi-amd requested a review from a team as a code owner September 3, 2026 10:50
@fengshaoyi-amd fengshaoyi-amd changed the title fix(profile): shape the SGLang capture so TraceLens can read it fix(profile): stop the per-batch-size graph-capture exporter taking the server down Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

CI E2E report — ❌ Failed

item value
result ❌ Failed
model Qwen/Qwen3-0.6B (dense)
resources 1× GPU, TP=1
PR branch fix/shafeng/profile-capture-tracelens-can-read
commit d056abfadb08965c6d46566eb5a31fa0f137522f
session_id bca22ebd-a769-4c77-90b8-28c4b7c9c79c
queue → dispatch 0s
run time 4m 3s
total 4m 3s
reason platform reported Failed: container hyperloom exited with code 2 (Error): ...ustom orchestration model support enabled; catalog has ['all-proxy-models', 'claude-opus-4-5', 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-sonnet-5', 'deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5-3', 'gpt-4.1', 'gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5', 'gpt-5.6', 'gpt-5.6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra']). Refusing to start. (exit 2)
detail platform reported Failed: container hyperloom exited with code 2 (Error): ...ustom orchestration model support enabled; catalog has ['all-proxy-models', 'claude-opus-4-5', 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-sonnet-5', 'deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5-3', 'gpt-4.1', 'gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4', 'gpt-5.5', 'gpt-5.6', 'gpt-5.6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra']). Refusing to start. (exit 2)

details

@devalshahamd

Copy link
Copy Markdown
Collaborator

@fengshaoyi-amd Can you please tell what exact issue are you facing and for which workload. We intentionally store one capture trace per batch size since that is required for downstream analysis flow and this change might break that.
@mohbasit

fengshaoyi-amd pushed a commit that referenced this pull request Sep 4, 2026
…he server down

SGLANG_ENABLE_CUDA_GRAPH_CAPTURE_TRACE replaces SGLANG_GRAPH_BATCH_CAPTURE.
The per-bs exporter indexes a list built from capture_bs once per profiler
callback, but capture() fires one callback per (bs, lora, dsa) combination, so
a DSA dual-graph model -- dense and sparse for every batch size -- walks off
the end of the list and takes the server down during graph capture, before it
is ready to serve. Upstream gives the single-trace flag precedence when both
are set (decode_cuda_graph_runner.py::_graph_batch_capture_active, from
sgl-project/sglang#24370), so an inherited SGLANG_GRAPH_BATCH_CAPTURE=1 cannot
re-select the crashing path and does not need to be pinned to "0". The key
also moves to the graph-capture section it belongs to.

The layout it writes is not a drop-in, and this commit stops that being
silent. One combined file per rank holds every batch size at once and carries
no variant identity in its name, so the shape-manifest classifier rejects it
-- deliberately, since admitting it mints one shape-identical variant per rank
and defeats the dedup behind variant_count. The identity is not inside the
file either: the capture-phase record_function spans are not emitted on this
path, measured on a GLM-5.2 MI355X capture at 15166 user_annotation events
with not one capture span among them. Nor is the sibling execution_details.json
a way out, though it carries the right schema: on this layout it records a
single batch_size for a file holding every batch size (256 for all eight
decode ranks, 8 for all eight EAGLE draft ranks in that same capture), so
admitting the file on its strength would hand Forge a dense shape that is
wrong rather than missing.

So the manifest degrades to eager -- which is also what an eager profile
produces legitimately, leaving the two indistinguishable downstream. It now
emits shape_manifest_capture_not_indexable when capture ran and only its
layout was unreadable, naming the files. A server that starts takes precedence
over a manifest that indexes, but the trade is recorded rather than inferred.

Three comments described these layouts as evidence of an unpatched SGLang.
All three are upstream exporters selected by env var, and after this change
the combined one is the default, so the misreading would now point at the
healthy configuration. Corrected, along with the capture_traces/bs_* paths
in the diagnostics, which name the pre-#24370 layout.

Based on #1399 by Shaofeng Feng <shafeng@amd.com>.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fengshaoyi-amd
fengshaoyi-amd force-pushed the fix/shafeng/profile-capture-tracelens-can-read branch from 3c7c493 to c52c929 Compare September 4, 2026 03:58
…he server down

SGLANG_ENABLE_CUDA_GRAPH_CAPTURE_TRACE replaces SGLANG_GRAPH_BATCH_CAPTURE.
The per-bs exporter indexes a list built from capture_bs once per profiler
callback, but capture() fires one callback per (bs, lora, dsa) combination, so
a DSA dual-graph model -- dense and sparse for every batch size -- walks off
the end of the list and takes the server down during graph capture, before it
is ready to serve. Upstream gives the single-trace flag precedence when both
are set (decode_cuda_graph_runner.py::_graph_batch_capture_active, from
sgl-project/sglang#24370), so an inherited SGLANG_GRAPH_BATCH_CAPTURE=1 cannot
re-select the crashing path and does not need to be pinned to "0". The key
also moves to the graph-capture section it belongs to.

The layout it writes is not a drop-in, and this commit stops that being
silent. One combined file per rank holds every batch size at once and carries
no variant identity in its name, so the shape-manifest classifier rejects it
-- deliberately, since admitting it mints one shape-identical variant per rank
and defeats the dedup behind variant_count. The identity is not inside the
file either: the capture-phase record_function spans are not emitted on this
path, measured on a GLM-5.2 MI355X capture at 15166 user_annotation events
with not one capture span among them. Nor is the sibling execution_details.json
a way out, though it carries the right schema: on this layout it records a
single batch_size for a file holding every batch size (256 for all eight
decode ranks, 8 for all eight EAGLE draft ranks in that same capture), so
admitting the file on its strength would hand Forge a dense shape that is
wrong rather than missing.

So the manifest degrades to eager -- which is also what an eager profile
produces legitimately, leaving the two indistinguishable downstream. It now
emits shape_manifest_capture_not_indexable when capture ran and only its
layout was unreadable, naming the files. A server that starts takes precedence
over a manifest that indexes, but the trade is recorded rather than inferred.

Three comments described these layouts as evidence of an unpatched SGLang.
All three are upstream exporters selected by env var, and after this change
the combined one is the default, so the misreading would now point at the
healthy configuration. Corrected, along with the capture_traces/bs_* paths
in the diagnostics, which name the pre-#24370 layout.

Based on #1399 by Shaofeng Feng <shafeng@amd.com>.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fengshaoyi-amd
fengshaoyi-amd force-pushed the fix/shafeng/profile-capture-tracelens-can-read branch from c52c929 to d056abf Compare September 4, 2026 04:00
@fengshaoyi-amd

Copy link
Copy Markdown
Contributor Author

@mohbasit Thanks — and you are right that this change breaks the per-batch-size
layout your downstream flow depends on. Let me give you the exact failure first,
then what I think the real fix is, because I don't think we actually disagree.

Workload and exact failure

GLM-5.2 (MXFP4) on MI355X, TP=8, DSA dual-graph capture enabled. The server
never reaches ready — it dies during startup graph capture.

decode_cuda_graph_runner.py (v0.5.18) names each capture trace from a list
indexed once per profiler callback:

# _init_profile_context_and_memory_record, lines 781-791
self._profile_bs_list = list(reversed(self.capture_bs))
self._profile_bs_idx = 0

def on_trace_ready(prof):
    bs = self._profile_bs_list[self._profile_bs_idx]      # <-- unguarded index
    trace_file = os.path.join(trace_dir, f"{runner_name}_bs_{bs}_rank{rank}.json.gz")
    prof.export_chrome_trace(trace_file)
    self._profile_bs_idx += 1

But capture() is a triple loop (lines 1094 / 1105 / 1107), so the callback
fires once per (bs, lora, dsa) combination, not once per bs:

for bs in capture_range:
    for variant_label, _variant_has_lora in lora_variants:
        for dsa_variant in dsa_variants:
            self.capture_one_shape(bs, ...)

With 8 capture_bs and DSA dual-graph (dense + sparse per bs) that is 16
callbacks against a list of 8, so callback #9 raises IndexError and takes the
server down before it can serve a request. Any model that captures more than
one variant per batch size hits this: DSA, LoRA, or both.

The part I think matters more to you

The crash is not the worst of it. The first 8 callbacks succeed, and 7 of
them write a trace under the wrong batch size:

DecodeCudaGraphRunner_bs_7_rank0.json.gz   actually holds bs=8
DecodeCudaGraphRunner_bs_6_rank0.json.gz   actually holds bs=7

So on a multi-variant model the per-bs layout does not deliver the guarantee
it is there to provide. Downstream does not get a missing shape it can detect
— it gets a confidently mislabelled one, and roofline / arithmetic intensity
are computed against the wrong batch. A model with fewer buckets than variants
would never crash at all and would mislabel silently, end to end.

On the combined trace — your concern is correct

We measured it, and it is worse than "might break": the combined layout carries
no variant identity in the filename, the capture-phase record_function spans
are not emitted on that path (15166 user_annotation events in our capture, not
one of them a capture span), and the sibling execution_details.json records a
single batch_size for a file holding every batch size (256 for all 8 decode
ranks). So the shape manifest degrades to eager and per-bs dense shapes are lost.

We switched only because a degraded manifest is recoverable and a server that
never starts is not. The PR now emits an explicit warning instead of degrading
silently, but that is damage control, not a fix.

What I would rather do

Name the trace after the shape actually being captured, instead of indexing a
list by callback count. I have a patch that does this and I would rather land it
than keep the combined-trace workaround:

self._set_profile_capture_tag(bs, variant_label, dsa_variant)   # before capture_one_shape
# -> DecodeCudaGraphRunner_bs_8_dense_rank0.json.gz
#    DecodeCudaGraphRunner_bs_8_sparse_rank0.json.gz

Replayed against four capture shapes:

model shape callbacks current with patch
Qwen3-8B (52 bs, no variants) 52 52 traces, 0 mislabelled identical filenames
LoRA (8 bs x 2) 16 crash at #9, 7/8 mislabelled 16 traces, 0 mislabelled
DSA (8 bs x 2) 16 crash at #9, 7/8 mislabelled 16 traces, 0 mislabelled
DSA + LoRA (8 bs x 2 x 2) 32 crash at #9, 7/8 mislabelled 32 traces, 0 mislabelled

Single-variant models produce byte-identical filenames, so nothing in your
current flow changes. Multi-variant models get the per-bs layout for the first
time, with the variant in the name so dense and sparse no longer collide.

Applies cleanly to v0.5.18 and to upstream main (the bug is still there on main).
Happy to send it upstream, or to route it through the TraceLens patch set if you
would rather carry it there — your call, since your flow is the consumer. Would
that address the downstream requirement you have in mind?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants