guychuk's picture
Serving-path speedup: state-KV LRU cache + suffix diet (see inference/bench_serve.py)
a9b9a8e verified
Raw History Blame Contribute Delete
8.83 kB
"""Typical: minimal from_pretrained + decide API around a native-readout PCDM checkpoint.
Mirrors pcdm_jev.decider.PCDMDecider(mode="native") exactly (same query_text/to_labels,
same native_kv_decide call) -- see that module's docstring for the mapping this
reproduces. Everything the checkpoint needs to rebuild backbone+head lives in
ckpt["args"]: backbone id, tap_layer, lora_r/lora_layers, nc_head, nc_render, null,
noul_head, score_head. z-score calibration (--zscore) needs no special handling here --
its buffers (mu_h/sd_h/mu_c/sd_c) are just part of ckpt["tower"]'s state_dict.
"""
import json
import time
from collections import OrderedDict
import torch
from huggingface_hub import hf_hub_download
from .backbone import Backbone, pick_device
from .native import DEFAULT_MAX_OPTION_TOKENS, NativeHead, encode_state, native_kv_decide
MAX_QUERY = 64 # trained query length (see pcdm_jev.decider) -- informational only below
MAX_STATE = 256 # trained state length -- native_kv_decide's own max_state default is separate
class _Head:
"""Duck-types the training repo's mcq.MCQHead just enough for native_kv_decide: a
.backbone (with .tokenizer/.model) and .device. The real MCQHead also builds a
restricted LM head over single-token letters for the mcq_zero_shot/energy readout
paths -- native_kv_decide never touches it, so it's not ported here."""
def __init__(self, backbone: Backbone):
self.backbone = backbone
self.device = backbone.device
def query_text(question: dict) -> str:
"""instructions + criteria: `yes: .. no: ..` (noul), `0: .. 1: ..` (score), `label: desc`
(choice). Verbatim port of pcdm_jev.decider.query_text."""
q, crit = question["instructions"], question.get("criteria")
if not crit:
return q
if question["type"] == "noul":
rubric = f"yes: {crit.get('true', 'Yes')} no: {crit.get('false', 'No')}"
elif isinstance(crit, list):
rubric = " ".join(f"{i}: {c}" for i, c in enumerate(crit))
else:
rubric = " ".join(f"{k}: {v or k}" for k, v in crit.items())
return f"{q}\n{rubric}"
def to_labels(p: torch.Tensor, labels: list[str]) -> tuple[dict, float]:
"""[K+1] (null last) -> (probs renormalised over labels, p_null). Verbatim port of
pcdm_jev.decider.to_labels."""
p = p.detach().float().cpu()
if not torch.isfinite(p).all():
raise RuntimeError("non-finite model output")
s = p[:-1].sum()
lab = p[:-1] / s if s > 0 else torch.zeros_like(p[:-1])
return dict(zip(labels, lab.tolist())), float(p[-1])
class Typical:
"""from_pretrained("OzLabs/typical-small") -> a ready-to-decide native-readout model."""
def __init__(self, head: _Head, model: NativeHead, device: str, max_state: int = 4096,
max_states: int = 16, max_option_tokens: int | None = DEFAULT_MAX_OPTION_TOKENS):
self.head, self.model, self.device, self.max_state = head, model, device, max_state
self.max_option_tokens = max_option_tokens
self.tok = head.backbone.tokenizer
# Persistent state -> prefix-KV cache (LRU, keyed on the exact state text): choice/
# score/noul/decide all route through _raw -> _state_kv_for, so a warm call (same
# state, new question) never re-runs the state_prefix_forward -- see native.py's
# encode_state/native_kv_decide docstrings. max_states caps how many distinct states
# are held at once (each entry is one KV cache, ~O(state_tokens) memory).
self.max_states = max_states
self._state_kv: OrderedDict[str, tuple] = OrderedDict()
@classmethod
def from_pretrained(cls, repo_id: str, device: str = "auto", filename: str = "best.pt",
revision: str | None = None, cache_dir: str | None = None,
max_state: int = 4096, max_states: int = 16,
max_option_tokens: int | None = DEFAULT_MAX_OPTION_TOKENS) -> "Typical":
path = hf_hub_download(repo_id, filename, revision=revision, cache_dir=cache_dir)
ckpt = torch.load(path, map_location="cpu", weights_only=True)
args = ckpt.get("args", {})
if args.get("readout") != "native":
raise ValueError(f"{repo_id}/{filename} is not a native-readout checkpoint (readout={args.get('readout')!r})")
dev = pick_device(device)
backbone = Backbone(args["backbone"], lora_layers=args.get("lora_layers", 8),
lora_r=args.get("lora_r", 16), device=dev, tap_layer=args.get("tap_layer", 0))
backbone.load_lora_state_dict(ckpt["lora"])
model = NativeHead(backbone.d, nc_head=args.get("nc_head", "n2n3"), null=args.get("null", "factored"),
render=args.get("nc_render", "letters"), score_head=args.get("score_head", "choice"),
noul_head=args.get("noul_head", "choice")).to(dev)
model.load_state_dict(ckpt["tower"])
model.eval()
return cls(_Head(backbone), model, dev, max_state=max_state, max_states=max_states,
max_option_tokens=max_option_tokens)
def _state_kv_for(self, state: str):
"""LRU lookup/insert of (past_key_values, Ls) for this exact state text. A hit skips
encode_state entirely -- bit-identical result to a fresh encode (deterministic
eval-mode forward), just without paying for it again."""
entry = self._state_kv.get(state)
if entry is not None:
self._state_kv.move_to_end(state)
return entry
entry = encode_state(self.head, self.model, state, self.max_state)
self._state_kv[state] = entry
if len(self._state_kv) > self.max_states:
self._state_kv.popitem(last=False)
return entry
def _raw(self, state, query: str, labels: list[str]) -> torch.Tensor:
if not isinstance(state, str):
state = json.dumps(state, ensure_ascii=False)
with torch.inference_mode():
cache = self._state_kv_for(state)
return native_kv_decide(self.head, self.model, state, [(query, labels)],
max_state=self.max_state, state_cache=cache,
max_option_tokens=self.max_option_tokens)[0]
def choice(self, state, question: str, labels: list[str]) -> dict:
"""-> {label: p, ...} + "p_null"."""
probs, p_null = to_labels(self._raw(state, question, labels), labels)
return {**probs, "p_null": p_null}
def noul(self, state, question: str) -> float:
"""P(yes), via the Bernoulli path if the checkpoint has one (noul_head="bern"), else
the plain K-way choice over ["no", "yes"] -- both paths are label-order invariant for
a genuine yes/no row (see native._is_bern_row / _yes_idx)."""
probs, _ = to_labels(self._raw(state, question, ["no", "yes"]), ["no", "yes"])
return probs["yes"]
def score(self, state, question: str, levels: list[str]) -> dict:
"""-> {level: p, ...} + "p_null" + "expected" (E[index] under the candidate-
conditional distribution, matching the training repo's metrics.ordinal_metrics --
candidate index IS the ordinal level, by construction of how levels are rendered)."""
probs, p_null = to_labels(self._raw(state, question, levels), levels)
expected = sum(i * probs[lv] for i, lv in enumerate(levels))
return {**probs, "p_null": p_null, "expected": expected}
def decide(self, state, question: dict, labels: list[str]) -> tuple[dict, dict]:
"""-> (probs over exactly `labels`, runtime block). Mirrors
pcdm_jev.decider.PCDMDecider.decide for a JevBench-style question dict
({"type", "instructions", "criteria"})."""
if not isinstance(state, str):
state = json.dumps(state, ensure_ascii=False)
query = query_text(question)
n_state = len(self.tok(state, add_special_tokens=False)["input_ids"])
n_query = len(self.tok(query, add_special_tokens=False)["input_ids"])
t0 = time.perf_counter()
p = self._raw(state, query, labels)
probs, p_null = to_labels(p, labels)
latency = time.perf_counter() - t0
return probs, {"mode": "native", "device": self.device, "latency_s": latency, "p_null": p_null,
"state_tokens": n_state, "max_state_tokens": self.max_state,
"state_truncated": n_state > self.max_state,
"trained_max_state": MAX_STATE, "state_beyond_train_len": n_state > MAX_STATE,
"query_tokens": n_query, "query_truncated": False,
"trained_max_query": MAX_QUERY, "query_beyond_train_len": False,
"probability_origin": "native-softmax"}