Repository navigation
[OMNIML-5899] Add Q8_0 quantization codec and backend #2516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
489ca8a
14df57e
0b5461f
e0eab44
6d43a4c
49f8d1e
3c0cfd1
2beb70f
71e3183
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Q8_0 fake quantization and GGML-compatible block packing. | ||
|
|
||
| Every 32 logical values become one 34-byte ``block_q8_0`` payload: a | ||
| little-endian FP16 block scale followed by 32 signed int8 quants. | ||
| """ | ||
|
|
||
| import torch | ||
|
|
||
| from ..extensions import get_cuda_ext_ggml | ||
| from .common import ( | ||
| GGMLFormat, | ||
| narrow_to_float32, | ||
| validate_block_chunk_size, | ||
| validate_packed_weights, | ||
| validate_weight, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "Q8_0_BLOCK_BYTES", | ||
| "Q8_0_BLOCK_SIZE", | ||
| "Q8_0_EFFECTIVE_BITS", | ||
| "Q8_0_FORMAT", | ||
| "dequantize_q8_0", | ||
| "q8_0_fake_quant", | ||
| "quantize_q8_0", | ||
| ] | ||
|
|
||
| Q8_0_BLOCK_SIZE = 32 | ||
| Q8_0_BLOCK_BYTES = 34 | ||
| Q8_0_EFFECTIVE_BITS = Q8_0_BLOCK_BYTES * 8 / Q8_0_BLOCK_SIZE | ||
| _Q8_0_MAX_QUANT = 127 | ||
| _DEFAULT_BLOCK_CHUNK_SIZE = 4096 | ||
| _DEFAULT_DECODE_CHUNK_SIZE = 16384 | ||
|
|
||
|
|
||
| def _encode_blocks(blocks: torch.Tensor) -> torch.Tensor: | ||
| """Encode a moderate-size batch of flattened 32-value blocks.""" | ||
| x = narrow_to_float32(blocks) | ||
| block_count = x.shape[0] | ||
| amax = x.abs().amax(dim=1) | ||
| # Keep the serialized fp16 scale finite. This extends the existing GGML backend policy for | ||
| # finite float64 values outside the float32/fp16 scale range. | ||
| d_float = (amax / _Q8_0_MAX_QUANT).clamp(max=65504.0) | ||
| d = d_float.to(torch.float16) | ||
| # Select quants with the unrounded float32 scale, not the serialized fp16 scale. | ||
| inverse = torch.where(d_float > 0, d_float.reciprocal(), torch.zeros_like(d_float)) | ||
|
Comment on lines
+58
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] Nothing in the test suite pins the choice of the unrounded fp32 This is the one subtle thing that makes the encoder byte-identical to The existing coverage can't catch it: Worth one test with a block whose
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 3c0cfd1. Added test_q8_0_selects_quants_before_rounding_scale_to_fp16 with amax=1 and +/-3.4999/127: the exact int8 bytes must be [127, 3, -3], whereas using the serialized FP16 scale would round to +/-4. The CUDA parity test now includes the same unrounded-scale case, and the encoder comment explicitly distinguishes normalization from scale serialization. The focused CPU suite passed (183 tests); current-head GPU CI is running: https://github.com/NVIDIA/Model-Optimizer/actions/runs/36763486005 |
||
| normalized = x * inverse.unsqueeze(1) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[P2] The newly wired CUDA path is not byte-identical to this reference: this code computes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed by the reciprocal-then-multiply kernel correction in 6d43a4c, retained after integration with latest main and the assumed #2595 dependency in 3c0cfd1. The [5207.0, 20.5, -20.5] CUDA/reference regression asserts exact int8 bytes [127, 0, 0]. All five Q8_0 CUDA tests passed on 6d43a4c (https://github.com/NVIDIA/Model-Optimizer/actions/runs/36492851651/job/109169747552). The new head expands that test with the unrounded FP32 scale boundary; its GPU CI has been triggered separately and is still running: https://github.com/NVIDIA/Model-Optimizer/actions/runs/36763486005 |
||
| # C roundf, used by the canonical Q8_0 encoder, rounds half-way cases away from zero. | ||
| magnitude = normalized.abs() | ||
| rounded = normalized.sign() * torch.where( | ||
| magnitude.frac() == 0.5, | ||
| magnitude.floor() + 1, | ||
| magnitude.round(), | ||
| ) | ||
| quants = rounded.clamp(-_Q8_0_MAX_QUANT, _Q8_0_MAX_QUANT).to(torch.int8) | ||
|
|
||
| packed = torch.empty((block_count, Q8_0_BLOCK_BYTES), dtype=torch.uint8, device=x.device) | ||
| packed[:, :2] = d.contiguous().view(torch.uint8).reshape(block_count, 2) | ||
| packed[:, 2:] = quants.view(torch.uint8) | ||
| return packed | ||
|
|
||
|
|
||
| @torch.no_grad() | ||
| def quantize_q8_0( | ||
| weight: torch.Tensor, *, block_chunk_size: int = _DEFAULT_BLOCK_CHUNK_SIZE | ||
| ) -> tuple[torch.Tensor, torch.Tensor]: | ||
| """Pack a floating-point weight into GGML-compatible Q8_0 blocks.""" | ||
| validate_weight(weight, "Q8_0", block_size=Q8_0_BLOCK_SIZE) | ||
| validate_block_chunk_size(block_chunk_size) | ||
|
|
||
| logical_shape = torch.tensor(weight.shape, dtype=torch.int64) | ||
| blocks = weight.contiguous().reshape(-1, Q8_0_BLOCK_SIZE) | ||
| if weight.is_cuda: | ||
| extension = get_cuda_ext_ggml() | ||
| if extension is not None: | ||
| packed = extension.q8_0_pack(blocks) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 4 '\bq8_0_pack\b|PYBIND11_MODULE|m\.def\s*\(' modeloptRepository: NVIDIA/Model-Optimizer Length of output: 9326 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- q8_0.py ---'
sed -n '1,125p' modelopt/torch/quantization/ggml/q8_0.py
printf '%s\n' '--- ggml.cpp ---'
sed -n '1,180p' modelopt/torch/kernels/quantization/ggml/ggml.cpp
printf '%s\n' '--- get_cuda_ext_ggml references ---'
rg -n -C 5 'get_cuda_ext_ggml|ggml.*extension|ggml.cpp|ggml_pack' modelopt/torch/quantization modelopt/torch/kernels/quantization/ggmlRepository: NVIDIA/Model-Optimizer Length of output: 18570 Fall back when the GGML extension has no When 🐛 Suggested fix- if extension is not None:
+ if extension is not None and hasattr(extension, "q8_0_pack"):
packed = extension.q8_0_pack(blocks)🤖 Prompt for AI Agents |
||
| packed_shape = ( | ||
| *weight.shape[:-1], | ||
| weight.shape[-1] // Q8_0_BLOCK_SIZE, | ||
| Q8_0_BLOCK_BYTES, | ||
| ) | ||
| return packed.reshape(packed_shape), logical_shape | ||
|
|
||
| chunks = [ | ||
| _encode_blocks(blocks[start : start + block_chunk_size]) | ||
| for start in range(0, blocks.shape[0], block_chunk_size) | ||
| ] | ||
| packed_shape = ( | ||
| *weight.shape[:-1], | ||
| weight.shape[-1] // Q8_0_BLOCK_SIZE, | ||
| Q8_0_BLOCK_BYTES, | ||
| ) | ||
| return torch.cat(chunks).reshape(packed_shape), logical_shape | ||
|
|
||
|
|
||
| @torch.no_grad() | ||
| def dequantize_q8_0( | ||
| packed_weights: torch.Tensor, | ||
| weight_shape: torch.Tensor, | ||
| *, | ||
| dtype: torch.dtype = torch.bfloat16, | ||
| block_chunk_size: int = _DEFAULT_DECODE_CHUNK_SIZE, | ||
| ) -> torch.Tensor: | ||
| """Decode GGML-compatible Q8_0 payload bytes.""" | ||
| shape = validate_packed_weights( | ||
| packed_weights, | ||
| weight_shape, | ||
| block_bytes=Q8_0_BLOCK_BYTES, | ||
| block_size=Q8_0_BLOCK_SIZE, | ||
| format_name="Q8_0", | ||
| ) | ||
| validate_block_chunk_size(block_chunk_size) | ||
|
|
||
| blocks = packed_weights.contiguous().reshape(-1, Q8_0_BLOCK_BYTES) | ||
| decoded = torch.empty((blocks.shape[0], Q8_0_BLOCK_SIZE), dtype=dtype, device=blocks.device) | ||
| for start in range(0, blocks.shape[0], block_chunk_size): | ||
| stop = min(start + block_chunk_size, blocks.shape[0]) | ||
| block_chunk = blocks[start:stop] | ||
| d = block_chunk[:, :2].contiguous().view(torch.float16).reshape(-1).float() | ||
| quants = block_chunk[:, 2:].contiguous().view(torch.int8).float() | ||
| decoded[start:stop] = (d.unsqueeze(1) * quants).to(dtype) | ||
| return decoded.reshape(shape) | ||
|
|
||
|
|
||
| Q8_0_FORMAT = GGMLFormat( | ||
| name="q8_0", | ||
| block_size=Q8_0_BLOCK_SIZE, | ||
| block_bytes=Q8_0_BLOCK_BYTES, | ||
| quantize=quantize_q8_0, | ||
| dequantize=dequantize_q8_0, | ||
| block_chunk_size=_DEFAULT_BLOCK_CHUNK_SIZE, | ||
| decode_chunk_size=_DEFAULT_DECODE_CHUNK_SIZE, | ||
| ) | ||
|
|
||
| # Keep the public per-format entry point on the same record used by backend dispatch. | ||
| q8_0_fake_quant = Q8_0_FORMAT.fake_quant | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SUGGESTION] Q8_0 is the first registered format whose
block_sizeis not 256, and dispatch still never checks a quantizer's configuredblock_sizesagainst the format's own block geometry.Until now the two could not disagree — every GGML format was 256 — so
block_sizes={-1: 256}was the only habit, and every recipe/config/test in the tree carries it. Withq8_0in the registry,QuantizerAttributeConfig(num_bits="q8_0", block_sizes={-1: 256}, backend="ggml")now sails through calibration (the encoder just splits each 256-wide block into eight 32-value sub-blocks, so the numbers are still right) and is only rejected much later at export, byquant_utils.get_quant_config:f"{v.upper()} requires block size {block_size}, got {block_size_value}". A user copying an IQ recipe pays for a full PTQ run before finding out.A cheap guard in
ggml_fake_quant, right before the dispatch, would move that to the first forward:Non-blocking — the quantization math is correct either way, and export does refuse. Feel free to defer it to the export PR (#2517) if you would rather keep the check next to the message it has to agree with.