Files
TheRock/rocm-libraries/dnn-providers/hip-kernel-provider/rocke/library/kernels/common/attention_unified.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

4546 lines
186 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) Advanced Micro Devices, Inc., or its affiliates.
# SPDX-License-Identifier: MIT
"""Native CK DSL entry points for AITER unified attention.
This module intentionally separates *feature selection* from *kernel emission*.
The selector mirrors AITER's Triton wrapper exactly; kernel emission is gated
until every required primitive and correctness/perf path is present.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any, Dict, Optional, Tuple
from rocke.core.ir import (
BF16,
F16,
F32,
FP8E4M3,
I32,
IRBuilder,
KernelDef,
PtrType,
Type,
Value,
)
from rocke.helpers.compile import compile_kernel
from rocke.runtime.launcher import (
KernelLauncher,
LaunchConfig,
LaunchSummary,
PipelineLauncher,
WorkspaceSpec,
WorkspacePool,
_resolved_fence,
no_fence,
wait_stream_and_release,
)
from rocke.helpers.attention import (
Attention2DConfig,
Attention3DConfig,
PagedKvDescriptor,
apply_softcap_log2,
select_2d_config,
select_3d_config,
use_2d_kernel,
)
from rocke.helpers.transforms import (
TensorDescriptor,
calculate_magic_numbers,
do_magic_division,
)
# Alias the promoted log2-domain softcap helper under the local name used by
# the scalar reference kernels (matches the gfx950 tiled modules' convention).
_apply_softcap = apply_softcap_log2
# NOTE: this dispatcher lives in ``instances/common/`` (arch-neutral) but the
# optimized tiled kernels it dispatches to are arch-specific. To keep
# ``common/`` from importing an arch package at module top -- so that
# ``import rocke.instances`` does not pull in the gfx950 (MFMA) implementation
# regardless of the running device, and so the future gfx1151 (WMMA) tiled impl
# can be added the same way -- the gfx950 tiled specs/builders/gates are
# imported *lazily* inside the dispatch functions that actually use them
# (``supports_native_unified_attention_tiled``,
# ``supports_native_unified_attention_3d_tiled``, ``_tiled_spec_from_problem``,
# ``_tiled_3d_spec_from_problem``, ``_get_2d_launcher``, ``_get_3d_pipeline``).
# Return-type annotations that name the tiled spec classes are strings at
# runtime (``from __future__ import annotations`` above), so they need no
# import. The arch is resolved via ``_resolve_attention_arch`` at dispatch
# time, which is the natural seam for routing to a gfx1151 WMMA tiled impl once
# the unify phase lands it.
@dataclass(frozen=True)
class UnifiedAttentionProblem:
total_q: int
num_seqs: int
num_query_heads: int
num_kv_heads: int
head_size: int
block_size: int
max_seqlen_q: int
max_seqlen_k: int
dtype: str
q_dtype: Optional[str] = None
sliding_window: int = 0
softcap: float = 0.0
use_sinks: bool = False
use_alibi: bool = False
use_qq_bias: bool = False
use_fp8: bool = False
num_sms: int = 120
# AMDGPU occupancy hint ("amdgpu-waves-per-eu"). The 2D-tiled and
# 3D-tiled specs both honour this knob; the scalar paths ignore it
# because they already fit at 1 wave per workgroup. ``None`` keeps
# the LLVM backend's heuristic choice.
waves_per_eu: Optional[int] = None
# Compile backend for the tiled 2D path:
# - ``None`` (default): auto-pick. Uses the LLVM-direct
# pipeline (``compile_kernel``) except for large prefill
# (``max_seqlen_q > 512 or num_seqs * max_seqlen_q > 1024``),
# where ``hipcc --genco`` is measurably faster (≈5% on
# ``b4_q1000_kv1000``) thanks to clang's heavier scheduling.
# - ``"llvm"``: always use the LLVM-direct path (~90ms compile).
# - ``"hipcc"``: always lower to HIP C++ and compile via hipcc
# (~450ms compile but ~5% faster on long-running kernels).
# See ``probe_hip_lowering.py`` for the per-shape comparison.
compile_backend: Optional[str] = None
# Number of physical blocks in the paged KV cache (``k.shape[0]``). Used
# only to decide whether the i32 buffer voffset can address the whole
# cache; 0 means "unknown" (assume small / fast i32 path). The
# dispatcher fills this from the K tensor when available.
num_kv_blocks: int = 0
@property
def num_queries_per_kv(self) -> int:
if self.num_query_heads % self.num_kv_heads:
raise ValueError("num_query_heads must be divisible by num_kv_heads")
return self.num_query_heads // self.num_kv_heads
@property
def all_decode(self) -> bool:
return self.max_seqlen_q == 1
@property
def total_num_q_blocks_upper_bound(self) -> int:
block_m = (
16
if self.num_queries_per_kv <= 16
else _next_power_of_2(self.num_queries_per_kv)
)
block_q = block_m // self.num_queries_per_kv
return self.total_q // block_q + self.num_seqs
def select_path(self) -> str:
target = self.num_sms * 4
num_2d = self.total_num_q_blocks_upper_bound * self.num_kv_heads
return (
"2d"
if use_2d_kernel(
head_size=self.head_size,
sliding_window=self.sliding_window,
all_decode=self.all_decode,
max_seqlen_q=self.max_seqlen_q,
max_seqlen_k=self.max_seqlen_k,
target_num_prgms=target,
num_2d_prgms=num_2d,
)
else "3d"
)
def select_2d(self) -> Attention2DConfig:
num_2d = self.total_num_q_blocks_upper_bound * self.num_kv_heads
return select_2d_config(
block_size=self.block_size,
head_size=self.head_size,
sliding_window=self.sliding_window,
all_decode=self.all_decode,
max_seqlen_q=self.max_seqlen_q,
max_seqlen_k=self.max_seqlen_k,
num_queries_per_kv=self.num_queries_per_kv,
num_2d_prgms=num_2d,
)
def select_3d(self) -> Tuple[Attention3DConfig, Attention3DConfig]:
target = self.num_sms * 4
num_2d = self.total_num_q_blocks_upper_bound * self.num_kv_heads
return select_3d_config(
head_size=self.head_size,
block_size=self.block_size,
element_size=2 if self.dtype in ("fp16", "bf16") else 1,
max_seqlen_k=self.max_seqlen_k,
target_num_prgms=target,
num_2d_prgms=num_2d,
)
def _next_power_of_2(x: int) -> int:
return 1 if x <= 1 else 1 << (int(x) - 1).bit_length()
def _magic_div(b: IRBuilder, dividend: Value, divisor: int) -> Value:
"""``dividend // divisor`` via CK Tile's magic mul-hi division.
``divisor`` is a loop-invariant compile-time constant so
``(multiplier, shift)`` fold to immediates and the runtime cost is one
``v_mul_hi_u32`` + add + shift instead of the AMDGPU integer divider's
~20-cycle Newton-Raphson sequence. ``dividend`` is a non-negative i32
(a grid axis or a KV position), well inside the documented 31-bit
unsigned range of the magic sequence -- so the unsigned magic quotient
equals the signed ``b.div`` it replaces. This is the device lowering of
``merge_v2_magic_division`` (``transforms.do_magic_division``).
"""
mult, shift = calculate_magic_numbers(divisor)
return do_magic_division(b, dividend, mult, shift)
def _magic_div_mod(b: IRBuilder, dividend: Value, divisor: int) -> Tuple[Value, Value]:
"""Split ``dividend`` into ``(dividend // divisor, dividend % divisor)``.
The quotient is the magic-division mul-hi sequence (:func:`_magic_div`);
the remainder is reconstructed as ``dividend - quotient * divisor`` --
exactly how CK Tile's ``merge_v2_magic_division`` recovers the low
coordinate, dropping the hardware ``mod`` (a second integer divide).
"""
quotient = _magic_div(b, dividend, divisor)
remainder = b.sub(dividend, b.mul(quotient, b.const_i32(divisor)))
return quotient, remainder
_RESOLVED_ATTENTION_ARCH: Optional[str] = None
def _resolve_attention_arch() -> str:
"""Pick the build/launch target for the tiled attention kernels.
These launch paths compile-then-run on the local device, so the target
must match the running GPU. Query the runtime; fall back to gfx950 (the
only arch the tiled MFMA path supports today) when the device arch is
unavailable (e.g. CPU-only static tests / cross-compile harnesses).
**Memoized process-wide.** The device arch never changes within a process,
yet the launch hot path resolves it ~20x per call (every selector that
feeds ``_tiled_cache_key`` consults it). Re-importing + re-calling
``get_device_arch`` that many times is pure host overhead that dominates
tiny-shape latency, so the result is cached after the first resolution.
Tests that monkeypatch this function replace it wholesale, so the cache
does not interfere with them.
"""
global _RESOLVED_ATTENTION_ARCH
if _RESOLVED_ATTENTION_ARCH is not None:
return _RESOLVED_ATTENTION_ARCH
try:
from rocke.runtime.hip_module import get_device_arch
arch = get_device_arch()
except Exception:
arch = None
_RESOLVED_ATTENTION_ARCH = arch or "gfx950"
return _RESOLVED_ATTENTION_ARCH
def _tiled_2d_impl(arch: str):
"""Lazily import the arch-specific tiled-2D spec / builder / gate.
Returns ``(UnifiedAttention2DTiledSpec, build_unified_attention_2d_tiled,
supports_tiled_2d)``. The import is performed here, inside the dispatch
seam, so the arch-neutral ``instances/common`` package never imports an
arch implementation at module top. This is the single place that routes the
tiled-2D backend on ``arch``:
* ``gfx1250`` (gfx1250) -> the wave32 WMMA ``16x16x32`` variant
(``instances/gfx1250``).
* ``gfx942`` (CDNA3) -> the narrow ``16x16x16`` strided-V variant
(``instances/gfx942``).
* everything else (default ``gfx950`` / CDNA4) -> the wide-K transpose-read
variant (``instances/gfx950``).
Routing gfx942 to its own variant is what keeps a gfx942 request off the
gfx950 builder after ``attention_arch.validate_tiled_attention_arch`` was
relaxed to admit gfx942 -- otherwise the gfx950 builder would emit
gfx950-only ISA on gfx942 and crash comgr.
"""
if arch == "gfx1250":
from ..gfx1250.attention_tiled_2d import (
UnifiedAttention2DTiledSpec,
build_unified_attention_2d_tiled,
supports_tiled_2d,
)
return (
UnifiedAttention2DTiledSpec,
build_unified_attention_2d_tiled,
supports_tiled_2d,
)
if arch == "gfx942":
from ..gfx942.attention_tiled_2d import (
UnifiedAttention2DTiledSpec,
build_unified_attention_2d_tiled,
supports_tiled_2d,
)
return (
UnifiedAttention2DTiledSpec,
build_unified_attention_2d_tiled,
supports_tiled_2d,
)
from ..gfx950.attention_tiled_2d import (
UnifiedAttention2DTiledSpec,
build_unified_attention_2d_tiled,
supports_tiled_2d,
)
return (
UnifiedAttention2DTiledSpec,
build_unified_attention_2d_tiled,
supports_tiled_2d,
)
def _tiled_3d_impl(arch: str):
"""Lazily import the arch-specific tiled-3D split-KV spec / builders / gate.
Returns ``(UnifiedAttention3DTiledSpec, UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled, build_unified_attention_reduce_tiled,
supports_tiled_3d)``. See :func:`_tiled_2d_impl` for why this import is
lazy and arch-keyed.
"""
if arch == "gfx1250":
from ..gfx1250.attention_tiled_3d import (
UnifiedAttention3DTiledSpec,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
supports_tiled_3d,
)
return (
UnifiedAttention3DTiledSpec,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
supports_tiled_3d,
)
if arch == "gfx942":
from ..gfx942.attention_tiled_3d import (
UnifiedAttention3DTiledSpec,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
supports_tiled_3d,
)
return (
UnifiedAttention3DTiledSpec,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
supports_tiled_3d,
)
from ..gfx950.attention_tiled_3d import (
UnifiedAttention3DTiledSpec,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
supports_tiled_3d,
)
return (
UnifiedAttention3DTiledSpec,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
supports_tiled_3d,
)
def supports_native_unified_attention(
problem: UnifiedAttentionProblem,
) -> Tuple[bool, str]:
"""Return whether CK DSL can run this problem without fallback today.
This is deliberately strict. It prevents a partially implemented backend
from being selected in `auto` mode and gives test code a single place to
check coverage.
Scalar 2D backend coverage (this returns True for these):
- head_size in {64, 128, 256} (the scalar kernel loops over head_size with
``b.unroll(p.head_size)``, so any HD that divides cleanly through the
online-softmax accumulator works)
- block_size in {16, 32, 64} (used only as the modulus in PagedKvDescriptor
address arithmetic)
- dtype in {fp16, bf16}
- has_sinks: yes
- sliding_window: yes
- softcap: yes
"""
if problem.head_size not in (64, 128, 256):
return False, f"unsupported head_size {problem.head_size}"
if problem.block_size not in (16, 32, 64):
return False, f"unsupported block_size {problem.block_size}"
if problem.dtype not in ("fp16", "bf16"):
return False, f"unsupported dtype {problem.dtype}"
if problem.use_fp8:
if problem.q_dtype is not None and problem.q_dtype not in ("fp16", "bf16"):
return False, f"scalar 2D kernel: unsupported q_dtype {problem.q_dtype!r}"
elif problem.q_dtype is not None:
return False, "scalar 2D q_dtype override requires use_fp8=True"
if problem.use_alibi:
return False, "ALiBi slopes are not enabled in CK DSL attention yet"
if problem.use_qq_bias:
return False, "QQ bias is not enabled in CK DSL attention yet"
return True, "supported by scalar CK DSL 2D attention backend"
def supports_native_unified_attention_tiled(
problem: UnifiedAttentionProblem,
) -> Tuple[bool, str]:
"""Return whether the optimized tiled MFMA path can run this problem."""
arch = _resolve_attention_arch()
if arch == "gfx1250" and problem.softcap > 0:
return False, "gfx1250 tiled 2D does not support softcap yet"
_, _, supports_tiled_2d = _tiled_2d_impl(arch)
gfx942_bf16_wide = _enable_gfx942_bf16_flash(problem)
if gfx942_bf16_wide:
# bf16 wide-K (32x32x8) transposed flash path mirrors the build spec
# in _tiled_spec_from_problem (T=64, mw=32, transposed-x8 bf16; D64 nw=4
# double-K, D128 nw=2 single-K).
nw, single_k = _gfx942_bf16_wide_geometry(problem)
use_cfvst = _gfx942_bf16_wide_use_cfvst(problem)
return supports_tiled_2d(
head_size=problem.head_size,
block_size=problem.block_size,
dtype=problem.dtype,
num_queries_per_kv=problem.num_queries_per_kv,
use_alibi=problem.use_alibi,
use_qq_bias=problem.use_qq_bias,
use_fp8=problem.use_fp8,
q_dtype=problem.q_dtype,
num_warps=nw,
block_m_per_warp=32,
kv_storage_dtype=_kv_storage_dtype(problem),
tile_size=_gfx942_bf16_wide_tile_size(problem),
arch=arch,
use_mfma_32x32x8=True,
use_transposed_qk_32x32=True,
use_k_single_buffer=single_k,
use_conflict_free_v_store=use_cfvst,
)
gfx942_flash = _enable_gfx942_fp16_flash(problem)
num_warps = (
_select_gfx942_flash_num_warps(problem)
if gfx942_flash
else _select_2d_num_warps(problem)
)
return supports_tiled_2d(
head_size=problem.head_size,
block_size=problem.block_size,
dtype=problem.dtype,
num_queries_per_kv=problem.num_queries_per_kv,
use_alibi=problem.use_alibi,
use_qq_bias=problem.use_qq_bias,
use_fp8=problem.use_fp8,
q_dtype=problem.q_dtype,
num_warps=num_warps,
block_m_per_warp=_select_2d_block_m_per_warp(problem),
kv_storage_dtype=_kv_storage_dtype(problem),
tile_size=_select_2d_tile_size(problem),
arch=arch,
use_mfma_32x32x8=gfx942_flash,
use_transposed_qk_32x32=gfx942_flash,
use_k_single_buffer=gfx942_flash and _gfx942_flash_use_single_buffer(problem),
use_conflict_free_v_store=gfx942_flash and _gfx942_flash_use_cfvst(problem),
use_k_sliced_ring=_enable_gfx942_flash_k_sliced_ring(problem),
)
def supports_native_unified_attention_3d_tiled(
problem: UnifiedAttentionProblem,
) -> Tuple[bool, str]:
"""Return whether the optimized tiled MFMA 3D split-KV path can run this."""
arch = _resolve_attention_arch()
*_, supports_tiled_3d = _tiled_3d_impl(arch)
return supports_tiled_3d(
head_size=problem.head_size,
block_size=problem.block_size,
dtype=problem.dtype,
num_queries_per_kv=problem.num_queries_per_kv,
use_alibi=problem.use_alibi,
use_qq_bias=problem.use_qq_bias,
use_fp8=problem.use_fp8,
q_dtype=problem.q_dtype,
kv_storage_dtype=_kv_storage_dtype(problem),
arch=arch,
)
_ATTN_CACHE: Dict[Tuple, bytes] = {}
_ATTN_TILED_CACHE: Dict[Tuple, bytes] = {}
_ATTN_3D_TILED_CACHE: Dict[Tuple, Tuple[bytes, str, bytes, str]] = {}
def _cache_key(problem: UnifiedAttentionProblem) -> Tuple:
return (
"scalar",
problem.total_q,
problem.num_seqs,
problem.num_query_heads,
problem.num_kv_heads,
problem.head_size,
problem.block_size,
problem.max_seqlen_q,
problem.max_seqlen_k,
problem.dtype,
problem.q_dtype,
problem.sliding_window,
bool(problem.use_sinks),
bool(problem.softcap > 0),
bool(problem.use_fp8),
)
def _enable_d128_small_tile(problem: UnifiedAttentionProblem) -> bool:
"""d128 occupancy lever: select T = block_size (small tile) + nw=2 for
the single-batch d128 combo so the kernel drops from 1 -> 2 WG/CU.
Diagnosis (gfx950 MI355X, llvm22+comgr7.2, measured same-session; re-verified
on the merged tree): the d128 combo at num_warps=2 is purely LDS-bound --
K_lds[2,T,HD] + V_lds[1,T,HD] = 48 KB at T=2*block_size -> only 1 workgroup
fits the 64 KB/CU budget; the register file already admits 2 waves/SIMD
(V+A=229 <= 256). Halving the tile to T=block_size makes LDS=24 KB and
VGPR=173 -> 2 WG/CU (measured via llvm-readelf on the produced HSACO:
OFF VGPR=229/256, LDS=48 KB -> 1 WG/CU; ON VGPR=173, AGPR=0, LDS=24 KB
-> 2 WG/CU, for S=1024/2048/4096 alike). Same-session vs torch SDPA FLASH
(apples-to-apples, OFF vs ON on this exact tree):
bf16: S1024 1.333x->1.437x, S2048 0.968x->1.059x (CROSS 1.0),
S4096 0.916x->0.974x (DSL 0.789->0.741 ms, +6% faster kernel).
fp16: S1024 1.312x->1.436x, S2048 0.973x->1.061x (CROSS 1.0),
S4096 0.918x->0.975x. Correctness rel ~4.4e-3 bf16 / ~5.5e-4 fp16
(IDENTICAL to baseline at every S).
**RECONCILIATION + DEFAULT-ON.** The small-tile win REQUIRES nw=2 for
ALL seqlens (the prior nw=4-at-S>=2048 rule + T=32 is occupancy-WORSE: 56 KB
LDS -> back to 1 WG/CU), so when this gate fires the num_warps selector
forces nw=2. Verified C-twin selector output == Python for the whole cohort
and that NO non-cohort shape changes (3826 trace records across the three
canonical aiter_ua* sets: 0 selector changes). It is therefore enabled by
DEFAULT for the gfx950 single-batch d128 no-FP8 combo. This is a production
ROUTING change (the dispatched d128 prefill kernel changes: T 64->32,
nw S>=2048 4->2); per-spec golden EMIT is unchanged (T=block_size is an
existing tile_size value, byte-identical to the old code path).
ESCAPE HATCH: ``HIPDNN_GFX950_D128_SMALL_TILE=0`` (or off/no/false) force-
DISABLES the lever, restoring the prior routing (T=64, nw=2/4). Any other
value (or unset) -> default-ON for the cohort.
"""
env = (
__import__("os")
.environ.get("HIPDNN_GFX950_D128_SMALL_TILE", "")
.strip()
.lower()
)
if env in ("0", "false", "no", "off"):
return False
return (
problem.head_size == 128
and not problem.use_fp8
and _enable_single_batch_combo(problem)
)
def _enable_k_single_buffer(problem: UnifiedAttentionProblem) -> bool:
"""d128 long-context lever: K single-buffer at T=64 (== 2*block_size).
For the same single-batch d128 cohort that ``_enable_d128_small_tile`` gates,
keep the LARGER T=64 tile (better long-context per-iter amortisation) but
halve K_lds via K single-buffer so LDS stays at 32 KB -> 2 WG/CU. The
next-K prefetch is re-issued after the PV-wait barrier (no WAR race). This
crosses flash at the S4096 holdout (0.976x -> 1.020x bf16+fp16) and beats
the small-tile T=32 pick at every measured shape (gfx950 MI355X, same-session
vs torch SDPA FLASH, GPU-idle; see the long-context analysis). Occupancy proof
(llvm-readelf):
T=64 + K-single -> VGPR=215 AGPR=0 LDS=32 KB -> 2 WG/CU.
Scoped to the EXACT V-single-buffer combo path the gfx950 emitter wires K
single-buffer on (no ring, no grouped_kv2, no FP8, V single-buffer). The
cohort already satisfies these; the spec __post_init__ re-validates loudly.
GEOMETRY GUARD: K-single needs Q to fit the lone K slot, i.e.
``block_m <= tile_size``. The cohort runs num_warps=2 / block_m_per_warp=32
-> block_m=64, with tile_size = 2*block_size, so this only holds for
``block_size >= 32`` (T>=64). At block_size=16 (T=32) Q (64 rows) cannot fit
the single 32-token K slot and the __post_init__ validator rejects it
(use_q_direct_reg would lift this but is not wired on this path). Fall back
to the no-K-single small-tile config there.
"""
return _enable_d128_small_tile(problem) and problem.block_size >= 32
def _d256_gfx950_cohort(problem: "UnifiedAttentionProblem") -> bool:
"""Arch-agnostic cohort for the gfx950 bf16 D256 prefill fast path.
The arch gate is applied by callers (``_d256_gfx950_fast`` uses the resolved
device arch; the ``dispatch.attention`` candidate uses the request arch), so
this stays CPU-pure and is the single source of truth for the cohort shape.
"""
return (
problem.head_size == 256
and problem.dtype == "bf16"
and not problem.use_fp8
and problem.sliding_window == 0
and problem.softcap == 0
and not problem.use_sinks
and not problem.use_alibi
and not problem.use_qq_bias
and problem.max_seqlen_q > 1
)
def _d256_gfx950_fast(problem: "UnifiedAttentionProblem") -> bool:
"""Route the D256 gfx950 bf16 prefill cohort to the 32x32
transposed fast path + FA3-style softmax<->MFMA interleave.
Gates the three launch-critical geometry selectors (num_warps / tile_size /
block_m_per_warp) so the builder spec, ``_tiled_cache_key`` and
``_get_2d_launch_meta`` all agree; the remaining codegen flags (32x32 MFMA,
transposed stack, register_pv off, K single-buffer, interleave mode2/g4) are
applied by the spec override in ``_tiled_spec_from_problem``. Narrow +
arch/feature-guarded: every other shape is byte-identical.
Perf (MI355X, direct-launch sweep, fp32-ref max_abs=1.5625e-02): +interleave
mode2 g4 gives +9.1% @ Sq4096 and +10.1% @ Sq8192 over the 32x32 base.
"""
return _resolve_attention_arch() == "gfx950" and _d256_gfx950_cohort(problem)
def _d256_gfx950_spec_overrides() -> dict:
"""Single source of truth for the D256 gfx950 bf16 prefill knob constellation.
The 32x32 transposed + FA3-style softmax<->MFMA-interleave (mode2/g4) +
slab-padded K_lds codegen pins. Consumed by BOTH the builder override in
``builders.common.attention_spec_builder._tiled_spec_from_problem`` (the
production path) and the ``dispatch.attention`` gfx950 D256 candidate, so the
dispatched spec and the built kernel cannot drift.
"""
return dict(
use_mfma_32x32=True,
use_transposed_qk_32x32=True,
use_q_direct_reg=True,
use_transposed_half_local_pv=True,
use_transposed_scalar_state=True,
use_transposed_mask_once=True,
use_transposed_mask_limit=True,
use_mask_phase_split=True,
use_register_pv=False,
use_k_single_buffer=True,
use_v_double_buffer=False,
use_early_v_schedule=False,
use_sched_barrier=False,
use_softmax_mfma_interleave=True,
softmax_interleave_mode=2,
softmax_interleave_groups=4,
use_fast_paged_kv_desc=False,
use_mfma32_skip_legacy_qreg=False,
# Slab-granularity K_lds pad (16 halves): breaks the row-aliased bank
# conflict on the QK K read for a ~25% Sq8192 latency win.
use_kq_lds_pad=True,
kq_lds_pad_halves=16,
)
def _enable_softmax_mfma_interleave(problem: UnifiedAttentionProblem) -> bool:
"""gfx950 single-batch d128 prefill: interleave the softmax VALU into the
MFMA window via ``iglp_opt(1)`` and widen to num_warps=4.
The shipped selector routes this cohort to num_warps=2 with no MFMA/softmax
interleave, which leaves the Matrix cores starved: rocprofv3 on D128 S8192
(Llama-3.1-405B, bf16) shows MFMA-busy ~5% / occupancy ~12% at 2 waves. The
``use_softmax_mfma_interleave`` lever (``iglp_opt(1)`` at the loop top, which
lets the backend spread the causal-mask + softmax VALU across the MFMA gap)
combined with num_warps=4 lifts MFMA-busy to ~18% and occupancy to ~24%.
Measured (gfx950 MI355X, comgr llvm22, same-session vs the shipped auto path;
``sweep_shapes_gfx950.py`` across the gap-doc cohort, correctness max_abs=0.0
vs the shipped kernel on every shape):
* D128 S8192: 122 -> 443 TF (3.6x); S4096 118 -> 412 (3.5x);
S2048 109 -> 350 (3.2x); S1024 108 -> 278 (2.6x); S512 105 -> 198 (1.9x).
* Holds across Llama-3-8B/70B, Llama-3.1-405B, Qwen2.5-7B, Qwen3-235B,
GPT-3-13B (MHA); bf16 and fp16 alike (no dtype cliff).
Occupancy (probe_occupancy / llvm-readelf on the produced HSACO): the widened
nw=4 + interleave kernel is 250 VGPR / 0 AGPR / 0 spill / 64 KB LDS -> 8
waves/CU, strictly BETTER than the shipped nw=2 kernel (296 VGPR / 40 AGPR,
4 waves/CU) -- so this does not trip the ``_enable_d128_small_tile`` nw=2
occupancy reconciliation (that concern was T=32 on llvm20; here T stays 128).
Scoped to the exact single-batch d128 combo cohort. The residual gap to
FlyDSL (~1090 TF) is structural (an 8-wave/32x32 dual-wave rewrite) and is
tracked separately; this is the hint-only ceiling and ships standalone.
**Why this cohort only (measured, not assumed).** The interleave lever pays
off wherever the loop is schedule-bound with a softmax-VALU/MFMA-idle window
to fill -- i.e. a 32x32-transposed (combo) geometry at nw>=2. The full
eligible space was swept (MI355X); adding ONLY the hint to each cohort's
SHIPPED spec:
cohort (shipped geometry) S1024 S2048 S4096
d128 single-batch combo, nw4 1.10x 1.05x 1.03x <- the gated win
d64 single-batch combo, nw4 0.97x 0.95x 0.93x (regresses)
d128 multi-seq combo, nw2 1.00x 1.00x 0.99x (neutral)
d256 single-seq plain, nw1 1.00x 1.00x 0.99x (see note)
d128 sliding-window plain, nw2 0.83x 0.72x 0.73x (see note)
The hint is inert on d128 multi-seq (already nw2-combo, no idle window to
exploit at that occupancy) and regresses d64 and d128-sliding-window on
their shipped geometry. It matches the runbook (arch/gfx950.md): iglp_opt
is neutral-to-negative without a real MFMA-idle window.
**d256 and d128-sliding-window ALSO benefit from this lever -- covered
elsewhere, not dropped.** On their SHIPPED plain geometry the hint alone is
inert/negative, but with the combo+nw geometry those cohorts want, the lever
helps there too and is being enabled by the cohort-owning PRs: d256 gfx950
via PR #9233 (AICK-1495, 32x32 combo + interleave mode2/g4, 1.1-1.94x vs
AOTriton), sliding-window d128 via AICK-1492 (wide-atom windowed path, in
progress). This gate stays scoped to the single-batch d128 combo so it does
not collide with those; the lever's d256/SWA coverage lives with the kernel
geometry work that unlocks it.
ESCAPE HATCH: ``HIPDNN_GFX950_D128_SOFTMAX_INTERLEAVE=0`` (or off/no/false)
force-DISABLES the lever, restoring the prior nw=2 / no-interleave routing.
"""
env = (
__import__("os")
.environ.get("HIPDNN_GFX950_D128_SOFTMAX_INTERLEAVE", "")
.strip()
.lower()
)
if env in ("0", "false", "no", "off"):
return False
return (
problem.head_size == 128
and not problem.use_fp8
and _enable_single_batch_combo(problem)
)
def _select_2d_tile_size(problem: UnifiedAttentionProblem) -> int:
"""Choose ``tile_size`` (T) for the tiled 2D kernel.
``T`` is the number of KV tokens consumed per outer-loop iter (per
kernel iteration). Larger ``T`` amortises the outer-loop overhead
(block-table lookup, async-DMA issue, softmax/PV scheduling) but
only pays off if there is enough KV per CTA to keep the multi-block
descriptor's wave-uniform path filled.
**Universal ``T = 2 * BS``** post the Q-in-registers + single-buffer-V
refactor: the LDS savings (8 KiB Q + 8 KiB V) make the multi-block
path fit comfortably for every workload class on MI355X. For decode,
the higher per-iter amortization beats the smaller-tile choice by
~24% (measured by an out-of-tree ``probe_prefill_sweep.py``: decode_b1
drops 34 µs → 26 µs).
The kernel's own gate (``supports_tiled_2d``) re-validates the
choice against the per-wave-tokens / LDS-budget constraints.
**FP8 sliding-window long-prefill exception** (round-2 cluster-B
sweep, ``prod_nw_sweep.log``): when the
sliding window prunes the kv-loop to a handful of iters per CTA,
bigger T over-allocates LDS without amortising any per-iter cost.
For ``use_fp8 + sliding_window > 0 + max_seqlen_q > 256``, drop to
``T = block_size`` (single block per iter, 32 tokens) — measured
1.15-1.30× win on every FP8 SW long-prefill shape.
"""
if _d256_gfx950_fast(problem):
return 64
if _resolve_attention_arch() == "gfx1250":
# gfx1250 v1 consumes exactly one 32-token paged-KV block per WMMA
# iteration; wider T needs separate multi-block block-table handling.
return problem.block_size
# Sliding-window long-prefill FP8 exception. The latest broad sweep
# confirmed this should stay FP8-only: for bf16 SW long-prefill the
# correctness-clean winner was T=64, not T=32
# (``trace_bench/sweep_attention2d_configs.json``:
# bf16_sw_n16_q1000_k1050 best T=64/mw16/hipcc at ~257us; T=32
# variants were >=258us and often incorrect under hipcc).
if problem.use_fp8 and problem.sliding_window > 0 and problem.max_seqlen_q > 256:
return problem.block_size
# gfx942 D64. The flash/L4 regime (use_mfma_32x32x8 + sliced-K ring) requires
# T in {64,128} and a multiple of 32, so force T=64 there (mirrors the D128
# flash rule below); otherwise paged block_size in {16,32} would yield T=16/32
# and the spec validator would reject the build (reachable via the public
# backend="auto" dispatcher for a head_size=64 fp16 long-prefill problem with
# a 16/32-token paged KV cache). The narrow path keeps the single-KV-block
# oracle (the generic ``2*block_size`` over-allocates LDS on MI300X with mw=32).
if _resolve_attention_arch() == "gfx942" and problem.head_size == 64:
if _enable_gfx942_fp16_flash(problem):
return 64
return problem.block_size
# gfx942 D128 (ALL dtypes): T=64. fp16 flash/L4 wants it for the overlapped
# sliced-K ring -- an exhaustive ~1200-kernel sweep (exhaustive_sweep.py)
# showed T=64+ring+mask-limit is 21-23% faster than the T=128 ring and beats
# Torch (S2048 221 vs 230us, S4096 680 vs 976us). And bf16 / fp16-with-extras
# D128 *require* T=64: the generic default (T=2*BS=128) makes the
# double-buffered K alone 64 KB, overflowing the LDS budget so the tiled-2D
# gate rejects it and the dispatcher drops to the (1000x slower) scalar
# kernel. T=64 fits the narrow 16x16x16 path (nw<=2 after the LDS step-down).
if _resolve_attention_arch() == "gfx942" and problem.head_size == 128:
return 64
# bf16 transposed-combo sliding-window. Paired with the combo's
# ``num_warps=2`` (BLOCK_M=64) prelude-light geometry, the smaller
# ``T = block_size`` tile prunes the window finer (less compute on
# partially-masked tiles) and dramatically cuts the per-CTA prelude:
# measured SW geomean 0.67x -> ~1.04x vs Triton-2d (bit-exact). This
# supersedes the older T=64 SW choice, which assumed num_warps=4 / no
# combo and a much heavier prelude.
if _enable_combo_2d(problem) and problem.sliding_window > 0:
return problem.block_size
# Single-batch (num_seqs == 1) d128/d64 prefill full-combo cohort.
# Autotuner-proven KV tile per shape (gfx950, no-SW):
# * d128 -> T = 2 * block_size (32). The d128 winners all kept the
# default 2x tile; wider tiles over-allocated LDS without amortising.
# * d64 -> T = 128 (8 * block_size). Paired with num_warps=4 the wider
# tile feeds the d64 KV loop and was the winner for both MHA and
# GQA-8 d64 S2048 (1.19-2.68x over prod).
if _enable_single_batch_combo(problem):
if problem.head_size == 64:
# fast_paged_kv_desc (needs T=64) is a ~1.3-1.4x win for the bf16
# h64kv8 no-SW family -> beats the T=128 early-V path (measured on
# MI355X). Force T=64 there so the combo's fast_paged enablement is
# valid; other d64 single-batch shapes keep the wider T=128 tile.
if (
problem.dtype == "bf16"
and problem.num_query_heads == 64
and problem.num_kv_heads == 8
and problem.sliding_window == 0
):
return 64
return 128
# d128 occupancy lever (supersedes the small-tile pick for the
# LONG-context holdout): at num_warps=2 the d128 combo is LDS-bound
# (K_lds[2,T,HD]+V_lds[1,T,HD] = 48 KB at T=2*BS=64 -> 1 WG/CU). The
# small-tile lever halved the TILE (T=block_size=32) to drop LDS
# 48->24 KB -> 2 WG/CU, but at T=32 the long-context S4096 path runs
# 128 outer iters whose per-iter overhead under-amortises -> only
# 0.976x flash. The K-single-buffer lever instead
# keeps the LARGER T=64 tile (64 iters at S4096) and halves K_lds via
# ``use_k_single_buffer`` (K_lds[1,T,HD]=16 KB + V_lds[1,T,HD]=16 KB =
# 32 KB -> 2 WG/CU, VGPR=215 AGPR=0). This crosses flash at S4096
# (0.976x -> 1.020x bf16+fp16) AND beats the T=32 pick at every shape
# (S1024 1.451->1.476x, S2048 1.065->1.099x; same correctness rel).
# The single K slot re-issues the next-K prefetch AFTER the PV-wait
# barrier (all QK reads drained) so it cannot WAR-race -- avoiding the
# documented gfx942 naive-single-buffer hazard. Default-OFF behind the
# same env opt-in (golden/production routing unchanged) until landed.
if _enable_d128_small_tile(problem):
return 2 * problem.block_size
return 2 * problem.block_size
# Qwen3-30B-A3B prefill specialization (bf16, hd64, BS=16, num_seqs=1,
# num_queries_per_kv=8). At BS=16 the default ``T=2*BS=32`` gives the
# async DMA loader only 32*64 = 2048 bytes per iter, which under-feeds
# the long-prefill kv loop. Measured on MI355X with the bench at
# ``/tmp/bench_prefill_sweep2.py``:
# - q=128: T=64 best (29µs vs 37µs)
# - q=256: T=64 best
# - q=512: T=128 best (37µs vs 56µs)
# - q>=1024: T=64 with mfma_32x32+half_local_pv best (the
# ``_select_2d_block_m_per_warp`` gate picks mw=32 for this case)
if (
problem.head_size == 64
and problem.block_size == 16
and problem.num_seqs <= 1
and not problem.use_fp8
and problem.dtype == "bf16"
and problem.num_queries_per_kv >= 4
):
if problem.max_seqlen_q >= 512 and problem.max_seqlen_q <= 768:
return 128
if problem.max_seqlen_q > 64:
return 64
# T = 4 * block_size = 128 was tested on bf16 long-prefill (the
# ``n=402 q=1000 k=1050`` regression bucket) and got WORSE, not
# better: 333us → 953us. The reason is the async DMA loader uses
# ``kv_calls_per_tile = (T * HD) // KV_HALVES_PER_CALL`` issuing
# one ``raw.ptr.buffer.load.lds`` per call. Doubling T doubles the
# call count which saturates the VMEM issue queue (16→32 calls per
# tile per warp). The pipeline back-pressure cost outweighs the
# halved iter count. T=2*bs remains the sweet spot for non-SW.
return 2 * problem.block_size
def _select_2d_num_warps(problem: UnifiedAttentionProblem) -> int:
"""Choose ``num_warps`` for the tiled 2D kernel.
The kernel supports ``num_warps in {1, 2, 4, 8}`` (each warp owns 16
rows of ``BLOCK_M``; no cross-warp reduction). The kernel also has a
hard ceiling ``T * HD >= num_warps * 64 * 8`` — at ``THREADS *
halves_per_call`` per call we need that many halves available in the
per-tile KV slab, otherwise the async DMA underfills.
Tuning thresholds (calibrated against the trace shapes documented in
an out-of-tree ``trace_bench_report.md`` on MI355X / gfx950; the
``warps``-sweep harness is an out-of-tree ``probe_prefill_sweep.py``
and the prefill-time harness is an out-of-tree
``probe_prefill_time.py``):
**Post Q-in-registers + single-buffer-V refactor** (measured with
``probe_prefill_sweep.py`` on MI355X):
``q <= 64`` (decode + tiny prefill) -> 1 (small grid; tiny
per-CTA work)
``q <= 128`` -> 2
``q in (128, 256]`` -> 4 (medium prefill;
BLOCK_M=64 wins
against q=256's
CTA count)
``q > 256`` -> 2 (NW=2 dominates large
prefill after the
LDS savings — was
NW=4 in the pre-
refactor heuristic)
The result is further clamped against:
- the architectural ceiling above (``T * HD >= num_warps * 512``);
- the per-wave-tokens constraint (``WAVE*8/HD <= BS``);
- an LDS-budget check (``<= 96 KiB`` so we keep >= 1 CTA/CU on
MI355X comfortably).
"""
if _d256_gfx950_fast(problem):
return 2
if _resolve_attention_arch() == "gfx1250":
# A gfx1250 workgroup is one wave32 in the v1 WMMA tiled path.
return 1
# The validated combo family (d64/b32/GQA-8 bf16, with sinks).
# * full attention: num_warps=4 (BLOCK_M=128) amortises the per-CTA
# prelude over the many KV tiles a long no-SW context produces.
# * sliding window: num_warps=2 (BLOCK_M=64). The window prunes the
# KV loop to a handful of tiles, so the shape is prelude-bound;
# halving BLOCK_M halves the per-CTA Q-load prelude and doubles the
# CTA count for better latency hiding. Paired with T=block_size
# (see _select_2d_tile_size) this takes SW from 0.67x to ~1.04x vs
# Triton-2d (bit-exact).
# Small/medium gfx942 prefill light narrow path: nw=1 for MHA, nw=2 for GQA
# (loser_sweep.py graph-timed winners). Must precede the D64/L4 rules below,
# which target the heavy flash/ring geometry.
if _enable_gfx942_small_q_narrow(problem):
return 1 if problem.num_queries_per_kv == 1 else 2
# gfx942 D128 fp16 flash/L4 (shipped): the flash kernel runs at
# ``_select_gfx942_flash_num_warps`` (wide, default 4 -> BLOCK_M = 4 * 32),
# not the legacy num_warps=1 geometry. Every production flash site reads the
# flash selector directly, so this branch is not on the live grid path
# today; defer to that selector so a future caller can't read a count that
# disagrees with the built kernel (num_warps is not part of the JitCache
# key, so a drift would silently launch the wrong CTA count, not rebuild).
if _enable_gfx942_l4(problem):
return _select_gfx942_flash_num_warps(problem)
# gfx942 D64 oracle: paired with ``T=block_size`` and mw=32, four waves fit
# in the MI300X 64 KB LDS budget and match the direct gfx942 harness.
if _resolve_attention_arch() == "gfx942" and problem.head_size == 64:
return 4
if _enable_combo_2d(problem):
# bf16 sliding-window is prelude-bound -> nw2 (lighter prelude). But
# fp8 SW is dequant-bound, not prelude-bound, so it wants nw4 to
# spread the fp8->bf16 dequant across more warps (nw2 concentrates
# it and regresses). No-SW (compute-bound) is nw4 for both.
target = 2 if (problem.sliding_window > 0 and not problem.use_fp8) else 4
HD = problem.head_size
BS = problem.block_size
T = _select_2d_tile_size(problem)
while target > 1:
if (T * HD) < 64 * target * 8:
target //= 2
continue
if (64 * 8) // HD > BS:
target //= 2
continue
break
return max(1, target)
if _enable_single_batch_combo(problem):
# Single-batch (num_seqs == 1) d128/d64 prefill full-combo cohort
# (re-tuned by the joint num_warps x schedule sweep). Per shape
# (gfx950, no-SW), same-session vs flash:
# * d128 (BLOCK_M = 32 * nw): S<=1024 -> nw=2 (nw=1 is occupancy-
# starved on the tiny single-seq grid; nw=2 with V-double-buffer
# OFF = 1.30x flash vs nw=1's 0.89x). S>=2048 -> nw=4 (more
# resident waves hide the long-KV-loop latency; nw=4 wpe=2 is +9%
# over nw=2 and lifts S=4096 from 0.81 to 0.885x).
# * d64: nw=4 paired with T=128 (the wider tile + 4 warps was the
# winner for both MHA and GQA-8 d64 S2048).
#
# RECONCILIATION: the d128 SMALL-TILE occupancy win (T=block_size
# -> 2 WG/CU) REQUIRES num_warps=2 for ALL seqlens. The prior nw=4-at-
# S>=2048 choice combined with T=block_size is occupancy-WORSE
# (nw=4 + T=32 -> 56 KB LDS -> back to 1 WG/CU); the measured 2-WG/CU
# crossing (S=2048 1.06x, S=4096 1.02x flash) only holds at nw=2 + T=32
# (V+A=173, LDS=24 KB). So when the small-tile cohort is active, force
# nw=2 here, overriding the S>=2048 -> nw=4 rule. When the small-tile
# gate is OFF this branch is byte-for-byte the prior behavior.
if problem.head_size == 64:
target = 4
elif _enable_softmax_mfma_interleave(problem):
# d128 softmax-MFMA-interleave cohort: widen to nw=4. The interleaved
# kernel is occupancy-BETTER than the shipped nw=2 (250 vs 296 VGPR,
# 8 vs 4 waves/CU -- see _enable_softmax_mfma_interleave), so the
# small-tile nw=2 reconciliation below does not apply. Overrides the
# _enable_d128_small_tile -> nw=2 rule for this cohort only.
target = 4
elif _enable_d128_small_tile(problem):
target = 2
elif problem.max_seqlen_q <= 1024:
target = 2
else:
target = 4
HD = problem.head_size
BS = problem.block_size
T = _select_2d_tile_size(problem)
while target > 1:
if (T * HD) < 64 * target * 8:
target //= 2
continue
if (64 * 8) // HD > BS:
target //= 2
continue
break
return max(1, target)
# Qwen3-30B-A3B prefill specialization (bf16, hd64, BS=16, num_seqs=1,
# num_queries_per_kv>=4). The default heuristic was tuned for HD=128 /
# multi-batch prefill; for this hd64 single-seq path the per-CTA cost
# is much higher than necessary because (a) q<=128 had NW=2 which
# over-allocated LDS/VGPR for a tiny grid, and (b) q>256 num_seqs<=1
# kept NW=2 which left perf on the table on long single-seq prefill.
# Measured (``/tmp/bench_prefill_sweep2.py``, MI355X bf16 hd64):
# q=128: NW=1 best (28.7µs vs 37µs default)
# q=256: NW=2 best (32µs vs 43µs default)
# q>=512: NW=2 (mw=16) or NW=4 (mw=32) best
if (
problem.head_size == 64
and problem.block_size == 16
and problem.num_seqs <= 1
and not problem.use_fp8
and problem.dtype == "bf16"
and problem.num_queries_per_kv >= 4
):
if problem.max_seqlen_q <= 128:
target = 1
elif problem.max_seqlen_q <= 768:
target = 2
else:
# Long single-seq prefill: NW=4 wins paired with mw=32+mfma_32x32
target = 4
elif problem.max_seqlen_q <= 64:
target = 1
elif problem.max_seqlen_q <= 128:
target = 2
elif problem.max_seqlen_q <= 256:
target = 4
elif problem.num_seqs <= 1:
# Long prefill with num_seqs <= 1: the production-shape sweep
# (an out-of-tree ``prefill_n_sweep.log``) shows
# nw=2 beats nw=4 by 3-5% at n=1 (fewer CTAs hurt under-saturated
# GPU at single-seq). The crossover happens at n=2 where nw=4
# takes over (4-16% faster than nw=2 from n=2 up to bench-cap n=16).
# Single-seq prefill is the only production regime where nw=2 wins.
target = 2
elif (
problem.num_queries_per_kv == 1
and problem.head_size == 64
and not _enable_combo_2d(problem)
):
# MHA (num_queries_per_kv == 1) d64 prefill is a DIFFERENT family
# than the GQA-8 cluster-A sweep that set the generic nw=4 rule
# below (that sweep is BLOCK_Q=8 => 8 q-rows per kv, a GQA shape).
# The perf_attn2d_sweep (B2 H8 d64 fp16, gfx950) measured the plain
# ``fallback`` geometry at both num_warps and nw=2 (BLOCK_M=32) wins
# at the short and long ends, nw=4 (BLOCK_M=64) only in the middle:
# s512 : nw2 72.1 vs nw4 67.7 -> nw2 (+6%, more CTAs fill the
# small grid)
# s1024: nw2 130 vs nw4 148 -> nw4 (mid grid already saturates;
# BLOCK_M=64 halves dispatch overhead)
# s2048: nw2 272 vs nw4 219 -> nw2 (+25%, the long KV loop wants
# the doubled CTA count for latency hiding)
# Encode that U-shape: nw=2 at the short (<=512) and long (>=1536)
# ends, nw=4 in the middle. Verified via direct-launch (no harness
# overhead) on gfx950.
target = (
2 if (problem.max_seqlen_q <= 512 or problem.max_seqlen_q >= 1536) else 4
)
else:
# Long prefill (q > 256) with num_seqs >= 2: nw=4 wins.
# Round-1 cluster-A sweep
# (an out-of-tree ``cluster_a_sweep.md``)
# showed ``nw=4 mw=16 T=2*BS`` beats ``nw=2`` on 14 of 17 long-prefill
# bf16 targets by 1.04-1.87× (no regressions). Resource analysis
# (``resources.json``): nw=4 keeps WGs/CU at 4 (vs default 5), VGPR
# at 121-126 (no spills), LDS at 36 KB (fits 4 WG/CU comfortably);
# BLOCK_M=64 (BLOCK_Q=8) cuts the q-block CTA count in half vs
# BLOCK_M=32 (BLOCK_Q=4), reducing per-CTA dispatch overhead.
target = 4
HD = problem.head_size
BS = problem.block_size
T = _select_2d_tile_size(problem)
WORK_BYTES = 2
# Step down until all constraints are satisfied.
while target > 1:
THREADS = 64 * target
BLOCK_M = 16 * target
# Architectural ceiling: T * HD halves must satisfy at least one
# async-DMA call's worth of lane-contiguous payload.
if (T * HD) < THREADS * 8:
target //= 2
continue
# Per-wave tokens must fit within one block (wave-uniform
# block_table lookup constraint, enforced by supports_tiled_2d).
per_wave_tokens = (64 * 8) // HD
if per_wave_tokens > BS:
target //= 2
continue
lds_bytes = (
BLOCK_M * HD * WORK_BYTES
+ 2 * T * HD * WORK_BYTES
+ 2 * T * HD * WORK_BYTES
+ BLOCK_M * T * WORK_BYTES
+ BLOCK_M * HD * 4
)
if lds_bytes <= 96 * 1024:
break
target //= 2
return max(1, target)
def _kv_storage_dtype(problem: UnifiedAttentionProblem) -> Optional[str]:
"""Return ``"fp8e4m3"`` for the FP8 K/V cache path, else ``None``.
The upstream API uses ``use_fp8=True`` to flip into the FP8 K/V cache
code path (with bf16/fp16 query, bf16/fp16 output, per-tensor
``k_scale``/``v_scale``). The kernel takes ``kv_storage_dtype`` so the
same plumbing can later add bf8e5m2 or other low-precision K/V.
"""
return "fp8e4m3" if problem.use_fp8 else None
def _tiled_cache_key(problem: UnifiedAttentionProblem) -> Tuple:
"""Compute the tiled-2D cache key WITHOUT building the spec dataclass.
On the hot path (every launch) we just need a hashable tuple to
look up the cached launcher. Building a full ``UnifiedAttention2DTiledSpec``
dataclass (17 fields, frozen=True validation) adds ~3µs per launch
which is material on decode kernels (~20µs). The spec is only built
on cache miss (first launch per shape) inside ``_get_2d_launcher``.
Note: this MUST match the spec-derived key — every selector knob
that affects the kernel build is included. If a selector changes
behaviour, update both this function and ``_tiled_spec_from_problem``.
"""
return (
"tiled",
_resolve_attention_arch(),
problem.num_seqs,
problem.num_query_heads,
problem.num_kv_heads,
problem.head_size,
problem.block_size,
problem.dtype,
problem.sliding_window,
bool(problem.use_sinks),
bool(problem.softcap > 0),
bool(problem.use_alibi),
bool(problem.use_qq_bias),
(
_select_gfx942_flash_num_warps(problem)
if _enable_gfx942_fp16_flash(problem)
else _select_2d_num_warps(problem)
),
_kv_storage_dtype(problem),
_select_2d_tile_size(problem),
_select_2d_waves_per_eu(problem),
_select_2d_block_m_per_warp(problem),
# Layer-2 override marker: flips the key exactly when the D256 gfx950
# codegen override applies in `_tiled_spec_from_problem`, so the key stays
# faithful to the built kernel (key === built kernel) without duplicating
# the override's flags here. The override is a hand-pinned exception that
# deliberately lives ABOVE the autotuner-regenerable `_enable_*` helpers
# below; keying on the predicate (not the flags) keeps that layering.
_d256_gfx950_fast(problem),
_enable_mfma_32x32(problem),
_enable_transposed_qk_32x32(problem),
_enable_transposed_half_local_pv(problem),
# Transposed-softmax VALU sub-flags + V-prefetch schedule. These
# vary with max_seqlen_q within the single-batch cohort independently of
# num_warps/wpe/tile, so they MUST be in the key to avoid a launcher
# collision between two seqlens that share the same geometry but differ
# on the V schedule (e.g. S=1500 vs S=2048).
_enable_transposed_subflags(problem),
_enable_v_double_buffer(problem),
_enable_early_v_schedule(problem),
_enable_register_pv(problem),
_enable_i64_kv_addr(problem),
_select_2d_compile_backend(problem),
_enable_gfx942_fp16_flash(problem),
_enable_gfx942_bf16_flash(problem),
(
_gfx942_flash_wide_setting()
if (
_enable_gfx942_fp16_flash(problem) or _enable_gfx942_bf16_flash(problem)
)
else None
),
(
_gfx942_flash_kv_cache_policy(problem)
if (
_enable_gfx942_fp16_flash(problem) or _enable_gfx942_bf16_flash(problem)
)
else None
),
_enable_gfx942_flash_q_direct(problem),
_enable_gfx942_flash_mask_limit(problem),
_enable_gfx942_flash_k_sliced_ring(problem),
_enable_gfx942_flash_k_sliced_ldsseq(problem),
# bf16-wide non-ring geometry knobs: cfvst (HIPDNN_GFX942_BF16_CFVST) and
# small-tile double-K (HIPDNN_GFX942_D128_SMALLTILE_DK) affect num_warps,
# use_k_single_buffer, and tile_size in _tiled_spec_from_problem. When the
# ring is active these are overridden by the ring path and don't need
# separate key entries (ring geometry is already captured above).
(
(
_gfx942_bf16_wide_geometry(problem),
_gfx942_bf16_wide_tile_size(problem),
_gfx942_bf16_wide_use_cfvst(problem),
)
if _enable_gfx942_bf16_flash(problem)
and not _enable_gfx942_flash_k_sliced_ring(problem)
else None
),
)
def _select_2d_waves_per_eu(problem: UnifiedAttentionProblem) -> Optional[int]:
"""Choose ``waves_per_eu`` for the tiled 2D kernel.
Triton's ``select_2d_config`` uses ``waves_per_eu=2`` for every config
(verified against the aiter Triton reference
``aiter/ops/triton/attention/unified_attention.py``).
We match that: it gives the LLVM backend more VGPR headroom per wave
(less risk of spill to scratch / LDS) while still meeting the
occupancy targets (the double-buffered K/V kernel runs at 2-3 WGs/CU
on MI355X depending on shape; pushing for wpe=3 was a marginal
+5% on isolated workloads but no consistent gain over the full
workload spectrum once we A/B'd it against Triton's choice).
If the problem itself pinned ``waves_per_eu`` (via the public
``UnifiedAttentionProblem.waves_per_eu`` field), respect that.
"""
if _resolve_attention_arch() == "gfx1250":
return problem.waves_per_eu
if problem.waves_per_eu is not None:
return problem.waves_per_eu
# Single-batch (num_seqs == 1) d128/d64 prefill full-combo cohort
# (re-tuned by the joint num_warps x schedule sweep): wpe=2 across all
# seqlens. The prior wpe=3-at-S>=4096 rule REGRESSED the single-batch
# cohort (d128 S4096: nw=4 wpe=2 = 0.881 vs nw=4 wpe=3 = 0.856). Checked
# before the combo wpe=4 rule because the single-batch geometry is
# smaller-grid and prefers fewer waves. (The small-tile lever uses nw=2 + T=32
# which is 2 WG/CU at wpe=2; wpe=2 is correct for that cohort too.)
if _enable_single_batch_combo(problem):
return 2
# Combo (incl. fp8 combo) wants wpe=4 -- handled below; check it before
# the generic fp8 wpe=3 rule so fp8 combo prefill gets wpe=4, not 3.
if _enable_combo_2d(problem):
return 4
# FP8 long-prefill specialisation. The sync FP8 dequant path runs
# extra VALU per iter (cvt_pk_f32_fp8 + scale + cvt_to_bf16 per K/V
# tile element) which makes the inner loop more VALU-bound than the
# bf16 path. ``waves_per_eu=3`` reduces the VGPR-per-wave budget so
# the compiler can schedule more concurrent waves on the same SIMD
# to hide the extra VALU. Sweep on ``n=16 q=1024 k=4096 fp8 no-sw``
# (the dominant trace bucket): wpe=2 → 4383us, wpe=3 → 3531us = 1.24×
# win, measured via /tmp/sweep_long_prefill.py. For SHORT prefill /
# decode the VALU pressure is already low so wpe=2 stays better.
if problem.use_fp8 and problem.max_seqlen_q > 256 and problem.num_seqs >= 2:
return 3
# (The transposed-32x32 combo is handled at the top of this function:
# wpe=4 reaches 4 WG/CU on both the nw4/T64 and nw2/T32 geometries and
# is a consistent win over wpe=2/3 on the d64 prefill-2D cohort.)
return 2
def _fp8_qk_loader_fits(problem: UnifiedAttentionProblem) -> bool:
"""True iff the async-fp8 K loader can tile the per-iter K bytes.
``raw.ptr.buffer.load.lds`` accepts dwords ∈ {1, 3, 4} = {4, 12, 16}
bytes per lane (a hardware quirk -- dwords=2 is rejected). We need
one of those per-call payloads to evenly tile ``T*HD`` bytes given
``num_warps * 64`` lanes per CTA.
"""
tile_bytes = _select_2d_tile_size(problem) * problem.head_size
threads = _select_2d_num_warps(problem) * 64
for bpl in (16, 12, 4):
payload = threads * bpl
if tile_bytes >= payload and tile_bytes % payload == 0:
return True
return False
def _enable_fp8_mfma_qk(problem: UnifiedAttentionProblem) -> bool:
"""Heuristic: enable the ULP-correct fp8-K-LDS path when it helps.
The path is bit-identical to the sync-dequant default. The win
pattern from the production trace:
- decode / short prefill: wins 10-55% (loader LDS writes are the
bottleneck; we save them by storing K as fp8 in LDS).
- long prefill (many KV iters * many MFMAs), no-SW *and* SW: loses
~10% (per-MFMA in-register dequant accumulates faster than the
loader LDS writes we replaced). Measured on the AmirFix fp8 SW
prefill cohort (q~8000, k~10000, SW=128): fp8qk=True 0.60x vs
sync-dequant 0.67x -- so prefill stays on the sync path.
Gate on (sliding-window OR ``max_seqlen_k <= 16 * T``) AND a small
``max_seqlen_q`` (decode / short prefill), plus the loader fitness
check.
"""
if not problem.use_fp8:
return False
# The 32x32 combo reads bf16 K from LDS, so it MUST use the sync-dequant
# loader (bf16 K_lds), not this in-LDS-fp8 path. Never combine them.
if _enable_combo_2d(problem):
return False
if not _fp8_qk_loader_fits(problem):
return False
# Large prefill (long per-CTA KV loop) prefers the sync-dequant path
# regardless of sliding window; the K-in-LDS loader only wins when the
# KV loop is short (decode / short prefill).
if problem.max_seqlen_q > 256:
return False
T_eff = _select_2d_tile_size(problem)
return problem.sliding_window > 0 or problem.max_seqlen_k <= 16 * T_eff
def _enable_single_batch_combo(problem: UnifiedAttentionProblem) -> bool:
"""Single-batch (num_seqs == 1) d128/d64 prefill -> full 32x32 combo.
The combinatorial autotuner proved
that for SINGLE-BATCH (num_seqs == 1) long prefill the production
selector was routing d128 and d64 to the legacy 16x16x32 path, which is
~1.5-2.7x SLOWER than the full 32x32 transposed "combo" and the combo is
correctness-equal (winner ``max_abs`` == flash's on every shape; bf16 +
fp16; S in {1024,2048,4096}).
The old ``_enable_transposed_qk_32x32`` / ``_select_2d_block_m_per_warp``
gates only picked mw=32 for ``num_seqs >= 2`` (the docstrings justified
that with a measurement of the PLAIN transposed path at 0.74-0.85x
slower at num_seqs == 1). That measurement was of the bare transposed
path -- the FULL combo (scalar-state + mask-once + mask-limit +
skip-legacy-qreg + half-local-PV, plus a V prefetch schedule) was never
built for num_seqs == 1, and it is 1.5-2.7x FASTER, not slower. So the
num_seqs >= 2 restriction was stale for the full combo.
Cohort (matches the autotuner's proven-winner shapes):
* gfx950 only (the wide-K 32x32 MFMA + transpose reads are gfx950-only;
gfx942 / RDNA stay on their narrow / flash paths).
* num_seqs == 1 (single-batch). Multi-batch keeps its existing combo /
transposed routing.
* dtype in {bf16, fp16} (the transposed softmax keeps m/l/acc in f32;
fp16 winners were as accurate as flash).
* no FP8 K/V (the combo reads bf16 K from LDS; the fp8 cache path uses
the sync-dequant loader and its own routing).
* no softcap / sinks (not wired into the transposed softmax VALU opts;
the spec validator rejects them).
* ALiBi / QQ bias ARE admitted: the transposed softmax body applies
them per-score, so biased single-batch prefill takes the combo path
instead of the fallback. Only softcap/sinks are excluded (above)
because the mask-limit shortcut can't fold them.
* no sliding window (the mask-once / mask-limit opts require no-SW).
* head_size in {64, 128}.
* max_seqlen_q > 256 (long prefill; decode-class shapes route to the 3D
split-KV path via ``select_path``, and the autotuner's win starts at
S=1024 -- short prefill stays on the legacy path where it is a tie).
"""
if _resolve_attention_arch() != "gfx950":
return False
if problem.num_seqs != 1:
return False
if problem.dtype not in ("bf16", "fp16"):
return False
if problem.use_fp8:
return False
if problem.softcap > 0 or problem.use_sinks:
return False
if problem.sliding_window > 0:
return False
if problem.head_size not in (64, 128):
return False
if problem.max_seqlen_q <= 256:
return False
return True
def _enable_v_double_buffer(problem: UnifiedAttentionProblem) -> bool:
"""Enable the V[i+1] double-buffer prefetch on the single-batch combo.
SHORT single-batch combo prefill (max_seqlen_q <= 1024) on **d64** stacks
``use_v_double_buffer`` on top of the combo: the extra in-flight V prefetch
hides the V async DMA latency the short KV loop cannot otherwise amortise.
Bit-identical to the no-flag path (a pure schedule rewrite); the spec
validator allows it on the bf16/fp16 no-FP8 combo.
**d128: V double-buffer is a NET DRAG and is OFF.** The joint num_warps x
schedule sweep found that on d128 removing the prefetch goes 0.89 -> 1.08x
even at nw=1, and the actual win path is nw=2 with NO V prefetch = 1.30x
flash (the earlier ~6-8% "win" measured the prefetch against an already
occupancy-starved nw=1 baseline). Turning vdbuf off for d128 also
auto-disables the lever-3 ``use_sched_barrier`` (it gates on this
predicate), which was only patching the symptom of the unwanted prefetch.
(The small-tile lever keeps d128 vdbuf OFF too: its 2-WG/CU LDS budget = K_lds[2]
24 KB + V_lds[1] 8 KB; a V[i+1] prefetch would re-inflate LDS and drop back
to 1 WG/CU.)
LONG prefill (>=2048) gets NO V double-buffer regardless of head_size: the
extra prefetch REGRESSES long prefill (the long KV loop already hides V
latency). d64 long uses ``use_early_v_schedule`` instead (mutually
exclusive).
"""
if not _enable_single_batch_combo(problem):
return False
if _enable_early_v_schedule(problem):
return False
# d128: vdbuf is a net drag (see docstring) -- OFF; the nw=2 / no-prefetch
# path is the winner. This also auto-disables sched_barrier.
if problem.head_size == 128:
return False
return problem.max_seqlen_q <= 1024
def _enable_sched_barrier(problem: UnifiedAttentionProblem) -> bool:
"""Enable the lever-3 sched_barrier fence (CK-Tile-derived).
A ``__builtin_amdgcn_sched_barrier`` is placed between the QK MFMA cluster
and the post-QK async prefetch issue so the LLVM post-RA scheduler keeps the
QK MFMAs packed instead of interleaving the next-tile ``buffer_load_lds``
into the MFMA window. Bit-identical numerics (pure scheduler hint).
Same-session A/B on MI355X (gfx950, LLVM backend):
* SINGLE-BATCH d128 S<=1024 (num_warps==1 + V-double-buffer): +35.6-36.0%
on bf16 AND fp16 (0.152 -> 0.112 ms) -> DSL/flash ~0.67x -> ~0.92x. The
lone resident wave cannot hide the prefetch-in-MFMA-window cost, so
packing the MFMAs is the dominant lever. ON.
* num_warps>=2 d128 (S>=2048): REGRESSES 2.5-4.6% (more ILP, the fence
over-constrains the scheduler) -> OFF.
Scoped to exactly the V-double-buffer cohort (single-batch combo,
max_seqlen_q <= 1024) where num_warps==1: ``_enable_v_double_buffer`` already
pins that cohort, so reuse it. Restricted to d128 (the measured win); d64
short single-batch combo uses num_warps==4 (BLOCK_M=128, ample ILP), where
the fence is the over-constrained num_warps>=2 regime, so leave it OFF.
"""
if not _enable_v_double_buffer(problem):
return False
return problem.head_size == 128
def _enable_early_v_schedule(problem: UnifiedAttentionProblem) -> bool:
"""Enable the early-V issue schedule on the single-batch combo.
LONG d64 single-batch combo prefill (head_size == 64, max_seqlen_q >= 2048)
issues the V load earlier in the iteration so the long KV loop overlaps the
V DMA with the QK MFMA + softmax window. Bit-identical to the no-flag path.
Scoped to d64-LONG by same-session ablation (MI355X gfx950 LLVM):
* d64 GQA-8 S2048: T128 + early-V 104.3us is the best (vs T128 none
108.1, T64 none 112.2) -> ON for d64 long.
* d128 S2048/S4096: early-V REGRESSES (e.g. fp16 S2048 187us vs none
143us) -> OFF; d128 long gets no V schedule flag.
"""
if not _enable_single_batch_combo(problem):
return False
return problem.head_size == 64 and problem.max_seqlen_q >= 2048
def _enable_mfma_32x32(problem: UnifiedAttentionProblem) -> bool:
"""Enable the in-kernel 32x32x16 migration on shapes where it wins.
The transposed 32x32 path (``use_mfma_32x32=True`` +
``use_transposed_qk_32x32=True``) has been parity-validated against the
default 16x16x32 path across 17 representative shapes (bf16 long /
short prefill, multi-batch, sliding-window, GQA, hd64/128/256,
decode). Measured speedup vs default (MI355X, bf16):
* multi-batch prefill (num_seqs >= 2, max_seqlen_q >= 256):
1.21-1.48x on hd64, 1.28-1.39x on hd128
* single-batch long prefill (num_seqs == 1): 0.74-0.85x (slower)
* decode / very short prefill: ~tie
The win pattern is driven by the softmax: the transposed layout
reduces K-reduce work from 5 stages of intra-32-lane butterfly to a
single cross-half xor, and amortises that win across the multi-CTA
parallelism that multi-batch shapes provide. Single-batch prefill
has fewer CTAs to absorb the (still-present) PV scalar-V-load and
PT cross-half xor overhead.
The non-transposed 32x32 path is currently slower than default on
every shape we tested (its PV uses P_lds with the standard
ds_read_tr16 V reader but pays the higher register pressure), so we
only enable mfma_32x32 in conjunction with the transposed flag.
"""
return _enable_transposed_qk_32x32(problem)
def _enable_transposed_qk_32x32(problem: UnifiedAttentionProblem) -> bool:
"""Heuristic for the transposed-K layout.
The transposed path requires ``block_m_per_warp == 32`` (the M32N32K16
MFMA shape) so the conditions here MUST be a strict subset of the
``_select_2d_block_m_per_warp`` conditions that pick ``mw=32``. The
multi-batch branch requires ``max_seqlen_q > 256 AND num_seqs >= 2 AND
not (sw > 0 AND not use_fp8)``; the single-batch combo branch
(``_enable_single_batch_combo``) requires ``num_seqs == 1 AND
max_seqlen_q > 256`` for d64/d128 no-SW no-FP8. Adding extra gates
beyond those silently breaks the post-init validation in the spec
dataclass.
**Single-batch (num_seqs == 1) update:** the combinatorial
autotuner proved the FULL combo is 1.5-2.7x FASTER than the legacy
16x16x32 path for single-batch d128/d64 long prefill (and
correctness-equal). The old gate refused num_seqs == 1 based on a
measurement of the bare transposed path (0.74-0.85x); that was stale for
the full combo, so ``_enable_single_batch_combo`` now short-circuits to
True here for that cohort.
Beyond the mw=32 prereq we gate on:
* dtype in {bf16, fp16} (the transposed softmax/PV path is dtype-
generic -- it casts the f32 softmax probabilities to the working
dtype at the MFMA boundary and keeps m/l/acc in f32, so fp16 gets
the same fp32-accumulated online softmax bf16 does; the legacy
16x16 path it replaces lost accuracy on long-KV d128 fp16). The
fp8 K/V cache path still uses the default kernel.
* no FP8 K/V (transposed path doesn't dequant K/V from fp8 yet)
* head_size in {64, 128} (hd=256 not benchmarked yet)
* no softcap / sinks (not wired into transposed softmax yet)
* ALiBi / QQ bias ARE admitted: ``_enable_combo_2d`` and
``_enable_single_batch_combo`` short-circuit to True for biased
problems, routing them onto the transposed path (whose softmax body
applies bias per-score). Only softcap/sinks stay excluded below.
The validated ``_enable_combo_2d`` family is a superset that DOES wire
sinks (and sliding window) through the transposed softmax, so it
short-circuits to True here.
The transposed/wide-K stack (``use_mfma_32x32`` + the gfx950 transpose
reads) is gfx950-only: the gfx942 spec validator rejects ``use_mfma_32x32``
and its dependents outright (on gfx942 this heuristic firing always built
an invalid spec). Gate the whole thing to gfx950 so gfx942 (and any other
arch) stays on its narrow 16x16x16 / flash path.
"""
if _resolve_attention_arch() != "gfx950":
return False
if _enable_combo_2d(problem):
return True
# Single-batch d128/d64 prefill full-combo cohort. This is the
# routing fix: num_seqs == 1 now gets the 32x32 transposed combo.
if _enable_single_batch_combo(problem):
return True
if problem.dtype not in ("bf16", "fp16"):
return False
if problem.use_fp8:
return False
# Only softcap/sinks gated here — alibi/qq_bias are handled by the
# transposed softmax body, so do NOT add them to this check.
if problem.softcap > 0 or problem.use_sinks:
return False
if problem.head_size not in (64, 128):
return False
# Must match _select_2d_block_m_per_warp's mw=32 conditions exactly.
multi_batch = problem.max_seqlen_q > 256 and problem.num_seqs >= 2
single_seq_hd64 = (
problem.head_size == 64
and problem.block_size == 16
and problem.num_seqs <= 1
and problem.num_queries_per_kv >= 4
and problem.max_seqlen_q > 768
)
if not (multi_batch or single_seq_hd64):
return False
if problem.sliding_window > 0 and not problem.use_fp8:
return False
return True
def _enable_gfx942_small_q_narrow(problem: UnifiedAttentionProblem) -> bool:
"""Small/medium gfx942 prefill -> light narrow geometry, not the heavy ring.
A graph-timed exhaustive sweep (loser_sweep.py) showed that for short
contexts (``max_seqlen_q <= 768``) a LIGHT narrow-atom geometry --
16x16x16, ``num_warps`` 1 (MHA) / 2 (GQA), ``block_m_per_warp=16``, T=64 --
beats both the sliced-K ring AND Torch on every D64/D128 fp16+bf16 shape
(e.g. D64 S64 0.37x, D128 GQA S512 0.74x). The ring's prelude (256 threads,
3-slot K staging, Q-direct, mask-limit, mw=32) is pure overhead when the KV
loop is only 1-2 tiles; it only amortises for long context, where it stays
the default. Decode (q==1) routes to the 3D path, so it is excluded here.
"""
return (
_resolve_attention_arch() == "gfx942"
and problem.dtype in ("fp16", "bf16")
and not problem.use_fp8
and problem.head_size in (64, 128)
and 1 < problem.max_seqlen_q <= 768
and problem.sliding_window == 0
and not problem.use_sinks
and problem.softcap == 0
and not problem.use_alibi
and not problem.use_qq_bias
)
def _enable_gfx942_fp16_flash(problem: UnifiedAttentionProblem) -> bool:
"""Gate the gfx942 fp16 transposed-x8 attention family.
The baseline L4 geometry is transposed-x8 + K single-buffer (WG=64). The
production provider defaults D128 to wide4 (WG=256). D64 uses the same
gfx942-legal 32x32x8 atom plus cfvst stack; measured on MI300X it improves
S2048 D64 from ~142 TFLOPS to ~199 TFLOPS with identical correctness.
"""
return (
_resolve_attention_arch() == "gfx942"
and problem.head_size in (64, 128)
and problem.dtype == "fp16"
and not problem.use_fp8
and problem.sliding_window == 0
and not problem.use_sinks
and problem.softcap == 0
and not problem.use_alibi
and not problem.use_qq_bias
# For GQA (num_queries_per_kv > 1) the light narrow path wins at short
# context because the ring overhead is not amortised over a 1-2-tile KV
# loop. For MHA (num_queries_per_kv == 1) the narrow path is pathologically
# slow at S512 (3.5 TF); the dense pipe cures it (57-63 TF) so skip the
# narrow carve-out and let the flash path handle MHA at all seqlens.
and not (
_enable_gfx942_small_q_narrow(problem) and problem.num_queries_per_kv > 1
)
)
def _enable_gfx942_bf16_flash(problem: UnifiedAttentionProblem) -> bool:
"""Gate the gfx942 **bf16** wide-K (32x32x8) transposed flash path.
The wide bf16 MFMA atom that is actually legal on CDNA3/gfx942 is the K=8
``mfma_f32_32x32x8_bf16`` (the ``.1k`` intrinsic ->
``v_mfma_f32_32x32x8_bf16``; selectable on ROCm 7.2 gfx942). The K=16
``mfma_f32_32x32x16_bf16`` advertised by the catalog is in fact CDNA4/gfx950-
only -- the gfx942 backend ``Cannot select`` it -- so bf16 wide flash uses the
SAME 32x32x8 path the fp16 flash family uses, just with the bf16 atom.
The transposed 32x32 orientation feeds QK from strided LDS + a register P^T
operand and PV from ordinary strided LDS reads, so it needs NONE of the
gfx950-only transpose reads. Routing bf16 prefill onto this wide atom replaces
the narrow 16x16x16 path (half-rate compute, ~63 KB LDS => 1 WG/CU) with a
wide path (the transposed PV consumes P from registers, dropping the P_lds
round-trip), addressing both throughput and occupancy.
DEFAULT-ON for eligible shapes: small_q_narrow shapes (q<=768) are sent
to the narrow light path because the bf16 D128 ring is not yet validated
in the kernel generator (D128 ring produces wrong results; D64 ring is
correct but insufficient to amortise the ring overhead at short context).
The fp16 flash path (``use_mfma_32x32x8``) is unaffected and takes
precedence for fp16.
"""
return (
_resolve_attention_arch() == "gfx942"
and problem.head_size in (64, 128)
and problem.dtype == "bf16"
and not problem.use_fp8
and problem.sliding_window == 0
and not problem.use_sinks
and problem.softcap == 0
and not problem.use_alibi
and not problem.use_qq_bias
# Prefill only (the wide 32x32 atom processes a 32-row M tile; decode
# q=1 has no rows to fill and routes to the 3D split-KV / narrow path).
and problem.max_seqlen_q > 1
# Unlike fp16 (which has a working ring for MHA short-context), bf16
# D128 ring is not yet validated. Send all small_q_narrow shapes —
# both GQA and MHA — to the narrow light path until D128 bf16 ring
# is fixed in the kernel generator.
and not _enable_gfx942_small_q_narrow(problem)
)
def _gfx942_bf16_wide_use_cfvst(problem: UnifiedAttentionProblem) -> bool:
"""Enable the conflict-free V store (cfvst) on the gfx942 bf16 wide path.
The fp16 transposed-x8 flash family feeds the PV B-operand (V^T) through a
conflict-free LDS layout: V is stored TRANSPOSED ``[HD, T+pad]`` via an
in-register 2x2 ``perm_b32`` transpose + one contiguous ``ds_write``, then
read with a wide bank-spread ``ds_read_b64`` (vs the naive 4x strided
``ds_read_u16`` that collapses 32 lanes onto one bank set). This is
byte-size driven, not fp16-specific (bf16 is also 2 bytes, the perm_b32
transpose works on raw i32 words, and the K=8 32x32x8 atom is gfx942-legal
for bf16), so it ports unchanged to bf16.
MEASURED (MI300X, graph, same-session steady-state, 3% median over 6 runs):
cfvst is a small consistent WIN on D64 (S1024 ~0.106->0.104ms, S2048
~0.309->0.301ms, S4096 ~1.027->0.997ms = ~3% each). On D128 cfvst-only
cannot fit the nw=4/BLOCK_M=128 geometry within the 64 KB LDS cap (it needs
the sliced-K ring to fit, and the ring's per-slice sync overhead is NOT
amortised by the bf16 K=8 atom on this path -- it regressed every config
that compiled), so D128 stays on the naive-V wide geometry. So cfvst is
enabled for D64 prefill only.
OPT-IN: only fires under HIPDNN_GFX942_BF16_WIDE (the caller already gated
that). HIPDNN_GFX942_BF16_CFVST=0 forces the legacy naive-V feed for A/B.
"""
env = __import__("os").environ.get("HIPDNN_GFX942_BF16_CFVST", "").strip().lower()
if env in ("0", "off", "disable", "disabled", "no", "false"):
return False
# D64 prefill only (the measured win); D128 nw=4 cfvst overflows LDS and the
# ring that would make it fit regresses. q==1 routes to the 3D/narrow path.
return problem.head_size == 64 and problem.max_seqlen_q > 1
def _gfx942_bf16_wide_geometry(problem: UnifiedAttentionProblem) -> Tuple[int, bool]:
"""(num_warps, use_k_single_buffer) for the gfx942 bf16 wide-K path.
D64: nw=4 (BLOCK_M=128). With cfvst (the default; see
_gfx942_bf16_wide_use_cfvst) the conflict-free transposed V_lds replaces the
naive strided-V feed; K stays double-buffered (single_buffer=False).
D128: nw=2 (BLOCK_M=64 == T=64) + K single-buffer; the nw=4 double-buffered
form is 80 KB (> 64 KB cap), so D128 trades the second K buffer + half the
BLOCK_M to land at 48 KB. K single-buffer requires BLOCK_M <= tile_size.
cfvst is not enabled on D128 (it needs nw=4, which only fits with the
sliced-K ring, and the ring regressed -- see _gfx942_bf16_wide_use_cfvst).
"""
if problem.head_size == 128:
# gfx942 bf16 D128 small-tile double-K (synthesized from the gfx942
# prefill campaign; ex-T2). At T=32 (BLOCK_M = nw*32 = 64) the K_lds
# DOUBLE buffer is 2*32*128*2 = 16 KB + V 8 KB = 24 KB -> still 2 WG/CU,
# so restore the K double-buffer (single_k=False) instead of the T=64
# K-single-buffer. Same-session MEASURED on MI300X (gfx942): xflash vs
# torch FLASH 0.24->0.57-0.65 (~2.3-2.4x the shipped T=64 single-K
# baseline) on bf16 D128 S1024/S2048/S4096, occupancy unchanged at
# 2 WG/CU (the win is K-double-buffer prefetch overlap, not occupancy --
# matches the CK Tile ground truth that D128 is already 2 WG/CU and the
# gap is inner-loop scheduling). Default-ON for the bf16 D128 prefill
# cohort with a paged cache block_size==32; env escape
# HIPDNN_GFX942_D128_SMALLTILE_DK=0 reverts to the shipped config. T=32
# is an EXISTING tile_size value so the per-spec emit is byte-identical
# (C-twin parity GREEN); only the selector routing changes.
if _enable_gfx942_d128_smalltile_doublek(problem):
return 2, False
return 2, True
return 4, False
def _gfx942_bf16_wide_tile_size(problem: UnifiedAttentionProblem) -> int:
"""tile_size (T) for the gfx942 bf16 wide-K path. Default T=64; the D128
small-tile double-K lever halves D128 to T=block_size(==32) to restore the
K double-buffer prefetch at the same 2 WG/CU. T=32 is an EXISTING tile_size
value -> per-spec EMIT byte-identical; this is a pure ROUTING change."""
if _enable_gfx942_d128_smalltile_doublek(problem):
return problem.block_size
return 64
def _enable_gfx942_d128_smalltile_doublek(
problem: UnifiedAttentionProblem,
) -> bool:
"""gfx942 bf16 D128 small-tile (T=32) double-K geometry.
DEFAULT-ON for the gfx942 bf16 D128 prefill cohort with a paged cache
block_size==32 (T=32 must be a multiple of block_size). Env escape
HIPDNN_GFX942_D128_SMALLTILE_DK in {0,off,...} disables and reverts to the
shipped T=64 K-single-buffer config (byte-identical OFF path). T=32 is an
EXISTING tile_size value so the per-spec emit is byte-identical; only the
selector routing changes. Restricted to the bf16 wide path (D128); fp16
keeps its sliced-K ring (small-tile regressed fp16 prefill)."""
env = (
__import__("os")
.environ.get("HIPDNN_GFX942_D128_SMALLTILE_DK", "")
.strip()
.lower()
)
if env in ("0", "off", "disable", "disabled", "no", "false"):
return False
if _resolve_attention_arch() != "gfx942":
return False
if problem.head_size != 128:
return False
if problem.block_size != 32:
return False
return _enable_gfx942_bf16_flash(problem)
def _enable_gfx942_d128_fp16_flash(problem: UnifiedAttentionProblem) -> bool:
"""D128 subset used by the legacy L4 geometry helpers."""
return _enable_gfx942_fp16_flash(problem) and problem.head_size == 128
def _gfx942_flash_wide_setting() -> int:
env = __import__("os").environ.get("HIPDNN_GFX942_FLASH_WIDE", "").strip().lower()
if env in ("0", "off", "disable", "disabled", "no", "false"):
return 0
if env in ("2", "4"):
return int(env)
return 4
def _select_gfx942_flash_num_warps(problem: UnifiedAttentionProblem) -> int:
"""Single source of truth for the gfx942 flash num_warps, for BOTH dtypes.
Mirrors attention_spec_builder._tiled_spec_from_problem so the launch-meta
grid/block matches the geometry the launcher actually builds:
* ring active (fp16, or bf16 D64/D128 prefill): the wide sliced-K ring
geometry -> _gfx942_flash_wide_setting() (nw=4 by default; 2 or 0 via
HIPDNN_GFX942_FLASH_WIDE). A 0/disabled setting means the L4 (WG=64)
fallback, i.e. num_warps=1.
* bf16 without the ring: the legacy bf16-wide geometry (nw=2, or the
smalltile double-K nw).
D64 and D128 prefill share the wide (num_warps=4) sliced-K ring path (the ring
superseded the prior D64 nw2/single-buffer config: 13-17% faster, beats Torch
at S2048). See _enable_gfx942_flash_k_sliced_ring.
"""
if _enable_gfx942_bf16_flash(problem) and not _enable_gfx942_flash_k_sliced_ring(
problem
):
nw, _ = _gfx942_bf16_wide_geometry(problem)
return nw
wide = _gfx942_flash_wide_setting()
return wide if wide in (2, 4) else 1
def _gfx942_flash_use_cfvst(problem: UnifiedAttentionProblem) -> bool:
# cfvst (conflict-free V store) is required by the ring and used by both
# D64 and D128 prefill under the wide ring geometry.
return _gfx942_flash_wide_setting() in (2, 4)
def _gfx942_flash_use_single_buffer(problem: UnifiedAttentionProblem) -> bool:
# Ring (D64/D128 prefill) stages K in a 3-slot ring, not single/double buffer.
return not _gfx942_flash_use_cfvst(problem)
def _gfx942_flash_kv_cache_policy(problem: UnifiedAttentionProblem) -> str:
return "all"
def _enable_gfx942_flash_q_direct(problem: UnifiedAttentionProblem) -> bool:
return (
_enable_gfx942_fp16_flash(problem) or _enable_gfx942_bf16_flash(problem)
) and problem.head_size == 64
def _enable_gfx942_flash_mask_limit(problem: UnifiedAttentionProblem) -> bool:
if not (_enable_gfx942_fp16_flash(problem) or _enable_gfx942_bf16_flash(problem)):
return False
env = __import__("os").environ.get("HIPDNN_GFX942_FLASH_MLIM", "").strip().lower()
if env in ("0", "off", "disable", "disabled", "no", "false"):
return False
if env in ("1", "on", "enable", "enabled", "yes", "true", "all"):
return True
if env in ("d64", "64"):
return problem.head_size == 64
if env in ("d128", "128"):
return problem.head_size == 128
# Default ON for both D64 and D128. D128 mask-limit regressed on the older
# T=128 / non-ring hot loop, but the exhaustive sweep (exhaustive_sweep.py)
# shows it is a small positive on the now-default T=64 sliced-K ring path and
# is part of the validated best D128 config (T=64+ring+mask-limit), which
# beats Torch at S2048/S4096. D64 remains a measured win.
return True
def _enable_gfx942_flash_k_sliced_ring(problem: UnifiedAttentionProblem) -> bool:
# The sliced-K ring (32-wide K slices -> k_groups = HD/32) is correctness-
# verified only for D64 (k_groups=2): measured T=64+ring+cfvst+mask-limit (nw4)
# is 13-17% faster than the prior D64 best (beats Torch at S2048, ~parity
# elsewhere).
#
# D128 (k_groups=4) is EXCLUDED for BOTH dtypes. The k_groups=4 ring produces
# numerically wrong results at realistic input magnitude -- max_abs ~0.5-1.3
# vs the fp32 oracle (bf16 AND fp16, GQA), even at S512 when the ring is forced
# -- while the non-ring D128 path (nw2/nw4, T=64, no ring) passes at max_abs
# ~0.0156 (bf16) / ~0.002 (fp16) on every S. #9198's "max_abs 0.00049 verified"
# claim was measured against a uniform_(-0.1,0.1) oracle whose near-uniform
# softmax never exercises the online-softmax running-max rescale across the four
# K slices, so the defect was masked; it reproduces immediately with randn
# (unit-variance) inputs (study s34_..._ring_correctness_regression). Until the
# k_groups=4 sliced-K accumulation is fixed in attention_tiled_2d.py, D128 stays
# on the non-ring flash geometry (correct-but-slower).
if _resolve_attention_arch() == "gfx942" and problem.head_size == 128:
return False
if not (
(_enable_gfx942_fp16_flash(problem) or _enable_gfx942_bf16_flash(problem))
and problem.head_size in (64, 128)
):
return False
env = (
__import__("os").environ.get("HIPDNN_GFX942_K_SLICED_RING", "").strip().lower()
)
if env in ("0", "off", "disable", "disabled", "no", "false"):
return False
if env in ("1", "on", "enable", "enabled", "yes", "true"):
return True
# Default ON for prefill. Decode-ish fp16 normally routes to the 3D split-KV
# path; keep the rare 2D-decode case on the legacy geometry until validated.
return problem.max_seqlen_q > 1
def _enable_gfx942_flash_k_sliced_ldsseq(problem: UnifiedAttentionProblem) -> bool:
if not _enable_gfx942_flash_k_sliced_ring(problem):
return False
env = __import__("os").environ.get("HIPDNN_GFX942_K_LDSSEQ", "").strip().lower()
return env in ("1", "on", "enable", "enabled", "yes", "true", "ck")
def _enable_gfx942_l4(problem: UnifiedAttentionProblem) -> bool:
"""Compatibility alias: true for the gfx942 D128 fp16 flash/L4 family."""
return _enable_gfx942_d128_fp16_flash(problem)
def _enable_transposed_half_local_pv(problem: UnifiedAttentionProblem) -> bool:
"""Enable the half-local PV optimization for the transposed 32x32 path.
When the transposed 32x32 kernel is selected, ``use_transposed_half_local_pv``
rewrites the PV phase so each 32-lane half consumes only P rows it owns,
avoiding the cross-half ``lane^32`` P fetches. Measured on Qwen3-30B-A3B
prefill (bf16 hd64, num_seqs=1, q=2048): 125µs → 101µs = 1.24× win on top
of plain mfma_32x32 (``/tmp/bench_prefill_min32.py``).
This is bit-identical to the without-flag path; it is a pure VALU schedule
rewrite. The existing prod heuristic never enabled it because the original
transposed-32x32 calibration was done before this opt landed. Enable it
whenever the transposed-32x32 path itself fires.
"""
return _enable_transposed_qk_32x32(problem)
def _enable_register_pv(problem: UnifiedAttentionProblem) -> bool:
"""Enable register-resident P for the existing 16x16x32 2D path.
P73: enable for ``dtype == "bf16"`` when the gate conditions hold
(no sinks, no sliding window, no softcap, no alibi, no qq_bias,
no kv_storage_dtype). The lane-XOR + bit-transpose register
permutation in ``attention_tiled_2d.py:1020-1078`` is now stable
enough to flip on for bf16; the f16 path stays disabled until the
same parity sweep validates it. Other configurations stay on the
P_LDS publish path.
"""
if problem.dtype != "bf16":
return False
if problem.use_sinks:
return False
if problem.sliding_window > 0:
return False
if _kv_storage_dtype(problem) is not None:
return False
# register-pv v1 does not implement softcap, ALiBi, or QQ-bias paths;
# the spec __post_init__ enforces this.
if problem.softcap > 0 or problem.use_alibi or problem.use_qq_bias:
return False
# use_register_pv requires the 16x16x32 MFMA path; it conflicts with
# use_mfma_32x32. When the 32x32 path is selected we leave it disabled
# (the 32x32 path has its own in-register P pipeline).
if _enable_mfma_32x32(problem):
return False
return True
def _enable_combo_2d(problem: UnifiedAttentionProblem) -> bool:
"""The full transposed-32x32 "combo" stack for the validated 2D family.
This wires the kernel config that the parity + trace benchmarks proved
fastest-and-correct for the AITER prefill-2D trace family (d64 / b32 /
GQA-8 / bf16, with attention sinks) into production. The combo stacks,
on top of ``use_mfma_32x32`` + ``use_transposed_qk_32x32``:
* ``use_transposed_scalar_state`` (one m/l per lane + broadcast alpha)
* ``use_transposed_mask_once`` (mask invariants once / KV iter; no-SW)
* ``use_transposed_half_local_pv`` (avoid cross-half lane^32 P fetches)
* ``use_mfma32_skip_legacy_qreg`` (drop the dead 16x16 Q gather)
* ``use_transposed_mask_limit`` (single min() softmax mask; no-SW)
* ``use_fast_paged_kv_desc`` (fast multi-block KV descriptor)
Crucially this stack supports attention **sinks** in the transposed
softmax path (the kernel folds the sink as the per-lane running-max
init), which the older ``_enable_transposed_qk_32x32`` gate refused.
Gating mirrors the validated cohort. ``max_seqlen_q > 256`` keeps it on
chunked-prefill / long-prefill (decode-class shapes prefer the 3D path
via ``select_path``). The half-local-PV transpose regresses on the
very-high-num-seq sliding-window tail (per-CTA work shrinks below the
transpose's fixed overhead), so we hand those back to the plain path.
gfx950-only: the combo stack is built on the gfx950 wide-K 32x32 MFMA +
transpose reads, which the gfx942 spec validator rejects. Gate to gfx950
so gfx942 (and any other arch) never builds the combo spec.
"""
if _resolve_attention_arch() != "gfx950":
return False
if problem.dtype != "bf16":
return False
# FP8 KV is supported via the *sync-dequant* loader, which writes bf16
# into K_lds/V_lds (k_scale folded in) -- exactly what the 32x32 combo
# reads. ``_enable_fp8_mfma_qk`` is forced off for the combo so the
# in-LDS-fp8 mode (incompatible with the bf16 32x32 reads) never fires.
# This takes the fp8 prefill cohort from ~0.5x to ~0.9x vs Triton-2d.
if problem.head_size != 64 or problem.block_size != 32:
return False
if problem.num_queries_per_kv != 8:
return False
if problem.max_seqlen_q <= 256:
return False
# (Historically the half-local-PV transpose regressed on the very-high
# num-seqs sliding-window tail, so it was handed back to the plain path
# at num_seqs >= 450. With waves_per_eu=3 unlocking the 4th WG/CU the
# combo now wins across the whole SW range -- measured +22% over the
# plain fallback at num_seqs=464 -- so the cutoff is removed.)
return True
def _enable_transposed_subflags(problem: UnifiedAttentionProblem) -> bool:
"""Whether to stack the no-SW transposed-softmax VALU sub-flags.
The sub-flags (``use_transposed_scalar_state`` + ``use_transposed_mask_once``
+ ``use_transposed_mask_limit`` + ``use_mfma32_skip_legacy_qreg``) are the
correctness-equal, bit-identical VALU rewrites that turn the bare
transposed-32x32 path into the FULL combo. They require the transposed
path AND no sliding window (spec ``__post_init__`` enforces both).
Historically these only fired for the narrow ``_enable_combo_2d`` family
(bf16 / block_size==32 / GQA-8). The combinatorial autotuner showed
the SAME stack is the proven winner for:
* single-batch (num_seqs == 1) d128/d64 prefill (``_enable_single_batch_combo``);
* the multi-batch (num_seqs >= 2) transposed d128/d64 prefill path -- the
prod selector enabled mw=32 + transposed + half-local-PV there but
LEFT the sub-flags on the table (the autotuner's ~1.19x multi-batch
miss); they are correctness-equal and strictly faster.
So enable them whenever the transposed-32x32 path is on and there is no
sliding window (the SW combo keeps its own nw2/T32 mask handling).
"""
if problem.sliding_window > 0:
return False
# use_transposed_mask_limit (and the other VALU sub-flags) do not support
# softcap, ALiBi or QQ bias; the spec __post_init__ enforces this.
if problem.softcap > 0 or problem.use_alibi or problem.use_qq_bias:
return False
return _enable_transposed_qk_32x32(problem)
def _enable_i64_kv_addr(problem: UnifiedAttentionProblem) -> bool:
"""Enable 64-bit paged-KV addressing when the cache may exceed ~2 GiB.
The default load path puts the full byte offset (incl.
``physical_block * block_stride``) in a 32-bit buffer voffset, which
overflows -- and silently corrupts -- once the paged cache exceeds
2 GiB. When the cache is that large we fold the per-block offset into a
64-bit buffer base (a tiny, wave-uniform ``make_buffer_rsrc`` per
block; measured within ~1-2% of the i32 path). Below the cap we keep
the fast i32 path. ``num_kv_blocks == 0`` means the cache size is
unknown (caller did not supply it) -> assume small / fast path.
"""
if problem.num_kv_blocks <= 0:
return False
elem_bytes = 1 if problem.use_fp8 else 2
block_stride = (
problem.block_size * problem.num_kv_heads * problem.head_size * elem_bytes
)
cache_bytes = problem.num_kv_blocks * block_stride
# The within-block voffset is always < block_stride, so the i32 offset
# overflows exactly when cache_bytes > 2^31 (the last block's base alone
# reaches 2^31). Keep the fast i32 path at/below that; switch to i64
# strictly above. (Verified: cap=65536 bf16 = 2^31 bytes, max offset
# 2147483646 < 2^31 -> still correct on i32.)
return cache_bytes > 0x8000_0000
# --- LDS-budget resolver ----------------------------------------------------
# The 2D-tiled register-PV path stages the K/V tiles plus a small epilogue Acc_lds
# buffer in LDS (Q, P^T and the running O accumulator are register-resident, so
# Acc_lds is the only auxiliary term). At head_dim=256 the default double-buffered
# K tile makes that footprint 204800 B, over gfx950's 163840 B cap, so the kernel
# fails to compile ("local memory (204800) exceeds limit (163840)"). Whether a
# geometry fits is deterministic (closed-form footprint vs the arch cap), so
# rather than hard-fail we deterministically shrink the geometry until it fits,
# using only reductions empirically confirmed to compile: (1) single-buffer K,
# (2) T=64. The resolver is a strict NO-OP whenever the footprint already fits or
# the path is not register-PV, so every currently-compiling config is byte-identical.
# Compute-dtype (bf16/fp16) width in bytes. The fp8 lever changes only the K/V
# *cache* storage width, never the output accumulator, so the epilogue Acc_lds
# staging buffer is always 16-bit.
_ACC_LDS_ELEM_BYTES = 2
def _kv_lds_elem_bytes(spec) -> int:
# Byte width of one K/V element as stored in LDS: 1 for the fp8 cache, else 2 (bf16/fp16).
if getattr(spec, "kv_storage_dtype", None) in (
"fp8e4m3",
"bf8e5m2",
"fp8",
"bf8",
"e4m3",
"e5m2",
):
return 1
return 2 # bf16 / fp16 K/V in LDS
def _out_stripe_cols(head_size: int) -> int:
# Epilogue Acc_lds column count: the narrow 32-col stripe for d<=64, else HD.
return 32 if head_size <= 64 else head_size
def _tiled_2d_lds_bytes(
*,
tile_size: int,
head_size: int,
block_m: int,
kv_elem_bytes: int,
k_slots: int,
v_slots: int = 1,
include_q_lds: bool = False,
include_p_lds: bool = False,
v_pad: int = 0,
) -> int:
"""Static LDS footprint (bytes) of a tiled-2D attention geometry -- the single
source of truth for the per-buffer tile arithmetic shared by the gfx942
admission gate (conservative: ``Q_lds``/``P_lds`` staged) and the gfx950
register-PV budget resolver (exact: Q/P^T register-resident). Callers pick
which buffers are LDS-resident; the tile math (K/V slots, the ``block_m<=2T``
Q alias, the ``OUT_STRIPE`` rule, the 16-bit Acc staging width) lives here so
the models cannot silently drift."""
k_bytes = k_slots * tile_size * head_size * kv_elem_bytes
v_bytes = v_slots * (tile_size + v_pad) * head_size * kv_elem_bytes
p_bytes = block_m * (tile_size + 8) * kv_elem_bytes if include_p_lds else 0
# Q aliases the K slab when it fits under the double-buffer window.
q_bytes = (
block_m * head_size * kv_elem_bytes
if include_q_lds and block_m > 2 * tile_size
else 0
)
acc_bytes = block_m * _out_stripe_cols(head_size) * _ACC_LDS_ELEM_BYTES
return k_bytes + v_bytes + p_bytes + q_bytes + acc_bytes
def _acc_lds_bytes(spec) -> int:
"""Epilogue Acc_lds staging buffer -- ``smem_alloc([BLOCK_M, OUT_STRIPE_COLS])``
of the compute dtype (see ``gfx950/attention_tiled_2d.py`` ~L1473/1248). In the
register-PV path this is the *only* LDS beyond the K/V tiles (Q, P^T and the
running softmax stats are register-resident), so it is the full auxiliary term.
Scales with ``num_warps``, ``block_m_per_warp`` and ``head_size``."""
block_m = spec.num_warps * spec.block_m_per_warp
return block_m * _out_stripe_cols(spec.head_size) * _ACC_LDS_ELEM_BYTES
def _lds_bytes_regpv(spec) -> int:
"""Exact LDS footprint of the register-PV 2D path: the K/V tiles plus the
epilogue Acc_lds (Q/P^T/O register-resident). Verified against comgr for the
D256 T=128 K-double geometry: ``(2+1)*128*256*2 (K/V) + 16*256*2 (Acc) =
196608 + 8192 = 204800``."""
return _tiled_2d_lds_bytes(
tile_size=spec.tile_size,
head_size=spec.head_size,
block_m=spec.num_warps * spec.block_m_per_warp,
kv_elem_bytes=_kv_lds_elem_bytes(spec),
# getattr: the gfx942 spec class declares fewer schedule fields than gfx950,
# so default the absent ones (K double-buffered, V single-buffered).
k_slots=1 if getattr(spec, "use_k_single_buffer", False) else 2,
v_slots=2 if getattr(spec, "use_v_double_buffer", False) else 1,
include_q_lds=False, # register-PV keeps Q in registers
include_p_lds=False, # register-PV keeps P^T in registers
)
def _lds_capacity_bytes() -> int:
# Lazy import: keep ``common/`` arch-neutral (see module top).
from rocke.core.arch.target import ArchTarget
return ArchTarget.from_gfx(_resolve_attention_arch()).lds_capacity_bytes
def _ldsfix_single_k(spec):
"""Candidate: drop K's second (prefetch) buffer. Returns ``(spec, None)`` on
success, or ``(None, reason)`` when the lever cannot apply -- the reason is
surfaced in the resolver's diagnostic rather than silently swallowed."""
if spec.use_k_single_buffer:
return None, "K already single-buffered"
# K-single needs Q to fit the lone K slot: block_m <= tile_size.
if spec.num_warps * spec.block_m_per_warp > spec.tile_size:
return None, "block_m > tile_size (Q would not fit the lone K slot)"
try:
return replace(spec, use_k_single_buffer=True), None
except ValueError as e: # __post_init__ rejected the new combo
return None, f"rejected by spec validation: {e}"
def _ldsfix_tile64(spec):
"""Candidate: shrink the KV tile to T=64. Returns ``(spec, None)`` or
``(None, reason)`` (see :func:`_ldsfix_single_k`)."""
if spec.tile_size <= 64:
return None, "tile_size already <= 64"
try:
return replace(spec, tile_size=64), None
except ValueError as e: # __post_init__ rejected the new combo
return None, f"rejected by spec validation: {e}"
def _resolve_lds_budget(spec):
"""Deterministically shrink an over-budget register-PV 2D spec until it fits
the arch LDS cap, using only compile-validated reductions. Returns the same
spec unchanged when it already fits or the path is not register-PV (so all
currently-compiling configs stay byte-identical)."""
# Arch-agnostic: the resolver keys off the register-PV path (whose footprint
# model it owns) and the target arch's LDS cap read *dynamically* from the
# arch-target API -- no hard-coded arch name or capacity. It engages for any
# register-PV 2D spec over that arch's cap. Validated on gfx950 (CDNA4); a
# strict no-op on other arches because their over-budget 2D specs are already
# filtered upstream by the (more conservative, Q/P-staged) ``supports_tiled_2d``
# gate, so any spec that reaches the resolver there already fits -> every
# currently-compiling config stays byte-identical.
if not getattr(spec, "use_register_pv", False):
return spec
try:
cap = _lds_capacity_bytes()
except Exception:
return spec # arch has no declared LDS cap -> nothing to resolve against
if _lds_bytes_regpv(spec) <= cap:
return spec
tried = [] # per-lever diagnostics, surfaced if nothing fits
def _consider(label, cand, why):
if cand is None:
tried.append(f"{label} n/a ({why})")
return None
used = _lds_bytes_regpv(cand)
if used <= cap:
return cand
tried.append(f"{label} still {used} B > {cap} B")
return None
# Cheapest-first ladder: single-buffer K, then T=64, then both.
single_k, why = _ldsfix_single_k(spec)
hit = _consider("single-K", single_k, why)
if hit is not None:
return hit
t64, why = _ldsfix_tile64(spec)
hit = _consider("T=64", t64, why)
if hit is not None:
return hit
if single_k is not None:
both, why = _ldsfix_tile64(single_k)
hit = _consider("single-K+T=64", both, why)
if hit is not None:
return hit
raise RuntimeError(
f"LDS budget: 2D register-PV D{spec.head_size} block_size={spec.block_size} "
f"T={spec.tile_size} needs {_lds_bytes_regpv(spec)} B > cap {cap} B on "
f"{_resolve_attention_arch()}; no validated reduction fits "
f"[{'; '.join(tried)}]."
)
def _tiled_spec_from_problem(
problem: UnifiedAttentionProblem,
*,
overrides=None,
):
from builders.common.attention_spec_builder import (
_tiled_spec_from_problem as _impl,
)
_spec = _impl(problem)
if overrides is not None:
_spec = replace(_spec, **overrides)
return _resolve_lds_budget(_spec)
def _select_2d_block_m_per_warp(problem: UnifiedAttentionProblem) -> int:
"""Choose ``block_m_per_warp`` for the tiled 2D kernel.
``block_m_per_warp=32`` stacks two MFMA-M=16 atoms per warp so each
warp processes 32 query rows instead of 16. This doubles per-CTA
work and halves the CTA count.
Empirical sweep on the bench-harness slowest shapes (see
``/tmp/sweep_long_prefill.py`` results):
- **fp8 long-prefill**, ``n=16 q=1024 k=4096 no-sw``: ``mw=32 T=64``
beats ``mw=16 T=64`` by 6% (4669us → 4383us). The sync FP8
dequant cost per CTA stays roughly constant when M doubles
because the dequant cost is per-K-byte not per-M-row, so
halving the CTA count cleanly halves the total dequant cost.
Production sweep on n=4: 1.08-1.32× wins.
- **bf16 long-prefill no-sw**, ``n=16 q=1000 k=1050``: ``mw=32 T=64``
beats ``mw=16 T=64`` by 6% (712us → 668us). The per-CTA
prelude amortisation (binary search for seq_idx, Q gather,
Acc zero, sinks load) cleanly halves with the CTA count, and
the per-iter MFMA + LDS-staging overhead doubles but stays
below the per-CTA prelude savings.
- **bf16 long-prefill SW**, ``n=16 q=1000 k=1050 sw=128``:
the broad sweep revised the earlier local result: correctness-
clean best is ``mw=16 T=64 hipcc`` at ~257us. ``mw=32`` and
T=32 do not beat it consistently, and several hipcc T=32
combinations are numerically wrong. Keep bf16 SW on mw=16.
Cost: VGPR pressure rises (each warp tracks 32 rows × QK_N_TILES +
32 rows × PV_N_TILES of f32 accumulators), pushing occupancy from
4 → 2 CTAs/CU. For long-prefill the per-CTA throughput gain wins
the trade. For SHORT prefill / decode shapes the per-CTA prelude
is already negligible (short kv-loop hides it inside the very few
iters), and the 4→2 CTAs/CU occupancy loss costs more than the
per-CTA throughput gain — so keep mw=16 there.
Gate: ``max_seqlen_q > 256 and num_seqs >= 2``. Below this, mw=16
is consistently within noise of mw=32 in the per-shape sweep.
"""
if _d256_gfx950_fast(problem):
return 32
if _resolve_attention_arch() == "gfx1250":
return 16
# mw=32 (BLOCK_M = 32 * num_warps) only pays off when a path actually
# exploits the doubled M rows:
# * the transposed-32x32 / combo path (32x32 MFMA atoms), or
# * the fp8 path (amortises the per-K-byte dequant over 2x the rows).
# Picking mw=32 with plain 16x16 atoms (no transpose, no fp8) was a
# latent trap: it doubled BLOCK_M without the atom benefit and ran
# ~1.4x slower than mw=16 on the sinks trace family. ``select_path``
# has already routed decode-class shapes to 3D before we get here.
# gfx942 (CDNA3 / MI300X) head_size=64: the oracle is always mw=32 with a
# 1x tile (tile_size == block_size) on the plain 16x16 atom path. gfx942 is
# LDS-bound at one CTA/CU on the 64 KB part, so the mw=32 + 2x-tile combo is
# rejected while the 1x-tile mw=32 combo fits, yielding 1.7-2.0x over mw=16.
# Arch-gated so the gfx950 selection below is byte-identical (gfx950 never
# enters this branch). The C++ SdpaCandidateSelector is the shipping +
# measured path on gfx942; this mirrors it for DSL-side consistency.
# Small/medium gfx942 prefill light narrow path uses one M=16 atom/warp
# (BLOCK_M=16*nw); mw=32 is pure overhead for the 1-2-tile KV loop. Precedes
# the D64/L4 mw=32 rules.
if _enable_gfx942_small_q_narrow(problem) and not _enable_gfx942_fp16_flash(
problem
):
return 16
if _resolve_attention_arch() == "gfx942" and problem.head_size == 64:
return 32
# gfx942 D128 fp16 L4 (shipped): one M=32 atom per warp (BLOCK_M=32).
if _enable_gfx942_l4(problem):
return 32
if _enable_transposed_qk_32x32(problem): # includes _enable_combo_2d
return 32
if problem.use_fp8 and problem.max_seqlen_q > 256 and problem.num_seqs >= 2:
return 32
# Qwen3-30B-A3B prefill specialization (bf16, hd64, BS=16, num_seqs=1,
# num_queries_per_kv>=4). Long single-seq prefill is the dominant
# bucket for chunked-prefill production traces with one decode-class
# request at a time. Measured at ``/tmp/bench_prefill_sweep2.py``:
# mw=32 with NW=4 + transposed-32x32 + half_local_pv beats the default
# mw=16 by 1.5-1.8× at q in {1024, 2048}. Below q=1024 the per-CTA
# prelude is small enough that mw=16 wins.
if (
problem.head_size == 64
and problem.block_size == 16
and problem.num_seqs <= 1
and not problem.use_fp8
and problem.dtype == "bf16"
and problem.num_queries_per_kv >= 4
and problem.max_seqlen_q > 768
and problem.sliding_window == 0
and problem.softcap == 0
and not problem.use_sinks
and not problem.use_alibi
and not problem.use_qq_bias
):
return 32
return 16
def _num_segments(problem: UnifiedAttentionProblem) -> int:
"""Mirror AITER ``select_3d_config`` num_segments derivation exactly."""
attn_cfg, _ = problem.select_3d()
segments = attn_cfg.NUM_SEGMENTS_PER_SEQ
# AITER's Triton path scales decode splits with the full MI300X CU count
# (typically 128 segments here). The gfx942 rocke segment+reduce kernels
# are not the same implementation; after host-overhead cleanup, 128-way
# split regresses these q=1/kv=2048 shapes while 64 segments is stable.
if (
_resolve_attention_arch() == "gfx942"
and problem.max_seqlen_q == 1
and problem.max_seqlen_k <= 2048
and problem.sliding_window == 0
):
if problem.head_size == 64:
return min(segments, 32)
if problem.head_size == 128:
return min(segments, 16)
# D256: sweep (decode_shapes_perf gfx942) shows seg128 wins or ties at
# every kv_len — do not cap below the formula value.
if problem.head_size != 256:
return min(segments, 64)
return segments
def _gfx942_3d_tile_size_override(problem: UnifiedAttentionProblem) -> Optional[int]:
arch = _resolve_attention_arch()
if not (arch == "gfx942" and problem.head_size >= 128 and problem.block_size >= 32):
return None
return problem.block_size // 2
def _select_3d_waves_per_eu(problem: UnifiedAttentionProblem) -> Optional[int]:
if problem.waves_per_eu is not None:
return problem.waves_per_eu
if _resolve_attention_arch() == "gfx1250":
return 2
return None
def _enable_gfx942_3d_invariant_hoist(problem: UnifiedAttentionProblem) -> bool:
if _resolve_attention_arch() != "gfx942":
return False
env = __import__("os").environ.get("HIPDNN_GFX942_3D_HOIST", "").strip().lower()
return env in ("1", "on", "enable", "enabled", "yes", "true")
def _enable_gfx942_3d_wide_kv_load(problem: UnifiedAttentionProblem) -> bool:
"""Wide 128-bit direct global->register->LDS KV feed for the 3D decode
segment kernel (replaces the gfx942 1-DWORD async DMA). fp16/bf16 only.
Opt-in via HIPDNN_GFX942_3D_WKV while it is A/B'd against the async path;
promoted to default once the win is confirmed.
"""
if _resolve_attention_arch() != "gfx942":
return False
if _kv_storage_dtype(problem) is not None: # fp8 path already loads wide
return False
env = __import__("os").environ.get("HIPDNN_GFX942_3D_WKV", "").strip().lower()
if env in ("0", "off", "disable", "disabled", "no", "false"):
return False
# Default ON: validated bit-identical and ~8% faster on D128 decode, neutral
# on D64, never slower (decode_ab.py A/B). Strict improvement over the 1-DWORD
# async DMA. (The remaining decode gap vs Torch is structural -- 64-thread /
# narrow-MFMA / LDS round-trip -- and needs the segment-kernel restructure.)
return True
def _d256_decode_cohort(problem: UnifiedAttentionProblem) -> bool:
"""Predicate: true when this problem belongs to the D256 bf16 decode cohort.
Intended for bf16, head_size=256, decode-only (all_decode=True) shapes; the
architecture gate (gfx942/gfx950) is enforced by the dispatcher/caller.
Excludes sliding window, softcap, sinks, ALiBi, and QQ-bias.
"""
return (
problem.head_size == 256
and problem.dtype == "bf16"
and problem.all_decode
and not problem.use_fp8
and problem.sliding_window == 0
and problem.softcap == 0.0
and not problem.use_sinks
and not problem.use_alibi
and not problem.use_qq_bias
)
def _env_enabled_true(var: str) -> bool:
env = __import__("os").environ.get(var, "").strip().lower()
return env in ("1", "on", "enable", "enabled", "yes", "true")
def _env_disabled(var: str) -> bool:
env = __import__("os").environ.get(var, "").strip().lower()
return env in ("0", "off", "disable", "disabled", "no", "false")
def _gfx1250_3d_num_waves() -> int:
"""gfx1250 3D decode cooperative multi-wave32 CTA width override
(``HIPDNN_GFX1250_3D_WAVES`` in {1,2,4,8}).
Returns ``-1`` (the default when unset / invalid) to mean *auto* — the
tuning policy (:func:`_resolve_gfx1250_tiled3d`) picks the wave count from
the problem shape. An explicit value in {1,2,4,8} pins it (overrides auto)."""
env = __import__("os").environ.get("HIPDNN_GFX1250_3D_WAVES", "").strip()
if env == "":
return -1
try:
w = int(env)
except ValueError:
return -1
return w if w in (1, 2, 4, 8) else -1
# ---------------------------------------------------------------------------
# gfx1250 3D decode tuning policy
# ---------------------------------------------------------------------------
# Single source of truth for *which config* the gfx1250 split-KV decode kernel
# runs for a given problem. Both the spec builder (kernel emission) and the
# cache key flow from one ``_resolve_gfx1250_tiled3d(problem)`` call, so the
# built kernel and its cache identity can never drift. This is the production
# routing path (not examples): the runtime calls it to auto-select per live
# shape. Empirical "best config per shape" knowledge from the examples sweeps is
# *distilled here* as policy, rather than living as logic in the harnesses.
# Hardened selection (occupancy gating, lever pairing) lands in this one place.
@dataclass(frozen=True)
class _ResolvedTiled3D:
"""Resolved gfx1250 3D decode knobs for one problem (see policy note above)."""
num_segments: int
num_waves: int
waves_per_eu: Optional[int]
kv_storage_dtype: Optional[str]
tile_size_override: Optional[int]
use_invariant_hoist: bool
use_wide_kv_load: bool
use_register_p: bool
use_wide_lds_reads: bool
use_dtla_prefetch: bool
use_ds_tr_reads: bool
use_fused_reduce: bool
use_dpp_softmax: bool
def _resolve_gfx1250_tiled3d(problem: UnifiedAttentionProblem) -> _ResolvedTiled3D:
"""Resolve the gfx1250 3D decode config for ``problem`` (the tuning policy).
Env knobs (``HIPDNN_GFX1250_3D_*``) override the policy for A/B work; with no
env set this returns the production defaults. Behaviour-preserving extract of
the former inline ``_tiled_3d_spec_from_problem`` gfx1250 branch.
"""
dtla = _env_enabled_true("HIPDNN_GFX1250_3D_DTLA")
dstr = _env_enabled_true("HIPDNN_GFX1250_3D_DSTR")
wkv = _env_enabled_true("HIPDNN_GFX1250_3D_WKV")
regp = _env_enabled_true("HIPDNN_GFX1250_3D_REGP")
num_waves = _gfx1250_3d_num_waves()
if num_waves == -1: # auto: policy picks the wave count from the shape
num_waves = 1
# lever 1, default-on (transposed V + wide ds_load); auto-off when an incompatible
# lever (multi-wave / double-buffer / register-P) is on.
use_wide_lds_reads = (
num_waves == 1
and not wkv
and not regp
and not dtla
and not dstr
and not _env_disabled("HIPDNN_GFX1250_3D_WLDS")
)
return _ResolvedTiled3D(
num_segments=_num_segments(problem),
num_waves=num_waves,
waves_per_eu=_select_3d_waves_per_eu(problem),
kv_storage_dtype=_kv_storage_dtype(problem),
tile_size_override=_gfx942_3d_tile_size_override(problem), # None on gfx1250
use_invariant_hoist=_env_enabled_true("HIPDNN_GFX1250_3D_HOIST"),
use_wide_kv_load=wkv,
use_register_p=regp,
use_wide_lds_reads=use_wide_lds_reads,
use_dtla_prefetch=dtla,
use_ds_tr_reads=dstr,
use_fused_reduce=_env_enabled_true("HIPDNN_GFX1250_3D_FRED"),
# DPP row_xmask softmax reduction (VALU, not LDS port); default on,
# disable via HIPDNN_GFX1250_3D_DPP=0.
use_dpp_softmax=not _env_disabled("HIPDNN_GFX1250_3D_DPP"),
)
def _tiled_3d_spec_from_problem(
problem: UnifiedAttentionProblem,
):
from builders.common.attention_spec_builder import (
_tiled_3d_spec_from_problem as _impl,
)
return _impl(problem)
def _tiled_3d_cache_key(problem: UnifiedAttentionProblem) -> Tuple:
base = (
"tiled3d",
problem.num_seqs,
problem.num_query_heads,
problem.num_kv_heads,
problem.head_size,
problem.block_size,
problem.dtype,
problem.sliding_window,
bool(problem.use_sinks),
bool(problem.softcap > 0),
bool(problem.use_alibi),
bool(problem.use_qq_bias),
_num_segments(problem),
_gfx942_3d_tile_size_override(problem),
_select_3d_waves_per_eu(problem),
_enable_gfx942_3d_invariant_hoist(problem),
_enable_gfx942_3d_wide_kv_load(problem),
_kv_storage_dtype(problem),
_enable_i64_kv_addr(problem),
)
if _resolve_attention_arch() == "gfx1250":
sp = _tiled_3d_spec_from_problem(problem)
return base + (
sp.use_invariant_hoist,
sp.use_wide_kv_load,
sp.use_register_p,
getattr(sp, "wmma_spacing", 0),
getattr(sp, "num_waves", 1),
getattr(sp, "use_wide_lds_reads", False),
getattr(sp, "use_dtla_prefetch", False),
getattr(sp, "use_ds_tr_reads", False),
getattr(sp, "use_fused_reduce", False),
getattr(sp, "use_dpp_softmax", False),
)
return base
def _3d_signature(dtype: str, *, kv_dtype: Optional[str] = None):
from rocke.helpers.spec import SignatureBuilder
io_dtype = "f16" if dtype == "fp16" else "bf16"
kv_io = kv_dtype if kv_dtype else io_dtype
return (
SignatureBuilder()
.ptr("segm_output_ptr", "f32")
.ptr("segm_max_ptr", "f32")
.ptr("segm_expsum_ptr", "f32")
.ptr("query_ptr", io_dtype)
.ptr("key_cache_ptr", kv_io)
.ptr("value_cache_ptr", kv_io)
.ptr("sink_ptr", io_dtype)
.ptr("block_tables_ptr", "i32")
.ptr("seq_lens_ptr", "i32")
.ptr("alibi_slopes_ptr", "f32")
.ptr("qq_bias_ptr", "f32")
.ptr("query_start_len_ptr", "i32")
.scalar("scale", "f32")
.scalar("k_scale", "f32")
.scalar("v_scale", "f32")
.scalar("softcap", "f32")
.scalar("num_seqs", "i32")
.scalar("block_table_stride", "i32")
.scalar("qq_bias_stride_0", "i32")
.build()
)
def _reduce_signature(dtype: str):
from rocke.helpers.spec import SignatureBuilder
io_dtype = "f16" if dtype == "fp16" else "bf16"
return (
SignatureBuilder()
.ptr("output_ptr", io_dtype)
.ptr("segm_output_ptr", "f32")
.ptr("segm_max_ptr", "f32")
.ptr("segm_expsum_ptr", "f32")
.ptr("seq_lens_ptr", "i32")
.build()
)
def _attn_signature(
dtype: str,
*,
include_bt_stride: bool,
include_qq_bias_stride: bool = False,
kv_dtype: Optional[str] = None,
):
from rocke.helpers.spec import SignatureBuilder
io_dtype = "f16" if dtype == "fp16" else "bf16"
# K/V cache dtype defaults to the working dtype (bf16/fp16). The FP8
# K/V path passes ``kv_dtype="fp8e4m3"`` so the signature uses 1-byte
# pointers for K/V cache instead of 2-byte.
kv_io = kv_dtype if kv_dtype else io_dtype
sb = (
SignatureBuilder()
.ptr("output_ptr", io_dtype)
.ptr("query_ptr", io_dtype)
.ptr("key_cache_ptr", kv_io)
.ptr("value_cache_ptr", kv_io)
.ptr("sink_ptr", io_dtype)
.ptr("block_tables_ptr", "i32")
.ptr("seq_lens_ptr", "i32")
.ptr("alibi_slopes_ptr", "f32")
.ptr("qq_bias_ptr", "f32")
.ptr("query_start_len_ptr", "i32")
.scalar("scale", "f32")
.scalar("k_scale", "f32")
.scalar("v_scale", "f32")
.scalar("out_scale", "f32")
.scalar("softcap", "f32")
.scalar("num_seqs", "i32")
)
if include_bt_stride:
sb.scalar("block_table_stride", "i32")
if include_qq_bias_stride:
sb.scalar("qq_bias_stride_0", "i32")
return sb.build()
def _attn_values(
*,
problem: UnifiedAttentionProblem,
q,
k,
v,
out,
cu_seqlens_q,
seqused_k,
softmax_scale: float,
block_table,
softcap: float,
sinks,
bt_stride: int,
include_bt_stride: bool,
alibi_slopes=None,
qq_bias=None,
qq_bias_stride_0: int = 0,
include_qq_bias_stride: bool = False,
k_scale: float = 1.0,
v_scale: float = 1.0,
out_scale: float = 1.0,
):
vals = {
"output_ptr": out,
"query_ptr": q,
"key_cache_ptr": k,
"value_cache_ptr": v,
"sink_ptr": sinks,
"block_tables_ptr": block_table,
"seq_lens_ptr": seqused_k,
"alibi_slopes_ptr": alibi_slopes if alibi_slopes is not None else 0,
"qq_bias_ptr": qq_bias if qq_bias is not None else 0,
"query_start_len_ptr": cu_seqlens_q,
"scale": float(softmax_scale),
"k_scale": float(k_scale),
"v_scale": float(v_scale),
"out_scale": float(out_scale),
"softcap": float(softcap),
"num_seqs": int(problem.num_seqs),
}
if include_bt_stride:
vals["block_table_stride"] = int(bt_stride)
if include_qq_bias_stride:
vals["qq_bias_stride_0"] = int(qq_bias_stride_0)
return vals
def _run_3d_tiled(
*,
problem: UnifiedAttentionProblem,
q,
k,
v,
out,
cu_seqlens_q,
seqused_k,
softmax_scale: float,
block_table,
softcap: float,
sinks,
bt_stride: int,
warmup: int,
attempts: int,
alibi_slopes=None,
qq_bias=None,
qq_bias_stride_0: int = 0,
stream: int = 0,
k_scale: float = 1.0,
v_scale: float = 1.0,
use_graph: bool = True,
):
"""Launch the tiled 3D segment + reduce kernels.
Mirrors AITER's 3D path:
1. Compile (and cache) both kernels for this problem shape.
2. Allocate the per-segment workspace tensors `segm_output`,
`segm_max`, `segm_expsum`.
3. Launch the 3D segment kernel with grid
`(total_num_q_blocks, num_kv_heads, num_segments)`.
4. Launch the reduce kernel with grid `(total_q, num_query_heads, 1)`.
"""
num_segments = _num_segments(problem)
cache_key = _tiled_3d_cache_key(problem)
if use_graph and _enable_3d_graph_replay(problem) and not _torch_stream_capturing():
graph_key = (
cache_key,
int(problem.total_q),
int(stream),
id(q),
id(k),
id(v),
id(out),
id(cu_seqlens_q),
id(seqused_k),
id(block_table),
id(sinks) if sinks is not None else 0,
id(alibi_slopes) if alibi_slopes is not None else 0,
id(qq_bias) if qq_bias is not None else 0,
float(softmax_scale),
float(k_scale),
float(v_scale),
float(softcap),
int(bt_stride),
int(qq_bias_stride_0),
)
graph = _3D_GRAPHS.get(graph_key)
if graph is None:
import torch
# Build/load launchers and allocate workspace outside capture.
_run_3d_tiled(
problem=problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
warmup=warmup,
attempts=attempts,
alibi_slopes=alibi_slopes,
qq_bias=qq_bias,
qq_bias_stride_0=qq_bias_stride_0,
stream=stream,
k_scale=k_scale,
v_scale=v_scale,
use_graph=False,
)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
with no_fence():
_run_3d_tiled(
problem=problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
warmup=warmup,
attempts=attempts,
alibi_slopes=alibi_slopes,
qq_bias=qq_bias,
qq_bias_stride_0=qq_bias_stride_0,
stream=stream,
k_scale=k_scale,
v_scale=v_scale,
use_graph=False,
)
_3D_GRAPHS[graph_key] = graph
_3D_GRAPH_REFS[graph_key] = (
q,
k,
v,
out,
cu_seqlens_q,
seqused_k,
block_table,
sinks,
alibi_slopes,
qq_bias,
)
graph.replay()
if _resolved_fence(True):
wait_stream_and_release(int(stream))
return LaunchSummary(launches=2)
# Lazily build (and cache) the PipelineLauncher + WorkspacePool for
# this problem shape. This single object owns: the compiled HSACO
# blobs, the loaded HIP module handles, the kernel function
# handles, and the segm_* workspace tensors. All five
# categories of lifetime / race / overhead bugs documented in
# ``rocke/runtime/launcher.py`` are removed by construction; the
# only remaining per-call cost is packing args and issuing two
# ``hipModuleLaunchKernel`` calls on the caller's stream.
prepared = _get_3d_pipeline(problem, cache_key, num_segments)
segm_output, segm_max, segm_expsum = prepared.workspace(
problem, num_segments, q.device
)
if _resolve_attention_arch() == "gfx1250":
import torch
segm_max.fill_(-1e30)
segm_expsum.zero_()
segm_output.zero_()
bound_key = (
cache_key,
int(problem.total_q),
str(q.device),
id(q),
id(k),
id(v),
id(cu_seqlens_q),
id(seqused_k),
id(block_table),
id(sinks) if sinks is not None else 0,
id(alibi_slopes) if alibi_slopes is not None else 0,
id(qq_bias) if qq_bias is not None else 0,
float(softmax_scale),
float(k_scale),
float(v_scale),
float(softcap),
int(problem.num_seqs),
int(bt_stride),
int(qq_bias_stride_0),
)
cached_values = _3D_BOUND_VALUES.get(bound_key)
if cached_values is None:
seg_vals = {
"segm_output_ptr": segm_output,
"segm_max_ptr": segm_max,
"segm_expsum_ptr": segm_expsum,
"query_ptr": q,
"key_cache_ptr": k,
"value_cache_ptr": v,
"sink_ptr": sinks,
"block_tables_ptr": block_table,
"seq_lens_ptr": seqused_k,
"alibi_slopes_ptr": alibi_slopes if alibi_slopes is not None else 0,
"qq_bias_ptr": qq_bias if qq_bias is not None else 0,
"query_start_len_ptr": cu_seqlens_q,
"scale": float(softmax_scale),
"k_scale": float(k_scale),
"v_scale": float(v_scale),
"softcap": float(softcap),
"num_seqs": int(problem.num_seqs),
"block_table_stride": int(bt_stride),
"qq_bias_stride_0": int(qq_bias_stride_0),
}
red_vals = {
"output_ptr": out,
"segm_output_ptr": segm_output,
"segm_max_ptr": segm_max,
"segm_expsum_ptr": segm_expsum,
"seq_lens_ptr": seqused_k,
}
_3D_BOUND_VALUES[bound_key] = (seg_vals, red_vals)
else:
seg_vals, red_vals = cached_values
# Output is commonly a fresh tensor per request; workspace and inputs are
# fixed by the bound key.
red_vals["output_ptr"] = out
return prepared.pipeline(
(seg_vals, red_vals),
(prepared.seg_config, prepared.red_config),
stream=int(stream),
)
@dataclass
class _Attention3DPrepared:
pipeline: PipelineLauncher
pool: WorkspacePool
seg_config: LaunchConfig
red_config: LaunchConfig
workspace_specs: Dict[Any, Tuple[WorkspaceSpec, WorkspaceSpec, WorkspaceSpec]]
workspace_tensors: Dict[Any, Tuple[Any, Any, Any]]
seg_values: Dict[str, Any]
red_values: Dict[str, Any]
def workspace(self, problem: UnifiedAttentionProblem, num_segments: int, device):
key = device
if key not in self.workspace_tensors:
specs = self.workspace_specs.get(key)
if specs is None:
specs = _attention_3d_workspace_specs(problem, num_segments, device)
self.workspace_specs[key] = specs
segm_output = self.pool.get_spec(specs[0])
segm_max = self.pool.get_spec(specs[1])
segm_expsum = self.pool.get_spec(specs[2])
self.workspace_tensors[key] = (segm_output, segm_max, segm_expsum)
return self.workspace_tensors[key]
@dataclass(frozen=True)
class _Attention2DLaunchMeta:
grid: Tuple[int, int, int]
block: Tuple[int, int, int]
# Per-cache-key prepared 3D launch state. Built lazily at first dispatch for a
# given problem shape; reused across every subsequent dispatch and timing-loop
# iteration. This is the same shape as CK Tile's `fmha_bwd_launcher` (one object
# per problem instance, owns kernels + workspace, survives every launch).
_3D_PIPELINES: Dict[Tuple, _Attention3DPrepared] = {}
_3D_BOUND_VALUES: Dict[Tuple, Tuple[Dict[str, Any], Dict[str, Any]]] = {}
_3D_GRAPHS: Dict[Tuple, Any] = {}
_3D_GRAPH_REFS: Dict[Tuple, Tuple[Any, ...]] = {}
_2D_LAUNCHERS: Dict[Tuple, KernelLauncher] = {}
_2D_LAUNCH_META: Dict[Tuple, _Attention2DLaunchMeta] = {}
_2D_GRAPHS: Dict[Tuple, Any] = {}
_2D_GRAPH_REFS: Dict[Tuple, Tuple[Any, ...]] = {}
_SCALAR_LAUNCHERS: Dict[Tuple, KernelLauncher] = {}
def _recommend_graph_replay(problem: UnifiedAttentionProblem) -> bool:
"""Graph-vs-ungraph heuristic.
CUDA-graph capture only removes *per-launch host overhead* (the Python
dispatch + kernarg pack + ``hipModuleLaunchKernel``), so it pays off exactly
when that overhead is a large fraction of the kernel time:
* **decode** (``max_seqlen_q == 1``) -- the kernel is tiny, overhead
dominates; tensors are stable across steps so the captured graph
replays. (The 3D split-KV decode path uses this.)
* **short prefill** (``max_seqlen_q <= 768``, the light-narrow regime)
-- 1-2 KV tiles, so overhead is a big fraction; graphing wins 3-4x.
**Long prefill** (``q > 768``) is kernel-bound: the overhead is noise and
its tensors usually differ per call, so graphing would only add recapture
cost. -> ungraph. Feature-flagged shapes (sinks/alibi/qq/softcap/sw/fp8) are
excluded for now. The dispatcher only engages an internal graph when the
caller is not already capturing (frameworks that graph the whole forward
take precedence)."""
if problem.use_sinks or problem.use_alibi or problem.use_qq_bias:
return False
if problem.softcap > 0 or problem.sliding_window > 0 or problem.use_fp8:
return False
return problem.max_seqlen_q <= 768
def _graph_env_enabled(var: str) -> bool:
env = __import__("os").environ.get(var, "").strip().lower()
return env not in ("0", "off", "disable", "disabled", "no", "false")
def _enable_3d_graph_replay(problem: UnifiedAttentionProblem) -> bool:
arch = _resolve_attention_arch()
if arch == "gfx942":
if not _recommend_graph_replay(problem):
return False
return _graph_env_enabled("HIPDNN_GFX942_3D_GRAPH")
if arch == "gfx1250":
# Decode / short-q 3D is launch-overhead bound. CUDA-graph capture is
# gfx942-validated; keep gfx1250 opt-in until the ROCm graph path is
# exercised (``HIPDNN_GFX1250_3D_GRAPH=1``).
if problem.max_seqlen_q > 768:
return False
if problem.use_alibi or problem.use_qq_bias or problem.softcap > 0:
return False
if problem.sliding_window > 0:
return False
env = (
__import__("os").environ.get("HIPDNN_GFX1250_3D_GRAPH", "").strip().lower()
)
return env in ("1", "on", "enable", "enabled", "yes", "true")
if arch == "gfx950":
# The 3D split-KV decode launch is host/dispatch bound here: the two
# kernels (segment + reduce) device-execute in ~26-35us but the eager
# Python dispatch adds a ~17us flat floor, so the wall time is ~43us and
# does not scale with context length. Capturing the (segment + reduce)
# launch sequence into a hipGraph and replaying it removes that
# per-launch host cost and roughly halves decode latency (43->21us at
# k=2048), producing bitwise-identical output. Gated to the decode /
# short-q regime (``max_seqlen_q <= 768``) so prefill routing is never
# affected, and feature-flagged shapes are excluded. Opt-in via
# ``HIPDNN_GFX950_3D_GRAPH=1`` until broadly validated across traces.
if problem.max_seqlen_q > 768:
return False
if problem.use_alibi or problem.use_qq_bias or problem.softcap > 0:
return False
if problem.sliding_window > 0 or problem.use_fp8:
return False
env = __import__("os").environ.get("HIPDNN_GFX950_3D_GRAPH", "").strip().lower()
return env in ("1", "on", "enable", "enabled", "yes", "true")
return False
def _enable_2d_graph_replay(problem: UnifiedAttentionProblem) -> bool:
"""Auto-graph the 2D prefill launch for the short-context regime where the
light-narrow kernel is host-overhead-bound (see _recommend_graph_replay)."""
if _resolve_attention_arch() != "gfx942":
return False
if problem.max_seqlen_q <= 1 or not _recommend_graph_replay(problem):
return False
return _graph_env_enabled("HIPDNN_GFX942_2D_GRAPH")
# Sentinel: 2D graph path could not handle the problem -> caller falls through.
_GRAPH_FALLBACK = object()
def _cheap_2d_sig(problem) -> Tuple:
"""Cheap shape-signature that determines the 2D kernel in a fixed-env
process (the kernel is a deterministic function of these problem fields).
Lets the graph fast path skip the ~16us supports + cache_key selector work
on replay. Env/monkeypatch changes (sweeps) invalidate via ``_2D_GRAPHS``
being cleared, so the signature need not encode the env knobs."""
return (
"2dg",
problem.head_size,
problem.num_query_heads,
problem.num_kv_heads,
problem.block_size,
problem.dtype,
problem.max_seqlen_q,
problem.max_seqlen_k,
problem.num_seqs,
problem.sliding_window,
bool(problem.use_sinks),
float(problem.softcap),
bool(problem.use_alibi),
bool(problem.use_qq_bias),
bool(problem.use_fp8),
int(problem.total_q),
)
def _run_2d_graphed(
problem,
*,
q,
k,
v,
out,
cu_seqlens_q,
seqused_k,
softmax_scale,
block_table,
softcap,
sinks,
bt_stride,
alibi_slopes,
qq_bias,
qq_bias_stride_0,
k_scale,
v_scale,
out_scale,
stream,
):
"""Look up (or build once) + replay a CUDA graph around the single 2D launch.
The graph cache is keyed on a cheap shape signature + tensor identities +
scalar args, looked up BEFORE any selector work -- so a replay (the steady
state: benchmarks, frameworks that reuse KV/IO buffers) skips supports +
cache_key + the kernarg pack entirely, leaving only the ~us key build +
``graph.replay()``. A new buffer set rebuilds (correctness). Returns
``_GRAPH_FALLBACK`` if the shape isn't tiled-supported so the caller can take
the non-graph path. Tensors + kernarg pack are held alive in
``_2D_GRAPH_REFS``."""
graph_key = _cheap_2d_sig(problem) + (
int(stream),
id(q),
id(k),
id(v),
id(out),
id(cu_seqlens_q),
id(seqused_k),
id(block_table),
id(sinks) if sinks is not None else 0,
id(alibi_slopes) if alibi_slopes is not None else 0,
id(qq_bias) if qq_bias is not None else 0,
float(softmax_scale),
float(k_scale),
float(v_scale),
float(softcap),
int(bt_stride),
int(qq_bias_stride_0),
)
graph = _2D_GRAPHS.get(graph_key)
if graph is not None:
graph.replay()
return None
# Miss: the full build path (selectors + launcher + kernarg pack + capture).
ok_t, _reason = supports_native_unified_attention_tiled(problem)
if not ok_t:
return _GRAPH_FALLBACK
import torch
key = _tiled_cache_key(problem)
launcher = _get_2d_launcher(problem, key)
vals = _attn_values(
problem=problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
include_bt_stride=True,
alibi_slopes=alibi_slopes,
qq_bias=qq_bias,
qq_bias_stride_0=qq_bias_stride_0,
include_qq_bias_stride=True,
k_scale=k_scale,
v_scale=v_scale,
out_scale=out_scale,
)
meta = _get_2d_launch_meta(problem, key)
cfg = LaunchConfig(grid=meta.grid, block=meta.block, stream=int(stream))
def _do():
launcher(vals, config=cfg)
# Warm OUTSIDE capture (compile already happened in _get_2d_launcher; this
# primes the module + produces the correct `out`).
with no_fence():
_do()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
with no_fence():
_do()
_2D_GRAPHS[graph_key] = graph
_2D_GRAPH_REFS[graph_key] = (
vals,
q,
k,
v,
out,
cu_seqlens_q,
seqused_k,
block_table,
sinks,
alibi_slopes,
qq_bias,
)
graph.replay()
return None
def _torch_stream_capturing() -> bool:
try:
import torch
return bool(torch.cuda.is_current_stream_capturing())
except Exception:
return False
def _attention_3d_workspace_specs(
problem: UnifiedAttentionProblem,
num_segments: int,
device,
) -> Tuple[WorkspaceSpec, WorkspaceSpec, WorkspaceSpec]:
"""CK Tile-style workspace declaration for the split-KV 3D pipeline.
This is the Python equivalent of FMHA forward split-KV's
`lse_acc_ptr` + `o_acc_ptr` sizing in
`example/ck_tile/01_fmha/fmha_fwd_runner.hpp`: all scratch shapes
are derived from the problem up front, owned by a long-lived pool,
and passed to the segment and reduce kernels by pointer.
"""
try:
import torch
f32 = torch.float32
except Exception:
# CPU-only/static tests can still ask for byte accounting without
# importing torch. WorkspacePool.required_nbytes understands this
# string fallback via `_dtype_element_size`.
f32 = "float32"
return (
WorkspaceSpec(
"segm_output",
(problem.total_q, problem.num_query_heads, num_segments, problem.head_size),
f32,
device,
),
WorkspaceSpec(
"segm_max",
(problem.total_q, problem.num_query_heads, num_segments),
f32,
device,
),
WorkspaceSpec(
"segm_expsum",
(problem.total_q, problem.num_query_heads, num_segments),
f32,
device,
),
)
def attention_3d_workspace_nbytes(
problem: UnifiedAttentionProblem,
*,
device=None,
) -> int:
"""Return required split-KV 3D workspace bytes for `problem`.
Public helper for tests/bench harnesses that want to report scratch
usage before dispatch. The `device` value only matters for the
eventual allocation, not byte accounting.
"""
return WorkspacePool.required_nbytes(
_attention_3d_workspace_specs(problem, _num_segments(problem), device)
)
def _get_3d_pipeline(
problem: UnifiedAttentionProblem,
cache_key: Tuple,
num_segments: int,
) -> _Attention3DPrepared:
prepared_key = cache_key + ("total_q", int(problem.total_q))
if prepared_key in _3D_PIPELINES:
return _3D_PIPELINES[prepared_key]
if cache_key not in _ATTN_3D_TILED_CACHE:
arch = _resolve_attention_arch()
(
_,
UnifiedAttentionReduceTiledSpec,
build_unified_attention_3d_tiled,
build_unified_attention_reduce_tiled,
_,
) = _tiled_3d_impl(arch)
seg_spec = _tiled_3d_spec_from_problem(problem)
reduce_spec = UnifiedAttentionReduceTiledSpec(
head_size=problem.head_size,
num_query_heads=problem.num_query_heads,
num_kv_heads=problem.num_kv_heads,
dtype=problem.dtype,
num_segments=num_segments,
waves_per_eu=_select_3d_waves_per_eu(problem),
)
seg_art = compile_kernel(
build_unified_attention_3d_tiled(seg_spec, arch=arch),
arch=arch,
capture_ir_text=False,
)
red_art = compile_kernel(
build_unified_attention_reduce_tiled(reduce_spec, arch=arch),
arch=arch,
capture_ir_text=False,
)
_ATTN_3D_TILED_CACHE[cache_key] = (
seg_art.hsaco,
seg_art.kernel_name,
red_art.hsaco,
red_art.kernel_name,
)
seg_hsaco, seg_kname, red_hsaco, red_kname = _ATTN_3D_TILED_CACHE[cache_key]
seg_launcher = KernelLauncher(
hsaco=seg_hsaco,
kernel_name=seg_kname,
signature=_3d_signature(problem.dtype, kv_dtype=_kv_storage_dtype(problem)),
cache_key=("3d_seg",) + cache_key,
)
red_launcher = KernelLauncher(
hsaco=red_hsaco,
kernel_name=red_kname,
signature=_reduce_signature(problem.dtype),
cache_key=("3d_red",) + cache_key,
)
pipeline = PipelineLauncher([seg_launcher, red_launcher])
pool = WorkspacePool()
block_q = (
16 // problem.num_queries_per_kv if problem.num_queries_per_kv <= 16 else 1
)
total_num_q_blocks = problem.total_q // block_q + problem.num_seqs
# gfx1250 (gfx1250) runs the split-KV segment + reduce as one wave32 CTA; the
# CDNA wave64 archs (gfx950/gfx942) use a wave64 CTA.
wave_size = 32 if _resolve_attention_arch() == "gfx1250" else 64
# Must match the wave count the seg spec was built with -> resolve through the
# same tuning policy (not the raw env reader, which now returns -1 for auto).
seg_waves = (
_resolve_gfx1250_tiled3d(problem).num_waves
if _resolve_attention_arch() == "gfx1250"
else 1
)
prepared = _Attention3DPrepared(
pipeline=pipeline,
pool=pool,
seg_config=LaunchConfig(
grid=(
int(total_num_q_blocks),
int(problem.num_kv_heads),
int(num_segments),
),
block=(wave_size * seg_waves, 1, 1),
),
red_config=LaunchConfig(
grid=(int(problem.total_q), int(problem.num_query_heads), 1),
block=(wave_size, 1, 1),
),
workspace_specs={},
workspace_tensors={},
seg_values={},
red_values={},
)
_3D_PIPELINES[prepared_key] = prepared
return prepared
def _select_2d_compile_backend(problem: UnifiedAttentionProblem) -> str:
"""Pick the compile backend (LLVM-direct vs hipcc) for the 2D tiled kernel.
The HIP path (``hipcc --genco``) is measurably faster than the
LLVM-direct path on large-batch bf16/fp16 prefill (≈5% on
``b4_q1000_kv1000``) because clang's frontend + AMDGPU backend
pipeline does heavier instruction scheduling for the long
unrolled loop body. Smaller workloads (decode, small prefill)
are 5-29% slower via hipcc, so the auto-selector only switches
when the workload is large enough to amortize hipcc's ~450ms
compile cost AND benefit from its scheduling.
The FP8 K/V path uses sync-dequant loaders with intrinsics that
the HIP debug backend may not fully cover; the auto-selector
pins FP8 to ``llvm`` until ``hipcc`` is validated for that
code path.
See the out-of-tree ``probe_hip_lowering.py`` for the per-shape sweep.
"""
if _resolve_attention_arch() == "gfx1250":
return "llvm"
if problem.compile_backend in ("llvm", "hipcc"):
return problem.compile_backend
# Auto: HIP for large-batch bf16/fp16 prefill workloads where
# hipcc's heavier scheduler measurably wins. FP8 stays on LLVM
# until the HIP path is exercised on the dequant-loader kernels.
if problem.use_fp8:
return "llvm"
# gfx942 kernels are validated through LLVM-direct. The HIP debug backend has
# emitted code objects that hipModuleLoadData rejects for both cfvst D128 and
# narrow D64 MI300X kernels, so keep gfx942 on the proven direct path unless
# the caller explicitly overrides ``compile_backend``.
if _resolve_attention_arch() == "gfx942":
return "llvm"
# MHA (num_queries_per_kv == 1) head_size-64 prefill REGRESSES under
# hipcc: the perf_attn2d sweep (B2 H8 d64 fp16, gfx950) measured the
# hipcc auto path at 95/186 TFLOPS for s1024/s2048 vs 131/218 on the
# LLVM-direct path (the byte-identical fallback geometry), i.e. hipcc
# is 17-27% SLOWER here, not the ~5% faster the b4 GQA shape showed.
# The fast-path 16x16 MHA loop body does not benefit from clang's
# heavier unrolled-loop scheduler the way the GQA-8 / combo geometry
# does, so pin this family to LLVM-direct. (The combo / GQA path below
# keeps its hipcc eligibility.)
if (
problem.num_queries_per_kv == 1
and problem.head_size == 64
and not _enable_combo_2d(problem)
):
return "llvm"
# Single-batch (num_seqs == 1) d128/d64 prefill combo cohort: pin to
# LLVM-direct. The hipcc scheduler's heavier unrolled-loop pass only pays
# off on the LARGE multi-batch grid; for the single-seq grid it is a big
# REGRESSION (same-session A/B: d128
# S1024 none_hipcc 95us vs none_llvm 49us; d128 S2048 hipcc ~174us vs llvm
# 143us). The total_work rule below would otherwise pick hipcc for these
# long single-seq shapes, so gate them to LLVM explicitly.
if _enable_single_batch_combo(problem):
return "llvm"
# P_LDS transposed-read PV path correctness pin. When the 2D
# kernel lands on the legacy 16x16x32 path with neither the register-P
# migration (``_enable_register_pv``) nor the 32x32 transposed combo
# (``_enable_mfma_32x32``), the PV stage publishes the softmax
# probabilities to LDS as 16-bit and reads them back transposed via
# ``ds_read_tr16``. The shipped system hipcc (ROCm 7.0 / LLVM 20)
# MISCOMPILES that transposed-LDS-read sequence: the output is ~0.84
# max-abs wrong (bad != 0) versus the fp32 reference, while the
# LLVM-direct (comgr / LLVM 22) build of the *same* IR is correct
# (rounding tol, bad == 0). The miscompile is path-specific, not
# dtype-specific -- it reproduces on bf16 too when register-P is forced
# off -- but the only production config that still routes onto this path
# is head_size==256 (the 32x32 combo excludes hd256 and register-P is
# bf16-only), so in practice this pins fp16 d256 long-prefill (which auto
# would otherwise compile via hipcc). Pin it to the proven LLVM-direct
# backend until the system hipcc is upgraded / the path is moved onto
# register-P for hd256. The perf delta is the ~5% hipcc-scheduler edge,
# which correctness strictly outranks.
if not _enable_register_pv(problem) and not _enable_mfma_32x32(problem):
return "llvm"
total_work = problem.num_seqs * max(problem.max_seqlen_q, 1)
if problem.max_seqlen_q > 512 and total_work > 1024:
return "hipcc"
return "llvm"
def _get_2d_launcher(
problem: UnifiedAttentionProblem,
cache_key: Tuple,
) -> KernelLauncher:
if cache_key in _2D_LAUNCHERS:
return _2D_LAUNCHERS[cache_key]
if cache_key not in _ATTN_TILED_CACHE:
arch = _resolve_attention_arch()
_, build_unified_attention_2d_tiled, _ = _tiled_2d_impl(arch)
spec = _tiled_spec_from_problem(problem)
kernel = build_unified_attention_2d_tiled(spec, arch=arch)
backend = _select_2d_compile_backend(problem)
if backend == "hipcc":
from rocke.helpers.compile import compile_kernel_via_hipcc
artifact = compile_kernel_via_hipcc(kernel)
else:
artifact = compile_kernel(kernel, arch=arch, capture_ir_text=False)
_ATTN_TILED_CACHE[cache_key] = (artifact.hsaco, artifact.kernel_name)
hsaco, kname = _ATTN_TILED_CACHE[cache_key]
launcher = KernelLauncher(
hsaco=hsaco,
kernel_name=kname,
signature=_attn_signature(
problem.dtype,
include_bt_stride=True,
include_qq_bias_stride=True,
kv_dtype=_kv_storage_dtype(problem),
),
cache_key=("2d",) + cache_key,
)
_2D_LAUNCHERS[cache_key] = launcher
return launcher
def _get_2d_launch_meta(
problem: UnifiedAttentionProblem,
cache_key: Tuple,
) -> _Attention2DLaunchMeta:
meta_key = cache_key + ("total_q", int(problem.total_q))
if meta_key in _2D_LAUNCH_META:
return _2D_LAUNCH_META[meta_key]
arch = _resolve_attention_arch()
if _enable_gfx942_bf16_flash(problem):
# _select_gfx942_flash_num_warps mirrors the spec builder for BOTH dtypes:
# ring-active -> fp16-flash wide geometry; bf16 non-ring -> bf16-wide nw.
# (Using a mismatched nw here would compute the grid/block for a different
# geometry than the launcher actually builds.)
num_warps = _select_gfx942_flash_num_warps(problem)
block_m_per_warp = 32
elif _enable_gfx942_fp16_flash(problem):
num_warps = _select_gfx942_flash_num_warps(problem)
block_m_per_warp = _select_2d_block_m_per_warp(problem)
else:
num_warps = _select_2d_num_warps(problem)
block_m_per_warp = _select_2d_block_m_per_warp(problem)
block_m = num_warps * block_m_per_warp
block_q = (
block_m // problem.num_queries_per_kv
if problem.num_queries_per_kv <= block_m
else 1
)
total_num_q_blocks = problem.total_q // block_q + problem.num_seqs
wave_size = 32 if arch == "gfx1250" else 64
meta = _Attention2DLaunchMeta(
grid=(int(problem.num_kv_heads), int(total_num_q_blocks), 1),
block=(int(wave_size * num_warps), 1, 1),
)
_2D_LAUNCH_META[meta_key] = meta
return meta
def _get_scalar_launcher(
problem: UnifiedAttentionProblem,
cache_key: Tuple,
) -> KernelLauncher:
if cache_key in _SCALAR_LAUNCHERS:
return _SCALAR_LAUNCHERS[cache_key]
if cache_key not in _ATTN_CACHE:
arch = _resolve_attention_arch()
spec = UnifiedAttention2DSpec(problem=problem)
artifact = compile_kernel(
build_unified_attention_2d(spec), arch=arch, capture_ir_text=False
)
_ATTN_CACHE[cache_key] = (artifact.hsaco, artifact.kernel_name)
hsaco, kname = _ATTN_CACHE[cache_key]
launcher = KernelLauncher(
hsaco=hsaco,
kernel_name=kname,
signature=_attn_signature(
problem.dtype,
include_bt_stride=False,
kv_dtype=_kv_storage_dtype(problem),
),
cache_key=("scalar",) + cache_key,
)
_SCALAR_LAUNCHERS[cache_key] = launcher
return launcher
def run_unified_attention_torch(
*,
problem: UnifiedAttentionProblem,
q,
k,
v,
out,
cu_seqlens_q,
seqused_k,
softmax_scale: float,
block_table,
softcap: float,
sinks=None,
alibi_slopes=None,
qq_bias=None,
qq_bias_stride_0: int = 0,
warmup: int = 0,
attempts: int = 1,
backend: str = "auto",
stream: int = 0,
k_scale: float = 1.0,
v_scale: float = 1.0,
out_scale: float = 1.0,
):
"""Launch a CK DSL attention kernel on torch tensors.
Backend selection:
- `"tiled"`: force the optimized MFMA path; raises if unsupported.
- `"scalar"`: force the slow correctness kernel.
- `"auto"`: prefer tiled when supported, else scalar.
``alibi_slopes`` is an optional `[num_query_heads]` f32 tensor; when
supplied, the kernel applies the ALiBi linear bias on each row.
``qq_bias`` is an optional 2D f32 query-to-query bias; ``qq_bias_stride_0``
is its first-axis stride (in elements). Both follow AITER's Triton
semantics exactly and require the corresponding ``problem.use_alibi`` /
``problem.use_qq_bias`` flags to be set.
``stream`` is the HIP stream handle (an `int`) to launch on. Pass
``torch.cuda.current_stream().cuda_stream`` to make the launches
visible to ``torch.cuda.graph`` capture; this is how the parity
harness amortises the segment + reduce launch overhead in the 3D
path under a hipgraph.
"""
bt_stride = (
int(block_table.stride(0))
if hasattr(block_table, "stride")
else int(block_table.shape[1])
)
# Fill the paged-cache block count from the K tensor (k.shape[0]) so the
# 2D dispatcher can switch to 64-bit KV addressing when the cache exceeds
# the ~2 GiB i32-voffset cap. ``num_kv_blocks`` has exactly ONE consumer
# (``_enable_i64_kv_addr``), and only caches >2 GiB need i64 -- so only pay
# the (~30us, fields()-introspecting) ``dataclasses.replace`` when the cache
# is actually that large. Small caches (the common case + every test shape)
# keep ``num_kv_blocks=0`` -> the correct fast i32 path -> no per-call
# replace, which otherwise dominates tiny-shape host latency.
if problem.num_kv_blocks <= 0 and hasattr(k, "shape") and len(k.shape) >= 1:
_eb = 1 if problem.use_fp8 else 2
_blk_stride = (
problem.block_size * problem.num_kv_heads * problem.head_size * _eb
)
if int(k.shape[0]) * _blk_stride > 0x8000_0000:
problem = replace(problem, num_kv_blocks=int(k.shape[0]))
# Auto path selection. Historically we *always* preferred 3D when
# supported because split-KV produces a huge grid that beats Triton
# 2-6× on decode-shape workloads (small total Q, few sequences).
# That assumption breaks badly on production traces:
#
# - chunked-prefill (large total Q across many seqs) -- 2D already
# saturates the device, so the extra split-KV segments add launch
# overhead with no parallelism gain. Triton picks 2D and we used
# to pick 3D, losing 30-150×.
# - sliding-window with long context -- 2D's per-iter window mask
# is much cheaper than 3D's per-segment scan over the full kv.
# - short context (k <= 512) -- the split-KV reduce kernel's launch
# overhead dominates the few real KV iterations.
#
# ``problem.select_path()`` (see UnifiedAttentionProblem.select_path)
# wraps Triton's ``use_2d_kernel`` selector, which decides exactly
# the above three cases. Honour it in auto mode unless the user
# explicitly forces ``backend == "3d"``. This matches Triton's
# production-tested selector on every shape and recovers the trace
# buckets (where our old auto picked 3D for chunked prefill at 30-
# 150× the right path's cost) while keeping the decode-shape 3D
# wins on the parity harness (those shapes still satisfy the "3D
# is fine" branch of ``use_2d_kernel``).
prefer_2d = backend == "auto" and problem.select_path() == "2d"
if backend == "3d" or (backend == "auto" and not prefer_2d):
ok_3d, reason_3d = supports_native_unified_attention_3d_tiled(problem)
if ok_3d:
return _run_3d_tiled(
problem=problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
warmup=warmup,
attempts=attempts,
alibi_slopes=alibi_slopes,
qq_bias=qq_bias,
qq_bias_stride_0=qq_bias_stride_0,
stream=int(stream),
k_scale=k_scale,
v_scale=v_scale,
)
if backend == "3d":
raise NotImplementedError(reason_3d)
if backend in ("tiled", "auto"):
# Graphed fast path FIRST: for the short-context regime the launch is
# auto-graphed (heuristic: _recommend_graph_replay). The graph is looked
# up by a cheap shape signature + tensor ids BEFORE any selector work, so
# a replay skips supports + cache_key + the kernarg pack -- the host
# overhead that otherwise dominates tiny-shape latency. Skipped when the
# caller is already capturing the forward (they take precedence).
if _enable_2d_graph_replay(problem) and not _torch_stream_capturing():
graphed = _run_2d_graphed(
problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
alibi_slopes=alibi_slopes,
qq_bias=qq_bias,
qq_bias_stride_0=qq_bias_stride_0,
k_scale=k_scale,
v_scale=v_scale,
out_scale=out_scale,
stream=stream,
)
if graphed is not _GRAPH_FALLBACK:
return graphed
ok_t, reason_t = supports_native_unified_attention_tiled(problem)
if ok_t:
# Hot path: compute the cache key directly from the problem +
# selectors (skip the 17-field dataclass build). Spec is only
# built on cache miss inside _get_2d_launcher and for grid
# math below.
key = _tiled_cache_key(problem)
launcher = _get_2d_launcher(problem, key)
vals = _attn_values(
problem=problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
include_bt_stride=True,
alibi_slopes=alibi_slopes,
qq_bias=qq_bias,
qq_bias_stride_0=qq_bias_stride_0,
include_qq_bias_stride=True,
k_scale=k_scale,
v_scale=v_scale,
out_scale=out_scale,
)
# The dispatcher must launch with the same BLOCK_Q/threads the
# kernel was built for. Cache that fixed metadata per kernel key so
# repeated same-shape calls avoid selector math on the hot path.
meta = _get_2d_launch_meta(problem, key)
return launcher(
vals,
config=LaunchConfig(
grid=meta.grid,
block=meta.block,
stream=int(stream),
),
)
if backend == "tiled":
raise NotImplementedError(reason_t)
# Scalar fallback. Uses the same KernelLauncher infrastructure as
# the tiled paths so module load + arg lifetime + stream resolution
# are handled by-construction.
ok, reason = supports_native_unified_attention(problem)
if not ok:
raise NotImplementedError(reason)
key = _cache_key(problem)
launcher = _get_scalar_launcher(problem, key)
vals = _attn_values(
problem=problem,
q=q,
k=k,
v=v,
out=out,
cu_seqlens_q=cu_seqlens_q,
seqused_k=seqused_k,
softmax_scale=softmax_scale,
block_table=block_table,
softcap=softcap,
sinks=sinks,
bt_stride=bt_stride,
include_bt_stride=False,
k_scale=k_scale,
v_scale=v_scale,
out_scale=out_scale,
)
return launcher(
vals,
config=LaunchConfig(
grid=(
int(problem.total_q),
int(problem.num_query_heads),
int(problem.head_size),
),
block=(64, 1, 1),
stream=int(stream),
),
)
@dataclass(frozen=True)
class UnifiedAttention2DSpec:
problem: UnifiedAttentionProblem
name: str = "rocke_unified_attention_2d_scalar"
@property
def dtype_ir(self) -> Type:
if self.problem.dtype == "fp16":
return F16
if self.problem.dtype == "bf16":
return BF16
raise ValueError(
f"unsupported dtype for scalar 2D kernel: {self.problem.dtype}"
)
def kernel_name(self) -> str:
from rocke.helpers.spec import kernel_name_join
p = self.problem
return kernel_name_join(
self.name,
f"q{p.total_q}",
f"h{p.num_query_heads}",
f"kv{p.num_kv_heads}",
f"d{p.head_size}",
f"b{p.block_size}",
p.dtype,
flags={
"sink": p.use_sinks,
"sw": p.sliding_window > 0,
"softcap": p.softcap > 0,
"fp8kv": p.use_fp8,
},
)
def build_unified_attention_2d(spec: UnifiedAttention2DSpec) -> KernelDef:
"""Build a scalar-correct 2D unified-attention kernel.
One workgroup computes one output element `(query_token, query_head, dim)`.
This is deliberately a correctness kernel: it implements the full paged
online-softmax semantics for fp16/bf16 without relying on Triton. The
optimized MFMA/tiled kernel will replace this body once parity is locked.
"""
p = spec.problem
if p.dtype not in ("fp16", "bf16"):
raise ValueError("scalar 2D kernel currently supports fp16/bf16")
dtype = spec.dtype_ir
kv_dtype = FP8E4M3 if p.use_fp8 else dtype
kv_vec_align = 8 if p.use_fp8 else 16
kv_scalar_align = 1 if p.use_fp8 else 2
b = IRBuilder(spec.kernel_name())
b.kernel.attrs["max_workgroup_size"] = 64
output = b.param(
"output_ptr", PtrType(dtype, "global"), noalias=True, writeonly=True, align=16
)
abi = _declare_scalar_attn_params(b, dtype, kv_dtype=kv_dtype)
query = abi["query"]
key = abi["key"]
value = abi["value"]
sinks = abi["sink"]
block_tables = abi["block_tables"]
seq_lens = abi["seq_lens"]
cu_q = abi["cu_q"]
scale = abi["scale"]
k_scale = abi["k_scale"]
v_scale = abi["v_scale"]
_out_scale = b.param("out_scale", F32)
softcap = b.param("softcap", F32)
num_seqs = b.param("num_seqs", I32)
q_tok = b.block_id_x()
q_head = b.block_id_y()
dim = b.block_id_z()
tid = b.thread_id_x()
active = b.cmp_eq(tid, b.const_i32(0))
# Find seq_idx by scanning cu_q: largest i such that cu_q[i] <= q_tok.
seq_init = b.const_i32(0)
scan = b.scf_for_iter(
b.const_i32(0), num_seqs, b.const_i32(1), [("seq_idx", seq_init)], iv_name="si"
)
with scan as (si, (seq_idx,)):
start_i = b.global_load_i32(cu_q, si)
le = b.cmp_le(start_i, q_tok)
next_seq = b.select(le, si, seq_idx)
b.scf_yield(next_seq)
seq_idx = scan.results[0]
cu_start = b.global_load_i32(cu_q, seq_idx)
cu_stop = b.global_load_i32(cu_q, b.add(seq_idx, b.const_i32(1)))
q_len = b.sub(cu_stop, cu_start)
query_pos = b.sub(q_tok, cu_start)
kv_len = b.global_load_i32(seq_lens, seq_idx)
context_len = b.sub(kv_len, q_len)
kv_head = _magic_div(b, q_head, p.num_queries_per_kv)
neg_inf = b.const_f32(float("-inf"))
zero_f = b.const_f32(0.0)
one_f = b.const_f32(1.0)
rcp_ln2 = b.const_f32(1.4426950408889634)
if p.use_sinks:
sink_h = b.global_load(sinks, q_head, dtype, align=2)
init_m = b.fmul(b.cast_to_f32(sink_h), rcp_ln2)
init_l = one_f
else:
init_m = neg_inf
init_l = one_f
init_acc = zero_f
# Coordinate transforms over the kernel's tensors. Q/output are a
# naive ``(query_token, query_head, dim)`` layout; the paged KV
# cache uses ``PagedKvDescriptor`` (in element units, not bytes).
# Both share the descriptor builders the 3D/reduce scalar kernels use.
q_desc = _q_descriptor(p)
kv_desc_elem = _paged_kv_descriptor(p)
loop = b.scf_for_iter(
b.const_i32(0),
kv_len,
b.const_i32(1),
[("m", init_m), ("l", init_l), ("acc", init_acc)],
iv_name="kpos",
)
with loop as (kpos, (m_val, l_val, acc_val)):
block_idx, token_in_block = _magic_div_mod(b, kpos, p.block_size)
physical = b.global_load_i32(
block_tables,
b.add(
b.mul(
seq_idx,
b.const_i32((p.max_seqlen_k + p.block_size - 1) // p.block_size),
),
block_idx,
),
)
# Vectorised QK dot product. Both Q[q_tok, q_head, :] and
# K[physical, token_in_block, kv_head, :] are stride-1 along the
# head_size axis, so we read them in ``vec8`` chunks (one
# ds_read_b128 / buffer_load_b128 each). The inner accumulation
# order ``score += q[d] * k[d]`` is preserved by walking the
# vec lanes in order, so the result is bit-identical to the
# prior per-element unroll. ``head_size`` is restricted to
# {64, 128, 256} by :func:`supports_native_unified_attention`,
# so it always divides cleanly by 8.
score = zero_f
q_off_base, _ = q_desc.offset(b, token=q_tok, head=q_head, dim=b.const_i32(0))
k_off_base = kv_desc_elem.offset(
b,
physical_block=physical,
token_in_block=token_in_block,
kv_head=kv_head,
dim=b.const_i32(0),
)
VEC = 8
for d8 in b.unroll(p.head_size // VEC):
d_base = b.const_i32(d8 * VEC)
qv_vec = b.global_load_vN(
query, b.add(q_off_base, d_base), dtype, VEC, align=16
)
kv_vec = b.global_load_vN(
key, b.add(k_off_base, d_base), kv_dtype, VEC, align=kv_vec_align
)
for i in range(VEC):
qv = b.cast_to_f32(b.vec_extract(qv_vec, i))
kv = b.vec_extract(kv_vec, i)
kv_f = b.cvt_fp8_to_f32(kv) if p.use_fp8 else b.cast_to_f32(kv)
if p.use_fp8:
kv_f = b.fmul(kv_f, k_scale)
score = b.fadd(
score,
b.fmul(qv, kv_f),
)
# Defensive tail scalar fold for head_size % 8 != 0; in
# production this loop is empty.
for d in b.unroll((p.head_size // VEC) * VEC, p.head_size):
d_v = b.const_i32(d)
q_off, _ = q_desc.offset(b, token=q_tok, head=q_head, dim=d_v)
k_off = kv_desc_elem.offset(
b,
physical_block=physical,
token_in_block=token_in_block,
kv_head=kv_head,
dim=d_v,
)
qv_s = b.cast_to_f32(b.global_load(query, q_off, dtype, align=2))
kv_raw = b.global_load(key, k_off, kv_dtype, align=kv_scalar_align)
kv_s = b.cvt_fp8_to_f32(kv_raw) if p.use_fp8 else b.cast_to_f32(kv_raw)
if p.use_fp8:
kv_s = b.fmul(kv_s, k_scale)
score = b.fadd(score, b.fmul(qv_s, kv_s))
score = b.fmul(b.fmul(score, scale), rcp_ln2)
if p.softcap > 0:
score = b.fmul(_apply_softcap(b, score, softcap), rcp_ln2)
causal_ok = b.cmp_le(kpos, b.add(context_len, query_pos))
if p.sliding_window > 0:
dist = b.sub(b.add(context_len, query_pos), kpos)
sw_ok = b.cmp_lt(dist, b.const_i32(p.sliding_window))
causal_ok = b.land(causal_ok, sw_ok)
score = b.select(causal_ok, score, neg_inf)
new_m_raw = b.fmax(m_val, score)
# If both running max and current score are -inf, the row is fully
# masked; force m to 0 so the resulting alpha/prob are 0 instead of NaN
# (matches Triton's `m_j = tl.where(m_j > -inf, m_j, 0.0)`).
is_finite = b.fcmp("ogt", new_m_raw, neg_inf)
new_m = b.select(is_finite, new_m_raw, zero_f)
alpha = b.exp2(b.fsub(m_val, new_m))
prob = b.exp2(b.fsub(score, new_m))
new_l = b.fadd(b.fmul(l_val, alpha), prob)
v_off = kv_desc_elem.offset(
b,
physical_block=physical,
token_in_block=token_in_block,
kv_head=kv_head,
dim=dim,
)
vv_raw = b.global_load(value, v_off, kv_dtype, align=kv_scalar_align)
vv = b.cvt_fp8_to_f32(vv_raw) if p.use_fp8 else b.cast_to_f32(vv_raw)
if p.use_fp8:
vv = b.fmul(vv, v_scale)
new_acc = b.fadd(b.fmul(acc_val, alpha), b.fmul(prob, vv))
b.scf_yield(new_m, new_l, new_acc)
out_val = b.fmul(loop.results[2], b.rcp(loop.results[1]))
out_cast = b.cast_f32_to(out_val, dtype)
out_off, _ = q_desc.offset(b, token=q_tok, head=q_head, dim=dim)
valid = b.land(active, b.cmp_lt(dim, b.const_i32(p.head_size)))
with b.scf_if(valid):
b.global_store(output, out_off, out_cast, align=2)
return b.kernel
@dataclass(frozen=True)
class UnifiedAttention3DSpec(UnifiedAttention2DSpec):
name: str = "rocke_unified_attention_3d_scalar"
num_segments: int = 8
def kernel_name(self) -> str:
from rocke.helpers.spec import kernel_name_join
p = self.problem
return kernel_name_join(
self.name,
f"q{p.total_q}",
f"h{p.num_query_heads}",
f"kv{p.num_kv_heads}",
f"d{p.head_size}",
f"b{p.block_size}",
f"seg{self.num_segments}",
p.dtype,
)
def build_unified_attention_3d(spec: UnifiedAttention3DSpec) -> KernelDef:
"""Build scalar-correct split-3D segment attention kernel."""
p = spec.problem
dtype = spec.dtype_ir
b = IRBuilder(spec.kernel_name())
b.kernel.attrs["max_workgroup_size"] = 64
segm_output = b.param(
"segm_output_ptr",
PtrType(F32, "global"),
noalias=True,
writeonly=True,
align=16,
)
segm_max = b.param(
"segm_max_ptr", PtrType(F32, "global"), noalias=True, writeonly=True, align=16
)
segm_expsum = b.param(
"segm_expsum_ptr",
PtrType(F32, "global"),
noalias=True,
writeonly=True,
align=16,
)
abi = _declare_scalar_attn_params(b, dtype)
query = abi["query"]
key = abi["key"]
value = abi["value"]
block_tables = abi["block_tables"]
seq_lens = abi["seq_lens"]
cu_q = abi["cu_q"]
scale = abi["scale"]
_softcap = b.param("softcap", F32)
num_seqs = b.param("num_seqs", I32)
q_tok = b.block_id_x()
q_head = b.block_id_y()
zd = b.block_id_z()
segm_idx, dim = _magic_div_mod(b, zd, p.head_size)
tid = b.thread_id_x()
active = b.cmp_eq(tid, b.const_i32(0))
seq_idx = _emit_find_seq_idx_scan(b, cu_q, q_tok, num_seqs)
cu_start = b.global_load_i32(cu_q, seq_idx)
cu_stop = b.global_load_i32(cu_q, b.add(seq_idx, b.const_i32(1)))
q_len = b.sub(cu_stop, cu_start)
query_pos = b.sub(q_tok, cu_start)
kv_len = b.global_load_i32(seq_lens, seq_idx)
context_len = b.sub(kv_len, q_len)
kv_head = _magic_div(b, q_head, p.num_queries_per_kv)
tiles_per_segment = _magic_div(
b,
b.add(kv_len, b.const_i32(spec.num_segments * p.block_size - 1)),
spec.num_segments * p.block_size,
)
seg_start = b.mul(segm_idx, b.mul(tiles_per_segment, b.const_i32(p.block_size)))
seg_stop_i = b.mul(
b.add(segm_idx, b.const_i32(1)),
b.mul(tiles_per_segment, b.const_i32(p.block_size)),
)
seg_stop_i = b.select(b.cmp_lt(seg_stop_i, kv_len), seg_stop_i, kv_len)
neg_inf = b.const_f32(float("-inf"))
zero_f = b.const_f32(0.0)
rcp_ln2 = b.const_f32(1.4426950408889634)
init_m = neg_inf
init_l = zero_f
init_acc = zero_f
loop = b.scf_for_iter(
seg_start,
seg_stop_i,
b.const_i32(1),
[("m", init_m), ("l", init_l), ("acc", init_acc)],
iv_name="kpos",
)
with loop as (kpos, (m_val, l_val, acc_val)):
score = _emit_qk_score(
b,
p,
dtype,
query,
key,
block_tables,
seq_idx,
q_tok,
q_head,
kv_head,
kpos,
scale,
rcp_ln2,
)
causal_ok = b.cmp_le(kpos, b.add(context_len, query_pos))
score = b.select(causal_ok, score, neg_inf)
new_m = b.fmax(m_val, score)
alpha = b.exp2(b.fsub(m_val, new_m))
prob = b.exp2(b.fsub(score, new_m))
new_l = b.fadd(b.fmul(l_val, alpha), prob)
vv = _emit_v_load(b, p, dtype, value, block_tables, seq_idx, kv_head, kpos, dim)
new_acc = b.fadd(b.fmul(acc_val, alpha), b.fmul(prob, vv))
b.scf_yield(new_m, new_l, new_acc)
ml_desc, out_desc = _segm_descriptors(p, spec.num_segments)
base, _ = out_desc.offset(b, token=q_tok, head=q_head, seg=segm_idx, dim=dim)
with b.scf_if(active):
b.global_store(segm_output, base, loop.results[2], align=4)
is_dim0 = b.cmp_eq(dim, b.const_i32(0))
with b.scf_if(is_dim0):
segm_base, _ = ml_desc.offset(
b,
token=q_tok,
head=q_head,
seg=segm_idx,
)
b.global_store(segm_max, segm_base, loop.results[0], align=4)
b.global_store(segm_expsum, segm_base, loop.results[1], align=4)
return b.kernel
@dataclass(frozen=True)
class UnifiedAttentionReduceSpec:
problem: UnifiedAttentionProblem
num_segments: int
name: str = "rocke_unified_attention_reduce_scalar"
@property
def dtype_ir(self) -> Type:
return F16 if self.problem.dtype == "fp16" else BF16
def kernel_name(self) -> str:
from rocke.helpers.spec import kernel_name_join
p = self.problem
return kernel_name_join(
self.name,
f"q{p.total_q}",
f"h{p.num_query_heads}",
f"d{p.head_size}",
f"seg{self.num_segments}",
p.dtype,
)
def build_unified_attention_reduce(spec: UnifiedAttentionReduceSpec) -> KernelDef:
p = spec.problem
dtype = spec.dtype_ir
b = IRBuilder(spec.kernel_name())
b.kernel.attrs["max_workgroup_size"] = 64
out = b.param(
"output_ptr", PtrType(dtype, "global"), noalias=True, writeonly=True, align=16
)
segm_output = b.param(
"segm_output_ptr", PtrType(F32, "global"), readonly=True, align=16
)
segm_max = b.param("segm_max_ptr", PtrType(F32, "global"), readonly=True, align=16)
segm_expsum = b.param(
"segm_expsum_ptr", PtrType(F32, "global"), readonly=True, align=16
)
_seq_lens = b.param("seq_lens_ptr", PtrType(I32, "global"), readonly=True, align=4)
_cu_q = b.param(
"query_start_len_ptr", PtrType(I32, "global"), readonly=True, align=4
)
q_tok = b.block_id_x()
q_head = b.block_id_y()
dim = b.block_id_z()
tid = b.thread_id_x()
active = b.cmp_eq(tid, b.const_i32(0))
neg_inf = b.const_f32(float("-inf"))
zero = b.const_f32(0.0)
ml_desc, seg_out_desc = _segm_descriptors(p, spec.num_segments)
q_desc = _q_descriptor(p)
max_loop = b.scf_for_iter(
b.const_i32(0),
b.const_i32(spec.num_segments),
b.const_i32(1),
[("mx", neg_inf)],
iv_name="seg",
)
with max_loop as (seg, (mx,)):
idx, _ = ml_desc.offset(b, token=q_tok, head=q_head, seg=seg)
mv = b.global_load_f32(segm_max, idx)
b.scf_yield(b.fmax(mx, mv))
overall = max_loop.results[0]
red = b.scf_for_iter(
b.const_i32(0),
b.const_i32(spec.num_segments),
b.const_i32(1),
[("den", zero), ("acc", zero)],
iv_name="seg2",
)
with red as (seg, (den, acc)):
idx, _ = ml_desc.offset(b, token=q_tok, head=q_head, seg=seg)
mv = b.global_load_f32(segm_max, idx)
lv = b.global_load_f32(segm_expsum, idx)
factor = b.exp2(b.fsub(mv, overall))
den2 = b.fadd(den, b.fmul(lv, factor))
out_idx, _ = seg_out_desc.offset(
b,
token=q_tok,
head=q_head,
seg=seg,
dim=dim,
)
ov = b.global_load_f32(segm_output, out_idx)
acc2 = b.fadd(acc, b.fmul(ov, factor))
b.scf_yield(den2, acc2)
result = b.fmul(red.results[1], b.rcp(red.results[0]))
cast = b.cast_f32_to(result, dtype)
out_idx, _ = q_desc.offset(b, token=q_tok, head=q_head, dim=dim)
with b.scf_if(active):
b.global_store(out, out_idx, cast, align=2)
return b.kernel
def _emit_find_seq_idx_scan(
b: IRBuilder, cu_q: Value, q_tok: Value, num_seqs: Value
) -> Value:
"""Find ``seq_idx`` for the given Q token via a binary search.
P74: replaces the historical linear scan (``O(num_seqs)``) with
the shared ``binary_search_seq_idx`` helper in per-token mode
(``cu_q[s] <= q_tok < cu_q[s+1]``). Cosmetic on the scalar
oracle but matches the tiled kernels' shape and saves
``O(num_seqs - log2(num_seqs))`` linear-scan iterations on
high-batch decode workloads.
"""
from rocke.helpers.attention import binary_search_seq_idx
return binary_search_seq_idx(
b,
cu_q,
q_tok,
num_seqs,
block_q=1, # unused in per_token mode
iterations=32, # bounded scf.for trip count; 32 covers num_seqs <= 2^32
per_token=True,
)
def _declare_scalar_attn_params(
b: IRBuilder, dtype_ir: Type, *, kv_dtype: Optional[Type] = None
) -> dict:
"""Declare the shared scalar-attention ABI prefix (Q/K/V + aux + scales).
The 2D and 3D scalar reference kernels declare an identical leading run
of params -- ``query/key/value``, the ``sink/block_tables/seq_lens/alibi/
qq_bias/cu_q`` aux pointers, then the ``scale/k_scale/v_scale`` f32
scalars -- in exactly this order. Param declaration order is load-bearing
(it fixes the kernel arg layout / ABI), so this helper emits the same
``b.param`` calls in the same order, returning the SSA values by name. The
builders append their own kernel-specific tail params (``out_scale``,
``softcap``, ``num_seqs``, ...) after calling this, preserving the overall
ABI byte-for-byte.
The ``sink`` and ``alibi``/``qq_bias`` pointers are unused by the scalar
oracle bodies (they only model causal/sliding masking + softcap), but they
are part of the AITER ABI and must occupy their slots.
"""
kv_dtype = kv_dtype or dtype_ir
query = b.param(
"query_ptr", PtrType(dtype_ir, "global"), noalias=True, readonly=True, align=16
)
key = b.param(
"key_cache_ptr",
PtrType(kv_dtype, "global"),
noalias=True,
readonly=True,
align=16,
)
value = b.param(
"value_cache_ptr",
PtrType(kv_dtype, "global"),
noalias=True,
readonly=True,
align=16,
)
sink = b.param("sink_ptr", PtrType(dtype_ir, "global"), readonly=True, align=16)
block_tables = b.param(
"block_tables_ptr", PtrType(I32, "global"), readonly=True, align=4
)
seq_lens = b.param("seq_lens_ptr", PtrType(I32, "global"), readonly=True, align=4)
alibi = b.param("alibi_slopes_ptr", PtrType(F32, "global"), readonly=True, align=4)
qq_bias = b.param("qq_bias_ptr", PtrType(F32, "global"), readonly=True, align=4)
cu_q = b.param(
"query_start_len_ptr", PtrType(I32, "global"), readonly=True, align=4
)
scale = b.param("scale", F32)
k_scale = b.param("k_scale", F32)
v_scale = b.param("v_scale", F32)
return {
"query": query,
"key": key,
"value": value,
"sink": sink,
"block_tables": block_tables,
"seq_lens": seq_lens,
"alibi": alibi,
"qq_bias": qq_bias,
"cu_q": cu_q,
"scale": scale,
"k_scale": k_scale,
"v_scale": v_scale,
}
def _q_descriptor(p: UnifiedAttentionProblem) -> TensorDescriptor:
"""Element-unit Q/output descriptor: ``(token, head, dim)``."""
return TensorDescriptor.naive(
"Q",
lengths=[p.max_seqlen_q + 1, p.num_query_heads, p.head_size],
coord_names=("token", "head", "dim"),
)
def _paged_kv_descriptor(p: UnifiedAttentionProblem) -> PagedKvDescriptor:
"""Element-unit paged-KV descriptor for the scalar kernels."""
return PagedKvDescriptor(
block_size=p.block_size,
stride_0=p.block_size * p.num_kv_heads * p.head_size,
stride_1=p.num_kv_heads * p.head_size,
stride_2=p.head_size,
stride_3=1,
)
def _segm_descriptors(
p: UnifiedAttentionProblem,
num_segments: int,
) -> Tuple[TensorDescriptor, TensorDescriptor]:
"""``(segm_ml, segm_output)`` descriptors used by 3D + reduce kernels.
Layouts:
``segm_ml`` : ``[total_q, num_query_heads, num_segments]``
``segm_output`` : ``[total_q, num_query_heads, num_segments, head_size]``
Both are produced by ``build_unified_attention_3d`` and consumed by
``build_unified_attention_reduce``. Encoding them as descriptors
means every offset becomes ``desc.offset(token=..., head=..., seg=...,
dim=...)`` instead of the original ``add(mul, mul)`` ladder.
"""
ml_desc = TensorDescriptor.naive(
"segm_ml",
lengths=[p.max_seqlen_q + 1, p.num_query_heads, num_segments],
coord_names=("token", "head", "seg"),
)
out_desc = TensorDescriptor.naive(
"segm_output",
lengths=[
p.max_seqlen_q + 1,
p.num_query_heads,
num_segments,
p.head_size,
],
coord_names=("token", "head", "seg", "dim"),
)
return ml_desc, out_desc
def _emit_qk_score(
b: IRBuilder,
p: UnifiedAttentionProblem,
dtype: Type,
query: Value,
key: Value,
block_tables: Value,
seq_idx: Value,
q_tok: Value,
q_head: Value,
kv_head: Value,
kpos: Value,
scale: Value,
rcp_ln2: Value,
) -> Value:
"""Per-(query_token, query_head) QK dot product for the scalar kernels.
The dot product sums ``head_size`` half-precision element pairs into
one f32 score. Both Q[q_tok, q_head, :] and
K[physical_block, token_in_block, kv_head, :] are contiguous along
the head_size dimension (the innermost stride-1 axis of their
respective descriptors), so the kernel reads them in
``vec8`` chunks via :meth:`IRBuilder.global_load_vN` instead of
one 16-bit ``b.global_load`` per element. The inner accumulation
order is preserved (``score += q[d] * k[d]`` for ``d`` increasing
monotonically), so the result is bit-identical to the prior
per-element form.
Head sizes that aren't a multiple of 8 fall back to a tail loop;
in production the scalar kernels are only built for
``head_size in {64, 128, 256}`` (see
:func:`supports_native_unified_attention`), all multiples of 8.
"""
score = b.const_f32(0.0)
physical, token_in_block = _physical_block_and_token(
b, p, block_tables, seq_idx, kpos
)
q_desc = _q_descriptor(p)
kv_desc = _paged_kv_descriptor(p)
q_off_base, _ = q_desc.offset(b, token=q_tok, head=q_head, dim=b.const_i32(0))
k_off_base = kv_desc.offset(
b,
physical_block=physical,
token_in_block=token_in_block,
kv_head=kv_head,
dim=b.const_i32(0),
)
VEC = 8
n_vec = p.head_size // VEC
for d8 in b.unroll(n_vec):
d_base = b.const_i32(d8 * VEC)
qv = b.global_load_vN(query, b.add(q_off_base, d_base), dtype, VEC, align=16)
kv = b.global_load_vN(key, b.add(k_off_base, d_base), dtype, VEC, align=16)
for i in range(VEC):
score = b.fadd(
score,
b.fmul(
b.cast_to_f32(b.vec_extract(qv, i)),
b.cast_to_f32(b.vec_extract(kv, i)),
),
)
# Tail scalar fold for head_size values not a multiple of VEC (defensive;
# the supported set in supports_native_unified_attention is {64,128,256}
# — all multiples of 8 — so this loop is empty for any compiled kernel).
for d in b.unroll(n_vec * VEC, p.head_size):
d_v = b.const_i32(d)
q_off, _ = q_desc.offset(b, token=q_tok, head=q_head, dim=d_v)
k_off = kv_desc.offset(
b,
physical_block=physical,
token_in_block=token_in_block,
kv_head=kv_head,
dim=d_v,
)
qv_s = b.cast_to_f32(b.global_load(query, q_off, dtype, align=2))
kv_s = b.cast_to_f32(b.global_load(key, k_off, dtype, align=2))
score = b.fadd(score, b.fmul(qv_s, kv_s))
return b.fmul(b.fmul(score, scale), rcp_ln2)
def _emit_v_load(
b: IRBuilder,
p: UnifiedAttentionProblem,
dtype: Type,
value: Value,
block_tables: Value,
seq_idx: Value,
kv_head: Value,
kpos: Value,
dim: Value,
) -> Value:
physical, token_in_block = _physical_block_and_token(
b, p, block_tables, seq_idx, kpos
)
v_off = _paged_kv_descriptor(p).offset(
b,
physical_block=physical,
token_in_block=token_in_block,
kv_head=kv_head,
dim=dim,
)
return b.cast_to_f32(b.global_load(value, v_off, dtype, align=2))
def _physical_block_and_token(
b: IRBuilder,
p: UnifiedAttentionProblem,
block_tables: Value,
seq_idx: Value,
kpos: Value,
) -> Tuple[Value, Value]:
block_idx, token_in_block = _magic_div_mod(b, kpos, p.block_size)
max_blocks = (p.max_seqlen_k + p.block_size - 1) // p.block_size
physical = b.global_load_i32(
block_tables, b.add(b.mul(seq_idx, b.const_i32(max_blocks)), block_idx)
)
return physical, token_in_block