<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ConfamNode]]></title><description><![CDATA[Real, transparent write-ups of the engineering work behind ConfamNode — including what actually broke and how we fixed it, not just the polished version.]]></description><link>https://confamnode.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a562c173a25c2d720c3444a/87a66015-cebf-428d-9abf-5e75cbff6b78.png</url><title>ConfamNode</title><link>https://confamnode.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 24 Sep 2026 11:48:38 GMT</lastBuildDate><atom:link href="https://confamnode.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Quantizing MedGemma to INT4 (GPTQ/W4A16): Everything That Broke Along the Way]]></title><description><![CDATA[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-ste]]></description><link>https://confamnode.hashnode.dev/quantizing-medgemma-to-int4-gptq-w4a16-everything-that-broke-along-the-way</link><guid isPermaLink="true">https://confamnode.hashnode.dev/quantizing-medgemma-to-int4-gptq-w4a16-everything-that-broke-along-the-way</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[llm]]></category><category><![CDATA[quantization]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[MachineLearning]]></category><category><![CDATA[open source]]></category><category><![CDATA[AI]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[JoTeq the First]]></dc:creator><pubDate>Tue, 14 Jul 2026 13:57:41 GMT</pubDate><content:encoded><![CDATA[<p>Quantized Google's MedGemma-1.5-4B (a medical vision-language model) to INT4 (W4A16) via <code>llm-compressor</code>'s GPTQModifier, for self-hosted deployment. 8.6 GB in BF16 -&gt; 5.2 GB quantized. Full step-by-step below, model link at the bottom.</p>
<p><strong>References:</strong> <a href="https://github.com/vllm-project/llm-compressor/blob/main/examples/multimodal_vision/gemma3_example.py"><code>gemma3_example.py</code></a> (model class, GPTQ/W4A16 recipe) and <a href="https://github.com/vllm-project/llm-compressor/blob/main/examples/multimodal_vision/gemma4_example.py"><code>gemma4_example.py</code></a> (calibration dataset pattern).</p>
<h2>Step 1: Choose the quantization method</h2>
<p><strong>GPTQ, via</strong> <code>llm-compressor</code><strong>. Not AWQ.</strong></p>
<ul>
<li><p><code>AutoAWQ</code> is <a href="https://github.com/casper-hansen/AutoAWQ">officially deprecated</a>.</p>
</li>
<li><p>Even setting that aside, AutoAWQ never supported Gemma3's architecture, which MedGemma is built on. <code>llm-compressor</code> has the same gap for AWQ specifically here — confirmed via two open GitHub issues describing outright failures.</p>
</li>
</ul>
<h2>Step 2: Set up your environment</h2>
<p>Ran on RunPod (RTX A5000), using the instance's own pre-configured JupyterLab directly.</p>
<pre><code class="language-bash">nvidia-smi   # check actual CUDA version first
</code></pre>
<pre><code class="language-bash">pip3 uninstall torch torchvision -y
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu128
</code></pre>
<pre><code class="language-bash">python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"
</code></pre>
<p>Confirm <code>True</code> before continuing. Then install <code>llmcompressor</code> separately, with <code>--no-deps</code> (otherwise it silently pulls in a conflicting <code>torch</code>):</p>
<pre><code class="language-bash">pip3 install llmcompressor==0.12.0 --no-deps
</code></pre>
<p>Then the other packages:</p>
<pre><code class="language-bash">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
</code></pre>
<p>Real final versions used: <code>torch==2.11.0+cu128</code>, <code>torchvision==0.26.0+cu128</code>, <code>llmcompressor==0.12.0</code>, <code>compressed-tensors==0.17.1</code>, <code>transformers==5.10.1</code>, <code>requests==2.32.5</code>, <code>pillow==11.0.0</code>, <code>zstandard==0.25.0</code>.</p>
<p>Set up your HF token before you need it — MedGemma is gated. Create a <code>.env</code>:</p>
<pre><code class="language-plaintext">HF_TOKEN=hf_xxx
</code></pre>
<pre><code class="language-python">from dotenv import load_dotenv
load_dotenv()
</code></pre>
<h2>Step 3: Load the model and processor</h2>
<pre><code class="language-bash">hf download google/medgemma-1.5-4b-it --local-dir ./medgemma-1.5-4b-it
</code></pre>
<pre><code class="language-python">from transformers import AutoProcessor, Gemma3ForConditionalGeneration

MODEL_ID = "./medgemma-1.5-4b-it"
model = Gemma3ForConditionalGeneration.from_pretrained(MODEL_ID)
processor = AutoProcessor.from_pretrained(MODEL_ID)
</code></pre>
<h2>Step 4: Build the calibration dataset manually</h2>
<p>We used <code>dataset="flickr30k", splits={...}</code> but hit:</p>
<pre><code class="language-plaintext">NotImplementedError: Label masking for vision datasets has not been implemented yet
</code></pre>
<p>This is <code>gemma3_example.py</code>'s own approach — anyone following that example verbatim hits the same error. Fix: pretokenize calibration data manually instead (the <code>gemma4_example.py</code> pattern):</p>
<pre><code class="language-python">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"]) &gt;= MAX_SEQUENCE_LENGTH)
        .select(range(NUM_CALIBRATION_SAMPLES))
    )
</code></pre>
<p>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.</p>
<h2>Step 5: Define the recipe</h2>
<pre><code class="language-python">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.*"
        ],
    ),
]
</code></pre>
<h2>Step 6: Run it</h2>
<pre><code class="language-python">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,
)
</code></pre>
<h2>Step 7: Verify the image actually reaches the model</h2>
<pre><code class="language-python">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())
</code></pre>
<p>Confirm <code>pixel_values</code> is in there. <strong>Real gotcha:</strong> it's easy to write <code>processor(image=raw_image, ...)</code> — singular — instead of <code>images=</code> (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.</p>
<h2>Step 8: Run the real generation test</h2>
<pre><code class="language-python">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))
</code></pre>
<p>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.</p>
<h2>Step 9: Save</h2>
<pre><code class="language-python">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
</code></pre>
<h2>Step 10: Recover tokenizer/processor files from the original</h2>
<p>Calling <code>processor.save_pretrained()</code> on this model renames <code>extra_special_tokens</code> to a non-standard <code>model_specific_special_tokens</code> key and drops <code>added_tokens_decoder</code> entirely — a documented <code>transformers</code> round-trip bug (also seen on Phi-4-mini). Copy the files directly from the original instead:</p>
<pre><code class="language-python">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.jinja",
]

for fname in files_to_copy:
    src = model_path / fname
    if src.exists():
        shutil.copy(src, quantized_model_path / fname)
</code></pre>
<h2>Step 11: Patch the rope config</h2>
<p>Gemma3's newer nested <code>rope_parameters</code> format (<code>full_attention</code>/ <code>sliding_attention</code> sub-dicts) isn't recognized by some downstream tooling expecting a flat schema with a top-level <code>rope_type</code> 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 <code>%%writefile patch_rope_config.py</code> as the first line of the cell to write it straight to disk:</p>
<pre><code class="language-python">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) &lt; 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) &gt; 2 else "default"
    patch_config(path, rtype)
</code></pre>
<pre><code class="language-bash">python patch_rope_config.py ./medgemma-1.5-4b-it-W4A16-G128/config.json
</code></pre>
<p>Keeps a <code>.json.bak</code> backup before touching anything, and is idempotent — safe to re-run if you're not sure whether it's already been applied.</p>
<h2>Step 12: Fix the vision tower ignore list naming mismatch</h2>
<p>A known <code>llm-compressor</code> bug (<a href="https://github.com/vllm-project/llm-compressor/issues/1546">issue #1546</a>): <code>transformers</code> &gt;=4.52's multimodal refactor changed internal weight naming during model loading, but <code>llm-compressor</code>'s <code>quantization_config.ignore</code> 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.</p>
<pre><code class="language-python">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")
</code></pre>
<h2>Step 13: Push to Hugging Face</h2>
<p>If you set <code>HF_TOKEN</code> in your <code>.env</code> back in Step 2, <code>huggingface_hub</code> picks it up automatically. Otherwise, <code>hf auth login</code> first.</p>
<p><strong>Don't use</strong> <code>model.push_to_hub()</code> — it calls <code>save_pretrained()</code> again internally, and since the model's already compressed, that throws <code>KeyError: 'weight'</code> (no plain <code>weight</code> key left to compress a second time). Upload the already-correct files directly instead:</p>
<pre><code class="language-python">from huggingface_hub import HfApi

REPO_ID = f"&lt;your-hf-username-or-org&gt;/{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)
</code></pre>
<p><strong>Result:</strong> 8.6 GB in BF16 -&gt; 5.2 GB quantized. Vision tower, multimodal projector, and <code>lm_head</code> stay unquantized, which is why it's not the full theoretical 75% reduction.</p>
<p>Model: <a href="https://huggingface.co/confamnode/medgemma-1.5-4b-it-W4A16-G128"><code>confamnode/medgemma-1.5-4b-it-W4A16-G128</code></a></p>
<p>More technical AI blogs at <a href="https://confamnode.com/blog/">ConfamNode</a></p>
]]></content:encoded></item></channel></rss>