Skip to content

Commit b2e801c

Browse files
Harden method micro-benchmark sampling
1 parent ae91002 commit b2e801c

2 files changed

Lines changed: 236 additions & 109 deletions

File tree

source/isaaclab/isaaclab/benchmark/method_benchmark.py

Lines changed: 74 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,12 @@
4848

4949
from __future__ import annotations
5050

51-
import contextlib
51+
import inspect
5252
import logging
53+
import statistics
5354
import time
5455
from collections.abc import Callable
55-
from dataclasses import dataclass, field
56-
57-
import numpy as np
58-
import torch
56+
from dataclasses import dataclass
5957

6058
from .benchmark_core import BaseIsaacLabBenchmark
6159
from .measurements import StatisticalMeasurement
@@ -85,6 +83,14 @@ class MethodBenchmarkRunnerConfig:
8583
device: str = "cuda:0"
8684
mode: str | list[str] = "all"
8785

86+
def __post_init__(self) -> None:
87+
"""Validate benchmark workload sizes."""
88+
for field_name in ("num_iterations", "num_instances", "num_bodies", "num_joints"):
89+
if getattr(self, field_name) <= 0:
90+
raise ValueError(f"{field_name} must be greater than zero")
91+
if self.warmup_steps < 0:
92+
raise ValueError("warmup_steps must be non-negative")
93+
8894

8995
@dataclass
9096
class MethodBenchmarkDefinition:
@@ -95,14 +101,12 @@ class MethodBenchmarkDefinition:
95101
method_name: Name of the method to benchmark on the target object.
96102
input_generators: Dict mapping mode names to input generator functions.
97103
category: Category for grouping results into phases.
98-
dependencies: List of method names that must be called first.
99104
"""
100105

101106
name: str
102107
method_name: str
103108
input_generators: dict[str, Callable]
104109
category: str = "default"
105-
dependencies: list[str] = field(default_factory=list)
106110

107111

108112
class MethodBenchmarkRunner(BaseIsaacLabBenchmark):
@@ -171,17 +175,13 @@ def run_benchmarks(
171175
self,
172176
benchmarks: list[MethodBenchmarkDefinition],
173177
target_object: object,
174-
dependencies: dict[str, list[str]] | None = None,
175178
) -> None:
176179
"""Run all defined benchmarks on the target object.
177180
178181
Args:
179182
benchmarks: List of benchmark definitions to run.
180183
target_object: Object containing the methods to benchmark.
181-
dependencies: Optional dict mapping method names to their dependencies.
182184
"""
183-
if dependencies is None:
184-
dependencies = {}
185185

186186
print(f"\nBenchmarking {len(benchmarks)} methods...")
187187
print(f"Config: {self._config.num_iterations} iterations, {self._config.warmup_steps} warmup steps")
@@ -201,9 +201,6 @@ def run_benchmarks(
201201
current_modes = self._modes_to_run if self._modes_to_run is not None else available_modes
202202
current_modes = [m for m in current_modes if m in available_modes]
203203

204-
# Get dependencies for this method
205-
method_deps = benchmark.dependencies or dependencies.get(benchmark.method_name, [])
206-
207204
for mode in current_modes:
208205
# Update manual recorders
209206
self.update_manual_recorders()
@@ -216,7 +213,6 @@ def run_benchmarks(
216213
method=method,
217214
method_name=bench_name,
218215
generator=generator,
219-
dependencies=method_deps,
220216
)
221217

222218
if result is None:
@@ -243,85 +239,97 @@ def _benchmark_method(
243239
method: Callable | None,
244240
method_name: str,
245241
generator: Callable,
246-
dependencies: list[str],
247242
) -> dict | None:
248243
"""Benchmark a single method.
249244
250245
Args:
251246
method: The method to benchmark (or None if not found).
252247
method_name: Name of the method for reporting.
253248
generator: Function that generates input arguments.
254-
dependencies: List of dependency method names to call first.
255249
256250
Returns:
257251
Dict with timing results, or None if method not found.
258252
"""
259253
if method is None:
260254
return None
261255

262-
# Try to call the method once to check for NotImplementedError
263-
try:
256+
inputs: dict = {}
257+
258+
def prepare() -> None:
259+
nonlocal inputs
264260
inputs = generator(self._config)
265-
_ = method(**inputs)
261+
262+
def operation() -> object:
263+
return method(**inputs)
264+
265+
try:
266+
prepare()
267+
operation()
266268
except NotImplementedError as e:
267269
return {"skipped": True, "skip_reason": f"NotImplementedError: {e}"}
268270
except Exception as e:
269-
return {"skipped": True, "skip_reason": f"Error: {type(e).__name__}: {e}"}
271+
raise RuntimeError(f"{method_name} failed during preflight") from e
270272

271-
# Warmup phase
272-
for _ in range(self._config.warmup_steps):
273+
return self._collect_samples(method_name, prepare, operation)
274+
275+
def _collect_samples(
276+
self,
277+
workload_name: str,
278+
prepare: Callable[[], None],
279+
operation: Callable[[], object],
280+
) -> dict:
281+
"""Collect synchronized latency samples for one workload.
282+
283+
Args:
284+
workload_name: Name included in contextual failures.
285+
prepare: Untimed callable that prepares one operation.
286+
operation: Callable measured after preparation.
287+
288+
Returns:
289+
Timing statistics in microseconds.
290+
"""
291+
for iteration in range(self._config.warmup_steps):
273292
try:
274-
inputs = generator(self._config)
275-
_ = method(**inputs)
276-
except Exception:
277-
pass
278-
# Sync GPU
293+
prepare()
294+
operation()
295+
except Exception as e:
296+
raise RuntimeError(f"{workload_name} failed during warmup iteration {iteration}") from e
279297
if self._config.device.startswith("cuda"):
280298
self._sync_device()
281299

282-
# Timing phase
283-
times = []
284-
for _ in range(self._config.num_iterations):
285-
inputs = generator(self._config)
300+
times: list[float] = []
301+
for iteration in range(self._config.num_iterations):
302+
try:
303+
prepare()
304+
except Exception as e:
305+
raise RuntimeError(f"{workload_name} failed during timed preparation iteration {iteration}") from e
286306

287-
# Sync before timing
288307
if self._config.device.startswith("cuda"):
289308
self._sync_device()
290309

291-
# Time the method
292-
start_time = time.perf_counter()
310+
start_time = time.perf_counter_ns()
293311
try:
294-
_ = method(**inputs)
295-
except Exception:
296-
continue
312+
operation()
313+
except Exception as e:
314+
raise RuntimeError(f"{workload_name} failed during timed iteration {iteration}") from e
297315

298-
# Sync after to ensure kernel completion
299316
if self._config.device.startswith("cuda"):
300317
self._sync_device()
301318

302-
end_time = time.perf_counter()
303-
times.append((end_time - start_time) * 1e6) # Convert to microseconds
304-
305-
if not times:
306-
return {"skipped": True, "skip_reason": "No successful iterations"}
319+
end_time = time.perf_counter_ns()
320+
times.append((end_time - start_time) / 1e3)
307321

308322
return {
309-
"mean": float(np.mean(times)),
310-
"std": float(np.std(times)),
323+
"mean": statistics.mean(times),
324+
"std": statistics.stdev(times) if len(times) > 1 else 0.0,
311325
"n": len(times),
312326
}
313327

314328
def _sync_device(self) -> None:
315329
"""Synchronize GPU device."""
316-
try:
317-
import warp as wp
318-
319-
wp.synchronize()
320-
except ImportError:
321-
pass
330+
import warp as wp
322331

323-
if torch.cuda.is_available():
324-
torch.cuda.synchronize()
332+
wp.synchronize_device(self._config.device)
325333

326334
def run_property_benchmarks(
327335
self,
@@ -408,66 +416,23 @@ def _benchmark_property(
408416
Dict with timing results, or None if property not found.
409417
"""
410418
# Check if property exists
411-
if not hasattr(target_data, prop_name):
419+
if inspect.getattr_static(target_data, prop_name, None) is None:
412420
return None
413421

414-
# Try to access the property once to check for errors
422+
def prepare() -> None:
423+
gen_mock_data(self._config)
424+
for dependency in dependencies:
425+
getattr(target_data, dependency)
426+
427+
def operation() -> object:
428+
return getattr(target_data, prop_name)
429+
415430
try:
416431
gen_mock_data(self._config)
417-
_ = getattr(target_data, prop_name)
432+
operation()
418433
except NotImplementedError as e:
419434
return {"skipped": True, "skip_reason": f"NotImplementedError: {e}"}
420435
except Exception as e:
421-
return {"skipped": True, "skip_reason": f"Error: {type(e).__name__}: {e}"}
422-
423-
# Warmup phase
424-
for _ in range(self._config.warmup_steps):
425-
try:
426-
gen_mock_data(self._config)
427-
# Access dependencies first
428-
for dep in dependencies:
429-
with contextlib.suppress(Exception):
430-
_ = getattr(target_data, dep)
431-
_ = getattr(target_data, prop_name)
432-
except Exception:
433-
pass
434-
# Sync GPU
435-
if self._config.device.startswith("cuda"):
436-
self._sync_device()
437-
438-
# Timing phase
439-
times = []
440-
for _ in range(self._config.num_iterations):
441-
gen_mock_data(self._config)
436+
raise RuntimeError(f"{prop_name} failed during preflight") from e
442437

443-
# Access dependencies first (not timed)
444-
for dep in dependencies:
445-
with contextlib.suppress(Exception):
446-
_ = getattr(target_data, dep)
447-
448-
# Sync before timing
449-
if self._config.device.startswith("cuda"):
450-
self._sync_device()
451-
452-
# Time the property access
453-
start_time = time.perf_counter()
454-
try:
455-
_ = getattr(target_data, prop_name)
456-
except Exception:
457-
continue
458-
459-
# Sync after
460-
if self._config.device.startswith("cuda"):
461-
self._sync_device()
462-
463-
end_time = time.perf_counter()
464-
times.append((end_time - start_time) * 1e6) # microseconds
465-
466-
if not times:
467-
return {"skipped": True, "skip_reason": "No successful iterations"}
468-
469-
return {
470-
"mean": float(np.mean(times)),
471-
"std": float(np.std(times)),
472-
"n": len(times),
473-
}
438+
return self._collect_samples(prop_name, prepare, operation)

0 commit comments

Comments
 (0)