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.
This commit is contained in:
2026-08-24 22:10:51 -07:00
parent 872c2c562f
commit 7b5fd91d3c
9 changed files with 1239 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# Training throughput probes
Instruments for finding where a training step's time actually went. Written
2026-08-24 during the Gemma-4 26B-A4B ERP/RP tune investigation; the lessons
they produced live in
[`docs/pfi/training-throughput-playbook.md`](../../docs/pfi/training-throughput-playbook.md).
**These are diagnostic instruments, not production code.** They hard-code paths
for that run. Adapt the constants at the top; keep the measurement design.
## The probes
| script | settles | GPU | runtime |
|---|---|---|---|
| `step0_mask.py` | mask band structure; which layers keep the `is_causal` fast path | no | ~30 s |
| `step2_padding.py` | padding waste, length distribution, CE chunk sizing | no | ~2 min |
| `step_bucket.py` | bucketing gain, bucket-size sweep, source diversity | no | ~3 min |
| `step1_profile.py` | scaling fit, padding penalty, CE wall clock, kernel table | **yes** | ~15 min |
Run in that order. Only the last needs the real checkpoint, and it wants an
idle card — it loads ~48 GiB and peaks near 77 GiB at `2 × 16,384`.
## Design rules worth preserving when you adapt these
**`step1_profile.py` reuses the harness's own `discover_target_modules` and
replicates its `compute_loss` byte-for-byte** rather than re-implementing the
step. A probe that reimplements the training step measures the probe. If you
port this, keep the import from the real harness.
**`step0_mask.py` needs no weights and no GPU** — SDPA backend selection and
mask construction depend on shapes, dtype and mask presence, not on weight
values. That is what makes the correctness assertion cheap enough to run before
every job.
**The scaling test takes three points, not two.** Two points over three
plausible terms (quadratic, linear, fixed-per-batch) is underdetermined; see
playbook §1.1 for the hour that cost.
**`step_bucket.py` sweeps bucket size deliberately.** The first version
re-sorted within each bucket, which silently collapsed every bucket size to a
full global sort and made the sweep a no-op. If you change the pairing logic,
check that the sweep still varies something.
## Raw evidence
`step1-profile-output-2026-08-24.txt` is the unedited output of the run the
playbook's numbers come from — scaling points, padding penalty, CE timing, and
the full `key_averages()` kernel table. Kept so the claims can be re-derived
rather than taken on faith.
⚠ That table **double-counts**: `key_averages()` lists both the ATen op and the
CUDA kernel it launched, each carrying the same self device time. Sum device
kernel rows only. See playbook §3.4.
+75
View File
@@ -0,0 +1,75 @@
"""Step 0 - assert the sliding mask band structure, and record which path
mask creation actually takes under the run config (attn_implementation=sdpa).
Correctness gate: transformers can SILENTLY skip mask creation and pass
attention_mask=None, which would make the 25 sliding layers do full causal
attention - a different model from the one vLLM serves. This converts
"probably fine because we are slow" into a measurement.
CPU only. No weights. No GPU.
"""
import torch
from transformers import AutoConfig
from transformers.masking_utils import (
create_causal_mask, create_sliding_window_causal_mask,
)
MODEL = "/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16"
N = 16384
W = 1024
PAD = " " + " " * 20
cfg = AutoConfig.from_pretrained(MODEL)
text = cfg.get_text_config()
text._attn_implementation = "sdpa"
print("sliding_window %s" % text.sliding_window)
print("layers %d (%d sliding / %d full)" % (
len(text.layer_types),
text.layer_types.count("sliding_attention"),
text.layer_types.count("full_attention")))
print("_attn_implementation %s" % text._attn_implementation)
print()
def build(attn_2d, label):
batch = attn_2d.shape[0] if attn_2d is not None else 1
embeds = torch.zeros(batch, N, 8, dtype=torch.bfloat16)
pos = torch.arange(N).unsqueeze(0)
kw = dict(config=text, inputs_embeds=embeds, attention_mask=attn_2d,
past_key_values=None, position_ids=pos)
full = create_causal_mask(**kw)
slide = create_sliding_window_causal_mask(**kw)
print("--- %s ---" % label)
for name, m in (("full_attention", full), ("sliding_attention", slide)):
if m is None:
print(" %-20s None -> flash / is_causal path AVAILABLE" % name)
continue
print(" %-20s tensor shape=%s dtype=%s" % (name, tuple(m.shape), m.dtype))
allowed = m if m.dtype == torch.bool else (m == 0)
per_row = allowed[0, 0].sum(-1)
print("%sallowed/row min=%d max=%d mean=%.1f" % (
PAD, per_row.min().item(), per_row.max().item(),
per_row.float().mean().item()))
if name == "sliding_attention":
ok = per_row.max().item() <= W
print("%sBAND <= %d ? %s" % (PAD, W, "PASS" if ok else "FAIL"))
sat = (per_row >= W).nonzero()
if sat.numel():
print("%ssaturates at row %d" % (PAD, sat[0].item()))
else:
print("%slast row allows %d of %d (%s)" % (
PAD, per_row[-1].item(), N,
"causal-full OK" if per_row[-1].item() == N else "UNEXPECTED"))
print()
# 1. no 2D mask at all - the "constraints silently dropped" scenario
build(None, "attention_mask=None (no padding info)")
# 2. all-ones 2D mask - equal-length batch, no padding
build(torch.ones(2, N, dtype=torch.long), "all-ones 2D (no padding)")
# 3. REAL right-padded batch - what collate_mixed actually produces
real = torch.ones(2, N, dtype=torch.long)
real[1, 6000:] = 0
build(real, "right-padded 2D (what collate_mixed emits)")
@@ -0,0 +1,126 @@
========================================================================
loading model
========================================================================
Loading weights: 0%| | 0/1013 [00:00<?, ?it/s]
Loading weights: 0%| | 2/1013 [00:00<01:19, 12.71it/s]
Loading weights: 0%| | 4/1013 [00:00<01:10, 14.40it/s]
Loading weights: 2%|▏ | 25/1013 [00:00<00:16, 61.02it/s]
Loading weights: 5%|▍ | 48/1013 [00:00<00:12, 79.42it/s]
Loading weights: 7%|▋ | 70/1013 [00:00<00:10, 87.32it/s]
Loading weights: 9%|▉ | 92/1013 [00:01<00:10, 91.14it/s]
Loading weights: 11%|█ | 113/1013 [00:01<00:09, 92.58it/s]
Loading weights: 13%|█▎ | 135/1013 [00:01<00:09, 95.13it/s]
Loading weights: 15%|█▌ | 157/1013 [00:01<00:08, 97.82it/s]
Loading weights: 18%|█▊ | 179/1013 [00:02<00:08, 101.21it/s]
Loading weights: 20%|█▉ | 201/1013 [00:02<00:08, 100.37it/s]
Loading weights: 22%|██▏ | 223/1013 [00:02<00:07, 102.12it/s]
Loading weights: 24%|██▍ | 244/1013 [00:02<00:07, 104.64it/s]
Loading weights: 26%|██▋ | 266/1013 [00:02<00:07, 105.68it/s]
Loading weights: 28%|██▊ | 288/1013 [00:03<00:06, 106.87it/s]
Loading weights: 30%|███ | 308/1013 [00:03<00:05, 122.26it/s]
Loading weights: 32%|███▏ | 322/1013 [00:03<00:05, 117.76it/s]
Loading weights: 33%|███▎ | 335/1013 [00:03<00:06, 99.26it/s]
Loading weights: 35%|███▍ | 354/1013 [00:03<00:06, 100.53it/s]
Loading weights: 37%|███▋ | 375/1013 [00:03<00:06, 104.62it/s]
Loading weights: 39%|███▉ | 397/1013 [00:04<00:05, 103.34it/s]
Loading weights: 41%|████▏ | 419/1013 [00:04<00:05, 106.05it/s]
Loading weights: 44%|████▎ | 441/1013 [00:04<00:05, 109.33it/s]
Loading weights: 46%|████▌ | 463/1013 [00:04<00:05, 107.04it/s]
Loading weights: 48%|████▊ | 485/1013 [00:04<00:04, 107.29it/s]
Loading weights: 50%|█████ | 507/1013 [00:05<00:04, 105.14it/s]
Loading weights: 52%|█████▏ | 528/1013 [00:05<00:04, 103.18it/s]
Loading weights: 54%|█████▍ | 550/1013 [00:05<00:04, 102.05it/s]
Loading weights: 56%|█████▋ | 572/1013 [00:05<00:04, 101.82it/s]
Loading weights: 59%|█████▊ | 594/1013 [00:05<00:03, 106.80it/s]
Loading weights: 61%|██████ | 616/1013 [00:06<00:03, 104.33it/s]
Loading weights: 63%|██████▎ | 638/1013 [00:06<00:03, 103.57it/s]
Loading weights: 77%|███████▋ | 778/1013 [00:06<00:00, 320.91it/s]
Loading weights: 91%|█████████ | 920/1013 [00:06<00:00, 532.70it/s]
Loading weights: 100%|██████████| 1013/1013 [00:06<00:00, 152.35it/s]
loaded in 9.8s targets=205
final_logit_softcapping = 30.0
attn_implementation = sdpa
========================================================================
A. SEQUENCE SCALING (no padding - isolates n)
========================================================================
2 x 2,048 1.776 s kept=3227 peak= 53.2 GiB
2 x 8,192 11.570 s kept=13050 peak= 62.3 GiB
2 x 16,384 35.017 s kept=25989 peak= 76.6 GiB
16384 -> 2048 ratio 19.71x (linear ~8x, launch-bound ~1x, quadratic ~64x)
16384 -> 8192 ratio 3.03x (linear ~2x, quadratic ~4x)
========================================================================
B. PADDING PENALTY (same real tokens, with vs without pad)
========================================================================
2 x 16,384 no padding 35.244 s kept=26048 peak= 76.6 GiB
2 x 16,384 50% pad on row 1 38.567 s kept=19640 peak= 77.8 GiB
========================================================================
C. ISOLATED CE WALL CLOCK
========================================================================
2 x 16,384 (CE timed) 35.329 s kept=26210 peak= 76.6 GiB CE=374 ms (1.1%)
2 x 4,096 (CE timed) 4.387 s kept=6512 peak= 55.3 GiB CE=93 ms (2.1%)
========================================================================
D. KERNEL TABLE - one fwd+bwd at 2 x 16,384
========================================================================
USDT:2026-08-24 22:03:51 574811:574811 SyncActivityProfilerHandler.cpp:52] profiler_start
USDT:2026-08-24 22:04:27 574811:574811 SyncActivityProfilerHandler.cpp:59] profiler_stop
------------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
Name Self CPU % Self CPU CPU total % CPU total CPU time avg Self CUDA Self CUDA % CUDA total CUDA time avg # of Calls
------------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
aten::_efficient_attention_backward 0.00% 573.671us 0.00% 1.681ms 56.028us 16.145s 45.71% 16.156s 538.546ms 30
fmha_cutlassB_bf16_aligned_128x64_k65536_sm80(PyTorc... 0.00% 0.000us 0.00% 0.000us 0.000us 16.145s 45.71% 16.145s 538.152ms 30
aten::_efficient_attention_forward 0.00% 795.660us 0.01% 1.933ms 32.218us 6.691s 18.94% 6.691s 111.519ms 60
fmha_cutlassF_bf16_aligned_32x128_gmem_sm80(PyTorchM... 0.00% 0.000us 0.00% 0.000us 0.000us 6.691s 18.94% 6.691s 111.519ms 60
aten::mm 0.51% 180.757ms 0.77% 270.268ms 10.614us 3.745s 10.60% 3.745s 147.063us 25463
aten::mul 0.13% 45.428ms 0.18% 64.624ms 12.129us 2.721s 7.70% 2.721s 510.606us 5328
aten::copy_ 0.06% 19.823ms 92.46% 32.574s 6.100ms 1.977s 5.60% 1.977s 370.189us 5340
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s1681... 0.00% 0.000us 0.00% 0.000us 0.000us 1.542s 4.37% 1.542s 656.804us 2348
void at::native::elementwise_kernel<128, 2, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 1.033s 2.92% 1.033s 545.219us 1894
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s1681... 0.00% 0.000us 0.00% 0.000us 0.000us 868.642ms 2.46% 868.642ms 583.373us 1489
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 787.028ms 2.23% 787.028ms 395.095us 1992
void at::native::unrolled_elementwise_kernel<at::nat... 0.00% 0.000us 0.00% 0.000us 0.000us 697.963ms 1.98% 697.963ms 304.521us 2292
aten::masked_fill_ 0.01% 3.403ms 0.01% 4.927ms 27.373us 576.927ms 1.63% 576.927ms 3.205ms 180
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 561.815ms 1.59% 561.815ms 413.403us 1359
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 469.818ms 1.33% 469.818ms 459.255us 1023
aten::add_ 0.01% 3.730ms 0.02% 6.640ms 7.209us 448.665ms 1.27% 448.665ms 487.150us 921
void cutlass::Kernel2<cutlass_80_tensorop_bf16_s1681... 0.00% 0.000us 0.00% 0.000us 0.000us 363.206ms 1.03% 363.206ms 394.789us 920
aten::add 0.03% 9.745ms 0.04% 14.287ms 10.205us 356.226ms 1.01% 356.226ms 254.447us 1400
void at::native::elementwise_kernel<128, 2, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 352.705ms 1.00% 352.705ms 1.959ms 180
aten::index 0.01% 4.542ms 0.09% 32.242ms 132.682us 352.404ms 1.00% 352.430ms 1.450ms 243
void at::native::vectorized_gather_kernel<16, long>(... 0.00% 0.000us 0.00% 0.000us 0.000us 351.768ms 1.00% 351.768ms 1.933ms 182
Memcpy DtoD (Device -> Device) 0.00% 0.000us 0.00% 0.000us 0.000us 341.588ms 0.97% 341.588ms 634.922us 538
aten::pow 0.06% 21.418ms 0.11% 37.019ms 18.659us 332.072ms 0.94% 493.271ms 248.624us 1984
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 330.315ms 0.94% 330.315ms 499.720us 661
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 316.108ms 0.89% 316.108ms 383.161us 825
aten::sum 0.01% 5.258ms 0.02% 7.700ms 13.461us 286.461ms 0.81% 286.464ms 500.811us 572
aten::native_dropout 0.02% 6.453ms 0.03% 11.139ms 27.168us 258.244ms 0.73% 258.244ms 629.865us 410
void at::native::(anonymous namespace)::fused_dropou... 0.00% 0.000us 0.00% 0.000us 0.000us 258.244ms 0.73% 258.244ms 629.865us 410
void at::native::unrolled_elementwise_kernel<at::nat... 0.00% 0.000us 0.00% 0.000us 0.000us 239.055ms 0.68% 239.055ms 583.061us 410
aten::_index_put_impl_ 0.01% 4.170ms 3.22% 1.134s 7.508ms 235.816ms 0.67% 236.930ms 1.569ms 151
void at::native::elementwise_kernel<128, 4, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 231.434ms 0.66% 231.434ms 385.081us 601
void at::native::vectorized_elementwise_kernel<4, at... 0.00% 0.000us 0.00% 0.000us 0.000us 226.451ms 0.64% 226.451ms 692.511us 327
void at::native::elementwise_kernel<128, 4, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 224.222ms 0.63% 224.222ms 2.491ms 90
void cutlass::Kernel2<cutlass_80_simt_sgemm_64x128_8... 0.00% 0.000us 0.00% 0.000us 0.000us 219.030ms 0.62% 219.030ms 534.219us 410
void at::native::elementwise_kernel<128, 4, at::nati... 0.00% 0.000us 0.00% 0.000us 0.000us 209.409ms 0.59% 209.409ms 1.745ms 120
aten::div 0.01% 4.911ms 0.02% 6.652ms 13.278us 192.925ms 0.55% 192.925ms 385.080us 501
void (anonymous namespace)::indexing_backward_kernel... 0.00% 0.000us 0.00% 0.000us 0.000us 183.000ms 0.52% 183.000ms 6.100ms 30
void at::native::unrolled_elementwise_kernel<at::nat... 0.00% 0.000us 0.00% 0.000us 0.000us 148.335ms 0.42% 148.335ms 988.899us 150
aten::mean 0.02% 6.214ms 0.02% 8.406ms 12.717us 144.904ms 0.41% 144.904ms 219.220us 661
void at::native::reduce_kernel<512, 1, at::native::R... 0.00% 0.000us 0.00% 0.000us 0.000us 144.904ms 0.41% 144.904ms 219.220us 661
aten::_log_softmax 0.00% 527.616us 0.00% 716.309us 13.775us 139.294ms 0.39% 139.294ms 2.679ms 52
void at::native::(anonymous namespace)::cunn_SoftMax... 0.00% 0.000us 0.00% 0.000us 0.000us 139.294ms 0.39% 139.294ms 2.679ms 52
void at::native::reduce_kernel<512, 1, at::native::R... 0.00% 0.000us 0.00% 0.000us 0.000us 137.743ms 0.39% 137.743ms 286.368us 481
void at::native::reduce_kernel<128, 4, at::native::R... 0.00% 0.000us 0.00% 0.000us 0.000us 130.526ms 0.37% 130.526ms 1.088ms 120
aten::native_dropout_backward 0.00% 1.378ms 0.01% 3.156ms 15.394us 118.690ms 0.34% 118.690ms 578.975us 205
------------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
Self CPU time total: 35.229s
Self CUDA time total: 35.322s
========================================================================
E. LAUNCH COUNTS (grouped_mm: 128/layer sequential = no-op, 1 = grouped)
+206
View File
@@ -0,0 +1,206 @@
"""Steps 1/2/4 - profiler kernel table, sequence scaling, isolated CE timing.
Loads the real model exactly as erp_sft_harness.runtime does (same
from_pretrained args, same PEFT config, same gradient checkpointing, same
chunked-CE compute_loss) and measures:
A. sequence scaling 2x2048 / 2x8192 / 2x16384 fwd+bwd
linear-dominated -> time falls ~8x from 16384 to 2048
launch-bound -> time barely falls
quadratic-dominated -> time falls ~64x
B. isolated CE wall clock (CUDA events around the chunked-CE block)
C. torch.profiler kernel table, sorted by self CUDA time
D. expert-GEMM launch counts (settles grouped_mm without kernel-name
archaeology: 128 sequential launches per layer = no-op, 1 = grouped)
Runs on GPU0, which is reserved and idle. Nothing else touches it.
"""
import json
import sys
import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
sys.path.insert(0, "/tank/erp-tune/eitri-smithy")
from erp_sft_harness.core import IGNORE_INDEX, discover_target_modules
MODEL = "/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16"
CHUNK = 1024
MB = 2
print("=" * 72)
print("loading model")
print("=" * 72, flush=True)
t0 = time.time()
model = AutoModelForCausalLM.from_pretrained(
MODEL, dtype=torch.bfloat16, device_map={"": 0}, attn_implementation="sdpa",
)
targets = discover_target_modules(model)
model = get_peft_model(model, LoraConfig(
r=64, lora_alpha=128, lora_dropout=0.05, target_modules=targets,
bias="none", task_type="CAUSAL_LM",
))
model.enable_input_require_grads()
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.train()
print("loaded in %.1fs targets=%d" % (time.time() - t0, len(targets)), flush=True)
base = model.base_model.model if hasattr(model, "base_model") else model
body = base.model
lm_head = base.get_output_embeddings()
softcap = getattr(model.config.get_text_config(), "final_logit_softcapping", None)
print("final_logit_softcapping = %s" % softcap)
print("attn_implementation = %s" % model.config.get_text_config()._attn_implementation)
print(flush=True)
ce_ms = {"fwd": 0.0}
def compute_loss(input_ids, attention_mask, labels, time_ce=False):
"""Byte-for-byte the harness's compute_loss, with optional CE timing."""
hidden = body(input_ids=input_ids, attention_mask=attention_mask,
use_cache=False).last_hidden_state
flat_hidden = hidden[:, :-1, :].reshape(-1, hidden.size(-1))
flat_labels = labels[:, 1:].reshape(-1)
keep = flat_labels != IGNORE_INDEX
kept_hidden = flat_hidden[keep]
kept_labels = flat_labels[keep]
kept = int(kept_labels.numel())
def chunk_loss(chunk_hidden, chunk_labels):
logits = lm_head(chunk_hidden).float()
if softcap is not None:
logits = torch.tanh(logits / softcap) * softcap
return torch.nn.functional.cross_entropy(logits, chunk_labels, reduction="sum")
if time_ce:
s, e = torch.cuda.Event(True), torch.cuda.Event(True)
torch.cuda.synchronize()
s.record()
total = torch.zeros((), device=kept_hidden.device, dtype=torch.float32)
for start in range(0, kept, CHUNK):
total = total + torch.utils.checkpoint.checkpoint(
chunk_loss, kept_hidden[start:start + CHUNK],
kept_labels[start:start + CHUNK], use_reentrant=False,
)
if time_ce:
e.record()
torch.cuda.synchronize()
ce_ms["fwd"] = s.elapsed_time(e)
return total / kept, kept
def make_batch(n, pad_frac=0.0):
"""Synthetic batch. pad_frac trims the SECOND row and right-pads it,
mimicking collate_mixed on a heterogeneous pair."""
ids = torch.randint(100, 200000, (MB, n), device="cuda")
am = torch.ones(MB, n, dtype=torch.long, device="cuda")
labels = ids.clone()
if pad_frac > 0:
keep = int(n * (1 - pad_frac))
am[1, keep:] = 0
labels[1, keep:] = IGNORE_INDEX
# ~40% of real tokens carry loss (measured mean 2188/2752 is higher, but
# rp-dialogue assistant-only masking pulls the mix down); use the measured
# global ratio 57.7M ctx -> 45.9M targets = 0.795
m = torch.rand(labels.shape, device="cuda") > 0.795
labels[m] = IGNORE_INDEX
return ids, am, labels
def timed(n, pad_frac=0.0, reps=2, time_ce=False, label=""):
ids, am, labels = make_batch(n, pad_frac)
for _ in range(1): # warmup
loss, kept = compute_loss(ids, am, labels)
loss.backward()
model.zero_grad(set_to_none=True)
torch.cuda.synchronize()
best = None
for _ in range(reps):
torch.cuda.reset_peak_memory_stats()
t = time.perf_counter()
loss, kept = compute_loss(ids, am, labels, time_ce=time_ce)
loss.backward()
torch.cuda.synchronize()
dt = time.perf_counter() - t
best = dt if best is None else min(best, dt)
model.zero_grad(set_to_none=True)
peak = torch.cuda.max_memory_allocated() / 2**30
print(" %-34s %7.3f s kept=%-6d peak=%5.1f GiB%s" % (
label or ("2x%d pad=%.0f%%" % (n, pad_frac * 100)),
best, kept, peak,
(" CE=%.0f ms (%.1f%%)" % (ce_ms["fwd"], 100 * ce_ms["fwd"] / 1000 / best)) if time_ce else ""))
return best
print("=" * 72)
print("A. SEQUENCE SCALING (no padding - isolates n)")
print("=" * 72, flush=True)
t2048 = timed(2048, 0.0, label="2 x 2,048")
t8192 = timed(8192, 0.0, label="2 x 8,192")
t16384 = timed(16384, 0.0, label="2 x 16,384")
print()
print(" 16384 -> 2048 ratio %.2fx (linear ~8x, launch-bound ~1x, quadratic ~64x)"
% (t16384 / t2048))
print(" 16384 -> 8192 ratio %.2fx (linear ~2x, quadratic ~4x)"
% (t16384 / t8192))
print(flush=True)
print("=" * 72)
print("B. PADDING PENALTY (same real tokens, with vs without pad)")
print("=" * 72, flush=True)
timed(16384, 0.0, label="2 x 16,384 no padding")
timed(16384, 0.5, label="2 x 16,384 50% pad on row 1")
print(flush=True)
print("=" * 72)
print("C. ISOLATED CE WALL CLOCK")
print("=" * 72, flush=True)
timed(16384, 0.0, reps=2, time_ce=True, label="2 x 16,384 (CE timed)")
timed(4096, 0.0, reps=2, time_ce=True, label="2 x 4,096 (CE timed)")
print(flush=True)
print("=" * 72)
print("D. KERNEL TABLE - one fwd+bwd at 2 x 16,384")
print("=" * 72, flush=True)
ids, am, labels = make_batch(16384, 0.0)
loss, _ = compute_loss(ids, am, labels)
loss.backward()
model.zero_grad(set_to_none=True)
torch.cuda.synchronize()
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA],
record_shapes=False, with_stack=False,
) as prof:
loss, _ = compute_loss(ids, am, labels)
loss.backward()
torch.cuda.synchronize()
model.zero_grad(set_to_none=True)
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=45))
print()
print("=" * 72)
print("E. LAUNCH COUNTS (grouped_mm: 128/layer sequential = no-op, 1 = grouped)")
print("=" * 72)
rows = []
for ev in prof.key_averages():
if ev.self_device_time_total <= 0:
continue
rows.append((ev.key, ev.count, ev.self_device_time_total / 1000.0))
rows.sort(key=lambda r: -r[2])
print(" %-58s %8s %10s" % ("kernel", "count", "self ms"))
for k, c, ms in rows[:30]:
print(" %-58s %8d %10.1f" % (k[:58], c, ms))
total_ms = sum(r[2] for r in rows)
print()
print(" total self CUDA time %.1f ms" % total_ms)
gemm = sum(ms for k, c, ms in rows if any(t in k.lower() for t in
("gemm", "cutlass", "sm90", "sm100", "sm120", "nvjet", "ampere", "tensor")))
print(" GEMM-ish kernels %.1f ms (%.1f%%)" % (gemm, 100 * gemm / total_ms))
print(" non-GEMM %.1f ms (%.1f%%)" % (total_ms - gemm, 100 * (total_ms - gemm) / total_ms))
+77
View File
@@ -0,0 +1,77 @@
"""Step 2 — padding ratio. Data-side, no GPU, no model.
Replicates the exact batching the trainer used: SequentialSampler over the
encode-cache order, per_device_batch_size=2, collate_mixed right-padding to
the pair max. Reports real vs padded token counts and the loss-target count
that sizes the chunked CE.
"""
import json, sys
from collections import Counter
CACHE = "/tank/erp-tune/run-01/encode-cache/encoded-a4b0796de1260930.jsonl"
IGNORE_INDEX = -100
MB = 2 # per_device_batch_size
ACCUM = 8 # gradient_accumulation_steps
lens, kept_counts, kinds = [], [], []
with open(CACHE) as fh:
for line in fh:
row = json.loads(line)
ids = row["input_ids"]
labels = row["labels"]
lens.append(len(ids))
kept_counts.append(sum(1 for x in labels if x != IGNORE_INDEX))
kinds.append(row.get("sample_kind", "?"))
n = len(lens)
print(f"records {n:,}")
print(f"sample_kind mix {dict(Counter(kinds))}")
print()
print(f"seq len min/mean/max {min(lens)} / {sum(lens)/n:.0f} / {max(lens)}")
print(f"loss targets min/mean/max {min(kept_counts)} / {sum(kept_counts)/n:.0f} / {max(kept_counts)}")
print()
# --- micro-batch padding, exactly as collate_mixed builds it ---
real = padded = 0
mb_widths, mb_waste, mb_kept = [], [], []
for i in range(0, n - n % MB, MB):
group = lens[i:i + MB]
width = max(group)
r = sum(group)
p = width * MB
real += r
padded += p
mb_widths.append(width)
mb_waste.append(1 - r / p)
mb_kept.append(sum(kept_counts[i:i + MB]))
nb = len(mb_widths)
print(f"micro-batches (mb={MB}) {nb:,}")
print(f"real tokens {real:,}")
print(f"padded tokens {padded:,}")
print(f"PADDING WASTE {100 * (1 - real / padded):.1f}% ({padded - real:,} pad tokens)")
print()
print(f"mb width min/mean/max {min(mb_widths)} / {sum(mb_widths)/nb:.0f} / {max(mb_widths)}")
srt = sorted(mb_widths)
for q in (50, 75, 90, 95, 99):
print(f" p{q} width {srt[int(nb*q/100)]}")
print(f"mb at max_seq_len 16384 {sum(1 for w in mb_widths if w >= 16384):,} ({100*sum(1 for w in mb_widths if w>=16384)/nb:.1f}%)")
print()
srtw = sorted(mb_waste)
print(f"per-mb waste p50/p90/max {100*srtw[nb//2]:.1f}% / {100*srtw[int(nb*0.9)]:.1f}% / {100*max(mb_waste):.1f}%")
print()
print(f"loss targets per mb min/mean/max {min(mb_kept)} / {sum(mb_kept)/nb:.0f} / {max(mb_kept)}")
print(f" -> CE chunks per mb (1024) min/mean/max {min(mb_kept)//1024+1} / {sum(mb_kept)/nb/1024:.1f} / {max(mb_kept)//1024+1}")
print()
# --- what length-bucketing would recover (sort by length, then batch) ---
order = sorted(range(n), key=lambda i: lens[i])
b_real = b_padded = 0
for i in range(0, n - n % MB, MB):
group = [lens[j] for j in order[i:i + MB]]
b_real += sum(group)
b_padded += max(group) * MB
print("--- counterfactual: length-bucketed sampler ---")
print(f"bucketed padded tokens {b_padded:,}")
print(f"bucketed waste {100 * (1 - b_real / b_padded):.1f}%")
print(f"TOKEN REDUCTION vs current {100 * (1 - b_padded / padded):.1f}%")
+112
View File
@@ -0,0 +1,112 @@
"""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()