Qwen-2.5-1B-RLCD / core /engine.py
Shreyansh Singh
Migrate to CUDA and add tree attention masking
217e8f8
Raw History Blame Contribute Delete
17.4 kB
"""Transformers inference with autoregressive decoding and batched field decisions."""
import copy
import json
import os
import threading
import time
from functools import wraps
from typing import Any, Dict, Generator
import torch
from torch.nn.attention.flex_attention import create_block_mask
from transformers import AutoModelForCausalLM, AutoTokenizer
from core.prompt_builder import build_naive_json_prompt
from core.schema import StructuredSchema
MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-1.5B-Instruct")
_model = None
_tokenizer = None
_gpu_lock = threading.Lock()
_load_lock = threading.Lock()
def gpu_locked(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
with _gpu_lock, torch.inference_mode():
return fn(*args, **kwargs)
return wrapper
def gpu_locked_gen(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
# Inference mode is thread-local. Starlette can resume a sync iterator on
# another worker, so enter it for each next(), never across a yield.
iterator = fn(*args, **kwargs)
_gpu_lock.acquire()
try:
while True:
with torch.inference_mode():
try:
event = next(iterator)
except StopIteration:
return
yield event
finally:
iterator.close()
_gpu_lock.release()
return wrapper
def get_engine():
with _load_lock, torch.inference_mode():
return _load_engine()
def _load_engine():
global _model, _tokenizer
if _model is None:
device = torch.device(os.environ.get("DEVICE", "cuda" if torch.cuda.is_available() else "cpu"))
dtype = torch.float32
if device.type == "cuda":
with torch.cuda.device(device):
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
print(f"Loading {MODEL_ID} on {device} ({dtype})...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=dtype).to(device).eval()
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
warmup = tokenizer("Warmup", return_tensors="pt").to(device)
model(**warmup, use_cache=True, logits_to_keep=1)
_synchronize(model)
_model, _tokenizer = model, tokenizer
print("Engine ready.")
return _model, _tokenizer
def _synchronize(model):
if model.device.type == "cuda":
torch.cuda.synchronize(model.device)
def _naive_result(text, schema, token_count, passes, elapsed_ms):
parsed = None
error = None
try:
parsed = json.loads(text.strip())
except ValueError as exc:
error = str(exc)
valid = error is None
missing, invalid = [], []
if isinstance(parsed, dict):
for name, field in schema.fields.items():
if name not in parsed:
missing.append(name)
elif (field.field_type == "boolean" and not isinstance(parsed[name], bool)) or (
field.field_type != "boolean" and parsed[name] not in field.choices
):
invalid.append(f"{name}={parsed[name]}")
return {
"mode": "naive_autoregressive", "elapsed_ms": round(elapsed_ms, 2),
"total_tokens": token_count,
"tokens_per_second": round(token_count * 1000 / max(elapsed_ms, 1e-6), 1),
"sequential_forward_passes": passes, "is_valid_json": valid,
"schema_match": isinstance(parsed, dict) and set(parsed) == set(schema.fields) and not invalid,
"raw_text": text, "parsed_json": parsed, "parse_error": error,
"missing_keys": missing, "invalid_enums": invalid,
"has_calibrated_probabilities": False,
}
@gpu_locked_gen
def stream_naive_generation(context: str, schema: StructuredSchema, max_tokens: int = 700,
temperature: float = 0.2) -> Generator[Dict[str, Any], None, None]:
if max_tokens < 1 or temperature < 0:
raise ValueError("max_tokens must be positive and temperature nonnegative")
model, tokenizer = get_engine()
inputs = tokenizer(build_naive_json_prompt(context, schema), add_special_tokens=False,
return_tensors="pt").to(model.device)
t0 = time.perf_counter()
cache = None
generated = []
current_text = "{\n "
eos = model.generation_config.eos_token_id
stop_tokens = set(eos if isinstance(eos, list) else [eos]) | {tokenizer.eos_token_id}
passes = 0
emitted = False
for _ in range(max_tokens):
output = model(**inputs, past_key_values=cache, use_cache=True, logits_to_keep=1)
cache = output.past_key_values
passes += 1
logits = output.logits[0, -1].float()
token = int(torch.argmax(logits) if temperature == 0 else
torch.multinomial(torch.softmax(logits / temperature, dim=-1), 1)[0])
if token in stop_tokens:
break
generated.append(token)
text = "{\n " + tokenizer.decode(generated, skip_special_tokens=True,
clean_up_tokenization_spaces=False)
# Wait for complete UTF-8 characters before emitting a text delta.
if not text.endswith("\ufffd"):
delta = text[len(current_text):] if emitted else text
emitted = True
current_text = text
yield {"type": "token", "token": delta, "accumulated": text,
"token_count": len(generated),
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 1)}
try:
if isinstance(json.loads(text), dict):
break
except ValueError:
pass
inputs = {"input_ids": torch.tensor([[token]], device=model.device),
"attention_mask": torch.cat([inputs["attention_mask"],
torch.ones((1, 1), dtype=torch.long, device=model.device)], dim=1)}
_synchronize(model)
yield {"type": "done", "result": _naive_result(
current_text, schema, len(generated), passes, (time.perf_counter() - t0) * 1000)}
def run_naive_generation(context: str, schema: StructuredSchema, max_tokens: int = 700,
temperature: float = 0.2) -> Dict[str, Any]:
for event in stream_naive_generation(context, schema, max_tokens, temperature):
if event["type"] == "done":
return event["result"]
def _candidate_scores(model, cache, first_logits, candidates, pad_id):
"""Score complete allowed continuations, including their closing delimiter."""
first_log_probs = first_logits.float().log_softmax(-1)
scores = first_log_probs[[tokens[0] for tokens in candidates]].clone()
passes = 0
# Bound memory when a field has hundreds of multi-token choices.
for start in range(0, len(candidates), 16):
sequences = candidates[start:start + 16]
width = max(map(len, sequences)) - 1
if width == 0:
continue
batch = len(sequences)
ids = torch.full((batch, width), pad_id, dtype=torch.long, device=model.device)
mask = torch.zeros_like(ids)
for row, tokens in enumerate(sequences):
ids[row, :len(tokens) - 1] = torch.tensor(tokens[:-1], device=model.device)
mask[row, :len(tokens) - 1] = 1
branch = copy.deepcopy(cache)
branch.batch_repeat_interleave(batch)
attention = torch.cat([torch.ones((batch, cache.get_seq_length()),
dtype=torch.long, device=model.device), mask], dim=1)
output = model(input_ids=ids, attention_mask=attention,
past_key_values=branch, use_cache=True)
passes += 1
log_probs = output.logits.float().log_softmax(-1)
for row, tokens in enumerate(sequences):
if len(tokens) > 1:
scores[start + row] += log_probs[row, torch.arange(len(tokens) - 1,
device=model.device), tokens[1:]].sum()
del output, branch, log_probs
return scores, passes
@gpu_locked
def run_parallel_generation(context: str, schema: StructuredSchema,
temperature: float = 1.0) -> Dict[str, Any]:
if temperature < 0:
raise ValueError("temperature must be nonnegative")
model, tokenizer = get_engine()
t0 = time.perf_counter()
meta = schema.compile_parallel_metadata(tokenizer)
prompt = (f"<|im_start|>system\nClassify JSON attributes:\n{schema.to_parallel_schema_str()}"
f"<|im_end|>\n<|im_start|>user\n{context}<|im_end|>\n"
f"<|im_start|>assistant\n{{\n")
inputs = tokenizer(prompt, add_special_tokens=False, return_tensors="pt").to(model.device)
t_pre = time.perf_counter()
output = model(**inputs, use_cache=True, logits_to_keep=1)
cache = output.past_key_values
prefix_length = cache.get_seq_length()
_synchronize(model)
prefill_ms = (time.perf_counter() - t_pre) * 1000
del output
cache.batch_repeat_interleave(len(schema))
suffixes = meta["suffixes_batch"].to(model.device)
suffix_mask = meta["suffix_attention_mask"].to(model.device)
attention = torch.cat([inputs["attention_mask"].repeat(len(schema), 1), suffix_mask], dim=1)
t_suffix = time.perf_counter()
output = model(input_ids=suffixes, attention_mask=attention, past_key_values=cache, use_cache=True)
_synchronize(model)
suffix_ms = (time.perf_counter() - t_suffix) * 1000
parsed, telemetry = {}, {}
passes = 2 # Prefix prefill plus batched field suffixes.
for i, (name, field) in enumerate(meta["field_items"]):
length = meta["suffix_lengths"][i]
logits = output.logits[i, length - 1]
candidates = meta["candidate_sequences"][i]
if meta["has_collisions"][i]:
branch = copy.deepcopy(cache)
branch.batch_select_indices(torch.tensor([i], device=model.device))
# Remove right padding before evaluating candidate continuations.
padding = branch.get_seq_length() - prefix_length - length
if padding:
branch.crop(-padding)
scores, extra_passes = _candidate_scores(model, branch, logits, candidates, tokenizer.pad_token_id)
passes += extra_passes
del branch
else:
scores = logits[[tokens[0] for tokens in candidates]].float()
probabilities = torch.softmax(scores / max(temperature, 1e-4), dim=-1).tolist()
winner = max(range(len(probabilities)), key=probabilities.__getitem__)
choice = field.choices[winner]
value = choice == "true" if field.field_type == "boolean" else choice
confidence = round(probabilities[winner], 4)
parsed[name] = {"value": value, "prob": confidence}
top = sorted([{"choice": c, "probability": round(p, 4)}
for c, p in zip(field.choices, probabilities)],
key=lambda item: item["probability"], reverse=True)
telemetry[name] = {"value": value, "type": field.field_type, "confidence": confidence,
"cardinality": field.cardinality, "top_choices": top[:5]}
_synchronize(model)
return {"mode": "parallel_constrained_calibrated", "elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"prefill_ms": round(prefill_ms, 2), "suffix_eval_ms": round(suffix_ms, 2),
"total_tokens_generated": 0, "sequential_forward_passes": passes,
"is_valid_json": True, "schema_match": True, "parsed_json": parsed,
"field_telemetry": telemetry, "has_calibrated_probabilities": True, "num_fields": len(schema)}
@torch.compile(fullgraph=True)
def _tree_attention_mask(starts, ends, prefix_length):
"""Compile the mask predicate into block metadata without a dense token mask."""
size = starts.shape[0]
limits = torch.tensor([size - 1, prefix_length, prefix_length + size], device=starts.device)
def visible(batch, head, query, key):
# FlexAttention can evaluate padded block indices. Clamp all lookups;
# the validity checks below keep those positions masked out.
q = torch.minimum(query.clamp_min(0), limits[0])
k = torch.minimum((key - limits[1]).clamp_min(0), limits[0])
ancestor = (starts[k] <= starts[q]) & (starts[q] < ends[k])
return ((query <= limits[0]) & (key < limits[2])
& ((key < limits[1]) | ancestor))
return create_block_mask(visible, B=None, H=None, Q_LEN=size,
KV_LEN=prefix_length + size, device=starts.device)
@gpu_locked
def run_parallel_generation_tree(context: str, schema: StructuredSchema,
temperature: float = 1.0) -> Dict[str, Any]:
"""Score all complete candidates in one tree pass over a single prefix cache.
Uses a FlexAttention BlockMask with DFS intervals for ancestor visibility.
Scores are mean token log-probabilities over the full candidate, including
shared value prefixes and closing delimiters. The model's original
attention backend is restored afterward.
"""
if temperature < 0:
raise ValueError("temperature must be nonnegative")
model, tokenizer = get_engine()
if "sliding_attention" in getattr(model.config, "layer_types", []):
raise ValueError("Tree generation requires full attention, not a sliding-window cache")
t0 = time.perf_counter()
meta = schema.compile_tree_metadata(tokenizer)
prompt = (f"<|im_start|>system\nClassify JSON attributes:\n{schema.to_parallel_schema_str()}"
f"<|im_end|>\n<|im_start|>user\n{context}<|im_end|>\n"
f"<|im_start|>assistant\n{{\n")
inputs = tokenizer(prompt, add_special_tokens=False, return_tensors="pt").to(model.device)
t_pre = time.perf_counter()
output = model(**inputs, use_cache=True, logits_to_keep=1)
cache = output.past_key_values
prefix_length = cache.get_seq_length()
_synchronize(model)
prefill_ms = (time.perf_counter() - t_pre) * 1000
del output
t_suffix = time.perf_counter()
ids = meta["input_ids"].to(model.device)
positions = meta["depths"].to(model.device) + prefix_length
size = ids.shape[1]
attention = _tree_attention_mask(meta["dfs_starts"].to(model.device),
meta["dfs_ends"].to(model.device), prefix_length)
original_backend = model.config._attn_implementation
try:
model.set_attn_implementation("flex_attention")
# The short-query GQA decode kernel can pack more queries than fit in
# a BlockMask tile. Use the general FlexAttention kernel for the tree.
output = model(input_ids=ids, attention_mask=attention, position_ids=positions,
past_key_values=cache, use_cache=True,
logits_to_keep=meta["logit_indices"].to(model.device),
kernel_options={"FORCE_USE_FLEX_ATTENTION": True})
finally:
model.set_attn_implementation(original_backend)
_synchronize(model)
suffix_ms = (time.perf_counter() - t_suffix) * 1000
# Normalize only the parent logits needed to score candidate edges, without
# allocating another full [nodes, vocabulary] log-probability tensor.
logits = output.logits[0].float()
rows = meta["score_rows"].to(model.device)
tokens = meta["score_tokens"].to(model.device)
edge_scores = logits[rows, tokens] - logits.logsumexp(-1)[rows]
scores = logits.new_zeros(sum(meta["candidate_counts"]))
scores.scatter_add_(0, meta["score_candidates"].to(model.device), edge_scores)
scores /= meta["candidate_lengths"].to(model.device)
del output, cache, logits, attention
parsed, telemetry = {}, {}
for (name, field), field_scores in zip(meta["field_items"], scores.split(meta["candidate_counts"])):
probabilities = torch.softmax(field_scores / max(temperature, 1e-4), dim=-1).tolist()
winner = max(range(len(probabilities)), key=probabilities.__getitem__)
choice = field.choices[winner]
value = choice == "true" if field.field_type == "boolean" else choice
confidence = round(probabilities[winner], 4)
parsed[name] = {"value": value, "prob": confidence}
top = sorted([{"choice": c, "probability": round(p, 4)}
for c, p in zip(field.choices, probabilities)],
key=lambda item: item["probability"], reverse=True)
telemetry[name] = {"value": value, "type": field.field_type, "confidence": confidence,
"cardinality": field.cardinality, "top_choices": top[:5]}
_synchronize(model)
return {"mode": "parallel_constrained_tree", "elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"prefill_ms": round(prefill_ms, 2), "suffix_eval_ms": round(suffix_ms, 2),
"total_tokens_generated": 0, "sequential_forward_passes": 2,
"is_valid_json": True, "schema_match": True, "parsed_json": parsed,
"field_telemetry": telemetry, "has_calibrated_probabilities": True, "num_fields": len(schema),
"tree_nodes": size, "prefix_tokens": prefix_length,
"scoring": "mean_token_log_probability"}
run_rlcd_generation = run_parallel_generation