fix(index): accumulate k-means centroids in a wider float - #8608
Draft
FANNG1 wants to merge 1 commit into
Draft
Conversation
FANNG1
marked this pull request as draft
August 18, 2026 02:15
FANNG1
force-pushed
the
fix/kmeans-f16-centroid-overflow
branch
2 times, most recently
from
August 19, 2026 02:54
08bff12 to
4392b3a
Compare
Building any IVF index over a float16 vector column hangs forever at 100% CPU once a cluster's values sum past the f16 range. The centroid update accumulated into the storage type, so a cluster of ~256 vectors with values around 1000 summed to `inf`, and a cluster larger than 65504 saturated its own size, making the reciprocal 0. Either way the centroid stopped being finite, every distance to it became non-finite, `argmin` assigned nothing, and every cluster came out empty. `split_clusters` then spun forever: its donor search drew against `p = (cnt - 1) / (n - k)`, which is negative when every count is zero, so the unbounded loop could never break. Three changes, each of which is sufficient to avoid the hang on its own: - `to_kmeans` sums, divides and perturbs in `f64` and narrows back to the storage type once, at the end. f64 holds every sum an f16, f32 or f64 cluster can produce. Narrowing clamps to the storage type's maximum, because the split perturbation scales a centroid by `1 + 1/1024` and would otherwise round a top-of-range value to `inf`. - `split_clusters` takes donors largest-first from a heap instead of by unbounded probabilistic search, and returns once no cluster has more than one vector to donate. Donor choice is now deterministic. - `train_kmeans` rejects training data where fewer than `k` vectors can be assigned to any centroid, which is exactly the input set that used to hang, rather than continuing with an unrepairable model. PQ codebook training reaches the same centroid update, so IVF_PQ with the dot metric -- which skips residuals and therefore quantizes the raw vectors -- hung for the same reason and is fixed by the same change. No public signature changes: the accumulator is a private implementation detail and the update uses only bounds `KMeansAlgoFloat` already required. Claude-Session: https://claude.ai/code/session_01HkgaggGvsdGuUGgnwEgKke
FANNG1
force-pushed
the
fix/kmeans-f16-centroid-overflow
branch
from
August 19, 2026 08:59
4392b3a to
638e071
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8607.
Building any IVF index over a float16 vector column hangs forever at 100% CPU once a cluster's values sum past the f16 range. The issue has the reproduction and the full chain; the short version is that the centroid update accumulated in the storage type, non-finite centroids made
argminassign nothing, andsplit_clustersthen spun in an unbounded loop that could never satisfy its exit condition.Changes
Three changes, each sufficient on its own to prevent the hang. They are kept together because the first is the root cause and the other two make the failure mode unreachable rather than merely unlikely.
to_kmeansaccumulates inf64. Summing, dividing and the split perturbation all happen there, and the centroids are narrowed back to the storage type once, at the end.f64holds every sum an f16, f32 or f64 cluster can produce, so this closes the f32 overflow path as well as the f16 one. Narrowing clamps to the storage type's maximum, because the split perturbation scales a centroid by1 + 1/1024, which would otherwise round a top-of-range value toinf.split_clusterstakes donors largest-first from aBinaryHeapinstead of by unbounded probabilistic search, breaking ties on the smaller cluster id, and returns once no cluster has more than one vector to donate. This replaces the direct port of faiss'sClustering.cppdonor selection, so donor choice is now deterministic and the routine terminates regardless of its input — it no longer relies on a precondition held by its only caller. Thenparameter is gone with the probability denominator.train_kmeansrejects training data where fewer thankvectors could be assigned to any centroid. That threshold is exact rather than conservative: with at leastkvectors assigned, pigeonhole guarantees that whenever an empty cluster exists some cluster has two or more members and can donate, sosplit_clustersalways fills every cluster; belowk, the old loop was guaranteed to reach a state with an empty cluster and all counts at most one, which is precisely where it hung. Small datasets, heavy duplication and the hierarchical path are unaffected.Non-finite rows cannot trip it from the index-building path, because every training entry point runs
filter_finite_training_datafirst (ivf.rs,builder.rs), so k-means never sees a NaN or infinite vector andkmeans_random_initcannot seed a centroid from one.test_create_ivf_flat_with_nan_rowspins that interaction — a column that is 99% NaN still indexes off the remaining rows, repeatedly, since the seeding is random. A caller reachingKMeans::new_with_paramsdirectly with unfiltered data and drawing an all-non-finite seed set now gets this error where it previously hung.PQ codebook training reaches the same
to_kmeans, soIVF_PQwith thedotmetric — which skips residuals and therefore quantizes the raw vectors — was hanging for the same reason and is fixed by the same change.Compatibility
No public signature changes. The accumulator is a private helper, the
KMeansAlgo for KMeansAlgoFloat<T>bound stays atT: ArrowNumericType, and the update uses only bounds that implementation already required, so downstream code generic overTkeeps compiling untouched.Two behavior changes worth calling out:
train_hierarchical_kmeansalready uses, and it is also asymptotically cheaper — the old search made roughlyn / mean_cluster_sizerandom draws per empty cluster.f32 centroids shift in the last few bits, since their sums are now exact rather than rounded at every step. Nothing depends on the previous values:
kmeans_random_initseeds fromSmallRng::from_os_rng(), so training is already non-deterministic between runs.Tests
New in
lance-index:split_clustersfilling empty clusters deterministically while preserving the vector count, stopping when donors run out ([3,0,0,0,0] -> [1,1,1,0,0]) and when there is no donor at all, the narrowing clamp in both directions, ato_kmeanscase with a cluster larger than 65504, f16 training at magnitude 1000, and an all-NaN float32 case that must error rather than hang — the last one pins the unbounded loop without involving f16 at all.test_create_ivf_flat_f16is now parameterized over vector magnitude and checks distance finiteness and recall; a newtest_create_ivf_pq_f16_dot_large_valuescovers the PQ codebook path. Both hang on the unfixed build and pass in about a second with the fix, verified by running them against a build with onlykmeans.rsreverted.The stale comment on the
k * 512training cap is updated: it existed to keep f16 centroid updates from underflowing, which the accumulator now handles, and what is left is a crude cost bound that keeps a prefix of the data rather than sampling.cargo clippy -p lance-index -p lance --tests --benches -- -D warningsis clean;cargo test -p lance-index --lib vector::is 370 passed andcargo test -p lance --lib index::vectoris 265 passed.https://claude.ai/code/session_01HkgaggGvsdGuUGgnwEgKke