mirror of https://github.com/razor-ai/soup.git
209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
"""v0.53.0 Part F — Advanced save / merge formats schema helpers.
|
|
|
|
Two new save-format surfaces ship this release (schema-only):
|
|
|
|
* ``soup merge --save-format <fmt>`` where fmt ∈ {fp16, 4bit, 4bit_forced}.
|
|
``4bit`` writes a single BNB-4bit-quantized merged checkpoint without
|
|
the dequant → merge → requant cycle (unsloth ``merged_4bit`` recipe).
|
|
``4bit_forced`` is the unsloth ``4bit_forced`` shortcut.
|
|
* ``soup export --format torchao --quant-config <yaml>`` — invokes
|
|
``torchao.quantize_`` then ``save_pretrained`` for the
|
|
``Int4WeightOnly`` / ``Int8DynActInt4`` / ``Float8DynActFloat8`` /
|
|
``NVFP4`` PTQ schemes (unsloth + axolotl parity).
|
|
|
|
Live wiring deferred to v0.53.1.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from types import MappingProxyType
|
|
from typing import Mapping
|
|
|
|
# --- Merge save formats ------------------------------------------------------
|
|
MERGE_SAVE_FORMATS: frozenset[str] = frozenset({
|
|
"fp16", "4bit", "4bit_forced",
|
|
})
|
|
|
|
# --- TorchAO PTQ schemes (a closed allowlist, mirrors the v0.38.0 Quant Menu
|
|
# string convention so YAML round-trips cleanly).
|
|
TORCHAO_PTQ_SCHEMES: frozenset[str] = frozenset({
|
|
"Int4WeightOnly",
|
|
"Int8DynActInt4",
|
|
"Float8DynActFloat8",
|
|
"NVFP4",
|
|
})
|
|
|
|
_MAX_SAVE_FORMAT_LEN: int = 32
|
|
_MAX_TORCHAO_SCHEME_LEN: int = 48
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MergeSaveSpec:
|
|
"""Frozen metadata for a merge-save format."""
|
|
|
|
name: str
|
|
bits: int
|
|
description: str
|
|
live_wired: bool
|
|
|
|
|
|
_MERGE_METADATA: Mapping[str, MergeSaveSpec] = MappingProxyType({
|
|
"fp16": MergeSaveSpec(
|
|
name="fp16", bits=16,
|
|
description="Standard FP16 merged checkpoint (default — pre-v0.53.0)",
|
|
live_wired=True,
|
|
),
|
|
"4bit": MergeSaveSpec(
|
|
name="4bit", bits=4,
|
|
description="Single BNB-4bit-quantized merged checkpoint",
|
|
live_wired=False,
|
|
),
|
|
"4bit_forced": MergeSaveSpec(
|
|
name="4bit_forced", bits=4,
|
|
description="Forced BNB-4bit merge (unsloth 4bit_forced)",
|
|
live_wired=False,
|
|
),
|
|
})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TorchAOPTQSpec:
|
|
"""Frozen metadata for a TorchAO PTQ scheme."""
|
|
|
|
name: str
|
|
bits: int
|
|
description: str
|
|
live_wired: bool
|
|
|
|
|
|
_TORCHAO_METADATA: Mapping[str, TorchAOPTQSpec] = MappingProxyType({
|
|
"Int4WeightOnly": TorchAOPTQSpec(
|
|
name="Int4WeightOnly", bits=4,
|
|
description="TorchAO Int4WeightOnly (weight-only int4)",
|
|
live_wired=False,
|
|
),
|
|
"Int8DynActInt4": TorchAOPTQSpec(
|
|
name="Int8DynActInt4", bits=4,
|
|
description="TorchAO Int8DynActInt4 (dynamic int8 act + int4 weight)",
|
|
live_wired=False,
|
|
),
|
|
"Float8DynActFloat8": TorchAOPTQSpec(
|
|
name="Float8DynActFloat8", bits=8,
|
|
description="TorchAO FP8 dynamic activations + FP8 weight",
|
|
live_wired=False,
|
|
),
|
|
"NVFP4": TorchAOPTQSpec(
|
|
name="NVFP4", bits=4,
|
|
description="TorchAO NVFP4 (Blackwell FP4 PTQ)",
|
|
live_wired=False,
|
|
),
|
|
})
|
|
|
|
|
|
def _validate_string_field(value: object, field: str, max_len: int) -> str:
|
|
if isinstance(value, bool):
|
|
raise TypeError(f"{field} must not be bool, got {value!r}")
|
|
if not isinstance(value, str):
|
|
raise TypeError(f"{field} must be str, got {type(value).__name__}")
|
|
if not value:
|
|
raise ValueError(f"{field} must be non-empty")
|
|
if "\x00" in value:
|
|
raise ValueError(f"{field} must not contain null bytes")
|
|
if len(value) > max_len:
|
|
raise ValueError(f"{field} too long (max {max_len} chars)")
|
|
return value
|
|
|
|
|
|
def validate_merge_save_format(value: object) -> str:
|
|
"""Validate ``--save-format`` arg. Case-insensitive normalisation.
|
|
|
|
Mirrors v0.41.0 ``optimizer`` policy. Returns the lowercase canonical
|
|
form so ``_MERGE_METADATA`` (all-lowercase keys) lookups are O(1).
|
|
"""
|
|
validated = _validate_string_field(value, "save_format", _MAX_SAVE_FORMAT_LEN)
|
|
canonical = validated.lower()
|
|
if canonical not in MERGE_SAVE_FORMATS:
|
|
supported = ", ".join(sorted(MERGE_SAVE_FORMATS))
|
|
raise ValueError(
|
|
f"save_format {value!r} not supported. Supported: {supported}"
|
|
)
|
|
return canonical
|
|
|
|
|
|
def validate_torchao_scheme(value: object) -> str:
|
|
"""Validate a TorchAO PTQ scheme name.
|
|
|
|
INTENTIONALLY CASE-SENSITIVE — these are PyTorch class names
|
|
(``Int4WeightOnly`` / ``NVFP4`` etc.) that ``torchao.quantize_`` looks up
|
|
by exact name. Diverges from ``validate_merge_save_format`` (which
|
|
lowercase-normalises) and ``validate_kv_cache_type`` (also lowercase)
|
|
on purpose: TorchAO uses CapWords, the others are operator-facing flags.
|
|
"""
|
|
validated = _validate_string_field(
|
|
value, "torchao_scheme", _MAX_TORCHAO_SCHEME_LEN,
|
|
)
|
|
if validated not in TORCHAO_PTQ_SCHEMES:
|
|
supported = ", ".join(sorted(TORCHAO_PTQ_SCHEMES))
|
|
raise ValueError(
|
|
f"torchao_scheme {value!r} not supported. Supported: {supported}"
|
|
)
|
|
return validated
|
|
|
|
|
|
def get_merge_save_spec(name: str) -> MergeSaveSpec:
|
|
"""Return the frozen :class:`MergeSaveSpec` for ``name`` (case-insensitive)."""
|
|
canonical = validate_merge_save_format(name)
|
|
return _MERGE_METADATA[canonical]
|
|
|
|
|
|
def get_torchao_spec(name: str) -> TorchAOPTQSpec:
|
|
"""Return the frozen :class:`TorchAOPTQSpec` for ``name`` (case-sensitive)."""
|
|
canonical = validate_torchao_scheme(name)
|
|
return _TORCHAO_METADATA[canonical]
|
|
|
|
|
|
def validate_quant_config_path(path: object) -> str:
|
|
"""Validate ``--quant-config <yaml>`` argument shape.
|
|
|
|
Boundary contract — what's enforced HERE vs at CLI dispatch (v0.53.1):
|
|
|
|
THIS helper enforces non-empty ``str``, no null bytes, length <= 4096.
|
|
|
|
CLI dispatch in v0.53.1 MUST additionally apply (mirrors v0.43.0 /
|
|
v0.46.0 / v0.47.0 TOCTOU policy):
|
|
* ``os.path.realpath`` + ``os.path.commonpath`` cwd containment
|
|
* ``os.lstat`` + ``stat.S_ISLNK`` rejection before any ``open()``
|
|
* existence + extension check (``.yaml`` / ``.yml``)
|
|
* ``yaml.safe_load`` only.
|
|
"""
|
|
if isinstance(path, bool):
|
|
raise TypeError(f"quant_config must not be bool, got {path!r}")
|
|
if not isinstance(path, str):
|
|
raise TypeError(
|
|
f"quant_config must be str, got {type(path).__name__}"
|
|
)
|
|
if not path:
|
|
raise ValueError("quant_config must be non-empty")
|
|
if "\x00" in path:
|
|
raise ValueError("quant_config must not contain null bytes")
|
|
if len(path) > 4096:
|
|
raise ValueError("quant_config path too long (max 4096 chars)")
|
|
return path
|
|
|
|
|
|
def merge_4bit() -> None:
|
|
"""Live 4bit-merge wiring — deferred to v0.53.1."""
|
|
raise NotImplementedError(
|
|
"soup merge --save-format 4bit live wiring deferred to v0.53.1. "
|
|
"Schema accepts the flag but no merged-4bit writer is registered yet."
|
|
)
|
|
|
|
|
|
def export_torchao() -> None:
|
|
"""Live TorchAO PTQ export — deferred to v0.53.1."""
|
|
raise NotImplementedError(
|
|
"soup export --format torchao live wiring deferred to v0.53.1. "
|
|
"Schema accepts the format but no torchao.quantize_ call is wired."
|
|
)
|