Skip to content

Commit 27c3160

Browse files
Kashu7100claude
andcommitted
[PERF] Optional shared static-geometry raycast BVH (n_batches=1)
The raycast BVH (Raycaster / DepthCamera) is allocated per env (n_batches=n_envs): nodes, AABBs, morton codes and radix-sort scratch are all replicated across envs. For a high-poly static terrain this is the dominant GPU-memory cost — it OOMs at a few thousand envs even though every env's tree is identical and the cast already reads batch 0 when it detects the trees match (#2867). Add RigidOptions.shared_static_raycast_bvh (default False). When True, the static (fully-fixed) collision BVH is allocated ONCE and shared by every env, removing the n_envs-fold replication. update_aabbs now iterates the AABB buffer's own batch dimension, and a single-batch tree is flagged shared so the cast reads batch 0 for all envs. It is opt-in rather than auto-detected because env-identity is a runtime property: a per-env set_pos on a fixed body diverges the geometry after build. The default path still handles that via the existing per-build shared-across-envs comparison (which needs the per-env trees, so it cannot itself be the basis for a 1-batch allocation). The flag is a caller guarantee that the static collision geometry stays identical across envs (e.g. one terrain shared by all envs). Benchmark — raycast DepthCamera over an 8087-face static terrain (RTX 3080), total GPU memory at 64x36: 256 env : 775 -> 154 MB (5.0x) 1024 env : 2884 -> 366 MB (7.9x) 4096 env : OOM -> runs (previously CUDA_ERROR_OUT_OF_MEMORY) Depth is bit-identical to the per-env path; cast speed is unchanged (it already read batch 0; the build is one-time). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d2bb629 commit 27c3160

4 files changed

Lines changed: 66 additions & 3 deletions

File tree

genesis/engine/sensors/raycaster.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,16 @@ def activate(self):
120120
maybe_static = all(link.is_fixed for link in solver.links)
121121
if isinstance(solver, RigidSolver):
122122
n_faces = solver.faces_info.geom_idx.shape[0]
123-
aabb = AABB(n_batches=n_envs, n_aabbs=n_faces)
123+
# A static, env-identical collision mesh produces bit-identical per-env BVHs, so one shared tree
124+
# serves every env (the cast reads batch 0) — dropping the n_envs-fold replication of nodes /
125+
# aabbs / morton codes / radix-sort scratch, which dominates GPU memory for a high-poly terrain.
126+
# This is opt-in (``shared_static_raycast_bvh``) rather than auto-detected because env-identity is
127+
# a runtime property — a per-env ``set_pos`` on a fixed body diverges the geometry after build —
128+
# so it cannot be proven at allocation time; the default path keeps per-env trees and the runtime
129+
# shared-across-envs detection below. The flag is a caller guarantee the static geometry stays
130+
# env-identical (e.g. a single terrain shared by all envs).
131+
shared_static = maybe_static and solver._options.shared_static_raycast_bvh
132+
aabb = AABB(n_batches=1 if shared_static else n_envs, n_aabbs=n_faces)
124133
bvh = LBVH(aabb, max_n_query_result_per_aabb=0, n_radix_sort_groups=64)
125134
self._bvh_contexts.append(BVHContext(solver, bvh, aabb, None, maybe_static))
126135
n_vfaces = solver.vfaces_info.vgeom_idx.shape[0]
@@ -201,7 +210,10 @@ def update(self):
201210
and torch.equal(aabb_max, aabb_max[:1].expand_as(aabb_max))
202211
)
203212
else:
204-
entry.shared_across_envs = False
213+
# A single-batch tree (n_batches==1, allocated for env-identical static geometry above) is shared
214+
# by construction: the one tree must serve every env, so the cast reads batch 0 for all. The
215+
# per-env dynamic case (n_batches==n_envs) is never shared.
216+
entry.shared_across_envs = entry.aabb.n_batches == 1
205217

206218
def reset(self, envs_idx):
207219
# A reset may change otherwise-static geometry (re-randomized terrain, teleported obstacles), so force every

genesis/options/solvers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,15 @@ class RigidOptions(Options):
480480
Broadphase traversal strategy. ``SAP`` (sweep-and-prune) or ``ALL_VS_ALL`` (parallel pair iteration). Defaults
481481
to ``None`` (auto: ``SAP`` on CPU or when hibernation/heterogeneous entities are enabled, ``ALL_VS_ALL`` on GPU
482482
otherwise). See ``gs.broadphase_traversal`` for details on each strategy.
483+
shared_static_raycast_bvh : bool, optional
484+
Optimization for raycast sensors (``Raycaster`` / ``DepthCamera``) over large static scenes. When True, the
485+
raycast BVH built over the solver's static (fully fixed) collision geometry is allocated **once** and shared
486+
by every env (each env reads the same tree), instead of replicating one tree per env. For a high-poly static
487+
terrain this is the dominant raycast memory cost, so sharing it cuts total GPU memory several-fold and lifts
488+
the env-count ceiling. It is a caller guarantee that the static collision geometry is **identical across
489+
envs**: do not enable it if you give fixed entities per-env poses (e.g. per-env ``set_pos`` on a fixed body)
490+
or per-env geometry, as every env would then incorrectly cast against env 0's tree. Defaults to False, which
491+
keeps per-env trees and the runtime "shared across envs" auto-detection. Only affects raycasting, not physics.
483492
484493
Warning
485494
-------
@@ -504,6 +513,10 @@ class RigidOptions(Options):
504513
batch_joints_info: StrictBool = False
505514
batch_dofs_info: StrictBool = False
506515

516+
# raycast: share one static-geometry BVH across envs instead of one per env (caller guarantees env-identical
517+
# static collision geometry). See the class docstring.
518+
shared_static_raycast_bvh: StrictBool = False
519+
507520
# constraint solver
508521
constraint_solver: gs.constraint_solver = gs.constraint_solver.Newton
509522
iterations: PositiveInt = 50

genesis/utils/raycast_qd.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,10 @@ def update_aabbs(
248248
one vertex buffer but activate different per-env geom ranges, it makes each env cast against only its own variant
249249
instead of the union of every variant.
250250
"""
251-
for i_b, i_f in qd.ndrange(free_verts_state.pos.shape[1], faces_info.verts_idx.shape[0]):
251+
# Iterate over the AABB buffer's own batch dimension, not the solver's env count: a shared static BVH is
252+
# allocated with n_batches=1 (one tree built from env 0's verts and read by every env), while a per-env BVH
253+
# has n_batches=n_envs. For the latter the two are equal, so this is a no-op there.
254+
for i_b, i_f in qd.ndrange(aabb_state.aabbs.shape[0], faces_info.verts_idx.shape[0]):
252255
aabb_state.aabbs[i_b, i_f].min.fill(qd.math.inf)
253256
aabb_state.aabbs[i_b, i_f].max.fill(-qd.math.inf)
254257

tests/test_sensors.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,41 @@ def test_raycaster_hits(show_viewer, n_envs):
11741174
assert_allclose(grid_distances, grid_distances_ref, tol=1e-3)
11751175

11761176

1177+
@pytest.mark.required
1178+
def test_raycaster_shared_static_bvh(show_viewer):
1179+
# With RigidOptions.shared_static_raycast_bvh=True, env-identical static collision geometry is allocated as ONE
1180+
# shared BVH (n_batches=1) read by every env, instead of a per-env tree. Verify the allocation collapses, the
1181+
# cast is flagged shared, and the distances are finite and identical across envs (they must be, since every env
1182+
# sees the same geometry from the same sensor pose). The opt-in default (False) and its per-env-set_pos
1183+
# divergence path are covered by test_lidar_bvh_parallel_env.
1184+
scene = gs.Scene(
1185+
rigid_options=gs.options.RigidOptions(shared_static_raycast_bvh=True),
1186+
show_viewer=show_viewer,
1187+
)
1188+
scene.add_entity(gs.morphs.Plane())
1189+
scene.add_entity(gs.morphs.Box(size=(0.4, 0.4, 0.4), pos=(1.0, 0.0, 0.2), fixed=True))
1190+
depth_camera = scene.add_sensor(
1191+
gs.sensors.DepthCamera(
1192+
pattern=gs.sensors.raycaster.DepthCameraPattern(res=(8, 8)),
1193+
pos_offset=(0.0, 0.0, 1.0),
1194+
euler_offset=(0.0, 40.0, 0.0),
1195+
)
1196+
)
1197+
scene.build(n_envs=4)
1198+
scene.step()
1199+
1200+
# The single rigid collision BVH collapsed to one shared tree and the cast reads batch 0 for every env.
1201+
(collision_bvh,) = depth_camera._shared_context.bvh_contexts
1202+
assert collision_bvh.aabb.n_batches == 1
1203+
assert collision_bvh.shared_across_envs is True
1204+
1205+
distances = depth_camera.read().distances # (n_envs, H, W)
1206+
assert torch.isfinite(distances).all()
1207+
assert (distances < depth_camera._options.max_range).any() # some rays hit the box/plane
1208+
# Identical geometry + identical sensor pose -> every env's depth image matches env 0's.
1209+
assert torch.equal(distances, distances[:1].expand_as(distances))
1210+
1211+
11771212
@pytest.mark.required
11781213
@pytest.mark.parametrize("n_envs", [0, 2])
11791214
@pytest.mark.parametrize("kin_raycastable", [True, False])

0 commit comments

Comments
 (0)