Skip to content

Commit e2f9cca

Browse files
Michael Norrismeta-codesync[bot]
authored andcommitted
Fix SQtqmse codebook scale: fold 1/sqrt(d) into Lloyd-Max table (#5517)
Summary: Pull Request resolved: #5517 `SQtqmse` reconstructions have inflated norms and badly degraded recall since D102408184. Reported as a 1.14.2 -> 1.14.3 regression: at `d=64`, median decoded norm of a unit-norm input went `0.9965` -> `1.1503` (+15%), while `SQtqmse8` looked "essentially unchanged". Fixes #5317 Root cause - D102408184 replaced the `tqmse` codebook construction with hardcoded Lloyd-Max tables: ``` - scalar_quantizer::train_TurboQuantMSE(d, 4, trained); + populate_lloyd_max_trained(4, trained); ``` The old codebook was built for one coordinate of a unit-norm vector in R^d, so its centroids scaled as `1/sqrt(d)`. The new tables are optimal for `N(0, 1)` and do not depend on `d` at all. That is correct for `_eden` and `_tq`, which rescale each vector to unit variance before lookup. Plain `tqmse` does not: it encodes raw unit-norm vectors, whose components are ~`1/sqrt(d)` (~`0.125` at `d=64`), against a codebook ~50x too wide. Nearly everything falls in the innermost cells, so the codebook collapses to a few levels and every component decodes too large: | d | bits | distinct codes used (before D102408184 -> after) | | --- | --- | --- | | 64 | 4 | 16/16 -> **4/16** | | 768 | 4 | 16/16 -> **2/16** | | 768 | 8 | 256/256 -> **32/256** | Gets worse as `d` grows. Fix - Give `populate_lloyd_max_trained` a `scale` argument that multiplies the table. The five `tqmse` cases pass `1/sqrt(d)` (the standard deviation of a unit-norm vector's components) so the codebook once again (like 1.14.2) matches the data it encodes. **No change for `_eden` / `_tq`.** They pass no `scale`, so it defaults to `1` and every table entry is bit-identical to today. Scaling `trained` rather than the encode path is what keeps this a one-line fix per callsite: every SIMD specialization and distance computer reads `this->centroids`, a pointer into `trained`. Notes - - NOT A REVERT TO 1.14.2, but that is fine. 1.14.2 trained on the exact unit-sphere marginal; this scales a Gaussian approximation of it, so centroids differ by up to ~20% at 8 bits. The approximation is not worse: max component error is lower than 1.14.2 and recall is restored (see test plan). The only consequence is that `tqmse` codes are not comparable across 1.14.2 / 1.14.3 / this version -- harmless, because the codebook ships with the index (below). - SERIALIZATION STILL COMPATIBLE: Existing 1.14.3-written `tqmse` indexes stay readable and self-consistent: `read_ScalarQuantizer` loads `trained` verbatim, so decode matches how they were encoded, and `trained` length is unchanged so size validation still passes. They are degraded, not corrupt, and need re-indexing to benefit. No version guard added. Reviewed By: alibeklfc Differential Revision: D115440646 fbshipit-source-id: a559f6a6a50a65dc3ab32ff3f17cda8796b2d4ae
1 parent a424dcb commit e2f9cca

2 files changed

Lines changed: 66 additions & 8 deletions

File tree

faiss/impl/ScalarQuantizer.cpp

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
// -*- c++ -*-
99

10+
#include <cmath>
1011
#include <cstring>
1112
#include <memory>
1213

@@ -425,14 +426,29 @@ const LloydMaxTable kLloydMaxTables[] = {
425426
{kLloydMaxCentroids8, kLloydMaxBoundaries8}, // 8
426427
};
427428

428-
void populate_lloyd_max_trained(size_t mse_bits, std::vector<float>& trained) {
429+
// The tables are Lloyd-Max optimal for N(0, 1) input. Callers whose input has
430+
// a different standard deviation pass it as `scale` to stretch the table.
431+
void populate_lloyd_max_trained(
432+
size_t mse_bits,
433+
std::vector<float>& trained,
434+
float scale = 1.0f) {
429435
FAISS_THROW_IF_NOT(mse_bits >= 1 && mse_bits <= 8);
430436
FAISS_THROW_IF_NOT(kLloydMaxTables[mse_bits].centroids);
431437
size_t k = size_t(1) << mse_bits;
432438
const auto& t = kLloydMaxTables[mse_bits];
433439
trained.resize(k + (k - 1));
434-
std::copy(t.centroids, t.centroids + k, trained.begin());
435-
std::copy(t.boundaries, t.boundaries + k - 1, trained.begin() + k);
440+
for (size_t i = 0; i < k; i++) {
441+
trained[i] = t.centroids[i] * scale;
442+
}
443+
for (size_t i = 0; i + 1 < k; i++) {
444+
trained[k + i] = t.boundaries[i] * scale;
445+
}
446+
}
447+
448+
// Component scale of a unit-norm vector in R^d.
449+
float unit_norm_component_scale(size_t d) {
450+
FAISS_THROW_IF_NOT(d > 0);
451+
return 1.0f / std::sqrt(static_cast<float>(d));
436452
}
437453

438454
} // namespace
@@ -588,19 +604,24 @@ void ScalarQuantizer::train(size_t n, const float* x) {
588604
populate_lloyd_max_trained(bits, trained);
589605
break;
590606
case QT_1bit_tqmse:
591-
populate_lloyd_max_trained(1, trained);
607+
populate_lloyd_max_trained(
608+
1, trained, unit_norm_component_scale(d));
592609
break;
593610
case QT_2bit_tqmse:
594-
populate_lloyd_max_trained(2, trained);
611+
populate_lloyd_max_trained(
612+
2, trained, unit_norm_component_scale(d));
595613
break;
596614
case QT_3bit_tqmse:
597-
populate_lloyd_max_trained(3, trained);
615+
populate_lloyd_max_trained(
616+
3, trained, unit_norm_component_scale(d));
598617
break;
599618
case QT_4bit_tqmse:
600-
populate_lloyd_max_trained(4, trained);
619+
populate_lloyd_max_trained(
620+
4, trained, unit_norm_component_scale(d));
601621
break;
602622
case QT_8bit_tqmse:
603-
populate_lloyd_max_trained(8, trained);
623+
populate_lloyd_max_trained(
624+
8, trained, unit_norm_component_scale(d));
604625
break;
605626
case QT_2bit_tq:
606627
case QT_3bit_tq:

tests/test_scalar_quantizer_correctness.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,43 @@ def test_tqmse_8bit(self):
6767
faiss.normalize_L2(self.xb)
6868
self.do_encode_decode(faiss.ScalarQuantizer.QT_8bit_tqmse, 0.1)
6969

70+
def test_tqmse_preserves_norm(self):
71+
"""tqmse encodes unit-norm vectors with no per-vector scale factor in
72+
the code, so reconstructions must keep unit norm. The Lloyd-Max table
73+
is optimal for unit *variance*, so it only lines up with unit-norm
74+
input once scaled by 1/sqrt(d) -- without that, every component lands
75+
in the innermost cells and the codebook collapses toward a sign
76+
quantizer, inflating norms by 15% at d=64 and 258% at d=768."""
77+
for qtype, nbits in (
78+
(faiss.ScalarQuantizer.QT_1bit_tqmse, 1),
79+
(faiss.ScalarQuantizer.QT_2bit_tqmse, 2),
80+
(faiss.ScalarQuantizer.QT_3bit_tqmse, 3),
81+
(faiss.ScalarQuantizer.QT_4bit_tqmse, 4),
82+
(faiss.ScalarQuantizer.QT_8bit_tqmse, 8),
83+
):
84+
for d in (32, 64, 768):
85+
with self.subTest(nbits=nbits, d=d):
86+
rs = np.random.RandomState(123)
87+
x = rs.randn(200, d).astype("float32")
88+
faiss.normalize_L2(x)
89+
90+
sq = faiss.ScalarQuantizer(d, qtype)
91+
sq.train(x)
92+
decoded = sq.decode(sq.compute_codes(x))
93+
norms = np.linalg.norm(decoded, axis=1)
94+
95+
# 1-bit cannot resolve magnitude at all (every component
96+
# reconstructs to +/-c), so it gets a looser band.
97+
tol = 0.25 if nbits == 1 else 0.1
98+
self.assertLess(abs(np.median(norms) - 1.0), tol)
99+
100+
# Guard the collapse directly: a codebook matched to the
101+
# data reconstructs most of its 2^nbits distinct levels.
102+
# This is what catches 8-bit, whose norm stays near 1 even
103+
# when collapsed (31 of 256 levels used at d=768).
104+
used = len(np.unique(decoded))
105+
self.assertGreaterEqual(used, 2**nbits // 2)
106+
70107
def test_codes_match_none(self):
71108
"""SQ codes are integer; encode dispatch (sq-dispatch.h) must
72109
produce bit-identical output at every SIMD level. Catches drift in

0 commit comments

Comments
 (0)