|
| 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