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/defaults.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""OPAL's defaults are fixed: the default prompt, the starting programs, and the code that writes them.
|
|
2
|
+
|
|
3
|
+
The blog's and the README's results were made with these files as released. A coding agent that edits them inside an
|
|
4
|
+
(editable) install changes every later run without a word, so their sha256 hashes ship in defaults.json, and a run
|
|
5
|
+
(rank 0) compares the files it is about to use with them when it starts: an edited default prints a warning, and the
|
|
6
|
+
run's records (the start event of events.jsonl, report.html) say which files differ. Customise through wrap()'s
|
|
7
|
+
arguments instead: prompt=<a template file>, program=<a program folder>, task_facts=, and the rest.
|
|
8
|
+
|
|
9
|
+
tests/test_defaults.py fails when defaults.json is stale; after a deliberate change to one of these files, write it
|
|
10
|
+
again with `python -m opal.defaults --write`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
HERE = Path(__file__).parent
|
|
21
|
+
MANIFEST = HERE / "defaults.json"
|
|
22
|
+
PROMPT_FILES = ("prompts/neutral.md", "misc/aggressive.py") # the named prompts (not used with prompt=<a path>)
|
|
23
|
+
PROGRAM_FILES = ("prompts/example_updater.py", "misc/neutral_analysis.py") # starting analysis, example (not with program=)
|
|
24
|
+
# what fills the prompt and writes the messages and the starting updater, whatever prompt or program is used
|
|
25
|
+
SHARED_FILES = ("misc/setup.py", "misc/taskfacts.py", "programs.py", "agent.py", "context.py")
|
|
26
|
+
FILES = PROMPT_FILES + PROGRAM_FILES + SHARED_FILES
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hashes(names=FILES) -> dict:
|
|
30
|
+
return {n: hashlib.sha256((HERE / n).read_bytes()).hexdigest() for n in names}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def modified(names=FILES) -> list:
|
|
34
|
+
"""The files among `names` whose content differs from the released one (defaults.json), or that are missing."""
|
|
35
|
+
try:
|
|
36
|
+
shipped = json.loads(MANIFEST.read_text())
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
return ["defaults.json"]
|
|
39
|
+
out = []
|
|
40
|
+
for n in names:
|
|
41
|
+
try:
|
|
42
|
+
now = hashlib.sha256((HERE / n).read_bytes()).hexdigest()
|
|
43
|
+
except OSError:
|
|
44
|
+
now = None
|
|
45
|
+
if now != shipped.get(n):
|
|
46
|
+
out.append(n)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
if sys.argv[1:] != ["--write"]:
|
|
52
|
+
sys.exit("usage: python -m opal.defaults --write (after a deliberate change to OPAL's defaults)")
|
|
53
|
+
MANIFEST.write_text(json.dumps(hashes(), indent=1, sort_keys=True) + "\n")
|
|
54
|
+
print(f"wrote {MANIFEST}")
|
opal/estimate.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Before a run: how long training will wait for the agent, and what the agent will cost.
|
|
2
|
+
|
|
3
|
+
`opal estimate --total-steps N --sec-per-step S` prints it before launch; a run prints the same at its first check,
|
|
4
|
+
measured from its own time per update. Both use the per-decision times and costs below, which come from our runs.
|
|
5
|
+
|
|
6
|
+
When `decisions` is not set, a blocking run schedules at most as many decisions as keep the expected waiting within
|
|
7
|
+
MAX_WAIT_FACTOR (4) times the run's own training time, at least one; a `decisions` the user sets is never changed, and a line says when its waiting is above that.
|
|
8
|
+
|
|
9
|
+
Per decision of claude-opus-5-5 at effort high, the agent's `seconds` and `cost_usd`, 567 decisions in all:
|
|
10
|
+
the first decision, which reads the training code and writes the first program: 101 to 432 s over 20 runs (median
|
|
11
|
+
251 s), $0.33 to $1.62 (median $1.08);
|
|
12
|
+
each later decision, averaged over a run: 10 to 51 s over the 15 runs with at least 10 of them (median 27 s), $0.058
|
|
13
|
+
to $0.377 (median $0.23).
|
|
14
|
+
Through a mailbox, add the broker's poll and the ssh transfer, a few seconds per decision. Other models and efforts were not measured.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
MODEL, EFFORT = "claude-opus-5-5", "high" # what the numbers below were measured with
|
|
20
|
+
FIRST_SECONDS = (100.0, 250.0, 430.0) # (low, typical, high) seconds of the first decision
|
|
21
|
+
LATER_SECONDS = (10.0, 27.0, 50.0) # seconds of each later decision (a run's average)
|
|
22
|
+
FIRST_COST = (0.30, 1.08, 1.60) # dollars
|
|
23
|
+
LATER_COST = (0.05, 0.23, 0.38)
|
|
24
|
+
MAX_WAIT_FACTOR = 4.0 # with decisions not set: expected waiting at most this many times the run's own training time
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def waiting(n, blocking=True, sec_per_step=None, max_lag=50, which=1, first=None, later=None):
|
|
28
|
+
"""Seconds training waits for n decisions: which=0 low, 1 typical, 2 high. blocking=False: none (training waits
|
|
29
|
+
only when the loss turns non-finite). "auto": the first decision, and the later ones whose answer would land more
|
|
30
|
+
than max_lag updates late."""
|
|
31
|
+
if n <= 0 or blocking is False:
|
|
32
|
+
return 0.0
|
|
33
|
+
f = FIRST_SECONDS[which] if first is None else first
|
|
34
|
+
g = LATER_SECONDS[which] if later is None else later
|
|
35
|
+
if blocking == "auto" and sec_per_step and g / sec_per_step <= max_lag:
|
|
36
|
+
g = 0.0
|
|
37
|
+
return f + (n - 1) * g
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cost(n, which=1):
|
|
41
|
+
return 0.0 if n <= 0 else FIRST_COST[which] + (n - 1) * LATER_COST[which]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def capped_decisions(n, run_seconds, blocking=True, sec_per_step=None, max_lag=50, factor=MAX_WAIT_FACTOR,
|
|
45
|
+
first=None, later=None): # fmt: skip
|
|
46
|
+
"""The most decisions, at most n and at least 1, whose typical waiting stays within factor x run_seconds."""
|
|
47
|
+
if n <= 1:
|
|
48
|
+
return n
|
|
49
|
+
budget = factor * run_seconds
|
|
50
|
+
k = n
|
|
51
|
+
while k > 1 and waiting(k, blocking, sec_per_step, max_lag, 1, first, later) > budget:
|
|
52
|
+
k -= 1
|
|
53
|
+
return k
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _t(s):
|
|
57
|
+
if s < 90:
|
|
58
|
+
return f"{s:.0f} s"
|
|
59
|
+
if s < 90 * 60:
|
|
60
|
+
return f"{s / 60:.0f} min"
|
|
61
|
+
return f"{s / 3600:.1f} h"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _x(a, b):
|
|
65
|
+
if b <= 0:
|
|
66
|
+
return "?"
|
|
67
|
+
r = a / b
|
|
68
|
+
return f"{r:.2f}x" if r < 1 else f"{r:.1f}x" if r < 10 else f"{r:,.0f}x"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _usd(x):
|
|
72
|
+
return f"${x:.2f}" if x < 10 else f"${x:.0f}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def lines(total_steps, sec_per_step, decisions, blocking=True, max_lag=50, model=MODEL, effort=EFFORT, mailbox=False,
|
|
76
|
+
capped_from=None, explicit=True): # fmt: skip
|
|
77
|
+
"""The estimate, a few lines. decisions: the scheduled count. capped_from: the count before the cap of
|
|
78
|
+
MAX_WAIT_FACTOR (decisions not set). explicit: the user set decisions (then a waiting above the factor is said)."""
|
|
79
|
+
run = total_steps * sec_per_step
|
|
80
|
+
lo, typ, hi = (waiting(decisions, blocking, sec_per_step, max_lag, w) for w in (0, 1, 2))
|
|
81
|
+
mode = (
|
|
82
|
+
"training waits for each (blocking=True)"
|
|
83
|
+
if blocking is True
|
|
84
|
+
else "training goes on while the agent thinks (blocking=False)"
|
|
85
|
+
if blocking is False
|
|
86
|
+
else 'training waits at the first and whenever an answer would land late (blocking="auto")'
|
|
87
|
+
)
|
|
88
|
+
out = [f"training without the agent: {total_steps:,} updates x {sec_per_step:.3g} s = {_t(run)}"]
|
|
89
|
+
if blocking is False:
|
|
90
|
+
wait = "no waiting for the agent, except when the training loss turns non-finite"
|
|
91
|
+
else:
|
|
92
|
+
wait = (
|
|
93
|
+
f"about {_t(typ)} of waiting for the agent ({_t(lo)} to {_t(hi)}), {_x(typ, run)} the run's own time "
|
|
94
|
+
f"({_x(lo, run)} to {_x(hi, run)})"
|
|
95
|
+
+ (", plus a few seconds per decision through the mailbox" if mailbox else "")
|
|
96
|
+
)
|
|
97
|
+
out.append(f"{decisions} decision{'s' * (decisions != 1)}, {mode}: {wait}")
|
|
98
|
+
out.append(f"agent cost: about {_usd(cost(decisions))} ({_usd(cost(decisions, 0))} to {_usd(cost(decisions, 2))})")
|
|
99
|
+
over = blocking is not False and typ > MAX_WAIT_FACTOR * run
|
|
100
|
+
if capped_from is not None and capped_from != decisions:
|
|
101
|
+
out.append(
|
|
102
|
+
f"decisions not set: {decisions} scheduled instead of {capped_from}, so that the expected waiting stays "
|
|
103
|
+
f"within {MAX_WAIT_FACTOR:g}x the run's own time (set decisions= to choose)"
|
|
104
|
+
)
|
|
105
|
+
if over and (decisions <= 1 or not explicit):
|
|
106
|
+
out.append(
|
|
107
|
+
f"even {'one decision' if decisions <= 1 else f'{decisions} decisions'} wait{'s' * (decisions <= 1)} more "
|
|
108
|
+
f"than {MAX_WAIT_FACTOR:g}x the run's own time; blocking=False lets training go on while the agent thinks"
|
|
109
|
+
)
|
|
110
|
+
elif over:
|
|
111
|
+
out.append(
|
|
112
|
+
f"the expected waiting is more than {MAX_WAIT_FACTOR:g}x the run's own time: ask fewer times "
|
|
113
|
+
f"(decisions={capped_decisions(decisions, run, blocking, sec_per_step, max_lag)} keeps it within "
|
|
114
|
+
f"{MAX_WAIT_FACTOR:g}x) or let training go on while the agent thinks (blocking=False)"
|
|
115
|
+
)
|
|
116
|
+
if (model, effort) != (MODEL, EFFORT):
|
|
117
|
+
out.append(
|
|
118
|
+
f"these numbers are from {MODEL} at effort {EFFORT}; {model} at effort {effort} was not measured"
|
|
119
|
+
)
|
|
120
|
+
return out
|
opal/hf.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""OPAL in a Hugging Face Trainer: OpalTrainer is the Trainer with its optimizer wrapped by opal.wrap.
|
|
2
|
+
|
|
3
|
+
from opal.hf import OpalTrainer
|
|
4
|
+
|
|
5
|
+
trainer = OpalTrainer(model=model, args=args, train_dataset=train, eval_dataset=dev,
|
|
6
|
+
opal=dict(goal="...", max_cost_usd=20)) # opal.wrap's keyword arguments
|
|
7
|
+
trainer.train()
|
|
8
|
+
|
|
9
|
+
The Trainer builds its optimizer and learning-rate scheduler as usual; OpalTrainer builds the scheduler on the Trainer's
|
|
10
|
+
own torch optimizer and then wraps that optimizer with it, so the scheduler stays on the Trainer's own optimizer (a scheduler built on the wrapper would overwrite the
|
|
11
|
+
rates the agent's program sets) and total_steps is the Trainer's number of updates. The Trainer calls optimizer.step()
|
|
12
|
+
without the loss, so OpalTrainer adds up the loss training_step returns for each micro-batch of an update and hands it
|
|
13
|
+
to OPAL at that update's step(): the update's mean loss (also at the end of an epoch, where an update can have fewer or
|
|
14
|
+
more micro-batches than gradient_accumulation_steps). The Trainer's evaluation values (eval_loss, ...) go into OPAL's
|
|
15
|
+
report and are not shown to the agent.
|
|
16
|
+
|
|
17
|
+
Clipping: the Trainer clips the gradients (max_grad_norm, 1.0 by default) before the optimizer's step; that clipping
|
|
18
|
+
stays the Trainer's, outside the optimizer, and the agent's program gets clipped gradients and cannot change it. This
|
|
19
|
+
keeps the run bit for bit the plain Trainer's. To hand the clipping to the agent, turn the Trainer's off and give it to
|
|
20
|
+
OPAL: TrainingArguments(max_grad_norm=0) and opal=dict(clip_grad_norm=1.0). The run then starts the same up to rounding
|
|
21
|
+
(accelerate computes the Trainer's gradient norm its own way, so not bit for bit: about 3e-4 apart after 6 updates of
|
|
22
|
+
the tiny test model). clip_grad_norm in opal= while max_grad_norm is set raises: the gradients would be clipped twice.
|
|
23
|
+
|
|
24
|
+
Needs transformers (pip install "opal-optimizer[hf]"). Tested on CPU with transformers 5.20 and accelerate 1.15.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import torch
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from transformers import Trainer
|
|
33
|
+
except ImportError as exc: # pragma: no cover - the optional extra
|
|
34
|
+
raise ImportError('opal.hf needs transformers: pip install "opal-optimizer[hf]"') from exc
|
|
35
|
+
|
|
36
|
+
from .wrapper import AgentOptimizer, wrap
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OpalTrainer(Trainer):
|
|
40
|
+
def __init__(self, *args, opal=None, **kwargs):
|
|
41
|
+
opal = dict(opal or {})
|
|
42
|
+
targs = kwargs.get("args") if "args" in kwargs else (args[1] if len(args) > 1 else None)
|
|
43
|
+
if opal.get("clip_grad_norm") is not None and (targs is None or getattr(targs, "max_grad_norm", None)):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"OpalTrainer: opal= sets clip_grad_norm and args.max_grad_norm is set too, so the gradients would be "
|
|
46
|
+
"clipped twice; to hand the clipping to the agent, also pass TrainingArguments(max_grad_norm=0)"
|
|
47
|
+
)
|
|
48
|
+
super().__init__(*args, **kwargs)
|
|
49
|
+
self.opal_kwargs = opal
|
|
50
|
+
self._opal_losses = [] # the losses training_step returned for this update's micro-batches
|
|
51
|
+
self._opal_loss_step = None
|
|
52
|
+
|
|
53
|
+
def create_scheduler(self, num_training_steps, optimizer=None):
|
|
54
|
+
"""Build the Trainer's schedule on its own torch optimizer, then wrap that optimizer with it. Transformers 4
|
|
55
|
+
calls this from create_optimizer_and_scheduler; 5 calls it after accelerate has wrapped the optimizer, so the
|
|
56
|
+
AgentOptimizer takes the torch optimizer's place inside accelerate's wrapper."""
|
|
57
|
+
holder = optimizer if optimizer is not None else self.optimizer
|
|
58
|
+
raw = holder
|
|
59
|
+
while isinstance(getattr(raw, "optimizer", None), torch.optim.Optimizer): # accelerate's wrapper
|
|
60
|
+
raw = raw.optimizer
|
|
61
|
+
if isinstance(raw, AgentOptimizer): # wrapped already
|
|
62
|
+
return super().create_scheduler(num_training_steps, optimizer=optimizer)
|
|
63
|
+
scheduler = super().create_scheduler(num_training_steps, optimizer=raw)
|
|
64
|
+
agent = wrap(raw, self.model, scheduler=scheduler, total_steps=num_training_steps, **self.opal_kwargs)
|
|
65
|
+
agent._loss_hook = self._update_loss # step() takes the update's loss from here
|
|
66
|
+
if self.optimizer is raw:
|
|
67
|
+
self.optimizer = agent
|
|
68
|
+
else:
|
|
69
|
+
outer = self.optimizer
|
|
70
|
+
while getattr(outer, "optimizer", None) is not raw:
|
|
71
|
+
outer = outer.optimizer
|
|
72
|
+
outer.optimizer = agent
|
|
73
|
+
return scheduler
|
|
74
|
+
|
|
75
|
+
def training_step(self, *args, **kwargs):
|
|
76
|
+
if self._opal_loss_step != self.state.global_step:
|
|
77
|
+
self._opal_losses = [] # a GradScaler-skipped update never called the optimizer's loss hook
|
|
78
|
+
self._opal_loss_step = self.state.global_step
|
|
79
|
+
loss = super().training_step(*args, **kwargs)
|
|
80
|
+
self._opal_losses.append(loss.detach())
|
|
81
|
+
return loss
|
|
82
|
+
|
|
83
|
+
def _update_loss(self):
|
|
84
|
+
"""The update's mean loss from its micro-batches' losses as training_step returns them: each is already scaled
|
|
85
|
+
for the update in recent Transformers (by current_gradient_accumulation_steps, or by the number of items), so
|
|
86
|
+
they add up to it; older ones divide by gradient_accumulation_steps whatever the update's micro-batches are."""
|
|
87
|
+
losses, self._opal_losses = self._opal_losses, []
|
|
88
|
+
if not losses:
|
|
89
|
+
return None
|
|
90
|
+
total = torch.stack([x.float().reshape(()) for x in losses]).sum()
|
|
91
|
+
if not hasattr(self, "current_gradient_accumulation_steps"):
|
|
92
|
+
total = total * self.args.gradient_accumulation_steps / len(losses)
|
|
93
|
+
return total
|
|
94
|
+
|
|
95
|
+
def log(self, logs, *args, **kwargs):
|
|
96
|
+
super().log(logs, *args, **kwargs)
|
|
97
|
+
opt = agent_optimizer(self)
|
|
98
|
+
if opt is not None:
|
|
99
|
+
shown = {k: v for k, v in logs.items() if k.startswith("eval_") and isinstance(v, (int, float))}
|
|
100
|
+
if shown:
|
|
101
|
+
opt.log(**shown) # in OPAL's report; not shown to the agent
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def agent_optimizer(trainer):
|
|
105
|
+
"""The AgentOptimizer inside a Trainer's optimizer (accelerate wraps it), or None before it is built."""
|
|
106
|
+
opt = trainer.optimizer
|
|
107
|
+
while opt is not None and not isinstance(opt, AgentOptimizer):
|
|
108
|
+
opt = getattr(opt, "optimizer", None)
|
|
109
|
+
return opt
|
opal/misc/__init__.py
ADDED
opal/misc/aggressive.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""OPAL 0.1.2-aggressive (prompt="aggressive"): the neutral prompt, except that each
|
|
2
|
+
decision starts with a review of every parameter group's hyperparameters, the rationale has five parts (observed,
|
|
3
|
+
review, changed, prediction, rules), the decision message no longer says that keeping the programs is a valid decision,
|
|
4
|
+
and reference/example_updater.py has a feedback controller of the learning rates (off by default). Everything else is
|
|
5
|
+
neutral.md word for word; this module holds only the differences, applied to neutral.md and example_updater.py."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# ----- the system prompt: neutral.md's DECISIONS section, old -> new ------------------------------------------------
|
|
10
|
+
|
|
11
|
+
PROMPT_EDITS = [
|
|
12
|
+
(
|
|
13
|
+
"At each decision you may edit your programs or keep them; keeping them is a valid decision. Before changing "
|
|
14
|
+
"anything,\ncheck what you predicted at your previous decision against what happened. End every decision with a "
|
|
15
|
+
"short rationale:\n",
|
|
16
|
+
"At each decision, first check what you predicted at your previous decision against what happened. Then review, "
|
|
17
|
+
"for\neach parameter group, whether its current hyperparameters (the update rule, the learning rate and its "
|
|
18
|
+
"schedule,\nmomentum or betas, epsilon, weight decay, clipping) suit the next stretch of training, and which "
|
|
19
|
+
"adjustments, in which\ndirection and by how much, would improve performance. Rules in your programs can also "
|
|
20
|
+
"make such adjustments between\ndecisions, at every update, from signals they measure. End every decision with a "
|
|
21
|
+
"short rationale:\n",
|
|
22
|
+
),
|
|
23
|
+
(
|
|
24
|
+
"2. Changed or kept: what you edited and why; or why you kept the programs and what evidence you are waiting "
|
|
25
|
+
"for.\n3. Prediction: the effect you expect, by roughly when, and what result would show you were wrong.\n4. "
|
|
26
|
+
"Rules: for each rule in your programs, whether it is a fixed schedule or reacts to a signal.\n",
|
|
27
|
+
"2. Review: for each parameter group, whether its hyperparameters suit the next stretch, and which adjustments\n"
|
|
28
|
+
" (direction and size) you expect to help.\n3. Changed: what you edited and why.\n4. Prediction: the effect you "
|
|
29
|
+
"expect, by roughly when, and what result would show you were wrong.\n5. Rules: for each rule in your programs, "
|
|
30
|
+
"whether it is a fixed schedule or reacts to a signal.\n",
|
|
31
|
+
),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# ----- the decision message (agent.format_message) ------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
EDIT_SENTENCE = "Edit the programs in place. "
|
|
37
|
+
RATIONALE_SENTENCE = (
|
|
38
|
+
"End with your rationale in the five parts of DECISIONS: observed, review, changed, prediction, rules."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _apply(text, edits, what):
|
|
43
|
+
for old, new in edits:
|
|
44
|
+
if text.count(old) != 1:
|
|
45
|
+
raise RuntimeError(f"opal: the aggressive variant no longer matches {what}: {old[:80]!r}")
|
|
46
|
+
text = text.replace(old, new)
|
|
47
|
+
return text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def template(neutral: str) -> str:
|
|
51
|
+
return _apply(neutral, PROMPT_EDITS, "prompts/neutral.md")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ----- reference/example_updater.py: a feedback controller of the learning rates, off by default ---------------------
|
|
55
|
+
|
|
56
|
+
CONSTANTS = """
|
|
57
|
+
|
|
58
|
+
# A feedback controller of the learning rates, off by default: at every update it measures, on the device, a running
|
|
59
|
+
# mean of each AdamW group's update-to-weight ratio, and lr_rule() below turns that signal into a multiplier of the
|
|
60
|
+
# group's learning rate. To use it, set LR_CONTROLLER = True and SIGNAL_DECAY (the weight of the newest measurement in
|
|
61
|
+
# the running mean), and write lr_rule() from your own measurements.
|
|
62
|
+
LR_CONTROLLER = False
|
|
63
|
+
SIGNAL_DECAY = None
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
CONTROLLER = '''
|
|
67
|
+
|
|
68
|
+
# ----- a feedback controller of the learning rates (off by default) -------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def lr_rule(signal, multiplier, update, total):
|
|
72
|
+
"""Your rule: the new learning-rate multiplier of one group, from its signal (a 0-d tensor on the device: the
|
|
73
|
+
running mean of the group's update-to-weight ratio) and its current multiplier (a 0-d tensor). Keep it on the
|
|
74
|
+
device (torch.where, torch.clamp, arithmetic; no .item(), no if on a tensor). As written it changes nothing."""
|
|
75
|
+
return multiplier
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LRController:
|
|
79
|
+
"""Per AdamW group (keyed like the group): the running mean of its update-to-weight ratio and its learning-rate
|
|
80
|
+
multiplier, 0-d float32 tensors on the device. The multiplier scales the group's whole update after the rule's
|
|
81
|
+
step, which for AdamW is the same as scaling its learning rate, so nothing is read on the host."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, rule, carried):
|
|
84
|
+
if SIGNAL_DECAY is None:
|
|
85
|
+
raise ValueError("set SIGNAL_DECAY to use the learning-rate controller")
|
|
86
|
+
self.rule = rule
|
|
87
|
+
self.keys = [str(g["key"]) for g in rule.groups]
|
|
88
|
+
self.signal, self.mult = {}, {}
|
|
89
|
+
for key, g in zip(self.keys, rule.groups):
|
|
90
|
+
device = g["params"][0].device
|
|
91
|
+
self.signal[key] = carried.get("signal", {}).get(key, torch.zeros((), device=device)).clone()
|
|
92
|
+
self.mult[key] = carried.get("mult", {}).get(key, torch.ones((), device=device)).clone()
|
|
93
|
+
|
|
94
|
+
def before(self):
|
|
95
|
+
return [torch._foreach_mul([p.detach() for p in g["params"]], 1) for g in self.rule.groups] # copies
|
|
96
|
+
|
|
97
|
+
def after(self, before, update, total):
|
|
98
|
+
for key, g, old in zip(self.keys, self.rule.groups, before):
|
|
99
|
+
params = [p.detach() for p in g["params"]]
|
|
100
|
+
delta = torch._foreach_sub(params, old)
|
|
101
|
+
moved = torch.linalg.vector_norm(torch.stack(torch._foreach_norm(delta)).float())
|
|
102
|
+
size = torch.linalg.vector_norm(torch.stack(torch._foreach_norm(old)).float())
|
|
103
|
+
ratio = moved / size.clamp_min(torch.finfo(size.dtype).tiny)
|
|
104
|
+
self.signal[key].mul_(1 - SIGNAL_DECAY).add_(ratio * SIGNAL_DECAY)
|
|
105
|
+
self.mult[key] = lr_rule(self.signal[key], self.mult[key], update, total)
|
|
106
|
+
# Interpolate from the saved weights: subtracting the update back can lose
|
|
107
|
+
# their low bits even at multiplier zero. lerp preserves both endpoints.
|
|
108
|
+
torch._foreach_lerp_(old, params, [self.mult[key].to(params[0].dtype)] * len(old))
|
|
109
|
+
torch._foreach_copy_(params, old)
|
|
110
|
+
|
|
111
|
+
def state_dict(self):
|
|
112
|
+
return {"signal": dict(self.signal), "mult": dict(self.mult)}
|
|
113
|
+
|
|
114
|
+
def load_state_dict(self, state):
|
|
115
|
+
for name in ("signal", "mult"):
|
|
116
|
+
mine = getattr(self, name)
|
|
117
|
+
mine.update({k: v for k, v in state.get(name, {}).items() if k in mine})
|
|
118
|
+
'''
|
|
119
|
+
|
|
120
|
+
EXAMPLE_EDITS = [
|
|
121
|
+
(
|
|
122
|
+
"Nothing\nhere is a recommendation: the multipliers, knots and rule table are left at values that change "
|
|
123
|
+
"nothing.",
|
|
124
|
+
"Nothing\nhere is a recommendation: the multipliers, knots and rule table are left at values that change "
|
|
125
|
+
"nothing, and the\nfeedback controller of the learning rates is off.",
|
|
126
|
+
),
|
|
127
|
+
("\n\n# ----- grouping by role and block", CONSTANTS + "\n\n# ----- grouping by role and block"),
|
|
128
|
+
("\n\n# ----- the updater -----", CONTROLLER + "\n\n# ----- the updater -----"),
|
|
129
|
+
(
|
|
130
|
+
" self.rules = {r: RULES[r](entries.get(r, []), carried) for r in RULES}\n",
|
|
131
|
+
" self.rules = {r: RULES[r](entries.get(r, []), carried) for r in RULES}\n"
|
|
132
|
+
' old = getattr(previous, "controller", None) # its signals and multipliers carry over\n'
|
|
133
|
+
" self.controller = (\n"
|
|
134
|
+
' LRController(self.rules["adamw"], old.state_dict() if old else {}) if LR_CONTROLLER else None\n'
|
|
135
|
+
" )\n",
|
|
136
|
+
),
|
|
137
|
+
(
|
|
138
|
+
" for rule in self.rules.values():\n rule.set_lrs(update, self.total)\n rule.step()\n",
|
|
139
|
+
" before = self.controller.before() if self.controller else None\n"
|
|
140
|
+
" for rule in self.rules.values():\n rule.set_lrs(update, self.total)\n rule.step()\n"
|
|
141
|
+
" if self.controller:\n self.controller.after(before, update, self.total)\n",
|
|
142
|
+
),
|
|
143
|
+
(
|
|
144
|
+
" def state_dict(self):\n return {r: rule.state_by_name() for r, rule in self.rules.items()} # keyed by "
|
|
145
|
+
"parameter name\n\n def load_state_dict(self, state):\n for r, rule in self.rules.items():\n"
|
|
146
|
+
" rule.load_state_by_name(state.get(r, {}))\n",
|
|
147
|
+
" def state_dict(self):\n out = {r: rule.state_by_name() for r, rule in self.rules.items()} # keyed by "
|
|
148
|
+
'parameter name\n if self.controller:\n out["lr_controller"] = self.controller.state_dict()\n'
|
|
149
|
+
" return out\n\n def load_state_dict(self, state):\n for r, rule in self.rules.items():\n"
|
|
150
|
+
' rule.load_state_by_name(state.get(r, {}))\n if self.controller and "lr_controller" in state:\n'
|
|
151
|
+
' self.controller.load_state_dict(state["lr_controller"])\n',
|
|
152
|
+
),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def example(base: str) -> str:
|
|
157
|
+
return _apply(base, EXAMPLE_EDITS, "prompts/example_updater.py")
|
opal/misc/audit.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""One audited update per new updater (rank 0, second update of each version): operator calls per update, and host
|
|
2
|
+
synchronisations per update on a GPU. These show when an updater is slow because of how it is written (values read
|
|
3
|
+
on the host, Python loops over tensors) rather than because of the algorithm it implements.
|
|
4
|
+
|
|
5
|
+
On a GPU the CUDA profiler is not used: synchronisations are counted with torch's sync debug mode, a flag checked at each sync, and
|
|
6
|
+
operators with the CPU-only profiler."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
COUNT_OPS_ON_CUDA = True # the CPU-only profiler on a GPU
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def audited(fn, device):
|
|
16
|
+
"""Call fn() once and return its counts, or None if they cannot be taken (fn still runs; its exceptions
|
|
17
|
+
propagate)."""
|
|
18
|
+
if device.type != "cuda":
|
|
19
|
+
return _profiled(fn)
|
|
20
|
+
import torch
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
prev = torch.cuda.get_sync_debug_mode()
|
|
24
|
+
except Exception:
|
|
25
|
+
fn()
|
|
26
|
+
return None
|
|
27
|
+
ops = {}
|
|
28
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
29
|
+
warnings.simplefilter("always")
|
|
30
|
+
torch.cuda.set_sync_debug_mode(1)
|
|
31
|
+
try:
|
|
32
|
+
if COUNT_OPS_ON_CUDA:
|
|
33
|
+
ops = _profiled(fn) or {}
|
|
34
|
+
else:
|
|
35
|
+
fn()
|
|
36
|
+
finally:
|
|
37
|
+
torch.cuda.set_sync_debug_mode(prev)
|
|
38
|
+
syncs = sum(1 for w in caught if "synchroniz" in str(w.message))
|
|
39
|
+
return dict(ops or {}, host_syncs=int(syncs))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _profiled(fn):
|
|
43
|
+
"""fn() under the CPU-only profiler: outermost operator calls (an operator called from Python, not one called by
|
|
44
|
+
another operator)."""
|
|
45
|
+
try:
|
|
46
|
+
from torch.profiler import ProfilerActivity, profile
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
prof = profile(activities=[ProfilerActivity.CPU], acc_events=True) # no end-of-cycle warning
|
|
50
|
+
except TypeError: # torch < 2.4
|
|
51
|
+
prof = profile(activities=[ProfilerActivity.CPU])
|
|
52
|
+
prof.__enter__()
|
|
53
|
+
except Exception:
|
|
54
|
+
fn()
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
fn()
|
|
58
|
+
finally:
|
|
59
|
+
try:
|
|
60
|
+
prof.__exit__(None, None, None)
|
|
61
|
+
except Exception:
|
|
62
|
+
prof = None
|
|
63
|
+
if prof is None:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
ops = sum(
|
|
67
|
+
1
|
|
68
|
+
for e in prof.events()
|
|
69
|
+
if e.name.startswith("aten::") and (e.cpu_parent is None or not e.cpu_parent.name.startswith("aten::"))
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
return dict(operator_calls=int(ops))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def describe(audit):
|
|
77
|
+
if not audit:
|
|
78
|
+
return "not measured"
|
|
79
|
+
parts = []
|
|
80
|
+
if "operator_calls" in audit:
|
|
81
|
+
parts.append(f"{audit['operator_calls']} operator calls")
|
|
82
|
+
if "host_syncs" in audit:
|
|
83
|
+
parts.append(f"{audit['host_syncs']} host synchronisations")
|
|
84
|
+
return " and ".join(parts) + " per update"
|