Skip to content
ry

Benchmarks

Each program runs three ways: as plain R on GNU R, as the same plain R on ry’s runtime, and with inline annotations checked in strict mode on ry’s runtime. All three compute the same result:

programtimery vs GNU Rpeak memory
GNU R, untypedry, untypedry, typed and strictGNU R, untypedry, untypedry, typed and strict
array_dot0.278 s0.043 s0.047 s6.5x81 MiB103 MiB124 MiB
array_filter0.246 s0.043 s0.038 s5.7x81 MiB102 MiB123 MiB
array_map0.385 s0.054 s0.067 s7.1x94 MiB123 MiB141 MiB
array_sum0.175 s0.033 s0.033 s5.3x81 MiB102 MiB123 MiB
closures1.100 s0.570 s0.607 s1.9x99 MiB145 MiB148 MiB
drawdown0.335 s0.074 s0.079 s4.5x73 MiB133 MiB141 MiB
fib1.312 s0.010 s0.010 s131.2x54 MiB43 MiB43 MiB
log_rank0.128 s0.137 s0.136 s0.9x107 MiB56 MiB58 MiB
loop_sum0.312 s0.069 s0.068 s4.5x50 MiB82 MiB106 MiB
vasicek0.055 s0.040 s0.040 s1.4x106 MiB64 MiB70 MiB
vector_arith0.290 s0.242 s0.249 s1.2x109 MiB157 MiB164 MiB

Code that spends its time in R itself gains the most. Recursion (fib) runs over 100 times faster, and loops over the elements of a vector, or carrying scalar state through one, run 4 to 7 times faster. Code that spends its time in vectorized builtins gains little, because GNU R already runs those in C: vector_arith, vasicek and log_rank are within a third of GNU R, and log_rank is slightly slower. The typed column stays within a few percent of the untyped one, so today types make code safer, not faster.

Memory goes both ways. ry’s process peaks higher than GNU R’s in most programs, up to 1.8 times as high in drawdown, and the typed programs peak another 2 to 24 MiB higher, while fib, log_rank and vasicek peak lower on ry.

Each program exists twice in tools/bench/why/: as plain R, and typed. Open one to compare the two versions; the tab you choose applies to every program.

array_dot 6.5x

Element loops over a million doubles: a dot product of `x[[i]] * y[[i]]`. The typed program stores the same values in Arrays, which hold no NA and no attributes.

array_dot <- function(x, y, times) {
total <- 0
n <- length(x)
for (k in seq_len(times)) {
for (i in seq_len(n)) total <- total + x[[i]] * y[[i]]
}
total
}
x <- seq(0, 1, length.out = 1000000L)
y <- rev(x)
measure("array_dot", function() array_dot(x, y, 5L))
array_filter 5.7x

Element loops over a million doubles: a filter counting the items above one half. The typed program stores the same values in Arrays, which hold no NA and no attributes.

array_filter <- function(x, y, times) {
total <- 0
n <- length(x)
for (k in seq_len(times)) {
for (i in seq_len(n)) if (x[[i]] > 0.5) total <- total + 1
}
total
}
x <- seq(0, 1, length.out = 1000000L)
y <- rev(x)
measure("array_filter", function() array_filter(x, y, 5L))
array_map 7.1x

Element loops over a million doubles: a map writing `out[[i]] <- x[[i]] * 2 + 1` into a preallocated vector. The typed program stores the same values in Arrays, which hold no NA and no attributes.

array_map <- function(x, y, times) {
total <- 0
n <- length(x)
out <- numeric(n)
for (k in seq_len(times)) {
for (i in seq_len(n)) out[[i]] <- x[[i]] * 2 + 1
total <- total + sum(out)
}
total
}
x <- seq(0, 1, length.out = 1000000L)
y <- rev(x)
measure("array_map", function() array_map(x, y, 5L))
array_sum 5.3x

Element loops over a million doubles: a sum of `x[[i]]`. The typed program stores the same values in Arrays, which hold no NA and no attributes.

array_sum <- function(x, y, times) {
total <- 0
n <- length(x)
for (k in seq_len(times)) {
for (i in seq_len(n)) total <- total + x[[i]]
}
total
}
x <- seq(0, 1, length.out = 1000000L)
y <- rev(x)
measure("array_sum", function() array_sum(x, y, 5L))
closures 1.9x

Closures and lapply: small closures called in a loop, then lapply and vapply over a sequence.

closures <- function(n) {
add <- function(a, b) a + b
scale <- 3
times_scale <- function(a) a * scale
s <- 0
for (i in 1:n) s <- add(s, times_scale(i))
squares <- lapply(seq_len(n), function(x) x * (x + 0.5))
halves <- vapply(seq_len(n), function(x) x / 2, numeric(1))
squared <- unlist(squares)
s + sum(squared) + sum(halves)
}
measure("closures", function() closures(300000L))
drawdown 4.5x

A hot path from the finance code base: maximum drawdown and EWMA volatility, scalar state carried through one pass over a return series.

risk_report <- function(returns, lambda = 0.94) {
wealth <- 1
peak <- 1
worst <- 0
variance <- returns[1] * returns[1]
for (i in seq_along(returns)) {
r <- returns[i]
wealth <- wealth * (1 + r)
if (wealth > peak) peak <- wealth
drawdown <- (peak - wealth) / peak
if (drawdown > worst) worst <- drawdown
variance <- lambda * variance + (1 - lambda) * r * r
}
c(max_drawdown = worst, volatility = sqrt(variance * 252))
}
returns <- sin(seq_len(2000000L) * 0.37) * 0.01 + 0.0002
measure("drawdown", function() sum(risk_report(returns)))
fib 131.2x

Recursion: about 2.7 million calls of a small function.

fib <- function(n) if (n < 2) n else fib(n - 1) + fib(n - 2)
measure("fib", function() fib(30))
log_rank 0.9x

A hot path from the biostatistics code base: the log-rank statistic, a loop over event times with whole-vector comparisons in its body.

log_rank <- function(data) {
times <- sort(unique(data$time[data$event == 1L]))
observed <- 0
expected <- 0
variance <- 0
for (t in times) {
risk <- data$time >= t
n <- sum(risk)
n1 <- sum(risk & data$arm == "treated")
d <- sum(data$time == t & data$event == 1L)
d1 <- sum(data$time == t & data$event == 1L & data$arm == "treated")
observed <- observed + d1
expected <- expected + d * n1 / n
if (n > 1) variance <- variance + d * (n1 / n) * (1 - n1 / n) * (n - d) / (n - 1)
}
(observed - expected)^2 / variance
}
subjects <- 4000L
data <- list(
time = (seq_len(subjects) * 7919L) %% 1000L + 1L,
event = as.integer(seq_len(subjects) %% 3L != 0L),
arm = ifelse(seq_len(subjects) %% 2L == 0L, "treated", "control")
)
measure("log_rank", function() log_rank(data))
loop_sum 4.5x

A numeric loop: a scalar double accumulator in a counted loop.

loop_sum <- function(n) {
s <- 0
for (i in 1:n) s <- s + i * 0.5
s
}
measure("loop_sum", function() loop_sum(10000000L))
vasicek 1.4x

A simulation kernel from the finance code base: Vasicek interest-rate paths, one matrix row per step.

vasicek_paths <- function(paths = 200L, steps = 2000L, r0 = 0.03, kappa = 0.5, theta = 0.04, sigma = 0.01, dt = 1 / 12, seed = 9L) {
set.seed(seed)
rates <- matrix(r0, nrow = steps + 1L, ncol = paths)
for (t in seq_len(steps)) {
shock <- stats::rnorm(paths)
previous <- rates[t, ]
rates[t + 1L, ] <- previous + kappa * (theta - previous) * dt + sigma * sqrt(dt) * shock
}
rates
}
measure("vasicek", function() sum(vasicek_paths()))
vector_arith 1.2x

Vector code: whole-vector arithmetic on a million doubles, repeated in a loop.

vector_arith <- function(x, times) {
total <- 0
for (k in seq_len(times)) {
y <- x * 2 + x / 3 - 1
total <- total + sum(y)
}
total
}
x <- seq(0, 1, length.out = 1000000L)
measure("vector_arith", function() vector_arith(x, 20L))
harness measure()

Shared by every benchmark: runs `run()` once to warm up (GNU R's JIT compiles, ry tiers up), then three timed times, and prints the checksum and the best time in seconds.

measure <- function(name, run) {
value <- run()
best <- Inf
for (k in 1:3) {
started <- proc.time()[[3]]
again <- run()
finished <- proc.time()[[3]]
best <- min(best, finished - started)
if (!identical(again, value)) stop("the benchmark is not deterministic")
}
cat(sprintf("%s %s %.4f\n", name, format(value, digits = 15), best))
}

Each script times its own work with the harness above, so a time leaves out startup and, for the typed column, the static check. The harness runs the program once to warm up (GNU R compiles it to bytecode, and ry compiles hot code to machine code), then three more times, and keeps the fastest. just bench-why --write runs every program twice per engine, alternating the engines so they never share the CPU, and stores the fastest of those six runs in tools/bench/why/results.json, which every table on this site reads. The fastest run is reported rather than the median because other load on a machine only ever slows a run down.

Peak memory is the peak resident set size (RSS) of the whole process, R’s startup included, as the operating system reports it when the process exits. Each cell keeps the largest peak of that engine’s runs, so no engine gets credit for a lucky run.

Measured on Intel(R) Xeon(R) Processor @ 2.80GHz, 4 cores, Linux 6.18.44-fc-v185, against GNU R 4.3.3.