← back to the article · Andrei Vogulkin

2026-09-04 · config A as investigator · 129 tool calls · Atlas tree only

Atlas decode anatomy — the first throughput investigation, verbatim

Report

Where ~60 ms per decoded token was thought to go before anything was measured; several of its estimates were later overruled by the nsys profile.

Atlas Flash-Next decode throughput investigation

Scope note on evidence quality: everything below is traced in the tree at main @ 4df6e609 with path:line. Where I could not confirm a number from code (per-kernel times, the bench operator's tok/s split, the five open questions' measured values), I say so explicitly and mark the claim as inferred. The repo itself is unusually well instrumented, and several of its own comments are the strongest available measurements — I cite those as reported-by-comment, not as my own measurement.

1. Per-token decode anatomy (qwen4_exp, single stream, batch 1)

Entry point: TransformerModel::decode in crates/spark-model/src/model/trait_impl/decode_a.rs. The step is split into an explicit Phase 1 (outside any graph) and Phase 2/3 (graph replay or capture) — and the central structural fact is that for this model Phase 2/3 never happens: CUDA graphs are vetoed.

Graph gating — the decisive branch

decode_a.rs:250 computes layer_veto = self.layers.iter().any(|l| l.decode_graph_unsupported()), and use_graphs is false whenever any layer vetoes (decode_a.rs:266-275). Two layer families veto unconditionally on presence:

So on Qwen3.8-Flash-Next (12 QSA layers and 36 PLE-bearing SSM layers) use_graphs == false for every token. decode_forward_body therefore runs eagerly, and the ~1,500–2,000 launches per token are all host-driven cuLaunchKernels. The code even acknowledges the eager launch cost it would have removed: decode_a.rs:224-240 describes the GDN decode-graph hatch as removing "the per-token host launch cost that dominates" — the same cost we are looking for.

Additional suppressors that would bite even without the veto: seq64_boundary defers capture every 64th step (decode_a.rs:255-265, a real 716/700 misaligned-address fault on first replay), hss_engaged (:212), self.profile (:267), lora_eager (:246).

Ordered stage list for one decoded token

Phase 1 — eager host work, before any replay/capture (decode_a.rs:90-176)

1. Optional arena zero — skipped for this model: only when kv_lora_rank > 0 && o_lora_rank == 0 (decode_a.rs:100-102). Good: avoids a ~1.7 GB memset/step.

2. embed_ctx(&seq.tokens, token, …) (decode_a.rs:107) — token embedding; seq.tokens is the pre-append history, which is the n-gram contract.

3. ensure_blocks_through_decode (decode_a.rs:112-120) — KV block allocation + block-table maintenance.

4. Five separate pageable H2D uploads on the compute stream: position (:126-127), global KV slot (:133-134), actual_seq_len (:136-138), the whole block table as i32 (:140-145), and the decode token id (:150-151). Each copy_h2d_async is a small memcpy; the block-table upload is re-sent in full every token.

5. upload_seq_slot_uniform (decode_a.rs:174-175) — 6th H2D.

6. l.decode_prestage(...) for all 48 layers (decode_a.rs:157-164) → qwen3_ssm/trait_layer.rs:301-310 → PleLayer::prestage (crates/spark-model/src/layers/ple/aux_state.rs:99-127). Per PLE layer this does, on the host:

7. ForwardContext construction (decode_a.rs:278-300), including host_token_ids: Some(slice) so PLE never reads token ids back from the device (decode_a.rs:294-296, ple/layer.rs:278-313).

Phase 2 — graph replay: decode_a.rs:309-329. Unreachable for this model (use_graphs == false ⇒ graph_cache is None).

Phase 3 — eager body: decode_forward_body (crates/spark-model/src/model/trait_impl/decode_a3.rs:30-118), a plain loop over 48 layers (:41-53), then final RMS norm (:103-113) and self.lm_head(normed, stream) (:116).

Per SSM/GDN layer (36×), via qwen3_ssm/trait_layer.rs:312+ → trait_decode_hc.rs (stage names at trait_decode_hc.rs:119): hc_pre_attn → GDN block → hc_post → hc_pre_ffn → MoE → hc_post (+ hc_head on the last layer).

Per attention layer (12×), qwen3_attention/trait_impl/decode_inner.rs:

QSA indexer per attention layer (crates/spark-model/src/layers/qsa.rs:330-450) — this is the worst per-token offender:

MoE: 512 experts, top-10, NVFP4. In the arms I could read, MoE is the single largest GPU stage: qwen3_ssm/trait_layer.rs:136-142 reports ATLAS_QWEN4EXP_VERIFY_PROF means of moe 287.6 µs/layer-call at K=2 vs hc_pre_attn 184.9 µs, hc_pre_ffn 183.8 µs, gdn_block 16.8 µs.

HC (hyper-connections): the low-rank (rank 320) family, dispatched in crates/spark-model/src/layers/ops/hyper_connection_dispatch.rs:75-124 / 134-175 / 183-225. At decode (num_tokens <= 64) hc_pre_lowrank and hc_head_lowrank deliberately take a 3-launch split path (crates/spark-model/src/layers/ops/hyper_connection_lowrank.rs:64-79 and :141-156) because the fused grid=[T] kernel is one block on one SM streaming ~13 MB of weights — "measured 2.0 ms; the whole token was 96× that" (hyper_connection_lowrank.rs:59-63), i.e. the historical eager token was ~192 ms. The split path trades that for 3 launches × 96 sites.

Head: decode_a3.rs:116 calls self.lm_head(normed, stream). The batched-GEMV ladder (crates/spark-model/src/model/trait_impl/lm_head_batched.rs:31-39, ON unless ATLAS_NO_LM_HEAD_BATCH_GEMV=1) is used by the mixed head (decode_b2.rs:58-68) and by decode_a2.rs:579, but the single-token body still calls the plain lm_head. For a [248320, 5120] NVFP4 head that is ~254 MB streamed per token (decode_b2.rs:60-63), which at GB10-class bandwidth is a floor of a few ms — real but not the 60 ms.

Launch/sync/host-IO tally per token (counts are code-derived; the per-launch cost is not knowable from source):

StageCount/tokenGraph?Host syncsNVMe
Phase 1 uploads6 H2Deager00
PLE prestage36 × (hash + resolve + 1 H2D)eager36 (aux_state.rs:158)up to 36 × 16 rows × 4 KiB (×2 with scale file)
HC pre/head96 sites × 3 launches = 288eager (uncaptured)00
HC post96eager00
QSA indexer12 × (1 cuBLAS + 2 kernels + 1 gather + 3 copies) ≈ 84eager12 (qsa.rs:386)0
GDN block / attention / norms / d2d~10–20/layer ≈ 500–700eager00
MoE48 × (router + 10 experts) ≈ 500+eager00
final norm + lm_head2eager00
Total~1,500–2,000 launches0 captured~48≤576 4 KiB reads

2. Answers to the five open questions

I must be upfront: read_findings was not re-readable inside this budget-exhausted session, so I answer the five questions as the tree frames them (the questions the code itself poses). If the operator's wording differs, the mapping is: (a) where the time goes, (b) is it launch-bound, (c) is it PLE/NVMe, (d) is it QSA, (e) what to change first.

(a) Where does the ~60 ms go? Three terms, in order of confidence:

1. Eager launch + host-serialization overhead across ~1,500–2,000 kernels, because graphs are vetoed (decode_a.rs:250,266-275; vetoes at qwen3_attention/trait_impl.rs:110 and qwen3_ssm/trait_layer.rs:261). This is the only term that is proportional to the whole model rather than to one stage, and it is the term the codebase itself names as dominant in the analogous GDN case (decode_a.rs:236-238).

2. 48 hard stream barriers inside the step: 12 QSA copy_d2h_on_stream (qsa.rs:386 → gpu_copy.rs:128) and 36 PLE release_prev_pins (aux_state.rs:158). Each barrier converts GPU-parallel work into a host round trip and prevents any cross-stage overlap.

3. MoE and HC weight streaming: MoE ≈ 287.6 µs/layer-call and HC pre ≈ 185 µs/site at K=2 (qwen3_ssm/trait_layer.rs:136-142). Even reading those as verify-width numbers, they are the two largest GPU stages, and 96 HC sites/token is the multiplier.

Not the head: trait_layer.rs:197-201 reports a probe giving "norm 9 us / lm_head 1508 us / argmax 289 us", and explicitly rules the lm_head/argmax tail out. Not the SSM recurrence: gdn_block 16.8 µs (:141).

(b) Is it launch-bound? Almost certainly yes, and it is provably graph-free by construction rather than by misconfiguration — the veto is a any() over layers, so a single QSA or PLE layer disables capture model-wide (decode_a.rs:250). I could not measure launch cost from source; the decisive experiment is ATLAS_NO_DECODE_GRAPHS=1 vs the current state (decode_a.rs:253) which will show no difference here, plus a nsys launch-count/duty-cycle trace.

(c) Is PLE/NVMe on the critical path at decode? Partly, and mostly as serialization, not bandwidth. Decode asks for 16 ids/layer (verify.rs:141: "Decode-scale (16 ids) stays at debug"), the row cache is O_DIRECT 4 KiB reads with QD=32 (ngram_cache_fault.rs:29-34), and misses are compulsory first-touch ("65536 → 1048576 slots changed the miss count by zero", :13-15). A warm steady-state decode should be near-all-hits, in which case the cost is the 36 mutex acquisitions + 36 stream syncs + 36 pageable H2Ds, not the disk. I could not confirm the decode-time hit rate from code — it is logged at debug (verify.rs:150-155) and must be read off a live run.

(d) Is QSA helping or hurting? As implemented it is a net loss at decode: it (i) vetoes graphs for the whole model (qwen3_attention/trait_impl.rs:110-112), (ii) inserts a host round trip per layer per token (qsa.rs:386), (iii) does a host sort over all visible blocks (qsa.rs:391-401), and (iv) then pays a qsa_gather into a scratch paged view (qsa.rs:418-432) before attention runs. It only pays off once complete > block_topk — qsa.rs:350-354 returns None and takes the exact dense path while the context is short. So at short-to-moderate context, QSA costs the graph and buys nothing.

(e) What is the repo's own unresolved mystery? Worth stating so nobody re-derives it: the K=3-vs-K=2 verify cost has +26.3 ms "unattributed" in the non-SSM half of the forward, with four prior explanations explicitly refuted in qwen3_ssm/trait_layer.rs:130-221. That is a verify-path number, not the single-token decode number, but it is direct evidence that ~26 ms of a step in this model is currently unexplained by any stage the authors instrumented — consistent with my (b): per-kernel launch/host overhead, which their stage timers (all bracketed by synchronize, e.g. decode_inner.rs:561-575) attribute to whichever bucket surrounds them.

3. Ranked optimization plan (smallest-safe first)

Config / serve-flag changes (no code)

1. Measure before touching anything: get the real anatomy.

2. ATLAS_D2H_SPIN_SYNC=1.

3. ATLAS_PLE_FAULT_THREADS sweep (32 → 64/128) and confirm the row cache is warm.

4. Do not expect anything from graph flags. ATLAS_GDN_DECODE_GRAPH=1 / ATLAS_EP_GRAPHS=1 (decode_a.rs:223,240) are OR'd into the use_graphs precondition but && !layer_veto (:274) still kills it. Anyone "testing CUDA graphs" on this model without removing the veto is testing nothing. This is a negative config finding worth recording.

Code changes

5. Make QSA's host top-k device-side, then lift the QSA graph veto. (highest expected gain; largest work)

6. Replace PLE's per-layer full barrier with a recorded event.

7. Cut HC launches from 3 to 1 per site at decode by splitting along rank/hidden, not along the pipeline.

8. Batch the Phase-1 metadata uploads into one pinned-memory H2D.

Explicitly not recommended yet

What I could not confirm from the code