Skip to content

Commit a469c58

Browse files
committed
Deploying to gh-pages from @ 1c81eb8 🚀
1 parent 58e0e8f commit a469c58

56 files changed

Lines changed: 1174 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

404.html

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DESIGN.html

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LICENSE-text.html

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LICENSE.html

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

articles/case-study-tidypredict.html

Lines changed: 556 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

articles/case-study-tidypredict.md

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
# Case study: Using debrief for iterative R performance optimization
2+
3+
## About this case study
4+
5+
This case study was conducted by Claude (an AI assistant) to optimize
6+
tidypredict’s random forest parsing. Two important notes about the
7+
debrief package usage:
8+
9+
1. **No prior knowledge**: Claude had no prior knowledge of the debrief
10+
package before this session. The package was not part of Claude’s
11+
training data.
12+
13+
2. **No documentation lookup**: Claude did not read debrief’s
14+
documentation, README, or help files. The only information provided
15+
was the user’s prompt showing the basic usage pattern
16+
(`pv_print_debrief(p)`). All interpretation of the output was done
17+
from first principles based on the section names and content.
18+
19+
3. **Test-driven verification**: After each optimization, Claude ran
20+
the package’s existing test suite (`devtools::test()`) to verify the
21+
changes didn’t break functionality. All 38 relevant tests (ranger:
22+
18, randomForest: 13, partykit: 7) passed after the optimizations.
23+
24+
This demonstrates that debrief’s output is self-explanatory enough to
25+
guide optimization work without requiring prior familiarity with the
26+
package. The section names (TOP FUNCTIONS BY SELF-TIME, HOT LINES,
27+
MEMORY ALLOCATION) clearly communicate what each part shows, and the
28+
“Next steps” suggestions in the output hinted at additional functions
29+
available.
30+
31+
## Overview
32+
33+
This case study demonstrates how the **debrief** package can guide
34+
iterative performance optimization in R. We use tidypredict’s random
35+
forest parsing as a real-world example, achieving a **331x speedup**
36+
through systematic profiling and targeted fixes.
37+
38+
## Problem statement
39+
40+
[tidymodels/orbital#83](https://github.com/tidymodels/orbital/issues/83)
41+
reported that parsing random forest models in tidypredict takes 45+
42+
seconds for a single tree. We rolled back to the problematic version
43+
(commit `0dde432`) to investigate.
44+
45+
### User prompt
46+
47+
The user provided the following instructions to guide the profiling
48+
session:
49+
50+
``` md
51+
profile the tidypredict_fit call in the following code.
52+
53+
library(tidymodels)
54+
library(orbital)
55+
56+
rec_spec <- recipe(Sale_Price ~ ., data = ames) |>
57+
step_normalize(all_numeric_predictors()) |>
58+
step_dummy(all_nominal_predictors())
59+
60+
mod_spec <- rand_forest("regression", trees = 1)
61+
wf_spec <- workflow(rec_spec, mod_spec)
62+
wf_fit <- fit(wf_spec, ames)
63+
rf_tree <- extract_fit_engine(wf_fit)
64+
65+
tmp <- tidypredict_fit(rf_tree)
66+
67+
I want you to use the profvis and debrief package to iteratively make improvements to the function
68+
69+
library(profvis)
70+
library(debrief)
71+
72+
# Profile some code
73+
p <- profvis({
74+
# your code here
75+
})
76+
77+
pv_print_debrief(p)
78+
```
79+
80+
This prompt established the workflow: profile with profvis, analyze with
81+
[`pv_print_debrief()`](https://emilhvitfeldt.github.io/debrief/reference/pv_print_debrief.md),
82+
optimize, and repeat.
83+
84+
## Iteration 1: Initial profiling
85+
86+
### Running debrief
87+
88+
``` r
89+
p <- profvis({
90+
tmp <- tidypredict_fit(rf_tree)
91+
})
92+
93+
pv_print_debrief(p)
94+
```
95+
96+
### Debrief output (key sections)
97+
98+
## PROFILING SUMMARY
99+
100+
Total time: 47340 ms (4734 samples @ 10 ms interval)
101+
102+
### TOP FUNCTIONS BY SELF-TIME
103+
15180 ms ( 32.1%) [.data.frame
104+
5280 ms ( 11.2%) <GC>
105+
4220 ms ( 8.9%) sys.call
106+
3950 ms ( 8.3%) [[.data.frame
107+
108+
### HOT LINES (by self-time)
109+
12710 ms ( 26.8%) /R/model-ranger.R:6
110+
row <- tree[tree$nodeID == j, ]
111+
2880 ms ( 6.1%) /R/model-ranger.R:7
112+
lc <- row["leftChild"][[1]] == find
113+
2580 ms ( 5.4%) /R/model-ranger.R:8
114+
lr <- row["rightChild"][[1]] == find
115+
116+
### MEMORY ALLOCATION (by line)
117+
15655.00 MB /R/model-ranger.R:6
118+
row <- tree[tree$nodeID == j, ]
119+
3450.47 MB /R/model-ranger.R:7
120+
lc <- row["leftChild"][[1]] == find
121+
122+
### How I interpreted this output
123+
124+
1. **TOP FUNCTIONS BY SELF-TIME** showed `[.data.frame` at 32.1% - this
125+
is the data.frame subsetting operator, suggesting repeated
126+
data.frame operations are the culprit.
127+
128+
2. **HOT LINES** pinpointed the exact line:
129+
`row <- tree[tree$nodeID == j, ]` consuming 26.8% of total time.
130+
This single line was the primary bottleneck.
131+
132+
3. **MEMORY ALLOCATION** revealed this same line was allocating 15.6
133+
GB - explaining the high GC time (11.2%). Each subsetting operation
134+
creates a new data.frame copy.
135+
136+
4. **The pattern**: Line 6 is inside a loop (indicated by the variable
137+
`j`). Filtering a data.frame with `tree[tree$nodeID == j, ]` is O(n)
138+
per call. In a loop, this becomes O(n^2).
139+
140+
### Optimization applied
141+
142+
Pre-extract columns as vectors and use direct indexing:
143+
144+
``` r
145+
# Before (O(n) per lookup):
146+
row <- tree[tree$nodeID == j, ]
147+
lc <- row["leftChild"][[1]] == find
148+
149+
# After (O(1) per lookup):
150+
idx <- j + 1L
151+
lc <- left_child[idx] == find
152+
```
153+
154+
### Result
155+
156+
| Metric | Before | After |
157+
|:--------|----------:|---------:|
158+
| Time | 47,340 ms | 280 ms |
159+
| Memory | ~18 GB | ~20 MB |
160+
| Speedup | \- | **169x** |
161+
162+
## Iteration 2: Finding the next bottleneck
163+
164+
### Debrief output after first optimization
165+
166+
## PROFILING SUMMARY
167+
168+
Total time: 290 ms (29 samples @ 10 ms interval)
169+
170+
### TOP FUNCTIONS BY SELF-TIME
171+
60 ms ( 20.7%) is.na
172+
20 ms ( 6.9%) .f
173+
20 ms ( 6.9%) if (lc || lr) {
174+
20 ms ( 6.9%) lc <- left_child[idx] == find
175+
176+
### HOT LINES (by self-time)
177+
60 ms ( 20.7%) /R/model-ranger.R:16
178+
if (is.na(lc)) {
179+
20 ms ( 6.9%) /R/model-ranger.R:14
180+
lc <- left_child[idx] == find
181+
20 ms ( 6.9%) /R/model-ranger.R:22
182+
if (lc || lr) {
183+
184+
### How I interpreted this output
185+
186+
1. **TOP FUNCTIONS BY SELF-TIME** now showed `is.na` as the top
187+
consumer at 20.7%. The bottleneck shifted from data.frame operations
188+
to NA checking.
189+
190+
2. **HOT LINES** showed the `is.na` checks on lines 16 and 19 were
191+
being called repeatedly in the loop.
192+
193+
3. **The insight**: The code was searching for parent nodes by
194+
iterating from `node_id` down to 0, checking each potential parent.
195+
This linear search could be replaced with a direct lookup.
196+
197+
### Optimization applied
198+
199+
Pre-compute parent relationships once, then traverse directly:
200+
201+
``` r
202+
# Before: Linear search for parent
203+
for (j in node_id:0) {
204+
idx <- j + 1L
205+
lc <- left_child[idx] == find
206+
lr <- right_child[idx] == find
207+
if (is.na(lc)) lc <- FALSE
208+
if (is.na(lr)) lr <- FALSE
209+
if (lc || lr) { ... }
210+
}
211+
212+
# After: Direct traversal using parent lookup
213+
while (!is.na(parent[current + 1L])) {
214+
current <- parent[current + 1L]
215+
path <- c(path, current)
216+
}
217+
```
218+
219+
### Result
220+
221+
| Metric | Before | After |
222+
|:--------|-------:|:---------------|
223+
| Time | 290 ms | 160 ms |
224+
| Speedup | \- | **45% faster** |
225+
226+
## Iteration 3: Diminishing returns
227+
228+
### Debrief output (using 10 trees for better sampling)
229+
230+
## PROFILING SUMMARY
231+
232+
Total time: 1650 ms (165 samples @ 10 ms interval)
233+
234+
### TOP FUNCTIONS BY SELF-TIME
235+
280 ms ( 17.0%) <GC>
236+
200 ms ( 12.1%) enexpr
237+
110 ms ( 6.7%) reduce_impl
238+
239+
### HOT LINES (by self-time)
240+
60 ms ( 3.6%) /R/model-rf.R:80
241+
if (.x$op == "less") i <- expr(!!sym(.x$col) < !!.x$val)
242+
30 ms ( 1.8%) /R/model-rf.R:78
243+
if (.x$op == "more") i <- expr(!!sym(.x$col) > !!.x$val)
244+
245+
### TOP FUNCTIONS BY TOTAL TIME
246+
1020 ms ( 61.8%) path_formulas
247+
500 ms ( 30.3%) expr
248+
249+
### How I interpreted this output
250+
251+
1. **TOP FUNCTIONS BY TOTAL TIME** showed `path_formulas` at 61.8% -
252+
the bottleneck shifted to formula building.
253+
254+
2. **HOT LINES** showed multiple if-statements checking `.x$op`
255+
repeatedly (lines 78-81).
256+
257+
3. **TOP FUNCTIONS BY SELF-TIME** showed `enexpr` (expression building)
258+
and `reduce_impl` as top consumers - these are doing useful work.
259+
260+
4. **The insight**: The if-chain could be replaced with
261+
[`switch()`](https://rdrr.io/r/base/switch.html), but we’re now
262+
optimizing code that’s doing inherent work. Further gains will be
263+
marginal.
264+
265+
### Optimization applied
266+
267+
Replace if-chain with switch statement:
268+
269+
``` r
270+
# Before:
271+
if (.x$op == "more") i <- expr(!!sym(.x$col) > !!.x$val)
272+
if (.x$op == "more-equal") i <- expr(!!sym(.x$col) >= !!.x$val)
273+
if (.x$op == "less") i <- expr(!!sym(.x$col) < !!.x$val)
274+
if (.x$op == "less-equal") i <- expr(!!sym(.x$col) <= !!.x$val)
275+
276+
# After:
277+
switch(
278+
.x$op,
279+
"more" = expr(!!col_sym > !!val),
280+
"more-equal" = expr(!!col_sym >= !!val),
281+
"less" = expr(!!col_sym < !!val),
282+
"less-equal" = expr(!!col_sym <= !!val)
283+
)
284+
```
285+
286+
### Result
287+
288+
Minor improvement (~4%). The remaining time is in expression building
289+
which is inherent to the task.
290+
291+
## Why I only used `pv_print_debrief()`
292+
293+
The debrief package offers several functions, and
294+
[`pv_print_debrief()`](https://emilhvitfeldt.github.io/debrief/reference/pv_print_debrief.md)
295+
suggests others at the end of its output:
296+
297+
### Next steps
298+
pv_source_context(p, "/R/model-ranger.R")
299+
pv_suggestions(p)
300+
pv_focus(p, ".f")
301+
pv_help()
302+
303+
### Functions I didn’t use
304+
305+
| Function | Purpose | Why I skipped it |
306+
|:------------------------------------------------------------------------------------------------|:--------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------|
307+
| [`pv_source_context()`](https://emilhvitfeldt.github.io/debrief/reference/pv_source_context.md) | Shows more code context around hot lines | I was already reading full source files directly. Once HOT LINES said “line 6 in model-ranger.R”, I just read that file. |
308+
| [`pv_suggestions()`](https://emilhvitfeldt.github.io/debrief/reference/pv_suggestions.md) | Provides automated optimization suggestions | The bottlenecks were obvious enough (32% in `[.data.frame`, 15.6 GB on one line) that I could diagnose the issue myself. |
309+
| [`pv_focus()`](https://emilhvitfeldt.github.io/debrief/reference/pv_focus.md) | Filters analysis to a specific function | The initial output was already clear about where problems were. No need to drill down. |
310+
311+
### When `pv_print_debrief()` alone is sufficient
312+
313+
The single function was enough because:
314+
315+
1. **HOT LINES gave exact locations** - File path and line number meant
316+
I could go straight to the problem code
317+
2. **MEMORY ALLOCATION correlated with time** - The same lines appeared
318+
in both sections, confirming the diagnosis
319+
3. **Patterns were recognizable** - Data.frame subsetting in a loop is
320+
a known anti-pattern
321+
322+
### When to use the other functions
323+
324+
I would reach for additional functions if:
325+
326+
- **Diffuse profile** - Time spread across many functions without a
327+
clear culprit
328+
- **Complex call paths** - Need to understand how a bottleneck is
329+
reached from different entry points
330+
- **Unfamiliar codebase** -
331+
[`pv_source_context()`](https://emilhvitfeldt.github.io/debrief/reference/pv_source_context.md)
332+
would help see surrounding code without switching tools
333+
- **Stuck on diagnosis** -
334+
[`pv_suggestions()`](https://emilhvitfeldt.github.io/debrief/reference/pv_suggestions.md)
335+
might catch patterns I missed
336+
337+
## Final results
338+
339+
| Stage | Time | Speedup | Key debrief insight |
340+
|:--------------------|-----------:|---------:|:-----------------------|
341+
| Original | 47,340 ms | 1x | `[.data.frame` at 32% |
342+
| After vectorization | 280 ms | 169x | `is.na` now at 20% |
343+
| After parent lookup | 160 ms | 296x | `path_formulas` at 60% |
344+
| After switch | **143 ms** | **331x** | Diminishing returns |

0 commit comments

Comments
 (0)