Skip to content

feat(export): write a merged qpeft model as a GPTQ checkpoint - #3

Merged
gapsong merged 4 commits into
mainfrom
fm/qpeft-gptq-export
Oct 12, 2026
Merged

gapsong merged 4 commits into
mainfrom
fm/qpeft-gptq-export

Conversation

@gapsong

@gapsong gapsong commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Intent

qpeft step 5 of the axolotl integration plan (captain chose 'path A'): a GPTQ export for the merged qpeft model, so PEFT-cheap training yields a merged int2/3/4 model that is exact AND loadable by standard GPTQ tooling (axolotl docs say it does not support GPTQ export; qpeft fills that gap). Requirements from the brief: qpeft/export/gptq.py as an Ousterhout deep module with one small public entry point (export_gptq(model, save_directory, checkpoint_format='gptq')), all layout details hidden inside; qweight already in GPTQ layout; scales transposed to (n_groups, out) fp16; packed integer qzeros respecting the target loader's zero-point convention (the classic v1 'zeros - 1' offset, verified in gptqmodel source, not assumed); g_idx; quantization_config in config.json (quant_method gptq, bits, group_size, sym/desc_act true for the data). Invariant 2 refuse-instead-of-approximate: fractional zero-points (QA-LoRA merge, captain decision D2) are refused with a clear message; anything that cannot round-trip exactly is refused (a bf16->fp16 scale cast is checked by value; fp32 models are refused because fp16 scales would round). Decisions I made: (1) default checkpoint_format 'gptq' (v1, what tooling reads by default) refuses zero-point 0, because gptqmodel undoes the z-1 offset word-wise with carry for 2/4/8 bits and field-wise mod 2bits for 3 bits, so z==0 is not portable; 'gptq_v2' stores z unchanged and is exact for every z but only gptqmodel reads it - this optional keyword is deliberate. (2) sym is written true only when every zero-point equals 2(bits-1). (3) The export refuses quantized layers outside the decoder blocks (e.g. lm_head with target_modules=None), different targets per block, and suffix matches that would make optimum's plain str.endswith rule replace an unquantized linear; for a subset of block linears it writes modules_in_block_to_quantize. (4) The config.json quantization_config is added after save_pretrained(state_dict=...) instead of mutating the user's model config; export does not mutate the model. Tests (invariants 1 and 6, same step): tests/test_gptq_export.py decodes the written safetensors with an independent bit-stream decoder and checks GPTQ-formula dequant == merged QuantLinear weight and forward bit for bit for PEQA and EfficientQAT, bits 2/3/4/8, both formats, plus every refusal; tests/test_gptq_load.py loads the export with real transformers + optimum + gptqmodel on macOS CPU (skipped when gptqmodel/optimum are absent, by design, because axolotl's optimum pin breaks optimum.gptq so it must run in a separate venv without axolotl, report A2): per-layer dequantized weight bitwise equal, fp16 logits argmax 100% and within fp16 rounding (oracle: the merged model upcast to fp32; loaded error <= 2x qpeft's own half-precision error). Out of scope: do NOT touch qpeft/integrations/axolotl/plugin.py (a parallel task owns it); wiring 'export:' into the plugin is step 6. Findings documented in README/AGENTS.md: vLLM main accepts GPTQ only sym=True at 4/8 bits, so asymmetric qpeft exports are refused there; gptqmodel's default 4-bit CPU kernel TorchAtenLinear rounds scales to bf16 (backend='torch' is exact). All local, no GitHub Actions.

What Changed

  • Adds qpeft.export.export_gptq(model, save_directory, checkpoint_format="gptq"). It writes a merged EfficientQAT or PEQA model as a Hugging Face GPTQ checkpoint. The checkpoint has qweight in GPTQ layout, fp16 scales transposed to (n_groups, out), packed qzeros, g_idx, and a quantization_config added to config.json after save_pretrained. "gptq" (v1) stores z - 1 and refuses a zero-point of 0. "gptq_v2" stores z as it is. sym is true only when every zero-point equals 2**(bits-1). The export does not change the model.

  • Before it writes a file, the export checks every layer and raises UnsupportedSchemeError for anything that would not round-trip exactly:

    • an unmerged layer, or bits other than 2/3/4/8
    • fractional zero-points (a QA-LoRA fold)
    • scales that fp16 does not hold exactly (for example an fp32 model)
    • mixed bits or group sizes
    • quantized layers outside the decoder blocks, or different targets per block
    • quantized nn.Linear subclasses that the GPTQ loader keeps in float
    • suffix matches that would make the loader replace an unquantized linear

    When only some of a block's linears are quantized, the export writes modules_in_block_to_quantize.

  • Adds tests/test_gptq_export.py. It decodes the written safetensors with an independent decoder and checks that the dequantized weights and forward match the merged model bit for bit (PEQA and EfficientQAT, bits 2/3/4/8, both formats). It also tests every refusal.

  • Adds tests/test_gptq_load.py. It does a real load through transformers + optimum + gptqmodel on CPU and is skipped when those are not installed.

  • Documents the export and the loader findings in README.md and AGENTS.md:

    • the v1 zero-point offset rules
    • vLLM accepts GPTQ only with sym=True at 4/8 bits
    • gptqmodel's default 4-bit CPU kernel rounds scales to bf16
    • the separate venv needed for the load test

Risk Assessment

✅ Low: The export is a new, self-contained module. The round-2 fix does what the user chose (option b): it builds a meta-device skeleton from a deep copy of root.config, uses that skeleton as the single loader simulation, and refuses quantized layers that are not exact nn.Linear before it writes anything. A test that fails without the fix covers this. I traced the full-block, subset, suffix-collision, router-subclass and outside-block paths, and none gives a wrong result without an error.

Testing

In a throwaway venv with real optimum + gptqmodel, I ran the two GPTQ test files (53 passed, none skipped). Then I drove the export as an end user on the cached real Qwen3-129m model. In fp16 with the torch kernel, the loader's per-layer weights matched qpeft's bit for bit and greedy text was the same for PEQA 4-bit v1, EfficientQAT 3-bit v2 and a 2-bit q/v subset. The bf16 default kernel stays as close to the exact model as qpeft itself (the documented bf16 rounding). Every refusal raised a clear UnsupportedSchemeError and wrote nothing: QA-LoRA, fp32, lm_head, per-block difference, quantized router subclass, unmerged, unknown format, and v1 zero-point 0. A v2 export with zero-point 0 loaded exactly, and the export left the model unchanged. No UI surface, so the evidence is CLI transcripts.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Merged PEQA 4-bit fp16 model exported as GPTQ v1 loads in transformers (optimum+gptqmodel, torch kernel) with bit-exact weights and identical greedy text ✅ pass live live-real-model-transcript.txt (peqa4-fp16-v1): 56/56 layers bit for bit, same greedy tokens True, quantization_config quant_method gptq bits 4 group_size 64 sym false desc_act false; scales (6,384) f…
EfficientQAT (Block-AP then E2E-QP) 3-bit export in gptq_v2 loads bit-exact and generates the same text ✅ pass live live-real-model-transcript.txt (efficientqat3-fp16-v2): 56/56 layers bit for bit, same greedy tokens True
A subset of block linears (q_proj, v_proj, 2-bit) writes modules_in_block_to_quantize and the loader keeps the others as float Linear ✅ pass live live-real-model-transcript.txt (peqa2-subset-q-v): modules_in_block_to_quantize present, loaded up_proj is Linear, 16/16 layers bit for bit, same greedy tokens
bf16 model with gptqmodel's default CPU kernel (TorchAtenLinear) stays within bf16 noise of the exact model ✅ pass live live-real-model-transcript.txt (peqa4-bf16-default-kernel): loaded error 0.0619 vs qpeft own 0.0634 against exact fp32, argmax agreement 0.972; also test_the_default_cpu_kernel_on_a_bf16_model
QA-LoRA merged model (fractional zero-points) is refused and nothing is written ✅ pass live live-refusals-transcript.txt: UnsupportedSchemeError 'zero-points are fractional', output dir exists afterwards False
fp32 model, lm_head quantized, different targets per block, unmerged model, unknown format are each refused with nothing written ✅ pass live live-refusals-transcript.txt: five [refused] entries, each 'output dir exists afterwards: False'
Round-2 decision: a quantized nn.Linear subclass (router) in every block is refused before writing; left unquantized it exports with no modules_in_block_to_quantize ✅ pass live live-refusals-transcript.txt: router refusal naming model.layers.*.router and exact-type rule, no output dir; unquantized-router export has modules_in_block_to_quantize False
Zero-point 0 is refused in v1 but exported in gptq_v2 and read bit-exact by a real loader ✅ pass live live-refusals-transcript.txt: v1 refused '3 groups have zero-point 0'; v2 loaded through transformers, dequant of that layer bit for bit True
export_gptq does not mutate the user's model or its config ✅ pass live live-refusals-transcript.txt: same logits True, same state_dict keys True, config unchanged True, no quantization_config on model config True
Suffix collision (optimum str.endswith would replace an unquantized myself_attn.o_proj) is refused ✅ pass live pytest tests/test_gptq_export.py::test_refuses_when_the_loader_suffix_match_would_hit_an_unquantized_linear (part of the 53 passed)
Evidence: Targeted GPTQ pytest run (export + real gptqmodel load)
.....................................................                    [100%]
53 passed in 23.96s
Evidence: Real-model export -> transformers load -> generate transcript

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights:   1%|          | 1/91 [00:00<00:09,  9.17it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 825.63it/s]

Writing model shards:   0%|          | 0/1 [00:00<?, ?it/s]
Writing model shards: 100%|██████████| 1/1 [00:00<00:00, 32.64it/s]
/tmp/qpeft-nm-FXmT/venv/lib/python3.12/site-packages/transformers/quantizers/auto.py:279: UserWarning: You passed `quantization_config` or equivalent parameters to `from_pretrained` but the model you're loading already has a `quantization_config` attribute. The `quantization_config` from the model will be used.However, loading attributes (e.g. ['backend', 'max_input_length']) will be overwritten with the one you passed to `from_pretrained`. The rest will be ignored.
  warnings.warn(warning_msg)
=== peqa4-fp16-v1: bits=4 dtype=torch.float16 checkpoint_format=gptq loader backend=torch
files: ['chat_template.jinja', 'config.json', 'generation_config.json', 'model.safetensors', 'tokenizer.json', 'tokenizer_config.json']
quantization_config: {"bits": 4, "block_name_to_quantize": "model.layers", "checkpoint_format": "gptq", "desc_act": false, "group_size": 64, "quant_method": "gptq", "sym": false}
{'qweight': ((48, 384), 'torch.int32'), 'qzeros': ((6, 48), 'torch.int32'), 'scales': ((6, 384), 'torch.float16'), 'g_idx': ((384,), 'torch.int32')}

WARN  Python GIL is enabled: Multi-gpu quant acceleration for MoE models is sub-optimal and multi-core accelerated cpu packing is also disabled. We recommend Python >= 3.13.3t with Pytorch > 2.8 for mult-gpu quantization and multi-cpu packing with env `PYTHON_GIL=0`.

INFO  ENV: Auto setting PYTORCH_ALLOC_CONF='expandable_segments:True,max_split_size_mb:256,garbage_collection_threshold:0.7' for memory saving.

INFO  ENV: Auto setting CUDA_DEVICE_ORDER=PCI_BUS_ID for correctness.           

INFO  

INFO  Kernel: selected: `TorchLinear`                                           
[transformers] `loss_type=None` was set in the config but it is unrecognized. Using the default loss: `ForCausalLMLoss`.

Loading weights:   0%|          | 0/259 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 259/259 [00:00<00:00, 18349.15it/s]

INFO  QuantizeConfig: offload_to_disk_path auto set to temporary dir `/tmp/qpeft-nm-FXmT/tmp/gptqmodel_zogt95it`

INFO  Format: Converting `format` from `FORMAT.GPTQ` to internal `FORMAT.GPTQ_V2`.

INFO  Format: Converting GPTQ v1 to v2                                          

INFO  Optimize: `TorchLinear` compilation triggered.                            

INFO  gc.collect() reclaimed 132 objects in 0.085s                              
loaded layer type: TorchLinear
per-layer loader dequant == qpeft dequant (fp16), bit for bit: 56/56 layers
qpeft merged model : 'The capital of France is a big big big big dog. The high-resolution maps them back, and a'
loaded GPTQ export : 'The capital of France is a big big big big dog. The high-resolution maps them back, and a'
same greedy tokens : True
max |logit - exact fp32|: qpeft own 0.0070, loaded 0.0067; argmax agreement loaded vs qpeft 1.000

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 12590.11it/s]

Writing model shards:   0%|          | 0/1 [00:00<?, ?it/s]
Writing model shards: 100%|██████████| 1/1 [00:00<00:00,  8.36it/s]
Writing model shards: 100%|██████████| 1/1 [00:00<00:00,  8.36it/s]
/tmp/qpeft-nm-FXmT/venv/lib/python3.12/site-packages/transformers/quantizers/auto.py:279: UserWarning: You passed `quantization_config` or equivalent parameters to `from_pretrained` but the model you're loading already has a `quantization_config` attribute. The `quantization_config` from the model will be used.However, loading attributes (e.g. ['backend', 'max_input_length']) will be overwritten with the one you passed to `from_pretrained`. The rest will be ignored.
  warnings.warn(warning_msg)
=== peqa4-bf16-default-kernel: bits=4 dtype=torch.bfloat16 checkpoint_format=gptq loader backend=auto
files: ['chat_template.jinja', 'config.json', 'generation_config.json', 'model.safetensors', 'tokenizer.json', 'tokenizer_config.json']
quantization_config: {"bits": 4, "block_name_to_quantize": "model.layers", "checkpoint_format": "gptq", "desc_act": false, "group_size": 64, "quant_method": "gptq", "sym": false}
{'qweight': ((48, 384), 'torch.int32'), 'qzeros': ((6, 48), 'torch.int32'), 'scales': ((6, 384), 'torch.float16'), 'g_idx': ((384,), 'torch.int32')}

INFO  Kernel: Auto-selection: adding candidate `TorchAtenLinear`                

INFO  Kernel: selected -> `TorchAtenLinear`.                                    

Loading weights:   0%|          | 0/259 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 259/259 [00:00<00:00, 18283.06it/s]

INFO  QuantizeConfig: offload_to_disk_path auto set to temporary dir `/tmp/qpeft-nm-FXmT/tmp/gptqmodel_kb8h9u7u`

INFO  Format: Converting `format` from `FORMAT.GPTQ` to internal `FORMAT.GPTQ_V2`.

INFO  Optimize: `TorchAtenLinear` compilation triggered.                        

INFO  gc.collect() reclaimed 98 objects in 0.092s                               
loaded layer type: TorchAtenLinear
qpeft merged model : 'The capital of France is a big factor. The simple black fox jumps over the small scale and a scale'
loaded GPTQ export : 'The capital of France is a short, and a short, and a short, and a short, and'
same greedy tokens : False
max |logit - exact fp32|: qpeft own 0.0634, loaded 0.0619; argmax agreement loaded vs qpeft 0.972

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 12876.81it/s]

Writing model shards:   0%|          | 0/1 [00:00<?, ?it/s]
Writing model shards: 100%|██████████| 1/1 [00:00<00:00, 45.19it/s]
=== efficientqat3-fp16-v2: bits=3 dtype=torch.float16 checkpoint_format=gptq_v2 loader backend=torch
files: ['chat_template.jinja', 'config.json', 'generation_config.json', 'model.safetensors', 'tokenizer.json', 'tokenizer_config.json']
quantization_config: {"bits": 3, "block_name_to_quantize": "model.layers", "checkpoint_format": "gptq_v2", "desc_act": false, "group_size": 64, "quant_method": "gptq", "sym": false}
{'qweight': ((36, 384), 'torch.int32'), 'qzeros': ((6, 36), 'torch.int32'), 'scales': ((6, 384), 'torch.float16'), 'g_idx': ((384,), 'torch.int32')}

INFO  Kernel: selected: `TorchLinear`                                           

Loading weights:   0%|          | 0/259 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 259/259 [00:00<00:00, 17087.83it/s]

INFO  gc.collect() reclaimed 521 objects in 0.099s                              
loaded layer type: TorchLinear
per-layer loader dequant == qpeft dequant (fp16), bit for bit: 56/56 layers
qpeft merged model : 'The capital of France is a short grass dog. The high-resolution data sets a low-resolution data set a'
loaded GPTQ export : 'The capital of France is a short grass dog. The high-resolution data sets a low-resolution data set a'
same greedy tokens : True
max |logit - exact fp32|: qpeft own 0.0105, loaded 0.0077; argmax agreement loaded vs qpeft 1.000

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights:   1%|          | 1/91 [00:00<00:15,  5.90it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 533.66it/s]

Writing model shards:   0%|          | 0/1 [00:00<?, ?it/s]
Writing model shards: 100%|██████████| 1/1 [00:00<00:00, 34.64it/s]
/tmp/qpeft-nm-FXmT/venv/lib/python3.12/site-packages/transformers/quantizers/auto.py:279: UserWarning: You passed `quantization_config` or equivalent parameters to `from_pretrained` but the model you're loading already has a `quantization_config` attribute. The `quantization_config` from the model will be used.However, loading attributes (e.g. ['backend', 'max_input_length']) will be overwritten with the one you passed to `from_pretrained`. The rest will be ignored.
  warnings.warn(warning_msg)
=== peqa2-subset-q-v: bits=2 dtype=torch.float16 checkpoint_format=gptq loader backend=torch
files: ['chat_template.jinja', 'config.json', 'generation_config.json', 'model.safetensors', 'tokenizer.json', 'tokenizer_config.json']
quantization_config: {"bits": 2, "block_name_to_quantize": "model.layers", "checkpoint_format": "gptq", "desc_act": false, "group_size": 64, "modules_in_block_to_quantize": [["self_attn.q_proj", "self_attn.v_proj"]], "quant_method": "gptq", "sym": false}
{'qweight': ((24, 384), 'torch.int32'), 'qzeros': ((6, 24), 'torch.int32'), 'scales': ((6, 384), 'torch.float16'), 'g_idx': ((384,), 'torch.int32')}

INFO  Kernel: selected: `TorchLinear`                                           

Loading weights:   0%|          | 0/139 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 139/139 [00:00<00:00, 12261.47it/s]

INFO  QuantizeConfig: offload_to_disk_path auto set to temporary dir `/tmp/qpeft-nm-FXmT/tmp/gptqmodel_5bze093a`

INFO  Format: Converting `format` from `FORMAT.GPTQ` to internal `FORMAT.GPTQ_V2`.

INFO  gc.collect() reclaimed 60307 objects in 0.127s                            
loaded layer type: Linear
per-layer loader dequant == qpeft dequant (fp16), bit for bit: 16/16 layers
qpeft merged model : 'The capital of France is the "The Great" (or "The Great" (or "The Great'
loaded GPTQ export : 'The capital of France is the "The Great" (or "The Great" (or "The Great'
same greedy tokens : True
max |logit - exact fp32|: qpeft own 0.0105, loaded 0.0094; argmax agreement loaded vs qpeft 1.000
Evidence: Refusal / adversarial scenarios transcript
[refused] QA-LoRA merged model (fractional zero-points)
    UnsupportedSchemeError: model.layers.0.self_attn.q_proj: its zero-points are fractional (a QA-LoRA fold, z' = z - delta / s). GPTQ stores integer zero-points, and rounding them would break fake_quant == merge. Keep the qpeft artifact (save_pretrained) or run it on tinygemm (qpeft.kernels.to_tinygemm).
    output dir exists afterwards: False
[refused] fp32 model (scales fp16 cannot hold)
    UnsupportedSchemeError: model.layers.0.self_attn.q_proj: 2172 of 2304 scales change when cast to fp16, GPTQ's scale dtype (the merged scales are torch.float32). Quantize and merge the model in bf16 or fp16, whose scales fp16 holds exactly.
    output dir exists afterwards: False
[refused] target_modules=None quantizes lm_head
    UnsupportedSchemeError: GPTQ loaders quantize only the linears inside the decoder blocks; ['lm_head'] are outside them. Quantize without them (target_modules), then export.
    output dir exists afterwards: False
[refused] only block 0 quantized
    UnsupportedSchemeError: GPTQ quantizes the same linears in every decoder block; this model quantizes different ones per block. Use the same target_modules for every block.
    output dir exists afterwards: False
[transformers] �[1mQwenWithRouters LOAD REPORT�[0m from: axolotl-ai-co/tiny-qwen3-129m
[refused] router (nn.Linear subclass) quantized in every block
    UnsupportedSchemeError: a GPTQ loader would keep ['model.layers.0.router', 'model.layers.1.router', 'model.layers.2.router', 'model.layers.3.router', 'model.layers.4.router', 'model.layers.5.router', 'model.layers.6.router', 'model.layers.7.router'] in float and random-initialize them: in the model it builds from config.json they are not plain nn.Linea
    output dir exists afterwards: False
[transformers] �[1mQwenWithRouters LOAD REPORT�[0m from: axolotl-ai-co/tiny-qwen3-129m
[exported] router left unquantized: modules_in_block_to_quantize present = False
[refused] unmerged model
    UnsupportedSchemeError: model.layers.0.self_attn.q_proj: GPTQ export needs a merged layer; call merge_and_unload() first.
    output dir exists afterwards: False
[refused] checkpoint_format='awq'
    UnsupportedSchemeError: checkpoint_format must be one of ('gptq', 'gptq_v2'), got 'awq'.
    output dir exists afterwards: False
[refused] zero-point 0 with checkpoint_format='gptq' (v1)
    UnsupportedSchemeError: model.layers.2.self_attn.o_proj: 3 groups have zero-point 0, which the v1 GPTQ format (it stores z - 1) cannot hold in a way all loaders read alike. Use checkpoint_format='gptq_v2' (exact for every zero-point; read by gptqmodel only).
    output dir exists afterwards: False
[transformers] `loss_type=None` was set in the config but it is unrecognized. Using the default loss: `ForCausalLMLoss`.
[exported] zero-point 0 with gptq_v2, real loader dequant of that layer == qpeft, bit for bit: True
[unchanged] after export: same logits True, same state_dict keys True, config unchanged True, no quantization_config on model config True
Evidence: Driver scripts
"""End-user flow on a real pretrained model (cached axolotl-ai-co/tiny-qwen3-129m, offline):
quantize + train (PEQA / EfficientQAT), merge, export_gptq, then load the folder with plain
transformers AutoModelForCausalLM + AutoTokenizer (optimum + gptqmodel) and generate text."""
import json, os, sys, tempfile, copy
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, GPTQConfig
from safetensors import safe_open
from qpeft import PEQAConfig, EfficientQATConfig, get_quant_model, run_block_ap
from qpeft.export import export_gptq
from qpeft.tuners.tuners_utils import QuantLinear

MODEL = "axolotl-ai-co/tiny-qwen3-129m"
T = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
TEXT = ("The quick brown fox jumps over the lazy dog. Quantization maps floating point weights "
        "onto a small grid of integers, and a scale and a zero-point per group bring them back.")
PROMPT = "The capital of France is"
tok = AutoTokenizer.from_pretrained(MODEL)
batch = tok([TEXT], return_tensors="pt")


def base(dtype):
    return AutoModelForCausalLM.from_pretrained(MODEL, dtype=dtype).eval()


def train(model, steps=3):
    opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-3)
    for _ in range(steps):
        opt.zero_grad(); model(**batch, labels=batch["input_ids"]).loss.backward(); opt.step()


def greedy(model, n=16):
    ids = tok(PROMPT, return_tensors="pt")["input_ids"]
    with torch.no_grad():
        for _ in range(n):
            nxt = model(input_ids=ids).logits[:, -1].argmax(-1, keepdim=True)
            ids = torch.cat([ids, nxt], 1)
    return ids[0]


def run(label, model, bits, dtype, fmt, backend):
    out = tempfile.mkdtemp(prefix=f"qpeft-gptq-{label}-")
    export_gptq(model, out, checkpoint_format=fmt)
    tok.save_pretrained(out)
    print(f"\n=== {label}: bits={bits} dtype={dtype} checkpoint_format={fmt} loader backend={backend}")
    print("files:", sorted(os.listdir(out)))
    qc = json.load(open(os.path.join(out, "config.json")))["quantization_config"]
    print("quantization_config:", json.dumps(qc, sort_keys=True))
    with safe_open(os.path.join(out, "model.safetensors"), "pt") as f:
        p = "model.layers.0.self_attn.q_proj"
        print({k: (tuple(f.get_tensor(f"{p}.{k}").shape), str(f.get_tensor(f"{p}.{k}").dtype))
               for k in ("qweight", "qzeros", "scales", "g_idx")})
    loaded = AutoModelForCausalLM.from_pretrained(out, dtype=dtype, device_map="cpu",
                                                  quantization_config=GPTQConfig(bits=bits, backend=backend)).eval()
    tok2 = AutoTokenizer.from_pretrained(out)
    print("loaded layer type:", type(loaded.model.layers[0].mlp.up_proj).__name__)
    if backend == "torch":
        mods = dict(loaded.named_modules()); bad = 0; n = 0
        for name, layer in model.base.named_modules():
            if isinstance(layer, QuantLinear):
                n += 1
                w = mods[name].dequantize_weight().t()
                ref = layer.scheme.dequant(layer.codes, layer.scale.half(), layer.zero_point.half())
                bad += not torch.equal(w, ref)
        print(f"per-layer loader dequant == qpeft dequant (fp16), bit for bit: {n - bad}/{n} layers")
    a, b = greedy(model), greedy(loaded)
    print("qpeft merged model :", repr(tok.decode(a)))
    print("loaded GPTQ export :", repr(tok2.decode(b)))
    print("same greedy tokens :", torch.equal(a, b))
    ids = batch["input_ids"]
    with torch.no_grad():
        exact = copy.deepcopy(model).float()(input_ids=ids).logits
        ref = model(input_ids=ids).logits.float(); got = loaded(input_ids=ids).logits.float()
    print(f"max |logit - exact fp32|: qpeft own {(ref - exact).abs().max():.4f}, loaded {(got - exact).abs().max():.4f}; "
          f"argmax agreement loaded vs qpeft {(got.argmax(-1) == ref.argmax(-1)).float().mean():.3f}")


torch.manual_seed(0)
m = get_quant_model(base(torch.float16), PEQAConfig(bits=4, group_size=64, target_modules=T))
train(m); m.merge_and_unload()
run("peqa4-fp16-v1", m, 4, torch.float16, "gptq", "torch")

m = get_quant_model(base(torch.bfloat16), PEQAConfig(bits=4, group_size=64, target_modules=T))
train(m); m.merge_and_unload()
run("peqa4-bf16-default-kernel", m, 4, torch.bfloat16, "gptq", "auto")

cfgs = [EfficientQATConfig(bits=3, group_size=64, phase=ph, target_modules=T) for ph in ("block_ap", "e2e_qp")]
m = get_quant_model(base(torch.float16), cfgs[0])
run_block_ap(m, [{"input_ids": batch["input_ids"]}], epochs=1, weight_lr=1e-4, quant_lr=1e-4, log=lambda _: None)
m = get_quant_model(m, cfgs[1]); train(m); m.merge_and_unload()
run("efficientqat3-fp16-v2", m, 3, torch.float16, "gptq_v2", "torch")

m = get_quant_model(base(torch.float16), PEQAConfig(bits=2, group_size=64, target_modules=["q_proj", "v_proj"]))
train(m); m.merge_and_unload()
run("peqa2-subset-q-v", m, 2, torch.float16, "gptq", "torch")
Evidence: Refusal driver script
"""Adversarial end-user flows: every export qpeft must refuse, checked to write nothing; plus
zero-point 0 (refused in v1, exact in v2 through a real transformers + gptqmodel load) and
'export does not mutate the model'."""
import json, os, tempfile, copy
import torch
from transformers import AutoModelForCausalLM, GPTQConfig, Qwen3ForCausalLM
from qpeft import PEQAConfig, QALoraConfig, get_quant_model, UnsupportedSchemeError
from qpeft.export import export_gptq
from qpeft.tuners.tuners_utils import QuantLinear

MODEL = "axolotl-ai-co/tiny-qwen3-129m"
T = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
ids = torch.randint(0, 1000, (1, 24), generator=torch.Generator().manual_seed(0))


class Router(torch.nn.Linear):
    """an MoE-style router: a subclass of nn.Linear with its own forward"""


class QwenWithRouters(Qwen3ForCausalLM):
    def __init__(self, config):
        super().__init__(config)
        for block in self.model.layers:
            block.router = Router(config.hidden_size, 8)


def base(dtype, cls=AutoModelForCausalLM):
    return cls.from_pretrained(MODEL, dtype=dtype).eval()


def peqa(dtype, targets=T, cls=AutoModelForCausalLM):
    m = get_quant_model(base(dtype, cls), PEQAConfig(bits=4, group_size=64, target_modules=targets))
    m.merge_and_unload()
    return m


def refused(label, model, fmt="gptq"):
    out = os.path.join(tempfile.mkdtemp(), "out")
    try:
        export_gptq(model, out, checkpoint_format=fmt)
        print(f"[FAIL] {label}: export did NOT refuse")
    except UnsupportedSchemeError as e:
        print(f"[refused] {label}\n    UnsupportedSchemeError: {str(e)[:330]}\n    output dir exists afterwards: {os.path.exists(out)}")
    except Exception as e:
        print(f"[other error] {label}: {type(e).__name__}: {e}")


# 1. QA-LoRA merge -> fractional zero-points
m = get_quant_model(base(torch.bfloat16), QALoraConfig(bits=4, group_size=64, r=4, target_modules=T))
for l in m.base.modules():
    if isinstance(l, QuantLinear):
        torch.nn.init.normal_(l.adapter.B, std=0.1)
m.merge_and_unload()
refused("QA-LoRA merged model (fractional zero-points)", m)
# 2. fp32 model
refused("fp32 model (scales fp16 cannot hold)", peqa(torch.float32))
# 3. lm_head quantized (target_modules=None)
refused("target_modules=None quantizes lm_head", peqa(torch.bfloat16, targets=None))
# 4. different targets per block
refused("only block 0 quantized", peqa(torch.bfloat16, targets=r"model\.layers\.0\..*_proj"))
# 5. round-2 decision: quantized nn.Linear subclass the loader keeps in float
refused("router (nn.Linear subclass) quantized in every block", peqa(torch.bfloat16, T + ["router"], QwenWithRouters))
# 5b. same model, router left unquantized -> must export (loader leaves it float too)
out = tempfile.mkdtemp()
export_gptq(peqa(torch.bfloat16, T, QwenWithRouters), out)
qc = json.load(open(os.path.join(out, "config.json")))["quantization_config"]
print(f"[exported] router left unquantized: modules_in_block_to_quantize present = {'modules_in_block_to_quantize' in qc}")
# 6. unmerged
refused("unmerged model", get_quant_model(base(torch.bfloat16), PEQAConfig(bits=4, group_size=64, target_modules=T)))
# 7. unknown format
refused("checkpoint_format='awq'", peqa(torch.bfloat16), fmt="awq")

# 8. zero-point 0: v1 refuses, v2 exports and a real loader reads it exactly
m = peqa(torch.float16)
layer = m.base.model.layers[2].self_attn.o_proj
layer.zero_point.data[5, :3] = 0
refused("zero-point 0 with checkpoint_format='gptq' (v1)", m)
out = tempfile.mkdtemp()
export_gptq(m, out, checkpoint_format="gptq_v2")
loaded = AutoModelForCausalLM.from_pretrained(out, dtype=torch.float16, device_map="cpu",
                                              quantization_config=GPTQConfig(bits=4, backend="torch")).eval()
w = loaded.model.layers[2].self_attn.o_proj.dequantize_weight().t()
ref = layer.scheme.dequant(layer.codes, layer.scale.half(), layer.zero_point.half())
print(f"[exported] zero-point 0 with gptq_v2, real loader dequant of that layer == qpeft, bit for bit: {torch.equal(w, ref)}")

# 9. export does not mutate the model
m = peqa(torch.bfloat16)
with torch.no_grad():
    before = m(input_ids=ids).logits.clone()
keys, cfg = set(m.state_dict()), m.base.config.to_dict()
export_gptq(m, tempfile.mkdtemp())
with torch.no_grad():
    same = torch.equal(m(input_ids=ids).logits, before)
print(f"[unchanged] after export: same logits {same}, same state_dict keys {set(m.state_dict()) == keys}, "
      f"config unchanged {m.base.config.to_dict() == cfg}, no quantization_config on model config "
      f"{getattr(m.base.config, 'quantization_config', None) is None}")
Evidence: Key result lines
peqa4-fp16-v1: per-layer loader dequant == qpeft dequant (fp16), bit for bit: 56/56 layers; same greedy tokens: True
efficientqat3-fp16-v2: 56/56 layers bit for bit; same greedy tokens: True
peqa2-subset-q-v: modules_in_block_to_quantize [[self_attn.q_proj, self_attn.v_proj]]; 16/16 bit for bit; same greedy tokens: True
[refused] router (nn.Linear subclass) quantized in every block ... output dir exists afterwards: False
[exported] zero-point 0 with gptq_v2, real loader dequant == qpeft, bit for bit: True

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (2) ✅
  • ⚠️ qpeft/export/gptq.py:138 - _loader_replaces is meant to mirror the layer types that optimum's get_layers finds (its docstring says so). But it uses type(module) is nn.Linear, while optimum's get_layers (default [Conv1D, nn.Conv2d, nn.Linear]) uses isinstance, and qpeft's own _is_target (tuners_utils.py:254) also uses isinstance(module, nn.Linear). Concrete case: a decoder block holds an unquantized subclass of nn.Linear, and the user quantizes every plain nn.Linear in the block with a target list. Then quantized == set(in_blocks) at gptq.py:123, so the export writes no modules_in_block_to_quantize. optimum replaces the subclass too, and the checkpoint has no qweight/qzeros/scales for it, so the loader fills that layer with uninitialized GPTQ tensors. The subset path at gptq.py:127 has the same gap: a suffix collision with such a subclass is not detected. Fix: use isinstance(module, (QuantLinear, nn.Linear, nn.Conv2d)) plus the Conv1D name check. Then the predicate matches optimum's isinstance semantics, and both the None path and the suffix-collision check at gptq.py:127-131 see the same set that the loader replaces.
  • ℹ️ README.md:357 - README says: 'The export is exact: the weight a GPTQ loader decodes equals the merged model's weight bit for bit.' For a bf16 model this is not literally true. tests/test_gptq_load.py:100 compares the loader's fp16 dequant with qpeft's dequant on scale.half(), not with the merged bf16 weight (_weight_in(bfloat16)). The torch kernel computes bf16 models in fp16 (see the test comment at line 103). The default 4-bit CPU kernel also rounds scales to bf16. The written tensors are exact (tests/test_gptq_export.py proves this with the GPTQ formula in the model's dtype). The README sentence should say what is exact: the stored codes, scale and zero-point, and the GPTQ dequant in the model's own dtype. The loader's result then depends on its compute dtype and kernel.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ qpeft/export/gptq.py:138 - _loader_replaces is meant to mirror the layer types that optimum's get_layers finds (its docstring says so). But it uses type(module) is nn.Linear, while optimum's get_layers (default [Conv1D, nn.Conv2d, nn.Linear]) uses isinstance, and qpeft's own _is_target (tuners_utils.py:254) also uses isinstance(module, nn.Linear). Concrete case: a decoder block holds an unquantized subclass of nn.Linear, and the user quantizes every plain nn.Linear in the block with a target list. Then quantized == set(in_blocks) at gptq.py:123, so the export writes no modules_in_block_to_quantize. optimum replaces the subclass too, and the checkpoint has no qweight/qzeros/scales for it, so the loader fills that layer with uninitialized GPTQ tensors. The subset path at gptq.py:127 has the same gap: a suffix collision with such a subclass is not detected. Fix: use isinstance(module, (QuantLinear, nn.Linear, nn.Conv2d)) plus the Conv1D name check. Then the predicate matches optimum's isinstance semantics, and both the None path and the suffix-collision check at gptq.py:127-131 see the same set that the loader replaces.
  • ⚠️ qpeft/export/gptq.py:140 - _loader_replaces counts every QuantLinear as a layer the loader replaces. Its docstring says why: "a QuantLinear was an nn.Linear". That assumption is not always true. qpeft's _is_target (qpeft/tuners/tuners_utils.py:251) wraps any isinstance(module, nn.Linear), so subclasses get wrapped too. Round 1 confirmed that optimum's get_layers takes nn.Linear by exact type. The fix round documented that rule for unquantized layers but left this quantized sibling behind.

Concrete sequence: take the round-1 test model (each block holds router = _Router(128, 4), a subclass of nn.Linear). Call get_quant_model(base, PEQAConfig(bits=4, group_size=64, target_modules=BLOCK_LINEARS + [&#34;router&#34;])), then merge_and_unload(), then export_gptq(...). Every in-block layer is now a QuantLinear, so quantized == set(in_blocks) at gptq.py:123. The export succeeds and writes router.qweight/qzeros/scales/g_idx with no modules_in_block_to_quantize. At load time, optimum rebuilds the model from config. It sees _Router, skips it because of the exact-type rule, and keeps it as a float layer. The checkpoint has no router.weight, so transformers random-initializes the router and only warns about missing and unexpected keys. The result is a silently wrong model, which breaks invariant 2 (refuse, do not approximate).

The export cannot detect this today: a QuantLinear does not record the class it replaced. Two possible fixes: (a) record the original type in QuantLinear, or (b) in export, build the model from root.config on the meta device and refuse any quantized name whose fresh module is not exactly nn.Linear. Option (a) changes qpeft/tuners, which the round-1 human decision put off-limits. Option (b) adds new machinery to the export. Because the fix needs that authorization (the defect itself is mechanical), this is ask-user.

No other site: the unquantized path at gptq.py:121-131 already uses the exact-type rule correctly.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Merged PEQA 4-bit fp16 model exported as GPTQ v1 loads in transformers (optimum+gptqmodel, torch kernel) with bit-exact weights and identical greedy text ✅ pass live live-real-model-transcript.txt (peqa4-fp16-v1): 56/56 layers bit for bit, same greedy tokens True, quantization_config quant_method gptq bits 4 group_size 64 sym false desc_act false; scales (6,384) f…
EfficientQAT (Block-AP then E2E-QP) 3-bit export in gptq_v2 loads bit-exact and generates the same text ✅ pass live live-real-model-transcript.txt (efficientqat3-fp16-v2): 56/56 layers bit for bit, same greedy tokens True
A subset of block linears (q_proj, v_proj, 2-bit) writes modules_in_block_to_quantize and the loader keeps the others as float Linear ✅ pass live live-real-model-transcript.txt (peqa2-subset-q-v): modules_in_block_to_quantize present, loaded up_proj is Linear, 16/16 layers bit for bit, same greedy tokens
bf16 model with gptqmodel's default CPU kernel (TorchAtenLinear) stays within bf16 noise of the exact model ✅ pass live live-real-model-transcript.txt (peqa4-bf16-default-kernel): loaded error 0.0619 vs qpeft own 0.0634 against exact fp32, argmax agreement 0.972; also test_the_default_cpu_kernel_on_a_bf16_model
QA-LoRA merged model (fractional zero-points) is refused and nothing is written ✅ pass live live-refusals-transcript.txt: UnsupportedSchemeError 'zero-points are fractional', output dir exists afterwards False
fp32 model, lm_head quantized, different targets per block, unmerged model, unknown format are each refused with nothing written ✅ pass live live-refusals-transcript.txt: five [refused] entries, each 'output dir exists afterwards: False'
Round-2 decision: a quantized nn.Linear subclass (router) in every block is refused before writing; left unquantized it exports with no modules_in_block_to_quantize ✅ pass live live-refusals-transcript.txt: router refusal naming model.layers.*.router and exact-type rule, no output dir; unquantized-router export has modules_in_block_to_quantize False
Zero-point 0 is refused in v1 but exported in gptq_v2 and read bit-exact by a real loader ✅ pass live live-refusals-transcript.txt: v1 refused '3 groups have zero-point 0'; v2 loaded through transformers, dequant of that layer bit for bit True
export_gptq does not mutate the user's model or its config ✅ pass live live-refusals-transcript.txt: same logits True, same state_dict keys True, config unchanged True, no quantization_config on model config True
Suffix collision (optimum str.endswith would replace an unquantized myself_attn.o_proj) is refused ✅ pass live pytest tests/test_gptq_export.py::test_refuses_when_the_loader_suffix_match_would_hit_an_unquantized_linear (part of the 53 passed)
  • pytest -p no:cacheprovider tests/test_gptq_export.py tests/test_gptq_load.py in a throwaway venv (torch 2.14.1, transformers 5.19.0, optimum, gptqmodel 7.5.0): 53 passed, 0 skipped
  • python live_real_model.py: PEQA 4-bit fp16 v1 / PEQA 4-bit bf16 default kernel / EfficientQAT (Block-AP + E2E-QP) 3-bit fp16 gptq_v2 / PEQA 2-bit q_proj+v_proj subset on cached axolotl-ai-co/tiny-qwen3-129m; export_gptq + tokenizer, loaded with AutoModelForCausalLM.from_pretrained + AutoTokenizer, per-layer dequant compare, greedy generation compare
  • python live_refusals.py: QA-LoRA, fp32, lm_head, per-block difference, quantized nn.Linear subclass router, unmerged, unknown format, zero-point 0 in v1 (refused) vs v2 (loaded through transformers, bit for bit), router left unquantized exports, export does not mutate the model
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

qpeft.export.export_gptq(model, dir, checkpoint_format="gptq") writes the
model's own save_pretrained files with GPTQ tensors for every QuantLinear
(qweight as is, packed integer qzeros, fp16 scales (n_groups, out), g_idx)
and a quantization_config in config.json, so transformers loads it with
from_pretrained through optimum and gptqmodel.

The export is exact or it refuses before writing anything:
- fractional zero-points (a QA-LoRA fold, decision D2)
- scales that fp16 does not hold exactly (an fp32 model)
- zero-point 0 in the v1 format, which stores z - 1 and which loaders
  undo in different ways (gptq_v2 stores z and takes every zero-point)
- quantized layers outside the decoder blocks, different targets per
  block, a suffix match that would make the loader replace an
  unquantized linear, bits other than 2/3/4/8, non-HF models

Tests: the GPTQ dequant of the written file equals the merged layers bit
for bit (PEQA and EfficientQAT, bits 2/3/4/8, both formats), every
refusal, and a real load through transformers + gptqmodel on CPU
(tests/test_gptq_load.py, skipped without gptqmodel).
@gapsong
gapsong merged commit 58aef09 into main Oct 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant