unsloth/studio/backend/core/inference/worker.py
Michael Han 3ff6204aa7
studio: load cached GGUF models when fully offline (#5505)
* studio: load cached GGUF models when fully offline

When huggingface.co is unreachable, GGUF model loads fail in three distinct
places even though the bits are already in ~/.cache/huggingface/hub. Each
failure has a different surface symptom:

1. list_gguf_variants() raises straight through HTTPException(500), so the
   variant dropdown shows 'Failed to list GGUF variants'.

2. detect_gguf_model_remote() silently returns None after retries fail. The
   caller then treats a GGUF-only repo as non-GGUF and routes it through the
   transformers/MLX path. On Apple Silicon this surfaces as 'Unsloth currently
   only works on NVIDIA, AMD and Intel GPUs.'

3. _download_gguf() loses list_repo_files() to the network and falls back to a
   filename heuristic ('{repo}-{variant}.gguf'). When the repo name does not
   echo the filenames (e.g. repo 'Qwen3.6-27B-MTP-GGUF' contains a file
   'Qwen3.6-27B-UD-Q4_K_XL.gguf' with no MTP), hf_hub_download cannot find
   that invented filename in the cache and aborts.

Fix in three layers:

- list_gguf_variants / detect_gguf_model_remote: honor HF_HUB_OFFLINE and
  fall back to scanning the local HF cache snapshot when the API throws.
  detect_gguf_model_remote still keeps its retry loop for transient flakes;
  the cache fallback only kicks in after every attempt fails.

- _download_gguf: when list_repo_files() fails, look up variant -> real
  filename inside the cached snapshot before resorting to the heuristic.

- llama_cpp.load_model / inference worker startup: when DNS for
  huggingface.co fails (2s probe), set HF_HUB_OFFLINE=1 for the process so
  every hf_hub_download call below resolves from cache instantly instead of
  spending ~25s on five exponential retries.

Online behavior is unchanged: the API is tried first and only used to fail
over. The cache scan is a strict subset of what list_local_gguf_variants
already does today for local paths.

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

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

* studio: tighten inline comments on offline GGUF fallback

* studio: address review feedback on offline GGUF fallback

Fixes from the review pass on #5505:

* ruff F823 (lint CI red): the late `import os` at the bottom of
  LlamaCppBackend.load_model made `os` a function-local name, so my
  new `os.environ` reference at the top of the same method was a
  use-before-bind. Surfaces at runtime as
  'cannot access local variable os where it is not associated with a value'
  and is why the Mac/Windows Studio API jobs were failing too. The
  env-var mutation has been moved into a module-level contextmanager,
  so load_model no longer touches `os` directly.

* Codex P1: cache variant match now uses the relative path, not the
  basename. Layouts like `BF16/foo.gguf` (variant token only in
  parent dir) were silently skipped, falling through to the bogus
  `{repo}-{variant}.gguf` heuristic and failing offline loads of
  models stored under quant-named subdirs.

* Codex P1: HF_HUB_OFFLINE no longer persists past one model load.
  llama_cpp.load_model now uses a contextmanager that probes DNS,
  sets HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE only when DNS is dead,
  and pops them in finally (preserving any prior user setting of
  TRANSFORMERS_OFFLINE). Pre-existing user-set HF_HUB_OFFLINE is
  respected as a no-op. worker.py keeps the startup probe because the
  orchestrator spawns a fresh worker per load -- comment updated to
  make that lifecycle explicit, and a warning is now logged.

* Gemini: cache-dir lookup centralized in `_iter_hf_cache_snapshots`.
  Three near-identical copies (in list/detect helpers and the
  llama_cpp offline scan) now go through one helper.

* Gemini: `huggingface_hub.utils.is_offline_mode` does not exist in
  1.x (verified locally); `huggingface_hub.constants.HF_HUB_OFFLINE`
  is snapshot-at-import-time and does not reflect runtime mutations.
  Manual env-var parsing kept.

* socket probe now saves and restores the prior default timeout
  instead of unconditionally setting None on exit, so it composes
  with caller code that already configured a timeout.

* worker.py probe now logs a warning when offline mode is auto-enabled
  so debugging the case isn't blind.

* studio: regression tests for offline GGUF cache fallback

Lock in the offline fallback path from #5505 so future refactors can't
silently regress either bug. 26 tests, 0.55 s, no network/GPU/subprocess.

Covers:

* _iter_hf_cache_snapshots: missing cache, missing repo, missing
  snapshots/, newest-mtime ordering, case-insensitive repo match.
* _list_gguf_variants_from_hf_cache and the list_gguf_variants
  online/offline-env/API-exception/reraise paths.
* _detect_gguf_from_hf_cache and detect_gguf_model_remote 3x-fail
  fallback. Pre-existing RepositoryNotFoundError early-return preserved.
* Codex P1 #1 regression: BF16/foo.gguf (quant only in subdir name)
  must resolve via _detect_gguf_from_hf_cache, which now matches the
  snapshot-relative path rather than the basename.
* _probe_dns_dead: returns True/False, restores prior socket timeout.
* Codex P1 #2 regression: _hf_offline_if_dns_dead sets env only inside
  the block, restores on exit (including on exception), re-probes DNS
  on the next call so a transient hiccup cannot lock the long-lived
  LlamaCppBackend singleton offline. Honors a user-set HF_HUB_OFFLINE
  as a no-op. Preserves a user-set TRANSFORMERS_OFFLINE across exit.

Follows the existing studio backend test stub pattern (loggers /
structlog / httpx stubs + backend dir on sys.path).

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

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

* studio: extend offline cache fallback to _download_mmproj and quant label

Two follow-up fixes from the review pass on #5505:

* _download_mmproj() now mirrors _download_gguf()'s offline path:
  when list_repo_files() fails, scan the local HF cache snapshot for
  any GGUF whose basename starts with mmproj-. Without this, offline
  vision GGUF loads succeed at the main weight (the existing PR fix)
  but the mmproj returns None and llama-server starts without vision
  support. Same _iter_hf_cache_snapshots helper, F16 preference and
  fallback to the first match are preserved.

* _extract_quant_label() now considers parent directory segments when
  the basename has no quant token. Layouts like BF16/foo.gguf are
  already documented in this file and are returned by the new
  snapshot-relative-path filter in _download_gguf; before this fix
  their variant label collapsed to "foo" (the last hyphen segment of
  the basename). Regex is the same; the search just walks parent
  segments innermost-first if the basename misses.

Tests (studio/backend/tests/test_offline_gguf_cache_fallback.py):

* TestExtractQuantLabelSubdir: basename quant unchanged, quant-only-
  in-parent, UD- prefix in parent, deeper nesting picks the
  innermost matching segment.
* TestDownloadMmprojOfflineCacheFallback: cache fallback returns the
  mmproj when list_repo_files fails, F16 preference holds when both
  variants are in cache, no-mmproj cache returns None.
* httpx stub now prefers the real package when installed (the CI
  install list already includes it) and falls back to the stub only
  when httpx is genuinely missing. Newer huggingface_hub imports
  HTTPError/Response/Request at module load, so the previous
  fixed-set stub broke when those names were added upstream.

26 existing cases plus 7 new = 33 pass in 0.74s.

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

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

* Fix/adjust offline cache + DNS probe per PR #5505 review

Four review findings tightened, with regression tests:

- list_local_gguf_variants subdir collapse (P1 codex 10:08): pass the
  snapshot-relative path to _extract_quant_label so BF16/foo.gguf and
  Q4_K_M/foo.gguf produce distinct labels instead of folding to the same
  basename pseudo-quant.
- list_gguf_variants cache fallback (P2 codex 12:10): surface
  RepositoryNotFoundError / GatedRepoError / RevisionNotFoundError /
  EntryNotFoundError to the caller instead of masking with stale cache,
  matching detect_gguf_model_remote.
- _detect_gguf_from_hf_cache mmproj (P2 codex 12:10): exclude mmproj
  files from the candidate list so a partial cache with only a vision
  projector cannot route the projector as the main model.
- _probe_dns_dead global timeout (P2 codex 13:06): run the gethostbyname
  on a daemon thread with join timeout so concurrent sockets in the same
  interpreter never inherit a process-wide socket.setdefaulttimeout
  mutation. Same shape applied in worker.py's startup probe.

* Make llama-server health check tolerant of warmup races

Two layered fixes for the Windows GGUF smoke CI Tool calling Tests
flake that exit-22'd on a single httpx.ReadError during llama-server
warmup. The 'windows-latest -> windows-2025-vs2026' image rollout is
hitting main with the identical symptom.

A. _wait_for_health: catch httpx.ReadError, RemoteProtocolError,
   WriteError alongside ConnectError and TimeoutException. A TCP RST
   mid-read while llama-server is still binding the port (WinError
   10054) is a 'still warming up' signal, not fatal. The existing
   _process.poll() check still wins for real crashes.

B. _drain_stdout + spawn: tee llama-server stdout/stderr to a
   per-launch log file at ~/.unsloth/studio/logs/llama-server/
   <port>.log. Any future subprocess crash leaves a forensic trace
   on disk even when Studio's traceback only captures the symptom
   (ReadError) and not the cause. Best-effort: a logging-side OSError
   never blocks the load.

Regression coverage: TestWaitForHealthRetriesOnReadError pins the
retry behaviour for the three new exception types and verifies that a
real process exit still short-circuits the loop.

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

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

* ci(windows): retry inference/load + collect llama-server logs

Composite fix for the Tool calling Tests flake that exit-22'd on a
single httpx.ReadError during llama-server warm-up. The
windows-latest -> windows-2025-vs2026 runner image rollout has been
hitting main with the identical symptom.

- All three jobs (openai-anthropic, tool-calling, json-images) now
  retry POST /api/inference/load up to 3 times with 10s backoff and
  preserve the response body for post-mortem. One transient 500 no
  longer fails the whole job.
- A new "Collect llama-server logs" step copies the per-launch
  llama-server stdout teed by Studio under ~/.unsloth/studio/logs/
  llama-server/ into the workspace, and the upload-artifact step
  now includes logs/llama-server/*.log so any future subprocess
  crash leaves a forensic trace.

---------

Co-authored-by: shimmyshimmer <shimmyshimmer@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-17 21:25:39 -07:00

994 lines
34 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
"""
Inference subprocess entry point.
Each inference session runs in a persistent subprocess (mp.get_context("spawn")).
This gives us a clean Python interpreter with no stale module state —
solving the transformers version-switching problem completely.
The subprocess stays alive while a model is loaded, accepting commands
(generate, load, unload) via mp.Queue. It exits on shutdown or unload.
Pattern follows core/training/worker.py.
"""
from __future__ import annotations
import base64
import structlog
from loggers import get_logger
import os
import queue as _queue
import sys
import threading
import time
import traceback
from io import BytesIO
from pathlib import Path
from typing import Any
logger = get_logger(__name__)
from utils.hardware import apply_gpu_ids
def _activate_transformers_version(model_name: str) -> None:
"""Activate the correct transformers version BEFORE any ML imports."""
# Ensure backend is on path for utils imports
backend_path = str(Path(__file__).resolve().parent.parent.parent)
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from utils.transformers_version import activate_transformers_for_subprocess
activate_transformers_for_subprocess(model_name)
def _decode_image(image_base64: str):
"""Decode base64 string to PIL.Image."""
from PIL import Image
image_data = base64.b64decode(image_base64)
return Image.open(BytesIO(image_data))
def _resize_image(img, max_size: int = 800):
"""Resize image while maintaining aspect ratio."""
if img is None:
return None
if img.size[0] > max_size or img.size[1] > max_size:
from PIL import Image
ratio = min(max_size / img.size[0], max_size / img.size[1])
new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
return img.resize(new_size, Image.Resampling.LANCZOS)
return img
def _send_response(resp_queue: Any, response: dict) -> None:
"""Send a response to the parent process."""
try:
resp_queue.put(response)
except (OSError, ValueError) as exc:
logger.error("Failed to send response: %s", exc)
def _build_model_config(config: dict):
"""Build a ModelConfig from the config dict."""
from utils.models import ModelConfig
model_name = config["model_name"]
hf_token = config.get("hf_token")
hf_token = hf_token if hf_token and hf_token.strip() else None
gguf_variant = config.get("gguf_variant")
mc = ModelConfig.from_identifier(
model_id = model_name,
hf_token = hf_token,
gguf_variant = gguf_variant,
)
if not mc:
raise ValueError(f"Invalid model identifier: {model_name}")
return mc
def _get_hf_download_state(
model_names: list[str] | None = None,
) -> tuple[int, bool] | None:
"""Return (total_bytes, has_incomplete) for the HF Hub cache, or None on error.
When *model_names* is provided, only those models' ``blobs/``
directories are checked instead of scanning every cached model --
much faster on systems with many models. Accepts multiple names so
that LoRA loads can watch both the adapter repo and the base model
repo simultaneously.
*has_incomplete* is True when any ``*.incomplete`` files exist in the
watched blobs directories, indicating that ``huggingface_hub`` is
actively downloading.
Returns None if the state cannot be determined (import error,
permission error, etc.) so callers can skip stall logic.
"""
try:
from huggingface_hub.constants import HF_HUB_CACHE
cache = Path(HF_HUB_CACHE)
if not cache.exists():
return (0, False)
total = 0
has_incomplete = False
blobs_dirs: list[Path] = []
if model_names:
from utils.paths import resolve_cached_repo_id_case
for name in model_names:
if not name:
continue
# Skip local filesystem paths -- HF model IDs use forward
# slashes (org/model) but never start with / . ~ or contain
# backslashes. This distinguishes them from absolute paths,
# relative paths, and Windows paths.
if name.startswith(("/", ".", "~")) or "\\" in name:
continue
name = resolve_cached_repo_id_case(name)
# HF cache dir format: models--org--name (slashes -> --)
cache_dir_name = "models--" + name.replace("/", "--")
blobs_dir = cache / cache_dir_name / "blobs"
if blobs_dir.exists():
blobs_dirs.append(blobs_dir)
else:
blobs_dirs = list(cache.glob("models--*/blobs"))
for bdir in blobs_dirs:
for f in bdir.iterdir():
try:
if f.is_file():
total += f.stat().st_size
if f.name.endswith(".incomplete"):
has_incomplete = True
except OSError:
pass
return (total, has_incomplete)
except Exception as e:
logger.debug("Failed to determine HF download state: %s", e)
return None
def _start_heartbeat(
resp_queue: Any,
interval: float = 30.0,
stall_timeout: float = 180.0,
xet_disabled: bool = False,
model_names: list[str] | None = None,
) -> threading.Event:
"""Start a daemon thread that sends periodic status heartbeats.
Monitors the HF Hub cache directory for download activity. A stall
is only reported when ``*.incomplete`` files are present (indicating
``huggingface_hub`` is actively downloading) **and** the total cache
size has not changed for *stall_timeout* seconds.
Once the download finishes (no more ``.incomplete`` files), the stall
timer resets, so post-download initialization (quantization, GPU
weight loading) is never misclassified as a stalled download.
Returns a stop event -- set it to terminate the heartbeat thread.
"""
stop = threading.Event()
transport = "https" if xet_disabled else "xet"
def _beat():
state = _get_hf_download_state(model_names)
last_size = state[0] if state is not None else 0
last_change = time.monotonic()
while not stop.wait(interval):
state = _get_hf_download_state(model_names)
now = time.monotonic()
# Skip stall logic if we cannot measure the cache
if state is None:
_send_response(
resp_queue,
{
"type": "status",
"message": f"Loading model ({transport} transport)...",
"ts": time.time(),
},
)
continue
current_size, has_incomplete = state
if current_size != last_size:
last_size = current_size
last_change = now
# Only fire stall when .incomplete files are present,
# confirming a download is actively in progress.
# Once downloads finish (no .incomplete), reset the timer
# so model init time is not counted as a stall.
if not has_incomplete:
last_change = now
elif now - last_change >= stall_timeout:
_send_response(
resp_queue,
{
"type": "stall",
"message": (
f"Download appears stalled ({transport} transport) "
f"-- no progress for {int(now - last_change)}s"
),
"ts": time.time(),
},
)
# Only fire once -- the orchestrator will kill us
return
_send_response(
resp_queue,
{
"type": "status",
"message": f"Loading model ({transport} transport)...",
"ts": time.time(),
},
)
t = threading.Thread(target = _beat, daemon = True)
t.start()
return stop
def _handle_load(backend, config: dict, resp_queue: Any) -> None:
"""Handle a load command: load a model into the backend."""
try:
mc = _build_model_config(config)
hf_token = config.get("hf_token")
hf_token = hf_token if hf_token and hf_token.strip() else None
# Auto-detect quantization for LoRA adapters
load_in_4bit = config.get("load_in_4bit", True)
if mc.is_lora and mc.path:
import json
from pathlib import Path
adapter_cfg_path = Path(mc.path) / "adapter_config.json"
if adapter_cfg_path.exists():
try:
with open(adapter_cfg_path) as f:
adapter_cfg = json.load(f)
training_method = adapter_cfg.get("unsloth_training_method")
if training_method == "lora" and load_in_4bit:
logger.info(
"adapter_config.json says lora — setting load_in_4bit=False"
)
load_in_4bit = False
elif training_method == "qlora" and not load_in_4bit:
logger.info(
"adapter_config.json says qlora — setting load_in_4bit=True"
)
load_in_4bit = True
elif not training_method:
if (
mc.base_model
and "-bnb-4bit" not in mc.base_model.lower()
and load_in_4bit
):
logger.info(
"No training method, base model has no -bnb-4bit — setting load_in_4bit=False"
)
load_in_4bit = False
except Exception as e:
logger.warning("Could not read adapter_config.json: %s", e)
# Auto-enable trust_remote_code for NemotronH/Nano models only.
# NemotronH has config parsing bugs requiring trust_remote_code=True.
# Other transformers 5.x models are native and do NOT need it.
# NOTE: Must NOT match Llama-Nemotron (standard Llama architecture).
_NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano")
trust_remote_code = config.get("trust_remote_code", False)
if not trust_remote_code:
model_name = config["model_name"]
_mn_lower = model_name.lower()
if any(sub in _mn_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) and (
_mn_lower.startswith("unsloth/") or _mn_lower.startswith("nvidia/")
):
trust_remote_code = True
logger.info(
"Auto-enabled trust_remote_code for Nemotron model: %s",
model_name,
)
# Send heartbeats every 30s so the orchestrator knows we're still alive
# (download / weight loading can take a long time on slow connections)
xet_disabled = os.environ.get("HF_HUB_DISABLE_XET") == "1"
# Watch both the model repo and base model repo (for LoRA loads
# where the base model download is the actual bottleneck)
watch_repos = [mc.identifier]
base = getattr(mc, "base_model", None)
if base and str(base) != mc.identifier:
watch_repos.append(str(base))
heartbeat_stop = _start_heartbeat(
resp_queue,
interval = 30.0,
xet_disabled = xet_disabled,
model_names = watch_repos,
)
try:
success = backend.load_model(
config = mc,
max_seq_length = config.get("max_seq_length", 2048),
load_in_4bit = load_in_4bit,
hf_token = hf_token,
trust_remote_code = trust_remote_code,
gpu_ids = config.get("resolved_gpu_ids"),
)
finally:
heartbeat_stop.set()
if success:
# Build model_info for the parent to mirror
model_info = {
"identifier": mc.identifier,
"display_name": mc.display_name,
"is_vision": mc.is_vision,
"is_lora": mc.is_lora,
"is_gguf": False,
"is_audio": getattr(mc, "is_audio", False),
"audio_type": getattr(mc, "audio_type", None),
"has_audio_input": getattr(mc, "has_audio_input", False),
}
_send_response(
resp_queue,
{
"type": "loaded",
"success": True,
"model_info": model_info,
"ts": time.time(),
},
)
else:
_send_response(
resp_queue,
{
"type": "loaded",
"success": False,
"error": "Failed to load model",
"ts": time.time(),
},
)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "loaded",
"success": False,
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
def _handle_generate(
backend,
cmd: dict,
resp_queue: Any,
cancel_event,
) -> None:
"""Handle a generate command: stream tokens back via resp_queue.
cancel_event is an mp.Event shared with the parent process.
The parent can set it at any time (e.g. user stops generation,
or user loads a new model while generating) and generation
stops within 1-2 tokens.
"""
request_id = cmd.get("request_id", "")
try:
# Decode image if provided
image = None
image_b64 = cmd.get("image_base64")
if image_b64:
image = _decode_image(image_b64)
image = _resize_image(image)
# Build generation kwargs
gen_kwargs = {
"messages": cmd["messages"],
"system_prompt": cmd.get("system_prompt", ""),
"image": image,
"temperature": cmd.get("temperature", 0.7),
"top_p": cmd.get("top_p", 0.9),
"top_k": cmd.get("top_k", 40),
"min_p": cmd.get("min_p", 0.0),
"max_new_tokens": cmd.get("max_new_tokens", 256),
"repetition_penalty": cmd.get("repetition_penalty", 1.0),
"cancel_event": cancel_event,
}
# Choose generation path
use_adapter = cmd.get("use_adapter")
if use_adapter is not None:
generator = backend.generate_with_adapter_control(
use_adapter = use_adapter,
**gen_kwargs,
)
else:
generator = backend.generate_chat_response(**gen_kwargs)
logger.info("Starting text generation for request_id=%s", request_id)
for cumulative_text in generator:
# cancel_event is an mp.Event — checked instantly, no queue polling
if cancel_event.is_set():
logger.info("Generation cancelled for request %s", request_id)
break
_send_response(
resp_queue,
{
"type": "token",
"request_id": request_id,
"text": cumulative_text,
"ts": time.time(),
},
)
_send_response(
resp_queue,
{
"type": "gen_done",
"request_id": request_id,
"ts": time.time(),
},
)
logger.info("Finished text generation for request_id=%s", request_id)
except Exception as exc:
logger.error("Generation error: %s", exc, exc_info = True)
_send_response(
resp_queue,
{
"type": "gen_error",
"request_id": request_id,
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
def _handle_generate_audio(
backend,
cmd: dict,
resp_queue: Any,
) -> None:
"""Handle TTS audio generation — returns WAV bytes + sample_rate."""
request_id = cmd.get("request_id", "")
try:
logger.info("Starting audio generation for request_id=%s", request_id)
wav_bytes, sample_rate = backend.generate_audio_response(
text = cmd["text"],
temperature = cmd.get("temperature", 0.6),
top_p = cmd.get("top_p", 0.95),
top_k = cmd.get("top_k", 50),
min_p = cmd.get("min_p", 0.0),
max_new_tokens = cmd.get("max_new_tokens", 2048),
repetition_penalty = cmd.get("repetition_penalty", 1.0),
use_adapter = cmd.get("use_adapter"),
)
# Send WAV bytes as base64 (bytes can't go through mp.Queue directly)
_send_response(
resp_queue,
{
"type": "audio_done",
"request_id": request_id,
"wav_base64": base64.b64encode(wav_bytes).decode("ascii"),
"sample_rate": sample_rate,
"ts": time.time(),
},
)
logger.info("Finished audio generation for request_id=%s", request_id)
except Exception as exc:
logger.error("Audio generation error: %s", exc, exc_info = True)
_send_response(
resp_queue,
{
"type": "audio_error",
"request_id": request_id,
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
def _handle_generate_audio_input(
backend,
cmd: dict,
resp_queue: Any,
cancel_event,
) -> None:
"""Handle audio input generation (ASR/Whisper) — streams text tokens back."""
request_id = cmd.get("request_id", "")
try:
import numpy as np
# Decode audio array from list (numpy arrays can't go through mp.Queue)
audio_array = np.array(cmd["audio_data"], dtype = np.float32)
audio_type = cmd.get("audio_type")
if audio_type == "whisper":
generator = backend.generate_whisper_response(
audio_array = audio_array,
cancel_event = cancel_event,
)
else:
generator = backend.generate_audio_input_response(
messages = cmd.get("messages", []),
system_prompt = cmd.get("system_prompt", ""),
audio_array = audio_array,
temperature = cmd.get("temperature", 0.7),
top_p = cmd.get("top_p", 0.9),
top_k = cmd.get("top_k", 40),
min_p = cmd.get("min_p", 0.0),
max_new_tokens = cmd.get("max_new_tokens", 512),
repetition_penalty = cmd.get("repetition_penalty", 1.0),
cancel_event = cancel_event,
)
logger.info("Starting audio input generation for request_id=%s", request_id)
for text_chunk in generator:
if cancel_event.is_set():
logger.info(
"Audio input generation cancelled for request %s", request_id
)
break
_send_response(
resp_queue,
{
"type": "token",
"request_id": request_id,
"text": text_chunk,
"ts": time.time(),
},
)
_send_response(
resp_queue,
{
"type": "gen_done",
"request_id": request_id,
"ts": time.time(),
},
)
logger.info("Finished audio input generation for request_id=%s", request_id)
except Exception as exc:
logger.error("Audio input generation error: %s", exc, exc_info = True)
_send_response(
resp_queue,
{
"type": "gen_error",
"request_id": request_id,
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
def _handle_unload(backend, cmd: dict, resp_queue: Any) -> None:
"""Handle an unload command."""
model_name = cmd.get("model_name", "")
try:
if model_name and model_name in backend.models:
backend.unload_model(model_name)
elif backend.active_model_name:
backend.unload_model(backend.active_model_name)
_send_response(
resp_queue,
{
"type": "unloaded",
"model_name": model_name,
"ts": time.time(),
},
)
except Exception as exc:
logger.error("Unload error: %s", exc)
_send_response(
resp_queue,
{
"type": "unloaded",
"model_name": model_name,
"error": str(exc),
"ts": time.time(),
},
)
def run_inference_process(
*,
cmd_queue: Any,
resp_queue: Any,
cancel_event,
config: dict,
) -> None:
"""Subprocess entrypoint. Persistent — runs command loop until shutdown.
Args:
cmd_queue: mp.Queue for receiving commands from parent.
resp_queue: mp.Queue for sending responses to parent.
cancel_event: mp.Event shared with parent — set by parent to cancel generation.
config: Initial configuration dict with model info.
"""
os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["PYTHONWARNINGS"] = (
"ignore" # Suppress warnings at C-level before imports
)
if config.get("disable_xet"):
os.environ["HF_HUB_DISABLE_XET"] = "1"
logger.info("Xet transport disabled (HF_HUB_DISABLE_XET=1)")
# Offline auto-detect: skip 25s of hf_hub_download retries per file
# if DNS is dead; cached files resolve instantly under HF_HUB_OFFLINE=1.
# Scope is this subprocess only -- orchestrator spawns a fresh worker
# per load (see core/inference/orchestrator.py), so the env cannot
# persist across loads.
if "HF_HUB_OFFLINE" not in os.environ:
import socket as _socket
import threading as _threading
# Probe on a daemon thread so concurrent sockets in the parent
# interpreter are not affected by socket.setdefaulttimeout.
_result: list = [None]
def _probe() -> None:
try:
_socket.gethostbyname("huggingface.co")
_result[0] = False
except Exception:
_result[0] = True
_t = _threading.Thread(target = _probe, daemon = True)
_t.start()
_t.join(2.0)
if _result[0] is None or _result[0] is True:
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
logger.warning(
"huggingface.co unreachable; HF_HUB_OFFLINE=1 set for this worker."
)
import warnings
from loggers.config import LogConfig
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
warnings.filterwarnings("ignore")
LogConfig.setup_logging(
service_name = "unsloth-studio-inference-worker",
env = os.getenv("ENVIRONMENT_TYPE", "production"),
)
apply_gpu_ids(config.get("resolved_gpu_ids"))
model_name = config["model_name"]
# ── 0. MLX fast-path — skip torch/transformers entirely ──
backend_path = str(Path(__file__).resolve().parent.parent.parent)
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE == _hw.DeviceType.MLX:
try:
_activate_transformers_version(model_name)
except Exception:
pass
try:
from core.inference.mlx_inference import MLXInferenceBackend
backend = MLXInferenceBackend()
_send_response(
resp_queue,
{"type": "status", "message": "Loading model...", "ts": time.time()},
)
_handle_load(backend, config, resp_queue)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "error",
"error": f"MLX inference init failed: {exc}",
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
return
# Enter same command loop as GPU path
logger.info("MLX inference subprocess ready, entering command loop")
while True:
try:
cmd = cmd_queue.get(timeout = 1.0)
except _queue.Empty:
continue
except (EOFError, OSError):
return
if cmd is None:
continue
cmd_type = cmd.get("type", "")
try:
if cmd_type == "generate":
cancel_event.clear()
_handle_generate(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "load":
if backend.active_model_name:
backend.unload_model(backend.active_model_name)
_handle_load(backend, cmd, resp_queue)
elif cmd_type == "unload":
_handle_unload(backend, cmd, resp_queue)
elif cmd_type == "cancel":
cancel_event.set()
elif cmd_type == "reset":
cancel_event.set()
backend.reset_generation_state()
_send_response(resp_queue, {"type": "reset_ack", "ts": time.time()})
elif cmd_type == "status":
_send_response(
resp_queue,
{
"type": "status_response",
"active_model": backend.active_model_name,
"models": {
k: {kk: vv for kk, vv in v.items() if kk != "model"}
for k, v in backend.models.items()
},
"loading": list(backend.loading_models),
"ts": time.time(),
},
)
elif cmd_type == "shutdown":
return
except Exception as exc:
logger.error("MLX command error (%s): %s", cmd_type, exc)
_send_response(
resp_queue,
{
"type": "gen_error" if cmd_type == "generate" else "error",
"request_id": cmd.get("request_id"),
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
return
# ── 1. Activate correct transformers version BEFORE any ML imports ──
try:
_activate_transformers_version(model_name)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "error",
"error": f"Failed to activate transformers version: {exc}",
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
return
# ── 1b. On Windows, check Triton availability (must be before import torch) ──
if sys.platform == "win32":
try:
import triton # noqa: F401
logger.info("Triton available — torch.compile enabled")
except ImportError:
os.environ["TORCHDYNAMO_DISABLE"] = "1"
logger.warning(
"Triton not found on Windows — torch.compile disabled. "
'Install for better performance: pip install "triton-windows<3.7"'
)
# ── 2. Import ML libraries (fresh in this clean process) ──
try:
_send_response(
resp_queue,
{
"type": "status",
"message": "Importing Unsloth...",
"ts": time.time(),
},
)
backend_path = str(Path(__file__).resolve().parent.parent.parent)
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from core.inference.inference import InferenceBackend
import transformers
logger.info("Subprocess loaded transformers %s", transformers.__version__)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "error",
"error": f"Failed to import ML libraries: {exc}",
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
return
# ── 3. Create inference backend and load initial model ──
try:
backend = InferenceBackend()
_send_response(
resp_queue,
{
"type": "status",
"message": "Loading model...",
"ts": time.time(),
},
)
_handle_load(backend, config, resp_queue)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "error",
"error": f"Failed to initialize inference backend: {exc}",
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)
return
# ── 4. Command loop — process commands until shutdown ──
# cancel_event is an mp.Event shared with parent — parent can set it
# at any time to cancel generation instantly (no queue polling needed).
logger.info("Inference subprocess ready, entering command loop")
while True:
try:
cmd = cmd_queue.get(timeout = 1.0)
except _queue.Empty:
continue
except (EOFError, OSError):
logger.info("Command queue closed, shutting down")
return
if cmd is None:
continue
cmd_type = cmd.get("type", "")
logger.info("Received command: %s", cmd_type)
try:
if cmd_type == "generate":
cancel_event.clear()
_handle_generate(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "load":
# Load a new model (reusing this subprocess)
# First unload current model
if backend.active_model_name:
backend.unload_model(backend.active_model_name)
_handle_load(backend, cmd, resp_queue)
elif cmd_type == "generate_audio":
cancel_event.clear()
_handle_generate_audio(backend, cmd, resp_queue)
elif cmd_type == "generate_audio_input":
cancel_event.clear()
_handle_generate_audio_input(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "unload":
_handle_unload(backend, cmd, resp_queue)
elif cmd_type == "cancel":
# Redundant with mp.Event but handle gracefully
cancel_event.set()
logger.info("Cancel command received")
elif cmd_type == "reset":
cancel_event.set()
backend.reset_generation_state()
_send_response(
resp_queue,
{
"type": "reset_ack",
"ts": time.time(),
},
)
elif cmd_type == "status":
# Return current status
_send_response(
resp_queue,
{
"type": "status_response",
"active_model": backend.active_model_name,
"models": {
name: {
"is_vision": info.get("is_vision", False),
"is_lora": info.get("is_lora", False),
}
for name, info in backend.models.items()
},
"loading": list(backend.loading_models),
"ts": time.time(),
},
)
elif cmd_type == "shutdown":
logger.info("Shutdown command received, exiting")
# Unload all models
for model_name in list(backend.models.keys()):
try:
backend.unload_model(model_name)
except Exception:
pass
_send_response(
resp_queue,
{
"type": "shutdown_ack",
"ts": time.time(),
},
)
return
else:
logger.warning("Unknown command type: %s", cmd_type)
_send_response(
resp_queue,
{
"type": "error",
"error": f"Unknown command type: {cmd_type}",
"ts": time.time(),
},
)
except Exception as exc:
logger.error(
"Error handling command '%s': %s", cmd_type, exc, exc_info = True
)
_send_response(
resp_queue,
{
"type": "error",
"error": f"Command '{cmd_type}' failed: {exc}",
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
},
)