Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
6 changes: 6 additions & 0 deletions docs/api/kda_decode.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ The public ``recurrent_kda`` API supports standard decode with one token per
sequence (``T=1``) and packed speculative decode with two or more tokens per
sequence (``T>=2``).

``fused_kda_decode`` combines width-four causal convolution, one KDA update,
and gated RMSNorm for ``T=1``. ``fused_kda_decode_packed`` extends the same
pipeline to packed ragged speculative decode, retaining one recurrent
checkpoint per token and an extended rolling convolution window.

Pass ``backend="cake"`` to select the exported Cake backend. On SM100-family
SM100a (B200/GB200) and SM103a (B300/GB300) devices, its D128 ``T=1..6``
family with in-kernel QK normalization exports 25 frozen CUDA bodies:
Expand Down Expand Up @@ -125,5 +130,6 @@ small ``B * HV * T``. Requires SM90+ for the WY path and ``K = V = 128``;
:toctree: ../generated

fused_kda_decode
fused_kda_decode_packed
packed_kda_decode
recurrent_kda
1 change: 1 addition & 0 deletions flashinfer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ def __dir__() -> list[str]:
from .kda import recurrent_kda as recurrent_kda
from .kda_decode import fused_kda_decode as fused_kda_decode
from .kda_decode import packed_kda_decode as packed_kda_decode
from .kda_decode import fused_kda_decode_packed as fused_kda_decode_packed
from .cake_minimax_h3 import MiniMaxH3Mxfp8PreAttention as MiniMaxH3Mxfp8PreAttention
from .mla import BatchMLAPagedAttentionWrapper as BatchMLAPagedAttentionWrapper
from . import mhc as mhc
Expand Down
156 changes: 156 additions & 0 deletions flashinfer/kda_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from .api_logging import flashinfer_api
from .trace.templates.kda import (
fused_kda_decode_packed_trace,
fused_kda_decode_trace,
packed_kda_decode_trace,
recurrent_kda_trace,
Expand All @@ -45,6 +46,16 @@
_FUSED_KDA_DECODE_AVAILABLE = False

from .kda_kernels import run_packed_kda_decode as _run_packed_kda_decode

try:
from .kda_kernels.fused_kda_decode_multitoken import (
run_fused_kda_decode_multitoken as _run_fused_kda_decode_packed,
)

_FUSED_KDA_DECODE_PACKED_AVAILABLE = True
except (ImportError, RuntimeError):
_run_fused_kda_decode_packed = None
_FUSED_KDA_DECODE_PACKED_AVAILABLE = False
from .kda_kernels import run_recurrent_kda as _run_recurrent_kda

try:
Expand Down Expand Up @@ -476,6 +487,151 @@ def fused_kda_decode(
)


@flashinfer_api(trace=fused_kda_decode_packed_trace)
def fused_kda_decode_packed(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Documentation check: flashinfer.kda_decode.fused_kda_decode_packed: Missing 'Parameters' / 'Args' section in docstring

x: torch.Tensor,
weight: torch.Tensor,
conv_state: torch.Tensor,
raw_gate: torch.Tensor,
raw_beta: torch.Tensor,
A_log: torch.Tensor,
dt_bias: torch.Tensor,
state_indices: torch.Tensor,
query_start_loc: torch.Tensor,
num_accepted_tokens: torch.Tensor,
state: torch.Tensor,
output_gate: torch.Tensor,
norm_weight: torch.Tensor,
lower_bound: Optional[float] = -5.0,
norm_eps: float = 1e-5,
output: Optional[torch.Tensor] = None,
) -> torch.Tensor:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

API nits: could be fixed before merge here, otherwise could become expensive to clean up after release (we can't change anything about this API after unless its bwd compatible or go through the long process for deprecation).

(a) Two parameters are inserted mid-signature, and nothing is keyword-only.
inspect.signature on both ops gives 16 parameters each, with 14 shared —
identical names, defaults and relative order
(x, weight, conv_state, raw_gate, raw_beta, A_log, dt_bias, state_indices, state, output_gate, norm_weight, lower_bound, norm_eps, output). But query_start_loc and num_accepted_tokens
land at positions 8 and 9, so everything from state onward shifts by two
[GPU]:

pos fused_kda_decode fused_kda_decode_packed
7 state_indices state_indices
8 state query_start_loc
9 output_gate num_accepted_tokens
10 norm_weight state
11 lower_bound output_gate
12 norm_eps norm_weight
13 output lower_bound
14 backend (keyword-only) norm_eps
15 state_indices_mode (keyword-only) output

All 16 parameters on the new op are POSITIONAL_OR_KEYWORD, whereas the sibling
makes its two newest (backend, state_indices_mode) KEYWORD_ONLY. So a caller
moving a positional call between two functions that otherwise share fourteen
parameters in the same order passes state where query_start_loc belongs. In
fairness, the validators would catch most tensor mix-ups with a confusing shape
error rather than corrupting anything — the hazard is the confusion, plus the fact
that the lower_bound / norm_eps / output tail is less certainly caught. The
reason to fix it now is that it is free today and a breaking change once 0.7 cuts,
which is #4936's own central thesis applied to this PR.

Ask: make query_start_loc and num_accepted_tokens keyword-only, following
the precedent the sibling already set in this file.

(b) The name inverts the modifier order of the op 190 lines above it.
packed_kda_decode already exists in this module; the new op spells the same
modifier the other way round. Cheapest item here, and the only one that is purely
cosmetic once released.

(c) The packed facade has no backend=, unlike the sibling.

fused_kda_decode exposes backend: Literal["auto", "cute-dsl", "cake"] and
state_indices_mode as keyword-only parameters
(flashinfer/kda_decode.py:386-389) and forwards them (:485-486).
fused_kda_decode_packed has neither. That is two sibling facades in one module,
sharing fourteen parameters, one with a backend selector and one without —
structurally the same divergence as #4936's problem 6 between kda.py and
kda_decode.py, on a new axis.

The concrete consequence lands on the T=1 branch, which calls
_run_fused_kda_decode at :597-612 without either argument and therefore always
takes the default "cute-dsl" route. A T=1 call that would have selected the
exported Cake kernel on SM100a/SM103a through fused_kda_decode cannot reach it
through this entrypoint, and nothing in the docstring says so. The claim at :518
that "T=1 dispatches to :func:fused_kda_decode" is also not quite right — it
dispatches to that op's backend, with a narrower argument set.

  • Ask: say whether backend= is planned for the packed path or forward both arguments on the T=1 branch

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I renamed it to packed_fused_kda_decode and moved query_start_loc and num_accepted_tokens behind *, so the shared positional arguments stay aligned with fused_kda_decode. I kept this facade CuTe-only rather than exposing backend=: T>1 exists only in CuTe and Cake doesn't consume packed metadata; the docstring now says that explicitly. I'd add backend selection if Cake gains packed semantics.

r"""Run packed T>=1 fused KDA with per-token cache checkpoints.

Tokens use a packed ragged layout described by ``query_start_loc``.
``state_indices`` has shape ``[N, T]``, where T is the maximum verification
length. ``num_accepted_tokens[n] - 1`` selects the source checkpoint and
convolution-history offset for sequence ``n``; token ``t`` writes its
recurrent checkpoint to ``state_indices[n, t]``. The convolution cache is
one rolling window of length ``T + 2`` in ``state_indices[n, 0]``.

T=1 dispatches to :func:`fused_kda_decode`, including its bfloat16-state and
softplus-gate support. T>1 uses the experimental SM10x packed backend,
which currently requires FP32 recurrent state and a finite negative
``lower_bound``. Both paths require head dimension 128, convolution width
four, and 12, 24, 32, 48, or 96 heads. Other tensors follow
:func:`fused_kda_decode`, with the packed row count supplied by ``x``.

``query_start_loc`` must be contiguous int32 of shape ``[N+1]``, start at
zero, and contain nondecreasing offsets within ``x`` with active lengths no
larger than T.
``num_accepted_tokens`` must be contiguous int32 of shape ``[N]`` with
values in ``[1, T]``. Active recurrent destinations must be positive cache
slots and must not alias destinations from another active sequence;
non-positive or zero-length rows are padding and do not mutate either cache.
These value constraints are caller-owned so CUDA Graph replay never performs
a device-to-host validation sync.

Args:
x:
Packed QKV projection with shape ``[num_rows, 3 * H * 128]`` and
dtype bfloat16.
weight:
Depthwise convolution weights with shape ``[3, 4, H * 128]`` and
dtype float32.
conv_state:
Paged bfloat16 convolution cache. T=1 uses history length three;
T>1 uses the extended rolling history length ``T + 2``.
raw_gate:
Raw recurrence gate with shape ``[1, num_rows, H, 128]`` and dtype
bfloat16.
raw_beta:
Raw delta-rule learning-rate logits with shape
``[1, num_rows, H]`` and dtype bfloat16.
A_log:
Log decay parameter with ``H`` elements and dtype float32.
dt_bias:
Per-channel decay bias with ``H * 128`` elements and dtype float32.
state_indices:
Contiguous int32 cache slots with shape ``[N, T]``. For T>1,
column ``t`` is the recurrent checkpoint destination for token
``t``. Column zero also selects the rolling convolution cache.
query_start_loc:
Contiguous int32 packed-row offsets with shape ``[N + 1]``.
num_accepted_tokens:
Contiguous int32 accepted-token counts with shape ``[N]``.
state:
Paged recurrent state with shape ``[num_slots, H, 128, 128]``.
T=1 accepts float32 or bfloat16; T>1 requires float32.
output_gate:
Gated RMSNorm logits with shape ``[num_rows, H, 128]`` or
``[1, num_rows, H, 128]`` and dtype bfloat16.
norm_weight:
RMSNorm weight with 128 elements and dtype float32.
lower_bound:
Negative recurrence-gate lower bound. T=1 also accepts ``None``
for the softplus gate; T>1 requires a finite negative value.
norm_eps:
Non-negative RMSNorm epsilon.
output:
Optional preallocated contiguous bfloat16 output with shape
``[1, num_rows, H, 128]``.

Returns:
The packed bfloat16 output with shape ``[1, num_rows, H, 128]``.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if state_indices.ndim == 2 and state_indices.shape[1] == 1:
if _run_fused_kda_decode is None:
raise NotImplementedError("fused KDA decode backend is unavailable")
legacy_state_indices = state_indices[:, 0]
if x.shape[0] != legacy_state_indices.shape[0]:
packed_rows = torch.arange(
x.shape[0], dtype=query_start_loc.dtype, device=x.device
)
sequence_indices = torch.searchsorted(
query_start_loc[1:], packed_rows, right=True
)
legacy_state_indices = legacy_state_indices.index_select(
0, sequence_indices
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The guard at :587 tests a shape (x.shape[0] vs the sequence count), but whether row i belongs to sequence i is a property of query_start_loc's values — it holds only when query_start_loc == arange(N+1). With num_rows == N and unbalanced sequences (e.g. live rows followed by trailing padding rows whose sequences are zero-length) the remap is skipped, and on a B200 I see conv and state slots {3,4} mutated where only {1,2} should be, plus non-zero values written into the padding output rows — which contradicts :531, "non-positive or zero-length rows are padding and do not mutate either cache". With num_rows != N the unclamped searchsorted at :591-593 returns N, and the index_select at :594 trips indexSelectSmallIndex: Assertion 'srcIndex < srcSelectDimSize' failed, which poisons the CUDA context rather than raising a catchable error. The sharpest case is CUDA-graph capture: this branch is evaluated once on the host, so capturing with an all-active query_start_loc compiles the remap out of the graph, and replaying with a zero-length sequence returns wrong live-row output (0.41 against the suite's atol=2e-2) with a corrupted state pool — the T>1 path replays correctly because the kernel reads query_start_loc on device.

Suggest dropping the guard and always remapping, which is a no-op when the mapping really is the identity, with one null slot appended so out-of-range rows land on the backend's existing null path instead of faulting:

legacy_state_indices = torch.cat(
    [state_indices[:, 0], state_indices.new_zeros(1)]
).index_select(0, torch.searchsorted(query_start_loc[1:], torch.arange(
    x.shape[0], dtype=query_start_loc.dtype, device=x.device), right=True))

Both shapes below are ones the T>1 path is explicitly tested for — test_fused_kda_decode_packed_ragged_rows_and_padding and test_fused_kda_decode_packed_cuda_graph_dynamic_metadata — so this is an inconsistency between the two halves of one facade rather than an arguable reading of the contract.

Output on a B200 (SM100, cc 10.0):

case A: trailing padding rows, num_rows == N == 4, query_start_loc=[0,1,2,2,2]
  conv slots mutated : [1, 2, 3, 4]   expected [1, 2]
  state slots mutated: [1, 2, 3, 4]   expected [1, 2]
  padding rows 2:4 written: True  max|value|=0.8164

case B: captured with query_start_loc=[0,1,2,3], replayed with [0,1,1,2]
  live-row output correct : False
  max|replay - truth|     : 0.4072   (test tolerance atol=2e-2)
  per-row max abs diff    : [0.0, 0.4072]
  state pool correct      : False

In case B, row 0 is correct and row 1 is wrong, which is what pins the diagnosis to the mapping rather than to the kernel.

Repro script (save at the repo root, PYTHONPATH=$PWD python repro_4809_t1.py)
import sys
import torch

sys.path.insert(0, "tests")
from kda.test_fused_kda_decode import _clone_strided, _make_inputs

import flashinfer
from flashinfer.kda_decode import fused_kda_decode_packed

DEV, H = torch.device("cuda"), 12


def build(num_rows, qsl, slots):
    n = len(qsl) - 1
    inp = _make_inputs(H, max(num_rows, n), state_dtype=torch.float32, conv_history=3)
    for key, dim in (("x", 0), ("raw_gate", 1), ("raw_beta", 1), ("output_gate", 0)):
        inp[key] = inp[key].narrow(dim, 0, num_rows)
    inp["state_indices"] = torch.tensor([[s] for s in slots], dtype=torch.int32, device=DEV)
    inp["query_start_loc"] = torch.tensor(qsl, dtype=torch.int32, device=DEV)
    inp["num_accepted_tokens"] = torch.ones(n, dtype=torch.int32, device=DEV)
    return inp


def mutated(after, before):
    d = (after.float() - before.float()).abs()
    return d.flatten(1).amax(1).nonzero().flatten().tolist()


# Case A: 2 live rows (sequences 0,1) + 2 trailing padding rows (sequences 2,3
# zero-length). num_rows == N == 4, so the guard at :587 is False and the remap
# never runs. Per :531, slots 3 and 4 must be untouched.
inp = build(num_rows=4, qsl=[0, 1, 2, 2, 2], slots=[1, 2, 3, 4])
conv0, state0 = _clone_strided(inp["conv_state"]), _clone_strided(inp["state"])
conv, state = _clone_strided(inp["conv_state"]), _clone_strided(inp["state"])
out = torch.full((1, 4, H, 128), torch.nan, dtype=torch.bfloat16, device=DEV)
fused_kda_decode_packed(**{**inp, "conv_state": conv, "state": state, "output": out})
torch.cuda.synchronize()

print("case A: trailing padding rows, num_rows == N == 4, query_start_loc=[0,1,2,2,2]")
print(f"  conv slots mutated : {mutated(conv, conv0)}   expected [1, 2]")
print(f"  state slots mutated: {mutated(state, state0)}   expected [1, 2]")
print(f"  padding rows 2:4 written: {not bool(torch.isnan(out[0, 2:]).all())}"
      f"  max|value|={float(out[0, 2:].float().nan_to_num().abs().max()):.4f}")

# Case B: capture all-active (num_rows == N, guard False, remap compiled OUT),
# then replay with a zero-length sequence.
inp = build(num_rows=3, qsl=[0, 1, 2, 3], slots=[1, 2, 3])
conv, state = _clone_strided(inp["conv_state"]), _clone_strided(inp["state"])
out = torch.zeros((1, 3, H, 128), dtype=torch.bfloat16, device=DEV)
call = {**inp, "conv_state": conv, "state": state, "output": out}

s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
    for _ in range(3):
        fused_kda_decode_packed(**call)
torch.cuda.current_stream().wait_stream(s)

g = torch.cuda.CUDAGraph()
conv.copy_(inp["conv_state"]); state.copy_(inp["state"])
with torch.cuda.graph(g):
    fused_kda_decode_packed(**call)

conv.copy_(inp["conv_state"]); state.copy_(inp["state"])
call["query_start_loc"].copy_(torch.tensor([0, 1, 1, 2], dtype=torch.int32, device=DEV))
g.replay()
torch.cuda.synchronize()

# Truth: rows 0 and 1 belong to sequences 0 and 2, i.e. slots 1 and 3.
truth_state = _clone_strided(inp["state"])
truth = flashinfer.fused_kda_decode(
    x=inp["x"].narrow(0, 0, 2),
    weight=inp["weight"],
    conv_state=_clone_strided(inp["conv_state"]),
    raw_gate=inp["raw_gate"].narrow(1, 0, 2),
    raw_beta=inp["raw_beta"].narrow(1, 0, 2),
    A_log=inp["A_log"],
    dt_bias=inp["dt_bias"],
    state_indices=torch.tensor([1, 3], dtype=torch.int32, device=DEV),
    state=truth_state,
    output_gate=inp["output_gate"].narrow(0, 0, 2),
    norm_weight=inp["norm_weight"],
)
torch.cuda.synchronize()

d = (out[0, :2].float() - truth.reshape(2, H, 128).float()).abs()
print("\ncase B: captured with query_start_loc=[0,1,2,3], replayed with [0,1,1,2]")
print(f"  live-row output correct : {bool(d.max() < 2e-2)}")
print(f"  max|replay - truth|     : {float(d.max()):.4f}   (test tolerance atol=2e-2)")
print(f"  per-row max abs diff    : {[round(float(v), 4) for v in d.flatten(1).amax(1)]}")
print(f"  state pool correct      : {bool((state - truth_state).abs().max() < 1e-3)}")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

fixed in 0e280eb: T=1 now resolves the packed row to its sequence inside the CuTe kernel from query_start_loc on every replay, and rows past query_start_loc[-1] go through the null slot. I added the trailing-padding and dynamic CUDA Graph cases from the repro; the fused/API/trace suite passes 1046/1046 on B300.

return _run_fused_kda_decode(
x=x,
weight=weight,
conv_state=conv_state,
raw_gate=raw_gate,
raw_beta=raw_beta,
A_log=A_log,
dt_bias=dt_bias,
state_indices=legacy_state_indices,
state=state,
output_gate=output_gate,
norm_weight=norm_weight,
lower_bound=lower_bound,
norm_eps=norm_eps,
output=output,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if _run_fused_kda_decode_packed is None:
raise NotImplementedError("packed fused KDA decode backend is unavailable")
return _run_fused_kda_decode_packed(
x=x,
weight=weight,
conv_state=conv_state,
raw_gate=raw_gate,
raw_beta=raw_beta,
A_log=A_log,
dt_bias=dt_bias,
state_indices=state_indices,
query_start_loc=query_start_loc,
num_accepted_tokens=num_accepted_tokens,
state=state,
output_gate=output_gate,
norm_weight=norm_weight,
lower_bound=lower_bound,
norm_eps=norm_eps,
output=output,
)


# Cached trivial metadata for the frozen mode's uniform batched form: the
# arange cu_seqlens / slot indices are content-stable per (device, B, T), so
# building them once keeps the hot path allocation- and launch-free (required
Expand Down
11 changes: 11 additions & 0 deletions flashinfer/kda_kernels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- run_recurrent_kda: Recurrent KDA standard decode and speculative decode backend
- run_fused_kda_decode: Fused Kimi K3 conv, recurrent KDA, and RMSNorm backend
- run_packed_kda_decode: Packed Kimi K3 T=1 recurrent decode backend
- run_fused_kda_decode_multitoken: Packed T>=1 fused KDA backend

The ``*_kda_prefill_sm120`` names below are the optional facade that
``flashinfer.kda_prefill`` dispatches through; they are not part of this
Expand Down Expand Up @@ -55,6 +56,14 @@
# ``run_recurrent_kda`` in ``recurrent_kda.py``); import it by module path
# only for tests and benchmarks.

try:
from .fused_kda_decode_multitoken import run_fused_kda_decode_multitoken

fused_kda_decode_multitoken = run_fused_kda_decode_multitoken
except (ImportError, RuntimeError):
run_fused_kda_decode_multitoken = None # type: ignore
fused_kda_decode_multitoken = None # type: ignore

try:
if _torch.cuda.is_available():
from ..cute_dsl.availability import is_cute_dsl_arch_supported as _dsl_arch_ok
Expand Down Expand Up @@ -106,9 +115,11 @@

__all__ = [
"fused_kda_decode",
"fused_kda_decode_multitoken",
"packed_kda_decode",
"recurrent_kda",
"run_fused_kda_decode",
"run_fused_kda_decode_multitoken",
"run_packed_kda_decode",
"run_recurrent_kda",
]
Loading
Loading