Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
Daniel Han
83900ffb3a Add regression tests for the stray-forward compile-cache reset
Covers the two codex findings fixed in this PR under the GPU-free
tests/conftest.py harness:

- the reset helper is an exported module-level symbol in
  unsloth.models._utils (it previously lived only inside the RL trainer
  template string, so every non-RL import silently no-op'd)
- _unsloth_install_pretrain_detector keeps a recorded "seen" forward on an
  idempotent reinstall with a live hook, and only resets it after teardown
- only a grad-enabled pre-train forward marks the cache poisoned
- _unsloth_reset_stray_compile_cache warns and clears seen when a stray
  forward was seen, tears the hook down even on the clean path, and walks
  the .model/.base_model/.module wrapper chain to reach a nested marker
2026-06-22 12:42:13 +00:00
pre-commit-ci[bot]
de6c777442 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-06-22 12:33:30 +00:00
Daniel Han
bc134a70c7 Make _unsloth_reset_stray_compile_cache an importable module-level helper
The reset was only defined inside the RLTrainer_replacement template string, so
'from unsloth.models.rl import _unsloth_reset_stray_compile_cache' raised ImportError
(swallowed) on the SFT auto-packing wrapper and the injected plain-Trainer loop -
both paths kept the poisoned Dynamo cache and the dangling detector hook.

Move the canonical implementation to unsloth.models._utils (next to the detector,
exported in __all__). The RL trainer template now imports it (no-op fallback if the
import ever fails), and trainer.py / llama.py import it from _utils too, so every
training entry point actually runs the reset.
2026-06-22 12:32:53 +00:00
Daniel Han
202d731903 Preserve detector evidence on reinstall + wire reset into plain Trainer path
P2 (_utils.py): _unsloth_install_pretrain_detector cleared marker['seen'] before the
live-hook early return, so a re-entrant get_peft_model/patch_peft_model after a
grad-enabled probe erased the recorded poisoning while leaving the hook installed,
and train() then skipped the Dynamo reset. Only reset seen when (re)installing a
fresh hook; keep it when a live hook is already recording.

P2 (llama.py): the detector is armed for every LoRA model, but only TRL SFT/RL train
wrappers consumed it. Inject _unsloth_reset_stray_compile_cache(self) at the start of
the generated _fast_inner_training_loop so a bare transformers.Trainer.train() also
drops a poisoned cache and tears down the hook. Idempotent with the TRL-wrapper reset.
2026-06-22 12:15:06 +00:00
pre-commit-ci[bot]
7a981a4708 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-06-22 10:09:21 +00:00
Daniel Han
430af1776b Wire stray-forward compile-cache reset into SFT path and PEFT pass-through
The pre-train forward detector is installed for plain LoRA/vision models in
get_peft_model, but only RL trainers ran the reset via prepare_for_training_mode.
A grad-enabled probe before SFTTrainer.train() therefore left the poisoned Dynamo
cache in place and the detector hook running on every training forward.

- trainer.py: wrap SFTTrainer.train to run _unsloth_reset_stray_compile_cache,
  which both drops the poisoned cache and tears down the detector hook. For
  UnslothSFTTrainer the later prepare_for_training_mode assignment supersedes it.
- llama.py: arm the detector before the 'Already have LoRA adapters' early return
  so pre-wrapped PEFT models keep the reset capability.
2026-06-22 10:08:51 +00:00
danielhanchen
d2f6eec004 torch.compile stray-forward reset: tighten comments (no code change) 2026-06-22 07:55:02 +00:00
danielhanchen
8596138a86 Install pre-train detector on the full-finetuning path too
get_peft_model returns early when UNSLOTH_ENABLE_FULL_FINETUNING=1, before the
detector was installed, so full-finetuning runs (which still use torch.compile) did
not drop a graph cache poisoned by a stray pre-train forward. Install the detector
before both full-finetuning early returns (FastLlamaModel and FastBaseModel). The
detector is idempotent, so this never stacks duplicate hooks when get_peft_model is
also called on a LoRA model.
2026-06-22 04:59:35 +00:00
danielhanchen
76d7932503 Walk DDP/FSDP .module when scanning for the pre-train marker
The chain walk followed only .model and .base_model, so a probe that fired on the
model below a DDP/FSDP wrapper (which exposes it via .module) left the marker
undetected and the poisoned compile cache un-reset. Add .module to the walk.
2026-06-22 02:07:57 +00:00
danielhanchen
b77b63e498 Ignore no-grad pre-train probes and detect probes across the wrapper chain
A no-grad forward (with torch.no_grad(): model(**batch)) builds no AOTAutograd
backward graph, so it cannot poison the compiled training graph. Gate the marker
on torch.is_grad_enabled() so such probes no longer trigger a needless dynamo
reset, recompile and warning on an otherwise clean run.

Also walk the model wrapper chain (PeftModel / DDP / base model) when resetting
so a probe that ran on a different wrapper than self.model is still detected, and
tear down every detector hook in the chain. Re-installing the detector is now
idempotent and only re-registers when a prior hook was already removed.
2026-06-21 13:21:43 +00:00
Daniel Han
9026037298 Reset torch.compile cache poisoned by a stray forward before trainer.train()
A manual forward / forward+backward run under model.train() before
trainer.train() (for example a pre-train grad-norm probe like
out = model(**batch); out.loss.backward()) silently poisons training when
torch.compile is enabled. The stray training-mode pass is the first one in the
process, so it compiles and caches the model forward and, via AOTAutograd, its
backward graph in a one-off context that does not match the real training loop.
When trainer.train() reuses that cached graph the gradients come out NaN/Inf,
the loss never moves, and the run looks like it trains but never learns.

Observed on gpt-oss-20b (loss frozen at ~4.25, grad_norm NaN from step 1) with
both use_gradient_checkpointing="unsloth" and =True. It does not reproduce when
the probe runs under torch.no_grad(), nor with UNSLOTH_COMPILE_DISABLE=1, and a
single torch._dynamo.reset() before training fully cures it (loss 4.29 -> 0.0002,
identical to a run with no probe). Resetting the gradient-checkpointing buffers,
zero_grad, empty_cache, or for_training does not help, confirming the corruption
lives in the torch._dynamo / torch.compile cache.

get_peft_model now attaches a one-shot forward pre-hook that records whether a
forward ran before train(). prepare_for_training_mode checks it at the start of
train() and, if a pre-train forward was seen and torch.compile is enabled, calls
torch._dynamo.reset() (plus a pristine gradient-checkpoint reset and zero_grad)
and warns once. On the normal path (no pre-train forward) it is a strict no-op:
no dynamo reset, no recompilation, identical loss curve.
2026-06-20 12:41:15 +00:00
6 changed files with 321 additions and 0 deletions

View file

@ -0,0 +1,149 @@
# Unsloth - 2x faster, 60% less VRAM LLM training and finetuning
# Copyright 2023-present Daniel Han-Chen, Michael Han-Chen & the Unsloth team. All rights reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
"""The stray-pre-train-forward detector and its torch.compile cache reset.
A grad-enabled forward/backward run before ``trainer.train()`` poisons the
AOTAutograd backward-graph cache; the detector records it so train() can drop
that cache. These cover the idempotent-reinstall evidence guard, the reset's
chain-walk/teardown behaviour, and that the helper is importable at module
scope (every non-RL training entry point imports it). Runs under the GPU-free
``tests/conftest.py`` harness.
"""
from __future__ import annotations
import warnings
import unsloth # noqa: F401 (installs the unsloth patches the functions live behind)
import torch
from unsloth.models._utils import (
_unsloth_install_pretrain_detector,
_unsloth_reset_stray_compile_cache,
)
class _Trainer:
"""Minimal ``self`` stand-in: the reset only reads ``self.model``."""
def test_reset_helper_is_importable_and_exported():
# Regression: the helper used to live only inside rl.py's RLTrainer_replacement template
# string (exec'd into a generated trainer module), so importing it from a real module raised
# ImportError and every non-RL consumer (SFT trainer.py, the plain-Trainer loop, the RL
# template's own delegation) silently no-op'd. Pin it as an exported module-level symbol.
from unsloth.models import _utils
assert callable(_utils._unsloth_reset_stray_compile_cache)
assert "_unsloth_reset_stray_compile_cache" in _utils.__all__
def test_fresh_install_starts_unseen():
m = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(m)
marker = m._unsloth_pretrain_marker
assert marker["seen"] is False
assert "hook" in marker # a live hook is registered
def test_reinstall_with_live_hook_preserves_seen():
# Re-entering get_peft_model/patch_peft_model after a grad-enabled probe must NOT wipe the
# recorded poisoning, or train() skips the reset and the NaN/flat-loss bug returns.
m = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(m)
hook = m._unsloth_pretrain_marker["hook"]
m._unsloth_pretrain_marker["seen"] = True # a probe the live hook recorded
_unsloth_install_pretrain_detector(m) # idempotent re-install
marker = m._unsloth_pretrain_marker
assert marker["seen"] is True # evidence kept
assert marker["hook"] is hook # same hook, not double-registered
def test_reinstall_after_teardown_resets_and_reregisters():
m = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(m)
marker = m._unsloth_pretrain_marker
marker["seen"] = True
marker.pop("hook").remove() # simulate teardown (what the reset does)
_unsloth_install_pretrain_detector(m) # no live hook -> fresh registration
assert marker["seen"] is False # reset for the new session
assert "hook" in marker
def test_grad_enabled_forward_marks_seen_no_grad_does_not():
m = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(m)
with torch.no_grad():
m(torch.zeros(1, 2))
assert m._unsloth_pretrain_marker["seen"] is False # no backward graph -> clean
m(torch.zeros(1, 2)) # grad-enabled forward poisons the cache
assert m._unsloth_pretrain_marker["seen"] is True
def test_reset_clears_seen_and_warns_when_a_stray_forward_was_seen():
m = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(m)
m._unsloth_pretrain_marker["seen"] = True # a stray pre-train forward
trainer = _Trainer()
trainer.model = m
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_unsloth_reset_stray_compile_cache(trainer)
assert any("manual forward/backward" in str(w.message) for w in caught)
assert "hook" not in m._unsloth_pretrain_marker # hook torn down
assert m._unsloth_pretrain_marker["seen"] is False # evidence consumed
def test_reset_tears_down_hook_even_when_not_seen():
# The clean path still removes the one-shot hook so it adds no per-step cost, but must not
# warn or reset Dynamo (nothing was poisoned).
m = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(m) # seen stays False
trainer = _Trainer()
trainer.model = m
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_unsloth_reset_stray_compile_cache(trainer)
assert not any("manual forward/backward" in str(w.message) for w in caught)
assert "hook" not in m._unsloth_pretrain_marker
assert m._unsloth_pretrain_marker["seen"] is False
def test_reset_walks_wrapper_chain_to_reach_a_nested_marker():
# The probe may have run on an inner wrapper (.model/.base_model/.module), not self.model.
inner = torch.nn.Linear(2, 2)
_unsloth_install_pretrain_detector(inner)
inner._unsloth_pretrain_marker["seen"] = True
class _Wrapper: # e.g. a PEFT base_model wrapping the real module
pass
outer = _Wrapper()
outer.base_model = inner
trainer = _Trainer()
trainer.model = outer
with warnings.catch_warnings():
warnings.simplefilter("ignore")
_unsloth_reset_stray_compile_cache(trainer)
assert "hook" not in inner._unsloth_pretrain_marker # found and torn down through the chain
assert inner._unsloth_pretrain_marker["seen"] is False

View file

@ -62,6 +62,8 @@ __all__ = [
"patch_unsloth_smart_gradient_checkpointing",
"unpatch_unsloth_smart_gradient_checkpointing",
"apply_unsloth_gradient_checkpointing",
"_unsloth_install_pretrain_detector",
"_unsloth_reset_stray_compile_cache",
"patch_compiled_autograd",
"process_vision_info",
"unsloth_compile_transformers",
@ -194,6 +196,109 @@ from unsloth_zoo.temporary_patches import (
)
def _unsloth_install_pretrain_detector(model):
"""Attach a one-shot forward pre-hook recording whether a forward ran before
trainer.train(), so prepare_for_training_mode can drop a torch.compile graph cache poisoned
by a stray manual forward/backward. Idempotent; no-op if the model cannot take hooks."""
if model is None or not hasattr(model, "register_forward_pre_hook"):
return model
marker = getattr(model, "_unsloth_pretrain_marker", None)
if isinstance(marker, dict):
# A live hook is already recording: keep it (no duplicates) and DON'T clear seen -- a
# grad-enabled probe may have already flagged the poisoned cache, and a re-entrant
# get_peft_model/patch_peft_model call must not erase that before train() resets.
if "hook" in marker:
return model
# Marker exists but its hook was torn down -> reinstall fresh, so reset seen.
marker["seen"] = False
else:
marker = {"seen": False}
try:
model._unsloth_pretrain_marker = marker
except Exception:
return model
def _mark(_module, _inp):
# Only a grad-enabled forward poisons the AOTAutograd backward-graph cache; a no-grad
# probe builds no backward graph, so treat it as clean (avoids a needless dynamo reset).
if torch.is_grad_enabled():
marker["seen"] = True
try:
marker["hook"] = model.register_forward_pre_hook(_mark)
except Exception:
pass
return model
def _unsloth_reset_stray_compile_cache(self):
# A manual forward/backward under torch.compile BEFORE trainer.train() (e.g. a grad-norm
# probe) caches a forward + AOTAutograd backward graph in a one-off context; reusing it
# poisons training with NaN/zero gradients. If such a forward was seen and compile is on,
# drop the compiled-graph cache so training recompiles cleanly. No-op on the normal path.
# Module-level (not just inside the RL trainer template) so the SFT auto-packing wrapper and
# the plain-Trainer loop can import and run it too.
import os
model = getattr(self, "model", None)
if model is None:
return
# The detector hook can sit on any wrapper in the chain, and the probe may have run on a
# different one than self.model, so walk the chain: detect a "seen" marker anywhere and
# collect every marker to tear down below.
markers = []
seen = False
_curr = model
_visited = set()
while _curr is not None and id(_curr) not in _visited:
_visited.add(id(_curr))
_m = getattr(_curr, "_unsloth_pretrain_marker", None)
if isinstance(_m, dict):
markers.append(_m)
if _m.get("seen"):
seen = True
# Follow the wrapper chain: Unsloth/HF (.model), PEFT (.base_model), DDP/FSDP (.module).
_nxt = getattr(_curr, "model", None)
if _nxt is None:
_nxt = getattr(_curr, "base_model", None)
if _nxt is None:
_nxt = getattr(_curr, "module", None)
_curr = _nxt
if seen and os.environ.get("UNSLOTH_COMPILE_DISABLE", "0") != "1":
try:
import torch._dynamo as _dynamo
_dynamo.reset()
except Exception:
pass
try:
from unsloth_zoo.gradient_checkpointing import (
reset_unsloth_gradient_checkpointing_buffers,
)
reset_unsloth_gradient_checkpointing_buffers()
except Exception:
pass
try:
model.zero_grad(set_to_none = True)
except Exception:
pass
import warnings
warnings.warn(
"Unsloth: detected a manual forward/backward run before trainer.train(); "
"reset the torch.compile graph cache it poisoned so training starts clean. "
"To avoid this, run any pre-train probe under `with torch.no_grad():`."
)
# Tear down every one-shot detector hook in the chain so none adds per-step cost.
for _m in markers:
hook = _m.pop("hook", None)
if hook is not None:
try:
hook.remove()
except Exception:
pass
_m["seen"] = False
def apply_unsloth_gradient_checkpointing(use_gradient_checkpointing, max_seq_length, dtype):
"""
Apply gradient checkpointing with smart heuristics.

View file

@ -2756,6 +2756,20 @@ class FastLlamaModel:
"is_torch_tpu_available()",
"False",
)
# Wire the stray-forward compile-cache reset into the plain Trainer path: get_peft_model
# arms the pre-train detector for every LoRA model, but only the TRL SFT/RL wrappers run
# the reset. A grad-enabled probe before a bare transformers.Trainer.train() would
# otherwise keep the poisoned Dynamo cache and leave the detector hook installed. Anchored
# on the first body statement; a no-op (and harmless) if upstream drops that line.
inner_training_loop = inner_training_loop.replace(
"self.accelerator.free_memory()",
"self.accelerator.free_memory()\n"
" try:\n"
" from unsloth.models._utils import _unsloth_reset_stray_compile_cache as _unsloth_reset_cc\n"
" _unsloth_reset_cc(self)\n"
" except Exception: pass",
1,
)
exec(inner_training_loop, globals())
Trainer._inner_training_loop = _fast_inner_training_loop
@ -2938,6 +2952,9 @@ class FastLlamaModel:
)
if os.environ.get("UNSLOTH_ENABLE_FULL_FINETUNING", "0") == "1":
print("Unsloth: Full finetuning is enabled, so .get_peft_model has no effect")
# Full finetuning still compiles, so a stray pre-train forward can poison the
# cache; install the detector here too (it is idempotent).
_unsloth_install_pretrain_detector(model)
return model
transformers_set_seed(random_state)
@ -3016,6 +3033,9 @@ class FastLlamaModel:
model.get_output_embeddings(), DEVICE_TYPE_TORCH
)
# Pre-wrapped PEFT model passes through here; still arm the detector so an RL
# trainer can reset a compile cache poisoned by a pre-train forward.
_unsloth_install_pretrain_detector(model)
return model
else:
raise TypeError(
@ -3368,6 +3388,9 @@ class FastLlamaModel:
m.for_training = functools.partial(FastBaseModel.for_training, m)
m.for_inference = functools.partial(FastBaseModel.for_inference, m)
m = m.model
# Detect a stray pre-train forward so train() can drop the torch.compile
# graph cache it would otherwise poison (see prepare_for_training_mode).
_unsloth_install_pretrain_detector(model)
return model
@staticmethod
@ -3585,6 +3608,9 @@ class FastLlamaModel:
m.for_training = functools.partial(FastBaseModel.for_training, m)
m.for_inference = functools.partial(FastBaseModel.for_inference, m)
m = m.model
# Detect a stray pre-train forward so train() can drop the torch.compile
# graph cache it would otherwise poison (see prepare_for_training_mode).
_unsloth_install_pretrain_detector(model)
return model
@staticmethod

View file

@ -390,9 +390,20 @@ try:
from unsloth_zoo.gradient_checkpointing import reset_unsloth_gradient_checkpointing_buffers
except:
def reset_unsloth_gradient_checkpointing_buffers(): pass
# Canonical reset lives in unsloth.models._utils so the SFT auto-packing wrapper and the plain
# Trainer loop can import the same helper; fall back to a no-op only if it can't be imported.
try:
from unsloth.models._utils import _unsloth_reset_stray_compile_cache
except Exception:
def _unsloth_reset_stray_compile_cache(self): pass
def prepare_for_training_mode(f):
@functools.wraps(f)
def wrapper(self, *args, **kwargs):
# Drop any torch.compile graph cache poisoned by a stray pre-train forward.
try:
_unsloth_reset_stray_compile_cache(self)
except Exception:
pass
# Finish the previous W&B run if this is a subsequent train() call.
# We do this at the START of train() (not the end) so that
# evaluate() / log() still work after train() completes.

View file

@ -1380,6 +1380,9 @@ class FastBaseModel:
):
if os.environ.get("UNSLOTH_ENABLE_FULL_FINETUNING", "0") == "1":
print("Unsloth: Full finetuning is enabled, so .get_peft_model has no effect")
# Full finetuning still compiles, so a stray pre-train forward can poison the
# cache; install the detector here too (it is idempotent).
_unsloth_install_pretrain_detector(model)
return model
transformers_set_seed(random_state)
@ -1577,6 +1580,9 @@ class FastBaseModel:
m.for_training = functools.partial(FastBaseModel.for_training, m)
m.for_inference = functools.partial(FastBaseModel.for_inference, m)
m = m.model
# Detect a stray pre-train forward so train() can drop the torch.compile
# graph cache it would otherwise poison (see prepare_for_training_mode).
_unsloth_install_pretrain_detector(model)
return model
@staticmethod

View file

@ -596,6 +596,30 @@ def _patch_sft_trainer_auto_packing(trl_module):
)
print(message)
# get_peft_model installs a pre-train forward detector for plain LoRA/vision models,
# but only RL trainers run the reset via prepare_for_training_mode. Wire it into the
# SFT train() path too, else a grad-enabled probe before train() leaves the poisoned
# Dynamo cache in place and the detector hook installed on every training forward.
# (For UnslothSFTTrainer the later prepare_for_training_mode assignment supersedes this.)
if not getattr(self, "_unsloth_train_reset_wrapped", False):
try:
from unsloth.models._utils import _unsloth_reset_stray_compile_cache
_orig_train = self.train
@wraps(_orig_train)
def _train_with_reset(*train_args, **train_kwargs):
try:
_unsloth_reset_stray_compile_cache(self)
except Exception:
pass
return _orig_train(*train_args, **train_kwargs)
self.train = _train_with_reset
self._unsloth_train_reset_wrapped = True
except Exception:
pass
sft_trainer.__init__ = new_init
sft_trainer._unsloth_auto_packing_wrapped = True