ward-clustering

Batched Ward hierarchical clustering on CUDA. One call returns a SciPy-compatible linkage tree, flat cluster labels, and the actual number of clusters per document. Supports variable-length batches and CUDA graph capture.

Usage

Install kernels in an environment with CUDA-enabled PyTorch:

pip install kernels
import torch
from kernels import get_kernel

kernel = get_kernel("sentence-transformers/ward-clustering", version=1, trust_remote_code=True)

x = torch.randn(32, 256, 128, device="cuda")
distances = torch.cdist(x, x)
linkage, labels, counts = kernel.ward(distances, num_clusters=128)

ward accepts square CUDA dissimilarity matrices of shape (N, N) or (B, N, N). Only the strict upper triangle is used. Inputs must be float32 or float64, with finite nonnegative distances whose squares are representable in that dtype.

Output Batched shape Dtype
Linkage tree, in SciPy layout (B, N - 1, 4) float64
Zero-based cluster labels (B, N) int64
Actual cluster counts (B,) int64

Single-matrix outputs omit the batch dimension. Outputs remain on CUDA.

For padded batches, provide valid prefix lengths and per-document cluster targets together as CUDA int64 tensors:

lengths = torch.tensor([256, 193, 81, 0], device="cuda")
targets = (lengths // 2).clamp_min(1).minimum(lengths)
linkage, labels, counts = kernel.ward(
    distances[:4], lengths=lengths, cluster_counts=targets
)

Lengths must be between 0 and N. Targets override num_clusters and must be between 1 and the valid length, or 0 for an empty document. Unused output rows are zero-padded. Pass validate=True to check input values, which synchronizes with the host.

Behavior and limits

  • Supports 1 to 4096 tokens per padded document and up to 65535 documents per batch, subject to GPU memory. Distance workspace grows quadratically with padded length.
  • Tied merges can produce fewer clusters than requested. Requesting one cluster per token returns singletons.
  • Float32 rounding and tied distances can produce different trees or partitions from SciPy. Use float64 for tighter reference agreement.
  • Euclidean distances give the usual Ward objective. Cosine dissimilarities are also accepted, as used in token pooling, but define a different objective.
  • Clustering outputs have no gradient. Distance construction and differentiable cluster means belong to the caller.

Benchmarks

Batched CUDA Ward latency compared with SciPy on CPU

Latency is measured in milliseconds per batch. Lower is better. b128_n512 means 128 documents of 512 tokens, each clustered to N/2 groups. Each CUDA call computes linkage and flat clusters from precomputed float32 cosine distances. Encoding, distance construction, and pooled means are excluded.

Measured on an RTX 3090 with PyTorch 2.10.0+cu128, one CPU thread, TF32 disabled, and asynchronous CUDA launches. CUDA timings average 100 synchronized samples after ten warmups. The SciPy reference uses resident CPU inputs and one timed pass, including the return of merge heights to CUDA for verification.

The standard benchmark entrypoint is benchmarks/benchmark.py, using kernels.benchmark.Benchmark. Direct Hub execution currently needs a CLI update to support native kernel repositories.

Throughput (batch calls per second) and an animated comparison are also available.

Hierarchical Token Pooling in Sentence Transformers

Use the released Sentence Transformers encoding and scoring APIs to pool document embeddings after inference. This example encodes one query and four documents with mLateOn, clusters all four documents in one CUDA call, and averages the vectors in each cluster using PyTorch.

pip install "sentence-transformers>=6.0.1" kernels
import torch
from kernels import get_kernel
from sentence_transformers import MultiVectorEncoder
from torch.nn.utils.rnn import pad_sequence

model = MultiVectorEncoder("lightonai/mLateOn", device="cuda")
kernel = get_kernel("sentence-transformers/ward-clustering", version=1, trust_remote_code=True)

query = "How do heat pumps keep a house warm in winter?"
documents = [
    """A heat pump heats a house by moving thermal energy from the outside air
    into the building. Even on a cold winter day, outdoor air contains energy
    that a refrigerant can absorb. A compressor raises the refrigerant's pressure
    and temperature, and a heat exchanger transfers that warmth to indoor air
    or water circulating through radiators. The refrigerant then expands and
    repeats the cycle. Unlike a gas boiler, the system does not burn fuel to
    create heat. Its efficiency depends on the outdoor temperature, the required
    heating temperature, and the design of the installation. Cold weather can
    require defrost cycles or additional heating capacity.""",
    """Improving insulation helps a house stay comfortable throughout winter.
    Heat can escape through the roof, walls, windows, and gaps around doors.
    Adding loft insulation and sealing draughts reduces these losses, while
    double or triple glazing limits heat transfer through windows. Ventilation
    still matters because an airtight building needs a reliable supply of fresh
    air to manage moisture. Before replacing a heating system, an energy survey
    can identify which improvements will have the largest effect. Better
    insulation usually lowers the heating demand and helps rooms maintain a
    more consistent temperature. It can also make lower-temperature radiators
    and underfloor heating more practical.""",
    """Rooftop solar panels convert sunlight into electricity using photovoltaic
    cells. An inverter converts the panels' direct current into alternating
    current that household appliances can use. Output changes with the time of
    day, cloud cover, shading, and the orientation of the roof. A battery can
    store some daytime generation for use after sunset, although its capacity
    limits how much energy can be shifted. Households may export surplus power
    to the grid under their electricity tariff. Planning an installation means
    checking the roof condition, estimating annual generation, and comparing
    that estimate with the home's electricity consumption. Panels generate
    electricity rather than directly warming the rooms.""",
    """A public library is digitizing its collection of historical newspapers
    so that readers can search local reporting from previous centuries. Staff
    first inspect fragile pages and photograph them using a scanner designed
    to protect the paper. Optical character recognition turns the images into
    searchable text, but volunteers must correct errors caused by faded ink
    and unusual typefaces. Each issue receives metadata describing its date,
    publisher, and place of publication. The library keeps preservation copies
    alongside smaller files for its website. Researchers can then trace changes
    in local businesses, transport, and community life without repeatedly
    handling the original newspapers in the reading room.""",
]

query_embeddings = model.encode_query([query], normalize_embeddings=True)
document_embeddings = model.encode_document(documents, normalize_embeddings=True)


def report(name, embeddings):
    sizes = [len(embedding) for embedding in embeddings]
    scores = model.similarity(query_embeddings, embeddings)[0].tolist()
    print(f"{name}: {sum(sizes)} document token embeddings")
    for i, (size, score) in enumerate(zip(sizes, scores), start=1):
        print(f"  Document {i}: {size:3d} embeddings, similarity {score:.4f}")


report("Before pooling", document_embeddings)

pool_factor = 4
lengths = torch.tensor([len(x) for x in document_embeddings], device="cuda")
padded = pad_sequence(document_embeddings, batch_first=True).float()
distances = (1 - padded @ padded.transpose(1, 2)).clamp(0, 2)
targets = (lengths // pool_factor).clamp_min(1)
_, labels, counts = kernel.ward(distances, lengths=lengths, cluster_counts=targets)

pooled_embeddings = []
for embedding, doc_labels, count in zip(document_embeddings, labels, counts.tolist()):
    doc_labels = doc_labels[: len(embedding)]
    sums = torch.zeros(count, embedding.shape[1], device="cuda", dtype=torch.float32)
    sums.index_add_(0, doc_labels, embedding.float())
    sizes = torch.bincount(doc_labels, minlength=count).unsqueeze(1)
    pooled_embeddings.append((sums / sizes).to(embedding.dtype))

report("After pooling", pooled_embeddings)

Example output:

Before pooling: 618 document token embeddings
  Document 1: 152 embeddings, similarity 12.9408
  Document 2: 154 embeddings, similarity 12.7925
  Document 3: 158 embeddings, similarity 12.6670
  Document 4: 154 embeddings, similarity 12.4875
After pooling: 153 document token embeddings
  Document 1:  38 embeddings, similarity 12.7861
  Document 2:  38 embeddings, similarity 12.6806
  Document 3:  39 embeddings, similarity 12.5529
  Document 4:  38 embeddings, similarity 12.4039

The query embeddings stay unchanged. Cluster means are not renormalized, and pooling can change scores and rankings. Here, pool_factor=4 targets roughly a fourfold reduction in document vectors.

Downloads last month
1
kernels
cuda
clustering
apache-2.0
Supported hardwares new
CUDA
7.07.27.58.08.68.78.99.010.010.311.012.09.0+PTX12.1+PTX
GPU
B300
288GB
NVIDIA SXM
B200
192GB
NVIDIA SXM
H200
141GB
NVIDIA SXM
H100
80GB
GPU
H800
80GB
GPU
H20
96GB
GPU
L40s
48GB
GPU
L40
48GB
GPU
L20
48GB
GPU
L4
24GB
DGX Spark
GB10
128GB
GPU
RTX PRO 6000 WS
96GB
GPU
RTX PRO 6000 Max-Q
96GB
GPU
RTX PRO 5000
48GB
GPU
RTX PRO 4500 WS
32GB
GPU
RTX PRO 4000
24GB
GPU
RTX PRO 4000 SFF
24GB
GPU
RTX PRO 2000
16GB
GPU
RTX 6000 Ada
48GB
GPU
RTX 5880 Ada
48GB
RTX
RTX 5000 Ada
32GB
GPU
RTX 4500 Ada
24GB
RTX
RTX 4000 Ada
20GB
RTX
RTX 4000 SFF Ada
20GB
GPU
RTX 3500 Ada Mobile
12GB
GPU
RTX 2000 Ada
16GB
GPU
RTX A6000
48GB
GPU
RTX A5000
8GB
GPU
RTX A5000 Max-Q
16GB
GPU
RTX A5000 Mobile
16GB
GPU
RTX A4000
16GB
GPU
RTX A4000 Max-Q
8GB
GPU
RTX A4000 Mobile
8GB
GPU
RTX A3000 Mobile
6GB
GPU
RTX A2000
6GB
GPU
RTX A2000 Embedded
4GB
GPU
RTX A2000 Max-Q
4GB
GPU
RTX A2000 Mobile
4GB
GPU
A800
40GB
GPU
A100
80GB
GPU
A40
48GB
GPU
A30
24GB
GPU
A10
24GB
GPU
A2
16GB
RTX
RTX 5090
32GB
RTX
RTX 5090 D
32GB
RTX
RTX 5090 Mobile
24GB
RTX
RTX 5080
16GB
RTX
RTX 5080 Mobile
16GB
RTX
RTX 5070
12GB
RTX
RTX 5070 Mobile
8GB
RTX
RTX 5070 Ti
16GB
RTX
RTX 5070 Ti Mobile
12GB
RTX
RTX 5060 Ti
16GB
RTX
RTX 5060
8GB
RTX
RTX 5060 Mobile
8GB
RTX
RTX 5050
8GB
RTX
RTX 5050 Mobile
8GB
RTX
RTX 4090
24GB
RTX
RTX 4090D
24GB
RTX
RTX 4090 Mobile
16GB
RTX
RTX 4080 SUPER
16GB
RTX
RTX 4080
16GB
RTX
RTX 4080 Mobile
12GB
RTX
RTX 4070
12GB
RTX
RTX 4070 Mobile
8GB
RTX
RTX 4070 Ti
12GB
RTX
RTX 4070 Super
12GB
RTX
RTX 4070 Ti Super
16GB
RTX
RTX 4060
8GB
RTX
RTX 4060 Ti
8GB
RTX
RTX 4090 Laptop
16GB
RTX
RTX 4080 Laptop
12GB
RTX
RTX 4070 Laptop
8GB
RTX
RTX 4060 Laptop
8GB
RTX
RTX 4050 Laptop
6GB
RTX
RTX 3090
24GB
RTX
RTX 3090 Ti
24GB
RTX
RTX 3080
12GB
RTX
RTX 3080 Ti
12GB
RTX
RTX 3080 Mobile
16GB
RTX
RTX 3070
8GB
RTX
RTX 3070 Ti
8GB
RTX
RTX 3070 Ti Mobile
8GB
RTX
RTX 3060 Ti
8GB
RTX
RTX 3060
12GB
GPU
RTX 2080 Ti
11GB
GPU
RTX 2080
8GB
GPU
RTX 2070
8GB
GPU
RTX 2070 SUPER Mobile
8GB
GPU
RTX 2070 SUPER
8GB
RTX
RTX 3060 Mobile
6GB
RTX
RTX 3050 Mobile
4GB
GPU
RTX 2060
6GB
GPU
RTX 2060 12GB
12GB
GPU
RTX 2060 Mobile
6GB
GPU
RTX 2050 Mobile
4GB
GPU
RTX Titan
24GB
GPU
GTX 1660
6GB
GPU
GTX 1650 Mobile
4GB
NVIDIA T4
T4
16GB
GPU
T10
16GB
GPU
V100
32GB
Jetson
Jetson AGX Orin 64GB
64GB
Jetson
Jetson AGX Orin 32GB
32GB
Jetson
Jetson Orin NX 16GB
16GB
Jetson
Jetson Orin NX 8GB
8GB
Jetson
Jetson Orin Nano 8GB
8GB
Jetson
Jetson Orin Nano 4GB
4GB
Jetson
Jetson AGX Xavier
32GB
Jetson
Jetson Xavier NX
8GB
OS
linuxwindows
Arch
x86_64
Kernel Builder
0.17.0-dev1