lm_head, and token embeddings are kept in bf16. See Development Notes below for why.vllm/vllm-openai:v0.26.0 on an NVIDIA RTX 5050 (8GB VRAM). Tested launch flags:1docker run \
2 --gpus=all \
3 --rm -d \
4 -p 8080:8080 \
5 -v /path/to/this/model:/model \
6 --name gemma3-4b-nvfp4 \
7 vllm/vllm-openai:v0.26.0-ubuntu2404 \
8 /model \
9 --served-model-name syaffers/gemma-3-4b-it-NVFP4 \
10 --max-model-len 4096 \
11 --max-num-seqs 8 \
12 --kv-cache-dtype fp8_e4m3 \
13 --host 0.0.0.0 \
14 --port 8080--max-model-len 4096 — the full 131k context in the base model's config is not realistic on 8GB; 4096 leaves enough headroom for KV cache after the ~5.6GB of weights are loaded.--max-num-seqs — this is the biggest lever for VRAM. Start at 8; drop to 1 or 2 if you see KV-cache-related OOMs at startup (vLLM's own startup log prints how much KV cache memory it actually has available — check that line first).--kv-cache-dtype fp8_e4m3 — halves KV cache memory at negligible quality cost, buying back some of the concurrency --max-num-seqs costs you. This is a serving-time flag, not something baked into the checkpoint (kv_cache_scheme in config.json is unset).1from openai import OpenAI
2
3client = OpenAI(api_key="EMPTY", base_url="http://localhost:8080/v1")
4
5response = client.chat.completions.create(
6 model="syaffers/gemma-3-4b-it-NVFP4",
7 messages=[{
8 "role": "user",
9 "content": [
10 {"type": "text", "text": "Describe this image in one sentence."},
11 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
12 ],
13 }],
14 max_tokens=60,
15)
16print(response.choices[0].message.content)transformers + compressed-tensors (no vLLM required), though without NVFP4 GEMM kernels it dequantizes to bf16 on first forward pass, so peak memory briefly needs close to the full bf16 footprint (~8GB) — tight but workable on an 8GB card, more comfortable on CPU or a larger GPU.1import torch
2from transformers import Gemma3Processor, Gemma3ForConditionalGeneration
3
4model_id = "syaffers/gemma-3-4b-it-NVFP4"
5processor = Gemma3Processor.from_pretrained(model_id)
6model = Gemma3ForConditionalGeneration.from_pretrained(model_id, dtype=torch.bfloat16, device_map="cuda:0")uv run quantize.py1# /// script
2# requires-python = ">=3.12"
3# dependencies = [
4# "torch>=2.11.0",
5# "transformers",
6# "datasets",
7# "llmcompressor==0.12.0",
8# ]
9#
10# [tool.uv.sources]
11# torch = { index = "pytorch-cu128" }
12#
13# [[tool.uv.index]]
14# name = "pytorch-cu128"
15# url = "https://download.pytorch.org/whl/cu128"
16# explicit = true
17# ///
18
19import json
20
21import torch
22from datasets import Dataset, load_dataset
23from llmcompressor import oneshot
24from llmcompressor.modifiers.quantization import QuantizationModifier
25from transformers import AutoProcessor, Gemma3ForConditionalGeneration
26
27MODEL_ID = "google/gemma-3-4b-it"
28SAVE_DIR = MODEL_ID.rstrip("/").split("/")[-1] + "-NVFP4"
29NUM_CALIBRATION_SAMPLES = 256
30MAX_SEQUENCE_LENGTH = 2048
31BATCH_SIZE = 1
32
33
34def build_calib_dataset(processor, num_samples: int):
35 """Pre-tokenized calibration set."""
36 ds = load_dataset("derek-thomas/ScienceQA", split="train").filter(
37 lambda ex: ex["image"] is not None
38 )
39 ds = ds.select(range(min(num_samples, len(ds))))
40
41 rows = []
42 for ex in ds:
43 question = f"{ex['question']}\nChoices: {', '.join(ex['choices'])}"
44 msgs = [
45 {
46 "role": "user",
47 "content": [{"type": "text", "text": question}, {"type": "image"}],
48 }
49 ]
50 prompt = processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
51 enc = processor(text=prompt, images=[ex["image"].convert("RGB")], return_tensors="pt")
52 rows.append({k: v[0].tolist() for k, v in enc.items()})
53
54 return Dataset.from_list(rows)
55
56
57def patch_vision_tower_ignore_list(output_dir: str):
58 """Fix the vision-tower module paths in config.json's `ignore` list."""
59 config_path = f"{output_dir}/config.json"
60 with open(config_path) as f:
61 config = json.load(f)
62
63 ignore = config["quantization_config"]["ignore"]
64 fixed = [
65 e.replace("model.vision_tower.", "model.vision_tower.vision_model.", 1)
66 if e.startswith("model.vision_tower.")
67 and not e.startswith("model.vision_tower.vision_model.")
68 else e
69 for e in ignore
70 ]
71 config["quantization_config"]["ignore"] = fixed
72 with open(config_path, "w") as f:
73 json.dump(config, f, indent=2)
74
75 print(
76 f"Patched {sum(a != b for a, b in zip(ignore, fixed))} ignore-list entries "
77 f"to match the real vision_tower.vision_model.* module path (in config.json)"
78 )
79
80
81def main():
82 print(f"[1/5] ## Loading model from {MODEL_ID}")
83 model = Gemma3ForConditionalGeneration.from_pretrained(MODEL_ID, dtype=torch.bfloat16)
84 processor = AutoProcessor.from_pretrained(MODEL_ID)
85
86 recipe = [
87 QuantizationModifier(
88 targets="Linear",
89 scheme="NVFP4",
90 ignore=[
91 "lm_head",
92 r"re:model\.vision_tower.*",
93 r"re:model\.multi_modal_projector.*",
94 ],
95 )
96 ]
97
98 print(f"[2/5] ## Building calibration dataset (scienceqa, n={NUM_CALIBRATION_SAMPLES})")
99 calib_dataset = build_calib_dataset(processor, NUM_CALIBRATION_SAMPLES)
100
101 print(f"[3/5] ## Oneshot NVFP4 quantization (n={NUM_CALIBRATION_SAMPLES})")
102 oneshot(
103 model=model,
104 processor=processor,
105 dataset=calib_dataset,
106 recipe=recipe,
107 batch_size=BATCH_SIZE,
108 shuffle_calibration_samples=False,
109 max_seq_length=MAX_SEQUENCE_LENGTH,
110 num_calibration_samples=NUM_CALIBRATION_SAMPLES,
111 )
112
113 print("[4/5] ## Sanity generation check")
114 msgs = [
115 {
116 "role": "user",
117 "content": [
118 {
119 "type": "text",
120 "text": "In one sentence, what is the capital of France?",
121 }
122 ],
123 }
124 ]
125 prompt = processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
126 inputs = processor(text=prompt, return_tensors="pt").to(model.device)
127 out = model.generate(**inputs, max_new_tokens=30, disable_compile=True)
128 print("===\n", processor.decode(out[0], skip_special_tokens=True), "\n===")
129
130 print(f"[5/5] ## Saving to {SAVE_DIR}")
131 model.save_pretrained(SAVE_DIR, save_compressed=True)
132 processor.save_pretrained(SAVE_DIR)
133 patch_vision_tower_ignore_list(SAVE_DIR)
134
135 print("Done.")
136
137
138if __name__ == "__main__":
139 main()config.json's quantization_config.ignore list needs one correction: llmcompressor==0.12.0 records vision-tower entries as model.vision_tower.encoder...., but the real module path (and the safetensors tensor names) is vision_tower.vision_model.encoder.... — missing the vision_model segment. This doesn't affect the weights themselves (the vision tower is genuinely plain bf16 either way), but vLLM builds each layer's quant method from this list at model-construction time, before reading any tensor, so a name that doesn't match makes it treat an unquantized bf16 layer as if it were quantized — and it crashes on load with KeyError/AttributeError deep in the SigLIP weight loader. transformers doesn't hit this because it decides per-tensor from what's actually in the checkpoint. Fixed with:1import json
2
3path = "gemma-3-4b-it-NVFP4/config.json"
4config = json.load(open(path))
5ignore = config["quantization_config"]["ignore"]
6config["quantization_config"]["ignore"] = [
7 e.replace("model.vision_tower.", "model.vision_tower.vision_model.", 1)
8 if e.startswith("model.vision_tower.") and not e.startswith("model.vision_tower.vision_model.")
9 else e
10 for e in ignore
11]
12json.dump(config, open(path, "w"), indent=2)lm-eval[api]==0.4.12) against this checkpoint and the unquantized google/gemma-3-4b-it bf16 baseline, both served via vLLM (local-completions backend, --apply_chat_template). Shot counts replicate google/gemma-3-4b-it's own benchmark table for consistency.| Benchmark | Shots | Metric | bf16 | NVFP4 | Recovery |
|---|---|---|---|---|---|
| BoolQ | 0 | acc | 83.73 | 82.45 | 98.47% |
| GSM8K | 8 | exact_match (flexible) | 77.26 | 69.67 | 90.18% |
| PIQA | 0 | acc_norm | 68.66 | 69.10 | 100.64% |
| WinoGrande | 5 | acc | 64.72 | 63.22 | 97.68% |
| ARC-easy | 0 | acc_norm | 63.34 | 61.20 | 96.62% |
| ARC-challenge | 25 | acc_norm | 60.75 | 57.42 | 94.52% |
| MMLU | 5 | acc | 59.58 | 56.13 | 94.21% |
| HellaSwag | 10 | acc_norm | 58.81 | 61.12 | 103.93% |
| TriviaQA | 5 | exact_match | 44.26 | 39.29 | 88.77% |
| DROP | 1 | f1 | 13.70 | 14.28 | 104.23% |
| Natural Questions | 5 | exact_match | 10.86 | 9.78 | 90.06% |
transformers (bf16 dequant) and vLLM (native NVFP4 GEMM via FlashInfer).1"""Runs the Gemma 3 PT benchmark scheme against a served checkpoint via lm-eval.
2
3Replicates the shot counts from google/gemma-3-4b-it's own README (HellaSwag
410-shot, ARC-e 0-shot, ARC-c 25-shot, MMLU 5-shot, GSM8K 8-shot, etc.) so the
5result is comparable across checkpoints. Targets any OpenAI-completions-
6compatible endpoint (vLLM, in our case), not a specific checkpoint, so the
7same script produces both the NVFP4 and bf16 numbers in this README.
8
9Calls lm-eval's own Python entry point (`lm_eval.simple_evaluate`, the same
10function the `lm_eval` CLI calls internally) directly, rather than shelling
11out to the CLI, so results come back as a plain dict instead of scraped
12terminal output.
13
14Requires a running server, e.g.:
15 docker run --gpus=all --rm -d -p 8080:8080 \\
16 -v /path/to/this/model:/model \\
17 vllm/vllm-openai:v0.26.0-ubuntu2404 /model \\
18 --served-model-name syaffers/gemma-3-4b-it-NVFP4 \\
19 --max-model-len 8192 --max-num-seqs 2 --gpu-memory-utilization 0.85 \\
20 --kv-cache-dtype fp8_e4m3 --host 0.0.0.0 --port 8080
21
22Then run with:
23 uv run evaluate.py \\
24 --base-url http://localhost:8080/v1/completions \\
25 --model syaffers/gemma-3-4b-it-NVFP4 \\
26 --tokenizer . \\
27 --output ./eval_results
28
29See README.md ("Evaluation") for the results this produced.
30"""
31
32# /// script
33# requires-python = ">=3.12"
34# dependencies = [
35# "lm-eval[api]",
36# "transformers",
37# ]
38# ///
39
40import argparse
41import json
42
43from lm_eval import simple_evaluate
44from lm_eval.loggers import EvaluationTracker
45from lm_eval.utils import handle_non_serializable, make_table
46
47# (tasks, num_fewshot) groups, batched by shot count to minimize server
48# restarts.
49FEWSHOT_GROUPS = [
50 (["boolq", "piqa"], 0),
51 (["drop"], 1),
52 (["triviaqa", "nq_open", "winogrande", "mmlu"], 5),
53 (["gsm8k"], 8),
54 (["hellaswag"], 10),
55 (["arc_challenge"], 25),
56]
57
58
59def run(tasks: list[str], num_fewshot: int, model_args: str, output: str, limit: int | None) -> dict:
60 print(f"=== {','.join(tasks)} (n={num_fewshot}) ===")
61 tracker = EvaluationTracker(output_path=output)
62 results = simple_evaluate(
63 model="local-completions",
64 model_args=model_args,
65 tasks=tasks,
66 num_fewshot=num_fewshot,
67 apply_chat_template=True,
68 log_samples=True,
69 evaluation_tracker=tracker,
70 limit=limit,
71 )
72 if results is None:
73 return {}
74
75 # simple_evaluate() only uses evaluation_tracker for config metadata --
76 # actually persisting results/samples to --output needs these explicit
77 # calls, same as the lm_eval CLI does after its own simple_evaluate() call.
78 samples = results.pop("samples")
79 tracker.save_results_aggregated(results=results, samples=samples)
80 for task_name in results["configs"]:
81 tracker.save_results_samples(task_name=task_name, samples=samples[task_name])
82
83 print(make_table(results))
84 if "groups" in results:
85 print(make_table(results, "groups"))
86 return results
87
88
89def main() -> None:
90 ap = argparse.ArgumentParser(description=__doc__)
91 ap.add_argument("--base-url", required=True, help="e.g. http://localhost:8080/v1/completions")
92 ap.add_argument("--model", required=True, help="served model name")
93 ap.add_argument("--tokenizer", required=True, help="local checkpoint dir or HF repo id")
94 ap.add_argument("--output", default="./eval_results")
95 ap.add_argument(
96 "--num-concurrent", type=int, default=1,
97 help="Keep this low (1-2) on consumer GPUs -- lm-eval's multiple-choice "
98 "tasks request logprobs over the *entire* prompt, which some vLLM "
99 "versions don't account for in startup memory profiling. This can "
100 "CUDA OOM mid-run even with modest --max-num-seqs. If that happens, "
101 "lower this further and/or relaunch the server with a lower "
102 "--gpu-memory-utilization (0.85 worked for an 8GB/12GB card here).",
103 )
104 ap.add_argument("--max-retries", type=int, default=3)
105 ap.add_argument(
106 "--limit", type=int, default=None,
107 help="Cap examples per task (for smoke-testing the script itself, not for "
108 "real numbers -- results with --limit set are not comparable to the "
109 "README's).",
110 )
111 args = ap.parse_args()
112
113 model_args = (
114 f"model={args.model},base_url={args.base_url},tokenizer={args.tokenizer},"
115 f"num_concurrent={args.num_concurrent},max_retries={args.max_retries}"
116 )
117
118 all_results = {}
119 for tasks, num_fewshot in FEWSHOT_GROUPS:
120 all_results.update(run(tasks, num_fewshot, model_args, args.output, args.limit).get("results", {}))
121
122 summary_path = f"{args.output}/summary.json"
123 with open(summary_path, "w") as f:
124 json.dump(all_results, f, indent=2, default=handle_non_serializable, ensure_ascii=False)
125 print(f"Wrote consolidated summary to {summary_path}")
126 print("ALL_TASKS_COMPLETE")
127
128
129if __name__ == "__main__":
130 main()nvidia-modelopt) was tried as an alternative to LLM Compressor. It produced a same-size, same-scope checkpoint, but its export_hf_checkpoint() writes quant_method: "modelopt" into config.json, which plain transformers and vLLM don't recognize — loading it silently skips quantization handling entirely (and then OOMs trying to allocate full-size tensors). That export path is meant for TensorRT-LLM or a modelopt-aware runtime, not the transformers/vLLM stack this checkpoint targets, so we standardized on LLM Compressor's native compressed-tensors output instead.transformers (AttributeError: 'Linear' object has no attribute 'weight' in SigLIP's _init_weights) and in vLLM (KeyError/AttributeError in the SigLIP weight loader) — a current gap in Gemma3-VLM + compressed-tensors support, not something specific to our recipe or calibration. The vision tower is kept unquantized for this reason.amax.is_cuda), but the 8.1GB bf16 model doesn't fit on an 8GB GPU with any headroom for activations. LLM Compressor's SequentialPipeline calibrates one decoder layer at a time, which sidesteps this cleanly — this is the main reason LLM Compressor was easier to work with here than modelopt, which required CPU-offload workarounds via accelerate on this hardware.--max-num-seqs 2 --gpu-memory-utilization 0.85 — lower than normal serving — because lm-eval's multiple-choice tasks request logprobs over the entire prompt, which vLLM's startup memory profiling doesn't account for and which OOM'd at the normal-serving settings. See Evaluation details for the exact script.