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/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
|
+
)
|