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.knn for 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, like faiss.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.