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/main.py
ADDED
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
"""Entry point: argument parsing, run configuration, and the run/collection/report commands.
|
|
2
|
+
|
|
3
|
+
All arguments live here, in one place, so that adding one means editing one file.
|
|
4
|
+
Model loading arguments are passed straight through to lm-eval in its own format; we do not redefine them.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
|
|
8
|
+
run evaluate a model and record selected signals
|
|
9
|
+
collect-research-data collect additional data from a saved evaluation
|
|
10
|
+
report compare runs and select documents for inspection
|
|
11
|
+
debug inspect execution traces
|
|
12
|
+
module-stats inspect per-document and dataset module statistics
|
|
13
|
+
|
|
14
|
+
There is no sweep runner and no scheduler; repeated runs are a shell loop.
|
|
15
|
+
A `run` into a directory that already holds shards does resume, in the narrow sense that documents already recorded are not recorded again - shards are write-once, so an interrupted run leaves valid data that a restart must add to rather than duplicate or overwrite.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import dataclasses
|
|
27
|
+
import functools
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import date, datetime, timezone
|
|
31
|
+
from typing import Any, Sequence
|
|
32
|
+
|
|
33
|
+
from . import debug, storage
|
|
34
|
+
from .debug import TRACE_BUFFER_EVENTS
|
|
35
|
+
from .storage import FIXED_SETTINGS, SAMPLING_SEED, SCHEMA_VERSION
|
|
36
|
+
|
|
37
|
+
#: Version of this tool, recorded in every manifest.
|
|
38
|
+
TOOL_VERSION = "0.1.0"
|
|
39
|
+
|
|
40
|
+
#: Default number of documents collected per correctness group.
|
|
41
|
+
DEFAULT_COLLECT_LIMIT = 500
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------
|
|
45
|
+
# Argument helpers
|
|
46
|
+
# --------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_hidden_layers(spec: str, n_residual: int) -> list[int]:
|
|
50
|
+
"""Parse `--hidden-layers`: `all`, a list (`0,1,2`), or ranges (`0-8`).
|
|
51
|
+
|
|
52
|
+
Duplicates are removed and the result is sorted.
|
|
53
|
+
An index outside `0..n_residual-1` fails here rather than being clipped: a silently clipped index means the run stores a different set of layers than was asked for, and nothing downstream would show that.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
n_residual: L+1, the number of residual entries.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Ascending, deduplicated absolute indices.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> parse_hidden_layers("all", 5)
|
|
63
|
+
[0, 1, 2, 3, 4]
|
|
64
|
+
>>> parse_hidden_layers("0-2,4,4", 5)
|
|
65
|
+
[0, 1, 2, 4]
|
|
66
|
+
>>> parse_hidden_layers("-1", 5)
|
|
67
|
+
[4]
|
|
68
|
+
>>> parse_hidden_layers("9", 5)
|
|
69
|
+
Traceback (most recent call last):
|
|
70
|
+
ValueError: hidden layer index 9 is outside 0..4
|
|
71
|
+
"""
|
|
72
|
+
if spec.strip().lower() == "all":
|
|
73
|
+
return list(range(n_residual))
|
|
74
|
+
|
|
75
|
+
chosen: set[int] = set()
|
|
76
|
+
for part in spec.split(","):
|
|
77
|
+
part = part.strip()
|
|
78
|
+
if not part:
|
|
79
|
+
continue
|
|
80
|
+
match = re.fullmatch(r"(-?\d+)\s*-\s*(-?\d+)", part)
|
|
81
|
+
if match:
|
|
82
|
+
start, stop = int(match.group(1)), int(match.group(2))
|
|
83
|
+
chosen.update(range(start, stop + 1))
|
|
84
|
+
else:
|
|
85
|
+
chosen.add(int(part))
|
|
86
|
+
|
|
87
|
+
resolved: set[int] = set()
|
|
88
|
+
for index in chosen:
|
|
89
|
+
# A negative index is recorded in the manifest as the absolute one it resolves to, so the run stays readable without knowing L.
|
|
90
|
+
absolute = index + n_residual if index < 0 else index
|
|
91
|
+
if not 0 <= absolute < n_residual:
|
|
92
|
+
raise ValueError(f"hidden layer index {index} is outside 0..{n_residual - 1}")
|
|
93
|
+
resolved.add(absolute)
|
|
94
|
+
return sorted(resolved)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_batch_size(value: str) -> int | str:
|
|
98
|
+
"""`--batch-size`: a positive integer, or "auto".
|
|
99
|
+
|
|
100
|
+
"auto" is lm-eval's own probe for the largest batch that fits, and `backend.py` already mirrors its handling because that code is part of the copied `_loglikelihood_tokens`.
|
|
101
|
+
Accepting the string here is what makes that branch reachable; without it the mirrored code could never run.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> parse_batch_size("8"), parse_batch_size("auto")
|
|
105
|
+
(8, 'auto')
|
|
106
|
+
>>> parse_batch_size("0")
|
|
107
|
+
Traceback (most recent call last):
|
|
108
|
+
ValueError: --batch-size must be a positive integer or "auto", got '0'
|
|
109
|
+
"""
|
|
110
|
+
if value.strip().lower() == "auto":
|
|
111
|
+
return "auto"
|
|
112
|
+
try:
|
|
113
|
+
size = int(value)
|
|
114
|
+
except ValueError:
|
|
115
|
+
size = 0
|
|
116
|
+
if size < 1:
|
|
117
|
+
raise ValueError(f'--batch-size must be a positive integer or "auto", got {value!r}')
|
|
118
|
+
return size
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def model_slug(model_id: str) -> str:
|
|
122
|
+
"""Filesystem-safe short name for a model id.
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
>>> model_slug("Qwen/Qwen3-8B")
|
|
126
|
+
'Qwen__Qwen3-8B'
|
|
127
|
+
"""
|
|
128
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", model_id.replace("/", "__"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
# Run configuration
|
|
133
|
+
# --------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class RunConfig:
|
|
138
|
+
"""Everything that defines one run, plus the hash that names its directory.
|
|
139
|
+
|
|
140
|
+
Attributes:
|
|
141
|
+
model_args: passed to lm-eval verbatim (`pretrained=...,dtype=...`).
|
|
142
|
+
tasks: task names.
|
|
143
|
+
include_path: directories containing local benchmark YAML, JSONL and scoring code.
|
|
144
|
+
Paths inside YAML resolve relative to that YAML. See examples/custom_benchmarks.
|
|
145
|
+
save_attention / save_hidden: opt-in signals, both collected in a second pass because correctness is not known during the first one.
|
|
146
|
+
debug: module tracing, off by default.
|
|
147
|
+
Absent from `identity` and `config_hash`: it changes how long a run takes, and with `sync` or `stop_on_nonfinite` whether it finishes, but not what the signals mean.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_args: str
|
|
151
|
+
tasks: list[str]
|
|
152
|
+
num_fewshot: int = 0
|
|
153
|
+
limit: int | None = None
|
|
154
|
+
batch_size: int | str = 1
|
|
155
|
+
adapter: str | None = None
|
|
156
|
+
save_attention: bool = False
|
|
157
|
+
save_hidden: bool = False
|
|
158
|
+
hidden_layers: str = "all"
|
|
159
|
+
collect_limit: int = DEFAULT_COLLECT_LIMIT
|
|
160
|
+
output: str | None = None
|
|
161
|
+
include_path: list[str] = dataclasses.field(default_factory=list)
|
|
162
|
+
model_factory: str | None = None
|
|
163
|
+
model_config: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
164
|
+
model_bundle: Any = dataclasses.field(default=None, repr=False)
|
|
165
|
+
resolved_bundle: Any = dataclasses.field(default=None, init=False, repr=False)
|
|
166
|
+
model_provenance: dict[str, Any] = dataclasses.field(default_factory=dict, init=False)
|
|
167
|
+
signals: tuple[str, ...] = ("logit_lens", "similarity")
|
|
168
|
+
system_instruction: str | None = None
|
|
169
|
+
apply_chat_template: bool | str = False
|
|
170
|
+
hook_factory: str | None = None
|
|
171
|
+
hook_config: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
172
|
+
hooks: list[Any] = dataclasses.field(default_factory=list)
|
|
173
|
+
resolved_hooks: list[Any] = dataclasses.field(default_factory=list, init=False, repr=False)
|
|
174
|
+
hook_provenance: list[dict[str, Any]] = dataclasses.field(default_factory=list, init=False)
|
|
175
|
+
# 이름 -> selector가 실제로 고른 경로. 모델이 있어야 알 수 있으므로 실행 식별에는
|
|
176
|
+
# 넣지 않고 manifest에만 남긴다. 식별에 들어가는 것은 selector 쪽이다.
|
|
177
|
+
hook_resolved: dict[str, list[str]] | None = dataclasses.field(default=None, init=False)
|
|
178
|
+
benchmark_provenance: dict[str, Any] = dataclasses.field(default_factory=dict, init=False)
|
|
179
|
+
debug: "debug.DebugConfig" = dataclasses.field(default_factory=lambda: debug.DebugConfig())
|
|
180
|
+
|
|
181
|
+
def model_kwargs(self) -> dict[str, str]:
|
|
182
|
+
"""The lm-eval model argument string parsed into a dict.
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
>>> RunConfig("pretrained=Qwen/Qwen3-8B,dtype=bfloat16", []).model_kwargs()
|
|
186
|
+
{'pretrained': 'Qwen/Qwen3-8B', 'dtype': 'bfloat16'}
|
|
187
|
+
"""
|
|
188
|
+
parsed: dict[str, str] = {}
|
|
189
|
+
for part in self.model_args.split(","):
|
|
190
|
+
if "=" in part:
|
|
191
|
+
key, value = part.split("=", 1)
|
|
192
|
+
parsed[key.strip()] = value.strip()
|
|
193
|
+
return parsed
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def model_id(self) -> str:
|
|
197
|
+
return self.model_provenance.get("model_id") or self.model_kwargs().get("pretrained", "unknown-model")
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def revision(self) -> str:
|
|
201
|
+
return self.model_kwargs().get("revision", "main")
|
|
202
|
+
|
|
203
|
+
def identity(
|
|
204
|
+
self, reducer_descriptors: Sequence[dict[str, Any]], lm_eval_version: str
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
"""What has to match for two runs to belong in the same directory.
|
|
207
|
+
|
|
208
|
+
The seed is deliberately absent: two runs differing only in seed are the same configuration.
|
|
209
|
+
Everything else here changes what the signals mean, so mixing them in one directory would produce a run that misdescribes itself.
|
|
210
|
+
|
|
211
|
+
Example:
|
|
212
|
+
>>> config.identity(descriptors, "0.4.9.1")["model_id"] # doctest: +SKIP
|
|
213
|
+
'Qwen/Qwen3-8B'
|
|
214
|
+
"""
|
|
215
|
+
identity = {
|
|
216
|
+
"model_id": self.model_id,
|
|
217
|
+
"revision": self.revision,
|
|
218
|
+
"tasks": sorted(self.tasks),
|
|
219
|
+
"num_fewshot": self.num_fewshot,
|
|
220
|
+
"limit": self.limit,
|
|
221
|
+
"reducers": reducer_descriptors,
|
|
222
|
+
"lm_eval_version": lm_eval_version,
|
|
223
|
+
}
|
|
224
|
+
# 사용하지 않은 확장 기능은 키 자체를 생략한다. 빈 값이라도 추가하면
|
|
225
|
+
# 기존 실행과 identity/hash가 달라져 resume과 결과 비교에 영향을 준다.
|
|
226
|
+
if self.model_provenance:
|
|
227
|
+
identity["custom_model"] = self.model_provenance
|
|
228
|
+
identity["custom_model_batch_size"] = self.batch_size
|
|
229
|
+
if self.system_instruction is not None or self.apply_chat_template:
|
|
230
|
+
identity["prompt_protocol"] = {
|
|
231
|
+
"system_instruction": self.system_instruction,
|
|
232
|
+
"apply_chat_template": self.apply_chat_template,
|
|
233
|
+
}
|
|
234
|
+
if self.benchmark_provenance:
|
|
235
|
+
identity["benchmarks"] = self.benchmark_provenance
|
|
236
|
+
if self.hook_provenance:
|
|
237
|
+
identity["hooks"] = {
|
|
238
|
+
"factory": self.hook_factory,
|
|
239
|
+
"config": self.hook_config,
|
|
240
|
+
"specs": self.hook_provenance,
|
|
241
|
+
}
|
|
242
|
+
return identity
|
|
243
|
+
|
|
244
|
+
def config_hash(self, reducer_descriptors: Sequence[dict[str, Any]], lm_eval_version: str) -> str:
|
|
245
|
+
"""Short hash naming this configuration.
|
|
246
|
+
|
|
247
|
+
The seed is deliberately not an input: two runs that differ only in seed are the same configuration.
|
|
248
|
+
Reducer versions are, because they change what the numbers mean.
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
>>> config.config_hash(descriptors, "0.4.9.1") # doctest: +SKIP
|
|
252
|
+
'ab12cd34'
|
|
253
|
+
"""
|
|
254
|
+
payload = self.identity(reducer_descriptors, lm_eval_version)
|
|
255
|
+
blob = json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
|
|
256
|
+
return hashlib.sha256(blob).hexdigest()[:8]
|
|
257
|
+
|
|
258
|
+
def default_output(self, config_hash: str) -> str:
|
|
259
|
+
"""`results/{task}/{model_slug}/{date}-{config_hash}`.
|
|
260
|
+
|
|
261
|
+
Only a convenience for browsing; `report` reads the manifest and never parses this path.
|
|
262
|
+
|
|
263
|
+
Example:
|
|
264
|
+
>>> RunConfig("pretrained=Qwen/Qwen3-8B", ["xnli_ko"]).default_output("ab12cd34")
|
|
265
|
+
'results/xnli_ko/Qwen__Qwen3-8B/2026-01-31-ab12cd34' # date varies
|
|
266
|
+
"""
|
|
267
|
+
task = "+".join(sorted(self.tasks)) or "unknown-task"
|
|
268
|
+
today = date.today().isoformat()
|
|
269
|
+
return os.path.join("results", task, model_slug(self.model_id), f"{today}-{config_hash}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------------------
|
|
273
|
+
# Environment checks
|
|
274
|
+
# --------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def validate_single_gpu_execution(config: RunConfig) -> None:
|
|
278
|
+
"""Reject lm-eval parallelism before tasks, models, or output paths are touched.
|
|
279
|
+
|
|
280
|
+
Accelerate 1.14 enters its GPU distributed path when ``LOCAL_RANK`` is set
|
|
281
|
+
to a non-negative integer. ``torchrun`` supplies that variable too. A
|
|
282
|
+
stray ``WORLD_SIZE`` alone is deliberately not enough: schedulers and
|
|
283
|
+
parent shells can leave it set for an otherwise ordinary process.
|
|
284
|
+
"""
|
|
285
|
+
import torch
|
|
286
|
+
|
|
287
|
+
distributed = torch.distributed.is_available() and torch.distributed.is_initialized()
|
|
288
|
+
try:
|
|
289
|
+
launched_rank = int(os.environ.get("LOCAL_RANK", "-1"))
|
|
290
|
+
except ValueError:
|
|
291
|
+
launched_rank = -1
|
|
292
|
+
if distributed or launched_rank >= 0:
|
|
293
|
+
detail = "an initialized torch process group" if distributed else f"LOCAL_RANK={launched_rank}"
|
|
294
|
+
raise RuntimeError(
|
|
295
|
+
"only a single process on one CUDA GPU is supported; distributed/data-parallel "
|
|
296
|
+
f"execution was detected from {detail}. Do not use `accelerate launch`, "
|
|
297
|
+
"`torchrun`, or another distributed launcher; run `evalmetry run` "
|
|
298
|
+
"directly with one visible GPU."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
options = config.model_kwargs()
|
|
302
|
+
rejected: list[str] = []
|
|
303
|
+
if "parallelize" in options and options["parallelize"].strip().lower() not in {
|
|
304
|
+
"false", "0", "none", "null", ""
|
|
305
|
+
}:
|
|
306
|
+
rejected.append("parallelize")
|
|
307
|
+
for name in ("tp_plan", "device_map", "device_mesh", "tp_size", "tensor_parallel_size"):
|
|
308
|
+
if name in options and options[name].strip().lower() not in {"none", "null", ""}:
|
|
309
|
+
rejected.append(name)
|
|
310
|
+
if rejected:
|
|
311
|
+
rendered = ", ".join(f"`{name}`" for name in rejected)
|
|
312
|
+
raise ValueError(
|
|
313
|
+
"only a single process on one CUDA GPU is supported; model sharding and tensor "
|
|
314
|
+
f"parallelism are unsupported, but {rendered} was set. Remove these model args "
|
|
315
|
+
"and load the complete model on one GPU."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _short(value: Any, width: int = 90) -> str:
|
|
320
|
+
"""Render one identity field compactly enough to read in an error.
|
|
321
|
+
|
|
322
|
+
The reducer list is the reason this exists: printed in full it is several hundred characters of configuration, which buries the field that actually differs.
|
|
323
|
+
"""
|
|
324
|
+
if isinstance(value, list) and value and isinstance(value[0], dict) and "name" in value[0]:
|
|
325
|
+
return "[" + ", ".join(f"{item['name']} v{item.get('version')}" for item in value) + "]"
|
|
326
|
+
text = repr(value)
|
|
327
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def check_resume_is_the_same_run(run_dir: str, identity: dict[str, Any]) -> None:
|
|
331
|
+
"""Refuse to resume into a directory that belongs to a different run.
|
|
332
|
+
|
|
333
|
+
Resuming skips documents already recorded, which is only correct if the directory holds *this* run.
|
|
334
|
+
Nothing about a run directory forces that: pointing a second model at it produced a directory whose manifest named one model while its `signals` held another's layers, and both halves looked entirely valid on their own.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
ValueError: naming the fields that differ, so the fix is obvious - a typo in --model-args, or an --output that should have been new.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
manifest = storage.read_manifest(run_dir)
|
|
341
|
+
except (OSError, ValueError, KeyError) as error:
|
|
342
|
+
if (not isinstance(error, FileNotFoundError)
|
|
343
|
+
and os.path.isfile(os.path.join(run_dir, storage.RESULTS_FILENAME))):
|
|
344
|
+
# A damaged manifest is not an empty directory: resuming would overwrite the only record of the run.
|
|
345
|
+
raise ValueError(f"{run_dir}: results.json exists but cannot be read ({error}); "
|
|
346
|
+
"restore it or use a new --output") from error
|
|
347
|
+
if (identity.get("benchmarks") or identity.get("hooks") or identity.get("custom_model")) and os.path.isdir(run_dir) and os.listdir(run_dir):
|
|
348
|
+
raise ValueError("custom extension resume requires an intact provenance manifest")
|
|
349
|
+
return # nothing recorded here yet, or not a run directory at all
|
|
350
|
+
previous = manifest.get("config_identity")
|
|
351
|
+
if not previous:
|
|
352
|
+
if (identity.get("benchmarks") or identity.get("hooks") or identity.get("custom_model")):
|
|
353
|
+
raise ValueError("custom extension resume requires provenance")
|
|
354
|
+
return # written before this check existed; nothing to compare against
|
|
355
|
+
|
|
356
|
+
if identity.get("custom_model") and not identity["custom_model"].get("resume_safe"):
|
|
357
|
+
raise ValueError("custom model is not reproducibly identified; automatic resume is disabled")
|
|
358
|
+
if any(not h["resume_safe"] for h in identity.get("hooks", {}).get("specs", [])):
|
|
359
|
+
raise ValueError("custom hook source is unavailable; automatic resume is disabled")
|
|
360
|
+
differing = {
|
|
361
|
+
key: (previous.get(key), identity.get(key))
|
|
362
|
+
for key in set(previous) | set(identity)
|
|
363
|
+
if previous.get(key) != identity.get(key)
|
|
364
|
+
}
|
|
365
|
+
if not differing:
|
|
366
|
+
return
|
|
367
|
+
lines = "\n".join(
|
|
368
|
+
f" {key}: existing run has {_short(old)}, this one has {_short(new)}"
|
|
369
|
+
for key, (old, new) in sorted(differing.items())
|
|
370
|
+
)
|
|
371
|
+
raise ValueError(
|
|
372
|
+
f"{run_dir} already holds a different run, so resuming would mix them:\n"
|
|
373
|
+
f"{lines}\n"
|
|
374
|
+
" Use a new --output, or correct the arguments to match the existing run."
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def check_environment(model: Any) -> dict[str, Any]:
|
|
379
|
+
"""Refuse unsupported device setups and describe the accepted environment.
|
|
380
|
+
|
|
381
|
+
* multi-GPU sharding is rejected: layers would sit on different devices, and the similarity reducer would pay a cross-device transfer per layer pair;
|
|
382
|
+
* CPU and MPS are not supported;
|
|
383
|
+
* fp16 and bf16 are the verified dtypes.
|
|
384
|
+
Quantized models are allowed but not guaranteed - the point at which weights are dequantized differs per kernel, so what the hooks see differs too.
|
|
385
|
+
`check_decode_identity()` is the practical test for whether a given quantized model is usable.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
A dict describing the device setup, for the manifest.
|
|
389
|
+
"""
|
|
390
|
+
import torch
|
|
391
|
+
|
|
392
|
+
devices = {str(p.device) for p in model.parameters()}
|
|
393
|
+
cuda_devices = {d for d in devices if d.startswith("cuda")}
|
|
394
|
+
if not cuda_devices:
|
|
395
|
+
raise RuntimeError(
|
|
396
|
+
f"CUDA is required; model parameters are on {sorted(devices)}. "
|
|
397
|
+
"CPU and MPS are not supported."
|
|
398
|
+
)
|
|
399
|
+
if len(cuda_devices) > 1:
|
|
400
|
+
raise RuntimeError(
|
|
401
|
+
f"model is sharded across {sorted(cuda_devices)}. Multi-GPU sharding is not "
|
|
402
|
+
"supported: the similarity reducer would move tensors between devices for "
|
|
403
|
+
"every layer pair. Load the model on a single GPU."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
dtypes = {str(p.dtype) for p in model.parameters()}
|
|
407
|
+
verified = {"torch.float16", "torch.bfloat16"}
|
|
408
|
+
warnings: list[str] = []
|
|
409
|
+
if not dtypes & verified:
|
|
410
|
+
warnings.append(
|
|
411
|
+
f"model dtype {sorted(dtypes)} is outside the verified set (fp16, bf16); "
|
|
412
|
+
"signals are produced but not guaranteed"
|
|
413
|
+
)
|
|
414
|
+
for warning in warnings:
|
|
415
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
416
|
+
return {
|
|
417
|
+
"devices": sorted(devices),
|
|
418
|
+
"dtypes": sorted(dtypes),
|
|
419
|
+
"cuda_device_count": torch.cuda.device_count(),
|
|
420
|
+
"warnings": warnings,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def force_eager_attention(model: Any) -> str:
|
|
425
|
+
"""Switch the model to eager attention so probabilities are materialised.
|
|
426
|
+
|
|
427
|
+
FlashAttention and SDPA compute the output without ever building the probability matrix, so there is nothing for a hook to catch.
|
|
428
|
+
Transformers dispatches on `config._attn_implementation` at forward time, so this can be flipped on a loaded model instead of reloading it.
|
|
429
|
+
|
|
430
|
+
Expect this to be slower and to use more memory: an explicit (seq x seq) matrix per head is exactly what the fast kernels avoid.
|
|
431
|
+
It only runs on the collected documents, not on the whole task.
|
|
432
|
+
"""
|
|
433
|
+
print(
|
|
434
|
+
"warning: switching to eager attention to capture attention weights. "
|
|
435
|
+
"This is slower than SDPA/FlashAttention and raises peak memory.",
|
|
436
|
+
file=sys.stderr,
|
|
437
|
+
)
|
|
438
|
+
configs = [model.config] + [
|
|
439
|
+
value
|
|
440
|
+
for value in vars(model.config).values()
|
|
441
|
+
if hasattr(value, "_attn_implementation")
|
|
442
|
+
]
|
|
443
|
+
for config in configs:
|
|
444
|
+
config._attn_implementation = "eager"
|
|
445
|
+
return "eager"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# --------------------------------------------------------------------------
|
|
449
|
+
# Assembling a run
|
|
450
|
+
# --------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def build_reducers(
|
|
454
|
+
adapter: Any,
|
|
455
|
+
tokenizer: Any,
|
|
456
|
+
*,
|
|
457
|
+
attention: bool,
|
|
458
|
+
hidden_layers: list[int] | None,
|
|
459
|
+
include_always_on: bool = True,
|
|
460
|
+
signals: Sequence[str] = ("logit_lens", "similarity"),
|
|
461
|
+
):
|
|
462
|
+
"""Pick the reducer set for one pass.
|
|
463
|
+
|
|
464
|
+
The evaluation signals default to logit lens and layer-pair similarity.
|
|
465
|
+
`signals` can select either or disable both; tensor dumps are opt-in.
|
|
466
|
+
|
|
467
|
+
`include_always_on` is False on the collection pass.
|
|
468
|
+
The first pass already collected the always-on signals for *every* document, so re-running them on the collected subset would write a second copy of those rows.
|
|
469
|
+
|
|
470
|
+
Example:
|
|
471
|
+
>>> [r.name for r in build_reducers(adapter, tok, attention=True, hidden_layers=None)]
|
|
472
|
+
['logit_lens', 'layer_similarity', 'value_norm', 'attention_weights']
|
|
473
|
+
>>> [r.name for r in build_reducers(adapter, tok, attention=True,
|
|
474
|
+
... hidden_layers=[0], include_always_on=False)]
|
|
475
|
+
['value_norm', 'attention_weights', 'raw_hidden']
|
|
476
|
+
"""
|
|
477
|
+
from . import reducers as reducer_module
|
|
478
|
+
|
|
479
|
+
chosen = []
|
|
480
|
+
if include_always_on:
|
|
481
|
+
if "logit_lens" in signals:
|
|
482
|
+
chosen.append(reducer_module.LogitLensReducer(adapter.decode_stack, tokenizer, adapter.vocab_size))
|
|
483
|
+
if "similarity" in signals:
|
|
484
|
+
chosen.append(reducer_module.SimilarityReducer(adapter.n_residual))
|
|
485
|
+
if attention:
|
|
486
|
+
chosen.append(
|
|
487
|
+
reducer_module.ValueNormReducer(adapter.n_heads, adapter.n_kv_heads, adapter.head_dim,
|
|
488
|
+
block_shapes=adapter.value_shapes())
|
|
489
|
+
)
|
|
490
|
+
chosen.append(reducer_module.AttentionWeightReducer())
|
|
491
|
+
if hidden_layers:
|
|
492
|
+
chosen.append(reducer_module.RawHiddenReducer(hidden_layers))
|
|
493
|
+
return chosen
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def load_model(config: RunConfig, extra: dict[str, Any] | None = None):
|
|
497
|
+
"""Load the model through our lm-eval backend.
|
|
498
|
+
|
|
499
|
+
Model loading arguments are lm-eval's, unchanged, so `pretrained`, `revision`, `dtype`, `trust_remote_code` and `peft` all behave as documented there.
|
|
500
|
+
"""
|
|
501
|
+
# cmd_run calls this before task setup as well. Keep the loading boundary
|
|
502
|
+
# guarded for callers that use this helper directly.
|
|
503
|
+
validate_single_gpu_execution(config)
|
|
504
|
+
|
|
505
|
+
from .backend import TracedHFLM
|
|
506
|
+
|
|
507
|
+
kwargs = {"batch_size": config.batch_size}
|
|
508
|
+
kwargs.update(extra or {})
|
|
509
|
+
if config.model_factory or config.model_bundle is not None:
|
|
510
|
+
from .models import load_bundle
|
|
511
|
+
from lm_eval.utils import simple_parse_args_string
|
|
512
|
+
options = simple_parse_args_string(config.model_args)
|
|
513
|
+
allowed = {"max_length", "max_gen_toks", "add_bos_token", "prefix_token_id",
|
|
514
|
+
"logits_cache", "truncation", "softmax_dtype"}
|
|
515
|
+
invalid = set(options) - allowed
|
|
516
|
+
if invalid:
|
|
517
|
+
raise ValueError(f"custom factories own loading/device/dtype; unsupported model_args: {sorted(invalid)}")
|
|
518
|
+
options.update(kwargs)
|
|
519
|
+
bundle = load_bundle(config)
|
|
520
|
+
lm = TracedHFLM(pretrained=bundle.model, tokenizer=bundle.tokenizer, **options)
|
|
521
|
+
if lm.model is not bundle.model or lm.tokenizer is not bundle.tokenizer:
|
|
522
|
+
raise RuntimeError("HFLM replaced the supplied model or tokenizer")
|
|
523
|
+
lm.prompt_transform = bundle.prompt_transform
|
|
524
|
+
return lm
|
|
525
|
+
config.resolved_bundle = None
|
|
526
|
+
config.model_provenance = {}
|
|
527
|
+
if config.model_config:
|
|
528
|
+
raise ValueError("model_config requires a model_factory or model_bundle")
|
|
529
|
+
if not config.model_kwargs().get("pretrained"):
|
|
530
|
+
raise ValueError("provide pretrained in --model-args, --model-factory, or model_bundle")
|
|
531
|
+
if not config.model_kwargs().get("device"):
|
|
532
|
+
import torch
|
|
533
|
+
if not torch.cuda.is_available():
|
|
534
|
+
# lm-eval would place the model on its default device, cuda, and fail inside torch.
|
|
535
|
+
raise ValueError("CUDA is required, but torch reports no CUDA device; "
|
|
536
|
+
"this tool runs on a single CUDA GPU")
|
|
537
|
+
return TracedHFLM.create_from_arg_string(config.model_args, kwargs)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def doc_id_set_hash(samples: Sequence[dict[str, Any]]) -> str:
|
|
541
|
+
"""Hash of the exact document set that was evaluated.
|
|
542
|
+
|
|
543
|
+
Two runs of the same task can still cover different documents (one of them used `--limit`), and averaging those together is a silent mistake.
|
|
544
|
+
`report` groups on this value.
|
|
545
|
+
|
|
546
|
+
Example:
|
|
547
|
+
>>> doc_id_set_hash([{"task_name": "t", "doc_id": 1}, {"task_name": "t", "doc_id": 0}])
|
|
548
|
+
'7c17a9358555ccf7'
|
|
549
|
+
"""
|
|
550
|
+
keys = sorted(f"{s['task_name']}:{int(s['doc_id'])}" for s in samples)
|
|
551
|
+
return hashlib.sha256("\n".join(keys).encode("utf-8")).hexdigest()[:16]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def lm_eval_install_info() -> dict[str, Any]:
|
|
555
|
+
"""Version, install form and git state of the installed lm-eval.
|
|
556
|
+
|
|
557
|
+
An editable checkout can report the same version string while running different code, so without the commit and the dirty flag `report` could group two runs that were not produced by the same harness.
|
|
558
|
+
"""
|
|
559
|
+
import subprocess
|
|
560
|
+
|
|
561
|
+
import lm_eval
|
|
562
|
+
|
|
563
|
+
package_dir = os.path.dirname(os.path.dirname(os.path.abspath(lm_eval.__file__)))
|
|
564
|
+
info: dict[str, Any] = {
|
|
565
|
+
"version": lm_eval.__version__,
|
|
566
|
+
"path": os.path.abspath(lm_eval.__file__),
|
|
567
|
+
"install_form": "editable" if os.path.isdir(os.path.join(package_dir, ".git")) else "wheel",
|
|
568
|
+
"commit": None,
|
|
569
|
+
"dirty": None,
|
|
570
|
+
}
|
|
571
|
+
if info["install_form"] == "editable":
|
|
572
|
+
try:
|
|
573
|
+
info["commit"] = subprocess.check_output(
|
|
574
|
+
["git", "-C", package_dir, "rev-parse", "HEAD"], text=True
|
|
575
|
+
).strip()
|
|
576
|
+
info["dirty"] = bool(
|
|
577
|
+
subprocess.check_output(
|
|
578
|
+
["git", "-C", package_dir, "status", "--porcelain"], text=True
|
|
579
|
+
).strip()
|
|
580
|
+
)
|
|
581
|
+
except Exception:
|
|
582
|
+
pass
|
|
583
|
+
return info
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def build_manifest(
|
|
587
|
+
config: RunConfig,
|
|
588
|
+
adapter: Any,
|
|
589
|
+
lm: Any,
|
|
590
|
+
reducer_descriptors: list[dict[str, Any]],
|
|
591
|
+
environment: dict[str, Any],
|
|
592
|
+
samples: Sequence[dict[str, Any]],
|
|
593
|
+
extra: dict[str, Any] | None = None,
|
|
594
|
+
) -> dict[str, Any]:
|
|
595
|
+
"""Assemble the manifest.
|
|
596
|
+
|
|
597
|
+
Everything `report` needs to decide comparability goes in here, along with enough provenance to interpret the run after the code has moved on: the fixed settings as they were at the time, the decode path that was used, and which lm-eval actually ran.
|
|
598
|
+
"""
|
|
599
|
+
from .backend import upstream_source_hashes
|
|
600
|
+
|
|
601
|
+
install = lm_eval_install_info()
|
|
602
|
+
manifest: dict[str, Any] = {
|
|
603
|
+
"schema_version": SCHEMA_VERSION,
|
|
604
|
+
"tool_version": TOOL_VERSION,
|
|
605
|
+
"lm_eval_version": install["version"],
|
|
606
|
+
"lm_eval_install": install,
|
|
607
|
+
"lm_eval_source_hashes": upstream_source_hashes(),
|
|
608
|
+
"backend": storage.BACKEND_NAME,
|
|
609
|
+
"model_id": config.model_id,
|
|
610
|
+
"revision": _resolved_revision(lm),
|
|
611
|
+
"model_args": config.model_args,
|
|
612
|
+
"tokenizer_id": getattr(lm.tokenizer, "name_or_path", config.model_id),
|
|
613
|
+
"tokenizer_revision": config.revision,
|
|
614
|
+
"tasks": list(config.tasks),
|
|
615
|
+
"benchmarks": config.benchmark_provenance,
|
|
616
|
+
"custom_model": config.model_provenance,
|
|
617
|
+
"signals": list(config.signals),
|
|
618
|
+
"prompt_protocol": {"system_instruction": config.system_instruction,
|
|
619
|
+
"apply_chat_template": config.apply_chat_template},
|
|
620
|
+
"internal_signals_available": adapter is not None,
|
|
621
|
+
"custom_hooks": {"factory": config.hook_factory, "config": config.hook_config,
|
|
622
|
+
"specs": config.hook_provenance, "resolved": config.hook_resolved},
|
|
623
|
+
"collection_sampling": {"method": "per_task_correctness", "seed": SAMPLING_SEED, "limit_per_group": config.collect_limit},
|
|
624
|
+
"num_fewshot": config.num_fewshot,
|
|
625
|
+
"limit": config.limit,
|
|
626
|
+
"n_documents": len(samples),
|
|
627
|
+
# Which filter's verdict `docs` carries, and what else the task offered.
|
|
628
|
+
"filters_available": storage.available_filters(samples),
|
|
629
|
+
"filters_used": storage.primary_filters(samples),
|
|
630
|
+
"doc_id_set_hash": doc_id_set_hash(samples),
|
|
631
|
+
# What a later `run` into this directory has to match before it may resume, so two configurations cannot end up in one run.
|
|
632
|
+
"config_identity": extra.pop("config_identity", None) if extra else None,
|
|
633
|
+
"reducers": reducer_descriptors,
|
|
634
|
+
"batch_size": config.batch_size,
|
|
635
|
+
"resolved_batch_size": getattr(lm, "batch_size", None),
|
|
636
|
+
"generate_batch_size": 1,
|
|
637
|
+
# Recorded because it is our one deliberate deviation from lm-eval's defaults, and in fp16 it can move a borderline document.
|
|
638
|
+
# Under matched settings the traced backend is bit-identical to stock lm-eval; see scripts/verify_scores.py.
|
|
639
|
+
"logits_cache": getattr(lm, "logits_cache", None),
|
|
640
|
+
"attn_implementation": getattr(lm.model.config, "_attn_implementation", "unknown"),
|
|
641
|
+
"options": {
|
|
642
|
+
"save_attention": config.save_attention,
|
|
643
|
+
"save_hidden": config.save_hidden,
|
|
644
|
+
"hidden_layers": config.hidden_layers,
|
|
645
|
+
"collect_limit": config.collect_limit,
|
|
646
|
+
},
|
|
647
|
+
"fixed_settings": dict(FIXED_SETTINGS),
|
|
648
|
+
"seed": SAMPLING_SEED,
|
|
649
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
650
|
+
"completed": False,
|
|
651
|
+
}
|
|
652
|
+
if adapter is not None:
|
|
653
|
+
manifest.update(adapter.manifest_entry())
|
|
654
|
+
else:
|
|
655
|
+
manifest.update({"model_type": getattr(lm.model.config, "model_type", "custom"),
|
|
656
|
+
"n_blocks": 0, "n_hidden_states": 0,
|
|
657
|
+
"layer_index_convention": "unavailable",
|
|
658
|
+
"attn_index_convention": "unavailable"})
|
|
659
|
+
manifest.update(environment)
|
|
660
|
+
manifest.update(extra or {})
|
|
661
|
+
return manifest
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _resolved_revision(lm: Any) -> str:
|
|
665
|
+
"""The model's actual commit, because a hub revision like `main` moves."""
|
|
666
|
+
commit = getattr(lm.model.config, "_commit_hash", None)
|
|
667
|
+
return str(commit) if commit else "unknown"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# --------------------------------------------------------------------------
|
|
671
|
+
# Commands
|
|
672
|
+
# --------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@contextmanager
|
|
676
|
+
def _tracing(config: RunConfig, model: Any, run_dir: str, name: str = "trace"):
|
|
677
|
+
"""Hook the model for the duration, or do nothing at all.
|
|
678
|
+
|
|
679
|
+
A plain context manager rather than a flag threaded through the call chain, so that the traced and untraced paths are the same code. When tracing is off this yields immediately and nothing in `debug.py` is touched.
|
|
680
|
+
|
|
681
|
+
`name` separates the two passes. They are different programs with different memory profiles - the second re-runs a sample with eager attention to dump `(heads, seq, seq)` maps - and sharing one ring buffer would let the long cheap pass push the expensive one out of it.
|
|
682
|
+
"""
|
|
683
|
+
if not config.debug.active:
|
|
684
|
+
yield None
|
|
685
|
+
return
|
|
686
|
+
path = debug.trace_path(run_dir, name)
|
|
687
|
+
tracer = debug.ModuleTracer(model, config.debug, path)
|
|
688
|
+
with tracer.session():
|
|
689
|
+
print(f"module tracing on: {len(tracer.traced_modules)} modules -> {path}")
|
|
690
|
+
if tracer.statistics is not None:
|
|
691
|
+
print(f"sample module statistics -> {tracer.statistics.directory}")
|
|
692
|
+
yield tracer
|
|
693
|
+
size = tracer.bytes_written
|
|
694
|
+
print(f"trace written: {path} {size / 1e6:.1f} MB "
|
|
695
|
+
f"(evalmetry debug {run_dir})")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def cmd_debug(args: argparse.Namespace) -> None:
|
|
699
|
+
"""Print what a run's traces say - the failure, or the modules of one forward."""
|
|
700
|
+
path = args.path
|
|
701
|
+
traces = debug.list_traces(path) if os.path.isdir(path) else [path]
|
|
702
|
+
if not traces:
|
|
703
|
+
# Let `read_trace` raise the message that explains why there is nothing here.
|
|
704
|
+
debug.read_trace(path)
|
|
705
|
+
|
|
706
|
+
if args.forward is None and args.doc is None:
|
|
707
|
+
for trace in traces:
|
|
708
|
+
print(debug.summarize_trace(trace, tail=args.events))
|
|
709
|
+
print()
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
for trace in traces:
|
|
713
|
+
events = debug.read_trace(trace)
|
|
714
|
+
wanted = _forwards_wanted(events, args)
|
|
715
|
+
if not wanted:
|
|
716
|
+
continue
|
|
717
|
+
print(f"trace: {trace}")
|
|
718
|
+
for number in wanted:
|
|
719
|
+
print()
|
|
720
|
+
print(debug.describe_forward(events, number, module=args.module))
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _forwards_wanted(events: Sequence[dict[str, Any]], args: argparse.Namespace) -> list[int]:
|
|
724
|
+
"""Which forward numbers the user asked for, by number or by document."""
|
|
725
|
+
if args.forward is not None:
|
|
726
|
+
return [args.forward]
|
|
727
|
+
task, _, raw = str(args.doc).rpartition("#")
|
|
728
|
+
try:
|
|
729
|
+
doc_id = int(raw)
|
|
730
|
+
except ValueError:
|
|
731
|
+
raise ValueError(f"--doc wants a document id, e.g. arc_easy#42 or 42, not {args.doc!r}")
|
|
732
|
+
found = []
|
|
733
|
+
for entry in debug.forward_index(events):
|
|
734
|
+
for row in entry.get("samples") or []:
|
|
735
|
+
if row[1] == doc_id and (not task or row[0] == task):
|
|
736
|
+
found.append(entry["forward"])
|
|
737
|
+
break
|
|
738
|
+
if not found:
|
|
739
|
+
print(f"no forward in this trace covers document {args.doc}")
|
|
740
|
+
return found
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _releasing_writer_locks(command):
|
|
744
|
+
"""Release the run-directory locks a command took when it returns or fails.
|
|
745
|
+
|
|
746
|
+
A failed run otherwise keeps its lock and file descriptor until the process exits,
|
|
747
|
+
one per directory, in a notebook or sweep that keeps going after the failure.
|
|
748
|
+
"""
|
|
749
|
+
@functools.wraps(command)
|
|
750
|
+
def wrapper(*args, **kwargs):
|
|
751
|
+
held = set(storage._WRITER_LOCKS)
|
|
752
|
+
try:
|
|
753
|
+
return command(*args, **kwargs)
|
|
754
|
+
finally:
|
|
755
|
+
for path in set(storage._WRITER_LOCKS) - held:
|
|
756
|
+
storage.release_writer_lock(path)
|
|
757
|
+
return wrapper
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _prepare_evaluation_tasks(config: RunConfig) -> tuple[Any, list[Any]]:
|
|
761
|
+
"""모델을 올리기 전에 custom hook과 benchmark를 검증하고 provenance를 채운다.
|
|
762
|
+
|
|
763
|
+
로컬 task는 여기서 생성해 YAML과 사용자 함수 오류를 먼저 발견한다.
|
|
764
|
+
built-in group 이름은 그대로 넘겨 lm-eval의 그룹 집계를 유지한다.
|
|
765
|
+
"""
|
|
766
|
+
from .benchmarks import prepare_benchmarks
|
|
767
|
+
from .hooks import load_hooks
|
|
768
|
+
|
|
769
|
+
config.resolved_hooks = load_hooks(config.hook_factory, config.hook_config, config.hooks)
|
|
770
|
+
if any(h.collection_resume for h in config.resolved_hooks) and (config.save_attention or config.save_hidden):
|
|
771
|
+
raise ValueError("custom collection_resume cannot share legacy attention/hidden dumps; use custom raw_tensors")
|
|
772
|
+
config.hook_provenance = [h.descriptor() for h in config.resolved_hooks]
|
|
773
|
+
task_manager, evaluation_tasks, config.benchmark_provenance = prepare_benchmarks(
|
|
774
|
+
config.include_path, config.tasks
|
|
775
|
+
)
|
|
776
|
+
if task_manager is not None:
|
|
777
|
+
# Instantiate local tasks once for schema/function validation before GPU allocation.
|
|
778
|
+
# Built-in group names stay intact so lm-eval retains their aggregate results.
|
|
779
|
+
evaluation_tasks = [
|
|
780
|
+
next(iter(task_manager.load([task])["tasks"].values()))
|
|
781
|
+
if isinstance(task, dict) else task for task in evaluation_tasks
|
|
782
|
+
]
|
|
783
|
+
return task_manager, evaluation_tasks
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@_releasing_writer_locks
|
|
787
|
+
def cmd_run(config: RunConfig) -> str:
|
|
788
|
+
"""Evaluate with lm-eval, collect signals, and leave a run directory behind.
|
|
789
|
+
|
|
790
|
+
With `--save-attention` or `--save-hidden` this runs two passes.
|
|
791
|
+
The first scores and records the always-on signals; the second collects a balanced sample of right and wrong answers with the expensive hooks attached.
|
|
792
|
+
It has to be two passes because correctness is not known until lm-eval has finished scoring, and deciding correctness ourselves would mean reimplementing it.
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
The run directory.
|
|
796
|
+
"""
|
|
797
|
+
validate_single_gpu_execution(config)
|
|
798
|
+
|
|
799
|
+
import lm_eval
|
|
800
|
+
|
|
801
|
+
from .adapters import check_decode_identity
|
|
802
|
+
from .backend import check_upstream_source
|
|
803
|
+
from .recorder import Recorder
|
|
804
|
+
from .hooks import ensure_collection_is_empty
|
|
805
|
+
|
|
806
|
+
# 1. 사용자 확장을 검증한 뒤 모델과 관측 구성을 준비한다.
|
|
807
|
+
task_manager, evaluation_tasks = _prepare_evaluation_tasks(config)
|
|
808
|
+
check_upstream_source(strict=False)
|
|
809
|
+
|
|
810
|
+
from .models import resolve_model_adapter
|
|
811
|
+
unknown = set(config.signals) - {"logit_lens", "similarity"}
|
|
812
|
+
if unknown:
|
|
813
|
+
raise ValueError(f"unknown signals: {sorted(unknown)}")
|
|
814
|
+
lm = load_model(config)
|
|
815
|
+
adapter = resolve_model_adapter(config, lm)
|
|
816
|
+
environment = check_environment(lm.model)
|
|
817
|
+
|
|
818
|
+
import torch
|
|
819
|
+
|
|
820
|
+
decode_identity = (check_decode_identity(
|
|
821
|
+
adapter, lm.model, torch.tensor([[lm.eot_token_id]], device=lm.device)
|
|
822
|
+
) if "logit_lens" in config.signals else None)
|
|
823
|
+
|
|
824
|
+
hidden_layers = (
|
|
825
|
+
parse_hidden_layers(config.hidden_layers, adapter.n_residual)
|
|
826
|
+
if config.save_hidden
|
|
827
|
+
else None
|
|
828
|
+
)
|
|
829
|
+
reducers = build_reducers(adapter, lm.tokenizer, attention=False, hidden_layers=None, signals=config.signals)
|
|
830
|
+
descriptors = [r.descriptor() for r in reducers]
|
|
831
|
+
|
|
832
|
+
# 2. 기존 기록과 호환되는지 확인하고 저장·관측 객체를 연결한다.
|
|
833
|
+
identity = config.identity(descriptors, lm_eval.__version__)
|
|
834
|
+
run_dir = config.output or config.default_output(
|
|
835
|
+
config.config_hash(descriptors, lm_eval.__version__)
|
|
836
|
+
)
|
|
837
|
+
check_resume_is_the_same_run(run_dir, identity)
|
|
838
|
+
ensure_collection_is_empty(run_dir, config.resolved_hooks)
|
|
839
|
+
if config.hook_provenance:
|
|
840
|
+
# 이 실행이 어떤 모듈을 관측했는지는 아래에서 다시 쓰지만, 먼저 이전 기록을
|
|
841
|
+
# 그대로 옮겨 둔다. 그러지 않으면 재개 직전의 manifest 덮어쓰기가 비교 기준을
|
|
842
|
+
# 지워버리고, 경로 집합이 달라진 것을 아무도 알아채지 못한다.
|
|
843
|
+
try:
|
|
844
|
+
config.hook_resolved = (storage.read_manifest(run_dir).get("custom_hooks")
|
|
845
|
+
or {}).get("resolved")
|
|
846
|
+
except (OSError, ValueError, KeyError):
|
|
847
|
+
pass
|
|
848
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
849
|
+
print(f"run directory: {run_dir}")
|
|
850
|
+
|
|
851
|
+
if config.benchmark_provenance or config.hook_provenance or config.model_provenance:
|
|
852
|
+
storage.write_results(run_dir, build_manifest(
|
|
853
|
+
config, adapter, lm, descriptors, environment, [],
|
|
854
|
+
extra={"config_identity": identity}), {})
|
|
855
|
+
writer = storage.RunWriter(run_dir)
|
|
856
|
+
if adapter is not None:
|
|
857
|
+
storage.check_recorded_tables_agree(run_dir, [r.table for r in reducers if r.table])
|
|
858
|
+
if writer.already_recorded:
|
|
859
|
+
print(
|
|
860
|
+
f"resuming: {len(writer.already_recorded)} requests are already recorded "
|
|
861
|
+
"and will be scored again but not re-recorded"
|
|
862
|
+
)
|
|
863
|
+
recorder = None
|
|
864
|
+
if adapter is not None:
|
|
865
|
+
recorder = Recorder(adapter, reducers, writer, lm.tokenizer, hooks=config.resolved_hooks)
|
|
866
|
+
lm.attach_recorder(recorder)
|
|
867
|
+
|
|
868
|
+
# 3. 평가와 judge 채점을 완료하고 문서별 결과를 저장한다.
|
|
869
|
+
results, samples = _evaluate_and_save_samples(
|
|
870
|
+
config, lm, writer, run_dir, task_manager, evaluation_tasks
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
# Read before the collection pass, which may switch the model to eager attention: the manifest should say what the scored pass actually ran with.
|
|
874
|
+
attn_implementation = getattr(lm.model.config, "_attn_implementation", "unknown")
|
|
875
|
+
|
|
876
|
+
# 4. 필요한 경우 후속 수집을 수행한다. 완료 표시는 이 단계까지 성공한 뒤 쓴다.
|
|
877
|
+
# Collection can die after scoring. Persist the scored result before entering any
|
|
878
|
+
# resumable unit so standalone replay cannot mark an empty score payload complete.
|
|
879
|
+
if any(h.collection_resume for h in config.resolved_hooks):
|
|
880
|
+
if recorder is not None:
|
|
881
|
+
config.hook_resolved = {**(config.hook_resolved or {}), **recorder.custom.resolved_paths()}
|
|
882
|
+
storage.write_results(run_dir, build_manifest(config, adapter, lm, descriptors,
|
|
883
|
+
environment, samples, extra={"config_identity": identity, "evaluation_completed": True,
|
|
884
|
+
"attn_implementation": attn_implementation,
|
|
885
|
+
"decode_identity": decode_identity}),
|
|
886
|
+
results.get("results", {}), writer.signal_files())
|
|
887
|
+
collection_counts: dict[str, int] = {}
|
|
888
|
+
if config.save_attention or config.save_hidden or any(h.pass_name == "collection" for h in config.resolved_hooks):
|
|
889
|
+
# Its own trace: the collection pass forces eager attention and materialises a
|
|
890
|
+
# (heads, seq, seq) map per block, which is the heaviest thing this tool does.
|
|
891
|
+
with _tracing(config, lm.model, run_dir, name="collection"):
|
|
892
|
+
collection_counts = _collect_research_data(
|
|
893
|
+
config, lm, adapter, writer, run_dir, hidden_layers
|
|
894
|
+
)
|
|
895
|
+
print(f"collection: {collection_counts}")
|
|
896
|
+
|
|
897
|
+
if recorder is not None and config.resolved_hooks:
|
|
898
|
+
# 확정은 첫 forward 전에 끝났다. 여기서는 그것을 기록할 뿐이다. 두 pass의 hook은
|
|
899
|
+
# 이름이 다르므로 합친다.
|
|
900
|
+
config.hook_resolved = {**(config.hook_resolved or {}),
|
|
901
|
+
**recorder.custom.resolved_paths()} or None
|
|
902
|
+
writer.close()
|
|
903
|
+
if recorder is not None and recorder.skipped:
|
|
904
|
+
print(f"skipped recording for {recorder.skipped} already-present requests")
|
|
905
|
+
manifest = build_manifest(
|
|
906
|
+
config,
|
|
907
|
+
adapter,
|
|
908
|
+
lm,
|
|
909
|
+
descriptors,
|
|
910
|
+
environment,
|
|
911
|
+
samples,
|
|
912
|
+
extra={
|
|
913
|
+
"config_identity": identity,
|
|
914
|
+
"attn_implementation": attn_implementation,
|
|
915
|
+
"collection_attn_implementation": getattr(
|
|
916
|
+
lm.model.config, "_attn_implementation", "unknown"
|
|
917
|
+
),
|
|
918
|
+
"decode_identity": decode_identity,
|
|
919
|
+
"collection_counts": collection_counts,
|
|
920
|
+
"evaluation_completed": True,
|
|
921
|
+
"generation_kwargs_source": "lm-eval task config",
|
|
922
|
+
"debug": config.debug.manifest_entry(),
|
|
923
|
+
},
|
|
924
|
+
)
|
|
925
|
+
storage.write_results(run_dir, manifest, results.get("results", {}), writer.signal_files())
|
|
926
|
+
storage.mark_complete(run_dir)
|
|
927
|
+
if recorder is None:
|
|
928
|
+
print(f"done: {len(samples)} samples scored; internal collection disabled")
|
|
929
|
+
else:
|
|
930
|
+
print(f"done: {writer.documents_written} documents recorded")
|
|
931
|
+
return run_dir
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _evaluate_and_save_samples(
|
|
935
|
+
config: RunConfig, lm: Any, writer: storage.RunWriter, run_dir: str,
|
|
936
|
+
task_manager: Any, evaluation_tasks: Sequence[Any],
|
|
937
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
938
|
+
"""평가하고 judge 결과를 반영한 samples/docs를 저장한다.
|
|
939
|
+
|
|
940
|
+
forward trace는 lm-eval 평가까지만 감싼다. signal을 flush한 다음 judge를
|
|
941
|
+
실행하고, 성공한 경우에만 최종 samples/docs를 쓴다. judge 실패 시의 부분
|
|
942
|
+
저장은 judges 모듈이 담당하며 여기서는 실패를 그대로 전달한다.
|
|
943
|
+
"""
|
|
944
|
+
import lm_eval
|
|
945
|
+
|
|
946
|
+
from .benchmarks import annotate_samples
|
|
947
|
+
|
|
948
|
+
# 미지정 옵션은 키 자체를 생략해 lm-eval의 기본 prompt 처리를 유지한다.
|
|
949
|
+
evaluation_options: dict[str, Any] = {}
|
|
950
|
+
if task_manager is not None:
|
|
951
|
+
evaluation_options["task_manager"] = task_manager
|
|
952
|
+
if config.system_instruction is not None or config.apply_chat_template:
|
|
953
|
+
evaluation_options["system_instruction"] = config.system_instruction
|
|
954
|
+
evaluation_options["apply_chat_template"] = config.apply_chat_template
|
|
955
|
+
|
|
956
|
+
# The tracer wraps the *whole* evaluation rather than each request loop, which is what
|
|
957
|
+
# puts the `--batch-size auto` probe inside it. That probe registers no documents and so
|
|
958
|
+
# records no signals, and it is a likely place to run out of memory - precisely the
|
|
959
|
+
# combination that leaves nothing behind without this.
|
|
960
|
+
with _tracing(config, lm.model, run_dir):
|
|
961
|
+
# `log_samples=True` is not optional: the grading join needs it, and forcing the output into the run directory keeps the run self-contained.
|
|
962
|
+
# `use_cache=None` keeps lm-eval's request cache off - a cached request is not re-run, which would leave a hole in the signals.
|
|
963
|
+
results = lm_eval.simple_evaluate(
|
|
964
|
+
model=lm,
|
|
965
|
+
tasks=evaluation_tasks,
|
|
966
|
+
**evaluation_options,
|
|
967
|
+
num_fewshot=config.num_fewshot,
|
|
968
|
+
limit=config.limit,
|
|
969
|
+
log_samples=True,
|
|
970
|
+
use_cache=None,
|
|
971
|
+
write_out=False,
|
|
972
|
+
)
|
|
973
|
+
writer.flush()
|
|
974
|
+
|
|
975
|
+
samples_by_task = results.get("samples", {})
|
|
976
|
+
from .models import save_effective_prompts
|
|
977
|
+
save_effective_prompts(samples_by_task, lm)
|
|
978
|
+
from .judges import score_judge_tasks
|
|
979
|
+
score_judge_tasks(results, evaluation_tasks, run_dir, config.benchmark_provenance)
|
|
980
|
+
annotate_samples(samples_by_task, config.benchmark_provenance)
|
|
981
|
+
storage.write_samples(run_dir, samples_by_task)
|
|
982
|
+
samples = storage.read_samples(run_dir)
|
|
983
|
+
storage.write_docs_table(run_dir, samples)
|
|
984
|
+
|
|
985
|
+
return results, samples
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _collect_research_data(
|
|
989
|
+
config: RunConfig,
|
|
990
|
+
lm: Any,
|
|
991
|
+
adapter: Any,
|
|
992
|
+
writer: storage.RunWriter,
|
|
993
|
+
run_dir: str,
|
|
994
|
+
hidden_layers: list[int] | None,
|
|
995
|
+
*,
|
|
996
|
+
write_steps: bool = False,
|
|
997
|
+
) -> dict[str, int]:
|
|
998
|
+
"""Collect selected documents through the shared second-pass pipeline.
|
|
999
|
+
|
|
1000
|
+
Both `run` and standalone collection use the same reducers, hook runtime and
|
|
1001
|
+
output checks. `write_steps` is needed only when the saved evaluation did not
|
|
1002
|
+
record internal signals; otherwise writing steps again would duplicate them.
|
|
1003
|
+
Resolved collection hook paths are returned through `config.hook_resolved`
|
|
1004
|
+
so each command can merge them into its own manifest checkpoint.
|
|
1005
|
+
"""
|
|
1006
|
+
from .backend import ResearchDataCollector
|
|
1007
|
+
from .recorder import Recorder
|
|
1008
|
+
|
|
1009
|
+
if config.save_attention:
|
|
1010
|
+
force_eager_attention(lm.model)
|
|
1011
|
+
reducers = build_reducers(
|
|
1012
|
+
adapter,
|
|
1013
|
+
lm.tokenizer,
|
|
1014
|
+
attention=config.save_attention,
|
|
1015
|
+
hidden_layers=hidden_layers,
|
|
1016
|
+
include_always_on=False,
|
|
1017
|
+
)
|
|
1018
|
+
# skip_recorded=False is essential, not incidental: collection exists to re-run documents that *are* already recorded, in order to collect signals the first pass did not.
|
|
1019
|
+
# The restart skip would drop every one of them and the command would report success while writing nothing.
|
|
1020
|
+
recorder = Recorder(adapter, reducers, writer, lm.tokenizer,
|
|
1021
|
+
write_steps=write_steps, skip_recorded=False,
|
|
1022
|
+
hooks=config.resolved_hooks, pass_name="collection")
|
|
1023
|
+
runner = ResearchDataCollector(lm, recorder, run_dir)
|
|
1024
|
+
counts = runner.run(limit=config.collect_limit)
|
|
1025
|
+
writer.flush()
|
|
1026
|
+
# A collection-pass hook is not in the evaluation runtime, so this is the only
|
|
1027
|
+
# place its resolved paths exist.
|
|
1028
|
+
config.hook_resolved = {**(config.hook_resolved or {}),
|
|
1029
|
+
**recorder.custom.resolved_paths()} or None
|
|
1030
|
+
_assert_collection_produced_output(writer, counts, config)
|
|
1031
|
+
return counts
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _assert_collection_produced_output(
|
|
1035
|
+
writer: storage.RunWriter, counts: dict[str, int], config: RunConfig
|
|
1036
|
+
) -> None:
|
|
1037
|
+
"""Fail loudly when a collection pass wrote nothing.
|
|
1038
|
+
|
|
1039
|
+
A collection pass that silently produces no files is indistinguishable from a successful one at the command line, and only shows up later as an empty directory.
|
|
1040
|
+
It has happened, so it is checked.
|
|
1041
|
+
"""
|
|
1042
|
+
if not counts.get("documents"):
|
|
1043
|
+
raise RuntimeError(
|
|
1044
|
+
"collection selected no documents; samples.jsonl may be empty or the task "
|
|
1045
|
+
"reports no correctness metric"
|
|
1046
|
+
)
|
|
1047
|
+
if config.save_attention and not writer.attention.files_written:
|
|
1048
|
+
raise RuntimeError(
|
|
1049
|
+
"collection ran but wrote no attention tensors. The recorder skipped every "
|
|
1050
|
+
"document, or the attention modules returned no weights."
|
|
1051
|
+
)
|
|
1052
|
+
if config.save_hidden and not writer.raw_hidden.files_written:
|
|
1053
|
+
raise RuntimeError("collection ran but wrote no hidden-state tensors.")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _collection_config_from_manifest(
|
|
1057
|
+
args: argparse.Namespace, manifest: dict[str, Any],
|
|
1058
|
+
) -> RunConfig:
|
|
1059
|
+
"""완료된 평가의 모델·hook을 복원하고 수집 옵션을 합친다.
|
|
1060
|
+
|
|
1061
|
+
CLI에서는 수집할 데이터와 trace 옵션만 받는다. 여기서는 평가 완료 여부와
|
|
1062
|
+
hook 복원을 검증한다. 모델 구현의 일치는 호출자가 모델을 로딩한 뒤 확인한다.
|
|
1063
|
+
"""
|
|
1064
|
+
run_dir = args.run_dir
|
|
1065
|
+
if not (manifest.get("completed") or manifest.get("evaluation_completed")):
|
|
1066
|
+
# A checkpoint written before scoring or judge grading finished has no scores;
|
|
1067
|
+
# marking it complete here would publish an empty result as a finished run.
|
|
1068
|
+
raise ValueError(
|
|
1069
|
+
"collection needs a finished evaluation: the manifest records neither completed "
|
|
1070
|
+
"nor evaluation_completed. Rerun `run` into the same directory first."
|
|
1071
|
+
)
|
|
1072
|
+
saved_model = manifest.get("custom_model", {})
|
|
1073
|
+
if saved_model and (not saved_model.get("factory") or not saved_model.get("resume_safe")):
|
|
1074
|
+
raise ValueError("standalone collection requires a reproducibly identified model factory; in-memory models can collect during run")
|
|
1075
|
+
saved_hooks = manifest.get("custom_hooks", {})
|
|
1076
|
+
from .hooks import load_hooks, ensure_collection_is_empty
|
|
1077
|
+
hook_specs = saved_hooks.get("specs", [])
|
|
1078
|
+
if hook_specs and not saved_hooks.get("factory"):
|
|
1079
|
+
if any(h["pass_name"] == "collection" for h in hook_specs):
|
|
1080
|
+
raise ValueError("standalone collection requires a restorable hook factory")
|
|
1081
|
+
hooks = []
|
|
1082
|
+
else:
|
|
1083
|
+
hooks = load_hooks(saved_hooks.get("factory"), saved_hooks.get("config", {}))
|
|
1084
|
+
if any(not h["resume_safe"] for h in hook_specs):
|
|
1085
|
+
raise ValueError("custom hook source is unavailable; standalone collection is disabled")
|
|
1086
|
+
if [h.descriptor() for h in hooks] != hook_specs:
|
|
1087
|
+
raise ValueError("custom hook implementation changed since evaluation")
|
|
1088
|
+
collection_hooks = [h for h in hooks if h.pass_name == "collection"]
|
|
1089
|
+
ensure_collection_is_empty(run_dir, collection_hooks)
|
|
1090
|
+
if any(h.collection_resume for h in collection_hooks) and not manifest.get("evaluation_completed"):
|
|
1091
|
+
raise ValueError("incomplete custom collection manifest: durable evaluation checkpoint missing")
|
|
1092
|
+
if any(h.collection_resume for h in collection_hooks) and (args.save_attention or args.save_hidden):
|
|
1093
|
+
raise ValueError("custom collection_resume cannot share legacy attention/hidden dumps; use custom raw_tensors")
|
|
1094
|
+
if not (args.save_attention or args.save_hidden or collection_hooks):
|
|
1095
|
+
raise ValueError(
|
|
1096
|
+
"collection has nothing to collect: pass --save-attention and/or --save-hidden. "
|
|
1097
|
+
"The always-on signals were already recorded for every document by `run`."
|
|
1098
|
+
)
|
|
1099
|
+
config = RunConfig(
|
|
1100
|
+
hooks=hooks,
|
|
1101
|
+
model_args=manifest["model_args"],
|
|
1102
|
+
model_factory=saved_model.get("factory"),
|
|
1103
|
+
model_config=saved_model.get("config", {}),
|
|
1104
|
+
signals=(),
|
|
1105
|
+
tasks=list(manifest["tasks"]),
|
|
1106
|
+
batch_size=1, # collection is always unbatched
|
|
1107
|
+
adapter=saved_model.get("adapter") if saved_model else manifest.get("model_type"),
|
|
1108
|
+
save_attention=args.save_attention,
|
|
1109
|
+
save_hidden=args.save_hidden,
|
|
1110
|
+
hidden_layers=args.hidden_layers,
|
|
1111
|
+
collect_limit=args.collect_limit,
|
|
1112
|
+
debug=debug_config_from_args(args),
|
|
1113
|
+
)
|
|
1114
|
+
config.resolved_hooks = hooks
|
|
1115
|
+
return config
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
@_releasing_writer_locks
|
|
1119
|
+
def cmd_collect_research_data(args: argparse.Namespace) -> str:
|
|
1120
|
+
"""Re-feed part of a finished run to collect signals it does not have yet.
|
|
1121
|
+
|
|
1122
|
+
Model and tokenizer settings come from the run's manifest, never from the command line: the two passes have to produce identical input tokens, so letting them be re-specified would be a way to get that wrong.
|
|
1123
|
+
"""
|
|
1124
|
+
run_dir = args.run_dir
|
|
1125
|
+
manifest = storage.read_manifest(run_dir)
|
|
1126
|
+
config = _collection_config_from_manifest(args, manifest)
|
|
1127
|
+
saved_model = manifest.get("custom_model", {})
|
|
1128
|
+
lm = load_model(config)
|
|
1129
|
+
if saved_model and config.model_provenance != saved_model:
|
|
1130
|
+
raise ValueError("custom model implementation/config/checkpoint changed since evaluation")
|
|
1131
|
+
from .models import resolve_model_adapter
|
|
1132
|
+
adapter = resolve_model_adapter(config, lm, collection=True)
|
|
1133
|
+
check_environment(lm.model)
|
|
1134
|
+
hidden_layers = (
|
|
1135
|
+
parse_hidden_layers(config.hidden_layers, adapter.n_residual)
|
|
1136
|
+
if config.save_hidden
|
|
1137
|
+
else None
|
|
1138
|
+
)
|
|
1139
|
+
writer = storage.RunWriter(run_dir)
|
|
1140
|
+
with _tracing(config, lm.model, run_dir, name="collection"):
|
|
1141
|
+
counts = _collect_research_data(
|
|
1142
|
+
config, lm, adapter, writer, run_dir, hidden_layers,
|
|
1143
|
+
write_steps=not manifest.get("internal_signals_available", True),
|
|
1144
|
+
)
|
|
1145
|
+
writer.close()
|
|
1146
|
+
# The manifest has to describe what the directory now holds, not only what the original `run` asked for.
|
|
1147
|
+
# A standalone collection adds dumps the first pass never made, and forces eager attention to make them; leaving the manifest saying `save_attention: false` and `sdpa` would misdescribe the files sitting next to it.
|
|
1148
|
+
options = dict(manifest.get("options", {}))
|
|
1149
|
+
options.update({
|
|
1150
|
+
"save_attention": options.get("save_attention") or config.save_attention,
|
|
1151
|
+
"save_hidden": options.get("save_hidden") or config.save_hidden,
|
|
1152
|
+
"hidden_layers": config.hidden_layers,
|
|
1153
|
+
"collect_limit": config.collect_limit,
|
|
1154
|
+
})
|
|
1155
|
+
payload = storage.read_results(run_dir)
|
|
1156
|
+
storage.write_results(run_dir, payload["manifest"], payload["results"], writer.signal_files())
|
|
1157
|
+
collection_metadata = {
|
|
1158
|
+
"collection_counts": counts,
|
|
1159
|
+
"collection_attn_implementation": getattr(
|
|
1160
|
+
lm.model.config, "_attn_implementation", "unknown"),
|
|
1161
|
+
"options": options,
|
|
1162
|
+
"collected_separately": True,
|
|
1163
|
+
"internal_signals_available": True,
|
|
1164
|
+
**adapter.manifest_entry(),
|
|
1165
|
+
"collection_sampling": {"method": "per_task_correctness", "seed": SAMPLING_SEED,
|
|
1166
|
+
"limit_per_group": config.collect_limit},
|
|
1167
|
+
}
|
|
1168
|
+
if config.resolved_hooks:
|
|
1169
|
+
saved_custom = manifest.get("custom_hooks") or {}
|
|
1170
|
+
collection_metadata["custom_hooks"] = {
|
|
1171
|
+
**saved_custom,
|
|
1172
|
+
"resolved": {
|
|
1173
|
+
**(saved_custom.get("resolved") or {}),
|
|
1174
|
+
**(config.hook_resolved or {}),
|
|
1175
|
+
},
|
|
1176
|
+
}
|
|
1177
|
+
storage.mark_complete(run_dir, extra=collection_metadata)
|
|
1178
|
+
print(f"collection: {counts}")
|
|
1179
|
+
return run_dir
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def cmd_report(args: argparse.Namespace) -> None:
|
|
1183
|
+
"""Gather runs, print the grouping, then write the aggregate and qualitative views.
|
|
1184
|
+
|
|
1185
|
+
Both come out of one partition: the curves say which model is better, the examples say on what.
|
|
1186
|
+
`--multilingual` asks the other question - one model, one benchmark, several languages - which is a different partition and a different reference, so it is a mode rather than an extra figure.
|
|
1187
|
+
"""
|
|
1188
|
+
from .report import run_report
|
|
1189
|
+
|
|
1190
|
+
from .report import parse_datasets
|
|
1191
|
+
|
|
1192
|
+
run_report(args.paths, args.output, args.reference, args.examples_per_category,
|
|
1193
|
+
datasets=parse_datasets(args.multilingual) if args.multilingual else None,
|
|
1194
|
+
assume_aligned=args.assume_aligned, pair_on=args.pair_on,
|
|
1195
|
+
pair_strict=args.pair_strict, pair_mapping=args.pair_mapping)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
# --------------------------------------------------------------------------
|
|
1199
|
+
# CLI
|
|
1200
|
+
# --------------------------------------------------------------------------
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def add_debug_arguments(parser: argparse.ArgumentParser) -> None:
|
|
1204
|
+
"""The module-tracing flags, shared by `run` and `collect-research-data`.
|
|
1205
|
+
|
|
1206
|
+
Off by default and absent from `RunConfig.identity`: tracing changes how long a run takes, not what its signals mean, so a traced and an untraced run belong in the same directory.
|
|
1207
|
+
|
|
1208
|
+
The levels are two flags rather than one verbosity number on purpose. Reading shapes and the allocator's counters queues no kernel and syncs nothing, so `--debug` is safe to leave on in a run that is already near the memory ceiling. `--debug-numeric` allocates several same-size intermediates per tensor and can itself be the allocation that fails.
|
|
1209
|
+
"""
|
|
1210
|
+
group = parser.add_argument_group(
|
|
1211
|
+
"module tracing",
|
|
1212
|
+
"Record what each module was handed and how far the forward pass got. "
|
|
1213
|
+
"Read it back with `evalmetry debug <run_dir>`.",
|
|
1214
|
+
)
|
|
1215
|
+
group.add_argument("--debug", action="store_true",
|
|
1216
|
+
help="trace every module: enter/exit, input and output "
|
|
1217
|
+
"shape/dtype/device, and the documents in flight")
|
|
1218
|
+
group.add_argument("--debug-modules", default=None, metavar="REGEX",
|
|
1219
|
+
help=r"trace only modules whose dotted path matches, "
|
|
1220
|
+
r"e.g. 'layers\.\d+$'")
|
|
1221
|
+
group.add_argument("--debug-numeric", action="store_true",
|
|
1222
|
+
help="also summarise tensor values: NaN/Inf counts and the "
|
|
1223
|
+
"finite range. Costs a device sync and several same-size "
|
|
1224
|
+
"intermediates per tensor - it can itself cause an OOM")
|
|
1225
|
+
group.add_argument("--module-stats", action="store_true",
|
|
1226
|
+
help="collect per-document module input/output moments, exclude "
|
|
1227
|
+
"padding, and export sample and dataset tables. Enables tracing; "
|
|
1228
|
+
"uses --debug-modules to select modules. Costs tensor reductions")
|
|
1229
|
+
group.add_argument("--module-stats-extremes", type=int, default=0, metavar="K",
|
|
1230
|
+
help="also record the K largest absolute values per document, "
|
|
1231
|
+
"module call and tensor, with their token position and "
|
|
1232
|
+
"feature index. Needs --module-stats and "
|
|
1233
|
+
"--module-stats-extremes-modules; writes K rows where the "
|
|
1234
|
+
"moments write one, so the selector is deliberately separate")
|
|
1235
|
+
group.add_argument("--module-stats-extremes-modules", default=None, metavar="REGEX",
|
|
1236
|
+
help=r"which traced modules record extreme positions, e.g. "
|
|
1237
|
+
r"'layers\.\d+\.mlp$'. Confirmed token/feature axes only: "
|
|
1238
|
+
r"attention matrices are measured but never unfolded")
|
|
1239
|
+
group.add_argument("--module-stats-axes", default=None,
|
|
1240
|
+
choices=("feature", "position", "both"),
|
|
1241
|
+
help="also record statistics along one axis of the same document "
|
|
1242
|
+
"slice: `feature` keeps the channel index and reduces the "
|
|
1243
|
+
"valid positions, `position` keeps the input column and "
|
|
1244
|
+
"reduces the channels. Needs --module-stats and "
|
|
1245
|
+
"--module-stats-axes-modules; writes one row per index")
|
|
1246
|
+
group.add_argument("--module-stats-axes-modules", default=None, metavar="REGEX",
|
|
1247
|
+
help=r"which traced modules record per-axis statistics, e.g. "
|
|
1248
|
+
r"'layers\.\d+\.mlp$'. Confirmed token/feature axes only: "
|
|
1249
|
+
r"attention matrices are measured but never unfolded")
|
|
1250
|
+
group.add_argument("--module-stats-routing", default=None, metavar="REGEX",
|
|
1251
|
+
help="which traced routers record the expert selection they "
|
|
1252
|
+
"returned: one row per token, rank, expert and weight. Needs "
|
|
1253
|
+
"--module-stats, and the selector must match a registered "
|
|
1254
|
+
"router boundary, or the run stops before it starts")
|
|
1255
|
+
group.add_argument("--module-stats-chunk-elements", type=int, default=None, metavar="N",
|
|
1256
|
+
help="elements per float64 reduction chunk (default 65536). Changes "
|
|
1257
|
+
"the size of temporary buffers, never a stored value; a strided "
|
|
1258
|
+
"sample may still be copied whole, so this is not a memory cap")
|
|
1259
|
+
group.add_argument("--debug-stop-on-nonfinite", action="store_true",
|
|
1260
|
+
help="raise at the first module whose output is not all "
|
|
1261
|
+
"finite, instead of letting it propagate. Implies "
|
|
1262
|
+
"--debug-numeric and changes what the run does")
|
|
1263
|
+
group.add_argument("--debug-tail", type=int, nargs="?", const=TRACE_BUFFER_EVENTS,
|
|
1264
|
+
default=None, metavar="N",
|
|
1265
|
+
help=f"keep only the last N events (default {TRACE_BUFFER_EVENTS}) "
|
|
1266
|
+
"instead of the whole run. For hunting a crash in a long run, "
|
|
1267
|
+
"where the full trace would be gigabytes and only the end "
|
|
1268
|
+
"matters. It is written when the run ends, so it does not "
|
|
1269
|
+
"survive the process being killed without an exception")
|
|
1270
|
+
group.add_argument("--debug-sync", action="store_true",
|
|
1271
|
+
help="torch.cuda.synchronize() at every module boundary, to "
|
|
1272
|
+
"pin an asynchronous device fault to its module. Slow, "
|
|
1273
|
+
"and it changes timing. Not needed for OOM, whose "
|
|
1274
|
+
"allocation is synchronous already")
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def debug_config_from_args(args: argparse.Namespace) -> "debug.DebugConfig":
|
|
1278
|
+
"""Build the tracing configuration from parsed arguments."""
|
|
1279
|
+
from . import debug as debug_module
|
|
1280
|
+
|
|
1281
|
+
return debug_module.DebugConfig(
|
|
1282
|
+
enabled=args.debug,
|
|
1283
|
+
modules=args.debug_modules,
|
|
1284
|
+
numeric=args.debug_numeric,
|
|
1285
|
+
stop_on_nonfinite=args.debug_stop_on_nonfinite,
|
|
1286
|
+
tail=args.debug_tail,
|
|
1287
|
+
sync=args.debug_sync,
|
|
1288
|
+
sample_stats=args.module_stats,
|
|
1289
|
+
extremes=args.module_stats_extremes,
|
|
1290
|
+
extremes_modules=args.module_stats_extremes_modules,
|
|
1291
|
+
axes=args.module_stats_axes,
|
|
1292
|
+
axes_modules=args.module_stats_axes_modules,
|
|
1293
|
+
routing=args.module_stats_routing,
|
|
1294
|
+
chunk_elements=args.module_stats_chunk_elements,
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1299
|
+
"""Every argument the tool takes.
|
|
1300
|
+
|
|
1301
|
+
Example:
|
|
1302
|
+
>>> build_parser().parse_args(
|
|
1303
|
+
... ["run", "--model-args", "pretrained=Qwen/Qwen3-8B", "--tasks", "xnli_ko"]
|
|
1304
|
+
... ).tasks
|
|
1305
|
+
'xnli_ko'
|
|
1306
|
+
"""
|
|
1307
|
+
parser = argparse.ArgumentParser(
|
|
1308
|
+
prog="evalmetry",
|
|
1309
|
+
description="Evaluate a model with lm-eval while collecting per-layer internal signals.",
|
|
1310
|
+
)
|
|
1311
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1312
|
+
|
|
1313
|
+
run = sub.add_parser(
|
|
1314
|
+
"run",
|
|
1315
|
+
help="evaluate a model and collect signals; resumes an interrupted run",
|
|
1316
|
+
description="Evaluate a model with lm-eval while collecting per-layer "
|
|
1317
|
+
"signals. Re-running into a directory that already holds "
|
|
1318
|
+
"shards resumes it: whatever is recorded there is scored "
|
|
1319
|
+
"again but not recorded twice, so no rows are duplicated.")
|
|
1320
|
+
run.add_argument("--model-args", default="",
|
|
1321
|
+
help="passed to lm-eval verbatim, e.g. pretrained=Qwen/Qwen3-8B,dtype=bfloat16")
|
|
1322
|
+
run.add_argument("--model-factory", help="package.module:function returning ModelBundle")
|
|
1323
|
+
run.add_argument("--model-config", type=json.loads, default={}, help="JSON object passed to model factory")
|
|
1324
|
+
run.add_argument("--signals", default="logit_lens,similarity",
|
|
1325
|
+
help="comma separated logit_lens,similarity; none for scoring only")
|
|
1326
|
+
run.add_argument("--system-instruction", default=None)
|
|
1327
|
+
run.add_argument("--apply-chat-template", action="store_true")
|
|
1328
|
+
run.add_argument("--tasks", required=True, help="comma separated lm-eval task names")
|
|
1329
|
+
run.add_argument("--include-path", action="append", default=[],
|
|
1330
|
+
help="local benchmark directory; repeat for multiple bundles")
|
|
1331
|
+
run.add_argument("--num-fewshot", type=int, default=0)
|
|
1332
|
+
run.add_argument("--limit", type=int, default=None, help="cap on the number of documents")
|
|
1333
|
+
run.add_argument("--batch-size", type=parse_batch_size, default=1,
|
|
1334
|
+
help='positive integer, or "auto" to let lm-eval find the '
|
|
1335
|
+
"largest batch that fits. generate tasks always run at 1")
|
|
1336
|
+
run.add_argument("--hook-factory", help="package.module:function returning HookSpec objects")
|
|
1337
|
+
run.add_argument("--hook-config", type=json.loads, default={}, help="JSON object passed to hook factory")
|
|
1338
|
+
run.add_argument("--output", default=None, help="run directory (default: results/...)")
|
|
1339
|
+
run.add_argument("--adapter", default=None,
|
|
1340
|
+
help="force a module-path adapter; the escape hatch for trust_remote_code models")
|
|
1341
|
+
run.add_argument("--save-attention", action="store_true",
|
|
1342
|
+
help="second pass: store attention weights and value norms")
|
|
1343
|
+
run.add_argument("--save-hidden", action="store_true",
|
|
1344
|
+
help="second pass: store raw hidden states")
|
|
1345
|
+
run.add_argument("--hidden-layers", default="all", help="all | 0,1,2 | 0-8")
|
|
1346
|
+
run.add_argument("--collect-limit", type=int, default=DEFAULT_COLLECT_LIMIT,
|
|
1347
|
+
help="documents collected per task and correctness group")
|
|
1348
|
+
|
|
1349
|
+
collection = sub.add_parser(
|
|
1350
|
+
"collect-research-data",
|
|
1351
|
+
help="add attention / hidden-state dumps to a finished run, without re-scoring",
|
|
1352
|
+
description="Add the opt-in signals to a run that already finished. This is "
|
|
1353
|
+
"NOT how an interrupted run is resumed - `run` does that by "
|
|
1354
|
+
"itself. It exists because which documents to dump can only be "
|
|
1355
|
+
"chosen once lm-eval has scored them: the sample is balanced "
|
|
1356
|
+
"across correct and incorrect answers, and correctness is not "
|
|
1357
|
+
"known during the first pass. Re-running `run` would reach the "
|
|
1358
|
+
"same result by re-scoring every document, which this avoids.")
|
|
1359
|
+
collection.add_argument("run_dir", help="the run directory of the first pass")
|
|
1360
|
+
collection.add_argument("--save-attention", action="store_true")
|
|
1361
|
+
collection.add_argument("--save-hidden", action="store_true")
|
|
1362
|
+
collection.add_argument("--hidden-layers", default="all")
|
|
1363
|
+
collection.add_argument("--collect-limit", type=int, default=DEFAULT_COLLECT_LIMIT)
|
|
1364
|
+
|
|
1365
|
+
add_debug_arguments(run)
|
|
1366
|
+
add_debug_arguments(collection)
|
|
1367
|
+
|
|
1368
|
+
trace = sub.add_parser(
|
|
1369
|
+
"debug",
|
|
1370
|
+
help="read back the module trace a --debug run left behind",
|
|
1371
|
+
description="Summarise a debug trace: how far the forward pass got, which "
|
|
1372
|
+
"module was executing when it stopped, what it was handed, and "
|
|
1373
|
+
"which documents were in flight. Takes a run directory or a "
|
|
1374
|
+
"trace file.")
|
|
1375
|
+
trace.add_argument("path", help="a run directory, or a debug/trace.jsonl")
|
|
1376
|
+
trace.add_argument("--events", type=int, default=10,
|
|
1377
|
+
help="module events to print from just before the failure")
|
|
1378
|
+
trace.add_argument("--forward", type=int, default=None, metavar="N",
|
|
1379
|
+
help="print every module of forward N, in call order, with what "
|
|
1380
|
+
"it was handed and what it returned. With no failure to "
|
|
1381
|
+
"report this is the view worth having")
|
|
1382
|
+
trace.add_argument("--doc", default=None, metavar="TASK#ID",
|
|
1383
|
+
help="the forwards covering one document, e.g. arc_easy#42 "
|
|
1384
|
+
"or just 42")
|
|
1385
|
+
trace.add_argument("--module", default=None, metavar="REGEX",
|
|
1386
|
+
help=r"narrow --forward / --doc to matching module paths. A "
|
|
1387
|
+
r"regex, so escape the dots: 'layers\.1\.' is layer 1, while "
|
|
1388
|
+
r"'layers.1.' is layers 1 and 10-19 because the trailing dot "
|
|
1389
|
+
r"matches the 0. The listing reports which layers it matched")
|
|
1390
|
+
|
|
1391
|
+
stats = sub.add_parser("module-stats", help="read sample and dataset module statistics")
|
|
1392
|
+
stats.add_argument("path", help="run directory, statistics session directory, or SQLite file")
|
|
1393
|
+
stats.add_argument("--pass", dest="pass_name", choices=("trace", "collection"), default="trace",
|
|
1394
|
+
help="which evaluation pass to inspect (latest session only)")
|
|
1395
|
+
stats.add_argument("--doc", metavar="TASK#ID", help="show one document's pooled statistics")
|
|
1396
|
+
stats.add_argument("--module", metavar="REGEX", help="filter module paths")
|
|
1397
|
+
|
|
1398
|
+
report = sub.add_parser("report", help="group runs, draw them, and pick examples")
|
|
1399
|
+
report.add_argument("paths", nargs="+", help="directories to search recursively")
|
|
1400
|
+
report.add_argument("--output", default=".",
|
|
1401
|
+
help="where comparison.parquet, report.pdf, examples.parquet "
|
|
1402
|
+
"and examples.md go")
|
|
1403
|
+
report.add_argument(
|
|
1404
|
+
"--reference", default=None,
|
|
1405
|
+
help="substring of the series label the example buckets are defined against - a "
|
|
1406
|
+
"model id normally, a language under --multilingual. "
|
|
1407
|
+
"Default: the highest-scoring run in each group, printed when chosen")
|
|
1408
|
+
report.add_argument("--examples-per-category", type=int, default=3,
|
|
1409
|
+
help="documents sampled per bucket; 0 skips the examples")
|
|
1410
|
+
report.add_argument("--multilingual", default=None, metavar="LANG=TASK,LANG=TASK",
|
|
1411
|
+
help="compare one model across languages instead of several models "
|
|
1412
|
+
"over one dataset. Name the datasets outright, e.g. "
|
|
1413
|
+
"en=global_mmlu_en,ko=global_mmlu_ko - benchmarks spell their "
|
|
1414
|
+
"languages in too many ways to be guessed from a task name. A "
|
|
1415
|
+
"bare task name labels itself. Runs are grouped by (model, shot "
|
|
1416
|
+
"count) and drawn one line per language")
|
|
1417
|
+
report.add_argument("--pair-on", default=None, metavar="FIELD",
|
|
1418
|
+
help="--multilingual: the logged `doc` field that identifies the same "
|
|
1419
|
+
"document in every language, e.g. sample_id for Global-MMLU. "
|
|
1420
|
+
"Without it one is looked for and used only if it is unique "
|
|
1421
|
+
"within each run and carries the same values in all of them; "
|
|
1422
|
+
"`--pair-on position` compares by document order instead")
|
|
1423
|
+
report.add_argument("--pair-strict", action="store_true",
|
|
1424
|
+
help="--multilingual: when no identity field or mapping pairs the "
|
|
1425
|
+
"documents, compare none of them one to one instead of pairing "
|
|
1426
|
+
"by position. Curves are unaffected")
|
|
1427
|
+
report.add_argument("--pair-mapping", default=None, metavar="FILE",
|
|
1428
|
+
help="--multilingual: a .csv or .jsonl mapping with columns language, "
|
|
1429
|
+
"task_name, doc_id, canonical_doc_id and optionally "
|
|
1430
|
+
"canonical_choice_ids (a|b|c) and canonical_answer_id. Its sha256 "
|
|
1431
|
+
"and coverage are written to pairing.json; the mapping is taken "
|
|
1432
|
+
"as given, not as proof that documents mean the same thing")
|
|
1433
|
+
report.add_argument("--assume-aligned", action="store_true",
|
|
1434
|
+
help="--multilingual: compare documents by doc_id even when the "
|
|
1435
|
+
"gold-answer check falls below the threshold. For a translation "
|
|
1436
|
+
"that shuffled the choices, where the documents do correspond "
|
|
1437
|
+
"but their gold positions do not. The measured rate is reported "
|
|
1438
|
+
"either way")
|
|
1439
|
+
|
|
1440
|
+
return parser
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
# CLI에서 traceback 대신 오류 메시지를 출력할 예외들이다. 설정 오류뿐 아니라
|
|
1444
|
+
# 수집·검증 중 발생한 RuntimeError도 포함한다. 그 밖의 예외는 원래 traceback을
|
|
1445
|
+
# 유지한다. LookupError에는 KeyError와 reference 검색 실패가 모두 포함된다.
|
|
1446
|
+
CONFIGURATION_ERRORS = (LookupError, ValueError, RuntimeError, FileNotFoundError)
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1450
|
+
"""Parse arguments, dispatch the command, and return a process exit code.
|
|
1451
|
+
|
|
1452
|
+
Raises:
|
|
1453
|
+
SystemExit: for CONFIGURATION_ERRORS, carrying the message rather than a traceback.
|
|
1454
|
+
Anything else propagates - an unexpected failure should show where it happened.
|
|
1455
|
+
"""
|
|
1456
|
+
args = build_parser().parse_args(argv)
|
|
1457
|
+
try:
|
|
1458
|
+
return _dispatch(args)
|
|
1459
|
+
except CONFIGURATION_ERRORS as error:
|
|
1460
|
+
raise SystemExit(f"error: {error}") from error
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
1464
|
+
"""Run the chosen subcommand."""
|
|
1465
|
+
if args.command == "run":
|
|
1466
|
+
cmd_run(
|
|
1467
|
+
RunConfig(
|
|
1468
|
+
model_args=args.model_args,
|
|
1469
|
+
model_factory=args.model_factory,
|
|
1470
|
+
model_config=args.model_config,
|
|
1471
|
+
signals=tuple(x.strip() for x in args.signals.split(",") if x.strip() and x.strip() != "none"),
|
|
1472
|
+
system_instruction=args.system_instruction,
|
|
1473
|
+
apply_chat_template=args.apply_chat_template,
|
|
1474
|
+
tasks=[t.strip() for t in args.tasks.split(",") if t.strip()],
|
|
1475
|
+
include_path=args.include_path,
|
|
1476
|
+
hook_factory=args.hook_factory,
|
|
1477
|
+
hook_config=args.hook_config,
|
|
1478
|
+
num_fewshot=args.num_fewshot,
|
|
1479
|
+
limit=args.limit,
|
|
1480
|
+
batch_size=args.batch_size,
|
|
1481
|
+
adapter=args.adapter,
|
|
1482
|
+
save_attention=args.save_attention,
|
|
1483
|
+
save_hidden=args.save_hidden,
|
|
1484
|
+
hidden_layers=args.hidden_layers,
|
|
1485
|
+
collect_limit=args.collect_limit,
|
|
1486
|
+
output=args.output,
|
|
1487
|
+
debug=debug_config_from_args(args),
|
|
1488
|
+
)
|
|
1489
|
+
)
|
|
1490
|
+
elif args.command == "collect-research-data":
|
|
1491
|
+
cmd_collect_research_data(args)
|
|
1492
|
+
elif args.command == "report":
|
|
1493
|
+
cmd_report(args)
|
|
1494
|
+
elif args.command == "debug":
|
|
1495
|
+
cmd_debug(args)
|
|
1496
|
+
elif args.command == "module-stats":
|
|
1497
|
+
from .module_stats import report_statistics
|
|
1498
|
+
|
|
1499
|
+
print(report_statistics(args.path, args.pass_name, args.doc, args.module))
|
|
1500
|
+
return 0
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
if __name__ == "__main__":
|
|
1504
|
+
raise SystemExit(main())
|