* Expose MLX grad value clipping in Studio * update test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * dataset ordering + wd * fix mlx smoke step expectations * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * cast norm activation output back to original input dtype * address mlx studio review feedback * Fix present-but-None seed override for PR #5656 studio/backend/core/training/worker.py `config.get("model_random_state", random_seed)` only fills the default when the key is absent. When a caller passes `config["model_random_state"] = None` explicitly (which happens any time a JSON payload sends an explicit `null`), the old code forwarded `None` to FastMLXModel and disabled deterministic init silently. Same for `lora_random_state`. Treat absent and explicit None the same way: fall back to random_seed. studio/backend/tests/test_training_raw_support.py Update the source-string assertions to match the new lines. * Guard optional MLXTrainingConfig fields and normalize random_seed for PR #5656 The MLX worker now passes `cast_norm_output_to_input_dtype` and `dataset_order` only when the linked unsloth-zoo dataclass actually declares them. Released zoo trees that predate the paired PR can still construct `MLXTrainingConfig` without raising `TypeError: unexpected keyword argument`. Once the dependency floor is bumped to a release that contains both fields, the feature-detect guards become no-ops. `random_seed = config.get("random_seed", 3407)` was unguarded against explicit `None` from raw / backend callers. The same value seeded the trainer and was the fallback target for `model_random_state` / `lora_random_state`. Normalize once at the top of the function and use the normalized value everywhere so an explicit `None` cannot reach FastMLXModel / get_peft_model / MLXTrainingConfig. Existing seed source-pattern test updated to match the new normalize helper. New test asserts the feature-detection guards exist and that the unconditional kwargs do not include the gated fields. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Normalize seed / cast / max_grad_value at TrainingBackend for PR #5656 Round-3 review consensus: the per-field guards that landed in the MLX worker only protect the MLX path. The same `TrainingBackend.start_training` config still reaches the CUDA/text trainer at `worker.py:2267`, the embedding LoRA init at `worker.py:2450`, and embedding TrainingArguments at `worker.py:2624` with raw `None` values, so an explicit `random_seed=None` from a raw / backend caller still breaks non-MLX training even after the previous fix. Move the normalization into `TrainingBackend.start_training` itself, where it runs once for every training mode: - `_coerce_seed(value)`: explicit `None`, non-int, or absent all become 3407. Every downstream worker now sees an int. - `_coerce_optional_bool(value, default)`: explicit `None` falls back to `default` instead of `bool(None) == False`. Also normalizes the common raw-config / YAML string aliases ("true" / "false" / "0" / "1"). Used for `cast_norm_output_to_input_dtype`. - `_coerce_optional_nonneg_float(name, value)`: rejects negative numerics from raw / backend callers, matching the Pydantic `ge=0` constraint the HTTP route already enforces. Used for `max_grad_value`. worker.py MLX path: the existing `bool(config.get(key, True))` for `cast_norm_output_to_input_dtype` was changed to also fall back on explicit `None`, so direct worker callers (bypassing `TrainingBackend.start_training`) are equally safe. `max_grad_value` also raises on negative values inside the worker for the same reason. TrainingStartRequest.random_seed default bumped from 42 to 3407 so direct REST callers that omit the field receive the same default as the Studio frontend and the MLX worker. New regression test exercises the three new helpers across explicit None, valid values, string aliases, and negative-value rejection. * Tighten feature-detect test paren tracking for PR #5656 The block-extraction used , which stops at the first inner closing paren (e.g. ) and would silently miss a future unconditional / added later in the same dict literal. Switched to proper paren-depth tracking so the unconditional block is checked end-to-end. * Shorten verbose comments in MLX Studio backend * Handle MLX Studio EOS appending by mode * Wire MLX leaf norm clipping through Studio * Respect VLM layer filters for explicit LoRA targets Rationale / guardrails for the local Studio/vision push: When callers provide explicit VLM LoRA target_modules together with layer filters, FastVisionModel still needs to route the explicit targets through get_peft_regex. Otherwise the layer filters are ignored and adapters can be attached outside the requested language/vision scope. Do not revert this to plain list(target_modules) for explicit module lists. The CUDA/Studio-facing contract is that explicit targets and layer filters compose: target_modules selects module names, while finetune_language_layers / finetune_vision_layers / finetune_attention_modules / finetune_mlp_modules constrain where those targets are allowed. The regression test covers the language-only explicit q_proj case and source-checks that explicit targets are wrapped through get_peft_regex when filters are active. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refresh MLX smoke clip-config note for leaf_norm default Trim the 11-line comment block to 5 lines and correct the stale claim that MLXTrainingConfig defaults to max_grad_value=1.0. The new default is max_grad_leaf_norm=1.0 (same memory profile as elementwise but direction-preserving). The smoke still pins max_grad_value=1.0 explicitly to keep the 13-seed pass-rate fixture stable. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Forward max_grad_leaf_norm through the training route and warn when layer filters constrain explicit target_modules for PR #5656 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han-Chen <info@unsloth.ai> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
403 lines
15 KiB
Python
403 lines
15 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
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from datasets import Dataset
|
|
|
|
from core.training.training import TrainingBackend
|
|
from models.training import TrainingStartRequest
|
|
from utils.datasets import format_dataset, format_and_template_dataset
|
|
from utils.datasets.raw_text import prepare_raw_text_dataset
|
|
|
|
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _load_route_module(name: str, relative_path: str):
|
|
spec = importlib.util.spec_from_file_location(name, _BACKEND_ROOT / relative_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class TestTrainingRawSupport(unittest.TestCase):
|
|
def test_training_backend_preserves_cpt_4bit_and_embedding_lr(self):
|
|
backend = TrainingBackend()
|
|
|
|
class DummyProcess:
|
|
pid = 12345
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class DummyThread:
|
|
def start(self):
|
|
return None
|
|
|
|
dummy_queue = object()
|
|
|
|
with (
|
|
patch(
|
|
"core.training.training.prepare_gpu_selection",
|
|
return_value = ([0], {"selection_mode": "auto"}),
|
|
),
|
|
patch(
|
|
"core.training.training._CTX.Queue",
|
|
side_effect = [dummy_queue, dummy_queue],
|
|
),
|
|
patch(
|
|
"core.training.training._CTX.Process", return_value = DummyProcess()
|
|
) as mock_process,
|
|
patch(
|
|
"core.training.training.threading.Thread",
|
|
return_value = DummyThread(),
|
|
),
|
|
):
|
|
backend.start_training(
|
|
job_id = "test-cpt-raw",
|
|
model_name = "unsloth/test-bnb-4bit",
|
|
training_type = "Continued Pretraining",
|
|
format_type = "raw",
|
|
load_in_4bit = True,
|
|
embedding_learning_rate = 1e-5,
|
|
)
|
|
|
|
config = mock_process.call_args.kwargs["kwargs"]["config"]
|
|
self.assertTrue(config["load_in_4bit"])
|
|
self.assertEqual(config["embedding_learning_rate"], 1e-5)
|
|
|
|
def test_training_backend_forwards_grad_clipping_controls(self):
|
|
backend = TrainingBackend()
|
|
|
|
class DummyProcess:
|
|
pid = 12345
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class DummyThread:
|
|
def start(self):
|
|
return None
|
|
|
|
dummy_queue = object()
|
|
|
|
with (
|
|
patch(
|
|
"core.training.training.prepare_gpu_selection",
|
|
return_value = ([0], {"selection_mode": "auto"}),
|
|
),
|
|
patch(
|
|
"core.training.training._CTX.Queue",
|
|
side_effect = [dummy_queue, dummy_queue],
|
|
),
|
|
patch(
|
|
"core.training.training._CTX.Process", return_value = DummyProcess()
|
|
) as mock_process,
|
|
patch(
|
|
"core.training.training.threading.Thread",
|
|
return_value = DummyThread(),
|
|
),
|
|
):
|
|
backend.start_training(
|
|
job_id = "test-grad-clip",
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
max_grad_norm = 0.7,
|
|
max_grad_value = 3.0,
|
|
max_grad_leaf_norm = 1.3,
|
|
)
|
|
|
|
config = mock_process.call_args.kwargs["kwargs"]["config"]
|
|
self.assertEqual(config["max_grad_norm"], 0.7)
|
|
self.assertEqual(config["max_grad_value"], 3.0)
|
|
self.assertEqual(config["max_grad_leaf_norm"], 1.3)
|
|
|
|
def test_training_backend_forwards_random_seed_without_internal_mlx_seed_keys(self):
|
|
backend = TrainingBackend()
|
|
|
|
class DummyProcess:
|
|
pid = 12345
|
|
|
|
def start(self):
|
|
return None
|
|
|
|
class DummyThread:
|
|
def start(self):
|
|
return None
|
|
|
|
dummy_queue = object()
|
|
|
|
with (
|
|
patch(
|
|
"core.training.training.prepare_gpu_selection",
|
|
return_value = ([0], {"selection_mode": "auto"}),
|
|
),
|
|
patch(
|
|
"core.training.training._CTX.Queue",
|
|
side_effect = [dummy_queue, dummy_queue],
|
|
),
|
|
patch(
|
|
"core.training.training._CTX.Process", return_value = DummyProcess()
|
|
) as mock_process,
|
|
patch(
|
|
"core.training.training.threading.Thread",
|
|
return_value = DummyThread(),
|
|
),
|
|
):
|
|
backend.start_training(
|
|
job_id = "test-seed",
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
random_seed = 1234,
|
|
)
|
|
|
|
config = mock_process.call_args.kwargs["kwargs"]["config"]
|
|
self.assertEqual(config["random_seed"], 1234)
|
|
self.assertNotIn("model_random_state", config)
|
|
self.assertNotIn("lora_random_state", config)
|
|
|
|
def test_route_forwards_all_grad_clipping_fields(self):
|
|
# The HTTP route builds the config dict by hand; a schema field that
|
|
# is not forwarded here is silently dropped for REST callers.
|
|
source = (_BACKEND_ROOT / "routes" / "training.py").read_text()
|
|
self.assertIn('"max_grad_norm": request.max_grad_norm', source)
|
|
self.assertIn('"max_grad_value": request.max_grad_value', source)
|
|
self.assertIn('"max_grad_leaf_norm": request.max_grad_leaf_norm', source)
|
|
|
|
def test_mlx_worker_falls_back_init_seeds_to_random_seed(self):
|
|
source = (_BACKEND_ROOT / "core" / "training" / "worker.py").read_text()
|
|
|
|
# random_seed itself is normalized first so explicit None coming
|
|
# from a raw / backend caller does not propagate through the chain.
|
|
self.assertIn('_raw_seed = config.get("random_seed", 3407)', source)
|
|
self.assertIn(
|
|
"random_seed = 3407 if _raw_seed is None else int(_raw_seed)",
|
|
source,
|
|
)
|
|
# Both absent and explicit None must fall back to random_seed.
|
|
# `dict.get(key, default)` only fills the default on absent keys,
|
|
# so an explicit `None` would otherwise reach FastMLXModel /
|
|
# get_peft_model and disable deterministic init.
|
|
self.assertIn('_model_seed = config.get("model_random_state")', source)
|
|
self.assertIn(
|
|
"model_random_state = random_seed if _model_seed is None else int(_model_seed)",
|
|
source,
|
|
)
|
|
self.assertIn('_lora_seed = config.get("lora_random_state")', source)
|
|
self.assertIn(
|
|
"lora_random_state = random_seed if _lora_seed is None else int(_lora_seed)",
|
|
source,
|
|
)
|
|
self.assertIn("random_state = model_random_state", source)
|
|
self.assertIn("random_state = lora_random_state", source)
|
|
# MLXTrainingConfig now receives the normalized seed directly.
|
|
self.assertIn("seed = random_seed,", source)
|
|
|
|
def test_mlx_worker_preserves_null_max_grad_value_for_trainer_default(self):
|
|
source = (_BACKEND_ROOT / "core" / "training" / "worker.py").read_text()
|
|
|
|
# None must survive to the MLX trainer so it picks its own runtime
|
|
# default, and any other value must coerce to float without
|
|
# rebinding None to 1.0 (which the legacy code did).
|
|
self.assertIn('max_grad_value = config.get("max_grad_value")', source)
|
|
self.assertIn("max_grad_value = float(max_grad_value)", source)
|
|
self.assertNotIn(
|
|
"max_grad_value = 1.0 if max_grad_value is None else float(max_grad_value)",
|
|
source,
|
|
)
|
|
|
|
def test_training_backend_normalizes_explicit_none_seed_and_dtypes(self):
|
|
# Raw / backend callers can pass `random_seed=None`,
|
|
# `cast_norm_output_to_input_dtype=None`, and MLX clip knobs
|
|
# as None (or omit them) and must NOT leak the
|
|
# `None` past `TrainingBackend.start_training`. Otherwise
|
|
# transformers.set_seed(None) raises, PEFT init becomes
|
|
# nondeterministic, and the MLX norm-output cast silently flips.
|
|
from core.training.training import (
|
|
_coerce_seed,
|
|
_coerce_optional_bool,
|
|
_coerce_optional_nonneg_float,
|
|
)
|
|
|
|
self.assertEqual(_coerce_seed(None), 3407)
|
|
self.assertEqual(_coerce_seed("123"), 123)
|
|
self.assertEqual(_coerce_seed("not-a-number"), 3407)
|
|
|
|
self.assertTrue(_coerce_optional_bool(None, True))
|
|
self.assertFalse(_coerce_optional_bool(None, False))
|
|
self.assertFalse(_coerce_optional_bool("false", True))
|
|
self.assertTrue(_coerce_optional_bool("true", False))
|
|
|
|
self.assertIsNone(_coerce_optional_nonneg_float("max_grad_value", None))
|
|
self.assertEqual(_coerce_optional_nonneg_float("max_grad_value", "2.5"), 2.5)
|
|
self.assertEqual(_coerce_optional_nonneg_float("max_grad_value", 0), 0.0)
|
|
with self.assertRaises(ValueError):
|
|
_coerce_optional_nonneg_float("max_grad_value", -1)
|
|
self.assertIsNone(_coerce_optional_nonneg_float("max_grad_leaf_norm", None))
|
|
self.assertEqual(
|
|
_coerce_optional_nonneg_float("max_grad_leaf_norm", "1.3"),
|
|
1.3,
|
|
)
|
|
with self.assertRaises(ValueError):
|
|
_coerce_optional_nonneg_float("max_grad_leaf_norm", -1)
|
|
|
|
def test_mlx_worker_feature_detects_optional_mlx_config_fields(self):
|
|
# `cast_norm_output_to_input_dtype`, `dataset_order`,
|
|
# `max_grad_leaf_norm`, and `append_eos` ship in the paired
|
|
# unsloth-zoo update. Until that floor is in place, the
|
|
# worker must gate them so releases that predate those fields can
|
|
# still construct MLXTrainingConfig without TypeError.
|
|
source = (_BACKEND_ROOT / "core" / "training" / "worker.py").read_text()
|
|
|
|
self.assertIn(
|
|
'getattr(MLXTrainingConfig, "__dataclass_fields__", {})',
|
|
source,
|
|
)
|
|
self.assertIn('if "cast_norm_output_to_input_dtype" in _supported_fields:', source)
|
|
self.assertIn('if "dataset_order" in _supported_fields:', source)
|
|
self.assertIn('if "max_grad_leaf_norm" in _supported_fields:', source)
|
|
self.assertIn(
|
|
'mlx_config_kwargs["max_grad_leaf_norm"] = max_grad_leaf_norm',
|
|
source,
|
|
)
|
|
self.assertIn('if "append_eos" in _supported_fields:', source)
|
|
self.assertIn('format_type == "raw"', source)
|
|
self.assertIn('mlx_config_kwargs["append_eos"] = bool(raw_text_mode)', source)
|
|
# The unconditional kwargs must NOT include any gated field.
|
|
# Use proper paren tracking; `source.find(")", ...)` would stop at
|
|
# the first close paren inside the dict body (e.g.
|
|
# `int(config.get("save_steps", 0) or 0)`) and miss any future
|
|
# unconditional addition of the gated fields later in the dict.
|
|
unconditional_block_start = source.find("mlx_config_kwargs = dict(")
|
|
self.assertNotEqual(unconditional_block_start, -1)
|
|
depth = 0
|
|
i = unconditional_block_start + len("mlx_config_kwargs = dict")
|
|
end = i
|
|
while i < len(source):
|
|
ch = source[i]
|
|
if ch == "(":
|
|
depth += 1
|
|
elif ch == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
end = i + 1
|
|
break
|
|
i += 1
|
|
unconditional = source[unconditional_block_start:end]
|
|
self.assertNotIn("cast_norm_output_to_input_dtype", unconditional)
|
|
self.assertNotIn("dataset_order", unconditional)
|
|
self.assertNotIn("max_grad_leaf_norm", unconditional)
|
|
self.assertNotIn("append_eos", unconditional)
|
|
|
|
def test_training_route_forwards_embedding_learning_rate(self):
|
|
training_route = _load_route_module(
|
|
"training_route_module_raw_support",
|
|
"routes/training.py",
|
|
)
|
|
captured: dict = {}
|
|
|
|
class DummyBackend:
|
|
current_job_id = None
|
|
|
|
def is_training_active(self):
|
|
return False
|
|
|
|
def start_training(self, **kwargs):
|
|
captured.update(kwargs)
|
|
return True
|
|
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test-bnb-4bit",
|
|
training_type = "Continued Pretraining",
|
|
format_type = "raw",
|
|
load_in_4bit = True,
|
|
embedding_learning_rate = 1e-5,
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
training_route,
|
|
"get_training_backend",
|
|
return_value = DummyBackend(),
|
|
),
|
|
patch.object(training_route, "load_model_defaults", return_value = {}),
|
|
patch(
|
|
"core.inference.get_inference_backend",
|
|
return_value = type(
|
|
"InferenceBackend",
|
|
(),
|
|
{"active_model_name": None},
|
|
)(),
|
|
),
|
|
patch(
|
|
"core.export.get_export_backend",
|
|
return_value = type(
|
|
"ExportBackend",
|
|
(),
|
|
{"current_checkpoint": None},
|
|
)(),
|
|
),
|
|
):
|
|
response = asyncio.run(
|
|
training_route.start_training(request, current_subject = "test-user")
|
|
)
|
|
|
|
self.assertEqual(response.status, "queued")
|
|
self.assertEqual(captured["embedding_learning_rate"], 1e-5)
|
|
self.assertTrue(captured["load_in_4bit"])
|
|
|
|
def test_format_dataset_supports_raw_text(self):
|
|
dataset = Dataset.from_dict(
|
|
{
|
|
"body": ["hello", "world"],
|
|
"title": ["a", "b"],
|
|
"id": [1, 2],
|
|
}
|
|
)
|
|
|
|
result = format_dataset(dataset, format_type = "raw")
|
|
|
|
self.assertEqual(result["final_format"], "raw_text")
|
|
self.assertIn("text", result["dataset"].column_names)
|
|
self.assertEqual(result["dataset"][0]["text"], "hello")
|
|
self.assertFalse(result["requires_manual_mapping"])
|
|
|
|
def test_format_and_template_dataset_supports_raw_text_without_template(self):
|
|
dataset = Dataset.from_dict({"body": ["hello raw world"]})
|
|
|
|
result = format_and_template_dataset(
|
|
dataset,
|
|
model_name = "unsloth/test",
|
|
tokenizer = None,
|
|
format_type = "raw",
|
|
)
|
|
|
|
self.assertTrue(result["success"])
|
|
self.assertEqual(result["final_format"], "raw_text")
|
|
self.assertEqual(result["dataset"][0]["text"], "hello raw world")
|
|
|
|
def test_prepare_raw_text_dataset_drops_null_rows_before_appending_eos(self):
|
|
dataset = Dataset.from_dict({"text": ["hello", None, "world"]})
|
|
|
|
result = prepare_raw_text_dataset(
|
|
dataset,
|
|
mode_label = "CPT",
|
|
split_name = "train",
|
|
eos_token = "<eos>",
|
|
append_eos = True,
|
|
)
|
|
|
|
self.assertEqual(len(result.dataset), 2)
|
|
self.assertEqual(result.dataset[0]["text"], "hello<eos>")
|
|
self.assertEqual(result.dataset[1]["text"], "world<eos>")
|
|
self.assertTrue(
|
|
any("null or non-string 'text' values" in notice.message for notice in result.notices)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|