Sys.setenv(POLARS_MAX_THREADS = as.character(params$threads))
Sys.setenv(RETICULATE_PYTHON = normalizePath(".venv/Scripts/python.exe", mustWork = FALSE))Tools for working with large data in R
Base R, data.table, Arrow, DuckDB and Polars — benchmarked honestly
1 Introduction
When a dataset stops fitting comfortably in memory, the tool you reach for matters more than the code you write with it. This workshop works through one realistic genomics task on a 46-million-row table, implementing it about twenty different ways — from read.table() and aggregate() through to Polars’ streaming engine and DuckDB’s out-of-core SQL — and measures what each one actually costs in time and memory.
Two things distinguish this version of the tutorial from the one I first wrote in 2024:
- The benchmarking is honest. The original measured memory with
profmem, which only sees allocations made through R’s own allocator. DuckDB, Polars and Arrow allocate in C++/Rust heaps, so they reported approximately zero memory used — which flattered them enormously and was simply wrong. Section 4 explains the problem and replaces it with a sampled peak-RSS measurement. - The tools have moved. Polars had a complete API rewrite, DuckDB reached 1.0 and then 1.5, and duckplyr went from “doesn’t quite work” to a genuine drop-in for dplyr. Code from the 2024 edition does not all still run. Section 2 lists the breaking changes.
The params block at the top of the source controls cost:
scale: "demo"builds a 200-sample subset and renders in a few minutes. Use this while you are editing.scale: "full"uses all 46M rows. Expect a long render, mostly because ofread.table().run_read_table: falseskips the single slowest chunk.threadscaps every engine to the same number of cores. Comparing 16-thread DuckDB against single-threaded base R is not a benchmark, it is a press release.
2 What changed since 2024
If you are updating your own materials, these are the changes that will actually break code.
2.1 Polars: the 1.0 rewrite
r-polars was rewritten from scratch. The version here is 1.13.0, tracking Python Polars 1.42. Old code fails in ways that range from loud to dangerous:
| 2024 code | Status now | Replacement |
|---|---|---|
pl$DataFrame(df) |
Silently wrong | as_polars_df(df) |
$to_data_frame() |
Removed | as.data.frame() |
$to_r(), $to_list() |
Removed | as.vector(), as.list() |
pl$dtypes$Float64 |
Removed | pl$Float64 |
$collect(streaming = TRUE) |
Hard error | $collect(engine = "streaming") |
pl$col(c("a","b")) |
Error | pl$col(!!!c("a","b")) |
$profile() |
Being retired | $explain() + bench::mark() |
pl$DataFrame(df) no longer errors on a data frame — it silently wraps the whole thing into a single struct column. Every downstream pl$col("samplename") then fails with a confusing message about a missing column, and if your pipeline happens not to reference a column by name you can get a plausible-looking wrong answer. We demonstrate this in Section 7.2.
Use as_polars_df() / as_polars_lf() instead. Always.
Polars is not on CRAN (archived in 2023 over Rust vendoring policy, never restored). Install it from R-multiverse:
Sys.setenv(NOT_CRAN = "true") # use the pre-built Rust binary instead of compiling
install.packages("polars", repos = "https://community.r-multiverse.org")
install.packages("tidypolars", repos = "https://community.r-multiverse.org")If you have old code you cannot rewrite today, the pre-rewrite API is published separately as polars0 and installs alongside the new one.
2.2 DuckDB: 1.0 → 1.5
DuckDB hit 1.0 in June 2024 with a storage format guarantee, and is now at 1.5.5. Highlights that matter here: an external file cache (1.3) that makes repeated remote Parquet scans dramatically faster; a rewritten k-way merge sort (1.4 LTS) that behaves far better out-of-core; MERGE INTO, database encryption and Iceberg writes (1.4); and the VARIANT type plus core GEOMETRY (1.5).
Day to day the friendly SQL dialect matters more than any of that — GROUP BY ALL, SELECT * EXCLUDE (...), QUALIFY, COUNT(*) FILTER (...) and FROM-first queries all remove boilerplate, and we use them throughout.
2.3 duckplyr became real
The 2024 edition of this tutorial contained the line “I have found it to not behave as expected for complicated queries.” That is no longer a fair assessment. duckplyr 1.2.1 is part of the tidyverse and is a genuine drop-in: same syntax and same semantics as dplyr, with automatic fallback to dplyr for anything DuckDB cannot do. Section 7.3.3 covers its two new concepts, prudence and fallback.
3 Setup
POLARS_MAX_THREADS is read when the Rust thread pool initializes and cannot be changed after library(polars). It has to be set before the package is loaded — so this is the first executable line in the document.
suppressPackageStartupMessages({
library(dplyr); library(ggplot2); library(data.table); library(collapse)
library(arrow); library(DBI); library(duckdb); library(dbplyr)
library(duckplyr, warn.conflicts = FALSE)
library(polars); library(tidypolars)
library(dtplyr); library(bench); library(knitr)
})
source("bench_helpers.R") # measure(), run_suite(), canon() -- see @sec-bench
# Apply the same thread cap everywhere we can
data.table::setDTthreads(params$threads)
arrow::set_cpu_count(params$threads)
options(duckdb.threads = params$threads)
theme_set(theme_minimal(base_size = 12) +
theme(legend.position = "none",
panel.grid.minor = element_blank(),
plot.title.position = "plot"))
fam_pal <- c("base R" = "#7B6B8F", "tidyverse" = "#4C6EA8", "data.table" = "#3E8E7E",
"collapse" = "#6AA84F", "arrow" = "#C0873F", "polars" = "#B4534B",
"DuckDB" = "#2F6D8C", "other" = "#8A8A8A")Machine and package versions
hw <- hardware_info()
tibble::tibble(
Item = c("OS", "R", "CPU (logical / physical)", "RAM", "Thread cap for benchmarks"),
Value = c(hw$os, hw$r_version,
paste(hw$logical_cpu, "/", hw$phys_cpu),
sprintf("%.1f GB", hw$ram_gb),
as.character(params$threads))
) |> kable()| Item | Value |
|---|---|
| OS | Windows 10 x64 |
| R | 4.5.1 |
| CPU (logical / physical) | 16 / 8 |
| RAM | 93.6 GB |
| Thread cap for benchmarks | 8 |
Machine and package versions
pkgs <- c("polars", "tidypolars", "duckdb", "duckplyr", "arrow", "data.table",
"dplyr", "dtplyr", "collapse", "dbplyr", "bench", "fst", "qs2", "nanoarrow")
tibble::tibble(Package = pkgs, Version = vapply(pkgs, pkg_ver, character(1))) |>
kable(caption = "Package versions used for these results")| Package | Version |
|---|---|
| polars | 1.13.0 |
| tidypolars | 0.19.0 |
| duckdb | 1.5.5 |
| duckplyr | 1.2.1 |
| arrow | 25.0.0 |
| data.table | 1.17.8 |
| dplyr | 1.2.0 |
| dtplyr | 1.3.3 |
| collapse | 2.1.7 |
| dbplyr | 2.5.1 |
| bench | 1.1.4 |
| fst | 0.9.8 |
| qs2 | 0.2.2 |
| nanoarrow | 0.8.0.1 |
3.1 The dataset
The example is aggregated CliPP output for all PCAWG samples. To avoid any issues with protected data, the file below has the same shape — same row count, same sample count, same column structure — as the real CliPP output, but the values are randomly generated.
FULL_TSV <- "fake_pcawg_all.tsv"
FULL_PQ <- "fake_pcawg_all.parquet"
con0 <- dbConnect(duckdb())
if (identical(params$scale, "demo")) {
# A 200-sample subset: same structure, ~7% of the rows, renders quickly.
PQ <- "dev_subsample.parquet"
if (!file.exists(PQ)) {
dbExecute(con0, sprintf("
COPY (SELECT * FROM read_parquet('%s') WHERE samplename IN (
SELECT samplename FROM read_parquet('%s')
GROUP BY samplename ORDER BY samplename LIMIT 200))
TO '%s' (FORMAT PARQUET, COMPRESSION ZSTD)", FULL_PQ, FULL_PQ, PQ))
}
TSV <- NA_character_ # the TSV read benchmarks only make sense at full scale
# The shipped .duckdb file holds the full table; build a matching subset one so
# that every method in @sec-compute is answering the same question.
DB <- "dev_subsample.duckdb"
if (!file.exists(DB)) {
cdb <- dbConnect(duckdb(), DB)
dbExecute(cdb, sprintf("CREATE TABLE pcawg_all AS SELECT * FROM read_parquet('%s')", PQ))
dbDisconnect(cdb, shutdown = TRUE)
}
} else {
PQ <- FULL_PQ
TSV <- FULL_TSV
DB <- "pcawg_data.duckdb"
}
shape <- dbGetQuery(con0, sprintf(
"SELECT COUNT(*) AS rows, COUNT(DISTINCT samplename) AS samples FROM read_parquet('%s')", PQ))
schema_tbl <- dbGetQuery(con0, sprintf(
"SELECT column_name, column_type FROM (DESCRIBE SELECT * FROM read_parquet('%s'))", PQ))
dbDisconnect(con0, shutdown = TRUE)
cat(sprintf("scale = %s\n%s rows x %d columns, %s samples\n",
params$scale, format(shape$rows, big.mark = ","),
nrow(schema_tbl), format(shape$samples, big.mark = ",")))scale = full
46,115,588 rows x 15 columns, 2,778 samples
Columns
kable(schema_tbl, caption = "Schema")| column_name | column_type |
|---|---|
| samplename | VARCHAR |
| chromosome | INTEGER |
| position | DOUBLE |
| alt_count | INTEGER |
| ref_count | INTEGER |
| VAF | DOUBLE |
| cluster_id | INTEGER |
| Subclonality | VARCHAR |
| purity | DOUBLE |
| major_cn | INTEGER |
| minor_cn | INTEGER |
| total_cn | INTEGER |
| b_i^V | INTEGER |
| CP_unpenalized | DOUBLE |
| CCF_unpenalized | DOUBLE |
3.2 The task: subclonal mutation fraction (sMF)
For each sample we want the fraction of mutations that are subclonal. Every mutation is assigned to a cluster; the cluster with the lowest cluster_id is the clonal cluster, and everything else is subclonal.
\[ \text{sMF}_s = \frac{n^{\text{subclonal}}_s}{n^{\text{SNV}}_s} = 1 - \frac{\#\{i \in s : \texttt{cluster\_id}_i = \min_{j \in s} \texttt{cluster\_id}_j\}}{\#\{i \in s\}} \]
It is deliberately unglamorous: a grouped minimum, a comparison against it, and two grouped counts. What makes it interesting as a benchmark is that the grouped minimum has to be broadcast back to every row before the counting happens, so an engine either needs a window function, a join, or two passes over the data. That is exactly the shape of computation where engines diverge.
Every implementation below must return one row per sample with columns samplename, n_snvs, n_clonal, n_subclonal, sMF.
4 How to benchmark this honestly
Before any numbers, the measurement. This section exists because the 2024 version of this tutorial got it wrong, and the way it got it wrong is the single most common mistake in R benchmarking blog posts.
4.1 The bug: R’s profiler cannot see foreign memory
The original harness was built on profmem:
mem_profile <- profmem::profmem(expr)
total_mem_gb <- sum(mem_profile$bytes) / 1024^3
peak_mem_gb <- max(mem_profile$bytes) / 1024^3 # <- not a peakThere are two independent problems.
profmem only sees R’s allocator. It hooks Rprofmem, which records allocations made through R’s memory manager. DuckDB, Polars and Arrow are compiled C++/Rust libraries that call malloc themselves. To profmem they are invisible. The same blind spot affects bench::mark()’s mem_alloc column and the peakRAM package — all three report zero for a gigabyte allocated in C++.
max(bytes) is not a peak. It is the size of the largest single allocation. A workload that allocates 1 GB five times over shows a “peak” of 1 GB whether those allocations coexist or not. And sum(bytes) is worse — it is total allocation churn, which over-reports badly whenever R copies a vector.
Here is the discrepancy on real data. Both columns describe the same DuckDB query:
con <- dbConnect(duckdb())
q_read <- sprintf("SELECT * FROM read_parquet('%s')", PQ)
pm <- profmem::profmem({ x <- dbGetQuery(con, q_read); nrow(x) }); rm(x); gc(FALSE) used (Mb) gc trigger (Mb) max used (Mb)
Ncells 1859385 99.4 3716928 198.6 2358165 126.0
Vcells 3238973 24.8 447490818 3414.1 510590528 3895.5
rss <- measure({ x <- dbGetQuery(con, q_read); nrow(x) }); rm(x); gc(FALSE) used (Mb) gc trigger (Mb) max used (Mb)
Ncells 1893029 101.1 3716928 198.6 1948697 104.1
Vcells 3305162 25.3 537132556 4098.0 510634516 3895.9
dbDisconnect(con, shutdown = TRUE)
tibble::tibble(
Measurement = c("profmem: sum(bytes)", "profmem: max(bytes) [the old 'peak']",
"Sampled peak RSS [reality]"),
GB = c(sum(pm$bytes, na.rm = TRUE)/GB, max(pm$bytes, na.rm = TRUE)/GB, rss$delta_gb)
) |> kable(digits = 3, caption = "Same DuckDB query, three 'memory' numbers")| Measurement | GB |
|---|---|
| profmem: sum(bytes) | 3.779 |
| profmem: max(bytes) [the old ‘peak’] | 0.344 |
| Sampled peak RSS [reality] | 9.779 |
The R-level profiler misses essentially all of it, because essentially all of it was allocated by DuckDB.
4.2 The fix: sample the operating system’s view
The only reliable measurement is the resident set size (RSS) of the whole R process, sampled from outside while the expression runs. Since R is blocked during the call, the sampler has to live in a separate process. bench_helpers.R implements this:
measure(expr) # -> elapsed, cpu, peak_gb, delta_gb, r_alloc_gb, n_samplesA background R process polls this process’s RSS every 50 ms and republishes its running maximum to a file, so we can kill it at any point and still recover the value. Two details matter:
- Do not poll
ps::ps_children()— it costs ~850 ms per call on Windows, which would leave you with one sample per run. It is also unnecessary: DuckDB, Polars and Arrow are loaded into the R process, so R’s own RSS already includes them. You would only need child accounting if you shelled out to a separateduckdb.exe. - Peak working set cannot be reset on Windows.
ps_memory_info()$peak_wsetis monotonic for the life of the process and no API resets it, so it is only usable for the first measurement in a session. Sampling sidesteps this entirely.
n_samples before you trust a memory number
At a 50 ms sampling interval, a 30 ms workload gets one or two samples and its peak is basically noise — it can even come out slightly negative, since the baseline is taken after a gc() that may have released more than the workload allocates. Memory figures below are meaningful for the multi-second workloads and should be ignored for the fast ones. Time is measured exactly regardless.
4.3 Three more things that quietly ruin benchmarks
Wall time and CPU time are different questions. system.time() reports user as CPU summed across all threads, so a well-parallelized engine shows user far greater than elapsed. Only elapsed is comparable across engines — reporting user would rank the fastest engine last. The ratio is informative in its own right, so the tables below carry a parallelism column (cpu / elapsed).
The page cache. After one read, a file sits in the OS cache and every subsequent read is warm — often several times faster. Whichever engine you benchmark second wins. On Windows you cannot drop the cache from R (there is no /proc/sys/vm/drop_caches; you would need Sysinternals RAMMap or EmptyStandbyList.exe, both external and both needing admin). The reproducible alternative is to standardize on warm: read the file once before timing anything, and say so.
warm <- function(path) if (!is.na(path) && file.exists(path))
invisible(readBin(path, "raw", n = file.size(path)))
warm(PQ)
gc(full = TRUE) used (Mb) gc trigger (Mb) max used (Mb)
Ncells 1892588 101.1 3716928 198.6 1973185 105.4
Vcells 151089593 1152.8 429706045 3278.4 510634516 3895.9
The lazy-evaluation trap. Polars scan_*, DuckDB dbSendQuery, arrow::open_dataset and dbplyr’s tbl() all return immediately without doing any work. Time one of those without materializing and you have benchmarked query planning — microseconds — and will conclude Polars is a thousand times faster than it is. Every lazy pipeline below ends in collect(), and run_suite() checks the returned result, so a pipeline that did nothing cannot pass.
4.4 The correctness check nobody runs
A fast wrong answer is not a benchmark result. Engines differ in row order, in integer versus double types, and in tibble versus data.frame versus data.table containers, so results need canonicalizing before comparison. canon() does that, and run_suite() compares every method against the first one:
canon() – reduce any engine’s output to one comparable form
print(canon)function (x, digits = 8)
{
if (inherits(x, "polars_data_frame") || inherits(x, "polars_lazy_frame")) {
if (inherits(x, "polars_lazy_frame"))
x <- x$collect()
x <- as.data.frame(x)
}
x <- as.data.frame(x, stringsAsFactors = FALSE)
keep <- c("samplename", "n_snvs", "n_clonal", "n_subclonal",
"sMF")
stopifnot(all(keep %in% names(x)))
x <- x[, keep, drop = FALSE]
x$samplename <- as.character(x$samplename)
x$n_snvs <- as.numeric(x$n_snvs)
x$n_clonal <- as.numeric(x$n_clonal)
x$n_subclonal <- as.numeric(x$n_subclonal)
x$sMF <- round(as.numeric(x$sMF), digits)
x <- x[order(x$samplename), , drop = FALSE]
rownames(x) <- NULL
x
}
Any method whose answer diverges is reported as MISMATCH rather than being quietly ranked. This caught two genuine bugs while updating this document, both described in Section 7.
5 Part 1 — Storage formats
The single largest speedup available is usually not a faster library. It is not storing 6.5 GB of numbers as text.
fmt_dir <- "format_bakeoff"; dir.create(fmt_dir, showWarnings = FALSE)
p <- function(f) file.path(fmt_dir, f)
d_mem <- as.data.frame(arrow::read_parquet(PQ)) # one in-memory copy to write from
write_specs <- list(
list(name = "TSV (text)", file = p("d.tsv"), needs = "data.table",
write = function() data.table::fwrite(d_mem, p("d.tsv"), sep = "\t")),
list(name = "TSV + gzip", file = p("d.tsv.gz"), needs = "data.table",
write = function() data.table::fwrite(d_mem, p("d.tsv.gz"), sep = "\t", compress = "gzip")),
list(name = "Parquet (snappy)", file = p("d_snappy.parquet"), needs = "arrow",
write = function() arrow::write_parquet(d_mem, p("d_snappy.parquet"), compression = "snappy")),
list(name = "Parquet (zstd)", file = p("d_zstd.parquet"), needs = "arrow",
write = function() arrow::write_parquet(d_mem, p("d_zstd.parquet"), compression = "zstd")),
list(name = "Arrow IPC / feather", file = p("d.arrow"), needs = "arrow",
write = function() arrow::write_feather(d_mem, p("d.arrow"), compression = "lz4")),
list(name = "fst", file = p("d.fst"), needs = "fst",
write = function() fst::write_fst(d_mem, p("d.fst"), compress = 50)),
list(name = "qs2", file = p("d.qs2"), needs = "qs2",
write = function() qs2::qs_save(d_mem, p("d.qs2")))
)
# Drop any format whose package will not load on this machine, rather than
# aborting the whole document.
write_specs <- Filter(function(s) all(vapply(s$needs, have_pkg, logical(1))), write_specs)
fmt_rows <- lapply(write_specs, function(s) {
m <- measure(s$write(), sample_mem = FALSE)
data.frame(format = s$name, write_s = m$elapsed,
size_gb = file.info(s$file)$size / GB, stringsAsFactors = FALSE)
})
fmt <- do.call(rbind, fmt_rows)
fmt$vs_tsv <- fmt$size_gb[1] / fmt$size_gb
rm(d_mem); gc(full = TRUE) used (Mb) gc trigger (Mb) max used (Mb)
Ncells 2048485 109.5 3716928 198.6 2068716 110.5
Vcells 3579853 27.4 978975288 7469.0 510947358 3898.3
kable(fmt, digits = 2, col.names = c("Format", "Write (s)", "Size (GB)", "x smaller than TSV"),
caption = "Same data, seven encodings")| Format | Write (s) | Size (GB) | x smaller than TSV |
|---|---|---|---|
| TSV (text) | 2.97 | 6.40 | 1.00 |
| TSV + gzip | 20.02 | 1.70 | 3.76 |
| Parquet (snappy) | 11.07 | 1.09 | 5.85 |
| Parquet (zstd) | 11.45 | 1.01 | 6.32 |
| Arrow IPC / feather | 5.13 | 1.92 | 3.34 |
| fst | 1.67 | 2.10 | 3.04 |
| qs2 | 7.10 | 0.97 | 6.60 |
fmt |>
transform(format = factor(format, levels = format[order(size_gb)])) |>
ggplot(aes(size_gb, format, fill = size_gb)) +
geom_col(width = .68) +
geom_text(aes(label = sprintf("%.2f GB", size_gb)), hjust = -0.12, size = 3.6) +
scale_fill_gradient(low = "#2F6D8C", high = "#B4534B") +
scale_x_continuous(expand = expansion(mult = c(0, .18))) +
labs(title = "File size by storage format", x = "GB on disk", y = NULL)
5.1 What Parquet actually buys you
Parquet is a binary, columnar, compressed format. Data is stored column by column rather than row by row, in independently-compressed chunks called row groups (this file has 44 of them), each carrying min/max statistics for its columns.
That layout produces three distinct wins, and it is worth separating them because only the first is about file size:
- Compression. Values within a column are homogeneous, so they compress far better than a row of mixed types. Snappy is fast to decompress; zstd is meaningfully smaller for a little more CPU and is the better default today.
- Projection pushdown. Reading 2 of 15 columns reads roughly 2/15ths of the bytes. In a row-oriented format you must read every row in full and discard.
- Predicate pushdown. Row-group statistics let a reader skip whole chunks that cannot contain matching rows, without decompressing them.
The last two are properties of the reader, not the file, which is why they show up in the Polars and DuckDB numbers and not in the base R ones. We measure them directly in Section 6.3.
The costs are real but narrow: Parquet is not human-readable, the overhead is not worth it for small data, and writes are slower than appending to a CSV. For a workshop dataset that is read many times and written once, that trade is overwhelmingly favorable.
5.2 .duckdb files versus Parquet
A .duckdb file is DuckDB’s own storage format. Compared with Parquet it trades portability for query speed: it carries indexes and zone maps, supports transactional updates, and DuckDB queries it faster than it queries Parquet. But only DuckDB reads it, and it is larger — 3.33 GB here against 1.10 GB for snappy Parquet.
Rule of thumb: Parquet for data you share or archive, .duckdb for a working set you query repeatedly. Since DuckDB 1.0 the storage format is stable across versions, so a .duckdb file is no longer a throwaway artifact.
6 Part 2 — Reading data
6.1 Reading text
This is the section that motivates everything else. read.table() on the full 6.5 GB TSV is the slowest thing in the document by a wide margin — it parses text in R, guesses types by scanning, and grows vectors as it goes.
text_methods <- list(
bench_method("data.table::fread", "data.table",
function() data.table::fread(TSV, nThread = params$threads),
"parallel C parser"),
bench_method("vroom (ALTREP, lazy)", "tidyverse",
function() vroom::vroom(TSV, delim = "\t", show_col_types = FALSE,
num_threads = params$threads),
"builds an index only -- see callout"),
bench_method("vroom (materialized)", "tidyverse",
function() vroom::vroom(TSV, delim = "\t", show_col_types = FALSE,
num_threads = params$threads, altrep = FALSE),
"forced to read every column"),
bench_method("readr::read_delim", "tidyverse",
function() readr::read_delim(TSV, delim = "\t", show_col_types = FALSE,
num_threads = params$threads),
"second-gen tidyverse parser"),
bench_method("polars scan_csv", "polars",
function() pl$scan_csv(TSV, separator = "\t")$collect(engine = "streaming"),
"Rust, multithreaded"),
bench_method("DuckDB read_csv", "DuckDB",
function() {
cc <- dbConnect(duckdb(), config = list(threads = as.character(params$threads)))
on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
dbGetQuery(cc, sprintf("SELECT * FROM read_csv('%s', delim='\t', header=true)", TSV))
}, "parallel CSV sniffer")
)
if (isTRUE(params$run_read_table)) {
text_methods <- c(list(bench_method("base R read.table", "base R",
function() utils::read.table(TSV, header = TRUE, sep = "\t", quote = "\""),
"the 'don't' baseline")), text_methods)
}
read_res <- run_suite(text_methods, reference = NULL, sample_mem = TRUE)read_res |>
transform(GB_per_s = (file.size(TSV)/GB) / elapsed) |>
subset(select = c(method, note, elapsed, peak_gb, parallelism, GB_per_s)) |>
kable(digits = 2, caption = "Reading a 6.5 GB tab-separated file",
col.names = c("Method", "Note", "Elapsed (s)", "Peak RSS (GB)", "CPU/wall", "GB/s"))| Method | Note | Elapsed (s) | Peak RSS (GB) | CPU/wall | GB/s |
|---|---|---|---|---|---|
| base R read.table | the ‘don’t’ baseline | 297.21 | 20.43 | 0.99 | 0.02 |
| data.table::fread | parallel C parser | 2.88 | 14.97 | 4.04 | 2.27 |
| vroom (ALTREP, lazy) | builds an index only – see callout | 3.36 | 17.29 | 7.42 | 1.94 |
| vroom (materialized) | forced to read every column | 13.00 | 21.88 | 4.61 | 0.50 |
| readr::read_delim | second-gen tidyverse parser | 13.05 | 21.87 | 4.62 | 0.50 |
| polars scan_csv | Rust, multithreaded | 4.20 | 19.13 | 3.61 | 1.55 |
| DuckDB read_csv | parallel CSV sniffer | 5.35 | 17.47 | 4.94 | 1.22 |
vroom rows are the lazy trap, live
vroom is deliberately lazy: by default it builds an index of where each field starts and returns immediately, materializing a column only when you touch it. The “ALTREP, lazy” row above therefore measures index construction, not reading, and it will look impossibly fast next to everything else.
That is not a criticism of vroom — deferring work you may never need is a genuinely good strategy, and if you only use three of fifteen columns you never pay for the other twelve. It is a criticism of benchmarking it naively. The “materialized” row passes altrep = FALSE to force a like-for-like comparison, and that is the number to read against fread and the others.
This is the same trap as timing a Polars scan_* without collect(), and it is easy to fall into precisely because the tool is behaving sensibly.
read.table() is so much slower
It is not merely that the parser is written in R. read.table() reads the file twice by default (once to infer types), builds every column as a growing R vector, and converts strings one at a time through R’s global string cache. fread and the Rust/C++ parsers memory-map the file, split it into chunks, and parse those chunks in parallel straight into typed buffers.
Note also the original 2024 tutorial’s call: read.table(file, sep = "\t") with no header = TRUE. That silently reads the header line as data and names the columns V1…V15, which is a good example of why the correctness check in Section 4 matters.
6.2 Reading binary formats
bin_methods <- list(
bench_method("arrow::read_parquet", "arrow",
function() as.data.frame(arrow::read_parquet(p("d_snappy.parquet"))), "snappy"),
bench_method("arrow::read_parquet (zstd)", "arrow",
function() as.data.frame(arrow::read_parquet(p("d_zstd.parquet"))), "zstd"),
bench_method("polars read_parquet", "polars",
function() as.data.frame(pl$read_parquet(p("d_snappy.parquet"))), "Rust reader"),
bench_method("DuckDB -> data.frame", "DuckDB",
function() {
cc <- dbConnect(duckdb(), config = list(threads = as.character(params$threads)))
on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
dbGetQuery(cc, sprintf("SELECT * FROM read_parquet('%s')", p("d_snappy.parquet")))
}, "via DBI"),
bench_method("arrow::read_feather", "arrow",
function() as.data.frame(arrow::read_feather(p("d.arrow"))), "Arrow IPC, lz4"),
bench_method("fst::read_fst", "other",
function() fst::read_fst(p("d.fst")), "R-specific, seekable", needs = "fst"),
bench_method("qs2::qs_read", "other",
function() qs2::qs_read(p("d.qs2")), "R serialization", needs = "qs2")
)
# only benchmark readers whose file actually got written above
bin_methods <- Filter(function(m) all(vapply(m$needs, have_pkg, logical(1))), bin_methods)
bin_res <- run_suite(bin_methods, reference = NULL, sample_mem = TRUE)
bin_res |>
subset(select = c(method, note, elapsed, peak_gb, parallelism)) |>
kable(digits = 2, caption = "Reading the same data from binary formats",
col.names = c("Method", "Note", "Elapsed (s)", "Peak RSS (GB)", "CPU/wall"))| Method | Note | Elapsed (s) | Peak RSS (GB) | CPU/wall |
|---|---|---|---|---|
| arrow::read_parquet | snappy | 3.73 | 12.35 | 1.00 |
| arrow::read_parquet (zstd) | zstd | 4.03 | 17.79 | 1.01 |
| polars read_parquet | Rust reader | 10.80 | 18.61 | 1.20 |
| DuckDB -> data.frame | via DBI | 3.63 | 20.19 | 2.61 |
| arrow::read_feather | Arrow IPC, lz4 | 0.66 | 17.90 | 4.02 |
| fst::read_fst | R-specific, seekable | 3.11 | 13.95 | 1.70 |
| qs2::qs_read | R serialization | 4.90 | 13.95 | 1.00 |
fst deserves more attention than it gets
fst is the veteran here and still excellent at what it does: fast, compressed, random-access storage of R data frames. Unlike Parquet it can seek to a row range without decompressing everything before it, and unlike saveRDS it is threaded.
Its limitation is the flip side of its design: it stores R’s own column types, so only R reads it, and it does not handle nested structures. If your data never leaves R and you want the fastest possible round-trip of a rectangular table, fst (or qs2 for arbitrary R objects) frequently beats Parquet. If anyone else needs to read it, use Parquet.
6.3 Projection and predicate pushdown
This is where columnar storage stops being a file-size story. The same query, asked three ways:
push <- list(
bench_method("Read all 15 columns", "arrow",
function() as.data.frame(arrow::read_parquet(p("d_snappy.parquet"))),
"no pushdown"),
bench_method("Read 2 columns (projection)", "arrow",
function() as.data.frame(arrow::read_parquet(
p("d_snappy.parquet"), col_select = c("samplename", "cluster_id"))),
"projection pushdown"),
bench_method("2 columns + row filter", "polars",
function() as.data.frame(pl$scan_parquet(p("d_snappy.parquet"))$
select("samplename", "cluster_id")$
filter(pl$col("cluster_id") == 0L)$collect(engine = "streaming")),
"projection + predicate")
)
push_res <- run_suite(push, reference = NULL, sample_mem = TRUE)
push_res |> subset(select = c(method, note, elapsed, peak_gb)) |>
kable(digits = 2, caption = "You pay for the columns you read, not the columns you have",
col.names = c("Query", "Optimization", "Elapsed (s)", "Peak RSS (GB)"))| Query | Optimization | Elapsed (s) | Peak RSS (GB) |
|---|---|---|---|
| Read all 15 columns | no pushdown | 3.75 | 17.82 |
| Read 2 columns (projection) | projection pushdown | 1.27 | 17.58 |
| 2 columns + row filter | projection + predicate | 4.47 | 16.86 |
Note these deliberately return different answers, so correct is not meaningful here — that column is suppressed above.
You can see the optimizer doing this. Polars will show you the plan it intends to execute, before and after optimization:
lazy_q <- pl$scan_parquet(PQ)$
select("samplename", "cluster_id")$
filter(pl$col("cluster_id") == 0L)
cat("--- as written -------------------------------------------\n")--- as written -------------------------------------------
cat(lazy_q$explain(optimized = FALSE))FILTER [(col("cluster_id")) == (0)]
FROM
SELECT [col("samplename"), col("cluster_id")]
Parquet SCAN [fake_pcawg_all.parquet]
PROJECT */15 COLUMNS
ESTIMATED ROWS: 46115588
cat("\n\n--- after optimization ------------------------------------\n")
--- after optimization ------------------------------------
cat(lazy_q$explain())Parquet SCAN [fake_pcawg_all.parquet]
PROJECT 2/15 COLUMNS
SELECTION: [(col("cluster_id")) == (0)]
ESTIMATED ROWS: 46115588
Read these bottom-up. In the optimized plan the projection has been pushed down into the Parquet scan itself (PROJECT 2/15 COLUMNS) and the filter has become part of the scan rather than a separate pass over materialized data.
DuckDB will tell you the same thing through EXPLAIN, and EXPLAIN ANALYZE adds measured per-operator timings — though note those are summed across threads, so they can exceed the query’s wall time.
$profile()
Polars’ $profile() returned per-node timings and appears in older tutorials. It is being retired upstream — it was built for the previous in-memory engine and its numbers are misleading under the streaming engine — and on this version it raises no data to time for simple queries. Use $explain() to see what will run and bench::mark() to measure how long it takes.
6.4 Partitioned datasets
One file is not the only option. Writing a Hive-partitioned dataset splits rows into a directory tree named by the partitioning column:
dataset/chromosome=1/part-0.parquet
dataset/chromosome=2/part-0.parquet
...
The partition column is then encoded in the path rather than stored in the files, and a query filtering on it can skip whole directories without opening them. This is the standard layout for data lakes, and all three engines here read it natively.
hive_dir <- file.path(tempdir(), "hive_ds")
unlink(hive_dir, recursive = TRUE)
arrow::write_dataset(d_hive <- as.data.frame(arrow::read_parquet(PQ)),
hive_dir, partitioning = "chromosome", format = "parquet")
rm(d_hive); gc(FALSE) used (Mb) gc trigger (Mb) max used (Mb)
Ncells 2529781 135.2 6789076 362.6 6789076 362.6
Vcells 993829800 7582.4 1908491198 14560.7 1617599944 12341.4
cat(length(list.dirs(hive_dir, recursive = FALSE)), "partitions:",
paste(head(basename(list.dirs(hive_dir, recursive = FALSE)), 3), collapse = ", "), "...\n")22 partitions: chromosome=1, chromosome=10, chromosome=11 ...
hive_glob <- gsub("\\\\", "/", hive_dir)
hive_res <- run_suite(list(
bench_method("arrow: scan all partitions", "arrow",
function() arrow::open_dataset(hive_dir) |> count() |> collect(), "no pruning"),
bench_method("arrow: prune to chr 1", "arrow",
function() arrow::open_dataset(hive_dir) |> filter(chromosome == 1) |>
count() |> collect(), "partition pruning"),
bench_method("polars: prune to chr 1", "polars",
function() as.data.frame(pl$scan_parquet(hive_dir)$
filter(pl$col("chromosome") == 1L)$select(pl$len()$alias("n"))$collect()),
"hive auto-detected"),
bench_method("DuckDB: prune to chr 1", "DuckDB",
function() {
cc <- dbConnect(duckdb(),
config = list(threads = as.character(params$threads)))
on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
dbGetQuery(cc, sprintf(
"SELECT COUNT(*) n FROM read_parquet('%s/**/*.parquet', hive_partitioning=true)
WHERE chromosome = 1", hive_glob))
}, "glob + pruning")
), sample_mem = FALSE)
hive_res |> subset(select = c(method, note, elapsed)) |>
kable(digits = 3, caption = "Partition pruning: filtering on the partition column never opens the other directories",
col.names = c("Query", "Note", "Elapsed (s)"))| Query | Note | Elapsed (s) |
|---|---|---|
| arrow: scan all partitions | no pruning | 1.22 |
| arrow: prune to chr 1 | partition pruning | 0.12 |
| polars: prune to chr 1 | hive auto-detected | 0.02 |
| DuckDB: prune to chr 1 | glob + pruning | 0.08 |
unlink(hive_dir, recursive = TRUE)Partition on a column you filter by constantly and that has modest cardinality — chromosome (22–25 values) is a good choice; samplename (2,778 values) would be a bad one, and position would be catastrophic. Thousands of tiny files cost far more in per-file overhead than pruning saves.
The other half of the rule is that pruning only helps queries that filter on the partition column. Partitioning by chromosome does nothing for a query grouped by samplename — which is exactly our sMF task, and why the rest of this document uses a single file.
7 Part 3 — Computing sMF
Now the actual work. Every method below produces the same table; run_suite() verifies that.
7.1 In-memory data frame methods
d <- as.data.frame(arrow::read_parquet(PQ))
dt <- as.data.table(d)
cat(sprintf("in-memory data frame: %s rows, %.2f GB\n",
format(nrow(d), big.mark = ","), as.numeric(object.size(d))/GB))in-memory data frame: 46,115,588 rows, 3.78 GB
7.1.1 Base R
The canonical approach uses ave() to broadcast the grouped minimum, then aggregate() to count.
sml_base_aggregate <- function() {
cl <- ave(d$cluster_id, d$samplename, FUN = min)
isc <- d$cluster_id == cl
a <- aggregate(list(n_snvs = d$cluster_id), by = list(samplename = d$samplename), FUN = length)
b <- aggregate(list(n_clonal = isc), by = list(samplename = d$samplename), FUN = sum)
r <- merge(a, b, by = "samplename") # NOT cbind() -- see the callout
r$n_subclonal <- r$n_snvs - r$n_clonal
r$sMF <- r$n_subclonal / r$n_snvs
r
}
sml_base_tapply <- function() {
cl <- tapply(d$cluster_id, d$samplename, min)
isc <- d$cluster_id == cl[d$samplename]
n1 <- tapply(d$cluster_id, d$samplename, length)
n2 <- tapply(isc, d$samplename, sum)
data.frame(samplename = names(n1), n_snvs = as.numeric(n1), n_clonal = as.numeric(n2),
n_subclonal = as.numeric(n1 - n2), sMF = as.numeric((n1 - n2) / n1))
}The 2024 version combined the two aggregates with cbind():
result <- cbind(n_snvs_result, n_clonal_result) # both have a samplename columnThis produces a frame with two samplename columns and — more importantly — relies on both aggregate() calls returning rows in exactly the same order. They happen to, because aggregate() sorts by the grouping variable, so the answer came out right. But it is right by coincidence: change one aggregation to something that drops empty groups and it silently misaligns every row. merge() states the intent and is safe.
7.1.2 collapse
collapse is the least famous package in this document and one of the most useful. It provides C/C++ implementations of grouped statistics that keep base R’s data structures — no new frontend to learn, no lazy frames, just fast versions of operations you already do. TRA = "replace" broadcasts a grouped statistic back over the original rows, which is precisely the awkward step in this task.
sml_collapse <- function() {
cl <- fmin(d$cluster_id, g = d$samplename, TRA = "replace")
isc <- d$cluster_id == cl
n1 <- fnobs(d$cluster_id, g = d$samplename)
n2 <- fsum(isc, g = d$samplename)
data.frame(samplename = names(n1), n_snvs = as.numeric(n1), n_clonal = as.numeric(n2),
n_subclonal = as.numeric(n1 - n2), sMF = as.numeric((n1 - n2) / n1))
}7.1.3 dplyr, data.table, dtplyr
sml_dplyr <- function() {
d |>
summarize(n_snvs = n(), n_clonal = sum(cluster_id == min(cluster_id)), .by = samplename) |>
mutate(n_subclonal = n_snvs - n_clonal, sMF = n_subclonal / n_snvs)
}
sml_datatable <- function() {
r <- dt[, .(n_snvs = .N, n_clonal = sum(cluster_id == min(cluster_id))), by = samplename]
r[, `:=`(n_subclonal = n_snvs - n_clonal, sMF = (n_snvs - n_clonal) / n_snvs)][]
}
sml_dtplyr <- function() {
dtplyr::lazy_dt(d) |>
group_by(samplename) |>
summarize(n_snvs = n(), n_clonal = sum(cluster_id == min(cluster_id))) |>
mutate(n_subclonal = n_snvs - n_clonal, sMF = n_subclonal / n_snvs) |>
as.data.frame()
}.by = samplename is worth noting: it does per-operation grouping without the group_by() / ungroup() dance, and it does not sort the result, which saves real time on 2,778 groups.
7.2 Polars
Polars is a DataFrame library written in Rust. Three things make it fast: a columnar (Arrow) memory layout, genuine multithreading throughout, and — in lazy mode — a query optimizer that rewrites your pipeline before running it.
pl$DataFrame() trap
Before the benchmarks, see the failure mode described in Section 2:
tiny <- head(d, 3)
cat("as_polars_df() columns:\n"); print(as_polars_df(tiny)$columns)as_polars_df() columns:
[1] "samplename" "chromosome" "position" "alt_count"
[5] "ref_count" "VAF" "cluster_id" "Subclonality"
[9] "purity" "major_cn" "minor_cn" "total_cn"
[13] "b_i^V" "CP_unpenalized" "CCF_unpenalized"
cat("\npl$DataFrame() columns:\n"); print(pl$DataFrame(tiny)$columns)
pl$DataFrame() columns:
[1] ""
One unnamed struct column, no error. Use as_polars_df().
7.2.1 Eager, lazy, and streaming
# The idiomatic formulation: group_by + agg, computing the grouped minimum inline.
polars_pipeline <- function(frame) {
frame$
group_by("samplename")$
agg(
pl$len()$alias("n_snvs"),
(pl$col("cluster_id") == pl$col("cluster_id")$min())$sum()$alias("n_clonal")
)$
with_columns((pl$col("n_snvs") - pl$col("n_clonal"))$alias("n_subclonal"))$
with_columns((pl$col("n_subclonal") / pl$col("n_snvs"))$alias("sMF"))
}
sml_polars_eager <- function() polars_pipeline(as_polars_df(d))
sml_polars_lazy <- function() polars_pipeline(as_polars_lf(d))$collect()
sml_polars_scan <- function() polars_pipeline(pl$scan_parquet(PQ))$collect()
sml_polars_streaming <- function() polars_pipeline(pl$scan_parquet(PQ))$collect(engine = "streaming")The differences are worth being precise about:
- eager does each step immediately, materializing intermediates.
- lazy builds a plan and optimizes it before executing — but still over data already in memory.
- scan starts from the Parquet file, so projection and predicate pushdown can reach all the way into the file, and the full table never enters R at all.
- streaming processes in batches rather than requiring the whole working set in memory at once. This is the engine that was rewritten in 2025; it is selected with
engine = "streaming", not the oldstreaming = TRUE, which now errors.
7.2.2 The 2024 window-function version
For comparison, the formulation the original tutorial used — broadcasting with over() and then de-duplicating — updated to the current API:
out_cols <- c("samplename", "n_snvs", "n_clonal", "n_subclonal", "sMF")
sml_polars_window <- function() {
pl$scan_parquet(PQ)$
with_columns(pl$col("cluster_id")$min()$over("samplename")$alias("clonal_cluster"))$
with_columns((pl$col("cluster_id") == pl$col("clonal_cluster"))$alias("is_clonal"))$
with_columns(
pl$col("samplename")$len()$over("samplename")$alias("n_snvs"),
pl$col("is_clonal")$sum()$over("samplename")$alias("n_clonal")
)$
with_columns((pl$col("n_snvs") - pl$col("n_clonal"))$alias("n_subclonal"))$
with_columns((pl$col("n_subclonal")$cast(pl$Float64) /
pl$col("n_snvs")$cast(pl$Float64))$alias("sMF"))$
select(!!!out_cols)$ # dynamic dots: pl$col(c(...)) is an error now
unique()$
collect(engine = "streaming")
}Two API changes are visible: !!! to splice a character vector into dynamic dots, and pl$Float64 instead of pl$dtypes$Float64. It is also a good illustration of why group_by()/agg() is preferable — the window version expands the frame to full width for every row before collapsing it back down.
$count() versus $len()
$count() counts non-null elements; $len() counts all of them, nulls included. pl$len() is the row counter you almost always want in $agg() — it is COUNT(*). The 2024 code used $count(), which gives the same answer here only because the column has no nulls.
7.2.3 tidypolars
If you want Polars’ speed without learning its syntax, tidypolars supplies dplyr and tidyr methods for Polars frames:
sml_tidypolars <- function() {
pl$scan_parquet(PQ) |>
group_by(samplename) |>
summarize(n_snvs = n(), n_clonal = sum(cluster_id == min(cluster_id))) |>
mutate(n_subclonal = n_snvs - n_clonal, sMF = n_subclonal / n_snvs) |>
collect()
}The catch: untranslated functions error rather than falling back to R (fallback is opt-in via options(tidypolars_fallback_to_r = TRUE) and is documented as potentially crashing the session on large frames). slice(), transmute() and nest()/unnest() are not implemented. It is excellent when it works and abrupt when it does not — the opposite trade-off from duckplyr below.
7.3 DuckDB
DuckDB is an in-process analytical SQL engine — SQLite’s deployment model with a column-store query engine. No server, no configuration, and it queries Parquet files on disk directly without importing them.
7.3.1 SQL over Parquet
duck_sql <- function(source) sprintf("
SELECT samplename,
COUNT(*) AS n_snvs,
COUNT(*) FILTER (cluster_id = clonal_cluster) AS n_clonal,
COUNT(*) - COUNT(*) FILTER (cluster_id = clonal_cluster) AS n_subclonal,
(COUNT(*) - COUNT(*) FILTER (cluster_id = clonal_cluster))::DOUBLE
/ COUNT(*) AS sMF
FROM (SELECT samplename, cluster_id,
MIN(cluster_id) OVER (PARTITION BY samplename) AS clonal_cluster
FROM %s)
GROUP BY ALL", source)
new_con <- function(...) dbConnect(duckdb(),
config = c(list(threads = as.character(params$threads)), list(...)))
sml_duckdb_parquet <- function() {
cc <- new_con(); on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
dbGetQuery(cc, duck_sql(sprintf("read_parquet('%s')", PQ)))
}
sml_duckdb_file <- function() {
cc <- dbConnect(duckdb(), DB, read_only = TRUE,
config = list(threads = as.character(params$threads)))
on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
dbGetQuery(cc, duck_sql("pcawg_all"))
}
# Zero-copy: register an R data frame as a virtual table. No data is copied into
# DuckDB storage -- contrast with dbWriteTable(), which does copy.
sml_duckdb_register <- function() {
cc <- new_con(); on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
duckdb_register(cc, "rdf", d)
dbGetQuery(cc, duck_sql("rdf"))
}
# Arrow result streaming (DBI Arrow API, added in duckdb 1.5.4)
sml_duckdb_arrow <- function() {
cc <- new_con(); on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
res <- dbSendQueryArrow(cc, duck_sql(sprintf("read_parquet('%s')", PQ)))
on.exit(dbClearResult(res), add = TRUE)
as.data.frame(dbFetchArrow(res))
}COUNT(*) FILTER (...) is DuckDB’s friendly-SQL spelling of the classic SUM(CASE WHEN ... THEN 1 ELSE 0 END), and GROUP BY ALL saves restating the grouping columns.
7.3.2 dbplyr: dplyr syntax, SQL execution
sml_dbplyr <- function() {
cc <- new_con(); on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
tbl(cc, sql(sprintf("SELECT * FROM read_parquet('%s')", PQ))) |>
group_by(samplename) |>
mutate(mn = min(cluster_id, na.rm = TRUE)) |> # -> SQL window function
summarize(n_snvs = n(),
n_clonal = sum(as.integer(cluster_id == mn), na.rm = TRUE)) |>
mutate(n_subclonal = n_snvs - n_clonal, sMF = n_subclonal * 1.0 / n_snvs) |>
collect()
}The obvious translation of the dplyr idiom fails:
summarize(n_clonal = sum(cluster_id == min(cluster_id)))
#> Binder Error: aggregate function calls cannot be nestedsum(... min(...)) is an aggregate inside an aggregate. SQL has no such thing. The fix is to compute the minimum in a grouped mutate() first, which dbplyr translates into a window function, and then aggregate. This is a general lesson about dplyr backends: the syntax is identical but the execution model is not, and the places where SQL is stricter than R will surface as errors like this one.
7.3.3 duckplyr
duckplyr drives DuckDB’s relational API in-process. No connection, no SQL: data frame in, data frame out.
sml_duckplyr <- function() {
duckplyr::read_parquet_duckdb(PQ) |>
summarize(n = n(), .by = c(samplename, cluster_id)) |>
mutate(mn = min(cluster_id), .by = samplename) |>
summarize(n_snvs = sum(n), n_clonal = sum(ifelse(cluster_id == mn, n, 0)),
.by = samplename) |>
mutate(n_subclonal = n_snvs - n_clonal, sMF = n_subclonal / n_snvs) |>
collect()
}Two concepts are new since 2024 and worth understanding, because they are how duckplyr avoids the failure modes that made it unreliable back then.
Prudence controls automatic materialization — the guard against silently pulling a larger-than-memory result into RAM. "lavish" always materializes (the default for duckdb_tibble()), "thrifty" only if the result is small (the default for the file readers), and "stingy" never does, which turns any silent fallback into a visible error. "stingy" is the setting you want while developing.
Fallback is what happens when DuckDB cannot express something: duckplyr materializes and hands that one step to dplyr, then carries on. This is why it is a true drop-in — but it also means a pipeline can be quietly slow. Ask:
duckplyr::fallback_sitrep()The two-stage formulation above exists precisely because the natural one-liner — summarize(n_clonal = sum(cluster_id == min(cluster_id))) — is a nested aggregate that DuckDB cannot do, so it falls back to dplyr and loses the advantage. Restructuring into group-then-regroup keeps the whole thing inside DuckDB.
7.4 Arrow / Acero
Arrow’s own query engine handles the two-stage formulation well. The intermediate (one row per sample × cluster) is tiny, so this stays cheap:
sml_arrow <- function() {
arrow::open_dataset(PQ) |>
group_by(samplename, cluster_id) |>
summarize(n = n(), .groups = "drop") |>
collect() |>
group_by(samplename) |>
summarize(n_snvs = sum(n), n_clonal = n[which.min(cluster_id)], .groups = "drop") |>
mutate(n_subclonal = n_snvs - n_clonal, sMF = n_subclonal / n_snvs)
}Acero does not support windowed mutate(), so the direct translation is unavailable — this restructuring is the idiomatic workaround.
7.5 Running the whole zoo
compute_methods <- list(
bench_method("base R aggregate", "base R", sml_base_aggregate, "ave + aggregate + merge"),
bench_method("base R tapply", "base R", sml_base_tapply, "vectorized"),
bench_method("collapse", "collapse", sml_collapse, "C-level grouped stats"),
bench_method("dplyr", "tidyverse", sml_dplyr, ".by = grouping"),
bench_method("dtplyr", "tidyverse", sml_dtplyr, "dplyr -> data.table"),
bench_method("data.table", "data.table", sml_datatable, "native by="),
bench_method("arrow (Acero)", "arrow", sml_arrow, "two-stage"),
bench_method("polars eager", "polars", sml_polars_eager, "in-memory"),
bench_method("polars lazy", "polars", sml_polars_lazy, "optimized, in-memory"),
bench_method("polars scan_parquet", "polars", sml_polars_scan, "from disk"),
bench_method("polars streaming", "polars", sml_polars_streaming,"batched, from disk"),
bench_method("polars window (2024)", "polars", sml_polars_window, "over() formulation"),
bench_method("tidypolars", "polars", sml_tidypolars, "dplyr syntax"),
bench_method("DuckDB parquet", "DuckDB", sml_duckdb_parquet, "SQL over file"),
bench_method("DuckDB .duckdb file", "DuckDB", sml_duckdb_file, "native storage"),
bench_method("DuckDB registered df", "DuckDB", sml_duckdb_register, "zero-copy view of R df"),
bench_method("DuckDB Arrow fetch", "DuckDB", sml_duckdb_arrow, "dbFetchArrow"),
bench_method("dbplyr -> DuckDB", "DuckDB", sml_dbplyr, "dplyr -> SQL"),
bench_method("duckplyr", "DuckDB", sml_duckplyr, "relational API")
)
compute_methods <- require_pkgs(compute_methods)
if (length(attr(compute_methods, "skipped")))
cat("skipped (package unavailable):",
paste(attr(compute_methods, "skipped"), collapse = ", "), "\n")
compute_res <- run_suite(compute_methods, reference = NULL, sample_mem = TRUE)compute_res |>
order_by_time() |>
transform(speedup = max(elapsed, na.rm = TRUE) / elapsed) |>
subset(select = c(method, family, note, elapsed, speedup, peak_gb, parallelism, correct)) |>
kable(digits = 2, caption = "Computing sMF: every method, same answer",
col.names = c("Method", "Family", "Note", "Elapsed (s)", "Speedup",
"Peak RSS (GB)", "CPU/wall", "Correct"))| Method | Family | Note | Elapsed (s) | Speedup | Peak RSS (GB) | CPU/wall | Correct |
|---|---|---|---|---|---|---|---|
| DuckDB parquet | DuckDB | SQL over file | 0.13 | 109.15 | 23.23 | 10.38 | match |
| DuckDB .duckdb file | DuckDB | native storage | 0.15 | 94.60 | 23.37 | 5.80 | match |
| DuckDB Arrow fetch | DuckDB | dbFetchArrow | 0.16 | 88.69 | 23.24 | 8.88 | match |
| duckplyr | DuckDB | relational API | 0.19 | 74.68 | 23.23 | 5.63 | match |
| dbplyr -> DuckDB | DuckDB | dplyr -> SQL | 0.32 | 44.34 | 23.23 | 4.78 | match |
| collapse | collapse | C-level grouped stats | 0.36 | 39.42 | 24.29 | 1.00 | match |
| DuckDB registered df | DuckDB | zero-copy view of R df | 0.36 | 39.42 | 23.22 | 10.83 | match |
| data.table | data.table | native by= | 0.56 | 25.34 | 23.46 | 2.39 | match |
| polars streaming | polars | batched, from disk | 0.56 | 25.34 | 25.86 | 3.55 | match |
| polars scan_parquet | polars | from disk | 0.59 | 24.05 | 25.84 | 3.68 | match |
| tidypolars | polars | dplyr syntax | 0.73 | 19.44 | 25.95 | 2.89 | match |
| dplyr | tidyverse | .by = grouping | 0.74 | 19.18 | 24.46 | 0.91 | match |
| arrow (Acero) | arrow | two-stage | 1.01 | 14.05 | 23.38 | 2.38 | match |
| dtplyr | tidyverse | dplyr -> data.table | 1.02 | 13.91 | 27.31 | 1.71 | match |
| polars window (2024) | polars | over() formulation | 1.14 | 12.45 | 24.03 | 7.96 | match |
| polars eager | polars | in-memory | 2.92 | 4.86 | 30.34 | 1.52 | match |
| polars lazy | polars | optimized, in-memory | 2.92 | 4.86 | 30.29 | 1.56 | match |
| base R tapply | base R | vectorized | 3.79 | 3.74 | 28.20 | 1.00 | match |
| base R aggregate | base R | ave + aggregate + merge | 14.19 | 1.00 | 28.23 | 1.00 | reference |
compute_res |>
subset(!is.na(elapsed)) |>
order_by_time(decreasing = TRUE) |>
ggplot(aes(elapsed, method, fill = family)) +
geom_col(width = .7) +
geom_text(aes(label = sprintf("%.1fs", elapsed)), hjust = -0.15, size = 3.3) +
scale_fill_manual(values = fam_pal) +
scale_x_continuous(expand = expansion(mult = c(0, .16))) +
labs(title = "sMF computation time by method",
subtitle = sprintf("%s rows, %s samples, %d threads",
format(shape$rows, big.mark = ","),
format(shape$samples, big.mark = ","), params$threads),
x = "Elapsed seconds", y = NULL)
compute_res |>
subset(!is.na(delta_gb)) |>
transform(marginal = pmax(delta_gb, 0)) |>
(\(x) transform(x, method = factor(method, levels = method[order(x$marginal)])))() |>
ggplot(aes(marginal, method, fill = family)) +
geom_col(width = .7) +
geom_text(aes(label = sprintf("%.2f", marginal)), hjust = -0.15, size = 3.3) +
scale_fill_manual(values = fam_pal) +
scale_x_continuous(expand = expansion(mult = c(0, .18))) +
labs(title = "Marginal peak memory by method",
subtitle = sprintf("Above a baseline of ~%.1f GB with the data frame loaded",
stats::median(compute_res$base_gb, na.rm = TRUE)),
x = "Peak RSS above baseline (GB)", y = NULL)
Every method above runs in a session where d, the full in-memory data frame, is already loaded, because the base R and dplyr methods need it. So the absolute peak RSS is roughly the same for all of them and tells you nothing.
The chart therefore plots the marginal cost: how much memory each method adds on top of that shared baseline. But this still flatters the in-memory methods, because they are getting their copy of the data for free — someone already paid for it.
The fair statement is the one the file-backed engines make possible: polars scan_parquet, polars streaming, DuckDB parquet and duckplyr never need d to exist at all. In a session that only ever runs those, the baseline is a few hundred megabytes rather than several gigabytes — the data frame is simply never materialized. That is the difference Section 8 makes concrete by putting DuckDB under a hard memory limit.
compute_res |>
subset(!is.na(elapsed) & !is.na(peak_gb)) |>
ggplot(aes(elapsed, peak_gb, color = family, label = method)) +
geom_point(size = 3.2, alpha = .85) +
ggrepel::geom_text_repel(size = 3, max.overlaps = 20, seed = 1) +
scale_color_manual(values = fam_pal) +
scale_x_log10() +
labs(title = "The actual trade-off", x = "Elapsed seconds (log scale)",
y = "Peak RSS (GB)")
7.6 How stable are these numbers?
Everything above is a single run, which is honest about cost but says nothing about variance. bench::mark() repeats each expression and reports the distribution — and its check argument gives us the correctness comparison for free if we hand it canon().
mark_res <- bench::mark(
collapse = sml_collapse(),
data.table = sml_datatable(),
polars = sml_polars_streaming(),
duckdb = sml_duckdb_parquet(),
check = function(a, b) isTRUE(all.equal(canon(a), canon(b), tolerance = 1e-8)),
iterations = 5,
filter_gc = FALSE, # at this size every iteration triggers GC; keep them all
memory = FALSE # mem_alloc cannot see DuckDB/Polars -- see @sec-bench
)
mark_res |>
dplyr::transmute(
expression = as.character(expression),
min = as.numeric(min),
median = as.numeric(median),
max = vapply(time, function(t) max(as.numeric(t)), numeric(1)),
sd = vapply(time, function(t) stats::sd(as.numeric(t)), numeric(1)),
n_itr = n_itr
) |>
kable(digits = 3, caption = "Five iterations each; times in seconds")| expression | min | median | max | sd | n_itr |
|---|---|---|---|---|---|
| collapse | 0.341 | 0.370 | 0.522 | 0.075 | 5 |
| data.table | 0.554 | 0.560 | 0.565 | 0.004 | 5 |
| polars | 0.556 | 0.574 | 0.576 | 0.008 | 5 |
| duckdb | 0.128 | 0.129 | 0.137 | 0.004 | 5 |
autoplot(mark_res) + labs(title = "Repeated measurements")
memory = FALSE is deliberate
bench::mark()’s mem_alloc column comes from Rprofmem and has exactly the blind spot described in Section 4 — it reports 0B for work done inside DuckDB or Polars. Leaving the column on would produce a table implying those engines are free. Time comes from bench; memory comes from the RSS sampler.
8 Part 4 — Scaling and memory pressure
8.1 Does it get faster with more threads?
thread_grid <- c(1, 2, 4, 8, params$threads) |> unique() |> sort()
sweep_rows <- lapply(thread_grid, function(nthr) {
duck <- measure({
cc <- dbConnect(duckdb(), config = list(threads = as.character(nthr)))
on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
dbGetQuery(cc, duck_sql(sprintf("read_parquet('%s')", PQ)))
}, sample_mem = FALSE)
data.table::setDTthreads(nthr)
dtt <- measure(sml_datatable(), sample_mem = FALSE)
data.frame(threads = nthr,
DuckDB = duck$elapsed, data.table = dtt$elapsed)
})
data.table::setDTthreads(params$threads)
sweep <- do.call(rbind, sweep_rows)
kable(sweep, digits = 2, caption = "Scaling with thread count (seconds)")| threads | DuckDB | data.table |
|---|---|---|
| 1 | 0.15 | 0.48 |
| 2 | 0.12 | 0.41 |
| 4 | 0.14 | 0.44 |
| 8 | 0.14 | 0.54 |
sweep |>
tidyr::pivot_longer(-threads, names_to = "engine", values_to = "elapsed") |>
ggplot(aes(threads, elapsed, color = engine)) +
geom_line(linewidth = .9) + geom_point(size = 2.6) +
scale_color_manual(values = c(DuckDB = "#2F6D8C", data.table = "#3E8E7E")) +
scale_x_continuous(breaks = thread_grid) +
theme(legend.position = "right") +
labs(title = "Elapsed time vs thread count", x = "Threads", y = "Seconds")
8.2 Working under a hard memory limit
This is the property that actually distinguishes DuckDB and Polars from every in-memory method: they will complete a query that does not fit in RAM, by spilling to disk. We can force it with an artificially tight budget.
The sMF query is a streaming aggregation — it never needs much working memory, so it is a poor test. Instead we force something genuinely memory-hungry: a full sort of all 46 million rows, written back out to Parquet. Sorting cannot emit its first row until it has seen the last one.
sort_out <- tempfile(fileext = ".parquet")
mem_limits <- c("1GB", "4GB", "24GB")
lim_rows <- lapply(mem_limits, function(lim) {
tdir <- file.path(tempdir(), paste0("duckspill_", gsub("[^A-Za-z0-9]", "", lim)))
dir.create(tdir, showWarnings = FALSE)
cc <- dbConnect(duckdb(), config = list(
threads = as.character(params$threads),
memory_limit = lim,
temp_directory = gsub("\\\\", "/", tdir)))
on.exit({ dbDisconnect(cc, shutdown = TRUE); unlink(tdir, recursive = TRUE) }, add = TRUE)
unlink(sort_out)
m <- measure(dbExecute(cc, sprintf(
"COPY (SELECT * FROM read_parquet('%s') ORDER BY VAF, position)
TO '%s' (FORMAT PARQUET)", PQ, gsub("\\\\", "/", sort_out))),
sample_mem = TRUE)
data.frame(memory_limit = lim, elapsed = m$elapsed,
marginal_gb = m$delta_gb, peak_gb = m$peak_gb)
})
unlink(sort_out)
do.call(rbind, lim_rows) |>
kable(digits = 2, caption = "A full 46M-row sort under different DuckDB memory limits",
col.names = c("memory_limit", "Elapsed (s)", "Memory above baseline (GB)",
"Peak RSS (GB)"))| memory_limit | Elapsed (s) | Memory above baseline (GB) | Peak RSS (GB) |
|---|---|---|---|
| 1GB | 2.78 | 8.63 | 31.92 |
| 4GB | 2.78 | 8.40 | 31.74 |
| 24GB | 2.81 | 8.39 | 31.70 |
Two things are worth taking from it, and one common claim is not supported.
The query completes at every limit, including one far below the size of the data. DuckDB sorted 46 million rows with its budget set to 1 GB. No in-memory method in this document can do that; base R would have to hold the whole table plus a sorted copy.
But memory_limit did not meaningfully change either runtime or process memory here. That is not a bug. DuckDB’s limit governs its buffer manager; the Parquet reader’s decompression buffers, the writer’s row-group buffers, query results and everything R itself holds all live outside it. Process RSS therefore sits well above the nominal limit no matter what you set, and our sampler correctly reports that. Treat memory_limit as a guard against runaway intermediate state, not as a hard cap on the process. DuckDB’s own guidance is 1–4 GB of limit per thread.
A caveat on honesty: I could not get this query to leave measurable evidence of spilling. duckdb_memory() reports current state, and DuckDB deletes its temp files as soon as the query finishes, so a post-hoc reading is always zero — whether it spilled or not. Measuring spill properly means sampling the temp directory during execution, the same way we sample RSS. Rather than show a table of zeros and imply “no spilling occurred”, the honest statement is: the query completed comfortably under a 1 GB budget, and I did not prove how it did so.
8.3 Scaling with data size
fracs <- c(0.05, 0.15, 0.35, 0.65, 1.0)
tmp_pq <- tempfile(fileext = ".parquet")
curve_rows <- lapply(fracs, function(f) {
cc <- new_con(); on.exit(dbDisconnect(cc, shutdown = TRUE), add = TRUE)
n <- round(shape$rows * f)
dbExecute(cc, sprintf("COPY (SELECT * FROM read_parquet('%s') LIMIT %d) TO '%s'
(FORMAT PARQUET, COMPRESSION ZSTD)", PQ, n, tmp_pq))
a <- measure(dbGetQuery(cc, duck_sql(sprintf("read_parquet('%s')", tmp_pq))), sample_mem = FALSE)
b <- measure(polars_pipeline(pl$scan_parquet(tmp_pq))$collect(engine = "streaming"),
sample_mem = FALSE)
dd <- as.data.frame(arrow::read_parquet(tmp_pq))
cl <- fmin(dd$cluster_id, g = dd$samplename, TRA = "replace")
cc2 <- measure({
n1 <- fnobs(dd$cluster_id, g = dd$samplename)
n2 <- fsum(dd$cluster_id == cl, g = dd$samplename)
data.frame(n1, n2)
}, sample_mem = FALSE)
rm(dd); gc(FALSE)
data.frame(rows = n, DuckDB = a$elapsed, polars = b$elapsed, collapse = cc2$elapsed)
})
unlink(tmp_pq)
curve <- do.call(rbind, curve_rows)
kable(curve, digits = 2, caption = "Elapsed seconds vs row count")| rows | DuckDB | polars | collapse |
|---|---|---|---|
| 2305779 | 0.02 | 0.03 | 0.00 |
| 6917338 | 0.03 | 0.07 | 0.04 |
| 16140456 | 0.05 | 0.19 | 0.07 |
| 29975132 | 0.07 | 0.36 | 0.18 |
| 46115588 | 0.13 | 0.54 | 0.25 |
curve |>
tidyr::pivot_longer(-rows, names_to = "engine", values_to = "elapsed") |>
ggplot(aes(rows, elapsed, color = engine)) +
geom_line(linewidth = .9) + geom_point(size = 2.4) +
scale_color_manual(values = c(DuckDB = "#2F6D8C", polars = "#B4534B", collapse = "#6AA84F")) +
scale_x_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
theme(legend.position = "right") +
labs(title = "Scaling with row count", x = "Rows", y = "Seconds")
8.4 Larger-than-memory output with sink_parquet()
Every method so far returns a small table. When the output is also large, pulling it into R defeats the purpose. Polars can stream a query straight to a new Parquet file without materializing it:
sink_out <- tempfile(fileext = ".parquet")
m_sink <- measure(
pl$scan_parquet(PQ)$
filter(pl$col("cluster_id") == 0L)$
select("samplename", "chromosome", "position", "VAF")$
sink_parquet(sink_out),
sample_mem = TRUE)
cat(sprintf("streamed %.2f GB -> %s in %.1fs, peak RSS %.2f GB\n",
file.info(sink_out)$size/GB, basename(sink_out), m_sink$elapsed, m_sink$peak_gb))streamed 0.51 GB -> file9b2c11be99.parquet in 0.4s, peak RSS 24.29 GB
unlink(sink_out)DuckDB’s equivalent is COPY (...) TO 'out.parquet' (FORMAT PARQUET, COMPRESSION ZSTD), which likewise never routes the data through R.
9 Part 5 — Leaving R
Quarto runs more than R. When a step genuinely does not belong in R — a tight loop, a library that only exists in Python, a numerical kernel — you can drop into another language in the same document and pass data across.
9.1 Python
The R and Python bindings for Polars and DuckDB wrap the same engines, so this is not really a language comparison: it is a measurement of binding overhead and of how each ecosystem hands data back.
The obvious approach is reticulate, which runs Python inside the R process and lets you write r.PQ and py$result. It does not work in this document, and the reason is worth knowing:
library(arrow) # R's arrow: loads Arrow C++ DLLs into the process
reticulate::py_run_string("import pyarrow")
#> ImportError: DLL load failed while importing lib:
#> The specified procedure could not be found.R’s arrow package and Python’s pyarrow each bundle their own build of the Arrow C++ libraries. Load both into one process on Windows and the dynamic linker resolves pyarrow’s extension against R’s already-loaded DLLs, which export different symbols. polars and duckdb import fine; pyarrow is the casualty. (Verified on this machine: with R arrow unloaded, all three import cleanly.)
Running Python as a separate process sidesteps the whole class of problem, and costs only that data must cross as files rather than as in-memory objects. For a benchmark that is no loss at all — the data is already a file.
The chunks below expect a virtual environment in .venv/ next to this document. With uv (fast, and it will fetch Python itself):
uv venv .venv --python 3.13
uv pip install --python .venv/Scripts/python.exe polars duckdb pyarrow pandasor with stock Python:
python -m venv .venv
.venv/Scripts/pip install polars duckdb pyarrow pandasIf .venv is absent the Python chunks are skipped and the rest of the document still renders — see the py_ok guard below.
We pass parameters out as JSON and read results back the same way.
py_cfg <- "py_config.json"
jsonlite::write_json(list(pq = normalizePath(PQ, winslash = "/"),
threads = params$threads,
out = "py_results.json"),
py_cfg, auto_unbox = TRUE)
py_exe <- if (file.exists(".venv/Scripts/python.exe")) ".venv/Scripts/python.exe" else
if (file.exists(".venv/bin/python")) ".venv/bin/python" else "python"
py_ok <- nzchar(Sys.which(py_exe)) || file.exists(py_exe)
cat("python:", py_exe, if (py_ok) "(found)" else "(NOT FOUND -- chunk will be skipped)", "\n")python: .venv/Scripts/python.exe (found)
import json, time
import polars as pl, duckdb, pyarrow
cfg = json.load(open("py_config.json"))
PQ = cfg["pq"]
THREADS = int(cfg["threads"])
print(f"polars {pl.__version__} | duckdb {duckdb.__version__} | pyarrow {pyarrow.__version__}")
# --- Polars, streaming engine -------------------------------------------------
q = (pl.scan_parquet(PQ)
.group_by("samplename")
.agg(pl.len().alias("n_snvs"),
(pl.col("cluster_id") == pl.col("cluster_id").min()).sum().alias("n_clonal"))
.with_columns((pl.col("n_snvs") - pl.col("n_clonal")).alias("n_subclonal"))
.with_columns((pl.col("n_subclonal") / pl.col("n_snvs")).alias("sMF")))
t0 = time.perf_counter(); out_pl = q.collect(engine="streaming"); t_pl = time.perf_counter() - t0
# --- DuckDB, results straight to Arrow ----------------------------------------
con = duckdb.connect(config={"threads": THREADS})
sql = f"""
SELECT samplename, COUNT(*) AS n_snvs,
COUNT(*) FILTER (cluster_id = mn) AS n_clonal,
COUNT(*) - COUNT(*) FILTER (cluster_id = mn) AS n_subclonal,
(COUNT(*) - COUNT(*) FILTER (cluster_id = mn))::DOUBLE / COUNT(*) AS sMF
FROM (SELECT samplename, cluster_id,
MIN(cluster_id) OVER (PARTITION BY samplename) mn
FROM read_parquet('{PQ}'))
GROUP BY ALL"""
# NB: .arrow() hands back a lazy RecordBatchReader, so timing it alone would
# measure nothing at all -- the lazy trap from @sec-bench, in Python. read_all()
# forces the query to actually run.
t0 = time.perf_counter()
out_db = con.sql(sql).arrow().read_all()
t_db = time.perf_counter() - t0
print(f"python polars (streaming): {t_pl:6.2f}s -> {out_pl.height} rows")
print(f"python duckdb (-> arrow): {t_db:6.2f}s -> {out_db.num_rows} rows")
json.dump({"python polars (streaming)": t_pl,
"python duckdb (-> arrow)": t_db,
"rows": out_pl.height,
"versions": {"polars": pl.__version__, "duckdb": duckdb.__version__}},
open(cfg["out"], "w"), indent = 2)polars 1.43.0 | duckdb 1.5.5 | pyarrow 25.0.0
python polars (streaming): 0.46s -> 2778 rows
python duckdb (-> arrow): 0.14s -> 2778 rows
if (file.exists("py_results.json")) {
pyr <- jsonlite::read_json("py_results.json", simplifyVector = TRUE)
r_side <- compute_res[compute_res$method %in%
c("polars streaming", "DuckDB parquet"), c("method", "elapsed")]
rbind(
data.frame(engine = names(pyr)[1:2], elapsed = unlist(pyr[1:2]), language = "Python"),
data.frame(engine = r_side$method, elapsed = r_side$elapsed, language = "R")
) |> kable(digits = 2, row.names = FALSE,
caption = "Same engines, two languages. Differences here are binding overhead, not engine speed.")
} else cat("Python chunk did not produce results.\n")| engine | elapsed | language |
|---|---|---|
| python polars (streaming) | 0.46 | Python |
| python duckdb (-> arrow) | 0.14 | Python |
| polars streaming | 0.56 | R |
| DuckDB parquet | 0.13 | R |
Passing a file path across the boundary costs nothing; passing a table costs a full copy and a serialization. The rule for polyglot pipelines is the same as for the engines themselves: move the computation to the data, not the data to the computation. Write Parquet on one side and scan it on the other — both languages read it natively and neither has to materialize it.
9.2 C++ via Rcpp
For this task a hand-written C++ kernel is genuinely competitive: a single hash-map pass computes the grouped minimum and both counts. This is what collapse is doing internally, and what you would write if you needed something collapse does not provide.
Rcpp::cppFunction('
DataFrame sml_cpp(CharacterVector sample, IntegerVector cluster) {
int n = sample.size();
std::unordered_map<std::string,int> idx;
std::vector<std::string> names;
std::vector<int> mn, tot;
// pass 1: grouped minimum and row counts
for (int i = 0; i < n; i++) {
std::string s = as<std::string>(sample[i]);
auto it = idx.find(s);
int j;
if (it == idx.end()) {
j = names.size(); idx[s] = j;
names.push_back(s); mn.push_back(cluster[i]); tot.push_back(0);
} else j = it->second;
if (cluster[i] < mn[j]) mn[j] = cluster[i];
tot[j]++;
}
// pass 2: count rows sitting in each group\'s minimum cluster
std::vector<int> clonal(names.size(), 0);
for (int i = 0; i < n; i++) {
int j = idx[as<std::string>(sample[i])];
if (cluster[i] == mn[j]) clonal[j]++;
}
int g = names.size();
NumericVector sub(g), sml(g);
for (int j = 0; j < g; j++) { sub[j] = tot[j] - clonal[j]; sml[j] = sub[j] / (double)tot[j]; }
return DataFrame::create(_["samplename"] = names, _["n_snvs"] = tot,
_["n_clonal"] = clonal, _["n_subclonal"] = sub,
_["sMF"] = sml, _["stringsAsFactors"] = false);
}',
includes = c("#include <unordered_map>", "#include <string>", "#include <vector>"))
cpp_res <- run_suite(list(
bench_method("C++ (Rcpp, 1 thread)", "other",
function() sml_cpp(d$samplename, d$cluster_id), "two-pass hash map"),
bench_method("collapse", "collapse", sml_collapse, "for reference")
), sample_mem = TRUE)
cpp_res |> subset(select = c(method, note, elapsed, peak_gb, correct)) |>
kable(digits = 2, caption = "A hand-written kernel against a tuned library",
col.names = c("Method", "Note", "Elapsed (s)", "Peak RSS (GB)", "Correct"))| Method | Note | Elapsed (s) | Peak RSS (GB) | Correct |
|---|---|---|---|---|
| C++ (Rcpp, 1 thread) | two-pass hash map | 3.23 | 23.28 | reference |
| collapse | for reference | 0.34 | 24.48 | match |
Read that table again: the C++ is roughly an order of magnitude slower than the three-line collapse call it was supposed to beat.
The algorithm is fine — one hash-map pass, no wasted work. The problem is this line, executed 92 million times:
std::string s = as<std::string>(sample[i]);Every row constructs a fresh std::string, copying the characters out of R’s internal string representation, just to use it as a hash key and throw it away. R already interns every string in a global cache, so each unique sample name exists exactly once in memory and collapse can hash the pointer rather than the bytes. The C++ version does an enormous amount of allocation to rediscover something R already knew.
You can fix it — hash the SEXP pointers from STRING_ELT() directly, or factor the column once in R and pass integers to C++. Both work. Both also make the code longer and more dependent on R’s internals, which is precisely the point:
“Rewrite it in C++” is not a performance strategy. A mature library encodes years of exactly this kind of knowledge about where the real costs are. Hand-written code beats it only when you know something about your problem that the library cannot — and on a generic grouped aggregation, you don’t. Reach for collapse first; reach for C++ when you have a computation no library expresses at all, and budget for learning where its sharp edges are.
9.3 Julia
Julia is a strong fit for this class of problem — a JIT-compiled numerical language where a hand-written loop runs at C speed without leaving the interactive session. JuliaCall bridges it to R, and Quarto supports native {julia} chunks.
No Julia toolchain is installed on the machine that rendered this document, so the block below is shown but not executed. To make it live:
- Install Julia with
juliaup—winget install julia -s msstoreon Windows, orcurl -fsSL https://install.julialang.org | shelsewhere. using Pkg; Pkg.add(["DataFrames", "Parquet2", "DuckDB", "Chain"])install.packages("JuliaCall")— knitr’sjuliaengine needs it, and will fail to register the engine without it even for a chunk markedeval: false. That is why this is a plain fenced block rather than an executable chunk.- Change the fence below from
```juliato```{julia}.
using Parquet2, DataFrames, Chain
df = DataFrame(Parquet2.Dataset("fake_pcawg_all.parquet"); copycols = false)
result = @chain df begin
groupby(:samplename)
combine(
nrow => :n_snvs,
[:cluster_id] => (c -> sum(c .== minimum(c))) => :n_clonal,
)
transform(
[:n_snvs, :n_clonal] => ((a, b) -> a .- b) => :n_subclonal,
)
transform(
[:n_snvs, :n_clonal] => ((a, b) -> (a .- b) ./ a) => :sMF,
)
endFrom R, the equivalent through JuliaCall:
library(JuliaCall)
julia_setup()
julia_library("DataFrames"); julia_library("Parquet2")
julia_assign("path", PQ)
julia_eval('
using Parquet2, DataFrames
df = DataFrame(Parquet2.Dataset(path); copycols=false)
combine(groupby(df, :samplename), nrow => :n_snvs)
')For this task Julia has little to offer over DuckDB or Polars — the work is a grouped aggregation, which is exactly what those engines are built for, and Parquet2.jl is less mature than Arrow’s reader. Julia earns its place when the inner computation is custom numerics that no query engine expresses: simulation, optimization, differential equations. Reaching for it to do a GROUP BY means paying the compilation latency and the interop cost for nothing.
9.4 Rust
There is a certain circularity in benchmarking Rust here: Polars is Rust, and the polars calls throughout this document already run compiled Rust. Arrow’s core and much of DuckDB’s ecosystem tooling are similar stories.
So the interesting question is not “is Rust fast” but “what if I need a kernel Polars does not have?” The answer is extendr, which is to Rust what Rcpp is to C++. rextendr provides a rust_function() helper and a knitr engine, so Rust can live in a chunk.
Not evaluated — no Rust toolchain here. Install rustup and install.packages("rextendr"), then set eval: true.
rextendr::rust_function('
fn sml_rust(cluster: Vec<i32>, group: Vec<i32>, n_groups: usize) -> Vec<f64> {
let mut mn = vec![i32::MAX; n_groups];
let mut tot = vec![0usize; n_groups];
for (c, g) in cluster.iter().zip(group.iter()) {
let g = *g as usize;
if *c < mn[g] { mn[g] = *c; }
tot[g] += 1;
}
let mut clonal = vec![0usize; n_groups];
for (c, g) in cluster.iter().zip(group.iter()) {
let g = *g as usize;
if *c == mn[g] { clonal[g] += 1; }
}
(0..n_groups).map(|j| (tot[j] - clonal[j]) as f64 / tot[j] as f64).collect()
}
')The realistic reason to reach for extendr is not speed — it is that you want memory safety and Rust’s ecosystem (including polars-rs itself) inside an R package, without hand-managing the lifetimes that make the Rcpp version above something you have to review carefully.
10 Part 6 — Choosing
10.1 What the numbers say
Best method per family
compute_res |>
subset(!is.na(elapsed)) |>
(\(x) x[order(x$family, x$elapsed), ])() |>
(\(x) x[!duplicated(x$family), ])() |>
transform(relative = round(elapsed / min(elapsed, na.rm = TRUE), 1)) |>
subset(select = c(family, method, elapsed, relative, peak_gb)) |>
kable(digits = 2, caption = "Fastest representative of each tool family",
col.names = c("Family", "Best method", "Elapsed (s)", "x slowest-in-class", "Peak RSS (GB)"))| Family | Best method | Elapsed (s) | x slowest-in-class | Peak RSS (GB) | |
|---|---|---|---|---|---|
| 7 | arrow | arrow (Acero) | 1.01 | 7.8 | 23.38 |
| 2 | base R | base R tapply | 3.79 | 29.2 | 28.20 |
| 3 | collapse | collapse | 0.36 | 2.8 | 24.29 |
| 6 | data.table | data.table | 0.56 | 4.3 | 23.46 |
| 14 | DuckDB | DuckDB parquet | 0.13 | 1.0 | 23.23 |
| 11 | polars | polars streaming | 0.56 | 4.3 | 25.86 |
| 4 | tidyverse | dplyr | 0.74 | 5.7 | 24.46 |
10.2 A decision guide
flowchart TD
A[How big is the data?] -->|Fits in RAM<br/>with room to spare| B[Do you already know<br/>data.table or dplyr?]
A -->|Larger than RAM,<br/>or close to it| C[Is your team<br/>comfortable with SQL?]
B -->|dplyr, want it faster<br/>with zero rewriting| D[duckplyr]
B -->|data.table| E[data.table<br/>already excellent]
B -->|Want maximum speed<br/>in base R idioms| F[collapse]
C -->|Yes| G[DuckDB + SQL]
C -->|No, prefer dplyr| H[duckplyr or dbplyr]
C -->|No, prefer a<br/>DataFrame API| I[Polars<br/>scan + streaming]
G --> J{Query the same data<br/>many times?}
J -->|Yes| K[Import to .duckdb]
J -->|No, or shared<br/>with others| L[Query Parquet directly]10.3 Recommendations
Change your storage format before you change your library. Going from TSV to Parquet costs one line and buys most of the benefit on offer. Almost nothing else in this document has that ratio.
If your data fits in memory and you already use data.table, stop here. data.table remains superb at this and no rewrite will pay for itself.
If you use dplyr and want it faster without rewriting, use duckplyr. That is its entire premise and it now delivers. Set prudence = "stingy" while developing so fallbacks surface as errors rather than as mysterious slowness.
If the data does not fit in memory, the choice is DuckDB or Polars, and it is mostly about which interface your group will maintain. DuckDB if SQL is a shared language on your team and you want the mature out-of-core story. Polars if you prefer a DataFrame API and are willing to track a faster-moving library. Both read Parquet directly and both are built to work out-of-core — Section 8 sorts all 46 million rows with DuckDB’s budget set to 1 GB — which is simply not a thing base R can do.
Reach for collapse more than you probably do. It is the best speed-per-unit-of- rewriting in R: base R data structures, base-R-shaped functions, C-level speed.
Do not assume dropping to C++ will help. The hand-written kernel in Section 9 came out roughly ten times slower than collapse, because a tuned library already knows where the costs are and a first draft does not. Leave R for a compiled language when you need a computation no library expresses — not for a grouped aggregation.
10.4 Gotchas worth writing down
| Trap | Symptom | Fix |
|---|---|---|
profmem on DuckDB/Polars |
Engine “uses no memory” | Sample process RSS (Section 4) |
pl$DataFrame(df) |
Missing-column errors, or a wrong answer | as_polars_df(df) |
$collect(streaming = TRUE) |
`...` must be empty |
$collect(engine = "streaming") |
pl$col(c("a","b")) |
Invalid input error | pl$col(!!!c("a","b")) |
POLARS_MAX_THREADS set late |
Thread cap ignored | Set it before library(polars) |
| Nested aggregate in dbplyr | aggregate function calls cannot be nested |
Grouped mutate() then summarize() |
| Silent duckplyr fallback | Unexpectedly slow | fallback_sitrep(), prudence = "stingy" |
| Timing a lazy pipeline | Impossibly fast | End in collect() |
Timing vroom() |
Impossibly fast — it only built an index | altrep = FALSE, or touch every column |
Python duckdb.sql(q).arrow() |
Returns a lazy RecordBatchReader |
.arrow().read_all() |
R arrow + pyarrow in one process |
DLL load failed while importing lib |
Run Python as a subprocess |
duckdb_memory() after a query |
Spill always reads 0 — temp files already deleted | Sample during execution |
| “Rewrite the hot loop in C++” | First draft can be slower than the library | Profile before assuming |
| Cold vs warm page cache | Second engine always wins | Warm the file first, and say so |
Reporting user time |
Parallel engines look slow | Report elapsed |
read.table() without header |
Columns named V1…Vn |
header = TRUE |
11 Conclusion
We implemented one grouped aggregation about twenty ways across five tool families — running it in R, Python and C++, and sketching it in Julia and Rust — and measured each with a harness that can actually see memory allocated outside R.
The consistent finding is that the largest wins come from not moving data. The methods at the top of Figure 2 are the ones that never materialize 46 million rows in R’s heap: they push the computation down to where the bytes already are — a Parquet file — and return only the 2,778-row answer. Base R’s problem on this task is not that aggregate() is a slow function; it is that everything must become an R object first.
Second: the modern tools have converged on making that possible without asking you to abandon the syntax you know. duckplyr and tidypolars both let you write dplyr and execute on a columnar engine. The 2024 edition of this tutorial treated that as aspirational; it is now simply how you should work.
Finally, measure honestly. The original version of this document reported that DuckDB used a fraction of a gigabyte for a query where the true peak was near ten. The conclusion it drew — use DuckDB — happened to survive the correction, but that was luck. Benchmarks that flatter your preferred tool are worse than no benchmarks, because they are persuasive.
Session info
sessioninfo::session_info(pkgs = c("polars", "duckdb", "duckplyr", "arrow",
"data.table", "dplyr", "collapse", "tidypolars"))─ Session info ───────────────────────────────────────────────────────────────
setting value
version R version 4.5.1 (2025-06-13 ucrt)
os Windows 11 x64 (build 26200)
system x86_64, mingw32
ui RTerm
language (EN)
collate English_United States.utf8
ctype English_United States.utf8
tz America/New_York
date 2026-07-27
pandoc 3.10 @ C:\\Users\\Matthew\\AppData\\Local\\Temp\\claude\\c--Users-Matthew-Downloads-polars-and-duckdb-tutorial\\d22db368-a447-452b-8a32-854a842eedb3\\scratchpad\\quarto\\bin\\tools/ (via rmarkdown)
quarto NA
─ Packages ───────────────────────────────────────────────────────────────────
package * version date (UTC) lib source
arrow * 25.0.0 2026-07-16 [1] CRAN (R 4.5.3)
assertthat 0.2.1 2019-03-21 [1] CRAN (R 4.5.3)
bit 4.6.0 2025-03-06 [1] CRAN (R 4.5.1)
bit64 4.6.0-1 2025-01-16 [1] CRAN (R 4.5.1)
cachem 1.1.0 2024-05-16 [1] CRAN (R 4.5.1)
cli 3.6.5 2025-04-23 [1] CRAN (R 4.5.1)
collapse * 2.1.7 2026-05-19 [1] CRAN (R 4.5.3)
collections 0.3.12 2026-03-22 [1] CRAN (R 4.5.3)
cpp11 0.5.2 2025-03-03 [1] CRAN (R 4.5.1)
data.table * 1.17.8 2025-07-10 [1] CRAN (R 4.5.1)
DBI * 1.2.3 2024-06-02 [1] CRAN (R 4.5.1)
dplyr * 1.2.0 2026-02-03 [1] CRAN (R 4.5.2)
duckdb * 1.5.5 2026-07-25 [1] CRAN (R 4.5.3)
duckplyr * 1.2.1 2026-03-10 [1] CRAN (R 4.5.3)
fastmap 1.2.0 2024-05-15 [1] CRAN (R 4.5.1)
generics 0.1.4 2025-05-09 [1] CRAN (R 4.5.1)
glue 1.8.0 2024-09-30 [1] CRAN (R 4.5.2)
jsonlite 2.0.0 2025-03-27 [1] CRAN (R 4.5.1)
lifecycle 1.0.5 2026-01-08 [1] CRAN (R 4.5.2)
magrittr 2.0.4 2025-09-12 [1] CRAN (R 4.5.1)
memoise 2.0.1 2021-11-26 [1] CRAN (R 4.5.1)
pillar 1.11.1 2025-09-17 [1] CRAN (R 4.5.1)
pkgconfig 2.0.3 2019-09-22 [1] CRAN (R 4.5.1)
polars * 1.13.0 2026-07-04 [1] https://r-multiverse.r-universe.dev (R 4.5.3)
purrr 1.2.1 2026-01-09 [1] CRAN (R 4.5.2)
R6 2.6.1 2025-02-15 [1] CRAN (R 4.5.1)
Rcpp 1.1.2 2026-07-05 [1] CRAN (R 4.5.3)
rlang 1.3.0 2026-07-05 [1] CRAN (R 4.5.3)
S7 0.2.2 2026-04-20 [1] https://r-multiverse.r-universe.dev (R 4.5.3)
stringi 1.8.7 2025-03-27 [1] CRAN (R 4.5.0)
stringr 1.6.0 2025-11-04 [1] CRAN (R 4.5.2)
tibble 3.3.1 2026-01-11 [1] CRAN (R 4.5.2)
tidypolars * 0.19.0 2026-07-04 [1] https://r-multiverse.r-universe.dev (R 4.5.3)
tidyr 1.3.2 2025-12-19 [1] CRAN (R 4.5.2)
tidyselect 1.2.1 2024-03-11 [1] CRAN (R 4.5.1)
utf8 1.2.6 2025-06-08 [1] CRAN (R 4.5.1)
vctrs 0.7.1 2026-01-23 [1] CRAN (R 4.5.2)
withr 3.0.2 2024-10-28 [1] CRAN (R 4.5.1)
[1] C:/Users/Matthew/AppData/Local/R/win-library/4.5
[2] C:/Program Files/R/R-4.5.1/library
* ── Packages attached to the search path.
──────────────────────────────────────────────────────────────────────────────