Skip to content

Commit 4271915

Browse files
fix(quantizer): add dimension guards for clear errors on GQA shape mismatch (#69)
Previously, passing a tensor whose last dimension did not match the quantizer's configured dim produced an opaque `RuntimeError` from PyTorch's reshape. This surfaced with Gemma 4 E4B on transformers 5.x, where GQA expansion changed the KV tensor shape before cache update. - Add `ValueError` guards to `quantize()`, `dequantize()`, and `estimate_inner_product()` that check `shape[-1] == self.dim` with actionable error messages - Add `TestDimMismatchGuard` (4 tests) and `TestGQAShapes` (5 tests) covering both MSE and Prod quantizers with expanded head counts - Extract `TestHFTokenPassthrough` to `tests/test_verify_token.py` (test maturity: split oversized test file by concern) Test: `uv run pytest tests/test_quantizer.py tests/test_verify_token.py -v` Closes #67
1 parent bee2071 commit 4271915

4 files changed

Lines changed: 172 additions & 32 deletions

File tree

src/turboquant_vllm/quantizer.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,18 @@ def quantize(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
121121
Returns:
122122
Tuple of (indices, norms) where indices is a long tensor of
123123
shape (..., dim) and norms is a float tensor of shape (..., 1).
124+
125+
Raises:
126+
ValueError: If ``x.shape[-1] != self.dim``.
124127
"""
128+
if x.shape[-1] != self.dim:
129+
msg = (
130+
f"Input last dimension {x.shape[-1]} does not match "
131+
f"quantizer dim {self.dim}. Ensure the compressor's "
132+
f"head_dim matches the model's actual head dimension."
133+
)
134+
raise ValueError(msg)
135+
125136
# Store original shape and flatten to 2D
126137
orig_shape = x.shape
127138
flat = x.reshape(-1, self.dim).float()
@@ -151,7 +162,18 @@ def dequantize(self, indices: torch.Tensor, norms: torch.Tensor) -> torch.Tensor
151162
152163
Returns:
153164
Reconstructed float tensor of shape (..., dim).
165+
166+
Raises:
167+
ValueError: If ``indices.shape[-1] != self.dim``.
154168
"""
169+
if indices.shape[-1] != self.dim:
170+
msg = (
171+
f"Indices last dimension {indices.shape[-1]} does not match "
172+
f"quantizer dim {self.dim}. Check that the same quantizer "
173+
f"instance is used for quantize and dequantize."
174+
)
175+
raise ValueError(msg)
176+
155177
orig_shape = indices.shape
156178
flat_idx = indices.reshape(-1, self.dim)
157179
flat_norms = norms.reshape(-1, 1)
@@ -316,7 +338,18 @@ def estimate_inner_product(
316338
Returns:
317339
Inner product estimates, shape matching broadcast of query and key
318340
batch dimensions.
341+
342+
Raises:
343+
ValueError: If ``query.shape[-1] != self.dim``.
319344
"""
345+
if query.shape[-1] != self.dim:
346+
msg = (
347+
f"Query last dimension {query.shape[-1]} does not match "
348+
f"quantizer dim {self.dim}. Ensure the compressor's "
349+
f"head_dim matches the model's actual head dimension."
350+
)
351+
raise ValueError(msg)
352+
320353
# MSE component: <q, k_mse>
321354
k_mse = self.mse_quantizer.dequantize(indices, norms)
322355
mse_term = (query.float() * k_mse).sum(dim=-1, keepdim=True)

tests/test_quantizer.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,98 @@ def test_zero_vector_inner_product(
175175
query, indices, norms, qjl_signs, res_norms
176176
)
177177
assert abs(estimated.item()) < 0.01
178+
179+
180+
@pytest.mark.unit
181+
class TestDimMismatchGuard:
182+
"""Validate clear errors when tensor last dim != quantizer dim (GH-67)."""
183+
184+
def test_quantize_wrong_last_dim_raises_valueerror(self) -> None:
185+
"""Feeding shape[-1] != self.dim should raise ValueError, not RuntimeError."""
186+
q = TurboQuantMSE(128, bits=4)
187+
x = torch.randn(1, 2, 10, 256) # last dim 256 != quantizer dim 128
188+
with pytest.raises(ValueError, match="last dimension"):
189+
q.quantize(x)
190+
191+
def test_dequantize_wrong_last_dim_raises_valueerror(self) -> None:
192+
"""Dequantize with indices whose last dim != self.dim should raise ValueError."""
193+
q = TurboQuantMSE(128, bits=4)
194+
indices = torch.zeros(1, 2, 10, 256, dtype=torch.long)
195+
norms = torch.ones(1, 2, 10, 1)
196+
with pytest.raises(ValueError, match="last dimension"):
197+
q.dequantize(indices, norms)
198+
199+
def test_prod_quantize_wrong_dim_raises_valueerror(self) -> None:
200+
"""TurboQuantProd should surface ValueError from inner MSE quantizer."""
201+
q = TurboQuantProd(128, bits=4)
202+
x = torch.randn(1, 2, 10, 256)
203+
with pytest.raises(ValueError, match="last dimension"):
204+
q.quantize(x)
205+
206+
def test_estimate_inner_product_wrong_query_dim_raises_valueerror(self) -> None:
207+
"""Query with shape[-1] != self.dim should raise ValueError."""
208+
q = TurboQuantProd(128, bits=4)
209+
keys = torch.randn(1, 2, 10, 128)
210+
indices, norms, signs, res_norms = q.quantize(keys)
211+
bad_query = torch.randn(1, 2, 10, 256) # wrong last dim
212+
with pytest.raises(ValueError, match="last dimension"):
213+
q.estimate_inner_product(bad_query, indices, norms, signs, res_norms)
214+
215+
216+
@pytest.mark.unit
217+
class TestGQAShapes:
218+
"""Validate quantizer handles GQA-expanded tensor shapes (GH-67).
219+
220+
GQA models may have different numbers of KV heads vs attention heads.
221+
The quantizer must handle any leading dimensions as long as the last
222+
dimension matches self.dim.
223+
"""
224+
225+
def test_quantize_gqa_expanded_shape(self) -> None:
226+
"""GQA-expanded tensor (batch, expanded_heads, seq, dim) should work."""
227+
q = TurboQuantMSE(DIM, bits=4)
228+
# Simulate GQA expansion: 2 KV heads expanded to 8 attention heads
229+
x = torch.randn(1, 8, 32, DIM)
230+
indices, norms = q.quantize(x)
231+
assert indices.shape == (1, 8, 32, DIM)
232+
assert norms.shape == (1, 8, 32, 1)
233+
234+
def test_quantize_unexpanded_kv_shape(self) -> None:
235+
"""Unexpanded KV tensor (batch, kv_heads, seq, dim) should work."""
236+
q = TurboQuantMSE(DIM, bits=4)
237+
x = torch.randn(1, 2, 32, DIM)
238+
indices, norms = q.quantize(x)
239+
assert indices.shape == (1, 2, 32, DIM)
240+
assert norms.shape == (1, 2, 32, 1)
241+
242+
def test_roundtrip_gqa_expanded_quality(self) -> None:
243+
"""Round-trip through GQA-expanded shape preserves cosine quality."""
244+
q = TurboQuantMSE(DIM, bits=4)
245+
x = torch.randn(1, 8, 32, DIM)
246+
indices, norms = q.quantize(x)
247+
reconstructed = q.dequantize(indices, norms)
248+
assert reconstructed.shape == x.shape
249+
cos = torch.nn.functional.cosine_similarity(
250+
x.flatten().float(), reconstructed.flatten().float(), dim=0
251+
).item()
252+
assert cos > 0.80, f"Round-trip cosine {cos:.4f} below 0.80 threshold"
253+
254+
def test_quantize_head_dim_256_gqa(self) -> None:
255+
"""Head dim 256 with GQA-expanded heads (Gemma 4 E4B scenario)."""
256+
q = TurboQuantMSE(256, bits=4)
257+
x = torch.randn(1, 8, 16, 256) # 8 expanded heads, head_dim=256
258+
indices, norms = q.quantize(x)
259+
assert indices.shape == (1, 8, 16, 256)
260+
assert norms.shape == (1, 8, 16, 1)
261+
reconstructed = q.dequantize(indices, norms)
262+
assert reconstructed.shape == x.shape
263+
264+
def test_prod_quantize_gqa_expanded(self) -> None:
265+
"""TurboQuantProd handles GQA-expanded shapes (key compressor path)."""
266+
q = TurboQuantProd(DIM, bits=4)
267+
x = torch.randn(1, 8, 32, DIM)
268+
indices, norms, signs, res_norms = q.quantize(x)
269+
assert indices.shape == (1, 8, 32, DIM)
270+
assert norms.shape == (1, 8, 32, 1)
271+
assert signs.shape == (1, 8, 32, DIM)
272+
assert res_norms.shape == (1, 8, 32, 1)

tests/test_verify.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -479,35 +479,4 @@ def test_run_verification_molmo2_passes(self) -> None:
479479
torch.cuda.empty_cache()
480480

481481

482-
class TestHFTokenPassthrough:
483-
"""Verify that HF_TOKEN is forwarded to all from_pretrained calls."""
484-
485-
def test_token_passed_to_auto_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
486-
"""AutoConfig.from_pretrained receives token= from HF_TOKEN env var."""
487-
monkeypatch.setenv("HF_TOKEN", "hf_test_sentinel")
488-
captured: dict[str, object] = {}
489-
490-
def spy(*args: object, **kwargs: object) -> object:
491-
captured["token"] = kwargs.get("token")
492-
raise RuntimeError("stop after capture")
493-
494-
monkeypatch.setattr("transformers.AutoConfig.from_pretrained", spy)
495-
with pytest.raises(RuntimeError, match="stop after capture"):
496-
_run_verification("fake/model", bits=4, threshold=0.99)
497-
498-
assert captured["token"] == "hf_test_sentinel"
499-
500-
def test_token_none_when_env_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
501-
"""token= is None when HF_TOKEN is not in the environment."""
502-
monkeypatch.delenv("HF_TOKEN", raising=False)
503-
captured: dict[str, object] = {}
504-
505-
def spy(*args: object, **kwargs: object) -> object:
506-
captured["token"] = kwargs.get("token")
507-
raise RuntimeError("stop after capture")
508-
509-
monkeypatch.setattr("transformers.AutoConfig.from_pretrained", spy)
510-
with pytest.raises(RuntimeError, match="stop after capture"):
511-
_run_verification("fake/model", bits=4, threshold=0.99)
512-
513-
assert captured["token"] is None
482+
# TestHFTokenPassthrough extracted to test_verify_token.py (GH-67 test maturity)

tests/test_verify_token.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Tests for HF_TOKEN passthrough in verify CLI (extracted from test_verify.py)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from turboquant_vllm.verify import _run_verification
8+
9+
pytestmark = [pytest.mark.unit]
10+
11+
12+
class TestHFTokenPassthrough:
13+
"""Verify that HF_TOKEN is forwarded to all from_pretrained calls."""
14+
15+
def test_token_passed_to_auto_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
16+
"""AutoConfig.from_pretrained receives token= from HF_TOKEN env var."""
17+
monkeypatch.setenv("HF_TOKEN", "hf_test_sentinel")
18+
captured: dict[str, object] = {}
19+
20+
def spy(*args: object, **kwargs: object) -> object:
21+
captured["token"] = kwargs.get("token")
22+
raise RuntimeError("stop after capture")
23+
24+
monkeypatch.setattr("transformers.AutoConfig.from_pretrained", spy)
25+
with pytest.raises(RuntimeError, match="stop after capture"):
26+
_run_verification("fake/model", bits=4, threshold=0.99)
27+
28+
assert captured["token"] == "hf_test_sentinel"
29+
30+
def test_token_none_when_env_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
31+
"""token= is None when HF_TOKEN is not in the environment."""
32+
monkeypatch.delenv("HF_TOKEN", raising=False)
33+
captured: dict[str, object] = {}
34+
35+
def spy(*args: object, **kwargs: object) -> object:
36+
captured["token"] = kwargs.get("token")
37+
raise RuntimeError("stop after capture")
38+
39+
monkeypatch.setattr("transformers.AutoConfig.from_pretrained", spy)
40+
with pytest.raises(RuntimeError, match="stop after capture"):
41+
_run_verification("fake/model", bits=4, threshold=0.99)
42+
43+
assert captured["token"] is None

0 commit comments

Comments
 (0)