[MOD-17528] Make the SQ8 quantized byte conversion defined for all finite input - #1015
Conversation
Codecov Report❌ Patch coverage is Please upload reports for the commit f959a60 to get more accurate results.
Additional details and impacted files@@ Coverage Diff @@
## dor-forer-MOD-17527-uint8-accumulators #1015 +/- ##
==========================================================================
- Coverage 97.19% 97.18% -0.01%
==========================================================================
Files 142 142
Lines 8381 8392 +11
==========================================================================
+ Hits 8146 8156 +10
- Misses 235 236 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
9e0ec40 to
6c457c2
Compare
92324fd to
d247ff2
Compare
cb9b002 to
8625f27
Compare
5ce042d to
d34b5e0
Compare
|
On the codecov annotation: the one uncovered line is the NaN fallback in if (!(min_val <= max_val)) {
return {0.0f, 0.0f};
}It is unreachable for supported input. Both branches above produce Reaching it therefore requires The guard stays because deleting it is worse: Both codecov statuses pass (patch 95.83% against a 5% threshold, project -0.01% against 1%), so nothing is blocked. Noted in the code so it does not get re-filed or "fixed" by removing the guard. The real fix is rejecting non-finite components at the public ingestion boundary, which nothing in VecSim does today and which is filed separately. |
d34b5e0 to
caa3deb
Compare
caa3deb to
86109f2
Compare
86109f2 to
2c2229f
Compare
2c2229f to
b9050d3
Compare
b9050d3 to
bc10ef1
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bc10ef1. Configure here.
2c7d0db to
36a1a6f
Compare
0f5fb77 to
bb809d4
Compare
…nput
QuantPreprocessor::quantize could execute undefined behaviour for input the
API accepts. Three paths, all ending at the same conversion to a byte, and
all reachable with entirely finite components.
The scale was derived in FP32, so for [-FLT_MAX, +FLT_MAX] the range
overflowed: max - min became inf, delta inf, inv_delta 0, and the
per-element product inf * 0 = NaN, whose conversion to an integer is
undefined. UBSan: "-nan is outside the range of representable values of
type 'unsigned char'". The existing diff == 0 guard covers equal values,
not overflow of the subtraction. (MOD-17528)
With WithNorm, centering is an FP32 subtraction, so finite input against a
finite mean reaches inf before any range is computed: FLT_MAX against mean
-FLT_MAX centers to 6.8e38. Widening the range does not help, because the
value is already lost upstream.
And delta is stored as FP32, so (float)(diff / 255) underflows to zero
while diff itself is nonzero, which leaves 1/delta inf and scales the
minimum element, numerator exactly zero, to 0 * inf.
find_min_max now guarantees that both endpoints are finite and ordered, and
everything else follows from that. The guarantee lives there rather than at
the call site because that is where it can be broken: the WithNorm branch
creates the inf itself, from two valid operands, and the plain branch passes
through whatever the input holds. Both endpoints get a two-sided clamp,
since for an all-+inf vector inf <= inf passes the order check and a
one-sided std::max would leave min at +inf. Clamping to the float range is
not merely defensive either: min is stored as an FP32 field, so a non-finite
endpoint could not be represented under any arithmetic.
With that established, quantize needs one delta comparison, and inv_delta
stays double. Not for precision, which needs only +/-0.5 in 255, but because
an FP32 reciprocal overflows for a subnormal delta: [0, 7e-37] gives delta
2.7e-39, whose FP64 reciprocal is finite and correctly maps the top element
to 255.
The per-element bound and rounding are written by hand. That replaces
std::round, an out-of-line libm call at this translation unit's baseline
that ran once per element: bounding first makes adding 0.5 and truncating
equivalent for non-negative values. Measured at -O3 it is 8 instructions
against 9 for std::clamp plus std::round's call.
Scope, stated precisely, because it is narrower than it might look:
* The byte conversion is defined for all finite components. That is the
goal and it is met.
* The stored min and delta are finite with delta positive. The *sums* are
not covered: they are accumulated in FP32 over the input values, so
[-FLT_MAX, +FLT_MAX] stores sum_squares as inf even though this
function's own arithmetic is now well defined. Separate problem,
separate change.
* Non-finite components are unsupported, and nothing here is tested
against them. std::minmax_element requires its comparison to induce a
strict weak ordering, and floating-point < is not one once a NaN is
present: incomparability must be transitive, yet 1.0 is incomparable
with NaN and NaN with 2.0 while 1.0 and 2.0 are comparable. Violating
that precondition is undefined behaviour inside the algorithm, before
any range exists, so no assertion about the outcome would be portable,
including a weak one about metadata finiteness. UBSan reporting nothing
does not establish otherwise. Rejecting non-finite components at the
public ingestion boundary is the actual fix and belongs in its own
change; nothing in VecSim does it today.
The order check on the endpoints stays, as an invariant guard rather than a
NaN policy: everything downstream is written assuming min <= max, and
asserting that once per vector costs less than reasoning about whether it
can be violated.
This matches how comparable systems handle it. Lucene validates vector
components and throws on NaN or infinity, and Elasticsearch rejects NaN,
infinity and magnitudes that overflow before delegating to Lucene. Faiss
guards only an exactly zero range and assumes finite input otherwise, with
the same float-to-integer concern at its final cast. Qdrant gets defined
bytes from Rust's saturating cast, which C++ does not have, and can still
store an unusable scale.
Tests: the MOD-17528 reproduction, the WithNorm centering case, and a
table-driven matrix over finite input only: constant vectors positive
negative and zero, a single element, a subnormal but representable delta, a
range that underflows and collapses, and the full FP32 range. Expected bytes
and scale metadata are asserted rather than a range check, which is vacuous
for uint8_t, and every expectation was derived by simulating the pipeline
rather than predicted. The parameterized test is named for what it checks,
ScaleMetadataAndBytesAreAsExpected, since the sums are out of scope.
Metadata meaning is unchanged: the sums are still FP32 over the input
values. Making them exact integer sums over the quantized bytes is a
storage-contract change that has to move together with every kernel that
reads them, and stays in #1011.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bb809d4 to
40659e8
Compare
Cursor flagged this on the PR and it is correct. transformed_value clamps a centered storage component to the representable FP32 range, but preprocessQuery centered by hand with no clamp, so for input this PR claims to support the two paths disagreed: storage held FLT_MAX where the query held inf. The query metadata is derived from those components, so a single overflowing component poisons every distance computed against that query, and asymmetric L2 could return NaN from 0 * inf in the quantized dot product. Reachable from entirely finite input: FLT_MAX centered by a mean of -FLT_MAX overflows FP32. The query loop now calls transformed_value rather than repeating the centering, so the two paths share one transform instead of two copies that have to agree. The regression test asserts each query component equals the input centered and clamped, and that the query metadata is not NaN. Verified both ways: with the fix it passes, and with the centering reverted it fails reporting component 0 as inf and component 2 as -inf. It deliberately does not assert that reconstruction stays finite. min + delta * byte overflows FP32 for a full-range vector, since delta is about 2.7e36 and delta * 255 exceeds FLT_MAX. That is a property of the quantization scheme at the extreme rather than of this fix, and is the same reason the neighbouring non-representable tests leave the sums unchecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9a8ab5f committed the comment and the regression test for this, but not the code change: the query loop was still centering by hand with no clamp. CI caught it, which is the test doing its job. The cause was a bad verification on my part. After an interrupted mutation test I checked whether the fix was present with a file-wide grep for `transformed_value(input, i)`, a string that also appears five times in the storage path, so it matched and reported the fix as present while the query loop was untouched. Verified this time by inspecting the query loop body specifically, and by building and running the test: 17/17 Quantization tests pass, where 9a8ab5f failed on lines 1434, 1445 and 1457. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Describe the changes in the pull request
QuantPreprocessor::quantizecould execute undefined behaviour for input the API accepts. Three paths, all ending at the same conversion to a byte, and all reachable with entirely finite components.The scale was derived in FP32, so for
[-FLT_MAX, +FLT_MAX]the range overflowed:max - minbecameinf,deltainf,inv_delta0, and the per-element productinf * 0 = NaN, whose conversion to an integer is undefined. UBSan:-nan is outside the range of representable values of type 'unsigned char'. The existingdiff == 0guard covers equal values, not overflow of the subtraction.With
WithNorm, centring is an FP32 subtraction, so finite input against a finite mean reachesinfbefore any range is computed:FLT_MAXagainst mean-FLT_MAXcentres to6.8e38. Widening the range does not help, because the value is already lost upstream.And
deltais stored as FP32, so(float)(diff / 255)underflows to0whilediffitself is nonzero, leaving1/deltainfand scaling the minimum element, numerator exactly zero, to0 * inf.A fourth path, raised by Cursor on this PR:
transformed_valueclamps a centered storage component, butpreprocessQuerycentered by hand with no clamp, so storage heldFLT_MAXwhere the query heldinf. The query metadata derives from those components, so one overflowing component poisons every distance computed against that query and asymmetric L2 could returnNaNfrom0 * inf. The query loop now callstransformed_value, so both paths share one transform rather than two copies that have to agree.Stack position 2 of 3, base
dor-forer-MOD-17527-uint8-accumulators(#1014). Review the top two commits; GitHub retargets this tomainwhen #1014 merges.Which issues this PR fixes
deltaunderflow).preprocessors.hmoves here, so the fixes and their tests live in this PR.Main objects this PR modified
QuantPreprocessor::find_min_max— now carries the postcondition that both endpoints are finite and ordered.QuantPreprocessor::transformed_value— applies the same clamp, so the postcondition holds for every element and not only the endpoints.QuantPreprocessor::quantize—doublerange, onedeltaguard, and ato_bytethat saturates withoutstd::round.QuantPreprocessor::preprocessQuery: centers throughtransformed_value, so the query carries the same clamp as storage.tests/unit/test_components.cpp.Mark if applicable
The fix
All three paths close by giving
find_min_maxa postcondition — both endpoints finite,min <= max— established where it can be broken rather than cleaned up at the call site. TheWithNormbranch creates theinfitself from two valid operands; the plain branch passes through whatever the input holds.Two-sided on both endpoints, deliberately: for an all-
+infvectorinf <= infpasses the order check, so a one-sidedstd::maxwould leaveminat+infand store it.minis an FP32 field, so a non-finite endpoint could not be represented under any arithmetic — this is the storage limit as much as a guard.transformed_valueapplies the same clamp (Cursor Bugbot, this PR). Clamping only the endpoints left elements outside them: when every centred value overflows the same way, both endpoints collapse to one clamped value — a constant range — but the element was still±inf, scaled to±inf, and saturated:A non-constant code for a constant range, and asymmetric between the two signs, reachable from finite input against a finite mean. The bound is a single shared
representable_floatmember so the two clamps cannot drift apart, which was the underlying fragility.Why
inv_deltastaysdoubleNot for precision — the byte only needs ±0.5 in 255, where FP32 gives
6e-8. It is because an FP32 reciprocal overflows for a subnormaldelta:[0, 7e-37]givesdelta = 2.7e-39, whose FP64 reciprocal3.6e38is finite and correctly maps the top element to exactly255.000. Pinned by thesubnormal_delta_survivescase.The per-element bound
-O3)std::clamp+std::roundfmin(fmax(...))+std::roundBounding first makes
+0.5and truncation equivalent tostd::roundfor non-negative values, which these are.std::roundis an out-of-line libm call at this translation unit's baseline and ran once per element.Scope, stated precisely
minanddeltaare finite withdelta > 0. The sums are not covered — accumulated in FP32 over the input values, so[-FLT_MAX, +FLT_MAX]storessum_squaresasinfeven though this function's own arithmetic is now well defined. Separate problem, separate change. The parameterized test is namedScaleMetadataAndBytesAreAsExpectedfor that reason.std::minmax_elementrequires its comparison to induce a strict weak ordering, and floating-point<is not one once aNaNis present: incomparability must be transitive, yet1.0is incomparable withNaNandNaNwith2.0while1.0and2.0are comparable. Violating that precondition is undefined behaviour inside the algorithm, before any range exists, so no assertion about the outcome would be portable — including a weak one about metadata finiteness, and UBSan reporting nothing does not establish otherwise. An earlier revision had such a test; it was removed.The order check on the endpoints remains as an invariant guard, not a NaN policy: everything downstream assumes
min <= max, and asserting it once per vector costs less than proving it cannot be violated. It also means aNaNendpoint cannot be stored, so a caller error degrades to meaningless-but-finite metadata rather than poisoning every distance against that vector.Rejecting non-finite components at the public ingestion boundary is the actual fix and is filed separately. Nothing in VecSim does it today —
grep -niE "isfinite|isnan"overvec_sim.cppandvec_sim_index.his empty.How comparable systems handle it
vdiff != 0None silently quantizes non-finite input, which is why this PR does not either.
Tests
The MOD-17528 reproduction, the
WithNormcentring case,QuantizationCollapsedCenteredRangeIsConstantAndSymmetric(both signs, so the asymmetry above cannot return), and a table-driven matrix over finite input only: constant vectors positive/negative/zero, a single element, a subnormal but representabledelta, a range that underflows and collapses, and the full FP32 range.Expected bytes and scale metadata are asserted rather than a range check, which is vacuous for
uint8_t. Every expectation was derived by simulating the pipeline, not predicted — which corrected three of them, including that the full-range midpoint is 128, not 127, because127.5 + 0.5truncates up.Known coverage gap
One uncovered line: the NaN fallback
return {0.0f, 0.0f};. It is unreachable for supported input — both branches producemin <= maxby construction for finite values — so reaching it requires the NaN input that makes thestd::minmax_elementcall undefined in the first place. Deleting the guard is worse:std::clamppropagates NaN, sominwould be stored as NaN. Both codecov statuses pass (patch 95.83% against a 5% threshold). Noted in the code so it is not re-filed or "fixed" by removing the guard.Verification
-fsyntax-only -Wall -Wextraclean onQuantPreprocessor<float, L2, false>,<float, IP, true>and<float16, L2, false>;check-format.shclean. Moving the normalization intofind_min_maxalso reduced codegen:<float, IP, true>231 → 222 instructions,<float16, L2, false>327 → 304,<float, L2, false>unchanged.Not built or executed locally. CI is the first real check, and the UBSan job is the one that matters here.
🤖 Generated with Claude Code
Note
Medium Risk
Touches the hot
preprocessForStorage/preprocessQuerypath for SQ8 indices on every add and search, but changes are narrowly scoped to numerical edge cases and align query with storage rather than altering the public API.Overview
Fixes undefined behavior and NaN-prone SQ8 preprocessing on vector add/query paths for inputs the API already accepts (including
±FLT_MAXand normalized L2 centering).QuantPreprocessor::quantizenow computes range and scale in double, forces a positivedeltawhen FP32 would underflow, uses ato_bytesaturating round (nostd::round/inf * 0), and documents that only finite components are supported whilemin/deltamust stay finite (sum metadata overflow is explicitly out of scope).find_min_maxandtransformed_valueshare anFLT_MAXclamp so every centered element stays within the stored range;preprocessQueryfor normalized L2 reusestransformed_valueso query bodies match storage instead of leaving±infthat broke asymmetric L2.Unit tests add UBSan-focused regressions (full FP32 range, subnormal delta, collapsed centered ranges, query/storage clamp parity) plus a parameterized finite-input matrix for expected bytes and scale metadata.
Reviewed by Cursor Bugbot for commit f959a60. Bugbot is set up for automated code reviews on this repo. Configure here.