Clustering is often the slowest offline step in a vector search or data-curation pipeline. FAISS 1.16.0, released on 10 November 2026, ships e-means, a new k-means replacement from Meta FAIR that its authors say reaches the loss of Lloyd’s k-means in about one-tenth of the epochs, with no extra hyperparameters. This tutorial shows how to install it, run it on CPU and GPU, compare it fairly against classic k-means, and use it to train an IVF index.
What e-means is
Every k-means variant keeps a running sum and count per cluster, and the centroid is sum divided by count. Lloyd’s algorithm recomputes these from the full dataset each iteration. e-means is a mini-batch method that updates them with exponential moving averages, so old assignments fade and centroids track recent batches.
According to the e-means README in the FAISS repository and the accompanying paper:
- Batch size and initial step size are set automatically from fitted scaling laws based on dataset size, cluster count and number of passes. By default you only choose
nepochs(default 5). - Small “dead” clusters are revived by splitting large ones.
- On CPU it uses
faiss.knnfor NumPy arrays. On GPU it runs through PyTorch with Triton or CuTe kernels. - The authors report lower loss than Lloyd’s k-means in roughly one-tenth the epochs across their test datasets. These are self-reported results, so measure on your own data.
Install
e-means ships inside FAISS, and the CPU build is enough to start:
pip install "faiss-cpu>=1.16.0" numpy
For GPU runs, install CUDA-enabled PyTorch from the official instructions (its Linux CUDA builds include Triton). The README notes that the CPU build of FAISS is enough on a GPU machine, because GPU e-means never calls FAISS. The fastest CuTe kernels are for H100 and H200 GPUs and need extra packages listed in the README.
Quick start on CPU
import numpy as np
from faiss.contrib.e_means import NumpyEmeans
x = np.random.rand(200_000, 64).astype("float32") # 200k vectors, 64 dims
em = NumpyEmeans(64, 500) # d=64, k=500 clusters, 5 passes by default
centroids, labels = em.train(x) # (500, 64) centroids and one label per vector
x_new = np.random.rand(1_000, 64).astype("float32")
distances, new_labels = em.assign(x_new) # nearest centroid for new vectors
print(centroids.shape, labels.shape, new_labels[:5])
print(em.recipe["batch_size"], em.recipe["alpha0"])
The recipe dictionary shows the batch size, step size and backend that e-means actually picked, which is useful for logging. One subtlety from the docs: the returned labels come from the last training pass, while centroids were still moving. For exact nearest-centroid labels, call assign() on the training data, at the cost of about one extra pass.
Compare it with Lloyd’s k-means on your data
Don’t take speed-up claims on trust. This script trains both on the same data and measures mean squared distance to the nearest centroid with the same FAISS index. We ran it with faiss-cpu 1.16.0 to check it works:
import time
import numpy as np
import faiss
from faiss.contrib.e_means import NumpyEmeans
rng = np.random.default_rng(0)
x = rng.standard_normal((200_000, 64)).astype("float32")
k = 1024
def mse(data, centroids):
index = faiss.IndexFlatL2(centroids.shape[1])
index.add(centroids)
dist, _ = index.search(data, 1)
return float(dist.mean())
t0 = time.perf_counter()
km = faiss.Kmeans(64, k, niter=25, seed=1234) # classic Lloyd, 25 iterations
km.train(x)
t_km = time.perf_counter() - t0
t0 = time.perf_counter()
em = NumpyEmeans(64, k) # default 5 passes
centroids, _ = em.train(x)
t_em = time.perf_counter() - t0
print(f"k-means : {t_km:6.2f}s MSE={mse(x, km.centroids):.4f}")
print(f"e-means : {t_em:6.2f}s MSE={mse(x, centroids):.4f}")
Swap the random data for your real embeddings. Random Gaussian data has no real cluster structure, so your own vectors are the only fair test. Also try nepochs=2 or 3 to find where the loss stops improving for your dataset.
Train an IVF index with e-means centroids
The coarse quantizer of an IVF index is just a set of k-means centroids, so e-means can replace the usual training step:
import numpy as np
import faiss
from faiss.contrib.e_means import NumpyEmeans
d, nlist = 64, 1024
rng = np.random.default_rng(0)
xb = rng.standard_normal((200_000, d)).astype("float32") # database vectors
xq = rng.standard_normal((5, d)).astype("float32") # queries
em = NumpyEmeans(d, nlist, nepochs=3)
centroids, _ = em.train(xb)
quantizer = faiss.IndexFlatL2(d)
quantizer.add(centroids)
index = faiss.IndexIVFFlat(quantizer, d, nlist)
print("trained:", index.is_trained) # True: quantizer already holds nlist centroids
index.add(xb)
index.nprobe = 16
distances, ids = index.search(xq, 5)
print(ids)
Because the quantizer already contains nlist centroids, the IVF index reports itself as trained, and you can add vectors immediately. Measure recall against a flat index before using this in production.
Running on a GPU
On GPU, pass a PyTorch tensor that is already on the device. e-means does not move data from CPU to GPU for you:
import torch
from faiss.contrib.e_means import Emeans
xg = torch.from_numpy(x).cuda()
assert torch.isfinite(xg).all() # the GPU path does not check for NaN/inf
em = Emeans(64, 1024, nepochs=5)
centroids, labels = em.train(xg)
print(em.recipe["assignment_backend"]) # which kernels were used
The README is explicit that GPU data is not checked for NaN or infinity, and that a single bad value can silently break centroids while the reported loss looks normal. Keep the assert.
Useful settings
nepochs: passes over the data, can be fractional (default 5).batch_law:"auto","throughput"or"quality". Quality uses batches about 8x smaller for lower final error, and is picked automatically for dimension 16 or less.max_points_per_centroid: trains on a random subset, likefaiss.Kmeans. Vectors left out get label -1.spherical=True: keeps centroids on the unit sphere for cosine similarity.seed: default 1234, for reproducible runs.
For data larger than device memory, the README documents a manual batch loop (begin plus per-batch updates), and for multi-GPU training there are DistributedEmeans and EmeansShardedCentroids.
Other FAISS 1.16.0 additions
The release notes also list a new PiPNN graph index (IndexPiPNN), fast-scan scalar quantizer indexes, more Metal backend work, native fp16 k-means training for IVF, and several RISC-V vector optimisations.
Takeaway
e-means is one of the rare algorithm upgrades that needs almost no code changes: import NumpyEmeans or Emeans, pick k, and train. If clustering is a bottleneck in your embedding pipeline or IVF index builds, run the comparison script on your own vectors this week. If the loss matches or beats your current k-means in fewer passes, you have an easy speed-up.
Sources: FAISS v1.16.0 release, e-means README, e-means paper.
