DEV Community

Cover image for Quantizing MedGemma to INT4 (GPTQ/W4A16): Everything That Broke Along the Way
JoTeq the First
JoTeq the First

Posted on

Quantizing MedGemma to INT4 (GPTQ/W4A16): Everything That Broke Along the Way

Quantized Google's MedGemma-1.5-4B (a medical vision-language
model) to INT4 (W4A16) via llm-compressor's GPTQModifier, for
self-hosted deployment. 8.6 GB in BF16 -> 5.2 GB quantized. Full
step-by-step below, model link at the bottom.

References: gemma3_example.py
(model class, GPTQ/W4A16 recipe) and
gemma4_example.py
(calibration dataset pattern).


Step 1: Choose the quantization method

GPTQ, via llm-compressor. Not AWQ.

  • AutoAWQ is officially deprecated.
  • Even setting that aside, AutoAWQ never supported Gemma3's architecture, which MedGemma is built on. llm-compressor has the same gap for AWQ specifically here — confirmed via two open GitHub issues describing outright failures.

Step 2: Set up your environment

Ran on RunPod (RTX A5000), using the instance's own pre-configured
JupyterLab directly.

nvidia-smi   # check actual CUDA version first
Enter fullscreen mode Exit fullscreen mode
pip3 uninstall torch torchvision -y
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu128
Enter fullscreen mode Exit fullscreen mode
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"
Enter fullscreen mode Exit fullscreen mode

Confirm True before continuing. Then install llmcompressor
separately, with --no-deps (otherwise it silently pulls in a
conflicting torch):

pip3 install llmcompressor==0.12.0 --no-deps
Enter fullscreen mode Exit fullscreen mode

Then the other packages:

pip3 install transformers==5.10.1 compressed-tensors==0.17.1 requests==2.32.5 \
  pillow==11.0.0 zstandard==0.25.0 python-dotenv==1.2.2
Enter fullscreen mode Exit fullscreen mode

Real final versions used: torch==2.11.0+cu128,
torchvision==0.26.0+cu128, llmcompressor==0.12.0,
compressed-tensors==0.17.1, transformers==5.10.1,
requests==2.32.5, pillow==11.0.0, zstandard==0.25.0.

Set up your HF token before you need it — MedGemma is gated. Create a
.env:

HF_TOKEN=hf_xxx
Enter fullscreen mode Exit fullscreen mode
from dotenv import load_dotenv
load_dotenv()
Enter fullscreen mode Exit fullscreen mode

Step 3: Load the model and processor

hf download google/medgemma-1.5-4b-it --local-dir ./medgemma-1.5-4b-it
Enter fullscreen mode Exit fullscreen mode
from transformers import AutoProcessor, Gemma3ForConditionalGeneration

MODEL_ID = "./medgemma-1.5-4b-it"
model = Gemma3ForConditionalGeneration.from_pretrained(MODEL_ID)
processor = AutoProcessor.from_pretrained(MODEL_ID)
Enter fullscreen mode Exit fullscreen mode

Step 4: Build the calibration dataset manually

We used dataset="flickr30k", splits={...} but hit:

NotImplementedError: Label masking for vision datasets has not been implemented yet
Enter fullscreen mode Exit fullscreen mode

This is gemma3_example.py's own approach — anyone following that
example verbatim hits the same error. Fix: pretokenize calibration data
manually instead (the gemma4_example.py pattern):

from datasets import load_dataset

NUM_CALIBRATION_SAMPLES = 32
MAX_SEQUENCE_LENGTH = 2048
BATCH_SIZE = 1

def get_calib_dataset(processor):
    ds = load_dataset("mit-han-lab/pile-val-backup", split=f"validation[:{NUM_CALIBRATION_SAMPLES * 10}]")
    def preprocess(example):
        return {"input_ids": processor.tokenizer.encode(example["text"].strip())[:MAX_SEQUENCE_LENGTH]}
    return (
        ds.shuffle(seed=42)
        .map(preprocess, remove_columns=ds.column_names)
        .filter(lambda ex: len(ex["input_ids"]) >= MAX_SEQUENCE_LENGTH)
        .select(range(NUM_CALIBRATION_SAMPLES))
    )
Enter fullscreen mode Exit fullscreen mode

Text-only, even for a vision model — the vision tower is excluded from
quantization anyway, so calibration only needs to exercise the
language-model layers.

Step 5: Define the recipe

from llmcompressor.modifiers.gptq import GPTQModifier

recipe = [
    GPTQModifier(
        targets="Linear",
        scheme="W4A16",
        ignore=[
            "lm_head",
            r"re:model\.vision_tower.*",
            r"re:model\.multi_modal_projector.*"
        ],
    ),
]
Enter fullscreen mode Exit fullscreen mode

Step 6: Run it

from llmcompressor import oneshot

oneshot(
    model=model,
    processor=processor,
    dataset=get_calib_dataset(processor),
    recipe=recipe,
    batch_size=BATCH_SIZE,
    shuffle_calibration_samples=False,
    max_seq_length=MAX_SEQUENCE_LENGTH,
    num_calibration_samples=NUM_CALIBRATION_SAMPLES,
)
Enter fullscreen mode Exit fullscreen mode

Step 7: Verify the image actually reaches the model

from PIL import Image
import requests

messages = [
    {"role": "user", "content": [
        {"type": "text", "text": "Please describe what you see in this image\n"},
        {"type": "image"},
    ]},
]
prompt = processor.apply_chat_template(messages, add_generation_prompt=True)

image_url = "http://images.cocodataset.org/train2017/000000231895.jpg"
raw_image = Image.open(requests.get(image_url, stream=True).raw)
print(raw_image.size, raw_image.mode)

inputs = processor(images=raw_image, text=prompt, return_tensors="pt").to(model.device)
print(inputs.keys())
Enter fullscreen mode Exit fullscreen mode

Confirm pixel_values is in there. Real gotcha: it's easy to write
processor(image=raw_image, ...) — singular — instead of images=
(plural). Get this wrong and the image silently never reaches the
model, but generation doesn't error — it just confidently describes
something completely unrelated. Caught us for a bit. Check this before
trusting any output.

Step 8: Run the real generation test

from compressed_tensors.offload import dispatch_model

dispatch_model(model)

# compile disabled — known issue: huggingface/transformers#38333
output = model.generate(**inputs, max_new_tokens=1024, disable_compile=True)
print(processor.decode(output[0], skip_special_tokens=True))
Enter fullscreen mode Exit fullscreen mode

Confirm the description actually matches the image. Also re-ran this
against a real chest X-ray — MedGemma's vision encoder is trained only
on medical imagery, so a generic photo confirms plumbing but isn't a
fair capability test.

Step 9: Save

QUANTIZED_MODEL_ID = MODEL_ID.rstrip("/").split("/")[-1] + "-W4A16-G128"
model.save_pretrained(QUANTIZED_MODEL_ID, save_compressed=True)
# do NOT call processor.save_pretrained() here — see Step 10
Enter fullscreen mode Exit fullscreen mode

Step 10: Recover tokenizer/processor files from the original

Calling processor.save_pretrained() on this model renames
extra_special_tokens to a non-standard model_specific_special_tokens
key and drops added_tokens_decoder entirely — a documented
transformers round-trip bug (also seen on Phi-4-mini). Copy the files
directly from the original instead:

import shutil
from pathlib import Path

model_path = Path("./medgemma-1.5-4b-it")
quantized_model_path = Path(f"./{QUANTIZED_MODEL_ID}")

files_to_copy = [
    "tokenizer.json",
    "tokenizer_config.json",
    "special_tokens_map.json",
    "preprocessor_config.json",
    "chat_template.json",
]

for fname in files_to_copy:
    src = model_path / fname
    if src.exists():
        shutil.copy(src, quantized_model_path / fname)
Enter fullscreen mode Exit fullscreen mode

Step 11: Patch the rope config

Gemma3's newer nested rope_parameters format (full_attention/
sliding_attention sub-dicts) isn't recognized by some downstream
tooling expecting a flat schema with a top-level rope_type key. The
key existed before, just nested — this restructures it, it doesn't add
something absent. If you're following along in a notebook, add
%%writefile patch_rope_config.py as the first line of the cell to
write it straight to disk:

import json
import shutil
import sys
from pathlib import Path


def patch_config(config_path: str, dominant_rope_type: str = "default"):
    config_path = Path(config_path)
    backup_path = config_path.with_suffix(".json.bak")

    if not backup_path.exists():
        shutil.copy2(config_path, backup_path)
        print(f"Backed up original to {backup_path}")
    else:
        print(f"Backup already exists at {backup_path}, not overwriting it")

    with open(config_path, "r") as f:
        config = json.load(f)

    text_config = config.get("text_config")
    if text_config is None:
        print("ERROR: no 'text_config' block found — is this the right file?")
        sys.exit(1)

    rope_params = text_config.get("rope_parameters")
    if rope_params is None:
        print("ERROR: no 'rope_parameters' found under text_config")
        sys.exit(1)

    if "rope_type" in rope_params:
        print("'rope_type' key already present at top level — nothing to do")
        return

    new_rope_params = {"rope_type": dominant_rope_type}
    new_rope_params.update(rope_params)
    text_config["rope_parameters"] = new_rope_params

    with open(config_path, "w") as f:
        json.dump(config, f, indent=2)

    print(f"Patched {config_path}: added top-level rope_type='{dominant_rope_type}' "
          f"inside rope_parameters (full_attention/sliding_attention left untouched)")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python patch_rope_config.py /path/to/config.json [rope_type]")
        sys.exit(1)
    path = sys.argv[1]
    rtype = sys.argv[2] if len(sys.argv) > 2 else "default"
    patch_config(path, rtype)
Enter fullscreen mode Exit fullscreen mode
python patch_rope_config.py ./medgemma-1.5-4b-it-W4A16-G128/config.json
Enter fullscreen mode Exit fullscreen mode

Keeps a .json.bak backup before touching anything, and is idempotent
— safe to re-run if you're not sure whether it's already been applied.

Step 12: Fix the vision tower ignore list naming mismatch

A known llm-compressor bug (issue #1546):
transformers >=4.52's multimodal refactor changed internal weight
naming during model loading, but llm-compressor's
quantization_config.ignore list is generated from the converted naming
scheme without being reconciled against the actual saved safetensors key
names. Left uncorrected, the ignore list matches no real tensor — the
vision tower would silently get quantized despite the recipe saying
otherwise.

import json

def fix_model_config(config_path):
    with open(config_path) as f:
        config = json.load(f)

    ignore_list = config["quantization_config"]["ignore"]

    new_ignore = []
    for entry in ignore_list:
        if entry.startswith("model.vision_tower."):
            fixed = entry.replace("model.vision_tower.", "vision_tower.vision_model.", 1)
            new_ignore.append(fixed)
        else:
            new_ignore.append(entry)  # e.g. "lm_head" stays as-is

    config["quantization_config"]["ignore"] = new_ignore

    with open(config_path, "w") as f:
        json.dump(config, f, indent=2)

    print("Sample before:", ignore_list[0])
    print("Sample after: ", new_ignore[0])

fix_model_config("medgemma-1.5-4b-it-W4A16-G128/config.json")
Enter fullscreen mode Exit fullscreen mode

Step 13: Push to Hugging Face

If you set HF_TOKEN in your .env back in Step 2, huggingface_hub
picks it up automatically. Otherwise, hf auth login first.

Don't use model.push_to_hub() — it calls save_pretrained() again
internally, and since the model's already compressed, that throws
KeyError: 'weight' (no plain weight key left to compress a second
time). Upload the already-correct files directly instead:

from huggingface_hub import HfApi

REPO_ID = f"<your-hf-username-or-org>/{QUANTIZED_MODEL_ID}"
api = HfApi()
api.create_repo(REPO_ID, exist_ok=True)
api.upload_folder(folder_path=QUANTIZED_MODEL_ID, repo_id=REPO_ID)
Enter fullscreen mode Exit fullscreen mode

Result: 8.6 GB in BF16 -> 5.2 GB quantized. Vision tower,
multimodal projector, and lm_head stay unquantized, which is why it's
not the full theoretical 75% reduction.

Model: confamnode/medgemma-1.5-4b-it-W4A16-G128
More technical AI blogs at ConfamNode

Top comments (0)