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/debug.py ADDED
@@ -0,0 +1,1421 @@
1
+ """Per-module execution tracing, for finding out where a forward pass died.
2
+
3
+ This is a debugging aid, off by default, and deliberately separate from `recorder.py`.
4
+ The recorder exists to capture *signals* and is driven by what the reducers ask for; this exists to capture *what happened*, must keep working when the recorder is not recording (a batch-size probe, a document already on disk), and must survive the process that produced it.
5
+
6
+ CLI options are available through `evalmetry debug --help`; additional review tasks are in REVIEW_CPU.md and REVIEW_GPU.md.
7
+ Three things are worth repeating here, because they are what the code looks strange without.
8
+
9
+ **`always_call=True` is not used, on purpose.**
10
+ PyTorch's always-called forward hooks fire for every ancestor when a forward raises, innermost first, with `output=None`.
11
+ A tracer that used them would see its module stack unwind perfectly on the way out and would lose the only record of where execution stopped.
12
+ Without the flag the pops simply do not happen, so whatever is left on the stack *is* the failure path, root first and failure site last.
13
+
14
+ **Shapes are free; values are not; the allocator's counters are cheap only if you ask correctly.**
15
+ Reading `tensor.shape` queues no kernel and syncs nothing, and neither does reading the caching allocator's host-side counters - so the execution trace never adds memory pressure and is safe to leave on in a run that is already near the ceiling.
16
+ `torch.isnan(x).any()` allocates a bool tensor the size of `x`, and reading the count back blocks on the device.
17
+ That is why numeric diagnosis is a second flag rather than a verbosity level: switched on in an OOM-prone run it can be the allocation that kills it.
18
+
19
+ Costing nothing in *memory* is not the same as costing nothing in *time*, and the first version of this file conflated them. `torch.cuda.memory_allocated()` is 69us because it builds the entire statistics dict to return one number; three per event was most of a 6.5x slowdown. See `_build_memory_reader`.
20
+
21
+ **The logit lens re-enters the model.**
22
+ `adapters._build_decode_stack` calls the model's real `final_norm` and `lm_head`, inside the decoder's own forward hook.
23
+ Every event therefore carries a phase, and `forward_id` does not advance for work done in `signal_collection`.
24
+
25
+ Example:
26
+ >>> cfg = DebugConfig(enabled=True, numeric=True) # doctest: +SKIP
27
+ >>> tracer = ModuleTracer(model, cfg, "run/debug/trace.jsonl")
28
+ >>> with tracer.session():
29
+ ... with phase("model_forward"):
30
+ ... model(input_ids)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+ import traceback
40
+ from collections import deque
41
+ from contextlib import contextmanager
42
+ from dataclasses import dataclass, field
43
+ from datetime import datetime, timezone
44
+ from typing import Any, Iterable, Iterator, Sequence
45
+
46
+
47
+ #: Version of the trace record layout, written into the header.
48
+ TRACE_SCHEMA_VERSION = "1.1"
49
+
50
+ # : How many events `--debug-tail` keeps when no size is given.
51
+ # A 32-layer model has roughly 500 modules, so one loglikelihood forward is about 1000
52
+ # events and a generate document is that many per decoded token; five thousand is four or
53
+ # five forwards, which is the window a post-mortem reads.
54
+ TRACE_BUFFER_EVENTS = 5000
55
+
56
+ #: Deepest nesting level described for a container passed to a module.
57
+ DESCRIBE_DEPTH = 2
58
+
59
+ #: Most items described inside one container.
60
+ DESCRIBE_WIDTH = 8
61
+
62
+ #: The name given to the model object itself, which `named_modules()` calls "".
63
+ ROOT_NAME = "<root>"
64
+
65
+ #: Phase set when nothing has claimed the work: lm-eval's own code, scoring, the batch-size probe.
66
+ DEFAULT_PHASE = "other"
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # Configuration
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass
75
+ class DebugConfig:
76
+ """What tracing was asked for.
77
+
78
+ Kept out of `RunConfig.identity`: tracing changes how long a run takes and, with `sync` or `stop_on_nonfinite`, whether it finishes, but it does not change what the signals mean.
79
+ Two runs differing only in these flags belong in the same directory.
80
+
81
+ Attributes:
82
+ enabled: register hooks at all.
83
+ modules: regex matched against the dotted module path, e.g. `r"layers\\.\\d+$"`.
84
+ Unset traces every module.
85
+ numeric: also summarise tensor values - NaN/Inf counts and the finite range.
86
+ Costs several same-size intermediates and a device sync per tensor.
87
+ stop_on_nonfinite: raise at the first module whose output is not all finite.
88
+ Implies `numeric`. Changes what the run does, so it is recorded in the manifest.
89
+ tail: keep only the last N events in memory and write them at the end, instead of writing every event as it happens.
90
+ Off by default, because "trace the run" should mean the whole run: a buffer that silently discards everything but the last few forwards is the right tool for a post-mortem and the wrong one for reading what a given document did.
91
+ Worth setting when hunting a crash in a long run, where the full trace would be gigabytes and only the end of it matters. Note that the buffered trace is written when the session ends, so it does not survive the process being killed without an exception - which is what host RAM exhaustion looks like.
92
+ sync: `torch.cuda.synchronize()` at every module boundary.
93
+ Pins an asynchronous device fault to the module that caused it, at a large cost in wall time and at the risk of changing whether an intermittent failure reproduces. Buys nothing for OOM, whose allocation is synchronous already.
94
+ """
95
+
96
+ enabled: bool = False
97
+ modules: str | None = None
98
+ numeric: bool = False
99
+ stop_on_nonfinite: bool = False
100
+ tail: int | None = None
101
+ sync: bool = False
102
+ # Research statistics use document/position mappings, independently of the
103
+ # optional whole-tensor diagnostics in the execution trace.
104
+ sample_stats: bool = False
105
+ # Largest absolute values per document and call, with their positions. Both the
106
+ # modules and k are explicit: this writes k rows per document, tensor and call,
107
+ # which is a different order of output from one moment row.
108
+ extremes: int = 0
109
+ extremes_modules: str | None = None
110
+ # Statistics of one axis of the same slice: `feature`, `position` or `both`. One row
111
+ # per index rather than per tensor, so the modules are named separately again.
112
+ axes: str | None = None
113
+ axes_modules: str | None = None
114
+ # Which routers record the expert selection they returned. One selector is enough:
115
+ # the k, the expert ids and the weights are the router's own output.
116
+ routing: str | None = None
117
+ # Elements per float64 reduction chunk. None keeps module_stats.CHUNK_ELEMENTS; the
118
+ # stored values must not depend on it, only the size of the temporary buffers does.
119
+ chunk_elements: int | None = None
120
+
121
+ def __post_init__(self) -> None:
122
+ if self.stop_on_nonfinite:
123
+ self.numeric = True
124
+ if self.chunk_elements is not None:
125
+ if self.chunk_elements < 1:
126
+ raise ValueError("--module-stats-chunk-elements must be at least 1")
127
+ if not self.sample_stats:
128
+ raise ValueError("--module-stats-chunk-elements needs --module-stats: it "
129
+ "sets how the moments are reduced")
130
+ if bool(self.extremes) != (self.extremes_modules is not None):
131
+ raise ValueError("--module-stats-extremes and --module-stats-extremes-modules "
132
+ "are set together: k alone would collect positions for every "
133
+ "traced module, and a selector alone collects nothing")
134
+ if self.extremes < 0:
135
+ raise ValueError("--module-stats-extremes must not be negative")
136
+ if self.extremes and not self.sample_stats:
137
+ raise ValueError("--module-stats-extremes needs --module-stats: extreme "
138
+ "positions are recorded beside the moments of the same tensor")
139
+ if self.extremes_modules is not None:
140
+ try:
141
+ re.compile(self.extremes_modules)
142
+ except re.error as error:
143
+ raise ValueError("--module-stats-extremes-modules is not a valid regular "
144
+ f"expression: {error}") from error
145
+ if bool(self.axes) != (self.axes_modules is not None):
146
+ raise ValueError("--module-stats-axes and --module-stats-axes-modules are set "
147
+ "together: an axis alone would write a row per channel of "
148
+ "every traced module, and a selector alone collects nothing")
149
+ if self.axes not in (None, "feature", "position", "both"):
150
+ raise ValueError("--module-stats-axes must be feature, position or both")
151
+ if self.axes and not self.sample_stats:
152
+ raise ValueError("--module-stats-axes needs --module-stats: per-axis statistics "
153
+ "reduce the same document slice the moments describe")
154
+ if self.axes_modules is not None:
155
+ try:
156
+ re.compile(self.axes_modules)
157
+ except re.error as error:
158
+ raise ValueError("--module-stats-axes-modules is not a valid regular "
159
+ f"expression: {error}") from error
160
+ if self.routing and not self.sample_stats:
161
+ raise ValueError("--module-stats-routing needs --module-stats: routing "
162
+ "selections are mapped to documents by the same layout rules")
163
+ if self.routing is not None:
164
+ try:
165
+ re.compile(self.routing)
166
+ except re.error as error:
167
+ raise ValueError("--module-stats-routing is not a valid regular "
168
+ f"expression: {error}") from error
169
+ if self.modules is not None:
170
+ # Fail here rather than on the first forward, half an hour into a run - and as
171
+ # a ValueError, which `main.CONFIGURATION_ERRORS` turns into a message instead
172
+ # of a traceback. `re.error` is not a ValueError and would escape it.
173
+ try:
174
+ re.compile(self.modules)
175
+ except re.error as error:
176
+ raise ValueError(
177
+ f"--debug-modules is not a valid regular expression: {error}"
178
+ ) from error
179
+
180
+ @property
181
+ def active(self) -> bool:
182
+ """True when anything should be traced at all."""
183
+ return self.enabled or self.stop_on_nonfinite or self.numeric or self.sample_stats
184
+
185
+ def manifest_entry(self) -> dict[str, Any]:
186
+ """What the manifest records, so a traced run is never mistaken for a plain one."""
187
+ return {
188
+ "enabled": self.active,
189
+ "modules": self.modules,
190
+ "numeric": self.numeric,
191
+ "stop_on_nonfinite": self.stop_on_nonfinite,
192
+ "tail": self.tail,
193
+ "sync": self.sync,
194
+ "sample_stats": self.sample_stats,
195
+ "extremes": self.extremes,
196
+ "extremes_modules": self.extremes_modules,
197
+ "axes": self.axes,
198
+ "axes_modules": self.axes_modules,
199
+ "routing": self.routing,
200
+ "chunk_elements": self.chunk_elements,
201
+ "trace_schema_version": TRACE_SCHEMA_VERSION,
202
+ }
203
+
204
+
205
+ class NonFiniteOutput(RuntimeError):
206
+ """Raised by `--debug-stop-on-nonfinite` at the module that first produced a NaN or an Inf.
207
+
208
+ Deliberately an exception rather than a log line: a corruption that is only logged goes on propagating, and twenty layers later the module named by a trace is not the module that caused it.
209
+
210
+ `module` carries the culprit separately from the message, because the module stack cannot: the check runs in the exit hook, after that module has already returned, so the deepest frame still open is its *parent*. The stack answers "where did execution stop"; this answers "whose output was bad", and for this one failure kind they differ by one level.
211
+ """
212
+
213
+ def __init__(self, message: str, module: str) -> None:
214
+ super().__init__(message)
215
+ self.module = module
216
+
217
+
218
+ # --------------------------------------------------------------------------
219
+ # The active tracer
220
+ # --------------------------------------------------------------------------
221
+
222
+ #: The tracer of the current session, or None. One model per process, so one tracer.
223
+ _ACTIVE: "ModuleTracer | None" = None
224
+
225
+
226
+ def active() -> "ModuleTracer | None":
227
+ """The tracer of the current session, or None when tracing is off."""
228
+ return _ACTIVE
229
+
230
+
231
+ @contextmanager
232
+ def phase(name: str) -> Iterator[None]:
233
+ """Label the module events raised inside this block.
234
+
235
+ A no-op when tracing is off, which is why call sites can use it unconditionally.
236
+
237
+ Example:
238
+ >>> with phase("model_forward"): # doctest: +SKIP
239
+ ... logits = model(input_ids).logits
240
+ """
241
+ tracer = _ACTIVE
242
+ if tracer is None:
243
+ yield
244
+ return
245
+ tracer._phases.append(name)
246
+ try:
247
+ yield
248
+ finally:
249
+ tracer._phases.pop()
250
+
251
+
252
+ def note_samples(contexts: Iterable[Any]) -> None:
253
+ """Say which documents the next forward passes are for.
254
+
255
+ A no-op when tracing is off.
256
+ Takes anything with `task_name`, `doc_id`, `choice_idx` and `batch_row` attributes - `ForwardContext` in practice - and is deliberately duck-typed so this module does not import `reducers`.
257
+
258
+ Pass the *unfiltered* contexts. A document already recorded by an interrupted run is scored again without being recorded, and it is still worth tracing.
259
+ """
260
+ tracer = _ACTIVE
261
+ if tracer is None:
262
+ return
263
+ tracer.set_samples(contexts)
264
+
265
+
266
+ @contextmanager
267
+ def without_samples() -> Iterator[None]:
268
+ """Do not attribute synthetic batch-size probes to the previous real batch."""
269
+ tracer = _ACTIVE
270
+ if tracer is None:
271
+ yield
272
+ return
273
+ samples, batch_rows = tracer._samples, tracer._batch_rows
274
+ stats = tracer.statistics
275
+ contexts = stats.contexts if stats is not None else None
276
+ tracer._samples = tracer._batch_rows = None
277
+ if stats is not None:
278
+ stats.contexts = []
279
+ try:
280
+ yield
281
+ finally:
282
+ tracer._samples, tracer._batch_rows = samples, batch_rows
283
+ if stats is not None:
284
+ stats.contexts = contexts
285
+
286
+
287
+ # --------------------------------------------------------------------------
288
+ # Describing what a module was handed
289
+ # --------------------------------------------------------------------------
290
+
291
+
292
+ def _build_memory_reader():
293
+ """Resolve the cheapest way this torch will hand over the allocator's counters.
294
+
295
+ Measured on an RTX PRO 4500 with torch 2.8, 20k iterations each:
296
+
297
+ torch.cuda.memory_allocated() 69.5 us
298
+ torch.cuda.memory_reserved() 71.5 us
299
+ torch.cuda.memory_stats() 71.3 us
300
+ torch.cuda.memory_stats_as_nested_dict 8.6 us
301
+ reading tensor.shape / dtype / device 0.6 us
302
+
303
+ The three public one-value accessors each build the whole ~60-entry statistics dict and return a single number out of it, so asking for three numbers pays for it three times. `memory_stats_as_nested_dict` is that same dict before the flattening pass, and one call answers all three questions.
304
+
305
+ It is a public name with a documented return shape, but it is also more of an implementation detail than `memory_allocated`, so the shape is probed once here and the flattened accessors stand in if a future torch changes it. The probe costs one call per session.
306
+
307
+ Returns:
308
+ A `() -> dict | None` reader, or None when there is no CUDA device.
309
+ """
310
+ import torch
311
+
312
+ if not torch.cuda.is_available():
313
+ return None
314
+
315
+ nested = getattr(torch.cuda, "memory_stats_as_nested_dict", None)
316
+ if nested is not None:
317
+ try:
318
+ probe = nested()
319
+ if probe:
320
+ probe["allocated_bytes"]["all"]["current"]
321
+ probe["reserved_bytes"]["all"]["current"]
322
+ probe["allocated_bytes"]["all"]["peak"]
323
+ else:
324
+ # CUDA is not initialised yet; the shape cannot be probed but the call is
325
+ # the right one. It returns {} until the first allocation, handled below.
326
+ pass
327
+ except (KeyError, TypeError):
328
+ nested = None
329
+
330
+ if nested is None:
331
+ def read_flattened() -> dict[str, int]:
332
+ return {
333
+ "allocated": torch.cuda.memory_allocated(),
334
+ "reserved": torch.cuda.memory_reserved(),
335
+ "max_allocated": torch.cuda.max_memory_allocated(),
336
+ }
337
+
338
+ return read_flattened
339
+
340
+ def read_nested() -> dict[str, int] | None:
341
+ stats = nested()
342
+ if not stats:
343
+ return None
344
+ allocated = stats["allocated_bytes"]["all"]
345
+ return {
346
+ "allocated": allocated["current"],
347
+ "reserved": stats["reserved_bytes"]["all"]["current"],
348
+ "max_allocated": allocated["peak"],
349
+ }
350
+
351
+ return read_nested
352
+
353
+
354
+ def _tensor_stats(tensor: Any) -> dict[str, Any]:
355
+ """NaN/Inf counts and the distribution of one tensor's finite values.
356
+
357
+ Reports min, max, mean, standard deviation and median rather than a single magnitude. A largest-absolute-value is derivable from min and max and says nothing they do not; what it cannot show is the *shape* of the distribution, which is where an activation going wrong actually announces itself - a mean drifting off zero, a standard deviation collapsing, a median far from the mean because a handful of outliers are carrying the tensor.
358
+
359
+ Everything is over the finite values only. Non-finite entries are replaced with NaN and the `nan*` reductions skip them, so a single Inf cannot swallow the mean and the counts stay the honest record of how many there were.
360
+
361
+ The arithmetic is done in float64 so squaring a large finite float32/bf16
362
+ activation does not itself overflow during variance calculation.
363
+
364
+ All eight numbers are stacked and read back together. The intermediates are
365
+ the real price: boolean masks, a float64 copy and variance/median workspace.
366
+ That is why `--debug-numeric` is a separate flag from `--debug`.
367
+
368
+ A tensor with no finite values at all reports no distribution rather than infinities that would read like data.
369
+ """
370
+ import torch
371
+
372
+ tensor = tensor.detach()
373
+ if tensor.numel() == 0:
374
+ return {"empty": True}
375
+ if not tensor.is_floating_point():
376
+ # An integer or boolean tensor cannot hold NaN or Inf, and reporting three zeros
377
+ # for every `input_ids` teaches the reader to skim the column that matters on the
378
+ # float tensors next to it. Its mean and median say nothing either: the average of
379
+ # a set of token ids is not a token.
380
+ low, high = torch.stack([tensor.min().float(), tensor.max().float()]).tolist()
381
+ return {"min": low, "max": high}
382
+
383
+ # `isposinf`/`isneginf` rather than `isinf & (tensor > 0)`: one kernel each instead of
384
+ # a comparison and an and, which measured 540us against 270us on a 368k-element tensor.
385
+ is_nan = torch.isnan(tensor)
386
+ positive = torch.isposinf(tensor)
387
+ negative = torch.isneginf(tensor)
388
+ finite = ~(is_nan | positive | negative)
389
+ masked = torch.where(finite, tensor.double(), float("nan"))
390
+ mean = torch.nanmean(masked)
391
+ centred = masked - mean
392
+ numbers = torch.stack(
393
+ [
394
+ is_nan.sum().float(),
395
+ positive.sum().float(),
396
+ negative.sum().float(),
397
+ torch.nan_to_num(masked, nan=float("inf")).amin(),
398
+ torch.nan_to_num(masked, nan=float("-inf")).amax(),
399
+ mean,
400
+ torch.nanmean(centred * centred).sqrt(),
401
+ torch.nanmedian(masked),
402
+ ]
403
+ )
404
+ nan, posinf, neginf, low, high, average, deviation, middle = numbers.tolist()
405
+ stats = {"nan": int(nan), "posinf": int(posinf), "neginf": int(neginf)}
406
+ if low != float("inf"):
407
+ stats.update(
408
+ {
409
+ "min": low,
410
+ "max": high,
411
+ "mean": average,
412
+ "std": deviation,
413
+ "median": middle,
414
+ }
415
+ )
416
+ return stats
417
+
418
+
419
+ def _is_nonfinite(stats: dict[str, Any]) -> bool:
420
+ return bool(stats.get("nan") or stats.get("posinf") or stats.get("neginf"))
421
+
422
+
423
+ def _describe(value: Any, numeric: bool, depth: int = 0) -> dict[str, Any]:
424
+ """One JSON-able description of whatever a module was passed or returned.
425
+
426
+ Bounded in both directions - `DESCRIBE_DEPTH` levels and `DESCRIBE_WIDTH` items - because a `past_key_values` is a list of layer-many tuples and describing it in full would bury the tensor that matters.
427
+ """
428
+ import torch
429
+
430
+ if isinstance(value, torch.Tensor):
431
+ described = {
432
+ "shape": list(value.shape),
433
+ "dtype": str(value.dtype).replace("torch.", ""),
434
+ "device": str(value.device),
435
+ }
436
+ if numeric:
437
+ described["stats"] = _tensor_stats(value)
438
+ return described
439
+ if value is None or isinstance(value, (bool, int, float, str)):
440
+ return {"type": type(value).__name__, "value": value if not isinstance(value, str) else value[:60]}
441
+ if isinstance(value, (list, tuple)) and depth < DESCRIBE_DEPTH:
442
+ items = [_describe(item, numeric, depth + 1) for item in list(value)[:DESCRIBE_WIDTH]]
443
+ return {"type": type(value).__name__, "len": len(value), "items": items}
444
+ if isinstance(value, dict) and depth < DESCRIBE_DEPTH:
445
+ keys = list(value)[:DESCRIBE_WIDTH]
446
+ return {
447
+ "type": "dict",
448
+ "len": len(value),
449
+ "items": {str(k): _describe(value[k], numeric, depth + 1) for k in keys},
450
+ }
451
+ cache = _describe_cache(value)
452
+ if cache is not None:
453
+ return cache
454
+ return {"type": type(value).__name__}
455
+
456
+
457
+ def _describe_cache(value: Any) -> dict[str, Any] | None:
458
+ """Size up a transformers KV cache, or return None if this is not one.
459
+
460
+ Worth a special case of its own because the cache is *the* thing that grows during
461
+ generation, and it is the usual answer to "what filled the card". It is not a list,
462
+ tuple or dict, so the generic path saw only `{"type": "DynamicCache"}` - a trace that
463
+ named the opaque object responsible for the allocation and said nothing about its size.
464
+
465
+ `bytes` is the number to read: keys and values summed across every layer. It comes from
466
+ `numel() * element_size()`, so it is metadata arithmetic - no device work, nothing
467
+ allocated, consistent with the rest of the execution trace.
468
+
469
+ Two layouts are handled because both are in scope: transformers 5.x keeps
470
+ `cache.layers[i].keys`, and 4.x keeps `cache.key_cache[i]`.
471
+ """
472
+ import torch
473
+
474
+ if not hasattr(value, "get_seq_length") and not hasattr(value, "key_cache"):
475
+ return None
476
+
477
+ pairs: list[tuple[Any, Any]] = []
478
+ layers = getattr(value, "layers", None)
479
+ if layers is not None:
480
+ pairs = [(getattr(x, "keys", None), getattr(x, "values", None)) for x in layers]
481
+ elif hasattr(value, "key_cache"):
482
+ keys, values = value.key_cache, getattr(value, "value_cache", [])
483
+ pairs = list(zip(keys, values)) if len(keys) == len(values) else [(k, None) for k in keys]
484
+
485
+ described: dict[str, Any] = {"type": type(value).__name__, "layers": len(pairs)}
486
+ total = 0
487
+ first: Any = None
488
+ for key, val in pairs:
489
+ for tensor in (key, val):
490
+ if isinstance(tensor, torch.Tensor):
491
+ total += tensor.numel() * tensor.element_size()
492
+ first = first if first is not None else tensor
493
+ if first is not None:
494
+ described.update(
495
+ {
496
+ "shape": list(first.shape),
497
+ "dtype": str(first.dtype).replace("torch.", ""),
498
+ "device": str(first.device),
499
+ "bytes": total,
500
+ }
501
+ )
502
+ try:
503
+ described["seq"] = int(value.get_seq_length())
504
+ except Exception: # noqa: BLE001 - an empty or exotic cache simply has no length yet
505
+ pass
506
+ return described
507
+
508
+
509
+ def _tensor_descriptions(described: Any) -> Iterator[dict[str, Any]]:
510
+ """Walk a `_describe` result and yield the entries that are tensors."""
511
+ if isinstance(described, dict):
512
+ if "shape" in described:
513
+ yield described
514
+ return
515
+ items = described.get("items")
516
+ if isinstance(items, list):
517
+ for item in items:
518
+ yield from _tensor_descriptions(item)
519
+ elif isinstance(items, dict):
520
+ for item in items.values():
521
+ yield from _tensor_descriptions(item)
522
+
523
+
524
+ def _jsonable(value: Any) -> Any:
525
+ """Replace the float values JSON has no literal for, so the log stays strict JSON.
526
+
527
+ `json.dumps` would happily emit `NaN` and `Infinity`, which no conforming reader accepts - and this log is full of both by design.
528
+ """
529
+ if isinstance(value, float):
530
+ if value != value:
531
+ return "nan"
532
+ if value == float("inf"):
533
+ return "inf"
534
+ if value == float("-inf"):
535
+ return "-inf"
536
+ return value
537
+ if isinstance(value, dict):
538
+ return {k: _jsonable(v) for k, v in value.items()}
539
+ if isinstance(value, (list, tuple)):
540
+ return [_jsonable(v) for v in value]
541
+ return value
542
+
543
+
544
+ # --------------------------------------------------------------------------
545
+ # The tracer
546
+ # --------------------------------------------------------------------------
547
+
548
+
549
+ @dataclass
550
+ class _Frame:
551
+ """One module entry that has not yet returned."""
552
+
553
+ path: str
554
+ type_name: str
555
+ call_id: int
556
+ phase: str
557
+ inputs: list[dict[str, Any]] = field(default_factory=list)
558
+
559
+
560
+ class ModuleTracer:
561
+ """Registers enter/exit hooks on every module and writes what it sees.
562
+
563
+ Args:
564
+ model: the loaded model. Every `nn.Module` under it is hooked unless `config.modules` says otherwise.
565
+ config: what to trace.
566
+ path: where the JSONL goes. Its directory is created on session entry.
567
+
568
+ Example:
569
+ >>> tracer = ModuleTracer(model, DebugConfig(enabled=True), "run/debug/trace.jsonl")
570
+ ... # doctest: +SKIP
571
+ >>> with tracer.session():
572
+ ... model(input_ids)
573
+ >>> summarize_trace("run/debug/trace.jsonl") # doctest: +SKIP
574
+ """
575
+
576
+ def __init__(self, model: Any, config: DebugConfig, path: str | os.PathLike) -> None:
577
+ self.model = model
578
+ self.config = config
579
+ self.path = str(path)
580
+
581
+ self._handles: list[Any] = []
582
+ # A deque rather than a list: the ring is trimmed once per event, and `list.pop(0)`
583
+ # is linear in the window size. At the 10^5 events a generate document produces
584
+ # that is the difference between a tracer that costs nothing and one that shows up
585
+ # in the measurement it was brought in to explain.
586
+ self._events: deque[dict[str, Any]] = deque(maxlen=config.tail or None)
587
+ self._stream_handle: Any = None
588
+ self._seq = 0
589
+ self._call_id = 0
590
+ self._forward_id = 0
591
+ self._stack: list[_Frame] = []
592
+ self._phases: list[str] = []
593
+ self._samples: list[list[Any]] | None = None
594
+ self._batch_rows: int | None = None
595
+ self._pattern = re.compile(config.modules) if config.modules else None
596
+ self._traced: list[tuple[str, Any]] = []
597
+ self._cuda = False
598
+ self._reader: Any = None
599
+ self._written = False
600
+ self.statistics: Any = None
601
+ self._inspection: dict[str, Any] | None = None
602
+
603
+ # -- lifetime ------------------------------------------------------------------
604
+
605
+ @contextmanager
606
+ def session(self) -> Iterator["ModuleTracer"]:
607
+ """Hook the model for a block of work, and always write the trace afterwards.
608
+
609
+ An exception escaping the block is recorded - type, message, traceback, and the module stack it left behind - and then re-raised untouched.
610
+ The trace is written either way: a run that succeeded still leaves the tail of what it did, which is what makes "it worked this time" checkable.
611
+ """
612
+ global _ACTIVE
613
+ if _ACTIVE is not None:
614
+ raise RuntimeError("a module tracer is already active in this process")
615
+
616
+ import torch
617
+
618
+ self._cuda = torch.cuda.is_available()
619
+ self._reader = _build_memory_reader()
620
+ _ACTIVE = self
621
+ success = False
622
+ try:
623
+ # Inside the try, so that a failure to open the log - an unwritable path, a
624
+ # full disk - does not leave the process with an active tracer nobody can
625
+ # replace. Everything here happens before a single hook fires.
626
+ self._open()
627
+ self._write_header()
628
+ if self.config.sample_stats:
629
+ from .module_stats import ModuleStatistics
630
+
631
+ config = self.config.manifest_entry()
632
+ # Recorded in the session's `reduction` metadata instead of its config, so
633
+ # sessions that differ only in chunk size keep identical config rows.
634
+ chunk_elements = config.pop("chunk_elements")
635
+ self.statistics = ModuleStatistics(self.path, config, chunk_elements)
636
+ self.register_hooks()
637
+ yield self
638
+ success = True
639
+ except BaseException as exc: # noqa: BLE001 - recorded, then re-raised
640
+ try:
641
+ self._record_error(exc)
642
+ except OSError as write_error:
643
+ print(f"warning: could not record trace error: {write_error}", file=sys.stderr)
644
+ raise
645
+ finally:
646
+ self.remove_hooks()
647
+ try:
648
+ try:
649
+ self._reconcile("session ended")
650
+ finally:
651
+ self._close()
652
+ except OSError as write_error:
653
+ # Never mask the failure being traced with a failure to write about it.
654
+ print(f"warning: could not write {self.path}: {write_error}", file=sys.stderr)
655
+ finally:
656
+ _ACTIVE = None
657
+ if self.statistics is not None:
658
+ # An analysis failure must not replace the model's exception - nor
659
+ # become one of its own. `main.py` writes the scores *after* this
660
+ # context manager exits, so raising here would discard an evaluation
661
+ # that had already finished, over tables that are a side artifact and
662
+ # can be rebuilt from the observations still in the file.
663
+ try:
664
+ self.statistics.close(success=success)
665
+ except Exception as analysis_error:
666
+ print(f"warning: module statistics were not aggregated: "
667
+ f"{analysis_error}. The evaluation is unaffected; the "
668
+ f"observations remain in {self.statistics.path}",
669
+ file=sys.stderr)
670
+
671
+ def register_hooks(self) -> None:
672
+ """Hook every module the filter accepts.
673
+
674
+ `named_modules()` de-duplicates: a module reachable by two attribute paths is hooked once, under the first name it is given.
675
+ """
676
+ if self._handles:
677
+ return
678
+ modules = dict(self.model.named_modules())
679
+ targets = [(name or ROOT_NAME, module) for name, module in modules.items()
680
+ if self._pattern is None or self._pattern.search(name or ROOT_NAME)]
681
+ if self.statistics is not None:
682
+ def parent_type(path: str) -> str | None:
683
+ """The class that called a module, which is what names its layout.
684
+
685
+ A child that flattens batch and sequence looks like any other module
686
+ on its own. A parent reachable only under a different name - the
687
+ de-duplication above - leaves this unknown rather than guessed.
688
+ """
689
+ if path == ROOT_NAME:
690
+ return None
691
+ parent = modules.get(path.rpartition(".")[0])
692
+ return None if parent is None else type(parent).__name__
693
+
694
+ self.statistics.resolve_modules(
695
+ (path, type(module).__name__, parent_type(path)) for path, module in targets)
696
+ # Root boundaries are bookkeeping, independent of the selected modules.
697
+ self._handles.append(self.model.register_forward_pre_hook(self._root_enter, with_kwargs=True))
698
+ for path, module in targets:
699
+ self._traced.append((path, type(module).__name__))
700
+ if self.statistics is not None:
701
+ self.statistics.registration(path, "registering")
702
+ try:
703
+ self._handles.append(module.register_forward_pre_hook(
704
+ self._make_enter(path, type(module).__name__), with_kwargs=True))
705
+ self._handles.append(module.register_forward_hook(self._make_exit(path)))
706
+ except BaseException as error:
707
+ if self.statistics is not None:
708
+ self.statistics.registration(path, "failed", f"{type(error).__name__}: {error}"[:2000])
709
+ raise
710
+ if self.statistics is not None:
711
+ self.statistics.registration(path, "registered")
712
+ self._handles.append(self.model.register_forward_hook(self._root_exit))
713
+
714
+ def _root_enter(self, module, args, kwargs):
715
+ self._reconcile("a new forward began")
716
+ self._inspection = None
717
+ self._begin_forward()
718
+ if self.statistics is not None:
719
+ self.statistics.begin(self._forward_id, args, kwargs)
720
+
721
+ def _root_exit(self, module, args, output):
722
+ if self.statistics is not None:
723
+ self.statistics.finish_forward()
724
+
725
+ def remove_hooks(self) -> None:
726
+ for handle in self._handles:
727
+ handle.remove()
728
+ self._handles.clear()
729
+
730
+ # -- hooks ---------------------------------------------------------------------
731
+
732
+ def _make_enter(self, path: str, type_name: str):
733
+ def enter(module, args, kwargs): # noqa: ANN001 - torch signature
734
+ if self.statistics is not None:
735
+ self.statistics.called(path)
736
+ if self.config.sync and self._cuda:
737
+ self._synchronize()
738
+ # Publish metadata before numerical inspection: the inspection itself
739
+ # can OOM, and must not erase the module that was about to run.
740
+ described = [_describe(a, False) for a in args]
741
+ described_kwargs = {
742
+ k: _describe(v, False) for k, v in kwargs.items()
743
+ }
744
+ frame = _Frame(path, type_name, self._next_call(), self._phase(), described)
745
+ self._stack.append(frame)
746
+ self._emit(
747
+ {
748
+ "event": "enter",
749
+ "module": path,
750
+ "type": type_name,
751
+ "depth": len(self._stack) - 1,
752
+ "phase": frame.phase,
753
+ "forward": self._forward_id,
754
+ "call": frame.call_id,
755
+ "args": described,
756
+ "kwargs": described_kwargs,
757
+ "mem": self._memory(),
758
+ }
759
+ )
760
+ if self.config.numeric:
761
+ described[:] = [_describe(a, True) for a in args]
762
+ described_kwargs.update({k: _describe(v, True) for k, v in kwargs.items()})
763
+ self._emit({"event": "input_numeric", "module": path,
764
+ "forward": self._forward_id, "call": frame.call_id,
765
+ "args": described, "kwargs": described_kwargs})
766
+ if self.statistics is not None:
767
+ self._collect_statistics(path, "input", {"args": args, "kwargs": kwargs},
768
+ frame.call_id, type_name)
769
+ return None
770
+
771
+ return enter
772
+
773
+ def _make_exit(self, path: str):
774
+ def exit_(module, args, output): # noqa: ANN001 - torch signature
775
+ if self.config.sync and self._cuda:
776
+ self._synchronize()
777
+ described = _describe(output, self.config.numeric)
778
+ if self.statistics is not None:
779
+ frame = self._stack[-1] if self._stack else None
780
+ self._collect_statistics(path, "output", output,
781
+ frame.call_id if frame else -1, type(module).__name__)
782
+ frame = self._pop_to(path)
783
+ self._emit(
784
+ {
785
+ "event": "exit",
786
+ "module": path,
787
+ "type": frame.type_name if frame else None,
788
+ "depth": len(self._stack),
789
+ "phase": self._phase(),
790
+ "forward": self._forward_id,
791
+ "call": frame.call_id if frame else None,
792
+ "output": described,
793
+ # No `mem` here, unlike `enter`. In a sequential trace a module's exit
794
+ # state is the next event's entry state, so the second reading buys
795
+ # almost nothing and doubles the one cost that actually showed up in
796
+ # the measurement. The readings that matter - the failing module's
797
+ # entry, and the error itself - are both still taken.
798
+ **({} if frame else {"orphan": True}),
799
+ }
800
+ )
801
+ if self.config.stop_on_nonfinite:
802
+ self._check_finite(path, described)
803
+ return None
804
+
805
+ return exit_
806
+
807
+ def _collect_statistics(self, path, io, value, call, type_name):
808
+ """Keep an inspection failure distinguishable from a model failure."""
809
+ self._inspection = {"module": path, "io": io}
810
+ self.statistics.observe(path, io, value, call, self._phase(), type_name)
811
+ self._inspection = None
812
+
813
+ def _check_finite(self, path: str, described: dict[str, Any]) -> None:
814
+ """Raise at the module that produced the first non-finite output.
815
+
816
+ Known false positive: an architecture whose module *returns* an additive attention mask returns `-inf` on purpose. `--debug-modules` is how that gets scoped out.
817
+ """
818
+ for tensor in _tensor_descriptions(described):
819
+ stats = tensor.get("stats")
820
+ if stats and _is_nonfinite(stats):
821
+ raise NonFiniteOutput(
822
+ f"{path} produced a non-finite output: "
823
+ f"{stats['nan']} NaN, {stats['posinf']} +Inf, {stats['neginf']} -Inf "
824
+ f"in a {tensor['shape']} {tensor['dtype']} tensor",
825
+ module=path,
826
+ )
827
+
828
+ def _synchronize(self) -> None:
829
+ import torch
830
+
831
+ torch.cuda.synchronize()
832
+
833
+ # -- bookkeeping ---------------------------------------------------------------
834
+
835
+ def _phase(self) -> str:
836
+ return self._phases[-1] if self._phases else DEFAULT_PHASE
837
+
838
+ def _next_call(self) -> int:
839
+ self._call_id += 1
840
+ return self._call_id
841
+
842
+ def _begin_forward(self) -> None:
843
+ """Start a new forward and say what it is for.
844
+
845
+ Not called for work in `signal_collection`: the logit lens re-enters the model's own final norm and head, and counting that as a new forward would make the trace disagree with the number of passes the model actually ran.
846
+ """
847
+ self._forward_id += 1
848
+ self._emit(
849
+ {
850
+ "event": "forward",
851
+ "forward": self._forward_id,
852
+ "phase": self._phase(),
853
+ "samples": self._samples,
854
+ "batch_rows": self._batch_rows,
855
+ }
856
+ )
857
+
858
+ def set_samples(self, contexts: Iterable[Any]) -> None:
859
+ """Record which documents the following forwards cover, and reconcile the stack.
860
+
861
+ Sticky: a generate document runs one forward per decoded token and they all belong to it, so this is replaced rather than consumed.
862
+ """
863
+ contexts = list(contexts)
864
+ if self.statistics is not None:
865
+ self.statistics.set_samples(contexts)
866
+ rows: list[list[Any]] = []
867
+ widest = -1
868
+ for ctx in contexts:
869
+ rows.append(
870
+ [
871
+ getattr(ctx, "task_name", None),
872
+ getattr(ctx, "doc_id", None),
873
+ getattr(ctx, "choice_idx", None),
874
+ getattr(ctx, "batch_row", 0),
875
+ ]
876
+ )
877
+ widest = max(widest, int(getattr(ctx, "batch_row", 0) or 0))
878
+ self._samples = rows or None
879
+ self._batch_rows = widest + 1 if rows else None
880
+ self._reconcile("a new batch was declared")
881
+
882
+ def _pop_to(self, path: str) -> _Frame | None:
883
+ """Pop the frame for `path`, recording anything above it as never having returned.
884
+
885
+ Frames above it exist when an exception unwound inner modules and something between here and there caught it.
886
+ """
887
+ for index in range(len(self._stack) - 1, -1, -1):
888
+ if self._stack[index].path == path:
889
+ if index < len(self._stack) - 1:
890
+ self._unwind(self._stack[index + 1 :], "an inner module did not return")
891
+ del self._stack[index + 1 :]
892
+ return self._stack.pop()
893
+ return None
894
+
895
+ def _reconcile(self, reason: str) -> None:
896
+ """Record and clear whatever is still on the stack."""
897
+ if not self._stack:
898
+ return
899
+ self._unwind(list(self._stack), reason)
900
+ self._stack.clear()
901
+
902
+ def _unwind(self, frames: Sequence[_Frame], reason: str) -> None:
903
+ """Write one `unwound` event per frame that was entered and never exited.
904
+
905
+ The deepest is the failure site: execution stopped inside it. Its ancestors are the path that led there, which is why they are recorded too but only one carries `deepest`.
906
+ """
907
+ for position, frame in enumerate(frames):
908
+ self._emit(
909
+ {
910
+ "event": "unwound",
911
+ "module": frame.path,
912
+ "type": frame.type_name,
913
+ "depth": len(self._stack) - len(frames) + position,
914
+ "phase": frame.phase,
915
+ "forward": self._forward_id,
916
+ "call": frame.call_id,
917
+ "args": frame.inputs,
918
+ "deepest": position == len(frames) - 1,
919
+ "reason": reason,
920
+ }
921
+ )
922
+
923
+ def _record_error(self, exc: BaseException) -> None:
924
+ """Record an exception on its way out of the session.
925
+
926
+ The phase comes from the deepest frame still on the stack, not from `_phase()`: by the time this runs the exception has already unwound through `phase()`'s own `finally`, so the live phase stack is empty and would report every failure as `other`. Each frame kept the phase it was entered under, which is the one that describes the work that died.
927
+ """
928
+ self._emit(
929
+ {
930
+ "event": "error",
931
+ "exc_type": type(exc).__name__,
932
+ "message": str(exc)[:2000],
933
+ "phase": ("module_statistics" if self._inspection else
934
+ self._stack[-1].phase if self._stack else self._phase()),
935
+ "module": (self._inspection["module"] if self._inspection else
936
+ self._stack[-1].path if self._stack else None),
937
+ "inspection": self._inspection,
938
+ # Only `--debug-stop-on-nonfinite` sets this, and it is the module whose
939
+ # output was bad rather than the one execution stopped inside.
940
+ "detected_at": getattr(exc, "module", None),
941
+ "forward": self._forward_id,
942
+ "samples": self._samples,
943
+ "batch_rows": self._batch_rows,
944
+ "mem": self._memory(),
945
+ "traceback": traceback.format_exception(type(exc), exc, exc.__traceback__)[-12:],
946
+ }
947
+ )
948
+
949
+ def _memory(self) -> dict[str, int] | None:
950
+ """The allocator's own counters, read through whichever path this torch makes cheap.
951
+
952
+ `reserved` is what the allocator holds from the driver and `allocated` is what tensors are using; an OOM with a wide gap between them is fragmentation rather than genuine exhaustion, which is the one thing an OOM traceback never says.
953
+
954
+ Neither counter queues a kernel or synchronizes - they are host-side numbers the caching allocator already maintains. That made them look free, and they are not: measured on an RTX PRO 4500, `torch.cuda.memory_allocated()` costs 69us, because it builds the *entire* ~60-entry statistics dict and then returns one value from it. Three of those per event, two events per module call, 547 modules per forward came to 420us of pure bookkeeping per module - about 90 seconds over a 200-document run, which was most of the tracer's overhead.
955
+
956
+ `memory_stats_as_nested_dict()` is the same data before the flattening, at 8.6us, and one call yields all three numbers. `_reader` is resolved once per session and falls back to the flattened path if a future torch drops it.
957
+ """
958
+ return self._reader() if self._reader is not None else None
959
+
960
+ # -- output --------------------------------------------------------------------
961
+
962
+ def _emit(self, event: dict[str, Any]) -> None:
963
+ self._seq += 1
964
+ event["i"] = self._seq
965
+ if self._stream_handle is not None:
966
+ self._stream_handle.write(json.dumps(_jsonable(event), allow_nan=False) + "\n")
967
+ self._stream_handle.flush()
968
+ return
969
+ self._events.append(event)
970
+
971
+ def _open(self) -> None:
972
+ directory = os.path.dirname(os.path.abspath(self.path))
973
+ os.makedirs(directory, exist_ok=True)
974
+ if not self.config.tail:
975
+ self._stream_handle = open(self.path, "w")
976
+
977
+ def _write_header(self) -> None:
978
+ self._emit(
979
+ {
980
+ "event": "header",
981
+ "trace_schema_version": TRACE_SCHEMA_VERSION,
982
+ "created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
983
+ "config": self.config.manifest_entry(),
984
+ "model_type": getattr(getattr(self.model, "config", None), "model_type", None),
985
+ "torch": self._torch_version(),
986
+ "cuda": self._cuda,
987
+ }
988
+ )
989
+
990
+ @staticmethod
991
+ def _torch_version() -> str:
992
+ import torch
993
+
994
+ return torch.__version__
995
+
996
+ def _close(self) -> None:
997
+ """Write the buffer, or close the stream.
998
+
999
+ Called from the `finally` of `session()`, so it runs after an exception has been recorded and before it is re-raised.
1000
+ Nothing here touches the device: after an OOM the allocator is the one thing not to ask for more, and after a device-side fault it would not answer.
1001
+ """
1002
+ if self._written:
1003
+ return
1004
+ self._written = True
1005
+ if self._stream_handle is not None:
1006
+ self._stream_handle.close()
1007
+ self._stream_handle = None
1008
+ return
1009
+ with open(self.path, "w") as handle:
1010
+ for event in self._events:
1011
+ handle.write(json.dumps(_jsonable(event), allow_nan=False) + "\n")
1012
+ dropped = self._seq - len(self._events)
1013
+ if dropped:
1014
+ handle.write(
1015
+ json.dumps({"event": "dropped", "count": dropped, "i": self._seq + 1}) + "\n"
1016
+ )
1017
+
1018
+ # -- what was traced -----------------------------------------------------------
1019
+
1020
+ @property
1021
+ def traced_modules(self) -> list[tuple[str, str]]:
1022
+ """`(path, class name)` for every hooked module, in `named_modules()` order."""
1023
+ return list(self._traced)
1024
+
1025
+ @property
1026
+ def events(self) -> list[dict[str, Any]]:
1027
+ """The buffered events. Empty unless `--debug-tail` is holding them."""
1028
+ return list(self._events)
1029
+
1030
+ @property
1031
+ def bytes_written(self) -> int:
1032
+ """Size of the trace on disk, so a long run's file is not a surprise."""
1033
+ return os.path.getsize(self.path) if os.path.exists(self.path) else 0
1034
+
1035
+
1036
+ # --------------------------------------------------------------------------
1037
+ # Reading a trace back
1038
+ # --------------------------------------------------------------------------
1039
+
1040
+
1041
+ def trace_path(run_dir: str | os.PathLike, name: str = "trace") -> str:
1042
+ """Where a run's trace lives.
1043
+
1044
+ Deliberately outside the parquet tables: `ShardWriter` buffers rows and writes a shard only every `SHARD_SIZE` documents, so a crash discards exactly the rows a post-mortem needs.
1045
+
1046
+ A run has one trace per pass, because the passes are different programs. The first scores every document; the second re-runs a sample with eager attention and dumps `(heads, seq, seq)` maps, which is the heaviest thing this tool does and the likeliest place for it to run out of memory. One file holding both would have the interesting half of a two-hour run scrolled out of the ring buffer by the cheap half.
1047
+ """
1048
+ return os.path.join(str(run_dir), "debug", f"{name}.jsonl")
1049
+
1050
+
1051
+ def list_traces(run_dir: str | os.PathLike) -> list[str]:
1052
+ """Every trace file a run directory holds, scoring pass first."""
1053
+ directory = os.path.join(str(run_dir), "debug")
1054
+ if not os.path.isdir(directory):
1055
+ return []
1056
+ found = sorted(
1057
+ os.path.join(directory, name)
1058
+ for name in os.listdir(directory)
1059
+ if name.endswith(".jsonl")
1060
+ )
1061
+ return sorted(found, key=lambda path: not path.endswith("trace.jsonl"))
1062
+
1063
+
1064
+ def read_trace(path: str | os.PathLike) -> list[dict[str, Any]]:
1065
+ """Read a trace file, or the trace inside a run directory.
1066
+
1067
+ Example:
1068
+ >>> events = read_trace("results/.../run") # doctest: +SKIP
1069
+ >>> [e["module"] for e in events if e.get("deepest")]
1070
+ ['model.layers.3.self_attn']
1071
+ """
1072
+ given = str(path)
1073
+ if os.path.isdir(given):
1074
+ found = list_traces(given)
1075
+ if not found:
1076
+ raise FileNotFoundError(
1077
+ f"no trace under {os.path.join(given, 'debug')}. A run only writes one "
1078
+ "when it was given --debug; a run that died before its directory existed "
1079
+ "has none at all."
1080
+ )
1081
+ path = found[0]
1082
+ else:
1083
+ path = given
1084
+ if not os.path.exists(path):
1085
+ raise FileNotFoundError(f"no trace at {path}")
1086
+ with open(path) as handle:
1087
+ events = [json.loads(line) for line in handle if line.strip()]
1088
+ # Metadata is durable before numerical inspection starts. For readers, enrich
1089
+ # the matching entry when inspection succeeded; a failed inspection has none.
1090
+ entries = {e.get("call"): e for e in events if e.get("event") == "enter"}
1091
+ for event in events:
1092
+ if event.get("event") == "input_numeric" and event["call"] in entries:
1093
+ entries[event["call"]].update(args=event["args"], kwargs=event["kwargs"])
1094
+ return events
1095
+
1096
+
1097
+ def _format_tensor(described: dict[str, Any]) -> str:
1098
+ """The failure summary's rendering: the same statistics, spelled out a little wider.
1099
+
1100
+ Shares `_stats_text` with the per-module listing rather than formatting its own. The
1101
+ two used to diverge, and the drift was not cosmetic: this one read `stats["nan"]`
1102
+ unconditionally, which an integer tensor does not carry, so a run that died with token
1103
+ ids on the stack would have crashed while printing the report about it.
1104
+ """
1105
+ if "shape" in described:
1106
+ text = f"{described['shape']} {described['dtype']} {described['device']}"
1107
+ stats = _stats_text(described.get("stats") or {})
1108
+ return f"{text} {stats}" if stats else text
1109
+ if "len" in described:
1110
+ return f"{described['type']}[{described['len']}]"
1111
+ return described.get("type", "?")
1112
+
1113
+
1114
+ def _format_args(event: dict[str, Any], indent: str) -> list[str]:
1115
+ lines = []
1116
+ for described in event.get("args") or []:
1117
+ lines.append(f"{indent}in {_format_tensor(described)}")
1118
+ for name, described in (event.get("kwargs") or {}).items():
1119
+ lines.append(f"{indent}in {name}={_format_tensor(described)}")
1120
+ return lines
1121
+
1122
+
1123
+ def _format_samples(event: dict[str, Any]) -> str:
1124
+ rows = event.get("samples")
1125
+ if not rows:
1126
+ return "none recorded (a batch-size probe, or a document already on disk)"
1127
+ by_doc: dict[tuple[Any, Any], list[Any]] = {}
1128
+ for task, doc_id, choice, _row in rows:
1129
+ by_doc.setdefault((task, doc_id), []).append(choice)
1130
+ parts = [f"{task}#{doc}x{len(choices)}" for (task, doc), choices in by_doc.items()]
1131
+ shown = ", ".join(parts[:8])
1132
+ return shown + (f", +{len(parts) - 8} more" if len(parts) > 8 else "")
1133
+
1134
+
1135
+ def _stats_text(stats: dict[str, Any]) -> str:
1136
+ """The numeric summary of one tensor as a line, or empty when there is none.
1137
+
1138
+ One function for both renderings. Every field is optional: an integer tensor carries no
1139
+ non-finite counts because it cannot hold any, an empty tensor carries nothing, and a
1140
+ tensor with no finite values carries the counts but no distribution.
1141
+ """
1142
+ if not stats or stats.get("empty"):
1143
+ return "(empty)" if stats.get("empty") else ""
1144
+ stats = {k: float(v) if isinstance(v, str) and v in {"nan", "inf", "-inf"} else v
1145
+ for k, v in stats.items()}
1146
+ parts = []
1147
+ if stats.get("nan") or stats.get("posinf") or stats.get("neginf"):
1148
+ parts.append(
1149
+ f"!nan={stats.get('nan', 0)}/+inf={stats.get('posinf', 0)}"
1150
+ f"/-inf={stats.get('neginf', 0)}"
1151
+ )
1152
+ if "min" in stats:
1153
+ parts.append(f"min={stats['min']:.3g} max={stats['max']:.3g}")
1154
+ if "mean" in stats:
1155
+ parts.append(
1156
+ f"mean={stats['mean']:.3g} sd={stats['std']:.3g} med={stats['median']:.3g}"
1157
+ )
1158
+ return " ".join(parts)
1159
+
1160
+
1161
+ def _io_summary(described: dict[str, Any]) -> str:
1162
+ """One tensor, container or cache rendered short enough to sit in a column."""
1163
+ if described is None:
1164
+ return "-"
1165
+ if "shape" in described:
1166
+ text = "x".join(str(n) for n in described["shape"])
1167
+ if described.get("type"): # a cache carries both
1168
+ text = f"{described['type']}[{described.get('layers', '?')}] {text}"
1169
+ stats = _stats_text(described.get("stats") or {})
1170
+ if stats:
1171
+ text += f" {stats}"
1172
+ if "bytes" in described:
1173
+ text += f" {described['bytes'] / 1e6:.1f}MB"
1174
+ return text
1175
+ if "len" in described:
1176
+ return f"{described['type']}[{described['len']}]"
1177
+ if "value" in described:
1178
+ # A scalar's value is the point of it: `use_cache=True` and `use_cache=False` are
1179
+ # different runs, and rendering both as "bool" loses the only bit it carries.
1180
+ return "None" if described["value"] is None else repr(described["value"])
1181
+ return str(described.get("type", "?"))
1182
+
1183
+
1184
+ def _io_columns(event: dict[str, Any]) -> str:
1185
+ """The inputs of one module entry, positional first then keyword, named."""
1186
+ parts = [_io_summary(d) for d in (event.get("args") or [])]
1187
+ # `None` keyword arguments are the architecture's defaults and there are many of them;
1188
+ # printing `attention_mask=None, position_ids=None, inputs_embeds=None, ...` on every
1189
+ # line would hide the two that carry a shape.
1190
+ parts += [
1191
+ f"{name}={_io_summary(d)}"
1192
+ for name, d in (event.get("kwargs") or {}).items()
1193
+ if d.get("type") != "NoneType"
1194
+ ]
1195
+ return ", ".join(parts) or "-"
1196
+
1197
+
1198
+ def describe_forward(
1199
+ events: Sequence[dict[str, Any]], forward_id: int, module: str | None = None
1200
+ ) -> str:
1201
+ """Every module one forward pass ran, in call order, with what went in and out.
1202
+
1203
+ This is the view the feature was asked for and the one a summary cannot give: not
1204
+ "where did it stop" but "what did each module see". It is deliberately one line per
1205
+ module - a 32-layer model runs several hundred of them per forward - with `module` as
1206
+ the way to narrow to the handful worth reading.
1207
+
1208
+ Entries are paired with their exits by call id, so a module that never returned shows
1209
+ its inputs and `(never returned)` where its output would be.
1210
+ """
1211
+ pattern = re.compile(module) if module else None
1212
+ header = next(
1213
+ (e for e in events if e.get("event") == "forward" and e["forward"] == forward_id),
1214
+ None,
1215
+ )
1216
+ outputs = {
1217
+ e["call"]: e.get("output")
1218
+ for e in events
1219
+ if e.get("event") == "exit" and e.get("call") is not None
1220
+ }
1221
+ entries = [
1222
+ e for e in events
1223
+ if e.get("event") in {"enter", "unwound"} and e.get("forward") == forward_id
1224
+ ]
1225
+ if not entries:
1226
+ return f"forward {forward_id}: nothing recorded (the ring buffer may have dropped it)"
1227
+
1228
+ lines = [
1229
+ f"forward {forward_id} phase {entries[0].get('phase')} "
1230
+ f"documents: {_format_samples(header) if header else 'unknown'}"
1231
+ + (f" {header['batch_rows']} batch row(s)" if header and header.get("batch_rows") else "")
1232
+ ]
1233
+ if pattern is not None:
1234
+ matched = [e["module"] for e in entries if pattern.search(e["module"])]
1235
+ depths = sorted({m.split(".")[2] for m in matched if m.startswith("model.layers.")},
1236
+ key=lambda n: int(n) if n.isdigit() else -1)
1237
+ note = f"modules matching {module!r} only: {len(matched)} of {len(entries)}"
1238
+ if len(depths) > 1:
1239
+ # An unescaped dot is why this line exists. `layers.1.` matches layers 1 and
1240
+ # 10 through 19, because the trailing `.` happily matches the `0` of `10`;
1241
+ # `layers\.1\.` matches one. The difference is invisible at layer 0, which
1242
+ # is the layer everyone tries first, and otherwise shows up only as a longer
1243
+ # listing than expected.
1244
+ note += f", across layers {', '.join(depths)}"
1245
+ lines.append(note)
1246
+ if any("stats" in d for e in entries for d in (e.get("args") or [])):
1247
+ # Say what the notation means in the output that uses it. A reader who has to ask
1248
+ # is reading a number they cannot act on.
1249
+ lines.append(
1250
+ " shapes are AxBxC. min/max/mean/sd/med are over the finite values only, so "
1251
+ "an Inf\n cannot swallow the mean; where a tensor holds NaN or Inf the counts "
1252
+ "are shown too.\n A median far from the mean means a few outliers are "
1253
+ "carrying the tensor."
1254
+ )
1255
+ lines.append("")
1256
+
1257
+ shown = 0
1258
+ for event in entries:
1259
+ if pattern is not None and not pattern.search(event["module"]):
1260
+ continue
1261
+ shown += 1
1262
+ indent = " " * event["depth"]
1263
+ name = f"{indent}{event['module']}"
1264
+ returned = (
1265
+ "(never returned)" if event["event"] == "unwound"
1266
+ else _io_summary(outputs.get(event.get("call")))
1267
+ )
1268
+ lines.append(f" {name:<46} {event['type']:<22} {_io_columns(event)}")
1269
+ lines.append(f" {'':<46} {'':<22} -> {returned}")
1270
+ if not shown:
1271
+ lines.append(f" no module in this forward matches {module!r}")
1272
+ return "\n".join(lines)
1273
+
1274
+
1275
+ def forward_index(events: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
1276
+ """What each recorded forward covers: its phase, its documents, its module count."""
1277
+ counts: dict[int, int] = {}
1278
+ phases: dict[int, str] = {}
1279
+ for event in events:
1280
+ if event.get("event") == "enter":
1281
+ counts[event["forward"]] = counts.get(event["forward"], 0) + 1
1282
+ phases.setdefault(event["forward"], event.get("phase", DEFAULT_PHASE))
1283
+ headers = {
1284
+ e["forward"]: e for e in events if e.get("event") == "forward"
1285
+ }
1286
+ return [
1287
+ {
1288
+ "forward": number,
1289
+ "modules": counts[number],
1290
+ "phase": phases.get(number),
1291
+ "samples": headers.get(number, {}).get("samples"),
1292
+ "batch_rows": headers.get(number, {}).get("batch_rows"),
1293
+ }
1294
+ for number in sorted(counts)
1295
+ ]
1296
+
1297
+
1298
+ def summarize_trace(path: str | os.PathLike, tail: int = 10) -> str:
1299
+ """Turn a trace into the paragraph a person actually wanted.
1300
+
1301
+ A raw JSONL of thousands of events is storage, not a debugging tool. This answers the three questions the trace exists for: how far it got, where it stopped, and on whose behalf.
1302
+
1303
+ Example:
1304
+ >>> print(summarize_trace("results/.../run")) # doctest: +SKIP
1305
+ """
1306
+ events = read_trace(path)
1307
+ if not events:
1308
+ return f"{path}: empty trace"
1309
+
1310
+ header = events[0] if events[0].get("event") == "header" else {}
1311
+ dropped = next((e["count"] for e in events if e.get("event") == "dropped"), 0)
1312
+ error = next((e for e in events if e.get("event") == "error"), None)
1313
+ unwound = [e for e in events if e.get("event") == "unwound"]
1314
+ forwards = {e["forward"] for e in events if e.get("event") == "enter"}
1315
+
1316
+ config = header.get("config", {})
1317
+ lines = [
1318
+ f"trace: {path} ({len(events)} events"
1319
+ + (f", {dropped} dropped" if dropped else "")
1320
+ + ")",
1321
+ f"model_type: {header.get('model_type')} torch {header.get('torch')} "
1322
+ f"cuda={header.get('cuda')} numeric={config.get('numeric')} sync={config.get('sync')}",
1323
+ ]
1324
+ if dropped:
1325
+ # Worth saying in forwards rather than events. A 547-module model spends about
1326
+ # 1100 events on one forward, so the default ring is four or five of them - which
1327
+ # is ample for locating a failure and nothing like enough to watch a trend. On a
1328
+ # generate task, where one forward is one token, that is the last few tokens.
1329
+ lines.append(
1330
+ f"this is the tail: the last {len(forwards)} forward"
1331
+ f"{'s' if len(forwards) != 1 else ''} of {dropped + len(events)} events. "
1332
+ "--debug-stream keeps all of it."
1333
+ )
1334
+ else:
1335
+ lines.append(f"forwards traced: {len(forwards)}")
1336
+ lines.append("")
1337
+
1338
+ if error is None and not unwound:
1339
+ lines.extend(_format_healthy_trace(path, events))
1340
+ else:
1341
+ lines.extend(_format_failed_trace(events, error, unwound, tail))
1342
+ return "\n".join(lines)
1343
+
1344
+
1345
+ def _format_healthy_trace(path: str | os.PathLike, events: list[dict[str, Any]]) -> list[str]:
1346
+ """실패가 없는 trace의 forward 목록과 상세 조회 명령을 출력 줄로 만든다."""
1347
+ lines: list[str] = []
1348
+ # A healthy trace is the normal case, not a dead end. The log holds every module's
1349
+ # inputs and outputs; stopping at "nothing went wrong" would leave the feature
1350
+ # unable to answer the question it was built for - what did each module see -
1351
+ # except when something had already crashed.
1352
+ lines.append("no failure recorded: every module that was entered also returned.")
1353
+ lines.append("")
1354
+ index = forward_index(events)
1355
+ lines.append(f"{len(index)} forward(s) in this trace:")
1356
+ for entry in index[:20]:
1357
+ lines.append(
1358
+ f" forward {entry['forward']:<5} {entry['modules']:>5} modules "
1359
+ f"{entry['phase']:<18} {_format_samples(entry)}"
1360
+ )
1361
+ if len(index) > 20:
1362
+ lines.append(f" ... and {len(index) - 20} more")
1363
+ first = index[0]["forward"] if index else 1
1364
+ lines.append("")
1365
+ lines.append("read one of them:")
1366
+ lines.append(f" evalmetry debug {path} --forward {first}")
1367
+ lines.append(f" evalmetry debug {path} --forward {first} "
1368
+ "--module 'layers\\.0\\.'")
1369
+ return lines
1370
+
1371
+
1372
+ def _format_failed_trace(
1373
+ events: list[dict[str, Any]], error: dict[str, Any] | None,
1374
+ unwound: list[dict[str, Any]], tail: int,
1375
+ ) -> list[str]:
1376
+ """오류·미반환 모듈·직전 이벤트를 출력한다. error 또는 unwound가 있어야 한다.
1377
+
1378
+ error가 있으면 그 시점을, 없으면 첫 unwound 이벤트를 경계로 사용한다.
1379
+ 이벤트를 다시 정렬하지 않아 trace가 기록한 호출 순서를 유지한다.
1380
+ """
1381
+ lines: list[str] = []
1382
+ if error is not None:
1383
+ lines.append(
1384
+ f"FAILED in phase {error['phase']}, forward {error['forward']}: "
1385
+ f"{error['exc_type']}: {error['message'].splitlines()[0][:160]}"
1386
+ )
1387
+ if error.get("detected_at"):
1388
+ lines.append(
1389
+ f" the bad output came from {error['detected_at']}, which had already "
1390
+ "returned; the stack below is its caller"
1391
+ )
1392
+ lines.append(f" documents in flight: {_format_samples(error)}")
1393
+ memory = error.get("mem")
1394
+ if memory:
1395
+ lines.append(
1396
+ f" cuda: {memory['allocated'] / 1e9:.2f} GB allocated, "
1397
+ f"{memory['reserved'] / 1e9:.2f} GB reserved"
1398
+ )
1399
+ lines.append("")
1400
+
1401
+ if unwound:
1402
+ lines.append("entered and never returned, outermost first:")
1403
+ for event in unwound:
1404
+ marker = " <- stopped here" if event.get("deepest") else ""
1405
+ lines.append(
1406
+ f" {' ' * event['depth']}{event['module']:<40} {event['type']}{marker}"
1407
+ )
1408
+ if event.get("deepest"):
1409
+ lines.extend(_format_args(event, " " + " " * event["depth"] + " "))
1410
+ lines.append("")
1411
+
1412
+ boundary = error["i"] if error is not None else unwound[0]["i"]
1413
+ before = [e for e in events if e["i"] < boundary and e.get("event") in {"enter", "exit"}]
1414
+ if before:
1415
+ lines.append(f"last {min(tail, len(before))} module events before that:")
1416
+ for event in before[-tail:]:
1417
+ lines.append(
1418
+ f" {event['i']:>6} {event['event']:<5} {event['module']:<40} "
1419
+ f"{event.get('phase', '')}"
1420
+ )
1421
+ return lines