perf(index): parallelise the k-means centroid update over data and admit k=16 into the AMX GEMM - #9234
Open
xtangxtang wants to merge 4 commits into
Open
perf(index): parallelise the k-means centroid update over data and admit k=16 into the AMX GEMM#9234xtangxtang wants to merge 4 commits into
xtangxtang wants to merge 4 commits into
Conversation
xtangxtang
force-pushed
the
perf/kmeans-data-parallel-update
branch
from
September 15, 2026 07:04
4f42f8f to
52b1b24
Compare
…d ranges
The centroid update in `KMeansAlgoFloat::to_kmeans` parallelised over the
*centroids*: each rayon block owned a range of centroid rows and scanned the
whole dataset to find the members falling in its range. Two costs follow from
that layout.
- Each of the `p` blocks walks the whole membership array to find its own
rows, `p * n` visits against the single pass the sum actually needs.
- A block cannot own fewer than one centroid, so the block count is capped by
`k`, and the guard `if k < num_cpus || k < 16 { num_cpus = 1 }` collapsed
the update to a single thread whenever `k` was below the core count.
Hierarchical k-means trains every node of its tree at `k <= hierarchical_k`
(16 by default), so on any build host with more than 16 cores every one of
those updates ran on one core -- and for an f16 column it ran as scalar f16
adds, each a round trip through f32. Sibling subtrees do run in parallel,
but the top of the tree has fewer nodes than the host has cores, and there
that one core is the critical path while the distance computation next to
it uses every core.
This partitions the *data* instead. Each block gets a private
`k * dimension` f32 accumulator, sums its own rows into it, and the partials
are folded in block order at the end. The block count is what the thread
count and the row count allow (`MIN_ROWS_PER_SUM_BLOCK` rows per block, at
most one block per thread), bounded by a 512 MB budget on the accumulators
taken together. Every row is read once and the parallelism no longer depends
on `k`.
The previous strategy stays as `sum_clusters_over_centroids`, and
`sum_clusters_over_data` returns `None` to route four shapes to it: an f64
column (more mantissa than the f32 accumulator keeps), a `k * dimension` too
large for even one accumulator under the budget, a non-f16 column with only
one block's worth of rows, and -- where the in-place update is itself
parallel -- a shape whose accumulators cost more to fold than the work they
save.
That last rule is what keeps this from regressing the shapes the in-place
path already served well. With `k` at or above the thread count that path
runs one owner per thread, each reading its own vectors exactly once, so
the only work the data path saves it is the membership walk (and, for f16,
the scalar convert-add-convert on every element an owner accumulates). The
data path pays for that saving with `blocks * k * dimension` floats to zero
and then fold serially. Measured on a 64-thread host with both strategies
called directly on the shapes of `benches/kmeans_recompute.rs`, the f32
data path is 0.13-0.45x the speed of the in-place one wherever
`k * dimension` runs to hundreds of thousands, and 0.78-0.87x at 16 folded
floats per row; it wins only on small accumulators against long
memberships (1.9-3.6x at 2-4 per row, a PQ sub-vector). f16 tolerates more
in proportion to `dimension / threads`, since the scalar adds it saves
scale with that: break-even near 32, 64, 128 and 256 folded floats per row
at `dimension` 128, 512, 1024 and 2048. So the data path keeps a shape only
while `blocks * k * dimension <= 4 * n` for f32, plus `6 * n * dimension /
threads` for f16, both constants on the winning side of the measured
crossovers. The same test the in-place path makes to decide between one
owner and many is shared as `centroid_owner_blocks`, so the routing cannot
drift from the update it routes to. Across the five benchmark shapes and
fourteen more around the crossover no shape is slower than before; where
the in-place path is serial (`k` below the thread count, or below 16) the
data path is taken unconditionally and is 3.7-10x faster on f32 and
140-420x on f16 at the hierarchical k-means shapes.
The accumulators are f32 whatever the element type. They are fresh memory
either way, so the widening costs one extra copy of `k * dimension` per block
and nothing in the pass over the data; what it buys is that an f16 column no
longer depends on the size of its clusters. f16 carries 11 significant bits,
so a running total past 2048 cannot represent an increment of 1 and a cluster
of a few thousand rows sums to a fraction of its true total. `train_kmeans`
caps its input at `k * 512` rows precisely so the in-place f16 update never
reaches that regime (lance-format#4977); the new path keeps the same property without
leaning on the cap, which is why an f16 column takes it even when there is
only one block to give it.
`available_threads` is a parameter rather than a read of
`get_num_compute_intensive_cpus` so the block arithmetic, and with it which
strategy a shape lands on, is reproducible in a test on any host. The fold is
in block order rather than completion order, so a given host reproduces the
same centroid bit for bit between runs. Across hosts with different core
counts the last bits differ; that is not something this path could offer
anyway, since `train_kmeans` seeds from `SmallRng::from_os_rng`.
Measured on LAION 100M x 768-dim fp16, dot distance, IVF_PQ with 10000
partitions and `sample_rate = 256` (2.56M training rows), on 32 physical
cores / 64 threads of a Xeon 6972P pinned with `taskset`, two runs per arm,
against `main` at 8c392b2 built with the same toolchain. The figure is the
`Trained IVF model in ...` log line, which covers hierarchical k-means and
the closing `compute_loss` assignment but not the sampling I/O in front of
them. With `LANCE_DISABLE_AMX=1` on both arms so the GEMM plays no part:
78.5 s -> 67.6 s. With AMX enabled: 22.4 s -> 10.1 s. The saving is the
same ~11-12 s on either arm, which is what a CPU-independent change should
show; what remains of the AMX-off time is the closing assignment on the
per-vector path, which this commit does not touch.
Tests pin that the two strategies agree to accumulation-order tolerance on
random data, that an f16 cluster too large to total in f16 still comes out
at the right mean on one block and on many, that the routing declines the
f64, over-budget and single-block shapes and no others, that against a
parallel in-place update it keeps a small accumulator and hands back a
large one at both element types' allowances while ignoring the allowance
wherever that update is serial, that an empty input produces zeros without
reaching `par_chunks` with a zero chunk size, and that an out-of-range
membership id is ignored rather than written past the accumulator.
`dot_membership_amx_f16` declined any centroid set smaller than the kernel's 32-wide block. Hierarchical k-means trains every node at `k <= hierarchical_k` (16 by default) and then assigns all of that node's rows to those centroids, so inside `train_ivf` the GEMM never ran for anything in the training loop -- only for the closing assignment against the full centroid set, and during shuffle, where `k` is the full partition count. Everything at `k <= 16` scored one vector at a time on the per-vector path, re-reading the centroid table for every row. The floor is now 16. `PackedCentroidsF16` already pads the centroid count up to a multiple of 32 with zero rows, so a 16-centroid set half-fills every 32-wide pass with columns that compute nothing useful. That is still the better trade: the alternative re-reads the whole centroid table per row and is memory-bound, while the half-filled GEMM keeps its arithmetic intensity. The zero columns produce a distance of exactly 1.0 and only the first `k` columns of a result row are ever consulted, which the existing padding test now pins at `k = 16` as well as at the odd-sized `k = 100`. 16 rather than any smaller number because it is the kernel's own granularity: one B tile holds exactly 16 centroids (32 dims x 16 centroids, VNNI interleaved), and the batch entry point sweeps centroids 16 at a time. Below that even a single tile is partly padding. `prefers_flat_amx_assignment` in `utils.rs` moves from 32 to 16 in the same change. Its doc comment requires it to stay in lockstep with this gate, and the two really do have to move together: a build that takes the exact assignment route because this predicate says yes, and then finds the GEMM declining the shape, would run the exact path with no GEMM under it. At `k = 16` the predicate is not reached in practice -- `may_train_index` returns before it whenever `num_centroids * dimension < 1_000_000` -- so the change there is about keeping the invariant true, not about behaviour. Measured on LAION 100M x 768-dim fp16, dot distance, IVF_PQ with 10000 partitions and `sample_rate = 256`, on 32 physical cores / 64 threads of a Xeon 6972P pinned with `taskset`, two runs per arm, with the data-parallel centroid update from the previous commit on both arms. With AMX enabled the `Trained IVF model in ...` figure went from 10.1 s to 8.5 s; the two runs of each arm are within 0.3 s of each other. With `LANCE_DISABLE_AMX=1` the gate is never consulted and the two arms measure the same within noise (67.6 s vs 65.2 s, where the AMX-off arms scatter by 2-4 s between runs). The closing assignment against all 10000 centroids took the GEMM before this change and is not credited to it. A new test pins that `k = 15` and `dimension = 31` still decline, on any host and without needing the kernel present.
xtangxtang
force-pushed
the
perf/kmeans-data-parallel-update
branch
from
September 15, 2026 08:33
52b1b24 to
84cdd82
Compare
xtangxtang
marked this pull request as ready for review
September 16, 2026 01:39
Contributor
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The effective PR patch is unchanged after the latest merge from main. The data-partitioned f32 accumulators remove the hierarchical k-means bottleneck while bounding memory and retaining the prior centroid-owner path for unsuitable shapes; the k=16 AMX threshold remains aligned with exact-assignment routing and excludes padded centroids from reduction.
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.
Related: #6369. Touches the same function as the open #8560 -- the two cover disjoint shapes, see the section on that below.
The problem
train_ivfis hierarchical k-means followed by one closing assignment of every training row against the full centroid set (compute_loss). The closing assignment has had the AMX-FP16 GEMM under it since #8540. The hierarchical part has not, and its centroid update ran on one core.The update in
KMeansAlgoFloat::to_kmeansparallelised over the centroids: each thread owned a range of centroid rows and scanned the whole dataset for members in its range. That layout has a hard ceiling -- you cannot have more threads than centroids -- and the code made it explicit:Hierarchical k-means trains every node of its tree at
k <= hierarchical_k, which defaults to 16. So on any build host with more than 16 cores, every centroid update in the training loop ran single-threaded -- and for an f16 column, as scalar f16 adds, each a round trip through f32 -- while the distance computation next to it used every core. Sibling subtrees run in parallel, but at the top of the tree there are fewer nodes than cores, and that one core is the critical path.A second, smaller thing sits next to it. The GEMM declined any centroid set below its 32-wide block. Everything in the training loop is
k <= 16-- the per-node training and the assignment of a node's rows to its children -- so the GEMM never ran there, only for the closing assignment and during shuffle.The fix
Two commits, independent, in order of impact.
1. Partition the data, not the centroids. Each block sums its own rows into a private
k × dimensionf32 accumulator; the partials are folded in block order at the end. Every row is visited once -- the old layout walked the whole membership array once per block,p × nvisits, to touch each vector from exactly one of them -- and the block count is bounded by the row count and thread count, not byk. The old strategy stays as a fallback for the shapes where accumulating in place is cheaper: f64;k × dimensiontoo large to hold under a 512 MB budget; a non-f16 column with only one block's worth of rows; and, where the in-place update is itself parallel (kat least the thread count, and at least 16), any shape whose accumulators would cost more to fold than the work they save. That last rule isblocks × k × dimension <= 4 × nfor f32, plus6 × n × dimension / threadsfor f16 (whose in-place path also pays a scalar convert-add-convert per element); the constants come from the crossover measurements below and sit on their winning side. The test the in-place path makes to choose between one owner and many is shared ascentroid_owner_blocks, so the routing cannot drift from the update it routes to.The accumulators are f32 whatever the element type. They are fresh memory either way, so widening them costs one extra
k × dimensionper block and nothing in the pass over the data; what it buys is that an f16 column no longer depends on the size of its clusters -- an f16 running total past 2048 cannot represent an increment of 1.train_kmeanscaps its input atk × 512rows precisely so the in-place f16 path never hits that (#4977); the new path keeps the property without leaning on the cap.2. Lower the GEMM's
kfloor from 32 to 16.PackedCentroidsF16already pads to a multiple of 32 with zero rows, so a 16-centroid set half-fills every pass. It is still the better trade: the alternative re-reads the whole centroid table per row and is memory-bound. The zero columns score a distance of exactly 1.0 and only the firstkcolumns are consulted, which the padding test now pins atk = 16.prefers_flat_amx_assignmentinutils.rsmoves with it, as its doc comment requires; atk = 16that predicate is not reached in practice (may_train_indexreturns first below1_000_000centroid elements), so the change there keeps the invariant true rather than changing behaviour.Relationship to #6369 and #8560
#6369 is the
p × nmembership rescan into_kmeans. #8560 (open) addresses it with a stable membership-to-row index for the casek >= available_parallelism, and keeps a single-threaded accumulation for everything below that:That excluded case is every node of hierarchical k-means on any host with more than 16 cores, and it is the case this PR measures. The two mostly do not compete on shapes: #8560's indexed path applies where
kis at least the thread count; commit 1 here applies unconditionally wherekis below it -- where an owner-partitioned layout has no parallelism to offer at all -- and above it only where the accumulators are small next to the membership (a PQ sub-vector, or f16 at high dimension), handing everything else back to the in-place update unchanged. They do edit the same function, so whichever lands second rebases; if #8560 goes first I will fold this onto itsrecompute_float_centroidsas the branch its serial arm takes. I have not marked this as closing #6369 while #8560 is open against it.Commit 2 is independent of both.
Performance
LAION 100M, 768-dim fp16, dot distance, IVF_PQ, 10000 partitions,
sample_rate = 256(2.56M training rows). 32 physical cores / 64 threads of a Xeon 6972P (Granite Rapids), pinned withnumactl+taskset,LANCE_CPU_THREADS=64. Three binaries from the same toolchain (clang-16,-mprefer-vector-width=512,fp16kernels):mainat 8c392b2,main+ commit 1,main+ both commits. Two runs per cell, in alternating order.The figure is the
Trained IVF model in ...log line: hierarchical k-means plus the closing assignment, without the sampling I/O in front of them (that stage is 23--43 s here and drifts with the page cache, which is why I do not quote whole-stagetook=numbers).Trained IVF model in, secondsmainmain→ PRLANCE_DISABLE_AMX=1Reading across the columns:
mainspends in that stage.mainand is not credited to this PR.So on an AMX host
mainspends ~19 s of its 22 s in the hierarchical stage and this PR brings that stage to ~5 s; on a host without AMX the closing assignment dominates and the PR is a 1.2× on the total.Three notes on how solid these numbers are.
LANCE_DISABLE_AMX=1, confirmed by the startup banner (amx_usable=false) and by the timing itself. The per-vector dot kernel in those arms was confirmed by disassembly to be 512-bit AVX-512 (zmmregisters present), not a scalar fallback, so the baseline is what it claims to be.main. The branch was first developed against 706b941, before the hierarchical k-means rewrite; there the single-core update was ~266 s of a ~320 strain_ivfand commit 1 saved ~245 s. The rewrite (training each node on at mostk × 512rows, subtrees in parallel) already took most of that away, which is why the same fix is now worth ~12 s. The structure of the result did not change -- commit 1 large, commit 2 small -- only the base it sits on.Correctness
Both commits keep the per-vector and in-place paths as fallbacks and pin the new paths against them:
kbelow the thread count, or below 16);par_chunkswith a zero chunk size;k = 15anddim = 31, on any host;k = 16.Within a host the new update is reproducible bit for bit (block-order fold). Across hosts with different core counts, the last bits of a centroid differ;
train_kmeansalready seeds fromSmallRng::from_os_rng, so cross-host reproducibility was not on offer before either.Checks
The AMX-dependent tests ran the kernel on the development host (Xeon 6972P, Granite Rapids, clang-16; CPUID reports AMX-FP16 and the
XTILEDATAgrant succeeds) and skip on hosts without it.Where the in-place update is already parallel
The open question when this went up as a draft was whether the data path regresses the shapes the in-place update already served well,
kat or above the thread count. It did, on some of them, and the routing rule above is the result. Method: a temporary#[ignore]test (not in the PR) callingsum_clusters_over_dataandsum_clusters_over_centroidsdirectly on random data, in a 64-thread rayon pool pinned withtasksetto 32 cores / 64 threads of the Xeon 6972P, median of 3--200 repetitions per cell after a warm-up. "data" is the data path forced on regardless of routing; "→" is what the routing now picks.The five shapes of
benches/kmeans_recompute.rs(from #8560), microseconds per update:N=512, dim=1024, k=256N=16384, dim=1024, k=64N=65536, dim=1024, k=4096N=65536, dim=64, k=256N=131072, dim=128, k=256¹ A conservative miss, not a regression: at 16 folded floats per row and
dimension64 the f16 allowance (4 + 6 × 64 / 64 = 10) declines a shape the data path would have won by 1.2--1.7× across runs. Run-to-run scatter on the in-place f16 cells is 1.5--2×, hence the ranges.The shapes this PR is for, and a PQ sub-vector, same units:
N=8192, dim=768, k=16N=131072, dim=768, k=16N=65536, dim=8, k=256N=2.56M, dim=768, k=10000Where the constants come from --
N=32768, 32 blocks,k=256(or 128 where marked), data path relative to in-place:dimensionk=128)k=128)k=128)f32 crosses over between 8 and 16 folded floats per row, independent of dimension; the allowance is 4. f16 crosses over at roughly 32, 64, 128 and 256 per row for
dimension128, 512, 1024 and 2048 -- proportional todimension / threads, because what it additionally saves is one scalar half-precision add per element per owner; the allowance is 4 + 6 ×dimension/ threads, against a measured slope of about 8. With the rule in place, none of the nineteen shapes above is slower thanmain; thetrain_ivfnumbers are unaffected, since every shape in that build isk <= 16.