AbstractPhil's picture
Deploy: 12-task vision extraction + fusion ZeroGPU showcase
fed954e verified
Raw
History Blame Contribute Delete
15.3 kB
"""
model_runner.py — Loads a Qwen instruct model once and exposes three generation modes.
Modes:
1. free — raw chat, no JSON instruction. Establishes a "what does the model
do unprompted?" floor.
2. json_mode — chat with a strong system prompt asking for JSON-only output.
No decoder-level constraint. Tests in-context schema obedience.
3. constrained — uses xgrammar (preferred) or outlines (fallback) to enforce the
grammar at decode time. Schema validity becomes guaranteed; the
interesting question is whether faithfulness survives.
The model is loaded ONCE in __init__. All three modes share the same weights.
Optional dependencies (xgrammar, outlines) degrade gracefully — if neither is installed,
generate_constrained falls back to json_mode and emits a warning.
"""
from __future__ import annotations
import json
import warnings
from dataclasses import dataclass
from typing import Optional
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
# Optional backends — import lazily and tolerate missing
try:
import xgrammar as xgr
_HAS_XGRAMMAR = True
except ImportError:
_HAS_XGRAMMAR = False
try:
import outlines
_HAS_OUTLINES = True
except ImportError:
_HAS_OUTLINES = False
SYSTEM_PROMPT_FREE = (
"You are a vision-language assistant. Given an image caption, describe what the "
"image shows."
)
# NOTE: this schema block mirrors SLOT_REGISTRY (registry.py) — the registry is
# the source of truth. If a slot is added/removed there, update this block too
# (a stale prompt validates fine because pydantic ignores extras, but the model
# wastes output budget on fields that get silently dropped — caught 2026-07).
SYSTEM_PROMPT_JSON = """You are a caption structuring assistant. Given an image caption,
extract its content into JSON matching this exact schema:
{
"subjects": [{"name": str, "attributes": [str]}],
"actions": [str],
"setting": "indoor" | "outdoor" | "unknown",
"style": str or null,
"mood": str or null
}
Rules:
- Only include subjects, attributes, and actions that are EXPLICITLY mentioned in the caption.
- Never invent details that aren't in the input.
- If the caption doesn't specify the setting, use "unknown".
- If no style or mood is evident, use null.
- Limits (hard): at most 8 subjects and at most 8 actions (attributes per subject are
unlimited), and every string under 64 characters. If the caption has more, keep only
the most important ones.
- Output ONLY the JSON object. No prose, no markdown, no code fences.
""".strip()
@dataclass
class GenResult:
"""Output of a single generation call."""
mode: str # "free" | "json_mode" | "constrained"
raw_text: str # exactly what the model decoded (after chat template strip)
backend: str # "transformers" | "xgrammar" | "outlines"
n_input_tokens: int
n_output_tokens: int
class QwenRunner:
"""Loads a Qwen instruct model once, runs three generation modes against it."""
def __init__(
self,
model_id: str = "Qwen/Qwen3.5-0.8B",
device: Optional[str] = None,
dtype: torch.dtype = torch.bfloat16,
trust_remote_code: bool = True,
enable_thinking: bool = False,
):
"""
Loads a Qwen3.5 post-trained checkpoint.
Notes on Qwen3.5-0.8B specifically:
* It is a vision-language model (image-text-to-text). For text-only use
(this benchmark), just don't pass image content; the chat template
handles it. The vision encoder still gets loaded into VRAM (~0.1 GB).
* model_type=qwen3_5 needs transformers from git main:
pip install "transformers @ git+https://github.com/huggingface/transformers.git@main"
* Default is non-thinking mode. Qwen3.5-0.8B is prone to thinking loops,
so leave enable_thinking=False unless you have a reason.
"""
self.model_id = model_id
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.dtype = dtype
self.enable_thinking = enable_thinking
print(f"[QwenRunner] loading {model_id} on {self.device} ({dtype})")
self.tokenizer = AutoTokenizer.from_pretrained(
model_id, trust_remote_code=trust_remote_code
)
self.model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=dtype,
device_map=self.device,
trust_remote_code=trust_remote_code,
)
self.model.eval()
# xgrammar compiler is reusable across calls — build once.
self._xgr_compiled_grammar = None
self._xgr_tokenizer_info = None
if _HAS_XGRAMMAR:
try:
self._xgr_tokenizer_info = xgr.TokenizerInfo.from_huggingface(self.tokenizer)
self._xgr_compiler = xgr.GrammarCompiler(self._xgr_tokenizer_info)
except Exception as e:
warnings.warn(f"xgrammar tokenizer init failed: {e}; falling back")
self._xgr_compiler = None
else:
self._xgr_compiler = None
print(f"[QwenRunner] ready. xgrammar={_HAS_XGRAMMAR}, outlines={_HAS_OUTLINES}")
# ── prompt construction ──────────────────────────────────────────────
def _build_chat(self, system: str, user: str) -> str:
"""Apply chat template; returns the formatted prompt string.
Per the Qwen3.5 card, thinking mode is toggled via the `enable_thinking`
template variable (the legacy /think /nothink soft switch was removed).
When calling apply_chat_template directly, pass it as a regular kwarg;
when calling via OpenAI-compat APIs, nest it under chat_template_kwargs.
"""
msgs = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
return self.tokenizer.apply_chat_template(
msgs,
tokenize=False,
add_generation_prompt=True,
enable_thinking=self.enable_thinking,
)
# Recommended sampling for Qwen3.5-0.8B non-thinking text tasks (per model card).
# Keep top_k since transformers supports it; min_p, presence_penalty likewise.
RECOMMENDED_SAMPLING_NONTHINKING = dict(
temperature=1.0, top_p=1.0, top_k=20, min_p=0.0,
repetition_penalty=1.0, # presence_penalty=2.0 not directly supported in HF generate
)
RECOMMENDED_SAMPLING_THINKING = dict(
temperature=1.0, top_p=0.95, top_k=20, min_p=0.0,
repetition_penalty=1.0,
)
def _generate_unconstrained(
self,
prompt_str: str,
max_new_tokens: int,
temperature: float,
sampling_preset: Optional[str] = None,
) -> tuple[str, int, int]:
"""Plain HF generation; returns (decoded, n_in, n_out).
sampling_preset:
None — greedy (or sampled at given temperature), default top_p/top_k
"recommended" — apply Qwen3.5 paper's recommended params for current mode
"""
inputs = self.tokenizer(prompt_str, return_tensors="pt").to(self.device)
n_in = inputs["input_ids"].shape[1]
gen_kwargs = dict(
max_new_tokens=max_new_tokens,
pad_token_id=self.tokenizer.eos_token_id,
)
if sampling_preset == "recommended":
preset = (
self.RECOMMENDED_SAMPLING_THINKING
if self.enable_thinking else self.RECOMMENDED_SAMPLING_NONTHINKING
)
gen_kwargs.update(preset)
gen_kwargs["do_sample"] = True
else:
gen_kwargs["do_sample"] = (temperature > 0)
gen_kwargs["temperature"] = temperature if temperature > 0 else 1.0
with torch.no_grad():
out = self.model.generate(**inputs, **gen_kwargs)
# Strip the prompt to keep only newly generated tokens
new_tokens = out[0, n_in:]
n_out = int(new_tokens.shape[0])
text = self.tokenizer.decode(new_tokens, skip_special_tokens=True)
return text, n_in, n_out
# ── public modes ─────────────────────────────────────────────────────
def generate_free(
self, caption: str, max_new_tokens: int = 256, temperature: float = 0.0,
sampling_preset: Optional[str] = None,
) -> GenResult:
prompt = self._build_chat(SYSTEM_PROMPT_FREE, caption)
text, n_in, n_out = self._generate_unconstrained(
prompt, max_new_tokens, temperature, sampling_preset
)
return GenResult("free", text, "transformers", n_in, n_out)
def generate_json_mode(
self, caption: str, max_new_tokens: int = 256, temperature: float = 0.0,
sampling_preset: Optional[str] = None,
) -> GenResult:
prompt = self._build_chat(SYSTEM_PROMPT_JSON, caption)
text, n_in, n_out = self._generate_unconstrained(
prompt, max_new_tokens, temperature, sampling_preset
)
return GenResult("json_mode", text, "transformers", n_in, n_out)
def generate_constrained(
self,
caption: str,
grammar_gbnf: Optional[str] = None,
json_schema: Optional[dict] = None,
max_new_tokens: int = 256,
temperature: float = 0.0,
sampling_preset: Optional[str] = None,
) -> GenResult:
"""
Grammar-constrained decoding. Prefers xgrammar (fastest), falls back to outlines,
then to plain json_mode with a warning.
Provide EITHER grammar_gbnf (xgrammar path) OR json_schema (outlines path).
If both are provided, xgrammar wins when available.
"""
prompt = self._build_chat(SYSTEM_PROMPT_JSON, caption)
# xgrammar path
if self._xgr_compiler is not None and grammar_gbnf is not None:
return self._generate_xgrammar(
prompt, grammar_gbnf, max_new_tokens, temperature, sampling_preset
)
# outlines path — keep as fallback; install instructions in dependencies.txt
if _HAS_OUTLINES and json_schema is not None:
warnings.warn("outlines path not yet implemented; falling back to json_mode")
# final fallback
warnings.warn(
"No constrained-decoding backend active; falling back to json_mode. "
"Install xgrammar for true grammar-constrained generation."
)
text, n_in, n_out = self._generate_unconstrained(
prompt, max_new_tokens, temperature, sampling_preset
)
return GenResult("constrained_fallback", text, "transformers", n_in, n_out)
def _generate_xgrammar(
self, prompt_str: str, grammar_gbnf: str, max_new_tokens: int,
temperature: float, sampling_preset: Optional[str] = None,
) -> GenResult:
"""xgrammar-backed constrained generation.
Uses a hand-rolled LogitsProcessor instead of `xgr.contrib.hf.LogitsProcessor`
because the latter passes a tensor scalar to `matcher.accept_token`, which
the current xgrammar tvm-ffi binding rejects (it requires a Python int).
Calling `.item()` on the token id, as every official xgrammar tutorial does,
sidesteps the bug.
"""
compiled = self._xgr_compiler.compile_grammar(grammar_gbnf)
inputs = self.tokenizer(prompt_str, return_tensors="pt").to(self.device)
n_in = inputs["input_ids"].shape[1]
logits_processor = _XGrammarLogitsProcessor(
compiled_grammar=compiled,
vocab_size=self._xgr_tokenizer_info.vocab_size,
prompt_len=n_in,
)
gen_kwargs = dict(
max_new_tokens=max_new_tokens,
pad_token_id=self.tokenizer.eos_token_id,
logits_processor=[logits_processor],
)
if sampling_preset == "recommended":
preset = (
self.RECOMMENDED_SAMPLING_THINKING
if self.enable_thinking else self.RECOMMENDED_SAMPLING_NONTHINKING
)
gen_kwargs.update(preset)
gen_kwargs["do_sample"] = True
else:
gen_kwargs["do_sample"] = (temperature > 0)
gen_kwargs["temperature"] = temperature if temperature > 0 else 1.0
with torch.no_grad():
out = self.model.generate(**inputs, **gen_kwargs)
new_tokens = out[0, n_in:]
n_out = int(new_tokens.shape[0])
text = self.tokenizer.decode(new_tokens, skip_special_tokens=True)
return GenResult("constrained", text, "xgrammar", n_in, n_out)
# ──────────────────────────────────────────────────────────────────────────────
# Custom xgrammar LogitsProcessor.
#
# Replaces the broken `xgr.contrib.hf.LogitsProcessor` (it passes a tensor scalar
# to `accept_token`, which the current tvm-ffi binding rejects with
# "Expected int but got ffi.Tensor"). We track previously-accepted positions and
# convert every token to a plain int via `.item()` before passing it to xgrammar.
# ──────────────────────────────────────────────────────────────────────────────
class _XGrammarLogitsProcessor:
"""Constrains HF `generate` output to a compiled xgrammar grammar."""
def __init__(self, compiled_grammar, vocab_size: int, prompt_len: int):
if not _HAS_XGRAMMAR: # pragma: no cover
raise RuntimeError("xgrammar is not installed")
self.matcher = xgr.GrammarMatcher(compiled_grammar)
# bitmask must be int32 CPU per xgrammar docs; we move to logits.device
# on apply.
self.bitmask = xgr.allocate_token_bitmask(1, vocab_size)
self.prompt_len = prompt_len
self.accepted_up_to = prompt_len # next position to accept from
def __call__(self, input_ids, scores):
# input_ids: (batch=1, cur_len) scores: (batch=1, vocab_size)
cur_len = int(input_ids.shape[1])
# Accept every token generated since we last ran. On the first call
# cur_len == prompt_len, so this loop is a no-op.
for pos in range(self.accepted_up_to, cur_len):
tok = int(input_ids[0, pos].item()) # ← the critical .item() fix
ok = self.matcher.accept_token(tok)
if not ok: # pragma: no cover — shouldn't happen with constrained sampling
break
self.accepted_up_to = cur_len
if self.matcher.is_terminated():
return scores
# Fill bitmask and apply to current-step logits.
self.matcher.fill_next_token_bitmask(self.bitmask)
xgr.apply_token_bitmask_inplace(scores, self.bitmask.to(scores.device))
return scores