Skip to content
Open
163 changes: 132 additions & 31 deletions atom/model_ops/attention_mha.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2024-2025, Advanced Micro Devices, Inc. All rights reserved.

import logging
from functools import cache

import aiter
Expand Down Expand Up @@ -29,6 +30,12 @@

from .attention_mla import MLAModules

logger = logging.getLogger("atom")

# Once per process: the hint names the opt-in, not the shape, so one line says
# all a default user needs, and the decode loop pays one bool test after it.
_flydsl_off_hint_logged = False


@cache
def use_pa_decode_bf16_asm() -> bool:
Expand Down Expand Up @@ -181,16 +188,23 @@ def _can_use_prefill_sink_asm(
return False
return q.shape[1] % k.shape[1] == 0

def _reject_per_token_scales_on_unified(self, k_scale, v_scale, phase: str):
@staticmethod
def _per_token_kv(k_scale, v_scale) -> bool:
return (k_scale is not None and k_scale.numel() > 1) or (
v_scale is not None and v_scale.numel() > 1
)

def _reject_per_token_scales_on_unified(
self, k_scale, v_scale, phase: str, flydsl_would_take: bool = False
):
"""unified_attention carries one descale for the whole tensor.

Feeding it a per-token cache is not an error downstream, just wrong
numbers, so it has to be refused here. Both phases route into unified on
the same conditions, so both check.
the same conditions, so both check. ``flydsl_would_take``: the decode
is one ATOM_PA_FLYDSL=1 would offer to FlyDSL, so the error says so.
"""
if (k_scale is not None and k_scale.numel() > 1) or (
v_scale is not None and v_scale.numel() > 1
):
if self._per_token_kv(k_scale, v_scale):
if phase == "decode" and not pa_decode_gluon_supports_arch():
reason = (
"set ATOM_PA_FLYDSL=1"
Expand All @@ -202,9 +216,15 @@ def _reject_per_token_scales_on_unified(self, k_scale, v_scale, phase: str):
f"cache on this GPU: {reason}, unified_attention cannot "
"carry per-token KV scales, and there is no gluon fallback"
)
hint = (
"; ATOM_PA_FLYDSL=1 lets aiter's FlyDSL pa_decode take it "
"(needs aiter with ROCm/aiter#7206)"
if flydsl_would_take
else ""
)
raise NotImplementedError(
f"layer {self.layer_num} takes unified_attention for {phase}, "
"which cannot carry its per-token KV scales"
f"which cannot carry its per-token KV scales{hint}"
)

def forward_impl(
Expand Down Expand Up @@ -608,19 +628,61 @@ def _shuffle_v_for_flydsl(self, v_cache, k_cache):
return v_cache
return self._view_v_cache_for_pa_decode_bf16_asm(v_cache, k_cache)

def _flydsl_takes_decode(self, q, k_cache, v_cache, attn_metadata) -> bool:
"""Without gluon decode (gfx1250): whether FlyDSL accepts this call.

ATOM_USE_UNIFIED_ATTN keeps the call on unified_attention.
"""
def _pa_decode_kv_scales(self, k_scale, v_scale):
"""The K/V scales ``paged_attention_triton`` hands ``run_pa_decode``."""
if self.kv_cache_dtype == "bf16":
return None, None
if k_scale is not None and k_scale.numel() > 1:
return k_scale.unsqueeze(-1), v_scale.unsqueeze(-1)
return k_scale, v_scale

def _flydsl_route_open(self, attn_metadata) -> bool:
"""Whether this unified-bound decode is one ATOM_PA_FLYDSL offers to
FlyDSL: not forced to unified, paged layout, no window, no sinks, not
BF16 KV on a gluon GPU (ATOM's own routing in
``_flydsl_pa_decode_num_seqs``), and on a gluon GPU past the gluon
envelope (inside it the gluon route is kept). aiter's predicate is not
asked here; the opt-in hint relies on this, so it never names a route
ATOM's routing would refuse."""
gluon = pa_decode_gluon_supports_arch()
if (
envs.ATOM_USE_UNIFIED_ATTN
or pa_decode_gluon_supports_arch()
or self.use_flash_layout
or self.sliding_window > 0
or self.sinks is not None
or (self.kv_cache_dtype == "bf16" and gluon)
):
return False
num_seqs = attn_metadata.context_lens.shape[0]
return bool(
return not gluon or gluon_decode_over_limit(
attn_metadata.max_seqlen_q, self.num_heads, self.num_kv_heads
)

def _flydsl_takes_decode(
self, q, k_cache, v_cache, k_scale, v_scale, attn_metadata
):
"""Sequence count FlyDSL runs a decode routed to unified with, or a
falsy value to keep it on unified.

Two cases reach unified with a call FlyDSL may take: a GPU without
gluon decode (gfx1250), and a gluon GPU whose query length x group is
past the gluon envelope (Qwen3.5 MTP-5: qlen 6, group 8). Inside that
envelope a gluon GPU keeps its route. The decision is made per call,
on the same tensors and metadata ``run_pa_decode`` then receives, and
the count is passed down as ``flydsl_seqs``, so aiter's predicate is
asked once per call and the two cannot disagree.

With ATOM_PA_FLYDSL unset such a call stays on unified;
``paged_attention_unified`` says what the flag would change.

ATOM_USE_UNIFIED_ATTN keeps the call on unified_attention.

gfx1250 with per-token FP8 KV past qlen 4 is decided by the same check;
it is not exercised by this path's tests or runs (out of scope here).
"""
if not envs.ATOM_PA_FLYDSL or not self._flydsl_route_open(attn_metadata):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test becomes vacuous now, _no_gluon sets ATOM_PA_FLYDSL=False so it returns early. also set it True there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ac263bc: the test sets ATOM_PA_FLYDSL=1 and is parametrized on ATOM_USE_UNIFIED_ATTN: off -> taken with 4 seqs, on -> not taken. Checked by removing the ATOM_USE_UNIFIED_ATTN condition: [True] fails (as does test_declined_calls_stay_on_unified[unified_env]).

return False
k_scale, v_scale = self._pa_decode_kv_scales(k_scale, v_scale)
return (
flydsl_decode_seqs(
output=q,
q=q,
Expand All @@ -629,21 +691,21 @@ def _flydsl_takes_decode(self, q, k_cache, v_cache, attn_metadata) -> bool:
block_tables=attn_metadata.block_tables,
context_lens=attn_metadata.context_lens,
max_seqlen_q=attn_metadata.max_seqlen_q,
max_context_partition_num=dense_decode_splits(
num_seqs, k_cache.shape[1]
),
context_partition_size=256,
compute_type=(
torch.bfloat16
if self.kv_cache_dtype == "bf16"
else aiter.dtypes.fp8
),
q_scale=None,
k_scale=k_scale,
v_scale=v_scale,
alibi_slopes=None,
sinks=self.sinks,
sliding_window=self.sliding_window,
ps=True,
)
or False
)

@mark_trace(prefix="paged_attention_unified", torch_compile=False)
Expand All @@ -657,11 +719,40 @@ def paged_attention_unified(
of the reasons for it.
"""
attn_metadata = fwd_ctx.attn_metadata
if self._flydsl_takes_decode(q, k_cache, v_cache, attn_metadata):
flydsl_seqs = self._flydsl_takes_decode(
q, k_cache, v_cache, k_scale, v_scale, attn_metadata
)
if flydsl_seqs:
return self.paged_attention_triton(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

can we ask the gate once per call instead of twice per layer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in accf537: _flydsl_takes_decode returns the sequence count, paged_attention_unified passes it down as flydsl_seqs, and run_pa_decode skips its own check when given one. Same arguments as before (the check already used the tensors run_pa_decode receives), so routing is unchanged; other run_pa_decode callers (vLLM/SGLang bridges, sparse sites) still check inside. Test: test_the_gate_is_asked_once_per_call drives unified -> triton -> run_pa_decode to the plan step and asserts the gate ran once. CPU: tests/test_paged_attention_dispatch.py 166 passed with #7206 pa_decode, 165 + 1 skip with the installed aiter; the only failure is main's test_batch_one_asks_for_the_ceiling, which needs a visible GPU.

q, k, v, k_cache, v_cache, k_scale, v_scale, fwd_ctx
q,
k,
v,
k_cache,
v_cache,
k_scale,
v_scale,
fwd_ctx,
flydsl_seqs=flydsl_seqs,
)
# A default user gets the opt-in named once: in the per-token FP8 KV
# refusal (unified cannot carry those scales), else in one info line.
flydsl_would_take = not envs.ATOM_PA_FLYDSL and self._flydsl_route_open(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hint can promise a route the gate then refuses for bf16 kv and sinks, fold those into _flydsl_route_open?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Folded in ac263bc: _flydsl_route_open now also returns False for sink layers and for BF16 KV on a gluon GPU (the two routing choices _flydsl_pa_decode_num_seqs makes before asking aiter), so the hint only appears where ATOM_PA_FLYDSL=1 would offer the call to FlyDSL. Test: test_no_hint_where_atom_keeps_the_call_off_flydsl[sinks|bf16_kv], with a positive control that the same layer without the condition is open.

attn_metadata
)
self._reject_per_token_scales_on_unified(
k_scale, v_scale, "decode", flydsl_would_take
)
global _flydsl_off_hint_logged
if flydsl_would_take and not _flydsl_off_hint_logged:
_flydsl_off_hint_logged = True
logger.info(
"decode with max_seqlen_q=%d x %d q heads per kv head runs on "
"unified_attention because ATOM_PA_FLYDSL is unset; "
"ATOM_PA_FLYDSL=1 lets aiter's FlyDSL pa_decode take it "
"(needs aiter with ROCm/aiter#7206)",
attn_metadata.max_seqlen_q,
self.num_heads // self.num_kv_heads,
)
self._reject_per_token_scales_on_unified(k_scale, v_scale, "decode")

if envs.ATOM_USE_UNIFIED_ATTN and self.kv_cache_dtype.startswith("fp8"):
o = torch.empty(*q.shape, dtype=torch.bfloat16, device=q.device)
Expand Down Expand Up @@ -696,13 +787,23 @@ def paged_attention_unified(

@mark_trace(prefix="paged_attention_triton", torch_compile=False)
def paged_attention_triton(
self, q, k, v, k_cache, v_cache, k_scale, v_scale, fwd_ctx: ForwardContext
self,
q,
k,
v,
k_cache,
v_cache,
k_scale,
v_scale,
fwd_ctx: ForwardContext,
flydsl_seqs: int | None = None,
):
"""Decode through ``run_pa_decode``: gluon, or opted-in FlyDSL.

Callers must have cleared ``gluon_decode_over_limit`` in
``_dispatch_decode`` or, on a GPU without gluon, established through
``_flydsl_takes_decode`` that FlyDSL accepts the call.
``_dispatch_decode`` or established through ``_flydsl_takes_decode``
that FlyDSL accepts the call; the latter pass the sequence count it
returned as ``flydsl_seqs``, so ``run_pa_decode`` does not ask again.
"""

attn_metadata = fwd_ctx.attn_metadata
Expand All @@ -723,9 +824,7 @@ def paged_attention_triton(
max_context_partition_num = 1
context_partition_size = 128

if k_scale is not None and k_scale.numel() > 1:
k_scale = k_scale.unsqueeze(-1)
v_scale = v_scale.unsqueeze(-1)
k_scale, v_scale = self._pa_decode_kv_scales(k_scale, v_scale)

compute_type = (
torch.bfloat16 if self.kv_cache_dtype == "bf16" else aiter.dtypes.fp8
Expand All @@ -743,8 +842,8 @@ def paged_attention_triton(
context_partition_size=context_partition_size,
compute_type=compute_type,
q_scale=None,
k_scale=None if self.kv_cache_dtype == "bf16" else k_scale,
v_scale=None if self.kv_cache_dtype == "bf16" else v_scale,
k_scale=k_scale,
v_scale=v_scale,
alibi_slopes=None,
sinks=self.sinks,
sliding_window=self.sliding_window,
Expand All @@ -755,6 +854,7 @@ def paged_attention_triton(
# max_context_length: the block-table bound is the same in graph
# and eager, so both pick the same kernel specialization.
work_plan=getattr(attn_metadata, "flydsl_work_plan", None),
flydsl_seqs=flydsl_seqs,
)

return o
Expand Down Expand Up @@ -1086,9 +1186,10 @@ def prefill_attention_triton(
def _dispatch_decode(self, max_qlen: int = 1):
"""Select the coarse decode runner from model shape and configuration.

Cache-dependent dispatch happens later: on a GPU without gluon,
``paged_attention_unified`` hands FlyDSL calls inside its supported
domain and keeps the rest on unified attention. The vLLM bridge shares
Cache-dependent dispatch happens later: on a GPU without gluon, or
past the gluon envelope on one with it, ``paged_attention_unified``
hands FlyDSL calls inside its supported domain and keeps the rest on
unified attention. The vLLM bridge shares
the constants and predicate, not this order -- its arms differ (no
unified fallback, no sliding-window arm). Both
paged kernels stop short of unified, at different points, and drafting
Expand Down
Loading
Loading