Skip to content

[RFC] Graph capture support for prefill and training: GDN/KDA chunk and convolution paths #1155

Open

Description

@zhiyuan1i

Proposal

Enable graph capture for the full prefill/training compute path of GDN and KDA models — the chunk attention forward/backward and the short-convolution forward/backward — so that a bounded input regime can be captured once and replayed many times.

"Graph capture" is backend-neutral in this proposal: CUDA Graphs on NVIDIA, and the equivalent graph mechanism on Ascend NPUs (ACL Graph, as already used by vLLM-Ascend), both rely on the same prerequisites — static grids, static addresses, capture-safe kernels. The contract proposed here is written once, against those prerequisites, and both backends benefit from the same work.

The dynamic parts of the varlen path today:

  1. Grids are sized by the number of chunks / tokens, which depends on the input lengths.
  2. The chunk index list (which sequence each chunk belongs to, its offset inside the sequence) is built on the host from cu_seqlens; its output length is data-dependent and the build involves host synchronization. Neither is allowed during capture.
  3. Tensor addresses change from call to call.

Batch size enters through (2): a static formulation must pin a maximum sequence count N_max alongside the token budget T_max. For GDN/KDA training, N_max is the micro-batch size — fixed per training configuration, so a run captures once and keeps replaying. For prefill, N_max is the serving engine's max sequences per batch. The pinned (T_max, N_max) pair also prices the memory overhead (below), so batch size is a first-class input to this design.

Principles

  • Scope: GDN and KDA, chunk attention fwd+bwd plus short-convolution fwd+bwd. Decode-specific paths (fused_recurrent, conv update-with-cache) are out of scope — serving stacks already handle those.
  • Switch off: behavior is bit-identical to today. Switch on: kernel math is unchanged; only scheduling metadata (grids, index tables) changes.
  • The graph path pins the maximum batch: inputs must satisfy tokens ≤ T_max and sequences ≤ N_max. Larger inputs transparently take the existing eager path with identical results.
  • One contract, both backends: the kernel-level changes (static grid, sentinel early-exit, device-side indices) are written once and are valid for both CUDA Graph and Ascend graph capture.
  • Success metric: host launches per layer drop to one replay within the bounded regime; end-to-end gain is measured by same-hardware benchmarks.

Core design

Static upper bound with sentinels. Given (T_max, N_max), the chunk count has a closed-form upper bound NT_max = cdiv(T_max, 64) + N_max − 1 (the cdiv term covers the first sequence's partial chunk; every further sequence contributes at most one more). Every chunk-count-sized kernel launches a static NT_max grid; unused index-table slots carry a sentinel, and the kernel returns immediately upon reading one.

Device-side chunk indices. The index list is generated on the device: a searchsorted-based routine fills a fixed-shape [NT_max, 2] buffer with (sequence id, intra-chunk offset) pairs and writes the sentinel into unused slots. cu_seqlens is padded to N_max + 1 entries with zero-length tail sequences so its shape is fixed. The operator can build this table internally, or the caller can precompute and pass it in — GDN already accepts external chunk_indices since #1132; KDA gains the same option here.

Static buffer contract. Replay reissues the captured addresses, so every call copies inputs into fixed-shape static buffers and reads outputs from them. This holds for prefill and training alike, independent of recompute mode, and defines how callers drive the operator.

Enable rule and fallback. An input is graph-eligible iff tokens ≤ T_max and sequences ≤ N_max (dense input is the N = 1 special case). Larger inputs take the eager path. Bucket choices (how many (T_max, N_max) tiers) are caller policy; the operator layer guarantees that any fixed tier is capturable.

Memory model (worst-case pricing). All graph-held tensors are sized at the static upper bound regardless of the actual input, and replay always touches the full static shapes. Cost follows the bound, in three parts: static input/output buffers at [T_max, …]; intermediates allocated at NT_max (for KDA-class states, K = V = 128 in fp32, that is 64 KB per head per chunk — T_max = 4096, N_max = 32, H = 32 gives ≈ 190 MB resident); and one graph private pool per tier. Convolution adds only its W-window states, negligible next to the chunk states. The first version ships a single tier; benchmarks decide whether tiering pays.

What needs to change

Four steps, each independently mergeable:

  1. Utils (fla/ops/utils/): device-side static chunk-index generation (searchsorted + sentinel fill), the NT_max helper, zero-length sequence padding, and static-grid variants of the varlen cumsum kernels. Pure-torch parity tests included.
  2. Kernel contract. Every varlen kernel in the two paths gains a compile-time GRAPH_CAPTURE flag and a sentinel early-exit. The flag defaults to off, in which case the new code is dead-code-eliminated and behavior is bit-identical. Wave-one checklist:
    • KDA fwd: kda_gate_chunk_cumsum, chunk_local_cumsum, chunk_kda_fwd_intra, chunk_gated_delta_rule_fwd_h, chunk_gla_fwd_o_gk.
    • KDA bwd: chunk_kda_bwd_intra, chunk_kda_bwd_wy_dqkg_fused, recompute_w_u_fwd, chunk_gated_delta_rule_bwd_dhu, the recomputed chunk_gated_delta_rule_fwd_h, kda_gate_bwd.
    • GDN fwd: chunk_local_cumsum, chunk_gated_delta_rule_fwd_intra, recompute_w_u_fwd, chunk_gated_delta_rule_fwd_h, chunk_fwd_o.
    • GDN bwd: prepare_wy_repr_bwd, chunk_bwd_dqkwg, chunk_bwd_dv_local, chunk_gated_delta_rule_bwd_dhu.
    • Conv fwd/bwd (fla/modules/conv/triton/): causal_conv1d_fwd_kernel, causal_conv1d_states_fwd_kernel, causal_conv1d_bwd_kernel, compute_dh0_kernel. The update/cache decode kernels stay untouched.
  3. Operator entry: KDA accepts external chunk_indices (mirroring GDN's [GDN] Allow passing pre-computed chunk_indices to chunk_gated_delta_rule #1132); the conv module and both operators gain the graph switch — an explicit argument, or automatic detection of an active capture (see Open questions).
  4. Tests: capture/replay bitwise parity against eager on identical inputs; repeated replay under varying cu_seqlens in the same static buffers; one coverage pass per gate branch; conv covered in the same capture/replay tests since it sits inside the same layer path. The context-parallel path needs no dedicated kernel change (its pre-process kernels are already static-grid); a two-rank capture/replay test guards it.

Limitations

  • Out-of-budget inputs fall back to eager: no gain, no loss. The expected benefit is short-sequence regimes; at long sequences the launch overhead is amortized away.
  • Capture requires a warmup: the kernels are autotuned, so the same static configuration must run eagerly first to fix the autotune choice; capturing cold triggers benchmarking during capture or records the wrong config. Warmup is a one-time caller cost.
  • Each (T_max, N_max) tier holds resident memory; with large training batches the NT_max-sized intermediates raise the memory floor. Size tiers from the model's actual shapes before enabling.
  • Do not combine with torch.compile's own cudagraph mode; pick one.

Open questions

  • Switch shape: an explicit graph_capture=True argument versus automatically taking the static path whenever a capture is active. I lean explicit.
  • Bucket ownership: operator layer exposes only the contract and callers (vLLM, training frameworks) own tiering and static buffers, versus FLA shipping a reference wrapper. I lean caller-owned, with an in-tree example.
  • Rollout order: after GDN/KDA stabilize, extend the same contract to the remaining chunk operators, or keep the scope closed.

Rationale

A chunked forward issues a dozen-plus host launches per layer, and a backward more; the short convolution adds its own launches on the same path. For short inputs (typically ≤ 4096 tokens) this per-launch overhead is a visible fraction of end-to-end time — short prefill requests in serving, and short-sequence training steps alike. Graph capture collapses the whole sequence into one replay. Serving stacks already rely on capture for decode; this proposal extends the same benefit to prefill and training, on both NVIDIA and Ascend, with the eager path preserved bit-identically for everything outside the bounded regime.

Activity

  1. zhiyuan1i commented on Aug 20, 2026

    @zhiyuan1i
    CollaboratorAuthor

    Scope note: context parallelism (CP) is out of scope for the first wave — the design above targets the single-rank path. Recording here what CP support is expected to require once it gets picked up, so the wave-one contract does not paint us into a corner:

    1. Shard computation moves off the host. Today fla/ops/cp/context.py derives each rank's local cu_seqlens on the host: a D2H sync of cu_seqlens, .item() reads for the total token count, host-side searchsorted over the sequence boundaries, and a data-dependent slice whose length varies per input. All four are capture-illegal.
    2. Static shard boundaries. Under a pinned (T_max, N_max) budget, each rank's token range is fixed at capture time (T_max split evenly across ranks), and the per-rank local cu_seqlens is computed on device (searchsorted over the padded global cu_seqlens) into a fixed [N_max + 1] buffer — the same zero-length padding trick as the chunk index table.
    3. Host-side cu_seqlens_cpu values must not shape anything on the graph path. The parameter stays for the eager path; the capture path uses the device-side computation only.
    4. Collectives under capture. NCCL collectives are capturable once their first call has happened outside the capture (side-stream warmup — same story as the autotune warmup above).

    None of this changes the wave-one contract; the sentinel / static-grid / device-side-indices design carries over unchanged.

  2. zheliuyu commented on Aug 20, 2026

    @zheliuyu
    Collaborator

    This is a really nice piece of work. Capturing the GDN/KDA chunk + conv path once and replaying it is exactly what's needed for short prefill and short-sequence training, and a single contract for both backends is the right call.

    FLA already has a triton-ascend path, so I ran a small ACL Graph probe on Ascend 910 (torch.npu.NPUGraph) to check that NPU actually shares those prerequisites. The three capture rules hold:

    • Replay is correct if new data is copied into the same captured buffers. Newly allocated tensors are ignored — the graph still writes the captured pointers.
    • A captured Triton grid=(N,) is frozen; replay cannot grow it. A Python launch loop is frozen the same way.
    • .item() / D2H during capture fails. Sizing the grid from a device tensor via .item() fails too, which is how prepare_chunk_indices / len(chunk_indices) work today.

    So today's varlen path is capture-illegal on NPU for the same reasons as on CUDA. NT_max + sentinel + device-side indices + static buffers is the contract we need. NPU has graph_task_update, but we shouldn't rely on it here.

    We'll follow up on the NPU side: Step-1 utils under NPUGraph, a static launch sequence for the graph path (existing 1D core-grid instead of host-splitting over NT), and capture/replay tests next to the CUDA ones.

  3. FightingZhen commented on Sep 8, 2026

    @FightingZhen
    Contributor

    Hi, I’m interested in contributing to the design and implementation of Graph Capture support for FLA on Ascend NPUs. I have opened Issue #1233 with an Ascend-specific implementation plan. I have access to Ascend hardware and can help with the TorchNPU implementation, correctness validation, benchmarking, and CI coverage :)

  4. zheliuyu commented on Sep 8, 2026

    @zheliuyu
    Collaborator

    @FightingZhen Thanks for picking this up, and for writing up #1233.

    Feel free to start whenever you’re ready. You can also track the work directly under this RFC — no need for a separate thread.

    Happy to review as things land.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions