evalmetry 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
evalmetry/backend.py ADDED
@@ -0,0 +1,942 @@
1
+ """Everything that runs the model and makes forward passes happen.
2
+
3
+ Two entry points live here because they do the same job - drive the model so the hooks fire:
4
+
5
+ * `TracedHFLM`, an lm-eval backend that records signals while lm-eval scores.
6
+ * `ResearchDataCollector`, which re-feeds prompts from a finished run without going through lm-eval at all (the second pass of --save-attention / --save-hidden).
7
+
8
+ We do not fork lm-eval.
9
+ The backend is registered with `@register_model` and subclasses `HFLM`, so scoring stays exactly lm-eval's.
10
+
11
+ Attaching a signal to the right document
12
+ ----------------------------------------
13
+ This is the part that fails silently if it is wrong: the numbers land on the wrong document, the plots look healthy, and nothing complains.
14
+ Two things go wrong on the way down from the evaluator to the hooks.
15
+
16
+ 1.
17
+ `doc_id` never reaches the model.
18
+ `HFLM.loglikelihood` takes the arguments out of each `Instance` and passes on plain `((context, continuation), context_enc, continuation_enc)` tuples; the `Instance` - and with it `doc_id` and `task_name` - is not part of them.
19
+ Upstream relies on list order to put results back.
20
+ 2.
21
+ That order changes again inside `_loglikelihood_tokens`, which sorts requests by length for batching efficiency and un-sorts only at the end.
22
+ The order the hooks see is not document order.
23
+
24
+ So both methods are overridden.
25
+ `loglikelihood` is a thin layer over the upstream logic that additionally records request index -> (task_name, doc_id, choice_idx).
26
+ `_loglikelihood_tokens` is derived from upstream and keeps the sort permutation explicit, so the mapping can be looked up right before each forward pass.
27
+ Because that one is a copy, `check_upstream_source()` guards it: CI fails when lm-eval changes the original.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import ast
33
+ import hashlib
34
+ import inspect
35
+ import os
36
+ import random
37
+ import sys
38
+ from typing import Any, Sequence
39
+
40
+ import torch
41
+ import torch.nn.functional as F
42
+ from tqdm import tqdm
43
+
44
+ from lm_eval.api.model import TemplateLM
45
+ from lm_eval.api.registry import register_model
46
+ from lm_eval.models.huggingface import HFLM
47
+ from lm_eval.models.utils import Collator
48
+
49
+ try: # lm-eval >= 0.4.10 moved this helper into its own module
50
+ from lm_eval.models.utils_hf import pad_and_concat
51
+ except ImportError: # lm-eval 0.4.9.x
52
+ from lm_eval.models.utils import pad_and_concat
53
+
54
+ from . import debug
55
+ from .reducers import ForwardContext
56
+ from .recorder import Recorder
57
+ from .storage import BACKEND_NAME, SAMPLING_SEED, read_samples
58
+
59
+
60
+ # --------------------------------------------------------------------------
61
+ # Upstream source check
62
+ # --------------------------------------------------------------------------
63
+
64
+ # SHA-256 hashes of reviewed upstream sources, keyed by lm-eval version.
65
+ # `_loglikelihood_tokens` is copied below; `loglikelihood` defines the request
66
+ # tuples we rebuild. Changes to either require reviewing the document mapping.
67
+ # Hash the whole Collator class because we also rely on its private index state
68
+ # (`_arr_with_indices`, `_reorder_indices`) to recover each batch row's document.
69
+ # An unlisted version produces a warning; a known version with changed source
70
+ # fails the strict test check. Package installation pins the default version.
71
+ UPSTREAM_SOURCE_HASHES: dict[str, dict[str, str]] = {
72
+ "0.4.9.1": {
73
+ "lm_eval.models.huggingface:HFLM._loglikelihood_tokens":
74
+ "816339a43a1c145cd0194c29dd1c3d386f658c9ddeb38a75b8736e16c862eeac",
75
+ "lm_eval.api.model:TemplateLM.loglikelihood":
76
+ "7b6422e672f176bcb2a25d062a2c662bec5046b04d3de6b76cae94a79ed5eba0",
77
+ "lm_eval.models.utils:Collator":
78
+ "33da8a838a542df718a1835754cb03d10c7e139501ff874631a0aa2d27ef9241",
79
+ },
80
+ # 0.4.13 diff against 0.4.9.1, reviewed: `_loglikelihood_tokens` gains two assertions, `strict=True` on a zip, and a reset of cached auto batch sizes (mirrored below); `loglikelihood` gains a docstring and a progress bar over tokenisation; `Collator` is retyped to builtin generics, its logic untouched.
81
+ # `pad_and_concat` moved to `lm_eval.models.utils_hf`.
82
+ # None of it changes the scoring arithmetic.
83
+ "0.4.13": {
84
+ "lm_eval.models.huggingface:HFLM._loglikelihood_tokens":
85
+ "994529f70fd23192506699a9e98fb83e6a63182494cc3e5997520582a28dc4f5",
86
+ "lm_eval.api.model:TemplateLM.loglikelihood":
87
+ "507df4a0fa1d0bae59b457b7624644fefcdf91c2f5fa3d234c97106b22580b5c",
88
+ "lm_eval.models.utils:Collator":
89
+ "2dc993c9a488146bd295a71e97f5bedf16a8a7d3405492a80a27d5742e53833a",
90
+ },
91
+ }
92
+
93
+
94
+ def method_source(cls: type, method_name: str) -> str:
95
+ """Return the source text of one method, located via the AST.
96
+
97
+ Deliberately not `inspect.getsource`: that walks backwards from the code object and can pick up neighbouring comments, so the hash would move for reasons that have nothing to do with the code.
98
+
99
+ Example:
100
+ >>> method_source(HFLM, "_loglikelihood_tokens").splitlines()[0].strip()
101
+ 'def _loglikelihood_tokens('
102
+ """
103
+ path = inspect.getsourcefile(cls)
104
+ if path is None:
105
+ raise RuntimeError(f"cannot locate source file for {cls.__name__}")
106
+ with open(path, encoding="utf-8") as fh:
107
+ lines = fh.readlines()
108
+ tree = ast.parse("".join(lines))
109
+ for node in ast.walk(tree):
110
+ if isinstance(node, ast.ClassDef) and node.name == cls.__name__:
111
+ for item in node.body:
112
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == method_name:
113
+ return "".join(lines[item.lineno - 1 : item.end_lineno])
114
+ raise LookupError(f"{cls.__name__}.{method_name} not found in {path}")
115
+
116
+
117
+ def class_source(cls: type) -> str:
118
+ """Return the whole source text of a class, located via the AST.
119
+
120
+ Used where we depend on more than one method of a class - reading private state, say - so that any change to it is caught rather than only a change to the one method we happened to name.
121
+ """
122
+ path = inspect.getsourcefile(cls)
123
+ if path is None:
124
+ raise RuntimeError(f"cannot locate source file for {cls.__name__}")
125
+ with open(path, encoding="utf-8") as fh:
126
+ lines = fh.readlines()
127
+ for node in ast.walk(ast.parse("".join(lines))):
128
+ if isinstance(node, ast.ClassDef) and node.name == cls.__name__:
129
+ return "".join(lines[node.lineno - 1 : node.end_lineno])
130
+ raise LookupError(f"{cls.__name__} not found in {path}")
131
+
132
+
133
+ def upstream_source_hashes() -> dict[str, str]:
134
+ """Hash the upstream methods as they exist in the installed lm-eval.
135
+
136
+ Example:
137
+ >>> upstream_source_hashes()["lm_eval.api.model:TemplateLM.loglikelihood"] # doctest: +SKIP
138
+ '2fbd0c...'
139
+ """
140
+ methods = {
141
+ "lm_eval.models.huggingface:HFLM._loglikelihood_tokens": (HFLM, "_loglikelihood_tokens"),
142
+ "lm_eval.api.model:TemplateLM.loglikelihood": (TemplateLM, "loglikelihood"),
143
+ }
144
+ found = {
145
+ key: hashlib.sha256(method_source(cls, name).encode("utf-8")).hexdigest()
146
+ for key, (cls, name) in methods.items()
147
+ }
148
+ found["lm_eval.models.utils:Collator"] = hashlib.sha256(
149
+ class_source(Collator).encode("utf-8")
150
+ ).hexdigest()
151
+ return found
152
+
153
+
154
+ def _upstream_resets_batch_sizes() -> bool:
155
+ """Whether the installed lm-eval clears cached auto batch sizes per request set.
156
+
157
+ Read off the installed source rather than gated on a version string, so the mirror follows whatever is actually installed.
158
+ """
159
+ global _RESETS_BATCH_SIZES
160
+ if _RESETS_BATCH_SIZES is None:
161
+ _RESETS_BATCH_SIZES = (
162
+ "self.batch_sizes = {}" in method_source(HFLM, "_loglikelihood_tokens")
163
+ )
164
+ return _RESETS_BATCH_SIZES
165
+
166
+
167
+ _RESETS_BATCH_SIZES: bool | None = None
168
+
169
+
170
+ def check_upstream_source(strict: bool = True) -> dict[str, str]:
171
+ """Compare the installed lm-eval against the versions this file was written for.
172
+
173
+ An editable checkout can have the same version string and different source, so the hash is the only reliable signal.
174
+
175
+ Args:
176
+ strict: raise on mismatch.
177
+ Tests use True so CI fails first; a run uses False and only warns, because a changed upstream is not necessarily an incompatible one.
178
+
179
+ Returns:
180
+ The observed hashes, which also go into the manifest.
181
+
182
+ Raises:
183
+ RuntimeError: in strict mode, when a reviewed version's source has moved under it.
184
+ An lm-eval version nobody has reviewed yet only warns: it is unverified, which is not the same as known-broken.
185
+ """
186
+ import lm_eval
187
+
188
+ observed = upstream_source_hashes()
189
+ expected = UPSTREAM_SOURCE_HASHES.get(lm_eval.__version__)
190
+ if expected is None:
191
+ message = (
192
+ f"lm-eval {lm_eval.__version__} has not been reviewed against this backend. "
193
+ f"Reviewed versions: {sorted(UPSTREAM_SOURCE_HASHES)}. It may work - diff "
194
+ "`_loglikelihood_tokens` and `Collator` against backend.py, then add the "
195
+ "hashes to UPSTREAM_SOURCE_HASHES."
196
+ )
197
+ print(f"warning: {message}", file=sys.stderr)
198
+ return observed
199
+
200
+ drifted = [key for key, value in expected.items() if observed.get(key) != value]
201
+ if drifted and strict:
202
+ raise RuntimeError(
203
+ f"lm-eval {lm_eval.__version__} source changed for: "
204
+ + ", ".join(drifted)
205
+ + ". `_loglikelihood_tokens` is mirrored in backend.py; diff the upstream "
206
+ "method against it, port any change, then update UPSTREAM_SOURCE_HASHES."
207
+ )
208
+ return observed
209
+
210
+
211
+ # --------------------------------------------------------------------------
212
+ # Traced backend
213
+ # --------------------------------------------------------------------------
214
+
215
+
216
+ @register_model(BACKEND_NAME)
217
+ class TracedHFLM(HFLM):
218
+ """`HFLM` that reports which document each forward pass belongs to.
219
+
220
+ Scoring is untouched - every number lm-eval reports comes from the upstream code path.
221
+ The only additions are the document mapping and the hook session around the request loops.
222
+
223
+ Example:
224
+ >>> lm = TracedHFLM(pretrained="Qwen/Qwen3-1.7B", batch_size=1) # doctest: +SKIP
225
+ >>> lm.attach_recorder(recorder)
226
+ >>> lm_eval.simple_evaluate(model=lm, tasks=["xnli_ko"], log_samples=True)
227
+ """
228
+
229
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
230
+ super().__init__(*args, **kwargs)
231
+ self.prompt_transform = None
232
+ self._recorder: Recorder | None = None
233
+ # Request index -> (task_name, doc_id, choice_idx), rebuilt on every `loglikelihood` call.
234
+ self._request_docs: list[tuple[str, int, int]] = []
235
+ self._last_generated_tokens: list[int] = []
236
+ # lm-eval's own defaults are left alone, `logits_cache` included.
237
+ # It is what makes a score reproduce a published lm-eval number: the cache changes which requests share a forward pass, and a different batch shape changes the order a matmul accumulates in.
238
+ # In fp16 that is enough to move a borderline document - stock lm-eval scores 0.66 / 0.66 / 0.64 at batch 1 / 4 / 8 on the same 50 MMLU documents.
239
+ #
240
+ # The cache does mean fewer forward passes than requests, which sounds like it would leave holes in the signals.
241
+ # It does not: a cache group is a set of requests sharing `context + continuation[:-1]`, so they share the *input* and therefore the hidden states.
242
+ # One forward pass supplies every member; only the gold token, and so `target_rank`, differs.
243
+ # `_build_contexts` fans the pass out across the group.
244
+
245
+ def prepare_context(self, context: str) -> str:
246
+ """Apply a deterministic custom formatter once, before tokenization."""
247
+ if self.prompt_transform is None:
248
+ return context
249
+ transformed = self.prompt_transform(context)
250
+ if not isinstance(transformed, str):
251
+ raise TypeError("prompt_transform must return str")
252
+ return transformed
253
+
254
+ def _prepare_requests(self, requests):
255
+ # Keep evaluator-owned instances unchanged, including their saved prompt
256
+ # hashes. Replay reads those originals and applies the same formatter.
257
+ if self.prompt_transform is None:
258
+ return requests
259
+ import copy
260
+ prepared = []
261
+ for request in requests:
262
+ item = copy.copy(request)
263
+ item.arguments = (self.prepare_context(request.args[0]), *request.args[1:])
264
+ prepared.append(item)
265
+ return prepared
266
+
267
+ def loglikelihood_rolling(self, requests, disable_tqdm=False):
268
+ if self.prompt_transform is not None:
269
+ raise ValueError("prompt_transform does not support loglikelihood_rolling; use a likelihood or generation task")
270
+ if self._recorder is not None:
271
+ raise ValueError("internal signals do not support loglikelihood_rolling; use signals=()")
272
+ return super().loglikelihood_rolling(requests, disable_tqdm=disable_tqdm)
273
+
274
+ def attach_recorder(self, recorder: Recorder) -> None:
275
+ """Start recording.
276
+ Without this the backend behaves exactly like `HFLM`.
277
+
278
+ Warns on `batch_size="auto"`, which is not safe here. lm-eval sizes the batch by running a forward pass and seeing what fits, but that probe registers no scored positions, so the reducers do nothing and the measurement is of a model that is not the one about to run. Recording then adds memory the chosen batch has no room for.
279
+
280
+ Seen on OLMo-2-7B over boolq, whose contexts are long and whose vocabulary is 100,352: the stock backend settled on 45 and the traced one - the heavier of the two - probed *higher*, at 51, and died on the first real batch trying to allocate 2.76 GiB for the logits. Short contexts hide it, which is why arc_easy never showed it.
281
+ """
282
+ self._recorder = recorder
283
+ if self.batch_size == "auto":
284
+ print(
285
+ "warning: batch_size=auto sizes the batch with a probe that does not "
286
+ "record, so it can overshoot what recording leaves room for and "
287
+ "fail with CUDA out of memory partway through. Pass an explicit "
288
+ "batch size for a run you need to finish.",
289
+ file=sys.stderr,
290
+ )
291
+
292
+ # -- loglikelihood -------------------------------------------------------------
293
+
294
+ def loglikelihood(self, requests, disable_tqdm: bool = False):
295
+ """Same request tuples as upstream, plus the document mapping.
296
+
297
+ Mirrors `TemplateLM.loglikelihood`; the only addition is `self._request_docs`, which records what each tuple came from before the `Instance` is dropped.
298
+
299
+ `choice_idx` also comes from here: a multiple-choice document produces one consecutive `Instance` per choice, and the position within that run of identical `doc_id`s is the choice index.
300
+ """
301
+ requests = self._prepare_requests(requests)
302
+ if self._recorder is None:
303
+ return super().loglikelihood(requests, disable_tqdm=disable_tqdm)
304
+
305
+ new_reqs = []
306
+ self._request_docs = []
307
+ seen_choices: dict[tuple[str, int], int] = {}
308
+ for instance in requests:
309
+ context, continuation = instance.args
310
+ if context == "":
311
+ # BOS or EOS as context, matching upstream.
312
+ context_enc, continuation_enc = (
313
+ [self.prefix_token_id],
314
+ self.tok_encode(continuation),
315
+ )
316
+ else:
317
+ context_enc, continuation_enc = self._encode_pair(context, continuation)
318
+ new_reqs.append(((context, continuation), context_enc, continuation_enc))
319
+
320
+ key = (instance.task_name, instance.doc_id)
321
+ choice_idx = seen_choices.get(key, 0)
322
+ seen_choices[key] = choice_idx + 1
323
+ self._request_docs.append((instance.task_name, instance.doc_id, choice_idx))
324
+
325
+ with self._recorder.session():
326
+ return self._loglikelihood_tokens(new_reqs, disable_tqdm=disable_tqdm)
327
+
328
+ def _loglikelihood_tokens(
329
+ self,
330
+ requests: list[tuple[tuple[str, str], list[int], list[int]]],
331
+ disable_tqdm: bool = False,
332
+ override_bs: int | None = None,
333
+ ) -> list[tuple[float, bool]]:
334
+ """Upstream's batching loop with the document mapping threaded through.
335
+
336
+ Derived from `HFLM._loglikelihood_tokens` (lm-eval 0.4.9.1) and pinned by `check_upstream_source()`.
337
+ The scoring arithmetic is upstream's line for line, and the batching is upstream's `Collator` with upstream's parameters - not a re-derivation of it.
338
+ That is deliberate: which requests share a forward pass determines the batch shape, the batch shape determines matmul accumulation order, and in fp16 that is enough to move a score.
339
+ Reproducing a published lm-eval number means running the same batches, not merely the same formula.
340
+
341
+ What is added:
342
+
343
+ * before each forward, the documents that pass covers are declared to the recorder (`_build_contexts`);
344
+ * after each forward, the reduced rows are flushed.
345
+
346
+ Causal decoder models only: a seq2seq model scores on the decoder side, where the residual axis means something else.
347
+
348
+ Returns:
349
+ `(logprob, is_greedy)` per request in the original order, as upstream.
350
+ """
351
+ if self._recorder is None:
352
+ return super()._loglikelihood_tokens(requests, disable_tqdm, override_bs)
353
+ if self.backend != "causal":
354
+ raise NotImplementedError(
355
+ f"{BACKEND_NAME} traces causal decoder models only, got backend={self.backend!r}"
356
+ )
357
+
358
+ res = []
359
+
360
+ def _collate(req: tuple[tuple[str, str], list[int], list[int]]):
361
+ """Upstream's sort key: longest first, so an OOM happens on batch one."""
362
+ toks = req[1] + req[2]
363
+ return -len(toks), tuple(toks)
364
+
365
+ def _lookup_one_token_cont(req: tuple[tuple[str, str], list[int], list[int]]):
366
+ """Upstream's grouping key: context + continuation minus its last token."""
367
+ return req[-2] + req[-1][:-1]
368
+
369
+ re_ord = Collator(
370
+ requests,
371
+ sort_fn=_collate,
372
+ group_by="contexts"
373
+ if self.backend == "causal" and self.logits_cache
374
+ else None,
375
+ group_fn=_lookup_one_token_cont,
376
+ )
377
+
378
+ n_reordered_requests = len(re_ord)
379
+ batch_size = (
380
+ self.batch_size
381
+ if self.batch_size != "auto"
382
+ else override_bs
383
+ if override_bs is not None
384
+ else 0
385
+ )
386
+ batch_fn = (
387
+ self._batch_scheduler
388
+ if self.batch_size == "auto" and n_reordered_requests > 0 and not override_bs
389
+ else None
390
+ )
391
+
392
+ if batch_fn is not None and _upstream_resets_batch_sizes():
393
+ # Mirrors lm-eval 0.4.10+: clear cached auto batch sizes so detection runs against this request set.
394
+ # Done only when upstream does it, so the batching stays identical on either version - and identical batching is what keeps the scores identical.
395
+ self.batch_sizes = {}
396
+
397
+ chunks = re_ord.get_batched(n=batch_size, batch_fn=batch_fn)
398
+ pbar = tqdm(
399
+ total=len(requests),
400
+ disable=(disable_tqdm or (self.rank != 0)),
401
+ desc="Running loglikelihood requests",
402
+ )
403
+ # How many items the collator has handed out so far.
404
+ # Without grouping it records the original index of each one as it goes, so this counter is what turns a chunk row back into a document.
405
+ emitted = 0
406
+
407
+ for chunk in chunks:
408
+ inps, cont_toks_list, inplens = [], [], []
409
+ padding_len_inp = None
410
+
411
+ for _, context_enc, continuation_enc in chunk:
412
+ assert len(context_enc) > 0
413
+ assert len(continuation_enc) > 0
414
+ assert len(continuation_enc) <= self.max_length
415
+
416
+ # CTX CONT inp 0 1 2 3|4 5 6 7 8 9 <- last token dropped by [:-1] logits 1 2 3|4 5 6 7 8 9 scored 4 5 6 7 8 9 Too long for the window: truncate on the left, like upstream.
417
+ inp = torch.tensor(
418
+ (context_enc + continuation_enc)[-(self.max_length + 1) :][:-1],
419
+ dtype=torch.long,
420
+ device=self.device,
421
+ )
422
+ (inplen,) = inp.shape
423
+ padding_len_inp = (
424
+ max(padding_len_inp, inplen) if padding_len_inp is not None else inplen
425
+ )
426
+ inps.append(inp)
427
+ cont_toks_list.append(continuation_enc)
428
+ inplens.append(inplen)
429
+
430
+ batched_inps = pad_and_concat(padding_len_inp, inps, padding_side="right")
431
+
432
+ self._recorder.expect_loglikelihood(
433
+ self._build_contexts(chunk, inplens, cont_toks_list, batched_inps.shape[1],
434
+ padding_len_inp, re_ord, emitted)
435
+ )
436
+ multi_logits = F.log_softmax(
437
+ self._model_call(batched_inps), dim=-1, dtype=self.softmax_dtype
438
+ ) # [batch, padding_len_inp, vocab]
439
+ emitted += len(chunk)
440
+
441
+ for (request_str, ctx_tokens, _), logits, inplen, cont_toks in zip(
442
+ chunk, multi_logits, inplens, cont_toks_list
443
+ ):
444
+ contlen = len(cont_toks)
445
+ ctx_len = inplen + (logits.shape[0] - padding_len_inp)
446
+ logits = self._select_cont_toks(logits, contlen=contlen, inplen=ctx_len)
447
+ logits = logits.unsqueeze(0) # [1, seq, vocab]
448
+ greedy_tokens = logits.argmax(dim=-1)
449
+
450
+ # Fans a cache group back out into its members, or is a no-op when grouping is off.
451
+ for request_str, cont_toks, logits in re_ord.get_cache( # noqa: B020
452
+ req_str=request_str,
453
+ cxt_toks=ctx_tokens,
454
+ cont_toks=cont_toks,
455
+ logits=logits,
456
+ ):
457
+ cont_toks = torch.tensor(
458
+ cont_toks, dtype=torch.long, device=self.device
459
+ ).unsqueeze(0) # [1, seq]
460
+ max_equal = (greedy_tokens[:, -cont_toks.shape[1] :] == cont_toks).all()
461
+ logits = torch.gather(logits, 2, cont_toks.unsqueeze(-1)).squeeze(-1)
462
+
463
+ answer = (float(logits.sum()), bool(max_equal))
464
+ res.append(answer)
465
+ if request_str is not None:
466
+ self.cache_hook.add_partial("loglikelihood", request_str, answer)
467
+ pbar.update(1)
468
+
469
+ self._recorder.flush()
470
+
471
+ pbar.close()
472
+ return re_ord.get_original(res)
473
+
474
+ def _build_contexts(
475
+ self,
476
+ chunk: Sequence[tuple[tuple[str, str], list[int], list[int]]],
477
+ inplens: Sequence[int],
478
+ cont_toks_list: Sequence[list[int]],
479
+ logits_len: int,
480
+ padding_len_inp: int,
481
+ re_ord: Collator,
482
+ emitted: int,
483
+ ) -> list[ForwardContext]:
484
+ """Describe, per batch row, which documents and positions this pass covers.
485
+
486
+ lm-eval scores the continuation tokens, and the logits that produce them sit at input positions `[inplen - contlen, inplen)`: the last context token predicts the first continuation token, and it runs on from there.
487
+ Those are the positions the signals are taken at - we do not offer a "which position?" option, we follow whatever lm-eval scores.
488
+
489
+ When `logits_cache` is on, one row stands for a whole group of requests that share `context + continuation[:-1]`.
490
+ They share the input, so they share the hidden states; each member gets its own context, differing only in `choice_idx` and in the gold tokens that `target_rank` is measured against.
491
+ A member whose continuation is shorter than the group representative's scores the trailing part of the same span, matching upstream's `greedy_tokens[:, -len(cont):]`.
492
+
493
+ Example: an MMLU document's four choices are " A".." D" after an identical context, so one forward pass covers all four, and this returns four contexts pointing at the same batch row and position.
494
+ """
495
+ contexts = []
496
+ for row, (_, context_enc, continuation_enc) in enumerate(chunk):
497
+ # Same correction as upstream, for models that insert virtual tokens (prompt/prefix tuning) ahead of the real input.
498
+ ctx_len = inplens[row] + (logits_len - padding_len_inp)
499
+ for request_index, member_cont in self._group_members(
500
+ re_ord, context_enc, continuation_enc, emitted + row
501
+ ):
502
+ task_name, doc_id, choice_idx = self._request_docs[request_index]
503
+ contlen = len(member_cont)
504
+ contexts.append(
505
+ ForwardContext(
506
+ task_name=task_name,
507
+ doc_id=doc_id,
508
+ choice_idx=choice_idx,
509
+ steps=list(range(contlen)),
510
+ positions=list(range(ctx_len - contlen, ctx_len)),
511
+ target_token_ids=list(member_cont),
512
+ n_residual=self._recorder.adapter.n_residual,
513
+ n_blocks=self._recorder.adapter.n_blocks,
514
+ task_kind="loglikelihood",
515
+ batch_row=row,
516
+ input_length=inplens[row],
517
+ input_offset=max(0, len(context_enc) + len(continuation_enc) - 1 - inplens[row]),
518
+ )
519
+ )
520
+ return contexts
521
+
522
+ def _group_members(
523
+ self,
524
+ re_ord: Collator,
525
+ context_enc: list[int],
526
+ continuation_enc: list[int],
527
+ position: int,
528
+ ) -> list[tuple[int, list[int]]]:
529
+ """Every original request this batch row will produce an answer for.
530
+
531
+ Two bookkeeping paths, because the collator tracks indices differently:
532
+
533
+ * grouping on (`logits_cache` on) - the group is still sitting in the collator under its context key, and each entry carries its original index.
534
+ `get_cache` pops it later, after this has read it.
535
+ * grouping off - the collator records the original index of each item as it hands it out, so the item handed out `position`-th is `_reorder_indices[position]`.
536
+
537
+ Both read the collator's own bookkeeping rather than recomputing it; the source-hash check covers `Collator` for exactly that reason.
538
+ """
539
+ if re_ord._group_by == "contexts":
540
+ key = tuple(context_enc + continuation_enc[:-1])
541
+ group = re_ord._arr_with_indices[key]
542
+ return [(index, item[-1]) for index, item in group]
543
+ return [(re_ord._reorder_indices[position], continuation_enc)]
544
+
545
+ # -- phase labels --------------------------------------------------------------
546
+
547
+ def _detect_batch_size(self, *args: Any, **kwargs: Any):
548
+ """Keep auto-batch probes out of sample-level research statistics."""
549
+ with debug.without_samples():
550
+ return super()._detect_batch_size(*args, **kwargs)
551
+
552
+ def _model_call(self, *args: Any, **kwargs: Any):
553
+ """Upstream's forward, labelled as the model's own work.
554
+
555
+ The label is what lets a module tracer tell three things apart that all run the same modules: the model computing what lm-eval scores, our reducers re-entering `final_norm` and `lm_head` for the logit lens, and everything else.
556
+ `debug.phase` is a no-op when no tracer is attached, so this costs nothing in a normal run.
557
+
558
+ Note what is deliberately *not* labelled: the `log_softmax` over `[batch, seq, vocab]` inside `_loglikelihood_tokens`. It is a mirror of upstream and is kept diffable, and it is not an `nn.Module` so no hook fires there anyway. A trace whose last event is the model exiting cleanly, followed by an error in phase `other`, is how that allocation announces itself.
559
+ """
560
+ with debug.phase("model_forward"):
561
+ return super()._model_call(*args, **kwargs)
562
+
563
+ # -- generate ------------------------------------------------------------------
564
+
565
+ def generate_until(self, requests, disable_tqdm: bool = False):
566
+ """One request at a time, so each generation maps to exactly one document.
567
+
568
+ Batch 1 is forced.
569
+ Batching generations would mix documents of different generated lengths in one tensor, and every hook call after the shortest one has hit EOS would be a padding step that has to be filtered out.
570
+ Batched generation is a TODO.
571
+
572
+ Generation length is not ours to choose: whatever the lm-eval task config asks for is what runs, and the manifest records it.
573
+ """
574
+ requests = self._prepare_requests(requests)
575
+ if self._recorder is None:
576
+ return super().generate_until(requests, disable_tqdm=disable_tqdm)
577
+
578
+ # Its own bar over documents. The per-instance calls below pass `disable_tqdm=True` - a bar per document would be 1,531 bars - so without this a generate run prints nothing at all from start to finish, which on a multi-hour task is indistinguishable from hung.
579
+ from tqdm import tqdm
580
+
581
+ results: list[str] = []
582
+ with self._recorder.session():
583
+ for instance in tqdm(requests, disable=disable_tqdm,
584
+ desc="Running generate_until requests (traced)"):
585
+ self._recorder.expect_generation(
586
+ task_name=instance.task_name, doc_id=instance.doc_id, prompt_length=0
587
+ )
588
+ if self._recorder._plan is not None:
589
+ original = generation_prompt_origin(self, instance.args[0])
590
+ if original is not None:
591
+ self._recorder._plan["original_prompt_length"] = original
592
+ # `super()` runs the real generation path, hooks and all.
593
+ output = super().generate_until([instance], disable_tqdm=True)
594
+ self._recorder.set_generated_tokens(self._last_generated_tokens)
595
+ self._recorder.flush()
596
+ results.extend(output)
597
+ return results
598
+
599
+ def _model_generate(self, context, max_length, stop, **generation_kwargs):
600
+ """Note the prompt length, then keep the token ids that came out.
601
+
602
+ The prompt length turns a decoding step into an absolute sequence position, and the emitted tokens fill the `steps` table - with sampling on they can differ from the last layer's top-1.
603
+ """
604
+ if self._recorder is not None:
605
+ self._recorder.set_prompt_length(int(context.shape[1]))
606
+ with debug.phase("model_forward"):
607
+ output = super()._model_generate(context, max_length, stop, **generation_kwargs)
608
+ if self._recorder is not None:
609
+ self._last_generated_tokens = output[0, context.shape[1] :].tolist()
610
+ return output
611
+
612
+
613
+ def generation_prompt_ids(lm: Any, prompt: str) -> list[int]:
614
+ """The ids lm-eval's `generate_until` encodes one prompt to, before any truncation.
615
+
616
+ That is `tok_batch_encode`, not `tok_encode`, and under lm-eval 0.4.13 the two decide on special tokens by different rules: the batch encoder skips them when the prompt starts with `tokenizer.bos_token`, `tok_encode` only when it starts with `decode(prefix_token_id)`.
617
+ With a BOS-prepending tokenizer, a prompt that already starts with the BOS text and a `prefix_token_id` that is not the BOS, `tok_encode` gives one token more (`<s><s>...` against the scored `<s>...`).
618
+ Collection replayed that longer prompt, and the first pass's `original_prompt_length` counted a truncated token that never was (`scripts/verify_bos_prompt_mismatch.py`).
619
+ """
620
+ input_ids, _ = lm.tok_batch_encode([prompt])
621
+ return input_ids[0].tolist()
622
+
623
+
624
+ def generation_prompt_origin(lm: Any, prompt: str) -> int | None:
625
+ """The prompt length that makes `input_offset` count only tokens removed from the left, or None when no such count exists.
626
+
627
+ The recorder takes `input_offset` as this length minus the prompt length the model received, and `input_offset` means tokens removed from the left.
628
+ Without `truncation`, `generate_until` only keeps the last `max_length - max_gen_toks` ids, so this is the untruncated length.
629
+ With `truncation=True` the tokenizer first cuts at `model_max_length` on its `truncation_side`: a right cut removes nothing from the left, so the length is what the tokenizer kept; a left cut does, so it is the untruncated length again.
630
+ A cut that is not a contiguous slice of the untruncated ids on that side - a tokenizer that puts BOS back after cutting from the left - removed no single left run of tokens, and None leaves `input_offset` unknown.
631
+ Measured before this: a right-cut 87-token prompt kept its first 32 tokens and was recorded with `input_offset` 55.
632
+ """
633
+ full = generation_prompt_ids(lm, prompt)
634
+ if not getattr(lm, "truncation", False):
635
+ return len(full)
636
+ input_ids, _ = lm.tok_batch_encode([prompt], truncation=True)
637
+ kept = input_ids[0].tolist()
638
+ if kept == full:
639
+ return len(full)
640
+ if getattr(lm.tokenizer, "truncation_side", "right") == "left":
641
+ return len(full) if kept == full[len(full) - len(kept):] else None
642
+ return len(kept) if kept == full[:len(kept)] else None
643
+
644
+
645
+ def refuse_truncated_generate_collection(lm: Any, samples: Sequence[dict[str, Any]]) -> None:
646
+ """Refuse to collect a generate task from a model loaded with `truncation=True`.
647
+
648
+ With it, `generate_until` asks the tokenizer to cut each prompt at its `model_max_length`, on its `truncation_side` (the right by default, so the end of the prompt goes) before lm-eval's own left truncation.
649
+ The run records neither the cut nor the side, so the collection pass cannot feed the prompt that was scored: on a GPU a 32-token tokenizer scored the first 32 tokens of an 87-token prompt, collection fed all 87 plus the generation, and the teacher-forced audit failed.
650
+ Loglikelihood requests do not go through that truncation, so only generate documents are refused, and before any forward pass.
651
+ """
652
+ if not getattr(lm, "truncation", False):
653
+ return
654
+ generate = sorted({sample["task_name"] for sample in samples
655
+ if not (sample.get("filtered_resps") and isinstance(sample["filtered_resps"][0], (list, tuple)))})
656
+ if generate:
657
+ raise ValueError(
658
+ f"cannot collect generate tasks {generate} from a model loaded with truncation=True: the tokenizer "
659
+ "cut their prompts at model_max_length on its truncation side while scoring, and the run does not "
660
+ "record that cut, so the collection pass would feed tokens the model never scored. Run with "
661
+ "truncation=False (the default) to collect generate tasks.")
662
+
663
+
664
+ # --------------------------------------------------------------------------
665
+ # Collection: run a finished run's prompts again, without lm-eval
666
+ # --------------------------------------------------------------------------
667
+
668
+
669
+ def select_documents_to_collect(
670
+ samples: Sequence[dict[str, Any]], limit: int, seed: int = SAMPLING_SEED
671
+ ) -> tuple[list[dict[str, Any]], dict[str, int]]:
672
+ """Pick up to `limit` documents per task and correctness group reproducibly.
673
+
674
+ Balanced on purpose: contrasting right against wrong answers is the main use of the attention and hidden-state dumps.
675
+ Taking the first N documents instead would be worse than it looks - many datasets are ordered by subject or genre, so the head of the file is one narrow slice.
676
+
677
+ Correctness comes from lm-eval's own grading in `samples.jsonl`; deciding it ourselves would mean reimplementing scoring, which is the one thing this tool does not do.
678
+ That is also why collection is a second pass: at forward time during the first pass, nothing knows yet which documents were right.
679
+
680
+ Args:
681
+ samples: records from `samples.jsonl`.
682
+ limit: N per group.
683
+ Fewer are taken when a group is smaller, and the real counts are reported back for the manifest.
684
+
685
+ Returns:
686
+ (selected samples in document order, counts actually taken).
687
+
688
+ Example:
689
+ >>> picked, counts = select_documents_to_collect(samples, limit=500) # doctest: +SKIP
690
+ >>> counts
691
+ {'correct': 500, 'incorrect': 233, 'unknown': 0}
692
+ """
693
+ from .storage import _extract_is_correct, primary_filters
694
+
695
+ if limit < 0:
696
+ raise ValueError("collection limit must be nonnegative")
697
+ primary = primary_filters(list(samples))
698
+ groups = {}
699
+ seen = set()
700
+ for sample in samples:
701
+ task = sample["task_name"]
702
+ if sample.get("filter") is not None and sample["filter"] != primary.get(task):
703
+ continue
704
+ identity = (task, int(sample["doc_id"]))
705
+ if identity in seen:
706
+ continue
707
+ seen.add(identity)
708
+ verdict = _extract_is_correct(sample)
709
+ key = "unknown" if verdict is None else ("correct" if verdict else "incorrect")
710
+ groups.setdefault((task, key), []).append(sample)
711
+
712
+ rng = random.Random(seed)
713
+ picked = []
714
+ counts = {"correct": 0, "incorrect": 0, "unknown": 0}
715
+ for (task, key), pool in sorted(groups.items()):
716
+ pool.sort(key=lambda sample: int(sample["doc_id"]))
717
+ take = min(limit, len(pool))
718
+ picked.extend(rng.sample(pool, take))
719
+ counts[key] += take
720
+ picked.sort(key=lambda s: (s["task_name"], int(s["doc_id"])))
721
+ return picked, counts
722
+
723
+
724
+ class ResearchDataCollector:
725
+ """Feed a finished run's prompts through the model again to collect more signals.
726
+
727
+ lm-eval is not involved: scoring finished in the first pass, and all that is left is one more forward pass with different hooks attached.
728
+
729
+ Prompts are reused verbatim from `samples.jsonl` rather than rebuilt.
730
+ Rebuilding them would change the few-shot examples: the few-shot sampler carries its random state forward, so evaluating a subset of documents hands each of them different examples than the first pass did.
731
+ The attention maps would then belong to prompts the model never saw during scoring - and would look completely normal.
732
+ Re-running lm-eval on a subset has the same problem plus renumbered `doc_id`s.
733
+
734
+ Both passes must produce the same input tokens, which is checked three ways: identical tokenizer settings (taken from the run's manifest, never re-entered by the user), batch size 1 so padding cannot differ, and a prompt hash comparison against `samples.jsonl`.
735
+
736
+ Being a second pass is not necessarily slower.
737
+ `eager` attention only runs on at most 2N documents here, where a single-pass version would have to run the entire task in eager.
738
+
739
+ Example:
740
+ >>> runner = ResearchDataCollector(lm, recorder, run_dir) # doctest: +SKIP
741
+ >>> runner.run(limit=500)
742
+ {'correct': 500, 'incorrect': 500, 'unknown': 0, 'documents': 1000}
743
+ """
744
+
745
+ def __init__(self, lm: HFLM, recorder: Recorder, run_dir: str | os.PathLike) -> None:
746
+ self.lm = lm
747
+ self.recorder = recorder
748
+ self.run_dir = str(run_dir)
749
+ self._emitted: dict[tuple[str, int], list[Any]] = {}
750
+ self._has_steps = False
751
+
752
+ def run(self, limit: int, seed: int = SAMPLING_SEED) -> dict[str, int]:
753
+ """Collect from a balanced sample of the run's documents.
754
+
755
+ Returns:
756
+ The counts actually collectioned, to be written into the manifest.
757
+ """
758
+ samples = read_samples(self.run_dir)
759
+ picked, counts = select_documents_to_collect(samples, limit=limit, seed=seed)
760
+ refuse_truncated_generate_collection(self.lm, picked)
761
+ self._emitted = self._recorded_generations(picked)
762
+
763
+ documents = 0
764
+ with self.recorder.session():
765
+ for sample in picked:
766
+ documents += self._collection_one(sample)
767
+ counts["documents"] = documents
768
+ return counts
769
+
770
+ def _collection_one(self, sample: dict[str, Any]) -> int:
771
+ """Collect from every forward pass of one document.
772
+
773
+ Flushed once per (document, choice), not once per document: the tensor dumps are one file per forward pass, and a multiple-choice document runs one forward per choice.
774
+ """
775
+ arguments = sample.get("arguments") or []
776
+ responses = sample.get("filtered_resps") or []
777
+ is_loglikelihood = bool(responses) and isinstance(responses[0], (list, tuple))
778
+
779
+ self._verify_prompt(sample, arguments)
780
+ if is_loglikelihood:
781
+ for choice_idx, args in enumerate(arguments):
782
+ if not self.recorder.custom.begin_unit(sample["task_name"], int(sample["doc_id"]), choice_idx):
783
+ continue
784
+ context, continuation = args[0], args[1]
785
+ context = self.lm.prepare_context(context) if hasattr(self.lm, "prepare_context") and not sample.get("model_prompt_prepared") else context
786
+ self._forward_loglikelihood(
787
+ sample["task_name"], int(sample["doc_id"]), choice_idx, context, continuation
788
+ )
789
+ self.recorder.flush()
790
+ self.recorder.custom.finish_unit()
791
+ else:
792
+ if not self.recorder.custom.begin_unit(sample["task_name"], int(sample["doc_id"]), 0):
793
+ return 1
794
+ context = arguments[0][0] if arguments else ""
795
+ context = self.lm.prepare_context(context) if hasattr(self.lm, "prepare_context") and not sample.get("model_prompt_prepared") else context
796
+ generated = self._generated_tokens(sample)
797
+ self._forward_generation(
798
+ sample["task_name"], int(sample["doc_id"]), context, generated
799
+ )
800
+ self.recorder.flush()
801
+ self.recorder.custom.finish_unit()
802
+ return 1
803
+
804
+ def _recorded_generations(self, picked: Sequence[dict[str, Any]]) -> dict[tuple[str, int], list[Any]]:
805
+ """The token ids each picked generate document emitted, from the first pass's `steps` table.
806
+
807
+ Collection teacher-forces these ids, not any text. It used to replay `filtered_resps`, which is what a task's filters made of the generation: for gsm8k the extracted answer, `[invalid]` when nothing matched. A document that generated 256 tokens was then collected over a three-token string, and every attention map, hidden-state dump and module statistic of the collection pass described that string. The raw `resps` text is closer but is still not the input - decoding and re-tokenising does not round-trip (283 tokens back from 256 emitted on a random Mixtral) - whereas the recorded ids are exactly what the model emitted and what the first pass's `steps` and signals describe.
808
+ """
809
+ from .storage import read_table
810
+
811
+ wanted = set()
812
+ for sample in picked:
813
+ responses = sample.get("filtered_resps") or []
814
+ if responses and not isinstance(responses[0], (list, tuple)):
815
+ wanted.add((sample["task_name"], int(sample["doc_id"])))
816
+ steps = read_table(self.run_dir, "steps")
817
+ self._has_steps = bool(len(steps))
818
+ emitted: dict[tuple[str, int], list[Any]] = {}
819
+ if not wanted or not self._has_steps:
820
+ return emitted
821
+ rows = steps[steps.choice_idx == 0]
822
+ for (task, doc), group in rows.groupby(["task_name", "doc_id"]):
823
+ key = (str(task), int(doc))
824
+ if key in wanted:
825
+ emitted[key] = group.sort_values("step").token_id.tolist()
826
+ return emitted
827
+
828
+ def _generated_tokens(self, sample: dict[str, Any]) -> list[int]:
829
+ """Token ids to teacher-force for one generate document; see `_recorded_generations`."""
830
+ key = (sample["task_name"], int(sample["doc_id"]))
831
+ if key in self._emitted:
832
+ ids = self._emitted[key]
833
+ if any(token is None or token != token for token in ids):
834
+ raise RuntimeError(
835
+ f"the steps table has a missing token id for {key[0]} doc {key[1]}, so its "
836
+ "generation cannot be replayed exactly")
837
+ return [int(token) for token in ids]
838
+ if self._has_steps:
839
+ raise RuntimeError(
840
+ f"no generated tokens recorded for {key[0]} doc {key[1]} in the steps table; "
841
+ "refusing to replay a different sequence than the one the first pass scored")
842
+ # A run without internal signals writes no steps table, so lm-eval's raw generation - never the filtered answer - is the only record of what was generated.
843
+ raw = (sample.get("resps") or [[""]])[0]
844
+ raw = raw[0] if isinstance(raw, (list, tuple)) else raw
845
+ return self.lm.tok_encode(str(raw), add_special_tokens=False)
846
+
847
+ def _verify_prompt(self, sample: dict[str, Any], arguments: Sequence[Sequence[str]]) -> None:
848
+ """Check the reused prompt is byte-identical to the one that was scored.
849
+
850
+ `samples.jsonl` carries the sha256 of the first request's context; if it does not match, the two passes are looking at different prompts and the signals must not be attributed to this document.
851
+ """
852
+ from lm_eval.utils import hash_string
853
+
854
+ expected = sample.get("prompt_hash")
855
+ if not expected or not arguments:
856
+ return
857
+ actual = hash_string(arguments[0][0])
858
+ if actual != expected:
859
+ raise RuntimeError(
860
+ f"prompt hash mismatch for {sample['task_name']} doc {sample['doc_id']}: "
861
+ f"samples.jsonl says {expected[:12]}..., collection built {actual[:12]}.... "
862
+ "The two passes are not running the same prompt."
863
+ )
864
+
865
+ def _forward_loglikelihood(
866
+ self, task_name: str, doc_id: int, choice_idx: int, context: str, continuation: str
867
+ ) -> None:
868
+ """One (context, continuation) pair, tokenised exactly as the first pass was."""
869
+ if context == "":
870
+ context_enc, continuation_enc = [self.lm.prefix_token_id], self.lm.tok_encode(continuation)
871
+ else:
872
+ context_enc, continuation_enc = self.lm._encode_pair(context, continuation)
873
+
874
+ inp = torch.tensor(
875
+ (context_enc + continuation_enc)[-(self.lm.max_length + 1) :][:-1],
876
+ dtype=torch.long,
877
+ device=self.lm.device,
878
+ ).unsqueeze(0)
879
+ inplen = inp.shape[1]
880
+ contlen = len(continuation_enc)
881
+ positions = list(range(inplen - contlen, inplen))
882
+
883
+ self.recorder.expect_loglikelihood(
884
+ [
885
+ ForwardContext(
886
+ task_name=task_name,
887
+ doc_id=doc_id,
888
+ choice_idx=choice_idx,
889
+ steps=list(range(contlen)),
890
+ positions=positions,
891
+ target_token_ids=list(continuation_enc),
892
+ n_residual=self.recorder.adapter.n_residual,
893
+ n_blocks=self.recorder.adapter.n_blocks,
894
+ task_kind="loglikelihood",
895
+ input_length=inplen,
896
+ input_offset=max(0, len(context_enc) + len(continuation_enc) - 1 - inplen),
897
+ )
898
+ ]
899
+ )
900
+ with torch.no_grad(), debug.phase("model_forward"):
901
+ self.lm.model(inp)
902
+
903
+ def _forward_generation(
904
+ self, task_name: str, doc_id: int, context: str, generated_enc: Sequence[int]
905
+ ) -> None:
906
+ """Teacher-force the tokens the first pass generated, instead of generating again.
907
+
908
+ Re-generating would produce different text whenever sampling is on, and the signals would then describe an output the run never scored.
909
+ Feeding prompt + generation in one pass is also cheaper than decoding.
910
+
911
+ Caveat, and it belongs in the output description as well: a single packed forward pass and an incremental KV-cache decode are not bitwise identical, so collectioned generate signals can differ slightly in the last digits from what the first pass saw.
912
+ """
913
+ context_enc = generation_prompt_ids(self.lm, context)
914
+ generated_enc = [int(token) for token in generated_enc]
915
+ if not generated_enc:
916
+ return
917
+ ids = (context_enc + generated_enc)[-self.lm.max_length :]
918
+ prompt_len = len(ids) - len(generated_enc)
919
+ inp = torch.tensor(ids, dtype=torch.long, device=self.lm.device).unsqueeze(0)
920
+
921
+ # `step t` is the position that predicted generated token t, matching the first pass, where step 0 is the last prefill position.
922
+ positions = list(range(prompt_len - 1, prompt_len - 1 + len(generated_enc)))
923
+ self.recorder.expect_loglikelihood(
924
+ [
925
+ ForwardContext(
926
+ task_name=task_name,
927
+ doc_id=doc_id,
928
+ choice_idx=0,
929
+ steps=list(range(len(generated_enc))),
930
+ positions=positions,
931
+ target_token_ids=None, # generate has no gold token to rank
932
+ step_token_ids=list(generated_enc),
933
+ n_residual=self.recorder.adapter.n_residual,
934
+ n_blocks=self.recorder.adapter.n_blocks,
935
+ task_kind="generate",
936
+ input_length=len(ids),
937
+ input_offset=max(0, len(context_enc) + len(generated_enc) - len(ids)),
938
+ )
939
+ ]
940
+ )
941
+ with torch.no_grad(), debug.phase("model_forward"):
942
+ self.lm.model(inp)