Skip to content

GPU

Three estimators run part of the work on the device: UMAPGpu, DensMAPGpu and TSNEGpu. The backend is wgpu, so there is no CUDA runtime to install and the same wheel works on Metal, Vulkan and DX12.

import manifolds_rs as mf

mf.gpu_available()  # True only if this build has GPU support AND an adapter exists

That one call answers both questions, which is what a caller choosing between UMAP and UMAPGpu actually wants to know.

What actually moves to the device

Not everything. The neighbour search and the Adam update run on the GPU; the graph construction, the spectral initialisation and t-SNE's Barnes-Hut repulsion stay on the CPU.

So the win depends entirely on whether the neighbour search was your bottleneck. On a few thousand points it will not be, and the transfers will make the GPU path slower than the CPU one. Past a few hundred thousand it usually is. Measure on your own data and hardware before switching; on Apple Silicon in particular the GPU rarely blows the CPU out of the water for these workloads.

float32 only

WGSL has no float64. fit casts a float64 array down rather than letting it fail somewhere inside a kernel, and that is the only silent narrowing this package performs anywhere. If that matters for your data, stay on the CPU estimators, which run in whatever precision you hand them.

Reproducibility

GPU embeddings are reproducible in structure, not in coordinates. The device neighbour searches are not always bit-stable at scale, and 500 epochs of Adam turn a 0.16% difference in the graph into visibly different coordinates. The clusters, the branches and the separations come back; the exact positions may not. See the guide for the measurements.

The GPU Adam update itself is bit-stable given a fixed graph, so handing in a precomputed graph makes a GPU run reproducible too.

Different neighbour backends

The device backends are not the CPU ones with a suffix. "nndescent_gpu" builds a CAGRA graph and is the default because it is the one that scales; "ivf_gpu" and "exhaustive_gpu" are the other two. Passing a CPU-only name like "hnsw" is an error rather than a silent fallback.

Their knobs live in NeighbourParamsGpu, which is a different set from the CPU NeighbourParams: CAGRA build degrees and beam-search budgets have no CPU counterpart.

mf.UMAPGpu(
    n_neighbors=15,
    ann="nndescent_gpu",
    nn_params=mf.NeighbourParamsGpu(k_build=64, beam_width=32),
)

Under TSNEGpu with nndescent_gpu, leaving NeighbourParamsGpu.k unset backfills the graph degree to 3 * perplexity, so it is sized for the query t-SNE actually makes.

Mixing CPU and GPU

The neighbour graph is just two arrays, so you can build it wherever it is cheapest and hand it to whichever estimator you like:

ind, dist = mf.knn_graph(X, k=15, ann="hnsw")  # CPU search
embedding = mf.UMAPGpu().fit_transform(X, knn_indices=ind, knn_distances=dist)

That skips the GPU search entirely and leaves only the Adam update on the device, which is occasionally the right split.

Headless machines and CI

gpu_available() returns False on a box with no adapter rather than raising, and the GPU estimators are still importable there. That is deliberate: a hosted CI runner can prove the GPU wheel compiles and degrades cleanly without having a device. It cannot prove the kernels are correct. That needs a runner with a GPU.