[OMNIML-5899] Add IQ post-training quantization recipes (#2449)

## Summary

- add numerics configs for IQ1_S and IQ2_XS
- add model presets and general PTQ recipes
- document the supported weight-shape requirement
- reuse the packed IQ weight across forwards instead of re-encoding it
every time
- add end-to-end `hf_ptq` coverage for both formats
- add the release-note entry

## PR split

This work is split into four focused PRs. Each PR targets `main` and
owns a disjoint file set:

1. **Kernel** — [#2448: Add CUDA kernels for IQ
packing](https://github.com/NVIDIA/Model-Optimizer/pull/2448)
2. **Quantization** — [#2446: Add IQ quantization codecs and
backend](https://github.com/NVIDIA/Model-Optimizer/pull/2446)
3. **Export** — [#2447: Export IQ checkpoints from HF and
Megatron](https://github.com/NVIDIA/Model-Optimizer/pull/2447)
4. **Recipes** — [#2449: Add IQ post-training quantization
recipes](https://github.com/NVIDIA/Model-Optimizer/pull/2449)

The required merge order is #2448, #2446, #2447, then #2449.

## Scope

Sixteen files. The PR started as eight recipe config, documentation and
changelog files; the end-to-end test added for them surfaced two
performance
bugs in the already-merged codec, and fixing those pulled in the codec
files
and their tests.

Beyond the original recipe set it now touches four files owned by #2446
—
`ggml/common.py`, `ggml/iq1_s.py`, `ggml/iq2_xs.py` and
`ggml/backend.py` —
plus three test files. It still contains no kernel or export files.

Keeping those fixes here rather than moving them to #2446 is deliberate
and
confirmed with the stack owner: #2446 is already merged, and both bugs
are only
observable through the end-to-end test this PR adds, so splitting them
would
separate each fix from the test that demonstrates it.

## Packed-weight cache fix

Adding the end-to-end test made the cost visible: a TinyLlama IQ1_S
`hf_ptq`
run spent **499 of its 537 seconds inside the IQ1_S encoder**, packing
the same
154 weights 15400 times — 100 times each.

The repacking is not calibration. These recipes set `algorithm: null`
and
`hf_ptq` logs `Dynamic quantization. Calibration skipped.`. The 100
passes are
the sample `generate()` calls `hf_ptq` makes before and after
quantization: one
decode step re-runs weight fake-quant on every linear, and the packed
payload
was thrown away each time.

`_PackedWeightCache` was already there to prevent exactly this, and it
never
hit. It keyed on the identity of the tensor the backend was handed, but
`TensorQuantizer` passes a fresh *view* of the weight on every forward,
so the
identity check never matched twice.

The fix anchors the entry to `inputs._base` — the parameter the view is
taken
from — held as a weakref. The parameter is stable across forwards, so
the cache
hits; the reference stays weak, so the payload is released with the
weight and
offloaded/meta-device flows are unaffected. (A strong reference does
make the
cache hit, but pins full-precision storage for the life of the
quantizer, which
is the opposite of what those flows need.)

Measured on TinyLlama IQ1_S `hf_ptq`, 2×H100, same command before and
after:

| | packer calls | packing time | wall clock |
|---|---|---|---|
| before | 15400 | 499.0 s | 8m57s |
| after | 154 (one per weight) | 6.2 s | 3m21s |

`test_ggml_weight_is_packed_once_across_forwards` pins this: it counts
encoder
calls across five forwards under `torch.inference_mode()` (what
`generate()`
runs under) and asserts exactly one.

## Decode chunk fix

With packing cached, the end-to-end cost moved entirely into the decode,
and
IQ2_XS was still 4x slower than IQ1_S (1081s vs 260s per case).
Instrumenting
both showed packing was no longer the cost at all — IQ2_XS packs
*faster*:

| | pack calls | packing time | wall clock |
|---|---|---|---|
| IQ1_S | 154 | 6.2 s | 3m21s |
| IQ2_XS | 154 | 1.6 s | 17m59s |

The cause was one constant serving two loops with opposite
characteristics.
`_DEFAULT_BLOCK_CHUNK_SIZE` bounds the torch encode fallback, which
holds the
large codebook-search temporaries and runs once per weight; IQ2_XS sets
it to
256 rather than IQ1_S's 1024 because its search sweeps sixteen local
scales per
grid tile. But the *decode* shared it — and the decode has tiny
temporaries,
runs on every forward, and is never cached, so a small chunk only
multiplies
kernel launches.

Decoding a 2048x5632 weight:

| chunk | IQ2_XS decode | transient peak |
|---|---|---|
| 256 (was) | 91.4 ms | +24 MiB |
| 1024 | 22.9 ms | +31 MiB |
| 4096 (now) | 5.8 ms | +56 MiB |

The decode now takes its own `_DEFAULT_DECODE_CHUNK_SIZE`, threaded
through
`fake_quantize_with_cache`. The encode bounds are untouched, so the
memory
ceiling stays where it was aimed. The seven-iteration sign-parity loop
in
`dequantize_iq2_xs` is also folded into three XOR steps, off the same
per-forward path.

Per case in `tests/examples/hf_ptq` on 2xH100:

| | before | after |
|---|---|---|
| IQ1_S | 259.65 s | 94.64 s |
| IQ2_XS | 1081.05 s | 102.77 s |

Both now fit the 300s `tests/examples` default, so the cases carry no
explicit
timeout.

## Integration contracts

- `block_sizes: {-1: 256}` records the native packed-block contract; it
does not drive the GGML fake-quant scale search. The export path reads
`TensorQuantizer.block_sizes[-1]` through `get_weight_block_size`,
validates it against the format block size, and records `group_size:
256` in checkpoint metadata.
- The model presets intentionally expose `--qformat iq1_s` and
`--qformat iq2_xs` in `hf_ptq`.

## Testing

- `tests/unit/torch/quantization/ -k 'ggml or iq1 or iq2'` — 52 passed,
1 skipped
- `tests/unit/recipe/test_presets.py` — both shipped IQ recipes load
with the expected backend and no unused search option
- `tests/examples/hf_ptq/test_llm_ptq.py -k 'iq1_s or iq2_xs'` — 2
passed on 2×H100 (TinyLlama, both formats end to end through export),
3m17s for the pair
- pre-commit hooks pass on all changed files
- larger-model sanity check outside CI: Qwen3.8-27B (2256 quantizers)
quantizes
and exports with `general/ptq/iq1_s` on 2xH100 in 66m46s, export itself
192s

---------

Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
Co-authored-by: Chenjie Luo <chenjiel@nvidia.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hychiang
2026-09-22 07:46:50 +00:00
committed by GitHub
co-authored by Chenjie Luo Claude Opus 5
parent 5bb7343592
commit 1b4e7dfb14
16 changed files with 395 additions and 20 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ def ggml_fake_quant(inputs: torch.Tensor, quantizer) -> torch.Tensor:
"""Dispatch an IQ quantizer to its format-specific implementation."""
num_bits = getattr(quantizer, "num_bits", None)
extra_args = getattr(quantizer, "backend_extra_args", None) or {}
unknown_args = set(extra_args) - {"block_chunk_size"}
unknown_args = set(extra_args) - {"block_chunk_size", "decode_chunk_size"}
if unknown_args:
raise ValueError(f"Unsupported ggml backend_extra_args: {sorted(unknown_args)}")
if num_bits == "iq1_s":
+30 -4
View File
@@ -27,7 +27,19 @@ GGML_BLOCK_SIZE = 256
@dataclass
class _PackedWeightCache:
input_ref: weakref.ReferenceType
"""One weight's packed payload, reused across forwards.
``base_ref`` points at the parameter, not at the tensor the backend was handed.
TensorQuantizer passes a fresh view of the weight on every forward, so a weakref to that
view dies as soon as the forward returns and an identity check against it never matches
again -- which is what kept this cache from ever hitting.
Keeping it a weakref matters: a strong reference would pin full-precision storage alive and
defeat offloaded or meta-device flows. Tying the entry to the parameter's lifetime means the
payload stops being reused exactly when the weight it came from is released.
"""
base_ref: weakref.ReferenceType
input_key: tuple[object, ...]
format_name: str
block_chunk_size: int
@@ -35,6 +47,16 @@ class _PackedWeightCache:
weight_shape: torch.Tensor
def _cache_base(inputs: torch.Tensor) -> torch.Tensor:
"""The tensor whose lifetime the cached payload should follow.
``inputs`` is a per-forward view; ``inputs._base`` is the parameter behind it, which lives
as long as the module does.
"""
base = inputs._base
return inputs if base is None else base
def _input_cache_key(inputs: torch.Tensor) -> tuple[object, ...] | None:
try:
version = inputs._version
@@ -57,16 +79,18 @@ def fake_quantize_with_cache(
*,
format_name: str,
block_chunk_size: int,
decode_chunk_size: int,
quantize: Callable[..., tuple[torch.Tensor, torch.Tensor]],
dequantize: Callable[..., torch.Tensor],
) -> torch.Tensor:
"""Fake-quantize a weight while caching its compact packed representation."""
input_key = _input_cache_key(inputs)
cache_base = _cache_base(inputs)
cache = getattr(quantizer, "_quantizer_cache", None)
if (
isinstance(cache, _PackedWeightCache)
and input_key is not None
and cache.input_ref() is inputs
and cache.base_ref() is cache_base
and cache.input_key == input_key
and cache.format_name == format_name
and cache.block_chunk_size == block_chunk_size
@@ -76,7 +100,7 @@ def fake_quantize_with_cache(
packed_weights, weight_shape = quantize(inputs, block_chunk_size=block_chunk_size)
if input_key is not None:
quantizer._quantizer_cache = _PackedWeightCache(
input_ref=weakref.ref(inputs),
base_ref=weakref.ref(cache_base),
input_key=input_key,
format_name=format_name,
block_chunk_size=block_chunk_size,
@@ -86,11 +110,13 @@ def fake_quantize_with_cache(
else:
quantizer._quantizer_cache = None
# Sized separately from the encode chunk: packing happens once per weight and is bounded
# by its search temporaries, while this runs on every forward and is bounded by launches.
reconstructed = dequantize(
packed_weights,
weight_shape,
dtype=inputs.dtype,
block_chunk_size=block_chunk_size,
block_chunk_size=decode_chunk_size,
)
return inputs + (reconstructed - inputs).detach()
+10 -2
View File
@@ -61,8 +61,14 @@ IQ1_S_EFFECTIVE_BITS = IQ1_S_BLOCK_BYTES * 8 / IQ1_S_BLOCK_SIZE
_IQ1_S_DELTA = 0.125
_IQ1_S_NATIVE_MAX = 16.875
_IQ1_S_SCALE_ANCHOR = 0.61
# At 1024 blocks, each largest IQ1_S search temporary is about 16 MiB in FP32.
# Bounds the torch encode fallback, whose codebook search holds the large temporaries: at
# 1024 blocks each is about 16 MiB in FP32. The CUDA encoder ignores this entirely.
_DEFAULT_BLOCK_CHUNK_SIZE = 1024
# The decode's temporaries are far smaller, so it is launch-bound rather than memory-bound
# and wants a bigger chunk -- and unlike packing it is not cached, so it runs on every
# forward. Measured decoding a 2048x5632 weight: 66.5 ms at 256 blocks, 4.2 ms at 4096,
# where the transient peak is +42 MiB.
_DEFAULT_DECODE_CHUNK_SIZE = 4096
_GRID_CACHE: dict[torch.device, torch.Tensor] = {}
@@ -198,7 +204,7 @@ def dequantize_iq1_s(
weight_shape: torch.Tensor,
*,
dtype: torch.dtype = torch.bfloat16,
block_chunk_size: int = _DEFAULT_BLOCK_CHUNK_SIZE,
block_chunk_size: int = _DEFAULT_DECODE_CHUNK_SIZE,
) -> torch.Tensor:
"""Decode GGML-compatible IQ1_S payload bytes."""
shape = validate_packed_weights(
@@ -234,6 +240,7 @@ def iq1_s_fake_quant(
quantizer,
*,
block_chunk_size: int = _DEFAULT_BLOCK_CHUNK_SIZE,
decode_chunk_size: int = _DEFAULT_DECODE_CHUNK_SIZE,
) -> torch.Tensor:
"""IQ1_S weight backend for TensorQuantizer, with pass-through backward."""
if getattr(quantizer, "num_bits", None) != "iq1_s":
@@ -243,6 +250,7 @@ def iq1_s_fake_quant(
quantizer,
format_name="iq1_s",
block_chunk_size=block_chunk_size,
decode_chunk_size=decode_chunk_size,
quantize=quantize_iq1_s,
dequantize=dequantize_iq1_s,
)
+20 -6
View File
@@ -60,8 +60,17 @@ _IQ2_XS_NATIVE_MAX = 43 * 31 / 8
_IQ2_XS_SCALE_ANCHOR_MIN = 0.65
_IQ2_XS_SCALE_ANCHOR_MAX = 0.92
_IQ2_XS_PEAK_TO_RMS_TAPER = 0.035
# At 256 blocks, the largest IQ2_XS search temporary is about 16 MiB in FP32.
# Bounds the torch encode fallback, whose codebook search holds the large temporaries: at
# 256 blocks the largest is about 16 MiB in FP32. The CUDA encoder ignores this entirely.
# This is four times smaller than the IQ1_S bound because the IQ2_XS search sweeps sixteen
# local scales per grid tile.
_DEFAULT_BLOCK_CHUNK_SIZE = 256
# The decode's temporaries are far smaller, so it is launch-bound rather than memory-bound
# and wants a bigger chunk -- and unlike packing it is not cached, so it runs on every
# forward. Sharing the encode bound above is what made IQ2_XS four times slower end to end
# than IQ1_S. Measured decoding a 2048x5632 weight: 91.4 ms at 256 blocks, 22.9 ms at 1024,
# 5.8 ms at 4096, where the transient peak is +56 MiB.
_DEFAULT_DECODE_CHUNK_SIZE = 4096
_SCALE_BLOCK_CHUNK_SIZE = 4096
@@ -204,7 +213,7 @@ def dequantize_iq2_xs(
weight_shape: torch.Tensor,
*,
dtype: torch.dtype = torch.bfloat16,
block_chunk_size: int = _DEFAULT_BLOCK_CHUNK_SIZE,
block_chunk_size: int = _DEFAULT_DECODE_CHUNK_SIZE,
) -> torch.Tensor:
"""Decode GGML-compatible IQ2_XS payload bytes."""
shape = validate_packed_weights(
@@ -225,10 +234,13 @@ def dequantize_iq2_xs(
)
entries = codes & 0x1FF
sign_index = codes >> 9
parity = torch.zeros_like(sign_index)
for bit in range(7):
parity ^= (sign_index >> bit) & 1
sign_mask = sign_index | (parity << 7)
# XOR-fold the seven payload bits down to bit 0 to recover the eighth sign bit.
# The loop this replaces cost seven elementwise passes per chunk, and unlike packing
# the decode is not cached -- it runs again on every forward.
folded = sign_index ^ (sign_index >> 4)
folded ^= folded >> 2
folded ^= folded >> 1
sign_mask = sign_index | ((folded & 1) << 7)
signs = 1.0 - 2.0 * ((sign_mask.unsqueeze(-1) >> bit_positions) & 1).float()
scale_bytes = block_chunk[:, 66:].to(torch.int64)
@@ -248,6 +260,7 @@ def iq2_xs_fake_quant(
quantizer,
*,
block_chunk_size: int = _DEFAULT_BLOCK_CHUNK_SIZE,
decode_chunk_size: int = _DEFAULT_DECODE_CHUNK_SIZE,
) -> torch.Tensor:
"""IQ2_XS weight backend for TensorQuantizer, with pass-through backward."""
if getattr(quantizer, "num_bits", None) != "iq2_xs":
@@ -257,6 +270,7 @@ def iq2_xs_fake_quant(
quantizer,
format_name="iq2_xs",
block_chunk_size=block_chunk_size,
decode_chunk_size=decode_chunk_size,
quantize=quantize_iq2_xs,
dequantize=dequantize_iq2_xs,
)