Skip to content

Commit d37adba

Browse files
authored
Optimize LBVH Construction using Bottom-up Build (#230)
* Optimize LBVH construction using bottom-up build - Replace top-down split search with a single bottom-up pass (Apetrei 2014) to build the hierarchy and bounding boxes simultaneously - Update `ConstructionInfo` to track range endpoints instead of parent indices - Add Catch2 benchmarks for `LBVH::build` performance testing * Fix std::array initialization in LBVH benchmark * Fix race condition in LBVH root assignment - Change `root_idx` to `std::atomic<int>` to prevent data races during parallel initialization - Safely assign the root node using `compare_exchange_strong` - Clarify `code_i` parameter in `delta` function documentation
1 parent e400bb0 commit d37adba

3 files changed

Lines changed: 169 additions & 137 deletions

File tree

src/ipc/broad_phase/lbvh.cpp

Lines changed: 119 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ namespace xs = xsimd;
1818
#endif
1919

2020
#include <array>
21+
#include <atomic>
2122

2223
using namespace std::placeholders;
2324

@@ -95,6 +96,11 @@ void LBVH::build(
9596
}
9697

9798
namespace {
99+
/// Returns the number of common leading bits (CLZ of XOR) between sorted
100+
/// Morton codes at positions i and j. code_i is the Morton code at position
101+
/// i, passed explicitly to avoid a redundant lookup. Returns -1 when j is
102+
/// out of bounds. Duplicate codes fall back to CLZ of the index XOR
103+
/// (offset by 32 so it sorts after any code-level difference).
98104
int delta(
99105
const LBVH::MortonCodeElements& sorted_morton_codes,
100106
int i,
@@ -107,8 +113,8 @@ namespace {
107113
uint64_t code_j = sorted_morton_codes[j].morton_code;
108114
if (code_i == code_j) {
109115
// handle duplicate morton codes
110-
int element_idx_i = i; // sorted_morton_codes[i].elementIdx;
111-
int element_idx_j = j; // sorted_morton_codes[j].elementIdx;
116+
int element_idx_i = i;
117+
int element_idx_j = j;
112118

113119
// add 32 for common prefix of code_i ^ code_j
114120
#if defined(__GNUC__) || defined(__clang__)
@@ -123,72 +129,6 @@ namespace {
123129
return __lzcnt64(code_i ^ code_j);
124130
#endif
125131
}
126-
127-
void determine_range(
128-
const LBVH::MortonCodeElements& sorted_morton_codes,
129-
int idx,
130-
int& lower,
131-
int& upper)
132-
{
133-
// determine direction of the range (+1 or -1)
134-
const uint64_t code = sorted_morton_codes[idx].morton_code;
135-
const int delta_l = delta(sorted_morton_codes, idx, code, idx - 1);
136-
const int delta_r = delta(sorted_morton_codes, idx, code, idx + 1);
137-
const int d = (delta_r >= delta_l) ? 1 : -1;
138-
139-
// compute upper bound for the length of the range
140-
const int delta_min = std::min(delta_l, delta_r);
141-
int l_max = 2;
142-
while (delta(sorted_morton_codes, idx, code, idx + l_max * d)
143-
> delta_min) {
144-
l_max = l_max << 1;
145-
}
146-
147-
// find the other end using binary search
148-
int l = 0;
149-
for (int t = l_max >> 1; t > 0; t >>= 1) {
150-
if (delta(sorted_morton_codes, idx, code, idx + (l + t) * d)
151-
> delta_min) {
152-
l += t;
153-
}
154-
}
155-
int jdx = idx + l * d;
156-
157-
// ensure idx < jdx
158-
lower = std::min(idx, jdx);
159-
upper = std::max(idx, jdx);
160-
}
161-
162-
int find_split(
163-
const LBVH::MortonCodeElements& sorted_morton_codes,
164-
int first,
165-
int last)
166-
{
167-
uint64_t first_code = sorted_morton_codes[first].morton_code;
168-
169-
// Calculate the number of highest bits that are the same
170-
// for all objects, using the count-leading-zeros intrinsic.
171-
int common_prefix = delta(sorted_morton_codes, first, first_code, last);
172-
173-
// Use binary search to find where the next bit differs.
174-
// Specifically, we are looking for the highest object that
175-
// shares more than common_prefix bits with the first one.
176-
int split = first; // initial guess
177-
int stride = last - first;
178-
do {
179-
stride = (stride + 1) >> 1; // exponential decrease
180-
int new_split = split + stride; // proposed new position
181-
if (new_split < last) {
182-
int split_prefix =
183-
delta(sorted_morton_codes, first, first_code, new_split);
184-
if (split_prefix > common_prefix) {
185-
split = new_split; // accept proposal
186-
}
187-
}
188-
} while (stride > 1);
189-
190-
return split;
191-
}
192132
} // namespace
193133

194134
void LBVH::init_bvh(
@@ -238,17 +178,35 @@ void LBVH::init_bvh(
238178
}
239179

240180
assert(boxes.size() <= std::numeric_limits<int>::max());
241-
const int LEAF_OFFSET = int(boxes.size()) - 1;
181+
const int N_LEAVES = int(boxes.size());
182+
const int LEAF_OFFSET = N_LEAVES - 1;
242183

243184
if (rightmost_leaves.size() != lbvh.size()) {
244185
rightmost_leaves.resize(lbvh.size());
245186
}
246187

247188
LBVH::ConstructionInfos construction_infos(lbvh.size());
248189
{
249-
IPC_TOOLKIT_PROFILE_BLOCK("build_hierarchy");
250-
tbb::parallel_for(size_t(0), boxes.size(), [&](size_t i) {
251-
assert(i < boxes.size());
190+
IPC_TOOLKIT_PROFILE_BLOCK("init_visitation_counts");
191+
tbb::parallel_for(size_t(0), lbvh.size(), [&](size_t i) {
192+
construction_infos[i].visitation_count.store(
193+
0, std::memory_order_relaxed);
194+
});
195+
}
196+
197+
// Apetrei 2014: single bottom-up pass that simultaneously builds the
198+
// hierarchy and computes bounding boxes. Each leaf thread walks toward the
199+
// root, choosing its parent in O(1) by comparing the CLZ-delta values at
200+
// the two ends of its current key range.
201+
//
202+
// In this layout internal node j always splits between sorted keys j and
203+
// j+1. The root is NOT necessarily at index 0, so after construction we
204+
// swap the root into position 0 to match the traversal code's expectation.
205+
std::atomic<int> root_idx(-1);
206+
{
207+
IPC_TOOLKIT_PROFILE_BLOCK("build_hierarchy_and_boxes");
208+
tbb::parallel_for(0, N_LEAVES, [&](int i) {
209+
// --- Initialize leaf node ---
252210
{
253211
const auto& box = boxes[morton_codes[i].box_id];
254212

@@ -261,59 +219,51 @@ void LBVH::init_bvh(
261219
rightmost_leaves[LEAF_OFFSET + i] = i;
262220
}
263221

264-
if (i < LEAF_OFFSET) {
265-
// Find out which range of objects the node corresponds
266-
// to. (This is where the magic happens!)
267-
268-
int first, last;
269-
determine_range(morton_codes, int(i), first, last);
222+
// --- Bottom-up walk (Apetrei 2014, Fig. 2) ---
223+
// Invariant: the current subtree covers the sorted-key range
224+
// [left_key, right_key].
225+
int left_key = i;
226+
int right_key = i;
227+
int current_node = LEAF_OFFSET + i;
270228

271-
// Determine where to split the range
272-
int split = find_split(morton_codes, first, last);
273-
274-
// Select child_a
275-
int child_a = -1;
276-
if (split == first) {
277-
// pointer to leaf node
278-
child_a = LEAF_OFFSET + split;
279-
} else {
280-
child_a = split; // pointer to internal node
281-
}
282-
283-
// Select child_b
284-
int child_b = -1;
285-
if (split + 1 == last) {
286-
child_b = LEAF_OFFSET + split + 1; // pointer to leaf node
229+
while (true) {
230+
// Choose parent. Candidates are internal node right_key
231+
// (current becomes its left / childA) or internal node
232+
// left_key-1 (current becomes its right / childB). Our delta()
233+
// returns CLZ (higher = more-similar = finer split), so the
234+
// CLOSER ancestor has the LARGER delta — hence ">".
235+
//
236+
// Boundary rules:
237+
// left_key == 0 → must be childA (no node -1)
238+
// right_key == n-1 → must be childB (no node n-1)
239+
const bool is_child_a = (left_key == 0)
240+
|| (right_key != N_LEAVES - 1
241+
&& delta(
242+
morton_codes, right_key,
243+
morton_codes[right_key].morton_code,
244+
right_key + 1)
245+
> delta(
246+
morton_codes, left_key - 1,
247+
morton_codes[left_key - 1].morton_code,
248+
left_key));
249+
const int parent = is_child_a ? right_key : left_key - 1;
250+
251+
auto& info = construction_infos[parent];
252+
253+
// Write the child pointer on the parent node.
254+
// childA writes .left; childB writes .right.
255+
if (is_child_a) {
256+
lbvh[parent].left = current_node;
257+
info.left_range = left_key;
287258
} else {
288-
child_b = split + 1; // pointer to internal node
259+
lbvh[parent].right = current_node;
260+
info.right_range = right_key;
289261
}
290262

291-
// Record parent-child relationships
292-
lbvh[i].left = child_a;
293-
lbvh[i].right = child_b;
294-
construction_infos[child_a].parent = int(i);
295-
construction_infos[child_b].parent = int(i);
296-
construction_infos[child_a].visitation_count.store(
297-
0, std::memory_order_relaxed);
298-
construction_infos[child_b].visitation_count.store(
299-
0, std::memory_order_relaxed);
300-
}
263+
// Atomic arrival gate: the first thread to reach this parent
264+
// stops; the second thread proceeds (it now knows both children
265+
// are complete).
301266

302-
// node 0 is the root and has no parent to set these values
303-
if (i == 0) {
304-
construction_infos[0].parent = 0;
305-
construction_infos[0].visitation_count.store(
306-
0, std::memory_order_relaxed);
307-
}
308-
});
309-
}
310-
311-
{
312-
IPC_TOOLKIT_PROFILE_BLOCK("populate_boxes");
313-
tbb::parallel_for(size_t(0), boxes.size(), [&](size_t i) {
314-
int node_idx = construction_infos[LEAF_OFFSET + i].parent;
315-
while (true) {
316-
auto& info = construction_infos[node_idx];
317267
if (info.visitation_count++ == 0) {
318268
// this is the first thread that arrived at this
319269
// node -> finished
@@ -322,23 +272,58 @@ void LBVH::init_bvh(
322272
// this is the second thread that arrived at this node,
323273
// both children are computed -> compute aabb union and
324274
// continue
325-
assert(lbvh[node_idx].is_inner());
326-
const Node& child_b = lbvh[lbvh[node_idx].right];
327-
const Node& child_a = lbvh[lbvh[node_idx].left];
328-
lbvh[node_idx].aabb_min =
329-
child_a.aabb_min.min(child_b.aabb_min);
330-
lbvh[node_idx].aabb_max =
331-
child_a.aabb_max.max(child_b.aabb_max);
275+
assert(lbvh[parent].is_inner());
276+
const Node& child_a = lbvh[lbvh[parent].left];
277+
const Node& child_b = lbvh[lbvh[parent].right];
278+
lbvh[parent].aabb_min = child_a.aabb_min.min(child_b.aabb_min);
279+
lbvh[parent].aabb_max = child_a.aabb_max.max(child_b.aabb_max);
332280

333281
// Compute rightmost leaf: max of children's rightmost
334-
rightmost_leaves[node_idx] = std::max(
335-
rightmost_leaves[lbvh[node_idx].left],
336-
rightmost_leaves[lbvh[node_idx].right]);
337-
338-
if (node_idx == 0) {
339-
break; // root node
282+
rightmost_leaves[parent] = std::max(
283+
rightmost_leaves[lbvh[parent].left],
284+
rightmost_leaves[lbvh[parent].right]);
285+
286+
// Reconstruct the full key range for the parent.
287+
left_key = construction_infos[parent].left_range;
288+
right_key = construction_infos[parent].right_range;
289+
current_node = parent;
290+
291+
if (left_key == 0 && right_key == N_LEAVES - 1) {
292+
// only one thread should reach the root
293+
int expected = -1;
294+
[[maybe_unused]] bool set =
295+
root_idx.compare_exchange_strong(
296+
expected, current_node);
297+
assert(set);
298+
break; // root AABB is complete
340299
}
341-
node_idx = info.parent;
300+
}
301+
});
302+
}
303+
304+
// --- Move the root to index 0 so traversal can start there. ---
305+
// In the Apetrei layout the root's index equals the global split position,
306+
// which is generally != 0. We swap the root node into position 0 and patch
307+
// up the single affected child pointer.
308+
//
309+
// Key invariant (Apetrei): node 0's subtree always has left_key=0, so it is
310+
// only ever written as a LEFT child — meaning no internal node ever has
311+
// right==0. Therefore swapping node 0 cannot create a spurious
312+
// is_inner_marker==0 (which would look like a leaf).
313+
const int root = root_idx.load();
314+
if (root > 0) {
315+
IPC_TOOLKIT_PROFILE_BLOCK("swap_root_to_zero");
316+
std::swap(lbvh[0], lbvh[root]);
317+
std::swap(rightmost_leaves[0], rightmost_leaves[root]);
318+
319+
// The root (now at 0) is never any node's child, so no pointer
320+
// references R that needs rewriting to 0. The only pointers that
321+
// referenced 0 (the old node-0) must be rewritten to R. And since old
322+
// node-0 was only ever a LEFT child (see invariant above), we only need
323+
// to patch .left pointers.
324+
tbb::parallel_for(size_t(0), lbvh.size(), [&](size_t i) {
325+
if (lbvh[i].is_inner() && lbvh[i].left == 0) {
326+
lbvh[i].left = root;
342327
}
343328
});
344329
}

src/ipc/broad_phase/lbvh.hpp

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,11 @@ class LBVH : public BroadPhase {
8686

8787
private:
8888
struct ConstructionInfo {
89-
/// @brief Parent to the parent
90-
int parent;
91-
/// @brief Number of threads that arrived
89+
/// @brief Left range endpoint passed up by the left child.
90+
int32_t left_range;
91+
/// @brief Right range endpoint passed up by the right child.
92+
int32_t right_range;
93+
/// @brief Number of threads that arrived at this node.
9294
std::atomic<int> visitation_count;
9395
};
9496

tests/src/tests/broad_phase/test_lbvh.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,49 @@ TEST_CASE(
346346
lbvh->detect_edge_edge_candidates(ee_candidates);
347347
return ee_candidates.size();
348348
};
349+
}
350+
351+
TEST_CASE("Benchmark LBVH::build", "[!benchmark][broad_phase][lbvh]")
352+
{
353+
constexpr double inflation_radius = 0;
354+
355+
struct Scene {
356+
std::string name, mesh_t0, mesh_t1;
357+
};
358+
359+
#ifdef NDEBUG
360+
constexpr int NUM_SCENES = 6;
361+
#else
362+
constexpr int NUM_SCENES = 1;
363+
#endif
364+
365+
const std::array<Scene, NUM_SCENES> scenes = { {
366+
Scene { "Cloth-Ball", "cloth_ball92.ply", "cloth_ball93.ply" },
367+
#ifdef NDEBUG
368+
Scene { "Cloth-Funnel", "cloth-funnel/227.ply",
369+
"cloth-funnel/228.ply" },
370+
Scene { "Armadillo-Rollers", "armadillo-rollers/326.ply",
371+
"armadillo-rollers/327.ply" },
372+
Scene { "Rod-Twist", "rod-twist/3036.ply", "rod-twist/3037.ply" },
373+
Scene { "N-Body-Simulation", "n-body-simulation/balls16_18.ply",
374+
"n-body-simulation/balls16_19.ply" },
375+
Scene { "Puffer-Ball", "puffer-ball/20.ply", "puffer-ball/21.ply" },
376+
#endif
377+
} };
378+
379+
for (const auto& [scene, mesh_t0, mesh_t1] : scenes) {
380+
Eigen::MatrixXd vertices_t0, vertices_t1;
381+
Eigen::MatrixXi edges, faces;
382+
REQUIRE(tests::load_mesh(mesh_t0, vertices_t0, edges, faces));
383+
REQUIRE(tests::load_mesh(mesh_t1, vertices_t1, edges, faces));
384+
385+
const std::shared_ptr<LBVH> lbvh = std::make_shared<LBVH>();
386+
387+
BENCHMARK(fmt::format("LBVH::build [{}]", scene))
388+
{
389+
lbvh->build(
390+
vertices_t0, vertices_t1, edges, faces, inflation_radius);
391+
return lbvh->edge_nodes().size();
392+
};
393+
}
349394
}

0 commit comments

Comments
 (0)