Files
esh-pfi-infrastructure/docs/pfi/gemma4-erp-tune-sizing.md
T
vh dae6ede8e2 docs(training-playbook): §4 — when the artifact lies about itself
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.
2026-08-25 18:12:27 -07:00

32 KiB
Raw Blame History

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 1025% 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_pathapply_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 410 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/.


6. Round-1 aborted; throughput root-caused (measured 2026-08-24 22:00 PDT)

Run-01 launched, reached step 19 of 1,312 at ~3546 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; the probes are at 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 1720%

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                                           ≈  5161 TFLOPS
                                                     ≈  1720% OF PEAK

We divided the intended (windowed) FLOPs by the wall time the dense reality took. 1720% 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 Noneis_causal fast path AVAILABLE
all-ones (no padding) Noneis_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.543.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 ~13% 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.