[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 61c4e90 to get more accurate results.
Additional details and impacted files@@ Coverage Diff @@
## dor-forer-MOD-17527-uint8-accumulators #1015 +/- ##
=========================================================================
Coverage ? 97.16%
=========================================================================
Files ? 141
Lines ? 8385
Branches ? 0
=========================================================================
Hits ? 8147
Misses ? 238
Partials ? 0 ☔ 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.
| float min_val, max_val; | ||
| if constexpr (!WithNorm) { | ||
| auto [min_it, max_it] = std::minmax_element(input, input + dim); | ||
| return {to_fp32<DataType>(*min_it), to_fp32<DataType>(*max_it)}; |
There was a problem hiding this comment.
Query path skips centering clamp
Medium Severity
transformed_value now clamps centered storage values to ±representable_float, but preprocessQuery for WithNorm L2 still centers without that clamp. For the same finite overflow cases this PR treats as supported, storage becomes clamped while the query body and its metadata can stay ±inf, so asymmetric L2 can yield NaN distances (for example via 0 * inf in the quantized dot product).
Additional Locations (1)
Reviewed by Cursor Bugbot for commit bc10ef1. Configure here.
bc10ef1 to
6d9e1ba
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>
6d9e1ba to
61c4e90
Compare


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.Stack position 2 of 3, base
dor-forer-MOD-17527-uint8-accumulators(#1014). Review only the top commit; 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.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 hot-path vector ingestion quantization used by distance kernels; behavior changes for extreme finite ranges but removes UB and keeps metadata contract (finite min/delta) for supported input.
Overview
Fixes undefined behavior in SQ8 storage quantization for finite vectors the API already accepts—previously reachable on
AddVectorvia UBSan (e.g.NaN→unsigned char).QuantPreprocessor::quantizenow computes range in double (max - minno longer overflows for±FLT_MAX), guardsdeltawhen FP32 underflow would make it zero, uses a doubleinv_deltafor subnormal scales, and maps values with ato_bytesaturating path instead ofstd::roundon possibly-NaN products.find_min_maxdocuments and enforces finite ordered endpoints (clamp toFLT_MAX, invariant check);transformed_valueapplies the same clamp underWithNormso centered±infcannot yield asymmetric non-constant byte patterns when the range collapses.Tests add UBSan-focused cases (full float range, centered extremes, collapsed centered range symmetry) plus a parameterized finite-input matrix for scale metadata and bytes. Sum metadata overflow is explicitly out of scope.
Reviewed by Cursor Bugbot for commit 61c4e90. Bugbot is set up for automated code reviews on this repo. Configure here.