Skip to content

Faster majority consensus & RenumberTips; Jansson evaluated (not implemented)#274

Merged
ms609 merged 11 commits into
mainfrom
consensus-perf
Jun 3, 2026
Merged

Faster majority consensus & RenumberTips; Jansson evaluated (not implemented)#274
ms609 merged 11 commits into
mainfrom
consensus-perf

Conversation

@ms609

@ms609 ms609 commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

Profiling-driven performance work on the consensus path, plus a data-backed
decision on Jansson's deterministic algorithm. Three independent pieces.

Note: this branch sits on top of the local-only commit exact→hash, which was
not yet on origin/main, so it appears in this PR's diff. (The only origin
divergence, a codemeta.json tweak, does not conflict.)

Three pieces:

  1. Defer split materialisation in the hashed consensus counter. Consensus()
    and SplitFrequency() built every distinct split's packed bit pattern eagerly
    on first sighting; at high tip counts most distinct splits never reach the
    threshold, so that work was wasted. Each distinct split now keeps a 12-byte
    (tree, L, R) witness and the pattern is built only for splits that survive
    thresholding (consensus) or for all splits (SplitFrequency). Results
    unchanged; up to ~13× faster for large/tall trees, median 1.23×.

  2. Faster RenumberTips() for unlabelled forests. An unlabelled multiPhylo
    / list was relabelled tree-by-tree in R; it is now relabelled in a single
    C++ pass (renumber_tips_to), with a no-op fast path for trees already in the
    target order. This was 54% of Consensus() wall time at high-k/low-n.
    Consensus() and every other RenumberTips() caller share the win.
    1.8–9× faster (9× at 5000 trees × 30 tips: 0.335 s → 0.037 s).
    The C++ helper returns NULL to fall back to the exact previous R path for
    anything it does not handle identically (numeric tipOrder, differing label
    sets, non-phylo elements), so behaviour is preserved on edge cases.

  3. Jansson's deterministic O(kn) majority algorithm — evaluated, not
    implemented.
    The headline ask. Using a rigorous lower bound (Jansson ≥
    2×strict-path), Jansson is proven to lose to the optimised hashed counter in
    every realistic, time-meaningful regime (high-k/low-n; high-k/high-n at
    concordant and moderate conflict; most low-k/high-n). The only cells where it
    is not proven to lose are sub-60 ms or degenerate near-star consensuses
    (≈ independent random trees — not a real majority-consensus input). Combined
    with the high implementation/correctness risk (the authors' own reference
    impl, FACT, ships broken majority code) and the project's "avoid redundant
    code" preference, it is not implemented. Full rationale, data, and the exact
    algorithm to build it on request: dev/profiling/DECISION.md.

On telling label-match "without looking"

The structural answer: a labelled multiPhylo (one carrying a TipLabel
attribute) promises every tree shares that label vector, so RenumberTips()
already skips per-tree inspection and uses a single shared permutation. Without
that attribute there is no free lunch — each tree's labels must be inspected —
but that inspection (and the relabel) now happens in C++ rather than a per-tree
R loop, and a tree already in order is detected and passed through untouched.

Verification

  • dev/profiling/correctness-gate.R: 590 checks — hashed == exact consensus
    split sets up to n=3000, SplitFrequency split sets and counts at
    n=2000/5000, ape agrees at p=0.5.
  • New test: RenumberTips() unlabelled C++ path is identical (edges, labels,
    order) to the per-tree path across mixed orderings.
  • Full test suite green; dev/red-team/verify-consensus.R green (also fixed: it
    had been calling a non-existent hash= arg and so had not run).

🤖 Generated with Claude Code

ms609 and others added 4 commits June 2, 2026 19:47
consensus_tree()'s third argument is 'exact', not 'hash'; the script called consensus_tree(forest, p, hash = FALSE), which errored with an unused-argument error and so had not run since the C++ export was renamed. Use exact = TRUE; the script is now green (0 failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Majority/threshold Consensus() and SplitFrequency() built every distinct split's packed bit pattern eagerly on first sighting. At high tip counts most distinct splits never reach the consensus threshold, so that work was wasted. Each distinct split now keeps a 12-byte (tree, L, R) witness; the pattern is materialised only for splits that survive thresholding (consensus) or for all splits (SplitFrequency).

Results are unchanged: split sets/counts identical to the deterministic exact path, verified at n up to 5000 (dev/profiling/correctness-gate.R, 590 checks; test-consensus 8/8; test-Support 6/6). Up to ~13x faster for large/tall trees, median 1.23x, no change at small sizes.

Adds dev/profiling/ harness (correctness gate, benchmark grid, Jansson lower-bound analysis + DECISION.md explaining why the full deterministic Jansson algorithm is not implemented).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RenumberTips() on an unlabelled multiPhylo or list previously relabelled each tree with a separate R call (RenumberTips.phylo per element). It now relabels the whole forest in a single C++ pass (renumber_tips_to), deriving each tree's permutation from one hash of the target labels, with a no-op pass-through for trees already in target order.

The C++ helper returns NULL to fall back to the existing per-tree R path for anything it would not handle identically: numeric tipOrder (per-tree target), a label set differing from the target (different length, unknown/NA label, or non-bijection), duplicate/NA target labels, or non-phylo list elements. List names are preserved. Results are unchanged.

1.8-9x faster (9x at high-k/low-n: 5000 trees of 30 tips, 0.335s -> 0.037s); Consensus() and every other RenumberTips() caller share the win. Full test suite green; new equivalence test confirms the C++ path matches the per-tree path across mixed orderings.

Telling label-match without looking: only a labelled multiPhylo (TipLabel attribute) guarantees a shared order without inspecting each tree; absent that, inspection is unavoidable but now done in C++.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
as.Splits(bigTrees) ⚪ NSD -0.53% 31.3 →
30.4, 37.9
as.Splits(someTrees) ⚪ NSD -1.15% 10.4 →
10.5, 10.6
Consensus(forest1k.888, check = FALSE) ⚪ NSD -1.7% 91.8 →
92.8, 94.4
Consensus(forest201.80, check = FALSE) ⚪ NSD -0.11% 3.69 →
3.63, 3.72
Consensus(forest21.260, 0.5, FALSE) ⚪ NSD 1.48% 1.14 →
1.11, 1.13
Consensus(forest21.260) ⚪ NSD -6.96% 1.12 →
1.19, 1.21
Consensus(forestMaj, 0.5, FALSE) ⚪ NSD 0.84% 2.88 →
2.81, 2.88
DropTip(tr2000, 5) ⚪ NSD 0.51% 16.3 →
16.1, 16.3
DropTip(tr80, 5) ⚪ NSD 0.08% 0.0854 →
0.0857, 0.0847
DropTip(unlen2k, 5) ⚪ NSD -0.89% 0.178 →
0.179, 0.179
DropTip(unlen80, 5) ⚪ NSD 0.91% 0.0363 →
0.0358, 0.0362
lapply(bigSplits, as.phylo) 🟣 ~same 1.06% 28.9 →
28.6, 28.6
lapply(someSplits, as.phylo) ⚪ NSD 1.46% 13.3 →
13.1, 13.2
PathLengths(tr2000, full = TRUE) ⚪ NSD -0.69% 15.6 →
15.5, 15.9
PathLengths(tr80, full = TRUE) ⚪ NSD 0.03% 0.0975 →
0.0968, 0.0982
PathLengths(tr80Unif, full = TRUE) ⚪ NSD 1.21% 0.101 →
0.0986, 0.101
RootTree(tr2000, 5) ⚪ NSD 0.57% 0.34 →
0.337, 0.339
RootTree(tr80, c("t3", "t36")) ⚪ NSD -0.75% 0.0614 →
0.0613, 0.0623
RootTree(tr80, "t3") ⚪ NSD -0.32% 0.0444 →
0.0448, 0.0444
RootTree(tr80, "t30") ⚪ NSD -1.09% 0.0441 →
0.0446, 0.0446
RootTree(unlen2k, 5) ⚪ NSD -0.51% 0.31 →
0.311, 0.311
RootTree(unlen80, c("t3", "t36")) ⚪ NSD -1.06% 0.0566 →
0.0573, 0.057
RootTree(unlen80, "t3") ⚪ NSD -0.75% 0.0388 →
0.0393, 0.0388
RootTree(unlen80, "t30") ⚪ NSD -1.56% 0.0391 →
0.0398, 0.0395
TreeDist::RobinsonFoulds(forest201.80) ⚪ NSD 0.16% 16.6 →
16.4, 16.7
TreeDist::RobinsonFoulds(forest21.888) ⚪ NSD -0.68% 3.18 →
3.16, 3.23
TreeTools:::path_lengths(tr80$edge, tr80$edge.length, FALSE) ⚪ NSD -0.26% 0.0892 →
0.0883, 0.0905
TreeTools:::postorder_order(bal40) ⚪ NSD -0.06% 0.00162 →
0.00162, 0.00162
TreeTools:::postorder_order(bal40k) ⚪ NSD 2.54% 0.54 →
0.524, 0.528
TreeTools:::postorder_order(dbal40) ⚪ NSD -1.82% 0.00164 →
0.00166, 0.00167
TreeTools:::postorder_order(dbal40k) ⚪ NSD 3.03% 2.21 →
2.15, 2.12
TreeTools:::postorder_order(dpec40) ⚪ NSD 11.47% 0.0029 →
0.00257, 0.00254
TreeTools:::postorder_order(dpec40k) 🟢 Faster! 7.19% 3100 →
2880, 2880
TreeTools:::postorder_order(drnd80) ⚪ NSD 0% 0.00389 →
0.00392, 0.00385
TreeTools:::postorder_order(nbal40) ⚪ NSD -1.01% 0.00198 →
0.00198, 0.00202
TreeTools:::postorder_order(nbal40k) ⚪ NSD 1.32% 2.33 →
2.29, 2.31
TreeTools:::postorder_order(npec40) ⚪ NSD 16.91% 0.00344 →
0.00287, 0.00283
TreeTools:::postorder_order(npec40k) 🟢 Faster! 7.39% 3120 →
2890, 2900
TreeTools:::postorder_order(nrnd80) ⚪ NSD 1.74% 0.00462 →
0.00455, 0.00452
TreeTools:::postorder_order(pec40) ⚪ NSD -2.46% 0.00158 →
0.00163, 0.00161
TreeTools:::postorder_order(pec40k) ⚪ NSD 30.09% 0.532 →
0.371, 0.373
TreeTools:::postorder_order(rnd80) ⚪ NSD 0.99% 0.00202 →
0.00201, 0.00199

@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.24%. Comparing base (488de78) to head (07c9ee2).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #274      +/-   ##
==========================================
+ Coverage   96.18%   96.24%   +0.05%     
==========================================
  Files          81       81              
  Lines        6035     6120      +85     
==========================================
+ Hits         5805     5890      +85     
  Misses        230      230              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ms609 and others added 2 commits June 3, 2026 09:44
Consensus() stripped edge.length/node.label from every input tree via a
per-tree lapply that forced a copy of each tree. The consensus core reads
only edge + tip.label and the output tree is rebuilt from scratch, so neither
field can affect the result; the strip's only load-bearing job was coercing a
labelled multiPhylo's shared tip labels onto each tree, which bare c() already
does. Replace the strip with 'trees <- c(trees)' (~7x cheaper than the strip:
0.82->0.11 ms at k=201), cutting wrapper overhead ~25% on small forests of many
short trees (forest201.80 3.38->2.54 ms), ~6% on forest1k.888 (strict C++ core
dominates there).

Output verified byte-identical: 108-cell before/after grid (plain /
unaligned / edge.length / node.label / both / labelled / labelled+edge.length /
list / differing-size x p x check.labels x hash) and a 36-cell core
metadata-independence check. New test-Consensus.R block covers the previously
untested metadata-bearing and labelled-multiPhylo paths. Full suite 4192 PASS;
correctness gate 590 checks; verify-consensus green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The KeepTip repeat-loop (triggered by trees of unequal size) was the one
combination not covered by the C-004 metadata-invariance test. Add a case
confirming edge.length does not change the (warned) result there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
as.Splits(bigTrees) ⚪ NSD -0.8% 22.6 →
22.8, 21.5
as.Splits(someTrees) ⚪ NSD 0.27% 8.26 →
8.24, 8.24
Consensus(forest1k.888, check = FALSE) ⚪ NSD 3.56% 73.1 →
70.5, 70.6
Consensus(forest201.80, check = FALSE) 🟢 Faster! 22.04% 2.89 →
2.27, 2.24
Consensus(forest21.260, 0.5, FALSE) ⚪ NSD 8.74% 0.887 →
0.814, 0.804
Consensus(forest21.260) ⚪ NSD 0.29% 0.876 →
0.877, 0.871
Consensus(forestMaj, 0.5, FALSE) 🟢 Faster! 16.41% 2.25 →
1.9, 1.85
DropTip(tr2000, 5) ⚪ NSD -0.05% 13.3 →
13.3, 13.2
DropTip(tr80, 5) ⚪ NSD -0.85% 0.067 →
0.0683, 0.0667
DropTip(unlen2k, 5) ⚪ NSD -0.1% 0.138 →
0.137, 0.139
DropTip(unlen80, 5) ⚪ NSD -0.32% 0.0281 →
0.0278, 0.0284
lapply(bigSplits, as.phylo) 🟣 ~same -1.09% 22.4 →
22.6, 22.6
lapply(someSplits, as.phylo) ⚪ NSD 0.03% 10.2 →
10.1, 10.3
PathLengths(tr2000, full = TRUE) ⚪ NSD 0.66% 12.8 →
12.7, 12.7
PathLengths(tr80, full = TRUE) ⚪ NSD -0.65% 0.0761 →
0.0756, 0.0779
PathLengths(tr80Unif, full = TRUE) ⚪ NSD -0.74% 0.0776 →
0.0771, 0.0796
RootTree(tr2000, 5) ⚪ NSD 0.69% 0.27 →
0.269, 0.266
RootTree(tr80, c("t3", "t36")) ⚪ NSD 1.26% 0.0487 →
0.0482, 0.0479
RootTree(tr80, "t3") ⚪ NSD 0.2% 0.0351 →
0.0351, 0.0349
RootTree(tr80, "t30") ⚪ NSD 0.57% 0.0349 →
0.0348, 0.0347
RootTree(unlen2k, 5) ⚪ NSD 0.55% 0.23 →
0.229, 0.229
RootTree(unlen80, c("t3", "t36")) ⚪ NSD 1.73% 0.045 →
0.0445, 0.0439
RootTree(unlen80, "t3") ⚪ NSD 0.33% 0.0303 →
0.03, 0.0304
RootTree(unlen80, "t30") ⚪ NSD 0.65% 0.0306 →
0.0302, 0.0306
TreeDist::RobinsonFoulds(forest201.80) ⚪ NSD 0.24% 12.8 →
12.8, 12.9
TreeDist::RobinsonFoulds(forest21.888) ⚪ NSD -0.29% 2.47 →
2.46, 2.49
TreeTools:::path_lengths(tr80$edge, tr80$edge.length, FALSE) ⚪ NSD 2.42% 0.0725 →
0.0695, 0.0728
TreeTools:::postorder_order(bal40) ⚪ NSD 0.79% 0.00125 →
0.00124, 0.00125
TreeTools:::postorder_order(bal40k) ⚪ NSD 0.36% 0.413 →
0.411, 0.413
TreeTools:::postorder_order(dbal40) ⚪ NSD 1.53% 0.00131 →
0.0013, 0.00128
TreeTools:::postorder_order(dbal40k) ⚪ NSD 1.75% 1.8 →
1.76, 1.77
TreeTools:::postorder_order(dpec40) ⚪ NSD -0.95% 0.00199 →
0.00201, 0.00201
TreeTools:::postorder_order(dpec40k) 🟢 Faster! 6.75% 2400 →
2240, 2240
TreeTools:::postorder_order(drnd80) ⚪ NSD -3.64% 0.00303 →
0.00459, 0.00302
TreeTools:::postorder_order(nbal40) ⚪ NSD 0.63% 0.00158 →
0.00157, 0.00157
TreeTools:::postorder_order(nbal40k) ⚪ NSD 2.26% 1.84 →
1.8, 1.79
TreeTools:::postorder_order(npec40) ⚪ NSD -1.4% 0.00221 →
0.00226, 0.00222
TreeTools:::postorder_order(npec40k) 🟢 Faster! 7.39% 2420 →
2240, 2240
TreeTools:::postorder_order(nrnd80) ⚪ NSD -2.62% 0.00344 →
0.00352, 0.00354
TreeTools:::postorder_order(pec40) ⚪ NSD 2.33% 0.00128 →
0.00126, 0.00125
TreeTools:::postorder_order(pec40k) ⚪ NSD 24.62% 0.412 →
0.309, 0.311
TreeTools:::postorder_order(rnd80) ⚪ NSD 0.05% 0.00158 →
0.00158, 0.00159

Repository owner deleted a comment from github-actions Bot Jun 3, 2026
The even-n tie fixture (2xBalancedTree + 2xPectinateTree, n=4) was only
exercised by the "exact == hashed" test, which checks the two internal
counters against each other -- it cannot catch an error shared by both.
Add ApeTest(tie, 0.5): at p=0.5 a split in exactly n/2 trees is dropped
(thresh = floor(n/2)+1), so the two conflicting 50% splits are both
dropped, matching ape::consensus. Pins the tie boundary to an oracle.

Separately, findings.md records an open question for the maintainer: for
p > 0.5 the code keeps count >= floor(n*p)+1 (strictly more than
floor(n*p)), whereas the docstring promises "p or more" and ape keeps
count >= p*n. They diverge only when n*p is an exact integer (e.g. n=3,
p=2/3: a clade in 2/3 of trees is kept by ape, dropped here). Both are
safe; the convention/doc-consistency call is the maintainer's. No
threshold change made. Repro: dev/profiling/drivers/tie-check.R.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
as.Splits(bigTrees) ⚪ NSD -2.2% 29.5 →
29.6, 31.5
as.Splits(someTrees) ⚪ NSD 0.05% 9.97 →
9.97, 9.96
Consensus(forest1k.888, check = FALSE) 🟣 ~same 5.75% 84.6 →
79.2, 81.1
Consensus(forest201.80, check = FALSE) 🟢 Faster! 20.59% 3.52 →
2.79, 2.8
Consensus(forest21.260, 0.5, FALSE) ⚪ NSD 9.65% 1.02 →
0.916, 0.924
Consensus(forest21.260) ⚪ NSD -0.94% 1.07 →
1.08, 1.08
Consensus(forestMaj, 0.5, FALSE) 🟢 Faster! 17.15% 2.65 →
2.18, 2.21
DropTip(tr2000, 5) ⚪ NSD 2.11% 26.9 →
26.3, 26.4
DropTip(tr80, 5) ⚪ NSD 0.73% 0.0909 →
0.0902, 0.0902
DropTip(unlen2k, 5) ⚪ NSD -3.81% 0.175 →
0.182, 0.182
DropTip(unlen80, 5) ⚪ NSD -0.28% 0.0341 →
0.0341, 0.0343
lapply(bigSplits, as.phylo) 🟣 ~same -4.73% 27.1 →
28.5, 28.3
lapply(someSplits, as.phylo) ⚪ NSD -0.35% 11.6 →
11.6, 11.7
PathLengths(tr2000, full = TRUE) ⚪ NSD 2.68% 25.8 →
24.1, 25.7
PathLengths(tr80, full = TRUE) ⚪ NSD 1.14% 0.0764 →
0.0751, 0.076
PathLengths(tr80Unif, full = TRUE) ⚪ NSD 1.36% 0.0787 →
0.0769, 0.0786
RootTree(tr2000, 5) ⚪ NSD -8.12% 0.301 →
0.325, 0.327
RootTree(tr80, c("t3", "t36")) ⚪ NSD -0.19% 0.0579 →
0.0578, 0.0582
RootTree(tr80, "t3") ⚪ NSD -0.14% 0.0406 →
0.0405, 0.0407
RootTree(tr80, "t30") ⚪ NSD 0.69% 0.0407 →
0.0404, 0.0405
RootTree(unlen2k, 5) ⚪ NSD -4.99% 0.293 →
0.307, 0.309
RootTree(unlen80, c("t3", "t36")) ⚪ NSD -0.85% 0.0534 →
0.0536, 0.054
RootTree(unlen80, "t3") ⚪ NSD -0.22% 0.0358 →
0.0359, 0.0359
RootTree(unlen80, "t30") ⚪ NSD -0.21% 0.0359 →
0.0359, 0.036
TreeDist::RobinsonFoulds(forest201.80) ⚪ NSD -1.24% 16.3 →
16.7, 16.5
TreeDist::RobinsonFoulds(forest21.888) ⚪ NSD 0.74% 3.13 →
3.1, 3.12
TreeTools:::path_lengths(tr80$edge, tr80$edge.length, FALSE) ⚪ NSD 1.33% 0.0705 →
0.0692, 0.0699
TreeTools:::postorder_order(bal40) ⚪ NSD 0.79% 0.00139 →
0.00139, 0.00136
TreeTools:::postorder_order(bal40k) ⚪ NSD 1.09% 0.416 →
0.412, 0.41
TreeTools:::postorder_order(dbal40) ⚪ NSD 2.41% 0.00158 →
0.00154, 0.00154
TreeTools:::postorder_order(dbal40k) ⚪ NSD 1.16% 2.29 →
2.27, 2.27
TreeTools:::postorder_order(dpec40) ⚪ NSD -0.17% 0.00238 →
0.00238, 0.00239
TreeTools:::postorder_order(dpec40k) 🟣 ~same 0.66% 4440 →
4400, 4410
TreeTools:::postorder_order(drnd80) ⚪ NSD -0.96% 0.00387 →
0.0039, 0.00393
TreeTools:::postorder_order(nbal40) ⚪ NSD 2.01% 0.00194 →
0.00189, 0.0019
TreeTools:::postorder_order(nbal40k) ⚪ NSD 3.68% 2.52 →
2.42, 2.43
TreeTools:::postorder_order(npec40) ⚪ NSD -0.13% 0.00269 →
0.0027, 0.00269
TreeTools:::postorder_order(npec40k) 🟣 ~same 0.48% 4430 →
4410, 4420
TreeTools:::postorder_order(nrnd80) ⚪ NSD -0.77% 0.00457 →
0.00459, 0.00466
TreeTools:::postorder_order(pec40) ⚪ NSD 1.49% 0.0014 →
0.00139, 0.00137
TreeTools:::postorder_order(pec40k) ⚪ NSD -2.3% 0.316 →
0.411, 0.315
TreeTools:::postorder_order(rnd80) ⚪ NSD -1.46% 0.00165 →
0.00165, 0.00169

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
as.Splits(bigTrees) ⚪ NSD -1.27% 30.9 →
31.2, 32.1
as.Splits(someTrees) ⚪ NSD 0.92% 10.9 →
10.8, 10.7
Consensus(forest1k.888, check = FALSE) ⚪ NSD 4.85% 95.8 →
91.1, 91.1
Consensus(forest201.80, check = FALSE) 🟢 Faster! 22.37% 3.79 →
2.96, 2.92
Consensus(forest21.260, 0.5, FALSE) ⚪ NSD 8.01% 1.14 →
1.05, 1.05
Consensus(forest21.260) ⚪ NSD 0.18% 1.13 →
1.13, 1.14
Consensus(forestMaj, 0.5, FALSE) 🟢 Faster! 15.39% 2.9 →
2.46, 2.45
DropTip(tr2000, 5) ⚪ NSD 0.94% 16.7 →
16.5, 16.5
DropTip(tr80, 5) ⚪ NSD 0.2% 0.0868 →
0.0871, 0.0862
DropTip(unlen2k, 5) ⚪ NSD -2.81% 0.178 →
0.186, 0.18
DropTip(unlen80, 5) ⚪ NSD -0.16% 0.0364 →
0.0367, 0.0362
lapply(bigSplits, as.phylo) ⚪ NSD 0.48% 29.3 →
28.9, 29.3
lapply(someSplits, as.phylo) ⚪ NSD 2.6% 13.9 →
13.5, 13.5
PathLengths(tr2000, full = TRUE) ⚪ NSD 1.08% 15.9 →
15.6, 15.8
PathLengths(tr80, full = TRUE) ⚪ NSD 0.87% 0.0998 →
0.0987, 0.0992
PathLengths(tr80Unif, full = TRUE) ⚪ NSD 0.38% 0.102 →
0.101, 0.102
RootTree(tr2000, 5) ⚪ NSD -0.13% 0.344 →
0.344, 0.344
RootTree(tr80, c("t3", "t36")) ⚪ NSD -1.65% 0.0627 →
0.0639, 0.0635
RootTree(tr80, "t3") ⚪ NSD -3.98% 0.045 →
0.0471, 0.0465
RootTree(tr80, "t30") ⚪ NSD -3.96% 0.0448 →
0.0468, 0.0464
RootTree(unlen2k, 5) ⚪ NSD -0.49% 0.296 →
0.297, 0.298
RootTree(unlen80, c("t3", "t36")) ⚪ NSD -1.19% 0.058 →
0.0587, 0.0586
RootTree(unlen80, "t3") ⚪ NSD -3.92% 0.0388 →
0.0407, 0.04
RootTree(unlen80, "t30") ⚪ NSD -3.8% 0.0393 →
0.0411, 0.0404
TreeDist::RobinsonFoulds(forest201.80) ⚪ NSD 0.55% 17.4 →
17.2, 17.4
TreeDist::RobinsonFoulds(forest21.888) ⚪ NSD -1.43% 3.2 →
3.21, 3.27
TreeTools:::path_lengths(tr80$edge, tr80$edge.length, FALSE) ⚪ NSD 0.64% 0.0909 →
0.0901, 0.0906
TreeTools:::postorder_order(bal40) ⚪ NSD -1.32% 0.0016 →
0.00164, 0.00161
TreeTools:::postorder_order(bal40k) ⚪ NSD -0.74% 0.526 →
0.529, 0.531
TreeTools:::postorder_order(dbal40) ⚪ NSD -0.61% 0.00167 →
0.00169, 0.00168
TreeTools:::postorder_order(dbal40k) ⚪ NSD 1.23% 2.15 →
2.12, 2.13
TreeTools:::postorder_order(dpec40) ⚪ NSD 0.39% 0.00258 →
0.00258, 0.00255
TreeTools:::postorder_order(dpec40k) 🟢 Faster! 7.35% 3110 →
2890, 2880
TreeTools:::postorder_order(drnd80) ⚪ NSD -2.3% 0.00392 →
0.0041, 0.00393
TreeTools:::postorder_order(nbal40) ⚪ NSD -0.51% 0.002 →
0.00201, 0.00201
TreeTools:::postorder_order(nbal40k) ⚪ NSD 1.35% 2.33 →
2.3, 2.3
TreeTools:::postorder_order(npec40) ⚪ NSD -0.7% 0.00284 →
0.00286, 0.00286
TreeTools:::postorder_order(npec40k) 🟣 ~same 3.19% 2950 →
2830, 2890
TreeTools:::postorder_order(nrnd80) ⚪ NSD -3.88% 0.00439 →
0.00458, 0.00455
TreeTools:::postorder_order(pec40) ⚪ NSD -0.06% 0.00161 →
0.00164, 0.00159
TreeTools:::postorder_order(pec40k) ⚪ NSD 27.73% 0.528 →
0.537, 0.374
TreeTools:::postorder_order(rnd80) ⚪ NSD 1.43% 0.00209 →
0.00208, 0.00205

Consensus(trees, p) documented that it retains splits in "a proportion p
or more" of trees, but the threshold was floor(n*p)+1 -- one tree too
strict, so a split in exactly p*n trees was dropped at integer
thresholds (e.g. a split in 2 of 3 trees with p = 2/3). ape::consensus
keeps it (count >= p*n).

Change the C++ threshold to thresh = max(floor(n/2)+1, ceil(p*n)), with a
relative tolerance (1e-9*pn) snapping p*n to an exact integer through
floating-point noise -- robust where ape's raw `bs >= p*ntree` is
fragile. p = 0.5 is unchanged: the floor(n/2)+1 term keeps it strict
(a split must occur in MORE than half the trees) so two conflicting 50%
splits can never both be retained and the result stays a valid tree.

Pin the boundary with ApeTest(thirds, 2/3) (clade in exactly 2/3 of
trees). Update docstring/Rd and NEWS. Gate 590 PASS / 0 ape
disagreements; full suite 4195 PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
as.Splits(bigTrees) ⚪ NSD -6.67% 22.9 →
25.7, 22.9
as.Splits(someTrees) ⚪ NSD -1.65% 11.2 →
11.4, 11.3
Consensus(forest1k.888, check = FALSE) 🟢 Faster! 8.11% 98.7 →
90.7, 91
Consensus(forest201.80, check = FALSE) 🟢 Faster! 22.33% 3.66 →
2.83, 2.85
Consensus(forest21.260, 0.5, FALSE) ⚪ NSD 9.73% 1.18 →
1.07, 1.07
Consensus(forest21.260) ⚪ NSD 0.34% 1.19 →
1.19, 1.19
Consensus(forestMaj, 0.5, FALSE) 🟢 Faster! 17.48% 2.92 →
2.42, 2.41
DropTip(tr2000, 5) 🟣 ~same 1.92% 16.8 →
16.5, 16.6
DropTip(tr80, 5) ⚪ NSD -0.36% 0.103 →
0.105, 0.102
DropTip(unlen2k, 5) ⚪ NSD -0.96% 0.204 →
0.21, 0.202
DropTip(unlen80, 5) ⚪ NSD 0.17% 0.0403 →
0.0407, 0.0398
lapply(bigSplits, as.phylo) 🟣 ~same -2.53% 29 →
30, 29.6
lapply(someSplits, as.phylo) ⚪ NSD -2.92% 13.5 →
14.2, 13.7
PathLengths(tr2000, full = TRUE) ⚪ NSD -1.09% 16.3 →
15.8, 17.2
PathLengths(tr80, full = TRUE) ⚪ NSD -0.07% 0.0994 →
0.1, 0.099
PathLengths(tr80Unif, full = TRUE) ⚪ NSD -0.52% 0.102 →
0.103, 0.102
RootTree(tr2000, 5) ⚪ NSD 2.85% 0.398 →
0.383, 0.389
RootTree(tr80, c("t3", "t36")) ⚪ NSD 1.25% 0.072 →
0.0714, 0.0709
RootTree(tr80, "t3") ⚪ NSD 1.87% 0.0514 →
0.0498, 0.0509
RootTree(tr80, "t30") ⚪ NSD 2.15% 0.0512 →
0.0498, 0.0504
RootTree(unlen2k, 5) ⚪ NSD -4.34% 0.327 →
0.345, 0.331
RootTree(unlen80, c("t3", "t36")) ⚪ NSD 0.23% 0.0659 →
0.0663, 0.0652
RootTree(unlen80, "t3") ⚪ NSD -0.92% 0.0436 →
0.0442, 0.0437
RootTree(unlen80, "t30") ⚪ NSD -0.91% 0.044 →
0.0446, 0.0441
TreeDist::RobinsonFoulds(forest201.80) ⚪ NSD -0.56% 16.1 →
16.4, 16.1
TreeDist::RobinsonFoulds(forest21.888) ⚪ NSD 0.25% 3.44 →
3.43, 3.42
TreeTools:::path_lengths(tr80$edge, tr80$edge.length, FALSE) ⚪ NSD -1.83% 0.0895 →
0.0913, 0.091
TreeTools:::postorder_order(bal40) ⚪ NSD -1.35% 0.00163 →
0.00164, 0.00166
TreeTools:::postorder_order(bal40k) ⚪ NSD 0.7% 0.544 →
0.539, 0.544
TreeTools:::postorder_order(dbal40) ⚪ NSD -4.73% 0.00171 →
0.00177, 0.00181
TreeTools:::postorder_order(dbal40k) ⚪ NSD -9.01% 2.11 →
2.28, 2.36
TreeTools:::postorder_order(dpec40) ⚪ NSD -19.04% 0.00253 →
0.00299, 0.00302
TreeTools:::postorder_order(dpec40k) 🟠 Slower 🙁 -6.67% 3390 →
3610, 3610
TreeTools:::postorder_order(drnd80) ⚪ NSD -28.99% 0.00398 →
0.0051, 0.00515
TreeTools:::postorder_order(nbal40) ⚪ NSD -3.91% 0.00204 →
0.0021, 0.00213
TreeTools:::postorder_order(nbal40k) ⚪ NSD -10.47% 2.21 →
2.42, 2.44
TreeTools:::postorder_order(npec40) ⚪ NSD -17.04% 0.00282 →
0.00329, 0.00331
TreeTools:::postorder_order(npec40k) 🟣 ~same -5.58% 3430 →
3620, 3620
TreeTools:::postorder_order(nrnd80) ⚪ NSD -24.72% 0.00454 →
0.00566, 0.00566
TreeTools:::postorder_order(pec40) ⚪ NSD -1.22% 0.00164 →
0.00165, 0.00167
TreeTools:::postorder_order(pec40k) ⚪ NSD -0.22% 0.43 →
0.433, 0.428
TreeTools:::postorder_order(rnd80) ⚪ NSD -3.92% 0.00205 →
0.00211, 0.00214

The mem-check (valgrind) job stalls for many minutes in test-consensus.R:
the 100k-leaf heap-limit build, the 33k-tip consensus, and two 33k-tree
consensus runs dominate runtime under valgrind's ~30x slowdown while
exercising no memory path the smaller cases miss. Gate them behind a new
TESTING_MEMCHECK env var (set in memcheck.yml).

TESTING_MEMCHECK is deliberately separate from USING_ASAN: the latter
suppresses deliberate-bad-input error tests that ASan aborts on but valgrind
tolerates, so setting USING_ASAN here would silently disable those error-path
tests under valgrind. Full-scale consensus tests still run in uninstrumented CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

Performance benchmark results

Call Status Change Time (ms)
as.Splits(bigTrees) 🟢 Faster! 17.76% 37.1 →
30.3, 31.1
as.Splits(someTrees) ⚪ NSD -1.63% 10.4 →
10.5, 10.5
Consensus(forest1k.888, check = FALSE) 🟣 ~same 3.82% 90.5 →
87.3, 86.4
Consensus(forest201.80, check = FALSE) 🟢 Faster! 19.28% 3.62 →
2.93, 2.91
Consensus(forest21.260, 0.5, FALSE) ⚪ NSD 8.14% 1.13 →
1.04, 1.04
Consensus(forest21.260) ⚪ NSD -0.02% 1.12 →
1.12, 1.11
Consensus(forestMaj, 0.5, FALSE) 🟢 Faster! 15.24% 2.81 →
2.4, 2.36
DropTip(tr2000, 5) 🟣 ~same 2.28% 15.8 →
15.5, 15.5
DropTip(tr80, 5) ⚪ NSD 0.07% 0.0831 →
0.0833, 0.0828
DropTip(unlen2k, 5) ⚪ NSD 32.69% 0.272 →
0.187, 0.178
DropTip(unlen80, 5) ⚪ NSD -1.22% 0.0352 →
0.0358, 0.0355
lapply(bigSplits, as.phylo) ⚪ NSD 0.17% 28.5 →
28.5, 28.4
lapply(someSplits, as.phylo) ⚪ NSD 0.26% 13 →
12.9, 13
PathLengths(tr2000, full = TRUE) ⚪ NSD -0.07% 14.7 →
14.6, 14.8
PathLengths(tr80, full = TRUE) ⚪ NSD -1.6% 0.0938 →
0.0946, 0.096
PathLengths(tr80Unif, full = TRUE) ⚪ NSD -1.27% 0.096 →
0.0965, 0.0979
RootTree(tr2000, 5) ⚪ NSD 1.4% 0.339 →
0.334, 0.334
RootTree(tr80, c("t3", "t36")) ⚪ NSD -2.54% 0.0595 →
0.0596, 0.0622
RootTree(tr80, "t3") ⚪ NSD -3.35% 0.0427 →
0.0433, 0.0448
RootTree(tr80, "t30") ⚪ NSD -3.67% 0.0428 →
0.0434, 0.0452
RootTree(unlen2k, 5) ⚪ NSD 0.48% 0.311 →
0.311, 0.308
RootTree(unlen80, c("t3", "t36")) ⚪ NSD -2.58% 0.0549 →
0.055, 0.0573
RootTree(unlen80, "t3") ⚪ NSD -3.9% 0.038 →
0.0382, 0.0403
RootTree(unlen80, "t30") ⚪ NSD -3.73% 0.0384 →
0.0385, 0.0407
TreeDist::RobinsonFoulds(forest201.80) ⚪ NSD -0.78% 16 →
16.2, 16.2
TreeDist::RobinsonFoulds(forest21.888) ⚪ NSD 3.07% 3.22 →
3.12, 3.12
TreeTools:::path_lengths(tr80$edge, tr80$edge.length, FALSE) ⚪ NSD -0.21% 0.0865 →
0.0863, 0.0872
TreeTools:::postorder_order(bal40) ⚪ NSD 1.86% 0.0016 →
0.00157, 0.00156
TreeTools:::postorder_order(bal40k) ⚪ NSD 0.2% 0.526 →
0.524, 0.526
TreeTools:::postorder_order(dbal40) ⚪ NSD -1.25% 0.00167 →
0.00168, 0.0017
TreeTools:::postorder_order(dbal40k) 🟠 Slower 🙁 -13.26% 2.15 →
2.43, 2.45
TreeTools:::postorder_order(dpec40) ⚪ NSD -13.6% 0.00257 →
0.00293, 0.00291
TreeTools:::postorder_order(dpec40k) 🟣 ~same -4.85% 3110 →
3260, 3260
TreeTools:::postorder_order(drnd80) ⚪ NSD -23.55% 0.004 →
0.00495, 0.00493
TreeTools:::postorder_order(nbal40) ⚪ NSD 0% 0.00199 →
0.00199, 0.002
TreeTools:::postorder_order(nbal40k) 🟠 Slower 🙁 -12.76% 2.32 →
2.62, 2.62
TreeTools:::postorder_order(npec40) ⚪ NSD -11.56% 0.00285 →
0.00319, 0.00319
TreeTools:::postorder_order(npec40k) 🟣 ~same -5.16% 3110 →
3270, 3260
TreeTools:::postorder_order(nrnd80) ⚪ NSD -24.42% 0.00447 →
0.00555, 0.00557
TreeTools:::postorder_order(pec40) ⚪ NSD 2.43% 0.0016 →
0.00155, 0.00158
TreeTools:::postorder_order(pec40k) ⚪ NSD -2.68% 0.371 →
0.526, 0.374
TreeTools:::postorder_order(rnd80) ⚪ NSD -2.02% 0.00198 →
0.00199, 0.00204

@ms609 ms609 merged commit 74dcf97 into main Jun 3, 2026
35 of 36 checks passed
@ms609 ms609 deleted the consensus-perf branch June 3, 2026 15:04
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