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/__init__.py +27 -0
- evalmetry/adapters.py +576 -0
- evalmetry/backend.py +942 -0
- evalmetry/benchmarks.py +261 -0
- evalmetry/debug.py +1421 -0
- evalmetry/hooks.py +782 -0
- evalmetry/judges.py +273 -0
- evalmetry/main.py +1504 -0
- evalmetry/models.py +209 -0
- evalmetry/module_stats.py +1084 -0
- evalmetry/recorder.py +556 -0
- evalmetry/reducers.py +578 -0
- evalmetry/report.py +2193 -0
- evalmetry/storage.py +1546 -0
- evalmetry-1.0.0.dist-info/METADATA +94 -0
- evalmetry-1.0.0.dist-info/RECORD +20 -0
- evalmetry-1.0.0.dist-info/WHEEL +5 -0
- evalmetry-1.0.0.dist-info/entry_points.txt +2 -0
- evalmetry-1.0.0.dist-info/licenses/LICENSE +21 -0
- evalmetry-1.0.0.dist-info/top_level.txt +1 -0
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
|