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
387 lines
13 KiB
Python
387 lines
13 KiB
Python
# 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 General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Lesser General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
"""Utilities for enabling packed (padding-free) batches across Unsloth."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections import OrderedDict
|
|
from typing import Any, Iterable, Optional, Sequence, Tuple
|
|
|
|
import torch
|
|
|
|
try:
|
|
from xformers.ops.fmha.attn_bias import (
|
|
BlockDiagonalCausalMask as _XFormersBlockMask,
|
|
)
|
|
except Exception:
|
|
try:
|
|
from xformers.attn_bias import BlockDiagonalCausalMask as _XFormersBlockMask
|
|
except Exception:
|
|
_XFormersBlockMask = None
|
|
|
|
_XFORMERS_MASK_CACHE_MAXSIZE = 32
|
|
_XFORMERS_MASK_CACHE: OrderedDict[Tuple[Tuple[int, ...], int], Any] = OrderedDict()
|
|
|
|
# Cache per device for get_packed_info_from_kwargs to avoid repeated D2H sync across layers
|
|
_PACKED_INFO_CACHE: dict = {}
|
|
|
|
# Cache per device for build_sdpa_packed_attention_mask to avoid repeated D2H sync across layers
|
|
_SDPA_MASK_CACHE: dict = {}
|
|
|
|
# Cache per device for build_xformers_block_causal_mask to avoid repeated D2H sync across layers
|
|
_XFORMERS_BLOCK_MASK_CACHE: dict = {}
|
|
|
|
|
|
def _window_cache_key(sliding_window: Optional[int]) -> int:
|
|
if sliding_window is None or sliding_window <= 0:
|
|
return 0
|
|
return int(sliding_window)
|
|
|
|
|
|
def _get_cached_block_mask(lengths: Tuple[int, ...], sliding_window: Optional[int]):
|
|
if _XFormersBlockMask is None:
|
|
return None
|
|
|
|
window_key = _window_cache_key(sliding_window)
|
|
cache_key = (lengths, window_key)
|
|
cached = _XFORMERS_MASK_CACHE.get(cache_key)
|
|
if cached is not None:
|
|
_XFORMERS_MASK_CACHE.move_to_end(cache_key)
|
|
return cached
|
|
|
|
mask = _XFormersBlockMask.from_seqlens(list(lengths))
|
|
if window_key and mask is not None and hasattr(mask, "make_local_attention"):
|
|
mask = mask.make_local_attention(window_size = window_key)
|
|
|
|
_XFORMERS_MASK_CACHE[cache_key] = mask
|
|
if len(_XFORMERS_MASK_CACHE) > _XFORMERS_MASK_CACHE_MAXSIZE:
|
|
_XFORMERS_MASK_CACHE.popitem(last = False)
|
|
return mask
|
|
|
|
|
|
class _TrlPackingWarningFilter(logging.Filter):
|
|
to_filter = (
|
|
"attention implementation is not",
|
|
"kernels-community",
|
|
)
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
message = record.getMessage()
|
|
return not any(substring in message for substring in self.to_filter)
|
|
|
|
|
|
_TRL_FILTER_INSTALLED = False
|
|
|
|
|
|
def _ensure_trl_warning_filter():
|
|
global _TRL_FILTER_INSTALLED
|
|
if _TRL_FILTER_INSTALLED:
|
|
return
|
|
logging.getLogger("trl.trainer.sft_trainer").addFilter(_TrlPackingWarningFilter())
|
|
_TRL_FILTER_INSTALLED = True
|
|
|
|
|
|
def mark_allow_overlength(module):
|
|
"""Mark a module hierarchy so padding-free batches can exceed max_seq_length."""
|
|
if module is None:
|
|
return
|
|
if hasattr(module, "max_seq_length"):
|
|
setattr(module, "_unsloth_allow_packed_overlength", True)
|
|
children = getattr(module, "children", None)
|
|
if children is None:
|
|
return
|
|
for child in children():
|
|
mark_allow_overlength(child)
|
|
|
|
|
|
def configure_sample_packing(config):
|
|
"""Mutate an ``SFTConfig`` so TRL prepares packed batches."""
|
|
_ensure_trl_warning_filter()
|
|
setattr(config, "packing", True)
|
|
setattr(config, "padding_free", True)
|
|
setattr(config, "remove_unused_columns", False)
|
|
|
|
|
|
def configure_padding_free(config):
|
|
"""Mutate an ``SFTConfig`` so TRL enables padding-free batching without packing."""
|
|
_ensure_trl_warning_filter()
|
|
setattr(config, "padding_free", True)
|
|
setattr(config, "remove_unused_columns", False)
|
|
|
|
|
|
def enable_sample_packing(
|
|
model,
|
|
trainer,
|
|
*,
|
|
sequence_lengths_key: str = "seq_lengths",
|
|
) -> None:
|
|
"""Enable runtime support for packed batches on an existing trainer."""
|
|
if model is None or trainer is None:
|
|
raise ValueError("model and trainer must not be None")
|
|
|
|
mark_allow_overlength(model)
|
|
|
|
if hasattr(trainer, "args") and hasattr(trainer.args, "remove_unused_columns"):
|
|
trainer.args.remove_unused_columns = False
|
|
|
|
collator = getattr(trainer, "data_collator", None)
|
|
if collator is None or not hasattr(collator, "torch_call"):
|
|
return
|
|
if getattr(collator, "_unsloth_packing_wrapped", False):
|
|
return
|
|
|
|
if hasattr(collator, "padding_free"):
|
|
collator.padding_free = True
|
|
if hasattr(collator, "return_position_ids"):
|
|
collator.return_position_ids = True
|
|
|
|
original_torch_call = collator.torch_call
|
|
|
|
def torch_call_with_lengths(examples: Sequence[dict]):
|
|
batch = original_torch_call(examples)
|
|
if examples and isinstance(examples[0], dict):
|
|
seq_lengths: list[int] = []
|
|
for example in examples:
|
|
lengths = example.get(sequence_lengths_key)
|
|
if isinstance(lengths, Iterable):
|
|
seq_lengths.extend(int(length) for length in lengths)
|
|
# Fallback: infer lengths from tokenized inputs when metadata is absent
|
|
if not seq_lengths:
|
|
for example in examples:
|
|
ids = example.get("input_ids")
|
|
if isinstance(ids, Iterable):
|
|
seq_lengths.append(len(ids))
|
|
if seq_lengths:
|
|
batch["packed_seq_lengths"] = torch.tensor(seq_lengths, dtype = torch.int32)
|
|
if "attention_mask" in batch:
|
|
batch.pop("attention_mask")
|
|
return batch
|
|
|
|
collator.torch_call = torch_call_with_lengths
|
|
collator._unsloth_packing_wrapped = True
|
|
|
|
|
|
def enable_padding_free_metadata(model, trainer):
|
|
"""Inject seq-length metadata when padding-free batching is enabled without packing."""
|
|
collator = getattr(trainer, "data_collator", None)
|
|
if (
|
|
collator is None
|
|
or getattr(collator, "_unsloth_padding_free_lengths_wrapped", False)
|
|
or not getattr(collator, "padding_free", False)
|
|
):
|
|
return
|
|
|
|
mark_allow_overlength(model)
|
|
if hasattr(collator, "return_position_ids"):
|
|
collator.return_position_ids = True
|
|
if hasattr(trainer, "args") and hasattr(trainer.args, "remove_unused_columns"):
|
|
trainer.args.remove_unused_columns = False
|
|
|
|
original_torch_call = collator.torch_call
|
|
|
|
def torch_call_with_padding_free_metadata(examples: Sequence[dict]):
|
|
seq_lengths: list[int] = []
|
|
if examples and isinstance(examples[0], dict):
|
|
for example in examples:
|
|
lengths = example.get("seq_lengths")
|
|
if lengths is None:
|
|
ids = example.get("input_ids")
|
|
if ids is None:
|
|
continue
|
|
lengths = [len(ids)]
|
|
example["seq_lengths"] = lengths
|
|
seq_lengths.extend(lengths)
|
|
|
|
batch = original_torch_call(examples)
|
|
if seq_lengths:
|
|
batch["packed_seq_lengths"] = torch.tensor(
|
|
seq_lengths,
|
|
dtype = torch.int32,
|
|
)
|
|
return batch
|
|
|
|
collator.torch_call = torch_call_with_padding_free_metadata
|
|
collator._unsloth_padding_free_lengths_wrapped = True
|
|
|
|
|
|
def get_packed_info_from_kwargs(
|
|
kwargs: dict, device: torch.device
|
|
) -> Optional[Tuple[torch.Tensor, torch.Tensor, int]]:
|
|
"""Return packed sequence metadata expected by the attention kernels."""
|
|
|
|
seq_lengths = kwargs.get("packed_seq_lengths")
|
|
if seq_lengths is None:
|
|
return None
|
|
|
|
entry = _PACKED_INFO_CACHE.get(device)
|
|
if entry is not None and entry["seq_lengths"] is seq_lengths:
|
|
return entry["result"]
|
|
|
|
lengths = seq_lengths.to(device = device, dtype = torch.int32, non_blocking = True)
|
|
cu_seqlens = torch.zeros(lengths.numel() + 1, dtype = torch.int32, device = device)
|
|
torch.cumsum(lengths, dim = 0, dtype = torch.int32, out = cu_seqlens[1:])
|
|
|
|
max_seqlen = int(lengths.max().item())
|
|
result = (lengths, cu_seqlens, max_seqlen)
|
|
_PACKED_INFO_CACHE[device] = {"seq_lengths": seq_lengths, "result": result}
|
|
return result
|
|
|
|
|
|
def build_xformers_block_causal_mask(
|
|
seq_info: Optional[Tuple[torch.Tensor, torch.Tensor, int]],
|
|
*,
|
|
sliding_window: Optional[int] = None,
|
|
base_mask: Optional[Any] = None,
|
|
):
|
|
if _XFormersBlockMask is None:
|
|
return None
|
|
if seq_info is not None:
|
|
seq_lengths, _, _ = seq_info
|
|
# Cache the mask to avoid repeated D2H sync across layers
|
|
device = seq_lengths.device
|
|
params = (sliding_window,)
|
|
entry = _XFORMERS_BLOCK_MASK_CACHE.get(device)
|
|
if entry is not None and entry["seq_lengths"] is seq_lengths and entry["params"] == params:
|
|
return entry["mask"]
|
|
|
|
lengths_tensor = seq_lengths.to("cpu", torch.int32)
|
|
if lengths_tensor.numel() == 0:
|
|
return None
|
|
lengths = tuple(int(x) for x in lengths_tensor.tolist())
|
|
mask = _get_cached_block_mask(lengths, sliding_window)
|
|
|
|
_XFORMERS_BLOCK_MASK_CACHE[device] = {
|
|
"seq_lengths": seq_lengths,
|
|
"params": params,
|
|
"mask": mask,
|
|
}
|
|
else:
|
|
mask = base_mask
|
|
|
|
if (
|
|
sliding_window is not None
|
|
and sliding_window > 0
|
|
and mask is not None
|
|
and hasattr(mask, "make_local_attention")
|
|
):
|
|
mask = mask.make_local_attention(window_size = sliding_window)
|
|
return mask
|
|
|
|
|
|
def build_sdpa_packed_attention_mask(
|
|
seq_info: Tuple[torch.Tensor, torch.Tensor, int],
|
|
*,
|
|
dtype: torch.dtype,
|
|
device: torch.device,
|
|
sliding_window: Optional[int] = None,
|
|
) -> torch.Tensor:
|
|
seq_lengths, _, _ = seq_info
|
|
|
|
params = (dtype, sliding_window)
|
|
entry = _SDPA_MASK_CACHE.get(device)
|
|
if entry is not None and entry["seq_lengths"] is seq_lengths and entry["params"] == params:
|
|
return entry["mask"]
|
|
|
|
total_tokens = int(seq_lengths.sum().item())
|
|
mask = torch.full(
|
|
(total_tokens, total_tokens),
|
|
float("-inf"),
|
|
dtype = dtype,
|
|
device = device,
|
|
)
|
|
offset = 0
|
|
for length in seq_lengths.tolist():
|
|
length = int(length)
|
|
if length <= 0:
|
|
continue
|
|
block = torch.zeros((length, length), dtype = dtype, device = device)
|
|
upper = torch.triu(torch.ones((length, length), device = device), diagonal = 1).bool()
|
|
block = block.masked_fill(upper, float("-inf"))
|
|
if sliding_window is not None and sliding_window > 0 and length > sliding_window:
|
|
idx = torch.arange(length, device = device)
|
|
dist = idx.unsqueeze(1) - idx.unsqueeze(0)
|
|
window_mask = dist >= sliding_window
|
|
block = block.masked_fill(window_mask, float("-inf"))
|
|
mask[offset : offset + length, offset : offset + length] = block
|
|
offset += length
|
|
|
|
result = mask.unsqueeze(0).unsqueeze(0)
|
|
_SDPA_MASK_CACHE[device] = {
|
|
"seq_lengths": seq_lengths,
|
|
"params": params,
|
|
"mask": result,
|
|
}
|
|
return result
|
|
|
|
|
|
def _normalize_packed_lengths(seq_lengths: Any, *, device: torch.device) -> Optional[torch.Tensor]:
|
|
if seq_lengths is None:
|
|
return None
|
|
if isinstance(seq_lengths, torch.Tensor):
|
|
lengths = seq_lengths.to(device = device, dtype = torch.int64)
|
|
else:
|
|
lengths = torch.tensor(seq_lengths, device = device, dtype = torch.int64)
|
|
if lengths.ndim != 1:
|
|
lengths = lengths.reshape(-1)
|
|
if lengths.numel() == 0:
|
|
return None
|
|
return lengths
|
|
|
|
|
|
def mask_packed_sequence_boundaries(
|
|
shift_labels: torch.Tensor,
|
|
seq_lengths: Any,
|
|
*,
|
|
ignore_index: int = -100,
|
|
) -> bool:
|
|
"""Mark final token of every packed sample so CE ignores boundary predictions."""
|
|
lengths = _normalize_packed_lengths(seq_lengths, device = shift_labels.device)
|
|
if lengths is None:
|
|
return False
|
|
|
|
flat = shift_labels.reshape(-1)
|
|
total_tokens = flat.shape[0]
|
|
boundary_positions = torch.cumsum(lengths, dim = 0) - 1
|
|
valid = boundary_positions < total_tokens
|
|
if not torch.all(valid):
|
|
boundary_positions = boundary_positions[valid]
|
|
if boundary_positions.numel() == 0:
|
|
return False
|
|
flat[boundary_positions] = ignore_index
|
|
return True
|
|
|
|
|
|
def clear_packed_caches():
|
|
"""Release cached masks/metadata to free device memory."""
|
|
_PACKED_INFO_CACHE.clear()
|
|
_SDPA_MASK_CACHE.clear()
|
|
_XFORMERS_BLOCK_MASK_CACHE.clear()
|
|
|
|
|
|
__all__ = [
|
|
"configure_sample_packing",
|
|
"configure_padding_free",
|
|
"enable_sample_packing",
|
|
"enable_padding_free_metadata",
|
|
"mark_allow_overlength",
|
|
"get_packed_info_from_kwargs",
|
|
"build_xformers_block_causal_mask",
|
|
"build_sdpa_packed_attention_mask",
|
|
"mask_packed_sequence_boundaries",
|
|
"clear_packed_caches",
|
|
]
|