feat: distributed centroid training - #7321
Conversation
Squashed commits: - docs(spec): distributed IVF centroid training abstraction - docs(plan): distributed IVF centroid training implementation plan - feat(kmeans): scaffold distributed kmeans submodule - feat(kmeans): add PartialStats Arrow wrapper - feat(kmeans): centroids_fingerprint helper for distributed stats - feat(kmeans): compute_partial_stats E-step kernel - feat(kmeans): merge_partial_stats / reduce_partial_stats - feat(kmeans): finalize_centroids M-step - test(kmeans): I1/I2/I4 invariants for distributed kmeans - feat(kmeans): reservoir sample + driver-side bootstrap helpers - refactor(ivf): streaming refine uses distributed kmeans primitives - feat(ivf): scaffold layer-2 distributed module - feat(ivf): sample_round_0 + compute_partial_stats layer-2 wrappers - test(ivf): end-to-end 4-worker distributed centroid training - feat(python): pyo3 bindings for distributed kmeans primitives - feat(python): distributed kmeans facade + ipc round-trip tests - feat(java): jni shim for distributed kmeans - feat(java): DistributedKMeans public api + junit round-trip - docs: distributed centroid training user guide - chore: cargo fmt --all after distributed kmeans rollout Change-Id: Ifff14151f0c021c502ad7619de6aacc9fdd6daae
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
| let combined = concat_samples(samples)?; | ||
| let dim = combined.value_length() as usize; | ||
| let params = KMeansParams::default() | ||
| .with_distance_type(distance_type) |
There was a problem hiding this comment.
Cosine bootstrap passes the cosine metric into the existing float kmeans path; ordinary-size runs can hit the unsupported-metric branch and panic instead of returning centroids.
There was a problem hiding this comment.
fixed inside bootstrap_centroids. When distance_type == Cosine we now L2-normalize the combined samples up front, dispatch the inner k-means with DistanceType::L2 (mirroring the Layer-1 contract in compute_partial_stats), and then re-normalize the returned means so callers still see unit-norm centroids
| } | ||
|
|
||
| /// Validate and adopt an externally-built RecordBatch. | ||
| pub fn from_record_batch(batch: RecordBatch) -> Result<Self> { |
There was a problem hiding this comment.
Externally supplied stats are accepted after only version and column-name checks, while later merge/finalize paths unwrap concrete column types; malformed Arrow IPC can panic instead of returning a validation error.
| stats: &PartialStats, | ||
| prev: &FixedSizeListArray, | ||
| ) -> Result<FixedSizeListArray> { | ||
| if stats.k() != prev.len() { |
There was a problem hiding this comment.
Finalization never checks that the stats fingerprint matches the previous centroids, so stale stats from another training round can be accepted and produce centroids for the wrong assignments.
There was a problem hiding this comment.
The fingerprint is the same 8-byte SHA-256 prefix already used to gate merge_partial_stats, so the trust boundary is consistent on both the merge and finalize paths
| ) -> Result<PartialStats> { | ||
| // Pull all matching rows; the worker is expected to have a small enough fragment | ||
| // slice that scanning it whole is cheap. | ||
| let raw = maybe_sample_training_data(dataset, column, usize::MAX, fragment_ids).await?; |
There was a problem hiding this comment.
The worker E-step materializes the entire fragment slice before computing stats, so large shards can create memory usage proportional to all rows times dimension instead of the bounded partial-stats state.
There was a problem hiding this comment.
Pick memory is O(k·d) instead of O(N·d). Empty input is handled by returning PartialStats::empty(k, dim, distance_type, fp) with the right fingerprint so downstream merge/finalize stays uniform
| ) -> Result<RecordBatch> { | ||
| // Round-0 oversamples to give the driver-side bootstrap enough material. | ||
| let raw = | ||
| maybe_sample_training_data(dataset, column, target.saturating_mul(2), fragment_ids).await?; |
There was a problem hiding this comment.
The public seed only controls the second reservoir pass; the earlier dataset sampling remains nondeterministic, so retrying the same worker with the same seed can train from different samples.
There was a problem hiding this comment.
The seed is now plumbed all the way through
| env: &mut JNIEnv<'a>, | ||
| fsl: &FixedSizeListArray, | ||
| ) -> Result<jni::objects::JFloatArray<'a>> { | ||
| let values = fsl.values().as_primitive::<Float32Type>().values().to_vec(); |
There was a problem hiding this comment.
The Java return path unconditionally reads centroid values as Float32 even though the Rust primitive can return Float16 or Float64, so supported vector dtypes can fail across JNI.
There was a problem hiding this comment.
now return jbyteArray carrying an Arrow-IPC RecordBatch whose single column is FixedSizeList<original_dtype>, so callers can dispatch on it instead of silently downcasting
| &dataset_guard.inner, | ||
| &column, | ||
| frags.as_deref(), | ||
| target as usize, |
There was a problem hiding this comment.
A negative Java target is cast to a huge unsigned size before sampling, so invalid input can turn Round-0 sampling into a full-slice scan and return.
There was a problem hiding this comment.
nativeSampleRound0 now validates target < 0 up front
Change-Id: I44ca6c6bacf77527caf02ccca94ca7c97d4fb4a1
| } | ||
|
|
||
| let batch_idx = self.held.len(); | ||
| self.held.push(chunk.clone()); |
There was a problem hiding this comment.
The streaming reservoir still stores every input chunk even after selected rows from that chunk are replaced, so Round-0 sampling can retain the full streamed vector payload in memory.
| DistanceType::L2, | ||
| ) | ||
| } else { | ||
| (combined, distance_type) |
There was a problem hiding this comment.
Hamming requests still flow into the float k-means bootstrap path, so a public distributed bootstrap call can panic instead of returning a validation error.
| "select/bootstrap requires at least one sample batch", | ||
| )); | ||
| } | ||
| let arrays: Vec<&dyn Array> = samples.iter().map(|b| b.column(0).as_ref()).collect(); |
There was a problem hiding this comment.
Externally supplied sample batches are indexed before validating that they contain a vector column, so malformed IPC can panic the driver process instead of producing an error.
| fragment_ids: Option<&[u32]>, | ||
| seed: Option<u64>, | ||
| ) -> Result<Pin<Box<dyn Stream<Item = Result<RecordBatch>> + Send>>> { | ||
| let num_rows = count_rows(dataset, fragment_ids).await?; |
There was a problem hiding this comment.
Duplicate fragment filters are counted before the later deduplication path, so a repeated id can hit the ordered-fragment assertion and panic public distributed calls.
| a.values() | ||
| .iter() | ||
| .zip(b.values().iter()) | ||
| .map(|(x, y)| x + y) |
There was a problem hiding this comment.
Merging externally supplied stats still adds counts with unchecked u64 arithmetic, so oversized counts can panic in debug builds or wrap in release and corrupt centroid averages.
| } | ||
|
|
||
| fn array_data_to_fsl(array: ArrayData) -> PyResult<FixedSizeListArray> { | ||
| Ok(FixedSizeListArray::from(array)) |
There was a problem hiding this comment.
Python centroid input is converted with a panic-on-wrong-type Arrow constructor, so malformed user input can abort the process instead of raising a Python error.
|
Thanks for this @summaryzb — the scheduler-neutral One suggestion that might help it land faster: would you be open to splitting it into stacked PRs? At +3959 / 20 files across four surfaces (Rust core, IVF integration, Java, Python) it's a lot to review in one pass, which may be part of why it's been quiet since early July. Splitting lets each surface get focused review and hit its own coverage bar (codecov currently flags 84.9%, 267 lines uncovered). A natural split — core lands first, the rest fan out in parallel:
Once #1 is in, #2–#4 are independent and reviewable in parallel. Happy to help however's useful — I can review, rebase against main, or take one of the binding/integration slices off your plate. |
Summary
This PR adds scheduler-neutral distributed IVF centroid training primitives so Spark, Ray, or custom RPC systems can train one global centroid set across many Lance fragments. It introduces a shared Arrow
PartialStatswire format in Rust, dataset-aware IVF wrappers, Python and Java bindings, and documentation for driving Round-0 sampling plus iterative Lloyd rounds outside Lance.
Problem
Lance already supports distributed index segment construction, but callers did not have a reusable way to train shared IVF centroids across workers. That forced distributed builders either to centralize training data on one process or to train independent per-segm
ent centroid models, which prevents a single global model from being reused across worker-built vector index segments. The missing piece was a small, stable API surface for the k-means math itself: sample local data, compute per-worker E-step statistics, merge tho
se statistics, and finalize the next centroids.
Approach
The implementation splits centroid training into two layers.
lance_index::vector::kmeans::distributedprovides pure Arrow-native primitives:PartialStats,compute_partial_stats,merge_partial_stats,reduce_partial_stats,finalize_centroids, reservoir sampling, and driver-side centroid initialization.
PartialStatsis represented as a fixed-schemaRecordBatchwith metadata for version, shape, distance type, and a centroid fingerprint so reducers reject stats from incompatible rounds.lance::index::vector::ivf::distributedadds dataset-aware async wrappers that scan optional fragment slices, normalize cosine input when needed, filter non-finite training data, and delegate to the pure primitives. The existing streaming IVF refinement path is refactored to use these same partial-stats primitives, keeping the new distributed path aligned with the in-process training behavior. Python and Java expose the same operations through Arrow IPC-friendly APIs while leaving broadcast, tree reduction, convergence ch
ecks, and scheduling to the caller.
Changes
rust/lance-index/src/vector/kmeans/distributed.rs: adds the core distributed k-means primitives,PartialStatsschema/metadata validation, centroid fingerprinting, E-step accumulation, merge/reduce, M-step finalization, reservoir sampling, and bootstrap helpers.
rust/lance-index/src/vector/kmeans.rs,rust/lance-index/Cargo.toml,Cargo.toml,Cargo.lock: wires the distributed k-means module intolance-indexand adds thesha2dependency used for centroid fingerprints.rust/lance/src/index/vector/ivf/distributed.rs: adds dataset-aware distributed IVF training wrappers for worker-side sampling and partial-stat computation over optional fragment id slices.rust/lance/src/index/vector/ivf.rs: refactors streaming IVF refinement to accumulate and finalize centroids through the newPartialStatsprimitives and adds a regression test against the legacy sum/weight calculation.python/src/indices.rs,python/python/lance/indices/distributed_kmeans.py,python/python/lance/indices/__init__.py: registers PyO3 bindings and a Python facade for sampling, partial-stat computation, merge/reduce, centroid finalization, and centroid initialization.
java/lance-jni/src/distributed_kmeans.rs,java/lance-jni/src/lib.rs,java/src/main/java/org/lance/index/vector/DistributedKMeans.java: adds JNI and Java public APIs using Arrow IPC for samples/stats and flat float arrays for centroid outputs.docs/src/guide/distributed_indexing.md: documents distributed centroid training roles, the Python workflow, and the ArrowRecordBatchinterchange contract.Test Coverage
PartialStatsschema/version validation, centroid fingerprint determinism, basic E-step counts/loss, Hamming rejection, all-NaN/empty handling, merge identity and fingerprint mismatch rejection, centroid finalization including empty-cluster fallback, deterministic reservoir sampling, initial centroid selection, and bootstrap k-means.
lance.indices.distributed_kmeansround trip, Arrow IPC serialization of partial stats, merge behavior after IPC restore, and initial centroid selection shape.