Skip to content

Commit d20e3ee

Browse files
committed
MLX ORPO/DPO: fix fast-tokenizer unwrap, LoRA reference collection, and pair-count accumulation
Three correctness fixes on the preference (ORPO/DPO) path: - create_preference_batches unwrapped the tokenizer with getattr(tokenizer, "_tokenizer", tokenizer), which on a plain PreTrainedTokenizerFast returns the low-level Rust tokenizers.Tokenizer (no eos_token_id; encode returns Encoding), crashing before training. Add _hf_encoding_tokenizer, which only unwraps non-HF wrappers such as mlx-lm's TokenizerWrapper, matching _get_processor_tokenizer. - The DPO reference collected adapters with tree_flatten(model, is_leaf=lambda x: isinstance(x, LoRALinear)), but tree_flatten only recurses dict/list/tuple, so a bare nn.Module is a single leaf and nested adapters were never reached, leaving _lora_mods empty and raising "model has none" for ordinary LoRA models. Use iter_mlx_lora_modules to walk the module tree, which also covers LoRAEmbedding, LoRASwitchLinear and DoRA. - The ORPO/DPO loss functions returned the response-token count as the accumulation weight, but these losses are means over preference pairs. With gradient_accumulation_steps > 1 (default 4) that let long-completion microbatches dominate and broke equivalence with a single batch of the same pairs. Return the pair count instead. Add regression tests covering all three.
1 parent 4035588 commit d20e3ee

3 files changed

Lines changed: 211 additions & 8 deletions

File tree

tests/test_mlx_trainer_internals.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,3 +1949,172 @@ def test_quantized_linear_forward():
19491949
# x @ W.T with W = [[0,1,2,3,4,5,6,7], [0,1,2,3,4,5,6,7]] = [28, 28]
19501950
out = layer(x)
19511951
torch.testing.assert_close(out, torch.tensor([[28.0, 28.0]]))
1952+
1953+
1954+
# --- Preference (ORPO / DPO) review-round-3 regressions ----------------
1955+
1956+
def _build_fast_tokenizer(eos="<eos>"):
1957+
"""A plain HF PreTrainedTokenizerFast (its ``_tokenizer`` is the Rust
1958+
backend, which exposes neither ``eos_token_id`` nor a list-returning
1959+
``encode``)."""
1960+
from transformers import PreTrainedTokenizerFast
1961+
from tokenizers import Tokenizer, models, pre_tokenizers
1962+
vocab = {eos: 0, "a": 1, "b": 2, "c": 3, "prompt": 4, "good": 5, "bad": 6}
1963+
inner = Tokenizer(models.WordLevel(vocab=vocab, unk_token=eos))
1964+
inner.pre_tokenizer = pre_tokenizers.Whitespace()
1965+
return PreTrainedTokenizerFast(tokenizer_object=inner, eos_token=eos)
1966+
1967+
1968+
def test_preference_tokenizer_keeps_public_hf_fast_tokenizer_api():
1969+
# Regression: create_preference_batches used getattr(tok, "_tokenizer", tok),
1970+
# which unwraps a plain PreTrainedTokenizerFast to its low-level Rust
1971+
# tokenizers.Tokenizer (no eos_token_id; encode returns an Encoding), so
1972+
# ORPO/DPO batching crashed before training. _hf_encoding_tokenizer must
1973+
# only unwrap non-HF wrappers (mlx-lm's TokenizerWrapper).
1974+
from unsloth_zoo.mlx.utils import _hf_encoding_tokenizer
1975+
1976+
ptf = _build_fast_tokenizer()
1977+
assert ptf._tokenizer is not ptf # the Rust backend is present...
1978+
resolved = _hf_encoding_tokenizer(ptf)
1979+
assert resolved is ptf # ...but we must NOT unwrap to it
1980+
assert resolved.eos_token_id == 0
1981+
assert isinstance(resolved.encode("prompt good"), list)
1982+
1983+
# A TokenizerWrapper-style object (not a PreTrainedTokenizerBase) that
1984+
# proxies the HF tokenizer under _tokenizer IS unwrapped.
1985+
class _Wrapper:
1986+
def __init__(self, inner):
1987+
self._tokenizer = inner
1988+
inner = object()
1989+
assert _hf_encoding_tokenizer(_Wrapper(inner)) is inner
1990+
1991+
1992+
def test_create_preference_batches_runs_with_plain_fast_tokenizer():
1993+
# End-to-end: batching a plain PreTrainedTokenizerFast must not raise.
1994+
from unsloth_zoo.mlx.utils import create_preference_batches
1995+
1996+
ptf = _build_fast_tokenizer()
1997+
dataset = [
1998+
{"prompt": "prompt", "chosen": "good", "rejected": "bad"},
1999+
{"prompt": "prompt a", "chosen": "good b", "rejected": "bad c"},
2000+
]
2001+
batches = create_preference_batches(
2002+
dataset=dataset,
2003+
tokenizer=ptf,
2004+
batch_size=2,
2005+
max_seq_length=16,
2006+
pad_to_multiple=8,
2007+
)
2008+
assert len(batches) == 1
2009+
batch, lengths, extra = batches[0]
2010+
assert extra is None
2011+
# 2 pairs -> 4 rows (chosen block then rejected block).
2012+
assert batch.shape[0] == 4
2013+
assert lengths.shape[0] == 4
2014+
2015+
2016+
def test_dpo_reference_collects_nested_lora_modules():
2017+
# Regression: the DPO reference collected adapters with
2018+
# tree_flatten(model, is_leaf=lambda x: isinstance(x, LoRALinear)),
2019+
# but tree_flatten only recurses dict/list/tuple, so a bare nn.Module is a
2020+
# single leaf and nested adapters are never reached -> _lora_mods empty ->
2021+
# make_dpo_loss_fn raises "model has none". iter_mlx_lora_modules walks the
2022+
# module tree instead.
2023+
import mlx.nn as nn
2024+
from mlx.utils import tree_flatten
2025+
from unsloth_zoo.mlx.utils import iter_mlx_lora_modules
2026+
2027+
class Adapter(nn.Module):
2028+
def __init__(self):
2029+
super().__init__()
2030+
self.lora_a = torch.zeros(2, 4)
2031+
self.lora_b = torch.zeros(4, 2)
2032+
2033+
class Block(nn.Module):
2034+
def __init__(self):
2035+
super().__init__()
2036+
self.q_proj = Adapter()
2037+
2038+
class Model(nn.Module):
2039+
def __init__(self):
2040+
super().__init__()
2041+
self.layers = [Block(), Block()]
2042+
2043+
model = Model()
2044+
# The old leaf-scan treats the whole module as one leaf: no adapter found.
2045+
flat = tree_flatten(model, is_leaf=lambda x: isinstance(x, Adapter))
2046+
assert all(not isinstance(m, Adapter) for _, m in flat)
2047+
# The fix traverses modules and finds every adapter that owns lora_a/lora_b.
2048+
mods = [m for _, m in iter_mlx_lora_modules(model)]
2049+
assert len(mods) == 2
2050+
assert all(hasattr(m, "lora_a") and hasattr(m, "lora_b") for m in mods)
2051+
2052+
2053+
class _StubLM:
2054+
def __init__(self, vocab):
2055+
self.vocab = vocab
2056+
2057+
def __call__(self, x):
2058+
import mlx.core as mx
2059+
return mx.zeros((x.shape[0], x.shape[1], self.vocab))
2060+
2061+
2062+
def _preference_batch(vocab=8):
2063+
# 2 pairs (B=2) -> 4 rows; chosen rows have long responses, rejected short,
2064+
# so the response-token count differs from the pair count.
2065+
import mlx.core as mx
2066+
batch = mx.array(
2067+
[
2068+
[4, 1, 1, 1, 1], # chosen pair 0
2069+
[4, 2, 2, 2, 2], # chosen pair 1
2070+
[4, 3, 0, 0, 0], # rejected pair 0
2071+
[4, 3, 0, 0, 0], # rejected pair 1
2072+
],
2073+
dtype=mx.int32,
2074+
)
2075+
# [response_start, seq_end): chosen span 4 tokens each, rejected 1 each.
2076+
lengths = mx.array(
2077+
[
2078+
[1, 5],
2079+
[1, 5],
2080+
[1, 2],
2081+
[1, 2],
2082+
]
2083+
)
2084+
return batch, lengths
2085+
2086+
2087+
def _patch_per_token_ce(monkeypatch):
2088+
# The shim's nn.losses.cross_entropy defaults to reduction="mean" and is
2089+
# not shape-faithful; real mlx returns a per-token tensor. Patch it so the
2090+
# loss fns run end-to-end (the pair-count return is what is under test).
2091+
import mlx.nn as nn
2092+
monkeypatch.setattr(
2093+
nn.losses,
2094+
"cross_entropy",
2095+
lambda logits, targets, **kw: torch.zeros(tuple(targets.shape)),
2096+
)
2097+
2098+
2099+
def test_dpo_loss_returns_pair_count_not_token_count(monkeypatch):
2100+
# Regression: DPO is a mean over PAIRS, so the accumulate-then-normalize
2101+
# trainer must weight each microbatch by its pair count. Returning the
2102+
# response-token count made long-completion microbatches dominate and broke
2103+
# equivalence with a single batch under gradient_accumulation_steps > 1.
2104+
from unsloth_zoo.mlx.utils import make_dpo_loss_fn
2105+
2106+
_patch_per_token_ce(monkeypatch)
2107+
batch, lengths = _preference_batch()
2108+
loss_fn = make_dpo_loss_fn(beta=0.1, lora_mods=None, reference_free=True)
2109+
_loss, count = loss_fn(_StubLM(8), batch, lengths)
2110+
assert int(count) == 2 # pair count (B), not the 10 response tokens
2111+
2112+
2113+
def test_orpo_loss_returns_pair_count_not_token_count(monkeypatch):
2114+
from unsloth_zoo.mlx.utils import make_orpo_loss_fn
2115+
2116+
_patch_per_token_ce(monkeypatch)
2117+
batch, lengths = _preference_batch()
2118+
loss_fn = make_orpo_loss_fn(beta=0.1)
2119+
_loss, count = loss_fn(_StubLM(8), batch, lengths)
2120+
assert int(count) == 2 # pair count (B), not the response-token count

unsloth_zoo/mlx/trainer.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
import mlx.nn as nn
5151
import mlx.optimizers as optim
5252
from mlx.utils import tree_flatten, tree_map, tree_reduce, tree_unflatten
53-
from mlx_lm.tuner.lora import LoRALinear
5453

5554
_PAD_MULTIPLE = 32
5655
SUPPORTED_MLX_OPTIMIZERS = ("adafactor", "adamw", "adam", "sgd", "muon", "lion")
@@ -2043,9 +2042,12 @@ def _main_print(*print_args, **print_kwargs):
20432042
elif getattr(args, "loss_type", "sft") == "dpo":
20442043
_db = getattr(args, "dpo_beta", 0.1)
20452044
_rf = bool(getattr(args, "reference_free", False))
2046-
_lora_mods = [mod for _, mod in tree_flatten(
2047-
model, is_leaf=lambda x: isinstance(x, LoRALinear))
2048-
if isinstance(mod, LoRALinear)]
2045+
# tree_flatten only recurses dict/list/tuple, so a bare
2046+
# nn.Module is a single leaf and never reaches nested LoRA
2047+
# layers. Walk the module tree instead so LoRALinear,
2048+
# LoRAEmbedding, LoRASwitchLinear and DoRA adapters are all
2049+
# collected for the reference (adapters-off) pass.
2050+
_lora_mods = [mod for _, mod in iter_mlx_lora_modules(model)]
20492051
loss_fn = make_dpo_loss_fn(beta=_db, lora_mods=_lora_mods, reference_free=_rf)
20502052
print("Unsloth: Using DPO loss (beta=" + str(_db) +
20512053
(", reference_free" if _rf else "") + ").")

unsloth_zoo/mlx/utils.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,13 @@ def loss_fn(model, batch, lengths, labels=None):
897897
# (model) dtype so the combined loss dtype is unchanged.
898898
or_loss = _orpo_odds_ratio_loss(logp_c, logp_r)
899899
loss = sft + beta * or_loss.astype(sft.dtype)
900-
return loss, mask.sum()
900+
# Return the PAIR count, not the response-token count. This is a
901+
# preference loss reduced as a mean over pairs (the odds-ratio term),
902+
# so the trainer's accumulate-then-normalize machinery must weight each
903+
# microbatch by its pair count. Weighting by tokens would make
904+
# long-completion microbatches dominate and break equivalence with a
905+
# single batch of the same pairs under gradient_accumulation_steps > 1.
906+
return loss, mx.array(B, dtype=mx.int32)
901907
return loss_fn
902908

903909

@@ -942,7 +948,7 @@ def _row_logp_and_mask(model, batch, lengths):
942948

943949
def loss_fn(model, batch, lengths, labels=None):
944950
B = batch.shape[0] // 2
945-
pol, ntoks = _row_logp_and_mask(model, batch, lengths)
951+
pol, _ = _row_logp_and_mask(model, batch, lengths)
946952
pol_c, pol_r = pol[:B], pol[B:]
947953

948954
if reference_free or not _mods:
@@ -962,9 +968,35 @@ def loss_fn(model, batch, lengths, labels=None):
962968

963969
logits = beta * ((pol_c - ref_c) - (pol_r - ref_r))
964970
loss = -mx.mean(nn.log_sigmoid(logits))
965-
return loss, ntoks
971+
# Return the PAIR count, not the response-token count. DPO is a mean
972+
# over preference pairs, so the trainer must weight each microbatch by
973+
# its pair count when accumulating; token weighting would let
974+
# long-completion microbatches dominate and break equivalence with a
975+
# single batch of the same pairs under gradient_accumulation_steps > 1.
976+
return loss, mx.array(B, dtype=mx.int32)
966977
return loss_fn
967978

979+
980+
def _hf_encoding_tokenizer(tokenizer):
981+
"""Return the Hugging Face tokenizer used for text encoding.
982+
983+
mlx-lm's ``TokenizerWrapper`` stores the HF tokenizer under ``_tokenizer``,
984+
so unwrapping it exposes ``eos_token_id`` and a list-returning ``encode``.
985+
HF fast tokenizers ALSO expose ``_tokenizer``, but there it is the
986+
low-level Rust ``tokenizers.Tokenizer`` (no ``eos_token_id``; ``encode``
987+
returns ``Encoding`` objects). Only unwrap when the object is not already
988+
an HF tokenizer, mirroring ``_get_processor_tokenizer`` /
989+
``_resolve_response_mask_tokenizer``.
990+
"""
991+
try:
992+
from transformers import PreTrainedTokenizerBase
993+
if isinstance(tokenizer, PreTrainedTokenizerBase):
994+
return tokenizer
995+
except Exception:
996+
pass
997+
return getattr(tokenizer, "_tokenizer", tokenizer)
998+
999+
9681000
def create_preference_batches(dataset, tokenizer, batch_size, max_seq_length,
9691001
prompt_key="prompt", chosen_key="chosen",
9701002
rejected_key="rejected", pad_to_multiple=32,
@@ -1004,7 +1036,7 @@ def create_preference_batches(dataset, tokenizer, batch_size, max_seq_length,
10041036
f"Unsloth MLX: unsupported preference dataset_order={order_mode!r}. "
10051037
"Expected 'default', 'sequential', or 'torch_randperm'."
10061038
)
1007-
hf = getattr(tokenizer, "_tokenizer", tokenizer)
1039+
hf = _hf_encoding_tokenizer(tokenizer)
10081040
eos_id = hf.eos_token_id
10091041
pad_id = eos_id if eos_id is not None else 0
10101042

0 commit comments

Comments
 (0)