knitr::opts_chunk$set( collapse = TRUE, comment = "#>", # Executed locally (NOT_CRAN=true) only when Seurat is available; skipped on # CRAN to avoid the "CPU time > elapsed" vignette NOTE from the CPU fallback. eval = identical(Sys.getenv("NOT_CRAN"), "true") && requireNamespace("Seurat", quietly = TRUE) && requireNamespace("SeuratObject", quietly = TRUE) ) library(ggmlR) if (requireNamespace("Seurat", quietly = TRUE)) { suppressMessages(library(Seurat)) suppressMessages(library(SeuratObject)) }
ggmlR can accelerate the heavy steps of a single-cell RNA-seq pipeline on the
GPU, working directly on Seurat objects. There is no conversion on your
side and no hard dependency: Seurat / SeuratObject live in Suggests, so
ggmlR installs fine without them and the adapter only activates when they are
present. Vulkan is used automatically when a GPU is available, with a
transparent CPU fallback.
The adapter leans on two more Suggests packages for the neighbour-graph path:
Matrix (the sparse graphs) and FNN (kd-tree kNN search). Install everything
once alongside ggmlR, then just load Seurat — the S3 methods activate on their
own:
install.packages(c("Seurat", "Matrix", "FNN")) library(ggmlR) library(Seurat)
RunGGML()RunGGML() mirrors Seurat's own RunPCA() / NormalizeData() style — object
in, object out, pipe-friendly. The supported operations map onto the expensive
matrix steps of a standard workflow:
| op | Replaces | What runs on the GPU |
|------|----------|----------------------|
| "normalize" | NormalizeData() (LogNormalize) | per-cell library-size scaling + log1p, elementwise |
| "scale" | ScaleData() | per-gene z-score (x − mean) / sd + clamp, over the full dense matrix |
| "embed" | RunPCA() | gene-by-gene covariance multiply (the eigendecomposition stays on the CPU — ggml has no eigensolver) |
| "umap" | RunUMAP() | two custom compute shaders — a tiled f32 pairwise-distance kernel and an SGD layout kernel |
| "neighbors" | FindNeighbors() | kNN distances on the GPU feeding a shared-nearest-neighbour (SNN/Jaccard) graph |
"normalize" and "scale" are transforms: they write the result back into
the assay (the data and scale.data layers), so the rest of the Seurat
pipeline picks them up unchanged. "embed" and "umap" add a dimensionality
reduction. "neighbors" writes the <assay>_nn / <assay>_snn graphs into
@graphs, exactly where FindClusters() looks.
We build a small synthetic object here so the vignette is self-contained; in
practice you would load your own data with Read10X().
set.seed(1) ng <- 400L; nc <- 200L counts <- matrix(rpois(ng * nc, lambda = 5), nrow = ng, ncol = nc) rownames(counts) <- paste0("gene", seq_len(ng)) colnames(counts) <- paste0("cell", seq_len(nc)) counts <- methods::as(counts, "dgCMatrix") pbmc <- CreateSeuratObject(counts = counts) pbmc
pbmc <- RunGGML(pbmc, op = "normalize") # -> assay "data" layer pbmc <- RunGGML(pbmc, op = "scale") # -> assay "scale.data" layer
These behave like NormalizeData() and ScaleData() — the transformed matrices
now live in the standard assay layers:
dim(LayerData(pbmc, layer = "data")) dim(LayerData(pbmc, layer = "scale.data"))
The GPU "normalize" result matches Seurat's NormalizeData() to floating-point
tolerance:
gpu_data <- as.matrix(LayerData(pbmc, layer = "data")) ref_data <- as.matrix(LayerData( NormalizeData(pbmc, verbose = FALSE), layer = "data")) max(abs(gpu_data - ref_data))
pbmc <- RunGGML(pbmc, op = "embed", n_components = 20, reduction_name = "ggml") Embeddings(pbmc, "ggml")[1:3, 1:4]
The reduction is an ordinary DimReduc, so every downstream step — Seurat's own
or ggmlR's — uses it directly.
op = "umap" lays the cells out in 2-D on the GPU. Both heavy phases are custom
Vulkan compute shaders: a tiled f32 pairwise-distance kernel that builds the
kNN graph (it sidesteps mul_mat, whose f16 accumulation would reorder nearest
neighbours and corrupt the graph), and an SGD layout kernel that runs one
dispatch per epoch with lock-free Hogwild updates. With a kd-tree kNN search and
a sparse fuzzy graph in between, the whole UMAP runs an order of magnitude faster
than a naive reference while matching its layout to float32 precision.
pbmc <- RunGGML(pbmc, op = "umap", reduction = "ggml", dims = 1:20, reduction_name = "umap") Embeddings(pbmc, "umap")[1:3, ]
op = "neighbors" is the GPU equivalent of FindNeighbors(): it builds the
binary kNN graph and the shared-nearest-neighbour (SNN) graph whose weights are
the Jaccard overlap of each pair's neighbourhoods. The graphs land in @graphs
under Seurat's naming convention, so FindClusters() consumes them unchanged.
pbmc <- RunGGML(pbmc, op = "neighbors", reduction = "ggml", dims = 1:20) Graphs(pbmc) # <assay>_nn and <assay>_snn pbmc <- FindClusters(pbmc, graph.name = paste0(DefaultAssay(pbmc), "_snn"), verbose = FALSE) table(pbmc$seurat_clusters)
DimPlot(pbmc, reduction = "umap", group.by = "seurat_clusters", label = TRUE)
The SNN weights are numerically identical to FindNeighbors() when both use the
same exact kNN (Seurat's default Annoy is approximate, so an exact match needs
nn.method = "rann").
Each operation records which backend it used (and timings) in the object's
Misc slot, keyed by the layer / reduction it produced:
Misc(pbmc, "data_ggml")$backend # normalize Misc(pbmc, "scale.data_ggml")$backend # scale Misc(pbmc, "ggml_ggml")$backend # embed (and neighbors) Misc(pbmc, "umap_ggml")$backend_sgd # umap: layout phase backend
For "umap" the per-phase backends are reported separately
(backend_dist for the kNN distances, backend_sgd for the layout), since each
falls back to the CPU independently; the summary backend is "vulkan" only
when both ran on the GPU.
RunGGML() is a thin wrapper over three public generics you can also call on
their own — handy for a bare matrix with no Seurat object, or to inspect
capabilities before dispatch:
# What can the adapter do? names(ggml_ops_registry()) ggml_ops_registry("embed") # Compose the layers manually on a plain matrix: mat <- ggml_extract(gpu_data) # genes x cells, dense task <- ggml_task("embed", mat, params = list(n_components = 10)) res <- ggml_run(task) # ggml_result dim(res$embedding) # cells x components
ggml_extract() pulls a feature × cell matrix out of a Seurat /
dgCMatrix / matrix, handling Seurat v4 (GetAssayData) vs v5
(LayerData) and sparse → dense.ggml_run() validates against ggml_ops_registry() and routes to the Vulkan
GPU or the CPU (device = "auto", with fallback).ggml_inject() writes the result back — a reduction for "embed" / "umap",
an assay layer for the "normalize" / "scale" transforms, or the
<assay>_nn / <assay>_snn graphs for "neighbors".SingleCellExperimentThe same RunGGML() works on a SingleCellExperiment (SCE) — the adapter has
methods for both object models. On an SCE the default assay read is
logcounts, results land in reducedDim(), transforms overwrite the named
assay, and the neighbour graphs and provenance go into metadata().
library(SingleCellExperiment) # a self-contained SCE (genes x cells), so this section does not depend on the # Seurat object built earlier set.seed(1) ng <- 200L; nc <- 120L sce_counts <- matrix(stats::rpois(ng * nc, lambda = 5), ng, nc) rownames(sce_counts) <- paste0("gene", seq_len(ng)) colnames(sce_counts) <- paste0("cell", seq_len(nc)) sce <- SingleCellExperiment(assays = list( counts = sce_counts, logcounts = log1p(sce_counts))) sce <- RunGGML(sce, op = "embed", n_components = 20) # -> reducedDim "ggml" sce <- RunGGML(sce, op = "neighbors", reduction = "ggml", dims = 1:20) reducedDimNames(sce) # "ggml" names(S4Vectors::metadata(sce)) # ggml_nn / ggml_snn / ggml_ggml
Install the Bioconductor pieces alongside ggmlR if you work with SCE objects:
BiocManager::install(c("SingleCellExperiment", "SummarizedExperiment", "S4Vectors"))
The worked example above is synthetic so the vignette stays self-contained. A full CPU-vs-GPU run on real data ships as an example script:
system.file("examples", "seurat_op2_gpu.R", package = "ggmlR")
It runs the classic Seurat preprocessing route
(percent.Largest.Gene → NormalizeData → FindVariableFeatures → ScaleData →
RunPCA → FindNeighbors → FindClusters → RunUMAP) twice — once as stock Seurat on
the CPU, once through RunGGML() on the GPU — then checks that the two arms
agree numerically and prints a per-step speed-up table. The data are the Kaggle
Open Problems – Single-Cell Perturbations (OP2) counts: 18 211 genes ×
240 090 human PBMCs. The full matrix densifies to ~35 GB, so — as in the
reference notebook — the pipeline runs on a random subsample of the cells.
# needs Suggests: Seurat, SeuratObject, Matrix, qs2, data.table, FNN, uwot Rscript seurat_op2_gpu.R # default 10% subsample Rscript seurat_op2_gpu.R --frac 0.05 # smaller subsample Rscript seurat_op2_gpu.R --gpu-knn # op="neighbors" kNN on the GPU Rscript seurat_op2_gpu.R --chunk 20000 # stream scale/PCA in blocks Rscript seurat_op2_gpu.R --no-cpu # GPU arm only (skip the CPU timings)
Every GPU step is one of the five ops from the table above; the two steps with
no GPU op (FindVariableFeatures, FindClusters) run identically in both arms.
percent.Largest.Gene maps to op = "largest_gene", a memory-bound sparse
column argmax kept on the CPU (over the dgCMatrix @x, no densify) — the point
there is parity with qlcMatrix::colMax, not a GPU speed-up.
A representative run — 11 % subsample (23 279 cells), 2 000 variable features, 50
PCs, --gpu-knn — on an RDNA-class GPU:
step cpu (s) gpu (s) speedup largest_gene 8.92 0.28 32.22x normalize 2.84 1.61 1.76x scale 1.84 1.94 0.95x embed (PCA) 15.92 3.03 5.26x neighbors 4.43 1.45 3.05x umap 15.32 5.34 2.87x TOTAL (gpu ops) 49.27 13.66 3.61x
The pattern is worth reading rather than just the bottom line:
centre 0.47s matmul_cov 1.27s eigen 0.06s matmul_proj 0.98s — the
eigensolve (CPU) is negligible, so keeping it off the GPU costs nothing.scale comes out ~1× (or slightly slower): it is a memory-bound
elementwise pass with nothing to accelerate, so the host↔VRAM copy eats the
gain. That is why op = "scale" defaults to the CPU even under Vulkan
(scale_backend = "vulkan" forces the GPU path).umap here runs its kNN on the FNN kd-tree and its SGD layout on the CPU
by default — the 2.9× comes from the GPU distance/graph phase. The SGD default
is deliberate (see the UMAP section); sgd_backend = "vulkan" opts into the
GPU shader.largest_gene looks like a 32× GPU win but is CPU on both sides — the
speed-up is the sparse @x argmax replacing stock Seurat's qlcMatrix path,
not the GPU.The script densifies a fixed random column sample and compares the two arms. Every accelerated step matches Seurat to float32-vs-float64 noise, and the downstream clustering/embedding preserve the same structure:
normalize max abs err 9.26e-07 (2000 features x 5000 sampled cells) largest_gene top-gene agree 1.0000 percent max abs err 0.00e+00 scale max abs err 2.38e-06 PCA min |cor| over PC1-10 1.0000 clusters ARI 0.9364 (11 GPU vs 11 CPU communities) UMAP within/total SS gpu 0.048 cpu 0.060 (lower = tighter)
The comparisons account for the maths, not just the bytes: PCs are eigenvectors
so their sign is arbitrary (compare |cor|), clusterings are label-permuted (the
adjusted Rand index is invariant), and UMAP is stochastic and initialised
differently in each arm (so instead of coordinates, measure how tightly each
clustering separates in the embedding — the GPU layout is actually a hair tighter
here, 0.048 vs 0.060).
Five steps of a standard workflow move to the GPU; only the final community detection stays on the CPU:
| Standard step | ggmlR | Runs on |
|---------------|-------|---------|
| NormalizeData() | RunGGML(op = "normalize") | GPU |
| ScaleData() | RunGGML(op = "scale") | GPU |
| RunPCA() | RunGGML(op = "embed") | GPU matrix multiply (eigensolve on CPU) |
| RunUMAP() | RunGGML(op = "umap") | GPU (distance + SGD shaders) |
| FindNeighbors() | RunGGML(op = "neighbors") | GPU distances → CPU sparse SNN |
| FindClusters() | — (use Seurat's) | CPU — iterative graph Louvain/Leiden |
So a typical run is normalize → scale → embed → umap for visualisation and
embed → neighbors → FindClusters for clustering. FindClusters is left to
Seurat because community detection is iterative and graph-structured — a poor fit
for the GPU and already well optimised on the CPU. The PCA eigendecomposition
likewise stays on the CPU, as ggml has no eigensolver. The same operations are
available on SingleCellExperiment objects (see above), so the adapter covers
both the Seurat and Bioconductor ecosystems.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.