---
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}
library(BiocStyle)
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 |

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

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

# Main takeaways

- **The generics run unchanged on disk.** `perCellQCMetrics()`, `modelGeneVar()`,
  `findMarkers()` and the rest dispatch to SQL-optimized methods for `DuckDBMatrix`,
  so existing `r Biocpkg("scran")`/`r Biocpkg("scuttle")` code works on a
  Parquet-backed matrix without changes or realization.
- **Against the out-of-core baseline, DuckDB wins every operation.** Compared with
  `r Biocpkg("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 `r Biocpkg("HDF5Array")` and in-memory.
- **Some steps still favor in-memory at this scale.** `summarizeAssayByGroup` and
  `modelGeneVar` are faster in memory (though DuckDB still beats
  `r Biocpkg("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
`r Biocpkg("DuckDBArray")` benchmarking vignette.

# Session information

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