dae6ede8e2
The playbook covered why a run is SLOW. It did not cover the more expensive
failure: a run that COMPLETES, reports plausible numbers, and is wrong about
itself. Seven of those turned up on the Gemma-4 ERP/RP tune between 08-24 and
08-26 and not one raised an error.
New §4, seven landmines plus a pre-launch checklist:
4.1 a cache key must cover the MEANING of the cached thing. The encode
cache missed the impersonation mask; run 2 would have reused run 1's
unmasked encodings and written impersonation_mask_sha256 into its own
manifest while doing it. No error, no count change, normal loss curve.
4.2 validating a VALUE is not validating the PARAMETER. warmup_ratio was
in range and deleted from transformers 5. Build kwargs as data and
diff the NAMES against the installed signature -- you cannot check the
argument list of a call you have already made.
4.3 record what the run RESOLVED to, never what it requested. Run 1
recorded no attention backend, so an MFU panel profiled the serving
seat under sdpa and recommended adopting flex_attention for a run that
was already using it.
4.4 never train from a dirty tree; harness_commit will name a commit that
does not describe the run. Annotate afterwards, never edit the shipped
artifact -- and state what is NOT wrong, or the note casts doubt on
every field it omits.
4.5 a watchdog whose pgrep pattern appears in its own argv can only ever
return "alive". The inert-gate shape in a liveness check.
4.6 an instrument nobody runs is not an instrument. Mutation-check any
test guarding a property that fails silently.
4.7 fix a stale measurement at the source. "~4.3 HOURS to rebuild the
encode cache" (really 145.5 s) was copied into a new launcher by the
same person who had just measured the real number.
4.8 the pre-launch honesty checklist, ten minutes.
Also:
- Header and framing widened. The file is now a training playbook with a
throughput half and an integrity half; the filename stays for inbound links.
- Sections 4-7 renumbered to 5-8. External refs are all to §1.1 and §3.4 and
are unaffected.
- Four rows added to the superseded-claims table, including the kernel table /
68% quadratic / 8.6% MFU set, which describe the serving seat rather than
the training run.
- gemma4-erp-tune-sizing.md §6 carries a correction banner with the explicit
falls/survives split, because that is the doc someone actually reads before
a run.
665 lines
32 KiB
Markdown
665 lines
32 KiB
Markdown
# Gemma-4 26B-A4B ERP/RP tune — GPU sizing adjudication
|
||
|
||
_Measured 2026-08-24 on `ana-ml2` against
|
||
`/tank/aimodels/gemma4-26b-a4b-it-heretic-bf16` (llmfan46 abliterated trainee)._
|
||
|
||
Division of labour for this run: **Eitri writes the harness, brokkr-smithy-dev
|
||
audits, infra-ops owns the GPU window and executes.** This document is the
|
||
sizing infra-ops owes; it is arithmetic against the real checkpoint and the
|
||
real card, not an estimate.
|
||
|
||
---
|
||
|
||
## 1. ⚠ QLoRA IS NOT AVAILABLE ON THIS ARCHITECTURE
|
||
|
||
**The proposed shape was QLoRA r64. It cannot be run as specified**, and the
|
||
reason is structural rather than a tuning preference.
|
||
|
||
The checkpoint stores each layer's 128 experts as **two fused 3-D
|
||
`nn.Parameter` tensors**, not as 128 `nn.Linear` modules:
|
||
|
||
model.language_model.layers.N.experts.gate_up_proj BF16 [128, 1408, 2816]
|
||
model.language_model.layers.N.experts.down_proj BF16 [128, 2816, 704]
|
||
|
||
Note the absence of a `.weight` suffix — compare `mlp.down_proj.weight`
|
||
(an `nn.Linear`) against `experts.down_proj` (a bare parameter). That is the
|
||
tell, and it is decisive: **`bitsandbytes` 4-bit replacement walks `nn.Linear`
|
||
modules.** A fused 3-D parameter is not one, so it is skipped and stays BF16.
|
||
|
||
What `load_in_4bit=True` would actually buy on this model:
|
||
|
||
| block | params | BF16 | after bnb NF4 | saved |
|
||
|---|---:|---:|---:|---:|
|
||
| **MoE experts** (fused 3-D — **NOT quantized**) | 22.84 B | 42.54 GiB | **42.54 GiB** | **0** |
|
||
| lm attention (`nn.Linear`) | 1.11 B | 2.07 GiB | 0.52 GiB | 1.55 |
|
||
| dense shared MLP (`nn.Linear`) | 0.54 B | 1.00 GiB | 0.25 GiB | 0.75 |
|
||
| vision tower (`nn.Linear`) | 0.57 B | 1.06 GiB | 0.27 GiB | 0.79 |
|
||
| embed (tied, normally kept BF16) | 0.74 B | 1.38 GiB | 1.38 GiB | 0 |
|
||
| router + norms | 0.01 B | 0.02 GiB | 0.02 GiB | 0 |
|
||
| **total** | **25.81 B** | **48.07 GiB** | **~44.98 GiB** | **~3.1 GiB** |
|
||
|
||
**88.5% of the model is in tensors bitsandbytes cannot touch.** "QLoRA" here
|
||
means paying the NF4 dequant tax on 6% of the weights to save 6% of the
|
||
footprint. The premise does not survive contact with the checkpoint.
|
||
|
||
> **Eitri: do not hard-code a `BitsAndBytesConfig` / `load_in_4bit` path.**
|
||
> It will not error loudly — it will load, report a 4-bit model, and quietly
|
||
> leave 42.5 GiB in BF16. Same silent-failure shape as the stale chat template.
|
||
|
||
**The one thing that could overturn this** is a third-party fork shipping
|
||
custom grouped-GEMM 4-bit MoE kernels for this specific architecture (Unsloth
|
||
is the candidate). **Not chased, deliberately** — see §4, where the run fits in
|
||
BF16 without displacing anything the fleet depends on, which collapses QLoRA's
|
||
value to zero. If it is ever revisited, it must be *before* the harness
|
||
hard-codes a quantization path, not after.
|
||
|
||
**Verdict: plain LoRA on BF16 weights.**
|
||
|
||
---
|
||
|
||
## 2. What the run actually costs
|
||
|
||
Adapter targeting `q_proj,k_proj,v_proj,o_proj` at r64, computed from the real
|
||
tensor shapes:
|
||
|
||
| | layers | per layer | total |
|
||
|---|---:|---:|---:|
|
||
| sliding-attention (q 4096, kv 2048, o 4096) | 25 | 1,507,328 | 37,683,200 |
|
||
| full-attention (q 8192, kv 1024, o 8192) | 5 | 1,654,784 | 8,273,920 |
|
||
| **trainable** | | | **45,957,120** (0.178% of base) |
|
||
|
||
⚠ **`v_proj` DOES NOT EXIST ON LAYERS 5, 11, 17, 23, 29.** Those are the
|
||
`full_attention` layers, and `attention_k_eq_v: true` means one projection
|
||
serves both K and V. Consequences the harness must respect:
|
||
|
||
- PEFT matches by name suffix, so a `v_proj` target **silently produces no
|
||
adapter** on those five layers. Do not assert a fixed adapter count.
|
||
- Adapting `k_proj` on a global layer **adapts K and V simultaneously** — a
|
||
different intervention than on the sliding layers. If that asymmetry matters
|
||
to the recipe, say so explicitly rather than discovering it in the loss curve.
|
||
|
||
### Memory budget, batch 1, `max_seq_len` 8192
|
||
|
||
| item | GiB | note |
|
||
|---|---:|---|
|
||
| base weights BF16 | 48.07 | measured: 25,805,936,206 params × 2 B |
|
||
| adapters + grads + AdamW fp32 m/v | 0.75 | 45.96 M trainable — rounding error |
|
||
| checkpointed layer inputs | 1.29 | 30 × 8192 × 2816 × 2 B |
|
||
| recompute peak, one layer | ~2.5 | 8192 tok × top-8 of 128, `moe_intermediate 704` |
|
||
| loss head, **fused/chunked CE** | ~2.0 | see the warning below |
|
||
| CUDA context + cuBLAS + fragmentation | ~3.0 | the item `--gpu-memory-utilization` never covered |
|
||
| **total** | **~57.6** | |
|
||
|
||
Marginal cost per extra sequence in the micro-batch: **~2.5 GiB.**
|
||
|
||
| micro-batch | GiB |
|
||
|---:|---:|
|
||
| 1 | 54.3 |
|
||
| 2 | 56.8 |
|
||
| **4** | **61.8** |
|
||
| 6 | 66.8 |
|
||
| 8 | 71.8 |
|
||
|
||
### ⚠ The loss head is the whole ballgame, and it is not in the brief
|
||
|
||
`vocab_size` is **262,144** and `final_logit_softcapping` is **30.0**. One
|
||
8192-token sequence produces **2.147 billion logits**. Through a naive HF
|
||
`ForCausalLM` loss that is:
|
||
|
||
BF16 logits 4.0 GiB
|
||
fp32 upcast 8.0 GiB
|
||
softcap tanh saved 8.0 GiB (autograd keeps the pre-cap tensor)
|
||
softmax + grad 8.0 GiB
|
||
------------------------------
|
||
~28-30 GiB transient, at BATCH 1
|
||
|
||
Naive CE at batch 1 lands the run at **~85.6 GiB on a 95.6 GiB card** — it will
|
||
appear to work and then OOM on the first long sample. At micro-batch 4 it is
|
||
~120 GiB and never starts. **Fused/chunked linear cross-entropy is mandatory,
|
||
not an optimization.**
|
||
|
||
⚠ Honest uncertainty: Liger ships per-architecture patches and Gemma-4 MoE with
|
||
softcapping may not have one. Three ways out, in order of preference —
|
||
(a) generic `LigerFusedLinearCrossEntropyLoss` wired against the lm_head with
|
||
softcapping applied inside the chunk; (b) `cut-cross-entropy`; (c) hand-rolled
|
||
sequence-chunked CE. **This must be proven on a 10-step smoke run before the
|
||
window is booked**, because everything else in this document assumes it works.
|
||
|
||
### Step count
|
||
|
||
58.2 M tokens / 20,576 samples = **2,829 tokens/sample average** — well under
|
||
8192, so packing matters.
|
||
|
||
- Packed to 8192: **7,104 sequences.** At micro-batch 4 × grad-accum 4
|
||
(effective 16) → **444 optimizer steps for the whole epoch.**
|
||
- ⚠ That is a *small* step count. A "checkpoint every 100 steps" default gives
|
||
four checkpoints across a multi-hour run. This is exactly why the amendment
|
||
asked for **wall-clock-interval checkpointing, not step-count** — the case is
|
||
now concrete, not hypothetical.
|
||
- ⚠ **Packing must use `position_ids` + varlen/block-diagonal attention.** Naive
|
||
concatenation bleeds samples into each other. `sliding_window` is 1024 on 25
|
||
of 30 layers so the damage is bounded there — but the 5 `full_attention`
|
||
layers see the entire packed sequence.
|
||
|
||
**Open question for brokkr/Eitri:** what fraction of the 20,576 samples exceed
|
||
8192 tokens? Below ~2%, 8192 is right. A long tail means truncation is cutting
|
||
the ends off RP scenes, which is where the signal lives.
|
||
|
||
### Runtime
|
||
|
||
Active parameters per token ≈ **3.67 B** (2.93 B routed + attention, plus the
|
||
0.74 B tied lm_head matmul). Forward + backward + gradient-checkpoint recompute
|
||
≈ 6 × active × tokens = **1.28e18 FLOPs** for the epoch.
|
||
|
||
At 10–25% MFU on a 300 W-capped Max-Q card — HF MoE paths with 704-wide experts
|
||
are not efficient — **4 to 10 hours, most likely ~6.** Treat as a band, not a
|
||
number; it will be measured on the smoke run.
|
||
|
||
---
|
||
|
||
## 3. Where it fits (measured 2026-08-24, 18:20 PDT)
|
||
|
||
Card total: 97,887 MiB = **95.60 GiB** each.
|
||
|
||
| | GPU0 | GPU1 |
|
||
|---|---|---|
|
||
| resident before the window | `vllm-gen` 42,508 MiB (up 3 h) | `vllm-mog-sec` 56,624 MiB + `embed` 3,304 + `reward` 9,512 + `coder` 6,158 + `rerank-a3` 2,170; Scriberr pinned here, loads on demand |
|
||
| free | 54,741 MiB = **53.46 GiB** | 19,446 MiB = **18.99 GiB** |
|
||
|
||
Three placements were on the table:
|
||
|
||
- **GPU0 beside `gen`: does not fit.** 53.46 GiB free against ~57.6 GiB needed —
|
||
short by ~4 GiB. And `gen` is only three hours old: measured footprint runs
|
||
38.5 GiB fresh → 42.5 GiB at 3 h → 45.6 GiB at 3 days. Budgeting against the
|
||
current number is budgeting against a moving one.
|
||
- **GPU1 with `mog-sec` stopped: 76,070 MiB free.** Fits, but shares a card with
|
||
four small seats and Scriberr.
|
||
- **GPU0 with `gen` MOVED OFF: the whole card.** ← what was chosen.
|
||
|
||
---
|
||
|
||
## 4. The window, as executed
|
||
|
||
**Operator call, 2026-08-24: move `gen` to GPU1 and stand `sec` down, so GPU0 is
|
||
emptied completely rather than shared.** This is strictly better than training
|
||
beside `gen`: the tune gets 95.60 GiB with no co-tenant, and the fleet's general
|
||
seat never goes dark beyond its own ~5-minute restart.
|
||
|
||
before: GPU0 [ gen 42.5 ] GPU1 [ sec 55.3 | small seats 20.7 ]
|
||
after: GPU0 [ ---- empty, 95.60 GiB ---- ] GPU1 [ gen ~41 | small seats 20.7 | ~33 free ]
|
||
|
||
`sec` is genuinely in use and this is not free — but it is the smaller blast
|
||
radius by a wide margin:
|
||
|
||
| | `gen` | `sec` |
|
||
|---|---|---|
|
||
| aliases | 7 (`gen`, `gen-reasoning`, `chat-judge`, `image-judge`, summarizer/classifier family) | 2 (`sec`, `sec-reasoning`) |
|
||
| standing role | the fleet's general seat; a documented always-available dependency in global `CLAUDE.md` | M.O.G.-SEC, niche |
|
||
| measured traffic | 765 busy-engine log lines in 24 h — continuously in use | bursty; peak 8 concurrent, **last request ~5 h ago** |
|
||
|
||
Traffic to `sec` arrives from `10.250.50.70` (the LiteLLM gateway), so the
|
||
aliases will fail at the gateway for the duration. Per the standing rule, let
|
||
them fail — **do not route `sec` to another model as a stand-in.**
|
||
|
||
Both directions are playbooks, and **the order in each is load-bearing**:
|
||
|
||
scripts/elway infra-ops@10.250.50.54 --playbook playbooks/ana-ml2-training-window-open.yaml
|
||
scripts/elway infra-ops@10.250.50.54 --playbook playbooks/ana-ml2-training-window-close.yaml
|
||
|
||
⚠ `gen` runs at `--gpu-memory-utilization 0.43`, which vLLM reads as a fraction
|
||
of **total** card memory: 42,091 MiB must be *free at startup* or the engine
|
||
refuses to boot. GPU1 has 19,446 MiB free while `mog-sec` is up. **Recreating
|
||
`gen` onto GPU1 before stopping `mog-sec` takes the fleet's main seat down and
|
||
leaves it down.** The open playbook stops `mog-sec` first and hard-gates on the
|
||
freed memory; the close playbook mirrors it, because `mog-sec` needs 50,901 MiB
|
||
of its own and cannot start until `gen` has vacated GPU1.
|
||
|
||
⚠ Invoke elway as `infra-ops@10.250.50.54`, not the `ana-ml2` ssh-target — that
|
||
resolves to `lkraven`, which has no NOPASSWD sudo, and elway aborts at its sudo
|
||
probe.
|
||
|
||
### ⚠ MEASURED 2026-08-24 — the estimates below this line were ~3× optimistic
|
||
|
||
Everything above was arithmetic. This was run on the real checkpoint on GPU0
|
||
with synthetic tokens (`/tank/erp-tune/smoke_ce.py`), and it moves the answer:
|
||
|
||
| config | peak | verdict |
|
||
|---|---:|---|
|
||
| naive CE, bsz1 seq 8192 | **81.93 GiB** | fits, ~14 GiB spare |
|
||
| naive CE, bsz1 seq 16384 | **OOM** | tried to allocate 16.00 GiB |
|
||
| chunked CE, bsz1 seq 16384 | **65.66 GiB** | ✅ |
|
||
| **chunked CE, bsz2 seq 16384** | **79.71 GiB** | ✅ **the run config** |
|
||
| chunked CE, bsz4 seq 16384 | **OOM** | — |
|
||
|
||
**The marginal cost of an extra 16,384-token sequence is ~14 GiB, not the ~5 GiB
|
||
estimated.** The estimate modelled gradient checkpointing as storing only layer
|
||
inputs plus a modest recompute peak; the real MoE recompute peak (8,192+ tokens ×
|
||
top-8 of 128 experts, plus scatter/gather buffers) is far heavier. **Do not size
|
||
an MoE run from dense-model intuition — measure it.**
|
||
|
||
Two predictions did land exactly, which is why the rest of the model of the thing
|
||
is trustworthy: **205 target modules** (q30/k30/v25/o30/gate30/up30/down30) and
|
||
**74,342,400 trainable params** at r64.
|
||
|
||
The headline: **chunked CE at seq 16384 costs 16 GiB LESS than naive CE at seq
|
||
8192.** Chunking is not an optimisation, it is what makes brokkr's 16384
|
||
recommendation reachable at all.
|
||
|
||
Base load peak: **49,221 MiB**, confirming the 48.07 GiB weight figure.
|
||
|
||
### Revised run parameters, now that it is a whole card
|
||
|
||
**FINAL, measured: `max_seq_len` 16384, `per_device_batch_size` 2,
|
||
`gradient_accumulation_steps` 8** → effective batch 16, **~1,280 optimizer
|
||
steps**, 79.71 GiB of 95.60 with ~15.9 GiB clear.
|
||
|
||
`max_seq_len` went 8192 → 16384 on brokkr's truncation finding: at 8192 the cap
|
||
drops **6.2% of samples but 22.4% of TOKENS** (61.2M → 47.5M), concentrated
|
||
*entirely* in dialogue — 46% of c2-logs, 47.5% of pippa, 95.6% of bluemoon —
|
||
which is 60% of the mix and the axis the seat exists for. Prose and fireball
|
||
truncate at zero. p50 is 2,084 and p90 4,751, so the cost is the long tail only.
|
||
|
||
⚠ The 79.71 GiB figure is **worst case** — every sample in the micro-batch at the
|
||
full cap. Samples are one-per-sequence padded to the batch max, so with p90 4,751
|
||
the typical step sits far below it.
|
||
|
||
⚠ **Keep gradient checkpointing ON**, and keep `enable_input_require_grads()`
|
||
with it. Dropping checkpointing looks like ~17% off wall-clock and instead
|
||
forces micro-batch 1. Worse, the second call is the silent one: **without
|
||
`enable_input_require_grads()` the frozen base produces no gradient through the
|
||
checkpointed blocks, every adapter stays at its initialisation, and the run
|
||
completes successfully with an inert adapter.** `prepare_model_for_kbit_training`
|
||
used to do it as a side effect of the 4-bit path — so removing 4-bit removes it
|
||
too, and nothing warns you.
|
||
|
||
### Harness changes this required (eitri-smithy `62b556b`)
|
||
|
||
`9d64257` as audited would not have run here. Four fixes:
|
||
|
||
1. `runtime.py` hardcoded `BitsAndBytesConfig(load_in_4bit=True)` — now a config
|
||
key, defaulting off, per §1.
|
||
2. Sequence-chunked CE replacing the model's own loss (the measured table above).
|
||
3. `chat_template_path` — `apply_chat_template` resolved the checkpoint's own
|
||
stale 365-line template and there was **no override parameter anywhere**, so
|
||
the upstream-template requirement was not expressible in the code.
|
||
4. Gradient checkpointing + `enable_input_require_grads()`.
|
||
|
||
Plus `training_eligibility_override` / `overridden_blockers` /
|
||
`substitute_controls` in the provenance manifest, and `device_map` pinned to
|
||
device 0 so the run cannot stray onto the card holding the inference seats.
|
||
|
||
Also fold in:
|
||
|
||
- **Scriberr STAYS on GPU1.** (An earlier draft of this document suggested moving
|
||
it to GPU0; that was written when training was going to live on GPU1, and it is
|
||
now exactly backwards. GPU0 is the training card and wants no co-tenant.)
|
||
- **Package as a `uv` venv on `/tank`, not a Docker image.** Root is at **91%
|
||
(36 GB free)** and `/var/lib/docker` lives on it; a PyTorch training image
|
||
would come close to filling it. `/tank` has 4.0 TB.
|
||
- **The run is still resumable-by-design** (INV-T7 + wall-clock checkpointing).
|
||
Nothing about a dedicated card removes that requirement — a 4–10 hour window
|
||
is long enough that an unresumable run is a bad bet regardless of who owns the
|
||
GPU.
|
||
|
||
---
|
||
|
||
## 5. Standing warnings that apply to this run
|
||
|
||
- **Never render training examples through the base's own
|
||
`chat_template.jinja`.** Every third-party Gemma-4 derivative ships a stale
|
||
one; the trainee's is 365 lines against upstream's 390. Use
|
||
`/tank/aimodels/gemma4-26b-a4b-it-bf16/chat_template.jinja`. Training through
|
||
the wrong template is train/serve skew with no error — it presents as a
|
||
tuning failure.
|
||
- **Base path and chat-template path are config keys, not constants.** The
|
||
trainee base already moved once (stock BF16 → `-heretic-bf16`).
|
||
- **`--gpu-memory-utilization` sizes the KV cache only.** It does not cover CUDA
|
||
context, graphs, or non-torch overhead — the same misreading that OOM'd the
|
||
char-rp seat.
|
||
- **SETTLED 2026-08-25 — merged weights are MANDATORY, and not for the reason
|
||
we expected.** The open question was whether LoRA-on-NVFP4 hot-swap still
|
||
silently no-ops (it did on vLLM 0.24.0, #47639). Retested on
|
||
`vllm/vllm-openai:latest` with the NVFP4A16 base plus the run's own
|
||
checkpoint adapter. It does not no-op — **it refuses to start:**
|
||
|
||
AttributeError: To support LoRA for MoE model,
|
||
'get_expert_mapping' must be implemented
|
||
|
||
⚠ **This is architectural, not quantization-related.** The check lives in
|
||
`vllm/lora/utils.py::process_packed_modules_mapping` and branches on whether
|
||
the model is MoE; quantization is not in the condition. `gemma4.py`,
|
||
`gemma4_mm.py`, `gemma4_mtp.py` and `gemma4_unified.py` contain **zero**
|
||
occurrences of `get_expert_mapping` (deepseek_v2, glm4_moe, ernie45_moe and
|
||
others do implement it). **vLLM cannot serve a LoRA on Gemma-4 at all —
|
||
BF16 or quantized.** Merging is the only path for this architecture.
|
||
|
||
Note this holds even though our adapter never touches experts:
|
||
`validate_adapter_parameters` forbids per-expert params, so all 205 targets
|
||
are attention + dense MLP. The refusal is about the *model* being MoE, not
|
||
about what the adapter targets.
|
||
|
||
Silver lining worth recording: a loud refusal is strictly better than the
|
||
0.24.0 behaviour. A silent no-op ships a base model wearing the tune's name
|
||
and passes every check that does not compare against base.
|
||
|
||
The merge → quantize → serve pipeline is implemented and validated end to
|
||
end at [`scripts/erp-tune-serve/`](../../scripts/erp-tune-serve/).
|
||
|
||
---
|
||
|
||
## 6. Round-1 aborted; throughput root-caused (measured 2026-08-24 22:00 PDT)
|
||
|
||
Run-01 launched, reached step 19 of 1,312 at ~35–46 s/it, and was **killed by
|
||
operator instruction** — not a crash, not an OOM. ETA was ~13.9 h at 8.6% MFU
|
||
and the operator elected to root-cause before spending the window.
|
||
|
||
Nothing was destroyed: the 609 MB encode cache, `order-manifest.jsonl`,
|
||
`truncation-report.json` and `resume-run-01.sh` are all preserved at
|
||
`/tank/erp-tune/run-01/`. **There are no checkpoints** — the first was due at
|
||
step 100, so brokkr's `lora_B` inert-adapter gate never ran. That question is
|
||
open and moves to the restart.
|
||
|
||
Model-agnostic lessons from this investigation are in
|
||
[`training-throughput-playbook.md`](training-throughput-playbook.md); the
|
||
probes are at [`scripts/training-probes/`](../../scripts/training-probes/).
|
||
What follows is Gemma-4-specific.
|
||
|
||
> ## ⚠⚠ CORRECTION 2026-08-26 — MUCH OF THIS SECTION MEASURES THE WRONG PROCESS
|
||
>
|
||
> **The benchmarks below were run against the SERVING seat with
|
||
> `attn_implementation="sdpa"` set explicitly. Training was running
|
||
> `flex_attention` the whole time.** `ATTN_IMPLEMENTATION = "flex_attention"`
|
||
> was a module constant passed unconditionally into `from_pretrained`, and
|
||
> run 1's step-time distribution (n=1,445; min 11.84 / p50 19.75 / p99 30.52 /
|
||
> max 45.79 s/it, the max being step 1's compile) confirms it stayed compiled —
|
||
> a dynamo fallback sits in the hundreds of seconds per step.
|
||
>
|
||
> **FALLS** — describes sdpa, not the training run:
|
||
> the three-point scaling fit and its 68% quadratic share; the kernel table
|
||
> (`fmha_cutlassF/B` sm80, `EFFICIENT_ATTENTION`, attention 65.2%); the **8.6%
|
||
> MFU** figure quoted above and throughout; the projection that elementwise
|
||
> becomes the largest line item post-fix; and "adopt `flex_attention`" as the
|
||
> round-two headline lever — **which round one already had.**
|
||
>
|
||
> **SURVIVES** — measured on the live training run:
|
||
> the padding/bucketing win (44.3 → 20.1 s/it); the zero-pad fast-path
|
||
> second-order effect; the eval-battery noise-floor work.
|
||
>
|
||
> ⚠ **Do not assume the direction of the correction.** Training's real MFU is
|
||
> *unmeasured*, not obviously better. Flex with a BlockMask ought to beat
|
||
> dense-masked sdpa, but that is a prediction and this investigation has been
|
||
> unkind to those.
|
||
>
|
||
> The root cause was procedural, not technical, and it is written up as
|
||
> playbook **§4.3**: run 1 recorded no attention backend in its provenance, so
|
||
> the benchmark/trainer delta was invisible and nobody enumerated it. Run 2
|
||
> onward records `attn_implementation_requested` **and** `_resolved`, plus the
|
||
> torch/transformers versions and dynamo's compile counters.
|
||
|
||
### 6.1 Where the step time goes
|
||
|
||
Real checkpoint, GPU0, `attn_implementation="sdpa"`, PEFT + gradient
|
||
checkpointing + the chunked CE, fwd+bwd, best-of-2 after warmup:
|
||
|
||
| shape | time | peak |
|
||
|---|---:|---:|
|
||
| 2 × 2,048 | 1.776 s | 53.2 GiB |
|
||
| 2 × 8,192 | 11.570 s | 62.3 GiB |
|
||
| 2 × 16,384 | **35.017 s** | 76.6 GiB |
|
||
|
||
Fitting `t(w) = A·w + B·w²` over all three (per-sequence `w`, batch 2):
|
||
|
||
A = 6.8715e-04 s/token B = 8.8509e-08 s/token²
|
||
|
||
| w | predicted | measured | linear | quadratic | quad share |
|
||
|---:|---:|---:|---:|---:|---:|
|
||
| 2,048 | 1.779 | 1.776 | 1.407 | 0.371 | 20.9% |
|
||
| 8,192 | 11.569 | 11.570 | 5.629 | 5.940 | 51.3% |
|
||
| 16,384 | 35.017 | 35.017 | 11.258 | 23.759 | **67.8%** |
|
||
|
||
**Two terms, three points, residuals under 3 ms across an 8× range.** No fixed
|
||
per-batch term was needed, which refutes the launch-bound hypothesis outright —
|
||
~3,840 expert-GEMM launches per forward are not the cost.
|
||
|
||
Independently, the profiler kernel table (device rows only — see playbook §3.4):
|
||
|
||
| device kernel | ms | of step |
|
||
|---|---:|---:|
|
||
| `fmha_cutlassB_bf16_aligned_128x64_k65536_sm80` (attn BWD) | 16,144.6 | 46.1% |
|
||
| `fmha_cutlassF_bf16_aligned_32x128_gmem_sm80` (attn FWD) | 6,691.2 | 19.1% |
|
||
| `cutlass_80_tensorop_bf16_s16816gemm` ×3 (dense GEMM) | 2,774.0 | 7.9% |
|
||
| elementwise / vectorized / unrolled ×9 | 4,787.4 | 13.7% |
|
||
| gather / Memcpy DtoD / dropout | 951.6 | 2.7% |
|
||
| **attention total** | **22,835.8** | **65.2%** |
|
||
|
||
**Scaling fit says 67.8% quadratic; kernel table says 65.2% attention. Two
|
||
independent methods, 2.6 points apart.**
|
||
|
||
### 6.1a ⚠ 8.6% MFU was an accounting artifact — real utilisation is 17–20%
|
||
|
||
`brokkr-smithy-dev`'s panel (arm: Bil) closed the fold by reading torch 2.13.0
|
||
and transformers 5.9.0 at the tag. The headline dissolves the anomaly rather
|
||
than explaining it:
|
||
|
||
nominal work billed 27.1 TFLOPS × 34.85 s = 9.4e14 FLOP
|
||
dense-sliding extra 25 layers, 2 seqs, 4 passes = +8.2e14 FLOP
|
||
padded full layers lose the causal skip = +3.5e14 FLOP
|
||
------------------------------------------------------------------
|
||
work actually performed ≈ 1.8e15
|
||
in 34.85 s ≈ 51–61 TFLOPS
|
||
≈ 17–20% OF PEAK
|
||
|
||
**We divided the *intended* (windowed) FLOPs by the wall time the *dense*
|
||
reality took.** 17–20% is squarely inside the honest stock band. The hardware
|
||
is fine, the utilisation is fine — the run is simply doing ~2× the arithmetic
|
||
the architecture specifies, and the excess is the sliding window being computed
|
||
and then thrown away.
|
||
|
||
Source-verified mechanism, no longer hypothesis:
|
||
|
||
| file | finding |
|
||
|---|---|
|
||
| `masking_utils.py:292-301` `_ignore_causal_mask_sdpa` | requires `kv_length < local_attention_size` to skip the mask. 16384 ≥ 1024, so **the sliding mask ALWAYS materialises at this seq len** — not sometimes, always |
|
||
| `sdp_utils_cpp.h:259-267`, `sdp_utils.cpp:933` | flash rejects **any** explicit mask |
|
||
| `sdp_utils.cpp:647`, `Context.h:480-485` | **cuDNN is unreachable on sm_120 twice over** — head_dim capped at 128, and the prefer-cuDNN branch requires major 9 or 10; sm_120 is major 12 |
|
||
| `attention.cu:1196/1759`, `kernel_forward.h:282-290` | mem-efficient has no mask gate and no head_dim cap, computes **full n×n** with the mask as additive bias; it trims only for `is_causal` |
|
||
|
||
Dispatch order on sm_120 is flash → efficient → math → cudnn, so the 25 sliding
|
||
layers land on mem-efficient computing dense O(n²), and **no backend on this
|
||
stack can rescue it.** cuDNN sliding-window does not exist at all — there is no
|
||
window argument in the public SDPA signature.
|
||
|
||
⚠ **Masked SDPA also blocks `enable_gqa`**, so KV gets `repeat_kv`-expanded on
|
||
every layer — extra memory traffic riding on top of the extra FLOPs.
|
||
|
||
### 6.1b Backend eligibility, measured — every source claim confirmed
|
||
|
||
Shapes-only, random weights, `sdpa_kernel()` pinning one backend at a time.
|
||
A forced failure is information: it identifies eligibility rather than
|
||
preference.
|
||
|
||
**Sliding layers (25 of 30) — H_q16/H_kv8, D=256, forward at N=16,384:**
|
||
|
||
| mask case | FLASH | EFFICIENT | CUDNN | MATH |
|
||
|---|---|---|---|---|
|
||
| `None` + `is_causal` | **8.8 ms** | 21.6 ms | refused | 217.5 ms |
|
||
| explicit 1024-band 4D | refused | **55.5 ms** | refused | — |
|
||
| explicit causal+pad 4D | refused | 57.1 ms | refused | — |
|
||
|
||
**Global layers (5 of 30) — H_q16/H_kv2, D=512:**
|
||
|
||
| mask case | FLASH | EFFICIENT | CUDNN | MATH |
|
||
|---|---|---|---|---|
|
||
| `None` + `is_causal` | refused | **45.9 ms** | refused | 334.1 ms |
|
||
| explicit 1024-band 4D | refused | 104.4 ms | refused | — |
|
||
| explicit causal+pad 4D | refused | 107.9 ms | refused | — |
|
||
|
||
Refusal reasons, straight from the runtime warnings:
|
||
|
||
- `Flash Attention does not support non-null attn_mask` — kills flash for all
|
||
25 sliding layers, always, at this seq len
|
||
- `Flash attention requires q,k,v … less than or equal to 256. Got 512` —
|
||
kills flash for the 5 global layers **even fully causal**. Dvalin was right
|
||
that they are a first-class hole
|
||
- `head_dim should be no more than 128` — kills cuDNN on **both** shapes
|
||
|
||
**The sliding layers run at 55.5 ms where a maskless flash path would cost
|
||
8.8 ms — a 6.3× penalty, and it is unreachable through any config on this
|
||
stack.** That is the whole problem in one row.
|
||
|
||
### 6.2 ⚠ The attention kernels are Ampere, on a Blackwell card
|
||
|
||
`fmha_cutlass*_sm80` on sm_120. There is no Blackwell-tuned attention kernel in
|
||
this path at all, and the forward is additionally on `gmem` — the
|
||
global-memory fallback tier of the memory-efficient backend, selected when the
|
||
working set will not fit in shared memory.
|
||
|
||
This is the mechanism behind the 100%-SM / 27-TFLOPS / 304-TFLOPS-capable
|
||
reading: the chip is saturated running a kernel generation behind on the
|
||
dominant cost centre.
|
||
|
||
The candidate fix is a purpose-built kernel for this architecture's mixed
|
||
256/512 head-dim split — `zzhhjjj/gemma-triton-flash-attn`
|
||
(`register_triton_attention()`, then `_attn_implementation = "triton_gqa"`),
|
||
reported 9.23× over SDPA at N=16K D=256 SWA and 2.94× fwd+bwd at D=512.
|
||
`flex_attention` + `BlockMask` is the no-new-dependency alternative.
|
||
|
||
⚠ **Prefer a UNIFORM backend over a per-layer split.** vLLM special-cased this
|
||
exact mixed-head-dim architecture and measured mixed backends **8% slower** than
|
||
uniform. And `attn_implementation` is all-or-nothing at `from_pretrained` /
|
||
`set_attn_implementation` — per-layer routing requires a custom function
|
||
registered on `ALL_ATTENTION_FUNCTIONS` branching on `module.head_dim` /
|
||
`sliding_window`.
|
||
|
||
⚠ **FA2 is not available for this model**: it caps head_dim at 256 and the 5
|
||
global layers are at 512. FA3 is Hopper-only. Do not bet on FA4 on sm_120.
|
||
|
||
### 6.3 Masking is CORRECT — and padding is what costs
|
||
|
||
Band structure asserted directly against the real config at n=16,384:
|
||
|
||
sliding_attention max 1,024 allowed/row, saturates at row 1,023 PASS
|
||
|
||
Constraints were **not** silently dropped; the 25 sliding layers were genuinely
|
||
windowed. Run-01 was training the model we intended.
|
||
|
||
The same probe found the mechanism nobody had measured:
|
||
|
||
| 2D mask supplied | `full_attention` mask returned |
|
||
|---|---|
|
||
| `None` | **`None`** → `is_causal` fast path AVAILABLE |
|
||
| all-ones (no padding) | **`None`** → `is_causal` fast path AVAILABLE |
|
||
| right-padded (what `collate_mixed` emits) | 4D `16384²` → **fast path LOST** |
|
||
|
||
**Padding is what pins the 5 global layers to an explicit mask.** The 25
|
||
sliding layers get a 4D tensor either way — `sdpa_attention_forward` sets
|
||
`is_causal=True` only when `attention_mask is None`, and a 1024 window cannot
|
||
be expressed as `is_causal`.
|
||
|
||
Isolated, same width, only the mask differing:
|
||
|
||
2 × 16,384, no padding 35.244 s 26,048 loss targets
|
||
2 × 16,384, 50% pad on row 1 38.567 s 19,640 loss targets
|
||
|
||
**9.4% slower for 24% less work.**
|
||
|
||
### 6.4 The corpus is 29.9% padding — and bucketing is the biggest win available
|
||
|
||
Measured off the preserved encode cache in true `SequentialSampler` order:
|
||
|
||
records 20,982 (3,583 rp-dialogue / 12,003 prose-chunk / 5,396 actual-play)
|
||
seq len min/mean/max 142 / 2,752 / 16,384
|
||
micro-batches (mb=2) 10,491
|
||
real tokens 57,733,156
|
||
padded tokens 82,337,318
|
||
PADDING WASTE 29.9%
|
||
mb width p50/p90/p99 2,092 / 10,634 / 16,341
|
||
micro-batches at 16,384 3 of 10,491 (0.0%)
|
||
|
||
⚠ Note the last line against §6.1: **the 2 × 16,384 benchmark shape occurs in
|
||
three micro-batches out of 10,491.** Weighted over the real distribution the
|
||
quadratic share is ~51%, not 67.8%.
|
||
|
||
**Bucket-to-pair, shuffle-to-mix** (brokkr's design, validated on measured
|
||
lengths — form micro-batches within length buckets, then shuffle the resulting
|
||
*micro-batches* globally):
|
||
|
||
| bucket | waste | predicted step | zero-pad mb | roots/accum window |
|
||
|---:|---:|---:|---:|---:|
|
||
| current | 29.9% | 44.3 s → 16.13 h | 0.1% | 3.68 |
|
||
| **2** | **0.0%** | **28.6 s → 10.40 h** | **78.3%** | 3.56 |
|
||
| 8 | 0.0% | 28.6 s → 10.41 h | 65.3% | 3.54 |
|
||
| 32 | 0.1% | 28.6 s → 10.43 h | 41.9% | 3.55 |
|
||
| 128 | 0.6% | 28.8 s → 10.51 h | 14.7% | 3.55 |
|
||
| 512 | 2.4% | 29.7 s → 10.82 h | 4.1% | 3.61 |
|
||
|
||
**≥35.5% wall clock, no kernel work, no new dependency, peak memory unchanged.**
|
||
|
||
Two findings that changed the design:
|
||
|
||
- **Bucket size is not a diversity knob.** Roots per accumulation window are
|
||
flat at 3.54–3.61 across a 256× range. The global micro-batch shuffle does
|
||
all the mixing. Use the tightest bucket.
|
||
- **35.5% is a floor.** Zero-pad micro-batches go 0.1% → 78.3%, which puts the
|
||
5 global layers back on `is_causal` for most of the run (§6.3). The cost
|
||
model does not capture that. Direction certain, magnitude not yet measured at
|
||
representative shapes.
|
||
|
||
⚠ **Source-homogeneity is a real hazard here** — length correlates hard with
|
||
root (kvasir short, chunked RP windows long), so length-homogeneous batches are
|
||
root-homogeneous batches. The global micro-batch shuffle is what prevents an
|
||
accumulation window drawing its whole gradient from one source. It is
|
||
load-bearing, not decoration.
|
||
|
||
### 6.5 The chunked CE is fine — do not swap it
|
||
|
||
2 × 16,384 CE forward 374 ms of 35.329 s = 1.1%
|
||
2 × 4,096 CE forward 93 ms of 4.387 s = 2.1%
|
||
|
||
⚠ **Forward only** — the `torch.utils.checkpoint` recompute runs inside
|
||
`.backward()`, outside the timing window. Even at 3× it is ~3%.
|
||
|
||
`liger-kernel` fused linear CE is a ~1–3% lever on this shape. §2's finding
|
||
stands unchanged: chunking is what makes seq 16384 *reachable*, and it is not
|
||
what makes it slow.
|
||
|
||
### 6.6 MoE is ~8% — stop optimising it
|
||
|
||
Dense GEMM is 7.9% of the step, confirming the earlier decomposition bound of
|
||
~10% from the kernel side.
|
||
|
||
On `grouped_mm`: **the trace does not adjudicate it.** Run-01 was relaunched on
|
||
`eager`, so the profile shows the *default* path — 25,463 `aten::mm` dispatches
|
||
in one fwd+bwd, far more than the ~90 a grouped path would produce, so the
|
||
default is per-expert sequential. Whether the flag changes that when set is a
|
||
different measurement and was not run. At 7.9% it is not worth running.
|
||
|
||
### 6.7 Restart parameters for round 2
|
||
|
||
**Do not relaunch without the sampler change.** It is the only lever that wins
|
||
under every branch of the diagnosis.
|
||
|
||
1. **Implement bucket-to-pair + shuffle-to-mix** in the harness, tightest
|
||
bucket, global micro-batch shuffle. Expected ~16.1 h → ~10.4 h or better.
|
||
2. **Re-assert the mask band structure** after the sampler change —
|
||
`scripts/training-probes/step0_mask.py`, 30 s, no GPU. The sampler touches
|
||
batch composition, which is what drives mask construction.
|
||
3. **Resume with `/tank/erp-tune/resume-run-01.sh`, NEVER the original launch
|
||
command** — it begins `rm -rf /tank/erp-tune/run-01` and would destroy the
|
||
609 MB encode cache (2.5 min to reuse, ~4.3 h to rebuild). ⚠ A sampler change
|
||
alters record *order*, not encoding, so the cache stays valid — but bump
|
||
`encode_version` if anything upstream of `input_ids` changes.
|
||
4. **Run the `lora_B` inert-adapter gate at step 100.** It never ran in round 1.
|
||
Norm every `lora_B` tensor in the checkpoint: all-non-zero = real, all-zero =
|
||
INERT (kill the run), partial = module-selection problem. This is the one
|
||
failure that stays invisible until brokkr's acceptance gate reports
|
||
base-identical numbers.
|
||
5. **The corpus override is ONE RUN ONLY** (`operator-2026-08-25-rnd-run`). A
|
||
second run needs a second operator grant.
|
||
6. **Attention backend is round 2's second lever**, gated on an A/B on the
|
||
replica — not on argument. It can run while the tuned job trains.
|
||
|
||
⚠ GPU0 is currently **reserved and idle** by operator instruction; `sec` /
|
||
mog-sec remains down. The window is still open, so
|
||
`playbooks/ana-ml2-training-window-close.yaml` has NOT been run.
|