Performance, Profiling and Benchmarking
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. The timing and profiling tools of R itself are in base R (system.time() and proc.time() in base, Rprof() and summaryRprof() in utils, the byte-code compiler in the compiler package that ships with R), and those examples need nothing else. The comparisons use four add-on packages: bench 1.1.4, microbenchmark 1.5.0 (a community package, labelled as one), profvis 0.4.0 (with htmlwidgets 1.6.4 and pandoc 3.1.3 to save its widget) and data.table 1.18.6.1; callr 3.8.0 starts fresh R processes for two examples; one example compiles C++ through Rcpp 1.1.2 with g++ 13.3; and the lintr 3.4.0 default linters were used only to check the scripts. Making R code fast comes down to one habit: measure first. Guessing which line is slow is wrong often enough that the manuals, the package documentation and every serious guide say the same thing, and R ships the tools to do it: a stopwatch (system.time()), a sampling profiler (Rprof()), and, in packages, benchmarking and visualisation (bench::mark(), microbenchmark(), profvis()). Only after a measurement says where the time goes does it make sense to reach for the remedies on the second half of the page: vectorising, preallocating, avoiding copies, vapply(), better matrix algebra, the byte-code compiler and its JIT (default since R 3.4.0), a faster BLAS, data.table and, last, a hot loop written in C++.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters, without any # nolint comment: the slow idioms on show (a growing vector, a loop that could be vectorised) are not ones that the default linters flag. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). No timing on this page is a number of seconds from the machine that ran the examples. Seconds depend on the processor, on what else is running, on the BLAS and on its threads, and a table of them would be wrong on every other computer. So a system.time() result is printed with <n> in place of each number, a bench::mark() result is shown through its relative columns (summary(b, relative = TRUE), every value divided by the smallest) rounded to a power of ten or to one digit because even the ratios move from run to run, a summaryRprof() table keeps its function names and its order but has <n> for the seconds and percentages, and wherever a number could not be made stable the script prints a comparison (TRUE when the slow version is at least a hundred times slower, for instance). The one measurement that does not vary is memory: the bytes a function allocates are the same on every run, so mem_alloc columns are printed as they are. The lines edited this way are the ones that contain <n>, and they are the only edits made to the output. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. A path in an error message is shortened to <tmp>. The BLAS of the machine that ran the examples is an OpenBLAS build, and the page never prints the library path that extSoftVersion() and La_library() return.
The neighbouring pages cover what this page only points at. Vectorisation and Iteration explains vectorised functions, the growing-vector trap and the byte-compiler in the first place; this page measures them and goes further on the measuring side. Memory and Copy Semantics is the place for tracemem(), Rprofmem(), gc() and copy-on-modify, Parallel and Asynchronous Computing for using several cores, and Interfacing C, C++ and Fortran for the compiled code that the last section only previews. The data.table package has its own page, data.table, and matrix algebra is in Matrices and Arrays.
Measure first
The manual Writing R Extensions has a chapter called "Tidying and profiling R code" for code that is worth keeping and sharing, and the advice of its section on profiling is the whole method in two sentences: profile to find the bottlenecks, then consider replacing the slow R code, perhaps with compiled code (Writing R Extensions: Profiling R code for speed). The profvis documentation states why: intuition leads people to optimise things that are a small share of the run time, and some of the commonly held beliefs are false (that the *apply functions are inherently faster than a for loop is the example it gives). The arithmetic behind it is Amdahl’s law. If a part of the program takes a share \( p \) of the run time and is made \( s \) times faster, the whole program takes the fraction below of its old time.
\[ \frac{T_{\rm new}}{T_{\rm old}} = (1 - p) + \frac{ p }{ s } \]
A loop that takes 10% of the run time (\( p = 0.1 \)) and is made five times faster (\( s = 5 \)) leaves the program at 92% of its old time, an 8% saving, however pleased one is with the five. Measuring first tells which \( p \) is large. The working loop is the same every time: write the clearest correct code, add a test that pins its result, measure it on realistic data, profile to find the part that matters, change that part, check that the result is unchanged, and measure again. The remedies in the second half of this page are all things to try at the fourth step, never first.
The tools differ in the question they answer, and a measurement session usually uses three of them in turn.
| Tool | Question it answers | What it costs and returns | Package |
|---|---|---|---|
|
How long did this one evaluation take? |
Almost nothing. One |
base R |
|
Which of these equivalent expressions is faster, and how much memory does each allocate? |
Runs each expression many times; a tibble with min, median, |
bench |
|
Which of these tiny expressions is faster? |
Runs each expression |
microbenchmark (community) |
|
Which functions of my program take the time? |
Samples the call stack every |
base R ( |
|
The same, drawn as a flame graph over the source lines. |
|
profvis |
|
Where is memory allocated and copied? |
base R |
Timing code
system.time and proc.time
system.time(expr) calls proc.time(), evaluates expr, calls proc.time() again and returns the difference. The result is an object of class proc_time: a numeric vector of length five holding the user and system CPU time of the R process (user.self, sys.self), the elapsed ("real", wall-clock) time (elapsed), and the user and system time of child processes that R has waited for (user.child, sys.child). Its print method folds the child times into the process times and shows three numbers, called user, system and elapsed. The help page defines the first two as the CPU time charged to the process for executing user instructions and for work done by the system on its behalf, and says that child times are not available on Windows and are NA there. The clock resolution is system specific: on Unix-alikes times are rounded down to milliseconds, and on Windows they are typically available to 10 ms (R documentation: proc.time). Two consequences follow. Anything shorter than a few milliseconds cannot be timed once with system.time(), and elapsed can be much smaller than user when a multi-threaded BLAS or other library keeps several cores busy at once.
The difference between CPU time and elapsed time is the first thing to understand about any timing. Sys.sleep() waits without computing: it costs elapsed time and no CPU time. A computation costs both. The script below shows the structure of the result, the sleeping against the computing, that the expression runs in the calling environment (so an assignment inside system.time() is visible afterwards), and the gcFirst argument, whose default TRUE runs a garbage collection just before the evaluation so that its cost is not charged to the expression that happens to trigger it.
# system.time() returns an object of class "proc_time" with five numbers.
set.seed(1)
st <- system.time(sort(runif(2e6)))
class(st)
#> [1] "proc_time"
names(st)
#> [1] "user.self" "sys.self" "elapsed" "user.child" "sys.child"
length(unclass(st))
#> [1] 5
# print() combines the child times with the process times and shows three.
st
#> user system elapsed
#> <n> <n> <n>
# Waiting costs elapsed time but no CPU time.
sleeping <- system.time(Sys.sleep(0.25))
sleeping[["elapsed"]] >= 0.25
#> [1] TRUE
sleeping[["user.self"]] < sleeping[["elapsed"]]
#> [1] TRUE
# Computing costs both.
busy <- system.time(for (i in seq_len(3e6)) i)
busy[["user.self"]] > 0
#> [1] TRUE
# The expression runs in the calling environment, so an assignment stays.
system.time(total <- sum(sort(runif(2e6))))
#> user system elapsed
#> <n> <n> <n>
total > 0
#> [1] TRUE
# proc.time() is the clock behind it; subtracting two readings is the same.
start <- proc.time()
invisible(sort(runif(2e6)))
spent <- proc.time() - start
class(spent)
#> [1] "proc_time"
spent[["elapsed"]] >= 0
#> [1] TRUE
# Timing without the garbage collection that normally precedes it.
st2 <- system.time(sort(runif(2e6)), gcFirst = FALSE)
inherits(st2, "proc_time")
#> [1] TRUE
Source: R documentation: system.time
If the expression fails, system.time() still reports how far it got, in a message that starts with Timing stopped at: and gives the same three numbers (see the section on common errors below). It is a good tool for a first look and for a long job, because it adds no overhead and no dependency. It is a poor tool for comparing two fast expressions, which is the reason for the next two sections.
One measurement is not enough
A single timing is one sample from a noisy process. The computer is doing other things, the CPU changes its clock speed, the memory caches are warm or cold, the garbage collector runs when it happens to, and the first time anything is used it can cost more than later: a package namespace is loaded, a function is byte-compiled by the JIT (a small closure on its second call, a closure with a loop on its first; see the section on the compiler), a file is read from disk instead of the cache. The remedy is the usual one in measurement: repeat, and report a statistic that does not move with the outliers, the minimum or the median rather than the mean. The script below repeats a computation ten times and shows that the numbers differ, shows that the first load of a package costs far more than the second, and times the parts of a computation separately, because the total of a section of a script hides which of its lines is slow.
# One timing is one sample from a noisy process: repeat it and look at the
# spread.
set.seed(42)
work <- function() sort(runif(5e5))
elapsed <- replicate(10, system.time(work())[["elapsed"]])
length(elapsed)
#> [1] 10
max(elapsed) > min(elapsed)
#> [1] TRUE
# The first use of something can cost far more than the later ones: here the
# first call loads and attaches the namespace of a package, the second finds
# it already loaded.
first <- system.time(loadNamespace("data.table"))[["elapsed"]]
second <- system.time(loadNamespace("data.table"))[["elapsed"]]
first > 10 * second
#> [1] TRUE
# Timing a whole section hides which part is slow: time the parts.
parts <- c(
generate = system.time(v <- runif(2e6))[["elapsed"]],
sort = system.time(s <- sort(v))[["elapsed"]],
cumulate = system.time(cs <- cumsum(s))[["elapsed"]]
)
names(parts)
#> [1] "generate" "sort" "cumulate"
parts[["sort"]] > parts[["cumulate"]]
#> [1] TRUE
Source: R documentation: system.time
Writing the loop by hand (for (i in 1:100) system.time(...) and averaging) is what the next two tools automate, with the repeats, the warm-up, the choice of statistic, the check that the expressions agree, and, in bench, the memory.
bench::mark
bench::mark() is the benchmarking function of the bench package (version 1.1.4 here; bench package documentation: mark). It takes any number of expressions, named or not, evaluates each at least twice, once to measure the memory allocation and store the result, and then as many times as needed to fill min_time seconds, and returns a tibble with one row per expression. Its arguments, with their defaults, are these.
| Argument | Default | Meaning |
|---|---|---|
|
none |
The expressions to compare. A name becomes the |
|
|
The minimum number of seconds to keep running each expression. |
|
|
If given, run each expression exactly this many times, overriding |
|
|
Lower and upper bound on the number of timed runs. |
|
|
Compare the results with |
|
|
Track allocations with |
|
|
Drop the iterations that contained a garbage collection before summarising. If every iteration had one, the filter is disabled, with a warning. |
|
|
Divide every summary column by the smallest, instead of reporting absolute values. |
|
|
Report times in one unit ( |
|
|
The environment in which the expressions are evaluated. |
The columns of the result are expression (class bench_expr), min and median (bench_time), itr/sec (iterations per second), mem_alloc (bench_bytes), gc/sec, n_itr (the number of timed iterations after GC filtering), n_gc (the garbage collections over all iterations, a pseudo-measure of the pressure on the collector), total_time, and the list columns result, memory, time (the raw bench_time vector of each run) and gc. The classes print readably (1.2ms, 7.63MB) and are numeric underneath. The check argument is the feature that matters most for correctness: an optimisation that returns a different answer is a bug, and mark() stops with an error if the expressions disagree. The script below is the comparison of the previous section made properly: the days a loan is overdue, for 120000 loans, computed by a preallocated loop and by vectorised arithmetic. The relative column is rounded to the nearest power of ten because the exact ratio changes from run to run (it was between 1000 and 2000 in the runs that were made), and the script passes filter_gc = FALSE to mark() and to summary() because the loop allocates enough small objects to collect garbage in every iteration, which would otherwise make the function print a warning.
source("scenario.R")
# The shelf loans repeated 2000 times: 120000 rows.
loan_date <- rep(loans$loan_date, 2000L)
return_date <- rep(loans$return_date, 2000L)
late_loop <- function(loan_date, return_date, today, allowed) {
out <- integer(length(loan_date))
for (i in seq_along(loan_date)) {
end <- return_date[[i]]
if (is.na(end)) {
end <- today
}
out[[i]] <- max(0L, as.integer(end - loan_date[[i]]) - allowed)
}
out
}
late_vector <- function(loan_date, return_date, today, allowed) {
end <- return_date
end[is.na(end)] <- today
pmax(0L, as.integer(end - loan_date) - allowed)
}
b <- bench::mark(
loop = late_loop(loan_date, return_date, shelf_today, loan_days),
vector = late_vector(loan_date, return_date, shelf_today, loan_days),
min_iterations = 10,
filter_gc = FALSE
)
class(b)
#> [1] "bench_mark" "tbl_df" "tbl" "data.frame"
names(b)
#> [1] "expression" "min" "median" "itr/sec" "mem_alloc"
#> [6] "gc/sec" "n_itr" "n_gc" "total_time" "result"
#> [11] "memory" "time" "gc"
# Times and memory have their own classes; the raw timings are kept per run.
vapply(b[c("min", "median", "mem_alloc")], \(x) class(x)[[1L]], character(1))
#> min median mem_alloc
#> "bench_time" "bench_time" "bench_bytes"
b$n_itr >= 10
#> [1] TRUE TRUE
lengths(b$time) == b$n_itr
#> [1] TRUE TRUE
# By default mark() checks that the expressions return the same value.
identical(b$result[[1L]], b$result[[2L]])
#> [1] TRUE
# Absolute times depend on the machine; ratios carry over better. Even the
# ratios change from run to run, so round them to the nearest power of ten.
power_of_ten <- \(x) 10^round(log10(x))
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
rel$median <- power_of_ten(rel$median)
rel[c("expression", "median")]
#> # A tibble: 2 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 loop 1000
#> 2 vector 1
bench::press() runs mark() across a grid of parameters and binds the results into one tibble, which is how one finds out how a cost grows (bench package documentation: press). Named arguments define the grid, and the one unnamed expression, usually a braced block, runs once per row of the grid with the parameters available as variables. The script below grows a vector with c() and fills a preallocated one, for three sizes, and prints the memory column. That column shows the difference with no noise: doubling the length roughly quadruples the memory of the growing version, because every iteration allocates a new vector of the size so far, and only doubles the memory of the preallocated one. The function bench::system_time() is the single-shot counterpart, a system.time() that returns bench_time values called process (CPU) and real (elapsed).
# Growing a vector with c() against preallocating it, for three sizes.
grow <- function(n) {
out <- c()
for (i in seq_len(n)) {
out <- c(out, i)
}
out
}
prealloc <- function(n) {
out <- integer(n)
for (i in seq_len(n)) {
out[[i]] <- i
}
out
}
# Run both once or twice first so that the byte-code compiler's own
# allocations are not counted in the first measurement.
for (warm in 1:3) {
grow(10L)
prealloc(10L)
}
res <- bench::press(
n = c(1000L, 2000L, 4000L),
{
bench::mark(
grow = grow(n),
prealloc = prealloc(n),
min_iterations = 5,
filter_gc = FALSE
)
},
.quiet = TRUE
)
names(res)[1:2]
#> [1] "expression" "n"
res[c("expression", "n", "mem_alloc")]
#> # A tibble: 6 × 3
#> expression n mem_alloc
#> <bch:expr> <int> <bch:byt>
#> 1 grow 1000 1.95MB
#> 2 prealloc 1000 3.95KB
#> 3 grow 2000 7.72MB
#> 4 prealloc 2000 7.86KB
#> 5 grow 4000 30.71MB
#> 6 prealloc 4000 15.67KB
# The memory column is the clearest signal: it is not noisy like a time.
# Doubling n quadruples the memory of the growing version (roughly) and
# doubles that of the preallocated one.
mem <- as.numeric(res$mem_alloc)
name <- as.character(res$expression)
mem[name == "grow" & res$n == 4000L] / mem[name == "grow" & res$n == 2000L]
#> [1] 3.975697
mem[name == "prealloc" & res$n == 4000L] /
mem[name == "prealloc" & res$n == 2000L]
#> [1] 1.994036
# bench::system_time() times one evaluation, like system.time(), but returns
# the process (CPU) time and the real (elapsed) time as bench_time values.
tm <- bench::system_time(sort(runif(1e6)))
names(tm)
#> [1] "process" "real"
class(tm)
#> [1] "bench_time" "numeric"
The script warms both functions up before the first measurement. The first call of a function allocates memory for its byte code, which mark() would otherwise count in the memory of the first expression it measures (without the warm-up the preallocated version at 1000 elements reported a larger figure than at 2000). With a ggplot2 installation, ggplot2::autoplot() of a bench_mark result draws the distribution of the timings of each expression; that is mentioned and not run here.
microbenchmark
The microbenchmark package (version 1.5.0, whose help page names Olaf Mersmann as its author, on CRAN; microbenchmark on CRAN) is a community package that predates bench and is still widely used in older answers and tutorials. microbenchmark(..., times = 100L) evaluates each expression times times, in an order chosen by control$order ("random", the default, "inorder" or "block"), after control$warmup warm-up iterations (default 2), using the nanosecond timer of the operating system and subtracting the estimated cost of the timing calls themselves. It returns a data frame of class microbenchmark with one row per evaluation, whose summary() has the columns expr, min, lq, mean, median, uq, max and neval, and whose unit argument ("relative" among others) chooses the unit. Its differences from bench are worth knowing. The check argument defaults to NULL, which skips the check, and takes a function or one of the strings "equal", "equivalent" and "identical". There is no memory column. And its own help page says it is meant for micro-benchmarks of small pieces of code, and recommends the R profiler for hot spots in larger chunks. The script below compares vapply() over 100000 numbers with the vectorised expression, shows the check refusing expressions that disagree, and uses the control list. Its relative median is printed rounded down to a power of ten (the ratio was about 350).
library(microbenchmark)
set.seed(7)
x <- runif(1e5)
mb <- microbenchmark(
vapply = vapply(x, \(v) v * 2 + 1, numeric(1)),
vector = x * 2 + 1,
times = 30L,
check = "equal"
)
class(mb)
#> [1] "microbenchmark" "data.frame"
names(summary(mb))
#> [1] "expr" "min" "lq" "mean" "median" "uq" "max" "neval"
# unit = "relative" divides every statistic by the smallest median. The ratio
# changes from run to run, so print it rounded down to a power of ten.
rel <- summary(mb, unit = "relative")
rel$median <- 10^floor(log10(rel$median))
rel[c("expr", "median", "neval")]
#> expr median neval
#> 1 vapply 100 30
#> 2 vector 1 30
# check = "equal" refuses to time expressions that disagree.
tryCatch(
microbenchmark(sum(x), mean(x), times = 5L, check = "equal"),
error = \(e) conditionMessage(e)
)
#> [1] "Input expressions are not equivalent."
# The control list: how many warm-up runs, and in which order to run.
mb2 <- microbenchmark(
cumsum = cumsum(x),
rev = rev(x),
times = 20L,
control = list(warmup = 5L, order = "random")
)
nrow(mb2)
#> [1] 40
levels(mb2$expr)
#> [1] "cumsum" "rev"
Source: microbenchmark on CRAN
The help page also says that a timing which turns out negative after the overhead is subtracted is replaced by zero with a warning, and that if every evaluation does so, the function stops with an error: the symptom of an expression too fast to measure, such as NULL. The data.table documentation, in its guide to benchmarking, adds a caution that applies to every tool in this section: repeating a benchmark hundreds of times is the right design for atomic calculations, but it is not a good picture of data-processing tasks, where a handful of runs on data large enough to take seconds is more representative, because call overhead and garbage collection dominate the tiny ones (data.table package documentation: Benchmarking data.table).
Choosing and reading a benchmark
The guidelines that follow are the ones that remove most wrong conclusions.
-
Compare expressions that compute the same thing, and let the tool check it:
check = TRUEinbench::mark()(with a tolerance function such as\(a, b) isTRUE(all.equal(a, b))for floating-point results),check = "equal"inmicrobenchmark(). -
Use data of realistic size. A trick that wins on 100 elements can lose on 100000, and the other way round;
bench::press()shows the trend. -
Look at the median or the minimum, not the mean, and at the memory as well as the time. Memory is reproducible; time is not.
-
Put the setup outside the timed expression: build the data once, before the call. Timing
x <- runif(1e6); sort(x)times the random numbers too. -
Time the code the way it will be used: as a call of a function with its arguments, not as loose statements that read globals.
-
Warm up. The first call pays for byte-compilation and lazy loading;
bench::mark()andmicrobenchmark()run more than once, and a script that times a single call should call the function once before. -
Do not benchmark what the optimiser can skip. An expression whose value is unused may do less work than it would in real use; assign or return the value.
-
Say what the machine was. The same comparison on another computer, another BLAS or with other programs running will give different ratios. A claim such as "ten times faster" needs "on this data, on this machine".
Profiling
Timing says how long; profiling says where. R’s profiler is a sampling profiler. Rprof() starts it, and every interval seconds it writes the current call stack, the list of the R functions that are being evaluated, one above the other, to a file. summaryRprof() reads the file and counts how many samples each function was on the stack. The approach is cheap and needs no change to the code, but it is statistical: a function that runs for less than one interval can be missed entirely, the result changes from run to run, and it is only as precise as the number of samples.
Rprof and summaryRprof
The usage of Rprof() is Rprof(filename = "Rprof.out", append = FALSE, interval = 0.02, memory.profiling = FALSE, gc.profiling = FALSE, line.profiling = FALSE, filter.callframes = FALSE, numfiles = 100L, bufsize = 10000L, event = c("default", "cpu", "elapsed")). Enabling profiling disables any profiling that was running, so Rprof(NULL) (or "") stops it; calling Rprof() again with append = TRUE adds to the same file (R documentation: Rprof).
| Argument | Default | Meaning |
|---|---|---|
|
|
The file that receives the samples. |
|
|
Overwrite the file, or add to it. |
|
|
Seconds between samples, in units of the profiling event. On Linux R requires at least 10 ms, elsewhere 1 ms; a shorter interval is rounded up with a warning. |
|
|
Also write memory use and the number of |
|
|
Record whether the garbage collector is running at each sample, as a |
|
|
Also record the source line, which needs source references (see the next section). |
|
|
Drop the intervening call frames that lazy evaluation and |
|
|
Sizes of the pre-allocated buffers for file names in line profiling; the profiler interrupts R asynchronously and cannot allocate memory while it runs. |
|
|
|
Source: R documentation: Rprof
Two details of what is recorded matter when reading a profile. Only functions that put a context on the call stack appear: some primitives, those of type "special", do not, which is why <-, [ and $ never show up as time even when they take much of it (their cost is charged to the function that called them, and to <GC> when gc.profiling is on and they allocate). And since the profile is of R code, the time spent in compiled code that R calls, .Call() or .Fortran() for instance, is charged to the R function that made the call; the profiler cannot look inside it. The section on profiling compiled code below says what to use for that.
summaryRprof() returns a list: by.self, a data frame sorted by self time, by.total, sorted by total time, sample.interval and sampling.time. Both data frames have the columns self.time, self.pct, total.time and total.pct, with times in seconds and percentages of the total sampled time. Self time is the time when the function was the last one on the stack, the one doing the work. Total time is the time when it was anywhere on the stack, so it includes everything it called. A function with a high total and a low self time is a router, calling something that is slow; a function with a high self time is itself the work. The row names are the function names inside double quotes (the quotes are part of the name). The script below profiles the ISBN check from the scenario code, is_valid_isbn13(), over 600000 strings, and prints the structure of the result and the two tables. The seconds and percentages are replaced with <n>; the function names and their order are what was printed, with one concession: by.self is printed for four rows picked by name, because the first few rows of that table are near ties whose order changes from run to run.
source("scenario.R")
# 600000 ISBN strings: the non-empty shelf ISBNs repeated 40000 times.
isbns <- rep(normalise_isbn(books$isbn[nzchar(books$isbn)]), 40000L)
length(isbns)
#> [1] 600000
# Profile one call: Rprof(file) starts sampling, Rprof(NULL) stops it.
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01)
valid <- is_valid_isbn13(isbns)
Rprof(NULL)
file.exists(prof)
#> [1] TRUE
mean(valid)
#> [1] 0.9333333
s <- summaryRprof(prof)
names(s)
#> [1] "by.self" "by.total" "sample.interval" "sampling.time"
s$sample.interval
#> [1] 0.01
colnames(s$by.total)
#> [1] "total.time" "total.pct" "self.time" "self.pct"
s$sampling.time > 0
#> [1] TRUE
# by.total: time in the function and in everything it called. The rows
# below the third are near ties that change order from run to run.
head(s$by.total, 3)
#> total.time total.pct self.time self.pct
#> "is_valid_isbn13" <n> <n> <n> <n>
#> "vapply" <n> <n> <n> <n>
#> "FUN" <n> <n> <n> <n>
# by.self: time in the function alone. Near ties change order from run to
# run, so pick the rows by name.
s$by.self[c("\"strsplit\"", "\"as.integer\"", "\"FUN\"", "\"vapply\""), ]
#> self.time self.pct total.time total.pct
#> "strsplit" <n> <n> <n> <n>
#> "as.integer" <n> <n> <n> <n>
#> "FUN" <n> <n> <n> <n>
#> "vapply" <n> <n> <n> <n>
unlink(prof)
Source: R documentation: summaryRprof
The profile reads top-down in by.total. The whole call, is_valid_isbn13(), is 100% of the time. vapply() is most of it, and FUN, the anonymous function that is_valid_isbn13() passes to vapply(), takes three quarters (R names the function that vapply() calls FUN, which is the name of its argument; an anonymous function that is not an argument shows as <Anonymous>). Below FUN the time is in strsplit() and as.integer(), the work of splitting one ISBN into digits and converting them, and a smaller part, outside FUN, is in gsub() and toupper(), called by normalise_isbn(). The reading is that the function is slow because it splits and converts one string at a time, and the remedy is to do that for all the elements at once. It is also clear from by.self that vapply() itself, the loop, is a small part of the cost: replacing vapply() with a for loop would have bought almost nothing.
The profiler can also be run from the shell on a file that Rprof() wrote: R CMD Rprof prints the same two tables as text. The script below writes Rprof.out in the working directory (the default name) after profiling a small job, and the commands below were run in that directory. Its options (--lines, --total, --self, --linesonly, --min%total=, --min%self=) are listed by R CMD Rprof --help. The output is shown with <n> for the numbers, and the header line with the total run time is likewise edited.
# Profile a small job and leave the samples in Rprof.out (the default name).
digit_sum <- function(isbn) {
sum(as.integer(strsplit(isbn, "")[[1L]]))
}
Rprof("Rprof.out", interval = 0.01)
sums <- vapply(rep("9780306406157", 2e5), digit_sum, integer(1))
Rprof(NULL)
unique(unname(sums))
#> [1] 56
Source: R documentation: Rprof
# the script above, saved as rprof-loans.R
Rscript rprof-loans.R
R CMD Rprof Rprof.out
Each sample represents 0.01 seconds.
Total run time: <n> seconds.
Total seconds: time spent in function and callees.
Self seconds: time spent in function alone.
% total % self
total seconds self seconds name
<n> <n> <n> <n> "vapply"
<n> <n> <n> <n> "FUN"
<n> <n> <n> <n> "as.integer"
<n> <n> <n> <n> "strsplit"
<n> <n> <n> <n> "sum"
% self % total
self seconds total seconds name
<n> <n> <n> <n> "as.integer"
<n> <n> <n> <n> "strsplit"
<n> <n> <n> <n> "FUN"
<n> <n> <n> <n> "vapply"
<n> <n> <n> <n> "sum"
The file that Rprof() writes is plain text, and knowing its shape explains several of the behaviours above. The first line is sample.interval= followed by the interval in microseconds, and every following line is one sample, the call stack with the innermost call first, each function name in double quotes (those are the quotes that appear in the row names of the summary tables). append = TRUE adds new samples to an existing file, which is how a profile of several separate stretches of a program is collected into one summary: the sampling time of the combined file is larger than that of the first session alone. The script below shows the first line, the shape of the others, the effect of append, and that Rprof(NULL) when nothing is running does nothing.
work <- function(n) sum(sort(runif(n)))
# The file starts with a header line giving the interval in microseconds; each
# later line is one sample: the call stack, innermost call first.
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01)
invisible(work(2e6))
Rprof(NULL)
lines <- readLines(prof)
lines[[1L]]
#> [1] "sample.interval=10000"
all(grepl("^\"", lines[-1L]))
#> [1] TRUE
first <- summaryRprof(prof)$sampling.time
# append = TRUE adds to the same file, so one summary covers both sessions.
Rprof(prof, interval = 0.01, append = TRUE)
invisible(work(2e6))
Rprof(NULL)
both <- summaryRprof(prof)$sampling.time
both > first
#> [1] TRUE
unlink(prof)
# Rprof(NULL) when nothing is running is harmless.
Rprof(NULL)
Source: R documentation: Rprof
Two arguments change what the samples contain. gc.profiling = TRUE records, for each sample, whether the garbage collector was running, and the summary then has a <GC> row. A function that allocates all the time, like the growing vector of the earlier sections, spends a visible share of its time there, and the row is the profile’s way of showing allocation as time; without the argument the collector’s time is charged to whatever function it interrupted. filter.callframes = TRUE (R 4.0.0) removes the frames that lazy evaluation and eval() put between a caller and the function that finally evaluates its argument. The help page gives try(EXPR) as the example: its call tree has tryCatch(), tryCatchList(), tryCatchOne() and doTryCatch() between try() and EXPR, and the filter drops those four. The two scripts show each: with gc.profiling the <GC> row appears and without it does not, and with the filter tryCatchList has disappeared from a profile of a function called inside try() while the function itself remains.
# Growing a vector allocates in every iteration, so the garbage collector runs
# during the loop. gc.profiling = TRUE records those samples as <GC>.
grow <- function(n) {
out <- c()
for (i in seq_len(n)) {
out <- c(out, i)
}
out
}
profile_names <- function(gc_profiling) {
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01, gc.profiling = gc_profiling)
invisible(grow(60000L))
Rprof(NULL)
s <- summaryRprof(prof)
unlink(prof)
rownames(s$by.total)
}
"\"<GC>\"" %in% profile_names(TRUE)
#> [1] TRUE
"\"<GC>\"" %in% profile_names(FALSE)
#> [1] FALSE
"\"grow\"" %in% profile_names(TRUE)
#> [1] TRUE
Source: R documentation: Rprof
# try() puts several intervening frames (tryCatch and its helpers) between the
# caller and the expression that is finally evaluated. filter.callframes drops
# them from the profile.
work <- function() {
sum(sort(runif(2e6)))
}
profile_names <- function(filter) {
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01, filter.callframes = filter)
invisible(try(work(), silent = TRUE))
Rprof(NULL)
s <- summaryRprof(prof)
unlink(prof)
rownames(s$by.total)
}
plain <- profile_names(FALSE)
filtered <- profile_names(TRUE)
"\"tryCatchList\"" %in% plain
#> [1] TRUE
"\"tryCatchList\"" %in% filtered
#> [1] FALSE
"\"work\"" %in% filtered
#> [1] TRUE
length(filtered) < length(plain)
#> [1] TRUE
Source: R documentation: Rprof
Reading a profile is a matter of asking the same three questions each time.
-
Which function has the largest total time that is not just the entry point? That is the branch of the program to look into.
-
Within it, which function has the largest self time? That is where the work is done, and what to change.
-
Is the work in R code or in compiled code? R functions such as
strsplit()andgsub()are mostly compiled code behind a short R wrapper; a large self time there means the argument is large or the call is repeated, and the remedy is to call it fewer times on more data, not to rewrite it.
Three cautions apply. Profiling has a small cost of its own, and the output file can grow large for a long run at the default interval. A short profile is unreliable: a run of a few hundred milliseconds has only a few dozen samples, so repeat the work (the scripts here loop over a few hundred thousand elements for that reason) or lengthen the data. And the garbage collector runs from time to time and its time is charged to whichever function was running when it started; Writing R Extensions suggests comparing a profile taken straight after a gc() with one taken without (Writing R Extensions: Profiling R code for speed). R-admin adds a fourth, for installations that use a multi-threaded BLAS: profiling uses the SIGPROF signal, which may cause problems with threaded libraries, and the advice is to disable profiling when one is used (R Installation and Administration: Linear algebra).
Line profiling
With line.profiling = TRUE the profiler also records the source line that is being executed, and summaryRprof(lines = "show") adds a by.line table, sorted by source location, whose row names are file#line. lines = "both" mixes function names and locations in one table, and lines = "hide" (the default) shows only function names; basenames is the number of components of the path to display. The condition is that the code was parsed with source references: the help page names keep.source = TRUE in source() and KeepSource: yes in a package’s DESCRIPTION as ways to get them (R documentation: summaryRprof). This matters because Rscript does not keep them. The option keep.source is FALSE unless the session is interactive, so a function defined in a script has no source reference and a line profile of it is empty; the next script shows the check, and the one after it works around it by writing the function to a file and sourcing it with keep.source = TRUE.
# Was this R built with the sampling profiler and the memory-allocation
# profiler? Both are on in the standard builds.
capabilities(c("Rprof", "profmem"))
#> Rprof profmem
#> TRUE TRUE
# Line profiling needs source references. Rscript does not keep them (the
# option keep.source is FALSE unless the session is interactive), so a
# function defined in a script has none.
interactive()
#> [1] FALSE
getOption("keep.source")
#> [1] FALSE
f <- function(x) x + 1
is.null(attr(f, "srcref"))
#> [1] TRUE
Source: R documentation: capabilities
The function in the next script computes a late fee for each loan with a for loop (the file is written under the session’s temporary directory, which keeps its name hotspot.R and has no path in the output). The profile points at line 5, as.integer(end - loan_date[[i]]), which is about four fifths of the samples (75 to 83% in the runs that were made): the subtraction of two dates dispatches to -.Date, which builds a difftime. Line 4, the if that handles unreturned loans, is the second most expensive; line 6, the arithmetic, is so cheap that some runs did not sample it at all, which is why the script picks the two rows by name. Without a line profile the by-function table would have said -.Date and difftime; with it, it names the line of the program that calls them, which is what one edits.
source("scenario.R")
# A function in a file, so that its lines have source references.
hotspot <- file.path(tempdir(), "hotspot.R")
writeLines(c(
"late_fees <- function(loan_date, return_date, today, allowed) {",
" out <- numeric(length(loan_date))",
" for (i in seq_along(loan_date)) {",
" end <- if (is.na(return_date[[i]])) today else return_date[[i]]",
" days <- as.integer(end - loan_date[[i]])",
" out[[i]] <- max(0, days - allowed) * 0.25",
" }",
" out",
"}"
), hotspot)
source(hotspot, keep.source = TRUE)
loan_date <- rep(loans$loan_date, 1500L)
return_date <- rep(loans$return_date, 1500L)
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01, line.profiling = TRUE)
fees <- late_fees(loan_date, return_date, shelf_today, loan_days)
Rprof(NULL)
length(fees)
#> [1] 90000
# lines = "show" adds by.line: time per source line, named file#line, in the
# order of the lines in the file.
s <- summaryRprof(prof, lines = "show")
names(s)
#> [1] "by.self" "by.total" "by.line" "sample.interval"
#> [5] "sampling.time"
s$by.line[c("hotspot.R#4", "hotspot.R#5"), ]
#> self.time self.pct total.time total.pct
#> hotspot.R#4 <n> <n> <n> <n>
#> hotspot.R#5 <n> <n> <n> <n>
# lines = "both" mixes function names and line locations in one table.
b <- summaryRprof(prof, lines = "both")
any(grepl("#", rownames(b$by.total), fixed = TRUE))
#> [1] TRUE
unlink(c(prof, hotspot))
Source: R documentation: summaryRprof
CPU time or elapsed time: Rprof(event =) (R 4.4.0)
Before R 4.4.0 the profiler measured one thing: CPU time on Unix-alikes, elapsed time on Windows. R 4.4.0 added the event argument and, with it, profiling by elapsed (real, wall-clock) time on Unix (R NEWS: "Rprof() gains an event argument and support for elapsed (real) time profiling on Unix", R NEWS: Changes in R 4.4.0). The help page explains the difference. With "cpu", the default on Unix-alikes and typically the preferred event for finding performance bottlenecks, the profiler counts CPU time of the R process, so it excludes the time R spends waiting for input or for processes started by system(); it can go slower than the elapsed clock when the process often waits, and faster when concurrent threads (OpenMP, a threaded BLAS) compute. With "elapsed", the default and the only supported event on Windows, it counts the time the process is running and executing an R command, including a readline() that waits for input. The help page also says that elapsed profiling on Unix is new and considered experimental: to avoid missing a sample, R tries to use the real-time FIFO scheduling policy with the maximum priority for the internal thread that takes each sample, and if that is not allowed it falls back to the maximum priority of the current policy, which matters only when profiling a machine under high load. "default" is the platform’s choice.
The choice changes what the profile says about code that waits, and the script below is built to show it. wait_then_work() sleeps for a second and then sorts a million random numbers. Sys.sleep() stands in for a slow download or a database query. The CPU profile never sees the sleep, only the sorting (a fraction of a second of CPU in a function that takes over a second), and the elapsed profile shows Sys.sleep() as nine tenths of the time. For a program that is slow because it waits on a network, a disk or another process, the CPU profile is blind to the cause and the elapsed profile is the right one. For a computation, the CPU profile is the cleaner. The script ends with the two errors the arguments can raise, with their real messages: an unknown event is an error from match.arg(), and an interval below the platform minimum is a warning that says what was used instead.
# A function that mostly waits: Sys.sleep() stands in for a slow download or
# a database query. It uses almost no CPU.
wait_then_work <- function() {
Sys.sleep(1)
sum(sort(runif(1e6)))
}
profile_with <- function(event) {
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01, event = event)
invisible(wait_then_work())
Rprof(NULL)
s <- summaryRprof(prof)
unlink(prof)
s
}
cpu <- profile_with("cpu")
elapsed <- profile_with("elapsed")
# CPU time never sees the sleep, only the sorting; elapsed time sees both.
"\"Sys.sleep\"" %in% rownames(cpu$by.total)
#> [1] FALSE
"\"order\"" %in% rownames(cpu$by.total)
#> [1] TRUE
cpu$sampling.time < 0.5
#> [1] TRUE
"\"Sys.sleep\"" %in% rownames(elapsed$by.total)
#> [1] TRUE
elapsed$sampling.time > 0.5
#> [1] TRUE
# The share of the profile that is Sys.sleep() with the elapsed event.
elapsed$by.total["\"Sys.sleep\"", "total.pct"] > 50
#> [1] TRUE
head(elapsed$by.total, 3)
#> total.time total.pct self.time self.pct
#> "profile_with" <n> <n> <n> <n>
#> "wait_then_work" <n> <n> <n> <n>
#> "Sys.sleep" <n> <n> <n> <n>
# "default" is "cpu" on Unix-alikes and "elapsed" on Windows; anything else is
# an error, and an interval below the platform minimum is raised with a
# warning.
tryCatch(Rprof(tempfile(), event = "wall"), error = \(e) conditionMessage(e))
#> [1] "'arg' should be one of “default”, “cpu”, “elapsed”"
tryCatch(
Rprof(tempfile(), interval = 0.001),
warning = \(w) conditionMessage(w)
)
#> [1] "interval too short for this platform, using '0.010000'"
Rprof(NULL)
Source: R documentation: Rprof
profvis, described below, follows the same rule: its timing argument defaults to elapsed time where that is possible, that is on Windows or on R 4.4.0 or later, and "cpu" selects CPU time (profvis package documentation: profvis).
Memory: three tools in brief
Memory use is part of speed, because allocation, copying and garbage collection are time, and the subject has its own page: Memory and Copy Semantics. In brief, there are three profiling tools. Rprof(memory.profiling = TRUE) writes the total allocation in small vectors, large vectors and cons cells at each sample and the number of calls to the internal duplicate function that copies objects, and summaryRprof(memory = "both") adds the change in memory to the timing tables; its caveat is that memory is attributed to the function running at the end of the sampling interval, and a collection can make the memory fall (Writing R Extensions: Memory statistics from Rprof). Rprofmem() is a non-sampling profiler that writes a stack trace each time a large vector is allocated (above a threshold given in bytes) or a new page of the R heap is allocated; it is what bench::mark() uses for its mem_alloc column (Writing R Extensions: Tracking memory allocations). tracemem() marks one object and prints a message whenever it is copied (Writing R Extensions: Tracing copies of an object), and gc() reports the memory in use and triggers a collection (R documentation: gc). The last two need R to have been built with memory profiling, which capabilities("profmem") (shown in the script above) answers. Writing R Extensions notes that memory profiling is difficult mainly because of garbage collection: allocation happens at well-defined times and release at whenever the collector runs.
profvis
profvis (version 0.4.0 here, profvis package documentation) draws the data of Rprof() as an interactive page. profvis(expr) runs the expression with line profiling and memory profiling on, and returns an htmlwidget of class profvis that RStudio and Positron show in a viewer and that htmlwidgets::saveWidget() writes to a self-contained HTML file. The page has the source code on top, with the time and memory attributed to each line, and a flame graph below it, where the horizontal axis is time and the vertical axis is the call stack, so the bottom block is the outermost call and every block above it is a function called by the one below; the width of a block is the time spent in it. A Data tab gives the same information as a table that expands the call stack. The documentation lists what the graph cannot show: some internal functions do not appear (<-, [ and $ among them, because they are not on the call stack, though their side effects show up as <GC> blocks), calls through an anonymous function appear as <Anonymous> unless the function is saved in a variable, white blocks are code for which profvis does not have the source (base R and packages), and nothing in C, C++ or Fortran is visible, only the R function that called it (profvis package documentation: Getting started with profvis, profvis package documentation: Frequently asked questions). Memory figures on a line are the allocation between the previous sample and this one, which may have happened on an earlier line.
The arguments of profvis() that matter are expr, the expression to profile (turned into the body of a function, so variables created inside it are not visible outside), interval (default 0.01, and values below 0.005 will probably not give accurate timings), prof_output (a file or directory in which to keep the Rprof() data; the default is a temporary file removed on exit), prof_input (the path to an Rprof() data file produced elsewhere, in which case there is no expr), timing (the event, described above), simplify (the equivalent of filter.callframes), rerun (run again until a sample is recorded, for expressions that return before the first sample), torture (garbage-collect after every so many allocations, which makes memory figures more exact) and split ("h" or "v", the orientation of the divider). A terminal has no viewer, so the script below does what a script, a test or a CI job can do: it creates the widget, checks its class, saves it to a temporary HTML file, and builds a second one from an Rprof() file with prof_input. The flame graph itself is an interactive page and is described, not shown. For the first widget, which profiles the loop of the benchmarking section for 36000 loans, the data behind it has late_loop at the bottom of the stack, -.Date above it with difftime and as.POSIXct.Date above that, and [[.Date with .Date as the other tower: the Date arithmetic inside the loop is what the graph is made of. Under Rscript the code has no source references (keep.source is FALSE, as the script above showed), so the flame graph has function names only and the code panel has no per-line figures; start R interactively, or run options(keep.source = TRUE) earlier in the script, which was checked to bring them back, to get them.
source("scenario.R")
loan_date <- rep(loans$loan_date, 600L)
return_date <- rep(loans$return_date, 600L)
late_loop <- function(loan_date, return_date, today, allowed) {
out <- integer(length(loan_date))
for (i in seq_along(loan_date)) {
end <- return_date[[i]]
if (is.na(end)) {
end <- today
}
out[[i]] <- max(0L, as.integer(end - loan_date[[i]]) - allowed)
}
out
}
# profvis() runs the expression under Rprof() and returns an htmlwidget.
p <- profvis::profvis({
late <- late_loop(loan_date, return_date, shelf_today, loan_days)
})
class(p)
#> [1] "profvis" "suppress_viewer" "htmlwidget"
inherits(p, "htmlwidget")
#> [1] TRUE
# In a terminal there is no viewer, so save the widget to a file instead.
page <- tempfile(fileext = ".html")
htmlwidgets::saveWidget(p, page)
file.exists(page)
#> [1] TRUE
unlink(page)
# Variables created inside the expression are not visible outside it.
exists("late")
#> [1] FALSE
# An Rprof() file profiled elsewhere can be viewed afterwards.
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01, line.profiling = TRUE)
fees <- late_loop(loan_date, return_date, shelf_today, loan_days)
Rprof(NULL)
q <- profvis::profvis(prof_input = prof)
inherits(q, "profvis")
#> [1] TRUE
unlink(prof)
In RStudio the viewer’s save icon writes an .Rprofvis file that RStudio opens and that, renamed to .html, opens in a browser (profvis package documentation: Frequently asked questions).
Profiling code you cannot see
R’s profiler stops at the boundary of compiled code, so a function whose self time is large and whose work is in C, C++ or Fortran (a .Call() into a package, a BLAS routine) needs a different tool. Writing R Extensions devotes a section to it, with the hints "gleaned from various R users" for Linux (perf, oprofile and operf, sprof for a shared object, and Google’s gperftools), macOS and Windows, with the warning that this is highly system specific and that the chapter is based on reports that may not be current (Writing R Extensions: Profiling compiled code). The tools work best when R and the packages were built with debugging symbols. For the common case, a BLAS call, perf record R -f script.R followed by perf report --sort=dso shows how the time divides between R itself, the C library, the BLAS shared object and the packages' shared objects, which is how one finds out that the time was spent in libRblas or libopenblas and not in the interpreter.
Making code faster
Profiling has named a function and a line. What follows is the list of remedies in the order that usually pays off: do less work (a better algorithm or a lookup in place of a search), use vectorised operations, avoid growing or copying, use the right container and the right function, and only then change the machinery under the code (the compiler, the BLAS, a different package, compiled code), or spread the work over cores (Parallel and Asynchronous Computing). Each remedy below is shown with a measurement, on the shelf data repeated to a size at which the difference is visible.
Vectorise
A vectorised function runs its loop in compiled code; a loop in R pays the interpreter’s cost for every element. The effect is the largest single win in everyday R, and Vectorisation and Iteration explains the functions that replace the common loops. The measurement here is of a kind that is easy to miss: a loop that looks up each loan’s book in a table. The loop calls which(keys == isbn[[i]]) for every loan, which scans the 16 keys every time and allocates a logical vector each time; titles[match(isbn, keys)] builds a hash table of the keys once and looks every loan up in it. The difference is about two orders of magnitude for 60000 loans. The relative column is printed with one significant digit (the ratio was between 100 and 125 in the runs that were made).
source("scenario.R")
# Look up the title of every loan by ISBN: 60000 loans, 16 books.
keys <- normalise_isbn(books$isbn)
titles <- trimws(books$title)
loan_isbn <- rep(loans$isbn, 1000L)
title_loop <- function(isbn, keys, titles) {
out <- character(length(isbn))
for (i in seq_along(isbn)) {
hit <- which(keys == isbn[[i]])
out[[i]] <- if (length(hit) == 0L) NA_character_ else titles[[hit[[1L]]]]
}
out
}
title_match <- function(isbn, keys, titles) {
titles[match(isbn, keys)]
}
b <- bench::mark(
loop = title_loop(loan_isbn, keys, titles),
match = title_match(loan_isbn, keys, titles),
min_iterations = 5,
filter_gc = FALSE
)
b$n_itr >= 5
#> [1] TRUE TRUE
# The ratio changes from run to run, so keep one significant digit.
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
rel$median <- signif(rel$median, 1)
rel[c("expression", "median")]
#> # A tibble: 2 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 loop 90
#> 2 match 1
Source: R documentation: match
Vectorising is not always possible. A loop whose step depends on the result of the previous step, like the running stock of a shelf below, has no vectorised form; the sections on the compiler and on C++ are for it. Even then the vectorised parts of the loop body are worth keeping out of the loop: compute everything that does not depend on the previous step in one pass before the loop.
Preallocate, and do not grow
Growing a result one element at a time (out <- c(out, value)) allocates a new vector and copies the old one on every iteration, so the total work grows with the square of the length. The section on bench::press() showed it in memory (doubling the length quadrupled the allocation of the growing version). Vectorisation and Iteration covers the idiom in the section called "Preallocation and the growing-vector trap" and the assignment-beyond-the-end behaviour that R 3.4.0 added; the rule is numeric(n), integer(n), character(n), logical(n) or vector("list", n) before the loop, x[[i]] <- value inside it, and, when the length is unknown, a list of the pieces combined once at the end.
The same applies to data frames and is much more costly: rbind(df, row) inside a loop copies the whole data frame and checks the columns every time. The script below builds a 2000-row data frame three ways: rbind() in a loop, a list of one-row data frames combined by one do.call(rbind, parts), and the columns built as vectors and wrapped in data.frame() once. The time ratios are printed rounded down to a power of ten, and the memory ratios to the nearest. The loop allocates a thousand times the memory of the vector version, and the list version about ten times.
# Three ways to build a data frame of 2000 rows.
rows_loop <- function(n) {
df <- data.frame(loan_id = character(), days = integer())
for (i in seq_len(n)) {
row <- data.frame(loan_id = sprintf("L%04d", i), days = i %% 40L)
df <- rbind(df, row)
}
df
}
rows_list <- function(n) {
parts <- vector("list", n)
for (i in seq_len(n)) {
parts[[i]] <- data.frame(loan_id = sprintf("L%04d", i), days = i %% 40L)
}
do.call(rbind, parts)
}
rows_vector <- function(n) {
data.frame(loan_id = sprintf("L%04d", seq_len(n)), days = seq_len(n) %% 40L)
}
# Warm up so that the compiler's allocations are not in the first measurement.
for (warm in 1:2) {
rows_loop(5L)
rows_list(5L)
rows_vector(5L)
}
b <- bench::mark(
loop = rows_loop(2000L),
list = rows_list(2000L),
vector = rows_vector(2000L),
min_iterations = 3,
filter_gc = FALSE
)
# Ratios to the fastest and to the smallest, rounded to the nearest power of
# ten (or down, for the time) because the exact values change between runs.
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
rel$median <- 10^floor(log10(rel$median))
rel$mem_alloc <- 10^round(log10(rel$mem_alloc))
rel[c("expression", "median", "mem_alloc")]
#> # A tibble: 3 × 3
#> expression median mem_alloc
#> <bch:expr> <dbl> <dbl>
#> 1 loop 100 1000
#> 2 list 100 10
#> 3 vector 1 1
Source: R documentation: cbind and rbind
Avoid copies
R’s functions behave as if arguments were copied (call by value), but a copy is made only when an object is modified while something else refers to it; Writing R Extensions explains the design with a package example in which the single line x$call <- x$printcall copies the whole object x, and shows that data frames are slower than matrices and that "this is an example of why" (Writing R Extensions: Profiling R code for memory use). The consequences for speed are three. Modifying an argument inside a function copies it, however large. Setting one cell of a data frame column in a loop, df$x[i] <- value, goes through $<-.data.frame and copies the column, and more, at every iteration, whereas the same assignment on a plain vector is done in place. And a function that takes a large object only to read a small piece of it costs nothing for the object. The script below measures the second and the first. For 10000 cells the data-frame version allocates ten thousand times the memory of the vector version, and the function that modifies its million-element argument allocates the 7.63 MB of a copy of it, while the function that only reads it allocates nothing. The remedy for the data frame is to do the loop on vectors and build the data frame once, or to work with a matrix or a list; the remedy for the argument is to make the modification a new, smaller object, or to accept the copy knowingly. Memory and Copy Semantics is the place for the rules (what counts as "something else refers to it", reference counting since R 4.0.0, and when R can modify in place) and for tracemem() to see a copy happen.
# Setting one cell of a data frame column inside a loop rebuilds the column
# (and checks the data frame) on every iteration; a plain vector does not.
cells_df <- function(n) {
df <- data.frame(x = numeric(n))
for (i in seq_len(n)) {
df$x[i] <- i
}
df
}
cells_vector <- function(n) {
x <- numeric(n)
for (i in seq_len(n)) {
x[[i]] <- i
}
data.frame(x = x)
}
# Modifying an argument inside a function copies it; reading it does not.
modify_arg <- function(v) {
v[[1L]] <- 0
v
}
read_arg <- function(v) {
v[[1L]]
}
big <- as.numeric(seq_len(1e6))
for (warm in 1:2) {
cells_df(10L)
cells_vector(10L)
modify_arg(big)
read_arg(big)
}
b1 <- bench::mark(
data_frame = cells_df(10000L),
vector = cells_vector(10000L),
min_iterations = 3,
filter_gc = FALSE
)
rel <- summary(b1, relative = TRUE, filter_gc = FALSE)
rel$mem_alloc <- 10^round(log10(rel$mem_alloc))
rel[c("expression", "mem_alloc")]
#> # A tibble: 2 × 2
#> expression mem_alloc
#> <bch:expr> <dbl>
#> 1 data_frame 10000
#> 2 vector 1
b2 <- bench::mark(
modify = modify_arg(big),
read = read_arg(big),
check = FALSE,
min_iterations = 3,
filter_gc = FALSE
)
# A million doubles are 8 MB: the modifying version allocates a copy of them.
b2[c("expression", "mem_alloc")]
#> # A tibble: 2 × 2
#> expression mem_alloc
#> <bch:expr> <bch:byt>
#> 1 modify 7.63MB
#> 2 read 0B
vapply and the apply family
A very common piece of advice is to replace loops by sapply() or lapply() because they are faster. They are not, by much: the apply functions are loops written in C that call an R function once per element, and the call is the cost. The profvis documentation says the same thing, and gives it as the example of a belief that is not true. The script below applies a small fee function to 120000 loan durations with a preallocated for loop, vapply(), sapply(), lapply() plus unlist(), and the vectorised pmax() form, and prints two comparisons (the four loops are within a factor of three of each other, and the vectorised form is more than ten times faster than the best of them; the factor was a hundred in the runs made) and the memory ratios, which are the same on every run. What the apply functions offer is not speed but clarity and safety, and vapply() is the safe one: it declares the type and length of each result, FUN.VALUE, and an element that does not conform is an error, not a surprise. sapply() guesses: it simplifies the result to a vector, a matrix or a list depending on what the function returns, and on an empty input it returns an empty list whatever the function would have returned. The script after it shows the two errors that vapply() raises, with their real messages, and the empty-input case.
source("scenario.R")
# Days on loan for 120000 loans, with a small function applied per element.
days <- rep(as.integer(loans$return_date - loans$loan_date), 2000L)
days <- days[!is.na(days)]
fee <- function(d, allowed) max(0, d - allowed) * 0.25
fee_for <- function(d, allowed) {
out <- numeric(length(d))
for (i in seq_along(d)) {
out[[i]] <- fee(d[[i]], allowed)
}
out
}
fee_vapply <- function(d, allowed) vapply(d, fee, numeric(1), allowed = allowed)
fee_sapply <- function(d, allowed) sapply(d, fee, allowed = allowed)
fee_lapply <- function(d, allowed) unlist(lapply(d, fee, allowed = allowed))
fee_vector <- function(d, allowed) pmax(0, d - allowed) * 0.25
b <- bench::mark(
`for` = fee_for(days, loan_days),
vapply = fee_vapply(days, loan_days),
sapply = fee_sapply(days, loan_days),
lapply = fee_lapply(days, loan_days),
vector = fee_vector(days, loan_days),
min_iterations = 5,
filter_gc = FALSE
)
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
# Memory is the one column that is the same on every run: the ratio of the
# bytes allocated to the smallest allocation.
rel$mem_alloc <- round(rel$mem_alloc, 1)
rel[c("expression", "mem_alloc")]
#> # A tibble: 5 × 2
#> expression mem_alloc
#> <bch:expr> <dbl>
#> 1 for 1.1
#> 2 vapply 1
#> 3 sapply 4.3
#> 4 lapply 2
#> 5 vector 3.6
# The four loops are within a small factor of each other ...
is_loop <- as.character(rel$expression) != "vector"
loops <- rel$median[is_loop]
max(loops) / min(loops) < 3
#> [1] TRUE
# ... and the vectorised form is far ahead of all of them.
min(loops) / rel$median[!is_loop] > 10
#> [1] TRUE
Source: R documentation: lapply
# vapply() declares the result type and length, so a surprise is an error ...
tryCatch(
vapply(list(1:3, 4:6), \(x) x[1:2], numeric(1)),
error = \(e) cat(conditionMessage(e), "\n")
)
#> values must be length 1,
#> but FUN(X[[1]]) result is length 2
tryCatch(
vapply(list(1:3, 4:6), \(x) "a", numeric(1)),
error = \(e) cat(conditionMessage(e), "\n")
)
#> values must be type 'double',
#> but FUN(X[[1]]) result is type 'character'
# ... and an empty input still has the declared type. sapply() has to guess:
# on an empty input it returns an empty list, whatever the function returns.
vapply(list(), length, integer(1))
#> integer(0)
sapply(list(), length)
#> list()
class(sapply(character(0), nchar))
#> [1] "list"
class(vapply(character(0), nchar, integer(1)))
#> [1] "integer"
Source: R documentation: lapply
Matrix algebra
Matrix operations are where R hands its work to the BLAS and LAPACK (see the next section), and the way they are written still matters, because the library is fast only for the operation it is asked to do. Three rules cover most of it. Do not form an inverse that is only going to be multiplied: solve(a, b) solves the linear system directly, and solve(a) %*% b computes the whole inverse first, which is more work and less accurate. Use crossprod(x) for t(x) %*% x and crossprod(x, y) for t(x) %*% y: it does not build the transpose, and for a single argument it computes only half of the symmetric result (R documentation: crossprod). Prefer a decomposition (qr(), chol()) when the same matrix is used for several right-hand sides, and keep the work in matrices: a data frame converted to a matrix once is faster to compute on than the data frame, as the section on copies showed. The script below solves the normal equations of a least-squares problem with 5000 rows and 500 columns written three ways, checking that they agree, and prints which is fastest, that the naive form is at least one and a half times slower, and the memory ratios. The crossprod form allocates about a seventeenth of the memory of the naive form, which is the cleanest measure of the difference.
set.seed(123)
n <- 5000L
p <- 500L
x <- matrix(rnorm(n * p), nrow = n)
y <- rnorm(n)
# The normal equations for a least-squares fit, written three ways.
naive <- function(x, y) solve(t(x) %*% x) %*% t(x) %*% y
better <- function(x, y) solve(t(x) %*% x, t(x) %*% y)
best <- function(x, y) solve(crossprod(x), crossprod(x, y))
same <- \(a, b) isTRUE(all.equal(a, b))
b <- bench::mark(
naive = naive(x, y),
better = better(x, y),
best = best(x, y),
check = same,
min_iterations = 15,
filter_gc = FALSE
)
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
# The fastest is crossprod() with solve(a, b); the naive form is slower.
exprs <- as.character(rel$expression)
exprs[which.min(rel$median)]
#> [1] "best"
rel$median[exprs == "naive"] > 1.5
#> [1] TRUE
# The memory ratios are the same on every run (to one decimal place).
rel$mem_alloc <- round(rel$mem_alloc, 1)
rel[c("expression", "mem_alloc")]
#> # A tibble: 3 × 2
#> expression mem_alloc
#> <bch:expr> <dbl>
#> 1 naive 16.9
#> 2 better 10.9
#> 3 best 1
Source: R documentation: solve
Matrices and Arrays has the matrix functions themselves, and its section on BLAS and LAPACK the matprod option, which selects how %*%, crossprod() and tcrossprod() treat NaN and Inf: the default checks for them and falls back to a plain loop so that they propagate correctly, and the setting "blas" skips the check and needs care.
A faster BLAS
R does not implement its own dense linear algebra. The Installation and Administration manual states it at the start of the appendix on linear algebra: the routines make use of BLAS (Basic Linear Algebra Subprograms) and, for most, of LAPACK; the sources contain reference implementations in Fortran, and they can be replaced by external libraries, usually tuned for specific CPUs (R Installation and Administration: Linear algebra). %*%, crossprod(), solve(), chol(), qr(LAPACK = TRUE), eigen() and svd() end up there, and a tuned BLAS multiplies large matrices many times faster than the reference one, partly through CPU-specific code and partly through threads. It is the one speed-up on this page that needs no change to the program.
The choices, as the manual describes them, are these.
| Library | What the manual says | Where |
|---|---|---|
The reference BLAS and LAPACK |
Built from the R sources and the default; the portable and the slowest choice. |
Everywhere |
OpenBLAS (and BLIS) |
OpenBLAS is a descendant of Kazushige Goto’s BLAS, covering Intel/AMD, Arm, MIPS and RISC-V CPUs; the threaded build uses |
Linux, macOS, Windows with effort |
Intel MKL |
Tuned for Intel processors; link line advisor from Intel; threads from |
Linux (macOS and Windows builds exist but did not work with R’s default compilers) |
Accelerate |
Apple’s framework, selected by |
macOS |
FlexiBLAS |
A wrapper that switches the BLAS at run time (Fedora offers it); R is configured with |
Linux |
ATLAS |
Tuned at build time and, the manual notes, last updated in 2018; static builds can be unusable from the shared objects of packages. |
Linux |
The ways to change the BLAS follow from where it is linked. If R was built with the shared BLAS (--enable-BLAS-shlib, the default on all platforms but AIX), every reference goes through libRblas, so the library can be swapped without rebuilding R or the packages: by symlinking an external shared BLAS over libRblas, or, on Debian-based systems, by building R with --with-blas and using update-alternatives; the manual points to the Debian wiki page on linear algebra libraries and says that ATLAS, MKL and OpenBLAS alternatives are available (R Installation and Administration: Shared BLAS). Since 2025 this is less free: the BLAS gained routines (dgemmtr, zgemmtr) that the bundled LAPACK 3.12.1 uses, so a replacement BLAS must contain them (OpenBLAS does from version 0.3.29) or R must be built with --with-2025blas, and the NEWS of R 4.5.0 says that 3.12.1 is expected to be the last bundled version, after which external libraries will be required (R NEWS: Changes in R 4.5.0). Binary distributions make the choice for the user: the NEWS of R 4.5.0 says Windows builds use the internal BLAS by default, the manual says some CRAN builds for macOS can be switched to Accelerate, and the Linux image used for this page runs on OpenBLAS.
R can say what it is using. extSoftVersion() returns a named character vector whose BLAS entry names the library file, La_library() returns the file that holds LAPACK and La_version() its version, and sessionInfo() prints a "Matrix products" line (the matprod option), a BLAS: line and a LAPACK: line with the version (R documentation: extSoftVersion, R documentation: La_library, R documentation: sessionInfo). The values are file paths and machine specific, so the script below asks questions about them (is the entry present, is it empty, does the LAPACK file name mention OpenBLAS) and shows the sessionInfo() lines with the paths replaced by <path>, and the answer to the last question, TRUE, is a fact about the machine that ran the examples and not about R: it is an OpenBLAS build, and on a build with the reference BLAS the answer is FALSE. The same script shows the effect of the threads. It multiplies two 1500 by 1500 matrices and checks that the CPU time (user.self) is larger than the elapsed time, which is only possible if several cores worked at once, and then times the same multiplication in two fresh R processes started by callr with OPENBLAS_NUM_THREADS set to 1 and to 4 (the variable is read when the library loads, so setting it in a running session is too late) and prints whether one thread is slower. On a computer with one core the last answer would be FALSE.
# Which BLAS and LAPACK does this R use? The values are file paths, which
# differ by machine, so the script only asks questions about them.
"BLAS" %in% names(extSoftVersion())
#> [1] TRUE
nzchar(extSoftVersion()[["BLAS"]])
#> [1] TRUE
is.character(La_library())
#> [1] TRUE
La_version()
#> [1] "3.12.0"
grepl("openblas", La_library(), ignore.case = TRUE)
#> [1] TRUE
# sessionInfo() records the same facts. Its printed form has a "Matrix
# products" line and a BLAS and a LAPACK line; the paths are replaced here.
si <- sessionInfo()
c("matprod", "BLAS", "LAPACK", "LA_version") %in% names(si)
#> [1] TRUE TRUE TRUE TRUE
si$matprod
#> [1] "default"
lines <- grep("^(Matrix|BLAS|LAPACK)", capture.output(print(si)), value = TRUE)
sub("/[^;]*", "<path>", lines)
#> [1] "Matrix products: default"
#> [2] "BLAS: <path>"
#> [3] "LAPACK: <path>; LAPACK version 3.12.0"
# Threaded BLAS: CPU time can exceed elapsed time.
set.seed(1)
a <- matrix(rnorm(1500 * 1500), nrow = 1500)
invisible(a %*% a)
st <- system.time(a %*% a)
st
#> user system elapsed
#> <n> <n> <n>
st[["user.self"]] > st[["elapsed"]]
#> [1] TRUE
# The thread count is read from an environment variable when the BLAS is
# loaded, so compare two fresh R processes started with different values.
product_time <- function(threads) {
callr::r(
function() {
set.seed(1)
a <- matrix(rnorm(1500 * 1500), nrow = 1500)
invisible(a %*% a)
system.time(replicate(3, a %*% a))[["elapsed"]]
},
env = c(callr::rcmd_safe_env(), OPENBLAS_NUM_THREADS = threads)
)
}
one <- product_time("1")
many <- product_time("4")
one > many
#> [1] TRUE
The manual lists the caveats of an external BLAS, and all of them matter for a decision. The results are not identical to the reference implementation; some differences are benign, such as the signs of singular vectors, but an optimised library can be less accurate, and its LAPACK can be older. R relies on IEEE 754 behaviour, and a library that assumes a term with a zero factor is zero can break it, since x * 0 can be NaN. A multi-threaded BLAS can use more CPU time, and occasionally much more elapsed time, than a single-threaded one, notably on a busy machine and on hyper-threaded CPUs, because the threads contend for the CPU caches. BLAS and LAPACK calls inside threaded code (OpenMP sections in a package such as mgcv) may be unsafe with external libraries, whose thread-safety is not guaranteed. A threaded BLAS and the profiler disagree (the SIGPROF signal, noted in the section on profiling). And a threaded BLAS inside a process that is itself one of several parallel workers oversubscribes the machine: set OPENBLAS_NUM_THREADS=1 (or OMP_NUM_THREADS=1) in the workers, a point Parallel and Asynchronous Computing returns to. The links to the projects are OpenBLAS and FlexiBLAS; for MKL and Accelerate the manual’s sections are the reference (R Installation and Administration: Intel MKL, R Installation and Administration: Accelerate). The Fortran Reference page BLAS, LAPACK and Numerical Libraries covers the libraries from the Fortran side.
The byte-code compiler and the JIT
R evaluates functions by walking their syntax tree, or, if they are compiled, by running a compact byte-code form of them on a virtual machine. The compiler package, part of R since 2.13.0, compiles closures (cmpfun()), expressions (compile()) and files (cmpfile()), and disassemble() prints what it produced. Since R 3.4.0 a just-in-time compiler is on by default: the NEWS says that functions are compiled on first or second use and that top-level loops are compiled and then run (R NEWS: Changes in R 3.4.0); and since R 3.5.0 all packages are byte-compiled on installation by default (R NEWS: Changes in R 3.5.0). Vectorisation and Iteration has the section "Byte-compilation" with the first look at all this; this section is about checking what the compiler did and how it is controlled.
The levels of enableJIT(level) are these (the help page, R documentation: compile); the function returns the previous level, a negative argument returns the current one, and level 3 needs the compiler option optimize to be 2 or 3.
| Level | What is compiled |
|---|---|
0 |
Nothing: the JIT is off. |
1 |
Larger closures, before their first use. |
2 |
Level 1, and some small closures before their second use. |
3 |
Level 2, and in addition all top-level loops, before they are executed. The default. |
Source: R documentation: compile
The level of a new R process is set by the environment variable R_ENABLE_JIT, and compilePKGS(TRUE) (or _R_COMPILE_PKGS_ set to a positive integer) makes the compiler compile packages when they are installed, but the help page says not to enable it outside installation, because it makes any serialized function be compiled, with a time and space overhead; R_COMPILE_PKGS is the variable that tells R CMD INSTALL whether to compile. A package can opt out in its DESCRIPTION with ByteCompile: no, and an installation can with R CMD INSTALL --no-byte-compile (Writing R Extensions: The DESCRIPTION file, R Installation and Administration: Byte-compilation). The compiler options are optimize (an integer from 0 to 3; the out-of-the-box default is 2), suppressAll, suppressUndefined and suppressNoSuperAssignVar, read by getCompilerOption() and set by setCompilerOptions(). Two of its details are visible in everyday use. The compiler does not compile code that contains explicit calls to browser(), so that one can single-step from there (R NEWS, 3.4.0). And the help page remarks that the "byte code" is, when R is compiled with gcc, actually threaded code rather than byte code.
How to tell what happened? Printing a compiled function ends with a <bytecode: ...> line that holds a memory address, so the script defines is_compiled(), which asks whether such a line is printed, and uses it to show the three facts about the default JIT: the functions of an installed package are compiled; a closure with a loop is compiled by its first call; a small closure by its second. It ends with cmpfun() and disassemble(), whose result is a list of the form (.Code, instructions, constants). For x + 1 the instruction sequence is GETVAR.OP (push x), LDCONST.OP (push the constant), ADD.OP and RETURN.OP, and the operands (1L, 2L, 0L) are positions in the constant pool that is the third element: x + 1, x and 1.
library(compiler)
# The JIT level in force (3 is the default) and the compiler's own options.
enableJIT(-1L)
#> [1] 3
getCompilerOption("optimize")
#> [1] 2
# A compiled closure prints a "<bytecode: ...>" line (with an address), so
# test for it instead of printing the function.
is_compiled <- \(f) any(grepl("^<bytecode", capture.output(print(f))))
# Functions in an installed package are byte-compiled when it is installed.
is_compiled(utils::head)
#> [1] TRUE
# JIT level 3: a closure is compiled on its first or second use. A closure
# that contains a loop is compiled on the first call, a small one on the
# second.
add_one <- function(x) x + 1
sum_to <- function(n) {
total <- 0
for (i in seq_len(n)) {
total <- total + i
}
total
}
c(add_one = is_compiled(add_one), sum_to = is_compiled(sum_to))
#> add_one sum_to
#> FALSE FALSE
invisible(sum_to(10L))
invisible(add_one(1))
c(add_one = is_compiled(add_one), sum_to = is_compiled(sum_to))
#> add_one sum_to
#> FALSE TRUE
invisible(add_one(2))
is_compiled(add_one)
#> [1] TRUE
# cmpfun() compiles at once; disassemble() shows what the compiler produced.
plus_one <- function(x) x + 1
plus_one_c <- cmpfun(plus_one)
is_compiled(plus_one_c)
#> [1] TRUE
disassemble(plus_one_c)
#> list(.Code, list(12L, GETVAR.OP, 1L, LDCONST.OP, 2L, ADD.OP,
#> 0L, RETURN.OP), list(x + 1, x, 1, structure(c(NA, 1L, 1L,
#> 2L, 2L, 0L, 0L, 0L), class = "expressionsIndex")))
Source: R documentation: compile
The effect of compilation can be measured, if the JIT is kept from compiling the function that is meant to be the interpreted one. The next script switches the JIT off with enableJIT(0L), which returns the previous level (3), defines a loop, compiles a copy of it by hand with cmpfun(), compares the two with bench::mark(), and restores the level. With the JIT back on, the "interpreted" function would be compiled by its first call, so the comparison has to happen with the JIT off. The compiled copy is about ten times faster on a loop of a million scalar additions (the ratio was between 8.6 and 9.3 in the runs made, and is printed rounded to the nearest power of ten). That is the entire gain of the compiler and it is large for scalar loops, but it does nothing for work that R already does in C: the loop of the first benchmark on this page was compiled by the JIT and was still a thousand times slower than the vectorised form. The script ends with R_ENABLE_JIT, the variable that sets the level for a new R process, shown through callr: a child process started with 0 reports level 0, and one started with 1 reports level 1.
library(compiler)
sum_doubles <- function(n) {
total <- 0
for (i in seq_len(n)) {
total <- total + i * 2
}
total
}
# Switch the JIT off (enableJIT() returns the previous level) while measuring,
# so that the plain function stays interpreted, and compare it with a copy
# compiled explicitly by cmpfun().
old <- enableJIT(0L)
old
#> [1] 3
enableJIT(-1L)
#> [1] 0
compiled <- cmpfun(sum_doubles)
b <- bench::mark(
interpreted = sum_doubles(1e6),
compiled = compiled(1e6),
min_iterations = 5,
filter_gc = FALSE
)
invisible(enableJIT(old))
enableJIT(-1L)
#> [1] 3
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
# The ratio is about ten on a typical machine: show the nearest power of ten.
rel$median <- 10^round(log10(rel$median))
rel[c("expression", "median")]
#> # A tibble: 2 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 interpreted 10
#> 2 compiled 1
# R_ENABLE_JIT sets the level for a new R process.
level_with <- function(value) {
callr::r(
function() compiler::enableJIT(-1L),
env = c(callr::rcmd_safe_env(), R_ENABLE_JIT = value)
)
}
level_with("0")
#> [1] 0
level_with("1")
#> [1] 1
Source: R documentation: compile
Two more entry points of the package complete the picture, and both are rarely needed. compile() turns an expression into a byte-code object (type "bytecode") that eval() runs, and cmpfile() compiles a file of R code into an .Rc file, writing a line that names the file, which loadcmp() later loads and runs. They exist for tools that distribute compiled code; ordinary programs and packages do not need them. The script also reads and sets the compiler options: setCompilerOptions() returns a named list of the previous values, so the old setting can be restored.
library(compiler)
# compile() turns an expression into byte code; eval() runs it.
code <- compile(quote(sum(1:10) * 2))
typeof(code)
#> [1] "bytecode"
eval(code)
#> [1] 110
# cmpfile() compiles a file of R code and writes a .Rc file (and prints a line
# with its path, which is captured here); loadcmp() runs the compiled file.
src <- tempfile(fileext = ".R")
writeLines(c("squares <- (1:5)^2", "total <- sum(squares)"), src)
msg <- capture.output(cmpfile(src))
grepl("^saving to file", msg)
#> [1] TRUE
file.exists(sub("\\.R$", ".Rc", src))
#> [1] TRUE
env <- new.env()
loadcmp(sub("\\.R$", ".Rc", src), envir = env)
env$total
#> [1] 55
unlink(c(src, sub("\\.R$", ".Rc", src)))
# The compiler's options: optimisation level, and what it reports.
getCompilerOption("optimize")
#> [1] 2
old <- setCompilerOptions(optimize = 3)
old
#> $optimize
#> [1] 2
getCompilerOption("optimize")
#> [1] 3
invisible(setCompilerOptions(optimize = old$optimize))
getCompilerOption("optimize")
#> [1] 2
Source: R documentation: compile
Calling cmpfun() by hand is therefore rarely needed: the JIT compiles what is called, and installation compiles packages. It remains useful in two cases, the one above where the interpreted time must be measured, and when the JIT is switched off on purpose (R_ENABLE_JIT=0) to debug a problem that might be the compiler’s. Compiled code changes the format of tracebacks and some messages, which is why the NEWS of R 3.5.0 mentioned it among the user-visible changes.
data.table
data.table (version 1.18.6.1 here) is a package for tables that are large or whose operations are heavy: grouping, joins, sorting and reading and writing files. It is fast because of how it works: it modifies columns by reference instead of copying, it uses its own radix sort and optimised grouping (GForce), it can build indexes and keys for binary-search joins, and parts of it (fread(), fwrite(), forder(), grouping) use OpenMP threads. data.table covers the package, including its section on printing and threads; this page shows only where it changes the measurements. The script below, run on the shelf loans repeated 10000 times (600000 rows), makes three comparisons. A grouped mean over the eight members: here tapply() is as fast as data.table (the script prints that only aggregate() is at least three times slower), which is the honest result for few groups. The same mean over 100000 groups, where tapply() and split() followed by vapply() are at least ten times slower than data.table (the factor was 20 to 35 in the runs made, printed rounded down to a power of ten). Reading a 600000-row CSV file, where fread() is more than ten times faster than read.csv() (about 16 in the runs made). The R FAQ gives the reason for the third part: read.table() reads everything as character data first and then works out what the columns are, which takes time and memory for a large data set, and giving colClasses helps substantially (R FAQ 7.28: Why is read.table() so inefficient?). fread() detects the types itself and in parallel.
suppressPackageStartupMessages(library(data.table))
source("scenario.R")
# Rounded down to a power of ten, because the ratios change from run to run.
floor_ten <- \(x) 10^floor(log10(x))
cols <- c("member", "loan_date", "return_date")
# 600000 loans: the shelf loans repeated 10000 times, with the days on loan.
big <- loans[rep(seq_len(nrow(loans)), 10000L), cols]
big$member <- trimws(tolower(big$member))
big$days <- as.integer(big$return_date - big$loan_date)
dt <- as.data.table(big)
nrow(dt)
#> [1] 600000
# Few groups (eight members): the base tools are close to data.table.
b1 <- bench::mark(
aggregate = aggregate(days ~ member, data = big, FUN = mean),
tapply = tapply(big$days, big$member, mean, na.rm = TRUE),
data.table = dt[!is.na(days), .(mean_days = mean(days)), by = member],
check = FALSE,
min_iterations = 5,
filter_gc = FALSE
)
rel1 <- summary(b1, relative = TRUE, filter_gc = FALSE)
setNames(rel1$median >= 3, rel1$expression) # at least 3 times slower?
#> aggregate tapply data.table
#> TRUE FALSE FALSE
# Many groups (100000 member numbers): data.table pulls far ahead.
set.seed(1)
n <- 1e6
many <- data.frame(
member_no = sample.int(1e5, n, replace = TRUE),
days = runif(n)
)
dt_many <- as.data.table(many)
b2 <- bench::mark(
tapply = tapply(many$days, many$member_no, mean),
split = vapply(split(many$days, many$member_no), mean, numeric(1)),
data.table = dt_many[, .(mean_days = mean(days)), by = member_no],
check = FALSE,
min_iterations = 3,
filter_gc = FALSE
)
rel2 <- summary(b2, relative = TRUE, filter_gc = FALSE)
rel2$median <- floor_ten(rel2$median)
rel2[c("expression", "median")]
#> # A tibble: 3 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 tapply 10
#> 2 split 10
#> 3 data.table 1
# Reading a CSV file: read.csv() against fread().
path <- tempfile(fileext = ".csv")
write.csv(big, path, row.names = FALSE)
b3 <- bench::mark(
read.csv = read.csv(path),
fread = fread(path),
check = FALSE,
min_iterations = 3,
filter_gc = FALSE
)
rel3 <- summary(b3, relative = TRUE, filter_gc = FALSE)
rel3$median <- floor_ten(rel3$median)
rel3[c("expression", "median")]
#> # A tibble: 2 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 read.csv 10
#> 2 fread 1
unlink(path)
# data.table uses OpenMP threads for fread(), sorting and grouping. The number
# depends on the machine, so the script prints checks, not the number.
old <- setDTthreads(1L)
getDTthreads()
#> [1] 1
invisible(setDTthreads(old))
getDTthreads() == old
#> [1] TRUE
The number of threads is the thing to know before benchmarking: getDTthreads() reports it, setDTthreads(n) changes it for the session and returns the previous value (setDTthreads(0) is all logical CPUs), and R_DATATABLE_NUM_THREADS and R_DATATABLE_NUM_PROCS_PERCENT set it at load time. The script ends by setting one thread, reading it back and restoring the setting. The default is documented as half of the logical CPUs, and the script does not print the number because it is a fact about the machine. The package’s own guide to benchmarking gives rules that carry over to any tool: clear the file cache before timing fread(); benchmark index creation and the indexed query separately; benchmark set* functions only on the first run, because they update their input by reference and later runs see the processed table; prepare the inputs in the class each tool expects so that the coercion is not timed; prefer a few runs on large data over many runs on small data; keep the number of threads in mind; inside a loop prefer set() to := and setDT() to data.table(); and where the benchmark is meant to be published, time the atomic steps separately as well as the end-to-end pipeline (data.table package documentation: Benchmarking data.table). Attaching data.table masks the %notin% that base R gained in 4.6.0 and prints a message about it; the script loads the package with suppressPackageStartupMessages().
Moving a hot loop to C++
When the profile points at a loop that cannot be vectorised, because each iteration depends on the previous one, and the byte-code compiler has already done what it can, the next step is to write that loop in a compiled language. Rcpp (version 1.1.2 here, Rcpp project site, Rcpp on CRAN) is the usual way for C++: Rcpp::cppFunction("code") compiles a function from a string and makes it callable from R, Rcpp::sourceCpp("file.cpp") does the same for a file whose exported functions carry a // [[Rcpp::export]] comment, and a package that contains such code lists Rcpp under LinkingTo and Imports in its DESCRIPTION. cpp11 is an alternative C++ interface to R. The compilation uses the tool chain of the machine: gcc and g++ 13.3 here, Rtools on Windows, the Xcode command line tools on macOS. The shelf example is a stock level that rises with returns, falls with loans and stays between zero and a capacity: level = min(capacity, max(0, level + change)), applied one day after another over a million days. The clamping makes it impossible to write with cumsum(), so the R version is a loop. The C++ version is the same loop on a NumericVector. The script checks that both return identical results before it compares them; the C++ function is about a hundred times faster (printed to the nearest power of ten; the ratio was between 90 and 105 in the runs made), at the price of a compiler dependency, a compilation step on every new session for cppFunction() (a package compiles once, at installation), and code in a second language to maintain. Because cppFunction() compiles the text it is given, the script’s compilation takes a few seconds on first use; it prints nothing when it succeeds.
# A loop that cannot be vectorised: each step depends on the previous one.
# The stock of a shelf rises with returns, falls with loans, and stays between
# 0 and the shelf capacity.
set.seed(2026)
change <- rpois(1e6, lambda = 3) - rpois(1e6, lambda = 3)
stock_r <- function(change, capacity) {
out <- numeric(length(change))
level <- 0
for (i in seq_along(change)) {
level <- min(capacity, max(0, level + change[[i]]))
out[[i]] <- level
}
out
}
Rcpp::cppFunction("
NumericVector stock_cpp(NumericVector change, double capacity) {
int n = change.size();
NumericVector out(n);
double level = 0.0;
for (int i = 0; i < n; ++i) {
level = std::min(capacity, std::max(0.0, level + change[i]));
out[i] = level;
}
return out;
}")
identical(stock_r(change, 500), stock_cpp(change, 500))
#> [1] TRUE
b <- bench::mark(
r_loop = stock_r(change, 500),
cpp = stock_cpp(change, 500),
min_iterations = 5,
filter_gc = FALSE
)
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
# Nearest power of ten: the ratio changes from run to run.
rel$median <- 10^round(log10(rel$median))
rel[c("expression", "median")]
#> # A tibble: 2 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 r_loop 100
#> 2 cpp 1
Source: Rcpp on CRAN
The C and C++ sides of the story, .Call(), PROTECT(), registering routines, src/Makevars and the rules for portable and compliant code, are the subject of Interfacing C, C++ and Fortran; the languages themselves have their references, C++ Reference, C Reference and Fortran Reference. The order matters: a compiled loop is the most expensive remedy to write, test and keep working across compilers and R versions, so it comes after the cheaper ones.
Settings that affect speed
The environment variables, options and arguments that the page mentioned are collected here. Environment variables are read when the process (or the library) starts, so they have to be set before R is started, in the shell, in .Renviron (Sessions, Workspaces and Startup), or, as the examples do, in the env argument of a function that starts a new process.
| Setting | Effect | Where it is described |
|---|---|---|
|
JIT level of a new R process, 0 to 3. |
|
|
A positive integer makes the compiler compile packages as they are installed; the help page says not to enable it outside installation. |
|
|
Tells |
|
|
Opt a package or an installation out of byte-compilation. |
Writing R Extensions; R Installation and Administration |
|
Whether functions keep source references; |
|
|
How |
|
|
Threads of a pthreads OpenBLAS. |
R Installation and Administration: OpenBLAS and BLIS |
|
Threads of OpenMP code: an OpenMP build of OpenBLAS, MKL, and packages that use OpenMP (data.table among them, within its own limits). |
R Installation and Administration: OpenBLAS and BLIS, Intel MKL |
|
MKL threads, and the interface and threading layers that must be set when MKL is used as |
R Installation and Administration: Intel MKL |
|
Threads of data.table, read at load time. |
|
|
Set and read the data.table thread count for the session. |
|
|
The profiler’s behaviour. |
|
|
The benchmark’s behaviour. |
bench documentation |
|
The same for microbenchmark. |
microbenchmark help page |
From symptom to first step
The table maps what one observes to the first thing to do. It is a starting point for the measurement, not a substitute for it.
| Symptom | First step | Likely remedy |
|---|---|---|
A loop over rows or elements is slow |
|
Vectorise, |
A function gets slower faster than the input grows |
|
Remove the growing vector or |
Memory use is large for a small result |
|
Remove copies; see the memory page |
Wall-clock time is much larger than CPU time |
|
Waiting on a network, a disk or a process: cache, batch, or run concurrently |
CPU time is much larger than elapsed time |
|
A threaded BLAS or OpenMP library is working; set its thread count if it competes with other work |
A matrix computation is slow |
Check |
A tuned BLAS; the right operations |
A grouped or joined data frame is slow |
Compare with data.table on the same data, using several runs on large data |
data.table, or a different data structure |
Reading a file is slow |
Time |
R FAQ 7.28; |
The profile ends in a function from a package with compiled code |
Profile the compiled code with |
Fewer calls; a different package |
A scalar loop with a dependence between iterations is slow and cannot be vectorised |
Check with |
Rewrite the loop in C++ or Fortran |
A worked example: from slow to fast
The shelf analysis of the overdue rate by genre is the kind of computation this page is about, written the way the problem would first be described: for every loan, find its book, work out the days on loan, decide whether it is overdue, and then average by genre. The script runs the steps of the measurement loop on 120000 loans (the shelf loans repeated 2000 times). It defines the first version, overdue_rate_slow(), and prints its result, so that there is an answer to check against. It times one call with system.time(); the elapsed time is over a second, enough to matter, and is printed as <n>. It profiles one call with Rprof(): the top of by.total is the function itself, -.Date (about half the time) and difftime (nearly as much), so the cost is in subtracting dates one element at a time, and the which() lookup that looked suspicious in the source is not the bottleneck. It then changes two things, the title lookup (match()) and the date arithmetic (the whole loan_date and return_date columns subtracted at once), keeps the grouping, and checks with all.equal() that the answer did not change. Last it measures again with bench::mark() (iterations = 3 because one call of the slow version takes over a second): the new version is about a hundred times faster. The profile of the first version is the point: reading the source, the lookup in books inside the loop looked like the expensive part, and the profile says that most of the time is in the date subtraction, which is where a change helps most. Measuring first is cheaper than being wrong.
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$key <- normalise_isbn(books$isbn)
many_loans <- loans[rep(seq_len(nrow(loans)), 2000L), ]
nrow(many_loans)
#> [1] 120000
# First version: correct, written the way the problem was described.
overdue_rate_slow <- function(loans, books, today, allowed) {
genre <- character(nrow(loans))
overdue <- logical(nrow(loans))
for (i in seq_len(nrow(loans))) {
hit <- which(books$key == loans$isbn[[i]])
genre[[i]] <- if (length(hit) > 0L) books$genre[[hit[[1L]]]] else NA
end <- loans$return_date[[i]]
if (is.na(end)) {
end <- today
}
overdue[[i]] <- as.integer(end - loans$loan_date[[i]]) > allowed
}
tapply(overdue, genre, mean, na.rm = TRUE)
}
slow <- overdue_rate_slow(many_loans, books, shelf_today, loan_days)
data.frame(genre = names(slow), overdue_rate = round(unname(slow), 3))
#> genre overdue_rate
#> 1 classic 0.600
#> 2 fiction 0.522
#> 3 mystery 0.500
#> 4 non-fiction 0.600
#> 5 poetry 0.667
#> 6 science fiction 0.467
# Step 1: measure. Is it slow enough to matter?
system.time(overdue_rate_slow(many_loans, books, shelf_today, loan_days))
#> user system elapsed
#> <n> <n> <n>
# Step 2: profile. Where does the time go?
prof <- tempfile(fileext = ".out")
Rprof(prof, interval = 0.01)
invisible(overdue_rate_slow(many_loans, books, shelf_today, loan_days))
Rprof(NULL)
head(summaryRprof(prof)$by.total, 3)
#> total.time total.pct self.time self.pct
#> "overdue_rate_slow" <n> <n> <n> <n>
#> "-.Date" <n> <n> <n> <n>
#> "difftime" <n> <n> <n> <n>
unlink(prof)
# Step 3: change one thing at a time. The lookup becomes match(), the dates
# are subtracted as vectors, and the grouping stays.
overdue_rate_fast <- function(loans, books, today, allowed) {
genre <- books$genre[match(loans$isbn, books$key)]
end <- loans$return_date
end[is.na(end)] <- today
overdue <- as.integer(end - loans$loan_date) > allowed
tapply(overdue, genre, mean, na.rm = TRUE)
}
# Step 4: check that the answer did not change, then measure again.
fast <- overdue_rate_fast(many_loans, books, shelf_today, loan_days)
all.equal(slow, fast)
#> [1] TRUE
b <- bench::mark(
slow = overdue_rate_slow(many_loans, books, shelf_today, loan_days),
fast = overdue_rate_fast(many_loans, books, shelf_today, loan_days),
iterations = 3,
filter_gc = FALSE
)
rel <- summary(b, relative = TRUE, filter_gc = FALSE)
rel$median <- 10^round(log10(rel$median))
rel[c("expression", "median")]
#> # A tibble: 2 × 2
#> expression median
#> <bch:expr> <dbl>
#> 1 slow 100
#> 2 fast 1
Source: R documentation: summaryRprof
Common errors and warnings
The messages below were captured from the container. Paths are replaced by <tmp>, and the numbers of the Timing stopped at: message by <n>.
# bench::mark() refuses expressions that return different values.
tryCatch(
bench::mark(a = 1 + 1, b = 1 + 2, min_iterations = 2),
error = \(e) cat(conditionMessage(e), "\n")
)
#> Each result must equal the first result:
#> `a` does not equal `b`
# An expression that allocates many small objects collects garbage in every
# iteration; the filter that drops such iterations is then disabled, with a
# warning (filter_gc = FALSE avoids it).
tryCatch(
bench::mark(
many_objects = lapply(seq_len(1e6), \(i) i),
min_iterations = 3,
filter_gc = TRUE
),
warning = \(w) conditionMessage(w)
)
#> [1] "Some expressions had a GC in every iteration; so filtering is disabled."
# system.time() reports how far an expression got before it failed.
res <- withCallingHandlers(
tryCatch(system.time(stop("boom")), error = \(e) conditionMessage(e)),
message = \(m) {
cat(sub("[0-9.]+ [0-9.]+ [0-9.]+", "<n> <n> <n>", conditionMessage(m)))
invokeRestart("muffleMessage")
}
)
#> Timing stopped at: <n> <n> <n>
res
#> [1] "boom"
# summaryRprof() on a profile with no samples returns empty tables ...
empty <- tempfile(fileext = ".out")
Rprof(empty, interval = 0.01)
Rprof(NULL)
s <- summaryRprof(empty)
nrow(s$by.total)
#> [1] 0
s$sampling.time
#> [1] 0
unlink(empty)
# ... and on a missing file warns, then fails (the path is shortened here).
missing <- file.path(tempdir(), "no-such-file.out")
tryCatch(
summaryRprof(missing),
warning = \(w) sub(tempdir(), "<tmp>", conditionMessage(w), fixed = TRUE)
)
#> [1] "cannot open file '<tmp>/no-such-file.out': No such file or directory"
suppressWarnings(
tryCatch(summaryRprof(missing), error = \(e) conditionMessage(e))
)
#> [1] "cannot open the connection"
# cmpfun() only compiles closures.
tryCatch(compiler::cmpfun("sum"), error = \(e) conditionMessage(e))
#> [1] "cannot compile a non-function"
| Message or symptom | Cause | Remedy |
|---|---|---|
|
|
Fix the difference, or pass |
|
Every iteration of an expression collected garbage, so |
Pass |
|
The expression given to |
Debug the expression; the error itself is raised after the message. |
|
|
Use 0.01 or more, or profile a longer run. |
|
An unknown |
Use one of the three values; |
A profile with no rows, |
Nothing was sampled: the code ran for less than one interval, or, with |
Run longer, loop over more data, or use |
|
|
Check the file name and the working directory; |
|
|
Declare |
|
|
Make the function return the declared type. |
|
|
Pass the function, not its name; to compile an expression use |
A line profile with no |
No source references: |
Source the code with |
|
Same cause. |
As above. |
Source: R documentation: summaryRprof
Pitfalls collected
| Pitfall | What to do |
|---|---|
Optimising before measuring. |
Profile first. The slow part is often not the one the source suggests, as the worked example shows. |
Trusting one timing. |
Repeat, and report the median or the minimum; use bench for anything under a second. |
Comparing expressions that compute different things. |
Use |
Benchmarking tiny inputs. |
A winner on 100 elements can lose on 100000. Use |
Timing the setup. |
Create the data outside the expression; assign nothing global inside it. |
Reading the memory column of the first measurement. |
Warm up first: the first call allocates for byte-compilation. |
Reporting seconds. |
They are specific to a machine. Report ratios and the memory, and say what the machine was. |
Using |
It is not faster than a loop; |
Growing vectors and data frames in a loop. |
Preallocate, or collect pieces in a list and combine them once. |
|
Loop on a vector and put it in the data frame once; the data-frame form copied about ten thousand times the memory in the measurement above. |
Profiling too short a run. |
Fewer than a few dozen samples are noise; repeat the work or lengthen it. |
Profiling a program that waits with the CPU event. |
Use |
Expecting a line profile under |
|
A multi-threaded BLAS with the profiler or with parallel workers. |
Disable profiling, and set the BLAS threads to 1 in the workers. |
Assuming a faster BLAS gives the same numbers. |
The results differ in the last digits and in the signs of eigenvectors; use |
Calling |
The JIT already compiles; measure with the JIT off if you want to see the effect. |
Rewriting in C++ first. |
It is the most expensive remedy. Vectorise, fix copies, then compile. |
Version notes
| Version | Change | Source |
|---|---|---|
R 2.13.0 |
The |
R FAQ 5.1.1 |
R 3.4.0 |
The JIT byte-code compiler is enabled by default at level 3; |
NEWS 3.4.0 |
R 3.5.0 |
All packages are byte-compiled on installation by default. |
NEWS 3.5.0 |
R 4.0.0 |
|
NEWS 4.0.0 |
R 4.1.0 |
|
NEWS 4.1.0 |
R 4.3.0 |
|
NEWS 4.3.0 |
R 4.4.0 |
|
NEWS 4.4.0 |
R 4.5.0 |
The bundled BLAS and LAPACK are updated to LAPACK 3.12.1, expected to be the last update; the 2025 BLAS routines ( |
NEWS 4.5.0 |
R 4.6.0 |
|
NEWS 4.6.0 |
R 4.0.0 to 4.6.1 |
The release NEWS has no entry for |
NEWS 4.x |
Source: R NEWS: Changes in R 4.4.0
The 3.x entries are in the NEWS of R 3.x. Package versions: bench 1.1.4, microbenchmark 1.5.0, profvis 0.4.0, data.table 1.18.6.1, Rcpp 1.1.2, callr 3.8.0.
Where older tutorials differ
| Older tutorial | Current practice | Why | Version |
|---|---|---|---|
Repeating |
|
They repeat, warm up, check results and report memory; |
bench 1.x |
|
|
|
always |
|
|
The default on Unix is CPU time, which is blind to waiting |
R 4.4.0 |
|
|
Tables by self and total time, line and memory summaries |
always |
Profiling with |
|
Writing R Extensions still names proftools and profr for call-graph analysis, so they remain an option |
always |
“R loops are slow; use |
Rely on the JIT; measure with it off if you want the effect |
Closures and top-level loops are compiled automatically at level 3 |
R 3.4.0 |
“Install the byte-compiler and compile packages with |
Nothing: packages are compiled on installation |
Default since R 3.5.0; |
R 3.5.0 |
|
|
The apply family is a loop in C calling R; profvis lists the belief as one that is not true |
always |
|
Preallocate with |
The |
always |
|
|
Each |
always |
|
Work on a vector or a matrix, then build the data frame |
It copies the column at every step |
always |
|
|
No explicit inverse or transpose; half the work for the symmetric product |
always |
|
|
R FAQ 7.28; |
always |
“Install ATLAS” or MKL as the first step |
Check |
ATLAS was last updated in 2018 per the manual; the BLAS must provide the 2025 routines for R 4.5.0 and later |
R 4.5.0 |
Timings quoted in seconds as “the speed” |
Ratios and memory, stated with the machine and the data |
A number of seconds is true on one machine |
always |
For the full list of changes by release see What’s New and Migrating Older Code.
Where to go next
-
Vectorisation and Iteration for the vectorised functions, the growing-vector trap, and byte-compilation, which this page measures.
-
Memory and Copy Semantics for
tracemem(),Rprofmem(),gc(), reference counting and when R copies. -
Parallel and Asynchronous Computing for forking, clusters, futures and the BLAS and data.table threads in parallel code.
-
Interfacing C, C++ and Fortran for
.Call(), Rcpp in packages,src/Makevarsand the toolchains. -
data.table for the package whose speed this page measured.
-
Matrices and Arrays and The apply Family and Functionals for the functions the speed-ups use.
-
Debugging for finding a wrong result before tuning a fast one.
-
Testing with testthat to pin the behaviour of a function before it is optimised.
-
Scripts and the Command Line for running a benchmark with
RscriptandR CMD.
References
-
Writing R Extensions: Tidying and profiling R code, including Profiling R code for speed, Profiling R code for memory use, Tracking memory allocations, Tracing copies of an object, Profiling compiled code and The DESCRIPTION file
-
R Installation and Administration: Linear algebra, BLAS, Shared BLAS, Intel MKL, Accelerate and Byte-compilation
-
R FAQ 7.28: Why is read.table() so inefficient?
-
R FAQ 5.1.1: Add-on packages in R
-
R NEWS: Changes in R 4.0.0 to 4.6.1; R 3.x NEWS: Changes in R 3.4.0 and 3.5.0
-
compile (cmpfun, compile, disassemble, enableJIT and compilePKGS)
-
bench package documentation: mark, press and summary.bench_mark
-
microbenchmark on CRAN (community package)
-
profvis package documentation, profvis, Getting started with profvis and Frequently asked questions
-
data.table package documentation: setDTthreads and getDTthreads and Benchmarking data.table