Skip to content

[Triton/Gluon] KDA: accept cu_seqlens_cpu, upload varlen metadata async - #6912

Draft
eppaneamd wants to merge 4 commits into
mainfrom
eppaneamd/kda-cu-seqlens-cpu
Draft

eppaneamd wants to merge 4 commits into
mainfrom
eppaneamd/kda-cu-seqlens-cpu

Conversation

@eppaneamd

@eppaneamd eppaneamd commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

A serving engine calls chunk_kimi_delta_attn once per KDA layer per step, with a new cu_seqlens each step. On the FlashKDA route the first KDA layer of a step reads cu_seqlens back to the host twice (prepare_chunk_indices and _seq_bounds) and, on a descriptor-cache miss, uploads the segment tables with two blocking copies. Each of these waits for all GPU work queued before that layer. Serving engines already hold the same bounds on the host.

Separately, prepare_chunk_indices numbers chunks wrongly when an empty sequence comes before a non-empty one: the old formula counted only sequences with chunks, so the later sequences' chunks are assigned to earlier sequences' bounds.

Changes

  • chunk_kimi_delta_attn(..., cu_seqlens_cpu=None), with the name and meaning of fla's chunk_kda argument: a CPU tensor shaped like cu_seqlens, trusted to hold the same values. With it, cu_seqlens is never read back; without it, it is read back once per tensor instead of twice.
  • flash_kda.py: _build_segments becomes _build_metadata, which also builds K1's chunk table. The three tables share one int32 buffer, uploaded with one non-blocking copy from pinned memory. Each block starts 16-byte aligned, because fast_launch specializes on pointer alignment. K1's chunk table is therefore int32 whatever the dtype of cu_seqlens; the Triton and Gluon K1 kernels both convert it on load.
  • prepare_chunk_indices(cu_seqlens, chunk_size, cu_seqlens_cpu=None) is built from the bounds and cached on them, and numbers chunks by their sequence's position in cu_seqlens, as fla does. Dtype, device and shape are unchanged.
  • A cached upload used from another stream waits on the uploading stream and is recorded on the current one, so cache eviction cannot free it under queued kernels. During CUDA graph capture a cache hit keeps its buffer for the life of the process (before, the 64-entry cache could free descriptor tables a captured graph still used), and a miss raises a RuntimeError.
  • No kernel changes. Every new argument defaults to None.
  • op_tests/op_benchmarks/triton/bench_flash_kda.py (second commit): --varlen LENS times one B=1 varlen call over those lengths; each call gets new bounds from a rotating set of 128, so the bounds and metadata caches miss as they do in serving. --seqlens host also passes cu_seqlens_cpu. The dense sweep is unchanged.

Scope

Model-independent host code. It reaches aiter.ops.triton.kimi_delta_attn.chunk_kimi_delta_attn (FlashKDA and the default pipeline) and aiter.ops.triton.attention.chunk_kda on archs other than gfx950 and gfx1250, which falls back to it without host bounds and gets the single read-back, the async upload and the numbering fix. Gated DeltaNet's own prepare_chunk_indices is not changed. KDA layers appear in Kimi-Linear, Kimi-K3 and GLM-5.3-Flash, the measured model.

Tests

Added to op_tests/triton_tests/kimi_delta_attn/test_flash_kda.py:

  • test_cu_seqlens_cpu_issues_no_sync: a paged FlashKDA call with host bounds makes no synchronizing call on a metadata-cache miss under torch.cuda.set_sync_debug_mode("error"), the same call with device bounds raises, and the two give identical output and state.
  • test_prepare_chunk_indices_numbers_empty_sequences: chunk numbering matches a repeat_interleave reference when empty sequences come before non-empty ones.

Run on MI355X (gfx950, one GPU; torch 2.13.0, Triton 3.8.0), with this branch's five changed source files overlaid on AITER 0.1.26:

  • Both new tests pass on this branch. Without the change (AITER 0.1.26 sources, same test files), test_cu_seqlens_cpu_issues_no_sync fails with TypeError (no cu_seqlens_cpu argument) and test_prepare_chunk_indices_numbers_empty_sequences fails at its value assertion.
  • op_tests/triton_tests/kimi_delta_attn/: 137 passed, 1 skipped on this branch; 135 passed, 2 failed (the two new tests), 1 skipped without the change. The skip needs a second GPU.

Black and Ruff are clean on the changed files.

Performance

The gap this targets was seen in a profile taken before this change:

  • AMD Instinct MI355X (gfx950), tensor parallel 2, amd/GLM-5.3-Flash-Quark-MXFP4 (34 KDA layers).
  • AITER 0.1.26, whose prepare_chunk_indices makes one more blocking copy than this PR's base.
  • vLLM with an unmerged change that routes KDA chunked prefill to chunk_kimi_delta_attn (FlashKDA, paged state_cache), now [ROCm][Perf] Add AITER FlashKDA prefill backend for GLM KDA vllm-project/vllm#63568.
  • One 512x512 image plus text per request, 1024 input / 128 output tokens, concurrency 32; torch profiler over 256 steps, 65 with prefill.

In each step with prefill, the GPU idles 1.27 ms just before the first KDA layer's FlashKDA kernels: 1.10 % of profiled time. Later KDA layers show no such gap. The profile does not separate host-side planning from a stream drain.

A standalone step emulation on this branch (MI355X; 34 KDA layers, 32 heads, one new cu_seqlens per step for 12 decodes and prefills of 1100, 1300 and 900 tokens; one 0.1 ms GEMM queued before each step) shows the copy is gone: with device bounds every traced step has one synchronous device-to-host copy before the first KDA layer, and with cu_seqlens_cpu the trace has none. It does not measure a gain: too little GPU work was queued ahead of the step for the copy to wait on.

Varlen microbenchmark (--varlen): one call per step above, 12 decodes plus prefills of 1100, 1300 and 900 tokens (3312 tokens), with new bounds on each call:

  • MI355X (gfx950), one GPU; torch 2.13.0, Triton 3.8.0.
  • Base: this PR's base sources (1e2d557d78), device bounds; the base has no cu_seqlens_cpu, so --seqlens host exits with an error there.
  • This PR: device bounds, then host bounds (cu_seqlens_cpu).
  • Three alternating rounds per arm; the rounds of each arm agree within 1.1 %.
python op_tests/op_benchmarks/triton/bench_flash_kda.py --shape 1 3312 32 128 128 \
  --varlen 1,1,1,1,1,1,1,1,1,1,1,1,1100,1300,900 --warmup-ms 2000 --seqlens device   # or host
Sources Bounds Time (ms), mean of 3 Change vs base
base device 0.4687 —
this PR device 0.3416 −27.1 %
this PR host (cu_seqlens_cpu) 0.2318 −50.5 %

As a regression check, the script's default sweep of 11 fixed-length shapes, one run per arm: 10 of 11 shapes are within ±0.8 % of the base, and 1×256 (H 12) is +2.0 µs (+6.0 %) on a 33 µs call.

The end-to-end impact has not been measured.

Related PRs

This change was developed with AI assistance (Claude); I reviewed every line and ran the tests listed.

eppaneamd and others added 2 commits October 24, 2026 21:09
chunk_kimi_delta_attn read cu_seqlens back to the host twice per new
tensor on the FlashKDA route (prepare_chunk_indices and _seq_bounds),
and on a descriptor-cache miss uploaded the segment tables with two
blocking copies. The first KDA layer of every serving step therefore
waited for all work queued before it.

- Accept fla's cu_seqlens_cpu on chunk_kimi_delta_attn and thread it
  to chunk_delta_attn_fwd and flash_kda_fwd. With it, cu_seqlens is
  never read back; without it, it is read once per tensor.
- Build the chunk indices and the segment descriptors from the host
  bounds and upload them with one non-blocking copy from pinned
  memory, cached on the bounds' contents. A hit from another stream
  waits on the uploading stream and records the buffer on its own. A
  miss during graph capture raises; a hit keeps its buffer for the
  life of the process, so cache eviction cannot free it under a graph.
- prepare_chunk_indices now numbers chunks by their sequence's
  position in cu_seqlens. The old cumsum-of-chunk-starts formula
  skipped empty sequences, so an empty sequence before a non-empty
  one sent the later sequences' chunks to the wrong bounds. The fix
  covers both the FlashKDA route and the default pipeline.

Tests: a call with host bounds makes no synchronizing call under
torch.cuda.set_sync_debug_mode("error") on a metadata-cache miss, the
same call with device bounds does, and both give bit-identical output
and state; prepare_chunk_indices numbers empty sequences like a
repeat_interleave reference.

Signed-off-by: Rony Leppänen <rony.leppanen@amd.com>
Co-authored-by: Claude <noreply@anthropic.com>
--varlen LENS times one B=1 varlen call, giving every call a new
cu_seqlens (pinned host tensor, non-blocking copy) from 128 rotating
bound sets, so the bounds and metadata caches miss as in a serving step.
--seqlens host also passes cu_seqlens_cpu; the dense sweep is unchanged.

Signed-off-by: Rony Leppänen <rony.leppanen@amd.com>
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs (added automatically when gfx942 configs change); main branch always runs both MI35X and MI300X
ci:triton-355 Run the full Triton test suite on MI35X, not only the tests the change affects
ci:windows Windows JIT op smoke test on the gfx1201 ROCm runner
ci:multi-gpu Aiter multi-GPU tests on the 8-GPU runner
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: DeepSeek-V4-Pro, MiniMax-M3-MXFP4
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 6912 --add-label <label>

One backend per PR:
A PR changes one kernel backend: [Triton/Gluon] (Triton and Gluon count as one), [HIP], [ASM], [CK], [OPUS] or [FlyDSL]. If the title ends up with two backend tags, split the PR -- as stacked pull requests when one part cannot merge without the other.

PR title tags & labels:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title and as PR labels automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf], op tags like [MLA], and human labels (ci:*) are left untouched. Add the no-auto-title label to stop the title rewrites; labels stay in sync either way.

…ens-cpu

Conflicts with #6609 (gfx1250 FlashKDA, ffddbaf), resolved by keeping
both sides:

- chunk_fwd.py: main packs q, k, v and g after the FlashKDA return; the
  default pipeline then builds chunk_indices from cu_seqlens_cpu.
- chunk_delta_attn.py: cu_seqlens_cpu is accepted (no longer listed as
  rejected); main's contiguity note, with the gfx1250 exception, is kept.
- test_flash_kda.py: both test blocks kept. #6609's
  test_positional_call_keeps_skipped_storage lists every flash_kda_fwd
  parameter, so it gains cu_seqlens_cpu.
- bench_flash_kda.py: --varlen's column is named "bounds", since
  --k3-tp8's "seqlens" column holds the lengths. --varlen rejects --k3-tp8
  (its inputs carry their own cu_seqlens) and --impl chunk_kda (it would
  time flash_kda_fwd).

The gfx1250 K1 and K2 read chunk_indices and the segment descriptors as
the gfx950 kernels do, so they take the int32 metadata from
_build_metadata unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Rony Leppänen <rony.leppanen@amd.com>
@eppaneamd
eppaneamd requested a balanced review from Copilot October 29, 2026 21:58
@aiter-bot

Copy link
Copy Markdown

NOT READY FOR HUMAN REVIEW

PR Self-Review

Revision 23fe469bc425 · mode shadow

Waiting for the PR author. The readiness report will appear after the self-review answers are complete for this revision.

Questions to answer in the PR description

  1. What behavior does this PR change, and why is the existing behavior insufficient?
  2. What did you validate, and what remains unverified? Include commands, hardware/configuration and results.
  3. What behavior would each changed op test fail on before this PR, and why is that coverage not already provided by an existing canonical test?
  4. For every changed op_tests/ file, what behavior does it cover and why is it not redundant? Write None if no op test changed.

Edit the PR Self-Review section in the PR description. Include the current revision SHA; this comment refreshes automatically.

Missing or invalid answers

  • FAIL — missing ### Reviewed revision in the PR description
  • FAIL — missing ### Behavior and intent in the PR description
  • FAIL — missing ### Validation and unverified scope in the PR description
  • FAIL — missing ### PR-specific question in the PR description
  • FAIL — missing ### Changed op tests in the PR description
  • FAIL — Reviewed revision must contain at least the first 7 characters of the current head SHA
  • FAIL — Behavior and intent has no substantive answer
  • FAIL — Validation and unverified scope has no substantive answer
  • FAIL — PR-specific question has no substantive answer
  • FAIL — changed op test not explained in Changed op tests: op_tests/op_benchmarks/triton/bench_flash_kda.py
  • FAIL — changed op test not explained in Changed op tests: op_tests/triton_tests/kimi_delta_attn/test_flash_kda.py
  • FAIL — Changed op tests must explain why each changed op test is necessary and non-redundant

Shadow mode reports what would block but keeps the status successful. Set AITER_PR_SELF_REVIEW_MODE=enforce only after reviewing real false positives.

Copilot AI left a comment

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.

🟡 Changes recommended

The staging tensor is not explicitly allocated on CPU, causing pin_memory() to fail on metadata-cache misses when CUDA is the default device.

3 open issues
What changed in this PR

This PR reduces host synchronization in KDA variable-length attention by accepting caller-provided CPU sequence bounds and uploading cached metadata asynchronously. It updates host-side preparation without changing kernel bodies.

Changes:

  • Add optional cu_seqlens_cpu support to FlashKDA and the default KDA pipeline.
  • Combine FlashKDA metadata into one aligned upload, manage stream and graph lifetimes, and correct chunk numbering after empty sequences.
  • Add synchronization and numbering tests, plus a variable-length benchmark with rotating bounds.
File Description
op_tests/​triton_tests/​kimi_delta_attn/​test_flash_kda.py Test host-bound synchronization avoidance and empty-sequence numbering.
op_tests/​op_benchmarks/​triton/​bench_flash_kda.py Add variable-length benchmarks with host or device bounds.
aiter/​ops/​triton/​kimi_delta_attn/​chunk_delta_attn.py Expose, validate, and forward optional CPU bounds.
aiter/​ops/​triton/​_triton_kernels/​kimi_delta_attn/​utils/​index.py Share bounds reads and cache correctly numbered chunk indices.
aiter/​ops/​triton/​_triton_kernels/​kimi_delta_attn/​flash_kda.py Pack chunk and segment metadata into one aligned upload.
aiter/​ops/​triton/​_triton_kernels/​kimi_delta_attn/​chunk_fwd.py Forward CPU bounds through both execution paths.
aiter/​ops/​triton/​_triton_kernels/​kimi_delta_attn/​chunk_delta_attn_utils.py Add asynchronous uploads, stream waits, and graph-lifetime retention.

Review effort: Balanced

"capturing so it is cached."
)
stream = torch.cuda.current_stream(device)
host = torch.tensor(values, dtype=dtype).pin_memory()
return
current = torch.cuda.current_stream(stream.device)
if current != stream:
current.wait_stream(stream)
Comment on lines +150 to +152
if torch.cuda.is_current_stream_capturing():
_captured_uploads[buf.data_ptr()] = buf
return

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants