perf: speed up ard_stack_hierarchical() outside the counting kernel (#176) - #587
Conversation
…176) Profiling ard_stack_hierarchical() after the #176 counting-engine rewrite showed the remaining cost was dominated by code outside the kernel. On a 47,640-row ADAE the full call dropped from ~3.7s to ~0.4s (~9x) with lower memory, and small-data calls improved ~1.75x. Outputs, messages, classes, and attributes are unchanged (verified byte-identical via a 13-scenario waldo grid, the full test suite, and the differential engine harness). - ard_stack_hierarchical.R: replace per-level dplyr::slice_tail(n=1L, by=) with a vctrs::vec_group_id()/vec_slice() equivalent (same "keep last row per group in data order" semantics); drop the redundant is.nan() scan (is.na() already covers NaN) and replace apply(., 1, any) with rowSums() > 0. - ard_tabulate.R: check the data-frame denominator branch before the scalar-string branches so a data frame is no longer coerced to character by `x %in% "column"` (mtfrm); guard the string tests in .process_denominator() the same way; vectorize the stat_label coalesce (drop the per-row map2_chr closure). - ard_hierarchical.R: anyDuplicated() -> vctrs::vec_duplicate_any(). - sort_ard_hierarchical.R: vectorize the rowwise unlist of the variable-level column with vapply(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Performance BenchmarkComparing main ( Each benchmark runs 5 independent rounds. The change column shows the mean % difference in median run time (negative = faster). The main mem / pr mem columns show total memory allocated ( Core ARD functions (20x replicated
|
| expression | main | pr | change | ci | main mem | pr mem | mem Δ |
|---|---|---|---|---|---|---|---|
| ard_summary | 49.4ms | 54.2ms | ➖ +9.9% | [-7.4%, 27.3%] | 8.51MB | 8.51MB | ➖ +0% |
| ard_tabulate | 34ms | 34.6ms | ➖ +2.4% | [-16%, 20.8%] | 1.74MB | 1.71MB | ✅ -1.9% |
Hierarchical (10x replicated ADAE, denominator ADSL)
| expression | main | pr | change | ci | main mem | pr mem | mem Δ |
|---|---|---|---|---|---|---|---|
| ard_stack_hierarchical | 1093.5ms | 201.2ms | ✅ -81.3% | [-84.9%, -77.6%] | 29.4MB | 24.2MB | ✅ -17.7% |
| ard_stack_hierarchical_count | 178.8ms | 127.4ms | ✅ -28.7% | [-32.5%, -24.8%] | 9.71MB | 7.41MB | ✅ -23.8% |
| ard_hierarchical | 520.1ms | 64.4ms | ✅ -87.6% | [-88%, -87.3%] | 11MB | 4.89MB | ✅ -55.7% |
High-cardinality tabulation (200k rows, 5k levels)
| expression | main | pr | change | ci | main mem | pr mem | mem Δ |
|---|---|---|---|---|---|---|---|
| ard_tabulate high-card | 2577.2ms | 490.3ms | ✅ -81% | [-82.3%, -79.7%] | 91MB | 57.6MB | ✅ -36.7% |
Code Coverage SummaryDiff against mainResults for commit: 95cd10d Minimum allowed coverage is ♻️ This comment has been updated with latest results |
Unit Tests Summary 1 files 296 suites 30s ⏱️ Results for commit 95cd10d. ♻️ This comment has been updated with latest results. |
Unit Test Performance Difference
Additional test case details
Results for commit 53638a2 ♻️ This comment has been updated with latest results. |
Rework the Performance Benchmark action to compare the PR branch against main instead of reporting only the PR branch's raw numbers, following the gtsummary benchmark workflow. - benchmark.R: install and benchmark both `pharmaverse/cards` (main) and the PR source (local::.) in isolated callr sessions across N_ROUNDS rounds; report per-expression mean % change in median time with a 95% CI and a pass/fail/inconclusive verdict, plus a memory-allocation delta. Uses cards' bundled ADSL/ADAE so the isolated session needs only cards + bench, and adds ard_stack_hierarchical(_count) cases. - benchmark.yaml: run in the rocker/r-ver container (R built with --enable-memory-profiling) so bench::mark() mem_alloc is populated; swap dependencies to callr/pak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stat_label vectorization in this PR benefits any ard_tabulate() call producing many rows (~4x faster on a 45k-row result), not only the hierarchical stacking functions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What changes are proposed in this pull request?
ard_stack_hierarchical()andard_stack_hierarchical_count(), primarily by replacing the per-leveldplyr::slice_tail()de-duplication with avctrs-based equivalent and by avoiding unnecessary coercions of data frame denominators. Results are unchanged. (Moreard_categorical()efficiencies 😿 #176)Profiling
ard_stack_hierarchical()after the #176 counting-engine rewrite showed the remaining cost was dominated by code outside the sparse counting kernel — chiefly the per-hierarchy-leveldplyr::slice_tail(n = 1L, by = ...)"highest-severity" de-duplication, plus a few full-data copies and coercions.Changes (no user-facing changes — inputs, outputs, messages, classes, attributes all unchanged):
ard_stack_hierarchical.Rdplyr::slice_tail(n = 1L, by = ...)with!duplicated(vctrs::vec_group_id(...), fromLast = TRUE)+vctrs::vec_slice()— identical "keep last row per group in data order" semantics, ~75× faster per level. (Largest win.)is.nan()scan (is.na()is alreadyTRUEforNaN) and replaceapply(., 1, any)withrowSums(.) > 0, avoiding two full-data character-matrix copies.ard_tabulate.Ris.data.frame(denominator)branch ahead of the scalar-string branches so a data frame denominator is no longer coerced to character byx %in% "column"(mtfrm), which ran 3× per call; guard the string tests in.process_denominator()the same way.stat_labelcoalesce()(remove the per-rowmap2_chr()closure).ard_hierarchical.R:anyDuplicated()→vctrs::vec_duplicate_any().sort_ard_hierarchical.R: vectorize the rowwise unlist of the variable-level column withvapply().Benchmarks (
variables = c(AESOC, AEDECOD),by = TRTA,denominator = ADSL,id = USUBJID):Verification:
waldo::compare()grid (defaults,bynot in denominator,include,overall,over_variables,attributes,total_n, 3-level, integer denominator, NA/NaN data, descending sort) — all byte-identical.Reference GitHub issue associated with pull request.
Related to #176.
Pre-review Checklist (if item does not apply, mark is as complete)
usethis::pr_merge_main()devtools::test_coverage()(no new functions; existing tests exercise all touched paths)Reviewer Checklist (if item does not apply, mark is as complete)
pkgdown::build_site(). Check the R console for errors, and review the rendered website.devtools::test_coverage()When the branch is ready to be merged:
NEWS.mdwith the changes from this pull request.🤖 Generated with Claude Code