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,
dplyr and
collapse 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, HAVE_CL)[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)),
collapse = quote(collapse::fsubset(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)),
collapse = quote(collapse::roworderv(d, c("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)),
collapse = quote(collapse::funique(collapse::get_vars(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)),
collapse = quote(collapse::fcount(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")),
collapse = quote(collapse::collap(d, x ~ g, collapse::fsd, sort = FALSE)))),
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")),
collapse = quote(collapse::join(d, dim_tbl, on = "g", how = "inner", verbose = 0)))),
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")),
collapse = quote(collapse::join(d, dim_tbl, on = "g", how = "semi", verbose = 0))))
)
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", "collapse"))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")]## # A tibble: 4 × 5
## engine min median `itr/sec` mem_alloc
## <chr> <bch:tm> <bch:tm> <dbl> <bch:byt>
## 1 basetable 2.88ms 2.96ms 336. 77.28KB
## 2 data.table 8.34ms 8.79ms 114. 8.2MB
## 3 dplyr 39.13ms 39.53ms 24.8 11.31MB
## 4 collapse 5.42ms 5.5ms 181. 1.58MB
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)]
)| Operation | Engine | Median (ms) | Mem (MB) | vs basetable |
|---|---|---|---|---|
| filter | basetable | 1.07 | 4.43 | 1.00 |
| filter | data.table | 3.05 | 7.64 | 2.84 |
| filter | dplyr | 4.33 | 9.43 | 4.04 |
| filter | collapse | 1.94 | 5.86 | 1.81 |
| sort (string key) | basetable | 13.27 | 10.34 | 1.00 |
| sort (string key) | data.table | 38.43 | 14.13 | 2.90 |
| sort (string key) | dplyr | 35.25 | 20.85 | 2.66 |
| sort (string key) | collapse | 18.18 | 11.52 | 1.37 |
| distinct | basetable | 0.81 | 0.03 | 1.00 |
| distinct | data.table | 6.70 | 6.24 | 8.27 |
| distinct | dplyr | 2.77 | 5.35 | 3.42 |
| distinct | collapse | 0.84 | 1.25 | 1.04 |
| count by group | basetable | 1.65 | 0.36 | 1.00 |
| count by group | data.table | 12.85 | 9.11 | 7.76 |
| count by group | dplyr | 286.09 | 9.95 | 172.87 |
| count by group | collapse | 2.28 | 1.75 | 1.38 |
| sd by group | basetable | 2.96 | 0.08 | 1.00 |
| sd by group | data.table | 8.79 | 8.20 | 2.98 |
| sd by group | dplyr | 39.53 | 11.31 | 13.38 |
| sd by group | collapse | 5.50 | 1.58 | 1.86 |
| equi join | basetable | 1.73 | 2.30 | 1.00 |
| equi join | data.table | 1.57 | 2.29 | 0.91 |
| equi join | dplyr | 23.55 | 30.99 | 13.61 |
| equi join | collapse | 1.04 | 3.74 | 0.60 |
| semi join | basetable | 1.26 | 1.17 | 1.00 |
| semi join | data.table | 14.84 | 17.72 | 11.75 |
| semi join | dplyr | 20.72 | 24.83 | 16.41 |
| semi join | collapse | 0.85 | 1.14 | 0.67 |
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", collapse = "#e08214")) +
labs(x = NULL, y = "Median time (ms)", fill = NULL) +
theme_minimal(base_size = 11) +
theme(legend.position = "none", strip.text = element_text(face = "bold"))Median runtime by engine (lower is better). Each panel has its own scale.
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", collapse = "#e08214")) +
labs(x = NULL, y = "Memory allocated (MB)", fill = NULL) +
theme_minimal(base_size = 11) +
theme(legend.position = "none", strip.text = element_text(face = "bold"))Memory allocated by each expression, as reported by bench (lower is better).
basetable beats data.table on
distinct, grouped count, sd by
group and semi join, and is level with it on
equi join and (on the common
col <op> scalar shape) filter. Against
dplyr it is faster across the board, often by an order of
magnitude on the grouped operations.
collapse is the fastest engine on most of these
operations, including filter, distinct,
grouped count and both joins; basetable is
ahead of it on grouped sd. String sort is the
operation where the single-thread gap is widest:
orderrows() is a stable parallel radix, ~20x faster than
base order(), but collapse and
data.table are faster when limited to one thread.
The memory panel is where basetable separates from all
three. 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 and
collapse a few.
The advantage over data.table 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.
Numbers are machine- and size-specific. Set
BT_VIGNETTE_N / BT_VIGNETTE_REPS to reproduce
at other sizes.