feat(erp-tune): NVFP4A16 serving pipeline, and the MoE landmine it uncovered
Merge + quantize path for turning the Gemma-4 26B-A4B ERP/RP LoRA into a
servable NVFP4A16 seat, plus a playbook entry for the defect found while
validating it.
The landmine (playbook §3.15): a `targets=["Linear"]` NVFP4 recipe silently
misses every MoE expert on this architecture. Gemma-4 stores each layer's 128
experts as two fused 3-D nn.Parameter tensors, not nn.Linear modules, so the
recipe resolves 205 of 427 modules and ZERO experts — 22.84 B params, 88.5% of
the model, left in BF16 with no warning. This is the same blind spot that
killed QLoRA here via bitsandbytes; the tool changed, the checkpoint layout did
not.
before linearize_moe: 427 Linears, 205 targeted, experts 0
after linearize_moe: 11,947 Linears, 11,725 targeted, experts 11,520
(30 layers x 128 experts x 3 projections)
llmcompressor's linearize_moe unfuses them; no registration needed because
Gemma-4 satisfies FusedExpertsProtocol structurally. Caught by an §4.1 dry run
that asserts the expert count before any GPU spend, which is now the documented
requirement rather than an optional step.
Scheme is NVFP4A16, deviating from the playbook's mixed-W4A4 default on
measured grounds: brokkr-smithy-dev benched the W4A4 quant of this checkpoint
at 12% on contradiction detection with CoT off against gen's 81%, the signature
of 4-bit input activations on a reasoning-dense task, and W4A4 KLD degrades
2-4x past ~10k ctx on sm_120. This is a 16,384-ctx RP seat. Marlin's prefill
cost is accepted.
Two further silent-failure guards, both from prior hard-won lessons:
- the merged model ships the UPSTREAM chat template, not the trainee base's
stale 365-line one, because training rendered through upstream and the
mismatch would present as a tuning failure
- calibration reads the run's own encode cache rather than re-tokenizing, which
sidesteps §3.14 (a fast tokenizer mutated by truncation=True and persisted by
save_pretrained clamps every prompt forever)
Merge-then-quantize rather than LoRA hot-swap, since hot-swap onto NVFP4 was a
silent no-op on vLLM 0.24.0 (#47639). merge_lora.py asserts sampled target
weights actually changed, so an inert adapter cannot ship as a tune.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# ERP/RP tune → served NVFP4 seat
|
||||
|
||||
Pipeline for turning the Gemma-4 26B-A4B ERP/RP LoRA into a servable NVFP4A16
|
||||
model on `ana-ml2`. Written 2026-08-24 alongside round 2 of the tune.
|
||||
|
||||
Live copies run from `/tank/erp-tune/serve/` on ana-ml2. Model-agnostic quant
|
||||
lessons belong in
|
||||
[`docs/pfi/model-quantization-playbook.md`](../../docs/pfi/model-quantization-playbook.md);
|
||||
the Gemma-4-specific ones are in
|
||||
[`docs/pfi/gemma4-erp-tune-sizing.md`](../../docs/pfi/gemma4-erp-tune-sizing.md).
|
||||
|
||||
## Order
|
||||
|
||||
```bash
|
||||
Q=/tank/aimodels/quant-work/.venv/bin/python # llmcompressor 0.12, ct 0.17.1
|
||||
|
||||
# 1. merge the adapter into bf16 (CPU, ~48 GB RAM, no GPU)
|
||||
$Q merge_lora.py \
|
||||
--base /tank/aimodels/gemma4-26b-a4b-it-heretic-bf16 \
|
||||
--adapter /tank/erp-tune/run-01/adapter \
|
||||
--out /tank/erp-tune/serve/merged-bf16
|
||||
|
||||
# 2. PROVE the target set before spending GPU time
|
||||
$Q quant_nvfp4a16.py --model /tank/erp-tune/serve/merged-bf16 \
|
||||
--out /tmp/x --calib-cache <encode-cache>.jsonl --dry-run
|
||||
|
||||
# 3. quantize
|
||||
$Q quant_nvfp4a16.py --model /tank/erp-tune/serve/merged-bf16 \
|
||||
--out /tank/erp-tune/serve/nvfp4a16 \
|
||||
--calib-cache /tank/erp-tune/run-01/encode-cache/encoded-*.jsonl
|
||||
```
|
||||
|
||||
## The three things that would silently ruin this
|
||||
|
||||
**1. `targets=["Linear"]` misses every MoE expert.** Gemma-4 stores 128 experts
|
||||
per layer as two fused 3-D `nn.Parameter`s, so a Linear-targeting recipe hits
|
||||
205 of 427 modules and **zero** experts — 22.84 B params stay BF16 and nothing
|
||||
warns you. `linearize_moe(model)` unfuses them (427 → 11,947 Linears, 11,520
|
||||
expert targets). Same blind spot that killed QLoRA here via `bitsandbytes`.
|
||||
Playbook §3.15. **The dry run exists to catch this; use it.**
|
||||
|
||||
**2. Shipping the base's own chat template is train/serve skew.** The trainee
|
||||
base carries a *stale* 365-line `chat_template.jinja`; upstream's is 390. The
|
||||
harness trained through upstream (config key `chat_template_path`), so the
|
||||
merged model must ship upstream's. Wrong template presents as a tuning failure
|
||||
with no error. `merge_lora.py` copies it explicitly and refuses if absent.
|
||||
|
||||
**3. Calibration bakes a truncation cap into the tokenizer.** Playbook §3.14 —
|
||||
a fast tokenizer called with `truncation=True` mutates its Rust backend state
|
||||
in place, and `save_pretrained` persists it, clamping every prompt forever.
|
||||
Sidestepped here by calibrating on the run's **encode cache** (already-tokenized
|
||||
records) so the tokenizer is never called with truncation at all. Both scripts
|
||||
still assert `tokenizer.json` has no `truncation` block before declaring success.
|
||||
|
||||
## Why NVFP4**A16** and not the playbook's default mixed W4A4
|
||||
|
||||
Playbook §1 prefers mixed NVFP4-W4A4 + FP8. This seat deviates deliberately:
|
||||
|
||||
- brokkr-smithy-dev benched the W4A4 quant of this checkpoint at **12% on
|
||||
contradiction detection with CoT off against gen's 81%**, while T1/T3/T4/T5
|
||||
sat at 100%. Not general degradation — the signature of 4-bit *input
|
||||
activations* on a reasoning-dense task.
|
||||
- W4A4 KLD is 2–4× worse past ~10k ctx on sm_120; activation-quant noise
|
||||
compounds with KV lookups.
|
||||
- This is a 16,384-ctx RP seat. Long sessions **are** the workload.
|
||||
|
||||
Cost accepted: A16 forces the Marlin kernel, ~half the prefill of native FP4.
|
||||
Decode is memory-bound and barely moves.
|
||||
|
||||
⚠ Several HF repos named `…-NVFP4A16` declare `input_activations num_bits 4` —
|
||||
they are W4A4 wearing an A16 label. `quant_nvfp4a16.py` refuses if the emitted
|
||||
config says `num_bits: 4`. Verify before substituting any upstream artifact.
|
||||
|
||||
## Merge, don't hot-swap
|
||||
|
||||
LoRA-on-NVFP4 hot-swap was a silent no-op on vLLM 0.24.0 (#47639, proven
|
||||
quant-agnostic). Merging first means the quantizer sees ordinary bf16 weights
|
||||
and the served artifact needs no adapter machinery. `merge_lora.py` asserts the
|
||||
merge actually changed sampled target weights — a bit-identical merge would
|
||||
otherwise ship the base model wearing the tune's name.
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge the ERP LoRA adapter into the bf16 base, producing servable weights.
|
||||
|
||||
WHY MERGE RATHER THAN HOT-SWAP. Serving NVFP4 base + LoRA at runtime was a
|
||||
silent no-op on vLLM 0.24.0 (#47639, proven quant-agnostic). Merging first
|
||||
sidesteps it entirely: the quantizer then sees ordinary bf16 weights and the
|
||||
served artifact needs no adapter machinery at all.
|
||||
|
||||
⚠⚠ CHAT TEMPLATE. The trainee base ships a STALE 365-line chat_template.jinja;
|
||||
upstream's is 390 lines. The harness deliberately trained through the UPSTREAM
|
||||
template (config key `chat_template_path`), so the merged model MUST ship that
|
||||
same upstream template. Shipping the base's own template here would be
|
||||
train/serve skew with no error — it presents as a tuning failure.
|
||||
|
||||
⚠ CPU merge. device_map=None keeps the 48 GiB on host RAM (566 GB total here)
|
||||
so this can run while GPU0 is training. Do not use device_map="auto".
|
||||
|
||||
⚠ Loader class. This checkpoint is Gemma4ForConditionalGeneration (vision +
|
||||
audio towers present). Loading it as a plain CausalLM is playbook §3.2 — a
|
||||
silent weight-load failure.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
UPSTREAM_TEMPLATE = "/tank/aimodels/gemma4-26b-a4b-it-bf16/chat_template.jinja"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True)
|
||||
ap.add_argument("--adapter", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--chat-template", default=UPSTREAM_TEMPLATE)
|
||||
a = ap.parse_args()
|
||||
|
||||
out = Path(a.out)
|
||||
if out.exists() and any(out.iterdir()):
|
||||
print(f"REFUSING: {out} exists and is non-empty", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import torch
|
||||
from transformers import AutoTokenizer, Gemma4ForConditionalGeneration
|
||||
from peft import PeftModel
|
||||
|
||||
print(f"[merge] loading base on CPU: {a.base}", flush=True)
|
||||
model = Gemma4ForConditionalGeneration.from_pretrained(
|
||||
a.base, dtype=torch.bfloat16, device_map=None, trust_remote_code=True,
|
||||
)
|
||||
|
||||
# Count LoRA-target params before/after as a merge-actually-happened check.
|
||||
print(f"[merge] applying adapter: {a.adapter}", flush=True)
|
||||
before = {n: p.detach().clone() for n, p in model.named_parameters()
|
||||
if n.endswith("self_attn.q_proj.weight")
|
||||
and ".language_model.layers.0." in n}
|
||||
|
||||
model = PeftModel.from_pretrained(model, a.adapter, is_trainable=False)
|
||||
n_lora = sum(1 for n, _ in model.named_parameters() if "lora_" in n)
|
||||
print(f"[merge] adapter tensors seen: {n_lora}", flush=True)
|
||||
if n_lora == 0:
|
||||
print("REFUSING: adapter contributed 0 tensors", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
model = model.merge_and_unload()
|
||||
print("[merge] merged", flush=True)
|
||||
|
||||
# ⚠ Prove the merge changed weights. A no-op merge is the failure mode that
|
||||
# ships a base model wearing the tune's name, and nothing else would catch it.
|
||||
changed = 0
|
||||
for n, p in model.named_parameters():
|
||||
if n in before:
|
||||
if not torch.equal(p.detach(), before[n]):
|
||||
changed += 1
|
||||
if changed == 0:
|
||||
print("REFUSING: merge produced BIT-IDENTICAL weights on sampled "
|
||||
"LoRA-target modules — the adapter was inert or did not apply",
|
||||
file=sys.stderr)
|
||||
return 3
|
||||
print(f"[merge] verified {changed}/{len(before)} sampled target(s) changed", flush=True)
|
||||
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
print(f"[merge] saving to {out}", flush=True)
|
||||
model.save_pretrained(out, safe_serialization=True)
|
||||
|
||||
# Tokenizer straight from the base — never one that has been through
|
||||
# calibration (playbook §3.14).
|
||||
AutoTokenizer.from_pretrained(a.base, trust_remote_code=True).save_pretrained(out)
|
||||
|
||||
# ⚠ Ship the UPSTREAM chat template, matching what training rendered.
|
||||
src = Path(a.chat_template)
|
||||
if not src.exists():
|
||||
print(f"REFUSING: chat template missing at {src}", file=sys.stderr)
|
||||
return 4
|
||||
shutil.copy2(src, out / "chat_template.jinja")
|
||||
n_lines = len(src.read_text().splitlines())
|
||||
print(f"[merge] chat_template.jinja <- {src} ({n_lines} lines)", flush=True)
|
||||
|
||||
tj = out / "tokenizer.json"
|
||||
if tj.exists() and json.loads(tj.read_text()).get("truncation"):
|
||||
print("REFUSING: shipped tokenizer carries a truncation cap", file=sys.stderr)
|
||||
return 5
|
||||
|
||||
print(f"[merge] DONE -> {out}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NVFP4A16 quantize the merged ERP/RP tune (Gemma-4 26B-A4B MoE).
|
||||
|
||||
SCHEME: weight-only NVFP4 **A16**, not W4A4. This is not the playbook's general
|
||||
default (§1 prefers mixed NVFP4-W4A4 + FP8) and the deviation is deliberate and
|
||||
measured on THIS architecture:
|
||||
|
||||
* brokkr-smithy-dev benched the W4A4 serving quant of gemma4-26b-a4b-it and
|
||||
got **12% on contradiction detection with CoT off, against gen's 81%**,
|
||||
while T1/T3/T4/T5 all sat at 100%. Not general degradation - exactly the
|
||||
shape 4-bit INPUT ACTIVATIONS produce on the most reasoning-dense task.
|
||||
* NVIDIA moved to W4A16 for sm_120 long-context: W4A4 KLD is 2-4x worse past
|
||||
~10k ctx, activation-quant noise compounding with KV lookups.
|
||||
* This seat is a 16,384-ctx RP model. Long sessions ARE the workload.
|
||||
|
||||
Cost paid on purpose: A16 forces the Marlin kernel, roughly half the prefill of
|
||||
native FP4. Decode is memory-bound and barely moves. Accepted.
|
||||
|
||||
CALIBRATION uses the run's own encode cache - the exact token sequences the
|
||||
model trained on, already rendered through the correct upstream chat template.
|
||||
That is both maximally faithful AND sidesteps playbook 3.14 entirely, because
|
||||
we never call the tokenizer with truncation=True at all.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# NVFP4 only the language-model dense Linears + the MoE experts.
|
||||
# Everything here stays BF16.
|
||||
IGNORE = [
|
||||
"lm_head",
|
||||
"re:.*embed_tokens.*",
|
||||
"re:.*embed_vision.*",
|
||||
"re:.*vision_tower.*",
|
||||
"re:.*audio_tower.*",
|
||||
"re:.*audio.*",
|
||||
"re:.*multi_modal_projector.*",
|
||||
"re:.*mm_projector.*",
|
||||
"re:.*patch_embedder.*",
|
||||
"re:.*norm.*",
|
||||
# ⚠ Routers stay BF16. The shipped gemma4-26b-a4b-it-nvfp4 artifact ignores
|
||||
# every `router.proj`, and a 4-bit router picks different experts - the
|
||||
# error does not average out downstream, it changes which weights run.
|
||||
"re:.*router.*",
|
||||
]
|
||||
|
||||
|
||||
def build_calib(cache_path, n, seqlen):
|
||||
"""Calibration set straight from the training encode cache.
|
||||
|
||||
Records are already tokenized and already rendered through the upstream
|
||||
chat template, so this is the true training distribution. Long sequences
|
||||
matter more than sample count for long-context fidelity, so prefer the
|
||||
longest records rather than the first N.
|
||||
"""
|
||||
from datasets import Dataset
|
||||
rows = []
|
||||
with open(cache_path) as fh:
|
||||
for line in fh:
|
||||
r = json.loads(line)
|
||||
rows.append(r["input_ids"])
|
||||
rows.sort(key=len, reverse=True)
|
||||
picked = rows[:n]
|
||||
out = [{"input_ids": ids[:seqlen],
|
||||
"attention_mask": [1] * len(ids[:seqlen])} for ids in picked]
|
||||
lens = [len(o["input_ids"]) for o in out]
|
||||
print("[calib] %d samples, tokens min/mean/max %d/%d/%d" % (
|
||||
len(out), min(lens), sum(lens) // len(lens), max(lens)), flush=True)
|
||||
return Dataset.from_list(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--model", required=True, help="merged bf16 model")
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--calib-cache", required=True, help="encode-cache jsonl")
|
||||
ap.add_argument("--num-calib", type=int, default=256)
|
||||
ap.add_argument("--seqlen", type=int, default=16384)
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="resolve targets and print what WOULD be quantized, then exit")
|
||||
a = ap.parse_args()
|
||||
|
||||
out = Path(a.out)
|
||||
if out.exists() and any(out.iterdir()):
|
||||
print(f"REFUSING: {out} exists and is non-empty", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import torch
|
||||
from transformers import AutoTokenizer, Gemma4ForConditionalGeneration
|
||||
from llmcompressor import oneshot
|
||||
from llmcompressor.modifiers.quantization import QuantizationModifier
|
||||
from llmcompressor.modeling.moe.linearize import linearize_moe
|
||||
|
||||
print(f"[load] {a.model} on CPU (oneshot onloads layer-by-layer)", flush=True)
|
||||
model = Gemma4ForConditionalGeneration.from_pretrained(
|
||||
a.model, dtype=torch.bfloat16, device_map=None, trust_remote_code=True)
|
||||
tok = AutoTokenizer.from_pretrained(a.model, trust_remote_code=True)
|
||||
|
||||
# ⚠⚠ WITHOUT THIS THE MoE STAYS BF16. Gemma-4 stores each layer's 128
|
||||
# experts as two fused 3-D nn.Parameters (`gate_up_proj` [128,1408,2816],
|
||||
# `down_proj` [128,2816,704]) - NOT nn.Linear modules. A recipe targeting
|
||||
# ["Linear"] therefore matches 205 of 427 modules and ZERO experts, leaving
|
||||
# 22.84 B params (88.5% of the model) unquantized. That is precisely how
|
||||
# QLoRA failed on this architecture via bitsandbytes, reproduced in a
|
||||
# different tool.
|
||||
#
|
||||
# `linearize_moe` unfuses them into per-expert `experts.N.{gate,up,down}_proj`
|
||||
# Linear modules. Gemma-4 needs no registration - it satisfies
|
||||
# FusedExpertsProtocol structurally (bare `down_proj` + `gate_up_proj`
|
||||
# Parameters). Verified by the dry run: experts go 0 -> 11,520 targets.
|
||||
print("[moe] linearizing fused experts", flush=True)
|
||||
linearize_moe(model)
|
||||
|
||||
# ⚠ §4.1 - prove the target set BEFORE spending GPU time. A recipe whose
|
||||
# ignore regexes silently miss the experts produces a "quantized" model
|
||||
# that is mostly still bf16, which is exactly how QLoRA failed on this
|
||||
# architecture (bitsandbytes skipped the fused 3-D expert params).
|
||||
import re as _re
|
||||
pats = [p[3:] for p in IGNORE if p.startswith("re:")]
|
||||
lits = [p for p in IGNORE if not p.startswith("re:")]
|
||||
lin = [n for n, m in model.named_modules() if isinstance(m, torch.nn.Linear)]
|
||||
def ignored(n):
|
||||
return any(l in n for l in lits) or any(_re.search(p, n) for p in pats)
|
||||
tgt = [n for n in lin if not ignored(n)]
|
||||
exp = [n for n in tgt if ".experts." in n]
|
||||
rtr = [n for n in lin if "router" in n]
|
||||
print("[targets] Linear modules %d" % len(lin))
|
||||
print("[targets] WILL quantize %d (experts: %d)" % (len(tgt), len(exp)))
|
||||
print("[targets] ignored %d (routers: %d)" % (len(lin) - len(tgt), len(rtr)))
|
||||
if exp == []:
|
||||
print("REFUSING: zero expert Linears targeted. The MoE would stay bf16 - "
|
||||
"this is the QLoRA failure mode. Check the model unfused its "
|
||||
"experts into experts.N.* modules.", file=sys.stderr)
|
||||
return 2
|
||||
for n in tgt[:3] + exp[:2]:
|
||||
print(" +", n)
|
||||
if a.dry_run:
|
||||
print("[dry-run] stopping before quantization")
|
||||
return 0
|
||||
|
||||
ds = build_calib(a.calib_cache, a.num_calib, a.seqlen)
|
||||
|
||||
recipe = QuantizationModifier(
|
||||
targets=["Linear"], scheme="NVFP4A16", ignore=IGNORE,
|
||||
)
|
||||
|
||||
print("[oneshot] starting", flush=True)
|
||||
oneshot(
|
||||
model=model, dataset=ds, recipe=recipe,
|
||||
max_seq_length=a.seqlen, num_calibration_samples=len(ds),
|
||||
output_dir=str(out),
|
||||
)
|
||||
print("[oneshot] done", flush=True)
|
||||
|
||||
# ⚠ playbook 3.14 - NEVER ship the calibration tokenizer. Re-read pristine.
|
||||
AutoTokenizer.from_pretrained(a.model, trust_remote_code=True).save_pretrained(out)
|
||||
src_tpl = Path(a.model) / "chat_template.jinja"
|
||||
if src_tpl.exists():
|
||||
(out / "chat_template.jinja").write_text(src_tpl.read_text())
|
||||
print("[post] chat_template.jinja carried over", flush=True)
|
||||
|
||||
tj = out / "tokenizer.json"
|
||||
if tj.exists() and json.loads(tj.read_text()).get("truncation"):
|
||||
print("REFUSING: shipped tokenizer carries a truncation cap "
|
||||
"(playbook 3.14) - the seat would clamp every prompt",
|
||||
file=sys.stderr)
|
||||
return 3
|
||||
print("[post] tokenizer truncation: clean", flush=True)
|
||||
|
||||
cfg = json.loads((out / "config.json").read_text())
|
||||
qc = cfg.get("quantization_config", {})
|
||||
print("[verify] quant_method %s format %s" % (
|
||||
qc.get("quant_method"), qc.get("format")))
|
||||
for g, v in (qc.get("config_groups") or {}).items():
|
||||
ia = v.get("input_activations")
|
||||
print("[verify] %s: w=%s a=%s" % (
|
||||
g, (v.get("weights") or {}).get("num_bits"),
|
||||
(ia or {}).get("num_bits") if ia else "null (A16)"))
|
||||
if ia and ia.get("num_bits") == 4:
|
||||
print("REFUSING: input_activations num_bits=4 - this is W4A4 "
|
||||
"wearing an A16 label. See the header for why that is wrong "
|
||||
"for this seat.", file=sys.stderr)
|
||||
return 4
|
||||
print(f"[quant] DONE -> {out}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user