---
title: "Benchmarking BiocDuckDB"
author:
- name: Patrick Aboyoun
  email: aboyounp@gene.com
  affiliation: Genentech, Inc.
date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026"
package: BiocDuckDB
vignette: >
  %\VignetteIndexEntry{2. Benchmarking BiocDuckDB}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
output:
  BiocStyle::html_document:
    number_sections: true
    toc: true
    toc_depth: 2
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>",
                      error = FALSE, warning = FALSE, message = FALSE)
```

# 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?**

`r Biocpkg("BiocDuckDB")` implements the common `r Biocpkg("scuttle")` and
`r Biocpkg("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 `r Biocpkg("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.

**`r Biocpkg("scuttle")`, QC, normalization, pseudo-bulk:**

| Function | Description |
|----------|-------------|
| `perCellQCMetrics` / `perFeatureQCMetrics` | library size, detected genes / mean, detection rate |
| `librarySizeFactors` / `normalizeCounts` | size factors and normalization |
| `summarizeAssayByGroup` | pseudo-bulk aggregation (`GROUP BY`) |

**`r Biocpkg("scran")`, variance modelling and markers:**

| Function | Description |
|----------|-------------|
| `modelGeneVar` / `modelGeneVarByPoisson` | decompose technical vs biological variance |
| `correlatePairs` | pairwise gene correlations |
| `pairwiseTTests` / `findMarkers` | pairwise DE and candidate markers |

**Dimensionality reduction is a different kind of case.** `r Biocpkg("BiocSingular")`'s
`runSVD()`, and so `r Biocpkg("scran")`'s `fixedPCA()` and `r Biocpkg("scater")`'s
`runPCA()`/`calculatePCA()`, already compute a correct PCA directly on a `DuckDBMatrix`
with the ordinary `BSPARAM = BiocSingular::IrlbaParam()`, through the SQL-pushdown
`%*%`/`crossprod` methods `r Biocpkg("DuckDBArray")` implements. But unlike the
aggregation-only functions above, PCA is not itself pure SQL: `irlba`'s Lanczos
iteration calls `%*%` once per solver step, so the query-planning cost of each call is
paid repeatedly rather than once, and that path is markedly slower on `DuckDBMatrix`
than in memory.

`BSPARAM = DuckDBIrlbaParam()` closes that gap: it materializes the (already
HVG-subsetted) matrix once into an in-memory sparse matrix and drives `irlba` directly
on it, the same size-gated materialization `loadIntoMemory()` uses elsewhere in this
package, instead of paying the SQL-pushdown cost once per iteration. It falls back to
ordinary `IrlbaParam` behavior whenever it doesn't apply (not a `DuckDBMatrix`, too
large for `"memory_limit"`), so it is safe to use as a drop-in replacement for
`IrlbaParam()` wherever a `DuckDBMatrix` might show up. The benchmark below uses
`DuckDBIrlbaParam()` for exactly this reason: to measure the path that is actually
meant to be fast, not the one that only demonstrates correctness.

# 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.

```{r live-demo}
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)
```

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 `r Biocpkg("ExperimentHub")`, accession `EH1039`), subset to 12,500 cells,
the size used by the original comparison. Each operation runs on three
backends: an in-memory `r CRANpkg("Matrix")` `dgCMatrix`, an
`r Biocpkg("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, and `calculatePCA` (10 components, top
200 HVGs, `BSPARAM = DuckDBIrlbaParam()`), run on log-normalized counts produced by
each backend.

# Results

The table below reports the offline benchmark's measured timings, bundled with the
package as `inst/scripts/benchmark_results.rds`. Regenerate it on your own
hardware with `inst/scripts/run_scran_scuttle_benchmarks.R` (see that script's
header).

```{r results, echo=FALSE, results='asis'}
helper <- system.file("scripts", "make_timings_table.R", package = "BiocDuckDB")
if (nzchar(helper)) {
    source(helper)
    res <- load_vignette_timings()
} else {
    res <- NULL
}
if (is.null(res)) {
    cat("_Precomputed benchmark results are not available in this build; ",
        "generate them with `inst/scripts/run_scran_scuttle_benchmarks.R`._\n", sep = "")
} else {
    print(make_timings_table(
        caption = "Elapsed seconds per operation. Speedups > 1 favor DuckDB.",
        results = res))
    cat("\n\n")
    timings_config_note(res)
}
```

# Summary

`perCellQCMetrics()`, `modelGeneVar()`, `findMarkers()`, and the rest of the
`r Biocpkg("scran")`/`r Biocpkg("scuttle")` generics used here dispatch to
SQL-optimized methods for `DuckDBMatrix`. That means existing analysis code that
calls these generics keeps working when the matrix happens to be Parquet-backed,
with no code changes and no realization into memory.

Against `r Biocpkg("HDF5Array")`, the fair comparison since both keep the matrix on
disk, DuckDB is faster on every operation measured, from about 1.4x on
`findMarkers` to well over 100x on `correlatePairs`. `perCellQCMetrics`,
`perFeatureQCMetrics`, and `normalizeCounts` are pure `SUM`/`AVG` aggregations,
which DuckDB pushes straight into a columnar scan; that lets them beat even the
in-memory `dgCMatrix` by several times, without ever loading the matrix.
`correlatePairs` is the standout: its sparse-aware SQL avoids the dense
intermediate matrices the other backends build for a pairwise correlation, which
is what turns the usual disk-vs-memory gap into a roughly two-order-of-magnitude
speedup over both `r Biocpkg("HDF5Array")` and in-memory.

Some steps still favor in-memory at this scale. `summarizeAssayByGroup` and
`modelGeneVar` are faster on an in-memory `dgCMatrix` (though DuckDB still beats
`r Biocpkg("HDF5Array")` on both); a dense in-memory group-by can outrun even a
single disk scan while the matrix comfortably fits in RAM, and the DuckDB
advantage should grow as the data outgrows it. Marker detection
(`pairwiseTTests`, `findMarkers`) lands on par with in-memory, since the per-gene
statistics computed after the SQL `GROUP BY` are identical work regardless of
which backend did the aggregation.

`calculatePCA` is run here with `BSPARAM = DuckDBIrlbaParam()`
(see [What BiocDuckDB optimizes]), not the ordinary `BSPARAM = IrlbaParam()`. The
distinction matters: with the ordinary `BSPARAM`, each Lanczos iteration re-pays SQL
query-planning overhead that a single-scan aggregation only pays once, measured at
about 50x slower than in-memory on a real 12,500-cell dataset, markedly worse than
every other operation in this table. `DuckDBIrlbaParam()` closes that gap by
materializing the matrix once and driving `irlba` directly on it, so the number in
the table above reflects that fast path, not the structural limit described for the
ordinary one.

The table in the Results section above has the exact measured numbers. There is
no per-call tuning needed to get them: `DuckDBMatrix` autotunes DuckDB's internal
threads up to the available core budget on its own. The lever that matters is
structural, covered next.

# 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
own hardware (`BENCH_NCELLS`, `BENCH_CORES`; set `BENCH_SYNTHETIC=1` to
smoke-test without the `EH1039` download). It writes `benchmark_results.rds` to
your working directory, in the same format `inst/scripts/make_timings_table.R`
reads to build the table in the Results section above. Running the script does
not, by itself, change that table: every installation of a given package
release bundles the same fixed, precomputed `inst/scripts/benchmark_results.rds`,
regardless of who built it or on what hardware. To have your own run replace the
bundled numbers, copy your output over `inst/scripts/benchmark_results.rds` in
the package source and rebuild the package.

For the lower-level matrix operations (`colSums`, `rowVars`, `rowDeviances`), see
the `r Biocpkg("DuckDBArray")` benchmarking vignette.

# Session information

```{r sessioninfo}
sessionInfo()
```
