Skip to content

Bucket DFS - #9

Merged
zfergus merged 4 commits into
Continuous-Collision-Detection:mainfrom
iiiian:bucket-dfs
Aug 6, 2026
Merged

Bucket DFS#9
zfergus merged 4 commits into
Continuous-Collision-Detection:mainfrom
iiiian:bucket-dfs

Conversation

@iiiian

@iiiian iiiian commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Verification: Test queries in Scalable CCD dataset with no max iter cap, get identical TOI compared to original. only test armadillo and cloth ball scene due to time constraint.

Benchmark: Generate queries from Scalable CCD dataset using Embree BVH and set max iter to 1e6 per PolyFEM default. All times are in ms.

image

New algorithm:

Observation is that majority of the CPU time is spent on queries with large bisection tree traversal depth. But compared to uv, the final refinement level of t is rather small. So instead of storing intervals in min heap, we make dedicate DFS traversal stack for each t lower bound and stored them in a binary tree (std::map). For each iteration, we always traverse DFS stack with smallest t.

In addition to performance benefits, bucket DFS guarantees better TOI lower bound compared to BFS because it tests intervals in strict chronological order thus achieves deeper refinement under fixed iteration budget.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new “bucket DFS” interval root-finding strategy for CCD, integrates it into the CCD method selection, and makes it the new default root-finding method. It also updates the CLI11 dependency version.

Changes:

  • Add interval_root_finder_bucket_DFS plus edge-edge / vertex-face bucket-DFS entry points.
  • Extend CCD method selection with BUCKET_DEPTH_FIRST_SEARCH and switch the public CCD API defaults to it.
  • Bump CLI11 from 2.3.2 to 2.6.2.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/tight_inclusion/interval_root_finder.hpp Declares the new bucket-DFS root-finding APIs.
src/tight_inclusion/interval_root_finder.cpp Implements the bucket-DFS traversal and adds explicit template instantiations and wrappers.
src/tight_inclusion/ccd.hpp Adds the new enum value and changes default root-finding method to bucket-DFS.
src/tight_inclusion/ccd.cpp Wires the new method into the CCD dispatch switch.
cmake/recipes/cli11.cmake Updates the CLI11 CPM dependency version.
app/main.cpp Switches the sample query runner to use bucket-DFS.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tight_inclusion/interval_root_finder.cpp
Comment thread src/tight_inclusion/interval_root_finder.cpp
Comment thread src/tight_inclusion/ccd.hpp
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@iiiian

iiiian commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

I do not have permission to merge, @zfergus can you do that for me?

@zfergus

zfergus commented Aug 5, 2026

Copy link
Copy Markdown
Member

I benchmarked this branch (e3d280f) against v1.0.6 from IPC Toolkit on a MacBook with an M3 Pro. On the [Wang et al. 2021] query datasets it's a big win:

Dataset Queries v1.0.6 (BFS) this PR
Simulation (chain, cow-heads, golf-ball, mat-twist) 200,000 6.38 µs/query 1.66 µs/query 3.8× faster
Handcrafted (erleben-*, unit-tests) 7,279 3793 µs/query 1352 µs/query 2.8× faster

False positive counts (16, 471) and false negative counts (0, 0) match, and IPC Toolkit's CCD tests pass unchanged.

Full pipeline, five scenes

Standalone queries aren't the whole story for a simulator, so I also ran IPC Toolkit's earliest-ToI narrow phase on frame pairs from five scenes. That's Candidates::compute_collision_free_stepsize, which calls edgeEdgeCCD/vertexFaceCCD on every candidate an LBVH broad phase produces, with tmax set to the earliest ToI found so far. Three interleaved A/B pairs per scene, 20 samples each:

Scene (frames) Candidates Earliest ToI v1.0.6 this PR
Cloth-funnel (227→228) 41,089 0.029 27.1 ms 11.3 ms 2.4× faster
Rod-twist (3036→3037) 2,489,702 2.7e-05 454.1 ms 480.5 ms 1.06× slower
Armadillo-rollers (326→327) 7,100,661 3.1e-05 361.8 ms 392.3 ms 1.08× slower
Cloth-ball (92→93) 6,852,873 4.8e-06 504.6 ms 577.6 ms 1.14× slower
N-body (balls16, 18→19) 31,763,290 0 1421.4 ms 1635.6 ms 1.15× slower

Every scene returns exactly the same step size on both versions, from the same candidate count, so none of this is a difference in what got computed.

Cloth-funnel is the one that behaves like the query datasets, and it's also the only one where the queries are expensive: 660 ns per candidate on v1.0.6, down to 274 ns here. The other four scenes have tiny ToIs and millions of candidates, and they all drift the wrong way by a similar small amount.

The regression looks like a fixed cost per query

Converting each of those four to overhead per candidate:

Scene v1.0.6 ns/candidate added by this PR
Rod-twist 182.4 +10.6 ns
Cloth-ball 73.6 +10.6 ns
N-body 44.7 +6.7 ns
Armadillo-rollers 50.9 +4.3 ns

A few nanoseconds each, roughly constant across scenes that differ by 15× in candidate count and 4× in per-query cost. That's the shape of a fixed setup cost, not anything algorithmic. It's invisible when a query costs 660 ns and it's most of the query when a candidate pair is far apart over the whole motion and gets thrown out on the first origin_in_function_bounding_box_vector call, which is the common case in these scenes.

For what it's worth, the container setup differs by one allocation, which is about the right size:

  • interval_root_finder_BFS uses std::priority_queue<std::pair<Interval3, int>, std::vector<...>>. The first emplace allocates once.
  • interval_root_finder_bucket_DFS uses std::map<NumCCD, std::vector<Interval3>>. stacks[initial[0].lower].push_back(initial) allocates a tree node, then the inner vector's buffer. Twice.

Overall I think this is a good change. 2.8-3.8× on real query sets and 2.4× on cloth-funnel is a much bigger effect than the 1.06-1.15× the other scenes give up, and the crossover seems to sit somewhere above 200 ns per candidate, since rod-twist at 182 ns is still slightly net negative.

@zfergus

zfergus commented Aug 5, 2026

Copy link
Copy Markdown
Member

Follow-up on the per-query overhead above. I tried the smallest change I could think of and it removes the regression on every scene, so it seemed worth sharing rather than just describing.

The idea is to process the root box before touching the heap. A default-constructed std::map doesn't allocate, so a query whose first box is rejected outright never does any heap traffic. Traversal order is unchanged, since inserting the root and immediately popping it back out gives you the same first box:

         std::map<NumCCD, std::vector<Interval3>> stacks;
-        stacks[initial[0].lower].push_back(initial);
+
+        Interval3 current = initial;
+        bool has_current = true;

         long iteration_count = 0;
-        while (!stacks.empty()) {
-            auto bucket = stacks.begin();
-            std::vector<Interval3> &stack = bucket->second;
-            Interval3 current = std::move(stack.back());
-            stack.pop_back();
-            if (stack.empty()) {
-                stacks.erase(bucket);
+        while (has_current || !stacks.empty()) {
+            if (!has_current) {
+                auto bucket = stacks.begin();
+                std::vector<Interval3> &stack = bucket->second;
+                current = std::move(stack.back());
+                stack.pop_back();
+                if (stack.empty()) {
+                    stacks.erase(bucket);
+                }
             }
+            has_current = false;

             ++iteration_count;

The rest of the loop body is untouched. Three-way interleaved sweep, same setup as before, 3 pairs per scene and 20 samples each. All three versions return the same step size on every scene:

Scene v1.0.6 this PR patched patched vs v1.0.6 patched vs this PR
Cloth-funnel 29.3 ms 12.5 ms 10.4 ms 2.8× faster 1.20× faster
N-body 1554.6 ms 1779.7 ms 1216.3 ms 1.28× faster 1.46× faster
Armadillo-rollers 359.2 ms 413.4 ms 308.2 ms 1.17× faster 1.34× faster
Rod-twist 519.6 ms 523.4 ms 474.4 ms 1.10× faster 1.10× faster
Cloth-ball 515.6 ms 615.5 ms 509.0 ms about even 1.21× faster

These are a fresh sweep, so the v1.0.6 and PR columns drift slightly from the numbers in my earlier post. Comparisons inside the sweep are interleaved, so they're the ones to trust.

On the query datasets nothing changes: 1.67 µs/query on the simulation set against your 1.66, and the handcrafted set comes out a bit better at 1254 µs/query against 1352. False positives and false negatives are identical in every case, and IPC Toolkit's CCD tests pass.

One thing worth noting: the patch saves 15 to 20 ns per candidate against this PR, which is more than the 4 to 11 ns I measured as the regression. That's why cloth-ball comes back to even instead of staying behind. v1.0.6 was paying one allocation per root-rejected query for its priority_queue, so taking those queries to zero allocations beats both versions rather than just closing the gap.

Happy to open this as a PR against your branch if that's easier than applying it by hand.

@iiiian

iiiian commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Oh, wow. Thanks for the in-depth analysis! In my benchmark I did not prune queries based on tmax as in ipc-toolkit, and turns out that make bucket DFS much less relevant. I'd like to do a bit more profiling based on the ipc-toolkit workload. The no allocation for root strategy is indeed useful, will add it later.

@iiiian

iiiian commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Update PR to reduce allocation by:

  • No heap allocation for root interval
  • Defer stack destruction to reduce heap allocation

I benchmark ipc-toolkit end-to-end pipeline on i5-13600k on linux to compared with bucket DFS before reduced allocation and BFS without root allocation. Each task is pinned to P core and are sampled 20 times.

Scene Original bucket PR Current bucket DFS Reduced alloc BFS Bucket vs original Bucket vs BFS
Rod-twist 409.136 ms 397.232 ms 485.761 ms 1.030× 1.223×
Armadillo-rollers 273.720 ms 237.018 ms 264.384 ms 1.155× 1.115×
Cloth-ball 414.493 ms 386.196 ms 419.376 ms 1.073× 1.086×
N-body 1096.040 ms 968.444 ms 1021.710 ms 1.132× 1.055×
Cloth-funnel 17.703 ms 14.207 ms 38.093 ms 1.246× 2.681×

Overall aside from cloth-funnel other scene are either tie or shows minor improvements. My final verdict is that this PR speedup difficult scenes that require deep tree traversal while keeping equivalent performance otherwise.

@zfergus
zfergus merged commit 12c223d into Continuous-Collision-Detection:main Aug 6, 2026
6 checks passed
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.

3 participants