Profiling and Optimizing R Code
Before you can make your code faster, you need to find what’s making it slow. This guide shows you how to profile R code to identify bottlenecks and use microbenchmarking to compare alternative implementations.
Why Profile First
Donald Knuth famously said that programmers waste enormous amounts of time optimizing noncritical parts of their programs. Profiling tells you exactly where your code spends time, so you can focus on the parts that actually matter.
R uses a sampling profiler. It stops code execution every few milliseconds and records the current call stack. Over time, this builds a picture of which functions consume the most time.
Using profvis for Profiling
The profvis package provides the easiest way to profile R code. It wraps the base R profiler and displays results in an interactive visualization. The base R profiler is called Rprof(), and it writes sampling data to a file. profvis takes that data and makes it readable.
You can also use Rprof directly:
Rprof(tmp <- tempfile(), interval = 0.01)
# Run your code here
your_function()
Rprof(NULL)
# Summarize results
summaryRprof(tmp)
The summary shows time spent in each function, sorted by total time. However, the profvis visualization makes it easier to understand call stacks, which is why most people prefer it.
# Install profvis if needed
install.packages("profvis")
# Profile a function
library(profvis)
profile_slow <- function() {
# Simulate some work
result <- numeric(1000)
for (i in 1:1000) {
result[i] <- mean(rnorm(1000))
}
# More work
data.frame(
x = rnorm(10000),
y = runif(10000)
)
}
profvis(profile_slow())
When you run this, profvis opens an HTML page with two panes. The top shows your source code with bar graphs overlaid on each line. The bottom shows a flame graph of the call stack.
The flame graph reveals which functions called which, and how much time each branch consumed. If you see <GC> taking significant time, your code creates too many temporary objects.
Memory Profiling
Memory problems show up clearly in profvis. The memory column shows allocation (right bar) and deallocation (left bar) for each line.
Watch for patterns like this:
# This creates many copies - slow!
x <- integer()
for (i in 1:10000) {
x <- c(x, i) # Creates a new copy each time
}
When you profile this, the garbage collector (<GC>) appears prominently because R copies the entire vector on each iteration. The solution is to pre-allocate or use vectorized operations:
# Fast: pre-allocate
x <- integer(10000)
for (i in 1:10000) {
x[i] <- i
}
# Even faster: vectorized
x <- 1:10000
Microbenchmarking with bench
Microbenchmarking measures the execution time of small code snippets with high precision. The bench package is the modern choice.
install.packages("bench")
library(bench)
x <- runif(100)
result <- bench::mark(
sqrt(x),
x ^ 0.5,
exp(log(x) / 2)
)
result
The output shows min, median, and mean times plus iterations per second. Focus on the minimum (best possible) and median (typical) times. The memory allocation column tells you how much each approach uses.
# Focus on the important columns
result[, c("expression", "min", "median", "itr/sec", "mem_alloc")]
The distribution of timings is heavily right-skewed. This is why comparing means is misleading. Always use median or min.
Common Optimization Patterns
Once profiling identifies bottlenecks, these patterns often help:
Vectorize over loops. Replace explicit loops with vectorized operations:
# Slow
sum <- 0
for (i in x) {
sum <- sum + i
}
# Fast
sum(x)
Pre-allocate. Allocate space before filling:
# Slow - keeps growing
result <- c()
for (i in 1:1000) {
result <- c(result, compute(i))
}
# Fast - fixed size
result <- numeric(1000)
for (i in 1:1000) {
result[i] <- compute(i)
}
Use matrices efficiently. Matrix operations in base R are often faster than data frame operations for numeric calculations:
# Convert to matrix for numeric-heavy work
mat <- as.matrix(df[, c("a", "b", "c")])
colMeans(mat)
Avoid copies. The copy-on-modify behavior in R can surprise you. Watch for functions that appear to modify objects in place but actually create copies:
# This doesn't modify x in place
x <- data.frame(a = 1:10)
x$a <- NULL # Actually creates a new data frame
When to Use Which Tool
- profvis - Find which parts of your code are slow
- bench - Compare multiple implementations precisely
- gc() - Check current memory usage
- object.size() - See how much memory an object uses
Profiling Limitations
Be aware of what profiling cannot tell you:
- It cannot profile C/C++ code called from R
- Anonymous functions are hard to identify in call stacks
- Lazy evaluation can make call stacks harder to interpret
Measuring Execution Time
For quick checks, system.time() works for single expressions:
system.time({
x <- rnorm(1000000)
mean(x)
})
The output shows user time (CPU time), system time (OS calls), and elapsed time. For longer-running code, this gives you a rough sense of performance. However, system.time is not precise enough for comparing similar implementations, which is why bench exists.
Practical Example
Here’s a realistic workflow:
library(profvis)
# Your actual analysis code
analyze_data <- function() {
# Load and process data
df <- read.csv("large_file.csv")
# Transformations
df$z <- (df$x - mean(df$x)) / sd(df$x)
# Model fitting
results <- lapply(unique(df$group), function(g) {
subset <- df[df$group == g, ]
lm(y ~ z, data = subset)
})
# Summaries
lapply(results, summary)
}
profvis(analyze_data())
The profvis output shows you which parts take the most time. Maybe the file read is slow (consider readr). Maybe the loop over groups creates too many copies (consider dplyr group_by). You won’t know until you profile.
See Also
- Functional Programming with purrr — vectorized alternatives to loops
- Memory Management in R — understanding R’s memory model
- The apply Family — when you still need iteration
Conclusion
Profile before optimizing. Use profvis to find bottlenecks, then bench to compare solutions. Focus on the biggest wins first. Most of the time, a single optimization in a hot path matters more than micro-optimizing everything.