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/hooks.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""사용자 관측 함수와 recorder 사이의 계약. 모델 출력은 교체하지 않는다."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Callable, Sequence
|
|
12
|
+
|
|
13
|
+
from .storage import ColumnSpec, TableSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def first_input(args, kwargs):
|
|
17
|
+
"""기본 입력 추출: 첫 positional tensor 또는 hidden_states keyword."""
|
|
18
|
+
return args[0] if args else kwargs.get("hidden_states")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def first_output(output):
|
|
22
|
+
"""기본 출력 추출: tensor 또는 tuple/list의 첫 원소."""
|
|
23
|
+
return output[0] if isinstance(output, (tuple, list)) else output
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HookContext:
|
|
28
|
+
"""한 문서의 한 모듈 호출. forward는 기존 ForwardContext이다.
|
|
29
|
+
|
|
30
|
+
observer에 전달되는 두 tensor는 [선택된 토큰 수, features]이며,
|
|
31
|
+
각 행은 forward.steps, forward.positions와 대응한다.
|
|
32
|
+
evaluation의 stage는 scoring/prefill/decode이다. collection은 기본 스키마에서
|
|
33
|
+
replay, 확장 스키마에서 loglikelihood/teacher_forced로 기록한다.
|
|
34
|
+
pass_name은 evaluation/collection이다.
|
|
35
|
+
"""
|
|
36
|
+
forward: Any
|
|
37
|
+
module_path: str
|
|
38
|
+
call_index: int
|
|
39
|
+
stage: str
|
|
40
|
+
pass_name: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class HookSpec:
|
|
45
|
+
"""관측 템플릿 하나를 등록한다.
|
|
46
|
+
|
|
47
|
+
modules: 모델 기준 정확한 경로들. boundary가 정의한 catalogue에서 선택.
|
|
48
|
+
boundary: decoder_descendant(기본), decoder 또는 lm_head. root container는 분석하지 않는다.
|
|
49
|
+
positions: scored(기본) 또는 full_prompt. 확장 위치에는 nullable step과 입력 token 귀속을 기록.
|
|
50
|
+
doc_ids / position_range: 문서 ID 필터와 post-truncation 입력 좌표 [start, stop).
|
|
51
|
+
max_rows / max_tensor_bytes: hook별 행/파일 bytes 상한. collection resume은 단위별 상한.
|
|
52
|
+
raw_tensors: input/output 중 저장할 선택 복사본. 원 dtype을 safetensors로 보존.
|
|
53
|
+
collection_resume: 모든 collection hook이 함께 활성화해야 하는 custom-only commit 계약.
|
|
54
|
+
module_pattern: 경로에 `re.search`로 맞추는 정규식. `--debug-modules`와 같은 규칙이다.
|
|
55
|
+
select_modules(catalogue): `(경로, 모듈 클래스 이름)` 쌍의 정렬된 tuple을 받아
|
|
56
|
+
선택한 경로들을 반환하는 함수. 살아 있는 모듈 객체는 넘기지 않는다.
|
|
57
|
+
modules·module_pattern·select_modules 중 정확히 하나만 지정한다. 어느 쪽이든
|
|
58
|
+
경로 집합은 모델이 만들어진 뒤 첫 forward 전에 확정되고, 정렬·중복 제거해
|
|
59
|
+
provenance에 남으며, 하나도 맞지 않으면 실행을 실패시킨다.
|
|
60
|
+
observe(inputs, outputs, context): 이름 -> [선택 토큰 수] 숫자 tensor/list.
|
|
61
|
+
입력과 출력은 각각 detach한 독립 복사본이다. 반환값은 저장에만 사용.
|
|
62
|
+
입력·출력의 feature 수는 달라도 되며 비교 방법은 사용자 함수가 정한다.
|
|
63
|
+
metrics: 반환할 지표 이름 -> 설명. 모든 지표는 float64로 저장한다.
|
|
64
|
+
extract_input(args, kwargs), extract_output(output): [batch, sequence, features]
|
|
65
|
+
tensor를 고르는 읽기 전용 함수. 원본을 받으므로 수정하면 안 된다.
|
|
66
|
+
kwargs/cache 등 전체 객체를 복사하지 않고 선택한 tensor만 복사한다.
|
|
67
|
+
version: 의존 코드/설정의 의미가 바뀌면 반드시 변경할 구현 식별자.
|
|
68
|
+
source_files: 함수가 정의된 파일 이외의 로컬 의존 파일. 내용 hash를 기록.
|
|
69
|
+
pass_name: evaluation 또는 collection. collection은 후속 재실행 데이터.
|
|
70
|
+
|
|
71
|
+
호출마다 선택된 위치만 복사하고 즉시 축약한다. observer가 tensor를 외부에
|
|
72
|
+
보관하면 메모리 회수는 보장할 수 없다. 임의 Python 코드의 격리는 제공하지 않는다.
|
|
73
|
+
"""
|
|
74
|
+
name: str
|
|
75
|
+
# modules 이후가 기본값을 갖는 것은 selector 셋 중 하나만 주기 위해서다. 없으면
|
|
76
|
+
# TypeError 대신 __post_init__이 무엇이 빠졌는지 말한다.
|
|
77
|
+
modules: Sequence[str] = ()
|
|
78
|
+
observe: Callable | None = None
|
|
79
|
+
metrics: dict[str, str] | None = None
|
|
80
|
+
version: str = ""
|
|
81
|
+
extract_input: Callable = first_input
|
|
82
|
+
extract_output: Callable = first_output
|
|
83
|
+
pass_name: str = "evaluation"
|
|
84
|
+
source_files: Sequence[str] = ()
|
|
85
|
+
module_pattern: str | None = None
|
|
86
|
+
select_modules: Callable | None = None
|
|
87
|
+
boundary: str = "decoder_descendant"
|
|
88
|
+
positions: str = "scored"
|
|
89
|
+
doc_ids: Sequence[int] | None = None
|
|
90
|
+
position_range: tuple[int, int] | None = None
|
|
91
|
+
max_rows: int = 100000
|
|
92
|
+
raw_tensors: Sequence[str] = ()
|
|
93
|
+
max_tensor_bytes: int = 67108864
|
|
94
|
+
collection_resume: bool = False
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def extended(self):
|
|
98
|
+
"""Use position-aware schema only when explicitly requested."""
|
|
99
|
+
return self.positions != "scored" or bool(self.raw_tensors) or self.collection_resume
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def __post_init__(self):
|
|
103
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", self.name):
|
|
104
|
+
raise ValueError(f"invalid hook name: {self.name!r}")
|
|
105
|
+
if not isinstance(self.version, str) or not self.version:
|
|
106
|
+
raise ValueError(f"hook {self.name}: a version string is required")
|
|
107
|
+
# 세 가지 selector는 서로 배타적이다. 둘을 함께 받으면 어느 쪽이 실제 대상인지
|
|
108
|
+
# provenance만 보고는 알 수 없고, 하나도 없으면 관측할 모듈이 없다.
|
|
109
|
+
given = [field for field, present in (
|
|
110
|
+
("modules", bool(self.modules)),
|
|
111
|
+
("module_pattern", self.module_pattern is not None),
|
|
112
|
+
("select_modules", self.select_modules is not None)) if present]
|
|
113
|
+
if len(given) != 1:
|
|
114
|
+
raise ValueError(f"hook {self.name}: give exactly one of modules, module_pattern "
|
|
115
|
+
f"or select_modules; got {given or ['none']}")
|
|
116
|
+
if self.modules and (isinstance(self.modules, str)
|
|
117
|
+
or any(not isinstance(p, str) or not p for p in self.modules)
|
|
118
|
+
or len(set(self.modules)) != len(self.modules)):
|
|
119
|
+
raise ValueError(f"hook {self.name}: distinct module paths are required")
|
|
120
|
+
if self.module_pattern is not None:
|
|
121
|
+
if not isinstance(self.module_pattern, str):
|
|
122
|
+
raise ValueError(f"hook {self.name}: module_pattern must be a string")
|
|
123
|
+
try:
|
|
124
|
+
re.compile(self.module_pattern)
|
|
125
|
+
except re.error as error:
|
|
126
|
+
raise ValueError(f"hook {self.name}: module_pattern is not a valid regular "
|
|
127
|
+
f"expression: {error}") from error
|
|
128
|
+
if self.select_modules is not None and not callable(self.select_modules):
|
|
129
|
+
raise ValueError(f"hook {self.name}: select_modules must be callable")
|
|
130
|
+
if any(k not in ("input", "output") for k in self.raw_tensors) or len(set(self.raw_tensors)) != len(self.raw_tensors):
|
|
131
|
+
raise ValueError("raw_tensors must select distinct input/output tensors")
|
|
132
|
+
if self.max_tensor_bytes < 0:
|
|
133
|
+
raise ValueError("max_tensor_bytes must be nonnegative")
|
|
134
|
+
if self.collection_resume and self.pass_name != "collection":
|
|
135
|
+
raise ValueError("collection_resume requires pass_name=collection")
|
|
136
|
+
if self.positions not in ("scored", "full_prompt"):
|
|
137
|
+
raise ValueError("positions must be scored or full_prompt")
|
|
138
|
+
if self.max_rows < 0:
|
|
139
|
+
raise ValueError("max_rows must be nonnegative")
|
|
140
|
+
if self.doc_ids is not None and any(not isinstance(i, int) or i < 0 for i in self.doc_ids):
|
|
141
|
+
raise ValueError("doc_ids must be nonnegative integers")
|
|
142
|
+
if self.position_range is not None and (len(self.position_range) != 2 or
|
|
143
|
+
not 0 <= self.position_range[0] <= self.position_range[1]):
|
|
144
|
+
raise ValueError("position_range must be a nonnegative [start, stop) pair")
|
|
145
|
+
if self.boundary not in ("decoder_descendant", "decoder", "lm_head"):
|
|
146
|
+
raise ValueError("custom boundary must be decoder_descendant, decoder or lm_head")
|
|
147
|
+
if self.pass_name not in ("evaluation", "collection"):
|
|
148
|
+
raise ValueError(f"hook {self.name}: invalid pass_name")
|
|
149
|
+
if not isinstance(self.metrics, dict) or not self.metrics or any(not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", k) for k in self.metrics):
|
|
150
|
+
raise ValueError(f"hook {self.name}: metrics must have valid names")
|
|
151
|
+
if any(not isinstance(v, str) for v in self.metrics.values()):
|
|
152
|
+
raise ValueError(f"hook {self.name}: metric descriptions must be strings")
|
|
153
|
+
reserved = {"task_name", "doc_id", "choice_idx", "step", "module_path", "call_index", "stage", "pass_name"}
|
|
154
|
+
if self.extended:
|
|
155
|
+
reserved.update({"input_offset", "forward_index", "position", "input_token_id", "input_length", "token_role"})
|
|
156
|
+
if reserved.intersection(self.metrics):
|
|
157
|
+
raise ValueError(f"hook {self.name}: metric name collides with a context column")
|
|
158
|
+
if not all(callable(f) for f in (self.observe, self.extract_input, self.extract_output)):
|
|
159
|
+
raise ValueError(f"hook {self.name}: callbacks must be callable")
|
|
160
|
+
|
|
161
|
+
def resolve(self, catalogue: Sequence[tuple[str, str]]) -> tuple[str, ...]:
|
|
162
|
+
"""selector를 실제 경로 집합으로 확정한다. 정렬·중복 제거하며 미매칭은 실패다.
|
|
163
|
+
|
|
164
|
+
catalogue는 `(경로, 모듈 클래스 이름)` 쌍이며 호출 시점에 모델이 존재해야 한다.
|
|
165
|
+
결과가 비면 조용히 아무것도 관측하지 않는 대신 실행을 멈춘다. 오타 난 정규식과
|
|
166
|
+
"이 모델에는 그런 모듈이 없다"는 사실은 둘 다 관측 0건으로 끝나기 때문이다.
|
|
167
|
+
"""
|
|
168
|
+
known = {path for path, _ in catalogue}
|
|
169
|
+
if self.modules:
|
|
170
|
+
missing = [path for path in self.modules if path not in known]
|
|
171
|
+
if missing:
|
|
172
|
+
raise ValueError(f"hook {self.name}: {missing[0]!r} is not a decoder descendant module")
|
|
173
|
+
chosen: list[str] = list(self.modules)
|
|
174
|
+
described = "the given module paths"
|
|
175
|
+
elif self.module_pattern is not None:
|
|
176
|
+
pattern = re.compile(self.module_pattern)
|
|
177
|
+
chosen = [path for path, _ in catalogue if pattern.search(path)]
|
|
178
|
+
described = f"module_pattern {self.module_pattern!r}"
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
chosen = list(self.select_modules(tuple(catalogue)))
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise ValueError(f"hook {self.name}: select_modules failed: {exc}") from exc
|
|
184
|
+
if any(not isinstance(path, str) for path in chosen):
|
|
185
|
+
raise ValueError(f"hook {self.name}: select_modules must return module paths as strings")
|
|
186
|
+
unknown = [path for path in chosen if path not in known]
|
|
187
|
+
if unknown:
|
|
188
|
+
raise ValueError(f"hook {self.name}: select_modules returned {unknown[0]!r}, "
|
|
189
|
+
"which is not a decoder descendant module")
|
|
190
|
+
described = "select_modules"
|
|
191
|
+
if not chosen:
|
|
192
|
+
raise ValueError(f"hook {self.name}: {described} matched none of the "
|
|
193
|
+
f"{len(catalogue)} decoder submodules")
|
|
194
|
+
return tuple(sorted(set(chosen)))
|
|
195
|
+
|
|
196
|
+
def selector(self) -> dict[str, Any] | None:
|
|
197
|
+
"""provenance에 남길 selector 식별. exact path는 기존 modules가 곧 selector다."""
|
|
198
|
+
if self.module_pattern is not None:
|
|
199
|
+
return {"kind": "pattern", "pattern": self.module_pattern,
|
|
200
|
+
"match": "re.search on the dotted module path"}
|
|
201
|
+
if self.select_modules is not None:
|
|
202
|
+
return {"kind": "function",
|
|
203
|
+
"function": getattr(self.select_modules, "__qualname__",
|
|
204
|
+
type(self.select_modules).__qualname__),
|
|
205
|
+
"match": "returns paths from (path, module type) pairs"}
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def table_spec(self) -> TableSpec:
|
|
209
|
+
"""실행별 저장 schema. 전역 TABLES를 수정하지 않는다."""
|
|
210
|
+
import pyarrow as pa
|
|
211
|
+
keys = ("task_name", "doc_id", "choice_idx", "step", "module_path", "call_index", "stage", "pass_name")
|
|
212
|
+
columns = tuple(ColumnSpec(k, pa.int64() if k in {"doc_id", "choice_idx", "step", "call_index"} else pa.string(), k) for k in keys)
|
|
213
|
+
if self.extended:
|
|
214
|
+
keys = tuple(k for k in keys if k != "step") + ("forward_index", "position")
|
|
215
|
+
columns += tuple(ColumnSpec(k, pa.int64(), k) for k in
|
|
216
|
+
("forward_index", "position", "input_token_id", "input_length", "input_offset"))
|
|
217
|
+
columns += (ColumnSpec("token_role", pa.string(), "Role of the actual input token"),)
|
|
218
|
+
columns += tuple(ColumnSpec(k, pa.float64(), v, comparable_across_vocab=False) for k, v in self.metrics.items())
|
|
219
|
+
return TableSpec(f"custom/{self.name}", keys, columns, None, f"Custom observation: {self.name}")
|
|
220
|
+
|
|
221
|
+
def descriptor(self) -> dict[str, Any]:
|
|
222
|
+
"""소스 파일과 명시한 의존 파일의 내용을 실행 식별에 포함한다.
|
|
223
|
+
|
|
224
|
+
source를 읽을 수 없는 메모리 함수는 version에 의존하며 자동 resume을
|
|
225
|
+
허용하지 않는다. 패키지/외부 의존성 변경은 version에 반영해야 한다.
|
|
226
|
+
"""
|
|
227
|
+
files = set(self.source_files)
|
|
228
|
+
complete = True
|
|
229
|
+
callbacks = [self.observe, self.extract_input, self.extract_output]
|
|
230
|
+
# 선택 함수도 같은 규칙을 따른다. 소스를 읽을 수 없으면 resume_safe가 아니다.
|
|
231
|
+
for fn in callbacks + ([self.select_modules] if self.select_modules is not None else []):
|
|
232
|
+
try:
|
|
233
|
+
path = inspect.getsourcefile(fn)
|
|
234
|
+
except TypeError:
|
|
235
|
+
path = None
|
|
236
|
+
if path and Path(path).is_file():
|
|
237
|
+
files.add(path)
|
|
238
|
+
else:
|
|
239
|
+
complete = False
|
|
240
|
+
sources = {str(Path(p).resolve()): hashlib.sha256(Path(p).read_bytes()).hexdigest() for p in sorted(files)}
|
|
241
|
+
# selector 키는 정규식·함수를 쓸 때만 붙인다. exact path hook의 descriptor는
|
|
242
|
+
# 이 변경 전과 같아야 이전 실행의 후속 수집이 계속 복원된다.
|
|
243
|
+
selector = self.selector()
|
|
244
|
+
return {"name": self.name, "modules": list(self.modules),
|
|
245
|
+
**({"selector": selector} if selector else {}),
|
|
246
|
+
**({"boundary": self.boundary, "input_layout": "BS_token_ids_or_BSF" if self.boundary == "decoder" else "BSF",
|
|
247
|
+
"output_features": "vocabulary" if self.boundary == "lm_head" else "hidden"}
|
|
248
|
+
if self.boundary != "decoder_descendant" else {}), "metrics": self.metrics,
|
|
249
|
+
"version": self.version, "pass_name": self.pass_name, "layout": "BSF",
|
|
250
|
+
"positions": self.positions,
|
|
251
|
+
**({"doc_ids": list(self.doc_ids) if self.doc_ids is not None else None,
|
|
252
|
+
"position_range": list(self.position_range) if self.position_range is not None else None, "max_rows": self.max_rows,
|
|
253
|
+
"position_contract": "post_truncation_input; cached_decode_only; nullable_scored_step",
|
|
254
|
+
"raw_tensors": list(self.raw_tensors), "max_tensor_bytes": self.max_tensor_bytes,
|
|
255
|
+
"tensor_dtype": "original", "collection_resume": self.collection_resume}
|
|
256
|
+
if self.extended or self.doc_ids is not None or self.position_range is not None or self.max_rows != 100000 else {}), "sources": sources, "resume_safe": complete,
|
|
257
|
+
"callbacks": [getattr(f, "__qualname__", type(f).__qualname__) for f in callbacks],
|
|
258
|
+
"schema": json.loads(self.table_spec().arrow_schema().metadata[b"eval_framework"])}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load_hooks(factory: str | None, config: dict, hooks: Sequence[HookSpec] = ()) -> list[HookSpec]:
|
|
262
|
+
"""Python 객체 또는 module:function(config) factory를 같은 API로 검증한다."""
|
|
263
|
+
if not isinstance(config, dict):
|
|
264
|
+
raise ValueError("hook config must be a JSON object")
|
|
265
|
+
result = list(hooks)
|
|
266
|
+
if factory:
|
|
267
|
+
module, sep, name = factory.partition(":")
|
|
268
|
+
if not sep or not module or not name:
|
|
269
|
+
raise ValueError("hook factory must be package.module:function")
|
|
270
|
+
try:
|
|
271
|
+
fn = getattr(importlib.import_module(module), name)
|
|
272
|
+
built = fn(config)
|
|
273
|
+
result.extend(built)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
raise ValueError(f"hook factory {factory}: {exc}") from exc
|
|
276
|
+
if any(not isinstance(h, HookSpec) for h in result):
|
|
277
|
+
raise ValueError("hook factory must return a sequence of HookSpec")
|
|
278
|
+
if len({h.name for h in result}) != len(result):
|
|
279
|
+
raise ValueError("duplicate custom hook name")
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def ensure_collection_is_empty(run_dir, specs) -> None:
|
|
284
|
+
"""완료 근거 없는 legacy Parquet는 재개하지 않는다. opt-in transaction만 별도 경로를 쓴다."""
|
|
285
|
+
collection = [s for s in specs if s.pass_name == "collection"]
|
|
286
|
+
if any(s.collection_resume for s in collection) and not all(s.collection_resume for s in collection):
|
|
287
|
+
raise ValueError("all collection hooks must opt into collection_resume together")
|
|
288
|
+
for spec in specs:
|
|
289
|
+
if spec.pass_name == "collection" and any(Path(run_dir, "custom", spec.name).glob("*.parquet")):
|
|
290
|
+
raise ValueError("custom collection data already exists; use a new run directory")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class HookRuntime:
|
|
294
|
+
"""한 recorder session의 관측 lifecycle. 입력 참조는 호출 종료 시 해제한다."""
|
|
295
|
+
def __init__(self, recorder, specs, pass_name):
|
|
296
|
+
self.recorder = recorder
|
|
297
|
+
self.specs = [s for s in specs if s.pass_name == pass_name]
|
|
298
|
+
self.pass_name = pass_name
|
|
299
|
+
self.handles = []
|
|
300
|
+
self.pending = {}
|
|
301
|
+
self.calls = {}
|
|
302
|
+
self.rows = {}
|
|
303
|
+
self.active = False
|
|
304
|
+
self.resume_checked = False
|
|
305
|
+
# 이름 -> 확정된 경로. 한 모델에 한 번만 확정하고 session마다 다시 풀지 않는다.
|
|
306
|
+
self.targets: dict[str, tuple[str, ...]] = {}
|
|
307
|
+
self.baseline_checked = False
|
|
308
|
+
self.forward_contexts = None
|
|
309
|
+
self.suspended = False
|
|
310
|
+
self.input_ids = None
|
|
311
|
+
self.logits_to_keep = 0
|
|
312
|
+
self.total_rows = {}
|
|
313
|
+
self.tensor_bytes = {}
|
|
314
|
+
self.tensor_indexes = []
|
|
315
|
+
self.attempt = None
|
|
316
|
+
self.collection = None
|
|
317
|
+
self.unit_rows = {}
|
|
318
|
+
self.unit_coverage = {}
|
|
319
|
+
|
|
320
|
+
def register(self) -> None:
|
|
321
|
+
"""대상·resume 검증을 마친 뒤 hook을 부착하고 필요한 저장소를 연다.
|
|
322
|
+
|
|
323
|
+
부착한 handle은 즉시 목록에 넣는다. 이후 다른 hook이나 저장소 등록이
|
|
324
|
+
실패해도 close가 부분 등록된 handle을 빠짐없이 회수할 수 있게 한다.
|
|
325
|
+
"""
|
|
326
|
+
ensure_collection_is_empty(self.recorder.writer.run_dir, self.specs)
|
|
327
|
+
modules = self._registration_modules()
|
|
328
|
+
# selector·이전 기록 검사는 hook 부착 전에 끝낸다.
|
|
329
|
+
self.resolve(modules)
|
|
330
|
+
self.check_resolved_set()
|
|
331
|
+
self.check_resume_rows()
|
|
332
|
+
try:
|
|
333
|
+
for spec in self.specs:
|
|
334
|
+
self.recorder.writer.register_table(spec.table_spec())
|
|
335
|
+
for path in self.targets[spec.name]:
|
|
336
|
+
pre, post = self.callbacks(spec, path)
|
|
337
|
+
self.handles.append(modules[path].register_forward_pre_hook(pre, with_kwargs=True))
|
|
338
|
+
self.handles.append(modules[path].register_forward_hook(post, with_kwargs=True))
|
|
339
|
+
if self.specs and self.specs[0].collection_resume:
|
|
340
|
+
self._open_collection_store()
|
|
341
|
+
except BaseException:
|
|
342
|
+
self.close()
|
|
343
|
+
raise
|
|
344
|
+
|
|
345
|
+
def _registration_modules(self) -> dict[str, Any]:
|
|
346
|
+
"""모델 기준 모듈 경로를 만들고 boundary별 selector 허용 범위를 기록한다.
|
|
347
|
+
|
|
348
|
+
decoder.named_modules()의 경로는 decoder 기준이다. block 경로의 prefix를
|
|
349
|
+
붙여 모델 기준으로 바꾼다. decoder 자체와 lm_head는 각각 별도 boundary에
|
|
350
|
+
넣어 descendant selector가 실수로 root까지 관측하지 않게 한다.
|
|
351
|
+
"""
|
|
352
|
+
prefix = self.recorder.adapter.paths.blocks.rpartition(".")[0]
|
|
353
|
+
modules = {f"{prefix}.{name}" if prefix else name: module
|
|
354
|
+
for name, module in self.recorder.adapter.decoder.named_modules() if name}
|
|
355
|
+
root = self.recorder.adapter.root_model
|
|
356
|
+
if any(s.boundary != "decoder_descendant" for s in self.specs) and root is None:
|
|
357
|
+
raise ValueError("decoder/lm_head boundaries require adapter.root_model")
|
|
358
|
+
if any(s.boundary == "lm_head" for s in self.specs) and not self.recorder.adapter.lm_head_path:
|
|
359
|
+
from .adapters import _resolve_named
|
|
360
|
+
path, _ = _resolve_named(root, self.recorder.adapter.paths.lm_head)
|
|
361
|
+
self.recorder.adapter.lm_head_path = path
|
|
362
|
+
self.boundary_paths = {"decoder_descendant": set(modules), "decoder": {prefix},
|
|
363
|
+
"lm_head": {self.recorder.adapter.lm_head_path}}
|
|
364
|
+
modules[prefix] = self.recorder.adapter.decoder
|
|
365
|
+
if root is not None:
|
|
366
|
+
modules.update({p: m for p, m in root.named_modules()
|
|
367
|
+
if p == self.recorder.adapter.lm_head_path})
|
|
368
|
+
elif any(s.boundary == "lm_head" for s in self.specs):
|
|
369
|
+
raise ValueError("lm_head boundary requires adapter.root_model")
|
|
370
|
+
return modules
|
|
371
|
+
|
|
372
|
+
def _open_collection_store(self) -> None:
|
|
373
|
+
"""재개 가능한 custom 수집의 계약을 확인하고 저장소를 연다.
|
|
374
|
+
|
|
375
|
+
register의 예외 처리 안에서 호출한다. 계약 검증이나 저장소 생성이 실패해도
|
|
376
|
+
이미 붙인 hook은 register가 회수한다. 모델·샘플·관측 경로의 식별 정보는
|
|
377
|
+
이전 수집과 같은 데이터를 이어 쓰는지 확인하기 위한 계약에 포함된다.
|
|
378
|
+
"""
|
|
379
|
+
if self.recorder.reducers:
|
|
380
|
+
raise ValueError("custom collection_resume requires custom-only collection reducers; legacy dumps have no shared commit contract")
|
|
381
|
+
from .storage import CustomCollectionStore, read_manifest
|
|
382
|
+
ensure_collection_is_empty(self.recorder.writer.run_dir, self.specs)
|
|
383
|
+
descriptors = [s.descriptor() for s in self.specs]
|
|
384
|
+
if any(not d["resume_safe"] for d in descriptors):
|
|
385
|
+
raise ValueError("custom collection resume requires source-backed callbacks")
|
|
386
|
+
root = Path(self.recorder.writer.run_dir)
|
|
387
|
+
identity = None
|
|
388
|
+
if (root / "results.json").exists():
|
|
389
|
+
manifest = read_manifest(root)
|
|
390
|
+
identity = manifest.get("config_identity")
|
|
391
|
+
if not identity:
|
|
392
|
+
raise ValueError("incomplete custom collection manifest: config_identity missing")
|
|
393
|
+
samples_path = root / "samples.jsonl"
|
|
394
|
+
contract = {
|
|
395
|
+
"version": 1,
|
|
396
|
+
"specs": descriptors,
|
|
397
|
+
"resolved": self.resolved_paths(),
|
|
398
|
+
"config_identity": identity,
|
|
399
|
+
"samples_sha256": (
|
|
400
|
+
hashlib.sha256(samples_path.read_bytes()).hexdigest()
|
|
401
|
+
if samples_path.is_file() else None
|
|
402
|
+
),
|
|
403
|
+
}
|
|
404
|
+
self.collection = CustomCollectionStore(root, contract)
|
|
405
|
+
|
|
406
|
+
def resolve(self, modules: dict[str, Any]) -> None:
|
|
407
|
+
"""모델이 존재하는 첫 시점에 경로 집합을 확정한다. session마다 바뀌지 않는다.
|
|
408
|
+
|
|
409
|
+
사용자 선택 함수에는 살아 있는 모듈이 아니라 `(경로, 클래스 이름)` 쌍만 넘긴다.
|
|
410
|
+
관측 계약은 모델을 바꾸지 않는 것이고, 선택 단계라고 다르지 않다.
|
|
411
|
+
"""
|
|
412
|
+
if self.targets:
|
|
413
|
+
return
|
|
414
|
+
catalogue = tuple(sorted((path, type(module).__name__) for path, module in modules.items()))
|
|
415
|
+
targets = {}
|
|
416
|
+
for spec in self.specs:
|
|
417
|
+
allowed_paths = self.boundary_paths[spec.boundary]
|
|
418
|
+
candidates = tuple(item for item in catalogue if item[0] in allowed_paths)
|
|
419
|
+
targets[spec.name] = spec.resolve(candidates)
|
|
420
|
+
# 모든 selector가 성공한 뒤 한 번에 확정한다. 중간 실패로 일부만 남으면
|
|
421
|
+
# 다음 등록의 `if self.targets`가 미완료 상태를 완료로 오인할 수 있다.
|
|
422
|
+
self.targets = targets
|
|
423
|
+
|
|
424
|
+
def check_resolved_set(self):
|
|
425
|
+
"""같은 디렉터리가 기록한 경로 집합과 달라지면 섞지 않는다.
|
|
426
|
+
|
|
427
|
+
selector 자체는 실행 식별에 들어가므로 정규식을 바꾸면 resume이 먼저 거부된다.
|
|
428
|
+
여기서 잡는 것은 selector가 그대로인 채 모델 구조가 달라져 같은 이름의 hook이
|
|
429
|
+
다른 모듈을 관측하게 되는 경우다. 별도 프로세스의 후속 수집도 같은 검사를 지난다.
|
|
430
|
+
"""
|
|
431
|
+
if self.baseline_checked or not self.specs:
|
|
432
|
+
return
|
|
433
|
+
self.baseline_checked = True
|
|
434
|
+
from .storage import read_manifest
|
|
435
|
+
try:
|
|
436
|
+
recorded = read_manifest(self.recorder.writer.run_dir).get("custom_hooks") or {}
|
|
437
|
+
except (OSError, ValueError, KeyError):
|
|
438
|
+
return
|
|
439
|
+
for spec in self.specs:
|
|
440
|
+
before = (recorded.get("resolved") or {}).get(spec.name)
|
|
441
|
+
now = list(self.targets[spec.name])
|
|
442
|
+
if before is None or list(before) == now:
|
|
443
|
+
continue
|
|
444
|
+
changed = sorted(set(before).symmetric_difference(now))
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f"hook {spec.name}: the selector now resolves to {len(now)} modules where this "
|
|
447
|
+
f"run recorded {len(before)}, differing at {changed[0]!r}; use a new run directory")
|
|
448
|
+
|
|
449
|
+
def resolved_paths(self) -> dict[str, list[str]]:
|
|
450
|
+
"""provenance에 기록할 이름 -> 실제 경로. 확정 전에는 비어 있다."""
|
|
451
|
+
return {name: list(paths) for name, paths in self.targets.items()}
|
|
452
|
+
|
|
453
|
+
def check_resume_rows(self):
|
|
454
|
+
"""steps만 먼저 flush된 중단 실행을 완료된 custom 관측으로 취급하지 않는다."""
|
|
455
|
+
if self.resume_checked or not self.specs or not self.recorder.already_recorded:
|
|
456
|
+
return
|
|
457
|
+
if any(s.extended for s in self.specs):
|
|
458
|
+
raise ValueError("extended evaluation custom capture cannot resume from steps alone; use a new run directory or standalone collection resume")
|
|
459
|
+
from .storage import read_table
|
|
460
|
+
run_dir = self.recorder.writer.run_dir
|
|
461
|
+
keys = ["task_name", "doc_id", "choice_idx", "step"]
|
|
462
|
+
steps = read_table(run_dir, "steps")
|
|
463
|
+
expected = set(steps[keys].itertuples(index=False, name=None))
|
|
464
|
+
for spec in self.specs:
|
|
465
|
+
try:
|
|
466
|
+
frame = read_table(run_dir, f"custom/{spec.name}")
|
|
467
|
+
except FileNotFoundError as exc:
|
|
468
|
+
raise ValueError(f"hook {spec.name}: missing custom data for recorded steps; use a new run directory") from exc
|
|
469
|
+
for path in self.targets[spec.name]:
|
|
470
|
+
observed = set(frame.loc[frame.module_path == path, keys].itertuples(index=False, name=None))
|
|
471
|
+
if expected != observed:
|
|
472
|
+
raise ValueError(f"hook {spec.name}: incomplete custom data for recorded steps; use a new run directory")
|
|
473
|
+
self.resume_checked = True
|
|
474
|
+
|
|
475
|
+
def note_root(self, module, args, kwargs):
|
|
476
|
+
"""Only explicit integer logits_to_keep establishes a trailing lm_head layout."""
|
|
477
|
+
self.logits_to_keep = kwargs.get("logits_to_keep", kwargs.get("num_logits_to_keep", 0))
|
|
478
|
+
|
|
479
|
+
def begin(self, args=(), kwargs=None):
|
|
480
|
+
self.active = self.recorder._plan is not None
|
|
481
|
+
kwargs = kwargs or {}
|
|
482
|
+
ids = args[0] if args else kwargs.get("input_ids")
|
|
483
|
+
# Retain only during this forward. No raw input reference survives end/close.
|
|
484
|
+
self.input_ids = ids if getattr(ids, "ndim", None) == 2 else None
|
|
485
|
+
self.calls.clear()
|
|
486
|
+
self.pending.clear()
|
|
487
|
+
self.forward_contexts = None
|
|
488
|
+
|
|
489
|
+
def contexts(self, length):
|
|
490
|
+
"""같은 forward의 모든 hook에 동일한 문서·step 매핑을 제공한다.
|
|
491
|
+
|
|
492
|
+
recorder의 context 생성은 generation step을 전진시킨다. 여기서는 값을
|
|
493
|
+
복원하고, 실제 전진은 decoder 종료 시 recorder가 한 번만 수행하게 한다.
|
|
494
|
+
"""
|
|
495
|
+
if self.forward_contexts is not None:
|
|
496
|
+
return self.forward_contexts
|
|
497
|
+
plan = self.recorder._plan
|
|
498
|
+
step = plan.get("next_step")
|
|
499
|
+
contexts = self.recorder._contexts_for_forward(length)
|
|
500
|
+
if step is not None:
|
|
501
|
+
plan["next_step"] = step
|
|
502
|
+
self.forward_contexts = contexts
|
|
503
|
+
return contexts
|
|
504
|
+
|
|
505
|
+
def _head_sequence_offset(self, spec: HookSpec, length: int) -> int:
|
|
506
|
+
"""입력 tensor 좌표를 마지막 토큰만 남긴 lm_head의 좌표로 옮길 offset.
|
|
507
|
+
|
|
508
|
+
logits_to_keep가 명시한 trailing slice만 지원한다. 길이가 같아도 index
|
|
509
|
+
tensor는 토큰 순서를 바꿀 수 있으므로 정수 여부를 먼저 검사한다.
|
|
510
|
+
"""
|
|
511
|
+
if spec.boundary != "lm_head":
|
|
512
|
+
return 0
|
|
513
|
+
keep = self.logits_to_keep
|
|
514
|
+
if type(keep) is not int or keep < 0:
|
|
515
|
+
raise ValueError("custom lm_head: unsupported logits_to_keep; requires a nonnegative integer")
|
|
516
|
+
if self.input_ids is None or length == self.input_ids.shape[1]:
|
|
517
|
+
return 0
|
|
518
|
+
width = self.input_ids.shape[1]
|
|
519
|
+
if keep <= 0 or length != min(keep, width):
|
|
520
|
+
raise ValueError("custom lm_head: unsupported sequence layout (requires explicit integer logits_to_keep)")
|
|
521
|
+
if spec.positions == "full_prompt":
|
|
522
|
+
raise ValueError("custom lm_head full_prompt unavailable: logits_to_keep omits prompt positions")
|
|
523
|
+
return width - length
|
|
524
|
+
|
|
525
|
+
def selected_contexts(self, spec, length):
|
|
526
|
+
"""Map actual input coordinates independently of nullable scoring steps.
|
|
527
|
+
|
|
528
|
+
Input positions start at zero after truncation. Incremental decode has one
|
|
529
|
+
physical position and an absolute post-truncation sequence coordinate.
|
|
530
|
+
Prefix recomputation is rejected until an explicit forward schema supports it.
|
|
531
|
+
"""
|
|
532
|
+
result = []
|
|
533
|
+
for ctx in self.contexts(length):
|
|
534
|
+
if spec.doc_ids is not None and ctx.doc_id not in spec.doc_ids:
|
|
535
|
+
continue
|
|
536
|
+
scored_steps = dict(zip(ctx.positions, ctx.steps))
|
|
537
|
+
target_tokens = (dict(zip(ctx.positions, ctx.target_token_ids))
|
|
538
|
+
if ctx.target_token_ids is not None else None)
|
|
539
|
+
plan = self.recorder._plan
|
|
540
|
+
incremental = plan["kind"] == "generate"
|
|
541
|
+
forward_index = ctx.steps[0] if incremental else 0
|
|
542
|
+
if incremental and forward_index > 0 and (self.input_ids.shape[1] if self.input_ids is not None else length) != 1 and spec.extended:
|
|
543
|
+
raise ValueError("custom full_prompt: cache-free prefix recomputation is unsupported")
|
|
544
|
+
if spec.positions == "full_prompt":
|
|
545
|
+
if self.input_ids is None:
|
|
546
|
+
raise ValueError("custom full_prompt requires actual input_ids")
|
|
547
|
+
if incremental and forward_index > 0:
|
|
548
|
+
positions, indices = list(ctx.positions), list(ctx.seq_indices)
|
|
549
|
+
else:
|
|
550
|
+
valid = ctx.input_length if ctx.input_length is not None else (length if incremental else None)
|
|
551
|
+
if valid is None:
|
|
552
|
+
raise ValueError("custom full_prompt requires input_length")
|
|
553
|
+
offset = ctx.seq_indices[0] - ctx.positions[0]
|
|
554
|
+
positions = list(range(valid))
|
|
555
|
+
indices = [p + offset for p in positions]
|
|
556
|
+
else:
|
|
557
|
+
positions, indices = list(ctx.positions), list(ctx.seq_indices)
|
|
558
|
+
head_offset = self._head_sequence_offset(spec, length)
|
|
559
|
+
selected = [(p, i) for p, i in zip(positions, indices)
|
|
560
|
+
if spec.position_range is None or spec.position_range[0] <= p < spec.position_range[1]]
|
|
561
|
+
if not selected:
|
|
562
|
+
continue
|
|
563
|
+
positions, indices = map(list, zip(*selected))
|
|
564
|
+
prompt_end = (plan["prompt_length"] if incremental else min(ctx.positions) + 1)
|
|
565
|
+
roles = [("prompt" if p < prompt_end else
|
|
566
|
+
"continuation" if ctx.task_kind == "loglikelihood" else "generated") for p in positions]
|
|
567
|
+
token_ids = ([int(self.input_ids[ctx.batch_row, i]) for i in indices]
|
|
568
|
+
if self.input_ids is not None else [None] * len(indices))
|
|
569
|
+
metadata = {"forward_index": forward_index, "token_roles": roles, "input_token_ids": token_ids,
|
|
570
|
+
"input_length": ctx.input_length or (plan.get("prompt_length", length) + forward_index),
|
|
571
|
+
"input_offset": ctx.input_offset}
|
|
572
|
+
# token ID는 원래 입력 좌표에서 읽고, head offset은 관측 tensor를 고를 때만
|
|
573
|
+
# 적용한다. 채점 대상이 아닌 prompt 위치의 step/target은 None으로 둔다.
|
|
574
|
+
result.append(replace(ctx, steps=[scored_steps.get(p) for p in positions], positions=positions,
|
|
575
|
+
seq_indices=[i - head_offset for i in indices],
|
|
576
|
+
target_token_ids=([target_tokens.get(p) for p in positions]
|
|
577
|
+
if target_tokens is not None else None),
|
|
578
|
+
step_token_ids=None,
|
|
579
|
+
shared={"custom": metadata}))
|
|
580
|
+
return result
|
|
581
|
+
|
|
582
|
+
@staticmethod
|
|
583
|
+
def select(tensor, ctx):
|
|
584
|
+
import torch
|
|
585
|
+
if not isinstance(tensor, torch.Tensor) or tensor.ndim != 3:
|
|
586
|
+
raise ValueError("custom hook extractor must return a [batch, sequence, features] tensor")
|
|
587
|
+
if ctx.batch_row >= tensor.shape[0] or any(i < 0 or i >= tensor.shape[1] for i in ctx.seq_indices):
|
|
588
|
+
raise ValueError("custom hook layout does not match the request positions")
|
|
589
|
+
return tensor[ctx.batch_row, ctx.seq_indices, :].detach().clone()
|
|
590
|
+
|
|
591
|
+
def callbacks(self, spec, path):
|
|
592
|
+
"""입력 복사본과 출력을 호출별로 짝지어 관측하는 PyTorch hook 쌍.
|
|
593
|
+
|
|
594
|
+
pending은 stack이다. 같은 모듈이 중첩 호출되어도 마지막 pre-hook의
|
|
595
|
+
입력부터 대응시킨다. 두 callback은 None을 반환해 모델 값을 유지한다.
|
|
596
|
+
"""
|
|
597
|
+
key = (spec.name, path)
|
|
598
|
+
|
|
599
|
+
def pre(module, args, kwargs):
|
|
600
|
+
if not self.active or self.suspended:
|
|
601
|
+
return
|
|
602
|
+
if spec.boundary == "decoder" and spec.extract_input is first_input:
|
|
603
|
+
tensor = args[0] if args else kwargs.get("inputs_embeds")
|
|
604
|
+
if tensor is None:
|
|
605
|
+
tensor = kwargs.get("input_ids")
|
|
606
|
+
if getattr(tensor, "ndim", None) == 2:
|
|
607
|
+
tensor = tensor.unsqueeze(-1)
|
|
608
|
+
else:
|
|
609
|
+
tensor = spec.extract_input(args, kwargs)
|
|
610
|
+
# context를 만들기 전에 shape을 검증한다.
|
|
611
|
+
if getattr(tensor, "ndim", None) != 3:
|
|
612
|
+
raise ValueError(f"hook {spec.name} at {path}: input must have BSF layout")
|
|
613
|
+
contexts = self.selected_contexts(spec, tensor.shape[1])
|
|
614
|
+
self._check_row_limit(spec, sum(ctx.n_positions for ctx in contexts))
|
|
615
|
+
call = self.calls.get(key, 0)
|
|
616
|
+
self.calls[key] = call + 1
|
|
617
|
+
coverage = self.unit_coverage.setdefault(spec.name, {})
|
|
618
|
+
coverage[path] = coverage.get(path, 0) + 1
|
|
619
|
+
self.pending.setdefault(key, []).append((call, [(ctx, self.select(tensor, ctx)) for ctx in contexts]))
|
|
620
|
+
|
|
621
|
+
def post(module, args, kwargs, output):
|
|
622
|
+
if not self.active or self.suspended:
|
|
623
|
+
return
|
|
624
|
+
import torch
|
|
625
|
+
call, inputs = self.pending[key].pop()
|
|
626
|
+
tensor = (getattr(output, "last_hidden_state", None)
|
|
627
|
+
if spec.boundary == "decoder" and spec.extract_output is first_output
|
|
628
|
+
else spec.extract_output(output))
|
|
629
|
+
try:
|
|
630
|
+
for ctx, before in inputs:
|
|
631
|
+
stage = self._stage(spec, ctx)
|
|
632
|
+
context = HookContext(ctx, path, call, stage, self.pass_name)
|
|
633
|
+
if spec.raw_tensors:
|
|
634
|
+
size = (before.numel() * before.element_size() if "input" in spec.raw_tensors else 0)
|
|
635
|
+
if "output" in spec.raw_tensors:
|
|
636
|
+
size += ctx.n_positions * tensor.shape[-1] * tensor.element_size()
|
|
637
|
+
if self.tensor_bytes.get(spec.name, 0) + size > spec.max_tensor_bytes:
|
|
638
|
+
raise ValueError(f"custom max_tensor_bytes exceeded: {spec.name}")
|
|
639
|
+
after = self.select(tensor, ctx)
|
|
640
|
+
self._check_row_limit(spec, ctx.n_positions)
|
|
641
|
+
self.total_rows[spec.name] = self.total_rows.get(spec.name, 0) + ctx.n_positions
|
|
642
|
+
# Save before invoking user code: observers may mutate their independent copies.
|
|
643
|
+
if spec.raw_tensors:
|
|
644
|
+
self.save_tensors(spec, before, after, context)
|
|
645
|
+
with torch.no_grad():
|
|
646
|
+
values = spec.observe(before, after, context)
|
|
647
|
+
scalars = self._metric_scalars(spec, values, ctx.n_positions)
|
|
648
|
+
for i in range(ctx.n_positions):
|
|
649
|
+
row = dict(ctx.axis_columns(i), module_path=path, call_index=call, stage=stage, pass_name=self.pass_name)
|
|
650
|
+
if spec.extended:
|
|
651
|
+
info = ctx.shared["custom"]
|
|
652
|
+
row.update(position=ctx.positions[i], forward_index=info["forward_index"],
|
|
653
|
+
input_token_id=info["input_token_ids"][i], token_role=info["token_roles"][i],
|
|
654
|
+
input_length=info["input_length"], input_offset=info["input_offset"])
|
|
655
|
+
row.update({name: values[i] for name, values in scalars.items()})
|
|
656
|
+
self.rows.setdefault(f"custom/{spec.name}", []).append(row)
|
|
657
|
+
except Exception as exc:
|
|
658
|
+
raise RuntimeError(f"hook {spec.name} at {path}, call {call}: {exc}") from exc
|
|
659
|
+
# PyTorch에는 항상 None을 반환하여 모델 출력을 유지한다.
|
|
660
|
+
return pre, post
|
|
661
|
+
|
|
662
|
+
def _check_row_limit(self, spec: HookSpec, additional_rows: int) -> None:
|
|
663
|
+
"""기존 기본 스키마의 무제한 동작을 유지하고 opt-in 상한만 검사한다.
|
|
664
|
+
|
|
665
|
+
pre-hook에서는 입력 복사 전에 전체 선택을 검사하고, post-hook에서는
|
|
666
|
+
중첩 호출이 추가한 행까지 포함해 실제 기록 직전에 다시 검사한다.
|
|
667
|
+
"""
|
|
668
|
+
enforce_limit = spec.extended or spec.max_rows != 100000
|
|
669
|
+
if enforce_limit and self.total_rows.get(spec.name, 0) + additional_rows > spec.max_rows:
|
|
670
|
+
raise ValueError(f"custom max_rows exceeded: {spec.name}")
|
|
671
|
+
|
|
672
|
+
def _stage(self, spec: HookSpec, ctx: Any) -> str:
|
|
673
|
+
"""실제 실행 단계의 저장 이름. 기본 collection의 replay 이름도 보존한다."""
|
|
674
|
+
if self.pass_name == "collection":
|
|
675
|
+
if not spec.extended:
|
|
676
|
+
return "replay"
|
|
677
|
+
return "loglikelihood" if ctx.task_kind == "loglikelihood" else "teacher_forced"
|
|
678
|
+
if ctx.task_kind == "loglikelihood":
|
|
679
|
+
return "scoring"
|
|
680
|
+
return "prefill" if ctx.shared["custom"]["forward_index"] == 0 else "decode"
|
|
681
|
+
|
|
682
|
+
@staticmethod
|
|
683
|
+
def _metric_scalars(spec: HookSpec, values: Any, n_positions: int) -> dict[str, list[float]]:
|
|
684
|
+
"""observer 결과를 검증하고 저장용 CPU 숫자로 바꿔 activation 참조를 끊는다.
|
|
685
|
+
|
|
686
|
+
각 지표는 선택한 토큰마다 실수 하나를 반환해야 한다. 모든 지표를 검증한
|
|
687
|
+
뒤에 행을 만들므로 뒤쪽 지표가 잘못되어도 일부 지표만 기록되지 않는다.
|
|
688
|
+
"""
|
|
689
|
+
import torch
|
|
690
|
+
|
|
691
|
+
if not isinstance(values, dict) or set(values) != set(spec.metrics):
|
|
692
|
+
raise ValueError("returned metric names do not match HookSpec.metrics")
|
|
693
|
+
scalars = {}
|
|
694
|
+
for name, value in values.items():
|
|
695
|
+
value = torch.as_tensor(value).detach()
|
|
696
|
+
if tuple(value.shape) != (n_positions,) or value.is_complex():
|
|
697
|
+
raise ValueError(f"metric {name} must have shape [{n_positions}] and be real-valued")
|
|
698
|
+
scalars[name] = value.to(device="cpu", dtype=torch.float64).tolist()
|
|
699
|
+
return scalars
|
|
700
|
+
|
|
701
|
+
def save_tensors(self, spec, before, after, context):
|
|
702
|
+
"""Persist a bounded selection in its original dtype, indexed without extension code."""
|
|
703
|
+
from .storage import save_custom_tensors
|
|
704
|
+
selected = {k: (before if k == "input" else after) for k in spec.raw_tensors}
|
|
705
|
+
used = self.tensor_bytes.get(spec.name, 0)
|
|
706
|
+
size = sum(t.numel() * t.element_size() for t in selected.values())
|
|
707
|
+
if used + size > spec.max_tensor_bytes:
|
|
708
|
+
raise ValueError(f"custom max_tensor_bytes exceeded: {spec.name}")
|
|
709
|
+
ctx = context.forward
|
|
710
|
+
info = ctx.shared["custom"]
|
|
711
|
+
rows = [dict(ctx.axis_columns(i), position=ctx.positions[i],
|
|
712
|
+
forward_index=info["forward_index"], module_path=context.module_path,
|
|
713
|
+
call_index=context.call_index, stage=context.stage, pass_name=self.pass_name,
|
|
714
|
+
input_token_id=info["input_token_ids"][i], token_role=info["token_roles"][i],
|
|
715
|
+
input_offset=info["input_offset"])
|
|
716
|
+
for i in range(ctx.n_positions)]
|
|
717
|
+
index, actual_size = save_custom_tensors(self.recorder.writer.run_dir, spec.name, selected,
|
|
718
|
+
rows, "vocabulary" if spec.boundary == "lm_head" else "hidden", self.attempt,
|
|
719
|
+
spec.max_tensor_bytes - used, input_features="token_id" if spec.boundary == "decoder" and before.shape[-1] == 1 else "hidden")
|
|
720
|
+
self.tensor_bytes[spec.name] = used + actual_size
|
|
721
|
+
self.tensor_indexes.append(index)
|
|
722
|
+
|
|
723
|
+
def end(self):
|
|
724
|
+
self.active = False
|
|
725
|
+
self.input_ids = None
|
|
726
|
+
self.forward_contexts = None
|
|
727
|
+
if self.recorder._plan is not None:
|
|
728
|
+
for spec in self.specs:
|
|
729
|
+
for path in self.targets.get(spec.name, ()):
|
|
730
|
+
if not spec.collection_resume and (spec.name, path) not in self.calls:
|
|
731
|
+
raise RuntimeError(f"hook {spec.name}: target {path} was not called in this forward")
|
|
732
|
+
self.pending.clear()
|
|
733
|
+
|
|
734
|
+
def begin_unit(self, task_name, doc_id, choice_idx):
|
|
735
|
+
"""Skip only a proven commit; otherwise allocate a fresh, non-overwriting attempt."""
|
|
736
|
+
if self.collection is None:
|
|
737
|
+
return True
|
|
738
|
+
import uuid
|
|
739
|
+
self.unit_key = (task_name, doc_id, choice_idx, "collection")
|
|
740
|
+
if self.unit_key in self.collection.completed:
|
|
741
|
+
return False
|
|
742
|
+
self.attempt = uuid.uuid4().hex
|
|
743
|
+
self.unit_rows = {}
|
|
744
|
+
self.tensor_indexes = []
|
|
745
|
+
self.total_rows = {}
|
|
746
|
+
self.tensor_bytes = {}
|
|
747
|
+
self.unit_coverage = {s.name: {p: 0 for p in self.targets[s.name]} for s in self.specs}
|
|
748
|
+
return True
|
|
749
|
+
|
|
750
|
+
def finish_unit(self):
|
|
751
|
+
"""Publish all scalar rows, tensor indexes and explicit zero-call coverage together."""
|
|
752
|
+
if self.collection is None:
|
|
753
|
+
return
|
|
754
|
+
self.collection.commit(self.unit_key, self.attempt, self.unit_rows, self.specs,
|
|
755
|
+
self.tensor_indexes, self.unit_coverage)
|
|
756
|
+
self.attempt = None
|
|
757
|
+
self.unit_rows = {}
|
|
758
|
+
self.tensor_indexes = []
|
|
759
|
+
|
|
760
|
+
def drain(self):
|
|
761
|
+
rows, self.rows = self.rows, {}
|
|
762
|
+
if self.collection is not None:
|
|
763
|
+
for name, values in rows.items():
|
|
764
|
+
self.unit_rows.setdefault(name, []).extend(values)
|
|
765
|
+
return {}
|
|
766
|
+
return rows
|
|
767
|
+
|
|
768
|
+
def close(self):
|
|
769
|
+
for handle in self.handles:
|
|
770
|
+
handle.remove()
|
|
771
|
+
self.handles.clear()
|
|
772
|
+
if self.collection is not None:
|
|
773
|
+
self.collection.close()
|
|
774
|
+
self.collection = None
|
|
775
|
+
self.unit_rows = {}
|
|
776
|
+
self.tensor_indexes = []
|
|
777
|
+
self.attempt = None
|
|
778
|
+
self.input_ids = None
|
|
779
|
+
self.forward_contexts = None
|
|
780
|
+
self.pending.clear()
|
|
781
|
+
self.calls.clear()
|
|
782
|
+
self.active = False
|