unsloth/studio/backend/tests/test_grouped_mm_rdna4_fallback.py
Leo Borcherding 3ea6d14c39
AMD: CI coverage for recent fixes, plus three wrong gfx ids (#7431)
* ROCm/AMD CI coverage: arch-table parity, native-Linux lib prepend, RDNA4 grouped_mm, discovery-based shell suite

Three merged ROCm fixes shipped without tests, and the CI wiring that
would have run them was gated on files the fixes do not touch.

Tests added (113):
  tests/studio/install/test_rocm_arch_table_parity.py (27)
    diffs the four duplicated gfx -> AMD pip-index tables across
    install.sh, install.ps1, studio/setup.ps1 and install_python_stack.py,
    plus the GPU-name -> arch tables and the torch 2.11 pin allowlist.
  tests/studio/install/test_rocm_native_linux_lib_dirs.py (26)
    covers #7233: system-ROCm lib dirs prepended ahead of bundled
    libggml-hip, the /dev/kfd + not-WSL + libhsa gate, the opt-out env
    var, root resolution order, and source parity between the two copies.
  studio/backend/tests/test_grouped_mm_rdna4_fallback.py (46)
    covers #7292: registration on the CUDA dispatch key, grouped and
    ungrouped numerics, bias/dtype promotion, and the Linux HIP<7.13 +
    RDNA4 name gate, executed from the shipped source rather than a copy.
  tests/studio/test_ci_shell_suite_coverage.py (14)
    fails if either shell runner goes back to a hardcoded list or skips
    a file without a recorded reason.

CI wiring:
  studio-backend-ci.yml: add install.sh / install.ps1 to the path filter
    (the suites it runs assert against those two files, so install-only
    changes -- the shape most AMD/ROCm routing fixes take -- skipped it),
    and replace the 13-file hardcoded shell list with directory
    discovery. That list had fallen seven files behind, including
    test_strixhalo_wsl_reroute.sh, the only shell coverage of the ROCm
    WSL reroute, which had never run on a PR.
  tests/run_all.sh: same discovery loop so local and CI agree.

* Test review fixes: assert on outcomes, not on the code under test

Self-review of the previous commit found four tests that passed for the
wrong reason.

1. The arch-table parity test pinned expected gfx ids copied out of the
   shipped tables, which enshrined three upstream inaccuracies as
   correct: RX 9070 (non-XT) is gfx1201 not gfx1200, RX 7800 XT is
   gfx1101 not gfx1100, and PRO V710 is gfx1101 not gfx1102 per AMD's
   ROCm compatibility matrix. The expectation is now the AMD pip index
   leaf -- the thing the tables exist to produce, and what a wrong
   answer costs the user. The three known drifts are listed explicitly
   with a test asserting they stay cosmetic, i.e. that the wrong and
   right ids still map to the same wheel index. That test turns red the
   day one of them starts routing users to the wrong wheel.

2. The RDNA4 device-name test extracted the regex from worker.py and
   then matched with it, so it could not fail. Widening the pattern --
   the dangerous edit, since it forces the slow Python mm fallback onto
   RDNA3 users -- would have been silently accepted. It now reads the
   live pattern and checks it against fixed cases, plus asserts the
   name match stays guarded by `not _lin_arch` and that the name is
   lowercased before matching.

3. The CI-coverage test matched a verbatim line of studio-backend-ci.yml,
   so reindenting the step would fail the build while a real regression
   to a hardcoded list could slip past a reformat. It now parses the
   YAML, finds the step by name, and asserts on the glob plus the
   absence of individual filenames. The path-filter test likewise reads
   the parsed trigger instead of scanning raw text.

4. A set comprehension in the parity helper had a ternary whose branches
   were identical.

Mutation-tested: widening the RDNA4 regex, desyncing one copy of the
name table, dropping install.sh from the path filter, and re-skipping
the ROCm WSL shell suite each fail at least two tests. Verified on
Linux (WSL Ubuntu 24.04) with CI's torch pin: 86 + 48 pass.

* Fix three wrong gfx ids in the GPU-name arch tables

The name -> gfx tables disagreed with AMD's ROCm compatibility matrix on
three entries. Corrected against the "Radeon GPU" list at
rocm.docs.amd.com/en/latest/compatibility/compatibility-matrix.html:

  RX 9070, RX 9070 GRE   gfx1200 -> gfx1201   (Navi 48, same die as the XT)
  RX 7800 XT, RX 7700 XT gfx1100 -> gfx1101   (Navi 32, not Navi 31)
  PRO W7700              gfx1100 -> gfx1101
  PRO V710               gfx1102 -> gfx1101   (Navi 32, not Navi 33)

No wheel changes for anyone: gfx1200/gfx1201 both resolve to gfx120X-all
and gfx1100/gfx1101/gfx1102 all resolve to gfx110X-all, in all four copies
of the index-family map. That collapse is why the errors survived being
copied into six places -- the leaf-level tests could not see them.

It was not purely cosmetic, though. install.sh's second copy feeds
"Tip: set UNSLOTH_ROCM_GFX_ARCH=<arch>", so a 7800 XT user following the
printed advice exported gfx1100 and made a wrong id authoritative for
every later run. It would also have become a real misroute the moment AMD
split a family across index leaves, as they already do for gfx1151/gfx1150.

Fixed in all six places, which is two more than the table's own "kept in
sync with" comments claim exist:

  install.sh   _infer_amd_gfx_arch_from_gpu_name
  install.sh   case "$_gpu_disp_mkt"          (banner + env tip; undocumented)
  studio/setup.sh
  install.ps1
  studio/setup.ps1
  studio/install_python_stack.py

Ordering is preserved: the gfx1102 arm still precedes gfx1101 in the shell
copies so "RX 7700S" cannot fall onto the "RX 7700" glob, and the
PowerShell copies keep the (?!S) lookahead.

Test changes:
  - test_rocm_arch_table_parity.py gains _AMD_DOCUMENTED_ARCH, exact gfx
    ids transcribed from AMD rather than from the tables. Agreement between
    six copies proves nothing when all six were transcribed from the same
    mistake, so the ground truth has to come from outside. Verified it
    catches the bug: against the pre-fix tables it fails 6 tests.
  - The parity check now covers all six copies. It had four; the two
    install.sh copies were being treated as one, and
    _WIN_GPU_NAME_ARCH_TABLE was not checked at all.
  - test_rocm_support.py's TestGfxArchNameFallback pinned two of the wrong
    ids as expected values; updated, and extended with a 9060 XT and a
    7900 XTX case so each RDNA3/4 die is represented.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Guard against unregistered copies of the GPU-name arch table

Counting the copies by hand is what let them drift: the in-code "kept in
sync with" comments claimed four, the arch-id fix found six, and scanning
the tree turns up a seventh.

TestNoUnregisteredArchTable rediscovers the copies from the source tree
instead of trusting a hand-maintained list. A table line is one that names
a card and gives its arch; real tables score 9-17 such lines and the only
other hits in the repo are two single-line prose comments, so the
three-line threshold is not load-bearing. A companion test asserts the
scan still finds the known copies, so the heuristic cannot go blind and
pass by finding nothing.

The seventh copy is tests/_zoo_rocm_spoof.py, the fixture other ROCm tests
build their fake AMD host from. It states the mapping backwards (gfx ->
the name torch should report), which makes it an independent witness: it
had gfx1101 -> RX 7800 XT and gfx1201 -> RX 9070 XT right while all six
installer copies were wrong, and nothing compared the two. Now they are
round-tripped against each other.

RX 6700 XT is pinned as a known divergence rather than normalised. AMD's
compatibility matrix documents no consumer RX 6000 card and no gfx1031 at
all, the installer arm is commented "gfx103X family", and gfx1031 appears
only as an index-family key, never as a value a name table emits. With no
external source to correct against, changing shipped behaviour would be
guesswork. A test fails if the divergence ever disappears, so the
exemption cannot go stale.

Also adds the reverse of the AMD-matrix check: a documented card that
matches no arm anywhere is a silent CPU fallback rather than a wrong id.
This cannot detect hardware nobody transcribed, which would need a live
fetch of AMD's matrix and a non-hermetic suite; the docstring says so
rather than implying coverage that is not there.

Verified on Linux: 478 passed, plus all five new guards mutation-tested
to confirm each fails when its invariant is broken.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Docstring said six copies; the list under it now has seven

* tests: run discovered shell tests with bash, not sh

tests/run_all.sh discovered tests/sh/ instead of listing files, but still
invoked each one with sh. Every file there declares a bash shebang, and on
Debian/Ubuntu /bin/sh is dash: test_apt_distro_prompt.sh,
test_studio_home_node_dir.sh and test_with_llama_cpp_dir_link_behavior.sh
fail on bashisms under dash and pass under bash. The old hand-written list
happened to name only dash-clean files, so switching to discovery is what
surfaced it. Backend CI already used bash, so this was a local-only break.

Guarded by a new test asserting both runners invoke tests/sh/ with bash.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix Krackan Point (Radeon 860M/840M) routed to the gfx1150 wheel index

The GPU-name tables map 860M/840M and the Ryzen AI 7 350 / AI 5 340 CPU
strings to gfx1150, but Krackan Point is gfx1152. AMD's own lemonade table
(src/cpp/server/system_info.cpp) maps both Krackan iGPUs to gfx1152.

Unlike the three ids already fixed here, this one is not wheel-neutral:
repo.amd.com publishes gfx1150 and gfx1152 as separate index leaves with
separately built torch wheels, so these laptops were installing wheels
built for a different LLVM target. gfx1152 was absent from the codebase
entirely, so it needed the index-family maps, the torch 2.11 floor lists
(same _grouped_mm bug as gfx1150/1151), the Strix reroute set and the
Windows arch allowlist as well as the seven name tables.

The parity test added in this PR did not catch it because its AMD-matrix
expectations stopped at 890M/880M. Added the APU rows, so the case that
actually changes a wheel is now covered: reverting the tables fails 9
tests naming 860M, 840M and Krackan.

gfx1153 (Ryzen AI 5 430 era) is left alone; AMD publishes no gfx1153
wheel family, so there is nothing to route it to.

Verified: bash -n on both shell installers, PowerShell AST parse on both
.ps1 files, python ast.parse on all touched modules, install suite 1334
passed with no new failures against main, shell suite 20 files.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add gfx1152 to unified-memory classifiers, make parity allowlist set-based

Krackan Point (gfx1152, Radeon 860M/840M) is the third RDNA 3.5 APU and
shares one GPU/system-RAM pool exactly like Strix Point (gfx1150) and
Strix Halo (gfx1151), but only the installers knew about it. The two
runtime classifiers still had two-element arch sets, so a Krackan laptop
got the 0.90 discrete headroom factor on a shared pool and ran llama.cpp
without GGML_CUDA_ENABLE_UNIFIED_MEMORY.

- worker.py _rocm_classify_unified_memory: add gfx1152 to the arch set,
  and 860m/840m to the device-name fallback. The NVIDIA GeForce 840M
  cannot collide there: the function is only reached under _hw.IS_ROCM.
- llama_cpp.py _amd_apu_wants_unified_memory: add gfx1152 to the arch set.
- Tests for both, including the :sramecc-:xnack- suffix form.

TestGfx211AllowlistParity compared four hardcoded allowlist strings, so
adding gfx1152 to all four installers correctly turned three assertions
red without any installer actually disagreeing with another. Each test
now extracts the set its installer holds and compares it to one EXPECTED
constant. Order and spacing are free, membership is not, and the next
leaf is a one-line edit instead of four.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-25 18:58:02 -05:00

418 lines
17 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Numerics + gating for the RDNA4 _grouped_mm CPU fallback (PRs #7276 / #7292).
RDNA4 (gfx1200/gfx1201) ships a null HIP `_grouped_mm` kernel on ROCm <= 7.12
(fixed in 7.13, ROCm/TheRock #5284). Training MoE models there crashes with
0xC0000005 on Windows and a plain segfault on Linux, so worker.py registers a
Python mm/bmm fallback on the CUDA dispatch key.
The fallback is silent, GPU-gated, and reimplements a matmul: if it is wrong, an
RX 9070 user does not crash, they train on quietly wrong gradients. Until now the
only coverage was `assert '_gm_lib.impl("_grouped_mm"' in source` -- the math was
never executed once, in any suite.
worker.py cannot be imported here (module-level structlog/backend imports), so
`_install_grouped_mm_cpu_fallback` is lifted out with ast and driven with a fake
`torch_mod` that forwards to real CPU torch. That also pins the op surface: the
fallback may only use the ops the fake exposes, and the registration is captured
instead of hitting a real CUDA dispatch key that CI runners do not have.
The two gates around it are exec'd straight out of the source so this file tests
the shipped expressions rather than a copy of them.
"""
import ast
import re
import textwrap
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
_WORKER_PATH = Path(__file__).resolve().parents[1] / "core" / "training" / "worker.py"
_WORKER_SOURCE = _WORKER_PATH.read_text(encoding = "utf-8")
def _load_installer():
"""exec just _install_grouped_mm_cpu_fallback out of worker.py."""
tree = ast.parse(_WORKER_SOURCE)
fn = [
n
for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name == "_install_grouped_mm_cpu_fallback"
]
assert fn, "_install_grouped_mm_cpu_fallback not found in core/training/worker.py"
ns: dict = {}
exec(compile(ast.Module(body = fn, type_ignores = []), str(_WORKER_PATH), "exec"), ns)
return ns["_install_grouped_mm_cpu_fallback"]
_install_grouped_mm_cpu_fallback = _load_installer()
class _RecordingLibrary:
"""Stands in for torch.library.Library: captures the registration instead of
binding it to a CUDA dispatch key no CI runner has."""
def __init__(self, namespace, kind):
self.namespace = namespace
self.kind = kind
self.registrations = []
def impl(self, name, fn, dispatch_key):
self.registrations.append((name, fn, dispatch_key))
class _RecordingLogger:
def __init__(self):
self.info_calls = []
self.warning_calls = []
def info(self, *args, **kwargs):
self.info_calls.append(args)
def warning(self, *args, **kwargs):
self.warning_calls.append(args)
def _fake_torch():
"""Real CPU torch behind the exact op surface the fallback is allowed to use.
Anything else the fallback reaches for raises AttributeError here, which is
the point: a new dependency has to be a deliberate edit, not a silent one."""
return SimpleNamespace(
library = SimpleNamespace(Library = _RecordingLibrary),
mm = torch.mm,
bmm = torch.bmm,
matmul = torch.matmul,
cat = torch.cat,
zeros = torch.zeros,
)
@pytest.fixture
def fallback():
"""The registered _grouped_mm implementation, plus the Library it landed on."""
torch_mod = _fake_torch()
logger = _RecordingLogger()
lib = _install_grouped_mm_cpu_fallback(torch_mod, logger, "test")
assert lib.registrations, "the fallback registered nothing"
name, fn, key = lib.registrations[0]
return SimpleNamespace(fn = fn, lib = lib, logger = logger, name = name, key = key)
class TestRegistration:
"""Where the override lands. Getting the namespace or dispatch key wrong is a
silent no-op: training still crashes on the null HIP kernel."""
def test_overrides_aten_grouped_mm_on_the_cuda_key(self, fallback):
assert fallback.lib.namespace == "aten"
assert fallback.lib.kind == "IMPL"
assert fallback.name == "_grouped_mm"
# ROCm dispatches through the CUDA key; "HIP"/"PrivateUse1" would not bind.
assert fallback.key == "CUDA"
def test_registers_exactly_once(self, fallback):
assert len(fallback.lib.registrations) == 1
def test_returns_the_library_so_the_caller_can_keep_it_alive(self, fallback):
"""A dropped Library is garbage collected and the override silently
unregisters mid-run; worker.py parks it in a module global."""
assert isinstance(fallback.lib, _RecordingLibrary)
assert "_WINDOWS_ROCM_GROUPED_MM_LIB = _install_grouped_mm_cpu_fallback(" in _WORKER_SOURCE
def test_logs_the_patch_with_its_label(self, fallback):
assert fallback.logger.info_calls, "the patch must be visible in the run log"
assert "test" in fallback.logger.info_calls[0]
class TestUngroupedNumerics:
"""offs=None: plain matmul, one path per rank combination. The 3-D case is
the regression #7292 fixed -- an unconditional mm() broke MoE experts."""
def test_2d_by_2d_matches_mm(self, fallback):
a = torch.randn(6, 4)
b = torch.randn(4, 5)
torch.testing.assert_close(fallback.fn(a, b), torch.mm(a, b))
def test_3d_by_3d_matches_bmm(self, fallback):
a = torch.randn(3, 6, 4)
b = torch.randn(3, 4, 5)
torch.testing.assert_close(fallback.fn(a, b), torch.bmm(a, b))
def test_3d_by_2d_matches_matmul(self, fallback):
a = torch.randn(3, 6, 4)
b = torch.randn(4, 5)
torch.testing.assert_close(fallback.fn(a, b), torch.matmul(a, b))
def test_2d_by_3d_matches_matmul(self, fallback):
a = torch.randn(6, 4)
b = torch.randn(3, 4, 5)
torch.testing.assert_close(fallback.fn(a, b), torch.matmul(a, b))
def test_non_contiguous_inputs_are_handled(self, fallback):
"""Transposed views reach _grouped_mm constantly; every path calls
.contiguous() and this catches it if one stops."""
a = torch.randn(4, 6).t()
b = torch.randn(5, 4).t()
torch.testing.assert_close(fallback.fn(a, b), torch.mm(a, b))
class TestGroupedNumerics:
"""offs=[end-row of each group], the MoE token-routing layout."""
def test_matches_per_group_mm_with_3d_weights(self, fallback):
a = torch.randn(7, 4)
b = torch.randn(3, 4, 5)
offs = torch.tensor([2, 5, 7])
expected = torch.cat([a[0:2] @ b[0], a[2:5] @ b[1], a[5:7] @ b[2]], dim = 0)
torch.testing.assert_close(fallback.fn(a, b, offs), expected)
def test_shared_2d_weight_is_reused_for_every_group(self, fallback):
a = torch.randn(7, 4)
b = torch.randn(4, 5)
offs = torch.tensor([2, 5, 7])
torch.testing.assert_close(fallback.fn(a, b, offs), a @ b)
def test_empty_group_produces_no_rows(self, fallback):
"""An expert that routed zero tokens (offs[i] == offs[i-1]) must
contribute nothing, not a stray row."""
a = torch.randn(5, 4)
b = torch.randn(3, 4, 5)
offs = torch.tensor([2, 2, 5])
expected = torch.cat([a[0:2] @ b[0], a[2:5] @ b[2]], dim = 0)
got = fallback.fn(a, b, offs)
assert got.shape == (5, 5)
torch.testing.assert_close(got, expected)
def test_rows_past_the_last_offset_are_not_dropped(self, fallback):
"""Trailing tokens beyond offs[-1] go through the last expert; dropping
them would silently shrink the output instead of raising."""
a = torch.randn(7, 4)
b = torch.randn(3, 4, 5)
offs = torch.tensor([2, 5])
expected = torch.cat([a[0:2] @ b[0], a[2:5] @ b[1], a[5:7] @ b[-1]], dim = 0)
got = fallback.fn(a, b, offs)
assert got.shape[0] == a.shape[0]
torch.testing.assert_close(got, expected)
def test_zero_rows_returns_an_empty_result_not_an_error(self, fallback):
a = torch.randn(0, 4)
b = torch.randn(3, 4, 5)
offs = torch.tensor([], dtype = torch.int64)
got = fallback.fn(a, b, offs)
assert got.shape == (0, 5)
assert got.dtype == a.dtype
def test_offsets_may_arrive_as_a_device_tensor_of_any_int_dtype(self, fallback):
a = torch.randn(4, 4)
b = torch.randn(2, 4, 5)
expected = torch.cat([a[0:2] @ b[0], a[2:4] @ b[1]], dim = 0)
for dtype in (torch.int32, torch.int64):
torch.testing.assert_close(
fallback.fn(a, b, torch.tensor([2, 4], dtype = dtype)), expected
)
class TestBiasAndDtype:
def test_bias_is_added(self, fallback):
a = torch.randn(6, 4)
b = torch.randn(4, 5)
bias = torch.randn(5)
torch.testing.assert_close(fallback.fn(a, b, None, bias), torch.mm(a, b) + bias)
def test_bias_is_added_on_the_grouped_path_too(self, fallback):
a = torch.randn(4, 4)
b = torch.randn(2, 4, 5)
bias = torch.randn(5)
offs = torch.tensor([2, 4])
expected = torch.cat([a[0:2] @ b[0], a[2:4] @ b[1]], dim = 0) + bias
torch.testing.assert_close(fallback.fn(a, b, offs, bias), expected)
def test_out_dtype_is_honoured(self, fallback):
a = torch.randn(6, 4)
b = torch.randn(4, 5)
got = fallback.fn(a, b, None, None, torch.float64)
assert got.dtype == torch.float64
torch.testing.assert_close(got, torch.mm(a, b).to(torch.float64))
def test_promotion_from_bias_is_cast_back_to_the_input_dtype(self, fallback):
"""Without the restore, a promoted result changes the autograd dtype
downstream of every MoE layer."""
a = torch.randn(6, 4, dtype = torch.float32)
b = torch.randn(4, 5, dtype = torch.float32)
bias = torch.randn(5, dtype = torch.float64)
got = fallback.fn(a, b, None, bias)
assert got.dtype == torch.float32
def test_out_dtype_wins_over_the_input_dtype_restore(self, fallback):
a = torch.randn(6, 4, dtype = torch.float32)
b = torch.randn(4, 5, dtype = torch.float32)
bias = torch.randn(5, dtype = torch.float64)
got = fallback.fn(a, b, None, bias, torch.float64)
assert got.dtype == torch.float64
def test_bf16_inputs_stay_bf16(self, fallback):
"""The dtype training actually runs in."""
a = torch.randn(6, 4).to(torch.bfloat16)
b = torch.randn(4, 5).to(torch.bfloat16)
got = fallback.fn(a, b)
assert got.dtype == torch.bfloat16
torch.testing.assert_close(got.float(), (a.float() @ b.float()), rtol = 2e-2, atol = 2e-2)
def _exec_source_snippet(anchor: str, last_line: str, **variables):
"""Run a slice of worker.py verbatim, so the gate under test is the shipped
one and not a copy that can drift."""
start = _WORKER_SOURCE.find(anchor)
assert start != -1, f"gate snippet not found in worker.py: {anchor!r}"
start = _WORKER_SOURCE.rfind("\n", 0, start) + 1 # keep the indent for dedent()
end = _WORKER_SOURCE.find(last_line, start)
assert end != -1, f"end of gate snippet not found: {last_line!r}"
snippet = textwrap.dedent(_WORKER_SOURCE[start : end + len(last_line)])
ns = {"re": re, **variables}
exec(compile(snippet, str(_WORKER_PATH), "exec"), ns)
return ns
class TestLinuxHipVersionGate:
"""PR #7292's Linux gate. Too low a floor keeps the slow Python fallback on
fixed ROCm 7.13+; too high reintroduces the segfault on 7.12."""
_ANCHOR = '_m = re.match(r"(\\d+)\\.(\\d+)", _hip_str)'
_LAST = '_hip_lt_713 = "rocmsdk" not in _ver'
def _decide(self, hip_str, version):
ns = _exec_source_snippet(self._ANCHOR, self._LAST, _hip_str = hip_str, _ver = version.lower())
return ns["_hip_lt_713"]
@pytest.mark.parametrize(
"hip_str,version,affected",
[
("7.12.0", "2.10.0+rocm7.12.0", True), # the broken kernel
("7.6.0", "2.9.0+rocm7.6.0", True),
("6.4.0", "2.8.0+rocm6.4.0", True),
("7.13.0", "2.11.0+rocm7.13.0", False), # AMD's fix
("7.14.0", "2.11.0+rocm7.14.0", False),
("8.0.0", "2.12.0+rocm8.0.0", False),
],
)
def test_torch_version_hip_decides_when_present(self, hip_str, version, affected):
assert self._decide(hip_str, version) is affected
@pytest.mark.parametrize(
"version,affected",
[
("2.10.0+rocm7.12.0", True),
("2.11.0+rocm7.13.0", False),
("2.11.0+rocm7.14.0", False),
],
)
def test_falls_back_to_the_rocm_tag_in_torch_version(self, version, affected):
"""AMD SDK / Radeon wheels leave torch.version.hip unset."""
assert self._decide("", version) is affected
def test_unknown_version_is_assumed_affected(self):
"""Fallback is slow but correct; a missed guard is a crash."""
assert self._decide("", "2.9.0+unknown") is True
def test_rocmsdk_wheels_without_a_version_are_assumed_fixed(self):
"""rocmsdk wheels post-date the gfx120X fix."""
assert self._decide("", "2.10.0+rocmsdk20260107") is False
class TestLinuxRdna4NameMatch:
"""The name regex is the fallback when a wheel omits gcnArchName."""
def _pattern(self):
"""Read whatever pattern worker.py currently uses, not a copy of the one
it used when this test was written. Anchoring on the literal pattern text
would make a *widened* regex -- the dangerous edit, since it silently
forces the slow Python fallback onto RDNA3 users -- fail as "moved"
instead of being checked against the cases below."""
m = re.search(r"re\.search\(r\"([^\"]+)\",\s*_lin_name\)", _WORKER_SOURCE)
assert m, "could not locate the RDNA4 device-name regex in worker.py"
return m.group(1)
def test_name_is_lowercased_before_matching(self):
"""The pattern is all-lowercase, so it only works against a lowercased
name. Device names arrive mixed case ("AMD Radeon RX 9070 XT")."""
assert self._pattern() == self._pattern().lower(), "pattern is not all-lowercase"
assert re.search(
r"_lin_name\s*=\s*\(getattr\(_props,\s*\"name\",\s*\"\"\)\s*or\s*\"\"\)\.lower\(\)",
_WORKER_SOURCE,
), "worker.py must lowercase the device name before matching the RDNA4 pattern"
def test_name_match_is_only_a_fallback_when_arch_is_unknown(self):
"""gcnArchName is authoritative when present. Letting the name regex fire
alongside a known arch would misclassify any card whose marketing name
happens to look RDNA4."""
assert re.search(
r"not _lin_arch and re\.search\(r\"[^\"]+\",\s*_lin_name\)", _WORKER_SOURCE
), "the RDNA4 name regex must be guarded by `not _lin_arch`"
@pytest.mark.parametrize(
"name,is_rdna4",
[
("AMD Radeon RX 9070 XT", True),
("AMD Radeon RX 9060 XT", True),
("Radeon RX9070", True),
("AMD Radeon AI PRO R9700", True),
("AMD Radeon RX 7900 XTX", False), # RDNA3, kernel is fine
("AMD Radeon 8060S Graphics", False), # Strix Halo
("AMD Radeon RX 6800 XT", False),
("NVIDIA GeForce RTX 4090", False),
],
)
def test_matches_only_rdna4_cards(self, name, is_rdna4):
assert bool(re.search(self._pattern(), name.lower())) is is_rdna4
class TestLinuxGateStructure:
"""The block is a few hundred lines into run_training_process and can only be
checked structurally; these pin the parts a refactor would quietly drop."""
def _linux_block(self):
start = _WORKER_SOURCE.find("1f-linux")
assert start != -1, "the Linux ROCm gfx120X guard (#7292) is gone from worker.py"
end = _WORKER_SOURCE.find("1g.", start)
assert end != -1
return _WORKER_SOURCE[start:end]
def test_gated_on_linux_and_rocm(self):
block = self._linux_block()
assert 'sys.platform.startswith("linux")' in block
assert "_hw.IS_ROCM" in block, "guard must not run on NVIDIA/CPU hosts"
def test_requires_both_rdna4_and_an_affected_hip(self):
block = self._linux_block()
assert "if _rdna4 and _hip_lt_713:" in block
def test_scans_every_visible_device(self):
"""device_map="balanced" can place layers on a later card, so checking
device 0 alone misses the RDNA4 GPU."""
block = self._linux_block()
assert "for _i in range(_torch_lin.cuda.device_count()):" in block
def test_matches_both_rdna4_arch_ids(self):
block = self._linux_block()
assert '("gfx1200", "gfx1201")' in block
def test_failure_to_patch_is_non_fatal(self):
"""A broken patch attempt must not take down the whole training run."""
block = self._linux_block()
assert "except Exception" in block
assert "logger.warning" in block
def test_windows_and_linux_share_one_implementation(self):
"""Two copies of this fallback would drift; #7292 deliberately hoisted it."""
assert _WORKER_SOURCE.count("def _install_grouped_mm_cpu_fallback(") == 1
assert _WORKER_SOURCE.count("_install_grouped_mm_cpu_fallback(") >= 3 # def + win32 + linux
if __name__ == "__main__":
pytest.main([__file__, "-v"])