mirror of https://github.com/razor-ai/soup.git
234 lines
8.3 KiB
Python
234 lines
8.3 KiB
Python
"""MultipackBatchSampler — First-Fit-Decreasing bin packing for SFT/pretrain.
|
|
|
|
Ports Axolotl's ``MultipackBatchSampler`` (utils/samplers/multipack.py:24-57)
|
|
without the numba runtime dependency. The pure-Python FFD here is fast enough
|
|
for typical dataset sizes (<1M samples); a future ``[multipack]`` extras can
|
|
add an optional numba JIT path keyed off the same ``ffd_bin_pack`` signature.
|
|
|
|
Compared with Axolotl, two intentional differences:
|
|
|
|
1. **Loud-fail on unknown architecture.** Axolotl's monkey-patch silently
|
|
misses architectures absent from its allowlist
|
|
(`monkeypatch/multipack.py:13-66`). Soup raises ``ValueError`` so the user
|
|
knows multipack is not active. Mirrors the v0.33.0 #43 schema-gate policy.
|
|
2. **bool rejection on numeric inputs.** Mirrors v0.30.0 ``Candidate``
|
|
policy — bool is a subclass of int and must not silently coerce.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from collections.abc import Iterator, Sequence
|
|
from typing import Union
|
|
|
|
# Allow-list of HF model architectures that support the FA varlen path
|
|
# (``_get_unpad_data`` monkey-patch). Mirrors Axolotl's list plus the v0.31.0
|
|
# Soup recipe expansion. Keep frozenset to prevent runtime mutation.
|
|
MULTIPACK_ARCHITECTURES: frozenset[str] = frozenset({
|
|
"LlamaForCausalLM",
|
|
"MistralForCausalLM",
|
|
"MixtralForCausalLM",
|
|
"QwenForCausalLM",
|
|
"Qwen2ForCausalLM",
|
|
"Qwen3ForCausalLM",
|
|
"Qwen2MoeForCausalLM",
|
|
"GemmaForCausalLM",
|
|
"Gemma2ForCausalLM",
|
|
"Gemma3ForCausalLM",
|
|
"PhiForCausalLM",
|
|
"Phi3ForCausalLM",
|
|
"Phi4ForCausalLM",
|
|
"DeepseekV2ForCausalLM",
|
|
"DeepseekV3ForCausalLM",
|
|
"FalconForCausalLM",
|
|
"StableLmForCausalLM",
|
|
"SmolLM2ForCausalLM",
|
|
})
|
|
|
|
|
|
# Worst-case FFD complexity is O(N^2). Cap N to prevent a crafted dataset
|
|
# from pinning a CPU. 1M samples is ~3 orders of magnitude beyond typical
|
|
# fine-tuning workloads.
|
|
_MAX_FFD_ITEMS: int = 1_000_000
|
|
|
|
|
|
def _check_int(name: str, value: object) -> int:
|
|
"""Reject bool, non-int, and return as int. Mirrors v0.30.0 policy."""
|
|
if isinstance(value, bool):
|
|
raise TypeError(f"{name} must not be bool, got {value!r}")
|
|
if not isinstance(value, int):
|
|
raise TypeError(f"{name} must be int, got {type(value).__name__}")
|
|
return value
|
|
|
|
|
|
def ffd_bin_pack(lengths: Sequence[int], max_len: int) -> list[list[int]]:
|
|
"""First-Fit-Decreasing bin packing.
|
|
|
|
Args:
|
|
lengths: per-sample sequence lengths.
|
|
max_len: maximum sum of lengths in any bin.
|
|
|
|
Returns:
|
|
A list of bins; each bin is a list of original indices into
|
|
``lengths``. Every index appears exactly once.
|
|
|
|
Raises:
|
|
ValueError: if any length is non-positive, exceeds ``max_len``,
|
|
or if ``max_len`` is non-positive.
|
|
TypeError: if ``max_len`` is bool.
|
|
"""
|
|
_check_int("max_len", max_len)
|
|
if max_len <= 0:
|
|
raise ValueError(f"max_len must be positive, got {max_len}")
|
|
|
|
# Materialise once — protects against a generator being exhausted by the
|
|
# validation pass, which would silently produce empty bins on the sort.
|
|
lengths = list(lengths)
|
|
if not lengths:
|
|
return []
|
|
if len(lengths) > _MAX_FFD_ITEMS:
|
|
raise ValueError(
|
|
f"too many items for FFD bin-packing: {len(lengths)} > "
|
|
f"{_MAX_FFD_ITEMS} (algorithm is O(N^2) worst-case)"
|
|
)
|
|
|
|
# Validate each length up-front so we fail loudly before packing.
|
|
for idx, length in enumerate(lengths):
|
|
_check_int(f"lengths[{idx}]", length)
|
|
if length <= 0:
|
|
raise ValueError(
|
|
f"lengths[{idx}] must be positive, got {length}"
|
|
)
|
|
if length > max_len:
|
|
raise ValueError(
|
|
f"lengths[{idx}]={length} exceeds max_len={max_len}"
|
|
)
|
|
|
|
# Pair each length with its original index, then sort by length descending.
|
|
indexed = sorted(
|
|
enumerate(lengths), key=lambda pair: pair[1], reverse=True,
|
|
)
|
|
|
|
bins: list[list[int]] = []
|
|
bin_remaining: list[int] = [] # parallel to bins: free space in each
|
|
for orig_idx, length in indexed:
|
|
placed = False
|
|
for bin_idx, remaining in enumerate(bin_remaining):
|
|
if length <= remaining:
|
|
bins[bin_idx].append(orig_idx)
|
|
bin_remaining[bin_idx] = remaining - length
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
bins.append([orig_idx])
|
|
bin_remaining.append(max_len - length)
|
|
|
|
return bins
|
|
|
|
|
|
def validate_multipack_architecture(arch_name: str) -> None:
|
|
"""Raise ``ValueError`` if ``arch_name`` is not multipack-supported.
|
|
|
|
Loud-fail policy (vs Axolotl's silent miss) — see module docstring.
|
|
"""
|
|
if not isinstance(arch_name, str):
|
|
raise TypeError(
|
|
f"arch_name must be str, got {type(arch_name).__name__}"
|
|
)
|
|
if not arch_name:
|
|
raise ValueError("arch_name must be non-empty")
|
|
if "\x00" in arch_name:
|
|
raise ValueError("arch_name must not contain null bytes")
|
|
if arch_name not in MULTIPACK_ARCHITECTURES:
|
|
supported = ", ".join(sorted(MULTIPACK_ARCHITECTURES))
|
|
raise ValueError(
|
|
f"Architecture {arch_name!r} not in multipack allowlist. "
|
|
f"Either add it to MULTIPACK_ARCHITECTURES or set "
|
|
f"multipack: false in your config. Supported: {supported}"
|
|
)
|
|
|
|
|
|
# Type alias — a real_batches=False yield is a flat list of indices,
|
|
# real_batches=True is a list of bins (each bin a list of indices).
|
|
BatchType = Union[list[int], list[list[int]]]
|
|
|
|
|
|
class MultipackBatchSampler:
|
|
"""Yield batches of indices packed via FFD into bins of ``batch_max_len``.
|
|
|
|
Two modes:
|
|
|
|
* ``real_batches=False`` — each yielded value is a flat list of indices
|
|
(one bin), to be flattened by the collator into a single packed
|
|
sequence. Axolotl's ``multipack_real_batches=False`` trick.
|
|
* ``real_batches=True`` — each yielded value is a list of bins
|
|
(length ``<= batch_size``), where each bin is a list of indices.
|
|
The collator stacks bins along the batch dimension.
|
|
|
|
Determinism: a fixed ``seed`` produces a stable batch order across runs.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
lengths: Sequence[int],
|
|
batch_max_len: int,
|
|
batch_size: int,
|
|
real_batches: bool = True,
|
|
seed: int = 0,
|
|
drop_last: bool = False,
|
|
) -> None:
|
|
batch_max_len = _check_int("batch_max_len", batch_max_len)
|
|
batch_size = _check_int("batch_size", batch_size)
|
|
seed = _check_int("seed", seed)
|
|
if batch_max_len <= 0:
|
|
raise ValueError(
|
|
f"batch_max_len must be positive, got {batch_max_len}"
|
|
)
|
|
if batch_size <= 0:
|
|
raise ValueError(
|
|
f"batch_size must be positive, got {batch_size}"
|
|
)
|
|
if not lengths:
|
|
raise ValueError("lengths must be non-empty")
|
|
|
|
# Pack once at construction so __len__ is exact and iteration is
|
|
# deterministic. Pre-pack validation in ffd_bin_pack catches
|
|
# over-length items.
|
|
self._lengths: list[int] = list(lengths)
|
|
self._batch_max_len = batch_max_len
|
|
self._batch_size = batch_size
|
|
self._real_batches = bool(real_batches)
|
|
self._seed = seed
|
|
self._drop_last = bool(drop_last)
|
|
|
|
bins = ffd_bin_pack(self._lengths, batch_max_len)
|
|
# Shuffle the packed bins deterministically — keeps inter-bin order
|
|
# randomised across epochs while preserving the FFD packing.
|
|
rng = random.Random(seed)
|
|
rng.shuffle(bins)
|
|
self._bins: list[list[int]] = bins
|
|
|
|
def __len__(self) -> int:
|
|
if not self._real_batches:
|
|
return len(self._bins)
|
|
full = len(self._bins) // self._batch_size
|
|
remainder = len(self._bins) % self._batch_size
|
|
if self._drop_last or remainder == 0:
|
|
return full
|
|
return full + 1
|
|
|
|
def __iter__(self) -> Iterator[BatchType]:
|
|
if not self._real_batches:
|
|
for bin_ in self._bins:
|
|
yield list(bin_)
|
|
return
|
|
|
|
batch: list[list[int]] = []
|
|
for bin_ in self._bins:
|
|
batch.append(list(bin_))
|
|
if len(batch) == self._batch_size:
|
|
yield batch
|
|
batch = []
|
|
if batch and not self._drop_last:
|
|
yield batch
|