Phase A serving speedup: zero-copy state-cache expand (no deepcopy per decision)
Browse files- inference/test_parity.py +44 -0
- inference/typical/native.py +213 -80
inference/test_parity.py
CHANGED
|
@@ -41,6 +41,49 @@ NOUL_ITEMS = [
|
|
| 41 |
]
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
@pytest.mark.skipif(not os.environ.get("RUN_SLOW"), reason="downloads the real typical-small "
|
| 45 |
"checkpoint (Qwen3-1.7B-Base + LoRA); set RUN_SLOW=1 to run")
|
| 46 |
def test_typical_matches_pcdm_decider():
|
|
@@ -218,6 +261,7 @@ def test_max_option_tokens_is_noop_when_nothing_truncated():
|
|
| 218 |
|
| 219 |
|
| 220 |
if __name__ == "__main__":
|
|
|
|
| 221 |
if not os.environ.get("RUN_SLOW"):
|
| 222 |
os.environ["RUN_SLOW"] = "1"
|
| 223 |
test_typical_matches_pcdm_decider()
|
|
|
|
| 41 |
]
|
| 42 |
|
| 43 |
|
| 44 |
+
def test_expand_state_cache_matches_deepcopy_and_does_not_alias():
|
| 45 |
+
"""Phase A (serving speedup): `_expand_state_cache` replaces `copy.deepcopy(state_cache)`
|
| 46 |
+
+ `batch_repeat_interleave` with a zero-copy `.expand()`. No checkpoint needed -- this
|
| 47 |
+
exercises the cache mechanics directly against transformers' real DynamicCache/DynamicLayer
|
| 48 |
+
with random tensors, checking (a) bit-identical results to the old deepcopy+repeat
|
| 49 |
+
reference and (b) that expanding+updating the new cache never mutates the original."""
|
| 50 |
+
import torch
|
| 51 |
+
from transformers.cache_utils import DynamicCache
|
| 52 |
+
|
| 53 |
+
from typical.native import _expand_state_cache
|
| 54 |
+
|
| 55 |
+
torch.manual_seed(0)
|
| 56 |
+
B, H, Ls, D, m = 1, 4, 7, 8, 5
|
| 57 |
+
k0, v0 = torch.randn(B, H, Ls, D), torch.randn(B, H, Ls, D)
|
| 58 |
+
k1, v1 = torch.randn(B, H, Ls, D), torch.randn(B, H, Ls, D)
|
| 59 |
+
cache = DynamicCache(ddp_cache_data=[(k0, v0), (k1, v1)])
|
| 60 |
+
orig_keys = [layer.keys.clone() for layer in cache.layers]
|
| 61 |
+
orig_values = [layer.values.clone() for layer in cache.layers]
|
| 62 |
+
|
| 63 |
+
# ground truth: the old path (deepcopy, then the real repeat_interleave every layer used
|
| 64 |
+
# to get regardless of whether it holds K/V or Gated-DeltaNet conv/recurrent state)
|
| 65 |
+
import copy
|
| 66 |
+
ref = copy.deepcopy(cache)
|
| 67 |
+
ref.batch_repeat_interleave(m)
|
| 68 |
+
|
| 69 |
+
new = _expand_state_cache(cache, m)
|
| 70 |
+
|
| 71 |
+
q_len = 3
|
| 72 |
+
torch.manual_seed(1)
|
| 73 |
+
new_k = [torch.randn(m, H, q_len, D), torch.randn(m, H, q_len, D)]
|
| 74 |
+
new_v = [torch.randn(m, H, q_len, D), torch.randn(m, H, q_len, D)]
|
| 75 |
+
for i in range(2):
|
| 76 |
+
k_ref, v_ref = ref.update(new_k[i], new_v[i], i)
|
| 77 |
+
k_new, v_new = new.update(new_k[i], new_v[i], i)
|
| 78 |
+
assert torch.equal(k_ref, k_new) and torch.equal(v_ref, v_new), f"layer {i} mismatch"
|
| 79 |
+
|
| 80 |
+
# the original (batch=1) cache must be untouched by both the expand and the update above
|
| 81 |
+
for i, layer in enumerate(cache.layers):
|
| 82 |
+
assert torch.equal(layer.keys, orig_keys[i]) and torch.equal(layer.values, orig_values[i]), \
|
| 83 |
+
f"layer {i} of the original state_cache was mutated"
|
| 84 |
+
print("[expand_state_cache] matches deepcopy+repeat_interleave reference, no aliasing")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
@pytest.mark.skipif(not os.environ.get("RUN_SLOW"), reason="downloads the real typical-small "
|
| 88 |
"checkpoint (Qwen3-1.7B-Base + LoRA); set RUN_SLOW=1 to run")
|
| 89 |
def test_typical_matches_pcdm_decider():
|
|
|
|
| 261 |
|
| 262 |
|
| 263 |
if __name__ == "__main__":
|
| 264 |
+
test_expand_state_cache_matches_deepcopy_and_does_not_alias()
|
| 265 |
if not os.environ.get("RUN_SLOW"):
|
| 266 |
os.environ["RUN_SLOW"] = "1"
|
| 267 |
test_typical_matches_pcdm_decider()
|
inference/typical/native.py
CHANGED
|
@@ -10,6 +10,8 @@ typical-small-preview) trains with nc_head n2/n2n3.
|
|
| 10 |
"""
|
| 11 |
import bisect
|
| 12 |
import copy
|
|
|
|
|
|
|
| 13 |
import string
|
| 14 |
|
| 15 |
import torch
|
|
@@ -68,8 +70,55 @@ def _render_query_only(query: str, cand_texts: list[str]):
|
|
| 68 |
return query + "\n", []
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
RENDERS = {"letters": _render, "tags": _render_tags, "letters_nonull": _render_letters_nonull,
|
| 72 |
-
"query_only": _render_query_only}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
def _is_bern_row(cand_texts, meta=None):
|
|
@@ -263,20 +312,73 @@ def _pool_matrix(spans, offsets, base, Kmax, T, K=None):
|
|
| 263 |
return pool / pool.sum(-1, keepdim=True).clamp_min(1.0)
|
| 264 |
|
| 265 |
|
| 266 |
-
def
|
| 267 |
-
"""
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
for layer in cache.layers:
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
def _causal_pad_mask(attn2d, q_len, dtype):
|
|
@@ -284,10 +386,7 @@ def _causal_pad_mask(attn2d, q_len, dtype):
|
|
| 284 |
past_key_values path mis-handles a right-padded batch sharing one KV cache; this
|
| 285 |
reproduces the un-batched per-row computation exactly."""
|
| 286 |
batch, kv_len = attn2d.shape
|
| 287 |
-
|
| 288 |
-
q_idx = torch.arange(q_len, device=attn2d.device).unsqueeze(1)
|
| 289 |
-
kv_idx = torch.arange(kv_len, device=attn2d.device).unsqueeze(0)
|
| 290 |
-
causal = kv_idx <= (past_len + q_idx)
|
| 291 |
allowed = causal.unsqueeze(0) & attn2d.bool().unsqueeze(1)
|
| 292 |
mask = torch.zeros(batch, 1, q_len, kv_len, dtype=dtype, device=attn2d.device)
|
| 293 |
return mask.masked_fill(~allowed.unsqueeze(1), torch.finfo(dtype).min)
|
|
@@ -310,20 +409,30 @@ def _truncate_options(tok, cand_texts: list[str], max_tokens: int | None) -> lis
|
|
| 310 |
|
| 311 |
|
| 312 |
@torch.inference_mode()
|
| 313 |
-
def encode_state(head, model, state: str, max_state: int = 256):
|
| 314 |
-
"""[eos] + state
|
|
|
|
| 315 |
run inside native_kv_decide on every call. Split out so a caller (Typical's state LRU)
|
| 316 |
can compute it once and reuse the KV cache across many decisions on the same state --
|
| 317 |
see native_kv_decide's `state_cache` param. Pure function of (backbone weights, state
|
| 318 |
-
text): deterministic in eval/inference_mode, so a cached entry is bit-identical to a
|
| 319 |
fresh encode, not just numerically close. @inference_mode here (not just relying on the
|
| 320 |
-
caller) because the resulting KV tensors get
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
context the caller happens to be in.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
tok, dev, lm = head.backbone.tokenizer, head.device, head.backbone.model
|
| 325 |
with record_function("typical/tokenize_state"):
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
with record_function("typical/state_prefix_forward"):
|
| 328 |
cache = lm(input_ids=prefix, use_cache=True).past_key_values
|
| 329 |
return cache, prefix.shape[1]
|
|
@@ -333,17 +442,25 @@ def encode_state(head, model, state: str, max_state: int = 256):
|
|
| 333 |
def native_kv_decide(head, model, state, queries, chunk: int = 32, max_state: int = 256,
|
| 334 |
max_suffix: int = MAX_SUFFIX, vec_cache=None, state_cache=None,
|
| 335 |
max_option_tokens: int | None = DEFAULT_MAX_OPTION_TOKENS):
|
| 336 |
-
"""Serving path: [eos] + state
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
of
|
| 340 |
-
(
|
| 341 |
-
|
| 342 |
-
the state prefix (Typical.choice/
|
| 343 |
-
core.py) -- and `max_option_tokens`
|
| 344 |
-
see _truncate_options; None/0 disables,
|
| 345 |
-
behaviour exactly). No hierarchical fallback: a
|
| 346 |
-
(raise max_suffix instead).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
if model.use2:
|
| 348 |
raise NotImplementedError(
|
| 349 |
"nc_head n2/n2n3 (Qwen3-Embedding candidate vectors) isn't ported to the "
|
|
@@ -352,47 +469,63 @@ def native_kv_decide(head, model, state, queries, chunk: int = 32, max_state: in
|
|
| 352 |
"checkpoint requires it."
|
| 353 |
)
|
| 354 |
tok, dev, lm = head.backbone.tokenizer, head.device, head.backbone.model
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
return out
|
|
|
|
| 10 |
"""
|
| 11 |
import bisect
|
| 12 |
import copy
|
| 13 |
+
import functools
|
| 14 |
+
import json
|
| 15 |
import string
|
| 16 |
|
| 17 |
import torch
|
|
|
|
| 70 |
return query + "\n", []
|
| 71 |
|
| 72 |
|
| 73 |
+
# Verbatim from pcdm_jev.decider.SEMIF_SYSTEM / native.SEMIF_SYSTEM (SemIf's core.py DIRECT_SYSTEM).
|
| 74 |
+
SEMIF_SYSTEM = ("Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. "
|
| 75 |
+
"Respond with only its uppercase letter, with no explanation or reasoning.")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@functools.lru_cache(maxsize=4)
|
| 79 |
+
def _semif_head_tail(tok):
|
| 80 |
+
"""(pre_head, tail): chat-template text around a sentinel "evidence" value -- pre_head =
|
| 81 |
+
system turn + user-turn opening + `{"evidence": "` (prepended to the state text to form the
|
| 82 |
+
KV-cached prefix), tail = end of user turn + assistant-turn opening (appended after the rest
|
| 83 |
+
of the JSON, see _render_semif). Verbatim port of native._semif_head_tail."""
|
| 84 |
+
sentinel = "\x00SEMIF_STATE\x00"
|
| 85 |
+
messages = [{"role": "system", "content": SEMIF_SYSTEM}, {"role": "user", "content": sentinel}]
|
| 86 |
+
try:
|
| 87 |
+
full = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=False)
|
| 88 |
+
except TypeError:
|
| 89 |
+
full = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 90 |
+
before, after = full.split(sentinel)
|
| 91 |
+
return before + '{"evidence": "', after
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _render_semif(query: str, cand_texts: list[str], tail_text: str):
|
| 95 |
+
"""native_v4 (--nc_render semif): closes the "evidence" string opened in the cached prefix,
|
| 96 |
+
then criterion + lettered options (json.dumps-escaped) + tail_text. spans[k] = (start, end)
|
| 97 |
+
of option k's description text -- K spans, no null line. Verbatim port of native._render_semif."""
|
| 98 |
+
text = '", "criterion": ' + json.dumps(query) + ', "options": ['
|
| 99 |
+
spans = []
|
| 100 |
+
for i, c in enumerate(cand_texts):
|
| 101 |
+
if i:
|
| 102 |
+
text += ", "
|
| 103 |
+
text += '{"letter": "' + _label(i) + '", "description": "'
|
| 104 |
+
esc = json.dumps(c)[1:-1]
|
| 105 |
+
spans.append((len(text), len(text) + len(esc)))
|
| 106 |
+
text += esc + '"}'
|
| 107 |
+
text += ']}' + tail_text
|
| 108 |
+
return text, spans
|
| 109 |
+
|
| 110 |
+
|
| 111 |
RENDERS = {"letters": _render, "tags": _render_tags, "letters_nonull": _render_letters_nonull,
|
| 112 |
+
"query_only": _render_query_only, "semif": _render_semif}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _render_row(tok, render: str, query: str, cand_texts: list[str]):
|
| 116 |
+
"""Dispatch RENDERS[render](query, cand_texts) for every render except "semif", which also
|
| 117 |
+
needs the tokenizer's chat-template tail text (_semif_head_tail). Verbatim port of
|
| 118 |
+
native._render_row."""
|
| 119 |
+
if render == "semif":
|
| 120 |
+
return _render_semif(query, cand_texts, _semif_head_tail(tok)[1])
|
| 121 |
+
return RENDERS[render](query, cand_texts)
|
| 122 |
|
| 123 |
|
| 124 |
def _is_bern_row(cand_texts, meta=None):
|
|
|
|
| 312 |
return pool / pool.sum(-1, keepdim=True).clamp_min(1.0)
|
| 313 |
|
| 314 |
|
| 315 |
+
def _expand_state_cache(cache, repeats: int):
|
| 316 |
+
"""Replaces the old `copy.deepcopy(state_cache)` + `_cache_batch_repeat_interleave`: a
|
| 317 |
+
new Cache whose K/V tensors are `repeats`-wide views of `cache`'s (batch=1) K/V via
|
| 318 |
+
`torch.Tensor.expand` -- stride-0 broadcast, zero bytes copied -- instead of a full deep
|
| 319 |
+
copy of the (up to 1024-token) prefix followed by a real repeat_interleave copy on top.
|
| 320 |
+
Safe because every attention layer's `.update()` (transformers' DynamicLayer et al.)
|
| 321 |
+
does `self.keys = torch.cat([self.keys, key_states], dim=-2)`: a rebind to a brand-new
|
| 322 |
+
tensor, never an in-place write to the old one, so the expanded view of the original
|
| 323 |
+
prefix is read (by torch.cat, which handles stride-0 inputs like any other tensor -- no
|
| 324 |
+
approximation, bit-identical to a materialised repeat) but never mutated. Only the outer
|
| 325 |
+
Cache object and each layer object are shallow-copied (cheap: Python attribute dicts,
|
| 326 |
+
not tensor storage).
|
| 327 |
+
ponytail: Gated-DeltaNet (Qwen3.5 hybrid cache) linear-attention layers have no K/V
|
| 328 |
+
tensors, only small conv_states/recurrent_states dicts (O(1) in sequence length) that
|
| 329 |
+
genuinely get mutated by the recurrent update -- those still get a real
|
| 330 |
+
repeat_interleave, just onto a copied dict so the write can't alias back into the
|
| 331 |
+
original state_cache. Only plain K/V (DynamicLayer-family) and this dict-state shape are
|
| 332 |
+
handled -- a future model with e.g. DynamicIndexedLayer's extra indexer_keys would need
|
| 333 |
+
its own expand branch here."""
|
| 334 |
+
new = copy.copy(cache)
|
| 335 |
+
new.layers = []
|
| 336 |
for layer in cache.layers:
|
| 337 |
+
nl = copy.copy(layer)
|
| 338 |
+
if hasattr(nl, "batch_repeat_interleave"):
|
| 339 |
+
if nl.keys is not None and nl.keys.numel() > 0:
|
| 340 |
+
nl.keys = nl.keys.expand(repeats, *nl.keys.shape[1:])
|
| 341 |
+
nl.values = nl.values.expand(repeats, *nl.values.shape[1:])
|
| 342 |
+
else:
|
| 343 |
+
nl.conv_states = dict(layer.conv_states)
|
| 344 |
+
nl.recurrent_states = dict(layer.recurrent_states)
|
| 345 |
+
for i in range(getattr(nl, "number_of_states", 1)):
|
| 346 |
+
if nl.is_conv_states_initialized[i]:
|
| 347 |
+
nl.conv_states[i] = nl.conv_states[i].repeat_interleave(repeats, dim=0)
|
| 348 |
+
if nl.is_recurrent_states_initialized[i]:
|
| 349 |
+
nl.recurrent_states[i] = nl.recurrent_states[i].repeat_interleave(repeats, dim=0)
|
| 350 |
+
new.layers.append(nl)
|
| 351 |
+
return new
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@functools.lru_cache(maxsize=64)
|
| 355 |
+
def _causal_template(q_len: int, kv_len: int, device_str: str):
|
| 356 |
+
"""[q_len, kv_len] boolean causal template (True = attend), cached per (q_len, kv_len,
|
| 357 |
+
device) -- a pure function of shape, so a repeat call at the same (prefix_len,
|
| 358 |
+
suffix_len) bucket reuses it instead of rebuilding the two arange()s + comparison."""
|
| 359 |
+
device = torch.device(device_str)
|
| 360 |
+
past_len = kv_len - q_len
|
| 361 |
+
q_idx = torch.arange(q_len, device=device).unsqueeze(1)
|
| 362 |
+
kv_idx = torch.arange(kv_len, device=device).unsqueeze(0)
|
| 363 |
+
return kv_idx <= (past_len + q_idx)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
@functools.lru_cache(maxsize=64)
|
| 367 |
+
def _position_ids_row(T: int, Ls: int, device_str: str):
|
| 368 |
+
"""[T] position ids (Ls, Ls+1, ..., Ls+T-1), cached per (T, Ls, device)."""
|
| 369 |
+
return torch.arange(T, device=torch.device(device_str)) + Ls
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@functools.lru_cache(maxsize=64)
|
| 373 |
+
def _sink_ones(m: int, Ls: int, device_str: str):
|
| 374 |
+
"""[m, Ls] all-ones long tensor (the cached prefix is always fully attended), cached per
|
| 375 |
+
(m, Ls, device) -- avoids a fresh CPU allocation on every decision."""
|
| 376 |
+
return torch.ones(m, Ls, dtype=torch.long, device=torch.device(device_str))
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
@functools.lru_cache(maxsize=64)
|
| 380 |
+
def _arange(n: int, device_str: str):
|
| 381 |
+
return torch.arange(n, device=torch.device(device_str))
|
| 382 |
|
| 383 |
|
| 384 |
def _causal_pad_mask(attn2d, q_len, dtype):
|
|
|
|
| 386 |
past_key_values path mis-handles a right-padded batch sharing one KV cache; this
|
| 387 |
reproduces the un-batched per-row computation exactly."""
|
| 388 |
batch, kv_len = attn2d.shape
|
| 389 |
+
causal = _causal_template(q_len, kv_len, str(attn2d.device))
|
|
|
|
|
|
|
|
|
|
| 390 |
allowed = causal.unsqueeze(0) & attn2d.bool().unsqueeze(1)
|
| 391 |
mask = torch.zeros(batch, 1, q_len, kv_len, dtype=dtype, device=attn2d.device)
|
| 392 |
return mask.masked_fill(~allowed.unsqueeze(1), torch.finfo(dtype).min)
|
|
|
|
| 409 |
|
| 410 |
|
| 411 |
@torch.inference_mode()
|
| 412 |
+
def encode_state(head, model, state: str, max_state: int = 256, semif: bool | None = None):
|
| 413 |
+
"""[eos] + state (or, --nc_render semif, the SemIf chat-template wrapper + state -- see
|
| 414 |
+
_semif_head_tail) -> (past_key_values, Ls), the once-per-state prefix encode that used to
|
| 415 |
run inside native_kv_decide on every call. Split out so a caller (Typical's state LRU)
|
| 416 |
can compute it once and reuse the KV cache across many decisions on the same state --
|
| 417 |
see native_kv_decide's `state_cache` param. Pure function of (backbone weights, state
|
| 418 |
+
text, semif): deterministic in eval/inference_mode, so a cached entry is bit-identical to a
|
| 419 |
fresh encode, not just numerically close. @inference_mode here (not just relying on the
|
| 420 |
+
caller) because the resulting KV tensors get `.expand()`'d and read (never in-place
|
| 421 |
+
mutated) by every subsequent decision on this state (see `_expand_state_cache`) -- an
|
| 422 |
+
inference_mode tensor is a stable, non-grad-tracked view source, so this must never run
|
| 423 |
+
under regular autograd tracking, regardless of what context the caller happens to be in.
|
| 424 |
+
semif=None defaults to `model.render == "semif"`; native_kv_decide passes semif=False
|
| 425 |
+
explicitly for a bern (yes/no) row's own prefix, since those always use the plain eos+state
|
| 426 |
+
sink regardless of nc_render (see native.py's module docstring)."""
|
| 427 |
+
if semif is None:
|
| 428 |
+
semif = getattr(model, "render", "letters") == "semif"
|
| 429 |
tok, dev, lm = head.backbone.tokenizer, head.device, head.backbone.model
|
| 430 |
with record_function("typical/tokenize_state"):
|
| 431 |
+
if semif:
|
| 432 |
+
prefix_ids = _ids(tok, [_semif_head_tail(tok)[0] + state], max_state)[0]
|
| 433 |
+
else:
|
| 434 |
+
prefix_ids = [tok.eos_token_id] + _ids(tok, [state], max_state)[0]
|
| 435 |
+
prefix = torch.tensor([prefix_ids], device=dev)
|
| 436 |
with record_function("typical/state_prefix_forward"):
|
| 437 |
cache = lm(input_ids=prefix, use_cache=True).past_key_values
|
| 438 |
return cache, prefix.shape[1]
|
|
|
|
| 442 |
def native_kv_decide(head, model, state, queries, chunk: int = 32, max_state: int = 256,
|
| 443 |
max_suffix: int = MAX_SUFFIX, vec_cache=None, state_cache=None,
|
| 444 |
max_option_tokens: int | None = DEFAULT_MAX_OPTION_TOKENS):
|
| 445 |
+
"""Serving path: [eos] + state (or, --nc_render semif, the SemIf chat-template wrapper +
|
| 446 |
+
state) encoded ONCE into a KV cache on head.backbone.model; every query's suffix runs
|
| 447 |
+
against that cached prefix, `chunk` queries per forward. queries = [(query, candidates)] ->
|
| 448 |
+
list of [K_i + 1] probability vectors (null last). Verbatim port of native.native_kv_decide
|
| 449 |
+
(head/model contract unchanged -- head needs only .backbone (tokenizer + model) and .device;
|
| 450 |
+
model is a NativeHead), plus two additions: `state_cache`, an optional (past_key_values, Ls)
|
| 451 |
+
pair from encode_state -- pass it to skip re-encoding the state prefix (Typical.choice/
|
| 452 |
+
score/noul/decide do this automatically via an LRU; see core.py) -- and `max_option_tokens`
|
| 453 |
+
("suffix diet": caps each rendered option's length, see _truncate_options; None/0 disables,
|
| 454 |
+
matching the training repo's untruncated behaviour exactly). No hierarchical fallback: a
|
| 455 |
+
suffix longer than max_suffix raises (raise max_suffix instead).
|
| 456 |
+
|
| 457 |
+
--nc_render semif: a bern (yes/no) row always renders query_only against the PLAIN eos+state
|
| 458 |
+
prefix, never the semif JSON wrapper (see native.py's module docstring), so it can't share
|
| 459 |
+
`state_cache` when one is supplied (Typical's per-state LRU key/encode_state default to
|
| 460 |
+
building the semif-wrapped cache whenever model.render=="semif" -- see encode_state). A
|
| 461 |
+
call that mixes bern and non-bern queries under a semif checkpoint therefore builds a
|
| 462 |
+
second, plain prefix on demand for the bern rows only; every other render (incl. no bern
|
| 463 |
+
rows present) is the original single-cache loop, unchanged."""
|
| 464 |
if model.use2:
|
| 465 |
raise NotImplementedError(
|
| 466 |
"nc_head n2/n2n3 (Qwen3-Embedding candidate vectors) isn't ported to the "
|
|
|
|
| 469 |
"checkpoint requires it."
|
| 470 |
)
|
| 471 |
tok, dev, lm = head.backbone.tokenizer, head.device, head.backbone.model
|
| 472 |
+
base_render = getattr(model, "render", "letters")
|
| 473 |
+
# is_bern/yes-idx/cmask all key off candidate COUNT and the literal "yes"/"no" text (never
|
| 474 |
+
# truncated -- 1 token), so they read the original `c`; only the rendered text is diet'd.
|
| 475 |
+
is_bern = [model.noul_head == "bern" and _is_bern_row(c) for _, c in queries]
|
| 476 |
+
renders = ["query_only" if b else base_render for b in is_bern]
|
| 477 |
+
out = [None] * len(queries)
|
| 478 |
+
|
| 479 |
+
def run_group(idxs, cache, Ls, tail_ids):
|
| 480 |
+
if not idxs:
|
| 481 |
+
return
|
| 482 |
+
for start in range(0, len(idxs), chunk):
|
| 483 |
+
grp = idxs[start:start + chunk]
|
| 484 |
+
qs = [queries[i] for i in grp]
|
| 485 |
+
rnds = [renders[i] for i in grp]
|
| 486 |
+
m = len(qs)
|
| 487 |
+
with record_function("typical/tokenize_suffix"):
|
| 488 |
+
rendered = [_render_row(tok, r, q, _truncate_options(tok, c, max_option_tokens))
|
| 489 |
+
for r, (q, c) in zip(rnds, qs)]
|
| 490 |
+
enc = tok([r[0] for r in rendered], add_special_tokens=False, return_offsets_mapping=True)
|
| 491 |
+
x_ids, offs = enc["input_ids"], enc["offset_mapping"]
|
| 492 |
+
assert max(len(x) for x in x_ids) <= max_suffix, f"suffix > max_suffix={max_suffix}; raise it"
|
| 493 |
+
input_ids, am, lengths = _pack(tok, [[]] * m, x_ids, tail=tail_ids, sink=False)
|
| 494 |
+
with record_function("typical/mask_build"):
|
| 495 |
+
T = input_ids.shape[1]
|
| 496 |
+
Kmax = max(len(c) for _, c in qs)
|
| 497 |
+
pool = torch.stack([_pool_matrix(r[1], o, 0, Kmax, T, K=len(c)) for r, o, (_, c) in zip(rendered, offs, qs)])
|
| 498 |
+
cmask = torch.zeros(m, Kmax, dtype=torch.bool)
|
| 499 |
+
for i, (_, c) in enumerate(qs):
|
| 500 |
+
cmask[i, :len(c)] = True
|
| 501 |
+
|
| 502 |
+
with record_function("typical/state_cache_expand"):
|
| 503 |
+
exp_cache = _expand_state_cache(cache, m)
|
| 504 |
+
with record_function("typical/suffix_forward"):
|
| 505 |
+
attn = torch.cat([_sink_ones(m, Ls, "cpu"), am], dim=1).to(dev)
|
| 506 |
+
position_ids = _position_ids_row(T, Ls, str(dev)).expand(m, -1)
|
| 507 |
+
H = lm(input_ids=input_ids.to(dev), attention_mask=_causal_pad_mask(attn, T, lm.dtype),
|
| 508 |
+
position_ids=position_ids, past_key_values=exp_cache, use_cache=False).last_hidden_state.float()
|
| 509 |
+
with record_function("typical/head_forward"):
|
| 510 |
+
h = H[_arange(m, str(dev)), (lengths - 1).to(dev)]
|
| 511 |
+
C3 = torch.bmm(pool.to(dev), H)
|
| 512 |
+
C2 = None # n2 unsupported -- guarded above
|
| 513 |
+
yi = _yes_idx([c for _, c in qs], dev) if model.noul_head == "bern" else None
|
| 514 |
+
bm = (torch.tensor([r == "query_only" for r in rnds], dtype=torch.bool, device=dev)
|
| 515 |
+
if model.noul_head == "bern" else None)
|
| 516 |
+
probs = torch.softmax(model(h, cmask.to(dev), C3, C2, yes_idx=yi, bern_mask=bm), dim=-1)
|
| 517 |
+
with record_function("typical/cpu_sync"):
|
| 518 |
+
for row, i in enumerate(grp):
|
| 519 |
+
_, c = qs[row]
|
| 520 |
+
out[i] = torch.cat([probs[row, :len(c)], probs[row, -1:]])
|
| 521 |
+
|
| 522 |
+
non_bern, bern = [i for i, b in enumerate(is_bern) if not b], [i for i, b in enumerate(is_bern) if b]
|
| 523 |
+
if base_render != "semif" or not bern:
|
| 524 |
+
cache, Ls = state_cache if state_cache is not None else encode_state(head, model, state, max_state)
|
| 525 |
+
run_group(list(range(len(queries))), cache, Ls, [tok.eos_token_id])
|
| 526 |
+
else:
|
| 527 |
+
semif_cache, semif_Ls = state_cache if state_cache is not None else encode_state(head, model, state, max_state)
|
| 528 |
+
run_group(non_bern, semif_cache, semif_Ls, [])
|
| 529 |
+
plain_cache, plain_Ls = encode_state(head, model, state, max_state, semif=False)
|
| 530 |
+
run_group(bern, plain_cache, plain_Ls, [tok.eos_token_id])
|
| 531 |
return out
|