File size: 17,078 Bytes
0124748 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | #!/usr/bin/env python3
"""Find when a training run (CSV log) reaches a target loss, after smoothing.
Works for fixed-batch runs and for dynamic-batch runs (batch size changes
mid-run, e.g. train_390M_dynamicb.py). Two cases, both reported:
- target already crossed within the logged data -> interpolated step/tokens.
- target not yet crossed -> fit L(T) = L0 + A*T^-alpha (Eq.14) to the
post-warmup smoothed trajectory and solve for T algebraically, flagging
how far past the observed range the extrapolation reaches.
Everything runs on a TOKEN axis, not steps: once the batch size changes, a step
no longer stands for a fixed amount of data, so L(S) kinks at every switch and
a predicted step can't be compared across runs. For a fixed batch T = B*S, so
L0 + A*S^-alpha and L0 + A'*T^-alpha are the same curve (same L0 and alpha,
A' = A*B^alpha) -- fixed-batch results are unchanged.
Batch size per step: the CSV's `global_batch` column if present (the dynamic
trainer logs it), else --batch-size, else parsed from the filename (_1Mbs).
Tokens consumed before each step: the CSV's `tokens` column if present, else
accumulated from the per-step batch (= step * B for a fixed batch).
"""
import argparse
import re
import sys
import numpy as np
import pandas as pd
from scipy.optimize import curve_fit
EXTRAPOLATION_WARN_FACTOR = 3.0
CONVERGENCE_TOL = 0.05
SWEEP_STEP = 300
SWEEP_MIN_WINDOW = 500
EMA_TOKEN_WINDOW = 100_000_000 # smoothing window in TOKENS, not steps -- see
# batchsize_scaling.py for why: a fixed step
# count covers a different amount of data per
# run depending on batch size, which biases
# the cross-batch-size comparison. The same
# holds across the stages of one dynamic run.
TOKEN_UNIT = 1e9 # the fit's x-axis is tokens / TOKEN_UNIT, which
# keeps A near O(1) so curve_fit's p0 is sane
def power_law(t, l0, a, alpha):
return l0 + a * np.power(t, -alpha)
def parse_batch_size_from_filename(path):
m = re.search(r"_([\d.]+)([MK])bs", path, re.IGNORECASE)
if not m:
return None
value, unit = float(m.group(1)), m.group(2).upper()
return value * (1_000_000 if unit == "M" else 1_000)
def parse_batch_schedule(pairs):
"""TOKENS:BATCH pairs, as train_390M_dynamicb.py's --batch-schedule takes
them -> [(start_tokens, batch), ...] sorted by start."""
try:
return sorted((float(t), float(b)) for t, b in (s.split(":") for s in pairs))
except ValueError:
sys.exit(f"--batch-schedule takes TOKENS:BATCH pairs, got {' '.join(pairs)}")
def load_run(path, loss_col, batch_size_arg):
"""-> (df with per-step `batch` and `tokens` columns, where the batch came from)."""
df = pd.read_csv(path)
if loss_col not in df.columns:
sys.exit(f"'{loss_col}' not in columns: {list(df.columns)}")
# A resumed run appends to its CSV from the checkpoint step, so steps the
# interrupted attempt logged past that checkpoint appear twice -- keep the
# resumed (later) rows.
df = df.drop_duplicates("step", keep="last").sort_values("step").reset_index(drop=True)
if "global_batch" in df.columns:
df["batch"] = df["global_batch"].astype(float)
source = "CSV global_batch column"
else:
b = batch_size_arg or parse_batch_size_from_filename(path)
if b is None:
sys.exit("no global_batch column and could not infer batch size "
"from filename -- pass --batch-size")
df["batch"] = float(b)
source = "--batch-size" if batch_size_arg else "filename"
if "tokens" in df.columns:
df["tokens"] = df["tokens"].astype(float)
else:
# Tokens consumed BEFORE each step (E = B*S for a fixed batch); a gap in
# the logged steps is filled with the batch of the row before it.
steps = df["step"].to_numpy(float)
batch = df["batch"].to_numpy()
tokens = np.empty(len(df))
tokens[0] = steps[0] * batch[0]
tokens[1:] = tokens[0] + np.cumsum(batch[:-1] * np.diff(steps))
df["tokens"] = tokens
df = df[df[loss_col].notna()].reset_index(drop=True)
return df, source
def batch_stages(df):
"""Runs of rows with a constant batch -> [(first_row, last_row, batch), ...]."""
b = df["batch"].to_numpy()
starts = np.flatnonzero(np.r_[True, b[1:] != b[:-1]])
ends = np.r_[starts[1:] - 1, len(b) - 1]
return [(int(s), int(e), float(b[s])) for s, e in zip(starts, ends)]
def token_window_ema(loss, batch, token_window):
"""EMA whose window is fixed in TOKENS: each step's weight uses the span
token_window / (that step's batch), so a batch switch mid-run doesn't change
how much data the smoothing averages over. For a fixed batch B this is
exactly pandas' .ewm(span=token_window / B, adjust=False)."""
span = token_window / batch
if (span < 1).any():
print(f"[WARN] EMA token window ({token_window:,.0f}) < batch size "
f"({batch.max():,.0f}) on some steps -- window is under one step "
"there; clamping span to 1 (effectively no smoothing).")
weight = 2.0 / (np.maximum(span, 1.0) + 1.0)
out = np.empty(len(loss))
ema = loss[0]
for i, (x, w) in enumerate(zip(loss, weight)):
ema = w * x + (1 - w) * ema
out[i] = ema
return out
def detect_warmup_end(df):
lr_max = df["lr"].max()
past_warmup = df["step"][df["lr"] >= 0.99 * lr_max]
return int(past_warmup.iloc[0]) if len(past_warmup) else 0
def interpolated_crossing(df, smoothed, target):
"""-> (step, tokens) where the smoothed loss first reaches target, linearly
interpolated between the two logged rows around it; None if it never does."""
below = np.flatnonzero(smoothed <= target)
if not len(below):
return None
steps, tokens = df["step"].to_numpy(float), df["tokens"].to_numpy()
i = below[0]
if i == 0:
return steps[0], tokens[0]
l0, l1 = smoothed[i - 1], smoothed[i]
frac = 1.0 if l0 == l1 else (l0 - target) / (l0 - l1)
return (steps[i - 1] + frac * (steps[i] - steps[i - 1]),
tokens[i - 1] + frac * (tokens[i] - tokens[i - 1]))
def fit_and_solve(tokens, smoothed, target):
"""-> (predicted tokens, (l0, a, alpha)), or (None, reason). A is in units
of TOKEN_UNIT tokens."""
x = tokens / TOKEN_UNIT
p0 = [smoothed.min() * 0.9, max(smoothed[0] - smoothed.min(), 1e-3), 0.3]
try:
(l0, a, alpha), _ = curve_fit(
power_law, x, smoothed, p0=p0,
bounds=([0, 1e-6, 1e-3], [smoothed.min(), np.inf, 10]),
maxfev=20000,
)
except RuntimeError as e:
return None, f"fit failed to converge: {e}"
if target <= l0:
return None, f"unreachable under this fit -- asymptote L0={l0:.4f} >= target {target}"
t_pred = (a / (target - l0)) ** (1.0 / alpha) * TOKEN_UNIT
return t_pred, (l0, a, alpha)
def tokens_to_step(t, df, schedule=None):
"""Step at which `t` tokens have been consumed. Inside the logged range it
is interpolated from the log. Past it, steps are counted forward from the
last logged row at its batch -- or, when --batch-schedule is given, at each
later stage's batch once its start is reached (the trainer may round a
stage's batch to a micro-batch multiple, so steps there are approximate)."""
steps, tokens = df["step"].to_numpy(float), df["tokens"].to_numpy()
if t <= tokens[-1]:
return float(np.interp(t, tokens, steps))
step, tok, batch = steps[-1], tokens[-1], float(df["batch"].iloc[-1])
for start, next_batch in (schedule or []):
if start <= tok:
continue
n = np.ceil((start - tok) / batch) # steps still run at `batch`
if t <= tok + n * batch:
break
step, tok, batch = step + n, tok + n * batch, next_batch
return float(step + (t - tok) / batch)
def stability_sweep(df, smoothed, fit_start, target, sweep_step, min_window, schedule):
s_max_total = int(df["step"].max())
checkpoints = list(range(fit_start + min_window, s_max_total, sweep_step))
if not checkpoints or checkpoints[-1] != s_max_total:
checkpoints.append(s_max_total)
rows = []
for max_step in checkpoints:
mask = ((df["step"] >= fit_start) & (df["step"] <= max_step)).to_numpy()
if mask.sum() < 10:
continue
tokens = df["tokens"].to_numpy()[mask]
t_pred, info = fit_and_solve(tokens, smoothed[mask], target)
s_pred = tokens_to_step(t_pred, df, schedule) if t_pred is not None else None
rows.append((max_step, tokens[-1], t_pred, s_pred, info))
return rows
def print_sweep(target, rows, convergence_tol, warn_factor):
print(f"\ntarget={target} -- stability sweep")
print(f"{'max_step':>10} {'s_pred':>14} {'t_pred':>18} {'ratio':>8} {'L0':>8} {'alpha':>8} note")
for max_step, t_max, t_pred, s_pred, info in rows:
if t_pred is None:
print(f"{max_step:>10} {'--':>14} {'--':>18} {'--':>8} {'--':>8} {'--':>8} {info}")
continue
l0, a, alpha = info
ratio = t_pred / t_max
flag = " [beyond warn factor]" if ratio > warn_factor else ""
print(f"{max_step:>10} {s_pred:>14,.0f} {t_pred:>18,.0f} {ratio:>7.2f}x "
f"{l0:>8.4f} {alpha:>8.4f}{flag}")
# Judged on predicted tokens; for a fixed batch that is the same verdict as on steps.
valid = [t for _, _, t, _, _ in rows if t is not None]
if len(valid) < 3:
print("verdict: NOT ENOUGH DATA to judge convergence yet (need >=3 valid windows)")
return
t_prev2, t_prev1, t_last = valid[-3:]
rel_changes = [abs(t_prev1 - t_prev2) / t_prev2, abs(t_last - t_prev1) / t_prev1]
if all(c <= convergence_tol for c in rel_changes):
print(f"verdict: CONVERGED -- last two window-to-window changes "
f"({rel_changes[0]:.1%}, {rel_changes[1]:.1%}) both within tolerance {convergence_tol:.0%}")
else:
print(f"verdict: NOT YET CONVERGED -- last two window-to-window changes "
f"({rel_changes[0]:.1%}, {rel_changes[1]:.1%}) exceed tolerance {convergence_tol:.0%}; "
f"keep training and re-check")
def print_stages(df, stages):
print(f"{'stage':>7} {'steps':>17} {'tokens (before step)':>33} {'batch':>11}")
for k, (r0, r1, b) in enumerate(stages, 1):
s0, s1 = df["step"].iloc[r0], df["step"].iloc[r1]
t0, t1 = df["tokens"].iloc[r0], df["tokens"].iloc[r1]
print(f"{k:>7} {s0:>8} - {s1:<6} {t0:>15,.0f} - {t1:<15,.0f} {b:>11,.0f}")
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("csv")
p.add_argument("--target", type=float, nargs="+", required=True)
p.add_argument("--loss-col", default="train_loss")
p.add_argument("--ema-token-window", type=float, default=EMA_TOKEN_WINDOW,
help=f"EMA smoothing window in TOKENS, not steps (default: "
f"{EMA_TOKEN_WINDOW:,.0f}) -- converted to a step-based "
"span using each step's own batch size, so different "
"batch sizes (across runs, or across the stages of a "
"dynamic run) get an equivalent amount of data smoothed, "
"not an equivalent step count")
p.add_argument("--batch-size", type=float, default=None,
help="tokens per step for a fixed-batch run; parsed from the "
"filename (e.g. _1Mbs) if omitted. Ignored when the CSV "
"has a global_batch column (dynamic-batch runs)")
p.add_argument("--batch-schedule", nargs="+", default=None, metavar="TOKENS:BATCH",
help="the run's batch schedule, same format as "
"train_390M_dynamicb.py --batch-schedule (e.g. "
"'0:589824 2e9:786432 8.3e9:819200'). Only used to "
"convert an extrapolated token count back to a step "
"when it lands in a stage the log hasn't reached yet; "
"without it, the last logged batch is assumed to continue")
p.add_argument("--fit-start-step", type=int, default=None,
help="default: auto-detected end of LR warmup")
p.add_argument("--fit-last-stage", action="store_true",
help="start the fit no earlier than the first step of the "
"last batch stage in the log, so the extrapolation "
"reflects the batch size the run is continuing at")
p.add_argument("--extrapolation-warn-factor", type=float,
default=EXTRAPOLATION_WARN_FACTOR)
p.add_argument("--stability-sweep", action="store_true",
help="instead of a single prediction, refit on growing windows "
"of data and report whether the extrapolated target has "
"converged yet")
p.add_argument("--sweep-step", type=int, default=SWEEP_STEP,
help="steps between successive sweep windows (default: 300)")
p.add_argument("--sweep-min-window", type=int, default=SWEEP_MIN_WINDOW,
help="post-warmup steps before the first sweep window (default: 500)")
p.add_argument("--convergence-tol", type=float, default=CONVERGENCE_TOL,
help="max relative change between successive windows to call "
"it converged (default: 0.05 = 5%%)")
args = p.parse_args()
df, batch_source = load_run(args.csv, args.loss_col, args.batch_size)
schedule = parse_batch_schedule(args.batch_schedule) if args.batch_schedule else None
stages = batch_stages(df)
dynamic = len(stages) > 1
fit_start = args.fit_start_step
if fit_start is None:
fit_start = detect_warmup_end(df)
if args.fit_last_stage:
fit_start = max(fit_start, int(df["step"].iloc[stages[-1][0]]))
batch = df["batch"].to_numpy()
smoothed = token_window_ema(df[args.loss_col].to_numpy(float), batch,
args.ema_token_window)
post = (df["step"] >= fit_start).to_numpy()
if post.sum() < 10:
sys.exit(f"only {post.sum()} logged rows at or after fit start step {fit_start}")
tokens_post = df["tokens"].to_numpy()[post]
s_max_observed = df["step"].max()
t_max_observed = df["tokens"].max()
spans = args.ema_token_window / np.array([b for _, _, b in stages])
if dynamic:
batch_desc = (f"batch: DYNAMIC, {len(stages)} stages "
f"({stages[0][2]:,.0f} -> {stages[-1][2]:,.0f} tokens/step, "
f"from {batch_source})")
span_desc = f"~{spans.max():.1f}-{spans.min():.1f} steps across stages"
else:
batch_desc = f"batch size: {stages[0][2]:,.0f} tokens/step (from {batch_source})"
span_desc = f"~{spans[0]:.1f} steps at this batch size"
print(f"loss col: {args.loss_col} | ema token window: {args.ema_token_window:,.0f} "
f"({span_desc}) | {batch_desc} | fit start step: {fit_start}")
if dynamic:
print_stages(df, stages)
print("-" * 100)
for target in args.target:
cross = interpolated_crossing(df, smoothed, target)
if cross is not None:
s_cross, t_cross = cross
print(f"target={target:<8} REACHED (interpolated): "
f"step={s_cross:,.1f} tokens={t_cross:,.0f}")
continue
if args.stability_sweep:
rows = stability_sweep(df, smoothed, fit_start, target, args.sweep_step,
args.sweep_min_window, schedule)
print_sweep(target, rows, args.convergence_tol, args.extrapolation_warn_factor)
continue
t_pred, info = fit_and_solve(tokens_post, smoothed[post], target)
if t_pred is None:
print(f"target={target:<8} NOT REACHED, fit says: {info}")
continue
l0, a, alpha = info
s_pred = tokens_to_step(t_pred, df, schedule)
ratio = t_pred / t_max_observed
flag = " ** EXTRAPOLATION BEYOND WARN FACTOR **" if ratio > args.extrapolation_warn_factor else ""
step_note = ""
if dynamic and schedule is None:
step_note = f" [step assumes batch stays {stages[-1][2]:,.0f}; pass --batch-schedule]"
print(f"target={target:<8} EXTRAPOLATED (fit L0={l0:.4f} A={a:.4f} alpha={alpha:.4f}, "
f"T in {TOKEN_UNIT:.0e} tokens): step={s_pred:,.1f} tokens={t_pred:,.0f} "
f"({ratio:.2f}x past observed T={t_max_observed:,.0f}, "
f"S={s_max_observed:,.0f}){flag}{step_note}")
if __name__ == "__main__":
main()
|