Files
esh-pfi-infrastructure/scripts/training-probes/step_bucket.py
T
vh 7b5fd91d3c docs(gemma4-erp-tune): root-cause the 8.6% MFU — attention on Ampere kernels, 29.9% padding
Run-01 was killed at step 19 by operator instruction to root-cause before
spending a ~13.9h window. Two independent methods now agree on where the step
time went, and neither was the hypothesis the consult panel converged on.

Scaling fit (3 points, 2 params, residuals <3ms over an 8x range):
  A = 6.87e-4 s/token, B = 8.85e-8 s/token^2
  quadratic share 20.9% @ w=2048 -> 67.8% @ w=16384
  No fixed term was needed, which refutes launch-bound outright.

Profiler kernel table (device rows only):
  attention   22,835.8 ms  65.2%   fmha_cutlass*_sm80
  dense GEMM   2,774.0 ms   7.9%
  other        5,739.0 ms  16.4%

The attention kernels are sm80 — Ampere-generation CUTLASS running on an
sm_120 Blackwell card, with the forward on the gmem fallback tier. That is the
mechanism behind 100% SM utilisation at 27 of 304 available TFLOPS.

Correctness cleared separately: the sliding mask asserts at max 1024
allowed/row, so the 25 windowed layers were genuinely windowed. The same probe
found that right-padding is what pins the 5 global layers to an explicit 4D
mask and off the is_causal fast path — measured at 9.4% slower for 24% less
loss work at fixed width.

The largest available win is not the attention kernel. The corpus is 29.9%
padding, and bucket-to-pair + shuffle-to-mix takes it to 0.0% for >=35.5% wall
clock, no new dependency, unchanged peak memory. Bucket size turned out not to
be a diversity knob — roots per accumulation window are flat across a 256x
range, so the global micro-batch shuffle does that work alone and the bucket
should be tight.

Adds docs/pfi/training-throughput-playbook.md as the durable model-agnostic
home (sibling to the quantization playbook), the four probes under
scripts/training-probes/ with raw output kept for re-derivation, and a §6 to
the sizing doc carrying the Gemma-4-specific numbers and round-2 restart
parameters.

Measured negatives recorded so they are not re-chased: grouped_mm (0.9%
slower, and MoE is only 7.9% of the step), CUDA graphs / torch.compile over
the expert loop (no fixed cost to amortise), liger fused CE (~1-3% lever),
FA4 on sm_120.

Round-1 state preserved: 609MB encode cache, order manifest, truncation
report, resume script. No checkpoints — it died at step 19 and the first was
due at 100, so the lora_B inert-adapter gate never ran and moves to the
restart.
2026-08-24 22:10:51 -07:00

113 lines
4.4 KiB
Python

"""Measure bucket-to-pair / shuffle-to-mix against the REAL encode cache.
Brokkr's design, validated on measured record lengths rather than a calibrated
length model:
1. sort records by length
2. cut into buckets of BUCKET records
3. form micro-batches of 2 WITHIN each bucket (adjacent after sort)
4. shuffle the resulting MICRO-BATCHES globally, seeded
Padding efficiency is a property of the pairing only, so step 4 costs nothing
and restores root-mixing inside each accumulation window.
Also applies the fitted cost model from the replica scaling test to convert
token savings into predicted wall clock.
"""
import json
import random
from collections import Counter
CACHE = "/tank/erp-tune/run-01/encode-cache/encoded-a4b0796de1260930.jsonl"
IGNORE_INDEX = -100
MB = 2
ACCUM = 8
SEED = 20260824
# fitted on the replica: t(w) = A*w + B*w^2 for a batch of 2 sequences of len w
A = 6.8715e-04
B = 8.8509e-08
rows = []
with open(CACHE) as fh:
for line in fh:
r = json.loads(line)
rows.append((len(r["input_ids"]), r.get("dataset_id", "?"),
r.get("sample_kind", "?")))
n = len(rows)
print("records %d" % n)
print()
def evaluate(order, label, show_roots=False):
real = padded = 0
widths = []
batches = []
for i in range(0, n - n % MB, MB):
grp = [rows[j] for j in order[i:i + MB]]
w = max(g[0] for g in grp)
real += sum(g[0] for g in grp)
padded += w * MB
widths.append(w)
batches.append([g[1] for g in grp])
nb = len(widths)
Ew = sum(widths) / nb
Ew2 = sum(w * w for w in widths) / nb
t_mb = A * Ew + B * Ew2
srt = sorted(widths)
print("--- %s ---" % label)
print(" padded tokens %s" % f"{padded:,}")
print(" waste %.1f%%" % (100 * (1 - real / padded)))
print(" E[w] (per-seq) %.0f" % Ew)
print(" E[w^2] %.3e" % Ew2)
print(" width p50/p90/p99 %d / %d / %d" % (
srt[nb // 2], srt[int(nb * .9)], srt[int(nb * .99)]))
print(" predicted micro-batch %.3f s (lin %.3f + quad %.3f, quad %.0f%%)" % (
t_mb, A * Ew, B * Ew2, 100 * B * Ew2 / t_mb))
print(" predicted step (x%d) %.1f s -> %.2f h over 1312 steps" % (
ACCUM, t_mb * ACCUM, t_mb * ACCUM * 1312 / 3600))
# unpadded micro-batches take the is_causal fast path on the 5 global layers
exact = sum(1 for i in range(0, n - n % MB, MB)
if len(set(rows[j][0] for j in order[i:i + MB])) == 1)
print(" ZERO-PAD micro-batches %d / %d (%.1f%%) <- global layers on is_causal" % (
exact, nb, 100 * exact / nb))
if show_roots:
# root diversity inside an accumulation window
div = []
for i in range(0, nb - nb % ACCUM, ACCUM):
win = [d for b in batches[i:i + ACCUM] for d in b]
div.append(len(set(win)))
print(" roots per accum window mean %.2f min %d (of %d roots)" % (
sum(div) / len(div), min(div), len({r[1] for r in rows})))
print()
return padded, t_mb
# --- current: encode-cache order, SequentialSampler ---
cur_padded, cur_t = evaluate(list(range(n)), "CURRENT (SequentialSampler)", True)
# --- bucket-to-pair + shuffle-to-mix ---
# BUCKET controls the efficiency-vs-diversity trade: records are globally
# sorted, cut into buckets of BUCKET, SHUFFLED WITHIN the bucket (not
# re-sorted), then paired adjacently. BUCKET=2 is a perfect global sort
# (0% waste, worst root mixing); larger buckets admit more length spread
# inside a pair but draw partners from a wider slice of the corpus.
for BUCKET in (2, 8, 32, 128, 512):
by_len = sorted(range(n), key=lambda i: rows[i][0])
rng = random.Random(SEED)
micro = []
for s in range(0, n, BUCKET):
chunk = by_len[s:s + BUCKET]
rng.shuffle(chunk) # mix WITHIN the length bucket
for k in range(0, len(chunk) - len(chunk) % MB, MB):
micro.append(chunk[k:k + MB])
rng.shuffle(micro) # shuffle-to-mix across buckets
order = [i for b in micro for i in b]
placed = set(order)
order += [i for i in by_len if i not in placed]
p, t = evaluate(order, "BUCKET=%d, shuffle within + global micro-batch shuffle" % BUCKET,
True)
print(" >>> vs current: %.1f%% fewer padded tokens, %.1f%% less wall clock" % (
100 * (1 - p / cur_padded), 100 * (1 - t / cur_t)))
print()