0755ba7d00
load_calib tokenizes with tok(..., truncation=True, max_length=seqlen). For a
fast tokenizer that mutates the Rust backend's truncation state in place, and
the subsequent tok.save_pretrained() persisted it, so every mixed-NVFP4 build
shipped a tokenizer.json carrying
"truncation": {"direction": "Right", "max_length": 2048, ...}
against a source whose value is null. Every prompt was clamped at the
calibration length, permanently.
It hid because older transformers does not enforce the text-vs-ids count
check. On a newer one the seat dies at startup with a message that names
images and never mentions tokenizers:
ValueError: Mismatch in `image` token count between text and `input_ids`.
Got ids=[2047] and text=[16384].
The cap also silently limited image resolution well before it killed
anything -- at 2048 the largest servable image is about 1448x1448, since
(edge/patch)^2 / merge^2 image tokens have to fit under it.
Fix saves a pristine tokenizer re-read from the source rather than the
mutated calibration object, and then asserts truncation is null so the
defect fails the build instead of shipping again.
Playbook gains section 3.14 with the symptom, the cause, the audit one-liner
and a table of which builds were affected, plus a fourth mandatory post-step.
The transferable lesson is called out: this is the third case of an artifact
carrying config authored against an older transformers that a newer one
begins enforcing, so an image bump is a config-compatibility event rather
than just a version change.
199 lines
8.9 KiB
Python
199 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Mixed-precision NVFP4(W4A4 MLP) + FP8(W8A8 attn) quant of Qwen3.8-27B-Uncensored.
|
|
|
|
Replicates the scheme of `unsloth/Qwen3.8-27B-NVFP4` (verified on-box to run
|
|
+19.1% faster than our weight-only NVFP4A16/Marlin build at identical MTP
|
|
acceptance), applied to the abliterated uncensored weights.
|
|
|
|
WHY THIS SHAPE, not the "NVFP4 weights + FP8 activations" W4A8 idea:
|
|
vLLM 0.24's compressed-tensors dispatcher (compressed_tensors.py:704-713) allows
|
|
NVFP4 weights with EXACTLY two activation options -- None (=W4A16, Marlin) or
|
|
NVFP4 (=W4A4). Anything else, FP8 included, raises
|
|
ValueError: For NVFP4 weights, input quantization must also be NVFP4 format
|
|
So a literal W4A8-on-NVFP4 checkpoint cannot load. The servable way to get FP8
|
|
into the mix is per-layer-group: NVFP4 W4A4 for the bulk MLPs, FP8 W8A8 for the
|
|
attention projections / linear_attn / lm_head / last-8-layer MLPs.
|
|
|
|
Groups (byte-for-byte the unsloth recipe):
|
|
group_0 FP8 W8A8 channel weights (static) + per-token dynamic activations
|
|
-> self_attn q/k/v/o, linear_attn in_proj_qkv/in_proj_z/out_proj,
|
|
lm_head, and layers 56-63 MLPs (late layers are accuracy-sensitive)
|
|
group_1 NVFP4 W4A4 tensor_group gsize16, fp8 scales, imatrix_mse weights
|
|
-> layers 0-55 MLP gate/up/down
|
|
kv_cache FP8 static tensor
|
|
|
|
Targets are made EXPLICITLY non-overlapping (group_1 enumerates layers 0-55)
|
|
rather than relying on group-precedence to resolve the 56-63 collision.
|
|
|
|
Kept out entirely: vision tower, linear_attn norm/in_proj_a/in_proj_b, and MTP.
|
|
MTP MUST stay in `ignore` -- otherwise vLLM loads the grafted bf16 MTP head as
|
|
quantized and it comes up uninitialised (0% acceptance). That bug cost two prior
|
|
rounds; do not remove `re:^mtp.*`.
|
|
"""
|
|
import argparse, json, os, sys
|
|
|
|
# --- group_0: FP8 W8A8 -------------------------------------------------------
|
|
G0_TARGETS = [
|
|
r"re:.*self_attn\.(q|k|v|o)_proj$",
|
|
r"re:.*linear_attn\.(in_proj_qkv|in_proj_z|out_proj)$",
|
|
r"re:.*lm_head",
|
|
r"re:.*layers\.(56|57|58|59|60|61|62|63)\.mlp\.(gate|up|down)_proj$",
|
|
]
|
|
# --- group_1: NVFP4 W4A4, layers 0-55 only (0-9 | 10-49 | 50-55) -------------
|
|
G1_TARGETS = [
|
|
r"re:.*layers\.([0-9]|[1-4][0-9]|5[0-5])\.mlp\.(gate|up|down)_proj$",
|
|
]
|
|
IGNORE = [
|
|
r"re:.*visual.*",
|
|
r"re:.*linear_attn\.(norm|in_proj_a|in_proj_b)$",
|
|
r"re:^mtp.*",
|
|
]
|
|
|
|
|
|
def build_recipe():
|
|
from compressed_tensors.quantization import QuantizationArgs, QuantizationScheme
|
|
from llmcompressor.modifiers.quantization import QuantizationModifier
|
|
|
|
g0 = QuantizationScheme(
|
|
targets=G0_TARGETS,
|
|
weights=QuantizationArgs(num_bits=8, type="float", strategy="channel",
|
|
symmetric=True, dynamic=False,
|
|
observer="memoryless_minmax"),
|
|
input_activations=QuantizationArgs(num_bits=8, type="float", strategy="token",
|
|
symmetric=True, dynamic=True),
|
|
)
|
|
g1 = QuantizationScheme(
|
|
targets=G1_TARGETS,
|
|
weights=QuantizationArgs(num_bits=4, type="float", strategy="tensor_group",
|
|
group_size=16, symmetric=True, dynamic=False,
|
|
observer="imatrix_mse", actorder="static",
|
|
scale_dtype="torch.float8_e4m3fn"),
|
|
input_activations=QuantizationArgs(num_bits=4, type="float", strategy="tensor_group",
|
|
group_size=16, symmetric=True, dynamic="local",
|
|
observer="static_minmax",
|
|
scale_dtype="torch.float8_e4m3fn"),
|
|
)
|
|
kv = QuantizationArgs(num_bits=8, type="float", strategy="tensor",
|
|
symmetric=True, dynamic=False, observer="static_minmax")
|
|
return QuantizationModifier(
|
|
config_groups={"group_0": g0, "group_1": g1},
|
|
ignore=IGNORE,
|
|
kv_cache_scheme=kv,
|
|
)
|
|
|
|
|
|
def load_calib(path, tok, n, seqlen):
|
|
from datasets import Dataset
|
|
rows = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
msgs = json.loads(line).get("messages")
|
|
if not msgs:
|
|
continue
|
|
try:
|
|
txt = tok.apply_chat_template(msgs, tokenize=False)
|
|
except Exception:
|
|
continue
|
|
rows.append({"text": txt})
|
|
if len(rows) >= n:
|
|
break
|
|
print(f"calibration rows: {len(rows)}", flush=True)
|
|
ds = Dataset.from_list(rows)
|
|
|
|
def tokenize(b):
|
|
return tok(b["text"], truncation=True, max_length=seqlen, add_special_tokens=False)
|
|
|
|
return ds.map(tokenize, remove_columns=["text"])
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--model", required=True)
|
|
ap.add_argument("--calib", required=True)
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--num-samples", type=int, default=256)
|
|
ap.add_argument("--seqlen", type=int, default=2048)
|
|
a = ap.parse_args()
|
|
|
|
from transformers import AutoTokenizer, Qwen3_5ForConditionalGeneration
|
|
from llmcompressor import oneshot
|
|
|
|
print(f"loading (wrapper class) {a.model}", flush=True)
|
|
tok = AutoTokenizer.from_pretrained(a.model, trust_remote_code=True)
|
|
model = Qwen3_5ForConditionalGeneration.from_pretrained(
|
|
a.model, torch_dtype="auto", device_map=None, trust_remote_code=True)
|
|
|
|
# llmcompressor 0.12 introspects attention structure off the TOP-LEVEL config
|
|
# (to size kv-cache/quant params). Qwen3_5 keeps num_attention_heads etc. under
|
|
# text_config, and transformers 5.10 no longer delegates the top-level lookup,
|
|
# so oneshot raises "Cannot determine num_attention_heads from config". Promote
|
|
# them from the authoritative text_config for the duration of quant, then
|
|
# restore, so the saved config keeps its canonical text_config-only shape.
|
|
# (This env moved under us since the 2026-08-15 heresy quant, where the older
|
|
# transformers still delegated — same "the fight is the environment" pattern.)
|
|
_promote = ("num_attention_heads", "num_key_value_heads", "hidden_size",
|
|
"head_dim", "num_hidden_layers")
|
|
_tc = getattr(model.config, "text_config", None)
|
|
_orig = {f: getattr(model.config, f, None) for f in _promote}
|
|
if _tc is not None:
|
|
for f in _promote:
|
|
v = getattr(_tc, f, None)
|
|
if v is not None:
|
|
setattr(model.config, f, v)
|
|
print("promoted text_config attention fields to top-level config for oneshot: "
|
|
+ ", ".join(f"{f}={getattr(model.config, f)}" for f in _promote), flush=True)
|
|
|
|
ds = load_calib(a.calib, tok, a.num_samples, a.seqlen)
|
|
recipe = build_recipe()
|
|
print("oneshot: NVFP4 W4A4 (L0-55 MLP) + FP8 W8A8 (attn/linear_attn/lm_head/L56-63 MLP) "
|
|
"+ FP8 KV; vision/linear_attn-norms/MTP ignored", flush=True)
|
|
oneshot(model=model, dataset=ds, recipe=recipe,
|
|
num_calibration_samples=len(ds), max_seq_length=a.seqlen)
|
|
|
|
# restore the canonical config shape (undo the promotion above) so the saved
|
|
# top-level config matches the known-good heresy output; vLLM reads text_config.
|
|
for f, v in _orig.items():
|
|
try:
|
|
setattr(model.config, f, v)
|
|
except Exception:
|
|
pass
|
|
|
|
print(f"saving -> {a.out}", flush=True)
|
|
model.save_pretrained(a.out, save_compressed=True)
|
|
|
|
# ⚠ DO NOT `tok.save_pretrained(a.out)` — that ships a CRIPPLED tokenizer.
|
|
# `load_calib` calls tok(..., truncation=True, max_length=seqlen), which
|
|
# MUTATES the fast tokenizer's Rust backend truncation state in place.
|
|
# save_pretrained then bakes {"truncation": {"max_length": <seqlen>}} into
|
|
# tokenizer.json, so every prompt is silently clamped at the CALIBRATION
|
|
# length forever. Latent on older transformers (it does not enforce the
|
|
# check) and fatal on newer ones: a vision model dies at startup because the
|
|
# dummy profiling image expands to more image tokens than the cap allows
|
|
# ("Mismatch in `image` token count ... Got ids=[<cap-1>]").
|
|
# Save a pristine tokenizer re-read from the SOURCE instead.
|
|
from transformers import AutoTokenizer as _AutoTokenizer
|
|
_AutoTokenizer.from_pretrained(a.model, trust_remote_code=True).save_pretrained(a.out)
|
|
|
|
# Fail loudly rather than shipping the defect again.
|
|
_tj = os.path.join(a.out, "tokenizer.json")
|
|
if os.path.exists(_tj):
|
|
with open(_tj) as _f:
|
|
_trunc = json.load(_f).get("truncation")
|
|
if _trunc:
|
|
raise SystemExit(
|
|
f"FAILED CHECK: saved tokenizer.json carries truncation={_trunc}. "
|
|
"It must be null — see quant playbook §3.14."
|
|
)
|
|
print("tokenizer saved pristine (truncation=null) — verified", flush=True)
|
|
|
|
print("DONE (post-steps still required: graft MTP, preprocessor_config, verify ignore)",
|
|
flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|