unsloth/studio/backend/tests/test_mlx_training_worker_config.py
Daniel Han cef7dcf160
Studio: improve logging for dynamic transformers version switching (#6108)
* Studio: log transformers version-switching decisions and stop swallowing MLX activation failures

Two logging gaps in dynamic transformers version switching (issue #6103):

1. get_transformers_tier returned a tier with no trace of why. Add an
   info log at each decision point naming the model and the trigger
   (which substring matched, or which config check fired), so a model
   landing on the wrong tier is diagnosable.

2. The MLX fast-path in run_training_process activated the transformers
   version inside a bare 'except Exception: pass', silently swallowing
   failures while the non-MLX path reports them. A missing or broken
   version venv (e.g. Gemma-4 needing 5.5.0) left no trace and only a
   confusing downstream crash. Extract a small _activate_transformers_version_or_warn
   helper that logs a warning on failure while keeping the non-fatal
   fall-through, and call it from the MLX path.

Adds tier-selection logging tests and helper warn/silent tests.

* Studio: clarify path-prepend log, warn on venv version mismatch, log per-package install progress

Completes the remaining logging items of #6103 in studio/backend/utils/transformers_version.py:

- activate_transformers_for_subprocess: the early "Activated transformers X.X.X" line was misleading because at that point only the venv directory has been prepended to sys.path, not imported. It now says it prepended the venv to sys.path and notes the loaded version is confirmed later by "Subprocess loaded transformers ...".
- _venv_dir_is_valid: a detected version mismatch is logged at warning instead of info, since it immediately triggers a full venv wipe and reinstall that should be visible in the logs.
- _ensure_venv_dir: log each package as it starts installing with an N/M progress counter, so a slow runtime install is not mistaken for a hang (pip/uv output is piped and only surfaced on error).

Adds tests covering all three behaviours; pre-existing unused imports are left untouched.

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

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

* Studio: make tier log-capture tests independent of import order

The new issue #6103 caplog assertions in test_transformers_version.py
relied on the module-level sys.modules.setdefault("loggers", stub)
winning the import race. In a full backend pytest run another module
(for example test_log_filter_no_truncation, collected earlier) imports
the real loggers first, so the setdefault is a no-op and
transformers_version.logger becomes a structlog/stdout logger that
caplog cannot capture -- the tier, activation, venv-mismatch and
install-progress log assertions then fail even though the line was
emitted.

Bind a real stdlib logger to transformers_version.logger for the
duration of each test via an autouse fixture, so the module logs through
logging and caplog captures them regardless of collection order.

* Studio: log local checkpoint tier decisions and warn on MLX inference activation

- get_transformers_tier: the local config.json fast path returned a tier
  without logging it, so local checkpoints stayed opaque while HF ids were
  traceable. Log each decision there too, with a caplog regression test.
- inference worker: the MLX path swallowed _activate_transformers_version
  failures with a bare except, the same gap issue #6103 fixed for training.
  Warn instead, keeping the non-fatal fall-through.

---------

Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-15 23:31:43 -07:00

264 lines
8.9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
import importlib.util
import sys
import types
from pathlib import Path
import pytest
def _load_worker_module():
stub_names = (
"structlog",
"loggers",
"utils",
"utils.hardware",
"utils.wheel_utils",
)
previous_modules = {name: sys.modules.get(name) for name in stub_names}
try:
sys.modules["structlog"] = types.ModuleType("structlog")
loggers = types.ModuleType("loggers")
loggers.get_logger = lambda *_args, **_kwargs: None
sys.modules["loggers"] = loggers
utils = types.ModuleType("utils")
utils.__path__ = []
sys.modules["utils"] = utils
hardware = types.ModuleType("utils.hardware")
hardware.apply_gpu_ids = lambda *_args, **_kwargs: None
sys.modules["utils.hardware"] = hardware
wheel_utils = types.ModuleType("utils.wheel_utils")
for name in (
"direct_wheel_url",
"flash_attn_wheel_url",
"has_blackwell_gpu",
"install_wheel",
"probe_torch_wheel_env",
"url_exists",
):
setattr(wheel_utils, name, lambda *_args, **_kwargs: None)
sys.modules["utils.wheel_utils"] = wheel_utils
worker_path = Path(__file__).resolve().parents[1] / "core" / "training" / "worker.py"
spec = importlib.util.spec_from_file_location("mlx_training_worker_under_test", worker_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
finally:
for name, module in previous_modules.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module
_worker = _load_worker_module()
_normalize_mlx_studio_optimizer = _worker._normalize_mlx_studio_optimizer
_normalize_mlx_studio_scheduler = _worker._normalize_mlx_studio_scheduler
_mlx_vlm_max_resized_size = _worker._mlx_vlm_max_resized_size
_mlx_vlm_resized_image_layout = _worker._mlx_vlm_resized_image_layout
_copy_mlx_vlm_image_processor = _worker._copy_mlx_vlm_image_processor
_resize_mlx_vlm_image = _worker._resize_mlx_vlm_image
_adapt_for_mlx_vlm = _worker._adapt_for_mlx_vlm
def test_mlx_studio_optimizer_aliases_are_explicit():
assert _normalize_mlx_studio_optimizer("adamw_8bit") == "adamw"
assert _normalize_mlx_studio_optimizer("paged_adamw_8bit") == "adamw"
assert _normalize_mlx_studio_optimizer("adafactor") == "adafactor"
def test_mlx_studio_rejects_unknown_optimizer():
with pytest.raises(ValueError, match = "Unsupported optimizer for MLX training"):
_normalize_mlx_studio_optimizer("adamw_typo")
def test_mlx_studio_rejects_unknown_scheduler():
with pytest.raises(ValueError, match = "Unsupported LR scheduler for MLX training"):
_normalize_mlx_studio_scheduler("linear_typo")
def test_mlx_studio_keeps_hf_style_tokenizer_dual_purpose():
source = (Path(__file__).resolve().parents[1] / "core" / "training" / "worker.py").read_text()
assert "tokenizer = tokenizer" in source
assert "processor = tokenizer if is_vlm else None" not in source
def test_mlx_vlm_resize_uses_max_dimension_like_torch_trainer():
assert _mlx_vlm_max_resized_size(1000, 500, 512) == (512, 256)
assert _mlx_vlm_max_resized_size(500, 1000, 512) == (256, 512)
assert _mlx_vlm_max_resized_size(1000, 1000, 512) == (512, 512)
assert _mlx_vlm_max_resized_size(256, 128, 1536) == (256, 128)
assert _mlx_vlm_max_resized_size(512, 256, 512) == (512, 256)
# Half-pixel cases must match the Torch collator (not banker's round).
assert _mlx_vlm_max_resized_size(333, 1000, 500) == (167, 500)
assert _mlx_vlm_max_resized_size(1000, 333, 500) == (500, 167)
def test_mlx_vlm_resize_keeps_default_numpy_layout_hwc():
Image = pytest.importorskip("PIL.Image")
image = Image.new("RGB", (320, 200), color = (10, 20, 30))
resized = _resize_mlx_vlm_image(image, 128)
assert resized.shape == (80, 128, 3)
assert resized.flags.c_contiguous
def test_mlx_vlm_resize_uses_requested_chw_numpy_layout():
Image = pytest.importorskip("PIL.Image")
image = Image.new("RGB", (320, 200), color = (10, 20, 30))
resized = _resize_mlx_vlm_image(image, 128, image_layout = "chw")
assert resized.shape == (3, 80, 128)
assert resized.flags.c_contiguous
def test_mlx_vlm_resized_image_layout_probes_processor_contract():
class ChwOnlyImageProcessor:
def __call__(self, images = None):
image = images[0]
if image.shape[0] == 3:
return {"pixel_values": image}
raise ValueError("expected CHW")
class HwcImageProcessor:
def __call__(self, images = None):
image = images[0]
if image.shape[-1] == 3:
return {"pixel_values": image}
raise ValueError("expected HWC")
assert (
_mlx_vlm_resized_image_layout(
types.SimpleNamespace(image_processor = ChwOnlyImageProcessor())
)
== "chw"
)
assert (
_mlx_vlm_resized_image_layout(types.SimpleNamespace(image_processor = HwcImageProcessor()))
is None
)
def test_mlx_vlm_layout_probe_copies_image_processor():
class StatefulImageProcessor:
def __init__(self):
self.calls = 0
def __call__(self, images = None):
self.calls += 1
image = images[0]
if image.shape[0] == 3:
return {"pixel_values": image}
raise ValueError("expected CHW")
image_processor = StatefulImageProcessor()
layout = _mlx_vlm_resized_image_layout(types.SimpleNamespace(image_processor = image_processor))
assert layout == "chw"
assert image_processor.calls == 0
def test_mlx_vlm_image_processor_copy_refuses_uncopyable_processors():
class UncopyableImageProcessor:
def __copy__(self):
raise RuntimeError("no copy")
def __deepcopy__(self, _memo):
raise RuntimeError("no deepcopy")
image_processor = UncopyableImageProcessor()
assert _copy_mlx_vlm_image_processor(image_processor) is None
def test_mlx_vlm_layout_probe_skips_uncopyable_processors():
class UncopyableImageProcessor:
def __copy__(self):
raise RuntimeError("no copy")
def __deepcopy__(self, _memo):
raise RuntimeError("no deepcopy")
def __call__(self, images = None):
raise AssertionError("live processor should not be probed")
assert (
_mlx_vlm_resized_image_layout(
types.SimpleNamespace(image_processor = UncopyableImageProcessor())
)
is None
)
def test_mlx_vlm_adapter_applies_chw_layout_to_message_images():
Image = pytest.importorskip("PIL.Image")
image = Image.new("RGB", (320, 200), color = (10, 20, 30))
item = {
"messages": [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": "Describe it."},
],
}
]
}
adapted = _adapt_for_mlx_vlm([item], resize = 128, image_layout = "chw")
assert adapted[0]["image"].shape == (3, 80, 128)
assert adapted[0]["messages"][0]["content"][0] == {"type": "image"}
# ---- issue #6103: MLX transformers-version activation must not fail silently ----
def test_activate_transformers_version_or_warn_logs_on_failure(monkeypatch):
"""A failed activation in the MLX fast-path must be logged, not swallowed.
The non-MLX path already surfaces this failure; the MLX path used a bare
``except Exception: pass`` so a missing/broken transformers venv produced
no trace and a confusing downstream crash.
"""
warnings_logged = []
fake_logger = types.SimpleNamespace(
warning = lambda *a, **k: warnings_logged.append((a, k)),
)
monkeypatch.setattr(_worker, "logger", fake_logger)
def _boom(_name):
raise RuntimeError("venv .venv_t5_550 missing")
monkeypatch.setattr(_worker, "_activate_transformers_version", _boom)
# Non-fatal: the MLX path falls through, so this must not raise.
_worker._activate_transformers_version_or_warn("google/gemma-4-12b")
assert len(warnings_logged) == 1, "activation failure was not logged"
assert "gemma-4-12b" in str(warnings_logged[0]), "log does not name the model"
def test_activate_transformers_version_or_warn_silent_on_success(monkeypatch):
warnings_logged = []
fake_logger = types.SimpleNamespace(
warning = lambda *a, **k: warnings_logged.append((a, k)),
)
monkeypatch.setattr(_worker, "logger", fake_logger)
monkeypatch.setattr(_worker, "_activate_transformers_version", lambda _name: None)
_worker._activate_transformers_version_or_warn("meta-llama/Llama-3-8B")
assert warnings_logged == [], "should not warn when activation succeeds"