Skip to content

Commit 44037cc

Browse files
committed
Parallelize reduced contact export
1 parent f4d2591 commit 44037cc

3 files changed

Lines changed: 178 additions & 153 deletions

File tree

newton/_src/geometry/contact_reduction_global.py

Lines changed: 139 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
BETA_THRESHOLD = 0.0001 # 0.1mm
8888

8989
VALUES_PER_KEY = NUM_SPATIAL_DIRECTIONS + 1
90+
PAIRS_PER_KEY = VALUES_PER_KEY * (VALUES_PER_KEY - 1) // 2
91+
EXPORT_REDUCED_CONTACTS_BLOCK_DIM = 32
92+
EXPORT_REDUCED_CONTACTS_THREAD_BUDGET_MULTIPLIER = 4
9093

9194
# Open-addressed linear probing gets expensive at high load and failed inserts
9295
# scan the whole table.
@@ -142,16 +145,20 @@ def _contacts_are_numerically_equivalent(
142145
"""Return whether two buffered contacts differ only by float rounding."""
143146
pd_a = position_depth[contact_a]
144147
pd_b = position_depth[contact_b]
148+
if not _floats_are_near_ulps(pd_a[0], pd_b[0]):
149+
return False
150+
if not _floats_are_near_ulps(pd_a[1], pd_b[1]):
151+
return False
152+
if not _floats_are_near_ulps(pd_a[2], pd_b[2]):
153+
return False
154+
if not _floats_are_near_ulps(pd_a[3], pd_b[3]):
155+
return False
156+
145157
n_a = normal[contact_a]
146158
n_b = normal[contact_b]
147-
return (
148-
_floats_are_near_ulps(pd_a[0], pd_b[0])
149-
and _floats_are_near_ulps(pd_a[1], pd_b[1])
150-
and _floats_are_near_ulps(pd_a[2], pd_b[2])
151-
and _floats_are_near_ulps(pd_a[3], pd_b[3])
152-
and _floats_are_near_ulps(n_a[0], n_b[0])
153-
and _floats_are_near_ulps(n_a[1], n_b[1])
154-
)
159+
if not _floats_are_near_ulps(n_a[0], n_b[0]):
160+
return False
161+
return _floats_are_near_ulps(n_a[1], n_b[1])
155162

156163

157164
@wp.func
@@ -1578,165 +1585,154 @@ def write_contact_to_reducer(
15781585
)
15791586

15801587

1581-
def create_export_reduced_contacts_kernel(writer_func: Any):
1582-
"""Create a kernel that exports reduced contacts using a custom writer function.
1583-
1584-
The kernel processes one hashtable ENTRY per thread (not one value slot).
1585-
Each entry has VALUES_PER_KEY value slots (``NUM_SPATIAL_DIRECTIONS`` spatial + 1 max-depth).
1586-
The thread reads all slots, collects unique contact IDs, and exports each
1587-
unique contact once.
1588+
@wp.func
1589+
def _roundoff_duplicate_bit_for_slot_pair(
1590+
pair_idx: int,
1591+
entry_idx: int,
1592+
ht_capacity: int,
1593+
ht_values: wp.array[wp.uint64],
1594+
position_depth: wp.array[wp.vec4],
1595+
normal: wp.array[wp.vec2],
1596+
contact_fingerprints: wp.array[wp.int32],
1597+
deterministic: int,
1598+
) -> int:
1599+
"""Return the slot bit to suppress for one pair, or zero."""
1600+
slot_b = int(1)
1601+
while pair_idx >= slot_b:
1602+
pair_idx = pair_idx - slot_b
1603+
slot_b = slot_b + 1
1604+
slot_a = pair_idx
1605+
1606+
value_a = ht_values[slot_a * ht_capacity + entry_idx]
1607+
value_b = ht_values[slot_b * ht_capacity + entry_idx]
1608+
if value_a == wp.uint64(0) or value_b == wp.uint64(0):
1609+
return 0
1610+
1611+
contact_a = unpack_contact_id(value_a, deterministic)
1612+
contact_b = unpack_contact_id(value_b, deterministic)
1613+
if contact_a == contact_b:
1614+
return 0
1615+
if not _contacts_are_numerically_equivalent(contact_a, contact_b, position_depth, normal):
1616+
return 0
1617+
1618+
if contact_fingerprints[contact_b] < contact_fingerprints[contact_a]:
1619+
return 1 << slot_a
1620+
return 1 << slot_b
15881621

1589-
This naturally deduplicates: one thread handles one (shape_pair, bin) entry
1590-
and can locally track which contact IDs it has already exported.
15911622

1592-
Args:
1593-
writer_func: A warp function with signature (ContactData, writer_data, int) -> None.
1594-
The third argument is an output_index (-1 indicates the writer should allocate
1595-
a new slot). This follows the same pattern as narrow_phase.py's write_contact_simple.
1623+
def create_export_reduced_contacts_kernel(writer_func: Any):
1624+
"""Create a tiled kernel that exports globally reduced contacts.
15961625
1597-
Returns:
1598-
A warp kernel that can be launched to export reduced contacts.
1626+
One thread block processes each active hashtable entry. Its first 21 lanes
1627+
compare the seven possible winner pairs in parallel, preserving the
1628+
lower-fingerprint representative for roundoff-equivalent geometry. Lane
1629+
zero then streams the surviving unique contacts to the writer.
15991630
"""
1600-
# Define vector type for tracking exported contact IDs
16011631
exported_ids_vec = wp.types.vector(length=VALUES_PER_KEY, dtype=wp.int32)
1602-
16031632
_module = f"export_reduced_contacts_{writer_func.__name__}"
16041633

16051634
@wp.kernel(enable_backward=False, module=_module)
16061635
def export_reduced_contacts_kernel(
1607-
# Hashtable arrays
16081636
ht_keys: wp.array[wp.uint64],
16091637
ht_values: wp.array[wp.uint64],
16101638
ht_active_slots: wp.array[wp.int32],
1611-
# Contact buffer arrays
16121639
position_depth: wp.array[wp.vec4],
1613-
normal: wp.array[wp.vec2], # Octahedral-encoded
1640+
normal: wp.array[wp.vec2],
16141641
shape_pairs: wp.array[wp.vec2i],
16151642
contact_fingerprints: wp.array[wp.int32],
1616-
# Global dedup flags: one int per buffer contact, for cross-entry deduplication
16171643
exported_flags: wp.array[wp.int32],
1618-
# Shape data for extracting margin and effective radius
16191644
shape_types: wp.array[int],
16201645
shape_data: wp.array[wp.vec4],
1621-
# Per-shape contact gaps
16221646
shape_gap: wp.array[float],
1623-
# Writer data (custom struct)
16241647
writer_data: Any,
1625-
# Grid stride parameters
1626-
total_num_threads: int,
1627-
# Packing mode (non-zero = deterministic 20-bit contact IDs)
1648+
total_num_blocks: int,
1649+
parallel_pairs: int,
16281650
deterministic: int,
16291651
):
1630-
"""Export reduced contacts to the writer.
1631-
1632-
Uses grid stride loop to iterate over active hashtable ENTRIES.
1633-
For each entry, reads all value slots, collects unique contact IDs,
1634-
and exports each unique contact once. Uses atomic flags per contact_id
1635-
for cross-entry deduplication (same contact winning multiple entries).
1636-
"""
1637-
tid = wp.tid()
1638-
1639-
# Get number of active entries (stored at index = ht_capacity)
1652+
block_id, lane = wp.tid()
16401653
ht_capacity = ht_keys.shape[0]
16411654
num_active = ht_active_slots[ht_capacity]
1642-
1643-
# Early exit if no active entries (fast path for empty work)
1644-
if num_active == 0:
1645-
return
1646-
1647-
# Grid stride loop over active entries
1648-
for i in range(tid, num_active, total_num_threads):
1649-
# Get the hashtable entry index
1650-
entry_idx = ht_active_slots[i]
1651-
1652-
# Track exported contact IDs for this entry (intra-entry dedup)
1653-
exported_ids = exported_ids_vec()
1654-
num_exported = int(0)
1655-
1656-
# Read all value slots for this entry (slot-major layout).
1657-
for slot in range(wp.static(VALUES_PER_KEY)):
1658-
value = ht_values[slot * ht_capacity + entry_idx]
1659-
1660-
# Skip empty slots (value = 0)
1661-
if value == wp.uint64(0):
1662-
continue
1663-
1664-
# Extract contact ID
1665-
contact_id = unpack_contact_id(value, deterministic)
1666-
1667-
# Skip if already exported within this entry
1668-
if is_contact_already_exported(contact_id, exported_ids, num_exported):
1669-
continue
1670-
1671-
# Record this contact ID for intra-entry dedup
1672-
exported_ids[num_exported] = contact_id
1673-
num_exported = num_exported + 1
1674-
1675-
# Suppress roundoff-equivalent winners using the lower topology
1676-
# fingerprint. This examines at most 21 pairs per seven-slot entry.
1677-
duplicate_mask = int(0)
1678-
for local_idx in range(num_exported):
1679-
contact_id = exported_ids[local_idx]
1680-
fingerprint = contact_fingerprints[contact_id]
1681-
for other_idx in range(local_idx):
1682-
other_id = exported_ids[other_idx]
1683-
if _contacts_are_numerically_equivalent(contact_id, other_id, position_depth, normal):
1684-
other_fingerprint = contact_fingerprints[other_id]
1685-
if fingerprint < other_fingerprint:
1686-
duplicate_mask = duplicate_mask | (1 << other_idx)
1687-
else:
1688-
duplicate_mask = duplicate_mask | (1 << local_idx)
1689-
1690-
for local_idx in range(num_exported):
1691-
if duplicate_mask & (1 << local_idx) != 0:
1692-
continue
1693-
1694-
contact_id = exported_ids[local_idx]
1695-
fingerprint = contact_fingerprints[contact_id]
1696-
1697-
# Cross-entry dedup: same contact can win slots in different entries
1698-
# (e.g., normal-bin AND voxel entry). Atomic flag per contact_id.
1699-
old_flag = wp.atomic_add(exported_flags, contact_id, 1)
1700-
if old_flag > 0:
1701-
continue
1702-
1703-
# Unpack contact data
1704-
position, contact_normal, depth = unpack_contact(contact_id, position_depth, normal)
1705-
1706-
# Get shape pair
1707-
pair = shape_pairs[contact_id]
1708-
shape_a = pair[0]
1709-
shape_b = pair[1]
1710-
1711-
# Extract margin offsets from shape_data (stored in w component)
1712-
margin_offset_a = shape_data[shape_a][3]
1713-
margin_offset_b = shape_data[shape_b][3]
1714-
1715-
# Compute effective radius for spheres, capsules, and cones
1716-
radius_eff_a = compute_effective_radius(shape_types[shape_a], shape_data[shape_a])
1717-
radius_eff_b = compute_effective_radius(shape_types[shape_b], shape_data[shape_b])
1718-
1719-
# Use additive per-shape contact gap (matching broad/narrow phase)
1720-
gap_a = shape_gap[shape_a]
1721-
gap_b = shape_gap[shape_b]
1722-
gap_sum = gap_a + gap_b
1723-
1724-
# Create ContactData struct
1725-
contact_data = ContactData()
1726-
contact_data.contact_point_center = position
1727-
contact_data.contact_normal_a_to_b = contact_normal
1728-
contact_data.contact_distance = depth
1729-
contact_data.radius_eff_a = radius_eff_a
1730-
contact_data.radius_eff_b = radius_eff_b
1731-
contact_data.margin_a = margin_offset_a
1732-
contact_data.margin_b = margin_offset_b
1733-
contact_data.shape_a = shape_a
1734-
contact_data.shape_b = shape_b
1735-
contact_data.gap_sum = gap_sum
1736-
contact_data.sort_sub_key = fingerprint
1737-
1738-
# Call the writer function
1739-
writer_func(contact_data, writer_data, -1)
1655+
duplicate_bits = wp.tile_zeros(shape=wp.static(EXPORT_REDUCED_CONTACTS_BLOCK_DIM), dtype=int, storage="shared")
1656+
1657+
for active_idx in range(block_id, num_active, total_num_blocks):
1658+
entry_idx = ht_active_slots[active_idx]
1659+
duplicate_bit = int(0)
1660+
1661+
if parallel_pairs != 0:
1662+
if lane < wp.static(PAIRS_PER_KEY):
1663+
duplicate_bit = _roundoff_duplicate_bit_for_slot_pair(
1664+
lane,
1665+
entry_idx,
1666+
ht_capacity,
1667+
ht_values,
1668+
position_depth,
1669+
normal,
1670+
contact_fingerprints,
1671+
deterministic,
1672+
)
1673+
elif lane == 0:
1674+
for pair_idx in range(wp.static(PAIRS_PER_KEY)):
1675+
duplicate_bit = duplicate_bit | _roundoff_duplicate_bit_for_slot_pair(
1676+
pair_idx,
1677+
entry_idx,
1678+
ht_capacity,
1679+
ht_values,
1680+
position_depth,
1681+
normal,
1682+
contact_fingerprints,
1683+
deterministic,
1684+
)
1685+
1686+
wp.tile_scatter_masked(duplicate_bits, lane, duplicate_bit, True)
1687+
duplicate_mask = wp.tile_reduce(wp.bit_or, duplicate_bits)[0]
1688+
1689+
if lane == 0:
1690+
exported_ids = exported_ids_vec()
1691+
num_exported = int(0)
1692+
1693+
for slot in range(wp.static(VALUES_PER_KEY)):
1694+
if duplicate_mask & (1 << slot) != 0:
1695+
continue
1696+
value = ht_values[slot * ht_capacity + entry_idx]
1697+
if value == wp.uint64(0):
1698+
continue
1699+
1700+
contact_id = unpack_contact_id(value, deterministic)
1701+
if is_contact_already_exported(contact_id, exported_ids, num_exported):
1702+
continue
1703+
exported_ids[num_exported] = contact_id
1704+
num_exported = num_exported + 1
1705+
1706+
old_flag = wp.atomic_add(exported_flags, contact_id, 1)
1707+
if old_flag > 0:
1708+
continue
1709+
1710+
position, contact_normal, depth = unpack_contact(contact_id, position_depth, normal)
1711+
pair = shape_pairs[contact_id]
1712+
shape_a = pair[0]
1713+
shape_b = pair[1]
1714+
margin_offset_a = shape_data[shape_a][3]
1715+
margin_offset_b = shape_data[shape_b][3]
1716+
radius_eff_a = compute_effective_radius(shape_types[shape_a], shape_data[shape_a])
1717+
radius_eff_b = compute_effective_radius(shape_types[shape_b], shape_data[shape_b])
1718+
gap_sum = shape_gap[shape_a] + shape_gap[shape_b]
1719+
1720+
contact_data = ContactData()
1721+
contact_data.contact_point_center = position
1722+
contact_data.contact_normal_a_to_b = contact_normal
1723+
contact_data.contact_distance = depth
1724+
contact_data.radius_eff_a = radius_eff_a
1725+
contact_data.radius_eff_b = radius_eff_b
1726+
contact_data.margin_a = margin_offset_a
1727+
contact_data.margin_b = margin_offset_b
1728+
contact_data.shape_a = shape_a
1729+
contact_data.shape_b = shape_b
1730+
contact_data.gap_sum = gap_sum
1731+
contact_data.sort_sub_key = contact_fingerprints[contact_id]
1732+
writer_func(contact_data, writer_data, -1)
1733+
1734+
# Keep lanes together before the shared tile is reused.
1735+
_sync = wp.tile_extract(duplicate_bits, lane)
17401736

17411737
return export_reduced_contacts_kernel
17421738

newton/_src/geometry/narrow_phase.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
)
3434
from ..geometry.contact_data import SHAPE_PAIR_HFIELD_BIT, ContactData, contact_passes_gap_check, make_contact_sort_key
3535
from ..geometry.contact_reduction_global import (
36+
EXPORT_REDUCED_CONTACTS_BLOCK_DIM,
37+
EXPORT_REDUCED_CONTACTS_THREAD_BUDGET_MULTIPLIER,
3638
HASHTABLE_WARN_LOAD_PERCENT,
3739
GlobalContactReducer,
3840
create_export_reduced_contacts_kernel,
@@ -2197,9 +2199,16 @@ def launch_custom_write(
21972199
if self.reduce_contacts:
21982200
# Zero exported_flags for cross-entry deduplication
21992201
self.global_contact_reducer.exported_flags.zero_()
2200-
wp.launch(
2202+
# Export has only one writer lane per block, so use a wider grid than
2203+
# the contact-generation kernels. On CPU, tiled kernels expose one lane.
2204+
effective_block_dim = min(self.block_dim, EXPORT_REDUCED_CONTACTS_BLOCK_DIM)
2205+
export_num_blocks = max(
2206+
1,
2207+
EXPORT_REDUCED_CONTACTS_THREAD_BUDGET_MULTIPLIER * self.total_num_threads // effective_block_dim,
2208+
)
2209+
wp.launch_tiled(
22012210
kernel=self.export_reduced_contacts_kernel,
2202-
dim=self.total_num_threads,
2211+
dim=export_num_blocks,
22032212
inputs=[
22042213
self.global_contact_reducer.hashtable.keys,
22052214
self.global_contact_reducer.ht_values,
@@ -2213,11 +2222,12 @@ def launch_custom_write(
22132222
shape_data,
22142223
shape_gap,
22152224
writer_data,
2216-
self.total_num_threads,
2225+
export_num_blocks,
2226+
int(self.block_dim > 1),
22172227
int(self.global_contact_reducer.deterministic),
22182228
],
22192229
device=device,
2220-
block_dim=self.block_dim,
2230+
block_dim=EXPORT_REDUCED_CONTACTS_BLOCK_DIM,
22212231
record_tape=False,
22222232
)
22232233
if self.hydroelastic_sdf is not None:

0 commit comments

Comments
 (0)