diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index fc4b7fa3a9..6fa88df01b 100644 --- a/.github/workflows/mlx-ci.yml +++ b/.github/workflows/mlx-ci.yml @@ -1,30 +1,35 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Focused PR gate for the MLX dispatch surface. One job, two matrix -# variants: +# Focused PR gate for the MLX dispatch surface, running on a real +# Apple Silicon runner. # -# - linux-cpu-spoof ubuntu-latest with hardware probes spoofed via -# monkeypatch (no Apple Silicon, no real GPU, no -# real MLX install required). -# - macos-m1-real macos-14 (M1, 3 vCPU / 7 GB / Apple Silicon -# standard runner -- FREE for public repositories -# per the GitHub Actions billing reference, larger -# variants like macos-14-large/-xlarge are paid -# so we deliberately avoid those). Adds two -# Mac-only steps before the dispatch tests: -# 1. verify unsloth._IS_MLX flips True on real -# Apple Silicon (no spoof); -# 2. smoke-import every PR-A MLX-only module -# (mlx_loader, mlx_trainer, mlx_compile, -# mlx_utils, mlx_cce, gated_delta_vjp). Each -# does `import mlx.core as mx` at module top -# level, so this catches a future change that -# breaks the real `mlx` PyPI wheel without -# needing a Mac developer in the loop. +# Runner: macos-14 (M1, 3 vCPU / 7 GB / Apple Silicon standard runner +# -- FREE for public repositories per the GitHub Actions billing +# reference; larger variants like macos-14-large/-xlarge are paid so +# we deliberately avoid those). # -# Both variants then run the SAME three dispatch test files documented -# in tests/studio/README.md: +# Why a single Mac job (no Linux+spoof leg): the dispatch tests are +# 100% spoofed monkeypatches and run identically on any host, so the +# Linux leg was duplicating the matrix tests already covered on Mac +# while missing everything Apple-specific. The Mac job runs the SAME +# spoofed matrix PLUS three things only a real Apple Silicon host +# can prove: +# +# 1. unsloth._IS_MLX flips True on Darwin+arm64 with mlx genuinely +# installed (no spoof). +# 2. Every PR-A MLX-only unsloth_zoo module (mlx_loader, mlx_trainer, +# mlx_compile, mlx_utils, mlx_cce, gated_delta_vjp) imports +# against the real `mlx` + `mlx-lm` + `mlx-vlm` PyPI wheels -- +# each does `import mlx.core as mx` at module top level, so this +# catches a future change that breaks the real wheels without +# needing a Mac developer in the loop. +# 3. The hardware-dispatch spoofs do not collide with the real +# environment (the test fixture installs a MetaPathFinder that +# blocks `import mlx.core` for "no-mlx" profiles, faithfully +# simulating a Mac without mlx even when mlx IS installed). +# +# Three dispatch test files documented in tests/studio/README.md: # - test_hardware_dispatch_matrix.py parametrized 7-profile matrix # + 2 dispatch-priority canaries # - test_is_mlx_dispatch_gate.py AST + runtime guard on @@ -32,9 +37,7 @@ # - test_mlx_training_worker_behaviors.py AST contract checks on # studio/backend/core/training/worker.py # -# Surfaces two PR checks ("MLX dispatch (linux-cpu-spoof)" and -# "MLX dispatch (macos-m1-real)") sharing one workflow definition so -# adding new dispatch tests applies to both runners automatically. +# Surfaces a single PR check ("MLX CI on Mac M1 / dispatch"). # # Security audit footprint: every package this workflow installs is # already covered by .github/workflows/security-audit.yml -- the deps @@ -44,7 +47,7 @@ # git URL through PyPI metadata; the audit comment in security-audit.yml # documents this). No new package is introduced solely by MLX CI. -name: MLX CI +name: MLX CI on Mac M1 on: pull_request: @@ -71,21 +74,9 @@ permissions: jobs: dispatch: - name: MLX dispatch (${{ matrix.label }}) - runs-on: ${{ matrix.os }} - timeout-minutes: ${{ matrix.timeout }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - label: linux-cpu-spoof - real_mlx: false - timeout: 10 - - os: macos-14 - label: macos-m1-real - real_mlx: true - timeout: 15 + name: dispatch + runs-on: macos-14 + timeout-minutes: 15 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -94,64 +85,24 @@ jobs: python-version: '3.12' cache: 'pip' - # Linux variant: spoofed hardware. Mirrors the Repo tests (CPU) - # dep set from studio-backend-ci.yml so unsloth's import chain - # succeeds on a runner without any GPU. CPU torch from the - # PyTorch index, transformers + datasets + bitsandbytes from - # the standard PyPI index, unsloth-zoo from PyPI. - # All explicit pip installs are version-pinned to a single - # released version. unsloth-zoo on Linux is the latest PyPI - # release; on macOS it is sourced from git main (PR-A is not - # yet on PyPI). The pin set was the latest as of 2026-05-07 - # within each project's existing constraint ranges; bump - # alongside the rest of the security audit when a new release - # of any of these lands. - - name: Install deps (Linux+CPU spoof) - if: matrix.real_mlx == false - run: | - python -m pip install --upgrade pip - pip install -r studio/backend/requirements/studio.txt - pip install \ - 'python-multipart==0.0.27' \ - 'aiofiles==25.1.0' \ - 'sqlalchemy==2.0.49' \ - 'cryptography==48.0.0' \ - 'pyyaml==6.0.3' \ - 'jinja2==3.1.6' \ - 'mammoth==1.12.0' \ - 'unpdf==1.0.0' \ - 'requests==2.33.1' \ - 'typer==0.25.1' \ - 'numpy==2.4.4' \ - 'pytest==9.0.3' \ - 'pytest-asyncio==1.3.0' \ - 'httpx==0.28.1' - pip install --index-url https://download.pytorch.org/whl/cpu \ - 'torch==2.10.0' 'torchvision==0.25.0' - pip install 'transformers==5.5.0' - pip install 'bitsandbytes==0.49.2' - pip install 'unsloth_zoo==2026.5.1' - pip install -e . --no-deps - - # macOS variant: real Apple Silicon. Install ladder validated - # locally against a Linux mac-sim venv (platform spoofed + - # mlx_simulation shim + real datasets/transformers/structlog). + # macOS install ladder, validated locally against a Linux + # mac-sim venv (platform spoofed + mlx_simulation shim + real + # datasets/transformers/structlog). # # 1. studio/backend/requirements/studio.txt brings structlog, # fastapi, etc. The hardware probe imports structlog at # module top level. - # 2. Same pytest / numpy / httpx stack as the Linux variant. + # 2. Same pytest / numpy / httpx stack the rest of the repo CI + # uses. # 3. torch is explicitly installed: unsloth-zoo's pyproject # deliberately excludes torch on darwin+arm64 (mlx replaces # it for runtime use), but the dispatch tests spoof # torch.cuda / torch.xpu / torch.backends.mps via monkeypatch # and so the test process needs torch importable. We pull - # from the PyTorch CPU index for both Linux and macOS so - # Apple Silicon gets the explicit cpu+MPS arm64 wheel rather - # than something the default PyPI resolver might pick up. - # https://download.pytorch.org/whl/cpu hosts - # torch-x.y.z-cp312-...-macosx_*_arm64.whl alongside the - # Linux x86_64 wheels. + # from the PyTorch CPU index so Apple Silicon gets the + # explicit cpu+MPS arm64 wheel rather than something the + # default PyPI resolver might pick up. The CPU index hosts + # macosx_*_arm64 wheels alongside the Linux x86_64 ones. # 4. unsloth-zoo from git main (NOT PyPI), WITH deps. PR-A's # MLX support landed after the most recent unsloth-zoo PyPI # release; the wheel still raises NotImplementedError on @@ -165,13 +116,12 @@ jobs: # dataprep/raw_text.py. # 5. unsloth -e . --no-deps so the editable install does not # fight the unsloth-zoo dep set. - # See the comment on the Linux variant -- same pin set, with - # two macOS-specific adjustments: no torchvision (zoo's mlx-vlm - # replaces it on Apple Silicon), no bitsandbytes (CUDA-only), - # no transformers explicit pin (zoo's deps already constrain - # it), and unsloth-zoo from git main rather than PyPI. - - name: Install deps (macOS real Apple Silicon) - if: matrix.real_mlx + # + # All explicit pip installs are version-pinned to a single + # released version (the latest as of 2026-05-07 within each + # project's existing constraint range). bump alongside the rest + # of the security audit when a new release lands. + - name: Install deps run: | python -m pip install --upgrade pip pip install -r studio/backend/requirements/studio.txt @@ -195,10 +145,9 @@ jobs: pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo" pip install -e . --no-deps - # Mac-only sanity: confirm _IS_MLX activates on real Apple - # Silicon hardware with no platform spoof. + # Real Apple Silicon sanity: confirm _IS_MLX activates on real + # hardware with no platform spoof. - name: Verify _IS_MLX flips True on real Apple Silicon - if: matrix.real_mlx run: | python -c " import platform @@ -209,10 +158,9 @@ jobs: print('OK: _IS_MLX activated on real Apple Silicon') " - # Mac-only sanity: confirm every PR-A MLX-only module loads - # against real mlx + mlx-lm + mlx-vlm wheels. + # Real Apple Silicon sanity: confirm every PR-A MLX-only module + # loads against real mlx + mlx-lm + mlx-vlm wheels. - name: Smoke-import every MLX-only unsloth_zoo module - if: matrix.real_mlx run: | python -c " import importlib @@ -232,13 +180,11 @@ jobs: print('OK: FastMLXModel + MLXTrainer surface present') " - # Both variants run the same dispatch tests. The monkeypatch - # spoofs in test_hardware_dispatch_matrix.py override - # platform.system / platform.machine / torch.cuda / - # torch.xpu / torch.backends.mps for each test, so they - # behave identically on Linux and on real Apple Silicon. The - # macOS variant additionally proves the spoofs do not collide - # with the real environment. + # Spoofed dispatch matrix. Runs on the real Mac too -- the + # test fixture installs a MetaPathFinder that blocks + # `import mlx.core` for "no-mlx" profiles, so the spoofs + # faithfully simulate every supported hardware combo regardless + # of whether mlx is installed for real. - name: MLX dispatch tests (3 files, 36 tests) env: PYTHONPATH: ${{ github.workspace }}/studio diff --git a/tests/studio/test_hardware_dispatch_matrix.py b/tests/studio/test_hardware_dispatch_matrix.py index c7a6841936..f0eaea0ad0 100644 --- a/tests/studio/test_hardware_dispatch_matrix.py +++ b/tests/studio/test_hardware_dispatch_matrix.py @@ -263,17 +263,42 @@ def spoof_hardware(monkeypatch): monkeypatch.setitem(sys.modules, "mlx", fake_mlx) monkeypatch.setitem(sys.modules, "mlx.core", fake_mlx_core) else: + # Drop any cached mlx modules and patch find_spec so the + # unsloth gate (which uses importlib.util.find_spec) sees + # mlx as absent. monkeypatch.delitem(sys.modules, "mlx", raising = False) monkeypatch.delitem(sys.modules, "mlx.core", raising = False) real_find_spec = importlib.util.find_spec def _no_mlx(name, *args, **kwargs): - if name == "mlx": + if name == "mlx" or name.startswith("mlx."): return None return real_find_spec(name, *args, **kwargs) monkeypatch.setattr(importlib.util, "find_spec", _no_mlx) + # Studio's _has_mlx() literally does `import mlx.core`, not + # find_spec, so on a real Apple Silicon host with mlx + # genuinely installed the import would still succeed. Block + # it via a meta_path finder that raises ImportError for any + # `mlx` / `mlx.*` import while this profile is active. + class _BlockMLXFinder: + def find_spec(self_inner, name, path = None, target = None): + if name == "mlx" or name.startswith("mlx."): + raise ImportError( + f"mlx import blocked by spoof_hardware " + f"(profile={profile.name})" + ) + return None + + blocker = _BlockMLXFinder() + # Replace meta_path with a NEW list so monkeypatch can fully + # restore the original on teardown (mutating the list in + # place would survive the test). + monkeypatch.setattr( + sys, "meta_path", [blocker, *sys.meta_path], + ) + return _apply