opal-optimizer 0.3.5__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.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/wrapper.py
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
"""The wrapper: a torch optimizer whose update is a small program that a coding agent rewrites while training runs.
|
|
2
|
+
|
|
3
|
+
This file is the main loop: build and swap programs, step, check, ask the agent. Checkpoints, recovery from a failing
|
|
4
|
+
program, DDP collectives and one-time setup live in opal/misc/.
|
|
5
|
+
|
|
6
|
+
Cost rules (DESIGN.md section 9): training waits for the agent at each decision by default (blocking=True); with
|
|
7
|
+
blocking=False it waits only when the loss turns non-finite, with "auto" only when an answer would land late; an
|
|
8
|
+
unchanged answer costs nothing, and an analysis-only change never rebuilds the updater; the wrapper
|
|
9
|
+
adds no per-update host sync (losses are copied into a device buffer and read at check steps, every `every` updates)
|
|
10
|
+
and, in DDP, no per-update collective outside the short trial window after a swap (swaps are agreed every `sync_every`
|
|
11
|
+
updates over a separate gloo group, asynchronously; one small broadcast per check step); no hooks touch the model
|
|
12
|
+
unless activations are requested.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
import copy
|
|
19
|
+
import datetime
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import numpy as np
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import traceback
|
|
28
|
+
import types
|
|
29
|
+
import weakref
|
|
30
|
+
from collections import deque
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import torch
|
|
34
|
+
import torch.distributed as dist
|
|
35
|
+
from torch.distributed.algorithms.join import Join, Joinable
|
|
36
|
+
|
|
37
|
+
from . import context, defaults, estimate, programs
|
|
38
|
+
from .agent import AgentWorker, Run, new_run_dir, now_stamp
|
|
39
|
+
from .backends import make_backend
|
|
40
|
+
from .backends._privacy import redact_secrets
|
|
41
|
+
from .misc import audit, boundary, integrity, neutral_analysis, waiting
|
|
42
|
+
from .misc.checkpoint import CheckpointMixin
|
|
43
|
+
from .misc.ddp import DDPMixin
|
|
44
|
+
from .misc.precision import PrecisionMixin
|
|
45
|
+
from .misc.recovery import RecoveryMixin
|
|
46
|
+
from .misc.schedulers import ScheduleMixin
|
|
47
|
+
from .misc.schedulers import _rebuild_scheduler # noqa: F401 (checkpoints of v0.1.0 pickle this path)
|
|
48
|
+
from .misc.setup import SetupMixin
|
|
49
|
+
from .misc.setup import resolve_prompt as setup_prompt
|
|
50
|
+
from .misc.skip import SkipMixin, takes_ctx
|
|
51
|
+
from .report import LiveReport
|
|
52
|
+
from . import schedule as decision_schedule
|
|
53
|
+
from .summaries import Summarizer
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _AccelerateCheckpointAdapter:
|
|
57
|
+
"""Keep Accelerate's public optimizer API on the original optimizer; adapt checkpoint I/O only."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, optimizer, opal):
|
|
60
|
+
object.__setattr__(self, "_optimizer", optimizer)
|
|
61
|
+
object.__setattr__(self, "_opal", opal)
|
|
62
|
+
|
|
63
|
+
def __getattr__(self, name):
|
|
64
|
+
return getattr(self._optimizer, name)
|
|
65
|
+
|
|
66
|
+
def __setattr__(self, name, value):
|
|
67
|
+
setattr(self._optimizer, name, value)
|
|
68
|
+
|
|
69
|
+
def state_dict(self):
|
|
70
|
+
return self._opal.state_dict()
|
|
71
|
+
|
|
72
|
+
def load_state_dict(self, state):
|
|
73
|
+
if state.get("opal") == 1:
|
|
74
|
+
return self._opal.load_state_dict(state)
|
|
75
|
+
if "state" in state and "param_groups" in state:
|
|
76
|
+
key = "accelerate-legacy-state"
|
|
77
|
+
if key not in self._opal._warned:
|
|
78
|
+
self._opal._warned.add(key)
|
|
79
|
+
print(
|
|
80
|
+
"[opal] WARNING: the legacy Accelerate checkpoint contains only the original optimizer state; "
|
|
81
|
+
"it holds no OPAL program or progress. Load a separately saved OPAL state_dict to restore them.",
|
|
82
|
+
file=sys.stderr, flush=True,
|
|
83
|
+
)
|
|
84
|
+
with programs.keep_param_group_identity():
|
|
85
|
+
return self._optimizer.load_state_dict(state)
|
|
86
|
+
return self._opal.load_state_dict(state)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ----- public entry points ------------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def wrap(optimizer, model, *, total_steps, goal, scheduler=None, decisions=None, schedule="front", **kw):
|
|
93
|
+
"""Wrap a torch optimizer (or a list of them) so that a coding agent can rewrite the update during training.
|
|
94
|
+
|
|
95
|
+
optimizer the training script's optimizer(s); the starting program calls them unchanged. With Accelerate,
|
|
96
|
+
wrap after accelerator.prepare (the torch optimizer inside is wrapped; no GradScaler, no
|
|
97
|
+
accumulation through Accelerate) and call the wrapped optimizer's step(loss=...). The prepared
|
|
98
|
+
optimizer delegates checkpoint save/load to OPAL, including accelerator.save_state/load_state
|
|
99
|
+
model the model being trained, bare or wrapped by DDP / torch.compile (names, shapes, source code)
|
|
100
|
+
total_steps number of updates in the run (opt.step() calls that apply an update)
|
|
101
|
+
goal one sentence: what the run trains and what it is judged by
|
|
102
|
+
scheduler the training script's LR scheduler(s); their step() runs while the program follows them
|
|
103
|
+
backend "claude" (default), "codex", "mailbox:<dir>", "keep" (no agent, every decision keeps the
|
|
104
|
+
programs), or a function(workspace, message, system) -> rationale str that may edit the files
|
|
105
|
+
backend_options claude: model= (default "claude-opus-5-5") and effort= (default "high"), the agent of the blog's
|
|
106
|
+
runs; another model or effort prints one line at the start saying the blog's results may not be
|
|
107
|
+
reproduced with it. unavailable_timeout= (claude and mailbox; default None, no deadline): an agent
|
|
108
|
+
that cannot work at all (not logged in, a usage or spend limit, the API overloaded, no network,
|
|
109
|
+
no broker answering the mailbox) is waited for, with a line when the wait starts and ends, and the
|
|
110
|
+
same decision goes on once it is back; with a number of seconds the decision fails after that
|
|
111
|
+
long. timeout= (seconds per decision), retries= (a failed call is made again
|
|
112
|
+
after backoff=15 s, then twice as long, ... at most backoff_max=600 s apart, while the decision's
|
|
113
|
+
timeout allows; None, the default: as often as it allows; 0: never), shell_timeout= (seconds per
|
|
114
|
+
shell command of the agent); mailbox: poll=, broker_wait= (seconds without a broker before it
|
|
115
|
+
counts as unavailable), timeout=
|
|
116
|
+
decisions how many times the agent is asked over the run (default 50). Each time it may change the programs
|
|
117
|
+
or keep everything as it is: this is the number of chances it gets, not the number of changes it
|
|
118
|
+
makes. Its analysis may ask for more (wake-ups); max_decisions (default: twice the scheduled
|
|
119
|
+
ones) caps them all. A short run gets fewer (decisions fall on checks, see every). Not set, a run
|
|
120
|
+
whose training waits for the agent asks at most as many as keep the expected waiting within 4x
|
|
121
|
+
its own training time (measured at its first check; at least one, in the same schedule), and says
|
|
122
|
+
so; a decisions you set is kept, with a line when its waiting is above 4x (opal/estimate.py;
|
|
123
|
+
`opal estimate` before launch)
|
|
124
|
+
schedule when those decisions are asked (not the learning-rate schedule, which is `scheduler`):
|
|
125
|
+
"front" (default): a fifth of them evenly over the first 5% of training, the rest evenly after;
|
|
126
|
+
"uniform": every total_steps // decisions updates; [(0.05, 20), (1.0, 30)]: segments of
|
|
127
|
+
(end as a fraction of training, count), here 20 evenly over the first 5% and then 30 evenly over
|
|
128
|
+
the rest; [500, 1000, 5000]: these updates. A list gives the count itself (leave decisions
|
|
129
|
+
out, or make it agree). Each is moved up to the next check (opal/schedule.py)
|
|
130
|
+
max_cost_usd stop asking when the remaining USD budget cannot cover another estimated call (since 0.3.4);
|
|
131
|
+
training continues with the current programs. Codex reports tokens and cannot enforce a USD cap
|
|
132
|
+
every how often OPAL looks at the run, in updates (default 50). At each check it reads the training
|
|
133
|
+
losses, looks for a non-finite one, asks a scheduled decision when one is due (decisions happen
|
|
134
|
+
only at checks), and runs the agent's analysis program (whose own interval, EVERY, the agent may
|
|
135
|
+
set). Between checks OPAL only runs the update
|
|
136
|
+
blocking True (default): training waits at each decision. False: it continues while the agent thinks.
|
|
137
|
+
"auto": it waits only when an answer would land more than max_lag_updates (50) updates late,
|
|
138
|
+
judged from the time per update and the agent's recent answer times, and at the first decision
|
|
139
|
+
share_code False keeps the training script and model source out of what the agent (and its provider) sees
|
|
140
|
+
clip_grad_norm if your loop clips gradients, hand the clipping to OPAL: delete the loop's
|
|
141
|
+
clip_grad_norm_(model.parameters(), x) and pass clip_grad_norm=x. The starting program then clips
|
|
142
|
+
the same way (the run starts identical, bitwise when the optimizer holds all of the model's
|
|
143
|
+
trainable parameters), and the agent, which sees the unclipped gradients, may change it. Clipping
|
|
144
|
+
left in the loop happens outside the optimizer: the agent's program gets clipped gradients and
|
|
145
|
+
cannot change the threshold
|
|
146
|
+
run_dir where the run's records go (default ./opal_runs/<time>-<pid>-<random>); report.html there is
|
|
147
|
+
rewritten after each decision and at the end
|
|
148
|
+
summarize True (default): with backend "claude", a language model (claude-opus-5-5 at low effort; a model
|
|
149
|
+
name here picks another) describes and tags each code change for the report, in a background thread. False: off
|
|
150
|
+
mode "code" (default, and the only mode): the agent writes the update rule. "knobs" (a fixed updater
|
|
151
|
+
with a menu of settings, up to 0.3.3) is not supported in this release and raises
|
|
152
|
+
Advanced: sync_every (20), trial_steps (20), rank_check_every (100; also after the last update), pause_on_nonfinite (True),
|
|
153
|
+
activations (module names to capture, eager models only), prompt ("neutral", the default; "aggressive", the
|
|
154
|
+
neutral prompt with a review of every group's hyperparameters at each decision (misc/aggressive.py); or the path of
|
|
155
|
+
a template file, whose {fields} are filled as in prompts/neutral.md), program (replay folder),
|
|
156
|
+
epochs, train_size, batch_size (ints or strings such as "223M tokens"), same_order_each_epoch: facts the
|
|
157
|
+
neutral prompt states in its TASK paragraph (a fact not given is left out),
|
|
158
|
+
snapshot_limit_gb (2.0: largest CPU copy kept to undo a failing update exactly).
|
|
159
|
+
validation OPAL 0.1.1-validation: N (an int) means the training script evaluates the model on a
|
|
160
|
+
development set held out from training about every N updates, at times it does not announce, and
|
|
161
|
+
reports it with opt.report_dev(loss=..., accuracy=...). The agent sees every measurement
|
|
162
|
+
(obs.metrics, and a line with their history in each decision message) and the neutral prompt says
|
|
163
|
+
so in its TASK paragraph ("about every N updates, at times that are not announced");
|
|
164
|
+
validation_size (e.g. "5,000 images") names the set's size there. None (default): off, and the
|
|
165
|
+
prompt is unchanged. N and the irregular times are your promise: OPAL does not run the
|
|
166
|
+
evaluation (examples/overfit draws one time at random in each epoch), and warns you if the times
|
|
167
|
+
are regular. Needs a prompt that states it (the neutral one, or a template with
|
|
168
|
+
{validation_note}). Never report the test set the run is judged by this way.
|
|
169
|
+
eval_integrity 0.2.2: check that the program does not move the parameters to another point for a development
|
|
170
|
+
evaluation and move them back after it (misc/integrity.py). None (default): on with validation=N;
|
|
171
|
+
True/False: on/off. A run that does is flagged (an eval_integrity event, the report, stderr); the
|
|
172
|
+
agent is not told of the check. eval_integrity_threshold (5): how many times a tensor's typical
|
|
173
|
+
change per update a jump must be. Cost: one sampled copy per update (at most 32K entries).
|
|
174
|
+
"""
|
|
175
|
+
return AgentOptimizer(
|
|
176
|
+
optimizer,
|
|
177
|
+
model,
|
|
178
|
+
total_steps=total_steps,
|
|
179
|
+
goal=goal,
|
|
180
|
+
scheduler=scheduler,
|
|
181
|
+
decisions=decisions,
|
|
182
|
+
schedule=schedule,
|
|
183
|
+
**kw,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def replay(program, optimizer, model, *, total_steps, scheduler=None, **kw):
|
|
188
|
+
"""Run a saved program with no agent: a run folder (its final program) or a programs/vNNN folder. Returns an
|
|
189
|
+
AgentOptimizer used like the wrapped one (opt.step(loss=...)); it writes its own run folder."""
|
|
190
|
+
return AgentOptimizer(
|
|
191
|
+
optimizer, model, total_steps=total_steps, goal="", scheduler=scheduler, program=program, backend=None, **kw
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def final_program(path) -> Path:
|
|
196
|
+
path = Path(path)
|
|
197
|
+
if (path / "updater.py").exists():
|
|
198
|
+
return path
|
|
199
|
+
if (path / "FINAL").exists():
|
|
200
|
+
return path / "programs" / (path / "FINAL").read_text().strip()
|
|
201
|
+
versions = sorted((p for p in (path / "programs").glob("v*") if p.name[1:].isdigit()),
|
|
202
|
+
key=lambda p: int(p.name[1:]))
|
|
203
|
+
if not versions:
|
|
204
|
+
raise FileNotFoundError(f"no program found in {path}")
|
|
205
|
+
return versions[-1]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
WAKE = "your analysis asked for a decision: "
|
|
209
|
+
MAX_REASONS = 10
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _reasons_shown(reasons):
|
|
213
|
+
"""At most MAX_REASONS reasons: the scheduled decision, a non-finite loss and skip alarms always, then the most
|
|
214
|
+
recent wake-up requests of the analysis, and how many earlier ones are left out."""
|
|
215
|
+
reasons = list(dict.fromkeys(reasons))
|
|
216
|
+
if len(reasons) <= MAX_REASONS:
|
|
217
|
+
return reasons
|
|
218
|
+
keep = [r for r in reasons if not r.startswith(WAKE)][:MAX_REASONS]
|
|
219
|
+
wakes = [r for r in reasons if r.startswith(WAKE)]
|
|
220
|
+
room = MAX_REASONS - len(keep)
|
|
221
|
+
shown = keep + (wakes[-room:] if room > 0 else [])
|
|
222
|
+
return shown + [f"{len(wakes) - max(room, 0)} earlier requests from your analysis, not listed"]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _plain_json(obj, bad, depth=0):
|
|
226
|
+
"""A report as plain JSON values: one-element tensors and numpy scalars become numbers, small tensors lists;
|
|
227
|
+
anything else becomes its text, and its type is noted in `bad`."""
|
|
228
|
+
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
229
|
+
return obj
|
|
230
|
+
if depth > 20:
|
|
231
|
+
bad.append("nesting deeper than 20")
|
|
232
|
+
return str(obj)
|
|
233
|
+
if torch.is_tensor(obj):
|
|
234
|
+
if obj.numel() == 1:
|
|
235
|
+
return obj.detach().reshape(()).item()
|
|
236
|
+
if obj.numel() <= 10_000:
|
|
237
|
+
return obj.detach().cpu().tolist()
|
|
238
|
+
bad.append(f"a tensor of {obj.numel()} elements")
|
|
239
|
+
return f"tensor{tuple(obj.shape)}"
|
|
240
|
+
if isinstance(obj, dict):
|
|
241
|
+
return {str(k): _plain_json(v, bad, depth + 1) for k, v in obj.items()}
|
|
242
|
+
if isinstance(obj, (list, tuple)):
|
|
243
|
+
return [_plain_json(v, bad, depth + 1) for v in obj]
|
|
244
|
+
item = getattr(obj, "item", None)
|
|
245
|
+
if callable(item) and hasattr(obj, "dtype"): # numpy scalars
|
|
246
|
+
try:
|
|
247
|
+
return _plain_json(item(), bad, depth + 1)
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
bad.append(type(obj).__name__)
|
|
251
|
+
return str(obj)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _rounded(recs):
|
|
255
|
+
return [
|
|
256
|
+
{k: (float(f"{v:.4g}") if isinstance(v, float) else v) for k, v in r.items() if k != "tensor"} for r in recs
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _weak_log(ref):
|
|
261
|
+
"""The optimizer's log for objects that outlive it (the report writer stays registered at exit)."""
|
|
262
|
+
return lambda msg: ref() is not None and ref()._log(msg)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _is_accelerate(obj):
|
|
266
|
+
return type(obj).__module__.startswith("accelerate.") and type(obj).__name__ in (
|
|
267
|
+
"AcceleratedOptimizer",
|
|
268
|
+
"AcceleratedScheduler",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _inside_accelerate(opt):
|
|
273
|
+
"""The torch optimizer inside Accelerate's AcceleratedOptimizer (wrap after accelerator.prepare): OPAL wraps that
|
|
274
|
+
one, and the loop calls the wrapped optimizer's step(). That is exact when Accelerate neither scales the gradients
|
|
275
|
+
(a GradScaler, for fp16) nor accumulates them over several step() calls; those are refused."""
|
|
276
|
+
if not _is_accelerate(opt):
|
|
277
|
+
return opt
|
|
278
|
+
if getattr(opt, "scaler", None) is not None:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
"opal: this AcceleratedOptimizer scales fp16 gradients with a GradScaler, which OPAL cannot wrap exactly; "
|
|
281
|
+
"use bf16 or fp32 mixed precision with Accelerate"
|
|
282
|
+
)
|
|
283
|
+
steps = getattr(getattr(opt, "gradient_state", None), "num_steps", 1) or 1
|
|
284
|
+
if steps > 1:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"opal: Accelerate accumulates gradients over {steps} step() calls here (gradient_accumulation_steps), "
|
|
287
|
+
"and OPAL's step() is an update each time; accumulate in your loop instead and call opt.step(loss=...) "
|
|
288
|
+
"once per update (Accelerator(gradient_accumulation_steps=1))"
|
|
289
|
+
)
|
|
290
|
+
return opt.optimizer
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _close_at_exit(ref):
|
|
294
|
+
opt = ref()
|
|
295
|
+
if opt is not None:
|
|
296
|
+
try:
|
|
297
|
+
opt.close(interrupted=opt.update < opt.total)
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AgentOptimizer(
|
|
303
|
+
CheckpointMixin,
|
|
304
|
+
RecoveryMixin,
|
|
305
|
+
ScheduleMixin,
|
|
306
|
+
DDPMixin,
|
|
307
|
+
PrecisionMixin,
|
|
308
|
+
SetupMixin,
|
|
309
|
+
SkipMixin,
|
|
310
|
+
torch.optim.Optimizer,
|
|
311
|
+
Joinable,
|
|
312
|
+
):
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
optimizer,
|
|
316
|
+
model,
|
|
317
|
+
*,
|
|
318
|
+
total_steps,
|
|
319
|
+
goal,
|
|
320
|
+
scheduler=None,
|
|
321
|
+
backend="claude",
|
|
322
|
+
backend_options=None,
|
|
323
|
+
decisions=None,
|
|
324
|
+
max_decisions=None,
|
|
325
|
+
max_cost_usd=None,
|
|
326
|
+
blocking=True,
|
|
327
|
+
max_lag_updates=50,
|
|
328
|
+
pause_on_nonfinite=True,
|
|
329
|
+
every=50,
|
|
330
|
+
sync_every=20,
|
|
331
|
+
trial_steps=20,
|
|
332
|
+
rank_check_every=100,
|
|
333
|
+
activations=None,
|
|
334
|
+
run_dir=None,
|
|
335
|
+
prompt=None,
|
|
336
|
+
program=None,
|
|
337
|
+
share_code=True,
|
|
338
|
+
schedule="front",
|
|
339
|
+
show_next_decision=None,
|
|
340
|
+
task_facts=None,
|
|
341
|
+
epochs=None,
|
|
342
|
+
train_size=None,
|
|
343
|
+
batch_size=None,
|
|
344
|
+
same_order_each_epoch=None,
|
|
345
|
+
snapshot_limit_gb=2.0,
|
|
346
|
+
summarize=True,
|
|
347
|
+
clip_grad_norm=None,
|
|
348
|
+
mode="code",
|
|
349
|
+
validation=None,
|
|
350
|
+
validation_size=None,
|
|
351
|
+
eval_integrity=None,
|
|
352
|
+
eval_integrity_threshold=5.0,
|
|
353
|
+
verbose=True,
|
|
354
|
+
):
|
|
355
|
+
Joinable.__init__(self)
|
|
356
|
+
self.user_optimizers = list(optimizer) if isinstance(optimizer, (list, tuple)) else [optimizer]
|
|
357
|
+
accelerated = [o for o in self.user_optimizers if _is_accelerate(o)]
|
|
358
|
+
self.user_optimizers = [_inside_accelerate(o) for o in self.user_optimizers]
|
|
359
|
+
self._user_parameter_ids = self._parameter_ids()
|
|
360
|
+
if scheduler is None:
|
|
361
|
+
self.user_schedulers = []
|
|
362
|
+
else:
|
|
363
|
+
self.user_schedulers = list(scheduler) if isinstance(scheduler, (list, tuple)) else [scheduler]
|
|
364
|
+
# Accelerate's AcceleratedScheduler steps the torch scheduler inside it, which OPAL follows
|
|
365
|
+
self.user_schedulers = [getattr(s, "scheduler", s) if _is_accelerate(s) else s for s in self.user_schedulers]
|
|
366
|
+
self.model, self.total, self.goal = model, int(total_steps), goal
|
|
367
|
+
self._share_code = bool(share_code)
|
|
368
|
+
self._clip_grad_norm = clip_grad_norm
|
|
369
|
+
if mode == "knobs":
|
|
370
|
+
raise ValueError('opal: mode="knobs" is not supported in this release; mode="code" (the default) is the only mode')
|
|
371
|
+
if mode != "code":
|
|
372
|
+
raise ValueError(f'opal: mode must be "code", got {mode!r}')
|
|
373
|
+
prompt = setup_prompt(prompt, validation if backend is not None else None)
|
|
374
|
+
if prompt is None:
|
|
375
|
+
prompt = "neutral" # the default prompt
|
|
376
|
+
self._aggressive = prompt == "aggressive" # OPAL 0.1.2-aggressive: the neutral prompt, edited
|
|
377
|
+
if show_next_decision is None: # on by default with the neutral prompts
|
|
378
|
+
show_next_decision = prompt in ("neutral", "aggressive")
|
|
379
|
+
self._show_next, self._task_facts = bool(show_next_decision), task_facts
|
|
380
|
+
n_decisions = decision_schedule.count(schedule, decisions) # raises on a bad schedule or a mismatch
|
|
381
|
+
self._schedule_kind = decision_schedule.kind(schedule) # "front", "uniform", "segments", "updates"
|
|
382
|
+
# decisions not set (and no list schedule): the first check may lower the count (opal/estimate.py)
|
|
383
|
+
self._may_cap = decisions is None and self._schedule_kind in ("front", "uniform")
|
|
384
|
+
self._max_decisions_given = max_decisions is not None
|
|
385
|
+
self._neutral = prompt in ("neutral", "aggressive")
|
|
386
|
+
# the neutral analysis compares with one epoch earlier only when the data repeat in the same order
|
|
387
|
+
self._neutral_epochs = epochs if same_order_each_epoch is True and isinstance(epochs, (int, float)) else None
|
|
388
|
+
self._run_facts = dict( # the neutral prompt's TASK sentence (misc/taskfacts.py)
|
|
389
|
+
epochs=epochs, train_size=train_size, batch_size=batch_size, same_order=same_order_each_epoch
|
|
390
|
+
)
|
|
391
|
+
if self.total < 1:
|
|
392
|
+
raise ValueError("opal: total_steps must be the number of updates in the run (>= 1)")
|
|
393
|
+
if backend is not None and not (isinstance(goal, str) and goal.strip()):
|
|
394
|
+
raise ValueError("opal: goal must say in one sentence what the run trains and what it is judged by")
|
|
395
|
+
if int(every) < 1 or int(sync_every) < 1:
|
|
396
|
+
raise ValueError("opal: every and sync_every must be >= 1")
|
|
397
|
+
self.mode = mode
|
|
398
|
+
if validation is not None and (
|
|
399
|
+
isinstance(validation, bool) or not isinstance(validation, int) or validation < 1
|
|
400
|
+
):
|
|
401
|
+
raise ValueError(
|
|
402
|
+
"opal: validation must be None (off) or the number of updates between development evaluations "
|
|
403
|
+
f"(an int >= 1), got {validation!r}"
|
|
404
|
+
)
|
|
405
|
+
self._validation, self._validation_size = validation, validation_size # OPAL 0.1.1-validation
|
|
406
|
+
self._dev = [] # the development measurements so far: dict(update=, loss=, accuracy=)
|
|
407
|
+
if eval_integrity not in (None, True, False):
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"opal: eval_integrity must be None (on with validation), True or False, got {eval_integrity!r}"
|
|
410
|
+
)
|
|
411
|
+
if isinstance(eval_integrity_threshold, bool) or not (
|
|
412
|
+
isinstance(eval_integrity_threshold, (int, float)) and eval_integrity_threshold > 1
|
|
413
|
+
):
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"opal: eval_integrity_threshold must be a number > 1, got {eval_integrity_threshold!r}"
|
|
416
|
+
)
|
|
417
|
+
self._integrity_on = validation is not None if eval_integrity is None else eval_integrity
|
|
418
|
+
self._integrity_threshold = float(eval_integrity_threshold)
|
|
419
|
+
self._integrity = None # misc/integrity.py, rank 0 (set up once the starting program is installed)
|
|
420
|
+
self.named = context.named_params(model, self.user_optimizers)
|
|
421
|
+
if not self.named:
|
|
422
|
+
raise ValueError("opal: the optimizer has no parameters of this model")
|
|
423
|
+
self._record_dtypes()
|
|
424
|
+
super().__init__([{"params": [p for _, p in self.named], "lr": float("nan")}], {})
|
|
425
|
+
self._own_groups = self.param_groups
|
|
426
|
+
self._initialised = True
|
|
427
|
+
self.device = self.named[0][1].device
|
|
428
|
+
|
|
429
|
+
self.dist = dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1
|
|
430
|
+
self.rank = dist.get_rank() if self.dist else 0
|
|
431
|
+
self.world = dist.get_world_size() if self.dist else 1
|
|
432
|
+
self.ctrl = dist.new_group(backend="gloo", timeout=datetime.timedelta(hours=12)) if self.dist else None
|
|
433
|
+
self.rank0 = self.rank == 0
|
|
434
|
+
self.verbose = verbose and self.rank0
|
|
435
|
+
|
|
436
|
+
if isinstance(blocking, str) and blocking != "auto":
|
|
437
|
+
raise ValueError(f'opal: blocking must be False, True or "auto", got {blocking!r}')
|
|
438
|
+
self.blocking, self.pause_on_nonfinite = (blocking != "auto" and bool(blocking), bool(pause_on_nonfinite))
|
|
439
|
+
self._auto = waiting.AutoWait(max_lag_updates) if blocking == "auto" else None # misc/waiting.py
|
|
440
|
+
self._block_now = False # this check's decision waits (blocking="auto")
|
|
441
|
+
self.default_every, self.sync_every = int(every), int(sync_every)
|
|
442
|
+
self.trial_steps, self.rank_check_every = (int(trial_steps), int(rank_check_every))
|
|
443
|
+
self.max_cost = max_cost_usd
|
|
444
|
+
self._cost_said = False # the line that max_cost_usd is reached was printed
|
|
445
|
+
self.snapshot_limit = snapshot_limit_gb * 2**30
|
|
446
|
+
# the scheduled decision updates, one list per segment (the prompt describes each), and all of them
|
|
447
|
+
self._schedule_segments = (
|
|
448
|
+
decision_schedule.decision_segments(self.total, n_decisions, self.default_every, schedule)
|
|
449
|
+
if backend is not None
|
|
450
|
+
else [[]]
|
|
451
|
+
)
|
|
452
|
+
self.schedule = [u for seg in self._schedule_segments for u in seg]
|
|
453
|
+
self.max_decisions = max_decisions if max_decisions is not None else 2 * len(self.schedule)
|
|
454
|
+
|
|
455
|
+
self.update = 0
|
|
456
|
+
self.version = 0
|
|
457
|
+
self.memory = {}
|
|
458
|
+
self.errors = deque(maxlen=50)
|
|
459
|
+
self.reports, self.metrics, self._metrics_new, self._shown_new = deque(maxlen=400), [], [], []
|
|
460
|
+
self._float_losses, self._loss_n = [], 0
|
|
461
|
+
self._loss_buf = torch.zeros(max(1, self.default_every), dtype=torch.float32, device=self.device)
|
|
462
|
+
self._losses_from = 1
|
|
463
|
+
self._pending_losses, self._pending_from = [], 1
|
|
464
|
+
self._pending_omitted = 0
|
|
465
|
+
self._analysis_losses, self._analysis_metrics, self._next_analysis = ([], [], self.default_every)
|
|
466
|
+
self._reasons, self._sched_i = [], 0
|
|
467
|
+
self._nonfinite_episode = False
|
|
468
|
+
self._last_applied, self._last_answer = None, None
|
|
469
|
+
self._prev_files, self._prev_updater, self._trial_until = None, None, 0
|
|
470
|
+
self._snapshot, self._hypers, self._first_pending = None, None, False
|
|
471
|
+
self._rank_snapshot, self._rank_trial_equal = None, False
|
|
472
|
+
self._rank_checked_at = None
|
|
473
|
+
self._ranks_repaired_at = []
|
|
474
|
+
# the tags (programs.load) of the running updater's and analysis' modules and of the fallback updater's; other
|
|
475
|
+
# versions' modules are dropped from sys.modules (_forget_retired)
|
|
476
|
+
self._updater_tag = self._analysis_tag = self._prev_tag = None
|
|
477
|
+
self._program_tags = set()
|
|
478
|
+
self.updater = None
|
|
479
|
+
self._ledger, self._install_rec, self._build_changes = None, None, None # misc/schedulers.py
|
|
480
|
+
self._resume_pending, self._observed_loss = None, None
|
|
481
|
+
self._in_updater = False
|
|
482
|
+
self._flag_work = None
|
|
483
|
+
self._timing = dict(
|
|
484
|
+
t=None,
|
|
485
|
+
u=None,
|
|
486
|
+
wall_ms_per_update=None,
|
|
487
|
+
program_ms=None,
|
|
488
|
+
program_ms_start=None,
|
|
489
|
+
analysis_ms=None,
|
|
490
|
+
audit=None,
|
|
491
|
+
audit_start=None,
|
|
492
|
+
)
|
|
493
|
+
self._audit_at = 2 # profile the second update of each new updater (rank 0)
|
|
494
|
+
self._step_loss, self._step_ctx, self._skips_in_a_row, self._skipped, self._skip_alarm = (
|
|
495
|
+
None,
|
|
496
|
+
False,
|
|
497
|
+
0,
|
|
498
|
+
[],
|
|
499
|
+
False,
|
|
500
|
+
)
|
|
501
|
+
self._events = None
|
|
502
|
+
self._paused_s = 0.0 # seconds training waited for the agent since the last check (rank 0)
|
|
503
|
+
self._reports_made = 0 # analysis reports since the previous decision (the inbox keeps the last 400)
|
|
504
|
+
self._capture_next = False
|
|
505
|
+
self._estimate_due, self._estimated, self._schedule_changed = False, False, False # opal/estimate.py
|
|
506
|
+
self._t_mark, self._steps_here = None, 0 # (update, time) from which this run's time per update is measured
|
|
507
|
+
self._loss_hook = None # fn() -> this update's loss, for a framework that calls step() without it (opal/hf.py)
|
|
508
|
+
self.agent_wait_seconds = 0.0 # seconds training waited for the agent in this process (rank 0; opal/results.py)
|
|
509
|
+
self._closed = False
|
|
510
|
+
self._resume_note = ""
|
|
511
|
+
self._warned = set()
|
|
512
|
+
self.activations = {}
|
|
513
|
+
|
|
514
|
+
# run folder and records (rank 0); other ranks keep program files in a private temporary folder
|
|
515
|
+
if self.rank0:
|
|
516
|
+
self._auto_run_dir = run_dir is None
|
|
517
|
+
self.run = Run(Path(run_dir) if run_dir else new_run_dir(Path("opal_runs")))
|
|
518
|
+
self._prog_root = self.run.programs
|
|
519
|
+
self._live = LiveReport(self.run.dir, log=_weak_log(weakref.ref(self)))
|
|
520
|
+
atexit.register(self._live.finish) # runs after _close_at_exit (registered below): the last report
|
|
521
|
+
else:
|
|
522
|
+
self._live = None
|
|
523
|
+
self._auto_run_dir = False
|
|
524
|
+
self.run = None
|
|
525
|
+
self._prog_root = Path(tempfile.mkdtemp(prefix=f"opal-rank{self.rank}-"))
|
|
526
|
+
|
|
527
|
+
names_by_id = {id(p): n for n, p in self.named}
|
|
528
|
+
if program is not None:
|
|
529
|
+
files = programs.program(programs.read_dir(final_program(program)))
|
|
530
|
+
else:
|
|
531
|
+
files = programs.starting_files(
|
|
532
|
+
self.user_optimizers,
|
|
533
|
+
self.user_schedulers,
|
|
534
|
+
names_by_id,
|
|
535
|
+
self.default_every,
|
|
536
|
+
clip=clip_grad_norm,
|
|
537
|
+
sources=share_code,
|
|
538
|
+
)
|
|
539
|
+
if self._neutral: # its own starting analysis (misc/neutral_analysis.py)
|
|
540
|
+
files["analysis.py"] = neutral_analysis.text(self.default_every, self._neutral_epochs)
|
|
541
|
+
if self.dist: # every rank runs rank 0's program text (descriptions of user objects may differ per rank)
|
|
542
|
+
box = [files, str(self.run.dir) if self.rank0 else None]
|
|
543
|
+
dist.broadcast_object_list(box, src=0, group=self.ctrl)
|
|
544
|
+
files, self._run_path = box[0], Path(box[1])
|
|
545
|
+
else:
|
|
546
|
+
self._run_path = self.run.dir
|
|
547
|
+
self._starting_files = files
|
|
548
|
+
self._starting_digest = programs.side_digests(files)[0]
|
|
549
|
+
err = self._install(files, previous=None, first=True)
|
|
550
|
+
if err:
|
|
551
|
+
raise RuntimeError(f"opal: the starting program failed to build:\n{err}")
|
|
552
|
+
self._new_install_record()
|
|
553
|
+
self._patch_schedulers()
|
|
554
|
+
self._hook_user_optimizers()
|
|
555
|
+
self._watch(activations)
|
|
556
|
+
if self._integrity_on and self.rank0:
|
|
557
|
+
self._integrity = integrity.Integrity(self.named, self._integrity_threshold)
|
|
558
|
+
self._integrity_record(0) # the starting parameters
|
|
559
|
+
|
|
560
|
+
self.worker = None
|
|
561
|
+
self._backend_label = "none"
|
|
562
|
+
if backend is not None and self.rank0:
|
|
563
|
+
be = make_backend(backend, **(backend_options or {}))
|
|
564
|
+
be.preflight()
|
|
565
|
+
note = be.reproduction_note() if hasattr(be, "reproduction_note") else None
|
|
566
|
+
if note: # another model or effort than the blog's (backends/claude.py)
|
|
567
|
+
self._log(note)
|
|
568
|
+
if share_code:
|
|
569
|
+
refs = context.reference_files(model, self.user_optimizers, self.user_schedulers)
|
|
570
|
+
for name, text in refs.items():
|
|
571
|
+
(self.run.workspace / "reference" / name).write_text(text)
|
|
572
|
+
self._script_copied = any(n.startswith("training_script_") for n in refs) # not in a notebook
|
|
573
|
+
self._agent_shell = getattr(be, "sandbox", None) # False: the agent has file tools only
|
|
574
|
+
self._decision_timeout = getattr(be, "timeout", None) # the neutral prompt states the limit
|
|
575
|
+
self._prompt_name, self._names_by_id = prompt, names_by_id # to write the prompt again (_set_schedule)
|
|
576
|
+
self.worker = AgentWorker(self.run, be, self._system_prompt(prompt, names_by_id), log=self._log)
|
|
577
|
+
# the estimate of the waiting and the cost at the first check, measured from this run (opal/estimate.py)
|
|
578
|
+
self._estimate_due = getattr(be, "name", None) in ("claude", "mailbox")
|
|
579
|
+
self.worker.after_decision = self._after_decision
|
|
580
|
+
self._backend_label = backend if isinstance(backend, str) else getattr(be, "name", "custom")
|
|
581
|
+
if summarize and getattr(be, "name", None) == "claude":
|
|
582
|
+
model = summarize if isinstance(summarize, str) else None
|
|
583
|
+
self._live.summarizer = Summarizer(model=model, binary=getattr(be, "binary", "claude"))
|
|
584
|
+
checked = self._check_defaults(prompt, program) if self.rank0 and backend is not None else {}
|
|
585
|
+
if self.rank0:
|
|
586
|
+
self.run.event(
|
|
587
|
+
"start",
|
|
588
|
+
**checked,
|
|
589
|
+
goal=self.goal,
|
|
590
|
+
total_updates=self.total,
|
|
591
|
+
world=self.world,
|
|
592
|
+
blocking="auto" if self._auto else self.blocking,
|
|
593
|
+
**(dict(max_lag_updates=self._auto.max_lag) if self._auto else {}),
|
|
594
|
+
schedule=self.schedule,
|
|
595
|
+
backend=getattr(self.worker.backend, "name", None) if self.worker else None,
|
|
596
|
+
version=self.version,
|
|
597
|
+
**(dict(validation=self._validation) if self._validation else {}),
|
|
598
|
+
**(dict(eval_integrity=dict(threshold=self._integrity_threshold)) if self._integrity else {}),
|
|
599
|
+
)
|
|
600
|
+
atexit.register(_close_at_exit, weakref.ref(self))
|
|
601
|
+
# Accelerator retains these objects. Public state/param_groups/defaults still delegate to the raw
|
|
602
|
+
# optimizer (including after load_state_dict replaces state); only checkpoint I/O goes through OPAL.
|
|
603
|
+
for prepared in accelerated:
|
|
604
|
+
prepared.optimizer = _AccelerateCheckpointAdapter(prepared.optimizer, self)
|
|
605
|
+
|
|
606
|
+
def _check_defaults(self, prompt, program):
|
|
607
|
+
"""OPAL's defaults are fixed (opal/defaults.py): say loudly when the files this run is about to use differ from
|
|
608
|
+
the released ones (an edited install), and say when it uses a custom prompt or starting program. Returns what
|
|
609
|
+
the start event records."""
|
|
610
|
+
named = prompt in ("neutral", "aggressive")
|
|
611
|
+
used = defaults.SHARED_FILES + (defaults.PROMPT_FILES if named else ()) + (
|
|
612
|
+
defaults.PROGRAM_FILES if program is None else ()
|
|
613
|
+
)
|
|
614
|
+
out = {}
|
|
615
|
+
changed = defaults.modified(used)
|
|
616
|
+
if changed:
|
|
617
|
+
out.update(defaults_modified=True, modified_files=changed)
|
|
618
|
+
print(
|
|
619
|
+
f"[opal] WARNING: OPAL's own defaults were modified ({', '.join(changed)}): these files differ from the "
|
|
620
|
+
"release, so this run's agent does not get the prompt or starting programs of the blog's and the README's results. "
|
|
621
|
+
"Do not edit files inside the OPAL package: reinstall OPAL, and customise through wrap()'s arguments "
|
|
622
|
+
"(prompt=<a template file>, program=<a program folder>, task_facts=).",
|
|
623
|
+
file=sys.stderr,
|
|
624
|
+
flush=True,
|
|
625
|
+
)
|
|
626
|
+
for what, value in (("prompt", None if named else prompt), ("starting program", program)):
|
|
627
|
+
if value is not None:
|
|
628
|
+
out["custom_" + what.split()[-1]] = str(value)
|
|
629
|
+
print(
|
|
630
|
+
f"[opal] this run uses a custom {what} ({value}); the blog's results used OPAL's defaults",
|
|
631
|
+
file=sys.stderr,
|
|
632
|
+
flush=True,
|
|
633
|
+
)
|
|
634
|
+
return out
|
|
635
|
+
|
|
636
|
+
def _announce(self):
|
|
637
|
+
"""Printed at the first update, after any load_state_dict, so it names the folder the run really writes to."""
|
|
638
|
+
self._announced = True
|
|
639
|
+
if not self.rank0:
|
|
640
|
+
return
|
|
641
|
+
self._log(
|
|
642
|
+
f"run folder {self.run.dir}; {len(self.schedule)} scheduled decision{'s' * (len(self.schedule) != 1)}; "
|
|
643
|
+
f"{'auto-blocking' if self._auto else 'blocking' if self.blocking else 'non-blocking'}; "
|
|
644
|
+
f"backend {self._backend_label}"
|
|
645
|
+
)
|
|
646
|
+
describe = getattr(self.worker.backend, "describe", None) if self.worker else None
|
|
647
|
+
if describe:
|
|
648
|
+
self._log(describe())
|
|
649
|
+
|
|
650
|
+
def __repr__(self):
|
|
651
|
+
where = f", run {self.run.dir}" if self.run is not None else ""
|
|
652
|
+
return f"AgentOptimizer(program v{self.version:03d}, update {self.update}/{self.total}{where})"
|
|
653
|
+
|
|
654
|
+
# ----- program versions -----------------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
def _spec(self):
|
|
657
|
+
return dict(
|
|
658
|
+
total_updates=self.total,
|
|
659
|
+
update=self.update,
|
|
660
|
+
world_size=self.world,
|
|
661
|
+
rank=self.rank,
|
|
662
|
+
user_optimizers=self.user_optimizers,
|
|
663
|
+
user_schedulers=self.user_schedulers,
|
|
664
|
+
clip_grad_norm=None if self._clip_grad_norm is None else float(self._clip_grad_norm),
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
def _groups_of(self, updater):
|
|
668
|
+
"""The groups the wrapper exposes: the updater's param_groups, plus one group (lr NaN) with every parameter
|
|
669
|
+
they leave out, so GradScaler unscales every gradient once and LR logging never meets a group without "lr"."""
|
|
670
|
+
groups = getattr(updater, "param_groups", None)
|
|
671
|
+
if not (isinstance(groups, list) and groups and all(isinstance(g, dict) and "params" in g for g in groups)):
|
|
672
|
+
return self._own_groups
|
|
673
|
+
ids = [id(p) for g in groups for p in g["params"]]
|
|
674
|
+
if len(ids) != len(set(ids)):
|
|
675
|
+
raise ValueError(
|
|
676
|
+
"param_groups lists a parameter in more than one group (a gradient scaler would unscale it twice)"
|
|
677
|
+
)
|
|
678
|
+
covered = set(ids)
|
|
679
|
+
rest = [p for _, p in self.named if id(p) not in covered]
|
|
680
|
+
return list(groups) + ([{"params": rest, "lr": float("nan")}] if rest else [])
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def param_groups(self):
|
|
684
|
+
"""The groups the training script sees (GradScaler, LR logging): the running updater's param_groups, read
|
|
685
|
+
again whenever its group dicts change (a property that builds new dicts each call shows its current rates), plus
|
|
686
|
+
the group of the parameters it leaves out. Cached while the updater returns the same dicts."""
|
|
687
|
+
d = self.__dict__
|
|
688
|
+
up = d.get("updater")
|
|
689
|
+
if up is None:
|
|
690
|
+
return d["_param_groups"]
|
|
691
|
+
groups = getattr(up, "param_groups", None)
|
|
692
|
+
cache = d.get("_groups_cache")
|
|
693
|
+
same = (
|
|
694
|
+
cache is not None
|
|
695
|
+
and cache[0] is up
|
|
696
|
+
and isinstance(groups, list)
|
|
697
|
+
and isinstance(cache[1], list)
|
|
698
|
+
and len(groups) == len(cache[1])
|
|
699
|
+
and all(a is b for a, b in zip(groups, cache[1]))
|
|
700
|
+
)
|
|
701
|
+
if same or (cache is not None and cache[0] is up and groups is None and cache[1] is None):
|
|
702
|
+
return cache[2]
|
|
703
|
+
try:
|
|
704
|
+
exposed = self._groups_of(up)
|
|
705
|
+
except ValueError: # a program that lists a parameter twice was refused when built; keep what it had
|
|
706
|
+
return cache[2] if cache is not None and cache[0] is up else d["_param_groups"]
|
|
707
|
+
d["_groups_cache"] = (up, list(groups) if isinstance(groups, list) else None, exposed)
|
|
708
|
+
return exposed
|
|
709
|
+
|
|
710
|
+
@param_groups.setter
|
|
711
|
+
def param_groups(self, value): # torch.optim.Optimizer.__init__
|
|
712
|
+
self.__dict__["_param_groups"] = value
|
|
713
|
+
|
|
714
|
+
def _every_of(self, an):
|
|
715
|
+
e = getattr(an, "EVERY", self.default_every) if an is not None else self.default_every
|
|
716
|
+
if isinstance(e, bool) or not isinstance(e, int) or e < 1:
|
|
717
|
+
raise TypeError(f"EVERY must be a positive int, got {e!r}")
|
|
718
|
+
return e
|
|
719
|
+
|
|
720
|
+
def _build(self, files, version, previous, updater=True, restoring=False):
|
|
721
|
+
"""Load `files` and, unless only the analysis side changed, build the updater. Raises on any problem and
|
|
722
|
+
registers nothing on failure."""
|
|
723
|
+
if self.rank0:
|
|
724
|
+
boundary.record(files, version, self.run)
|
|
725
|
+
d = self._prog_root / f"v{version:03d}"
|
|
726
|
+
programs.write_dir(d, files)
|
|
727
|
+
tag = f"r{self.rank}v{version}"
|
|
728
|
+
try:
|
|
729
|
+
an = programs.load(files, "analysis.py", tag, d)
|
|
730
|
+
if an is not None and not callable(getattr(an, "analyze", None)):
|
|
731
|
+
raise TypeError("analysis.py must define analyze(obs, memory); this one does not, so it would never run")
|
|
732
|
+
every, up, groups = self._every_of(an), None, None
|
|
733
|
+
if updater:
|
|
734
|
+
mod = programs.load(files, "updater.py", tag, d)
|
|
735
|
+
with programs.keep_param_group_identity(), self._dtypes_kept():
|
|
736
|
+
up = mod.build(self.named, previous=previous, spec=self._spec())
|
|
737
|
+
starting = programs.side_digests(files)[0] == self._starting_digest
|
|
738
|
+
methods = ("step",) if starting else ("step", "state_dict", "load_state_dict")
|
|
739
|
+
for method in methods:
|
|
740
|
+
if not callable(getattr(up, method, None)):
|
|
741
|
+
raise TypeError(f"build() must return an updater with a callable {method} method")
|
|
742
|
+
bad = programs.unpicklable(programs.normalize_state(up.state_dict())) if not (starting or restoring) else []
|
|
743
|
+
if bad:
|
|
744
|
+
raise TypeError(
|
|
745
|
+
"state_dict() must contain tensors and weights-only-safe values in acyclic containers, "
|
|
746
|
+
"so that checkpoints load with weights_only=True; found: "
|
|
747
|
+
+ "; ".join(bad)
|
|
748
|
+
)
|
|
749
|
+
groups = self._groups_of(up)
|
|
750
|
+
except BaseException:
|
|
751
|
+
programs.forget(tag)
|
|
752
|
+
if self.rank0:
|
|
753
|
+
shutil.move(str(d), str(self._prog_root / f"rejected-v{version:03d}-{now_stamp()}"))
|
|
754
|
+
else:
|
|
755
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
756
|
+
raise
|
|
757
|
+
return an, up, every, groups
|
|
758
|
+
|
|
759
|
+
def _install(self, files, previous, first=False, updater=True, handover=True, restoring=False):
|
|
760
|
+
"""Make `files` the next version. updater=False: only analysis.py (or its helpers) changed, so the running
|
|
761
|
+
updater object and its state stay. On failure on any rank, keep the current programs (with their
|
|
762
|
+
hyperparameters as they were) and return the error text. handover=False: the caller puts the user's schedule
|
|
763
|
+
in place itself (a revert, a resume)."""
|
|
764
|
+
version = 0 if first else self.version + 1
|
|
765
|
+
swap = updater and not first
|
|
766
|
+
hypers = self._hyper_state() if swap else None
|
|
767
|
+
before = self._schedule_state() if swap else None # the user's schedule as the running program left it
|
|
768
|
+
ledger = copy.deepcopy(self._ledger) if swap else None
|
|
769
|
+
user_dtypes = self._user_state_dtypes() if swap else None # misc/precision.py
|
|
770
|
+
err = None
|
|
771
|
+
try:
|
|
772
|
+
an, up, every, groups = self._build(files, version, previous, updater, restoring=restoring)
|
|
773
|
+
except programs.AGENT_ERRORS:
|
|
774
|
+
err = traceback.format_exc(limit=6)[-3000:]
|
|
775
|
+
if not self._agree(err is None):
|
|
776
|
+
if err is None: # built here, failed on another rank
|
|
777
|
+
programs.forget(f"r{self.rank}v{version}")
|
|
778
|
+
self._restore_hypers(hypers)
|
|
779
|
+
if before is not None:
|
|
780
|
+
self._set_schedule_state(before)
|
|
781
|
+
return err or "failed on another rank"
|
|
782
|
+
digests = programs.side_digests(files)
|
|
783
|
+
# an analysis stopped for changing training stays stopped until analysis.py (or a module it imports) changes
|
|
784
|
+
off = not first and getattr(self, "_analysis_off", False) and digests[1] == self.digests[1]
|
|
785
|
+
self.files, self.version, self.digests = files, version, digests
|
|
786
|
+
self.analysis, self.every, self._analysis_off = an, every, off
|
|
787
|
+
tag = f"r{self.rank}v{version}"
|
|
788
|
+
self._analysis_tag = tag
|
|
789
|
+
self._program_tags.add(tag)
|
|
790
|
+
if updater:
|
|
791
|
+
old = self.updater
|
|
792
|
+
self.updater, self._hypers = up, hypers # param_groups follows the updater (the property below)
|
|
793
|
+
self._updater_tag = tag
|
|
794
|
+
self._step_ctx, self._skips_in_a_row, self._ctx_any = takes_ctx(up), 0, None
|
|
795
|
+
if swap:
|
|
796
|
+
self._build_changes = self._changed_settings(before) # what build() set on the user's groups
|
|
797
|
+
if handover:
|
|
798
|
+
self._schedule_handover(old, up, before, self._build_changes)
|
|
799
|
+
self._install_rec = dict(before=before, ledger=ledger, calls=[], user_dtypes=user_dtypes)
|
|
800
|
+
if not first:
|
|
801
|
+
self._next_analysis = min(self._next_analysis, self.update + every)
|
|
802
|
+
if self.rank0:
|
|
803
|
+
(self.run.dir / "FINAL").write_text(f"v{version:03d}\n")
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
def _forget_retired(self):
|
|
807
|
+
"""Drop from sys.modules the modules of the versions that can no longer run (all but the running updater and
|
|
808
|
+
analysis and the fallback updater of a trial window), so that what their module globals hold is freed."""
|
|
809
|
+
keep = {self._updater_tag, self._analysis_tag, self._prev_tag if self._prev_updater is not None else None}
|
|
810
|
+
for tag in self._program_tags - keep:
|
|
811
|
+
programs.forget(tag)
|
|
812
|
+
self._program_tags &= keep
|
|
813
|
+
|
|
814
|
+
def _apply(self, resp, u):
|
|
815
|
+
"""Swap in an agent's answer at the start of update u (every rank calls this with the same resp)."""
|
|
816
|
+
if not resp:
|
|
817
|
+
return
|
|
818
|
+
k = resp.get("k")
|
|
819
|
+
rec = dict(k=k, requested_at=resp.get("requested_at"), applied_at=u)
|
|
820
|
+
rec.update({x: True for x in ("gave_up", "late") if resp.get(x)}) # backend stopped waiting / answered after
|
|
821
|
+
if resp.get("backend_error") is not None:
|
|
822
|
+
rec["failed"] = True
|
|
823
|
+
if self._auto is not None and self.rank0:
|
|
824
|
+
self._auto.answered(resp)
|
|
825
|
+
for e in resp.get("errors") or []:
|
|
826
|
+
self.errors.append(dict(update=u, where=f"your answer to decision {k} (previous programs kept)", error=e))
|
|
827
|
+
if resp.get("backend_error") is not None:
|
|
828
|
+
err = str(resp["backend_error"]) or 'the agent call failed'
|
|
829
|
+
self.errors.append(dict(update=u, where=f"agent backend (decision {k})", error=err[-1500:]))
|
|
830
|
+
self._log(f"decision {k}: the agent backend failed: {err[:300]}")
|
|
831
|
+
if resp.get("errors"):
|
|
832
|
+
return self._outcome("rejected", rec, resp)
|
|
833
|
+
agent, base = resp.get("files"), resp.get("base") or {}
|
|
834
|
+
if not agent:
|
|
835
|
+
return self._outcome("kept", rec, resp)
|
|
836
|
+
# three-way merge at file level: apply only what the agent changed relative to the files it was shown, so an
|
|
837
|
+
# answer computed before a revert does not bring the reverted program back
|
|
838
|
+
files = dict(self.files)
|
|
839
|
+
for name in set(agent) | set(base):
|
|
840
|
+
if agent.get(name) != base.get(name):
|
|
841
|
+
if name in agent:
|
|
842
|
+
files[name] = agent[name]
|
|
843
|
+
else:
|
|
844
|
+
files.pop(name, None)
|
|
845
|
+
files = programs.program(files)
|
|
846
|
+
up_d, an_d = programs.side_digests(files)
|
|
847
|
+
if (up_d, an_d) == self.digests:
|
|
848
|
+
return self._outcome("kept", rec, resp)
|
|
849
|
+
rank_equal = False
|
|
850
|
+
if self.dist and up_d != self.digests[0]:
|
|
851
|
+
version = self.version
|
|
852
|
+
rank_equal = not self._check_ranks(u - 1) # before build() can change shared parameters or state
|
|
853
|
+
if self.version != version: # the outgoing trial failed; merge the answer against the fallback instead
|
|
854
|
+
return self._apply(resp, u)
|
|
855
|
+
changed = sorted(n for n in set(files) | set(self.files) if files.get(n) != self.files.get(n))
|
|
856
|
+
t0 = time.time()
|
|
857
|
+
old_files, old_updater, old_tag = self.files, self.updater, self._updater_tag
|
|
858
|
+
new_updater = up_d != self.digests[0]
|
|
859
|
+
# the old updater is still in its trial window: the fallback stays the program before it, which passed one,
|
|
860
|
+
# with its group settings and the record of the user's schedule since it ran
|
|
861
|
+
held = (self._hypers, self._install_rec) if new_updater and u <= self._trial_until else None
|
|
862
|
+
# taken before build(), so that a failing build or first update is undone exactly (misc/recovery.py)
|
|
863
|
+
snap = self._take_snapshot() if new_updater else None
|
|
864
|
+
err = self._install(files, previous=old_updater, updater=new_updater)
|
|
865
|
+
if err:
|
|
866
|
+
if isinstance(snap, dict):
|
|
867
|
+
self._put_back_placement()
|
|
868
|
+
self._restore_snapshot(snap, old_updater)
|
|
869
|
+
self.errors.append(dict(update=u, where="loading your new programs (previous programs kept)", error=err))
|
|
870
|
+
return self._outcome("load_failed", rec, resp, "new programs failed to load; previous kept", error=err)
|
|
871
|
+
self._last_applied = rec
|
|
872
|
+
if new_updater: # trial window: a failure in it goes back to the old updater, the first update exactly
|
|
873
|
+
self._timing["program_ms"], self._events = None, None
|
|
874
|
+
if held is None:
|
|
875
|
+
self._prev_files, self._prev_updater, self._prev_tag = old_files, old_updater, old_tag
|
|
876
|
+
self._rank_snapshot, self._rank_trial_equal = (snap, rank_equal) if self.dist else (None, False)
|
|
877
|
+
else: # the old updater is dropped (it never passed its trial window); the fallback stays
|
|
878
|
+
self._rank_trial_equal = self._rank_trial_equal and rank_equal
|
|
879
|
+
self._hypers, self._install_rec = held
|
|
880
|
+
if self._first_pending and isinstance(self._snapshot, dict):
|
|
881
|
+
snap = self._snapshot # the old updater never stepped: the copy taken before it undoes both builds
|
|
882
|
+
elif isinstance(snap, dict):
|
|
883
|
+
snap["copies"]["old"] = None # the old updater's state, which is not the fallback's
|
|
884
|
+
self._trial_until, self._snapshot, self._first_pending = u + self.trial_steps, snap, True
|
|
885
|
+
self._timing["audit"], self._audit_at = None, u + 1
|
|
886
|
+
self._forget_retired()
|
|
887
|
+
note = (
|
|
888
|
+
f"applied at update {u} as v{self.version:03d} (requested at {rec['requested_at']}): {', '.join(changed)}"
|
|
889
|
+
)
|
|
890
|
+
self._outcome(
|
|
891
|
+
"applied",
|
|
892
|
+
rec,
|
|
893
|
+
resp,
|
|
894
|
+
note,
|
|
895
|
+
version=self.version,
|
|
896
|
+
changed=changed,
|
|
897
|
+
rebuilt_updater=new_updater,
|
|
898
|
+
swap_seconds=round(time.time() - t0, 3),
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
def _outcome(self, kind, rec, resp, note=None, **extra):
|
|
902
|
+
# what became of this answer: the next message says it in one line (agent.previous_answer)
|
|
903
|
+
self._last_answer = dict(rec, outcome=kind, **{x: resp.get(x) for x in ("backend_error", "seconds")})
|
|
904
|
+
if self.rank0:
|
|
905
|
+
lag = None if rec.get("applied_at") is None else rec["applied_at"] - (rec.get("requested_at") or 0)
|
|
906
|
+
waited = resp.get("unavailable_seconds") # waiting for an unavailable agent, apart from its own time
|
|
907
|
+
self.run.event(
|
|
908
|
+
kind,
|
|
909
|
+
**rec,
|
|
910
|
+
**extra,
|
|
911
|
+
lag_updates=lag,
|
|
912
|
+
seconds=resp.get("seconds"),
|
|
913
|
+
cost_usd=resp.get("cost_usd"),
|
|
914
|
+
**({"unavailable_seconds": waited} if waited else {}),
|
|
915
|
+
)
|
|
916
|
+
cost = resp.get("cost_usd") or 0
|
|
917
|
+
cost_text = "USD unavailable" if "usage" in resp and resp.get("cost_usd") is None else f"${cost:.2f}"
|
|
918
|
+
then = f", after {waited:.0f} s waiting for the agent to be available" if waited else ""
|
|
919
|
+
self._log(f"decision {rec['k']}: {note or 'programs ' + kind} ({resp.get('seconds')} s{then}, {cost_text})")
|
|
920
|
+
if self.worker is not None:
|
|
921
|
+
self._cost_reached() # said as soon as an answer spends the rest
|
|
922
|
+
self._live.refresh() # only queued: the report is written in its own thread
|
|
923
|
+
|
|
924
|
+
def _after_decision(self, k, req, resp):
|
|
925
|
+
"""In the agent's thread, once a decision's response is recorded: queue the summary of a proposed code change
|
|
926
|
+
(when there is a summarizer) and a new report."""
|
|
927
|
+
if resp.get("files"):
|
|
928
|
+
self._live.summarize(k, resp.get("text"), req["files"], resp["files"], self.total)
|
|
929
|
+
if self._closed:
|
|
930
|
+
self.run.event(
|
|
931
|
+
"cost_settled", k=k,
|
|
932
|
+
cost_usd=self.worker.recorded_cost,
|
|
933
|
+
unknown_cost_decisions=sorted(self.worker.unknown_cost_decisions),
|
|
934
|
+
)
|
|
935
|
+
self._live.refresh()
|
|
936
|
+
|
|
937
|
+
# ----- the update -------------------------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
def _parameter_ids(self):
|
|
940
|
+
return tuple(frozenset(id(p) for g in o.param_groups for p in g["params"]) for o in self.user_optimizers)
|
|
941
|
+
|
|
942
|
+
def _check_user_parameters(self):
|
|
943
|
+
if self._parameter_ids() != self._user_parameter_ids:
|
|
944
|
+
raise RuntimeError(
|
|
945
|
+
"opal: the raw optimizer's parameter set changed after wrapping; "
|
|
946
|
+
"create all parameter groups before opal.wrap"
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
@property
|
|
950
|
+
def _step_supports_amp_scaling(self):
|
|
951
|
+
# GradScaler queries this before unscaling or deciding whether to skip the wrapper's step.
|
|
952
|
+
if len(self.user_optimizers) > 1:
|
|
953
|
+
raise RuntimeError(
|
|
954
|
+
"opal: multiple optimizers with GradScaler are unsupported: "
|
|
955
|
+
"a combined step cannot preserve each optimizer's independent overflow handling; "
|
|
956
|
+
"use a single optimizer or disable gradient scaling"
|
|
957
|
+
)
|
|
958
|
+
self._check_user_parameters()
|
|
959
|
+
return False
|
|
960
|
+
|
|
961
|
+
def observe(self, loss):
|
|
962
|
+
"""Record this update's loss when a framework calls step() for you (Accelerate, Lightning, HF Trainer). Called
|
|
963
|
+
before step(), it is also the loss a step(grads, update, ctx) program gets as ctx.loss."""
|
|
964
|
+
self._push_loss(loss)
|
|
965
|
+
self._observed_loss = loss
|
|
966
|
+
|
|
967
|
+
def _push_loss(self, loss):
|
|
968
|
+
if torch.is_tensor(loss):
|
|
969
|
+
v = loss.detach()
|
|
970
|
+
if v.numel() != 1:
|
|
971
|
+
self._warn("loss-vector", f"the loss passed to step() has {v.numel()} elements; its mean is used")
|
|
972
|
+
v = v.float().mean()
|
|
973
|
+
if self._loss_n < self._loss_buf.numel():
|
|
974
|
+
self._loss_buf[self._loss_n].copy_(v.reshape(()).float()) # async, CUDA-graph safe
|
|
975
|
+
self._loss_n += 1
|
|
976
|
+
else:
|
|
977
|
+
self._float_losses.append((self._loss_n, float(loss)))
|
|
978
|
+
|
|
979
|
+
@torch.no_grad()
|
|
980
|
+
def step(self, closure=None, loss=None):
|
|
981
|
+
Join.notify_join_context(self)
|
|
982
|
+
self._check_join_inputs()
|
|
983
|
+
self._check_user_parameters()
|
|
984
|
+
if closure is not None:
|
|
985
|
+
with torch.enable_grad():
|
|
986
|
+
loss = closure()
|
|
987
|
+
if loss is None and self._observed_loss is None and self._loss_hook is not None:
|
|
988
|
+
loss = self._loss_hook() # opal/hf.py: the update's loss, which the Trainer does not pass to step()
|
|
989
|
+
if not getattr(self, "_announced", False):
|
|
990
|
+
self._announce()
|
|
991
|
+
if getattr(self.step, "_wrapped_by_lr_sched", False):
|
|
992
|
+
self._warn(
|
|
993
|
+
"sched-on-wrapper",
|
|
994
|
+
"a scheduler was built on the wrapper itself; pass your scheduler to wrap() "
|
|
995
|
+
"instead, or it will overwrite the learning rates the agent's program sets",
|
|
996
|
+
)
|
|
997
|
+
if self._resume_pending is not None:
|
|
998
|
+
self._finish_resume()
|
|
999
|
+
self.update += 1
|
|
1000
|
+
u = self.update
|
|
1001
|
+
if self._estimate_due and self._t_mark is None and self._steps_here == 1:
|
|
1002
|
+
# The second update: warm-up is behind; agent waiting is not training time.
|
|
1003
|
+
self._t_mark = (u, time.perf_counter() - self.agent_wait_seconds)
|
|
1004
|
+
self._steps_here += 1
|
|
1005
|
+
if u == self.total + 1:
|
|
1006
|
+
self._warn(
|
|
1007
|
+
"past-total",
|
|
1008
|
+
f"step() called past total_steps={self.total}; programs written as functions of "
|
|
1009
|
+
"update / total_updates may misbehave from here (check total_steps)",
|
|
1010
|
+
)
|
|
1011
|
+
self._maybe_swap(u)
|
|
1012
|
+
self._step_loss = loss if loss is not None else self._observed_loss
|
|
1013
|
+
self._observed_loss = None
|
|
1014
|
+
if loss is not None:
|
|
1015
|
+
self._push_loss(loss)
|
|
1016
|
+
analyze = u >= self._next_analysis or u == self.total
|
|
1017
|
+
if analyze or u % self.default_every == 0:
|
|
1018
|
+
self._at_check(u, analyze)
|
|
1019
|
+
self._do_update(u)
|
|
1020
|
+
if self._integrity is not None:
|
|
1021
|
+
self._integrity_record(u)
|
|
1022
|
+
if self.dist and self.rank_check_every and self._rank_checked_at != u and (
|
|
1023
|
+
u % self.rank_check_every == 0 or u == self.total
|
|
1024
|
+
):
|
|
1025
|
+
self._check_ranks(u)
|
|
1026
|
+
self._capture_next = self.rank0 and bool(self._hooks) and u + 1 >= self._next_analysis
|
|
1027
|
+
if u == self.total:
|
|
1028
|
+
self.close()
|
|
1029
|
+
return loss
|
|
1030
|
+
|
|
1031
|
+
def _do_update(self, u):
|
|
1032
|
+
grads = {n: p.grad for n, p in self.named}
|
|
1033
|
+
trial = u <= self._trial_until
|
|
1034
|
+
first = trial and self._first_pending
|
|
1035
|
+
profile = self.rank0 and u == self._audit_at
|
|
1036
|
+
# one update per check, and the run's first updates, so that the starting program is measured before any swap
|
|
1037
|
+
measure = ((u - 1) % self.default_every == 0 or u <= 3) and not profile
|
|
1038
|
+
if measure:
|
|
1039
|
+
if self.device.type == "cuda":
|
|
1040
|
+
ev = (torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True))
|
|
1041
|
+
ev[0].record()
|
|
1042
|
+
else:
|
|
1043
|
+
if self.device.type == "mps":
|
|
1044
|
+
torch.mps.synchronize() # asynchronous like CUDA: time the work, not the launches
|
|
1045
|
+
t0 = time.perf_counter()
|
|
1046
|
+
err = None
|
|
1047
|
+
self._in_updater = True
|
|
1048
|
+
try:
|
|
1049
|
+
if profile:
|
|
1050
|
+
self._record_audit(audit.audited(lambda: self._call_updater(grads, u), self.device), u)
|
|
1051
|
+
else:
|
|
1052
|
+
self._call_updater(grads, u)
|
|
1053
|
+
except programs.AGENT_ERRORS:
|
|
1054
|
+
err = traceback.format_exc(limit=6)[-3000:]
|
|
1055
|
+
finally:
|
|
1056
|
+
self._in_updater = False
|
|
1057
|
+
if err is None: # a changed dtype fails like an exception (misc/precision.py); the state at some updates only
|
|
1058
|
+
err = self._precision_error(grads, first, state=trial or u % self.default_every == 0)
|
|
1059
|
+
failed = (not self._agree(err is None)) if trial else err is not None
|
|
1060
|
+
if failed:
|
|
1061
|
+
self._recover(u, grads, err or "raised on another rank", trial, first)
|
|
1062
|
+
return
|
|
1063
|
+
if self.dist and trial and (first or u == self._trial_until or u == self.total):
|
|
1064
|
+
version = self.version
|
|
1065
|
+
self._check_ranks(u)
|
|
1066
|
+
if self.version != version: # exact rollback; do not step on gradients from the rejected trajectory
|
|
1067
|
+
return
|
|
1068
|
+
if first:
|
|
1069
|
+
self._snapshot, self._first_pending = None, False # the exact undo is only for the first update
|
|
1070
|
+
if trial and u == self._trial_until:
|
|
1071
|
+
self._prev_updater = self._hypers = None # trial passed: release the old updater
|
|
1072
|
+
self._rank_snapshot, self._rank_trial_equal = None, False
|
|
1073
|
+
self._forget_retired()
|
|
1074
|
+
if measure:
|
|
1075
|
+
starting = self.digests[0] == self._starting_digest
|
|
1076
|
+
if self.device.type == "cuda":
|
|
1077
|
+
ev[1].record()
|
|
1078
|
+
self._events = (ev, self.version, u, starting)
|
|
1079
|
+
else:
|
|
1080
|
+
if self.device.type == "mps":
|
|
1081
|
+
torch.mps.synchronize()
|
|
1082
|
+
self._set_program_ms((time.perf_counter() - t0) * 1000, self.version, u, starting)
|
|
1083
|
+
|
|
1084
|
+
def _set_program_ms(self, ms, version, measured_at, starting):
|
|
1085
|
+
"""Updater time per update, measured at one update per check and at the run's first updates. The first
|
|
1086
|
+
measurement of a run includes warm-up and is not kept; the starting program's figure is its latest measurement
|
|
1087
|
+
(also after a revert to it), so while it runs the two agree."""
|
|
1088
|
+
if measured_at <= 1:
|
|
1089
|
+
return
|
|
1090
|
+
if version == self.version:
|
|
1091
|
+
self._timing["program_ms"] = round(ms, 4)
|
|
1092
|
+
if starting:
|
|
1093
|
+
self._timing["program_ms_start"] = round(ms, 4)
|
|
1094
|
+
|
|
1095
|
+
def _record_audit(self, counts, u):
|
|
1096
|
+
self._timing["audit"] = counts
|
|
1097
|
+
if self.digests[0] == self._starting_digest:
|
|
1098
|
+
self._timing["audit_start"] = counts
|
|
1099
|
+
if self.rank0 and counts:
|
|
1100
|
+
self.run.event("audit", update=u, version=self.version, **counts)
|
|
1101
|
+
|
|
1102
|
+
def _read_losses(self):
|
|
1103
|
+
"""This rank's losses since the last check (rank 0's are what the agent sees; no collective)."""
|
|
1104
|
+
n, self._loss_n = self._loss_n, 0
|
|
1105
|
+
floats, self._float_losses = self._float_losses, []
|
|
1106
|
+
if not self.rank0:
|
|
1107
|
+
return []
|
|
1108
|
+
tensors = self._loss_buf[:n].tolist() if n else []
|
|
1109
|
+
if not floats:
|
|
1110
|
+
return tensors
|
|
1111
|
+
losses, start = [], 0
|
|
1112
|
+
for at, value in floats:
|
|
1113
|
+
losses.extend(tensors[start:at])
|
|
1114
|
+
losses.append(value)
|
|
1115
|
+
start = at
|
|
1116
|
+
losses.extend(tensors[start:])
|
|
1117
|
+
return losses
|
|
1118
|
+
|
|
1119
|
+
def _at_check(self, u, analyze):
|
|
1120
|
+
"""Every `every` updates (and whenever the agent's analysis is due): rank 0 reads its losses, watches for a
|
|
1121
|
+
non-finite loss and for scheduled decisions, runs analysis.py when it is due and may request a decision. Ranks
|
|
1122
|
+
learn from rank 0 whether to pause, in one small broadcast."""
|
|
1123
|
+
losses_from = self._losses_from
|
|
1124
|
+
losses = self._read_losses()
|
|
1125
|
+
self._losses_from = u + 1
|
|
1126
|
+
new_episode = False
|
|
1127
|
+
if self.rank0:
|
|
1128
|
+
if not losses and u >= self.default_every:
|
|
1129
|
+
self._warn(
|
|
1130
|
+
"no-loss", "no loss received: call opt.step(loss=loss) (or opt.observe(loss)) so the agent sees it"
|
|
1131
|
+
)
|
|
1132
|
+
nonfinite = any(not math.isfinite(x) for x in losses)
|
|
1133
|
+
new_episode = nonfinite and not self._nonfinite_episode
|
|
1134
|
+
if losses:
|
|
1135
|
+
self._nonfinite_episode = nonfinite
|
|
1136
|
+
now = time.perf_counter()
|
|
1137
|
+
if self._timing["t"] is not None and u > self._timing["u"]: # the pauses for the agent left out
|
|
1138
|
+
busy = max(0.0, now - self._timing["t"] - self._paused_s)
|
|
1139
|
+
self._timing["wall_ms_per_update"] = round(1000 * busy / (u - self._timing["u"]), 2)
|
|
1140
|
+
self._timing["t"], self._timing["u"], self._paused_s = now, u, 0.0
|
|
1141
|
+
if self._auto is not None:
|
|
1142
|
+
self._auto.tick(u, now)
|
|
1143
|
+
if self._events is not None and analyze:
|
|
1144
|
+
(a, b), v, mu, starting = self._events
|
|
1145
|
+
b.synchronize()
|
|
1146
|
+
self._set_program_ms(a.elapsed_time(b), v, mu, starting)
|
|
1147
|
+
self._events = None
|
|
1148
|
+
if self.rank0:
|
|
1149
|
+
self._rank0_check(u, analyze, losses, losses_from, new_episode)
|
|
1150
|
+
else:
|
|
1151
|
+
self._next_analysis = u + self.every if analyze else self._next_analysis
|
|
1152
|
+
pause = self._from_rank0(self.blocking or self._block_now or (new_episode and self.pause_on_nonfinite))
|
|
1153
|
+
if pause:
|
|
1154
|
+
self._blocking_point(u)
|
|
1155
|
+
# a non-finite loss found while an older decision was in flight: ask about it now, and wait again
|
|
1156
|
+
nan_left = self.rank0 and any(r.startswith("non-finite") for r in self._reasons)
|
|
1157
|
+
if nan_left and self.worker is not None and not self.worker.busy and self._budget_left():
|
|
1158
|
+
self._submit(u, pause=True)
|
|
1159
|
+
if self._from_rank0(nan_left):
|
|
1160
|
+
self._blocking_point(u)
|
|
1161
|
+
self._block_now = False
|
|
1162
|
+
|
|
1163
|
+
def _rank0_check(self, u, analyze, losses, losses_from, new_episode):
|
|
1164
|
+
lrs = []
|
|
1165
|
+
for g in self.param_groups:
|
|
1166
|
+
try:
|
|
1167
|
+
lrs.append(float(g.get("lr")))
|
|
1168
|
+
except (TypeError, ValueError, AttributeError):
|
|
1169
|
+
pass
|
|
1170
|
+
self.run.append("losses.jsonl", dict(from_update=losses_from, to_update=u, losses=losses, lr=lrs))
|
|
1171
|
+
new_metrics = [self._plain(m) for m in self._metrics_new]
|
|
1172
|
+
self._metrics_new = []
|
|
1173
|
+
for m in new_metrics:
|
|
1174
|
+
self.run.append("metrics.jsonl", m)
|
|
1175
|
+
self._analysis_losses.extend(losses)
|
|
1176
|
+
self._analysis_metrics.extend(self._plain(m) for m in self._shown_new) # only values shown to the agent
|
|
1177
|
+
self._shown_new = []
|
|
1178
|
+
del self._analysis_losses[:-100_000]
|
|
1179
|
+
wake = self._run_analysis(u) if analyze else None
|
|
1180
|
+
self._pending_losses.extend(losses)
|
|
1181
|
+
dropped = max(0, len(self._pending_losses) - 100_000)
|
|
1182
|
+
self._pending_from += dropped
|
|
1183
|
+
self._pending_omitted += dropped
|
|
1184
|
+
del self._pending_losses[:-100_000]
|
|
1185
|
+
if new_episode:
|
|
1186
|
+
bad = sum(1 for _, p in self.named if not bool(torch.isfinite(p.detach()).all()))
|
|
1187
|
+
self._reasons.append(
|
|
1188
|
+
f"non-finite training loss before update {u:,} ({bad} of {len(self.named)} parameter tensors are "
|
|
1189
|
+
"non-finite now)"
|
|
1190
|
+
)
|
|
1191
|
+
if self._estimate_due and self._t_mark is not None and u - self._t_mark[0] >= 2:
|
|
1192
|
+
self._first_estimate(u)
|
|
1193
|
+
while self._sched_i < len(self.schedule) and u >= self.schedule[self._sched_i]:
|
|
1194
|
+
if "scheduled decision" not in self._reasons:
|
|
1195
|
+
self._reasons.append("scheduled decision")
|
|
1196
|
+
self._sched_i += 1
|
|
1197
|
+
if wake:
|
|
1198
|
+
self._reasons.append(f"{WAKE}{str(wake)[:300]}")
|
|
1199
|
+
if self._skip_alarm:
|
|
1200
|
+
self._skip_alarm = False
|
|
1201
|
+
self._reasons.append("your program kept skipping updates (see errors)")
|
|
1202
|
+
if self._reasons and self.total - u < self.default_every:
|
|
1203
|
+
self._reasons = [] # an answer could no longer act on this run
|
|
1204
|
+
if self.worker is not None:
|
|
1205
|
+
self.worker.collect_late() # an answer that came after the backend stopped waiting, before a newer request
|
|
1206
|
+
if self.worker is None or self.worker.k >= self.max_decisions or (
|
|
1207
|
+
not self.worker.busy and not self._budget_left()
|
|
1208
|
+
):
|
|
1209
|
+
self._reasons = [] # no future call can consume these; keep analysis reports, not unbounded wake strings
|
|
1210
|
+
wakes_only = all(r.startswith(WAKE) for r in self._reasons)
|
|
1211
|
+
if self._reasons and self.worker is not None and not self.worker.busy and self._budget_left(wakes_only):
|
|
1212
|
+
self._submit(u, pause=new_episode and self.pause_on_nonfinite)
|
|
1213
|
+
|
|
1214
|
+
def _first_estimate(self, u):
|
|
1215
|
+
"""At the first check (rank 0): print the estimate of the waiting and the cost from this run's own time per
|
|
1216
|
+
update. With decisions not set, a blocking run keeps the expected waiting within estimate.MAX_WAIT_FACTOR (4)
|
|
1217
|
+
times its own training time by scheduling fewer decisions (at least one), in the same shape."""
|
|
1218
|
+
self._estimate_due, self._estimated = False, True
|
|
1219
|
+
since_u, since_t = self._t_mark
|
|
1220
|
+
sps = max(1e-9, (time.perf_counter() - self.agent_wait_seconds - since_t) / (u - since_u))
|
|
1221
|
+
blocking = "auto" if self._auto is not None else self.blocking
|
|
1222
|
+
max_lag = self._auto.max_lag if self._auto is not None else 50
|
|
1223
|
+
n, capped_from = len(self.schedule), None
|
|
1224
|
+
if self._may_cap and blocking is not False and n > 1:
|
|
1225
|
+
k = estimate.capped_decisions(n, self.total * sps, blocking, sps, max_lag)
|
|
1226
|
+
if k < n or self.worker.k > self._sched_i:
|
|
1227
|
+
capped_from = n
|
|
1228
|
+
if self._sched_i or self.worker.k:
|
|
1229
|
+
# Keep consumed checks; thin only the rest, counting calls already asked (including wake-ups).
|
|
1230
|
+
remaining = self.schedule[self._sched_i :]
|
|
1231
|
+
left = min(len(remaining), max(0, k - self.worker.k))
|
|
1232
|
+
keep = set(self.schedule[: self._sched_i])
|
|
1233
|
+
keep.update(remaining[i * len(remaining) // left] for i in range(left))
|
|
1234
|
+
segments = [[x for x in seg if x in keep] for seg in self._schedule_segments]
|
|
1235
|
+
else:
|
|
1236
|
+
segments = decision_schedule.decision_segments(self.total, k, self.default_every, self._schedule_kind)
|
|
1237
|
+
# A one-decision uniform grid lands at the excluded final update. Keep a usable original check.
|
|
1238
|
+
self._set_schedule(segments if any(segments) else [self.schedule[:1]])
|
|
1239
|
+
be = self.worker.backend
|
|
1240
|
+
mailbox = getattr(be, "name", None) == "mailbox"
|
|
1241
|
+
model = estimate.MODEL if mailbox else getattr(be, "model", None) or "the CLI's default model"
|
|
1242
|
+
effort = estimate.EFFORT if mailbox else getattr(be, "effort", None) or "default"
|
|
1243
|
+
said = estimate.lines(
|
|
1244
|
+
self.total, sps, len(self.schedule), blocking, max_lag, model, effort, mailbox, capped_from,
|
|
1245
|
+
explicit=not self._may_cap,
|
|
1246
|
+
) # fmt: skip
|
|
1247
|
+
self._log(
|
|
1248
|
+
f"estimate from this run's updates {since_u:,} to {u - 1:,} ({sps:.3g} s each) and our runs of "
|
|
1249
|
+
f"{estimate.MODEL} at effort {estimate.EFFORT}:"
|
|
1250
|
+
)
|
|
1251
|
+
for line in said:
|
|
1252
|
+
self._log(" " + line)
|
|
1253
|
+
self.run.event(
|
|
1254
|
+
"estimate",
|
|
1255
|
+
update=u,
|
|
1256
|
+
sec_per_update=round(sps, 6),
|
|
1257
|
+
decisions=len(self.schedule),
|
|
1258
|
+
**(dict(capped_from=capped_from, schedule=self.schedule) if capped_from is not None else {}),
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
def _set_schedule(self, segments):
|
|
1262
|
+
"""Replace the scheduled decisions (the first check's cap, or a resume of a capped run) and write the prompt
|
|
1263
|
+
again, which states them."""
|
|
1264
|
+
self._schedule_segments, self._schedule_changed = segments, True
|
|
1265
|
+
self.schedule = [x for seg in segments for x in seg]
|
|
1266
|
+
if not self._max_decisions_given:
|
|
1267
|
+
self.max_decisions = 2 * len(self.schedule)
|
|
1268
|
+
if self.worker is not None:
|
|
1269
|
+
self.worker.system = self._system_prompt(self._prompt_name, self._names_by_id)
|
|
1270
|
+
(self.run.dir / "system.md").write_text(self.worker.system)
|
|
1271
|
+
|
|
1272
|
+
def _run_analysis(self, u):
|
|
1273
|
+
"""Call the agent's analysis.analyze(obs, memory); keep its report, return its wake request (if any)."""
|
|
1274
|
+
self._next_analysis = u + self.every
|
|
1275
|
+
wake = None
|
|
1276
|
+
if self.analysis is not None and not self._analysis_off:
|
|
1277
|
+
obs = types.SimpleNamespace(
|
|
1278
|
+
update=u,
|
|
1279
|
+
total_updates=self.total,
|
|
1280
|
+
losses=self._analysis_losses,
|
|
1281
|
+
params={n: p for n, p in self.named},
|
|
1282
|
+
grads={n: p.grad for n, p in self.named},
|
|
1283
|
+
updater=self.updater,
|
|
1284
|
+
metrics=self._analysis_metrics,
|
|
1285
|
+
program_ms_per_update=self._timing["program_ms"],
|
|
1286
|
+
wall_ms_per_update=self._timing["wall_ms_per_update"],
|
|
1287
|
+
activations=self.activations,
|
|
1288
|
+
)
|
|
1289
|
+
marks = self._analysis_marks()
|
|
1290
|
+
t0 = time.perf_counter()
|
|
1291
|
+
try:
|
|
1292
|
+
res = self.analysis.analyze(obs, self.memory) or {}
|
|
1293
|
+
except programs.AGENT_ERRORS:
|
|
1294
|
+
self.errors.append(
|
|
1295
|
+
dict(update=u, where="analysis.analyze", error=traceback.format_exc(limit=6)[-3000:])
|
|
1296
|
+
)
|
|
1297
|
+
res = {}
|
|
1298
|
+
self._timing["analysis_ms"] = round(1000 * (time.perf_counter() - t0), 2)
|
|
1299
|
+
touched = self._analysis_changes(marks)
|
|
1300
|
+
if touched: # it changed training; it cannot be undone, so it is stopped and the agent told
|
|
1301
|
+
self._put_back_placement()
|
|
1302
|
+
self._analysis_off = True
|
|
1303
|
+
self.errors.append(
|
|
1304
|
+
dict(
|
|
1305
|
+
update=u,
|
|
1306
|
+
where="analysis.analyze (not run again until analysis.py changes)",
|
|
1307
|
+
error="analysis.analyze changed parameters or gradients (in place, or their dtype or device), "
|
|
1308
|
+
"which it may not do: " + ", ".join(touched[:8]) + (f" ({len(touched)} in all)" if len(touched) > 8 else ""),
|
|
1309
|
+
)
|
|
1310
|
+
)
|
|
1311
|
+
if isinstance(res, dict):
|
|
1312
|
+
if "report" in res:
|
|
1313
|
+
bad = []
|
|
1314
|
+
report = _plain_json(res["report"], bad) # converted now, not when the message is written
|
|
1315
|
+
if bad:
|
|
1316
|
+
self.errors.append(
|
|
1317
|
+
dict(
|
|
1318
|
+
update=u,
|
|
1319
|
+
where="analysis.analyze report",
|
|
1320
|
+
error="values that are not JSON were stored as text: " + ", ".join(sorted(set(bad))[:8]),
|
|
1321
|
+
)
|
|
1322
|
+
)
|
|
1323
|
+
self.reports.append(dict(update=u, report=report))
|
|
1324
|
+
self._reports_made += 1
|
|
1325
|
+
self.run.append("reports.jsonl", dict(update=u, version=self.version, report=report))
|
|
1326
|
+
wake = res.get("wake")
|
|
1327
|
+
self._analysis_losses, self._analysis_metrics = [], []
|
|
1328
|
+
self.activations = {}
|
|
1329
|
+
return wake
|
|
1330
|
+
|
|
1331
|
+
def _budget_left(self, wakes_only=False):
|
|
1332
|
+
"""Whether one more decision may be asked. Wake-ups of the analysis alone may not use the decisions the
|
|
1333
|
+
remaining scheduled ones need."""
|
|
1334
|
+
left = self.max_decisions - self.worker.k
|
|
1335
|
+
if left <= 0 or (wakes_only and left <= len(self.schedule) - self._sched_i):
|
|
1336
|
+
return False
|
|
1337
|
+
return not self._cost_reached()
|
|
1338
|
+
|
|
1339
|
+
def _cost_reached(self):
|
|
1340
|
+
"""True once max_cost_usd cannot cover another estimated call; says so on stdout the first time."""
|
|
1341
|
+
if self.max_cost is None:
|
|
1342
|
+
return False
|
|
1343
|
+
left = self.max_cost - self.worker.cost
|
|
1344
|
+
needed = max(0.01, self.worker.cost / max(1, self.worker.k))
|
|
1345
|
+
if left >= needed:
|
|
1346
|
+
return False
|
|
1347
|
+
if not self._cost_said:
|
|
1348
|
+
self._cost_said = True
|
|
1349
|
+
why = f", ${left:.3g} remaining is below the ${needed:.3g} call estimate" if left > 0 else ""
|
|
1350
|
+
self._notice(
|
|
1351
|
+
f"max_cost_usd reached (${self.worker.cost:.2f} spent, cap ${self.max_cost:.2f}{why}); training continues "
|
|
1352
|
+
"with the current programs, no more agent calls."
|
|
1353
|
+
)
|
|
1354
|
+
return True
|
|
1355
|
+
|
|
1356
|
+
def _submit(self, u, pause=False):
|
|
1357
|
+
"""Ask a decision at update u. pause: training waits for it whatever the waiting mode (a non-finite loss)."""
|
|
1358
|
+
inbox = dict(
|
|
1359
|
+
update=u,
|
|
1360
|
+
losses=self._pending_losses,
|
|
1361
|
+
losses_omitted=self._pending_omitted,
|
|
1362
|
+
losses_from=self._pending_from,
|
|
1363
|
+
reports=list(self.reports),
|
|
1364
|
+
reports_made=self._reports_made,
|
|
1365
|
+
errors=list(self.errors),
|
|
1366
|
+
metrics=self._metrics_since(consume=True),
|
|
1367
|
+
skipped=list(self._skipped),
|
|
1368
|
+
timing=dict(self._timing, t=None),
|
|
1369
|
+
**({"development": list(self._dev)} if self._validation else {}),
|
|
1370
|
+
)
|
|
1371
|
+
notes = self.run.new_notes()
|
|
1372
|
+
if self._resume_note:
|
|
1373
|
+
notes, self._resume_note = ((self._resume_note + (" " + notes if notes else "")), "")
|
|
1374
|
+
wait = self._auto.decide() if self._auto is not None else {} # blocking="auto": wait at this decision?
|
|
1375
|
+
self._block_now = wait.get("wait") == "waited"
|
|
1376
|
+
left = self.max_decisions - (self.worker.k + 1) # decisions that may still be asked after this one
|
|
1377
|
+
upcoming = self.schedule[self._sched_i :][: max(0, left)]
|
|
1378
|
+
if self.max_cost is not None: # what the run may still spend, for a backend that can cap one call
|
|
1379
|
+
self.worker.backend.budget_left = max(0.0, self.max_cost - self.worker.cost)
|
|
1380
|
+
req = dict(
|
|
1381
|
+
update=u,
|
|
1382
|
+
total_updates=self.total,
|
|
1383
|
+
blocking=self.blocking or self._block_now or pause,
|
|
1384
|
+
reasons=_reasons_shown(self._reasons),
|
|
1385
|
+
last_applied=self._last_applied,
|
|
1386
|
+
previous=self._last_answer,
|
|
1387
|
+
files=self.files,
|
|
1388
|
+
inbox=inbox,
|
|
1389
|
+
timing=self._timing,
|
|
1390
|
+
notes=notes,
|
|
1391
|
+
scheduled_left=len(upcoming),
|
|
1392
|
+
**({"last": True} if left <= 0 else {}),
|
|
1393
|
+
**({"next_decision": next(iter(upcoming), None)} if self._show_next else {}),
|
|
1394
|
+
**({"neutral": True} if self._neutral else {}),
|
|
1395
|
+
**({"aggressive": True} if self._aggressive else {}),
|
|
1396
|
+
**({"epochs": self._run_facts["epochs"]} if isinstance(self._run_facts["epochs"], (int, float)) else {}),
|
|
1397
|
+
**({"development": list(self._dev)} if self._validation else {}),
|
|
1398
|
+
shared_code=self._share_code,
|
|
1399
|
+
script_shared=getattr(self, "_script_copied", False),
|
|
1400
|
+
cost_so_far=self.worker.cost,
|
|
1401
|
+
)
|
|
1402
|
+
k = self.worker.submit(req)
|
|
1403
|
+
self.run.event("requested", k=k, update=u, reasons=req["reasons"], **wait)
|
|
1404
|
+
if getattr(self.worker.backend, "name", None) != "keep": # keep answers at once: no line to wait on
|
|
1405
|
+
then = "training waits for the answer" if req["blocking"] else "training continues meanwhile"
|
|
1406
|
+
self._log(f"decision {k}: sent to the agent at update {u} ({then})")
|
|
1407
|
+
self.reports.clear()
|
|
1408
|
+
self.errors.clear()
|
|
1409
|
+
self._reasons, self._last_applied, self._last_answer = [], None, None
|
|
1410
|
+
self._pending_losses, self._pending_from = [], u + 1
|
|
1411
|
+
self._pending_omitted = 0
|
|
1412
|
+
self._skipped, self._reports_made = [], 0
|
|
1413
|
+
|
|
1414
|
+
def _blocking_point(self, u):
|
|
1415
|
+
"""Training waits here (all ranks) for the decision in flight, then applies it before update u's step."""
|
|
1416
|
+
busy = self._from_rank0(self.rank0 and self.worker is not None and self.worker.busy)
|
|
1417
|
+
if not busy:
|
|
1418
|
+
return
|
|
1419
|
+
resp = None
|
|
1420
|
+
if self.rank0:
|
|
1421
|
+
t0 = time.perf_counter()
|
|
1422
|
+
self.worker.wait()
|
|
1423
|
+
resp = self.worker.take()
|
|
1424
|
+
self._paused_s += time.perf_counter() - t0
|
|
1425
|
+
self.agent_wait_seconds += time.perf_counter() - t0
|
|
1426
|
+
if self._auto is not None:
|
|
1427
|
+
self._auto.paused(time.perf_counter() - t0)
|
|
1428
|
+
if self.dist:
|
|
1429
|
+
box = [resp]
|
|
1430
|
+
dist.broadcast_object_list(box, src=0, group=self.ctrl)
|
|
1431
|
+
resp = box[0]
|
|
1432
|
+
self._apply(resp, u)
|
|
1433
|
+
|
|
1434
|
+
def _maybe_swap(self, u):
|
|
1435
|
+
"""Non-blocking swaps happen at update boundaries. One process: as soon as the answer is there. DDP: rank 0
|
|
1436
|
+
announces readiness in an asynchronous broadcast every sync_every updates; every rank reads the announcement
|
|
1437
|
+
one interval later, so no rank waits for the host or the network in between."""
|
|
1438
|
+
if not self.dist:
|
|
1439
|
+
if self.worker is not None and self.worker.ready is not None:
|
|
1440
|
+
self._apply(self.worker.take(), u)
|
|
1441
|
+
return
|
|
1442
|
+
if u % self.sync_every:
|
|
1443
|
+
return
|
|
1444
|
+
if self._flag_work is not None:
|
|
1445
|
+
self._flag_work.wait()
|
|
1446
|
+
if int(self._flag_buf.item()):
|
|
1447
|
+
box = [self.worker.take() if self.rank0 and self.worker is not None else None]
|
|
1448
|
+
dist.broadcast_object_list(box, src=0, group=self.ctrl)
|
|
1449
|
+
self._apply(box[0], u)
|
|
1450
|
+
ready = self.rank0 and self.worker is not None and self.worker.ready is not None
|
|
1451
|
+
self._flag_buf = torch.tensor([1 if ready else 0])
|
|
1452
|
+
self._flag_work = dist.broadcast(self._flag_buf, src=0, group=self.ctrl, async_op=True)
|
|
1453
|
+
|
|
1454
|
+
# ----- user-facing extras -------------------------------------------------------------------------------------
|
|
1455
|
+
|
|
1456
|
+
def log(self, values=None, *, show_agent=False, **kw):
|
|
1457
|
+
"""Record values (validation loss, accuracy, ...) in metrics.jsonl and the report: opt.log(test_acc=a), or a
|
|
1458
|
+
dict as in wandb, opt.log({"test_acc": a}). The agent does not see them
|
|
1459
|
+
unless show_agent=True: then its next analysis call sees them in obs.metrics and its next decision message lists
|
|
1460
|
+
them, and it may tune the run to them (show only a split you accept that for, never the test set). Scalars only
|
|
1461
|
+
(numbers or one-element tensors); tensors are read on the host at the next check step, not here. Only rank 0's
|
|
1462
|
+
values are kept."""
|
|
1463
|
+
if not self.rank0:
|
|
1464
|
+
return
|
|
1465
|
+
if values is not None and not isinstance(values, dict):
|
|
1466
|
+
raise TypeError("opt.log takes a dict of values or keyword values, e.g. opt.log(test_acc=a)")
|
|
1467
|
+
values = dict(values or {}, **kw)
|
|
1468
|
+
rec = dict(update=self.update)
|
|
1469
|
+
for k, v in values.items():
|
|
1470
|
+
if torch.is_tensor(v):
|
|
1471
|
+
if v.numel() != 1:
|
|
1472
|
+
self._warn(f"log-{k}", f"opt.log: {k} has {v.numel()} elements; only scalars are kept")
|
|
1473
|
+
continue
|
|
1474
|
+
rec[k] = v.detach()
|
|
1475
|
+
elif isinstance(v, (int, float, np.integer, np.floating)) and not isinstance(v, bool):
|
|
1476
|
+
rec[k] = int(v) if isinstance(v, (int, np.integer)) else float(v)
|
|
1477
|
+
else:
|
|
1478
|
+
self._warn(f"log-{k}", f"opt.log: {k} is {type(v).__name__}; only numbers are kept")
|
|
1479
|
+
if len(rec) == 1:
|
|
1480
|
+
self._warn("log-empty", "opt.log was called with no number to keep; nothing was recorded")
|
|
1481
|
+
return
|
|
1482
|
+
if self._closed: # e.g. the final evaluation after the last update: keep it in the records and the report
|
|
1483
|
+
self.run.append("metrics.jsonl", self._plain(rec))
|
|
1484
|
+
self._live.refresh()
|
|
1485
|
+
return
|
|
1486
|
+
self._metrics_new.append(rec)
|
|
1487
|
+
if show_agent:
|
|
1488
|
+
self.metrics.append(rec)
|
|
1489
|
+
self._shown_new.append(rec)
|
|
1490
|
+
if len(self.metrics) > 1000:
|
|
1491
|
+
self.metrics = self.metrics[-1000:]
|
|
1492
|
+
|
|
1493
|
+
def report_dev(self, loss=None, accuracy=None, update=None):
|
|
1494
|
+
"""OPAL 0.1.1-validation: report one evaluation on the development set (held out from training; never the
|
|
1495
|
+
test set the run is judged by). With wrap(validation=N) the agent sees it in obs.metrics (as dev_loss and
|
|
1496
|
+
dev_accuracy) and, with the history of earlier measurements, in each decision message; it is also kept in
|
|
1497
|
+
metrics.jsonl. Without validation= it is only recorded. update defaults to the number of updates applied so
|
|
1498
|
+
far. Values are read on the host here (a development evaluation synchronises anyway). Rank 0 only.
|
|
1499
|
+
|
|
1500
|
+
Call it right after evaluating the parameters as they are after the latest update: with eval_integrity on,
|
|
1501
|
+
it opens the check of that evaluation (misc/integrity.py)."""
|
|
1502
|
+
if not self.rank0:
|
|
1503
|
+
return
|
|
1504
|
+
if self._integrity is not None and not self._closed:
|
|
1505
|
+
self._integrity.open(self.update, version=self.version)
|
|
1506
|
+
rec = dict(update=int(self.update if update is None else update))
|
|
1507
|
+
for key, v in (("loss", loss), ("accuracy", accuracy)):
|
|
1508
|
+
if v is not None:
|
|
1509
|
+
rec[key] = float(v.detach().float().mean()) if torch.is_tensor(v) else float(v)
|
|
1510
|
+
shown = dict(update=rec["update"], **{f"dev_{k}": v for k, v in rec.items() if k != "update"})
|
|
1511
|
+
if not self._validation:
|
|
1512
|
+
self._warn("dev-off", "opt.report_dev without wrap(validation=N): recorded only; the agent does not see it")
|
|
1513
|
+
self.log(**{k: v for k, v in shown.items() if k != "update"})
|
|
1514
|
+
return
|
|
1515
|
+
if self._closed:
|
|
1516
|
+
self.run.append("metrics.jsonl", shown)
|
|
1517
|
+
return
|
|
1518
|
+
gaps = [b["update"] - a["update"] for a, b in zip(self._dev[-3:], self._dev[-2:] + [rec])]
|
|
1519
|
+
if len(gaps) == 3 and len(set(gaps)) == 1:
|
|
1520
|
+
self._warn(
|
|
1521
|
+
"dev-regular",
|
|
1522
|
+
f"opt.report_dev: the last three evaluations were {gaps[0]} updates apart; the agent is told the "
|
|
1523
|
+
f"script evaluates about every {self._validation} updates at times that are not announced, and it sees "
|
|
1524
|
+
"the update of each measurement, so regular times let it predict the next one (draw each time at "
|
|
1525
|
+
"random, as examples/overfit does)",
|
|
1526
|
+
)
|
|
1527
|
+
mean_gap = (rec["update"] - self._dev[0]["update"]) / len(self._dev) if self._dev else None
|
|
1528
|
+
if len(self._dev) >= 4 and not 0.5 <= mean_gap / self._validation <= 2:
|
|
1529
|
+
self._warn(
|
|
1530
|
+
"dev-interval",
|
|
1531
|
+
f"opt.report_dev: {len(self._dev) + 1} evaluations from update {self._dev[0]['update']} to "
|
|
1532
|
+
f"{rec['update']}; the agent is told the script evaluates about every {self._validation} updates "
|
|
1533
|
+
"(wrap(validation=N) is your promise of that: OPAL does not run the evaluation)",
|
|
1534
|
+
)
|
|
1535
|
+
self._dev.append(rec)
|
|
1536
|
+
self._metrics_new.append(dict(shown))
|
|
1537
|
+
self._shown_new.append(dict(shown))
|
|
1538
|
+
|
|
1539
|
+
# ----- evaluation integrity (misc/integrity.py; rank 0; the agent is not told) ------------------------------
|
|
1540
|
+
|
|
1541
|
+
def _integrity_record(self, u):
|
|
1542
|
+
"""Sample the parameters after update u, and check the evaluations whose window is complete. A failure turns the
|
|
1543
|
+
check off, with a note to the person running the training, and never stops training."""
|
|
1544
|
+
try:
|
|
1545
|
+
self._integrity.record(u)
|
|
1546
|
+
if self._integrity.due(u):
|
|
1547
|
+
self._integrity_report(self._integrity.resolve(u))
|
|
1548
|
+
except Exception:
|
|
1549
|
+
self._integrity_failed()
|
|
1550
|
+
|
|
1551
|
+
def _integrity_report(self, results):
|
|
1552
|
+
for t, meta, trips, jumps in results:
|
|
1553
|
+
if jumps: # informational: a lone change that stayed (a weight average swapped in for good)
|
|
1554
|
+
self.run.event("eval_jump", update=t, **meta, n_tensors=len(jumps), tensors=_rounded(jumps[:20]))
|
|
1555
|
+
if not trips:
|
|
1556
|
+
continue
|
|
1557
|
+
self.run.event(
|
|
1558
|
+
"eval_integrity", update=t, flagged=True, **meta, n_tensors=len(trips), tensors=_rounded(trips[:20])
|
|
1559
|
+
)
|
|
1560
|
+
names = ", ".join(
|
|
1561
|
+
f"{r['name']} (norm x{r['norm_factor']:.3g}, changed at update {r['changed_at']:,}, back at "
|
|
1562
|
+
f"{r['restored_at']:,})"
|
|
1563
|
+
for r in trips[:4]
|
|
1564
|
+
)
|
|
1565
|
+
more = f" and {len(trips) - 4} more tensors" if len(trips) > 4 else ""
|
|
1566
|
+
print(
|
|
1567
|
+
f"[opal] evaluation integrity: for the development evaluation after update {t:,}, the program "
|
|
1568
|
+
f"(v{meta.get('version', self.version):03d}) moved {names}{more} to another point and moved them back "
|
|
1569
|
+
"after the evaluation. The run is flagged (eval_integrity in events.jsonl, report.html); read "
|
|
1570
|
+
"programs/ before trusting its development or test results.",
|
|
1571
|
+
file=sys.stderr,
|
|
1572
|
+
flush=True,
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
def _integrity_failed(self):
|
|
1576
|
+
err = traceback.format_exc(limit=3)
|
|
1577
|
+
self._integrity = None
|
|
1578
|
+
if self.rank0:
|
|
1579
|
+
self.run.event("eval_integrity_off", update=self.update, error=err[-1500:])
|
|
1580
|
+
print(
|
|
1581
|
+
"[opal] the evaluation integrity check failed and is off for the rest of this run: "
|
|
1582
|
+
+ (err.strip().splitlines() or ["?"])[-1],
|
|
1583
|
+
file=sys.stderr,
|
|
1584
|
+
flush=True,
|
|
1585
|
+
)
|
|
1586
|
+
|
|
1587
|
+
@staticmethod
|
|
1588
|
+
def _plain(m):
|
|
1589
|
+
return {k: (float(v) if torch.is_tensor(v) else v) for k, v in m.items()}
|
|
1590
|
+
|
|
1591
|
+
def _metrics_since(self, consume=False):
|
|
1592
|
+
out = [self._plain(m) for m in self.metrics]
|
|
1593
|
+
if consume:
|
|
1594
|
+
self.metrics = []
|
|
1595
|
+
return out
|
|
1596
|
+
|
|
1597
|
+
def zero_grad(self, set_to_none=True):
|
|
1598
|
+
self._check_user_parameters()
|
|
1599
|
+
for _, p in self.named:
|
|
1600
|
+
if p.grad is not None:
|
|
1601
|
+
if set_to_none:
|
|
1602
|
+
p.grad = None
|
|
1603
|
+
else:
|
|
1604
|
+
if p.grad.grad_fn is not None:
|
|
1605
|
+
p.grad.detach_()
|
|
1606
|
+
else:
|
|
1607
|
+
p.grad.requires_grad_(False)
|
|
1608
|
+
p.grad.zero_()
|
|
1609
|
+
|
|
1610
|
+
def add_param_group(self, param_group):
|
|
1611
|
+
if getattr(self, "_initialised", False):
|
|
1612
|
+
raise NotImplementedError("opal: create all parameter groups before opal.wrap")
|
|
1613
|
+
super().add_param_group(param_group)
|
|
1614
|
+
|
|
1615
|
+
def close(self, interrupted=False):
|
|
1616
|
+
"""End of the run: called at the last update, by you after early stopping, and at interpreter exit (where a run
|
|
1617
|
+
that stopped before total_steps counts as interrupted: a mailbox broker then keeps waiting for the resumed
|
|
1618
|
+
run). Marks the final program and stops a decision in flight."""
|
|
1619
|
+
if self._closed:
|
|
1620
|
+
return
|
|
1621
|
+
self._closed = True
|
|
1622
|
+
self._rank_snapshot = None # early stopping can end a trial before its usual release point
|
|
1623
|
+
if not self.rank0:
|
|
1624
|
+
shutil.rmtree(self._prog_root, ignore_errors=True)
|
|
1625
|
+
return
|
|
1626
|
+
for m in self._metrics_new:
|
|
1627
|
+
self.run.append("metrics.jsonl", self._plain(m))
|
|
1628
|
+
self._metrics_new = []
|
|
1629
|
+
be = self.worker.backend if self.worker else None
|
|
1630
|
+
if self.worker is not None and self.worker.busy and be is not None:
|
|
1631
|
+
be.cancel() # its answer could no longer be applied
|
|
1632
|
+
self.run.event("cancelled", k=self.worker.k)
|
|
1633
|
+
if self.worker.thread is not None:
|
|
1634
|
+
self.worker.thread.join(timeout=1.0) # bounded settlement; slower answers record cost_settled
|
|
1635
|
+
settling = self.worker is not None and self.worker.thread is not None and self.worker.thread.is_alive()
|
|
1636
|
+
checked = None
|
|
1637
|
+
if self._integrity is not None: # the evaluations of the last updates, with what is left of their window
|
|
1638
|
+
try:
|
|
1639
|
+
self._integrity_report(self._integrity.resolve(self.update, final=True))
|
|
1640
|
+
checked = dict(checked=self._integrity.checks, flagged=self._integrity.flags)
|
|
1641
|
+
except Exception:
|
|
1642
|
+
self._integrity_failed()
|
|
1643
|
+
self.run.event(
|
|
1644
|
+
"interrupted" if interrupted else "end",
|
|
1645
|
+
update=self.update,
|
|
1646
|
+
version=self.version,
|
|
1647
|
+
decisions=self.worker.k if self.worker else 0,
|
|
1648
|
+
cost_usd=self.worker.recorded_cost if self.worker else 0,
|
|
1649
|
+
unknown_cost_decisions=sorted(self.worker.unknown_cost_decisions) if self.worker else [],
|
|
1650
|
+
**(dict(pending_decision=self.worker.k) if settling else {}),
|
|
1651
|
+
**(dict(usage=self.worker.usage) if self.worker and self.worker.usage is not None else {}),
|
|
1652
|
+
**(dict(eval_integrity=checked) if checked else {}),
|
|
1653
|
+
)
|
|
1654
|
+
if be is not None:
|
|
1655
|
+
be.finish(interrupted=interrupted)
|
|
1656
|
+
self._live.finish(timeout=30.0) # the final report is on disk when close() returns
|
|
1657
|
+
self._log(
|
|
1658
|
+
f"{'stopped' if interrupted else 'done'} at update {self.update}: program v{self.version:03d} in "
|
|
1659
|
+
f"{self.run.dir / 'programs' / f'v{self.version:03d}'}"
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
def _log(self, msg):
|
|
1663
|
+
if self.verbose:
|
|
1664
|
+
print(f"[opal] {redact_secrets(msg)}", file=sys.stderr, flush=True)
|
|
1665
|
+
|
|
1666
|
+
def _notice(self, msg):
|
|
1667
|
+
"""A line the user should not miss (the agent will not be asked again): on stdout."""
|
|
1668
|
+
if self.verbose:
|
|
1669
|
+
print(f"[opal] {msg}", flush=True)
|
|
1670
|
+
|
|
1671
|
+
def _warn(self, key, msg):
|
|
1672
|
+
if key not in self._warned:
|
|
1673
|
+
self._warned.add(key)
|
|
1674
|
+
self._log(msg)
|