Contents

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

2 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:

Function Description
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:

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

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

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

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

Table 1: Elapsed seconds per operation
Speedups > 1 favor DuckDB.
Operation In-memory (s) HDF5Array (s) DuckDB (s) vs HDF5Array (x) vs in-memory (x)
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.

6 Summary

perCellQCMetrics(), modelGeneVar(), findMarkers(), and the rest of the scran/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 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 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 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.

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.

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

8 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 DuckDBArray benchmarking vignette.

9 Session information

sessionInfo()
#> R version 4.6.1 Patched (2026-06-24 r90190)
#> Platform: x86_64-apple-darwin20
#> Running under: macOS Ventura 13.7.8
#> 
#> Matrix products: default
#> BLAS:   /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRblas.0.dylib 
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
#> 
#> locale:
#> [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#> 
#> time zone: America/New_York
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] scuttle_1.23.2              SingleCellExperiment_1.35.2
#>  [3] SummarizedExperiment_1.43.0 Biobase_2.73.2             
#>  [5] BiocDuckDB_0.99.22          DuckDBGRanges_0.99.8       
#>  [7] GenomicRanges_1.65.4        Seqinfo_1.3.2              
#>  [9] DuckDBArray_0.99.8          DelayedArray_0.39.6        
#> [11] SparseArray_1.13.2          S4Arrays_1.13.0            
#> [13] abind_1.4-8                 MatrixGenerics_1.25.0      
#> [15] matrixStats_1.5.0           Matrix_1.7-6               
#> [17] DuckDBDataFrame_0.99.26     IRanges_2.47.5             
#> [19] S4Vectors_0.51.9            BiocGenerics_0.59.12       
#> [21] generics_0.1.4              bit64_4.8.6                
#> [23] BiocStyle_2.41.0           
#> 
#> loaded via a namespace (and not attached):
#>  [1] tidyselect_1.2.1                    blob_1.3.0                         
#>  [3] dplyr_1.2.1                         arrow_25.0.1                       
#>  [5] fastmap_1.2.0                       bluster_1.23.1                     
#>  [7] duckdb_1.5.5                        digest_0.6.39                      
#>  [9] rsvd_1.0.5                          lifecycle_1.0.5                    
#> [11] cluster_2.1.8.3                     sf_1.1-2                           
#> [13] statmod_1.5.2                       magrittr_2.0.5                     
#> [15] compiler_4.6.1                      rlang_1.3.0                        
#> [17] sass_0.4.10                         tools_4.6.1                        
#> [19] igraph_2.3.3                        yaml_2.3.12                        
#> [21] knitr_1.52                          dqrng_0.4.1                        
#> [23] bit_4.6.0                           classInt_0.4-11                    
#> [25] BiocParallel_1.47.0                 KernSmooth_2.23-27                 
#> [27] withr_3.0.3                         purrr_1.2.2                        
#> [29] grid_4.6.1                          beachmat_2.29.2                    
#> [31] e1071_1.7-17                        edgeR_4.99.4                       
#> [33] MultiAssayExperiment_1.39.1         cli_3.6.6                          
#> [35] rmarkdown_2.32                      otel_0.2.0                         
#> [37] metapod_1.21.0                      rjson_0.2.23                       
#> [39] DBI_1.3.0                           cachem_1.1.0                       
#> [41] proxy_0.4-29                        assertthat_0.2.1                   
#> [43] parallel_4.6.1                      BiocManager_1.30.27                
#> [45] XVector_0.53.0                      vctrs_0.7.3                        
#> [47] jsonlite_2.0.0                      bookdown_0.48                      
#> [49] BiocSingular_1.29.1                 BiocNeighbors_2.7.3                
#> [51] irlba_2.3.7                         magick_2.9.1                       
#> [53] locfit_1.5-9.12                     limma_3.99.0                       
#> [55] jquerylib_0.1.4                     units_1.0-1                        
#> [57] glue_1.8.1                          codetools_0.2-20                   
#> [59] ScaledMatrix_1.21.0                 tibble_3.3.1                       
#> [61] pillar_1.11.1                       htmltools_0.5.9                    
#> [63] MultiAssaySpatialExperiment_0.99.12 R6_2.6.1                           
#> [65] dbplyr_2.6.0                        evaluate_1.0.5                     
#> [67] lattice_0.23-1                      SpatialExperiment_1.23.0           
#> [69] scran_1.41.1                        bslib_0.12.0                       
#> [71] class_7.3-24                        Rcpp_1.1.2                         
#> [73] xfun_0.60                           pkgconfig_2.0.3