Skip to content

perf: speed up ard_stack_hierarchical() outside the counting kernel (#176) - #587

Merged
ddsjoberg merged 5 commits into
mainfrom
perf/stack-hierarchical-176
Jul 31, 2026
Merged

perf: speed up ard_stack_hierarchical() outside the counting kernel (#176)#587
ddsjoberg merged 5 commits into
mainfrom
perf/stack-hierarchical-176

Conversation

@ddsjoberg

Copy link
Copy Markdown
Collaborator

What changes are proposed in this pull request?

  • Further reduced the run time and memory use of ard_stack_hierarchical() and ard_stack_hierarchical_count(), primarily by replacing the per-level dplyr::slice_tail() de-duplication with a vctrs-based equivalent and by avoiding unnecessary coercions of data frame denominators. Results are unchanged. (More ard_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-level dplyr::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.R
    • Replace dplyr::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.)
    • Drop the redundant is.nan() scan (is.na() is already TRUE for NaN) and replace apply(., 1, any) with rowSums(.) > 0, avoiding two full-data character-matrix copies.
  • ard_tabulate.R
    • Move the is.data.frame(denominator) branch ahead of the scalar-string branches so a data frame denominator is no longer coerced to character by x %in% "column" (mtfrm), which ran 3× per call; guard the string tests in .process_denominator() the same way.
    • Vectorize the stat_label coalesce() (remove 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().

Benchmarks (variables = c(AESOC, AEDECOD), by = TRTA, denominator = ADSL, id = USUBJID):

Scenario Before After Speedup Memory
40× ADAE (47,640 rows) 3.69 s 409 ms ~9.0× 87 → 73 MB
1× ADAE (small) 588 ms 336 ms ~1.75× 10.5 → 8.1 MB

Verification:

  • Full test suite: 800 passed, 0 failed, 0 warnings.
  • 13-scenario waldo::compare() grid (defaults, by not in denominator, include, overall, over_variables, attributes, total_n, 3-level, integer denominator, NA/NaN data, descending sort) — all byte-identical.
  • Differential engine harness (167 cases) reports the same 112/1/51/3 split before and after — these changes are neutral against the pre-existing accepted legacy-vs-rewrite deviations.

Reference GitHub issue associated with pull request.
Related to #176.


Pre-review Checklist (if item does not apply, mark is as complete)

  • All GitHub Action workflows pass with a ✅
  • PR branch has pulled the most recent updates from master branch: usethis::pr_merge_main()
  • If a bug was fixed, a unit test was added. (no bug fix; behavior is unchanged and covered by existing tests)
  • Code coverage is suitable for any new functions/features (generally, 100% coverage for new code): devtools::test_coverage() (no new functions; existing tests exercise all touched paths)
  • Request a reviewer

Reviewer Checklist (if item does not apply, mark is as complete)

  • If a bug was fixed, a unit test was added.
  • Run pkgdown::build_site(). Check the R console for errors, and review the rendered website.
  • Code coverage is suitable for any new functions/features: devtools::test_coverage()

When the branch is ready to be merged:

  • Update NEWS.md with the changes from this pull request.
  • All GitHub Action workflows pass with a ✅
  • Approve Pull Request
  • Merge the PR. Please use "Squash and merge" or "Rebase and merge".

🤖 Generated with Claude Code

…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>
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Performance Benchmark

Comparing main (0.8.1.9000) vs PR (0.8.1.9000)

Each benchmark runs 5 independent rounds. The change column shows the mean % difference in median run time (negative = faster).
The 95% CI column shows the confidence interval on the change. If the CI excludes 0%, the result is flagged as a real improvement (✅) or regression (❌); otherwise it is inconclusive (➖).

The main mem / pr mem columns show total memory allocated (bench::mark() mem_alloc), and mem Δ its % change (negative = less memory). Allocation is deterministic, so no confidence interval is shown. These columns show n/a when R is built without memory profiling.

Core ARD functions (20x replicated ADSL)

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%

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

badge

Code Coverage Summary

Filename                       Stmts    Miss  Cover    Missing
---------------------------  -------  ------  -------  ---------------------------------------------------------------------------------------------------------------------------------------
R/add_calculated_row.R            53       6  88.68%   51-56
R/apply_fmt_fun.R                116       0  100.00%
R/ard_attributes.R                46       1  97.83%   57
R/ard_formals.R                   13       0  100.00%
R/ard_hierarchical.R             104      12  88.46%   88-93, 187-192
R/ard_identity.R                  13       0  100.00%
R/ard_missing.R                   70       7  90.00%   45-50, 61
R/ard_mvsummary.R                 54       7  87.04%   76-81, 100
R/ard_pairwise.R                  50       0  100.00%
R/ard_stack_hierarchical.R       272       0  100.00%
R/ard_stack.R                     98       0  100.00%
R/ard_strata.R                    38       0  100.00%
R/ard_summary.R                  200       8  96.00%   87-92, 226-227
R/ard_tabulate_rows.R             12       0  100.00%
R/ard_tabulate_value.R            85       8  90.59%   51-56, 73, 97
R/ard_tabulate.R                 629     103  83.62%   108-113, 194, 271, 310, 400, 412-415, 547, 607-612, 643, 819-823, 829-842, 869, 876-881, 885-904, 940-950, 954-973, 977-981, 1011, 1044
R/ard_total_n.R                   19       0  100.00%
R/as_card_fn.R                     8       0  100.00%
R/as_card.R                       12       0  100.00%
R/as_nested_list.R                41       0  100.00%
R/bind_ard.R                      49      11  77.55%   74-85
R/cards-package.R                  1       1  0.00%    14
R/check_ard_structure.R           55       5  90.91%   35-39
R/compare_ard_helpers.R          127      32  74.80%   44-50, 66-69, 113, 148, 153, 158-184, 227, 230
R/compare_ard.R                   26       0  100.00%
R/default_stat_labels.R           20       0  100.00%
R/deprecated.R                    34      30  11.76%   40-41, 56-73, 88-134
R/eval_capture_conditions.R       30       0  100.00%
R/filter_ard_hierarchical.R      207       0  100.00%
R/get_ard_statistics.R            20       0  100.00%
R/maximum_variable_value.R        13       0  100.00%
R/mock.R                         137       2  98.54%   116, 245
R/nest_for_ard.R                  80       1  98.75%   64
R/print_ard_conditions.R          83       0  100.00%
R/print.R                        101       2  98.02%   164-165
R/process_selectors.R            126       1  99.21%   337
R/rename_ard_columns.R            84       0  100.00%
R/rename_ard_groups.R             60       0  100.00%
R/replace_null_statistic.R        11       0  100.00%
R/round5.R                         1       0  100.00%
R/selectors.R                     23       0  100.00%
R/shuffle_ard.R                  167       0  100.00%
R/sort_ard_hierarchical.R        160       0  100.00%
R/summary_functions.R             25       1  96.00%   59
R/tidy_ard_order.R                42       0  100.00%
R/tidy_as_ard.R                   40       0  100.00%
R/unlist_ard_columns.R            28       0  100.00%
R/update_ard.R                    60       6  90.00%   54-59
R/utils.R                         32       1  96.88%   37
TOTAL                           3775     245  93.51%

Diff against main

Filename                      Stmts    Miss  Cover
--------------------------  -------  ------  --------
R/ard_stack_hierarchical.R       -4       0  +100.00%
R/ard_tabulate.R                 +2       0  +0.05%
R/sort_ard_hierarchical.R        -2       0  +100.00%
TOTAL                            -4       0  -0.01%

Results for commit: 95cd10d

Minimum allowed coverage is 80%

♻️ This comment has been updated with latest results

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Unit Tests Summary

  1 files  296 suites   30s ⏱️
295 tests 294 ✅ 1 💤 0 ❌
805 runs  803 ✅ 2 💤 0 ❌

Results for commit 95cd10d.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Unit Test Performance Difference

Test Suite $Status$ Time on main $±Time$ $±Tests$ $±Skipped$ $±Failures$ $±Errors$
filter_ard_hierarchical 💀 $0.01$ $-0.01$ $-9$ $0$ $0$ $0$
sort_ard_hierarchical 💀 $0.00$ $-0.00$ $-3$ $0$ $0$ $0$
Additional test case details
Test Suite $Status$ Time on main $±Time$ Test Case
eval_capture_conditions 👶 $+0.04$ unnamed
filter_ard_hierarchical 💀 $0.01$ $-0.01$ filter_ard_hierarchical_error_messaging_works
filter_ard_hierarchical 💀 $0.01$ $-0.01$ filter_ard_hierarchical_keep_empty_works
filter_ard_hierarchical 💀 $0.00$ $-0.00$ filter_ard_hierarchical_returns_only_summary_rows_when_all_rows_filtered_out
filter_ard_hierarchical 💀 $0.01$ $-0.01$ filter_ard_hierarchical_var_works
filter_ard_hierarchical 💀 $2.74$ $-2.74$ filter_ard_hierarchical_works
filter_ard_hierarchical 💀 $0.00$ $-0.00$ filter_ard_hierarchical_works_when_some_variables_not_included_in_x
filter_ard_hierarchical 💀 $0.00$ $-0.00$ filter_ard_hierarchical_works_with_ard_stack_hierarchical_count_results
filter_ard_hierarchical 💀 $0.01$ $-0.01$ filter_ard_hierarchical_works_with_column_specific_filters
filter_ard_hierarchical 💀 $0.01$ $-0.01$ filter_ard_hierarchical_works_with_non_standard_filters
filter_ard_hierarchical 💀 $0.00$ $-0.00$ filter_ard_hierarchical_works_with_only_one_variable_in_x
filter_ard_hierarchical 💀 $0.00$ $-0.00$ filter_ard_hierarchical_works_with_overall_data
selectors 👶 $+0.05$ unnamed
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_sort_alphanumeric_works
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_sort_descending_works
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_sort_works_with_different_sorting_methods_for_each_variable
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_warning_messaging_works
sort_ard_hierarchical 💀 $0.38$ $-0.38$ sort_ard_hierarchical_works
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_works_when_some_variables_not_included_in_x
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_works_when_sorting_using_p_instead_of_n
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_works_when_there_is_no_overall_row_in_x
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_works_with_only_one_variable_in_x
sort_ard_hierarchical 💀 $0.00$ $-0.00$ sort_ard_hierarchical_works_with_overall_data

Results for commit 53638a2

♻️ This comment has been updated with latest results.

ddsjoberg and others added 4 commits July 31, 2026 09:24
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>
@ddsjoberg
ddsjoberg merged commit 2e637fb into main Jul 31, 2026
34 of 35 checks passed
@ddsjoberg
ddsjoberg deleted the perf/stack-hierarchical-176 branch July 31, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant