Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions unsloth_zoo/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,109 @@ def replace_with_grouped_query_attention(module, source):
pass




def replace_sdpa_with_amd_aiter(source):
"""
For AMD ROCm with amd-aiter installed: replace scaled_dot_product_attention
calls with amd-aiter Flash Attention in the compiled source.

Safety requirements (all must hold for the aiter path to activate):
1. get_amd_attention_implementation() == "amd_aiter"
2. is_causal=True as a literal (not a complex expression)
3. No extra SDPA args that aiter does not support:
attn_mask, dropout_p, scale, enable_gqa
4. q/k seq lengths equal (non-square causal has different bias alignment)
5. Input dtype is float16 or bfloat16 (aiter does not support float32)

No-op on NVIDIA and on ROCm without amd-aiter or ROCm < 7.0.
"""
# Import inside function — avoids compile-time NameError in caller scope
from unsloth_zoo.device_type import get_amd_attention_implementation
if get_amd_attention_implementation() != "amd_aiter":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Import the AMD selector before calling it

replace_sdpa_with_amd_aiter() is called for each SDPA module, but compiler.py only imports DEVICE_TYPE from the package and never imports get_amd_attention_implementation. Once the syntax error is fixed, any SDPA-capable model that enters this loop will raise NameError here even on NVIDIA/ROCm setups where this should be a no-op.

Useful? React with 👍 / 👎.

return source

import re

# Pattern: out = <prefix.>scaled_dot_product_attention(q, k, v, ...)
# Excludes: disable_compile_scaled_dot_product_attention (opt-out shim)
# Uses ((?:[^)]|\([^)]*\))*) to handle one level of nested parens in args
sdpa_call_pattern = (
r"([ \t]*)([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Anchor the SDPA assignment matcher

In AMD+aiter builds, this pattern is not anchored to a statement boundary, so it also matches the attn_output = substring in attribute assignments such as self.attn_output = F.scaled_dot_product_attention(q, k, v, is_causal=True). re.sub leaves the self. prefix in place and inserts the generated multi-line if block after it, making the compiled cache module syntactically invalid instead of falling back to SDPA; require a line/start-of-statement anchor or a negative lookbehind for ./identifier characters.

Useful? React with 👍 / 👎.

r"(?:(?:torch\.nn\.functional|F|nn\.functional)\.)?scaled_dot_product_attention"
r"\("
r"[ \t\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*,"
r"[ \t\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*,"
r"[ \t\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*"
r"((?:[^)]|\([^)]*\))*)\)"
Comment on lines +420 to +424

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject chained SDPA call suffixes

When AMD+aiter sees an assignment that immediately chains the SDPA result, such as attn_output = F.scaled_dot_product_attention(q, k, v, is_causal=True).transpose(1, 2), this matcher stops at the SDPA closing paren and re.sub leaves the .transpose(...) suffix after the generated multi-line if/else block. That produces invalid compiled source instead of preserving a valid attention implementation; require an end-of-statement after the call or include the suffix in the rewrite.

Useful? React with 👍 / 👎.

)

def aiter_replacement(m):
indent = m.group(1)
out_var = m.group(2)
q_var = m.group(3)
k_var = m.group(4)
v_var = m.group(5)
rest_args = m.group(6)

# Only rewrite when rest_args is exactly ", is_causal=True" (with optional
# whitespace). aiter accepts only (q, k, v, causal=True) — any additional
# argument (positional or keyword) may carry semantics the kernel ignores
# (e.g. positional dropout_p, attn_mask, scale, enable_gqa). Keyword-name
# checking alone is insufficient: positional calls like
# scaled_dot_product_attention(q, k, v, None, 0.1, is_causal=True) contain
# no keyword named "dropout_p" yet silently drop the positional dropout value.
# Accepting only the bare causal-only form guarantees correctness.
# Strip leading/trailing whitespace, leading comma, and optional trailing
# comma (Black/multiline formatting). Then fullmatch the bare keyword.
rest_args_stripped = rest_args.strip().lstrip(",").strip().rstrip(",").strip()
if not re.fullmatch(r"is_causal\s*=\s*True", rest_args_stripped):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept trailing comma on causal-only SDPA calls

For AMD+aiter, a safe causal-only call formatted in the common multiline style as scaled_dot_product_attention(q, k, v, is_causal=True,) is left unchanged because stripping only the leading comma leaves is_causal=True,, which this fullmatch rejects. That means many otherwise eligible HF/Black-formatted SDPA calls never take the new aiter fast path even though they satisfy the intended “bare causal-only” guard; allowing an optional trailing comma would preserve the safety check without disabling the feature for this format.

Useful? React with 👍 / 👎.

# rest_args has more than just is_causal=True — leave call unchanged
return m.group(0)

# Clean leading newlines from rest_args
rest_args_clean = rest_args.lstrip("\r\n").strip()
# rest_args already starts with a comma when non-empty; do NOT add another
# rest_args is ", is_causal=True" at this point — pass it through to the
# SDPA fallback so the original call semantics are preserved.
rest_args_clean = rest_args.lstrip("\r\n").strip()
rest_args_formatted = rest_args_clean # already starts with comma

# Generate the replacement block.
# All symbols used at runtime are imported inside the generated code so
# that the exec()'d module namespace is self-contained (no NameError).
lines = [
f"{indent}# AMD aiter Flash Attention (MFMA + causal tile skip)",
f"{indent}# Requires: pip install amd-aiter (ROCm >= 7.0)",
f"{indent}from unsloth_zoo.device_type import get_amd_flash_attn_func as _get_aiter_fn",
f"{indent}_aiter_fn = _get_aiter_fn()",
f"{indent}_aiter_ok = (",
f"{indent} _aiter_fn is not None",
f"{indent} and {q_var}.shape[-2] == {k_var}.shape[-2]", # equal seq lengths
f"{indent} and {q_var}.dtype in (torch.float16, torch.bfloat16)", # dtype guard
f"{indent})",
f"{indent}if _aiter_ok:",
f"{indent} _aiter_q = {q_var}.transpose(1, 2)",
f"{indent} _aiter_k = {k_var}.transpose(1, 2)",
f"{indent} _aiter_v = {v_var}.transpose(1, 2)",
# Wrap in try/except: unsupported head dim, arch mismatch, or JIT build
# failure inside flash_attn_func must fail-soft to SDPA, not crash training.
f"{indent} try:",
f"{indent} {out_var} = _aiter_fn(_aiter_q, _aiter_k, _aiter_v, causal=True).transpose(1, 2)",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip aiter when gradients need LSE

When training on AMD with aiter, the attention inputs generally require gradients, but aiter.flash_attn_func's autograd path asserts that return_lse is enabled when gradients are active (upstream FlashAttnFunc.forward). This generated call omits return_lse=True, so each training forward that passes _aiter_ok first tries aiter, raises, and then recomputes with SDPA in the except, making the new fast path exception-driven double work instead of an optimization; either guard it to inference-only or request and unwrap the LSE-returning training form.

Useful? React with 👍 / 👎.

f"{indent} except Exception:",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid emitting try/except into compiled attention forwards

When the SDPA attention class is actually compiled (disable=False, for example with sdpa_dynamic_compile=False), this generated try/except becomes part of a @torch.compile(fullgraph=...) forward. TorchDynamo does not support tracing Python exception handling in fullgraph mode, so an otherwise eligible AMD+aiter attention module can fail on the first forward instead of falling back to SDPA. Keep the fail-soft fallback out of the compiled region, or skip this rewrite when the SDPA forward is compiled.

Useful? React with 👍 / 👎.

f"{indent} {out_var} = torch.nn.functional.scaled_dot_product_attention(",
f"{indent} {q_var}, {k_var}, {v_var}{rest_args_formatted})",
f"{indent}else:",
f"{indent} {out_var} = torch.nn.functional.scaled_dot_product_attention(",
f"{indent} {q_var}, {k_var}, {v_var}{rest_args_formatted})",
]
return "\n".join(lines)

new_source, n = re.subn(sdpa_call_pattern, aiter_replacement, source, flags=re.DOTALL)
return new_source
Comment on lines +407 to +488

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

There are two critical NameError bugs, some robustness issues, and compliance concerns with repository rules in this implementation:

  1. Compile-time NameError: get_amd_attention_implementation is called on line 398 but is never imported in unsloth_zoo/compiler.py.
  2. Runtime NameError: get_amd_flash_attn_func is used in the generated code on line 446, but it is not imported in the compiled module, which will fail when the compiled module is loaded and executed.
  3. Regex Robustness & Line Structure Preservation: The regex pattern ([^)]) for rest_args will fail if any argument contains nested parentheses. We can use ((?:[^)]|([^)]))*)) to safely support one level of nested parentheses. Additionally, when patching code via regex, we must ensure replacement strings do not introduce unintended blank lines by checking if captured suffixes (like rest_args) already contain leading newlines.
  4. Causal Fast-Path Routing: When optimizing attention mechanisms by routing to fast paths (like aiter FlashAttention) that assume causal masking, we must ensure that bidirectional attention layers (where is_causal is False and attention_mask is None) are not incorrectly forced to be causal. We should explicitly verify that the call is causal before routing to the causal-only fast path.

We can resolve all of these issues cleanly by importing get_amd_attention_implementation at the start of the function, importing get_amd_flash_attn_func directly inside the generated code block, checking if is_causal is True before routing to _aiter_fn, cleaning up leading newlines in rest_args, and updating the regex patterns.

    from unsloth_zoo.device_type import get_amd_attention_implementation
    if get_amd_attention_implementation() != "amd_aiter":
        return source

    sdpa_call_pattern = (
        r"([ \t]*)([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*"
        r"(?:[A-Za-z_.]*scaled_dot_product_attention)"
        r"\("
        r"[ \t\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*,"
        r"[ \t\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*,"
        r"[ \t\n]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*"
        r"((?:[^)]|\([^)]*\))*)\)"
    )

    import re

    def aiter_replacement(m):
        indent = m.group(1)
        out_var = m.group(2)
        q_var = m.group(3)
        k_var = m.group(4)
        v_var = m.group(5)
        rest_args = m.group(6)

        causal_match = re.search(r"is_causal\s*=\s*([^,]+)", rest_args)
        is_causal = causal_match.group(1).strip() if causal_match else "False"

        rest_args_clean = rest_args.lstrip('\r\n')
        if rest_args_clean != rest_args:
            rest_args_formatted = ", " + rest_args_clean.strip() if rest_args_clean.strip() else ""
        else:
            rest_args_formatted = rest_args

        return (
            f"{indent}# AMD aiter Flash Attention (MFMA + fast softmax + causal tile skip)\n"
            f"{indent}# Requires: pip install amd-aiter  (ROCm >= 7.0)\n"
            f"{indent}from unsloth_zoo.device_type import get_amd_flash_attn_func\n"
            f"{indent}_aiter_fn = get_amd_flash_attn_func()\n"
            f"{indent}if _aiter_fn is not None and {is_causal}:\n"
            f"{indent}    _aiter_q = {q_var}.transpose(1, 2)\n"
            f"{indent}    _aiter_k = {k_var}.transpose(1, 2)\n"
            f"{indent}    _aiter_v = {v_var}.transpose(1, 2)\n"
            f"{indent}    {out_var} = _aiter_fn(_aiter_q, _aiter_k, _aiter_v, causal=True).transpose(1, 2)\n"
            f"{indent}else:\n"
            f"{indent}    {out_var} = torch.nn.functional.scaled_dot_product_attention(\n"
            f"{indent}        {q_var}, {k_var}, {v_var}{rest_args_formatted})"
        )

    new_source, n = re.subn(sdpa_call_pattern, aiter_replacement, source, flags=re.DOTALL)
    if n > 0:
        pass
    return new_source
References
  1. When patching code via regex, ensure replacement strings do not introduce unintended blank lines by checking if captured suffixes already contain leading newlines. Verify the fix using tests that check for line structure preservation and, if applicable, live round-trip checks against the upstream source.
  2. When optimizing attention mechanisms by routing to fast paths (like FlashAttention) that assume causal masking, ensure that bidirectional attention layers (where is_causal is False and attention_mask is None) are not incorrectly forced to be causal. Explicitly verify that the call is causal or has an explicit mask before routing to a causal-only fast path.




def _get_compile_folder(use_tempfile=False):
global UNSLOTH_COMPILE_LOCATION
global UNSLOTH_COMPILE_USE_TEMP
Expand Down Expand Up @@ -3809,6 +3912,15 @@ def _def_pos(name):
disabled_scaled_dot_product_attention_modules.append(module)
pass
pass
# AMD ROCm: replace SDPA with amd-aiter Flash Attention if available.
# Note: this fires on the model's compiled source after Unsloth's SDPA
# rewriting. The Unsloth-rewritten SDPA calls carry attn_mask= or
# is_causal=is_causal (variable, not literal True) and will not match
# the aiter guards. User-added model code with bare
# scaled_dot_product_attention(q, k, v, is_causal=True) will match.
# A future PR can also rewrite the Unsloth SDPA shim to emit
# is_causal=True literally where provably safe.
new_source = replace_sdpa_with_amd_aiter(new_source)
scaled_dot_product_attention_modules[module] = new_source
pass

Expand Down
131 changes: 131 additions & 0 deletions unsloth_zoo/device_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

__all__ = [
"is_hip",
"get_amd_attention_implementation",
"get_amd_flash_attn_func",
"get_device_type",
"DEVICE_TYPE",
"DEVICE_TYPE_TORCH",
Expand Down Expand Up @@ -213,6 +215,135 @@ def is_hip():
return bool(getattr(getattr(torch, "version", None), "hip", None))
pass


@functools.cache
def _detect_gfx_arch():
"""Return the GPU architecture string (e.g. 'gfx942') or None if undetectable."""
import subprocess, re as _re
try:
# rocminfo is the most reliable source
r = subprocess.run(["rocminfo"], capture_output=True, text=True, timeout=10)
m = _re.search(r"gfx[0-9a-f]+", r.stdout)
if m:
return m.group(0)
Comment on lines +243 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Probe the active HIP device before rocminfo

On mixed-architecture ROCm hosts, or when HIP_VISIBLE_DEVICES exposes a different GPU than physical device 0, this returns the first gfx... token from system-wide rocminfo output before checking PyTorch's active device. Since the later gate only accepts gfx942/gfx950, a supported visible MI300/MI355 can be forced to SDPA if another agent is printed first, or an unsupported visible GPU can repeatedly try aiter because a supported agent was printed first; prefer torch.cuda.get_device_properties() for the current visible device before parsing global CLI output.

Useful? React with 👍 / 👎.

except Exception:
pass
try:
# hipconfig fallback
r = subprocess.run(["hipconfig", "--full"], capture_output=True, text=True, timeout=10)
m = _re.search(r"gfx[0-9a-f]+", r.stdout)
if m:
return m.group(0)
except Exception:
pass
# Final fallback: query torch directly — available in all ROCm wheel installs
# even when rocminfo and hipconfig are absent (e.g. runtime-only containers).
try:
if torch.cuda.is_available():
arch = torch.cuda.get_device_properties(0).gcnArchName
# gcnArchName may be e.g. "gfx942:sramecc+:xnack-" — extract gfxNNNN
import re as _re2
m = _re2.match(r"gfx[0-9a-f]+", arch)
if m:
return m.group(0)
except Exception:
pass
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back to PyTorch for the GFX arch probe

In ROCm runtime or PyTorch-wheel containers that expose an MI300/MI355 GPU to torch but do not include the developer utilities rocminfo or hipconfig, both subprocess probes fail and _detect_gfx_arch() returns None; the architecture gate then treats the supported card as unsupported and returns sdpa, so the new aiter rewrite is never enabled. Querying the active torch device's gcnArchName before returning None would keep the fast path available in these supported runtime-only deployments.

Useful? React with 👍 / 👎.

pass


@functools.cache
def get_amd_attention_implementation():
"""
Return the best available attention implementation for AMD ROCm.

Priority:
1. "amd_aiter" — AMD aiter (pip install amd-aiter, ROCm >= 7.0 required)
2. "sdpa" — PyTorch SDPA via MIOpen (always available on ROCm)

Returns: "amd_aiter" | "sdpa"
"""
if not is_hip():
return "sdpa"

# Gate on ROCm >= 7.0 (amd-aiter has hard ABI dep on libamdhip64.so.7).
# Prefer torch.version.hip — always present in ROCm PyTorch wheels and not
# affected by rocm-smi which reports the kernel driver version, not the
# ROCm runtime version (the two can differ on wheel-only installs).
try:
_hip_ver = getattr(torch.version, "hip", None)
if _hip_ver is not None:
_hip_major = int(str(_hip_ver).split(".")[0])
if _hip_major < 7:
return "sdpa"
else:
# Fallback for unusual builds without torch.version.hip
rocm_version = _detect_rocm_major_minor()
if rocm_version is not None:
if int(rocm_version.split(".")[0]) < 7:
return "sdpa"
except Exception:
return "sdpa"

# Gate on GPU architecture — aiter's CK/ASM kernels are CDNA-only.
# flash_attn_func is always importable on any arch where aiter installs,
# but the underlying kernels only work on gfx942 (MI300X/MI325X) and
# gfx950 (MI355X). Consumer RDNA (gfx1100, gfx1200, etc.) is not supported.
try:
_gfx = _detect_gfx_arch()
_AITER_SUPPORTED_ARCHS = ("gfx942", "gfx950") # CDNA3, CDNA4
if _gfx not in _AITER_SUPPORTED_ARCHS:
return "sdpa"
except Exception:
return "sdpa" # fail safe: unknown arch → use SDPA

# Check for amd-aiter (AMD AI Tensor Engine for ROCm)
try:
import importlib.util # must import submodule explicitly (not bare importlib)
if importlib.util.find_spec("aiter") is not None:
import aiter as _aiter
# Validate: AMD AI tensor engine exposes the functional flash_attn_func API.
# FlashAttnFunc (class API) requires 13+ positional args and cannot be
# wrapped safely; only accept the simpler flash_attn_func functional API.
if hasattr(_aiter, "flash_attn_func"):
return "amd_aiter"
except Exception:
pass

return "sdpa"


@functools.cache
def get_amd_flash_attn_func():
"""
Return the amd-aiter flash attention function, or None if unavailable.

Checks both naming conventions across amd-aiter versions:
- flash_attn_func (newer, lowercase)
- FlashAttnFunc (older, class-based callable)

Call signature: func(q, k, v, causal=True)
Shapes: q/k/v = (batch, seqlen, nheads, headdim), float16 or bfloat16
Returns: output tensor, same shape as q
"""
if get_amd_attention_implementation() != "amd_aiter":
return None
try:
import aiter as _aiter
if hasattr(_aiter, "flash_attn_func"):
return _aiter.flash_attn_func
# FlashAttnFunc is a torch.autograd.Function whose .apply() requires 13+
# positional arguments (dropout_p, softmax_scale, causal, window_size,
# bias, alibi_slopes, deterministic, return_lse, return_softmax,
# is_grad_enabled, ...) — see ROCm/aiter:aiter/ops/mha.py.
# We cannot safely wrap it without knowing the required defaults for the
# installed aiter version. Return None so callers fall back to SDPA.
# (FlashAttnFunc environments should expose flash_attn_func in aiter >= 0.7)
except Exception:
pass
return None


@functools.cache
def get_device_type():
if _IS_MLX:
Expand Down
Loading