Profiling R Code with profvis: A Practical Guide
Picture this: a script that ran in 40 seconds yesterday takes 4 minutes today after a small data change. You stare at it, guess that the new lapply is the problem, and rewrite it as a for loop. Nothing changes. You have just spent an hour optimising the wrong line.
Profiling R code with profvis is the fix for that guess-work. The package samples R’s call stack while your code runs and renders the result as a flame graph, so the widest block on the chart is the function that actually eats the most time. The current CRAN version is 0.4.0 (released 2024-09-20) and it requires R ≥ 4.0.0. It is maintained by Winston Chang at Posit, and it sits on top of base R’s Rprof().
This guide walks through installing profvis, reading the flame graph, tuning the profiler for the common gotchas, and pairing it with bench::mark() so you can confirm a fix actually works. For the canonical reference, see the profvis homepage, the practical vignette, and the function reference.
TL;DR
profvissamples R’s call stack and renders an htmlwidget with a flame graph; CRAN 0.4.0 needs R ≥ 4.0.0.- Wrap any code in
profvis({...}); the widest top-level block on the flame graph is your bottleneck. - Use
bench::mark()to confirm a fix moved wall-clock time, not just reshuffled the flame graph. - Set
rerun = TRUEwhen the call finishes in under one sample interval (10 ms by default). - Replace
Sys.sleep()withprofvis::pause()if you want a deliberate wait to show up in the profile.
Install and run a first profile
Install from CRAN the usual way:
install.packages("profvis")
The minimal usage wraps any expression in profvis({...}). The expression is run inside an anonymous function, so variables created inside do not leak into your workspace.
library(profvis)
profvis({
dat <- data.frame(
x = rnorm(5e4),
y = rnorm(5e4)
)
plot(x ~ y, data = dat)
m <- lm(x ~ y, data = dat)
abline(m, col = "red")
})
When the block finishes, an htmlwidget opens in your browser (or inline, if you are in RStudio or knitting a document). The widget has three panels: a flame graph on top, the source code underneath, and a Data tab with a tabular view.
How do I read a profvis flame graph?
The flame graph is the part worth learning. The x-axis is wall-clock time in milliseconds, the y-axis is the call stack. Every rectangle is a call: its width is the time spent in that call (including everything it called), and its position on the y-axis shows which call was on top of the stack at each sample.
A few rules of thumb:
- The widest top-level block is your bottleneck. Everything else is downstream noise.
- Blocks that span the full width are usually I/O, plotting, or a call into compiled C code that profvis cannot see.
- Yellow blocks on the code panel correspond to lines whose total time was at least 1% of the run; click one to see only the samples that hit that line.
If you only look at one thing, look at the widest block. Optimising a 1% line is a distraction.
The code panel and the Data tab
Underneath the flame graph, the code panel shows your source with two coloured bars per line: time spent (ms) and memory allocated. The Data tab is the same information in tabular form, with sortable columns for Time, Memory, and Self Time (time spent in the function itself, not its children).
When the numbers in the Memory column look wrong (for example, a single line that allocated 120 MB), that is usually the sampling interval catching the next sample after a large allocation. The line that caused the allocation is somewhere earlier in the same block. For more deterministic attribution, set torture = TRUE when calling profvis(). It runs gctorture()-style allocation tracking. It is much slower, so use it as a second pass once the flame graph has pointed you at the right region.
A practical workflow
Here is the loop I use on a slow script:
- Wrap the slow section in
profvis({...})and run it. - Identify the widest block. Read the function name.
- Open that function and look at the code panel: which line is yellow?
- Form a hypothesis about why that line is slow.
- Apply one fix at a time. Re-run
profvis(). - When the flame graph looks clean, switch to
bench::mark()to confirm the wall-clock improvement with statistical rigour.
The first half of that loop looks like this in code. Define a candidate function, then hand it to profvis() and let the sampler capture the call stack while it runs:
library(profvis)
slow_sum <- function(n) {
total <- 0
for (i in seq_len(n)) {
total <- total + sqrt(i) * log(i + 1)
}
total
}
profvis(slow_sum(1e6), rerun = TRUE)
If the flame graph points at the for loop, the next step is a vectorised version you can benchmark head-to-head against the original. This is the only honest way to know whether the fix actually moved wall-clock time, because the sampler only tells you where the cost is, not by how much it changed:
library(bench)
fast_sum <- function(n) sum(sqrt(seq_len(n)) * log(seq_len(n) + 1))
bench::mark(
slow = slow_sum(1e6),
fast = fast_sum(1e6),
check = FALSE
)
Profiling tells you where the time goes. Benchmarking tells you whether your change moved the needle. You need both.
When should I tune profvis() settings?
The default settings work for most cases, but a handful of arguments control sampling precision, memory attribution, and how the flame graph is laid out. Here is what each one does and when you would change it:
intervalis the sampling period in seconds. The default is0.01(10 ms). Values below0.005(5 ms) are flagged as inaccurate by profvis because the sampler itself starts to dominate.rerun = TRUEre-runs the expression until a usable profile is produced. Useful when the call is faster than the first sample and produces an empty flame graph.torture = 0(the default) disables allocation tracking. Increase it to attribute memory allocations more precisely; expect a large speed penalty.simplify = TRUEdrops lazy-evaluation frames from the flame graph, equivalent toRprof(filter.callframes = TRUE). Leave it on unless you need to see promise evaluation.timingcontrols what “time” means:"elapsed"for wall-clock time,"cpu"for CPU time.NULLpicks the default for your platform, which is elapsed on Windows or R ≥ 4.4.0, CPU elsewhere.splitsets the orientation of the flame graph and code panel:"h"stacks them vertically,"v"puts them side by side. Use"v"on narrow screens.
If a function finishes in under 10 ms, bump rerun = TRUE before reaching for a smaller interval. The sampler is the bottleneck at that point, and shrinking the interval mostly measures the sampler itself.
Profiling R on a server or in CI
Sometimes you cannot open an htmlwidget, for example when profiling a long-running job on a headless box, or in a CI pipeline. In that case, drive Rprof() directly and feed the result to profvis(prof_input = ...):
library(profvis)
Rprof("data.Rprof",
interval = 0.01,
line.profiling = TRUE,
gc.profiling = TRUE,
memory.profiling = TRUE)
# ... your code here ...
Rprof(NULL)
profvis(prof_input = "data.Rprof")
The same recipe works for profiling a Shiny app on a remote server: wrap the Rprof() call in onStart and the Rprof(NULL) call in onStop, copy the data.Rprof file back to your laptop, and load it with profvis(). You can also save a profile to a self-contained HTML file you can email or attach to a ticket:
library(htmlwidgets)
saveWidget(p, "profile.html", selfcontained = TRUE)
For an in-app profiler with Start/Stop/View/Download buttons, the package ships a Shiny module pair that wraps the same Rprof() machinery and renders the widget in a uiOutput slot:
library(shiny)
library(profvis)
ui <- fluidPage(
profvis::profvis_ui("profiler"),
actionButton("go", "Run slow thing")
)
server <- function(input, output, session) {
profvis::profvis_server("profiler")
observeEvent(input$go, {
sum(rnorm(1e6)) # replace with your real workload
})
}
shinyApp(ui, server)
What are the common profvis gotchas?
Even with a clean flame graph, a few surprises catch people the first time they use profvis. None of them are show-stoppers, but knowing about them saves a confusing re-run. The list below is in roughly the order you are likely to hit each one.
- Empty flame graph. Your expression was faster than one sample. Use
rerun = TRUEor slow the workload down slightly. For loop bodies,profvis::pause(0.05)inside the function is a clean way to make each iteration show up in the flame graph. - Results differ run to run. A 10 ms sample on a 50 ms task lands in one of five places. Run the profile 3 to 5 times before deciding that line X is the bottleneck.
Sys.sleep()is invisible. The C-level sleeper does not show up in the call stack. Useprofvis::pause()instead, which is the only way to make a deliberate wait appear in the flame graph.- First run shows
compiler:::tryCmpfun. R compiles functions on first call. Warm the function with one call outsideprofvis(), or just run the profile twice and read the second result. - The function shows as
<Anonymous>orFUN. Anonymous functions passed tolapply/purrr::mapshow up with generic labels. Assign the function to a named variable to get a readable block in the flame graph. - Internal Shiny calls are hidden by default. The profvis UI option Hide internal function calls is on by default when you profile inside Shiny. Uncheck it to see the framework machinery.
Profiling a lapply loop iteration by iteration
When the body of a loop finishes faster than one sample, every iteration can vanish from the flame graph. profvis::pause() is the documented escape hatch: it sleeps for a given number of seconds while remaining visible in the profile, unlike Sys.sleep().
library(profvis)
profvis({
results <- lapply(1:50, function(i) {
profvis::pause(0.05) # makes the iteration visible
mean(rnorm(1e5))
})
})
An anonymous function passed straight into lapply shows up as <Anonymous> in the flame graph, which makes the call site hard to find. Assigning the body to a named variable first gives the rectangle a readable label, so the bottleneck is obvious at a glance:
slow_iter <- function(i) mean(rnorm(1e5))
profvis({
results <- lapply(1:50, slow_iter)
})
Frequently asked questions
Is profvis the same as Rprof()?
profvis wraps base R’s Rprof() and adds the htmlwidget rendering. If you cannot open a browser (headless server, CI), drive Rprof() directly and pass the output file to profvis(prof_input = ...). For a text-only summary, utils::summaryRprof() is the older alternative.
When should I use bench::mark() instead of profvis?
bench::mark() measures elapsed time with high precision, which is what you want for confirming a fix. Use profvis to find the hotspot, then use bench::mark() to verify the fix actually moved wall-clock time. They are complementary, not interchangeable.
Does profvis work with R Markdown and Quarto?
Yes. profvis auto-prints inline in knitr, so a profvis({...}) chunk renders the widget in the HTML output. For very long profiles, save to a file with saveWidget() and link to it instead of inlining the widget.
Why does memory attribution look wrong on the Data tab?
Sampling cannot attribute allocations to a single line. A 120 MB “allocation” on line N may have actually happened on line N-1 and been caught by the next sample. Use torture = TRUE for more deterministic attribution, at a large speed cost.
Conclusion
Profiling R code is a workflow, not a single command. The mental model stays the same every time: find the widest rectangle, identify the yellow line, change one thing, and re-profile. Use profvis to locate the hotspot, then reach for /guides/r-bench-microbenchmark/ to confirm the wall-clock speedup, and use /guides/r-memory-management/ when the flame graph points at allocation rather than CPU.
See Also
- /guides/r-profiling/ — broader overview of R’s profiling tools, including
Rprof()andsummaryRprof(). - /guides/r-bench-microbenchmark/ — measuring performance with
bench::mark()andmicrobenchmark. - /guides/r-memory-management/ —
gc(), memory limits, andprofmemfor allocation tracking.