GPU¶
Four estimators run part of the work on the device: UMAPGpu, DensMAPGpu,
TSNEGpu and DREAMSGpu. 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 and the initialisation stay on the CPU. For t-SNE it depends
on approx: with "barnes_hut" the repulsion stays on the CPU, with
"fft_3k_gpu" the whole optimisation runs on the device.
"fft_3k_gpu" is FFT-interpolated repulsion with three kernels (q, q^2 dx,
q^2 dy) instead of the four-term expansion, with the embedding resident on the
device between epochs. "fft" and "fft_3k" run the same FFT schemes on the
CPU after a GPU neighbour search.
Otherwise the win depends 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.
MLX on Apple Silicon¶
There is a second Apple GPU path, through MLX via mlx-c, and it is not in the published wheel. It links mlx-c dynamically, so a wheel carrying it fails to import on any Mac without the library. Build it yourself:
MLX is not a separate set of estimators. The CPU estimators gain a few extra names instead:
mf.UMAP(ann="nndescent_mlx", optimiser="adam_mlx").fit_transform(X)
mf.DensMAP(optimiser="adam_mlx").fit_transform(X)
mf.TSNE(ann="nndescent_mlx", approx="fft_3k_mlx").fit_transform(X)
mf.DREAMS(approx="fft_3k_mlx").fit_transform(X)
mf.knn_graph(X, k=15, ann="ivf_mlx")
The neighbour backends are "exhaustive_mlx", "ivf_mlx" and
"nndescent_mlx". TSNEGpu and DREAMSGpu take approx="fft_3k_mlx" as well,
with the search on wgpu and the optimiser on MLX. den-SNE has no MLX path.
Everything MLX runs in float32, whatever you hand it. In a build without MLX
these names are refused with a pointer to the build flag.
Is it worth the trouble? On an M1 Max the MLX neighbour search is 1.2x to 1.8x
faster than wgpu. UMAP's optimiser ties, and the t-SNE one falls to half of
wgpu's speed at a million points, so keep TSNEGpu(approx="fft_3k_gpu") for
large t-SNE. See the
wgpu vs MLX benchmarks.