Skip to content

[WIP] perf(k3): optimize decode - #834

Draft
nperrin-fr wants to merge 24 commits into
mainfrom
dev/decode-k3
Draft

[WIP] perf(k3): optimize decode#834
nperrin-fr wants to merge 24 commits into
mainfrom
dev/decode-k3

Conversation

@nperrin-fr

Copy link
Copy Markdown
Collaborator

Summary

Test Plan

nperrin-fr and others added 21 commits July 27, 2026 23:40
…code

The single-CTA packed-key sigmoid-bias top-16 kernel was gated to gfx950;
NVIDIA decode routed through the grouped multi-pass path (7.0us vs 3.7us
per MoE layer on B300 at the K3 decode shape). Enable it for NVIDIA and
keep it selected under the deployed defaults:

- pass the CDNA-only waves_per_eu launch hint only on AMD
- fuse the static expert-location dispatch map into the kernel (the
  server's default static dispatch previously forced every K3 decode
  step onto the grouped path even at ep_size=1)
- support bf16 route-weight output so the sidecar adapter's cast stays
  a no-op; selection and normalization stay in FP32

bs1 decode TPOT on 8x B300 TP8: 10.71ms -> 10.53ms (93.4 -> 95.0 tok/s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Replace the fused-AR + replicated up-projection tail with three fused
stages inside the decode CUDA graphs: one kernel for the latent
all-reduce + RMSNorm + shared-expert reduce-scatter, a per-rank-sharded
up-projection (1/tp of the replicated weight traffic) whose epilogue
multicast-stores the shard into every rank's mailbox over NVLS, and a
barrier-free Lamport gather. Buffers come from stock PyTorch symmetric
memory; the CuTe-DSL kernels are vendored under thirdparty/cute_dsl/
(see the package header for provenance and license).

Engages only inside the CUDA-graph phase on plain TP8/TP16 (no EP),
SM100-family, bf16, hidden 7168 / latent 3584, and at most 16 tokens;
eager forwards and larger batches keep the existing fused-AR tail.
TOKENSPEED_K3_MULTICAST_TAIL=0 disables it.

Standalone (8x B300, graph replay): 8.15us vs ~18.6us per layer for the
current lamport-AR + replicated-gemv tail. bs1 decode TPOT: 10.53ms ->
9.96ms (95.0 -> 100.4 tok/s), stable P99 == median.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Drop the enablement environment variable: the tail already has a full
eligibility chain (platform/TP/shape checks, a symmetric-memory multicast
probe, and a per-layer try/except fallback to the fused-AR tail), so the
switch added no selection power — optional knobs just rot unconfigured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Vendor the warp-specialized SM100 attention-residual forward kernel
(online-softmax mix over the block snapshots + prefix in one launch, TMEM
V-row caching, PDL chaining, optional in-place delta accumulate and fused
out-norm; provenance and license in the file headers) and register it as
the CUDA solution for the attn_res family. The split partial/combine
arrangement stays as the portable fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Add an mnnvl-structured one-shot kernel to the vendored AR-fusion fork
(single multicast store through the switch, local-only lamport poll,
deterministic rank-ordered reduction, self-rotating device flags) and
instantiate the existing FusedOp epilogues over it, covering the plain,
residual+RMSNorm and AttnRes-combine patterns. The workspace comes from
torch symmetric memory; arming is a collective capability probe with the
IPC lamport workspace as automatic fallback, both in the kernel-package
manager and the runtime backend's plain all-reduce. The latent-lane norm
pattern stays on the IPC path (measured slower on the wide lane).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…ernels

One launch now quantizes the NoPE query and commits the latent row into
the fp8 FlatKV cache (byte-exact vs the previous pair, with the same NaN
sanitization). The q/kv dual RMSNorm moves to a two-CTA Triton kernel,
and the SiTU/sigmoid-mul/gated-RMSNorm glue kernels get launch shapes
tuned for decode (fewer, lighter CTAs that co-reside with aux-stream
work). Derived offline from Inductor codegen of the same modules; no
runtime compile dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…code

Every Triton kernel on the K3 decode path gains an enable_pdl flag with
conservative griddepcontrol fencing (wait before the first
predecessor-output load, launch_dependents after the last store),
bit-identical output either way; enablement stays with the caller and
defaults off pending an uncontended A/B. Also check in the load-shaped
regression tests distilled from the bs64 stability investigation: an
eval-shaped multi-rank chain test for the deferred-delta arrangement and
a sidecar contract test that pins the deferred-finalize out-of-bounds
write (xfail on the affected sidecar build; the reland gate for both
suspended features).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…itive

The router GEMV at one token underfills the dense kernel (~1.8 TB/s on
896 rows); route it through the row-per-CTA streaming GEMV instead
(4.0us vs 6.2us uncontended), shifting the main-stream front left while
the top-k stays hidden under the latent down-projection on the aux
stream. Also add a single-read merged [gate|latent] front kernel with an
auto strategy: measured slower at decode (the top-k serializes behind
the merged sweep) so it stays off at bs1 and is available for mid-batch
prefill fronts; design source credited in comments. bs1 decode:
9.91 -> 9.77ms TPOT; GSM8K 0.978 / GPQA 0.9242 at bs64, zero faults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Plumbs the flag from the public try-wrapper through the registry adapter
to the megafuse launcher (default off). The runtime call site stays
unwired: the serving A/B measured noise-to-worse, consistent with every
PDL experiment on this decode graph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…ather

The multicast tail's final gather now takes an optional addend and folds
the layer's residual stream into the copy (fp32 add, bf16 round — bit
identical to the eager add, asserted with torch.equal in the distributed
test), removing one elementwise launch per MoE tail layer. bs1 decode:
9.76 -> 9.62ms TPOT; bs64 eval clean (GSM8K 0.975; GPQA within its
temp=1.0 sampling band for a bit-identical change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
The NVLS one-shot kernel's .cu was absent from the trtllm_comm build
group, so fresh builds silently lacked the symbol and the capability
probe fell back to the IPC workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
The lane pattern was routed back to the IPC workspace on measurement;
the test still asserted mnnvl parity and failed on the supports() guard.
It now asserts the routing decision itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
The rejected GEMM+AR experiment's runtime hooks rode along in the
residual-fusion commit via a concurrent edit and referenced a module
that no longer exists, breaking model import. Restore the model to the
tail-fusion state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Rewrite the single-launch KDA decode (conv+SiLU, f_b gate GEMV, QK
L2-norm, delta-rule recurrence against the paged fp32 state) in CuTe
DSL: warp-per-row-group state streaming with a cp.async prefetch front,
cluster-synchronized conv-window commit, and PDL with producer-
independent loads issued before the dependency wait. Ties the previous
kernel at single-token decode and wins from four tokens up (+24% at 16);
recurrence-phase design derived from an Apache-2.0 packed-decode
reference (header note), the fusion math ported from the in-tree kernel.

The Triton megafusion it replaces is removed outright: its in-place
conv-window shift raced across the column-split programs once the grid
exceeded one co-resident wave, double-shifting windows at large decode
batches (reproduced deterministically at 64). The verify variant writes
per-position windows to its own scratch and is unaffected. Platforms
without CuTe DSL fall back to the unfused conv/recurrence chain.

bs1 decode holds 9.62ms; bs64 eval clean with GSM8K 0.9788 /
GPQA-diamond 0.9293 (both above the campaign gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
The fused single-read projection+activation kernel was gfx950-only;
enable it for NVIDIA at one token with a B300-tuned launch (one output
row per CTA, 1024-wide K tile — the tile must be a power of two that
divides the hidden size, or the unmasked fast path reads past K).
Replaces the cublasLt GEMM + separate SiTU launch on the shared-expert
fork: 8.0us vs 9.2 standalone, bs1 decode 9.62 -> 9.52ms TPOT
(105.0 tok/s); bs64 eval clean, GSM8K 0.975 / GPQA-diamond 0.9394.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oPfcso15ywRoX9NTbbycp
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Fold rmsnorm(o) * w * sigmoid(gate) into the CuTe DSL KDA fused decode
kernel's epilogue, normalizing the fp32 accumulator directly (one fewer
bf16 round-trip than the separate kernel). NV=1 owns the full V row;
NV=2 exchanges sum-of-squares partials over distributed shared memory on
the existing single cluster-barrier phase, with the state writeback
deferred past the barrier to keep the store stream drained.

Handoff is attempt-and-verify via KdaGatedNormRequest: the model stashes
weight/gate/eps, the wrapper fuses only when shapes/dtypes/strides match
and marks the stash consumed; otherwise the host-side norm still runs.
Armed only in the CUDA-graph phase: the eager 1-token-prefill path
faults when epilogue fusion arms there (same dark corner as the latent
tail; root cause tracked separately).

Decode: 9.52 -> 9.47 ms median TPOT at bs1 (4k/500, TP8). Quality:
GSM8K 0.9795, GPQA-diamond 0.9293 at bs64. 9 new kernel tests cover
both NV paths, strided gates, fallback, and graph capture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…rm epilogue

The NV=2 fused-norm epilogue issued its st.shared::cluster partial-sum
exchange without a completed cluster-barrier phase, violating the CUDA
requirement that the target CTA has begun execution before its shared
memory is accessed, and its peer partial landed in a smem word still
read by the phase-2 normalizer (cross-CTA WAR). Under irregular SM
scheduling (a 1-token prefill interleaving with graph replay - which
every prefix-cached bench warmup produces) this surfaced as rare
cudaErrorIllegalAddress plus sporadic per-head output-scale corruption.

Give the peer partial a dedicated smem slot and restore the early
cluster arrive with an epilogue-side wait before the DSM store, keeping
the deferred state writeback ordering. compute-sanitizer: 294 hazards +
invalid peer writes -> 0. Stability: 14 bench runs under concurrent
1-token-prefill injection and double-bench load, zero faults (unfixed
build faulted within 4 runs); TPOT unchanged at 9.47 ms; GSM8K 0.9750,
GPQA-diamond 0.9141 at bs64.

Also sync the capture tests' side-stream warmups (missing wait_stream
ordering) and add a sanitizer-gated regression test that fails on the
unfixed kernel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Graph-replay A/B on B300 shows the fused gated-norm epilogue wins only
while launch overhead dominates: +0.03 to +0.09 ms/step for B<=16 but
-0.08 at B=32, -0.19 at B=64, -0.46 at B=128 (x69 layers) - at larger
batches the epilogue's per-row reductions serialize inside the kernel
while the standalone norm amortizes its launch cost. Decline the stash
for t > 16 so large-batch decode keeps the separate norm kernel; the
decision remains shape-deterministic and CUDA-graph stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…lective

Two of the three cluster-barrier uses inside the collective's per-token
loop arrived on every CTA but waited only on CTA0's first warp. Arrive/
wait counts must stay symmetric on every CTA: with the loop wrapping at
m >= 2, the skewed phase counters let a later iteration's pre-exchange
wait match an already-completed phase, so the st.shared::cluster store
into the sibling CTA ran without the peer-entered guarantee - an
intermittent illegal memory access. The window opens exactly when a
prefill admits a second sequence and the next step replays the bs>=2
decode graph, which matches every observed crash (including the earlier
bs64-eval faults previously attributed elsewhere by elimination); pure
bs1 replay never wraps the loop, which is why bs1-only soaks always
passed.

Make all three waits unconditional. Cost is ~0.01 ms/step at bs1 (noise
level). Soak: 5x bs1 + 3x forced-bs2 benches and a full bs64 eval,
zero faults; GSM8K 0.9757, GPQA-diamond 0.9192; 7/7 distributed tail
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
…ecode

Restructure the CuTe DSL fused decode's state handling around batch size.
At NV=1 (batch*heads >= 96) a single thread stages the full 64KB [K,V]
state slab with one 1D bulk-TMA copy behind an mbarrier (re-armed every
launch, so graph replays stay self-contained), and pass 2 recomputes the
decayed row from shared memory instead of holding a 64-register strip
that spilled. At NV>1 the register-resident pass 2 remains (faster at
small batch). The f_a gate operand loads as bf16x4 vectors (the front's
top long-scoreboard stall), and the gated-norm epilogue now fuses at
every batch size - the NV=1 epilogue is block-local, which removes the
large-batch crossover that motivated the old t<=16 gate.

Recurrence-phase design notes and provenance are documented in the
module docstring. Auto-selected; misaligned pool pitches fall back to
the layout-agnostic NV>1 path.

Standalone (graph replay, us/layer): B1 5.82->5.51, B8 6.32->6.13,
B16 9.18->7.91, B64 27.9->19.0. e2e decode: 9.47-9.48 -> 9.35 ms median
TPOT at bs1 TP8 (x6), forced-bs2 11.86 (x3). Quality: three greedy
GSM8K-128 evals at bs16 match the baseline failure distribution
(0.9844-0.9922); full bs64 eval runs as the final gate. 34 kernel tests
including envelope-strided pools and large-batch fused-norm parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
Collapse branch-added multi-line comment blocks to single-line
invariants, drop the narrative doc addition, and remove test files whose
gated features were reverted or quarantined (their history and repro
recipes live in the campaign records).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
nperrin-fr and others added 3 commits July 29, 2026 04:25
Both landed during the campaign but ended up with no runtime callers:
the online-softmax v2 attn-res family lost its e2e A/B and carries an
unresolved race (quarantined from the build since; the sources and
history remain on the backup branch), and the merged-front GEMV
primitive was superseded by the rowcta router path. Drop the kernels,
their wrappers, registry entries, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
The v2 removal dropped the trailing side-effect imports along with the
v2 entry point, so no attn_res backend registered and every K3 decode
raised NoKernelFoundError at startup. Import-only smoke checks and test
collection both pass without them; only a live server exercises the
registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
The guard coerced a falsy value to -1, so a request asking for prompt
logprobs from position 0 - the natural way to ask for all of them - was
accepted and answered with no logprobs at all, while 1 and above were
correctly rejected. Compare against None explicitly and handle the
batch (list) form.

Also correct the FlatKV note about recycled pages: the MLA decode kernel
masks the K side but feeds V-tail rows into the PV MMA verbatim, so the
writer's sanitize and zeroed pages are what keep a previous tenant's
bytes out of the output, not a kernel-side fixup that does not exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013y4dRxfUyRVFZWhf5ZSkJE
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
@nperrin-fr

Copy link
Copy Markdown
Collaborator Author

this is one is probably too big and I'll break it up later.

@nperrin-fr nperrin-fr changed the title perf(k3): optimize decode [WIP] perf(k3): optimize decode Jul 29, 2026
nperrin-fr added a commit that referenced this pull request Jul 29, 2026
At decode batch sizes the NoPE fp8 path was launch-latency bound: one
kernel assembled the fp8 query, a second committed the latent row into
the fp8 FlatKV cache. mla_nope_query_kv_fp8 does both in one launch,
byte-exact against the previous pair and with the same NaN sanitization.

The pool gains mla_fp8_commit_view, which runs set_mla_kv_buffer's
structural location checks and hands out the raw per-layer fp8 view only
when a direct dtype-cast store matches the write path's semantics. It
returns None otherwise, and the caller keeps the two-kernel path -- so
non-FlatKV pools, non-fp8 caches and non-unit-stride views are unaffected.

Split out of #834 (dev/decode-k3). The original commit also carried a
dual-RMSNorm rewrite and launch-shape tuning for the SiTU/sigmoid-mul glue
kernels; those are independent and follow separately.

Tests: 10 kernel cases (fp8 parity vs the unfused pair, NaN sanitization,
PDL on/off) and 5 pool cases (view gating and location checks).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKty3aLeTKb2vhRu4kvRgc
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
nperrin-fr added a commit that referenced this pull request Jul 29, 2026
At decode batch sizes the NoPE fp8 path was launch-latency bound: one
kernel assembled the fp8 query, a second committed the latent row into the
KV cache. mla_nope_query_kv_fp8 does both in one launch, byte-exact
against the previous pair and with the same NaN sanitization.

The destination is the existing get_key_buffer(layer_id): both MLA pools
expose an absolute-token-indexed latent buffer there, which is exactly what
the kernel indexes with out_cache_loc. No new pool API, and no capability
probing at the call site -- the surrounding branch already establishes MLA
kernel backend, fp8 data_type and k_scale == 1.0, and the kernel asserts
the rest (row width, unit last stride) while its store casts to the
destination dtype.

Split out of #834 (dev/decode-k3). The original commit also carried a
dual-RMSNorm rewrite and launch-shape tuning for the SiTU/sigmoid-mul glue
kernels; those are independent and follow separately.

Tests: 10 kernel cases (fp8 parity vs the unfused pair, NaN sanitization,
PDL on/off).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKty3aLeTKb2vhRu4kvRgc
Signed-off-by: Nicolas <nperrin.ai@gmail.com>
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