Skip to content

Commit a051c39

Browse files
authored
Surface additional simulation benchmark metrics (#3566)
1 parent ac6d562 commit a051c39

13 files changed

Lines changed: 1117 additions & 124 deletions

.github/workflows/aws_gpu_benchmarks.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,13 @@ jobs:
137137

138138
- name: Run Benchmarks
139139
run: |
140+
# ASV assumes smaller values are better, so compare only metrics with that direction.
140141
uvx --with virtualenv asv continuous \
141142
--launch-method spawn \
142143
--interleave-rounds \
143144
--append-samples \
144145
--no-only-changed \
145-
-e -b Fast \
146+
-e -b '^(?!.*track_(simulation_steps_per_second|real_time_factor)).*Fast' \
146147
${{ inputs.base_ref }} \
147148
${{ inputs.ref }}
148149
continue-on-error: true

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- Add opt-in `body_frame_origin="com"` to `ModelBuilder.add_rod()` and `ModelBuilder.add_rod_graph()` for COM-centered cable capsule body frames.
2424
- Add `sign_method` argument to `Mesh.build_sdf` and `SDF.create_from_mesh` support for a `"normal"` (angle-weighted pseudo-normal) sign strategy, for selecting the inside/outside sign of the baked SDF (`"auto"`, `"parity"`, `"winding"`, or `"normal"`).
2525
- Add `forward_depth_image` output support to `SensorTiledCamera.update()` and `SensorTiledCamera.utils.create_forward_depth_image_output()` for native forward-depth rendering without post-processing `depth_image`.
26+
- Add simulation throughput, real-time factor, p95 step-time, steady-state GPU-memory, timestep, and MuJoCo solver-iteration metrics to the ASV robot-learning benchmarks.
2627

2728
### Changed
2829

@@ -68,6 +69,7 @@
6869
- Fix Style3D solver divergence caused by isolated vertices.
6970
- Fix `SolverFeatherstone` BALL joints to apply passive `joint_damping` on all three angular DOFs.
7071
- Fix excessive memory usage when importing MJCF or URDF models containing many visual-only shapes with self-collisions disabled.
72+
- Fix `FastKitchenG1` ASV metrics to build the kitchen scene instead of a plain G1 model.
7173
- Fix the `diffsim_bear` example crashing with its default CUDA configuration and diverging after a few training iterations.
7274
- Fix masked PID state reset to execute on the integral-state device. (#3447)
7375
- Preserve muscles and rigid-body color groups when copying or replicating a `ModelBuilder`.

asv/benchmarks/benchmark_kamino.py

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212

1313
import newton
1414

15+
if __package__:
16+
from .benchmark_metrics import validate_simulation_state
17+
else:
18+
from benchmark_metrics import validate_simulation_state
19+
1520
_NUM_ACTIONS = 12
1621
_OBS_DIM = 94
1722
_MIN_STANDING_HEIGHT = 0.20
@@ -435,37 +440,23 @@ def step(self):
435440
self.sim_time += self.frame_dt
436441

437442
def test_final(self):
438-
state_values = {}
439-
for name in ("joint_q", "body_q", "body_qd"):
440-
values = getattr(self.state_0, name).numpy()
441-
if not np.isfinite(values).all():
442-
raise RuntimeError(f"Simulation produced non-finite values in state.{name}")
443-
state_values[name] = values
444-
445-
body_count = self.model.body_count // self.world_count
446-
body_qd = state_values["body_qd"].reshape(self.world_count, body_count, 6)
447-
max_linear_speed = np.linalg.norm(body_qd[:, :, :3], axis=-1).max()
448-
max_angular_speed = np.linalg.norm(body_qd[:, :, 3:], axis=-1).max()
449-
if max_linear_speed > _MAX_BODY_LINEAR_SPEED:
450-
raise RuntimeError(
451-
f"Maximum body linear speed is {max_linear_speed:.3f} m/s, exceeding {_MAX_BODY_LINEAR_SPEED:.1f} m/s"
452-
)
453-
if max_angular_speed > _MAX_BODY_ANGULAR_SPEED:
454-
raise RuntimeError(
455-
f"Maximum body angular speed is {max_angular_speed:.3f} rad/s, "
456-
f"exceeding {_MAX_BODY_ANGULAR_SPEED:.1f} rad/s"
457-
)
443+
validate_simulation_state(
444+
self.state_0,
445+
max_linear_speed=_MAX_BODY_LINEAR_SPEED,
446+
max_angular_speed=_MAX_BODY_ANGULAR_SPEED,
447+
)
458448

459449
if self.policy_controller is None:
460450
return
461451

452+
body_count = self.model.body_count // self.world_count
462453
body_labels = [label.rsplit("/", 1)[-1] for label in self.model.body_label[:body_count]]
463454
try:
464455
pelvis_index = body_labels.index("pelvis")
465456
except ValueError as e:
466457
raise RuntimeError("DR Legs model has no pelvis root body") from e
467458

468-
body_q = state_values["body_q"].reshape(self.world_count, body_count, 7)[:, pelvis_index]
459+
body_q = self.state_0.body_q.numpy().reshape(self.world_count, body_count, 7)[:, pelvis_index]
469460
body_com = self.model.body_com.numpy().reshape(self.world_count, body_count, 3)[:, pelvis_index]
470461
quat_vector = body_q[:, 3:6]
471462
twice_cross = 2.0 * np.cross(quat_vector, body_com)
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import math
5+
import time
6+
from collections.abc import Callable, Sequence
7+
from dataclasses import dataclass
8+
from typing import Any
9+
10+
import numpy as np
11+
import warp as wp
12+
from asv_runner.benchmarks.mark import skip_benchmark_if
13+
14+
15+
@dataclass(frozen=True)
16+
class SimulationMetrics:
17+
"""Metrics collected from one simulation benchmark configuration."""
18+
19+
mean_world_step_time_ms: float
20+
world_steps_per_second: float
21+
real_time_factor: float
22+
p95_frame_time_ms: float
23+
gpu_memory_mib: float
24+
sim_dt: float
25+
sim_substeps: int
26+
solver_niter_mean: float | None = None
27+
solver_niter_max: float | None = None
28+
29+
30+
class _SimulationMetricTracks:
31+
"""ASV track methods backed by cached simulation metrics."""
32+
33+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
34+
def track_simulate(self, metrics, world_count):
35+
return metrics[world_count].mean_world_step_time_ms
36+
37+
track_simulate.unit = "ms/world-step"
38+
39+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
40+
def track_simulation_steps_per_second(self, metrics, world_count):
41+
return metrics[world_count].world_steps_per_second
42+
43+
track_simulation_steps_per_second.unit = "world-steps/s"
44+
45+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
46+
def track_real_time_factor(self, metrics, world_count):
47+
return metrics[world_count].real_time_factor
48+
49+
track_real_time_factor.unit = "x"
50+
51+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
52+
def track_p95_step_time(self, metrics, world_count):
53+
return metrics[world_count].p95_frame_time_ms
54+
55+
track_p95_step_time.unit = "ms/frame"
56+
57+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
58+
def track_steady_state_gpu_memory(self, metrics, world_count):
59+
return metrics[world_count].gpu_memory_mib
60+
61+
track_steady_state_gpu_memory.unit = "MiB"
62+
63+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
64+
def track_sim_dt(self, metrics, world_count):
65+
return metrics[world_count].sim_dt
66+
67+
track_sim_dt.unit = "s"
68+
69+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
70+
def track_sim_substeps(self, metrics, world_count):
71+
return metrics[world_count].sim_substeps
72+
73+
track_sim_substeps.unit = "simulation-steps/frame"
74+
75+
76+
class _SimulationMetricTracksUnparameterized:
77+
"""ASV track methods backed by one cached simulation configuration."""
78+
79+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
80+
def track_mean_world_step_time(self, metrics):
81+
return metrics.mean_world_step_time_ms
82+
83+
track_mean_world_step_time.unit = "ms/world-step"
84+
85+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
86+
def track_simulation_steps_per_second(self, metrics):
87+
return metrics.world_steps_per_second
88+
89+
track_simulation_steps_per_second.unit = "world-steps/s"
90+
91+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
92+
def track_real_time_factor(self, metrics):
93+
return metrics.real_time_factor
94+
95+
track_real_time_factor.unit = "x"
96+
97+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
98+
def track_p95_step_time(self, metrics):
99+
return metrics.p95_frame_time_ms
100+
101+
track_p95_step_time.unit = "ms/frame"
102+
103+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
104+
def track_steady_state_gpu_memory(self, metrics):
105+
return metrics.gpu_memory_mib
106+
107+
track_steady_state_gpu_memory.unit = "MiB"
108+
109+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
110+
def track_sim_dt(self, metrics):
111+
return metrics.sim_dt
112+
113+
track_sim_dt.unit = "s"
114+
115+
@skip_benchmark_if(wp.get_cuda_device_count() == 0)
116+
def track_sim_substeps(self, metrics):
117+
return metrics.sim_substeps
118+
119+
track_sim_substeps.unit = "simulation-steps/frame"
120+
121+
122+
def compute_simulation_metrics(
123+
frame_times: Sequence[float],
124+
sim_dt: float,
125+
sim_substeps: int,
126+
world_count: int,
127+
gpu_memory_bytes: int,
128+
experience_frame_times: Sequence[float] | None = None,
129+
) -> SimulationMetrics:
130+
"""Compute comparable simulation metrics from synchronized frame times."""
131+
if not frame_times or any(not math.isfinite(value) or value <= 0.0 for value in frame_times):
132+
raise ValueError("frame_times must contain positive finite values")
133+
if experience_frame_times is None:
134+
experience_frame_times = frame_times
135+
if len(experience_frame_times) != len(frame_times) or any(
136+
not math.isfinite(value) or value <= 0.0 for value in experience_frame_times
137+
):
138+
raise ValueError("experience_frame_times must contain one positive finite value per frame")
139+
if not math.isfinite(sim_dt) or sim_dt <= 0.0:
140+
raise ValueError("sim_dt must be positive and finite")
141+
if sim_substeps <= 0 or world_count <= 0:
142+
raise ValueError("sim_substeps and world_count must be positive")
143+
if gpu_memory_bytes < 0:
144+
raise ValueError("gpu_memory_bytes must be non-negative")
145+
146+
total_time = sum(frame_times)
147+
experience_total_time = sum(experience_frame_times)
148+
world_steps = len(frame_times) * sim_substeps * world_count
149+
return SimulationMetrics(
150+
mean_world_step_time_ms=total_time * 1000.0 / world_steps,
151+
world_steps_per_second=world_steps / experience_total_time,
152+
real_time_factor=world_steps * sim_dt / experience_total_time,
153+
p95_frame_time_ms=float(np.percentile(experience_frame_times, 95.0)) * 1000.0,
154+
gpu_memory_mib=gpu_memory_bytes / 1024**2,
155+
sim_dt=sim_dt,
156+
sim_substeps=sim_substeps,
157+
)
158+
159+
160+
def validate_simulation_state(
161+
state: Any,
162+
max_linear_speed: float,
163+
max_angular_speed: float,
164+
quaternion_tolerance: float = 1.0e-3,
165+
):
166+
"""Validate finite rigid-body state, normalized rotations, and bounded speeds."""
167+
state_values = {}
168+
for name in ("joint_q", "joint_qd", "body_q", "body_qd"):
169+
values = getattr(state, name).numpy()
170+
if not np.isfinite(values).all():
171+
raise RuntimeError(f"Simulation produced non-finite values in state.{name}")
172+
state_values[name] = values
173+
174+
body_q = state_values["body_q"].reshape(-1, 7)
175+
quaternion_norms = np.linalg.norm(body_q[:, 3:7], axis=-1)
176+
if not np.allclose(quaternion_norms, 1.0, atol=quaternion_tolerance, rtol=0.0):
177+
max_error = np.abs(quaternion_norms - 1.0).max()
178+
raise RuntimeError(f"Maximum body quaternion norm error is {max_error:.3g}")
179+
180+
body_qd = state_values["body_qd"].reshape(-1, 6)
181+
max_measured_linear_speed = np.linalg.norm(body_qd[:, :3], axis=-1).max()
182+
max_measured_angular_speed = np.linalg.norm(body_qd[:, 3:], axis=-1).max()
183+
if max_measured_linear_speed > max_linear_speed:
184+
raise RuntimeError(
185+
f"Maximum body linear speed is {max_measured_linear_speed:.3f} m/s, exceeding {max_linear_speed:.1f} m/s"
186+
)
187+
if max_measured_angular_speed > max_angular_speed:
188+
raise RuntimeError(
189+
f"Maximum body angular speed is {max_measured_angular_speed:.3f} rad/s, "
190+
f"exceeding {max_angular_speed:.1f} rad/s"
191+
)
192+
193+
194+
def collect_simulation_metrics(
195+
create_workload: Callable[[], Any],
196+
world_count: int,
197+
num_frames: int,
198+
samples: int,
199+
synchronize: Callable[[], None] | None = None,
200+
validate: Callable[[Any], None] | None = None,
201+
timer: Callable[[], float] = time.perf_counter,
202+
) -> SimulationMetrics:
203+
"""Collect simulation metrics using internal or synchronized wall timing."""
204+
frame_times = []
205+
experience_frame_times = []
206+
gpu_memory_bytes = None
207+
sim_dt = None
208+
sim_substeps = None
209+
210+
wp.synchronize_device()
211+
device = wp.get_device()
212+
free_memory_before = device.free_memory
213+
214+
for sample_index in range(samples):
215+
workload = create_workload()
216+
if sim_dt is None:
217+
sim_dt = workload.sim_dt
218+
sim_substeps = workload.sim_substeps
219+
elif workload.sim_dt != sim_dt or workload.sim_substeps != sim_substeps:
220+
raise ValueError("simulation parameters changed between samples")
221+
222+
if synchronize is not None:
223+
synchronize()
224+
for _ in range(num_frames):
225+
experience_start_time = timer()
226+
benchmark_start_time = workload.benchmark_time if synchronize is None else None
227+
workload.step()
228+
if synchronize is not None:
229+
synchronize()
230+
experience_frame_time = timer() - experience_start_time
231+
experience_frame_times.append(experience_frame_time)
232+
frame_times.append(
233+
experience_frame_time
234+
if benchmark_start_time is None
235+
else workload.benchmark_time - benchmark_start_time
236+
)
237+
238+
if sample_index == 0:
239+
wp.synchronize_device()
240+
gpu_memory_bytes = free_memory_before - device.free_memory
241+
if gpu_memory_bytes < 0:
242+
raise RuntimeError("GPU free memory increased after workload initialization")
243+
if validate is not None:
244+
validate(workload)
245+
246+
return compute_simulation_metrics(
247+
frame_times=frame_times,
248+
sim_dt=sim_dt,
249+
sim_substeps=sim_substeps,
250+
world_count=world_count,
251+
gpu_memory_bytes=gpu_memory_bytes,
252+
experience_frame_times=experience_frame_times,
253+
)

asv/benchmarks/benchmark_mujoco.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@
2323
from newton.sensors import SensorContact
2424
from newton.utils import EventTracer
2525

26+
if __package__:
27+
from .benchmark_metrics import validate_simulation_state
28+
else:
29+
from benchmark_metrics import validate_simulation_state
30+
2631
_NEW_LAYOUT_AVAILABLE = hasattr(newton, "use_coord_layout_targets")
32+
_MAX_BODY_LINEAR_SPEED = 100.0
33+
_MAX_BODY_ANGULAR_SPEED = 500.0
2734

2835

2936
def _target_q(owner):
@@ -485,17 +492,24 @@ def step(self):
485492
self.apply_waypoint_control()
486493

487494
wp.synchronize_device()
488-
start_time = time.time()
489-
if self.use_cuda_graph:
495+
start_time = time.perf_counter()
496+
if self.use_cuda_graph and self.graph is not None:
490497
wp.capture_launch(self.graph)
491498
else:
492499
self.simulate()
493500
wp.synchronize_device()
494-
end_time = time.time()
501+
end_time = time.perf_counter()
495502

496503
self.benchmark_time += end_time - start_time
497504
self.sim_time += self.frame_dt
498505

506+
def test_final(self):
507+
validate_simulation_state(
508+
self.state_0,
509+
max_linear_speed=_MAX_BODY_LINEAR_SPEED,
510+
max_angular_speed=_MAX_BODY_ANGULAR_SPEED,
511+
)
512+
499513
def render(self):
500514
if self.renderer is None:
501515
return

0 commit comments

Comments
 (0)