unsloth/tests/test_grpo_width_dispatch_sites.py
Daniel Han 42ed15f4cc
Some checks are pending
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Core / Core (HF=default + TRL=default) (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / connection (hermes) (push) Waiting to run
Local Agent Guides CI / connection (openclaw) (push) Waiting to run
Local Agent Guides CI / connection (opencode) (push) Waiting to run
Local Agent Guides CI / connection (pi) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
Local Agent Guides CI / connection (claude) (push) Waiting to run
Local Agent Guides CI / connection (codex) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Security audit / pytest tests/security (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Unsloth export capability / capability (ubuntu-latest) (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
GRPO: dispatch on width at the remaining lm_head matmul call sites (#8204)
* GRPO: dispatch on width at the remaining lm_head matmul call sites

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

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

* GRPO: read an explicit hidden-states signal when the widths cannot decide

The dispatch guard compared the forward's last dim against lm_head.shape[1],
which is the hidden size. On a model whose vocab_size equals its hidden size,
real logits satisfy that test and were sent through
chunked_hidden_states_selective_log_softmax, applying the lm_head a second
time and silently corrupting the log probabilities. The packed path's per-row
verifier made the same misclassification, so it compared corrupted against
corrupted and accepted the result.

Route all four call sites through _unsloth_grpo_returns_hidden_states, which
reads an explicit signal that the forward honoured UNSLOTH_RETURN_HIDDEN_STATES:
__UNSLOTH_SUPPORTS_RETURN_HIDDEN_STATES__ written by the zoo compiler, or the
_unsloth_grpo_hidden_states_forward_wrapped pair set by the rl.py fallback
wrapper. The width comparison stays: it is decisive whenever vocab_size differs
from hidden_size, and the signal is only consulted for the square case the
shape cannot answer, so an unsloth_zoo old enough to write no marker keeps
today's behaviour.

* GRPO: propagate hidden-state signal to gradient dispatches

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

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

* Track GRPO hidden-state degradation per call, and reject a half-patched zoo

The GRPO width dispatch reads two things the code assumed but did not hold.

_warn_grpo_hidden_states_fallback_once only ever set
_unsloth_grpo_hidden_states_warning_issued, so the flag the dispatch reads
after a forward meant "ever degraded", not "degraded on this call". Degradation
is per call: a forward that splats **kwargs into a sub-module only some inputs
reach rejects the hidden-state request on those batches and honours it on the
rest. With vocab_size == hidden_size the width test cannot correct that, so one
degraded batch sent every later hidden-state tensor to the raw-logits helper,
skipping the lm_head matmul. Record the outcome of each call in
_unsloth_grpo_hidden_states_degraded and keep the warning flag for warn-once
logging only; the signal reader falls back to the old flag when the attribute
is absent, so a stale generated trainer keeps working.

The fallback's TypeError retry also could not work: _drop_forward_kwargs_
consumed_positionally hands the caller's dict straight back when there is
nothing to drop, which every GRPO call site hits since they pass everything by
keyword, so adding output_hidden_states/return_dict poisoned the caller's
kwargs and the retry re-sent exactly what the model had just rejected. Copy
before mutating.

Finally, the source patch over zoo's gradient dispatches only failed when
nothing matched. A zoo that respells some of its dispatch sites still leaves
one the pattern recognises, which was enough to suppress the compatibility
error while the respelled sites kept deciding on width alone. Count the branch
headers that decide off an lm_head dimension and require that none survive the
substitution.

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

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

---------

Co-authored-by: Daniel Han <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-11 10:44:06 -07:00

268 lines
11 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Every lm_head matmul in the no-grad GRPO logprob path dispatches on width.
`_get_per_token_logps_and_entropies` sets UNSLOTH_RETURN_HIDDEN_STATES=1, but
`.logits` carries hidden states only when the model's forward is the Unsloth
generated one. When it is not, `.logits` is a real [.., vocab] tensor, and
handing that to `chunked_hidden_states_selective_log_softmax` runs it into the
lm_head matmul:
a and b must have same reduction dim, but got
[((s47*s87 + 255)//256), s33] X [1536, 151936]
`s33` there is a backed symbol that specialises to the hidden size; the message
only appears when the tensor genuinely is the wrong width, and 151936 is the
vocab. The VLM branch of the padded loop already dispatched on
`logits_chunk.shape[-1] == lm_head.shape[1]`; the text branch of the same loop
and both sequence-packing call sites did not.
All four now go through `_unsloth_grpo_returns_hidden_states`, which prefers the
explicit signal that the forward honoured the flag and keeps the width
comparison as its fallback. Width alone cannot answer the question for a model
whose `vocab_size` equals its `hidden_size`.
These checks are structural (AST), not textual, so that neither a comment
mentioning the guard nor a reformat can satisfy them.
"""
from __future__ import annotations
import ast
import os
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
SOURCE_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
HIDDEN_STATES_HELPER = "chunked_hidden_states_selective_log_softmax"
RAW_LOGITS_HELPER = "chunked_selective_log_softmax"
DISPATCH_HELPER = "_unsloth_grpo_returns_hidden_states"
SIGNAL_HELPER = "_unsloth_grpo_hidden_states_signal"
# One shared parse: nodes from separate parses never compare equal, which would
# silently make every containment check below vacuously true.
TREE = ast.parse(open(SOURCE_PATH, "r", encoding = "utf-8").read())
def _logprob_function():
for node in ast.walk(TREE):
if isinstance(node, ast.FunctionDef) and node.name == "_get_per_token_logps_and_entropies":
return node
return None
def _matmul_calls(scope):
"""Calls to the hidden-states helper, i.e. the ones that hit the matmul.
The PrefixGrouper site passes the helper to `extract_logps` as a bare Name
rather than calling it, so it is deliberately not one of these.
"""
return [
node
for node in ast.walk(scope)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == HIDDEN_STATES_HELPER
]
def _is_dispatch_test(test):
"""`_unsloth_grpo_returns_hidden_states(<model>, <tensor>, lm_head)`.
The width comparison itself lives inside that helper, next to the explicit
UNSLOTH_RETURN_HIDDEN_STATES signal it defers to; see
`test_the_dispatch_helper_prefers_the_explicit_signal` below.
"""
if not (isinstance(test, ast.Call) and isinstance(test.func, ast.Name)):
return False
if test.func.id != DISPATCH_HELPER:
return False
if len(test.args) != 3 or test.keywords:
return False
return ast.unparse(test.args[2]) == "lm_head"
def _guard_for(call):
"""The nearest enclosing `if` that dispatches and holds `call` in its body."""
best = None
for node in ast.walk(TREE):
if not isinstance(node, ast.If) or not _is_dispatch_test(node.test):
continue
if not any(call is inner for stmt in node.body for inner in ast.walk(stmt)):
continue
if best is None or node.lineno > best.lineno:
best = node
return best
def _called_names(statements):
names = set()
for stmt in statements:
for node in ast.walk(stmt):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
names.add(node.func.id)
return names
def test_the_logprob_function_is_present():
assert _logprob_function() is not None, (
"_get_per_token_logps_and_entropies not found; this file's other "
"checks would pass vacuously"
)
def test_the_matmul_call_sites_are_all_accounted_for():
"""Four sites: packed, packed verifier, padded text, padded VLM.
Pinned so that a new unguarded call site added later fails here rather than
slipping past the per-site checks below.
"""
calls = _matmul_calls(_logprob_function())
assert len(calls) == 4, [call.lineno for call in calls]
def test_every_matmul_call_site_dispatches_on_the_shared_helper():
calls = _matmul_calls(_logprob_function())
unguarded = [call.lineno for call in calls if _guard_for(call) is None]
assert not unguarded, (
f"lines {unguarded} call {HIDDEN_STATES_HELPER} without first asking "
f"{DISPATCH_HELPER} whether the tensor is hidden states, so a forward "
"that returns real logits reaches the lm_head matmul"
)
def test_the_dispatch_helper_prefers_the_explicit_signal():
"""The helper is what makes the four sites correct, so pin its shape.
It must (a) still compare the tensor's last dim against `lm_head.shape[1]`,
which is the fallback for an unsloth_zoo old enough never to write the
marker, and (b) consult `_unsloth_grpo_hidden_states_signal`, which is the
only thing that can separate real logits from hidden states when
`vocab_size == hidden_size`.
"""
helpers = {
node.name: node
for node in TREE.body
if isinstance(node, ast.FunctionDef) and node.name in (DISPATCH_HELPER, SIGNAL_HELPER)
}
assert sorted(helpers) == sorted((DISPATCH_HELPER, SIGNAL_HELPER)), sorted(helpers)
dispatch = helpers[DISPATCH_HELPER]
compared = {
ast.unparse(operand)
for node in ast.walk(dispatch)
if isinstance(node, ast.Compare)
for operand in [node.left, *node.comparators]
}
assert {"tensor.shape[-1]", "lm_head.shape[1]"} <= compared, sorted(compared)
assert {"lm_head.shape[0]", "lm_head.shape[1]"} <= compared, (
"the helper does not check whether vocab_size == hidden_size, so it "
"either never consults the signal or lets it overrule a width "
"comparison that was already decisive"
)
assert SIGNAL_HELPER in _called_names(dispatch.body), (
f"{DISPATCH_HELPER} never calls {SIGNAL_HELPER}, so it is back to "
"dispatching on an ambiguous dimension comparison alone"
)
# The signal has to come from an explicit marker, not from a shape.
signal_source = ast.unparse(helpers[SIGNAL_HELPER])
for marker in (
"__UNSLOTH_SUPPORTS_RETURN_HIDDEN_STATES__",
"_unsloth_grpo_hidden_states_forward_wrapped",
"_unsloth_grpo_hidden_states_warning_issued",
):
assert marker in signal_source, f"{SIGNAL_HELPER} no longer reads {marker}"
def test_both_helpers_reach_the_generated_trainer():
"""`RL_PRE_ITEMS` is how the call sites see them; without it, NameError."""
shipped = {
ast.unparse(node.value.args[0].args[0])
for node in ast.walk(TREE)
if isinstance(node, ast.Expr)
and isinstance(node.value, ast.Call)
and ast.unparse(node.value.func) == "RL_PRE_ITEMS['grpo_trainer'].append"
and node.value.args
and isinstance(node.value.args[0], ast.Call)
and ast.unparse(node.value.args[0].func) == "inspect.getsource"
and node.value.args[0].args
}
assert {DISPATCH_HELPER, SIGNAL_HELPER} <= shipped, sorted(shipped)
def test_every_dispatch_guard_falls_back_to_the_raw_logits_helper():
calls = _matmul_calls(_logprob_function())
for call in calls:
guard = _guard_for(call)
assert guard is not None, call.lineno
assert RAW_LOGITS_HELPER in _called_names(guard.orelse), (
f"the guard at line {guard.lineno} has no {RAW_LOGITS_HELPER} "
"fallback, so the raw-logits case is unhandled"
)
def test_the_raw_logits_fallback_skips_scaling_and_softcapping():
"""The forward already applied them, so re-applying would double them."""
forbidden = {"logit_scale_multiply", "logit_scale_divide", "logit_softcapping"}
calls = _matmul_calls(_logprob_function())
for call in calls:
guard = _guard_for(call)
assert guard is not None, call.lineno
for node in ast.walk(ast.Module(body = guard.orelse, type_ignores = [])):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)):
continue
if node.func.id != RAW_LOGITS_HELPER:
continue
passed = {ast.unparse(arg) for arg in node.args}
passed |= {kw.arg for kw in node.keywords if kw.arg is not None}
passed |= {ast.unparse(kw.value) for kw in node.keywords}
leaked = forbidden & passed
assert not leaked, (
f"the raw-logits fallback at line {node.lineno} passes {leaked}; "
"the model forward already applied them"
)
def test_the_padded_text_branch_is_guarded():
"""The crash site: pixel_values is None, so no enclosing try catches it.
Located by structure rather than by line number: the `if pixel_values is
None` inside the padded loop.
"""
function = _logprob_function()
branches = [
node
for node in ast.walk(function)
if isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and ast.unparse(node.test) == "pixel_values is None"
and _matmul_calls(ast.Module(body = node.body, type_ignores = []))
]
assert len(branches) == 1, [node.lineno for node in branches]
(text_branch,) = branches
calls = _matmul_calls(ast.Module(body = text_branch.body, type_ignores = []))
assert len(calls) == 1, [call.lineno for call in calls]
assert _guard_for(calls[0]) is not None, (
"the text branch of the padded loop reaches the lm_head matmul "
"unguarded, and unlike the packing sites it is not inside a try, so "
"this is what surfaces as a TorchRuntimeError during training"
)
def test_the_packing_sites_are_guarded():
"""Both `_pk_` sites: the packed forward and its first-use verifier.
They sit inside `except Exception`, so a failure here is swallowed into a
permanent packing-disable rather than a crash.
"""
function = _logprob_function()
packed = [
call
for call in _matmul_calls(function)
if any(isinstance(node, ast.Name) and node.id.startswith("_pk_") for node in ast.walk(call))
]
assert len(packed) == 2, [call.lineno for call in packed]
unguarded = [call.lineno for call in packed if _guard_for(call) is None]
assert not unguarded, unguarded