e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""Regression guard for batched left-padded generation (issues #1066, #3699).
|
|
|
|
Guards `_fast_prepare_inputs_for_generation` (unsloth/models/llama.py),
|
|
shared by every decoder family wired through fix_prepare_inputs_for_generation,
|
|
against two historical bugs:
|
|
(a) 2D attention mask truncated to its last column during cached decode,
|
|
losing padding info (fixed by #2216);
|
|
(b) position_ids taken from cache_position (which counts left-pad tokens),
|
|
so padded rows generated garbage (fixed by #4100).
|
|
|
|
Two CPU-only deterministic layers: (1) AST structural checks (no unsloth
|
|
import); (2) behavioral checks calling the real function with synthetic
|
|
left-padded masks and fake caches. Companion GPU check:
|
|
tests/utils/test_batched_leftpad_generation_gpu.py
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
LLAMA_PY = REPO_ROOT / "unsloth" / "models" / "llama.py"
|
|
|
|
FUNC_NAME = "_fast_prepare_inputs_for_generation"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Layer 1: AST structural guard (stdlib only, no unsloth import)
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Model files that call fix_prepare_inputs_for_generation(...) and share the
|
|
# guarded function. glm4_moe (MLA attention, different path) and falcon_h1
|
|
# (its own variant) are intentionally absent.
|
|
WIRED_MODEL_FILES = [
|
|
"mistral.py",
|
|
"gemma.py",
|
|
"gemma2.py",
|
|
"qwen2.py",
|
|
"qwen3.py",
|
|
"qwen3_moe.py",
|
|
"cohere.py",
|
|
"granite.py",
|
|
]
|
|
|
|
|
|
def _load_function():
|
|
tree = ast.parse(LLAMA_PY.read_text())
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef) and node.name == FUNC_NAME:
|
|
return node
|
|
raise AssertionError(
|
|
f"{FUNC_NAME} not found in {LLAMA_PY}; if it was renamed or moved, "
|
|
"update this guard so batched left-padded generation stays protected"
|
|
)
|
|
|
|
|
|
def _names_in(node):
|
|
"""All Name ids, attribute names and string constants in a subtree."""
|
|
found = set()
|
|
for sub in ast.walk(node):
|
|
if isinstance(sub, ast.Name):
|
|
found.add(sub.id)
|
|
elif isinstance(sub, ast.Attribute):
|
|
found.add(sub.attr)
|
|
elif isinstance(sub, ast.Constant) and isinstance(sub.value, str):
|
|
found.add(sub.value)
|
|
return found
|
|
|
|
|
|
def _mentions_attention_mask(node):
|
|
return any("attention_mask" in name for name in _names_in(node))
|
|
|
|
|
|
def _is_kwargs_position_ids_target(target):
|
|
return (
|
|
isinstance(target, ast.Subscript)
|
|
and isinstance(target.value, ast.Name)
|
|
and target.value.id == "kwargs"
|
|
and isinstance(target.slice, ast.Constant)
|
|
and target.slice.value == "position_ids"
|
|
)
|
|
|
|
|
|
def _walk_with_paths(node, path = ()):
|
|
yield node, path
|
|
for child in ast.iter_child_nodes(node):
|
|
yield from _walk_with_paths(child, path + (node,))
|
|
|
|
|
|
def _find_mask_branch(func):
|
|
"""The If whose test checks the 2D attention mask (dim() == 2)."""
|
|
for node in ast.walk(func):
|
|
if not isinstance(node, ast.If):
|
|
continue
|
|
test_names = _names_in(node.test)
|
|
if "dim" in test_names and any("attention_mask" in n for n in test_names):
|
|
return node
|
|
return None
|
|
|
|
|
|
def test_mask_derived_position_ids_branch_exists():
|
|
func = _load_function()
|
|
branch = _find_mask_branch(func)
|
|
assert branch is not None, (
|
|
f"{FUNC_NAME} no longer has a branch testing the 2D attention mask "
|
|
"(dim() == 2); position_ids must be derived per row from the mask for "
|
|
"left-padded batches (see PR #4100 / issues #1066, #3699)"
|
|
)
|
|
|
|
body_names = set()
|
|
for stmt in branch.body:
|
|
body_names |= _names_in(stmt)
|
|
assert "cumsum" in body_names and _mentions_attention_mask(
|
|
ast.Module(body = branch.body, type_ignores = [])
|
|
), (
|
|
"the attention-mask branch must compute position_ids via "
|
|
"attention_mask.cumsum(...); reintroducing cache_position-based "
|
|
"positions breaks left-padded batched generation (issue #3699)"
|
|
)
|
|
assert (
|
|
"masked_fill_" in body_names or "masked_fill" in body_names
|
|
), "the attention-mask branch must mask pad positions (masked_fill on mask == 0)"
|
|
assigns_kwargs = any(
|
|
isinstance(stmt, ast.Assign)
|
|
and any(_is_kwargs_position_ids_target(t) for t in stmt.targets)
|
|
for stmt in ast.walk(ast.Module(body = branch.body, type_ignores = []))
|
|
)
|
|
assert (
|
|
assigns_kwargs
|
|
), 'the attention-mask branch must store the derived positions into kwargs["position_ids"]'
|
|
|
|
|
|
def test_cache_position_only_used_as_fallback_for_position_ids():
|
|
func = _load_function()
|
|
branch = _find_mask_branch(func)
|
|
assert branch is not None
|
|
|
|
orelse_nodes = set()
|
|
for stmt in branch.orelse:
|
|
for sub in ast.walk(stmt):
|
|
orelse_nodes.add(id(sub))
|
|
|
|
offenders = []
|
|
for node, path in _walk_with_paths(func):
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
if not any(_is_kwargs_position_ids_target(t) for t in node.targets):
|
|
continue
|
|
value_names = _names_in(node.value)
|
|
# Direct use of cache_position, or the local alias `cp` the current
|
|
# implementation builds from it inside the fallback branch.
|
|
derives_from_cache_position = any("cache_position" in n for n in value_names) or bool(
|
|
value_names & {"cp"}
|
|
)
|
|
if derives_from_cache_position and id(node) not in orelse_nodes:
|
|
offenders.append(ast.unparse(node))
|
|
|
|
assert not offenders, (
|
|
'kwargs["position_ids"] must never be assigned from cache_position '
|
|
"outside the fallback (orelse) of the 2D attention-mask branch; "
|
|
"cache_position counts left-pad tokens, so padded rows generate "
|
|
f"garbage (issues #1066, #3699). Offending assignments: {offenders}"
|
|
)
|
|
|
|
|
|
def test_attention_mask_never_truncated_to_last_column():
|
|
func = _load_function()
|
|
offenders = []
|
|
for node in ast.walk(func):
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
value = node.value
|
|
if not isinstance(value, ast.Subscript):
|
|
continue
|
|
if not _mentions_attention_mask(value.value):
|
|
continue
|
|
# Match a trailing [-1]-style column selection: [:, [-1]] or [:, -1:]
|
|
sl = value.slice
|
|
if isinstance(sl, ast.Tuple) and len(sl.elts) == 2:
|
|
col = sl.elts[1]
|
|
is_last_col_list = (
|
|
isinstance(col, ast.List)
|
|
and len(col.elts) == 1
|
|
and isinstance(col.elts[0], ast.UnaryOp)
|
|
)
|
|
is_last_col_slice = (
|
|
isinstance(col, ast.Slice)
|
|
and col.lower is not None
|
|
and isinstance(col.lower, ast.UnaryOp)
|
|
and getattr(getattr(col.lower, "operand", None), "value", None) == 1
|
|
and col.upper is None
|
|
)
|
|
if is_last_col_list or is_last_col_slice:
|
|
offenders.append(ast.unparse(node))
|
|
assert not offenders, (
|
|
"the 2D attention mask must not be truncated to its last column; this "
|
|
"was the pre-#2216 bug that drops padding information in cached decode "
|
|
f"(issue #1066). Offending assignments: {offenders}"
|
|
)
|
|
|
|
|
|
def test_model_families_stay_wired_to_shared_prepare_inputs():
|
|
missing = []
|
|
for fname in WIRED_MODEL_FILES:
|
|
path = REPO_ROOT / "unsloth" / "models" / fname
|
|
if not path.exists():
|
|
continue
|
|
if "fix_prepare_inputs_for_generation(" not in path.read_text():
|
|
missing.append(fname)
|
|
assert not missing, (
|
|
"these model files no longer call fix_prepare_inputs_for_generation, "
|
|
"so they lose the guarded left-padding-safe prepare_inputs path: "
|
|
f"{missing}"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Layer 2: behavioral guard (calls the real function, lazy unsloth import)
|
|
# --------------------------------------------------------------------------
|
|
|
|
PAST_LEN = 4
|
|
|
|
# Three rows with different amounts of left padding (0 = pad).
|
|
MASK = torch.tensor(
|
|
[
|
|
[0, 0, 1, 1, 1],
|
|
[1, 1, 1, 1, 1],
|
|
[0, 1, 1, 1, 1],
|
|
],
|
|
dtype = torch.long,
|
|
)
|
|
BS, SEQ = MASK.shape
|
|
|
|
# Per-row positions: cumsum(-1) - 1 with pad slots filled with 1.
|
|
EXPECTED_PREFILL_POSITIONS = torch.tensor(
|
|
[
|
|
[1, 1, 0, 1, 2],
|
|
[0, 1, 2, 3, 4],
|
|
[1, 0, 1, 2, 3],
|
|
],
|
|
dtype = torch.long,
|
|
)
|
|
|
|
|
|
class FakeDynamicCache:
|
|
"""Minimal stand-in for transformers DynamicCache with a non-empty cache."""
|
|
|
|
def __init__(self, seq_length):
|
|
self._seq_length = seq_length
|
|
|
|
def __len__(self):
|
|
return 1
|
|
|
|
def get_seq_length(self):
|
|
return self._seq_length
|
|
|
|
|
|
class FakeModel:
|
|
"""Bare-minimum `self` for _fast_prepare_inputs_for_generation."""
|
|
|
|
dtype = torch.float32
|
|
config = None
|
|
|
|
|
|
class FakeModelWith4DMask(FakeModel):
|
|
"""Variant exposing the HF 4D mask builder; records what it receives."""
|
|
|
|
def __init__(self):
|
|
self.mask_calls = []
|
|
|
|
def _prepare_4d_causal_attention_mask_with_cache_position(
|
|
self,
|
|
attention_mask,
|
|
sequence_length,
|
|
target_length,
|
|
dtype,
|
|
device,
|
|
cache_position,
|
|
batch_size,
|
|
config = None,
|
|
past_key_values = None,
|
|
):
|
|
self.mask_calls.append(
|
|
{
|
|
"mask_shape": tuple(attention_mask.shape) if attention_mask is not None else None,
|
|
"sequence_length": sequence_length,
|
|
"target_length": target_length,
|
|
"batch_size": batch_size,
|
|
}
|
|
)
|
|
return torch.zeros((batch_size, 1, sequence_length, target_length), dtype = dtype)
|
|
|
|
|
|
def _prepare(model, input_ids, attention_mask, **kwargs):
|
|
from unsloth.models import llama as llama_mod
|
|
return llama_mod._fast_prepare_inputs_for_generation(
|
|
model, input_ids, attention_mask = attention_mask, **kwargs
|
|
)
|
|
|
|
|
|
def test_prefill_position_ids_derived_from_left_padded_mask():
|
|
input_ids = torch.arange(BS * SEQ).reshape(BS, SEQ)
|
|
result = _prepare(FakeModel(), input_ids, MASK)
|
|
|
|
position_ids = result.get("position_ids", None)
|
|
assert (
|
|
position_ids is not None
|
|
), "prefill with a left-padded 2D attention mask must populate position_ids"
|
|
assert torch.equal(position_ids.long().cpu(), EXPECTED_PREFILL_POSITIONS), (
|
|
"prefill position_ids must be derived per row from the attention mask "
|
|
"(cumsum - 1, pads masked), so each row starts counting at its first "
|
|
f"real token; got {position_ids.tolist()}"
|
|
)
|
|
assert result["input_ids"].shape == (BS, SEQ)
|
|
|
|
|
|
@pytest.mark.parametrize("pass_cache_position", [True, False])
|
|
def test_cached_decode_position_ids_ignore_left_padding(pass_cache_position):
|
|
# Decode step: PAST_LEN tokens cached, current token is the mask's last
|
|
# column. Row 0 has 2 pads, so its current token sits at logical position 2,
|
|
# NOT at cache_position == PAST_LEN. This is exactly issue #3699.
|
|
input_ids = torch.arange(BS * SEQ).reshape(BS, SEQ)
|
|
kwargs = {"past_key_values": FakeDynamicCache(PAST_LEN)}
|
|
if pass_cache_position:
|
|
kwargs["cache_position"] = torch.arange(PAST_LEN, PAST_LEN + 1)
|
|
|
|
result = _prepare(FakeModel(), input_ids, MASK, **kwargs)
|
|
|
|
assert result["input_ids"].shape == (
|
|
BS,
|
|
1,
|
|
), "cached decode must slice input_ids to the last token only"
|
|
position_ids = result.get("position_ids", None)
|
|
assert position_ids is not None
|
|
expected = torch.tensor([[2], [4], [3]], dtype = torch.long)
|
|
assert torch.equal(position_ids.long().cpu().reshape(BS, 1), expected), (
|
|
"left-padded cached decode must derive per-row position_ids from the "
|
|
"attention mask, not from cache_position which counts pad tokens; got "
|
|
f"{position_ids.tolist()}, expected {expected.tolist()} "
|
|
"(row 0 has 2 pads: its position must be 2, not 4)"
|
|
)
|
|
|
|
|
|
def test_cached_decode_does_not_truncate_2d_attention_mask():
|
|
# Without a 4D mask builder the original 2D mask must survive untouched.
|
|
# The historical bug replaced it with attention_mask[:, [-1]].
|
|
input_ids = torch.arange(BS * SEQ).reshape(BS, SEQ)
|
|
result = _prepare(FakeModel(), input_ids, MASK, past_key_values = FakeDynamicCache(PAST_LEN))
|
|
mask_out = result["attention_mask"]
|
|
assert mask_out is not None
|
|
assert mask_out.dim() != 2 or mask_out.shape[-1] == SEQ, (
|
|
"the 2D attention mask must not be truncated to its last column during "
|
|
f"cached decode (got shape {tuple(mask_out.shape)}); padding rows lose "
|
|
"their pad information otherwise"
|
|
)
|
|
|
|
|
|
def test_cached_decode_4d_mask_builder_receives_full_target_length():
|
|
model = FakeModelWith4DMask()
|
|
input_ids = torch.arange(BS * SEQ).reshape(BS, SEQ)
|
|
result = _prepare(model, input_ids, MASK, past_key_values = FakeDynamicCache(PAST_LEN))
|
|
assert len(model.mask_calls) == 1
|
|
call = model.mask_calls[0]
|
|
assert call["mask_shape"] == (BS, SEQ), (
|
|
"the 4D mask builder must receive the full 2D padding mask, not a "
|
|
f"truncated one (got {call['mask_shape']})"
|
|
)
|
|
assert call["sequence_length"] == 1
|
|
assert call["target_length"] == SEQ, (
|
|
"target_length must cover the whole mask so padded positions stay "
|
|
f"masked (got {call['target_length']})"
|
|
)
|
|
assert result["attention_mask"].dim() == 4
|
|
|
|
|
|
def test_caller_supplied_position_ids_are_passed_through():
|
|
input_ids = torch.arange(BS * SEQ).reshape(BS, SEQ)
|
|
custom = torch.full((BS, SEQ), 7, dtype = torch.long)
|
|
result = _prepare(FakeModel(), input_ids, MASK, position_ids = custom)
|
|
assert torch.equal(
|
|
result["position_ids"], custom
|
|
), "caller-supplied position_ids must not be overwritten"
|
|
|
|
|
|
def test_legacy_tuple_cache_still_takes_cached_decode_path():
|
|
# Legacy cache format: tuple of (K, V) per layer; past length from K.shape[-2].
|
|
k = torch.zeros((BS, 1, PAST_LEN, 8))
|
|
legacy_cache = ((k, k.clone()),)
|
|
input_ids = torch.arange(BS * SEQ).reshape(BS, SEQ)
|
|
result = _prepare(FakeModel(), input_ids, MASK, past_key_values = legacy_cache)
|
|
assert result["input_ids"].shape == (BS, 1)
|
|
expected = torch.tensor([[2], [4], [3]], dtype = torch.long)
|
|
assert torch.equal(result["position_ids"].long().cpu().reshape(BS, 1), expected)
|