unsloth/tests/_rl_source.py
Daniel Han baa96a7160
Stop the GRPO hidden-states wrapper paying for logits it discards (#8576)
* Stop the GRPO hidden-states wrapper paying for logits it discards

`Kaggle-Muse_Glimmer_(30B)-GRPO` dies on a 2 x T4 kernel at the first training
step:

    accelerate/hooks.py:429  AlignDevicesHook.post_forward
      -> send_to_device(output, self.input_device)
    OutOfMemoryError: Tried to allocate 1002.00 MiB.
    GPU 0 has 14.56 GiB capacity, of which 768.81 MiB is free.

It was reported before that as `CUDA error: an illegal memory access was
encountered` inside `transformers.masking_utils.fast_all`, which is a
`tensor.sum()` and therefore the first synchronising op after the real fault.
Re-running with CUDA_LAUNCH_BLOCKING=1 resolved it to the OOM above.

Two costs meet in that copy, and this wrapper creates both.

UnslothEfficientGRPO never sees a logits tensor: it takes per-token logps plus
`lm_head` and chunks the projection itself, and this wrapper exists to hand it
hidden states in place of logits. But it forwarded the caller's
`logits_to_keep` unchanged, and the GRPO trainer does not pass one:

    outputs = unwrapped_model(
        input_ids = input_ids_chunk,
        attention_mask = attention_mask_chunk,
        ...
    )
    logits_chunk = outputs.logits
    del outputs

transformers reads a missing or zero value as `slice(-0, None)`, which is
`slice(0, None)`: every position. So the model projected the whole prompt and
completion over a 202048-wide vocabulary, softcapped the result twice, and the
wrapper then overwrote it with hidden states.

`output_hidden_states = True` also returns every layer. Only `[-1]` is read and
the rest stayed attached to the output.

On one card neither cost is visible; `del outputs` frees both a line later.
Under an accelerate layer-split dispatch, `io_same_device` walks the whole
returned object and copies every tensor in it to the input device first, so both
cross the bus and the first card runs out.

## The repair

`_minimise_logits_kwarg` pins the limit to 1 when we are going to replace the
logits anyway. 1 rather than 0 because 0 means "all of them". It prefers
`logits_to_keep`, falls back to the legacy `num_logits_to_keep`, and does
nothing when the forward takes neither, when the caller passed the value
positionally (passing it again by keyword is a TypeError), or when `labels` is
present, since a model computing its own loss needs real logits. If a forward
advertises the parameter and then rejects the value, the call is retried
without it rather than losing hidden states over an optimisation.

`_drop_spare_hidden_states` clears the layers nobody reads. Note that
`outputs.hidden_states = None` does NOT do this. ModelOutput.__setattr__ is

    if name in field_names and value is not None:
        super().__setitem__(name, value)
    super().__setattr__(name, value)

so assigning None updates the attribute and leaves the mapping entry holding
the full tuple, and `__delitem__`, `pop`, `update` and `setdefault` all raise.
Anything that walks the object as a mapping -- `send_to_device`, which is the
one that matters -- still sees and copies all of it. Writing through
`OrderedDict.__setitem__` is what actually clears it.

## Measured

Real forward, `unsloth/Qwen2.5-0.5B-Instruct`, 401 positions, 151936 vocab,
transformers 4.57.6:

              lm_head rows   peak MiB   hidden_states entry   returned shape
    before         401         1094.1         tuple           (1, 401, 896)
    after            1          989.6         None            (1, 401, 896)

The returned tensor is identical, so nothing downstream sees a difference.

## Scope

The wrapper is only installed for models that do not natively honour
UNSLOTH_RETURN_HIDDEN_STATES; `_model_supports_unsloth_return_hidden_states`
short-circuits the ones unsloth patches itself, so llama, mistral and the rest
of the fast paths are untouched. The `outputs.hidden_states` reads in
`llama.py` and `mistral.py` are inside their own forwards, on the inner model's
output, and run before this wrapper's return.

## Tests

`tests/test_grpo_hidden_states_logits_cost.py`, 23 tests. The fake output
object reproduces ModelOutput's assignment semantics exactly, and one test
asserts the trap still exists so the rest cannot pass vacuously. Both spellings
of the kwarg, the positional case, the `labels` case, var-keyword forwards, the
reject-and-retry path, and an unrelated TypeError still propagating.

Mutation-checked: dropping the minimisation turns 3 red, and replacing the
mapping write with the naive `= None` turns the layer-drop test red.

1019 GRPO and hidden-state tests pass together.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep the raw-logits fallback whole when the logits limiter is in play

The wrapper now asks for one logit position because it overwrites them with
hidden states. Three paths still hand those logits back:

- a forward that refuses the limiter and then also refuses
  output_hidden_states raised its second TypeError outside the handler, where
  the first would have fallen back to raw logits. Route the retry through the
  same fallback.
- a forward that accepts the limiter but returns no hidden states returned the
  one-position tensor as the result. GRPO drops the last position and slices
  the completion window out of the rest, so it had nothing left. Re-run on the
  caller's own arguments instead.
- labels supplied positionally never reached the keyword lookup that keeps the
  logits intact, so the model computed its own loss over one position. Read
  them off the bound arguments.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Re-run the absent-hidden-states fallback on the shape that just worked

The caller's own kwargs could carry a value already bound positionally; the
minimised kwargs are the ones the forward has just accepted, so put the
caller's limit back into those and reuse them.

* Do not reach for the legacy kwarg when a width is already positional

Two things, both from measuring the transformers range this has to work
across rather than assuming it.

The comment was wrong. It said 4.57.6 spells the limit num_logits_to_keep.
inspect.signature on LlamaForCausalLM.forward in real 4.57.6, 5.0.0 and
5.15.0 venvs says all three declare logits_to_keep and none declares the
other name. So the legacy branch is not there for transformers; it is there
for us. unsloth/models/llama.py and mistral.py patch in forwards declaring
both, and unsloth/models/vision.py probes the old name first because some
VLM stacks still carry only it. The branch stays; the reason is now stated
correctly.

The behaviour change: when the modern name is already bound positionally,
give up instead of falling through to the legacy one. On a forward that
declares only the modern name and sinks the rest into **kwargs, the legacy
name is accepted and ignored -- no logits saved -- and returning a non-None
name arms the absent-hidden-states re-run, so we would buy a second full
forward for nothing. A caller that bound a width positionally has said what
it wants; leave it alone. New test covers exactly that shape.

27 tests pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-13 05:15:07 -07:00

77 lines
2.6 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Load the GRPO hidden-states forward wrapper out of ``unsloth/models/rl.py``.
Same trick as ``_grpo_dispatch_source``: lift the module-level defs with ``ast``
rather than importing ``unsloth``, so the tests stay CPU-only and import-free
while still tracking the shipped code.
"""
from __future__ import annotations
import ast
import collections
import inspect
import logging
import os
from pathlib import Path
SOURCE_PATH = Path(__file__).resolve().parents[1] / "unsloth" / "models" / "rl.py"
WRAPPER_NAMES = (
"_module_returns_logits",
"_grpo_hidden_states_wrap_target",
"_model_supports_unsloth_return_hidden_states",
"_drop_forward_kwargs_consumed_positionally",
"_get_num_logits_to_keep",
"_warn_grpo_hidden_states_fallback_once",
"_note_grpo_hidden_states_success",
"_replace_outputs_logits",
"_minimise_logits_kwarg",
"_drop_spare_hidden_states",
"_install_grpo_hidden_states_forward_wrapper",
)
# present only once the per-call degradation fix has landed
OPTIONAL_NAMES = ("_note_grpo_hidden_states_success",)
CONSTANT_NAMES = (
"_UNSLOTH_RETURN_HIDDEN_STATES_SUPPORT_MARKER",
"_UNSLOTH_GRPO_HIDDEN_STATES_WRAPPED_ATTR",
"_UNSLOTH_GRPO_HIDDEN_STATES_WARNING_ATTR",
"_UNSLOTH_GRPO_HIDDEN_STATES_DEGRADED_ATTR",
)
def load_rl_wrapper(names = WRAPPER_NAMES):
"""Return ``{name: object}`` for the wrapper helpers, exec'd from live source."""
text = SOURCE_PATH.read_text(encoding = "utf-8")
tree = ast.parse(text, filename = str(SOURCE_PATH))
wanted = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names:
wanted.append(node)
elif isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id in CONSTANT_NAMES for t in node.targets
):
wanted.append(node)
found = {
node.name for node in wanted if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
missing = set(names) - found - set(OPTIONAL_NAMES)
names = tuple(name for name in names if name in found)
if missing:
raise AssertionError(f"missing module-level defs in {SOURCE_PATH}: {sorted(missing)}")
namespace: dict = {
"os": os,
"collections": collections,
"inspect": inspect,
"logger": logging.getLogger("unsloth-repro"),
}
exec(compile(ast.Module(body = wanted, type_ignores = []), str(SOURCE_PATH), "exec"), namespace)
return {name: namespace[name] for name in names}