Spaces:
Running on Zero
Running on Zero
File size: 15,302 Bytes
fed954e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | """
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
|