localllm / models.py
h-murai's picture
Upload folder using huggingface_hub
a188e55 verified
Raw
History Blame Contribute Delete
13 kB
"""Model loading and all GPU-decorated inference for the ZeroGPU Space.
This is the ONLY module that imports torch/transformers. Models are placed on
`cuda` at module level — the required ZeroGPU pattern (CUDA is emulated outside
`@spaces.GPU` functions and real inside them).
Presets (env `SHOWCASE_PRESET`):
full gemma-4-12b-it bf16 (~24 GB, multimodal) — for the ZeroGPU Space (48 GB slice)
small gemma-4-12b-it in bitsandbytes 4-bit (~8 GB, multimodal) — local rehearsal
The `small` preset is the same 12B multimodal checkpoint as `full`, only loaded
with 4-bit NF4 quantization so it fits a consumer GPU; vision tabs work in both.
Quantization can be forced/disabled independently of the preset with
`SHOWCASE_QUANTIZE=1`/`0`. It requires a CUDA GPU (bitsandbytes is CUDA-only).
"""
from __future__ import annotations
import os
import threading
from collections.abc import Iterator
import numpy as np
import torch
from prompts import CHAT_SYSTEM, OCR_SYSTEM, build_translate_messages, language_label
from transformers import (
AutoModelForCausalLM,
AutoModelForImageTextToText,
AutoProcessor,
AutoTokenizer,
BitsAndBytesConfig,
TextIteratorStreamer,
pipeline,
)
from zerogpu import GPU
PRESETS = {
"full": "google/gemma-4-12b-it",
"small": "google/gemma-4-12b-it",
}
PRESET = os.environ.get("SHOWCASE_PRESET", "full")
GEMMA_ID = os.environ.get("SHOWCASE_GEMMA_ID") or PRESETS.get(PRESET, PRESETS["full"])
# The `small` preset trades VRAM for the same checkpoint via 4-bit quantization.
# Honour an explicit override; otherwise quantize only for the small preset.
QUANTIZE_4BIT = os.environ.get("SHOWCASE_QUANTIZE", "1" if PRESET == "small" else "0") == "1"
WHISPER_ID = os.environ.get("SHOWCASE_WHISPER_ID", "openai/whisper-large-v3-turbo")
# On ZeroGPU, CUDA is emulated at module level and must be used; locally we
# fall back to CPU when no CUDA build of torch is installed (slow but works).
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# Some Gemma variants ship only a tokenizer — no AutoProcessor-recognizable
# processing class — so fall back to text-only mode. The 12B checkpoint used by
# both presets is multimodal, so this normally keeps the vision tabs enabled.
try:
processor = AutoProcessor.from_pretrained(GEMMA_ID)
except (ValueError, OSError):
processor = AutoTokenizer.from_pretrained(GEMMA_ID)
MULTIMODAL = hasattr(processor, "image_processor")
_tokenizer = getattr(processor, "tokenizer", processor)
if QUANTIZE_4BIT:
if DEVICE != "cuda":
raise RuntimeError(
"4-bit (bitsandbytes) quantization requires a CUDA GPU. Install a CUDA "
"build of torch, or set SHOWCASE_QUANTIZE=0 to load in bf16 instead."
)
# NF4 + double quant + bf16 compute: the standard high-quality 4-bit recipe.
#
# Two non-obvious requirements (transformers 5.11 + bitsandbytes 0.49):
# * Do NOT also pass `dtype=` to from_pretrained — combining an explicit
# dtype with a bnb config silently skips the 4-bit packing, so weights
# load unquantized and the first forward asserts inside bitsandbytes.
# `bnb_4bit_compute_dtype` already fixes the compute precision.
# * Keep the vision/audio towers and the (tied) lm_head in full precision.
# The Gemma vision encoder casts pixel_values to its patch-projection
# weight dtype; if that Linear is 4-bit its weight dtype is uint8 and the
# following LayerNorm dies with "not implemented for 'Byte'". And an
# explicit skip list *replaces* the auto-detected one, so lm_head must be
# re-listed or its tied embedding weight never gets packed either.
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
llm_int8_skip_modules=["lm_head", "model.embed_vision", "model.embed_audio"],
)
_model_kwargs: dict = {"quantization_config": quantization_config, "device_map": "auto"}
else:
_model_kwargs = {"dtype": torch.bfloat16}
try:
gemma = AutoModelForImageTextToText.from_pretrained(GEMMA_ID, **_model_kwargs)
except ValueError:
gemma = AutoModelForCausalLM.from_pretrained(GEMMA_ID, **_model_kwargs)
# A quantized model is already placed on its device(s) via `device_map`, and
# moving a 4-bit model with `.to()` is unsupported — only place the bf16 model.
if not QUANTIZE_4BIT:
gemma = gemma.to(DEVICE)
asr = pipeline(
"automatic-speech-recognition",
model=WHISPER_ID,
dtype=torch.bfloat16,
device=DEVICE,
)
_generate_lock = threading.Lock()
# Greedy decoding can fall into repetition loops, and Whisper hallucinates a
# single token many times over near-silent trailing audio — the live "last
# chunk" (when you pause or cut the mic) becomes one word repeated dozens of
# times. A mild repetition penalty plus an n-gram block break the loop without
# distorting well-formed speech. Applied to the translation/ASR paths; chat and
# OCR stay pure-greedy so legitimately repeated text (tables, code) is preserved.
_ANTI_REPEAT = {"repetition_penalty": 1.3, "no_repeat_ngram_size": 3}
# Whisper's feature extractor expects 16 kHz. The transformers ASR pipeline can
# resample mismatched rates itself, but only via torchaudio — which isn't
# installed on the Space (and pinning it risks a torch-version clash on ZeroGPU).
# Resampling here with numpy means the pipeline is always handed 16 kHz and never
# reaches its torchaudio path.
ASR_SAMPLE_RATE = 16000
# Cap the long edge of OCR page images. Gemma's vision tower pan-and-scans a
# high-res image into multiple ~896px tiles; a 3000px phone photo balloons the
# prefill (more tiles → more image tokens → slower). 1536 keeps text legible
# while roughly halving the tile count on large captures.
OCR_MAX_EDGE = 1536
def _resample_to_16k(audio: np.ndarray, sample_rate: int) -> np.ndarray:
"""Mono float32 → 16 kHz via linear interpolation (no torchaudio/scipy).
Callers already pass mono float32 (to_mono_float32); this only changes rate.
"""
audio = np.asarray(audio, dtype=np.float32).reshape(-1)
if sample_rate == ASR_SAMPLE_RATE or audio.size == 0:
return audio
duration = audio.size / sample_rate
target_len = max(int(round(duration * ASR_SAMPLE_RATE)), 1)
src_t = np.linspace(0.0, duration, audio.size, endpoint=False)
dst_t = np.linspace(0.0, duration, target_len, endpoint=False)
return np.interp(dst_t, src_t, audio).astype(np.float32)
def _fit_image(image):
"""Downscale so the long edge is at most OCR_MAX_EDGE (keeps aspect ratio)."""
from PIL import Image
width, height = image.size
longest = max(width, height)
if longest <= OCR_MAX_EDGE:
return image
scale = OCR_MAX_EDGE / longest
return image.resize((max(round(width * scale), 1), max(round(height * scale), 1)), Image.LANCZOS)
def _flatten_messages(messages: list[dict]) -> list[dict]:
"""Collapse content-part lists to plain strings for tokenizer-only templates."""
flattened = []
for message in messages:
content = message["content"]
if isinstance(content, list):
content = "\n".join(
part["text"] for part in content if part.get("type") == "text"
)
flattened.append({"role": message["role"], "content": content})
return flattened
def _gemma_generate(
messages: list[dict], *, max_new_tokens: int = 512, **gen_kwargs
) -> str:
if not MULTIMODAL:
messages = _flatten_messages(messages)
inputs = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
).to(gemma.device)
with _generate_lock, torch.inference_mode():
output = gemma.generate(
**inputs, max_new_tokens=max_new_tokens, do_sample=False, **gen_kwargs
)
new_tokens = output[0][inputs["input_ids"].shape[-1] :]
return processor.decode(new_tokens, skip_special_tokens=True).strip()
def _utterance_duration(audio: np.ndarray, sample_rate: int, *_args) -> int:
"""Dynamic @spaces.GPU duration: short hints improve visitor queue priority."""
seconds = len(audio) / max(sample_rate, 1)
return int(min(40, 10 + seconds * 1.5))
@GPU(duration=_utterance_duration)
def transcribe_and_translate(
audio: np.ndarray,
sample_rate: int,
source_lang: str | None,
target_lang: str,
tone: str,
context: list[tuple[str, str]],
) -> tuple[str, str]:
"""One live-interpreter utterance: Whisper STT + Gemma MT in a single GPU call."""
asr_kwargs: dict = {"task": "transcribe", **_ANTI_REPEAT}
if source_lang:
asr_kwargs["language"] = language_label(source_lang).lower()
audio = _resample_to_16k(audio, sample_rate)
transcript = asr(
{"array": audio, "sampling_rate": ASR_SAMPLE_RATE},
generate_kwargs=asr_kwargs,
)["text"].strip()
if not transcript:
return "", ""
translation = _gemma_generate(
build_translate_messages(
transcript,
source_lang=source_lang,
target_lang=target_lang,
tone=tone,
context=context,
),
max_new_tokens=256,
**_ANTI_REPEAT,
)
return transcript, translation
@GPU(duration=120)
def transcribe_file(audio: np.ndarray, sample_rate: int, source_lang: str | None) -> str:
asr_kwargs: dict = {"task": "transcribe", **_ANTI_REPEAT}
if source_lang:
asr_kwargs["language"] = language_label(source_lang).lower()
audio = _resample_to_16k(audio, sample_rate)
return asr(
{"array": audio, "sampling_rate": ASR_SAMPLE_RATE},
chunk_length_s=30,
generate_kwargs=asr_kwargs,
)["text"].strip()
@GPU(duration=60)
def translate_text(
text: str, source_lang: str | None, target_lang: str, tone: str
) -> str:
return _gemma_generate(
build_translate_messages(text, source_lang=source_lang, target_lang=target_lang, tone=tone),
max_new_tokens=1024,
**_ANTI_REPEAT,
)
@GPU(duration=90)
def chat_stream(history: list[dict], image=None) -> Iterator[str]:
"""Streaming chat; `history` is [{'role','content'}] text turns, optional PIL image."""
if image is not None and not MULTIMODAL:
raise NotImplementedError(
f"The current demo preset ({GEMMA_ID}) is text-only — image understanding "
"needs the multimodal 12B preset (or the full local app)."
)
messages: list[dict] = [
{"role": "system", "content": [{"type": "text", "text": CHAT_SYSTEM}]}
]
for i, turn in enumerate(history):
content: list[dict] = []
if image is not None and i == len(history) - 1 and turn["role"] == "user":
content.append({"type": "image", "image": image})
content.append({"type": "text", "text": turn["content"]})
messages.append({"role": turn["role"], "content": content})
if not MULTIMODAL:
messages = _flatten_messages(messages)
inputs = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
).to(gemma.device)
streamer = TextIteratorStreamer(
_tokenizer, skip_prompt=True, skip_special_tokens=True
)
kwargs = dict(**inputs, max_new_tokens=1024, do_sample=False, streamer=streamer)
with _generate_lock:
thread = threading.Thread(target=lambda: gemma.generate(**kwargs))
thread.start()
reply = ""
for token in streamer:
reply += token
yield reply
thread.join()
@GPU(duration=120)
def ocr_images(images: list, instructions: str = "") -> str:
"""Gemma vision OCR over one or more page images."""
if not MULTIMODAL:
raise NotImplementedError(
f"The current demo preset ({GEMMA_ID}) is text-only — OCR needs the "
"multimodal 12B preset (or the full local app)."
)
pages: list[str] = []
for image in images:
user_text = "Extract all text from this image."
if instructions.strip():
user_text += f" Additional instructions: {instructions.strip()}"
messages = [
{"role": "system", "content": [{"type": "text", "text": OCR_SYSTEM}]},
{
"role": "user",
"content": [
{"type": "image", "image": _fit_image(image)},
{"type": "text", "text": user_text},
],
},
]
# 1024 covers a dense page; 2048 mostly bought worst-case decode time on
# ZeroGPU's per-call bf16 generate (the dominant cost), not more text.
pages.append(_gemma_generate(messages, max_new_tokens=1024))
return "\n\n---\n\n".join(pages)