---
title: "Benchmarks"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Benchmarks}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
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")

# 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`](https://bench.r-lib.org) package, which reports both wall-clock
time and the memory allocated by each expression.

```{r data}
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)
}
```

## Running the benchmark

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.

```{r run}
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 = TRUE)
  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"))
```

## A raw `bench::mark` result

This is the object `bench` returns, for the grouped-`sd` case: iterations
per second, memory allocated, and garbage collections.

```{r raw}
marks[[which(ops == "sd by group")]][, c("engine", "min", "median", "itr/sec", "mem_alloc", "n_gc")]
```

## Summary table

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

```{r table, results='asis'}
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
tab <- tab[order(tab$operation, tab$engine),
           c("operation", "engine", "median_ms", "mem_mb", "vs_time")]
for (col in c("median_ms", "mem_mb", "vs_time"))
  tab[[col]] <- format(round(tab[[col]], 2), nsmall = 2)
knitr::kable(
  tab, row.names = FALSE,
  col.names = c("Operation", "Engine", "Median (ms)", "Mem (MB)", "vs basetable"),
  align = c("l", "l", "r", "r", "r")
)
```

## Speed

```{r plot-time, fig.cap="Median runtime by engine (lower is better). Each panel has its own scale."}
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"))
```

## Memory

```{r plot-mem, fig.cap="Memory allocated by each expression, as reported by bench (lower is better)."}
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"))
```

## Reading the results

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

```{r teardown, include=FALSE}
options(basetable.threads = .old_threads)
```
