DEG-5B-E2B-1018 / source /src /deg /bench /systems /constrained.py
gcoderw's picture
Rename package to deg (DEG_ env vars, deg.json); note upcoming modalities
16d760d verified
Raw History Blame Contribute Delete
13.7 kB
"""Same-model baseline: the base Qwen3.5-0.8B (the instruct/chat release deg starts from,
NOT deg's weights) writes the whole JSON object under xgrammar JSON-schema-constrained
greedy decoding (HF transformers generate + xgrammar logits processor), batch 1.
Prompt: chat template, thinking off, :data:`deg.bench.systems.INSTRUCTION` as the system
message, schema + context as the user message. The grammar is compiled from the schema
(``x-generate`` dropped as an annotation) with standard ``", "`` / ``": "`` separators and no
other whitespace; ``compact=True`` uses ``","`` / ``":"`` (fewer tokens, but the 0.8B model
then often writes a lone ``","`` as a string value: an ablation, not the main arm). Grammar compilation is timed separately (``extra.compile_s``) and is not in
``latency_s``; generated tokens count every emitted token except the stop token.
"""
from __future__ import annotations
import copy
import json
import time
from pathlib import Path
from typing import Any
import torch
from deg.bench.score import Prediction
from deg.bench.systems import INSTRUCTION, System, user_message
MAX_NEW_TOKENS = 1536
def grammar_schema(schema: Any, drop: tuple[str, ...] = ("x-generate",)) -> Any:
def walk(node: Any) -> Any:
if isinstance(node, dict):
return {k: walk(v) for k, v in node.items() if k not in drop}
if isinstance(node, list):
return [walk(v) for v in node]
return node
return walk(copy.deepcopy(schema))
def stop_ids(tok: Any, model_dir: Path) -> list[int]:
"""End-of-turn ids: Qwen's ``<|im_end|>`` when the vocab has it, the tokenizer eos, and
every ``eos_token_id`` in ``generation_config.json`` (Gemma / LFM / MiniCPM turn ends)."""
stop: list[int] = []
im_end = tok.convert_tokens_to_ids("<|im_end|>")
if isinstance(im_end, int) and im_end != tok.unk_token_id:
stop.append(im_end)
cands: list[Any] = [tok.eos_token_id]
gc = model_dir / "generation_config.json"
if gc.exists():
e = json.loads(gc.read_text()).get("eos_token_id")
cands += e if isinstance(e, list) else [e]
for t in cands:
if isinstance(t, int) and t not in stop:
stop.append(t)
return stop
def native_context(model_dir: Path) -> int | None:
"""The model's own maximum context (``max_position_embeddings``, text config first)."""
cfg = json.loads((model_dir / "config.json").read_text())
for c in (cfg.get("text_config") or {}, cfg):
if c.get("max_position_embeddings"):
return int(c["max_position_embeddings"])
return None
def chat_prompt(tok: Any, rec: dict[str, Any]) -> str:
"""The model's own chat template, thinking off where the template supports it; a
template without a system role gets the instruction prepended to the user turn."""
user = user_message(rec["context"], rec["schema"])
try:
return tok.apply_chat_template(
[{"role": "system", "content": INSTRUCTION}, {"role": "user", "content": user}],
tokenize=False, add_generation_prompt=True, enable_thinking=False)
except Exception: # noqa: BLE001 - template rejects a system turn
return tok.apply_chat_template(
[{"role": "user", "content": INSTRUCTION + "\n\n" + user}],
tokenize=False, add_generation_prompt=True, enable_thinking=False)
class ConstrainedSystem(System):
def __init__(self, model_path: str | None, label: str | None = None,
compact: bool = False) -> None:
import xgrammar as xgr
from transformers import AutoModelForCausalLM, AutoTokenizer, Qwen3_5ForCausalLM
from deg.model import base_snapshot
path = Path(model_path) if model_path else base_snapshot()
self.tok = AutoTokenizer.from_pretrained(str(path))
model_type = json.loads((path / "config.json").read_text()).get("model_type", "")
cls = Qwen3_5ForCausalLM if model_type.startswith("qwen3_5") else AutoModelForCausalLM
self.lm = cls.from_pretrained(str(path), dtype=torch.bfloat16)
self.lm.to("cuda").eval()
vocab = self.lm.get_output_embeddings().weight.shape[0]
self.stop = stop_ids(self.tok, path)
self.native_ctx = native_context(path)
info = xgr.TokenizerInfo.from_huggingface(self.tok, vocab_size=vocab,
stop_token_ids=self.stop)
self.compiler = xgr.GrammarCompiler(info, max_threads=8)
self.xgr = xgr
self.separators = (",", ":") if compact else (", ", ": ")
self.name = label or ("qwen3.5-0.8b-xgrammar" + ("-compact" if compact else ""))
self.info = {"model": str(path),
"weights": ("base Qwen3.5-0.8B (chat release), not deg"
if model_path is None else f"{model_type} (as released)"),
"decoding": "greedy, xgrammar JSON-schema constrained",
"separators": list(self.separators), "native_context": self.native_ctx,
"dtype": "bf16", "batch": 1, "runtime": "HF transformers generate"}
self._warm()
def _warm(self) -> None:
rec = {"uid": "warm", "context": "Order AB-1 arrived late.",
"schema": {"type": "object", "properties": {"id": {"type": "string"}},
"required": ["id"]}}
self.predict(rec)
def prompt_ids(self, rec: dict[str, Any]) -> list[int]:
return self.tok(chat_prompt(self.tok, rec), add_special_tokens=False)["input_ids"]
def compile(self, schema: Any) -> tuple[Any, str]:
clean = grammar_schema(schema)
try:
return self.compiler.compile_json_schema(
json.dumps(clean), any_whitespace=False, separators=self.separators), "full"
except Exception: # noqa: BLE001 - fall back to a schema without string formats
loose = grammar_schema(clean, drop=("format", "pattern"))
return self.compiler.compile_json_schema(
json.dumps(loose), any_whitespace=False, separators=self.separators), "no-format"
@torch.no_grad()
def predict(self, rec: dict[str, Any]) -> Prediction:
from xgrammar.contrib.hf import LogitsProcessor
t0 = time.perf_counter()
try:
grammar, grammar_mode = self.compile(rec["schema"])
except Exception as exc: # noqa: BLE001 - record and score as a failure
return Prediction(rec["uid"], error=f"grammar: {str(exc)[:200]}")
compile_s = time.perf_counter() - t0
torch.cuda.synchronize()
t1 = time.perf_counter()
ids = self.prompt_ids(rec)
if self.native_ctx and len(ids) >= self.native_ctx:
# deterministic: the document does not fit the model's own context; scored 0
return Prediction(rec["uid"], error=f"context_exceeded: native {len(ids)} prompt "
f"tokens >= {self.native_ctx}",
prompt_tokens=len(ids))
inp = torch.tensor([ids], device="cuda")
out = self.lm.generate(inp, attention_mask=torch.ones_like(inp),
logits_processor=[LogitsProcessor(grammar)],
max_new_tokens=MAX_NEW_TOKENS, do_sample=False,
eos_token_id=self.stop, pad_token_id=self.stop[0])
torch.cuda.synchronize()
dt = time.perf_counter() - t1
new = out[0, len(ids):].tolist()
while new and new[-1] in self.stop:
new.pop()
text = self.tok.decode(new, skip_special_tokens=True)
extra = {"compile_s": round(compile_s, 4), "grammar": grammar_mode}
try:
value = json.loads(text)
except json.JSONDecodeError as exc:
hit_max = len(new) >= MAX_NEW_TOKENS - 1
return Prediction(rec["uid"], error=f"json: {exc}"[:200] + (" (max tokens)"
if hit_max else ""),
generated_tokens=len(new), prompt_tokens=len(ids), latency_s=dt,
extra={**extra, "text": text[-300:]})
return Prediction(rec["uid"], value=value, generated_tokens=len(new),
prompt_tokens=len(ids), latency_s=dt, extra=extra)
class LlamaCppConstrainedSystem(System):
"""The same baseline on llama.cpp: a GGUF model on a llama.cpp server (CUDA), the
model's own chat template (from its HF snapshot, ``model_path``; default the base
Qwen3.5-0.8B), greedy, with the server's JSON-schema -> GBNF grammar.
``max_new_tokens`` and :meth:`prompt` are hooks for subclasses (the extraction
specialists in :mod:`deg.bench.systems.extractors`).
Context: a prompt longer than the model's native context is ``context_exceeded:
native``; one that fits the model but not the server's ``n_ctx`` (sized to the GPU
memory left beside Clef) is ``context_exceeded: server`` (a harness limit, reported
apart). Both score 0."""
max_new_tokens = MAX_NEW_TOKENS
def __init__(self, url: str | None, label: str | None = None,
model_path: str | None = None, quant: str | None = None) -> None:
from transformers import AutoTokenizer
from deg.model import base_snapshot
self.url = (url or "http://127.0.0.1:8792").rstrip("/")
path = Path(model_path) if model_path else base_snapshot()
self.tok = AutoTokenizer.from_pretrained(str(path))
self.native_ctx = native_context(path)
self.server_ctx = self._server_ctx()
self.name = label or "qwen3.5-0.8b-llamacpp-gbnf"
self.info = {"model": (str(path) if model_path else
"Qwen3.5-0.8B bf16 GGUF (same base weights, not deg)"),
"quantization": quant, "native_context": self.native_ctx,
"server_n_ctx": self.server_ctx,
"decoding": "greedy, llama.cpp json_schema -> GBNF grammar",
"runtime": "llama.cpp server (CUDA, GPU0), batch 1"}
def _server_ctx(self) -> int | None:
import urllib.request
try:
with urllib.request.urlopen(self.url + "/props", timeout=30) as r:
props = json.loads(r.read())
return int(props["default_generation_settings"]["n_ctx"])
except Exception: # noqa: BLE001 - older servers: rely on the HTTP error instead
return None
def prompt(self, rec: dict[str, Any]) -> str:
return chat_prompt(self.tok, rec)
def predict(self, rec: dict[str, Any]) -> Prediction:
import urllib.error
import urllib.request
prompt = self.prompt(rec)
bos = self.tok.bos_token
if bos and prompt.startswith(bos): # llama.cpp adds BOS itself (GGUF add_bos_token)
prompt = prompt[len(bos):]
n = len(self.tok(prompt, add_special_tokens=False)["input_ids"])
if self.native_ctx and n >= self.native_ctx:
return Prediction(rec["uid"], error=f"context_exceeded: native {n} prompt tokens "
f">= {self.native_ctx}", prompt_tokens=n)
if self.server_ctx and n >= self.server_ctx:
return Prediction(rec["uid"], error=f"context_exceeded: server n_ctx {n} prompt "
f"tokens >= {self.server_ctx} (GPU memory)",
prompt_tokens=n)
last = ""
for mode, schema in (("full", grammar_schema(rec["schema"])),
("no-format", grammar_schema(rec["schema"],
drop=("x-generate", "format",
"pattern")))):
body = {"prompt": prompt, "n_predict": self.max_new_tokens, "temperature": 0.0,
"top_k": 1, "json_schema": schema, "cache_prompt": False}
req = urllib.request.Request(self.url + "/completion",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=3600) as r:
resp = json.loads(r.read())
except urllib.error.HTTPError as exc:
last = f"HTTP {exc.code}: {exc.read().decode(errors='replace')[:200]}"
if "context" in last.lower():
return Prediction(rec["uid"], error=f"context_exceeded: server {last}"[:240],
prompt_tokens=n)
continue
except (OSError, ValueError) as exc:
return Prediction(rec["uid"], error=f"server: {exc}"[:200])
dt = time.perf_counter() - t0
text = resp.get("content", "")
gen, ptok = resp.get("tokens_predicted"), resp.get("tokens_evaluated")
extra = {"grammar": mode, "server_timings": resp.get("timings")}
try:
value = json.loads(text)
except json.JSONDecodeError as exc:
return Prediction(rec["uid"], error=f"json: {exc}"[:200], generated_tokens=gen,
prompt_tokens=ptok, latency_s=dt,
extra={**extra, "text": text[-300:]})
return Prediction(rec["uid"], value=value, generated_tokens=gen, prompt_tokens=ptok,
latency_s=dt, extra=extra)
return Prediction(rec["uid"], error=f"grammar: {last}"[:240])