Lie24's picture
StartLux-Decision-27B-Q8_0-GGUF
a54b311
Raw History Blame Contribute Delete
15.4 kB
"""StartLux-Decision inference.
Each question is rendered as one prompt (startlux_decision/jevfmt.py), the model runs one forward pass, and the
answer is read from the next-token logits of the option letters at the last prompt position, divided by the
temperature of the question type and normalised over the listed options. Nothing is generated.
All questions of one request run in one forward pass, one row per question. On CUDA the pass is a recorded graph:
one per padded input length (128 ... 4096 tokens) for a single question, and one per (question count, length) for two
to four questions of up to 1024 tokens each. Replaying a graph removes the per-layer launch overhead (the approach of
the JevK5 runtime, Apache-2.0). Inputs are right-padded; every layer is causal and rows never mix, so padding after the
last prompt token never reaches the position that is read. Other requests run eagerly as one padded batch (inputs
padded to one of PAD_LENGTHS); decide_batch() batches questions across many requests.
"""
import importlib
import json
import os
import warnings
import torch
from . import jevfmt as J
__all__ = ["StartLuxDecision", "load_model", "fast_kernels_active"]
GRAPH_LENGTHS = (128, 192, 256, 320, 384, 512, 640, 768, 1024, 1536, 2048, 3072, 4096)
GRAPH_ROWS = (1, 2, 3, 4) # questions per request replayed as one graph
MULTI_ROW_MAX_LENGTH = 1024 # longest prompt for the multi-question graphs (rows x length <= 4096 tokens)
# Batched inputs are padded to one of these lengths. The linear-attention kernels are compiled once per sequence length
# (about a second each), so padding to exact lengths would recompile for almost every batch. Above 256 tokens a step
# adds at most 25% padding.
PAD_LENGTHS = (128, 192, 256, 320, 384, 448, 512, 640, 768, 896, 1024, 1280, 1536, 1792, 2048, 2560, 3072, 3584, 4096,
5120, 6144, 7168, 8192, 10240, 12288, 14336, 16384, 20480, 24576, 28672, 32768, 40960, 49152, 57344, 65536)
def padded_length(n):
"""The length a batch whose longest input has n tokens is padded to."""
for length in PAD_LENGTHS:
if n <= length:
return length
return -(-n // 8192) * 8192
def fast_kernels_active(path):
"""True when transformers will use the fla / causal-conv1d kernels for the linear-attention layers of the model
in `path`."""
from transformers import AutoConfig
kind = AutoConfig.from_pretrained(path).model_type
try:
modeling = importlib.import_module(f"transformers.models.{kind}.modeling_{kind}")
except ImportError:
return False
return bool(getattr(modeling, "is_fast_path_available", False))
def load_model(path, device):
"""The checkpoint's own transformers class in bf16 on `device`, and its text decoder (the stack without the
output head; checkpoints that also carry other towers keep the text decoder under .language_model)."""
import transformers
arch = transformers.AutoConfig.from_pretrained(path).architectures[0]
model = getattr(transformers, arch).from_pretrained(path, dtype=torch.bfloat16, device_map={"": device})
return model, getattr(model.model, "language_model", model.model)
class StartLuxDecision:
"""decide(state, questions) -> (answers, usage), answers in the TypeSafe /v1/systemone format."""
def __init__(self, path, device=None, max_length=65536, max_batch_tokens=65536, graphs=True):
from transformers import AutoTokenizer
if not os.path.isdir(path):
raise FileNotFoundError(f"{path}: expected a local StartLux-Decision directory (weights are shared separately)")
cfg = json.load(open(os.path.join(path, "decision_config.json")))
self.tok = AutoTokenizer.from_pretrained(path)
self.letters = J.check_tokenizer(self.tok)
if self.letters != cfg["letter_token_ids"]:
raise ValueError("tokenizer letter ids differ from decision_config.json")
self.temperature = {k: float(v) for k, v in cfg["temperature_by_type"].items()}
wide = cfg.get("wide_choice", {})
self.group, self.keep, self.residual = wide.get("group", 25), wide.get("keep", 3), wide.get("residual", 1e-3)
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
self.fast_kernels = fast_kernels_active(path)
if self.device.type == "cuda" and not self.fast_kernels:
msg = ("flash-linear-attention and causal-conv1d are not active, so transformers would run the plain torch "
"path of the linear-attention layers (>10x slower). pip install flash-linear-attention causal-conv1d, "
"or set STARTLUX_ALLOW_SLOW=1 to run anyway.")
if os.environ.get("STARTLUX_ALLOW_SLOW") != "1":
raise RuntimeError(msg)
warnings.warn(msg)
model, body = load_model(path, self.device)
self.body = body.eval()
head = model.get_output_embeddings().weight
self.letter_rows = head.index_select(0, torch.tensor(self.letters, device=head.device)).float()
del model # only the decoder and the letter rows of the output head are used
self.pad = self.tok.pad_token_id
self.max_length, self.max_batch_tokens = int(max_length), int(max_batch_tokens)
self.graphs = {}
if graphs and self.device.type == "cuda" and os.environ.get("STARTLUX_GRAPHS", "1") != "0":
self._capture()
# ---- CUDA-graph path (the questions of one request as rows, right-padded, no attention mask)
def _slot_logits(self, ids, last):
with torch.autocast(self.device.type, dtype=torch.bfloat16):
hidden = self.body(input_ids=ids, use_cache=False, return_dict=True).last_hidden_state
h = hidden[torch.arange(ids.shape[0], device=ids.device), last].float()
return h @ self.letter_rows.T
@torch.inference_mode()
def _capture(self):
shapes = [(b, n) for b in GRAPH_ROWS for n in GRAPH_LENGTHS if b == 1 or n <= MULTI_ROW_MAX_LENGTH]
pool = None # one memory pool for every graph, sized by the largest
for b, n in sorted(shapes, key=lambda s: (-s[0] * s[1], -s[1])):
ids = torch.full((b, n), self.pad, dtype=torch.long, device=self.device)
last = torch.full((b,), n - 1, dtype=torch.long, device=self.device)
stream = torch.cuda.Stream(device=self.device)
stream.wait_stream(torch.cuda.current_stream(self.device))
with torch.cuda.stream(stream):
for _ in range(3): # autotune and warm every kernel outside the capture
self._slot_logits(ids, last)
torch.cuda.current_stream(self.device).wait_stream(stream)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool=pool):
out = self._slot_logits(ids, last)
pool = graph.pool()
self.graphs[(b, n)] = (graph, ids, last, out)
def _graph_length(self, enc):
"""The recorded length for these rows, or None when no graph fits."""
longest = max(len(t) for _, t, _ in enc)
fits = [n for b, n in self.graphs if b == len(enc) and n >= longest]
return min(fits) if fits else None
@torch.inference_mode()
def _graph_logits(self, enc, n):
graph, static_ids, last, out = self.graphs[(len(enc), n)]
ids = torch.full((len(enc), n), self.pad, dtype=torch.long)
for j, (_, t, _) in enumerate(enc):
ids[j, :len(t)] = torch.tensor(t)
static_ids.copy_(ids)
last.copy_(torch.tensor([len(t) - 1 for _, t, _ in enc]))
graph.replay()
res = out.cpu()
return [res[j, :c].clone() for j, (_, _, c) in enumerate(enc)]
# ---- readout
@torch.no_grad()
def _logits(self, rows):
"""rows: rendered records (<= 26 options) -> (letter logits per row in option order, prompt tokens)."""
enc = []
for i, r in enumerate(rows):
order = [o["id"] for o in r["options"]]
ids, _ = J.render_ids(r, self.tok, order, max_length=self.max_length)
enc.append((i, ids, len(order)))
out, tokens = [None] * len(rows), 0
n = self._graph_length(enc) if self.graphs and enc else None
if n is not None: # every question of the request in one replay
for (i, t, _), z in zip(enc, self._graph_logits(enc, n)):
out[i] = z
tokens += len(t)
return out, tokens
enc.sort(key=lambda x: -len(x[1]))
start = 0
while start < len(enc):
longest = padded_length(len(enc[start][1]))
n = max(1, min(len(enc) - start, self.max_batch_tokens // longest))
batch = enc[start:start + n]
start += n
ids = torch.full((len(batch), longest), self.pad, dtype=torch.long)
attn = torch.zeros_like(ids)
for j, (_, t, _) in enumerate(batch):
ids[j, :len(t)] = torch.tensor(t)
attn[j, :len(t)] = 1
tokens += len(t)
ids, attn = ids.to(self.device), attn.to(self.device)
with torch.autocast(self.device.type, dtype=torch.bfloat16):
hidden = self.body(input_ids=ids, attention_mask=attn, use_cache=False, return_dict=True).last_hidden_state
pos = attn.sum(1) - 1
h = hidden[torch.arange(len(batch), device=self.device), pos].float()
logits = h @ self.letter_rows.T
for j, (i, _, c) in enumerate(batch):
out[i] = logits[j, :c].cpu()
return out, tokens
def _probs(self, rows):
logits, tokens = self._logits(rows)
return [torch.softmax(z / self.temperature.get(r["type"], 1.0), -1).tolist() for r, z in zip(rows, logits)], tokens
def self_test(self, state, questions):
"""Largest |p_graph - p_eager| over the questions of one request (0.0 when no graphs were recorded)."""
if not self.graphs:
return 0.0
rows = [J.from_systemone(state, q) for q in questions.values()]
graphed, _ = self._probs(rows)
graphs, self.graphs = self.graphs, {}
try:
eager, _ = self._probs(rows)
finally:
self.graphs = graphs
return max(abs(a - b) for pg, pe in zip(graphed, eager) for a, b in zip(pg, pe))
def _wide(self, state, spec):
"""Choice lists over 26 options: near-equal groups, the top `keep` of each group go to a final round."""
keys = list(spec["criteria"])
n_groups = -(-len(keys) // self.group)
size, extra = divmod(len(keys), n_groups)
groups, start = [], 0
for g in range(n_groups):
end = start + size + (1 if g < extra else 0)
groups.append(keys[start:end])
start = end
rows = [J.from_systemone(state, dict(spec, criteria={k: spec["criteria"][k] for k in g})) for g in groups]
first, tokens = self._probs(rows)
first_p = {k: p for g, ps in zip(groups, first) for k, p in zip(g, ps)}
finalists = [k for g, ps in zip(groups, first) for k, _ in sorted(zip(g, ps), key=lambda x: -x[1])[:self.keep]]
fin_spec = dict(spec, criteria={k: spec["criteria"][k] for k in finalists})
if len(finalists) > J.MAX_OPTIONS:
final_p, more = self._wide(state, fin_spec)
else:
ps, more = self._probs([J.from_systemone(state, fin_spec)])
final_p = dict(zip(finalists, ps[0]))
rest = [k for k in keys if k not in set(finalists)]
mass = sum(first_p[k] for k in rest) or 1.0
probs = {k: final_p[k] * (1 - self.residual) for k in finalists}
probs.update({k: self.residual * first_p[k] / mass for k in rest})
z = sum(probs.values())
return {k: v / z for k, v in probs.items()}, tokens + more
# ---- public API
@staticmethod
def _answer(row, p, question):
ids = [o["id"] for o in row["options"]]
if row["type"] == "noul":
return {"type": "noul", "noul": p[ids.index("true")]}
if row["type"] == "score":
levels = question.get("criteria") or []
levels = list(levels.values()) if isinstance(levels, dict) else list(levels)
return {"type": "score", "score": sum(i * v for i, v in enumerate(p)), "confidence": max(p),
"legend": {str(i): (levels[i] if i < len(levels) else str(i)) for i in range(len(p))},
"probabilities": {str(i): v for i, v in enumerate(p)}}
dist = dict(zip(ids, p))
best = max(dist, key=dist.get)
return {"type": "choice", "choice": best, "confidence": dist[best], "probabilities": dist}
def _split(self, state, questions):
"""-> (answers decided without the model, [(key, rendered row)], tokens spent on wide lists)"""
answers, rows, tokens = {}, [], 0
for k, q in questions.items():
t = q.get("type", "choice")
crit = q.get("criteria") or {}
if t == "choice" and isinstance(crit, dict) and len(crit) == 1:
only = next(iter(crit))
answers[k] = {"type": "choice", "choice": only, "confidence": 1.0, "probabilities": {only: 1.0}}
elif t == "choice" and isinstance(crit, dict) and len(crit) > J.MAX_OPTIONS:
p, n = self._wide(state, q)
tokens += n
best = max(p, key=p.get)
answers[k] = {"type": "choice", "choice": best, "confidence": p[best], "probabilities": p}
else:
rows.append((k, J.from_systemone(state, q)))
return answers, rows, tokens
def decide(self, state, questions):
"""One request -> (answers, usage), answers in the TypeSafe /v1/systemone format."""
answers, rows, tokens = self._split(state, questions)
if rows:
probs, n = self._probs([r for _, r in rows])
tokens += n
for (k, row), p in zip(rows, probs):
answers[k] = self._answer(row, p, questions[k])
return answers, {"input_tokens": tokens, "output_tokens": 0}
def decide_batch(self, requests):
"""[(state, questions), ...] -> [answers, ...]. Every question of every request goes through one length-sorted
set of padded forward passes (up to max_batch_tokens each), which is much faster than calling decide() in a loop
when the requests are short. Answers are the same as decide() up to bf16 rounding."""
parts, flat = [], []
for state, questions in requests:
answers, rows, _ = self._split(state, questions)
parts.append((answers, rows, questions))
flat.extend(r for _, r in rows)
graphs, self.graphs = self.graphs, {} # batched eager path; graphs are sized for one request
try:
probs, _ = self._probs(flat) if flat else ([], 0)
finally:
self.graphs = graphs
out, i = [], 0
for answers, rows, questions in parts:
for k, row in rows:
answers[k] = self._answer(row, probs[i], questions[k])
i += 1
out.append(answers)
return out