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/misc/ddp.py ADDED
@@ -0,0 +1,147 @@
1
+ """Collectives on the control group (gloo) and the periodic check that every rank holds the same parameters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sys
7
+
8
+ import torch
9
+ import torch.distributed as dist
10
+ from torch.distributed.algorithms.join import JoinHook
11
+
12
+
13
+ UNEVEN_INPUTS_ERROR = (
14
+ "opal: uneven inputs across DDP ranks (torch.distributed.algorithms.Join) are not supported; "
15
+ "give every rank the same number of steps"
16
+ )
17
+
18
+
19
+ class _UnevenInputsHook(JoinHook):
20
+ def __init__(self, optimizer):
21
+ self.optimizer = optimizer
22
+
23
+ def main_hook(self):
24
+ self.optimizer._check_join_inputs(joined=True)
25
+
26
+
27
+
28
+
29
+ def _parameter_chunks(tensor, limit=1_048_576):
30
+ """Yield logical row-major chunks without reshaping an entire noncontiguous parameter.
31
+
32
+ Narrow before making a copy. Each resolved/contiguous/CPU temporary has at most `limit` elements,
33
+ even for transposes, strided slices, conjugate/negative views and scalar parameters.
34
+ """
35
+ if tensor.numel() <= limit:
36
+ yield tensor.reshape(-1)
37
+ elif tensor.is_contiguous():
38
+ yield from tensor.view(-1).split(limit)
39
+ else:
40
+ # Split the first nontrivial axis; completed leading axes remain in logical C order.
41
+ axis = next(i for i, n in enumerate(tensor.shape) if n > 1)
42
+ per_slice = tensor.numel() // tensor.shape[axis]
43
+ width = max(1, limit // per_slice)
44
+ for start in range(0, tensor.shape[axis], width):
45
+ yield from _parameter_chunks(tensor.narrow(axis, start, min(width, tensor.shape[axis] - start)), limit)
46
+
47
+
48
+ class DDPMixin:
49
+ def join_hook(self, **kwargs):
50
+ return _UnevenInputsHook(self)
51
+
52
+ @property
53
+ def join_device(self):
54
+ return self.device
55
+
56
+ @property
57
+ def join_process_group(self):
58
+ # Join requires the model and optimizer to use the same process group.
59
+ from ..context import _wrappers
60
+
61
+ for model in _wrappers(self.model):
62
+ if isinstance(model, torch.nn.parallel.DistributedDataParallel):
63
+ return model.process_group
64
+ return dist.group.WORLD
65
+
66
+ def _check_join_inputs(self, joined=False):
67
+ if not self.dist or not self._join_config.enable:
68
+ return
69
+ active = torch.tensor([0 if joined else 1])
70
+ dist.all_reduce(active, op=dist.ReduceOp.MIN, group=self.ctrl)
71
+ if not active.item():
72
+ raise RuntimeError(UNEVEN_INPUTS_ERROR)
73
+
74
+ def _agree(self, ok: bool) -> bool:
75
+ """True on every rank only if `ok` on every rank (one gloo all-reduce; no-op on one process)."""
76
+ if not self.dist:
77
+ return ok
78
+ t = torch.tensor([1 if ok else 0])
79
+ dist.all_reduce(t, op=dist.ReduceOp.MIN, group=self.ctrl)
80
+ return bool(t.item())
81
+
82
+ def _from_rank0(self, flag: bool) -> bool:
83
+ """Rank 0's flag on every rank (one gloo broadcast; no-op on one process)."""
84
+ if not self.dist:
85
+ return flag
86
+ t = torch.tensor([1 if (self.rank0 and flag) else 0])
87
+ dist.broadcast(t, src=0, group=self.ctrl)
88
+ return bool(t.item())
89
+
90
+ def _rank_differences(self):
91
+ """Compare SHA-256 digests of every tensor's dtype, shape and ordered bytes (two gloo all-reduces).
92
+
93
+ These collision-resistant digests detect permutations that sum/sum-of-squares misses. Checks copy parameter
94
+ bytes to CPU in bounded chunks and synchronize device work at check boundaries. Optimizer state is neither
95
+ compared nor broadcast: a sharded optimizer keeps different state on each rank by design.
96
+ """
97
+ digests = []
98
+ for _, p in self.named:
99
+ digest = hashlib.sha256(f"{p.dtype}:{tuple(p.shape)}".encode())
100
+ for chunk in _parameter_chunks(p.detach()):
101
+ raw = chunk.resolve_conj().resolve_neg().contiguous().view(torch.uint8).cpu().numpy()
102
+ digest.update(memoryview(raw))
103
+ digests.append(list(digest.digest()))
104
+ s = torch.tensor(digests, dtype=torch.uint8)
105
+ lo, hi = s.clone(), s.clone()
106
+ dist.all_reduce(lo, op=dist.ReduceOp.MIN, group=self.ctrl)
107
+ dist.all_reduce(hi, op=dist.ReduceOp.MAX, group=self.ctrl)
108
+ bad = torch.nonzero((lo != hi).any(dim=1)).flatten().tolist()
109
+ return [self.named[i][0] for i in bad]
110
+
111
+ def _check_ranks(self, u):
112
+ """Reject a fresh updater's drift from a consistent starting point; otherwise repair and flag the run."""
113
+ names = self._rank_differences()
114
+ self._rank_checked_at = u
115
+ if not names:
116
+ return names
117
+ shown = ", ".join(names[:20]) + (f" and {len(names) - 20} more" if len(names) > 20 else "")
118
+ if u <= self._trial_until and self._rank_trial_equal:
119
+ msg = (
120
+ f"Your updater v{self.version:03d} made the ranks' parameters differ at update {u} "
121
+ f"({len(names)} of {len(self.named)} tensors: {shown}). They agreed before this trial. "
122
+ "Check rank-local state: for example, momentum copied from a sharded optimizer contains only "
123
+ "this rank's shard; using it to update every matrix independently makes ranks diverge. "
124
+ "The new updater failed its trial."
125
+ )
126
+ if self.rank0:
127
+ self.run.event(
128
+ "rank_divergence", update=u, version=self.version, tensors=len(names), names=names,
129
+ snapshot_available=isinstance(self._rank_snapshot, dict), error=msg,
130
+ )
131
+ self._recover(u, None, msg, trial=True, rollback=True)
132
+ return names
133
+ for _, p in self.named:
134
+ dist.broadcast(p.data, src=0)
135
+ self._ranks_repaired_at.append(u)
136
+ if self.rank0:
137
+ msg = (
138
+ f"parameters differed across ranks at update {u} ({len(names)} of {len(self.named)} tensors: {shown}); "
139
+ "rank 0's parameters were broadcast, the updater's state was not. The updater must act identically "
140
+ "on every rank: state it keeps per rank (for example momentum copied from a sharded optimizer) "
141
+ "makes the ranks drift again."
142
+ )
143
+ self.errors.append(dict(update=u, where="rank consistency check", error=msg))
144
+ self.run.event("ranks_resynced", update=u, version=self.version, tensors=len(names), names=names)
145
+ self._live.refresh()
146
+ print(f"[opal] WARNING: {msg}", file=sys.stderr, flush=True)
147
+ return names
opal/misc/integrity.py ADDED
@@ -0,0 +1,263 @@
1
+ """Evaluation integrity (0.2.2): does the running program move the parameters to another point for a development
2
+ evaluation and move them back after it?
3
+
4
+ Two overfit runs did: one multiplied the classifier by a probe factor just before each evaluation and restored it at
5
+ the next update (A2); one copied a running average into every parameter 49 updates before each evaluation, held it
6
+ there at learning rate 0 and restored the iterate after the evaluation (A1). Both read the evaluation times from the
7
+ prompt and the training script. Since then the times are not announced, and the rule is enforced by a check, not by
8
+ prompt text. The agent is not told that the check exists.
9
+
10
+ What is recorded (rank 0, every update, no host sync): a fixed sample of each parameter tensor's entries, a strided
11
+ view of its storage with a seeded offset (at most SAMPLE_PER_TENSOR entries, SAMPLE_TOTAL in all), copied by one
12
+ torch.cat into a ring that holds the last BEFORE + AFTER + 2 updates. The views are made once and rebuilt only when a
13
+ tensor's storage changes (p.data replaced).
14
+
15
+ What is checked: report_dev opens a check for the update t whose parameters the script has just evaluated; AFTER
16
+ updates later the window [t - BEFORE - 1, t + AFTER] is read once to the host. Per tensor, d_j = s_j - s_(j-1) is the
17
+ sampled change of update j and m_j = |d_j|. An update is a jump when m_j exceeds `threshold` times the tensor's typical
18
+ change (the median of its non-zero m_j in the window, or 0 when fewer than MIN_MOVING are non-zero, as for a frozen
19
+ tensor) and JUMP_FLOOR of its sampled norm. A round trip, which flags the run: a = the last jump at or before t and
20
+ b = the first jump after t, where
21
+ - the evaluation saw a moved point: |s_t - s_(a-1)| >= 0.5 m_a;
22
+ - b moves it back: |s_b - s_(a-1)| <= BACK |s_t - s_(a-1)|;
23
+ - the updates just before a and just after b are not jumps (a sustained oscillation, or a change of learning rate
24
+ that persists, is not a round trip).
25
+ A lone jump at or before t that is not undone (a weight average swapped in for good) is recorded as an informational
26
+ "eval_jump" event and does not flag the run.
27
+
28
+ Not detected: an excursion that starts more than BEFORE updates before the evaluation or ends more than AFTER - 1
29
+ updates after it (training under the change for that long is a training intervention, visible in the program); a
30
+ change made outside the updater between the step and the evaluation (the record is taken as the updater leaves the
31
+ parameters, so a script's own swap of EMA weights around its evaluation is not attributed to the program); and, by
32
+ construction, a program that undoes one of its own updates one update later when an evaluation happens to fall in
33
+ between looks the same as a probe (the event names the updates, so the program can be read).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import math
39
+ import random
40
+
41
+ import torch
42
+
43
+ SAMPLE_PER_TENSOR = 1024
44
+ SAMPLE_TOTAL = 32768
45
+ MIN_PER_TENSOR = 32
46
+ BEFORE, AFTER = 128, 8 # updates before / after the evaluation that the window covers
47
+ MIN_MOVING = 8 # fewer non-zero changes than this in the window: the tensor counts as frozen (typical change 0)
48
+ JUMP_FLOOR = 1e-3 # a jump also changes the tensor's sampled entries by more than this fraction of their norm
49
+ SEEN = 0.5 # the evaluation saw the moved point: |s_t - s_(a-1)| >= SEEN * m_a
50
+ BACK = 0.25 # moved back: |s_b - s_(a-1)| <= BACK * |s_t - s_(a-1)|
51
+ SEED = 20261018
52
+
53
+
54
+ def _stride(numel, count, shape):
55
+ """Spacing of the sampled entries: about numel / count, and coprime with every dimension so that the sample does
56
+ not fall into one row or one column."""
57
+ s = max(1, numel // max(1, count))
58
+ while s > 1 and any(math.gcd(s, d) != 1 for d in shape if d > 1):
59
+ s -= 1
60
+ return s
61
+
62
+
63
+ def _unravel(flat, shape):
64
+ """torch.unravel_index for torch < 2.2: the index tuple of each flat (row-major) position."""
65
+ out = []
66
+ for size in reversed(shape):
67
+ out.append(flat % size)
68
+ flat = flat // size
69
+ return tuple(reversed(out))
70
+
71
+
72
+ def _flat(t):
73
+ """A flat view of a dense tensor in memory order (contiguous, channels_last, any permutation), or None."""
74
+ if t.is_contiguous():
75
+ return t.view(-1)
76
+ order = sorted(range(t.dim()), key=lambda d: -t.stride(d))
77
+ q = t.permute(order)
78
+ return q.view(-1) if q.is_contiguous() else None
79
+
80
+
81
+ class Integrity:
82
+ def __init__(self, named, threshold=5.0, seed=SEED):
83
+ self.names = [n for n, _ in named]
84
+ self.params = [p for _, p in named]
85
+ self.threshold = float(threshold)
86
+ n = len(self.params)
87
+ per = max(MIN_PER_TENSOR, min(SAMPLE_PER_TENSOR, SAMPLE_TOTAL // max(1, n)))
88
+ rng = random.Random(seed)
89
+ self.spec = [] # per tensor: (offset, stride, count) in its memory order
90
+ for p in self.params:
91
+ numel = p.numel()
92
+ count = min(per, numel)
93
+ stride = _stride(numel, count, tuple(p.shape))
94
+ count = min(count, (numel - 1) // stride + 1) if numel else 0
95
+ offset = rng.randrange(0, numel - (count - 1) * stride) if count else 0
96
+ self.spec.append((offset, stride, count))
97
+ self.sizes = [c for _, _, c in self.spec]
98
+ self.R = BEFORE + AFTER + 2
99
+ self.groups = {} # device -> indices of its tensors (one ring and one cat per device)
100
+ for i, p in enumerate(self.params):
101
+ self.groups.setdefault(p.device, []).append(i)
102
+ self.rings = {
103
+ d: torch.zeros(self.R, sum(self.sizes[i] for i in idx), dtype=torch.float32, device=d)
104
+ for d, idx in self.groups.items()
105
+ }
106
+ self.slot_update = [None] * self.R
107
+ self.pending = [] # updates whose evaluation is still to be checked
108
+ self.meta = {} # update -> what the caller noted when it opened the check
109
+ self.recorded_jumps = set() # (tensor index, update) already reported, as a one-way jump or a round trip
110
+ self.checks = self.flags = 0
111
+ self._ptrs, self._views, self._gather = None, None, {}
112
+
113
+ # ----- every update ---------------------------------------------------------------------------------------
114
+
115
+ def _build_views(self, ptrs):
116
+ self._ptrs, self._views, self._gather = ptrs, {}, {}
117
+ for d, idx in self.groups.items():
118
+ views = []
119
+ for i in idx:
120
+ off, stride, count = self.spec[i]
121
+ t = self.params[i].detach()
122
+ flat = _flat(t)
123
+ if flat is None: # not dense: gather these entries at each update (a small copy)
124
+ flat_idx = torch.arange(off, off + (count - 1) * stride + 1, stride, device=t.device)
125
+ self._gather[(d, len(views))] = (t, _unravel(flat_idx, tuple(t.shape)))
126
+ views.append(None)
127
+ else:
128
+ views.append(flat[off : off + (count - 1) * stride + 1 : stride])
129
+ self._views[d] = views
130
+
131
+ def record(self, u):
132
+ """The sampled entries after update u (u = 0: the starting parameters)."""
133
+ ptrs = [p.data_ptr() for p in self.params]
134
+ if ptrs != self._ptrs:
135
+ self._build_views(ptrs)
136
+ slot = u % self.R
137
+ for d, views in self._views.items():
138
+ if self._gather:
139
+ views = list(views)
140
+ for (dd, j), (t, ix) in self._gather.items():
141
+ if dd == d:
142
+ views[j] = t[ix]
143
+ torch.cat(views, out=self.rings[d][slot])
144
+ self.slot_update[slot] = u
145
+
146
+ def open(self, t, **meta):
147
+ if self.slot_update[t % self.R] == t and t not in self.pending:
148
+ self.pending.append(t)
149
+ self.meta[t] = meta
150
+
151
+ def due(self, u):
152
+ return bool(self.pending) and self.pending[0] + AFTER <= u
153
+
154
+ def reset(self):
155
+ """After a checkpoint is loaded: the ring no longer describes the parameters' history."""
156
+ self.slot_update = [None] * self.R
157
+ self.pending, self.meta = [], {}
158
+
159
+ # ----- once per evaluation --------------------------------------------------------------------------------
160
+
161
+ def resolve(self, u, final=False):
162
+ """Check the pending evaluations whose window is complete at update u (all of them when final). Returns a
163
+ list of (t, meta, round_trips, jumps); round_trips and jumps are lists of dicts naming the tensor."""
164
+ out = []
165
+ while self.pending and (final or self.pending[0] + AFTER <= u):
166
+ t = self.pending.pop(0)
167
+ meta = self.meta.pop(t, {})
168
+ window = self._window(t, u)
169
+ if window is None:
170
+ continue
171
+ updates, rows = window
172
+ trips, jumps = analyze(rows, updates, t, self.sizes, self.threshold, final=final)
173
+ self.checks += 1
174
+ for r in trips + jumps:
175
+ r["name"] = self.names[r["tensor"]]
176
+ jumps = [j for j in jumps if (j["tensor"], j["changed_at"]) not in self.recorded_jumps]
177
+ self.recorded_jumps.update((j["tensor"], j["changed_at"]) for j in jumps)
178
+ # a flagged excursion's way out and back are not jumps of a later evaluation
179
+ self.recorded_jumps.update((r["tensor"], r[k]) for r in trips for k in ("changed_at", "restored_at"))
180
+ self.flags += bool(trips)
181
+ out.append((t, meta, trips, jumps))
182
+ return out
183
+
184
+ def _window(self, t, u):
185
+ """The consecutive recorded updates around t, up to t + AFTER, as (updates, rows on the host, float64)."""
186
+ have = lambda j: j >= 0 and self.slot_update[j % self.R] == j # noqa: E731
187
+ if not have(t):
188
+ return None
189
+ lo = t
190
+ while lo - 1 >= t - BEFORE - 1 and have(lo - 1):
191
+ lo -= 1
192
+ hi = t
193
+ while hi + 1 <= min(u, t + AFTER) and have(hi + 1):
194
+ hi += 1
195
+ if hi == lo:
196
+ return None
197
+ updates = list(range(lo, hi + 1))
198
+ slots = torch.tensor([j % self.R for j in updates])
199
+ parts = [None] * len(self.params)
200
+ for d, idx in self.groups.items():
201
+ block = self.rings[d].index_select(0, slots.to(d)).to("cpu", torch.float64)
202
+ at = 0
203
+ for i in idx:
204
+ parts[i] = block[:, at : at + self.sizes[i]]
205
+ at += self.sizes[i]
206
+ return updates, torch.cat(parts, dim=1)
207
+
208
+
209
+ def analyze(rows, updates, t, sizes, threshold, final=False):
210
+ """rows: (L, S) sampled entries after each of the consecutive `updates` (S = sum(sizes), tensor by tensor); t: the
211
+ evaluated update. Returns (round_trips, jumps), lists of dicts with the tensor's index."""
212
+ L, n = rows.shape[0], len(sizes)
213
+ it = updates.index(t)
214
+ seg = torch.repeat_interleave(torch.arange(n), torch.tensor(sizes, dtype=torch.long))
215
+ D = rows[1:] - rows[:-1] # D[j - 1]: the change of update updates[j]
216
+ m = torch.zeros(L - 1, n, dtype=rows.dtype).index_add_(1, seg, D * D).sqrt()
217
+ wn = torch.zeros(L, n, dtype=rows.dtype).index_add_(1, seg, rows * rows).sqrt()
218
+ typical = torch.zeros(n, dtype=rows.dtype)
219
+ for i in range(n):
220
+ nz = m[:, i][m[:, i] > 0]
221
+ if len(nz) >= MIN_MOVING:
222
+ typical[i] = nz.median()
223
+ big = ((m > threshold * typical) & (m > JUMP_FLOOR * wn[:-1]) & (m > 0)).T.tolist() # big[i][j - 1]: update j
224
+ ml, wl, tl = m.T.tolist(), wn.T.tolist(), typical.tolist()
225
+ trips, jumps, ends = [], [], torch.cumsum(torch.tensor(sizes, dtype=torch.long), 0).tolist()
226
+ for i in range(n):
227
+ lo, hi = ends[i] - sizes[i], ends[i]
228
+ before = [j for j in range(1, it + 1) if big[i][j - 1]]
229
+ if not before:
230
+ continue
231
+ a = before[-1] # row index: the change of update updates[a] is the jump
232
+ away = float((rows[it, lo:hi] - rows[a - 1, lo:hi]).norm())
233
+ if away < SEEN * ml[i][a - 1]:
234
+ continue # moved back before the evaluation: not what it saw
235
+ ref = max(wl[i][a - 1], 1e-30)
236
+ rec = dict(
237
+ tensor=i,
238
+ changed_at=updates[a],
239
+ relative_change=away / ref,
240
+ norm_factor=wl[i][it] / ref,
241
+ typical_relative_change=tl[i] / ref,
242
+ )
243
+ if a < 2 or big[i][a - 2]:
244
+ continue # not a lone change: part of a stretch of large updates (a high learning rate, an oscillation)
245
+ after = [j for j in range(it + 1, L) if big[i][j - 1]]
246
+ if after:
247
+ b = after[0]
248
+ back = float((rows[b, lo:hi] - rows[a - 1, lo:hi]).norm())
249
+ isolated_after = (b + 1 < L and not big[i][b]) or (b + 1 >= L and final)
250
+ if back <= BACK * away and isolated_after:
251
+ cos = torch.nn.functional.cosine_similarity(D[a - 1, lo:hi], D[b - 1, lo:hi], dim=0)
252
+ rec.update(
253
+ restored_at=updates[b],
254
+ remaining=back / max(away, 1e-30),
255
+ norm_factor_after=wl[i][b] / max(wl[i][it], 1e-30),
256
+ cosine=float(cos),
257
+ )
258
+ trips.append(rec)
259
+ continue
260
+ if a + 1 < L and big[i][a]:
261
+ continue # followed by more large updates: a change of pace, not a jump to another point
262
+ jumps.append(rec)
263
+ return trips, jumps
opal/misc/nested.py ADDED
@@ -0,0 +1,67 @@
1
+ """Helpers for nested structures of tensors (updater state, snapshots)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+
7
+ import torch
8
+
9
+
10
+ def devices(obj):
11
+ if torch.is_tensor(obj):
12
+ return obj.device.type
13
+ if isinstance(obj, dict):
14
+ return {k: devices(v) for k, v in obj.items()}
15
+ if isinstance(obj, (list, tuple)):
16
+ return [devices(v) for v in obj]
17
+ return None
18
+
19
+
20
+ def restore_devices(obj, dev, default, key=None):
21
+ """Checkpoints are often loaded with map_location='cpu', and in DDP every rank loads rank 0's file. Put each
22
+ tensor of the updater state back on this rank's device if it was on an accelerator, or on the CPU if it was
23
+ there. Old checkpoints without device records: everything to the device except torch optimizers' step counters."""
24
+ if torch.is_tensor(obj):
25
+ if isinstance(dev, str):
26
+ return obj.to("cpu") if dev == "cpu" else obj.to(default)
27
+ return obj if key == "step" else obj.to(default)
28
+ if isinstance(obj, dict):
29
+ return {
30
+ k: restore_devices(v, dev.get(k) if isinstance(dev, dict) else None, default, k) for k, v in obj.items()
31
+ }
32
+ if isinstance(obj, (list, tuple)):
33
+ out = [
34
+ restore_devices(v, dev[i] if isinstance(dev, list) and i < len(dev) else None, default)
35
+ for i, v in enumerate(obj)
36
+ ]
37
+ return type(obj)(out) if isinstance(obj, tuple) else out
38
+ return obj
39
+
40
+
41
+ def nbytes(obj, seen=None):
42
+ """Bytes of the distinct tensors in a nested structure (a tensor reachable twice counts once)."""
43
+ seen = set() if seen is None else seen
44
+ if torch.is_tensor(obj):
45
+ if id(obj) in seen:
46
+ return 0
47
+ seen.add(id(obj))
48
+ return obj.numel() * obj.element_size()
49
+ if isinstance(obj, dict):
50
+ return sum(nbytes(v, seen) for v in obj.values())
51
+ if isinstance(obj, (list, tuple)):
52
+ return sum(nbytes(v, seen) for v in obj)
53
+ return 0
54
+
55
+
56
+ def to_cpu(obj, memo=None):
57
+ """CPU copy of a nested structure; a tensor reachable twice is copied once."""
58
+ memo = {} if memo is None else memo
59
+ if torch.is_tensor(obj):
60
+ if id(obj) not in memo:
61
+ memo[id(obj)] = obj.detach().to("cpu", copy=True)
62
+ return memo[id(obj)]
63
+ if isinstance(obj, dict):
64
+ return {k: to_cpu(v, memo) for k, v in obj.items()}
65
+ if isinstance(obj, (list, tuple)):
66
+ return type(obj)(to_cpu(v, memo) for v in obj)
67
+ return copy.deepcopy(obj)
@@ -0,0 +1,166 @@
1
+ """The starting analysis.py of the draft neutral prompt (prompt="neutral"): raw measurements only. The default prompt keeps
2
+ programs.STARTING_ANALYSIS."""
3
+
4
+ from __future__ import annotations
5
+
6
+ TEXT = '''"""Analysis: reports what you want to see at your next decision. Runs on rank 0 only, every EVERY updates,
7
+ after the backward pass and before the update. It never changes the update; the updater computes its own signals.
8
+
9
+ obs.update, obs.total_updates, obs.losses (loss of each update since the previous call), obs.params and obs.grads
10
+ (name -> tensor), obs.updater (the running updater object), obs.metrics (values the training script chose to show
11
+ you), obs.program_ms_per_update, obs.activations (only if enabled). memory is a dict that persists across calls.
12
+ Return {{"report": <small JSON>}} to keep it for your next decision, {{"wake": "<reason>"}} to ask for a decision now.
13
+
14
+ Starting program: the mean loss of each of the last 4 calls' windows, the least-squares loss slope per 1,000 updates
15
+ and the standard deviation of per-update losses over the last window{epoch_doc}; per parameter role the
16
+ update-to-weight ratio (since the previous call, and per update) and the cosine between gradient and momentum of one
17
+ sampled tensor, and gradient and weight norms; the gradient norm of each block.
18
+ """
19
+ import math
20
+ import re
21
+
22
+ import torch
23
+
24
+ EVERY = {every}
25
+ EPOCHS = {epochs} # set when the run gave its epochs and the data repeat in the same order each epoch
26
+ WINDOWS = 4
27
+
28
+
29
+ def _role(name):
30
+ return re.sub(r"\\.\\d+\\.", ".*.", name)
31
+
32
+
33
+ def _block(name):
34
+ m = re.search(r"\\.(\\d+)\\.", name)
35
+ return int(m.group(1)) if m else None
36
+
37
+
38
+ def _mean(xs):
39
+ return sum(xs) / len(xs) if xs else None
40
+
41
+
42
+ def _momentum(updater):
43
+ """param -> momentum tensor (exp_avg or momentum_buffer) of the torch optimizers the updater holds."""
44
+ opts = list(getattr(updater, "opts", None) or [])
45
+ opts += [v for v in vars(updater).values() if isinstance(v, torch.optim.Optimizer)] if hasattr(updater, "__dict__") else []
46
+ out = {{}}
47
+ for o in opts:
48
+ for p, st in o.state.items():
49
+ for k in ("exp_avg", "momentum_buffer"):
50
+ if torch.is_tensor(st.get(k)):
51
+ out[p] = st[k]
52
+ break
53
+ return out
54
+
55
+
56
+ def analyze(obs, memory):
57
+ names = [n for n, g in obs.grads.items() if g is not None]
58
+ if not names:
59
+ return None
60
+ report = dict(update=obs.update, program_ms_per_update=obs.program_ms_per_update)
61
+
62
+ # loss: means of the last WINDOWS windows, slope and spread over the last one
63
+ losses = [x for x in obs.losses if math.isfinite(x)]
64
+ report["nonfinite_losses"] = len(obs.losses) - len(losses)
65
+ means = memory.setdefault("window_means", [])
66
+ if losses:
67
+ means.append(_mean(losses))
68
+ del means[:-WINDOWS]
69
+ report["loss_window_means"] = [round(x, 5) for x in means]
70
+ n = len(losses)
71
+ if n >= 2:
72
+ mx, my = (n - 1) / 2, _mean(losses)
73
+ slope = sum((i - mx) * (y - my) for i, y in enumerate(losses)) / sum((i - mx) ** 2 for i in range(n))
74
+ report["loss_slope_per_1000_updates"] = round(1000 * slope, 5)
75
+ report["loss_std"] = round(math.sqrt(sum((y - my) ** 2 for y in losses) / (n - 1)), 5)
76
+ if EPOCHS: # the same updates one epoch earlier saw the same data
77
+ per_epoch = round(obs.total_updates / EPOCHS)
78
+ hist = memory.setdefault("epoch_losses", [])
79
+ hist.extend(obs.losses)
80
+ del hist[: max(0, len(hist) - per_epoch - len(obs.losses))]
81
+ if len(hist) >= per_epoch + len(obs.losses) and obs.losses:
82
+ now = [x for x in hist[-len(obs.losses) :] if math.isfinite(x)]
83
+ then = [x for x in hist[-per_epoch - len(obs.losses) : -per_epoch] if math.isfinite(x)]
84
+ if now and then:
85
+ report["loss_minus_one_epoch_earlier"] = round(_mean(now) - _mean(then), 5)
86
+
87
+ # per role: one sampled tensor (the middle one) for the update-to-weight ratio
88
+ by_role = {{}}
89
+ for n_ in names:
90
+ by_role.setdefault(_role(n_), []).append(n_)
91
+ sample = {{r: ns[len(ns) // 2] for r, ns in by_role.items()}}
92
+ snap, since = memory.get("snapshot", {{}}), obs.update - memory.get("snapshot_update", obs.update)
93
+ ratios = {{}}
94
+ for r, n_ in sample.items():
95
+ w = obs.params[n_].detach()
96
+ if n_ in snap and since > 0:
97
+ ratios[r] = (torch.linalg.vector_norm((w - snap[n_]).float()) / torch.linalg.vector_norm(w.float()).clamp_min(1e-12))
98
+ memory["snapshot"] = {{n_: obs.params[n_].detach().clone() for n_ in sample.values()}} # one tensor per role
99
+ memory["snapshot_update"] = obs.update
100
+
101
+ # norms per role over all tensors; the gradient-momentum cosine of the sampled tensor of each role
102
+ gs = [obs.grads[n_].float() for n_ in names]
103
+ gn = torch.stack(torch._foreach_norm(gs))
104
+ wn = torch.stack(torch._foreach_norm([obs.params[n_].detach().float() for n_ in names]))
105
+ mom = _momentum(obs.updater)
106
+ picked = set(sample.values())
107
+ ms = [mom.get(obs.params[n_]) if n_ in picked else None for n_ in names]
108
+ have = [i for i, m in enumerate(ms) if m is not None]
109
+ if have:
110
+ dots = torch.stack([torch.dot(gs[i].reshape(-1), ms[i].float().reshape(-1)) for i in have])
111
+ mn = torch.stack(torch._foreach_norm([ms[i].float() for i in have]))
112
+ ratio_t = torch.stack(list(ratios.values())) if ratios else None
113
+ gn, wn = gn.tolist(), wn.tolist() # host reads, once per call
114
+ dots, mn = (dots.tolist(), mn.tolist()) if have else ([], [])
115
+ ratio_v = dict(zip(ratios, ratio_t.tolist())) if ratios else {{}}
116
+
117
+ roles = {{}}
118
+ for i, n_ in enumerate(names):
119
+ acc = roles.setdefault(_role(n_), [0.0, 0.0, 0.0, 0.0, 0.0])
120
+ acc[0] += gn[i] ** 2
121
+ acc[1] += wn[i] ** 2
122
+ for j, i in enumerate(have):
123
+ acc = roles[_role(names[i])]
124
+ acc[2] += dots[j]
125
+ acc[3] += gn[i] ** 2
126
+ acc[4] += mn[j] ** 2
127
+ blocks = {{}}
128
+ for i, n_ in enumerate(names):
129
+ b = _block(n_)
130
+ if b is not None:
131
+ blocks[b] = blocks.get(b, 0.0) + gn[i] ** 2
132
+ report.update(
133
+ grad_norm_total=round(math.sqrt(sum(g * g for g in gn)), 6),
134
+ grad_norm_by_role={{r: round(math.sqrt(a[0]), 6) for r, a in roles.items()}},
135
+ weight_norm_by_role={{r: round(math.sqrt(a[1]), 4) for r, a in roles.items()}},
136
+ grad_momentum_cosine_by_role={{
137
+ r: round(a[2] / math.sqrt(a[3] * a[4]), 4) for r, a in roles.items() if a[3] > 0 and a[4] > 0
138
+ }},
139
+ update_to_weight_by_role={{r: round(v, 7) for r, v in ratio_v.items()}},
140
+ update_to_weight_per_update_by_role={{r: round(v / since, 9) for r, v in ratio_v.items()}},
141
+ grad_norm_by_block=[round(math.sqrt(blocks[b]), 6) for b in sorted(blocks)],
142
+ )
143
+ return {{"report": report}}
144
+ '''
145
+
146
+
147
+ def text(every, epochs=None) -> str:
148
+ doc = (
149
+ ", and the mean loss over the last window minus that over the same updates one epoch earlier" if epochs else ""
150
+ )
151
+ return TEXT.format(every=every, epochs=repr(epochs if epochs else None), epoch_doc=doc)
152
+
153
+
154
+ def note(epochs=None) -> str:
155
+ """The prompt's sentences on the starting analysis."""
156
+ epoch = (
157
+ ", and the mean loss over the last window minus that over the same updates one epoch earlier" if epochs else ""
158
+ )
159
+ return (
160
+ "The starting analysis.py reports, at each call, the mean training loss of each of the last 4 windows between "
161
+ "calls, the loss slope per 1,000 updates and the standard deviation of per-update losses over the last window"
162
+ + epoch
163
+ + "; per parameter role, gradient and weight norms, and for one sampled tensor the update-to-weight ratio and the "
164
+ "cosine between gradient and momentum; and the gradient norm of each block. These are starting measurements; "
165
+ "replace or extend them."
166
+ )