Skip to content

[Perf] [MI355X] deepseek_v41: One launch per reuse layer, the index sources' compressor in K1, the Engram projection in the layer launches, cheaper hand-offs, the Markov block on the package's kernels - #37

Open
heslami wants to merge 12 commits into
base/dsv41-pr82from
perf/dsv41-handoffs-1
Open

heslami wants to merge 12 commits into
base/dsv41-pr82from
perf/dsv41-handoffs-1

Conversation

@heslami

@heslami heslami commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Depends on sgl-project#82 ([Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8), which merges first. This branch contains sgl-project#82's commits. This PR's own diff is the 12 commits after sgl-project#82's head (44c8515). Every number below is measured against sgl-project#82's head, not main.

DSpark verify cycle on sgl-project#82, at TP4 with one request: about 4.15 ms. Of that, about 3.2 ms is in the 40 layers, 0.27 ms is SGLang glue between the layers' launches, and 0.8 ms is outside the layers. This PR takes time out of all three, as follows.

  • Inside a layer:
    • one launch per reuse layer;
    • cheaper hand-offs: uncached scratch, the router's gate rows ahead of its wait and a scalar-polled box for x;
    • an index source's head weights on more CTAs.
  • Glue between launches:
    • an index KV source's compressor (projection, compression and index-key store) runs inside K1;
    • the walk plan is computed in K2i;
    • the Engram projection runs in the reuse layers' launches.
  • Outside the layers:
    • the sampled Markov draft block runs on the package's kernels;
    • layer 0 reads the embedding table.
  • A spill fix: past 32 rows, K2 and the FFN launch spilled VGPRs on [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82's head already. One commit fixes that; the census below gives the before and after.

Two commits change the order of a sum: the compressor projection (8) and the Engram projection (10). Every other commit leaves every value bit for bit as it was.

Result on MI355X, TP4, DSpark (block 5), one request, sgl-project#82's head → this PR (A/B/A, one allocation):

  • 8K prompts: verify cycle 4.162 / 4.169 ms → 3.690 ms (−11.4%), i.e. 843 → 951 tok/s/user at InferenceX's golden accept length 3.51.
  • 320K prompt: 4.274 / 4.267 ms → 3.781 ms (−11.5%), 822 → 928 tok/s/user at AL 3.51.
  • DSpark off, 8K, one request: TPOT 2.95 / 2.96 ms → 2.78 ms (−5.9%).
  • TP2: 5.494 / 5.465 ms → 5.071 ms (−7.5%), 640 → 692 tok/s/user; TP8: 3.870 / 3.865 ms → 3.419 ms (−11.6%), 908 → 1027 tok/s/user.
  • AgentX (InferenceX's agentic replay, fast mode, TP4, request-paired): ITL −9.9% / −9.4% at C = 1 (687 → 772 tok/s/user) and −10.1% / −10.3% at C = 8 (709 → 818).
  • AIME25 (thinking, temperature 1.0, 1,920 samples a head): 0.9906 on [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82's head, 0.9922 on this PR. GSM8K (1,319, 8-shot, greedy, three runs): 0.919 / 0.906 / 0.911 against 0.908 / 0.905 / 0.911.

Modifications

Twelve commits, one change each. Each one's gain is in the per-commit table (Speed Tests). Unless a subsection says otherwise, a commit doesn't change any value.

1. An index source's head weights on 10 CTAs a token tile (attention/index_query.py, attention/plan.py, layer.py)

At 6+ rows K1 computes the indexer's head weights (x against weights_proj: 10 K splits of 512 per 16-head tile) on 2 CTAs a token tile. Each of those CTAs read 164 KB, so an index source's K1 ended about 9 µs after its other CTAs.

  • Each split pair now runs on a CTA of its own, a split a wave, with every step's loads in flight.
  • Part 0 polls the other parts' f32 partials (iwp, a new scratch region) and sums all ten in split order. The head weights stay bit for bit SGLang's.
  • The CTAs follow the q / kv norms'. iw_tasks (from [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82) now counts the 10 a tile. Where the grid has no room past the norms (33–47 rows), the head weights start right after the q / kv norms, which run from CTA 0, so the two never share a CTA.

2. A reuse layer's K1 and K2 in one launch (layer.py, runner.py, attention/front.py, attention/back.py, stages/seam.py, spec.py, draft.py, common/epoch.py)

A reuse layer (one that isn't an index source) now runs K1 and K2 in one launch (build_mono_layer, runner.layer).

  • K1's products no longer cross a launch boundary. q, the window records, the key table and the mixes are stored device scope. Every CTA flags its q rows with K2's tag past its wq_b, and that flag also says the CTA's records, key-table rows and mixes have landed.
  • The waits. The split waits on every CTA's q flag of its token's tile and on its token's key-table flag. The combine waits on the key table's flag, and the FFN seam's slices on the gates' flags. Token parallel, each record copied from another rank gets a flag of its own.
  • No end-of-launch fan-in. The CTA that runs split unit 0 has seen a q flag from every CTA, each stored after that CTA read the epoch, so it moves the epoch on and clears the MoE counter slot there. common/epoch.py: epoch_end takes the CTA that runs it and can skip the marks. Its defaults keep every other model's code as it was.
  • An index source's K1 and K2, and the FFN launch, keep the marks.
  • The layer launch reloads K2's arguments from the kernarg segment at K2's start, and holds the epoch in a scalar register, so neither stays live in VGPRs through K1, where it spilled. K1's X8 and the MoE's no longer share a region.
  • check_layer.py (new) checks the layer launch bit for bit against K1 then K2 at every variant, and under graph replay. test_deepseek_v41_kernels.py runs it.

3. Past 32 rows, the FFN seam slice's loads after its reduce (stages/seam.py, layer.py) — spill fix

A slice of the FFN seam issues its first loads (its rows' first batch and its mix weights) before it waits on the ranks' attention partials. Past 32 rows the reduce of those partials takes more than one pass, and the loads stayed live through all of them, so K2 and the FFN launch spilled VGPRs there, on sgl-project#82's head already.

  • Up to 32 rows nothing changes: the loads go out ahead of the wait, and the code objects are the same.
  • Past 32 rows the loads go out after the reduce, which runs as a traced loop with one pass's polls in flight at a time.
  • The sums keep their order. Registers: see Speed Tests.

4. The router's gate rows before x's wait at every width (stages/moe.py)

Below 12 rows the router CTAs loaded their gate rows (41 KB a CTA at 6 rows) only after the FFN norm's x flags, so the router started with its weights still in flight. The rows don't depend on x, so every width now issues them before the wait, as wide steps already did.

5. The first layer's launch reads the embedding table (layer.py, stages/seam.py, token_par.py, runner.py, spec.py, draft.py)

Before layer 0, the verify forward gathered the step's embedding rows (indexSelect) and broadcast them into the four mHC streams (_hc_broadcast_kernel); the draft's first stage did the same with a repeat.

  • The layer-0 launch now takes the whole table, kept at load, and the step's token ids (MonoBuild.embed). The seam's slices read each stream from the table row of the token's id.
  • Every stream of SGLang's broadcast is the row itself, so the layer's inputs are bit for bit the same. check_layer.py checks the launch on (table, ids) against the launch on the broadcast rows.

6. Uncached launch scratch, every cross-CTA payload read device scope (runner.py, attention/back.py, attention/gemv.py)

7. The router waits on x through a scalar-polled box (stages/moe.py, layer.py)

A router CTA has its gate rows (41 KB) in flight when it polls the FFN norm's x flags. A vector poll returns only behind them, since loads return in issue order: up to 1.6 µs after it is issued at 6 rows.

8. An index KV source's compressor projection computed in K1 (attention/index_query.py, attention/plan.py, layer.py, runner.py, spec.py) — changes the order of a sum

At 6+ rows (not token parallel), K1 now also runs the compressor's bf16 projection of its normed rows:

  • wkv_gate (kv and score, f32 out) at compress ratio 2, and wkv (bf16 out) at ratio 1;
  • a 16-column tile and K quarter a CTA, in q's wait, with the quarters summed in order;
  • SGLang's fused compression takes it as its projection (_low_ratio_compress_fused(projected=)), in place of its GEMM between K1 and K2i.

The sums run in another order than SGLang's GEMM (Accuracy Tests). check_k1 bounds them against fp64 (k1.cp.rel 1e-5 for f32, k1.cp_bf16.rel 8e-3 for bf16) and now also counts the values that differ from SGLang's own projection.

9. An index KV source's compressor in K1's tail, and the walk plan in K2i (indexer/compress.py (new), indexer/k2i.py, indexer/walk_plan.py, indexer/score_fp4.py, indexer/runner.py, attention/index_query.py, attention/plan.py, layer.py, runner.py, spec.py)

Between an index KV source's K1 and its K2i, SGLang ran its fused decode / verify compression in three launches: the c2 / c1 decode, wk and the index-K store. Before the first FULL or producer source of each compress ratio, a one-CTA Triton launch also wrote the walk plan that K2i's FULL tasks read.

  • KC (indexer/compress.py) does the compression a row at a time, from K1's projection (commit 8):
    • the ratio-2 pair pooling against the state ring (even positions parked), or the ratio-1 kv;
    • the norm, RoPE, the FP4 fake quant and the V4 record store;
    • then the indexer's wk, k_norm, RoPE, both FP4 stages and the split FP4 plane store.
  • KC in K1's tail. The projection's part-0 CTAs store their columns device-wide and flag each (token tile, column tile).
    • At K1's end, the grid's last CTAs, which finish their front first, run KC's rows, a row a CTA.
    • Each waits for the flags of its token tile and those before it, with the row's other loads issued while they come in.
    • The spec builds KC's arguments ahead of the index K1 (K2iRunner.kc_args). The KC kernel also builds as a launch of its own, for its test.
    • KC's wk dot reads wk in its threads' order (compress.wk_order, prepared with the K1 weights), so a wave's loads are contiguous, a K quarter a wave.
  • The walk plan. The step's first K2i of a ratio works out the plan itself, a row a lane of every wave, and each CTA writes its row for the ratio's later sources.
  • Checks. test_deepseek_v41_kc.py (new) checks the ring, records and keys against SGLang's chain, including its wk GEMM: equal bytes on every case. The plan equals the host plan (test_deepseek_v41_k2i.py), so the selection is unchanged.

10. The Engram rows projected in the reuse layers' launches (stages/engram.py (new), attention/back.py, layer.py, token_par.py, runner.py, spec.py) — changes the order of a sum

wkv projects only the step's retrieved n-gram rows, so the reuse layers between two Engram layers can compute it before the gate.

  • Who computes what. Layer 0 takes all of layer 1's projection. Reuse layers 1–13 (not the index sources 2 and 8) share layer 14's.
  • Where it runs. Each launch takes a range of this rank's rows of wkv (an N shard, all of K). It runs on the CTAs without attention or wo_a units, after their attention work and before their GEMV weights load (run_back(idle=)), so none of the back's registers are live under it.
  • How. A CTA quantizes the token tile into LDS as SGLang's MXFP8 linear quantizes its input, before any wkv weights load. It then runs its 16-row tasks as a loop: each K step's registers take the next task's weights once its MFMA has them, so at most one task's weights are in flight and the live registers don't grow with the tasks a CTA runs. It pushes the bf16 outputs to every rank's ENG peer region as tagged pairs. At the launch's end the same CTAs copy the range into the runner's kv, which SGLang's gate reads.
  • What it replaces: [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82's K-split path (quant, copy, GEMM, all-reduce). Token-parallel steps and TP1 or shared host tables keep that path.
  • Numerics. Each output is now one f32 sum over K (the GEMV's step order, the waves in order), rounded to bf16 once. The K-split path rounded each rank's partial to bf16 and summed them on the all-reduce.
  • Checks. check_layer.py bounds kv against an fp64 product of the MXFP8 operands. It also checks that the launch's other outputs are bit for bit as without the projection, that kv is alike on every rank, and that a projection shared by two launches equals one launch's.

11. The sampled Markov draft block on whole step logits (draft_sampler.py, markov/, check_markov.py)

The image's SGLang keeps a sampled draft's step logits whole over the vocab, so sgl-project#95's Markov kernels never ran: every sampled draft step took SGLang's seven launches (two all-gathers and a token broadcast).

12. Down round 0's flags and weights under the shared expert's down MFMAs (stages/moe.py)

A down CTA ran the shared expert's down projection before it issued routed round 0's ug-flag loads and weights. It now:

  1. issues the shared down's weights;
  2. loads the shared rows into LDS;
  3. issues round 0's flag loads and weights;
  4. runs the shared MFMAs while they land.

The shared down's own loads no longer queue behind round 0's.

It does so only where both weight sets fit in registers: a routed intermediate of at most 5 K steps (DOWN_EARLY_STEPS), i.e. TP4 and TP8. TP2 keeps the routed rounds after the shared down.

Accuracy Tests

Numerics

Every commit except the compressor projection (8) and the Engram projection (10) leaves every value bit for bit as it was. The kernel checks show it per launch:

  • All on this PR's head. check_layer.py (new in commit 2), TP4 at 1 / 4 / 6 / 12 / 17 / 24 / 36 / 42 / 48 rows and TP2 at 1 / 6 / 12 / 24 / 48. The layer launch matches K1 then K2 bit for bit in every variant: fold, compressed keys, the embedding table, and token parallel from 12 rows. It also holds under 20 graph replays.
  • check_k1.py, TP4 at 1 / 6 / 12 / 24 / 30 / 36 / 42 / 48 rows and TP2 at 1 / 6 / 12 / 24 / 36 / 48. Every seam output, the KV records and the index query (iq, iqs, iw) differ from SGLang's chain in 0 bytes, as on [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82's head. That covers the head weights' 10-way split (commit 1) and its new placement at 33–47 rows.
  • check_k2.py at TP4 (1 / 6 / 12 rows, 20 replays) and TP2 (5 replays): the FFN stages bit for bit.
  • KC (commit 9): test_deepseek_v41_kc.py compares the state ring, compressed records, FP4 index plane and scales byte for byte against SGLang's chain, including its wk GEMM. Every case matches.
  • Walk plan (commit 9): test_deepseek_v41_k2i.py. The plan K2i lays out equals the host plan on 240 random ragged steps, and the selection tests pass.
  • Markov block (commit 11): check_markov.py gives 0 differing tokens at batch 1–4, and the startup check compares against SGLang on every rank.

Commit 8, the compressor projection. K1's projection is summed in another order than SGLang's GEMM. check_k1 on this PR's head at 6 / 12 / 24 / 48 rows (the SGLang comparison at TP4, 2 trials; the fp64 bound at TP4 and TP2):

Ratio (output) vs fp64, max rel (bound) values differing from SGLang's projection max rel vs SGLang's
2 (f32, wkv_gate) 1.9–2.3e-7 (1e-5) 77–87% (9,513 of 12,288 at 6 rows; 42,853 of 49,152 at 24) 2.9–5.2e-7
1 (bf16, wkv) 2.1–3.2e-3 (8e-3) 0.03–0.04% (5 of 12,288 at 12 rows; 13 of 49,152 at 48) 5.1e-4–1.7e-3

At ratio 2 nearly every f32 value differs in its last bits. The compressor then rounds the pooled kv to bf16, so a key's FP4 code can flip where a value sits on a rounding edge.

Commit 10, the Engram projection. Each kv element is now one f32 sum over K, rounded to bf16 once. sgl-project#82's K-split path rounded each rank's 1536-wide partial to bf16 and summed the four on the custom all-reduce. check_layer.py at TP4 (1–48 rows) and TP2 (1–48 rows) gives, against the fp64 product of the MXFP8 operands:

  • max |d| / Σ|x·w| is 3.75–5.16e-4, within the bound |ref| · 2^-8 + Σ|x·w| · K · 2^-24;
  • 1.2% of the elements are not the fp64 product's nearest bf16;
  • kv is identical on every rank, and a projection shared by two launches equals one launch's;
  • every other output of the launch is bit for bit as without the projection.

End to end (TP4, real acceptance, the e2e test's server flags)

  • test/e2e/test_deepseek_v41_server.py, all 9 cases on this PR's head: DSpark verify (monokernel batches, GSM8K past the largest width, decode vs prefill KL, long prompts vs prefill at 18K–27K tokens, passkey past the candidate span), decode (the same three), and the stalled-rank fault. 9 passed (55 min 51 s), with HSA_DISABLE_COREDUMP_ON_EXCEPTION=1.
  • AIME25 with sgl-eval 0.1.3 and its DeepSeek-V4.1-Flash preset: temperature 1.0, top_p 0.95, thinking on, reasoning effort high, up to 65,536 tokens, 64 concurrent requests. The sampled DSpark path, so the Markov block (commit 11) is used. One server per run.
  • The generations reach long context. Median about 4.5K tokens and p90 about 20K; 13–14% of the samples are longer than 16,384 tokens, where every index source scores two-level. So both commits that change a sum order are on the path.
Run Samples sgl-project#82's head (44c8515) pass@1 ± SEM This PR pass@1 ± SEM Truncated at 65,536 (sgl-project#82 / PR)
1 30 × 16 0.9875 ± 0.0042 0.9917 ± 0.0048 0 / 1
2 30 × 16 0.9958 ± 0.0028 0.9917 ± 0.0037 0 / 2
3 30 × 32 0.9896 ± 0.0028 0.9927 ± 0.0025 2 / 1
All 1,920 0.9906 0.9922 2 / 4
  • The PR's runs are on earlier heads of this branch: 2361a0f (runs 1 and 2) and 6631639 (run 3). Both also carried four commits that were dropped later: the seam's 16 B LIN loads, SGLang's DSpark folds, the verify logits in two launches and the producer's block maxima. Each of those was bitwise, to SGLang or to the code before it, by its own check.

  • The kernels are equal: every eager layer, K1 and K2 launch of check_layer.py at TP4, 6–48 rows by 6, digested on every rank, is bitwise the same on 6631639 and on this head. That is 1,344 launches, 0 differing. The same tree run twice also differs in 0, the control.

  • majority@16 and pass@16 are 1.0 in every run. Every truncation is a repetition loop at the token cap, on either head.

  • GSM8K with SGLang's few_shot_gsm8k: 8-shot, greedy, all 1,319 questions, 16 in parallel, on the same servers.

Run sgl-project#82's head This PR
1 0.919 0.908
2 0.906 0.905
3 0.911 0.911

Greedy DSpark serving isn't reproducible run to run on this stack, sgl-project#82's head included: its three runs span 1.3 points. These are the same servers as the AIME runs.

Speed Tests and Profiling

Setup

  • Hardware and image. One node of 8 × MI355X. lmsysorg/sglang-rocm:v0.5.22-rocm720-mi35x-20261028, imported as a squashfs with sha256 f04d57dce53ca5d6aafe6d9bc92b8d81a6b316fde7e55e38564c857f60bc2252 (the registry digest was not recorded at import). It ships SGLang 0.5.22.dev20261028 (5d8e90a0f9), AITER 285579b6, FlyDSL 0.4.0, torch 2.9.1+rocm7.2.0 and ROCm 7.2.0. The plugin tree is mounted over the image's.
  • Checkpoint. deepseek-ai/DeepSeek-V4.1-Flash at 314113f8a7dadb388215396ba5fbee2c610a04d6.
  • Server. The flags of test/e2e/deepseek_v41_server.py: --tp 4 --trust-remote-code --enforce-shared-experts-fusion --fp8-gemm-backend aiter --mem-fraction-static 0.7, plus --enable-amd-monokernel. The e2e test passes --monokernel-backend amd, which this SGLang takes as a deprecated alias of it.
    • DSpark: --speculative-algorithm DSPARK --speculative-dspark-block-size 5 --cuda-graph-max-bs-decode 16 --max-running-requests 16, i.e. 6 verify rows a request.
    • DSpark off: --cuda-graph-max-bs-decode 64 --max-running-requests 56.
    • Environment: SGLANG_USE_AITER=1 SGLANG_USE_ROCM700A=0 AITER_BF16_FP8_MOE_BOUND=0.
    • TP2 and TP8 use the same flags with --tp 2 / --tp 8. TP2 also sets SGLANG_ENABLE_DSV41_ENGRAM_HOST_TABLE=1 SGLANG_DSV41_ENGRAM_HOST_TABLE_LAYOUT=per_rank, as InferenceX's TP2 recipes do.
  • Client (not in this repo). One request at a time over /generate: an exact 8,192-token (or 327,680-token) prompt of ShareGPT text, its own slice so nothing is cached, and 1,024 greedy output tokens with EOS ignored. 24 prompts follow a warm-up request; the 320K point is one prompt.
    • Cycle: a request's decode time over SGLang's spec_verify_ct, median over the prompts. It is the robust number: the accept length of greedy requests moves by about ±0.1 between servers on the same prompts.
    • tok/s/user at AL 3.51: 3.51 / cycle, InferenceX's golden accept length.
    • TPOT: decode time over the tokens after the first.
  • Heads. Every number is from this PR's head (aa45727) against [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82's head (44c8515), unless a table says otherwise.

This PR against sgl-project#82's head (TP4)

DSpark, one request: A/B/A in one allocation, one server per column.

sgl-project#82's head This PR sgl-project#82's head again
8K cycle median (24 prompts) 4.162 ms 3.690 ms 4.169 ms
8K TPOT median 1.011 ms 0.964 ms 1.052 ms
8K accept length 3.95 3.85 3.93
8K tok/s/user at AL 3.51 843 951 842
320K cycle (one request) 4.274 ms 3.781 ms 4.267 ms
320K accept length 4.11 3.66 4.21
320K tok/s/user at AL 3.51 821 928 823
  • 8K: −475 µs a cycle (−11.4%), with an A/A spread of 7 µs: 843 → 951 tok/s/user at AL 3.51 (+12.9%).
  • 320K: −490 µs (−11.5%): 822 → 928 tok/s/user.
  • Where the time went (SGLang's profiler on a 40-step wave after the timed ones, rank 0, median step):
    • at 8K the step goes 4272 / 4215 → 3764 µs: layers 3174 / 3113 → 3006, glue 276 → 104, outside 816 / 822 → 632;
    • at 320K, 4317 / 4304 → 3817 µs: layers 3153 / 3137 → 3003, glue 342 / 338 → 168, outside 820 / 824 → 635.
  • "Glue" is the non-monokernel kernels and gaps between a step's 40 layer launches. "Outside" is everything outside the 40 layers: the draft, LM heads, sampling, metadata. The trace reader is not in this repo.

DSpark off, one request, 8 prompts at 8K, A/B/A:

sgl-project#82's head This PR sgl-project#82's head again
TPOT median 2.95 ms 2.78 ms 2.96 ms
tok/s/user 338.5 360.0 338.0
  • −5.9% a token.

Each commit on its own (TP4)

# Commit 8K Δ per round (µs) 8K mean ± SE (µs) 8K % 320K median (µs)
1 Run an index source's head weights on 10 CTAs a token tile -43 / -31 / -43 / -49 -41 ± 4 -0.99% -42
2 Run a reuse layer's K1 and K2 in one launch +6 / -10 / +5 / -14 -3 ± 5 -0.08% +22
3 Past 32 rows, issue the FFN seam slice's loads after its reduce +4 / -9 / +12 / +7 +3 ± 4 +0.08% +10
4 Issue the router's gate rows before x's wait at every width -50 / -40 / -90 / -37 -54 ± 12 -1.31% -53
5 Read the embedding table in the first layer's launch -20 / -33 / -15 / -47 -29 ± 7 -0.69% -11
6 Allocate the launch scratch uncached, read every cross-CTA payload device scope -59 / -46 / -36 / -33 -44 ± 6 -1.05% -56
7 Wait for the router's x through a scalar-polled box +6 / -11 / -9 / -8 -6 ± 4 -0.14% +1
8 Compute an index KV source's compressor projection in K1 -36 / -8 / -14 / -20 -20 ± 6 -0.47% -24
9 Run an index KV source's compressor in K1's tail, and the walk plan in K2i -71 / -78 / -70 / -70 -72 ± 2 -1.74% -43
10 Project the Engram rows in the reuse layers' launches -50 / -49 / -69 / -73 -60 ± 6 -1.44% -67
11 Run the sampled Markov draft block on whole step logits -103 / -106 / -114 / -115 -109 ± 3 -2.63% -92
12 Issue down round 0's flags and weights under the shared down's MFMAs -29 / -29 / -18 / -28 -26 ± 3 -0.63% -49
  • Within noise: "Run a reuse layer's K1 and K2 in one launch" (−3 ± 5 µs) and "Wait for the router's x through a scalar-polled box" (−6 ± 4 µs).
    • The box at the head. Measured against the commit before it, the box is inside noise. Taken out of the final head (the other 11 commits as they are), the head is slower: +19 ± 11 µs a cycle at 8K and +39 ± 6 µs at 320K. That is three A/B pairs, two of them in one allocation. So its gain shows up only with the later launches above it.
    • At TP8 the one launch is a gain on its own: −87 µs a cycle (−2.3%) against the commit before it (TP2 and TP8, below).
    • Later commits need the one launch: the first layer reads the embedding table, and the reuse layers project the Engram rows, inside it.
    • The spill fix ("Past 32 rows, …") does not change the code at the served widths (6 rows here); it is there for its registers (below).

TP2 and TP8

TP2 (2 GPUs, the Engram host table per_rank), one request at 8K, A/B/A in one allocation for each table:

sgl-project#82's head This PR sgl-project#82's head again
DSpark: cycle median 5.494 ms 5.071 ms 5.465 ms
DSpark: accept length 4.00 3.88 3.78
DSpark: tok/s/user at AL 3.51 639 692 642
DSpark off: TPOT median 3.49 ms 3.31 ms 3.47 ms
DSpark off: tok/s/user 286 302 288
  • DSpark: −409 µs a cycle (−7.5%). DSpark off: −5.0% a token.
  • This includes the cost of the TP2 index-source K1 spills listed under Registers.
  • The one-launch commit on its own is slower at TP2: 5.471 / 5.545 / 5.455 ms against the commit before it, +82 µs (+1.5%), in one allocation. The later commits more than make it up; the whole PR is −7.5% at TP2 (above).

TP8 (8 GPUs), one DSpark request at 8K, A/B/A, then the one-launch commit against the commit before it, in the same allocation:

sgl-project#82's head This PR sgl-project#82's head again
DSpark: cycle median 3.870 ms 3.419 ms 3.865 ms
DSpark: accept length 3.89 3.99 4.04
DSpark: tok/s/user at AL 3.51 907 1027 908
One launch (columns: the commit before it, the one-launch commit, the commit before it again): cycle median 3.842 ms 3.751 ms 3.834 ms
  • DSpark: −449 µs a cycle (−11.6%), with an A/A spread of 5 µs: 908 → 1027 tok/s/user at AL 3.51 (+13.2%).
  • The one-launch commit on its own is faster at TP8: −87 µs a cycle (−2.3%) against the commit before it, with an A/A spread of 8 µs.

AgentX (TP4)

  • Workload. InferenceX's AgentX replay of its agentic coding traces (semianalysis_cc_traces_weka_062126 at 23f152f6, scenario inferencex-agentx-mvp), in InferenceX's fast mode: a 1,200 s replay after one warm-up request a lane, seed 42.
    • The harness and AIPerf's AgentX build are out of tree.
    • The seed gives both heads the same sessions and turns, in the same order.
    • Prompts are replayed with prefix caching on and EOS ignored.
    • Acceptance is fixed at InferenceX's golden 3.51 (SGLang's simulated acceptance, SGLANG_SIMULATE_ACC_LEN=3.51), so tok/s/user is 1 / ITL. The measured accept length is 3.51 on every server.
  • Server. The DSpark flags above plus InferenceX's AgentX serving settings: --reasoning-parser auto --tool-call-parser auto --enable-cache-report --chunked-prefill-size 16384 --prefill-decode-interval 192 --schedule-policy lpm --swa-prefix-tails 128 (512 at C = 8) --enable-decoder-swa-bounded-replay --cuda-graph-backend-prefill disabled, with SGLANG_DEFAULT_THINKING=1 SGLANG_DSV41_REASONING_EFFORT=high.
  • Method. A/B/A in one allocation for each point, one server per column. A request's change is its own ITL on this PR over the same request on [Perf] deepseek_v41: A line a flag, the Engram K split and the PV hoist; serve at TP8 sgl-project/sgl-amd-mono#82's head; the median is over the requests both completed, with a bootstrap 95% CI.
  • Points. C = 1 and C = 8 of InferenceX's TP4 points (1, 2, 4, 8, 16). The 1,200 s replay draws a shorter session window than InferenceX's 3,600 s cells (median input about 51–60K tokens here), so the absolute numbers are not the leaderboard's.
sgl-project#82's head This PR sgl-project#82's head again
C = 1: input tokens a request, median (cached) 57.1K (93.7%) 60.0K (93.8%) 57.1K (93.8%)
C = 1: uncached input a request, median / P90 2,566 / 10,592 2,648 / 11,402 2,567 / 11,041
C = 1: ITL median / P90 1.440 / 2.051 ms 1.296 / 1.955 ms 1.473 / 2.010 ms
C = 1: tok/s/user, median 694 772 679
C = 1: TTFT median 233 ms 230 ms 225 ms
C = 8: input tokens a request, median (cached) 50.9K (95.0%) 50.4K (95.0%) 51.4K (95.1%)
C = 8: uncached input a request, median / P90 1,786 / 6,534 1,773 / 6,231 1,732 / 5,995
C = 8: ITL median / P90 1.426 / 1.952 ms 1.223 / 1.766 ms 1.394 / 1.894 ms
C = 8: tok/s/user, median 701 818 717
C = 8: TTFT median 350 ms 306 ms 336 ms
  • C = 1: −9.9% / −9.4% a request in ITL (95% CI ±0.4%), with an A/A spread of 0.5%: 687 → 772 tok/s/user (+12.4%). TTFT doesn't change.
  • C = 8: −10.1% / −10.3% in ITL, with an A/A spread of 0.0%: 709 → 818 tok/s/user (+15.3%).
  • About 94–95% of input tokens are served from the prefix cache on both heads. The uncached rows give the prefill each request actually does.

Startup

Every launch of every width is built at startup. On an empty FlyDSL cache, this PR builds about five times as much as sgl-project#82's head. Each width adds the layer launch's variants (fold × compressed keys × the Engram share it carries, and layer 0 on the embedding table) and the index sources' K1 with the compressor.

TP4, DSpark off (widths 1–48), measured in one allocation; the build time is the spec's built in line:

sgl-project#82's head This PR
Build, empty FlyDSL cache 291 s 1394 s
Server ready, empty cache 528 s 1528 s
Build, warm cache 9.5 s 21.6 s
Server ready, warm cache 141–145 s 162 s
  • A first start of this PR on a new image or node therefore takes about 25 minutes at TP4 with DSpark off.
  • Later starts load the builds from the cache.
  • A width that runs token parallel never carries an Engram share, so no Engram variant is built for it.

Registers

A compile-only build of every key the spec builds:

Largest VGPR spill / scratch bytes a lane, over the builds of each class:

sgl-project#82's head This PR
TP8, K2 / layer launch 11–42 / 40–164 at 34–47 rows none
TP4, K2 / layer launch 2–8 / 12–28 at 12–16 and 42–47 rows none
TP2, K2 / layer launch 2–6 / 12–20 at 9–16 and 41–47 rows none row-major; 1 / 8 preshuffled at 43, 45 and 47 rows
K1, every TP (not index sources) none none
TP2, an index source's K1 8–34 / 36–116 at 18–48 rows (none below) 8–36 / 36–144 at 18–48 rows (none below), see below
TP4 and TP8, an index source's K1 none none

Tests

Relation to other PRs

Checklist

  • pre-commit run --all-files passes.
  • Tests under test/ cover the change (check_layer.py and test_deepseek_v41_kc.py are new; check_k1.py, check_markov.py and test_deepseek_v41_k2i.py are extended).
  • The SGLang commit or PR this was tested against is named above.
  • Accuracy and speed results are attached when kernel or forward code changes.
  • A new model adds one line to .github/CODEOWNERS that names its owner. (No new model.)

Hassan Eslami added 12 commits November 4, 2026 15:11
…token tile

At 6+ rows K1 computes the indexer's head weights (x against weights_proj,
10 K splits of 512 per 16-head tile) on 2 CTAs a token tile, which read 164 KB
each, so an index source's K1 ended about 9 us after its other CTAs. Each split
pair now runs on its own CTA, a split a wave with every step's loads in flight,
on the CTAs past the q / kv norms' (iw_tasks counts them); the first part polls
the others' partials and sums all ten in split order, so the head weights stay
bit for bit SGLang's.
A reuse layer (not an index source) now runs K1 and K2 in one launch
(build_mono_layer, runner.layer). K1's products no longer cross a launch
boundary: q, the window records, the key table and the mixes are stored
device scope, and every CTA flags its q rows with K2's tag past its wq_b;
that flag also tells that the CTA's records, key-table rows and mixes
landed. The attention back's split waits on every CTA's q flag of its
token's tile and on its token's key-table flag, the combine on the key
table's, the FFN seam's slices on the gates' flags; token parallel, the
records copied from the other ranks get a flag a row. K1's X8 and the
MoE's no longer share a region.

The CTA that runs split unit 0 has seen a q flag from every CTA, each
stored past that CTA's epoch read, so it moves the epoch on and clears
the MoE counter slot there; the end-of-launch fan-in is gone. An index
source's K1 and K2 and the FFN launch keep the marks, and the last CTA
waits on them off the launch's end. The layer launch loads K2's
arguments again from the kernarg segment at K2's start, so they are not
live (spilled) through K1. The epoch read at the launch's start is held in a scalar
register through K1 into K2.

Bit for bit: check_layer.py checks the layer launch against K1 then K2 at
every variant and under graph replay.
…fter its reduce

A slice of the FFN seam issued its first loads (its rows' first batch and its mix
weights) before it waited on the ranks' attention partials. Past 32 rows the reduce of
those partials takes more than one pass, and the loads stayed live through all of them,
so the K2 and FFN builds there spilled VGPRs, on sgl-project#82's head already. Up to 32 rows the
loads still go out ahead of the wait and the code is unchanged; past 32 they go out after
the reduce, which runs as a traced loop with one pass's polls in flight at a time. The
sums keep their order.
…every width

Below 12 rows the router CTAs loaded their gate rows (41 KB a CTA at 6 rows) only after
the FFN norm's x flags, so the router started with its weights still in flight: about 2.8 us
of exposed load at 4 and 6 rows. The rows do not depend on x, so every width now issues them
before the wait, as wide steps already did. Bit for bit.
…unch

The verify forward gathered the step's embedding rows (indexSelect, about 6 us) and broadcast
them into the four mHC streams (_hc_broadcast_kernel, about 5.5 us) before layer 0; the draft's
first stage did the same with a repeat. A reuse layer's launch now takes the whole table kept at
load and the step's token ids (MonoBuild.embed): the seam's slices read each stream from the
table row of the token's id. Every stream of SGLang's broadcast is the row itself, so the layer's
inputs are bit for bit the same; check_layer.py checks the launch on (table, ids) against the
launch on the broadcast rows.
… cross-CTA payload device scope

Each width's scratch is a one-rank uncached common.peer.PeerBuffer, as glm53_flash's
(sgl-project#68), so the hand-offs between CTAs go through no cache line: plain stores, vmcnt(0),
then the flag; the consumers read with sc1. The payloads K1 and K2 read behind a flag
with the default policy (the X8, q latent, XO and X8B rows, the combine's partials and
stats) are now sc1 loads: a default load can hit a stale line in the CU's own L1,
uncached memory included. Bit for bit.
A router CTA has its gate rows (41 KB) in flight when it polls the FFN norm's x flags,
and a vector poll returns only behind them (the loads return in issue order): up to 1.6 us
after it is issued at 6 rows. Each FFN-norm CTA now also stores the launch's tag into its
word of every CTA's 128 B box line once its normed row landed, and a router CTA polls its
own line with one scalar glc load (lgkmcnt), as glm53_flash's boxes (sgl-project#63) do. The box
lives in the uncached scratch. Bit for bit.
…on in K1

At 6+ rows (not token parallel) K1 now also runs the compressor's bf16 projection
of its normed rows -- wkv_gate (kv and score, f32) at compress ratio 2, wkv
(bf16) at ratio 1 -- a 16-column tile and K quarter a CTA in q's wait, the
quarters summed in order. SGLang's fused compression takes it as its
projection, in place of its GEMM between K1 and K2i. The sums run in another
order than SGLang's GEMM: check_k1 bounds them against fp64 (f32: 1e-5;
bf16: 8e-3) and counts the values that differ from SGLang's projection.
…, and the walk plan in K2i

Between an index KV source's K1 and its K2i, SGLang ran its fused decode / verify
compression in three launches (c2 / c1 decode, wk, index-K store), and a one-CTA Triton
launch wrote the walk plan K2i's FULL tasks read before the first FULL or producer source
of each compress ratio.

KC (indexer/compress.py) does the compression a row at a time from K1's projection: the
ratio-2 pair pooling against the state ring (even positions parked) or the ratio-1 kv,
the norm, RoPE, the FP4 fake quant and the V4 record store, then the indexer's wk,
k_norm, RoPE, both FP4 stages and the split FP4 plane store. It runs in K1's tail: the
projection's part-0 CTAs store their columns device-wide and flag each (token tile,
column tile), and at K1's end the grid's last CTAs, which finish their front first, run
KC's row a CTA once the flags of their token tile and those before are in, with the
row's other loads issued while they come in. The spec builds KC's arguments ahead of the
index K1 (K2iRunner.kc_args); the KC kernel also builds as its own launch for its test.
KC's wk dot reads wk in its threads' order (compress.wk_order, prepared with the K1
weights), so a wave's loads are contiguous, a K quarter a wave.

The step's first K2i of a ratio now works out the walk plan itself, a row a lane of
every wave, and each CTA writes its row for the ratio's later sources.

test_deepseek_v41_kc.py checks the ring, records and keys against SGLang's chain (equal
bytes on its cases); the plan equals the host plan (test_deepseek_v41_k2i.py), so the
selection is unchanged.
…nches

wkv projects the step's retrieved n-gram rows alone, so the reuse layers between two Engram
layers can compute it ahead of the gate: each launch takes a range of this rank's rows of
wkv (an N shard, all of K), on the CTAs without attention or wo_a units, after their
attention work and before their GEMV weights load, so none of the back's registers are
live under it. A CTA quantizes the token tile into LDS as SGLang's MXFP8 linear quantizes
its input before any wkv weights load, then runs its 16-row tasks as a loop: each K
step's registers take the next task's weights once its MFMA has them, so at most one
task's weights are in flight and the live registers do not grow with the tasks a CTA
runs. It pushes the bf16 outputs to every rank's ENG region; at the launch's end the same CTAs
copy the range into the runner's kv for SGLang's gate, in place of the K-split path's
quant, copy, GEMM and all-reduce. Layer 0 takes all of layer 1's projection, the reuse
layers 1 .. 13 share layer 14's.

Each output is one f32 sum over K (the GEMV's step order, the waves in order) rounded to
bf16 once, where the K-split path rounded each rank's partial and summed them on the
all-reduce: check_layer.py bounds kv against an fp64 product of the MXFP8 operands and
checks the launch's other outputs bit for bit as without, kv alike on every rank, and a
projection shared by two launches.
… logits

SGLang keeps a sampled draft's step logits whole over the vocab, so the Markov block's
kernels never ran and every draft step took SGLang's seven launches (two all-gathers and
a token broadcast). Each rank now writes its shard's step logits to its exchange buffer,
and after the block's last step one launch gathers every rank's shard into the sampled
sequences' whole rows; a temperature-0 row's stay as SGLang's buffer holds them.
… shared down's MFMAs

A down CTA ran the shared expert's down projection, its input ready long before the
routed results, before it issued routed round 0's ug-flag loads and weights. It now
issues the shared down's weights, loads the shared rows into LDS, issues round 0's
flag loads and weights, and runs the shared MFMAs while they land; the shared down's
own loads no longer queue behind round 0's. It does so only where both weight sets fit in
registers, a routed intermediate of at most 5 K steps (TP4 and TP8); TP2 keeps the
rounds after the shared down. Bit for bit.
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.

1 participant