Skip to content

Commit e6b1b21

Browse files
Add timer utility (#1146)
## Summary Add a reusable timer utility ## Detailed description - Extract Alex's timer utility and focused tests from #1145. - Provide named wall-time statistics, NVTX ranges, optional CUDA synchronization, table output, and JSON helpers. - Leave experiment, policy, video, and OSMO instrumentation out of this PR. - Existing runtime behavior is unchanged until callers adopt the utility. --------- Signed-off-by: alex <amillane@nvidia.com> Signed-off-by: Clemens Volk <cvolk@nvidia.com> Co-authored-by: alex <amillane@nvidia.com>
1 parent 6cb3831 commit e6b1b21

2 files changed

Lines changed: 586 additions & 0 deletions

File tree

isaaclab_arena/tests/test_timer.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Unit tests for the Timer context manager and TimerStats."""
7+
8+
import json
9+
import random
10+
11+
import pytest
12+
13+
import isaaclab_arena.utils.timer as timer_module
14+
from isaaclab_arena.utils.timer import (
15+
Timer,
16+
TimerStats,
17+
get_timer_stats,
18+
get_timer_stats_json,
19+
merge_timer_stats_json,
20+
print_timer_stats,
21+
reset_timer_stats,
22+
write_timer_stats_json,
23+
)
24+
25+
26+
class TestTimerStats:
27+
"""Tests for the TimerStats dataclass."""
28+
29+
def test_initial_state(self) -> None:
30+
"""Verify default state of a fresh TimerStats."""
31+
stats = TimerStats()
32+
assert stats.count == 0
33+
assert stats.total_ms == 0.0
34+
assert stats.min_ms == float("inf")
35+
assert stats.max_ms == float("-inf")
36+
assert stats.mean_ms == 0.0
37+
38+
def test_accumulation(self) -> None:
39+
"""Verify min/max/mean/total after multiple measurements."""
40+
stats = TimerStats()
41+
stats.update(10.0)
42+
stats.update(20.0)
43+
stats.update(30.0)
44+
assert stats.count == 3
45+
assert stats.total_ms == 60.0
46+
assert stats.min_ms == 10.0
47+
assert stats.max_ms == 30.0
48+
assert stats.mean_ms == pytest.approx(20.0)
49+
50+
def test_percentile_empty(self) -> None:
51+
"""Verify percentile returns None when no data recorded."""
52+
stats = TimerStats()
53+
assert stats.percentile(50) is None
54+
55+
def test_percentile_values(self) -> None:
56+
"""Verify approximate percentiles on a known distribution."""
57+
stats = TimerStats()
58+
for i in range(1, 101):
59+
stats.update(float(i))
60+
assert stats.percentile(10) == 10.0
61+
assert stats.percentile(50) == 50.0
62+
assert stats.percentile(90) == 90.0
63+
64+
def test_reservoir_sampling_does_not_modify_global_random_state(self) -> None:
65+
"""Verify reservoir sampling does not affect application randomness."""
66+
stats = TimerStats(percentile_approximation_reservoir_size=1)
67+
global_random_state = random.getstate()
68+
69+
stats.update(1.0)
70+
stats.update(2.0)
71+
72+
assert random.getstate() == global_random_state
73+
74+
def test_reservoir_accuracy(self, monkeypatch: pytest.MonkeyPatch) -> None:
75+
"""Verify reservoir sampling percentiles track the exact ones on skewed data."""
76+
seed = 32
77+
monkeypatch.setattr(timer_module, "_reservoir_random_generator", random.Random(seed))
78+
synthetic_data_random_generator = random.Random(seed)
79+
num_samples = 10_000
80+
81+
values = [synthetic_data_random_generator.gauss(20.0, 5.0) for _ in range(num_samples)]
82+
# Sprinkle in ~1% outliers.
83+
for _ in range(num_samples // 100):
84+
values.append(synthetic_data_random_generator.uniform(80.0, 200.0))
85+
86+
exact = TimerStats(percentile_approximation_reservoir_size=len(values))
87+
approx = TimerStats()
88+
for value in values:
89+
exact.update(value)
90+
approx.update(value)
91+
92+
for p in (10, 50, 90):
93+
assert approx.percentile(p) == pytest.approx(exact.percentile(p), rel=0.05)
94+
95+
96+
class TestTimer:
97+
"""Tests for the Timer context manager."""
98+
99+
def setup_method(self) -> None:
100+
"""Reset timer registry before each test."""
101+
reset_timer_stats()
102+
103+
def test_basic_timing(self) -> None:
104+
"""Verify a single timer records one measurement."""
105+
with Timer("test_op"):
106+
pass
107+
108+
stats = get_timer_stats()
109+
assert "test_op" in stats
110+
assert stats["test_op"].count == 1
111+
assert stats["test_op"].total_ms >= 0.0
112+
113+
def test_multiple_names(self) -> None:
114+
"""Verify distinct timer names produce separate stats."""
115+
with Timer("op_a"):
116+
pass
117+
with Timer("op_b"):
118+
pass
119+
120+
stats = get_timer_stats()
121+
assert stats["op_a"].count == 1
122+
assert stats["op_b"].count == 1
123+
124+
def test_accumulation(self) -> None:
125+
"""Verify repeated use of the same timer name accumulates."""
126+
for _ in range(5):
127+
with Timer("repeated"):
128+
pass
129+
130+
assert get_timer_stats()["repeated"].count == 5
131+
132+
def test_nesting(self) -> None:
133+
"""Verify nested timers both record and outer >= inner."""
134+
with Timer("outer"):
135+
with Timer("inner"):
136+
pass
137+
138+
stats = get_timer_stats()
139+
assert stats["outer"].total_ms >= stats["inner"].total_ms
140+
141+
def test_exception_propagation(self) -> None:
142+
"""Verify exceptions propagate and stats are still recorded."""
143+
with pytest.raises(ValueError, match="test error"):
144+
with Timer("failing_op"):
145+
raise ValueError("test error")
146+
147+
assert get_timer_stats()["failing_op"].count == 1
148+
149+
def test_timer_returns_self(self) -> None:
150+
"""Verify the context manager yields the Timer instance."""
151+
with Timer("self_test") as timer:
152+
assert timer.name == "self_test"
153+
154+
155+
class TestPrintTimerStats:
156+
"""Tests for print_timer_stats output formatting."""
157+
158+
def setup_method(self) -> None:
159+
"""Reset timer registry before each test."""
160+
reset_timer_stats()
161+
162+
def test_empty_stats(self, capsys: pytest.CaptureFixture[str]) -> None:
163+
"""Verify output when no timers have been recorded."""
164+
print_timer_stats()
165+
assert "No timer stats recorded." in capsys.readouterr().out
166+
167+
def test_output_format(self, capsys: pytest.CaptureFixture[str]) -> None:
168+
"""Verify the printed table has header, separator, and data row."""
169+
with Timer("my_operation"):
170+
pass
171+
172+
print_timer_stats()
173+
lines = capsys.readouterr().out.strip().split("\n")
174+
assert len(lines) == 4 # units line, header, separator, one data row
175+
assert "Name" in lines[1]
176+
assert "p50" in lines[1]
177+
assert "my_operation" in lines[3]
178+
179+
180+
class TestGetTimerStatsJson:
181+
"""Tests for get_timer_stats_json."""
182+
183+
def setup_method(self) -> None:
184+
"""Reset timer registry before each test."""
185+
reset_timer_stats()
186+
187+
def test_records(self) -> None:
188+
"""Verify one record per timer, each tagged with the app name and expected keys."""
189+
with Timer("op_a"):
190+
pass
191+
with Timer("op_b"):
192+
pass
193+
194+
records = get_timer_stats_json(app_name="test_app")
195+
assert len(records) == 2
196+
assert all(record["app_name"] == "test_app" for record in records)
197+
assert all(record["type"] == "timing" for record in records)
198+
assert set(records[0].keys()) == {
199+
"type",
200+
"name",
201+
"app_name",
202+
"count",
203+
"mean_ms",
204+
"total_ms",
205+
"min_ms",
206+
"max_ms",
207+
"p10_ms",
208+
"p50_ms",
209+
"p90_ms",
210+
}
211+
212+
213+
class TestMergeTimerStatsJson:
214+
"""Tests for merge_timer_stats_json."""
215+
216+
@staticmethod
217+
def _record(name: str, count: int, total_ms: float, min_ms: float, max_ms: float) -> dict:
218+
return {
219+
"type": "timing",
220+
"name": name,
221+
"app_name": "some_app",
222+
"count": count,
223+
"mean_ms": total_ms / count,
224+
"total_ms": total_ms,
225+
"min_ms": min_ms,
226+
"max_ms": max_ms,
227+
"p50_ms": min_ms,
228+
}
229+
230+
def test_empty_input(self) -> None:
231+
"""Verify merging nothing produces nothing."""
232+
assert merge_timer_stats_json([]) == []
233+
234+
def test_combines_records_that_share_a_name(self) -> None:
235+
"""Verify counts and totals add while min and max span every record."""
236+
merged = merge_timer_stats_json([
237+
self._record("step", count=2, total_ms=10.0, min_ms=3.0, max_ms=7.0),
238+
self._record("step", count=3, total_ms=30.0, min_ms=1.0, max_ms=20.0),
239+
])
240+
241+
assert merged == [{"name": "step", "count": 5, "total_ms": 40.0, "min_ms": 1.0, "max_ms": 20.0, "mean_ms": 8.0}]
242+
243+
def test_separate_names_sorted(self) -> None:
244+
"""Verify distinct names stay separate and come back sorted by name."""
245+
merged = merge_timer_stats_json([
246+
self._record("b_op", count=1, total_ms=2.0, min_ms=2.0, max_ms=2.0),
247+
self._record("a_op", count=1, total_ms=1.0, min_ms=1.0, max_ms=1.0),
248+
])
249+
250+
assert [record["name"] for record in merged] == ["a_op", "b_op"]
251+
252+
def test_percentiles_are_dropped(self) -> None:
253+
"""Verify percentile fields are not carried into the merged record."""
254+
merged = merge_timer_stats_json([self._record("step", count=1, total_ms=5.0, min_ms=5.0, max_ms=5.0)])
255+
256+
assert set(merged[0]) == {"name", "count", "total_ms", "min_ms", "max_ms", "mean_ms"}
257+
258+
def test_merges_recorded_stats(self) -> None:
259+
"""Verify merging one process's own records reproduces its totals."""
260+
reset_timer_stats()
261+
for _ in range(3):
262+
with Timer("op"):
263+
pass
264+
265+
records = get_timer_stats_json(app_name="test_app")
266+
merged = merge_timer_stats_json(records)
267+
assert merged[0]["count"] == 3
268+
assert merged[0]["total_ms"] == pytest.approx(records[0]["total_ms"])
269+
270+
271+
class TestWriteTimerStatsJson:
272+
"""Tests for write_timer_stats_json."""
273+
274+
def setup_method(self) -> None:
275+
"""Reset timer registry before each test."""
276+
reset_timer_stats()
277+
278+
def test_written_file_round_trips(self, tmp_path) -> None:
279+
"""Verify the written file parses back to the in-memory records."""
280+
with Timer("op_a"):
281+
pass
282+
283+
output_path = write_timer_stats_json(tmp_path / "timings.json", app_name="test_app")
284+
written_records = json.loads(output_path.read_text(encoding="utf-8"))
285+
assert written_records == get_timer_stats_json(app_name="test_app")
286+
assert written_records[0]["name"] == "op_a"
287+
288+
def test_empty_registry_writes_empty_list(self, tmp_path) -> None:
289+
"""Verify a file is still written when no timers were recorded."""
290+
output_path = write_timer_stats_json(tmp_path / "timings.json", app_name="test_app")
291+
assert json.loads(output_path.read_text(encoding="utf-8")) == []
292+
293+
294+
class TestResetTimerStats:
295+
"""Tests for reset_timer_stats."""
296+
297+
def test_reset_clears_all(self) -> None:
298+
"""Verify reset removes all recorded stats and new timers start fresh."""
299+
with Timer("first_run"):
300+
pass
301+
302+
assert len(get_timer_stats()) > 0
303+
reset_timer_stats()
304+
assert len(get_timer_stats()) == 0
305+
306+
with Timer("second_run"):
307+
pass
308+
309+
stats = get_timer_stats()
310+
assert "first_run" not in stats
311+
assert stats["second_run"].count == 1

0 commit comments

Comments
 (0)