Skip to content

Commit d37f4d3

Browse files
nvtwclaude
andauthored
Improve hydroelastic contact performance (#4142)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 55c824a commit d37f4d3

11 files changed

Lines changed: 848 additions & 158 deletions

asv/benchmarks/simulation/bench_contacts.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,67 @@ def time_simulate(self):
317317
wp.synchronize_device()
318318

319319

320+
class _ExampleCollideBenchmark:
321+
"""Collision-only timing of an example scene sized so contact generation dominates.
322+
323+
The ``*Defaults`` benchmarks time whole simulation frames, where the solver
324+
hides most of the collision cost. This base class builds the same example
325+
with more worlds, captures ``CollisionPipeline.collide`` alone into a CUDA
326+
graph, and replays it, so contact-generation changes show up directly while
327+
the setup cost stays close to the defaults benchmarks.
328+
"""
329+
330+
module_names: ClassVar[list[str]] = []
331+
world_count = 200
332+
launch_count = 20
333+
repeat = pr_gate_repeat(5)
334+
number = 1
335+
336+
def setup_cache(self):
337+
_download_external_git_folder(ISAACGYM_ENVS_REPO_URL, ISAACGYM_NUT_BOLT_FOLDER)
338+
339+
def setup(self):
340+
device = wp.get_device()
341+
if not device.is_cuda:
342+
raise SkipNotImplemented
343+
example_cls = _import_example_class(self.module_names)
344+
args = newton.examples.default_args(example_cls.create_parser())
345+
args.world_count = self.world_count
346+
args.num_per_world = 1
347+
self.example = example_cls(ViewerNull(num_frames=1), args)
348+
self.pipeline = self.example.collision_pipeline
349+
self.state = self.example.state_0
350+
self.contacts = self.example.contacts
351+
352+
for _ in range(3):
353+
self.pipeline.collide(self.state, self.contacts)
354+
wp.synchronize_device()
355+
if int(self.contacts.rigid_contact_count.numpy()[0]) == 0:
356+
raise RuntimeError("collide benchmark scene produced no contacts")
357+
358+
with wp.ScopedCapture(device=device) as capture:
359+
self.pipeline.collide(self.state, self.contacts)
360+
self.graph = capture.graph
361+
362+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
363+
def time_collide(self):
364+
for _ in range(self.launch_count):
365+
wp.capture_launch(self.graph)
366+
wp.synchronize_device()
367+
368+
369+
class FastExampleContactSdfCollide(_ExampleCollideBenchmark):
370+
"""Collision-only benchmark of the mesh-SDF nut-bolt scene at 200 worlds."""
371+
372+
module_names: ClassVar[list[str]] = ["newton.examples.contacts.example_nut_bolt_sdf"]
373+
374+
375+
class FastExampleContactHydroCollide(_ExampleCollideBenchmark):
376+
"""Collision-only benchmark of the hydroelastic nut-bolt scene at 200 worlds."""
377+
378+
module_names: ClassVar[list[str]] = ["newton.examples.contacts.example_nut_bolt_hydro"]
379+
380+
320381
class FastExampleContactPyramidDefaults:
321382
"""Benchmark the box pyramid example with default configuration."""
322383

@@ -528,6 +589,8 @@ def time_collide(self, case):
528589
benchmark_list = {
529590
"FastExampleContactSdfDefaults": FastExampleContactSdfDefaults,
530591
"FastExampleContactHydroWorkingDefaults": FastExampleContactHydroWorkingDefaults,
592+
"FastExampleContactSdfCollide": FastExampleContactSdfCollide,
593+
"FastExampleContactHydroCollide": FastExampleContactHydroCollide,
531594
"FastExampleContactPyramidDefaults": FastExampleContactPyramidDefaults,
532595
"FastConvexCollision": FastConvexCollision,
533596
"BroadPhaseCollision": BroadPhaseCollision,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Speed up mesh-mesh SDF and hydroelastic SDF contact generation without changing the produced contacts:
2+
3+
- Hydroelastic octree refinement scans only the active records instead of the full worst-case buffers, and the marching-cubes kernel reads voxel corners with fewer texture lookups.
4+
- The mesh-SDF edge kernels use 128-thread blocks with overlapped pre-prune hashtable probes, and the contact reducer clears its active entries with more threads.
5+
6+
No migration is needed.

newton/_src/geometry/contact_reduction_global.py

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from newton._src.geometry.hashtable import (
6060
HASHTABLE_EMPTY_KEY,
6161
HashTable,
62+
hashtable_find,
6263
hashtable_find_or_insert,
6364
)
6465

@@ -1096,8 +1097,11 @@ def clear_active(self):
10961097
later-scheduled blocks (or even later-issued warps/lanes under
10971098
independent thread scheduling), causing some entries to be skipped.
10981099
"""
1099-
# Use fixed thread count for efficient GPU utilization
1100-
num_threads = min(1024, self.hashtable.capacity)
1100+
# The clear is a grid-stride loop over the active entries with several
1101+
# scattered stores each, so it needs many resident warps to hide the
1102+
# store latency; the active count is only known on the device, and
1103+
# surplus threads exit immediately.
1104+
num_threads = min(65536, self.hashtable.capacity)
11011105

11021106
wp.launch(
11031107
_clear_active_kernel,
@@ -1567,41 +1571,55 @@ def _export_and_reduce_contact_centered_two_spatial_depths(
15671571
pos_2d = project_point_to_plane(bin_id, centered_position)
15681572
key = make_contact_key(shape_a, shape_b, bin_id)
15691573

1574+
# === Voxel bin: inner depth coverage ===
1575+
voxel_idx = compute_voxel_index(position_local, aabb_lower_voxel, aabb_upper_voxel, voxel_res)
1576+
voxel_idx = wp.clamp(voxel_idx, 0, wp.static(NUM_VOXEL_DEPTH_SLOTS - 1))
1577+
1578+
voxels_per_group = wp.static(NUM_SPATIAL_DIRECTIONS + 1)
1579+
voxel_group = voxel_idx // voxels_per_group
1580+
voxel_local_slot = voxel_idx % voxels_per_group
1581+
voxel_bin_id = wp.static(NUM_NORMAL_BINS) + voxel_group
1582+
voxel_key = make_contact_key(shape_a, shape_b, voxel_bin_id)
1583+
1584+
# Resolve both keys up front so their probes and the slot reads below can
1585+
# overlap. Missing voxel keys are published only after a contact ID is
1586+
# available; deleting a speculative key after publication would race with
1587+
# concurrent threads that have already found it.
15701588
entry_idx = hashtable_find_or_insert(key, reducer_data.ht_keys, reducer_data.ht_active_slots)
1571-
might_win = False
1589+
voxel_entry_idx = -1
1590+
if use_inner:
1591+
voxel_entry_idx = hashtable_find(voxel_key, reducer_data.ht_keys)
15721592

1593+
might_win = False
15731594
if entry_idx >= 0:
1595+
# Read every slot before comparing so the loads issue back to back.
1596+
slot_values = replaced_values_vec_type()
1597+
for dir_i in range(wp.static(NUM_SPATIAL_DIRECTIONS + 1)):
1598+
slot_values[dir_i] = reducer_data.ht_values[dir_i * ht_capacity + entry_idx]
1599+
voxel_slot_value = wp.uint64(0)
1600+
if voxel_entry_idx >= 0:
1601+
voxel_slot_value = reducer_data.ht_values[voxel_local_slot * ht_capacity + voxel_entry_idx]
1602+
15741603
if use_inner:
15751604
if deterministic != 0:
15761605
max_depth_probe = _make_preprune_probe_det(-depth, fingerprint)
15771606
else:
15781607
max_depth_probe = _make_contact_value_fast(-depth, 0, 0)
1579-
if reducer_data.ht_values[wp.static(NUM_SPATIAL_DIRECTIONS) * ht_capacity + entry_idx] < max_depth_probe:
1608+
if slot_values[wp.static(NUM_SPATIAL_DIRECTIONS)] < max_depth_probe:
1609+
might_win = True
1610+
if voxel_entry_idx >= 0 and voxel_slot_value < max_depth_probe:
1611+
might_win = True
1612+
if voxel_entry_idx < 0:
15801613
might_win = True
15811614

15821615
for dir_i in range(wp.static(NUM_SPATIAL_DIRECTIONS)):
1583-
if not might_win:
1584-
dir_2d = get_spatial_direction_2d(dir_i)
1585-
score = wp.dot(pos_2d, dir_2d)
1586-
probe = make_spatial_preprune_probe(score, use_inner, fingerprint, deterministic)
1587-
if reducer_data.ht_values[dir_i * ht_capacity + entry_idx] < probe:
1588-
might_win = True
1616+
dir_2d = get_spatial_direction_2d(dir_i)
1617+
score = wp.dot(pos_2d, dir_2d)
1618+
probe = make_spatial_preprune_probe(score, use_inner, fingerprint, deterministic)
1619+
if slot_values[dir_i] < probe:
1620+
might_win = True
15891621
else:
15901622
wp.atomic_add(reducer_data.ht_insert_failures, 0, 1)
1591-
1592-
# === Voxel bin: inner depth coverage ===
1593-
voxel_idx = compute_voxel_index(position_local, aabb_lower_voxel, aabb_upper_voxel, voxel_res)
1594-
voxel_idx = wp.clamp(voxel_idx, 0, wp.static(NUM_VOXEL_DEPTH_SLOTS - 1))
1595-
1596-
voxels_per_group = wp.static(NUM_SPATIAL_DIRECTIONS + 1)
1597-
voxel_group = voxel_idx // voxels_per_group
1598-
voxel_local_slot = voxel_idx % voxels_per_group
1599-
voxel_bin_id = wp.static(NUM_NORMAL_BINS) + voxel_group
1600-
voxel_key = make_contact_key(shape_a, shape_b, voxel_bin_id)
1601-
1602-
voxel_entry_idx = -1
1603-
if use_inner and not might_win:
1604-
voxel_entry_idx = hashtable_find_or_insert(voxel_key, reducer_data.ht_keys, reducer_data.ht_active_slots)
16051623
if voxel_entry_idx >= 0:
16061624
if deterministic != 0:
16071625
voxel_probe = _make_preprune_probe_det(-depth, fingerprint)
@@ -1613,11 +1631,6 @@ def _export_and_reduce_contact_centered_two_spatial_depths(
16131631
if not might_win:
16141632
return -1
16151633

1616-
# Compete with reserved ID zero before materializing contact geometry, so
1617-
# stale pre-prune survivors consume no buffer space.
1618-
if use_inner and voxel_entry_idx < 0:
1619-
voxel_entry_idx = hashtable_find_or_insert(voxel_key, reducer_data.ht_keys, reducer_data.ht_active_slots)
1620-
16211634
won_mask = int(0)
16221635
replaced_values = replaced_values_vec_type()
16231636
if use_inner and entry_idx >= 0:
@@ -1664,7 +1677,8 @@ def _export_and_reduce_contact_centered_two_spatial_depths(
16641677
won_mask |= 1 << wp.static(NUM_SPATIAL_DIRECTIONS + 1)
16651678
replaced_values[wp.static(NUM_SPATIAL_DIRECTIONS + 1)] = previous_value
16661679

1667-
if won_mask == 0:
1680+
voxel_entry_missing = use_inner and voxel_entry_idx < 0
1681+
if won_mask == 0 and not voxel_entry_missing:
16681682
return -1
16691683

16701684
# Avoid allocating candidates superseded during their own slot updates.
@@ -1693,7 +1707,11 @@ def _export_and_reduce_contact_centered_two_spatial_depths(
16931707
if reducer_data.ht_values[voxel_local_slot * ht_capacity + voxel_entry_idx] == provisional_value:
16941708
still_wins = True
16951709

1696-
if not still_wins:
1710+
# Without a surviving slot win, a contact may still claim the voxel slot of
1711+
# an entry that is not published yet. That claim happens after the contact
1712+
# ID exists, so a losing claimant returns its ID below.
1713+
voxel_only = voxel_entry_missing and not still_wins
1714+
if not still_wins and not voxel_only:
16971715
return -1
16981716
contact_id = export_contact_to_buffer(shape_a, shape_b, position, normal, depth, fingerprint, reducer_data)
16991717
if contact_id < 0:
@@ -1756,9 +1774,30 @@ def _export_and_reduce_contact_centered_two_spatial_depths(
17561774
voxel_entry_idx = hashtable_find_or_insert(voxel_key, reducer_data.ht_keys, reducer_data.ht_active_slots)
17571775
if voxel_entry_idx >= 0:
17581776
voxel_value = make_contact_value(-depth, fingerprint, contact_id, deterministic)
1759-
reduction_update_slot(voxel_entry_idx, voxel_local_slot, voxel_value, reducer_data.ht_values, ht_capacity)
1777+
if voxel_only:
1778+
previous_value = reduction_try_update_slot(
1779+
voxel_entry_idx,
1780+
voxel_local_slot,
1781+
voxel_value,
1782+
reducer_data.ht_values,
1783+
ht_capacity,
1784+
)
1785+
if previous_value >= voxel_value:
1786+
reclaim_contact_id(contact_id, reducer_data)
1787+
return -1
1788+
else:
1789+
reduction_update_slot(
1790+
voxel_entry_idx,
1791+
voxel_local_slot,
1792+
voxel_value,
1793+
reducer_data.ht_values,
1794+
ht_capacity,
1795+
)
17601796
else:
17611797
wp.atomic_add(reducer_data.ht_insert_failures, 0, 1)
1798+
if voxel_only:
1799+
reclaim_contact_id(contact_id, reducer_data)
1800+
return -1
17621801

17631802
return contact_id
17641803

newton/_src/geometry/narrow_phase.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2691,15 +2691,18 @@ class (``gjk_candidate_pairs_count``, ``split_gjk_work_count``,
26912691
self.mesh_triangle_block_dim = 32 if device_obj.is_cuda else self.block_dim
26922692

26932693
# Dynamic block allocation for mesh-mesh and mesh-plane contacts.
2694-
# On CUDA we partition toward ~4 blocks per SM and launch twice as many
2695-
# mesh-mesh blocks to reduce serial work in long per-pair queues. On CPU
2696-
# there is no SM notion so we pick 64 as a modest parallelism
2697-
# target that splits pair work across OpenMP threads without
2698-
# over-subscribing on small scenes.
2694+
# On CUDA we partition toward ~4 blocks per SM. The 128-thread mesh-mesh
2695+
# kernel keeps four blocks resident per SM, and launching four times
2696+
# that many blocks (several waves of small chunks) balances the uneven
2697+
# per-chunk edge work: with only two waves, scenes around 100 pairs
2698+
# measured 25% slower because busy chunks dominated the tail. On CPU
2699+
# there is no SM notion so we pick 64 as a modest parallelism target
2700+
# that splits pair work across OpenMP threads without over-subscribing
2701+
# on small scenes.
26992702
if self.reduce_contacts:
27002703
target_blocks = device_obj.sm_count * 4 if device_obj.is_cuda else 64
27012704
# Mesh-mesh
2702-
self.num_mesh_mesh_blocks = target_blocks * 2 if device_obj.is_cuda else target_blocks
2705+
self.num_mesh_mesh_blocks = target_blocks * 4 if device_obj.is_cuda else target_blocks
27032706
self.mesh_mesh_target_blocks = target_blocks
27042707
mesh_mesh_scan_size = self.max_mesh_mesh_pairs + 1
27052708
self.mesh_mesh_block_offsets = wp.zeros(mesh_mesh_scan_size, dtype=wp.int32, device=device)

newton/_src/geometry/sdf_contact.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@
4949
# ``mesh_sdf_collision_kernel`` and ``mesh_sdf_collision_global_reduce_kernel``.
5050
# Both kernels assume ``wp.block_dim() == MESH_SDF_BLOCK_DIM`` so that the
5151
# tile-stack capacity below correctly sizes the cooperative push overflow
52-
# margin.
53-
MESH_SDF_BLOCK_DIM = 256
52+
# margin. 128 threads (four resident blocks per SM at the 128-register cap)
53+
# measured about 8% faster than 256 threads with two blocks: the culling loop
54+
# barriers wait on four warps instead of eight at the same occupancy.
55+
MESH_SDF_BLOCK_DIM = 128
5456

5557
# Capacity of the cooperative edge-selection tile stack. Sized to
5658
# ``2 * MESH_SDF_BLOCK_DIM`` so that the inner push loop can never
@@ -1541,7 +1543,7 @@ def mesh_sdf_collision_kernel(
15411543

15421544
# The tiled launch provides exactly ``total_num_blocks`` blocks and the
15431545
# kernel strides those blocks over all active combinations itself.
1544-
@wp.kernel(enable_backward=False, launch_bounds=(256, 2), grid_stride=False, module=_module)
1546+
@wp.kernel(enable_backward=False, launch_bounds=(MESH_SDF_BLOCK_DIM, 4), grid_stride=False, module=_module)
15451547
def mesh_sdf_collision_global_reduce_kernel(
15461548
shape_data: wp.array[wp.vec4],
15471549
shape_transform: wp.array[wp.transform],

0 commit comments

Comments
 (0)