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.
Files changed (49) hide show
  1. opal/__init__.py +7 -0
  2. opal/_version.py +3 -0
  3. opal/agent.py +394 -0
  4. opal/backends/__init__.py +31 -0
  5. opal/backends/_codex_client.py +450 -0
  6. opal/backends/_privacy.py +116 -0
  7. opal/backends/base.py +248 -0
  8. opal/backends/claude.py +665 -0
  9. opal/backends/codex.py +356 -0
  10. opal/backends/mailbox.py +1034 -0
  11. opal/backends/unavailable.py +161 -0
  12. opal/cli.py +396 -0
  13. opal/context.py +111 -0
  14. opal/defaults.json +11 -0
  15. opal/defaults.py +54 -0
  16. opal/estimate.py +120 -0
  17. opal/hf.py +109 -0
  18. opal/misc/__init__.py +2 -0
  19. opal/misc/aggressive.py +157 -0
  20. opal/misc/audit.py +84 -0
  21. opal/misc/boundary.py +226 -0
  22. opal/misc/checkpoint.py +399 -0
  23. opal/misc/ddp.py +147 -0
  24. opal/misc/integrity.py +263 -0
  25. opal/misc/nested.py +67 -0
  26. opal/misc/neutral_analysis.py +166 -0
  27. opal/misc/precision.py +210 -0
  28. opal/misc/recovery.py +231 -0
  29. opal/misc/schedulers.py +214 -0
  30. opal/misc/setup.py +253 -0
  31. opal/misc/skip.py +165 -0
  32. opal/misc/taskfacts.py +293 -0
  33. opal/misc/waiting.py +58 -0
  34. opal/programs.py +482 -0
  35. opal/prompts/example_updater.py +252 -0
  36. opal/prompts/neutral.md +206 -0
  37. opal/report.py +1001 -0
  38. opal/report_page.py +175 -0
  39. opal/report_privacy.py +159 -0
  40. opal/results.py +80 -0
  41. opal/schedule.py +134 -0
  42. opal/summaries.py +224 -0
  43. opal/wrapper.py +1674 -0
  44. opal_optimizer-0.3.5.dist-info/METADATA +232 -0
  45. opal_optimizer-0.3.5.dist-info/RECORD +49 -0
  46. opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
  47. opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
  48. opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
  49. 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
@@ -0,0 +1,2 @@
1
+ """Side machinery the main loop in wrapper.py delegates to: checkpoints and resume, recovery from a failing
2
+ program, DDP collectives, the user's schedulers, prompt and hook setup, tensor helpers."""
@@ -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"