From ee38e604a31fb7bfbae0e1b45aa8cd9a955476ee Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 28 Jul 2026 00:24:14 -0500 Subject: [PATCH 1/5] amd: require bitsandbytes>=0.50.0 in the amd extra bnb <= 0.49.2 NaNs at decode shape on every AMD GPU. The ROCm 4-bit GEMV fix (bnb PR #1887) first ships in 0.50.0, on PyPI since 2026-07-24, so the old >=0.49.1 floor could still resolve the broken range. Mirrors the same change made on the pip release branch in #7278. --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f57ecf4df..67065b60fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1224,8 +1224,10 @@ intel = [ ] amd = [ "unsloth[huggingfacenotorch]", - "bitsandbytes>=0.49.1 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')", - "bitsandbytes>=0.49.1 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + # bnb <= 0.49.2 NaNs at decode shape on every AMD GPU; the ROCm 4-bit GEMV + # fix (bnb PR #1887) first ships in 0.50.0, which is now on PyPI. + "bitsandbytes>=0.50.0 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')", + "bitsandbytes>=0.50.0 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", ] rocm702-torch280 = [ "unsloth[amd]", From 48b5eb1a14ef57a973946c5a133b68b81fc038e4 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 28 Jul 2026 00:40:49 -0500 Subject: [PATCH 2/5] tests: pin the amd/huggingfacenotorch extras and name the audit failure security-audit.yml builds its hf-stack scan set by indexing [huggingfacenotorch] out of pyproject.toml with no guard. The pip release branch never had that extra, so advisory audit and the three pip scan-packages jobs died on a bare KeyError from at least 2026-07-08 to 2026-07-27 without anyone reading it as a missing extra. Add a contract test for both extras (existence, torch-free, bnb floor excludes the NaN-at-decode range) plus a check that every extra the workflow indexes actually exists, and give the workflow a message that names the problem instead of a raw traceback. --- .github/workflows/security-audit.yml | 20 ++++- tests/python/test_amd_extras_contract.py | 100 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 tests/python/test_amd_extras_contract.py diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 27eafbedea..008a972a88 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -228,7 +228,15 @@ jobs: with open("pyproject.toml", "rb") as f: d = tomllib.load(f) core = d["project"]["dependencies"] - extras = d["project"]["optional-dependencies"]["huggingfacenotorch"] + try: + extras = d["project"]["optional-dependencies"]["huggingfacenotorch"] + except KeyError: + # A bare KeyError here reads as a generic crash, which is how this + # went unnoticed on the pip branch for three weeks. + raise SystemExit( + "pyproject.toml has no [huggingfacenotorch] extra, so the hf-stack " + "scan set cannot be built. Add the extra, or update this workflow." + ) print("# Auto-generated from pyproject.toml by security-audit.yml.") print("# core deps + huggingfacenotorch extras.") for spec in core + extras: @@ -821,7 +829,15 @@ jobs: with open("pyproject.toml", "rb") as f: d = tomllib.load(f) core = d["project"]["dependencies"] - extras = d["project"]["optional-dependencies"]["huggingfacenotorch"] + try: + extras = d["project"]["optional-dependencies"]["huggingfacenotorch"] + except KeyError: + # A bare KeyError here reads as a generic crash, which is how this + # went unnoticed on the pip branch for three weeks. + raise SystemExit( + "pyproject.toml has no [huggingfacenotorch] extra, so the hf-stack " + "scan set cannot be built. Add the extra, or update this workflow." + ) print("# Auto-generated from pyproject.toml by security-audit.yml.") print("# core deps + huggingfacenotorch extras.") for spec in core + extras: diff --git a/tests/python/test_amd_extras_contract.py b/tests/python/test_amd_extras_contract.py new file mode 100644 index 0000000000..1b9f615491 --- /dev/null +++ b/tests/python/test_amd_extras_contract.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Contract tests for the amd / huggingfacenotorch extras. + +security-audit.yml builds its hf-stack scan set by indexing +[huggingfacenotorch] straight out of pyproject.toml. When the pip release +branch shipped without that extra, four security jobs died on a bare +KeyError for three weeks and nobody noticed, because the failure looked +like a generic Python crash rather than a missing extra. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import pytest +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "pyproject.toml" +SECURITY_AUDIT = REPO_ROOT / ".github" / "workflows" / "security-audit.yml" + +# bnb <= 0.49.2 NaNs at decode shape on every AMD GPU; the ROCm 4-bit GEMV +# fix (bnb PR #1887) first ships in 0.50.0. +BNB_MIN = Version("0.50.0") + + +def _extras() -> dict[str, list[str]]: + if sys.version_info >= (3, 11): + import tomllib + else: + tomllib = pytest.importorskip("tomli") + data = tomllib.loads(PYPROJECT.read_text(encoding = "utf-8")) + return data["project"]["optional-dependencies"] + + +def _project_name(spec: str) -> str: + """Leading distribution name of a PEP 508 requirement, lowercased.""" + return re.split(r"[<>=!~;\[\s@]", spec.strip(), maxsplit = 1)[0].strip().lower() + + +class TestExtrasExist: + """Both extras must be present, on every branch.""" + + @pytest.mark.parametrize("name", ["huggingfacenotorch", "amd"]) + def test_extra_present(self, name: str): + assert name in _extras(), f"pyproject.toml is missing the [{name}] extra" + + def test_amd_pulls_the_no_torch_stack(self): + assert any( + s.replace(" ", "") == "unsloth[huggingfacenotorch]" for s in _extras()["amd"] + ), "the amd extra must pull unsloth[huggingfacenotorch]" + + +class TestHuggingfaceNoTorchIsTorchFree: + """The whole point of the extra is that it names no torch distribution.""" + + @pytest.mark.parametrize("banned", ["torch", "torchvision"]) + def test_no_torch_distribution(self, banned: str): + named = [s for s in _extras()["huggingfacenotorch"] if _project_name(s) == banned] + assert not named, f"[huggingfacenotorch] must not name {banned}: {named}" + + +class TestAmdBitsandbytesFloor: + """Guards the NaN-at-decode range out of the AMD install path.""" + + def test_every_marker_line_excludes_the_broken_range(self): + specs = [s for s in _extras()["amd"] if _project_name(s) == "bitsandbytes"] + assert specs, "the amd extra must pin bitsandbytes" + for spec in specs: + requirement = spec.split(";", 1)[0].strip() + allowed = SpecifierSet(requirement[len("bitsandbytes"):].strip()) + assert not allowed.contains(Version("0.49.2")), ( + f"{requirement} still admits bnb 0.49.2, which NaNs at decode shape on AMD" + ) + assert allowed.contains(BNB_MIN), f"{requirement} excludes the fixed release {BNB_MIN}" + + +class TestSecurityAuditWorkflowStaysInSync: + """Every extra the audit workflow indexes has to actually exist. + + This is the generic form of the failure: the workflow reaches into + pyproject.toml by name, so any rename or omission takes out the scan + jobs rather than the branch that caused it. + """ + + def test_indexed_extras_exist(self): + referenced = set( + re.findall( + r'optional-dependencies"\]\["([^"]+)"\]', + SECURITY_AUDIT.read_text(encoding = "utf-8"), + ) + ) + assert referenced, "expected security-audit.yml to index at least one extra" + missing = sorted(referenced - set(_extras())) + assert not missing, f"security-audit.yml indexes extras that pyproject.toml lacks: {missing}" From 54147ddedcc67482731d795a371f9b7c1cf2aecf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:44:09 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/python/test_amd_extras_contract.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/python/test_amd_extras_contract.py b/tests/python/test_amd_extras_contract.py index 1b9f615491..806f8dd49c 100644 --- a/tests/python/test_amd_extras_contract.py +++ b/tests/python/test_amd_extras_contract.py @@ -73,10 +73,10 @@ class TestAmdBitsandbytesFloor: assert specs, "the amd extra must pin bitsandbytes" for spec in specs: requirement = spec.split(";", 1)[0].strip() - allowed = SpecifierSet(requirement[len("bitsandbytes"):].strip()) - assert not allowed.contains(Version("0.49.2")), ( - f"{requirement} still admits bnb 0.49.2, which NaNs at decode shape on AMD" - ) + allowed = SpecifierSet(requirement[len("bitsandbytes") :].strip()) + assert not allowed.contains( + Version("0.49.2") + ), f"{requirement} still admits bnb 0.49.2, which NaNs at decode shape on AMD" assert allowed.contains(BNB_MIN), f"{requirement} excludes the fixed release {BNB_MIN}" @@ -97,4 +97,6 @@ class TestSecurityAuditWorkflowStaysInSync: ) assert referenced, "expected security-audit.yml to index at least one extra" missing = sorted(referenced - set(_extras())) - assert not missing, f"security-audit.yml indexes extras that pyproject.toml lacks: {missing}" + assert ( + not missing + ), f"security-audit.yml indexes extras that pyproject.toml lacks: {missing}" From 3c1819630490a52c18a4d1a1a8425e88fd1af805 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 28 Jul 2026 13:48:36 -0500 Subject: [PATCH 4/5] amd: cite the 0.50.0 ROCm work accurately in the bnb floor comment The comment credited bnb PR #1887 as "the ROCm 4-bit GEMV fix" for every AMD GPU. #1887 decouples blocksize from warp size and fixes a hardcoded warp size of 32 in kgemm_4bit_inference_naive, which is a CDNA problem by construction. The RDNA-side work is #1979 (fused 4-bit SIMT GEMM) and #2012 (RDNA3/4 workgroup resonance). All three first ship in 0.50.0, so the >=0.50.0 floor is unchanged; only the justification was wrong. --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 67065b60fc..f88faee35d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1224,8 +1224,9 @@ intel = [ ] amd = [ "unsloth[huggingfacenotorch]", - # bnb <= 0.49.2 NaNs at decode shape on every AMD GPU; the ROCm 4-bit GEMV - # fix (bnb PR #1887) first ships in 0.50.0, which is now on PyPI. + # 4-bit decode is unreliable on ROCm before 0.50.0, the first PyPI release + # carrying the full path: blocksize/warp decoupling (bnb #1887), fused SIMT + # GEMM on RDNA (#1979), RDNA3/4 workgroup fix (#2012). "bitsandbytes>=0.50.0 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')", "bitsandbytes>=0.50.0 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", ] From 0e82ce7e0bdc0ffa44f96902314f24193d18d098 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 28 Jul 2026 13:48:46 -0500 Subject: [PATCH 5/5] tests: match the corrected bnb 0.50.0 justification Drops the unverified "NaNs on every AMD GPU" phrasing and the single-PR attribution from the test's comments and assertion message. The floor itself is unchanged. --- tests/python/test_amd_extras_contract.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/python/test_amd_extras_contract.py b/tests/python/test_amd_extras_contract.py index 1b9f615491..ff59ba93f5 100644 --- a/tests/python/test_amd_extras_contract.py +++ b/tests/python/test_amd_extras_contract.py @@ -24,8 +24,8 @@ REPO_ROOT = Path(__file__).resolve().parents[2] PYPROJECT = REPO_ROOT / "pyproject.toml" SECURITY_AUDIT = REPO_ROOT / ".github" / "workflows" / "security-audit.yml" -# bnb <= 0.49.2 NaNs at decode shape on every AMD GPU; the ROCm 4-bit GEMV -# fix (bnb PR #1887) first ships in 0.50.0. +# 4-bit decode is unreliable on ROCm before 0.50.0, the first PyPI release +# carrying the full path (bnb #1887, #1979, #2012). BNB_MIN = Version("0.50.0") @@ -66,7 +66,7 @@ class TestHuggingfaceNoTorchIsTorchFree: class TestAmdBitsandbytesFloor: - """Guards the NaN-at-decode range out of the AMD install path.""" + """Keeps the pre-0.50.0 ROCm range out of the AMD install path.""" def test_every_marker_line_excludes_the_broken_range(self): specs = [s for s in _extras()["amd"] if _project_name(s) == "bitsandbytes"] @@ -75,7 +75,7 @@ class TestAmdBitsandbytesFloor: requirement = spec.split(";", 1)[0].strip() allowed = SpecifierSet(requirement[len("bitsandbytes"):].strip()) assert not allowed.contains(Version("0.49.2")), ( - f"{requirement} still admits bnb 0.49.2, which NaNs at decode shape on AMD" + f"{requirement} still admits bnb 0.49.2, which predates the ROCm 4-bit fixes" ) assert allowed.contains(BNB_MIN), f"{requirement} excludes the fixed release {BNB_MIN}"