[OMNIML-5899] Add IQ tests and release notes - #2450
hychiang-git wants to merge 8 commits into
Conversation
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
|
/claude review |
| blocks = packed.reshape(-1, 50) | ||
| qh = blocks[:, 34:50:2].to(torch.int64) | (blocks[:, 35:50:2].to(torch.int64) << 8) | ||
| assert torch.all(((qh >> 12) & 0x7) < 8) | ||
| assert torch.all((qh & 0xFFF) < 0x1000) |
There was a problem hiding this comment.
[IMPORTANT Compatibility] These two "payload field" assertions are tautologies — they can never fail, so this half of the test validates nothing.
qh is built from two uint8 bytes, so qh ∈ [0, 65535]. Then:
((qh >> 12) & 0x7)is masked to 3 bits, so it is always in[0, 7]→< 8holds unconditionally.(qh & 0xFFF)is masked to 12 bits, so it is always in[0, 4095]→< 0x1000holds unconditionally.
Why it matters: the metadata word bit layout (bits 0..11 = high index bits, 12..14 = local scale, 15 = delta sign) is the single highest-risk part of the GGML-compatibility claim, and this is the only CPU test that purports to check the encoder's output bits on real data. As written, a bug that put the local-scale index in the wrong bit position, or that emitted grid indices >= 2048, would still pass.
Suggested fix — assert something the encoder could actually get wrong, e.g. that the decoded grid indices are in range and that the metadata fields are not degenerate:
blocks = packed.reshape(-1, 50)
qh = blocks[:, 34:50:2].to(torch.int64) | (blocks[:, 35:50:2].to(torch.int64) << 8)
low = blocks[:, 2:34].to(torch.int64).reshape(-1, 8, 4)
shifts = torch.tensor([0, 3, 6, 9])
entries = low | (((qh.unsqueeze(-1) >> shifts) & 0x7) << 8)
# Every decoded codebook index must address the 2048-entry grid, and the
# search must actually exercise more than one local scale / delta sign.
assert entries.max() < 2048
assert ((qh >> 12) & 0x7).unique().numel() > 1
d = blocks[:, :2].contiguous().view(torch.float16).float()
assert torch.all(d > 0)| blocks = packed.reshape(-1, 74) | ||
| codes = blocks[:, 2:66:2].to(torch.int64) | (blocks[:, 3:66:2].to(torch.int64) << 8) | ||
| assert torch.all((codes & 0x1FF) < 512) | ||
| assert torch.all((codes >> 9) < 128) |
There was a problem hiding this comment.
[IMPORTANT Compatibility] Same tautology as in test_iq1_s.py — both assertions hold for any possible byte payload:
codesis assembled from twouint8bytes, socodes ∈ [0, 65535].(codes & 0x1FF)is masked to 9 bits → always in[0, 511], so< 512is unconditional.(codes >> 9)is at most65535 >> 9 == 127, so< 128is unconditional.
Why it matters: IQ2_XS has the more intricate layout of the two (32 uint16 codes carrying a 9-bit grid index + 7-bit sign mask, plus 16 four-bit local scales packed two-per-byte in bytes 66..73), yet the only byte-level coverage in this file is the all-zero payload. Nothing here or in test_iq2_xs_cuda.py checks the sign field or the packed scale nibbles against a hand-constructed reference the way test_iq1_s_dequantizes_ggml_metadata_bit_fields does for IQ1_S. A transposed sign bit or a swapped scale nibble would round-trip self-consistently through our own encoder/decoder pair and pass every test in this PR, while producing a checkpoint llama.cpp decodes incorrectly.
Two suggestions:
- Replace the vacuous bounds with checks that can fail — e.g. the 7-bit sign popcount parity constraint GGML relies on, that the decoded 4-bit scale nibbles are not all identical, and that
d > 0. - Add an IQ2_XS counterpart to
test_iq1_s_dequantizes_ggml_metadata_bit_fieldsthat builds bytes by hand (a known grid index, a known sign mask, a known scale nibble) and assertsdequantize_iq2_xsreturns the value the GGML formula predicts. That is the assertion that actually pins the wire format.
| packed = _extension().pack(weight, iq1_s_grid("cuda")).reshape(8, 1, 50) | ||
| packed_again = _extension().pack(weight, iq1_s_grid("cuda")).reshape(8, 1, 50) | ||
| dispatched, shape = quantize_iq1_s(weight) | ||
| reconstructed = dequantize_iq1_s(packed, shape) | ||
|
|
||
| assert packed.shape == (8, 1, 50) | ||
| assert torch.equal(packed, packed_again) | ||
| assert torch.equal(packed, dispatched) |
There was a problem hiding this comment.
[IMPORTANT Compatibility] assert torch.equal(packed, dispatched) does not test what the test name implies — both sides come from the same CUDA extension, so this is a restatement of the determinism assertion on line 39, not a check of the native-dispatch path against a reference.
Trace: quantize_iq1_s (modelopt/torch/quantization/ggml/iq1_s.py:240-252) takes the CUDA branch whenever weight.is_cuda and an extension is loadable. _extension() has already forced the extension to load with raise_if_failed=True and get_cuda_ext_iq1_s caches on the function attribute, so quantize_iq1_s is guaranteed to hit extension.pack(...). packed and dispatched are therefore the same kernel's output twice over.
Why it matters: iq1_s.cu is an independent reimplementation of _encode_blocks — the 2048-entry codebook search, the d = (amax / 16.875) * 0.61 fp16 scale rounding, the 16-way (8 local scales × 2 delta signs) choice, and the "lowest index wins on equal error" tie-break. Divergence between the two encoders is the most likely defect in the whole feature, and no test in this PR compares them. The GPU suite would stay green with a CUDA kernel that systematically picks a different local scale than the CPU reference; only the loose normalized_mse < 0.25 bound would notice, and only for a gross error.
Suggested change (applies equally to tests/gpu/torch/quantization/test_iq2_xs_cuda.py:33-40) — compare against the reference encoder rather than against itself:
def test_iq1_s_cuda_pack_matches_reference_encoder():
generator = torch.Generator(device="cuda").manual_seed(1234)
weight = torch.randn((8, 256), generator=generator, device="cuda", dtype=torch.bfloat16)
packed = _extension().pack(weight, iq1_s_grid("cuda")).reshape(8, 1, 50)
reference, _ = quantize_iq1_s(weight.cpu())
assert torch.equal(packed.cpu(), reference)If exact byte equality is not a contract the kernel intends to hold (e.g. tie-breaks are allowed to differ), then say so in a comment and assert the weaker but still meaningful property instead — that the two encoders' reconstruction errors agree to within a tight tolerance:
cuda_err = (dequantize_iq1_s(packed, shape).float() - weight.float()).square().mean()
cpu_err = (dequantize_iq1_s(reference, shape.cpu()).float() - weight.cpu().float()).square().mean()
torch.testing.assert_close(cuda_err.cpu(), cpu_err, rtol=1e-3, atol=0)Either way, keep the existing determinism assertion — it is the only part of the current test that can fail.
| ) | ||
| def test_megatron_name_remapping_exports_iq_payload(qformat, payload_bytes, dequantize): | ||
| """Megatron export writes the same scale-free IQ representation as HF export.""" | ||
| linear = torch.nn.Linear(256, 2, bias=False, dtype=torch.bfloat16) |
There was a problem hiding this comment.
[SUGGESTION] The docstring claims parity with HF export, but the test never exercises the HF path — it only calls GPTModelExporter._name_remapping and compares against linear.weight_quantizer(linear.weight), i.e. the same quantize_iq1_s/quantize_iq2_xs encoder that _get_iq_weight_state calls. So it verifies the Megatron plumbing (packed buffer lands under the ...weight key, layer_config_dict gets the right metadata), which is worth having, but it cannot detect the Megatron and HF exporters drifting apart.
Either reword to what is actually asserted, e.g. """Megatron export writes the packed IQ payload under the plain weight key, with no scale tensors.""", or make the parity claim real by also running _export_quantized_weight on an equivalent nn.Linear and asserting the two payloads are byte-identical. A wrong docstring on a cross-backend-parity test is the kind that gets trusted later.
| def test_iq1_s_requires_complete_last_dimension_blocks(): | ||
| with pytest.raises(ValueError, match="last weight dimension"): | ||
| quantize_iq1_s(torch.ones(2, 257)) |
There was a problem hiding this comment.
[SUGGESTION] The PR description lists "input validation" as covered, but only one of the validation branches has a test. Still unexercised across both test_iq1_s.py and test_iq2_xs.py:
validate_weight: the empty-weightValueError, the non-floating-pointTypeError(note it isTypeError, notValueError— an easy thing to regress), and the non-finiteValueError. The last one matters most:_encode_blockscomputesamaxfrom the block, so a NaN would silently poison the fp16 scale for all 256 values if the guard were ever dropped.quantize_iq*: theblock_chunk_size <= 0ValueError.validate_packed_weights: the wrong-dtype / wrong-last-dimValueErrorand the size-mismatchValueError— the latter is the guard that stops a mismatchedweight_shapefrom reshaping garbage during restore.iq*_fake_quant: thenum_bitsmismatchValueError, and thesearch_impl != "auto"NotImplementedError. That second one is user-facing — someone settingbackend_extra_args={"search_impl": "greedy"}should get a clear error, and the code also silently accepts aniq_search_implalias that nothing tests.
Most of these are one-line pytest.raises additions in the shared style already used by test_iq1_s_requires_complete_last_dimension_blocks, and the two fake_quant guards in particular are cheap given the Quantizer stub already exists in this file.
There was a problem hiding this comment.
Claude review — 3 IMPORTANT, 2 SUGGESTION, 0 CRITICAL
Scope. 9 changed files (497 additions, 0 deletions), all tests plus one CHANGELOG.rst line. I reviewed all 8 test files and cross-read the code under test on the base branch (modelopt/torch/quantization/ggml/{iq1_s,iq2_xs,common}.py, modelopt/torch/kernels/quantization/ggml/*.{cpp,cu}, modelopt/torch/export/{unified_export_hf,unified_export_megatron,quant_utils,convert_hf_config,moe_utils}.py, extensions.py) to check the assertions against real behaviour. I could not execute pytest in this environment, so the findings below are from static tracing.
What checks out
- Payload sizes, shapes, and derived constants are all consistent with the implementation: 50/74 block bytes,
1.5625/2.3125effective bits (convert_hf_config.py:121),(N, cols//256, bytes)packed shape, and the[*shape[:-2], shape[-2] * 256]logical-shape reconstruction in the Megatron test. test_iq1_s_dequantizes_ggml_metadata_bit_fieldsis the strongest test in the PR — I hand-verified the arithmetic:d = 2.0,local = 3→scale = 2 * (2*3+1) = 14.0, bit 15 set →delta = -0.125, andqh = 48712fitsuint16. It matchesdequantize_iq1_sexactly.- The
pytest.raisesregexes match the real messages:"last weight dimension"againstcommon.py:31,"supports float32, float64, float16, and bfloat16"againstiq1_s.cu:249/iq2_xs.cu:276,"tensor model parallel size 1"againstunified_export_megatron.py:322. test_megatron_iq_export_rejects_tensor_parallelismreaches the guard safely:_get_quantization_formatis stateless, and the raise atunified_export_megatron.py:317precedes every use of instance state theobject.__new__shell does not set.test_export_registers_packed_weight_buffersmonkeypatches the right target —moe_utils.py:77imports_export_quantized_weightinside the function, so patching theunified_export_hfmodule attribute does take effect. The(wrapper, dtype)stub signature matches the call atmoe_utils.py:218, anddel wrapper.weighton annn.Parameterfollowed byregister_buffercorrectly drives theelsebranch atmoe_utils.py:224.iq2_xs_packvalidates onnumel, not shape, so passing the unreshaped(8, 512)weight in the CUDA test is fine and matches the(16, 256)dispatch path byte-for-byte.- The
CHANGELOG.rstentry is one sentence, user-facing, and filed under the existing*Quantization*sub-section — perCLAUDE.mdguidance.
Most impactful findings
-
Four assertions across two tests are tautologies (
test_iq1_s.py:89-90,test_iq2_xs.py:76-77). Every one is a comparison of a bit-masked value against its own mask bound —(qh >> 12) & 0x7 < 8,qh & 0xFFF < 0x1000,codes & 0x1FF < 512, andcodes >> 9 < 128(max65535 >> 9 == 127). They hold for any byte payload and cannot fail. Both tests are named..._payload_fields, so they read as covering the bit layout while asserting nothing about it. -
The CUDA tests never compare against the CPU reference encoder (
test_iq1_s_cuda.py:40,test_iq2_xs_cuda.py:40).assert torch.equal(packed, dispatched)has both operands produced by the same extension —quantize_iq*takes the CUDA branch whenever the weight is on device and the extension is cached, which_extension()guarantees.iq1_s.cu/iq2_xs.cuare independent reimplementations of_encode_blocks(codebook search, fp16 scale rounding, 16-way local-scale/delta choice, lowest-index tie-break); nothing in this PR would catch them diverging from the reference. -
No IQ2_XS byte-layout test. IQ1_S gets a hand-constructed-bytes decode test; IQ2_XS — which has the harder layout (9-bit grid index + 7-bit sign per code, plus 16 four-bit scales packed two per byte) — gets only all-zero payloads and a self-consistent round trip. A transposed sign bit or swapped scale nibble would pass everything here while producing a checkpoint llama.cpp decodes wrongly, which defeats the "GGML-compatible" claim in the changelog entry.
Risk
Low for the shipped library — this PR adds no product code and cannot break existing checkpoints, recipes, or public API. The risk is in the coverage it is credited with: with findings 1-3 fixed the suite genuinely pins the GGML wire format and the CUDA/CPU encoder equivalence; as it stands, the two highest-risk parts of the stacked feature (bit packing and the hand-written kernels) are effectively unguarded, and the vacuous assertions make that gap invisible to a future reader.
Findings 1 and 2 are small, local edits. Finding 3 and the two SUGGESTIONs are additive and can reasonably follow later if you would rather land the stack — your call.
🤖 Generated with Claude Code
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
Signed-off-by: Hung-Yueh Chiang <hungyuehc@nvidia.com>
7f977a2 to
bab8456
Compare
|
Superseded by the four scoped PRs. The tests and changelog formerly in this PR now live with the code they cover:
Closing this PR without merging. |
The existing cases only proved the kernels run: any codebook entry, any bit offset, and any sign convention produce non-zero input-dependent bytes that differ per block, so a wrong index, a shifted qh field, or an inverted parity rule all passed. Add three cases per format that check the encoding itself: - test_cuda_ext_iq_encoding_is_optimal packs a random tensor, decodes the payload from the GGML field positions rather than from the kernel's own layout code, and asserts the reconstruction error equals the brute-force minimum over every local scale, delta sign, and codebook entry at the block scale the payload carries. Comparing achieved error rather than raw indices keeps it robust to the kernels' fused-multiply-add ordering reordering near-ties. This pins the payload layout, the search, and the block-wide reductions in one assertion. - test_cuda_ext_iq_input_dtype_equivalence packs values exact in every accepted dtype and requires byte-identical payloads. - test_cuda_ext_iq_non_finite_inputs_are_zeroed checks NaN and both infinities pack as zeros, and that a finite float64 outside the float32 range saturates instead -- a regression test for the narrowing fix. The codebooks are synthetic random grids rather than the GGML tables. The kernels treat the grid as an opaque argument, so this exercises the search identically while keeping the tests free of any dependency on the reference encoder or its tables, and a random grid has no ties to break. The decoder and the brute-force oracle were checked against an independent CPU packer written from the same specification, and against four injected layout bugs -- local scale off by one bit, dropped delta sign, sign mask at bit 8, swapped scale nibbles -- each of which the new assertion rejects. Parity against the PyTorch reference encoder still belongs with that encoder in #2446/#2450, and should compare dequantized error rather than bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
Summary
Scope
This is PR 5 of 5 extracted from #2381. Its diff contains tests and the changelog only. It is stacked on the recipes PR.
Validation
Row-alignment regression coverage
[512, 384]input whose total element count is divisible by 256 but whose rows are not