* fix: install.sh Mac Intel compatibility + Studio no-torch support (#4621) On Intel Macs (x86_64), PyTorch has no wheels for torch >= 2.3, so the installer crashes. Even when torch is absent, Studio crashes on startup because two files have bare top-level torch imports. Studio's GGUF inference (llama.cpp) does not need PyTorch. Training and HF-inference already isolate torch to subprocesses. Only 2 files in the server startup chain had top-level torch imports preventing startup. Changes: - install.sh: detect architecture, default to Python 3.12 on Intel Mac, skip torch install, add Python 3.13.8 guard for arm64, pass UNSLOTH_NO_TORCH env var to setup.sh - data_collators.py: remove unused `import torch` (no torch.* refs) - chat_templates.py: lazy-import IterableDataset into function bodies - install_python_stack.py: add IS_MACOS/NO_TORCH constants, skip torch-dependent packages, skip overrides.txt, skip triton on macOS No existing working flow changes. Linux/WSL and macOS arm64 behavior is identical. * tests: add test suite for Mac Intel compat + no-torch mode Shell tests (test_mac_intel_compat.sh): - version_ge edge cases (9 tests) - Architecture detection for Darwin x86_64/arm64, Linux x86_64/aarch64 - get_torch_index_url returns cpu on simulated Darwin - UNSLOTH_NO_TORCH propagation to both setup.sh branches Python unit tests (test_no_torch_filtering.py): - _filter_requirements with NO_TORCH_SKIP_PACKAGES - NO_TORCH env var parsing (true/1/TRUE/false/0/unset) - IS_MACOS constant check - Overrides skip and triton macOS skip guards Python import tests (test_studio_import_no_torch.py): - data_collators.py loads in isolated no-torch venv - chat_templates.py has no top-level torch imports - Negative control confirms import torch fails without torch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests: add E2E sandbox tests for Mac Intel no-torch mode Replace static/synthetic test stubs with real sandbox tests: - Shell: E2E uv venv creation at Python 3.12, mock uv shim to verify torch install is skipped when MAC_INTEL=true, dynamic env propagation test for UNSLOTH_NO_TORCH in both local and non-local install paths - Python filtering: test real extras.txt and extras-no-deps.txt with NO_TORCH_SKIP_PACKAGES, subprocess mock of install_python_stack() for 5 platform configs (NO_TORCH+macOS, Windows+NO_TORCH, normal Linux, Windows-only, macOS-only), VCS URL and env marker edge cases - Python imports: parametrized Python 3.12+3.13 venv fixture, dataclass instantiation for all 3 collator classes, chat_templates.py exec with stubs, negative controls proving import torch and torchao install fail in no-torch venvs 91 total tests, all passing. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: address reviewer findings for Intel Mac no-torch mode P1 fixes: - Auto-infer NO_TORCH in install_python_stack.py via platform.machine() so `unsloth studio update` preserves GGUF-only mode without needing the UNSLOTH_NO_TORCH env var (6/10 reviewers) - Add openai-whisper and transformers-cfg to NO_TORCH_SKIP_PACKAGES since both have unconditional torch dependencies (4/10 reviewers) - Skip unsloth-zoo on Intel Mac --local installs (depends on torch) in both migrated and fresh install paths (1/10) - Recreate stale 3.13 venvs as 3.12 on Intel Mac re-runs (1/10) - Detect Apple Silicon under Rosetta via sysctl hw.optional.arm64 and warn user to use native arm64 terminal (1/10) P2 fixes: - Wire new test files into tests/run_all.sh (4/10 reviewers) - Add update-path tests (skip_base=False) for Intel Mac - Add _infer_no_torch tests for platform auto-detection P3 fixes: - Fix macOS progress bar total (triton step skipped but was counted) - Fix temp file leak when Windows + NO_TORCH filters stack All tests pass: 30 shell, 66 Python (96 total). * feat: add --python override flag to install.sh Lets users force a specific Python version, e.g. ./install.sh --python 3.12. Addresses M2 Mac users whose systems resolve to a problematic 3.13.x patch. When --python is set, the Intel Mac stale-venv guard and 3.13.8 auto-downgrade are skipped so the user's choice is respected. * tests: add comprehensive E2E sandbox tests for no-torch mode Add test_e2e_no_torch_sandbox.py with 7 test groups (43 tests total) covering the full no-torch import chain, edge cases, and install logic: - Group 1: BEFORE vs AFTER import chain comparison (proves the bug existed and the fix works by synthetically prepending top-level torch imports) - Group 2: Dataclass instantiation without torch - Group 3: Edge cases with broken/fake torch modules on sys.path - Group 4: Hardware detection fallback to CPU without torch - Group 5: install.sh flag parsing, version resolution, arch detection - Group 6: install_python_stack.py NO_TORCH filtering - Group 7: Live server startup without torch (marked @server, skipped when studio venv is unavailable) All 43 tests pass on both Python 3.12 and 3.13 isolated venvs. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: add --no-torch flag to install.sh/ps1, fix lazy import bug in dataset formatting - Fix chat_templates.py: narrow torch IterableDataset import into inner try/except ImportError so dataset.map() works without torch installed - Fix format_conversion.py: same lazy import fix for convert_chatml_to_alpaca and convert_alpaca_to_chatml - Add --no-torch flag to install.sh with unified SKIP_TORCH variable (driven by --no-torch flag OR MAC_INTEL auto-detection) - Add --no-torch flag to install.ps1 with $SkipTorch variable - Print CPU hint when no GPU detected and --no-torch not set - Replace MAC_INTEL guards with SKIP_TORCH in torch install sections - Update shell tests (40 pass) and Python tests (90 pass) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: address reviewer findings for --no-torch installer paths - Fix migrated-env branch in install.sh and install.ps1: check SKIP_TORCH first, then branch on STUDIO_LOCAL_INSTALL. Previously SKIP_TORCH+non-local fell into else and installed unsloth-zoo (which depends on torch), defeating --no-torch mode. - Fix $env:UNSLOTH_NO_TORCH leak in install.ps1: always set to "true" or "false" instead of only setting on the true branch. Prevents stale no-torch state from leaking across runs in the same PS session. - Fix install_python_stack.py update path: add NO_TORCH guard around base.txt install so unsloth studio update does not reinstall unsloth-zoo (which depends on torch) in no-torch mode. * fix: install unsloth + unsloth-zoo with --no-deps in no-torch mode Instead of skipping unsloth-zoo entirely (which breaks unsloth's dependency on it), install both packages with --no-deps so they are present but torch is not pulled in transitively. Applied consistently across all no-torch paths: migrated-env, fresh-local, fresh-non-local in install.sh, install.ps1, and install_python_stack.py. * chore: temporarily remove test files (will be added in a follow-up) * refactor: deduplicate SKIP_TORCH conditional branches in installers Collapse if/else blocks that differ only by --no-deps into a single branch with a conditional flag variable. Applied to migrated-env and fresh-local paths in install.sh, install.ps1, and install_python_stack.py. * fix: apply --no-deps to fresh non-local --no-torch install path The non-local else branch was missing $_no_deps_arg/$noDepsArg, so uv pip install unsloth would resolve torch from PyPI metadata (the published unsloth package still declares torch as a hard dep). Now --no-deps is applied consistently to all SKIP_TORCH code paths. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
202 lines
5.9 KiB
Python
202 lines
5.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""
|
|
Data collators for dataset processing.
|
|
|
|
This module contains custom data collators for training,
|
|
particularly for VLM/OCR processing.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, List, Optional, Union
|
|
from loggers import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class DataCollatorSpeechSeq2SeqWithPadding:
|
|
"""
|
|
Data collator for Whisper speech-to-text training.
|
|
|
|
Pads input features (audio) and label sequences (text) separately,
|
|
masks padding in labels with -100, and strips leading BOS token.
|
|
Mirrors the collator from the Whisper.ipynb notebook.
|
|
"""
|
|
|
|
processor: Any
|
|
|
|
def __call__(self, features: List[dict]) -> dict:
|
|
input_features = [
|
|
{"input_features": feature["input_features"]} for feature in features
|
|
]
|
|
batch = self.processor.feature_extractor.pad(
|
|
input_features, return_tensors = "pt"
|
|
)
|
|
|
|
label_features = [{"input_ids": feature["labels"]} for feature in features]
|
|
labels_batch = self.processor.tokenizer.pad(label_features, return_tensors = "pt")
|
|
|
|
labels = labels_batch["input_ids"].masked_fill(
|
|
labels_batch.attention_mask.ne(1), -100
|
|
)
|
|
|
|
if (labels[:, 0] == self.processor.tokenizer.bos_token_id).all().cpu().item():
|
|
labels = labels[:, 1:]
|
|
|
|
batch["labels"] = labels
|
|
return batch
|
|
|
|
|
|
@dataclass
|
|
class DeepSeekOCRDataCollator:
|
|
"""
|
|
Data collator for DeepSeek OCR VLM training.
|
|
|
|
Handles:
|
|
- Image processing via processor
|
|
- Text tokenization
|
|
- Proper label masking for instruction fine-tuning
|
|
"""
|
|
|
|
processor: Any # Qwen2VLProcessor or similar
|
|
max_length: int = 2048
|
|
ignore_index: int = -100
|
|
|
|
def __call__(self, batch: List[dict]) -> dict:
|
|
"""
|
|
Collate a batch of samples.
|
|
|
|
Args:
|
|
batch: List of dicts, each with 'messages' containing
|
|
[{'role': 'user', 'content': [...]}, {'role': 'assistant', 'content': [...]}]
|
|
|
|
Returns:
|
|
dict with input_ids, attention_mask, labels, pixel_values, etc.
|
|
"""
|
|
from PIL import Image
|
|
|
|
# Extract messages and images
|
|
all_messages = []
|
|
all_images = []
|
|
|
|
for sample in batch:
|
|
messages = sample["messages"]
|
|
all_messages.append(messages)
|
|
|
|
# Extract PIL images from content
|
|
for msg in messages:
|
|
content = msg.get("content", [])
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "image":
|
|
img = item.get("image")
|
|
if img is not None and hasattr(img, "size"): # PIL Image
|
|
all_images.append(img)
|
|
|
|
# Process with the VL processor
|
|
try:
|
|
# Qwen2VL style processing
|
|
texts = [
|
|
self.processor.apply_chat_template(
|
|
msgs, tokenize = False, add_generation_prompt = False
|
|
)
|
|
for msgs in all_messages
|
|
]
|
|
|
|
# Process with images
|
|
inputs = self.processor(
|
|
text = texts,
|
|
images = all_images if all_images else None,
|
|
return_tensors = "pt",
|
|
padding = True,
|
|
truncation = True,
|
|
max_length = self.max_length,
|
|
)
|
|
|
|
# Create labels (mask input, keep output)
|
|
labels = inputs["input_ids"].clone()
|
|
|
|
# Simple masking: mask padding tokens
|
|
labels[labels == self.processor.tokenizer.pad_token_id] = self.ignore_index
|
|
|
|
inputs["labels"] = labels
|
|
|
|
return inputs
|
|
|
|
except Exception as e:
|
|
logger.info(f"⚠️ DeepSeekOCRDataCollator error: {e}")
|
|
raise
|
|
|
|
|
|
@dataclass
|
|
class VLMDataCollator:
|
|
"""
|
|
Generic VLM data collator that works with various processors.
|
|
|
|
Supports:
|
|
- Qwen2VL
|
|
- LLaVA
|
|
- Other VL models with compatible processors
|
|
"""
|
|
|
|
processor: Any
|
|
max_length: int = 2048
|
|
ignore_index: int = -100
|
|
mask_input_tokens: bool = True # Whether to mask user tokens in labels
|
|
|
|
def __call__(self, batch: List[dict]) -> dict:
|
|
"""
|
|
Collate a batch of VLM samples.
|
|
"""
|
|
all_messages = []
|
|
all_images = []
|
|
|
|
for sample in batch:
|
|
messages = sample.get("messages", [])
|
|
all_messages.append(messages)
|
|
|
|
# Extract images
|
|
for msg in messages:
|
|
content = msg.get("content", [])
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict):
|
|
img = item.get("image")
|
|
if img is not None:
|
|
all_images.append(img)
|
|
|
|
# Apply chat template
|
|
texts = [
|
|
self.processor.apply_chat_template(
|
|
msgs, tokenize = False, add_generation_prompt = False
|
|
)
|
|
for msgs in all_messages
|
|
]
|
|
|
|
# Process inputs
|
|
inputs = self.processor(
|
|
text = texts,
|
|
images = all_images if all_images else None,
|
|
return_tensors = "pt",
|
|
padding = True,
|
|
truncation = True,
|
|
max_length = self.max_length,
|
|
)
|
|
|
|
# Create labels
|
|
labels = inputs["input_ids"].clone()
|
|
|
|
# Mask padding
|
|
if hasattr(self.processor, "tokenizer"):
|
|
pad_token_id = self.processor.tokenizer.pad_token_id
|
|
else:
|
|
pad_token_id = self.processor.pad_token_id
|
|
|
|
if pad_token_id is not None:
|
|
labels[labels == pad_token_id] = self.ignore_index
|
|
|
|
inputs["labels"] = labels
|
|
|
|
return inputs
|