Files
vh 0755ba7d00 fix(quant): stop baking the calibration truncation cap into the shipped tokenizer
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.
2026-08-22 00:32:21 -07:00
..

gen-seat mixed-precision quant — NVFP4 W4A4 MLP + FP8 W8A8 attention

The pipeline that produced /tank/aimodels/qwen38-27b-uncensored-nvfp4-mixed, the current fleet gen seat on ana-ml2 GPU0 :8015. +18% decode over the previous weight-only NVFP4A16 build, at equal MTP acceptance and +1.7% perplexity.

General lessons live in docs/pfi/model-quantization-playbook.md — read that before starting a quant on a different model. This file is the worked example for Qwen3.8-27B-Uncensored specifically. Where the two disagree, the playbook wins.

The headline correction: "W4A8" is not a thing you can serve

The queued task was "re-quant to NVFP4 weights + FP8 activations (W4A8) for ~20% more decode". That checkpoint cannot load. vLLM 0.24's compressed-tensors dispatcher (compressed_tensors.py:704-713) allows NVFP4 weights with exactly two activation options:

input_activations scheme kernel
None W4A16 Marlin (forced — kernels/linear/__init__.py:881-883)
NVFP4 W4A4 native Blackwell FP4

anything else — FP8 included — raises

ValueError: For NVFP4 weights, input quantization must also be NVFP4 format,
            None for NVFP4A16

CompressedTensorsW4A8Fp8 exists but is INT4 weights (W4A8_SUPPORTED_TYPES_MAP = {4: int4}) gated on _check_scheme_supported(90, match_exact=True) — Hopper only. ana-ml2 is Blackwell (sm_120), so that path is doubly closed.

The ~20% intuition was right; the scheme name was wrong. The servable way to get FP8 into the mix is per-layer-group, which is exactly what unsloth/Qwen3.8-27B-NVFP4 does — and that build, measured on-box, ran +19.1% faster than ours at identical MTP acceptance. This pipeline replicates its recipe on the abliterated weights.

The recipe

group scheme targets
group_0 FP8 W8A8 — channel weights (static), per-token dynamic activations self_attn.{q,k,v,o}_proj, linear_attn.{in_proj_qkv,in_proj_z,out_proj}, lm_head, layers 56-63 MLPs
group_1 NVFP4 W4A4 — tensor_group gsize16, fp8 scales, imatrix_mse weights, dynamic:"local" activations layers 0-55 MLP {gate,up,down}_proj
kv cache FP8 static tensor
ignored vision tower, linear_attn.{norm,in_proj_a,in_proj_b}, re:^mtp.*

Keeping the last 8 layers' MLPs at FP8 is the accuracy-preservation trick — late layers are the sensitive ones. conv1d in linear_attn is not a Linear and stays BF16 in both our build and unsloth's.

Targets are deliberately non-overlapping (group_1 enumerates layers 0-55 rather than matching all MLPs) instead of relying on group precedence to resolve the 56-63 collision. validate_targets.py proves this against the real module names before any GPU time is spent — run it first.

Running it

# 0. prove the regexes hit what you think (free, no GPU)
python3 validate_targets.py /tank/aimodels/qwen38-27b-uncensored-bf16
#    -> expect OVERLAP 0, MLP layer union covers 0-63 True

# 1. quant (~20 min on one Blackwell; needs llmcompressor, NOT modelopt)
python3 quant_mixed_nvfp4.py \
  --model /tank/aimodels/qwen38-27b-uncensored-bf16 \
  --calib /tank/aimodels/heretic2-nvfp4-work/production_calib_512.jsonl \
  --out   /tank/aimodels/qwen38-27b-uncensored-nvfp4-mixed \
  --num-samples 256 --seqlen 2048

# 2. MANDATORY post-steps — graft MTP, restore preprocessor, repair the mtp ignore
python3 post_quant.py <bf16-source> <out-dir>

pip install llmcompressor into the stock vllm/vllm-openai:latest image gives llmcompressor 0.13.0 + compressed-tensors 0.18.0 without disturbing torch or transformers. Do not use modelopt 0.43 — dependency hell on qwen3_5.

The foot-gun that has now cost three rounds

llm-compressor prunes ignore entries that matched no module at quant time. The wrapper class (Qwen3_5ForConditionalGeneration) never loads the MTP head, so re:^mtp.* matches nothing and is silently dropped from the saved config. vLLM then treats the freshly grafted BF16 MTP head as quantized, brings it up uninitialised, and speculative decoding runs at 0% acceptance.

post_quant.py re-injects the entry after the graft and re-verifies. It is not optional, and it verifies rather than assumes — that check fired on this very run.

Acceptance gate (bench/)

Speed alone does not justify cutting over a seat backing 7 LiteLLM aliases.

metric W4A16 (old) mixed (new) delta
decode tok/s, bs=1, cache-busted 80.12 94.53 +18.0%
prefill tok/s, ~6.7k prompt 3,206 6,334 +98%
prefill tok/s, ~27k prompt 2,862 5,085 +78%
TTFT on a ~27k-token doc 9.43 s 5.31 s 44%
MTP acceptance 47.8% 47.7% unchanged
perplexity, 6 held-out passages 6.941 7.059 +1.7% worse
abliteration compliance 4/4 4/4 preserved
weights on disk 27.7 GB 22.5 GB 19%

Prefill roughly doubled — the bigger practical win, and exactly what theory predicts: decode at bs=1 is memory-bandwidth-bound (weights are 4-bit either way, so little changes), while prefill is compute-bound and is where Blackwell's native FP4 tensor cores replace the Marlin dequant-to-BF16 path. This is what the summarizer / summarizer-large aliases feel on long documents.

  • quickbench.py — cache-busted bs=1 decode + MTP acceptance. Bust the cache: with a fixed prompt, prefix caching returns byte-identical timings and you measure nothing.
  • prefill_bench.py — TTFT on long prompts. Same trap, worse: a seeded nonce reproduces the previous run's prompts verbatim, so prefix caching serves them and you read ~41k tok/s of cache-hit instead of ~5k of real prefill. Uses SystemRandom; never seed it.
  • eval_quality.py — perplexity, deterministic generations, abliteration survival. PPL must be measured with --speculative-config OFF: under MTP, vLLM's prompt_logprobs come back ~uniform over the vocab (median rank ~10^5, logprob ≈ log(1/vocab)). The harness raises rather than reporting the garbage.
  • surface_test.py — the real gate: plain chat, vision, tool calling, thinking split, 36K-token needle retrieval, streaming. All six must pass before a cutover.
  • serve_probe.sh <model-dir> [nospec] — serve a candidate on :8017 without touching the live seat.

GPU0 budget

The mixed build's weights are 5.2 GB smaller. At the old GEN_GPU_MEM_UTIL=0.45 the seat absorbed that slack as extra KV (17.0 GiB / 477K tokens) and left meromero-charrp 0.18 GiB short of its 0.52 budget — it crash-looped on startup. Fixed by handing the space back: GEN_GPU_MEM_UTIL=0.43 → 15.1 GiB / 422K tokens, still 1.6× the 262K context. Both seats co-resident at 89.8 / 97.9 GB.

Levers already measured — do not re-chase

GEN_SPEC_TOKENS swept on this seat: n=2 → 77.1, n=3 → 80.1, n=4 → 78.7, n=5 → 75.9 tok/s. Three is the optimum; higher n trades acceptance for draft width and loses.

Rollback

The previous build is untouched at /tank/aimodels/qwen38-27b-uncensored-nvfp4.

ssh infra-ops@10.250.50.54
sudo cp /opt/docker/compose/gen-seat/.env.bak-w4a16-20260815 /opt/docker/compose/gen-seat/.env
cd /opt/docker/compose/gen-seat && sudo docker compose up -d vllm-gen

gen-seat/.env is mode 0600 and lkraven-owned — every docker compose call against it needs sudo, or compose fails with permission denied reading .env, leaves the old container running, and the change silently does not take.