---
title: "Benchmarking DuckDBGRanges"
author:
- name: Patrick Aboyoun
  email: aboyounp@gene.com
  affiliation: Genentech, Inc.
date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026"
package: DuckDBGRanges
vignette: >
  %\VignetteIndexEntry{2. Benchmarking DuckDBGRanges}
  %\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 DuckDBGRanges* vignette shows how a `DuckDBGRanges` behaves
like an ordinary `GRanges` while keeping its data on disk. This vignette asks the
follow-up question: **what does that cost, and what does it buy?** We compare the
DuckDB backend against an in-memory `r Biocpkg("GenomicRanges")` object on two
range-analysis scenarios at scale:

1. **scATAC-seq peaks** --- a million peak-like intervals a few hundred bases wide,
   the shape of multi-sample consensus peak sets;
2. **variants** --- ten million SNV-like point positions, the shape of a large
   variant catalogue.

The headline results below were produced **offline** (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 so you
can see the mechanics end to end.

The short version: **DuckDBGRanges wins decisively on memory and on the filtering
that dominates the early stages of a workflow, while in-memory `GRanges` keeps its
edge on the interval-tree operations** --- `findOverlaps()`, `subsetByOverlaps()`,
`nearest()`. The two are complementary, which is why the idiomatic pattern is to
*filter with DuckDB, then materialize to `GRanges`* for overlap work.

# What DuckDBGRanges optimizes

`r Biocpkg("DuckDBGRanges")` turns coordinate and metadata filters into SQL that
DuckDB pushes into the Parquet reader --- so a region restriction reads only the
matching row groups instead of scanning every range. Existing Bioconductor code
picks this up automatically: calling `restrict()` on a `DuckDBGRanges` *is* a
`WHERE` clause, and chained filters compose into a single lazy query.

| Category | Operations | Backend that wins |
|----------|------------|-------------------|
| Memory footprint | the R object itself | **DuckDBGRanges** (constant ~175 KB) |
| Coordinate / metadata filtering | `restrict`, logical `[`, pipelines | **DuckDBGRanges** (predicate pushdown) |
| Nearest-feature distance | `distanceToNearest` | **DuckDBGRanges** (SQL aggregation) |
| Inter-range transforms | `reduce`, `disjoin` | comparable; DuckDBGRanges pulls ahead at scale |
| Overlap enumeration | `findOverlaps`, `subsetByOverlaps` | GRanges (interval trees) |

The design vignette (*Design and extension of DuckDBGRanges*) shows the SQL each
operation translates to.

# A small, live comparison

To show the mechanics without a large dataset, we build a small range set and run
one operation on both an in-memory `GRanges` and a `DuckDBGRanges`, confirming the
results agree.

```{r live-demo}
library(DuckDBGRanges)
library(GenomicRanges)
library(arrow)

set.seed(1L)
n <- 2000L
df <- data.frame(
    seqnames = sample(paste0("chr", 1:5), n, replace = TRUE),
    start = sample(1:1e6, n),
    strand = sample(c("+", "-", "*"), n, replace = TRUE),
    score = runif(n))
df$end <- df$start + sample(100:500, n, replace = TRUE)

gr <- makeGRangesFromDataFrame(df, keep.extra.columns = TRUE)
path <- tempfile(fileext = ".parquet"); write_parquet(df, path)
ddb <- DuckDBGRanges(path, seqnames = "seqnames", start = "start",
                     end = "end", strand = "strand", mcols = "score")

## same answer, one in memory and one queried from disk
r_gr  <- restrict(gr,  start = 1L, end = 500000L)
r_ddb <- restrict(ddb, start = 1L, end = 500000L)
length(r_gr) == length(r_ddb)
```

At this size the in-memory object is faster --- there is nothing to gain from going
to disk, and the query has fixed overhead. The advantage appears at scale, which is
what the offline benchmark measures.

# Benchmark setup

The full benchmark generates synthetic ranges at scale across the human autosomes:
**1,000,000** peaks for the scATAC-seq scenario and **10,000,000** SNVs for the
variant scenario, plus a 50,000-feature annotation set used as the subject of the
overlap operations. For each scenario it times six operations on both backends and
records the in-memory footprint of each object.

The backends parallelize differently, so we give each its best effort on the same
core budget: **`GRanges` runs single-threaded C code** (the in-memory baseline),
while **`DuckDBGRanges` autotunes** DuckDB's internal threads up to the budget with
no configuration. The exact parameters are recorded with the results and shown
beneath the tables.

# Results

Rendered from the bundled offline results (`inst/scripts/benchmark_results.rds`);
regenerate with `inst/scripts/run_vignette_benchmarks.R` (see that script's header).

```{r results, echo=FALSE, results='asis'}
helper <- system.file("scripts", "make_timings_table.R", package = "DuckDBGRanges")
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_vignette_benchmarks.R`._\n", sep = "")
} else {
    cat("\n**Memory footprint**\n\n")
    print(make_memory_table(
        caption = "In-memory object size; DuckDBGRanges is constant regardless of N.",
        results = res))
    cat("\n\n**scATAC-seq scenario (1M peaks)**\n\n")
    print(make_timings_table("scATAC", results = res,
        caption = "Elapsed seconds. Speedup = GRanges / DuckDBGRanges (>1 favors DuckDB)."))
    cat("\n\n**Variant scenario (10M variants)**\n\n")
    print(make_timings_table("variant", results = res,
        caption = "Elapsed seconds. Speedup = GRanges / DuckDBGRanges (>1 favors DuckDB)."))
    cat("\n\n")
    timings_config_note(res)
}
```

# Main takeaways

- **Memory is the headline.** A `DuckDBGRanges` is a small, constant-size handle
  (~175 KB) whichever scenario it fronts, while a `GRanges` grows with the number
  of ranges --- 150x smaller for a million peaks, over 1,500x smaller for ten
  million variants. This is what lets you *reference* a hundred-million-range
  catalogue without paying for it until a query touches it.
- **Filtering favors DuckDB.** `restrict()` and logical subsetting become `WHERE`
  clauses pushed into the Parquet reader, so they read only matching rows --- the
  advantage grows with data size (a few times faster at a million peaks, ~13x at
  ten million variants).
- **Nearest-feature distance favors DuckDB, strongly.** `distanceToNearest()`
  runs as an SQL aggregation rather than an interval-tree walk, and is the largest
  single win measured (~10x at a million peaks, ~70x at ten million variants).
- **Inter-range transforms are a wash that tips to DuckDB at scale.** `reduce()`
  and `disjoin()` are close; in-memory `GRanges` edges `reduce()` at a million
  peaks, but DuckDB pulls ahead on both by ten million ranges (`disjoin()` ~2x).
- **Overlap enumeration favors GRanges, decisively.** `findOverlaps()` and
  `subsetByOverlaps()` are backed by in-memory interval trees that a set-based SQL
  join does not beat (GRanges is ~100x faster here). Hand these to a materialized
  `GRanges`.
- **The two compose.** The *filter, then materialize* pattern uses DuckDB to cut a
  large set down cheaply, then hands the small result to `GRanges` for the
  interval-tree step --- each backend where it is strongest.

# Backend comparison

| Feature | GRanges | DuckDBGRanges |
|---------|---------|---------------|
| Storage | Memory | Parquet file |
| Object footprint | Proportional to N | Constant (~175 KB) |
| Filtering | Scans all ranges | Predicate pushdown (faster) |
| Nearest-feature distance | Interval trees | SQL aggregation (faster) |
| Overlap enumeration | Interval trees (faster) | SQL join |
| `reduce` / `disjoin` | Optimized C | SQL (comparable; faster at scale) |
| Persistence | Session only | Files persist |
| Read by other languages | R only | R, Python, Julia, ... |

## When to use each

- **`GRanges`** --- data fits comfortably in RAM; the fastest choice for
  overlap-heavy work and small-to-mid range sets.
- **`DuckDBGRanges`** --- larger-than-memory interval sets, filter-heavy pipelines,
  keeping a huge annotation referenceable at constant memory cost, or when the
  Parquet files are shared with non-R tooling. Combine with `GRanges` via *filter,
  then materialize*.

# Running your own benchmarks

`inst/scripts/run_vignette_benchmarks.R` reproduces these numbers on your hardware
and scales to other range counts (`BENCH_SCATAC_PEAKS`, `BENCH_VARIANTS`,
`BENCH_CORES`; set small counts to smoke-test first). It writes
`benchmark_results.rds`, which this vignette renders via
`inst/scripts/make_timings_table.R`.

For higher-level genomic workflows built on this backend, see the
`r Biocpkg("BiocDuckDB")` package.

# Session information

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