Introduction
The Introduction to BiocDuckDB vignette shows how an
experiment can keep its assays on disk while presenting the standard
Bioconductor API. This vignette asks the follow-up question: can
the standard single-cell analysis methods run directly on that on-disk
representation, and how fast?
BiocDuckDB
implements the common scuttle
and scran
generics for DuckDBMatrix as SQL-optimized queries, so QC,
normalization, variance modelling, and marker detection run on the
Parquet-backed matrix without realizing it into memory. We compare those
against the same generics on an in-memory dgCMatrix and on
HDF5Array.
The headline results below were produced offline on
the 10x Genomics 1.3 million brain-cell dataset (see Benchmark setup) and are rendered here from
a bundled results file, so this vignette builds quickly. The A small, live comparison section
runs a miniature version at build time.
What BiocDuckDB optimizes
Each method is implemented as per-gene / per-group SQL aggregation on
the DuckDBMatrix, so the scan and the arithmetic happen in
DuckDB and only the (small) result crosses back into R.
scuttle —
QC, normalization, pseudo-bulk:
perCellQCMetrics /
perFeatureQCMetrics |
library size, detected genes / mean, detection rate |
librarySizeFactors / normalizeCounts |
size factors and normalization |
summarizeAssayByGroup |
pseudo-bulk aggregation (GROUP BY) |
scran —
variance modelling and markers:
modelGeneVar / modelGeneVarByPoisson |
decompose technical vs biological variance |
correlatePairs |
pairwise gene correlations |
pairwiseTTests / findMarkers |
pairwise DE and candidate markers |
A small, live comparison
To show the mechanics without a large download, we build a small
sparse matrix and run one QC metric on both an in-memory
dgCMatrix and a DuckDBMatrix, confirming the
results agree.
library(BiocDuckDB)
library(DuckDBArray)
library(Matrix)
library(scuttle)
set.seed(1L)
m <- as(Matrix(rpois(2000 * 400, lambda = 0.3), nrow = 2000, ncol = 400,
sparse = TRUE), "dgCMatrix")
rownames(m) <- paste0("Gene", seq_len(nrow(m)))
colnames(m) <- paste0("Cell", seq_len(ncol(m)))
path <- tempfile()
writeParquet(t(m), path)
mt <- t(m)
mat <- DuckDBMatrix(path, datacol = "value",
keycols = list(index2 = setNames(seq_len(ncol(mt)), colnames(mt)),
index1 = setNames(seq_len(nrow(mt)), rownames(mt))),
dimtbls = createDimTables(mt))
## same answer, one in memory and one queried from disk
qc_mem <- perCellQCMetrics(m)
qc_ddb <- perCellQCMetrics(mat)
all.equal(qc_mem$sum, qc_ddb$sum)
#> [1] TRUE
At this size the in-memory matrix is faster — there is nothing to
gain from going to disk. The advantage appears at scale, which is what
the offline benchmark measures.
Benchmark setup
The full benchmark uses the 10x Genomics 1.3 million brain-cell
dataset (available through ExperimentHub,
accession EH1039), subset to 12,500 cells — the size used
by the original comparison. Each operation runs on three backends: an
in-memory Matrix
dgCMatrix, an HDF5Array,
and a DuckDBMatrix. The in-memory and HDF5 backends run
single-threaded; DuckDBMatrix autotunes DuckDB’s internal
threads up to the core budget. The variance and marker operations run on
log-normalized counts produced by each backend.
Results
Rendered from the bundled offline results
(inst/scripts/benchmark_results.rds); regenerate with
inst/scripts/run_scran_scuttle_benchmarks.R (see that
script’s header).
Elapsed seconds per operation. Speedups > 1 favor
DuckDB.
| perCellQCMetrics |
0.56 |
3.09 |
0.10 |
30.6 |
5.5 |
| perFeatureQCMetrics |
0.77 |
4.44 |
0.09 |
48.3 |
8.4 |
| summarizeAssayByGroup |
0.11 |
1.25 |
0.58 |
2.2 |
0.2 |
| normalizeCounts |
0.78 |
1.95 |
0.15 |
13.0 |
5.2 |
| modelGeneVar |
0.28 |
10.42 |
1.02 |
10.3 |
0.3 |
| correlatePairs |
54.98 |
77.66 |
0.61 |
127.1 |
90.0 |
| pairwiseTTests |
6.98 |
15.95 |
7.11 |
2.2 |
1.0 |
| findMarkers |
21.67 |
31.17 |
22.16 |
1.4 |
1.0 |
Configuration: 27,998 genes x 12,500 cells, 16-core budget.
In-memory: dgCMatrix.
HDF5Array: 10x/HDF5 backend. DuckDB:
DuckDBMatrix over Parquet (autotuned threads). All backends
run the same scran/scuttle generics.
Main takeaways
- The generics run unchanged on disk.
perCellQCMetrics(), modelGeneVar(),
findMarkers() and the rest dispatch to SQL-optimized
methods for DuckDBMatrix, so existing scran/scuttle
code works on a Parquet-backed matrix without changes or
realization.
- Against the out-of-core baseline, DuckDB wins every
operation. Compared with HDF5Array
— the fair comparison, since both keep the matrix on disk — DuckDB is
faster on all eight operations, from ~1.4x (
findMarkers) to
well over 100x (correlatePairs).
- QC and normalization beat in-memory too.
perCellQCMetrics, perFeatureQCMetrics, and
normalizeCounts are pure SUM/AVG
aggregations and run several times faster than an in-memory
dgCMatrix while never loading the matrix.
correlatePairs is the standout. Its
sparse-aware SQL avoids the dense intermediates the other backends
build, making it roughly two orders of magnitude faster than both HDF5Array
and in-memory.
- Some steps still favor in-memory at this scale.
summarizeAssayByGroup and modelGeneVar are
faster in memory (though DuckDB still beats HDF5Array);
the DuckDB advantage on these grows as data outgrows RAM. Marker
detection (pairwiseTTests, findMarkers) is on
par with in-memory, since the per-gene statistics after the SQL
GROUP BY are the same in every backend.
Consult the rendered table above for the measured numbers on your
build’s bundled results.
When this matters
The value is compounding: because these methods run on the
DuckDB-backed object directly, an analysis can go from raw counts
through QC, normalization, feature selection, and marker detection
before ever realizing the matrix into memory —
realizing only the small, filtered result it actually needs. That is
what makes the filter, realize, analyze pattern from the
introduction practical on datasets far larger than RAM.
Running your own benchmarks
inst/scripts/run_scran_scuttle_benchmarks.R reproduces
these numbers on your hardware (BENCH_NCELLS,
BENCH_CORES; set BENCH_SYNTHETIC=1 to
smoke-test without the EH1039 download). It writes
benchmark_results.rds, which this vignette renders via
inst/scripts/make_timings_table.R. For the lower-level
matrix operations (colSums, rowVars,
rowDeviances), see the DuckDBArray
benchmarking vignette.