Skip to content

[DPLR] A Tilelang implementation of DPLR - #1069

Open
sablin39 wants to merge 46 commits into
fla-org:mainfrom
sablin39:tilelang-dplr-backend
Open

[DPLR] A Tilelang implementation of DPLR#1069
sablin39 wants to merge 46 commits into
fla-org:mainfrom
sablin39:tilelang-dplr-backend

Conversation

@sablin39

@sablin39 sablin39 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

A TileLang implementation of the DPLR chunk pipeline, registered as a
dispatch backend on chunk_dplr_delta_rule (chunk_rwkv7 is an alias).
A verifier accepts a call only where the TileLang path is correct and
measurably faster; everything else falls back to the default Triton
implementation unchanged. On by default when tilelang is present;
FLA_TILELANG=0 forces Triton. Context parallelism (cp_context) works
through FLA's existing boundary-state scheme.

1. Differences from the Triton implementation

Structure

  • Forward: one fused kernel produces the chunk states h and the output
    o in a single sweep, replacing Triton's separate fwd_h / fwd_o
    passes and their global-memory round trip of h/v_new.
  • Backward: one fused kernel runs the reverse dh scan together with its
    consumers (dq/dk/dw/db/dv2/dgk_last/dh0); dh is never
    materialized. Triton runs separate bwd_dhu / dv / dqkwg kernels.
  • Gate cumsum: computed in-CTA from raw gk at K = 64; at K = 128 a
    segmented fp32 scan kernel replaces a PyTorch cumsum chain that
    profiled at ~2.3–2.8 ms/iter of glue (h4096).
  • Autograd: torch.library.custom_op + register_fake +
    register_autograd — opaque to torch.compile, no graph break.
    Triton uses autograd.Function under @torch.compiler.disable.
    Dead zero-grad fills for the 13 saved intermediates are disabled via
    set_materialize_grads(False) (~2.8 GB/step at h4096).
  • disable_recompute keeps the Triton semantics (save vs. recompute);
    both policies are supported and benchmarked.

Numerics — same conventions as Triton (log2 gates, mid-chunk
gate-centering for tensor-core operands, fp32 state accumulation, fp32
A_ab_inv), except A_ab/A_ak are stored in fp16 (Triton: fp32),
validated within the existing tolerances. Context parallelism uses the
exact same boundary-state exchange as the Triton CP path.

Accuracy vs. the fp32 per-token recurrence (max err ratio over o, final
state, and all input grads; B8 T256 H32, initial_state + dht
backward, measured on sm_120):

Config Triton TileLang
cs16 D64 bf16 0.0061 0.0053
cs32 D64 bf16 0.0081 0.0053
cs32 D128 bf16 0.0104 0.0058
cs32 D64 fp16 0.0021 0.0019
cs32 D128 fp16 0.0021 0.0018
cs64 D64 bf16 (RWKV7 gates) 0.0068 0.0060
cs64 D128 bf16 (RWKV7 gates) 0.0084 0.0084

TileLang is at or below Triton's error in every config (up to ~1.8×
closer at cs32 bf16); the fp16 A_ab/A_ak storage costs nothing
measurable (fp16 rows). Under sustained −5 gate saturation both stay
finite, but Triton's dgk err ratio is ~3× worse (0.51 vs. 0.17).

Routing

  • Requires a caller-asserted gate bound — safe_gate=True, or a
    lower_bound that fits the chunk size (GDN2's convention): the
    A-stage's mid-chunk-centered exp2 operands must stay in fp32 range.
    Unbounded calls stay on Triton, which handles arbitrary gates.
  • The verifier rejects unsupported or measurably slower configurations
    (one log line per reason). For chunk_size=64 it shares schedule
    arithmetic with the launcher, so acceptance implies launchability.

2. Benchmark results

bf16, triton.testing.do_bench (warmup 10, rep 50), FLA_TILELANG=0
vs. =1 through the dispatched public op, idle GPUs: RTX PRO 6000
(sm_120, 99KB smem) and H800 (sm_90, 228KB smem). dr =
disable_recompute. † = verifier reroutes to Triton (~1.00× by
construction). Headline cells were additionally re-measured with
CUDA-event wall timing and profiler attribution — same ratios. Varlen
harness: each row splits 8192 tokens into N sequences with lengths
rand(N) + 0.35 (seed = 17 + N), [1, 8192] layout, gk drawn as
-0.61·sigmoid(5x) in fp32; the timing script is the same do_bench
harness for every table and is included in the PR discussion.

PRO 6000 (sm_120)

Rectangular, cs32, T=4096 (hidden = H·D):

Workload (hidden, H, D, B) Mode Triton dr=0 Triton dr=1 TileLang dr=0 TileLang dr=1 Speedup dr=0 Speedup dr=1
1024, 16, 64, 8 fwd 2.26 2.26 1.67 1.66 1.36× 1.36×
fwd+bwd 7.79 5.87 5.85 4.26 1.33× 1.38×
1024, 8, 128, 8 † fwd 3.64 3.60 3.63 3.62 1.00× 1.00×
fwd+bwd 10.56 7.30 10.53 7.33 1.00× 1.00×
2560, 40, 64, 8 fwd 6.23 6.25 4.30 4.31 1.45× 1.45×
fwd+bwd 21.26 15.91 15.14 11.18 1.40× 1.42×
2560, 20, 128, 8 fwd 9.57 9.54 4.90 4.90 1.95× 1.95×
fwd+bwd 27.90 19.19 19.66 12.95 1.42× 1.48×
4096, 64, 64, 8 fwd 8.99 9.09 6.41 6.44 1.40× 1.41×
fwd+bwd 31.37 23.69 22.95 17.09 1.37× 1.39×
4096, 32, 128, 8 fwd 14.66 14.63 7.46 7.47 1.96× 1.96×
fwd+bwd 42.78 29.53 31.02 20.51 1.38× 1.44×
2560, 40, 64, 16 fwd 12.63 12.57 9.19 9.25 1.37× 1.36×
fwd+bwd 42.53 31.82 32.08 23.62 1.33× 1.35×
2560, 20, 128, 16 fwd 19.30 19.33 9.54 9.93 2.02× 1.95×
fwd+bwd 56.34 39.07 38.55 26.22 1.46× 1.49×
4096, 64, 64, 16 fwd 18.75 18.69 13.92 13.87 1.35× 1.35×
fwd+bwd 64.11 48.07 48.82 35.95 1.31× 1.34×
4096, 32, 128, 16 fwd 30.24 29.82 15.49 15.59 1.95× 1.91×
fwd+bwd 87.02 60.32 59.58 40.07 1.46× 1.51×

Varlen, cs32, 8192 tokens total ([1, 8192] layout; N = sequence count):

Workload (hidden, H, D, N) Mode Triton dr=0 Triton dr=1 TileLang dr=0 TileLang dr=1 Speedup dr=0 Speedup dr=1
2560, 40, 64, 3 fwd 1.56 1.55 1.21 1.20 1.29× 1.29×
fwd+bwd 5.34 4.01 4.12 2.96 1.30× 1.35×
2560, 20, 128, 5 fwd 2.37 2.37 1.27 1.27 1.87× 1.87×
fwd+bwd 6.95 4.83 5.67 3.87 1.23× 1.25×
4096, 64, 64, 2 fwd 2.38 2.39 1.85 1.83 1.29× 1.31×
fwd+bwd 8.23 6.22 6.38 4.54 1.29× 1.37×
4096, 32, 128, 3 fwd 3.69 3.69 1.98 1.98 1.87× 1.86×
fwd+bwd 10.79 7.49 8.97 6.02 1.20× 1.24×
1024, 16, 64, 6 fwd 0.57 0.57 0.54 0.54 1.07× 1.05×
fwd+bwd 1.96 1.45 1.82 1.31 1.08× 1.11×
1024, 8, 128, 6 † fwd 0.91 0.92 0.91 0.92 1.00× 1.00×
fwd+bwd 2.65 1.82 2.64 1.82 1.00× 1.00×

H800 (sm_90)

Rectangular, cs32, T=4096:

Workload (hidden, H, D, B) Mode Triton dr=0 Triton dr=1 TileLang dr=0 TileLang dr=1 Speedup dr=0 Speedup dr=1
1024, 16, 64, 8 fwd 1.91 1.89 1.47 1.47 1.29× 1.29×
fwd+bwd 5.84 4.11 5.27 4.01 1.11× 1.02×
1024, 8, 128, 8 fwd 3.83 3.82 1.78 1.77 2.15× 2.16×
fwd+bwd 9.78 6.13 6.47 4.98 1.51× 1.23×
2560, 40, 64, 8 fwd 4.24 4.26 3.52 3.52 1.21× 1.21×
fwd+bwd 13.65 9.84 13.01 10.23 1.05× 0.96×
2560, 20, 128, 8 fwd 9.13 9.13 4.80 4.81 1.90× 1.90×
fwd+bwd 23.84 15.24 16.67 12.62 1.43× 1.21×
4096, 64, 64, 8 fwd 6.93 6.94 5.03 4.97 1.38× 1.40×
fwd+bwd 21.74 15.47 19.02 15.10 1.14× 1.02×
4096, 32, 128, 8 fwd 15.14 15.15 6.90 6.90 2.19× 2.20×
fwd+bwd 38.79 24.43 22.80 17.29 1.70× 1.41×
2560, 40, 64, 16 fwd 8.43 8.43 6.37 6.37 1.32× 1.32×
fwd+bwd 26.93 19.34 23.95 18.96 1.12× 1.02×
2560, 20, 128, 16 fwd 17.84 17.84 8.62 8.66 2.07× 2.06×
fwd+bwd 46.56 29.77 30.27 23.26 1.54× 1.28×
4096, 64, 64, 16 fwd 13.46 13.46 9.87 9.87 1.36× 1.36×
fwd+bwd 42.66 30.49 37.72 30.09 1.13× 1.01×
4096, 32, 128, 16 fwd 30.24 30.23 13.48 13.48 2.24× 2.24×
fwd+bwd 77.47 48.75 45.27 34.21 1.71× 1.42×

Varlen, cs32, 8192 tokens total:

Workload (hidden, H, D, N) Mode Triton dr=0 Triton dr=1 TileLang dr=0 TileLang dr=1 Speedup dr=0 Speedup dr=1
2560, 40, 64, 3 fwd 1.31 1.32 1.11 1.10 1.18× 1.20×
fwd+bwd 4.02 2.84 3.87 2.94 1.04× 0.96×
2560, 20, 128, 5 fwd 8.00 8.00 1.34 1.34 5.95× 5.96×
fwd+bwd 17.48 9.61 4.63 3.39 3.77× 2.84×
4096, 64, 64, 2 fwd 2.07 2.06 1.69 1.68 1.22× 1.23×
fwd+bwd 6.35 4.47 5.92 4.50 1.07× 0.99×
4096, 32, 128, 3 fwd 12.22 12.22 2.18 2.13 5.60× 5.74×
fwd+bwd 26.68 14.66 7.21 5.20 3.70× 2.82×
1024, 16, 64, 6 fwd 0.62 0.61 0.57 0.56 1.08× 1.08×
fwd+bwd 1.84 1.29 1.91 1.42 0.96× 0.91×
1024, 8, 128, 6 fwd 3.19 3.19 0.72 0.71 4.45× 4.48×
fwd+bwd 7.03 3.89 2.55 1.87 2.76× 2.08×

Reading the numbers:

  • fwd+bwd is the weighted mix of the two passes, so it always lands
    below the fwd-only ratio: the forward carries the large wins, the
    backward (bwd-only, derived as fb − fwd) is 1.0–1.5× at dr=0 on both
    devices. At dr=1 on sm_90 the TileLang backward alone loses to
    Triton's dr=1 backward (0.6–0.9× — Triton skips its recompute too),
    and the fwd win carries the total; dr=0 is the default and the
    recommended mode.
  • The H800 varlen D128 rows are genuine routes: profiling shows Triton's
    intra tensorcore kernel degrading on ragged varlen on sm_90 (14.4 of
    17.7 ms over fwd+bwd at 2560/D128/N5 — two calls, ~7.2 ms each). On
    sm_120 the same kernel is healthy (2.7 of 7.5 ms) and the fused
    TileLang A-stage still wins ~1.9× fwd there.
  • Honest weak cells: small varlen D64 at dr=1 is 0.91–0.99× on sm_90
    (1.11–1.37× on sm_120); varlen cs16 measured 0.83–1.02× on sm_90 (no
    win at any size) and is now rerouted to Triton below cc12 — cc12x
    keeps it (1.08–1.23× at h2560/h4096), with the smallest cell (h1024,
    N6) at 0.92–0.98× there; one rect D64 dr=1 cell (h2560, B8) is 0.96×
    on sm_90. Grids below half the SMs reroute to Triton at ~1.00×.
  • An earlier small-model/varlen collapse (0.16–0.7×) was host-side
    launch overhead in the tvm_ffi adapter (dynamic output-shape
    resolution per launch), fixed by caller-allocated outputs — not a
    kernel effect.
  • Outlier note: the 7.08× PRO 6000 varlen cell from the previous table
    does not reproduce on an idle GPU — it was measured while two sweep
    shards shared one device, which inflated the Triton baseline; all
    tables above were re-run one shard per GPU. The current maximum ratio
    (5.96×, H800 varlen D128) is profiler-attributed above.

chunk_size=64 (PRO 6000 only)

Two footprint fixes (staging-pair aliasing in the A-backward,
global-gk reads in the A-forward) made every BT=64 stage launchable in
99KB, bit-exact against their predecessors. The routed sweep:

Workload dr=0 fwd dr=0 fwd+bwd dr=1 fwd dr=1 fwd+bwd
rect h2560 D64 B8 2.11× 2.03× 2.10× 1.97×
rect h4096 D64 B8 2.22× 2.12× 2.23× 2.05×
rect h4096 D64 B16 2.21× 2.07× 2.22× 1.99×
varlen h2560 D64 N3 1.94× 1.83× 1.93× 1.82×
varlen h4096 D64 N2 2.10× 1.92× 2.10× 1.93×
rect/varlen D128 (fallback) 1.00× 1.00× 1.00× 1.00×

(h1024 D64 spot cells: 1.93–2.20× rect, 1.66–1.92× varlen.) On H800
every cs64 schedule launches but measured 0.68–1.05× vs. Triton, so the
verifier accepts cs64 only on cc12x+; below that it falls back at 1.00×.

Why cs64 loses on sm_90 (per-kernel attribution via torch.profiler
on both GPUs at the same rect h4096/h1024 D64/D128 dr1 configs,
generated code inspected from the TileLang/Triton kernel caches):

  • The deficit is confined to the two serial state-scan stages — the
    fused h+o forward and the stream backward — which walk T/64 chunks
    serially per block on grids of N·H·(V/BV) and N·H blocks. At h1024
    D128 that is 256/64 blocks against 132 SMs, so the scans run long
    underfilled chains while Triton's split passes (dhu + dqkwg + o)
    re-parallelize over chunks and V-blocks (stream 1.13 ms vs. 0.65 ms
    combined; h+o 0.74 ms vs. 0.53 ms — together most of the 0.92×
    total, while the fused intra/A stage itself wins 0.61 ms vs.
    0.92 ms). Once the grid fills, the scan stages reach parity
    (stream 2.50 ms vs. 2.64 ms at h4096 D64 dr1) and the residual
    delta is the intra/A stage (2.96 ms vs. 2.24 ms), where Triton's
    sm_90a schedule is at its strongest.
  • IKET warp-level tracing of the stream kernel (TileLang's in-kernel
    event tool, nvidia-cutlass-dsl) confirms the mechanism at 64
    blocks / 8 warps on 132 SMs (h1024 D64, cs64): per chunk iteration
    (~8 µs of dependent phases), global staging dominates — input
    loads 33% + output row stores 21% = 54% of warp time vs. 37% for
    all mma phases (dv2 11% + state gemms 23% + dh update 4%), with
    dgk reduction at 8%. The next chunk's loads are not prefetched
    ahead of the dh dependency chain, and 1 block/SM leaves ~12% warp
    occupancy to hide load latency. That points at next-chunk
    prefetch/pipelining as the follow-up optimization, on all
    architectures.
  • Triton's sm_90a intra kernels are an exceptionally tuned baseline:
    the same stage takes 2.2 ms on H800 vs. 12.3 ms on the PRO 6000 —
    a 5.5× regression of the Triton reference itself on sm_120 (2.4×
    for the backward intra). Our kernels' absolute times are flat across
    architectures (≤1.3×, tracking the 1980 vs. 3090 MHz clocks). Both
    backends emit the same instruction class on both GPUs (mma.sync
    m16n8k16 + ldmatrix; no wgmma/TMA on either side), so the gap is
    scheduling, not tensor-core ISA.
  • The intra stages are latency-bound everywhere (~3% of peak MMA
    FLOPS, ~15–18% of peak bandwidth), which is why fusion pays once
    the GPU is filled, and why sm_120 — where the reference's intra
    path collapses — flips the same h4096 config from 0.89× to 1.88×.
  • The same serial structure is not a liability on cc12x: stage-for-
    stage the scans hold up there (stream 3.21 ms vs. Triton's combined
    dh-side passes 5.01 ms = 1.56×; fused h+o at parity, 2.42 vs.
    2.54 ms) — Triton's split passes are just as latency-bound and
    gain nothing from the extra 56 SMs. What flips the total is the
    intra/A stage turning a 0.76× deficit into a 3.3× win (3.72 ms
    vs. 12.30 ms): the stages the design is weak in stay at or above
    parity, and the stage it is strong in decides the total
    (24.85 ms vs. 46.69 ms = 1.88×).

Correctness gates: route-parity vs. Triton within assert_close
0.008–0.01 on all outputs and gradients (rect + varlen, fwd + bwd,
fp16/bf16, fp32 gates, K=V∈{64,128}, chunk 16/32/64); parity vs. the
per-token fp32 recurrence at the same 0.007/0.008 ratios
test_dplr_delta holds the Triton path to (worst observed 0.0051);
exact 0.0000 match for bf16 + fp32 gates on rectangular inputs; the
2-GPU CP test against the naive recurrence. The full gate
(tests/ops/test_dplr_tilelang.py + tests/ops/test_dplr_delta.py,
99 tests) passes on both devices: 67 passed + 2 skipped in the backend
file, and test_dplr_delta.py apart from the one D100-chunk_size64
fp16 failure that reproduces identically on main (pre-existing; head
dim 100 never routes to TileLang). With cs64 gated to cc12x+, the sm_90
run skips the cs64 parity rows and exercises the rejection unit test
instead.

3. Current incompatibilities

The verifier rejects these and the call falls back to Triton (one log
line per reason):

Configuration Reason
safe_gate=False without a fitting lower_bound The mid-chunk-centered A-stage needs a caller-asserted gate bound to keep exp2 operands in fp32 range; unbounded calls stay on Triton. RWKV7's clamp (w ∈ (−0.61, 0)) qualifies as either.
fp32 activations No launchable fp32 stream-backward schedule on ≤99KB smem; fp32 is also second-class in the Triton path.
gk dtype ∉ {fp16, bf16, fp32}; k/v/a/b dtype ≠ q dtype Gate math needs a real dtype; intra kernels load at one dtype boundary.
K != V The streaming backward targets square states.
head dim ∉ {64, 128}; chunk_size ∉ {16, 32, 64} Not validated.
chunk_size 16 + head dim 128; varlen chunk_size 16 below cc12 ~0.5× measured (non-vectorized A-stage, 2-warp h+o at BT=16); varlen cs16 measures 0.83–1.02× on sm_90 — cc12x keeps it (1.08–1.23× at h2560/h4096).
chunk_size 64 Two gates: launchability (K=128 needs ≥167936B smem — rejected on 99KB caps and A100; K=64 fits via 98432B/82944B schedules) and measurement (0.68–1.05× on sm_90, so accepted only on cc12x+, where K=64 is 1.66–2.23×).
Small grids (N·H·(V/BV) < SMs/2) The fused serial state pass underfills the GPU (up to 10× slower at B2 H8).
Small varlen workloads Fixed (host launch overhead; PRO 6000 low-smem stream-bwd vectorization). Now 1.05–1.87× on sm_120, 1.08–5.96× fwd / 0.91–3.77× fwd+bwd on sm_90; residual dr=1 D64 cells 0.91–0.99× on sm_90 only.
Non-CUDA devices TileLang kernels are CUDA-only.
GQA / MQA Shared-head ABI (same constraint as the Triton DPLR).

cp_context is supported (same boundary-state exchange and the same
contract as the Triton entry: no user initial_state /
output_final_state under CP, loud failure on a user dht).

One stress-test note: under sustained saturation at the −5 gate bound
(every gate pinned, cs32) the Triton reference's dgk diverges from the
true recurrence far beyond parity tolerance (measured err ratio ~0.5 vs.
TileLang's ~0.17 on sm_120; both stay finite), so stress rows pin ~1% of
gates. The lower_bound kwarg is wired through both routes.

Test plan

  • tests/ops/test_dplr_tilelang.py (new): verifier accept/reject per
    branch (incl. safe_gate=False, lower_bound licensing, cs64 device
    gates, small-grid guard), route parity vs. Triton (fwd + all grads +
    dh0, rect + varlen, bf16/fp16, fp32 gates, chunk 16/32/64, both dr
    policies), gate-stress rows, PyTorch-recurrence baseline rows, and
    opcheck / torch.compile fullgraph / torch.utils.checkpoint
    smokes.
  • tests/ops/test_backends.py: nvcc/tilelang gating parametrization
    extended to the new backend.
  • tests/context_parallel/test_cp_dplr.py: new 2-GPU
    test_cp2_tilelang_route with per-rank route assertion.
  • Regression: test_dplr_delta.py, test_rwkv7.py,
    test_modeling_rwkv7.py, test_layer_cache_layer_idx.py pass (the 4
    channel_mixing_gradients and 1 D100-chunk_size64 failures
    reproduce identically on main — pre-existing).

Breaking changes

None. The change is additive: where the verifier does not accept a call,
the default Triton implementation is used unchanged.


Developed on tilelang-dplr-backend; benchmarks on idle RTX PRO 6000
(sm_120) and H800 (sm_90). Correctness gates pass on both.

sablin39 and others added 7 commits July 28, 2026 09:51
Register a TileLang backend on chunk_dplr_delta_rule (which also covers
chunk_rwkv7) via the generic dispatch system. The backend wraps the
custom-op TileLang DPLR pipeline (intra A-stage, WY repr, fused h+o
forward, streaming backward) behind a verifier that falls back to the
default Triton implementation for unsupported configurations: CP,
non-fp16/bf16 dtypes, K != V, head dims outside {64, 128}, and
chunk_size 64 off Hopper (the intra backward exceeds sm_120's 99KB
shared-memory cap there).

Also fixes three latent kernel bugs surfaced by the new route-parity
tests:
- WY inversion raced on a fragment accumulator spread across threads;
  the j-reduction now runs serially per lane.
- fp16 forwards overflowed the gate-centered exp2 operands (inf/NaN);
  q-side GEMM operands are kept fp32 for fp16 inputs.
- K=128 at BT=16 failed to compile (vectorize-planner issue in the
  ThreadSync pass) and the fused h+o kernel used a warp count no
  16-row GEMM can partition; the A-stage now compiles a non-vectorized
  variant for BT <= 16 and the h+o config caps threads there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fused h+o state pass parallelizes only over (V/BV, N, H) and walks
chunks serially, so it loses to the split Triton path on small grids
(up to 10x slower at B2 H8 on PRO 6000) while winning at the design
envelope (1.73x fwd+bwd at B8 H32 D64 cs32). The verifier now rejects
calls whose state-pass grid underfills half the GPU, falling back to
Triton. Route-parity tests move to shapes above the crossover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified each rejection branch directly against the kernels (all raise
hard errors or silently miscompute when bypassed): cp_context is
silently dropped by the backend method (no boundary-state exchange in
the TileLang path), fp32 has no launchable stream-backward schedule,
mixed gk dtypes fail kernel dtype checks, K != V is a stream-backward
constraint, and head dims outside {64, 128} hit explicit wrappers. All
branches stay. Also raise TypeError on unexpected kwargs in the
TileLang wrapper so a direct cp_context call fails loudly instead of
being swallowed, and gate chunk_size 64 on the device's shared-memory
opt-in cap instead of Hopper-only (allows A100-class parts where the
BT=64 intra backward fits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FLA's own tests and many training setups keep gk in fp32 while the
activations are fp16/bf16. The from_gk A-stage and the DERIVE_GE intra
backward now take gk in its native dtype (gate math was already fp32
everywhere), and the backward's recycled dgk buffer is only reused
when it matches gk's dtype. The verifier now requires only k/v/a/b to
match q.dtype; gk may be fp16/bf16/fp32. Verified exact parity vs
Triton (0.0000) for bf16/fp16, K=64/128, chunk 16/32, rect and varlen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TileLang DPLR chunk path now handles cp_context using FLA's
boundary-state scheme: after the WY stage, each rank exchanges its
compact local state contribution [c|M] via the existing
chunk_gated_delta_rule_fwd_h_pre_process and folds it into the
corrected initial state; the backward symmetrically folds the
following ranks' dh via chunk_gated_delta_rule_bwd_dhu_pre_process
before the streaming dh scan. The FLACPContext is threaded through a
ContextVar (custom ops cannot take the object) and persisted on the
autograd ctx so the exchange runs in the backward as well. Verified
against the naive DPLR recurrence on 2 GPUs (route assertion included)
and against the Triton CP path on identical inputs (ratios <= 0.006).
The harness in tests/context_parallel/test_cp_dplr.py gains an
op_chunk_size and a route-assertion switch for this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured ~0.5x vs Triton (the non-vectorized A-stage and 2-warp h+o
path at BT=16 do not pay off at K=128); fall back to Triton there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 15:42
The in-CTA cumsum and the gating loop now read gk from a shared tile
loaded once, and gi is written with one batched coalesced store instead
of a global load+store per serial step (wind_rwkv-style register/shared
scan). chunk_dplr_fwd_intra_from_gk at B8 T4096 H32 D64 cs32 bf16:
1.85ms -> 1.52ms (-18%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a TileLang backend for the generalized delta rule DPLR “chunk” pipeline and wires it into FLA’s backend dispatch system, aiming to improve performance via additional fusion while preserving correctness by falling back to the existing Triton implementation when unsupported.

Changes:

  • Register chunk_dplr_delta_rule under backend dispatch (generalized_delta_rule.dplr) and add a TileLang backend implementation with verifier-based routing.
  • Implement TileLang kernels/stages for DPLR forward/backward (A-stage, WY, fused H/O forward, streaming backward, cumsum/layout utilities).
  • Add unit tests for verifier behavior, backend availability gating, route parity, and CP routing.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/ops/test_dplr_tilelang.py New tests covering verifier acceptance/rejection and TileLang-vs-Triton parity routing.
tests/ops/test_backends.py Extends existing backend availability tests to include the DPLR TileLang backend module.
tests/context_parallel/test_cp_dplr.py Adds a CP test case that asserts the TileLang DPLR route is taken.
fla/ops/generalized_delta_rule/dplr/chunk.py Hooks chunk_dplr_delta_rule into the backend dispatch decorator for DPLR.
fla/ops/generalized_delta_rule/dplr/backends/init.py Introduces a DPLR backend registry and registers the TileLang backend.
fla/ops/generalized_delta_rule/dplr/backends/tilelang/init.py Adds DPLRTileLangBackend with availability checks and a shape/perf verifier.
fla/ops/generalized_delta_rule/dplr/backends/tilelang/utils.py Implements shared chunk layout utilities for rect/varlen execution.
fla/ops/generalized_delta_rule/dplr/backends/tilelang/cumsum.py Implements chunk-local cumsum (PyTorch fast path for rect, TileLang for varlen).
fla/ops/generalized_delta_rule/dplr/backends/tilelang/wy_fast_fwd.py TileLang forward for WY inversion and W/U products.
fla/ops/generalized_delta_rule/dplr/backends/tilelang/wy_fast_bwd.py TileLang backward for WY path (dA_ab, dA_ak, dv, dag).
fla/ops/generalized_delta_rule/dplr/backends/tilelang/chunk_A_fwd.py TileLang intra-chunk A-stage forward (incl. from-gk specialization for K=64).
fla/ops/generalized_delta_rule/dplr/backends/tilelang/chunk_A_bwd.py TileLang intra-chunk backward producing dq/dk/da/db/dgk (with fused q-side recompute).
fla/ops/generalized_delta_rule/dplr/backends/tilelang/chunk_h_fwd.py TileLang chunk recurrent-state forward producing per-chunk state and v_new.
fla/ops/generalized_delta_rule/dplr/backends/tilelang/chunk_ho_fwd.py TileLang fused H/O forward kernel (optionally with saved context buffers).
fla/ops/generalized_delta_rule/dplr/backends/tilelang/chunk_stream_bwd.py TileLang streaming backward kernel(s) with schedule selection based on SMEM caps.
fla/ops/generalized_delta_rule/dplr/backends/tilelang/chunk.py Main TileLang DPLR chunk op implementation using torch.library.custom_op + autograd wiring.
Comments suppressed due to low confidence (1)

fla/ops/generalized_delta_rule/dplr/backends/tilelang/init.py:84

  • The chunk_size == 64 verifier path calls torch.cuda.get_device_properties(...) via _smem_optin_bytes(...) before checking q.is_cuda. If a caller runs the verifier on CPU tensors with chunk_size=64, this will raise instead of cleanly returning a fallback-to-Triton rejection.
            if _smem_optin_bytes(q.device.index or 0) < smem_need:

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +62 to +63
if q.dtype not in (torch.float16, torch.bfloat16):
return False, f"TileLang backend does not support dtype {q.dtype}; fall back to Triton"
Comment thread tests/ops/test_backends.py Outdated
Comment on lines +103 to +106
return backend_module.RWKV6TileLangBackend
return backend_module.KDATileLangBackend
if backend_module is kda_tilelang_backend:
return backend_module.KDATileLangBackend
return backend_module.DPLRTileLangBackend
Comment thread tests/context_parallel/test_cp_dplr.py Outdated
Comment on lines +275 to +276
if assert_tilelang:
assert route_spy, "TileLang backend route was not taken"
Comment on lines +47 to +49
ok, reason = DPLRTileLangBackend().chunk_dplr_delta_rule_verifier(
*_verifier_inputs(K=K, dtype=dtype), chunk_size=chunk_size, cp_context=SimpleNamespace(),
)
sablin39 and others added 2 commits July 29, 2026 02:15
Interior chunks now load their kg/w/qg/v/u/A tiles with bulk T.copy
(vectorized cp.async) instead of scalar predicated elementwise loops;
tail chunks keep the masked path. chunk_dplr_fwd_ho at B8 T4096 H32
D64 cs32 bf16: 1.42ms -> 1.03ms (-27%). The chunk loop is switched to
T.Pipelined so num_stages pipelining is available; it is a no-op at
the current default (0), and stages 2/3 measured no further gain at
the design shape, so the default stays 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three TL_DISABLE_DATA_RACE_CHECK: True entries in chunk_A_fwd.py
are unnecessary: with the checker enabled, the intra and from_gk
kernels compile warning-free at BT 16/32/64, K 64/128, bf16/fp16 (the
WY race that motivated silencing was fixed properly in
prepare_wy_repr_fwd). Keeping the warning-only checker on matches the
rest of the package and preserves the safety net for future edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 03:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (7)

tests/ops/test_dplr_tilelang.py:53

  • This test asserts the verifier accepts a CUDA configuration, but it will run (and fail) on CPU-only environments because _verifier_inputs() uses fla.utils.device which can be 'cpu'. Add a CUDA guard.
def test_chunk_verifier_accepts_fp32_gk():

tests/ops/test_dplr_tilelang.py:61

  • This test expects the verifier to accept a CUDA-only path, but it can run on CPU-only CI where _verifier_inputs() uses fla.utils.device='cpu', causing the verifier to reject. Gate it on CUDA availability.
def test_chunk_verifier_accepts_chunk64_on_large_smem_device(monkeypatch):

tests/ops/test_dplr_tilelang.py:80

  • test_chunk_verifier_rejects depends on CUDA-specific rejection reasons (dtype mismatch, SMEM caps, etc.). On CPU-only environments the verifier will instead reject with the generic CUDA-only message, breaking the expected reason in why assertions. Add a CUDA guard.
def test_chunk_verifier_rejects(monkeypatch, case: str, reason: str):

tests/context_parallel/test_cp_dplr.py:276

  • The test patches DPLRTileLangBackend.chunk_dplr_delta_rule but never restores it. Even though this runs in a spawned worker, restoring avoids leaking the patch to later code paths in the same process (e.g., if additional assertions/logging are added or the worker is reused).
        if assert_tilelang:
            assert route_spy, "TileLang backend route was not taken"

tests/ops/test_dplr_tilelang.py:45

  • These verifier-acceptance tests will fail on CPU-only CI because fla.utils.device can be 'cpu', making the verifier return False (q.is_cuda is required). Gate these tests on CUDA availability (or otherwise ensure inputs are CUDA tensors).

This issue also appears in the following locations of the same file:

  • line 53
  • line 61
  • line 80
@pytest.mark.parametrize(('K', 'dtype', 'chunk_size'), [(64, torch.bfloat16, 32), (128, torch.bfloat16, 32), (64, torch.float16, 16)])

tests/context_parallel/test_cp_dplr.py:480

  • This test unconditionally deletes FLA_TILELANG in the finally block. If the variable was already set in the environment (e.g. a user/CI opt-in), this permanently removes the prior value for the remainder of the test session. Save and restore the previous value instead.
    os.environ['FLA_TILELANG'] = '1'
    try:
        run_cp_test_with_spawn(
            world_size=2,
            test_name="CP2_TileLang",
            T=1024, H=128, D=64,
            lengths=[400, 624],
            dtype=torch.bfloat16,
            op_chunk_size=32,
            assert_tilelang=True,
        )
    finally:
        del os.environ['FLA_TILELANG']

fla/ops/generalized_delta_rule/dplr/backends/tilelang/init.py:104

  • The small-grid heuristic ignores cp_context: when cp_context is provided, chunk_dplr_delta_rule callers typically pass cu_seqlens=None and rely on cp_context.cu_seqlens instead. Using q.shape[0] in that case underestimates n_seqs and can incorrectly reject the TileLang route for CP workloads.
        bv = 64 if v.shape[-1] <= 64 else 32
        n_seqs = len(cu_seqlens) - 1 if cu_seqlens is not None else q.shape[0]
        grid = n_seqs * q.shape[2] * ((v.shape[-1] + bv - 1) // bv)

sablin39 and others added 4 commits July 29, 2026 03:23
build_varlen_chunk_layout was rebuilt on every call (~0.9ms of host
time in small op launches, and torch.diff's int32 path stalls the
stream). It is now memoized by cu_seqlens identity (bounded deque of
4, refs held so ids cannot recycle — same contract as fla's
tensor_cache), and lengths are computed with a slice-sub instead.
Varlen fwd+bwd vs Triton at hidden4096 D64 cs32: 1.00x -> 1.44x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tvm_ffi adapter resolves dynamic output shapes on every launch with
str(tirx.Var) comparisons, which lower to hundreds of TIR _script calls
per launch on multi-output kernels (~2 ms for the 9-output from_gk
A-stage). Small and varlen workloads are dominated by this host cost:
measured 0.16x-0.7x vs Triton at <=8K tokens while the GPU kernels
themselves were already faster.

Drop out_idx from the remaining forward factories and pass preallocated
outputs instead, matching the pattern the backward kernels already used.
Forward host time per call drops 2.91 ms -> 0.27 ms (varlen h1024);
varlen fwd+bwd speedups move from 0.16-1.06x to 0.73-1.97x on a
contended box.
Every stage rebuilt the same rect layout per call (~6 small CUDA ops
each, ~30 extra launches per forward). Key it by (B, T, chunk_size,
device) like the varlen layout cache.
The verifier ignored cp_context, so a CP call with initial_state,
output_final_state, or no cu_seqlens was accepted and then raised
inside the backend; reject these and fall back to Triton instead. The
grid guard also read the sequence count from the packed batch dim
(always 1 under CP) — take it from the context's cu_seqlens.

Review follow-ups: make _backend_cls exhaustive in test_backends.py,
restore the route spy in the CP worker, and test CP verifier
acceptance with a real context instead of a bare SimpleNamespace.
Copilot AI review requested due to automatic review settings July 29, 2026 05:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sablin39
sablin39 marked this pull request as ready for review July 29, 2026 05:11

@zhiyuan1i zhiyuan1i left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks — impressive work. We checked the generated CUDA and the headline claims hold: the fused fwd h+o single sweep (no h/v_new global round-trip) and the never-materialized bwd dh are real, and the stage mapping onto the Triton pipeline is complete. A few items to address before merge:

  1. Verifier routing must require safe_gate=True and chunk_size == 64 (inline). The TileLang A-stage always uses the mid-chunk-centered tensorcore scheme, which is only valid while (chunk_size/2) * max|gk| stays in fp32 range. Default safe_gate=False callers assert nothing about gate range (Triton's sub_intra is correct for arbitrary gates — those calls must stay on Triton), and the documented safe range [-5, 0) only covers BT≤32: at BT=64 the centered half-range is 160 log2 and overflows fp32. RWKV7 satisfies both conditions (bounded w, explicit chunk_size=64). Longer term, consider GDN2's explicit lower_bound contract.
  2. Please add route-parity tests for initial_state=None + output_final_state=False — every parity case currently passes h0 and requires the final state, but RWKV7 training hits exactly those uncovered branches. A checkpoint-elision test and a torch.compile/opcheck smoke test would also be worthwhile, since both are headline features.
  3. Robustness (details inline): initial_state needs a contiguity guard; exceptions from the impl (JIT failure, schedule selection) escape instead of falling back, contrary to the PR description; and the (K=128, chunk64) smem gate does not match the stream-bwd requirement — on A100 this passes the verifier and then raises. A100 is a target platform for us.
  4. Benchmark evidence: per the repo's MR-readiness conventions, kernel PRs need before/after numbers on the same hardware, dense + varlen. If Hopper access is the blocker, we can run them on our H100 — happy to help.
  5. Style: please move the raw torch.cuda.get_device_capability/properties queries into small fla.utils helpers, per the repo guidelines.

output_final_state: bool = False,
cu_seqlens: torch.LongTensor | None = None,
cu_seqlens_cpu: torch.LongTensor | None = None,
safe_gate: bool = False,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

safe_gate is accepted here but never checked. Please route only when safe_gate=True and chunk_size == 64: the kernels' mid-chunk-centered tensorcore scheme overflows fp32 when (chunk_size/2) * max|gk| ≳ 127 log2. safe_gate=False callers make no gate-range assertion — Triton's sub_intra stays correct for arbitrary gates, so those calls must not be rerouted. And safe_gate=True alone is not sufficient for BT=64: the documented [-5, 0) range gives a 160-log2 half-range at BT=64 (it only licenses BT≤32). RWKV7 satisfies both conditions (w architecturally clamped to (-0.61, 0), explicit chunk_size=64).

if chunk_size == 64:
# the intra backward at BT=64 needs 131200B (K=64) or 148480B
# (K=128) of shared memory per block; smaller caps cannot run it
smem_need = 131200 if k.shape[-1] <= 64 else 148480

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This gate is derived from the intra-bwd requirement, but the stream-bwd schedules need more: at (K=128, BT=64) high=295424B and low=167936B, both above A100's 166912B optin — so on A100 the call passes this check and then _select_stream_bwd_schedule raises at backward time instead of falling back. A100 is a target platform for us; please derive the gate from the schedule's own arithmetic (or reject the combo here).

scale_f = float(q.shape[-1] ** -0.5 if scale is None else scale)
n_seqs = len(cu_seqlens) - 1 if cu_seqlens is not None else q.shape[0]
if initial_state is not None:
h0 = initial_state

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

initial_state needs a contiguity guard: a non-contiguous fp32 h0 would be read with wrong strides by the kernels (the Triton path is protected by input_guard, which this backend bypasses). Please normalize here or check contiguity in the verifier.

):
if not torch.cuda.is_available():
return [{"BV": 16, "threads": 64}]
major = torch.cuda.get_device_capability()[0]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This queries the current device rather than q.device — on multi-GPU with a different current device the configs come from the wrong GPU while the Triton path (via input_guard's device ctx) works. Same pattern in _ho_fragment_merge_flags and _chunk_h_fwd_configs. Also consider moving these raw torch.cuda queries into fla.utils helpers, per repo guidelines.

dgk_out_f = dgk_out.reshape(N_tokens, H, K).contiguous()
# Temporary ABI shim: the fused kernel specialization ignores dAqk/dAqb,
# but the shared JIT signature still requires same-shape bf16 inputs.
dummy_dA = q.new_empty((N_tokens, H, BT))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: two full-size [N_tokens, H, BT] allocations that the FUSE_QSIDE_DA kernel never reads (~128MB dead memory at T=8192/H=64/BT=64). Consider 0-elem tensors or splitting the kernel signature.

return wy_fast_bwd_tl


def chunk_dplr_bwd_wy(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: dead code — only the _into variant is used.

dw_f = dw.reshape(N_tokens, H, K).contiguous()
du_f = du.reshape(N_tokens, H, V).contiguous()
dv0_f = dv0.reshape(N_tokens, H, V).contiguous()
dA_ab_f = dA_ab_out.reshape(N_tokens, H, BT).contiguous()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: reshape(...).contiguous() on an out-param silently writes into a temporary copy if a caller ever passes a non-contiguous tensor — the kernel's writes would be dropped. Please assert out.is_contiguous() here and in the other _into wrappers.

)


def test_cp2_tilelang_route():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this should skip when DPLRTileLangBackend.is_available() is False — on a 2-GPU machine without tilelang/nvcc it fails instead of skipping (the requires_tilelang_route pattern in test_dplr_tilelang.py covers this). Also please restore FLA_TILELANG to its previous value rather than del.

token = _DPLR_CP_CONTEXT.set(cp_context)
try:
if disable_recompute:
if not is_compiling and checkpoint_phase == _CHECKPOINT_PHASE_FORWARD or (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: please parenthesize — A and B or (C and D and E) happens to parse correctly here but is fragile.

sablin39 added 6 commits July 31, 2026 16:46
- verifier: route safe_gate inputs, gate chunk_size=64 by the
  schedule-derived smem footprint, and log fallbacks
- chunk wrapper: elide the checkpoint context when recompute is
  disabled, pass CP metadata through a ContextVar side channel,
  and keep h0 contiguous
- per-device kernel configs via cached get_device_capability /
  get_device_smem_optin helpers that respect device_index
- tests: extend TileLang DPLR parity coverage and the CP
  constraint checks
…for chunk_size=64

- stream bwd: new "mid" schedule selected between high and low_v2
  aliases K/V in shared and stages the output tile so K=128/BT=64
  fits the cc90 opt-in smem budget (stream kernel 17.0 -> 2.44 ms
  on H800; cs64 fwd+bwd 0.37-0.55x -> 0.54-0.81x vs Triton)
- bwd intra, wu_fwd and wy_fast_bwd: 256 threads and bulk T.copy
  tile loads on cc90 with BT>=64 only; cc120 and BT<=32 keep the
  previous scalar path, where the bulk path measured slower
The K=128 low-smem stream backward runs one serial chunk scan per
(seq, head) block with no V split, and its 97KB shared footprint
leaves no room to prefetch, so below half the SM count the serial
chain is exposed: measured 0.44-0.63x vs Triton at varlen D128 with
N*H=48 on the PRO 6000. The verifier now rejects low_v2 below the
same half-SM fill the forward grid guard already uses, and no longer
relies on the cs64-only schedule check (acceptance implies
launchability at every chunk size). The low schedule also moves to
256 threads at K=V=128, halving the persistent (K, V) fp32 dh
fragment to 64 registers per thread (stream kernel 2.81 -> 2.64 ms).
Compare the routed op against the per-token fp32 recurrence
(dplr_recurrence) for output, final state and all input gradients at
the same 0.007/0.008 ratios the Triton path is held to in
test_dplr_delta, covering rect and varlen, bf16/fp16, chunk 16/32/64
and the safe-gate stress regimes (saturated lower bound at cs32,
RWKV7-clamped at cs64). Also adds the verifier reject case for the
underfilled low-smem stream backward grid guard.
…unks

The low_v2 schedule loaded every input tile and q-side slice with scalar
predicated element accesses, which cap at ~1.5TB/s. Mirror the high/mid
kernel: interior (full-tile) chunks bulk-load qg/bg/w/kg/do/A via
vectorized T.copy, v_new and v are staged per chunk like do so the q-side
slices stay in shared memory, and exactly-divisible h slices bulk-load.
Boundary chunks and ragged Vs keep the scalar predicated path.

Gate: tests/ops/test_dplr_tilelang.py 48 passed, 6 skipped.
Bench (PRO 6000, cs32 D128 bf16, fwd+bwd vs Triton, dr=0/dr=1):
varlen h1024 N6 0.62/0.47 -> 0.96/0.78, rect h1024 0.71/0.57 -> 0.98/0.83,
varlen h4096 N3 0.88/0.73 -> 1.19/1.04, varlen h2560 N5 2.26/1.50 ->
3.10/2.18, rect h2560 1.06/0.86 -> 1.24/1.03.
…roups

The low_v2 schedule reduced the kg*dk and bg*db dgk terms as two serial
over-BT chains with only K lanes active. Port the high/mid schedule's
(4, K) lane-group split so all threads participate, and account for the
new staging tile in the shared-memory formula.

Gate: smoke parity (forced native vs Triton) max-rel ~1e-2 bf16.
Bench (PRO 6000, cs32 D128 bf16, fwd+bwd vs Triton, dr=0/dr=1):
varlen h1024 N6 0.96/0.78 -> 1.05/0.87, rect h1024 0.98/0.83 -> 1.04/0.89,
varlen h4096 N3 1.19/1.04 -> 1.25/1.11, varlen h2560 N5 3.10/2.18 ->
3.28/2.34, rect h2560 1.24/1.03 -> 1.27/1.06.
sablin39 added 14 commits August 1, 2026 12:42
…eLang backend

Addresses the remaining PR review style item: raw
torch.cuda.get_device_capability/get_device_properties/current_device
queries now go through fla.utils helpers (get_device_capability,
get_device_smem_optin), matching the verifier. The dead current_device
fallbacks in the ho/h config pickers are dropped (all call sites pass
kg.device.index); the stream-bwd schedule selection keeps the device
name fetch only on its error path.

Gates: tests/ops/test_dplr_tilelang.py + tests/ops/test_backends.py
64 passed/6 skipped; tests/context_parallel/test_cp_dplr.py 5
passed/2 skipped.
The four fp32 (BT,BT) dA staging tiles become two: dA1/dA2 serve
dAqk/dAqb for the q-side GEMM group, then are overwritten with
dAak/dAab (strict-lower mask applied after the overwrite) for the
a-side group. Per-accumulator GEMM order is unchanged, so results are
bit-identical to the four-tile layout; the footprint drops by
2*(BT,BT) fp32 tiles, taking the BT=64/BK=32 config from 131200B to
98432B — under cc120's 101376B smem optin, one of the two stages that
blocked native chunk_size=64 on 99KB devices.

Evidence: bit-exact vs HEAD at BT=32 rect+varlen with boundary chunks
(max diff 0.0); BT=32 perf flat (rect h2560 fwd+bwd: D64 8.659 vs
8.695ms, D128 15.723 vs 15.728ms vs HEAD).

Gates: tests/ops/test_dplr_tilelang.py 48 passed/6 skipped.
The gating loop re-reads gk from global memory (L2-resident after the
serial scan) instead of gk_shared, ending the tile's lifetime at the
scan so the allocator packs it against the fp32 operand tiles. Values
are identical, so results are bit-exact; the BT=64 footprint drops
from 98816B (bf16 gk) / 107008B (fp32 gk) to 98304B for both — under
cc120's 101376B optin. Together with the A-backward staging aliasing
this makes every chunk_size=64 D64 stage launchable on 99KB devices;
native cs64-D64 ran end-to-end for the first time there, with parity
vs the fp32 naive recurrence inside the gate envelope (o 0.0037 vs
0.007 threshold, worst dgk 0.0064 vs 0.008).

Evidence: bit-exact vs HEAD at BT=32 rect+varlen (max diff 0.0); cs64
acceptance arithmetic stays unchanged until native cs64 wins a bench.

Gates: tests/ops/test_dplr_tilelang.py 48 passed/6 skipped.
With the fused A-backward down to 98432B and the from_gk A-forward at
98304B, every BT=64 D64 stage fits a 101376B optin (the stream backward
selects low_v2 at 82944B), so the verifier's binding constant drops
131200 -> 98432 and cc120-class devices accept cs64 at K=64. K=128 cs64
stays rejected there — every stream schedule is >=167936B — and the D128
control rows measure exactly 1.00x, confirming the fallback costs nothing.

Native cs64-D64 wins every measured cell vs Triton cs64 (safe_gate,
rwkv7 gates, bf16): routed sweep rect h2560/h4096 dr=0 fwd 1.76/1.85x,
fwd+bwd 1.70/1.71x, dr=1 1.37-1.39x fwd and 1.20-1.21x fwd+bwd; varlen
N2/N3 1.50-1.73x fwd and 1.25-1.65x fwd+bwd. Native parity vs the fp32
naive recurrence sits inside the gate envelope on all outputs (o 0.0037
vs 0.007, worst dgk 0.0064 vs 0.008).

The reject-case chunk64_small_smem_k64 becomes an explicit accept test;
the K=128 small-smem reject stays, now documented as stream-bound.

Gates: tests/ops/test_dplr_tilelang.py 52 passed/2 skipped (the four
cs64 cases now run natively); tests/ops/test_backends.py 16 passed;
tests/context_parallel/test_cp_dplr.py 5 passed/2 skipped;
tests/ops/test_dplr_delta.py 28 passed + 1 pre-existing D100-cs64 suite
flake (rejected by head dim before the cs64 arithmetic; isolated reruns
pass).
The 256-thread + vectorized-T.copy configs for wu_fwd and wy_fast_bwd
were gated cc90-only at BT>=64, but the kernel-level win reproduces on
cc120 (wu_fwd BT64 2.12x at D64 / 1.67x at D128, wy_fast_bwd BT64
1.47x), and bulk-vs-scalar is bit-exact at BT64 rect+varlen including
boundary chunks. With cs64 now verifier-accepted on 99KB devices this
was the largest cs64 deficit: wu_fwd ran 3.53ms/iter vs Triton's
0.88ms at dr=1.

End-to-end cs64-D64 vs Triton: dr=1 fwd+bwd 1.18-1.23x -> 1.30-1.38x,
dr=0 fwd+bwd 1.59-1.69x -> 1.79-1.90x, dr=0 fwd up to 2.16x. BT=32
keeps the scalar path (bulk measured flat to slightly slower there).

Gates: tests/ops/test_dplr_tilelang.py 52 passed/2 skipped (the native
cs64 cases exercise the bulk path).
…ckward

The ctx forward already computes qg/kg/ag/bg/w/A_ab_inv/A_ak/A_qk/A_qb;
return them as extra op outputs so the disable_recompute backward reads
them from ctx instead of recomputing the from_gk/intra A-stage,
prepare_wy_repr, and wu. Only the cumsum gi is still recomputed, matching
FLA's recompute contract. The saved set stays read-only (mutates_args=()),
so gradients land in fresh buffers instead of recycling the intermediates.
CP keeps the recompute path (its boundary pre-process reads u) and the
dr=0 plain path is untouched.

A/B vs HEAD: D128 bit-exact; D64 shifts by bf16 rounding noise only
(backward now uses the forward's own cumsum gi rather than from_gk's
internally re-derived one). Gates: tilelang 52p/2s, backends 16p,
CP 5p/2s. dr=1 fwd+bwd tl-ms: rect h1024 B8 8.77 -> 8.19,
rect h2560 B4 11.07 -> 10.37, rect h4096 B4 17.61 -> 16.55,
varlen h2560 N5 5.49 -> 4.87, varlen h1024 N5 2.15 -> 1.95;
routed dr=1 fwd+bwd now 1.40-1.62x vs Triton (was 1.31-1.40x);
dr=0 and D128 control rows unchanged.
Two changes to the generic prepare_wy_repr inversion kernel (BT 16/32):
the inner j-reduction now stops at row_i instead of running the full BT
range (v[j>=row_i] is identically zero, so the skipped terms contribute
nothing), and interior chunks bulk-copy A_ab into shared memory and apply
the strict-lower mask there while boundary chunks keep the scalar
predicated path (a bulk store measured slower, so stores stay scalar).
Also drop the vestigial single-entry _WY_INV_CONFIGS table. The BT=64
blocked kernel already matches Triton and the same probes measured flat
there, so it is untouched.

w/u/A_ab_inv bit-exact vs HEAD at BT 16/32/64, rect+varlen including
boundary chunks. Kernel level (B8 T4096 H32/H64 grids): BT16
0.170/0.325 -> 0.106/0.259 ms, BT32 0.326/0.623 -> 0.196/0.384 ms
(exact parity with Triton's 0.193/0.383). Gates: tilelang 52p/2s,
backends 16p, CP 5p/2s. End-to-end cs32 routed dr=0 fwd+bwd:
1.34-1.36x rect h2560/h4096 B8, 1.29x varlen h2560 N3.
…k A-stage

The ctx forward (the disable_recompute path) was the only entry still
doing the standalone cumsum + tensorcore intra split at head_dim 64; the
plain forward has used the fused from_gk stage for a long time. Besides
the extra cumsum kernels, the tensorcore intra's torch-side gate
preparation showed up as ~1.4 ms/iter of elementwise work in profiles,
which was most of the dr=1 forward deficit against Triton.

A/B: D128 bit-exact; D64 differs at the bf16 rounding class already
accepted with the wy save set (forward from_gk gi vs backward cumsum gi).
Gates: tilelang 52p/2s, backends 16p, CP 5p/2s.

Routed dr=1: cs32 rect fwd 0.94-0.95x -> 1.29-1.34x, fwd+bwd
1.02-1.03x -> 1.13-1.14x; cs32 varlen 1.12/1.17x -> 1.23/1.23x;
cs64 fwd 1.53-1.57x -> 1.87-2.06x (near dr=0 parity), cs64 fwd+bwd
now 1.55-1.70x. dr=0 cells unchanged.
The cc90 config already used 256 threads to hide unpipelined load
latency; the same applies at BT=64 on cc120 (bwd_intra -14%), while
BT<=32 regresses (+16%) since occupancy already hides latency there, so
the extra warps are gated to BT>=64. The BT=64 bulk-copy path stays
cc90-only: its staging tiles need 106496B, over cc120's 99KB cap.

Bit-exact vs HEAD at cs64-D64 and cs32-D64, rect+varlen. Gates:
tilelang 52p/2s, backends 16p, CP 5p/2s. cs64 fwd+bwd: dr=0
1.86-1.90x -> 1.91-1.95x rect, dr=1 1.55-1.62x -> 1.60-1.67x.
- remove the never-referenced _H_FWD_CONFIGS/_HO_FWD_CONFIGS constants and
  trim the config helpers to the parameters their bodies actually use
- remove the allocate_state_cache escape hatch (no caller) and the dead
  scale_value/num_stages/USE_SWIZZLE kernel parameters (USE_SWIZZLE was a
  provable no-op: tilelang's use_swizzle returns None when disabled;
  num_stages=0 disables pipelining, so the loop is plainly serial)
- merge the two fwd_ho ctx wrappers into one store_context entry point
- drop the unused cc parameter from stream_bwd_schedule_or_none and the
  unused qside_bv override from stream_low_bwd_config
- fix the stale from_gk docstring and the double-assigned h_out
…ang backend

With the backend active, unbounded-gate calls must stay on Triton's robust
sub_intra path and return correct, finite results at every supported chunk
size.
chunk_dplr_delta_rule gains a lower_bound kwarg asserting lower_bound <= gk < 0.
When the bound fits the chunk size (per-row centered exponents stay in fp32
range) it canonicalizes to safe_gate=True on the Triton path and lets the
TileLang verifier accept the call without safe_gate. A non-negative bound
raises ValueError on every route.
low_v2 -> low (the high/mid/low smem ladder), the fused stream backward
drops the dhu_o suffix (chunk_dplr_bwd_stream_*), and the BT=64 WY-inverse
factory spells out _bt64.
@sablin39

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all five items are now addressed in the pushed commits. Details per item:

1. Gate-bound routing. The verifier now requires a caller-asserted gate bound, exactly as you framed it. safe_gate=False calls with no bound stay on Triton unconditionally. For cs64 we took your longer-term suggestion rather than the chunk_size == 64 carve-out: a new lower_bound kwarg (GDN2's convention) is wired through chunk_dplr_delta_rule/chunk_rwkv7 — it canonicalizes to safe_gate=True on the Triton path (so both routes validate the bound identically, including ValueError on non-negative bounds), and the TileLang verifier accepts when |lower_bound|·(chunk_size/2)·log2e ≤ 120 (gate_bound_is_safe in fla/ops/generalized_delta_rule/dplr/chunk.py). RWKV7's architectural clamp (w ∈ (−0.61, 0) → 28 log2 at BT=64) is what licenses cs64; the [-5, 0) safe_gate range licenses cs16/32. The flag/bound is a pure contract — kernels run the same centered scheme either way, so there is no second code path to validate. Tests cover accept/reject per branch, ValueError parity across routes, and a lower_bound route-parity row asserting the TileLang route is actually taken.

2. Coverage of the RWKV7-training branches. tests/ops/test_dplr_tilelang.py now has route-parity rows (fwd + all six input grads + dh0) with initial_state=None and output_final_state=False, in both rect and varlen layouts. Also added, since both are headline features: torch.library.opcheck on the forward custom op, a torch.compile full-graph smoke on the backend entry, and a torch.utils.checkpoint test that asserts the context-function elision route is taken. PyTorch-baseline rows compare the routed op against the per-token fp32 recurrence at the same 0.007/0.008 ratios test_dplr_delta holds the Triton path to, including gate-stress rows at the safe_gate envelope.

3. Robustness. (a) initial_state is .contiguous()-guarded in the backend entry. (b) The TileLang route is wrapped so that any exception from the impl (JIT failure, schedule selection, launch failure) falls back to Triton with a logged warning — behavior now matches the PR description. (c) The smem gate is fixed properly rather than patched: the verifier and the launcher now share one arithmetic module (schedules.py) that computes every stage's shared-memory footprint from (K, BT, dtype, device optin), so acceptance implies every stage launches on the current device. A100's 166912B optin rejects K=128 cs64 (all three stream-backward schedules exceed it) and accepts K=64; the test suite includes A100-shaped cases via monkeypatched device properties. We'd appreciate an A100 spot-check from your side if convenient — our hardware coverage is PRO 6000 (sm_120) and H800 (sm_90).

4. Benchmarks. Same-hardware before/after, dense + varlen, now in the PR body: PRO 6000 (sm_120) and H800 (sm_90), bf16, routed FLA_TILELANG=0 vs. =1, both disable_recompute policies. Headline: all 64 H800 cells ≥1.02×; on the PRO 6000 every accepted cs32 cell is ≥0.96× (small-grid cells reroute to Triton at ~1.00× by design) and cs64-D64 is 1.6–2.2×. Two anomalies are footnoted with profiling evidence (one genuine 7.1× varlen win, one rerouted fallback measuring do_bench noise). If you can rerun the sweep on your H100 we'd gladly take the cross-check — the H800 numbers suggest nothing is architecture-specific to sm_120.

5. Style. Device queries now go through small fla.utils helpers (commit 0e4f1e19); the raw torch.cuda.get_device_capability/properties calls are gone from the backend.

Inline nits (dummy 1-elem alloc in chunk_A_bwd, _into-only wy_fast_bwd, and/or parenthesization, FLA_TILELANG save/restore in the CP test) are all fixed in place.

Rebased onto current main (includes your DPLR safe-gate midpoint fix — our centering math was unaffected, and it's nice to see the same midpoint analysis land upstream).

@sablin39
sablin39 requested a review from zhiyuan1i August 10, 2026 10:13

@zhiyuan1i zhiyuan1i left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the thorough follow-up — the A100 smem gate now correctly rejects (K=128, cs64) and accepts (K=64, cs64), the device queries are properly routed through fla.utils helpers, and the new parity/opcheck/compile/checkpoint tests are real additions. Four items still block merge:

  1. Numerical-safety hole (inline): the verifier skips the gate-bound check entirely when safe_gate=True, so safe_gate=True, chunk_size=64 — a combination the public docstring still advertises as ~[-5, 0) — enters the centered TileLang A-stage with worst-case exponents of ~231–238 log2, past fp32 range. The new tests even assert this unsafe combination is accepted. The RWKV7 built-in caller is safe (clamped w → ~28 log2), but the public path is not.

  2. Positional-argument break (inline): lower_bound is inserted between safe_gate and chunk_size in the public signature, so existing positional callers silently bind their chunk_size into lower_bound. Please append it after the existing parameters (keyword-style, per the GDN2 convention).

  3. CP rank-local fallback: the runtime TileLang→Triton fallback is decided per rank. Under context parallelism, one rank failing over while its peers stay on TileLang can diverge the collective sequence or hang. The fallback decision needs to be collective-uniform (e.g. all-reduce the verifier result), or disabled under CP.

  4. H800 cs64 regression still routed: your own table shows H800 cs64 fwd+bwd at 0.54–0.81x, yet the verifier accepts cs64 on sm90 — contradicting the "only accepts when measurably faster" claim. Please either reject cs64 on sm90 (fall back to Triton) or provide per-shape data justifying the accepted subset; the Impact section's "cs64 up to ~2x on both architectures" also needs correcting against your H800 numbers.

Two smaller items:

  • gate_bound_is_safe is off by one: it uses chunk_size/2 rows, but centered_ge spans chunk_size/2 + 1 rows. At cs16 the helper accepts lower_bound≈-10.4, where the real exponent reaches ~135 log2 and overflows. Please tighten the formula.
  • The varlen benchmark numbers aren't reproducible: only total tokens and sequence count N are given — no cu_seqlens, generation rule, seed, or script. Please add the harness details (and the 7.08x PRO 6000 outlier deserves a sanity note).

Once the verifier gates both safe_gate paths on the (corrected) bound formula, the signature keeps positional compatibility, the CP fallback is made uniform, and sm90 cs64 stops routing to a slower backend, this should be in good shape.

chunk_size = 16 if chunk_size is None else chunk_size
if chunk_size not in (16, 32, 64):
return False, f"TileLang backend supports chunk_size 16/32/64, got {chunk_size}; fall back to Triton"
if not safe_gate:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: when safe_gate=True, the gate-bound check is skipped entirely, so safe_gate=True, chunk_size=64 enters the mid-chunk-centered A-stage unconditionally. The public docstring still describes safe_gate gates as roughly [-5, 0), which at cs64 reaches 5×32×log2e ≈ 231 log2 — well past the fp32 exp2 range (~127). safe_gate=True callers assert a bound, but nothing here enforces that the asserted bound fits the chunk size. The verifier should apply gate_bound_is_safe to both paths: for safe_gate=True use the documented default bound (-5), and reject cs64 for it, same as the lower_bound path.

cu_seqlens: torch.LongTensor | None = None,
cu_seqlens_cpu: torch.LongTensor | None = None,
safe_gate: bool = False,
lower_bound: float | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: inserting lower_bound between safe_gate and chunk_size breaks every existing positional caller — a positional chunk_size now binds into lower_bound (a float), silently changing behavior instead of raising. Please append new parameters after the existing public ones (or make it keyword-only), per the GDN2 convention.

…bound

The verifier trusted safe_gate=True unconditionally, but the documented
safe_gate range [-5, 0) overflows the mid-chunk-centered A-stage at
chunk_size 64: per-row exponents reach (64/2)*5*log2(e) = 231 log2, past
the fp32 limit of 128. With all gates pinned, the measured NaN onset
matches a chunk_size/2-row half-range exactly (cs64 onset between 2.75
and 2.80 vs 2.77 predicted; cs16 between 11.0 and 11.5 vs 11.09
predicted), so the cs/2 formula in gate_bound_is_safe needs no change.

Map safe_gate=True to its documented -5 bound and apply
gate_bound_is_safe on both gate paths, so safe_gate=True + chunk_size=64
falls back to Triton. RWKV7 keeps its cs64 route by asserting its
architectural w clamp via lower_bound=-exp(-0.5). Sustained-pin stress
tests cover the licensed bounds at cs16/32/64: the route stays finite
and tracks the fp32 recurrence (at the -5 pin, dgk RMS err 0.12-0.17 vs
0.34-0.48 for the Triton reference, so parity is asserted against the
baseline).

Also correct the varlen layout cache comment: fla.utils.tensor_cache is
not reusable there since it identity-keys every argument.
lower_bound landed between safe_gate and chunk_size, which breaks
positional callers that pass chunk_size. Move it after cp_context in the
public op and in the TileLang backend protocol methods; all in-tree
callers use keywords.
A rank-local fallback to Triton would diverge the cross-rank collectives
inside the CP forward and hang the job; surface the error instead.
@sablin39

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. Items 1–3 and the two minors are addressed in d24c7e9, fd7ee74, 43beb65 (pushed); item 4 needs a Hopper box, plan below.

1. safe_gate + cs64 overflow — confirmed and fixed. The verifier now maps safe_gate=True to its documented [-5, 0) bound and runs gate_bound_is_safe on both gate paths, so safe_gate=True + cs64 falls back to Triton (the cs64 half-range at -5 is 231 log2, past the 128 fp32 limit). RWKV7 keeps its cs64 TileLang route by asserting its architectural w clamp through lower_bound=-exp(-0.5) in fla/layers/rwkv7.py. New sustained-pin stress tests clamp every gate at the licensed bound for the whole sequence (cs16/32 at -5, cs64 at -2.0 and -0.61) and assert finite outputs tracking the fp32 per-token recurrence; a routing regression test asserts safe_gate+cs64 stays on Triton. One finding worth noting: at the -5 pin the Triton reference itself is the less accurate backend — dgk RMS err vs the fp32 recurrence is 0.34–0.48 for Triton vs 0.12–0.17 for TileLang — so those rows assert parity against the baseline rather than cross-backend.

5. Off-by-one in gate_bound_is_safe — measured the actual onset to check this; the formula is correct as-is. With all gates pinned, the NaN onset matches a cs/2-row half-range, not cs/2+1: at cs64 it lands between 2.75 and 2.80 (cs/2 predicts 128/(32·log2e) = 2.77; cs/2+1 predicts 2.69, but 2.70 is still finite); at cs16 between 11.0 and 11.5 (cs/2 predicts 11.09). The extra row does not participate in the max exponent in this kernel, and the 120 threshold keeps real margin to the 128 limit.

2. Positional breaklower_bound moved after cp_context in the public op and the backend protocol methods; all in-tree callers use keywords.

3. CP fallback — the forward now re-raises under cp_context instead of falling back rank-locally.

6. Varlen repro — generation rule added to the benchmark preamble: 8192 tokens flattened to [1, 8192], N sequences from torch.Generator(seed=17+N), lengths proportional to rand(N)+0.35, last sequence absorbing the remainder; inputs are bf16 randn with a = normalize(rand), b = -a, fp32 gates -0.61·sigmoid(5·randn). Note the cs64 cells now pass lower_bound=-0.61 to license the route post-fix.

4. H800 cs64 — agree with the data (0.54–0.81×). Plan: gate cs64 to devices with measured wins (cc12x) in the verifier, one cc check next to the existing schedule arithmetic — unless a Hopper re-measurement justifies keeping sm_90. I'll run the Hopper pass first (machine access incoming) and follow up either way.

…can kernel

The rectangular-batch gate cumsum ran as a vectorized PyTorch chain
(fp32 cast, pad, cumsum(dim=2), scale, zeros_like + shift), which
profiled at ~2.3-2.8 ms/iter of direct_copy / scan_outer_dim / fill
glue on H800 at h4096. A segmented fp32 scan kernel (SEG lane groups
scan their segment serially in registers, then apply a per-channel
exclusive prefix of segment sums) emits gi and the shifted ge in one
pass at ~1.3 ms, ulp-level parity with the chain.

chunk_local_cumsum gains output_ge=False: the disable_recompute
backward at head dim 64 derives ge in-kernel, so the ge write (and its
fp32 allocation) is now skipped there; both scan kernels take an
OUTPUT_GE constexpr and the dead buffer aliases gi.
Python autograd.Function backward receives dense zero grads for every
forward output slot. The ctx forward returns 13 save-for-backward
intermediates alongside o/final_state, so every backward step
materialized ~2.8GB of zeros (h4096, bf16) that the backward discards
unread. set_materialize_grads(False) passes None instead; both
backward entry points already treat a missing dht as None.
cs64 measured 0.68-1.05x vs Triton on sm_90 (H800 sweep, rect and
varlen, both head dims, pre- and post-cumsum-fix): the BT=64 wy/wu
UT-transform stages are latency-bound there and the fused h+o pass
does not amortize at twice the chunk serial length. Schedulability
was never the blocker on Hopper's 228KB optin — the route simply
loses, so the verifier now rejects it below cc12 and the call falls
back to Triton (1.00x). sm_120 keeps cs64 (validated 1.30-2.18x).

The sm90-accepts-cs64 verifier test flips to assert the rejection,
and the parity-suite skip helper mirrors the gate (renamed
_cs64_launchable -> _cs64_routed).
The a-side centered operand uses the exclusive cumsum ge (ge[0]=0)
against the inclusive gi[mid], so its worst positive exponent spans
chunk_size/2+1 rows, not chunk_size/2. The old helper accepted bounds
whose true exponent overflows fp32 (e.g. lower_bound=-10.39 at cs16
reaches 135 log2). Use the exact span and set the threshold to 124 =
128 log2 (fp32 limit) minus 4 log2 of headroom for the activation
multiply; the documented safe_gate range [-5, 0) stays licensed at
cs16/cs32 (65/123 log2) and rejected at cs64 (238 log2), and RWKV7's
-0.61 clamp at cs64 (29 log2) is unaffected.
Measured 0.83-1.02x vs Triton on sm_90 (H800, D64, all varlen sizes, both
disable_recompute policies): the BT=16 serial state pass does not
amortize ragged chunks there, so varlen cs16 now falls back to Triton
below cc12. cc12x keeps the route (1.08-1.23x at h2560/h4096 on the PRO
6000), as does rect cs16 on both architectures (1.06-1.35x).
…havior

Fresh probes on sm_120 (every gate pinned at the -5 bound, cs32, h0+dht
backward) show the Triton reference stays finite too; what actually
breaks parity is its dgk diverging from the fp32 recurrence at an err
ratio of ~0.5 vs TileLang's ~0.17. State that instead of the earlier
unreproduced fp32-overflow claim.
@sablin39

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — all four blockers and both smaller items are addressed in the current head.

1. Gate-bound hole. Both safe_gate and lower_bound now go through gate_bound_is_safe; safe_gate=True + cs64 (33-row span, ~238 log2, past fp32 range) is rejected and falls back to Triton, with tests asserting the rejection and the Triton fallback results. cs64 is exercised via lower_bound; RWKV7's clamped w (~29 log2) still routes.

2. Signature order. lower_bound is appended after cp_context, keyword-style per GDN2 — positional callers are unaffected.

3. CP fallback. Under cp_context the forward re-raises instead of failing over per rank (43beb65, pushed after your review), so ranks can't diverge on the collective sequence; a new 2-GPU test asserts the route per rank.

4. sm90 cs64. cs64 now routes only on cc12x+ (acceptance still implies launchability via shared schedule arithmetic); varlen cs16 measured the same and got the same gate:

Route H800 (sm_90) PRO 6000 (sm_120) Now routes on
cs64, K=64 0.68–1.05× 1.66–2.23× cc12x+ only
varlen cs16 0.83–1.02× 1.08–1.23× (h2560/h4096) cc12x+ only
rect cs16 1.06–1.35× 1.06–1.35× both

Rejected configs fall back at 1.00×; the Impact section is corrected.

Why it lags on Hopper (per-kernel attribution + IKET warp-level tracing): the deficit is confined to the two serial state-scan stages (fused h+o forward, stream backward), which walk T/64 chunks serially on grids of N·H·(V/BV) and N·H blocks — at h1024 D128 that's 256/64 blocks against 132 SMs, while Triton's split passes re-parallelize over chunks and V-blocks (stream 1.13 vs. 0.65 ms; h+o 0.74 vs. 0.53), even though our fused intra stage wins there (0.61 vs. 0.92 ms). IKET confirms it: per ~8 µs chunk chain, 54% of warp time is global staging vs. 37% for all mma phases, with no next-chunk prefetch and ~12% occupancy — prefetch/pipelining is the follow-up. Once the grid fills, the scans reach parity and the residual is the intra stage (2.96 vs. 2.24 ms), Triton's strongest.

On cc12x the same structure holds up: the stream still wins (3.21 vs. 5.01 ms) and h+o is at parity — Triton's split passes are just as latency-bound and gain nothing from the extra 56 SMs. The total flips because Triton's sm_90a-tuned intra regresses 5.5× on sm_120 (2.2 → 12.3 ms) while ours stays flat (≤1.3×, tracking clocks): 0.76× deficit → 3.3× win, deciding the 1.88× total. Same ISA class on both GPUs (mma.sync m16n8k16, no wgmma/TMA) — scheduling, not tensor cores. The cumsum/stream fixes also moved the worst rect cell from 0.80× to 0.92× on H800; what remains is the structural underfill, hence the gate.

Off-by-one. gate_bound_is_safe now uses the exact chunk_size/2 + 1-row span with a 124 log2 threshold (128 limit − 4 headroom); your −10.4/cs16 example (135 log2) is rejected, boundary cases unit-tested.

Varlen reproducibility. The harness is documented: 8192 tokens split into N sequences with lengths ∝ rand(N) + 0.35 (seed = 17 + N), [1, 8192] layout, fp32 gk = −0.61·sigmoid(5x), and the same do_bench script (warmup 10, rep 50, FLA_TILELANG=0/1 through the dispatched op) drives every table. The 7.08× cell was two sweep shards sharing a GPU inflating the Triton baseline; all tables re-ran one shard per GPU, headline cells cross-checked with CUDA-event timing and profiler attribution (current max 5.96×, H800 varlen D128).

Accuracy. Pinned by the new tests: route parity holds TileLang to Triton's own err-ratio bars vs. the fp32 recurrence (0.007/0.008) across rect+varlen, bf16/fp16, fp32 gates, cs16/32/64, both dr policies — with consistently tighter margins (0.0058 vs. 0.0104 at cs32/D128/bf16). Under sustained gate pinning both stay finite, but Triton's dgk err lands ~0.5 vs. ours ~0.17 — why the stress rows pin only ~1% of gates. Rect bf16 + fp32-gates forward is bit-exact.

Suite: test_dplr_tilelang.py 70 passed + 2 skipped; test_dplr_delta.py passes (the D100-cs64 fp16 failure reproduces on main). Branch merged with current main, audits re-ran clean.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants