guychuk commited on
Commit
dd605dc
·
verified ·
1 Parent(s): f902f39

Phase A serving speedup: zero-copy state-cache expand (no deepcopy per decision)

Browse files
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 _cache_batch_repeat_interleave(cache, repeats: int):
267
- """cache.batch_repeat_interleave(repeats), generalised to Qwen3.5's hybrid cache -- see
268
- native.py's copy for the long form (its Gated-DeltaNet linear-attention layers have no
269
- batch_repeat_interleave of their own; Qwen3's cache is unaffected, every layer hits the
270
- first branch)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  for layer in cache.layers:
272
- if hasattr(layer, "batch_repeat_interleave"):
273
- layer.batch_repeat_interleave(repeats)
274
- continue
275
- for i in range(getattr(layer, "number_of_states", 1)):
276
- if layer.is_conv_states_initialized[i]:
277
- layer.conv_states[i] = layer.conv_states[i].repeat_interleave(repeats, dim=0)
278
- if layer.is_recurrent_states_initialized[i]:
279
- layer.recurrent_states[i] = layer.recurrent_states[i].repeat_interleave(repeats, dim=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- past_len = kv_len - q_len
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 -> (past_key_values, Ls), the once-per-state prefix encode that used to
 
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 copy.deepcopy'd later (native_kv_decide's
321
- per-chunk batch_repeat_interleave) -- a non-leaf, grad-tracked tensor can't be
322
- deepcopy'd, so this must never run under regular autograd tracking, regardless of what
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
- prefix = torch.tensor([[tok.eos_token_id] + _ids(tok, [state], max_state)[0]], device=dev)
 
 
 
 
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 encoded ONCE into a KV cache on head.backbone.model; every
337
- query's suffix runs against that cached prefix, `chunk` queries per forward. queries =
338
- [(query, candidates)] -> list of [K_i + 1] probability vectors (null last). Verbatim port
339
- of native.native_kv_decide (head/model contract unchanged -- head needs only .backbone
340
- (tokenizer + model) and .device; model is a NativeHead), plus two additions: `state_cache`,
341
- an optional (past_key_values, Ls) pair from encode_state -- pass it to skip re-encoding
342
- the state prefix (Typical.choice/score/noul/decide do this automatically via an LRU; see
343
- core.py) -- and `max_option_tokens` ("suffix diet": caps each rendered option's length,
344
- see _truncate_options; None/0 disables, matching the training repo's untruncated
345
- behaviour exactly). No hierarchical fallback: a suffix longer than max_suffix raises
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
- state_cache, Ls = state_cache if state_cache is not None else encode_state(head, model, state, max_state)
356
- out = []
357
- for start in range(0, len(queries), chunk):
358
- qs = queries[start:start + chunk]
359
- m = len(qs)
360
- with record_function("typical/tokenize_suffix"):
361
- # is_bern/yes-idx/cmask all key off candidate COUNT and the literal "yes"/"no"
362
- # text (never truncated -- 1 token), so they read the original `c`; only the
363
- # rendered text is diet'd.
364
- is_bern = [model.noul_head == "bern" and _is_bern_row(c) for _, c in qs]
365
- rendered = [RENDERS["query_only" if b else getattr(model, "render", "letters")]
366
- (q, _truncate_options(tok, c, max_option_tokens))
367
- for b, (q, c) in zip(is_bern, qs)]
368
- enc = tok([r[0] for r in rendered], add_special_tokens=False, return_offsets_mapping=True)
369
- x_ids, offs = enc["input_ids"], enc["offset_mapping"]
370
- assert max(len(x) for x in x_ids) <= max_suffix, f"suffix > max_suffix={max_suffix}; raise it"
371
- input_ids, am, lengths = _pack(tok, [[]] * m, x_ids, tail=[tok.eos_token_id], sink=False)
372
- with record_function("typical/mask_build"):
373
- T = input_ids.shape[1]
374
- Kmax = max(len(c) for _, c in qs)
375
- pool = torch.stack([_pool_matrix(r[1], o, 0, Kmax, T, K=len(c)) for r, o, (_, c) in zip(rendered, offs, qs)])
376
- cmask = torch.zeros(m, Kmax, dtype=torch.bool)
377
- for i, (_, c) in enumerate(qs):
378
- cmask[i, :len(c)] = True
379
-
380
- with record_function("typical/state_cache_deepcopy"):
381
- cache = copy.deepcopy(state_cache)
382
- _cache_batch_repeat_interleave(cache, m)
383
- with record_function("typical/suffix_forward"):
384
- attn = torch.cat([torch.ones(m, Ls, dtype=torch.long), am], dim=1).to(dev)
385
- position_ids = (torch.arange(T) + Ls).expand(m, -1).to(dev)
386
- H = lm(input_ids=input_ids.to(dev), attention_mask=_causal_pad_mask(attn, T, lm.dtype),
387
- position_ids=position_ids, past_key_values=cache, use_cache=False).last_hidden_state.float()
388
- with record_function("typical/head_forward"):
389
- h = H[torch.arange(m, device=dev), (lengths - 1).to(dev)]
390
- C3 = torch.bmm(pool.to(dev), H)
391
- C2 = None # n2 unsupported -- guarded above
392
- yi = _yes_idx([c for _, c in qs], dev) if model.noul_head == "bern" else None
393
- bm = torch.tensor(is_bern, dtype=torch.bool, device=dev) if model.noul_head == "bern" else None
394
- probs = torch.softmax(model(h, cmask.to(dev), C3, C2, yes_idx=yi, bern_mask=bm), dim=-1)
395
- with record_function("typical/cpu_sync"):
396
- for i, (_, c) in enumerate(qs):
397
- out.append(torch.cat([probs[i, :len(c)], probs[i, -1:]]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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