mirror of https://github.com/razor-ai/soup.git
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""Tests for FP8 recipe support (v0.28.1).
|
|
|
|
Covers:
|
|
- Schema: fp8_recipe field accepts valid literals, rejects invalid strings
|
|
- Schema: fp8_recipe requires quantization_aware='fp8' when non-default
|
|
- Schema: default recipe is 'tensorwise' (backward compat with v0.28.0)
|
|
- Dispatch: apply_fp8_training passes correct recipe to Float8LinearConfig
|
|
- Integration: fp8_recipe with non-SFT tasks rejected (via quantization_aware gate)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from soup_cli.config.schema import SoupConfig, TrainingConfig
|
|
|
|
# ─── Schema: fp8_recipe field ──────────────────────────────────────────────
|
|
|
|
|
|
class TestFP8RecipeSchema:
|
|
"""fp8_recipe accepts 'tensorwise', 'rowwise', 'rowwise_with_gw_hp'."""
|
|
|
|
def test_fp8_recipe_default_tensorwise(self):
|
|
cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
|
|
assert cfg.training.fp8_recipe == "tensorwise"
|
|
|
|
def test_fp8_recipe_tensorwise_explicit(self):
|
|
cfg = SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"quantization_aware": "fp8", "fp8_recipe": "tensorwise"},
|
|
)
|
|
assert cfg.training.fp8_recipe == "tensorwise"
|
|
|
|
def test_fp8_recipe_rowwise(self):
|
|
cfg = SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"quantization_aware": "fp8", "fp8_recipe": "rowwise"},
|
|
)
|
|
assert cfg.training.fp8_recipe == "rowwise"
|
|
|
|
def test_fp8_recipe_rowwise_with_gw_hp(self):
|
|
cfg = SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": "rowwise_with_gw_hp",
|
|
},
|
|
)
|
|
assert cfg.training.fp8_recipe == "rowwise_with_gw_hp"
|
|
|
|
def test_fp8_recipe_invalid_string_rejected(self):
|
|
"""Only the three literal values are accepted."""
|
|
with pytest.raises(ValidationError) as exc:
|
|
SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": "delayed",
|
|
},
|
|
)
|
|
assert "fp8_recipe" in str(exc.value)
|
|
|
|
def test_fp8_recipe_invalid_empty_string_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"quantization_aware": "fp8", "fp8_recipe": ""},
|
|
)
|
|
|
|
def test_fp8_recipe_invalid_int_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"quantization_aware": "fp8", "fp8_recipe": 42},
|
|
)
|
|
|
|
|
|
# ─── Schema: fp8_recipe requires quantization_aware='fp8' ─────────────────
|
|
|
|
|
|
class TestFP8RecipeRequiresFP8:
|
|
"""Non-default fp8_recipe without quantization_aware='fp8' is rejected."""
|
|
|
|
def test_default_recipe_allowed_without_fp8(self):
|
|
"""tensorwise (default) is fine even without fp8 — it's the default."""
|
|
cfg = SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"fp8_recipe": "tensorwise"},
|
|
)
|
|
assert cfg.training.fp8_recipe == "tensorwise"
|
|
assert cfg.training.quantization_aware is False
|
|
|
|
def test_rowwise_without_fp8_rejected(self):
|
|
with pytest.raises(ValidationError) as exc:
|
|
SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"fp8_recipe": "rowwise"},
|
|
)
|
|
assert "quantization_aware" in str(exc.value)
|
|
|
|
def test_rowwise_with_gw_hp_without_fp8_rejected(self):
|
|
with pytest.raises(ValidationError) as exc:
|
|
SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"fp8_recipe": "rowwise_with_gw_hp"},
|
|
)
|
|
assert "quantization_aware" in str(exc.value)
|
|
|
|
def test_rowwise_with_bool_true_qat_rejected(self):
|
|
"""Bool True = int8 QAT, not FP8 — recipe should be rejected."""
|
|
with pytest.raises(ValidationError) as exc:
|
|
SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={
|
|
"quantization_aware": True,
|
|
"fp8_recipe": "rowwise",
|
|
},
|
|
)
|
|
assert "quantization_aware" in str(exc.value)
|
|
|
|
|
|
# ─── Dispatch: apply_fp8_training recipe parameter ────────────────────────
|
|
|
|
|
|
class TestFP8RecipeDispatch:
|
|
"""apply_fp8_training passes recipe to Float8LinearConfig.from_recipe_name."""
|
|
|
|
def test_apply_fp8_dispatches_tensorwise(self):
|
|
"""Default recipe passes 'tensorwise' to from_recipe_name."""
|
|
mock_config = MagicMock()
|
|
mock_from_recipe = MagicMock(return_value=mock_config)
|
|
mock_convert = MagicMock()
|
|
|
|
fake_float8 = MagicMock()
|
|
fake_float8.convert_to_float8_training = mock_convert
|
|
fake_config_mod = MagicMock()
|
|
fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"torchao": MagicMock(),
|
|
"torchao.float8": fake_float8,
|
|
"torchao.float8.config": fake_config_mod,
|
|
},
|
|
):
|
|
# Need to reimport to pick up the mocked modules
|
|
import importlib
|
|
|
|
import soup_cli.utils.fp8 as fp8_mod
|
|
|
|
importlib.reload(fp8_mod)
|
|
|
|
# Mock is_fp8_available to return True
|
|
with patch.object(fp8_mod, "is_fp8_available", return_value=True):
|
|
model = MagicMock()
|
|
result = fp8_mod.apply_fp8_training(model, recipe="tensorwise")
|
|
|
|
mock_from_recipe.assert_called_once_with("tensorwise")
|
|
mock_convert.assert_called_once_with(model, config=mock_config)
|
|
assert result is True
|
|
|
|
def test_apply_fp8_dispatches_rowwise(self):
|
|
"""Rowwise recipe passes 'rowwise' to from_recipe_name."""
|
|
mock_config = MagicMock()
|
|
mock_from_recipe = MagicMock(return_value=mock_config)
|
|
mock_convert = MagicMock()
|
|
|
|
fake_float8 = MagicMock()
|
|
fake_float8.convert_to_float8_training = mock_convert
|
|
fake_config_mod = MagicMock()
|
|
fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"torchao": MagicMock(),
|
|
"torchao.float8": fake_float8,
|
|
"torchao.float8.config": fake_config_mod,
|
|
},
|
|
):
|
|
import importlib
|
|
|
|
import soup_cli.utils.fp8 as fp8_mod
|
|
|
|
importlib.reload(fp8_mod)
|
|
|
|
with patch.object(fp8_mod, "is_fp8_available", return_value=True):
|
|
model = MagicMock()
|
|
result = fp8_mod.apply_fp8_training(model, recipe="rowwise")
|
|
|
|
mock_from_recipe.assert_called_once_with("rowwise")
|
|
assert result is True
|
|
|
|
def test_apply_fp8_dispatches_rowwise_with_gw_hp(self):
|
|
"""rowwise_with_gw_hp recipe passes through correctly."""
|
|
mock_config = MagicMock()
|
|
mock_from_recipe = MagicMock(return_value=mock_config)
|
|
mock_convert = MagicMock()
|
|
|
|
fake_float8 = MagicMock()
|
|
fake_float8.convert_to_float8_training = mock_convert
|
|
fake_config_mod = MagicMock()
|
|
fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"torchao": MagicMock(),
|
|
"torchao.float8": fake_float8,
|
|
"torchao.float8.config": fake_config_mod,
|
|
},
|
|
):
|
|
import importlib
|
|
|
|
import soup_cli.utils.fp8 as fp8_mod
|
|
|
|
importlib.reload(fp8_mod)
|
|
|
|
with patch.object(fp8_mod, "is_fp8_available", return_value=True):
|
|
model = MagicMock()
|
|
result = fp8_mod.apply_fp8_training(
|
|
model, recipe="rowwise_with_gw_hp"
|
|
)
|
|
|
|
mock_from_recipe.assert_called_once_with("rowwise_with_gw_hp")
|
|
assert result is True
|
|
|
|
def test_apply_fp8_returns_false_when_unavailable(self):
|
|
"""When FP8 deps are missing, apply_fp8_training returns False."""
|
|
from soup_cli.utils.fp8 import apply_fp8_training
|
|
|
|
with patch("soup_cli.utils.fp8.is_fp8_available", return_value=False):
|
|
model = MagicMock()
|
|
assert apply_fp8_training(model, recipe="rowwise") is False
|
|
|
|
def test_apply_fp8_default_recipe_is_tensorwise(self):
|
|
"""Calling without recipe= uses 'tensorwise' (v0.28.0 compat)."""
|
|
mock_config = MagicMock()
|
|
mock_from_recipe = MagicMock(return_value=mock_config)
|
|
mock_convert = MagicMock()
|
|
|
|
fake_float8 = MagicMock()
|
|
fake_float8.convert_to_float8_training = mock_convert
|
|
fake_config_mod = MagicMock()
|
|
fake_config_mod.Float8LinearConfig.from_recipe_name = mock_from_recipe
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"torchao": MagicMock(),
|
|
"torchao.float8": fake_float8,
|
|
"torchao.float8.config": fake_config_mod,
|
|
},
|
|
):
|
|
import importlib
|
|
|
|
import soup_cli.utils.fp8 as fp8_mod
|
|
|
|
importlib.reload(fp8_mod)
|
|
|
|
with patch.object(fp8_mod, "is_fp8_available", return_value=True):
|
|
model = MagicMock()
|
|
fp8_mod.apply_fp8_training(model)
|
|
|
|
# Default should be tensorwise
|
|
mock_from_recipe.assert_called_once_with("tensorwise")
|
|
|
|
|
|
# ─── Integration: fp8_recipe + non-SFT tasks ──────────────────────────────
|
|
|
|
|
|
class TestFP8RecipeNonSFT:
|
|
"""FP8 recipe on non-SFT tasks: accepted on transformer backends (v0.35.0)."""
|
|
|
|
def test_fp8_recipe_on_dpo_accepted(self):
|
|
"""DPO + fp8 + recipe is valid (all transformer trainers wired in v0.35.0)."""
|
|
cfg = SoupConfig(
|
|
base="m",
|
|
task="dpo",
|
|
data={"train": "./d.jsonl", "format": "dpo"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": "rowwise",
|
|
},
|
|
)
|
|
assert cfg.training.fp8_recipe == "rowwise"
|
|
|
|
def test_fp8_recipe_on_grpo_accepted(self):
|
|
cfg = SoupConfig(
|
|
base="m",
|
|
task="grpo",
|
|
data={"train": "./d.jsonl"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": "rowwise_with_gw_hp",
|
|
},
|
|
)
|
|
assert cfg.training.fp8_recipe == "rowwise_with_gw_hp"
|
|
|
|
def test_fp8_recipe_on_mlx_rejected(self):
|
|
"""MLX backend does not support FP8 — rejected at config level."""
|
|
with pytest.raises(ValidationError):
|
|
SoupConfig(
|
|
base="m",
|
|
task="sft",
|
|
backend="mlx",
|
|
data={"train": "./d.jsonl"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": "rowwise",
|
|
},
|
|
)
|
|
|
|
def test_fp8_recipe_on_sft_accepted(self):
|
|
"""SFT + fp8 + recipe is valid."""
|
|
cfg = SoupConfig(
|
|
base="m",
|
|
task="sft",
|
|
data={"train": "./d.jsonl"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": "rowwise",
|
|
},
|
|
)
|
|
assert cfg.training.fp8_recipe == "rowwise"
|
|
assert cfg.training.quantization_aware == "fp8"
|
|
|
|
def test_all_recipes_accepted_on_sft(self):
|
|
"""All three recipes are valid on SFT with fp8."""
|
|
for recipe in ("tensorwise", "rowwise", "rowwise_with_gw_hp"):
|
|
cfg = SoupConfig(
|
|
base="m",
|
|
task="sft",
|
|
data={"train": "./d.jsonl"},
|
|
training={
|
|
"quantization_aware": "fp8",
|
|
"fp8_recipe": recipe,
|
|
},
|
|
)
|
|
assert cfg.training.fp8_recipe == recipe
|
|
|
|
|
|
# ─── Backward compatibility ───────────────────────────────────────────────
|
|
|
|
|
|
class TestFP8RecipeBackwardCompat:
|
|
"""v0.28.0 configs without fp8_recipe still work (defaults to tensorwise)."""
|
|
|
|
def test_v028_config_no_recipe_field(self):
|
|
"""Config with quantization_aware='fp8' but no fp8_recipe is valid."""
|
|
cfg = SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"quantization_aware": "fp8"},
|
|
)
|
|
assert cfg.training.quantization_aware == "fp8"
|
|
assert cfg.training.fp8_recipe == "tensorwise"
|
|
|
|
def test_v028_bool_true_unaffected(self):
|
|
"""Bool True (int8 QAT) is unaffected by fp8_recipe field."""
|
|
cfg = SoupConfig(
|
|
base="test/model",
|
|
data={"train": "./data.jsonl"},
|
|
training={"quantization_aware": True},
|
|
)
|
|
assert cfg.training.quantization_aware is True
|
|
assert cfg.training.fp8_recipe == "tensorwise" # default, unused
|
|
|
|
def test_training_config_fp8_recipe_default(self):
|
|
"""TrainingConfig alone defaults fp8_recipe to tensorwise."""
|
|
tcfg = TrainingConfig()
|
|
assert tcfg.fp8_recipe == "tensorwise"
|
|
|
|
|
|
# ─── Dispatch through apply_v028_speed_memory (covers 10 non-SFT trainers) ──
|
|
|
|
|
|
class TestFP8RecipeViaV028Features:
|
|
"""All non-SFT trainers go through apply_v028_speed_memory; verify it
|
|
passes the user-configured recipe through to apply_fp8_training instead
|
|
of always defaulting to tensorwise (silent no-op bug fix)."""
|
|
|
|
def _run_dispatch(self, recipe: str) -> MagicMock:
|
|
"""Invoke apply_v028_speed_memory with quantization_aware=fp8 and
|
|
the given recipe, returning the patched apply_fp8_training mock."""
|
|
tcfg = MagicMock()
|
|
tcfg.quantization_aware = "fp8"
|
|
tcfg.fp8_recipe = recipe
|
|
tcfg.use_cut_ce = False
|
|
tcfg.kernel_auto_compose = False
|
|
|
|
with patch("soup_cli.utils.fp8.apply_fp8_training", return_value=True) as m:
|
|
from soup_cli.utils.v028_features import apply_v028_speed_memory
|
|
|
|
apply_v028_speed_memory(
|
|
model=MagicMock(),
|
|
tcfg=tcfg,
|
|
base_model="test/model",
|
|
console=None,
|
|
device="cuda",
|
|
backend="transformers",
|
|
)
|
|
return m
|
|
|
|
def test_v028_dispatch_tensorwise(self):
|
|
m = self._run_dispatch("tensorwise")
|
|
m.assert_called_once()
|
|
assert m.call_args.kwargs.get("recipe") == "tensorwise"
|
|
|
|
def test_v028_dispatch_rowwise(self):
|
|
m = self._run_dispatch("rowwise")
|
|
m.assert_called_once()
|
|
assert m.call_args.kwargs.get("recipe") == "rowwise"
|
|
|
|
def test_v028_dispatch_rowwise_with_gw_hp(self):
|
|
m = self._run_dispatch("rowwise_with_gw_hp")
|
|
m.assert_called_once()
|
|
assert m.call_args.kwargs.get("recipe") == "rowwise_with_gw_hp"
|
|
|
|
def test_v028_skips_apply_when_quant_not_fp8(self):
|
|
"""If quantization_aware != 'fp8', apply_fp8_training is not called."""
|
|
tcfg = MagicMock()
|
|
tcfg.quantization_aware = True # int8 QAT, not fp8
|
|
tcfg.fp8_recipe = "rowwise"
|
|
tcfg.use_cut_ce = False
|
|
tcfg.kernel_auto_compose = False
|
|
|
|
with patch("soup_cli.utils.fp8.apply_fp8_training") as m:
|
|
from soup_cli.utils.v028_features import apply_v028_speed_memory
|
|
|
|
apply_v028_speed_memory(
|
|
model=MagicMock(),
|
|
tcfg=tcfg,
|
|
base_model="test/model",
|
|
console=None,
|
|
)
|
|
m.assert_not_called()
|