knitr::opts_chunk$set( echo = TRUE, message = FALSE, warning = FALSE, fig.width = 8, fig.height = 6, dpi = 120 ) library(basetable) library(bench) library(ggplot2) have <- function(pkg) requireNamespace(pkg, quietly = TRUE) HAVE_DT <- have("data.table") HAVE_DP <- have("dplyr") # Some check machines (notably certain r-devel builds) do not support # Rprofmem()-based memory profiling; bench::mark(memory = TRUE) errors there. HAVE_PROFMEM <- isTRUE(capabilities("profmem")) # Keep the vignette build quick; inst/benchmarks/benchmark-scale.R runs the # full size x cardinality matrix, and inst/benchmarks/make-readme-figures.R # regenerates the figures shown in the README. N <- as.integer(Sys.getenv("BT_VIGNETTE_N", "3e5")) REPS <- as.integer(Sys.getenv("BT_VIGNETTE_REPS", "9")) .old_threads <- getOption("basetable.threads") basetable::setthreads(percent = 100)
basetable runs every operation on its own bundled C++ engine and has no
data.table dependency. This vignette measures it head to head with
data.table and dplyr using the
bench package, which reports both wall-clock
time and the memory allocated by each expression.
set.seed(1) n <- N d <- data.frame( g = sprintf("k%07d", sample(2000L, n, replace = TRUE)), # ~2000 groups gh = sprintf("k%08d", sample(max(n %/% 10L, 1L), n, replace = TRUE)), # ~n/10 groups x = rnorm(n), y = rnorm(n), id = seq_len(n), stringsAsFactors = FALSE ) dim_tbl <- d[!duplicated(d$g), c("g", "y")] if (HAVE_DT) { dt <- data.table::as.data.table(d) dmt <- data.table::as.data.table(dim_tbl) }
Each operation is timed for every engine with bench::mark(..., check =
FALSE, memory = TRUE). check = FALSE because the engines return different
object types for the same logical result.
bench_one <- function(label, exprs) { keep <- c(TRUE, HAVE_DT, HAVE_DP)[seq_along(exprs)] exprs <- exprs[keep] m <- bench::mark(exprs = exprs, iterations = REPS, check = FALSE, memory = HAVE_PROFMEM) m$operation <- label m$engine <- names(exprs) m } marks <- list( bench_one("filter", list( basetable = quote(basetable::subset(d, x > 0.5)), data.table = quote(dt[x > 0.5]), dplyr = quote(dplyr::filter(d, x > 0.5)))), bench_one("sort (string key)", list( basetable = quote(basetable::orderrows(d, by = c("g", "x"))), data.table = quote(data.table::setorder(data.table::copy(dt), g, x)), dplyr = quote(dplyr::arrange(d, g, x)))), bench_one("distinct", list( basetable = quote(basetable::uniquerows(d, cols = "g")), data.table = quote(unique(dt[, list(g)])), dplyr = quote(dplyr::distinct(d, g)))), bench_one("count by group", list( basetable = quote(basetable::count(d, by = "gh", sort = FALSE)), data.table = quote(dt[, .N, by = gh]), dplyr = quote(dplyr::count(d, gh)))), bench_one("sd by group", list( basetable = quote(basetable::aggregate(d, by = "g", value = "x", fun = sd, sort = FALSE)), data.table = quote(dt[, list(x = sd(x)), by = g]), dplyr = quote(dplyr::summarise(dplyr::group_by(d, g), x = sd(x), .groups = "drop")))), bench_one("equi join", list( # basetable::merge() keeps input order; pin data.table to sort = FALSE so # neither side also sorts the joined result. basetable = quote(basetable::merge(d, dim_tbl, by = "g")), data.table = quote(merge(dt, dmt, by = "g", sort = FALSE)), dplyr = quote(dplyr::inner_join(d, dim_tbl, by = "g")))), bench_one("semi join", list( basetable = quote(basetable::semimerge(d, dim_tbl, by = "g")), data.table = quote(dt[dmt, on = "g", nomatch = NULL]), dplyr = quote(dplyr::semi_join(d, dim_tbl, by = "g")))) ) ops <- c("filter", "sort (string key)", "distinct", "count by group", "sd by group", "equi join", "semi join") res <- do.call(rbind, lapply(marks, function(m) { data.frame( operation = m$operation, engine = m$engine, median_ms = as.numeric(m$median) * 1000, mem_mb = as.numeric(m$mem_alloc) / 1024^2, itr_sec = as.numeric(m$`itr/sec`), stringsAsFactors = FALSE ) })) res$operation <- factor(res$operation, levels = ops) res$engine <- factor(res$engine, levels = c("basetable", "data.table", "dplyr"))
bench::mark resultThis is the object bench returns, for the grouped-sd case: iterations
per second, memory allocated, and garbage collections.
marks[[which(ops == "sd by group")]][, c("engine", "min", "median", "itr/sec", "mem_alloc", "n_gc")]
median (ms) and mem (MB) are lower-is-better; vs basetable is the
engine's median time divided by basetable's for that operation (below 1 means
faster than basetable).
bt <- res[res$engine == "basetable", c("operation", "median_ms", "mem_mb")] names(bt)[2:3] <- c("bt_ms", "bt_mb") tab <- merge(res, bt, by = "operation") tab$vs_time <- tab$median_ms / tab$bt_ms tab$vs_mem <- tab$mem_mb / tab$bt_mb cols <- if (HAVE_PROFMEM) { c("operation", "engine", "median_ms", "mem_mb", "vs_time") } else { c("operation", "engine", "median_ms", "vs_time") } tab <- tab[order(tab$operation, tab$engine), cols] for (col in intersect(c("median_ms", "mem_mb", "vs_time"), names(tab))) tab[[col]] <- format(round(tab[[col]], 2), nsmall = 2) nms <- if (HAVE_PROFMEM) { c("Operation", "Engine", "Median (ms)", "Mem (MB)", "vs basetable") } else { c("Operation", "Engine", "Median (ms)", "vs basetable") } knitr::kable( tab, row.names = FALSE, col.names = nms, align = c("l", "l", "r", "r", "r")[seq_along(nms)] )
ggplot(res, aes(engine, median_ms, fill = engine)) + geom_col(width = 0.7) + geom_text(aes(label = round(median_ms)), hjust = -0.15, size = 3) + facet_wrap(~operation, ncol = 2, scales = "free_x") + coord_flip() + scale_fill_manual(values = c(basetable = "#1b7837", data.table = "#762a83", dplyr = "#c2a5cf")) + labs(x = NULL, y = "Median time (ms)", fill = NULL) + theme_minimal(base_size = 11) + theme(legend.position = "none", strip.text = element_text(face = "bold"))
cat("_Memory profiling (`Rprofmem()`) is not available on this system, so the memory comparison is omitted here. See the README or `inst/benchmarks/make-readme-figures.R` for a system where it is supported._\n")
ggplot(res, aes(engine, mem_mb, fill = engine)) + geom_col(width = 0.7) + geom_text(aes(label = ifelse(mem_mb < 1, sprintf("%.2f", mem_mb), sprintf("%.0f", mem_mb))), hjust = -0.15, size = 3) + facet_wrap(~operation, ncol = 2, scales = "free_x") + coord_flip() + scale_fill_manual(values = c(basetable = "#1b7837", data.table = "#762a83", dplyr = "#c2a5cf")) + labs(x = NULL, y = "Memory allocated (MB)", fill = NULL) + theme_minimal(base_size = 11) + theme(legend.position = "none", strip.text = element_text(face = "bold"))
basetable beats data.table on distinct, grouped count, sd by
group, semi join, and edges it on equi join and (on the common
col <op> scalar shape) filter; the one operation it trails on is string
sort. Against dplyr it is faster across the board, often by an order of
magnitude on the grouped operations.
The memory panel is the clearest separation. A grouped aggregate or
count in basetable reduces inside the C++ engine without building
intermediate columns, so it allocates a fraction of a megabyte where
data.table and dplyr allocate tens.
The advantage widens with size and cardinality:
inst/benchmarks/benchmark-scale.R at 1e6 rows with ~100k groups puts
semimerge near 0.25x of data.table and grouped count / sd well
below 1. String sort is the one operation basetable does not match:
orderrows() is a stable parallel radix, ~20x faster than base order(),
but data.table's hand-tuned radix stays ahead.
Numbers are machine- and size-specific. Set BT_VIGNETTE_N /
BT_VIGNETTE_REPS to reproduce at other sizes.
options(basetable.threads = .old_threads)
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.