Skip to content

Commit 42a61be

Browse files
committed
add testing for fps for renderers
1 parent 6d47c89 commit 42a61be

3 files changed

Lines changed: 146 additions & 0 deletions

File tree

docs/testing.rst

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,65 @@ attributes and methods:
175175
output is copied **to** the baseline instead of compared, making it
176176
easy to regenerate references.
177177

178+
.. method:: assert_min_fps(scene, min_fps=60, *, frames=20, warmup=5, label=None)
179+
180+
Assert that a scene renders at a minimum frame rate.
181+
182+
Once a scene is fully initialized, rendering a frame is just submitting
183+
GPU commands — there should be no expensive CPU recomputation per frame.
184+
This method catches regressions where work accidentally leaks into the
185+
per-frame path (e.g. a CoefficientFunction being re-evaluated on every
186+
render call, or a pipeline being recreated each frame).
187+
188+
**How it works:**
189+
190+
1. Renders *warmup* frames (discarded) to let pipelines compile and
191+
caches fill.
192+
2. Renders *frames* frames and records each frame time.
193+
3. Asserts that the **median** frame time is below ``1 / min_fps``
194+
seconds.
195+
196+
Using the median (not mean) makes the check robust against a single GC
197+
pause or OS scheduling hiccup without masking real regressions.
198+
199+
**Choosing** ``min_fps``:
200+
201+
+-----------------------------------------+----------------+
202+
| Scene type | Recommended |
203+
+=========================================+================+
204+
| Simple/medium (mesh, CF, vectors, 2D) | ``60`` (default) |
205+
+-----------------------------------------+----------------+
206+
| Heavy (3D + clipping, large meshes) | ``20`` |
207+
+-----------------------------------------+----------------+
208+
209+
**Parameters:**
210+
211+
:param scene: The scene to render (must already be initialized with a
212+
canvas).
213+
:param min_fps: Minimum acceptable frames per second (median).
214+
:param frames: Number of frames to measure after warmup.
215+
:param warmup: Number of frames to discard before measurement.
216+
:param label: A descriptive label included in the failure message.
217+
218+
**Example usage:**
219+
220+
.. code-block:: python
221+
222+
def test_my_scene(self, webgpu_env):
223+
webgpu_env.ensure_canvas(600, 600)
224+
scene = Draw(ngs.x * ngs.y, mesh, width=600, height=600)
225+
226+
webgpu_env.assert_min_fps(scene, min_fps=60, label="scalar CF")
227+
webgpu_env.assert_matches_baseline(scene, "my_scene.png")
228+
229+
**Failure message example:**
230+
231+
.. code-block:: text
232+
233+
Scene [scalar CF] too slow: 0.8 fps (median frame 1250.3ms),
234+
minimum required: 60 fps (17ms/frame).
235+
Frame times: min=1180.2ms, max=1320.1ms, p90=1290.5ms
236+
178237
179238
Quick start for downstream packages
180239
------------------------------------

tests/test_webgpu.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,5 @@ def test_draw_triangulation_readback(self, webgpu_env):
104104
renderer = self._make_triangle_renderer()
105105
scene = webgpu_env.wj.Draw([renderer], width=400, height=400)
106106

107+
webgpu_env.assert_min_fps(scene, min_fps=60, label="single triangle")
107108
webgpu_env.assert_matches_baseline(scene, "triangle.png")

webgpu/testing.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,92 @@ def readback_texture(self, scene, path):
282282
img.save(str(path))
283283
return path
284284

285+
def assert_min_fps(self, scene, min_fps=60, *, frames=20, warmup=5, label=None):
286+
"""Assert a scene can render at least *min_fps* frames per second.
287+
288+
This is designed to catch performance regressions (e.g. a
289+
CoefficientFunction being re-evaluated every frame, or an O(n²)
290+
loop that should be O(n)).
291+
292+
Strategy
293+
--------
294+
- Renders *warmup* frames to let JIT/caches/pipelines stabilize.
295+
- Then renders *frames* frames and measures the **median** frame time.
296+
- Asserts that the median frame time is below ``1/min_fps`` seconds.
297+
- Uses median (not mean) so a single GC pause or scheduling hiccup
298+
doesn't cause a false failure.
299+
300+
Choosing ``min_fps``
301+
--------------------
302+
Once a scene is initialized, rendering a frame is just submitting
303+
GPU commands — there is no reason for it to be slow unless something
304+
is being recomputed on the CPU each frame.
305+
306+
- Simple/medium scenes (mesh, CF, vectors): ``min_fps=60`` (default)
307+
- Heavy scenes (3D + clipping, high-order, large meshes): ``min_fps=20``
308+
309+
Parameters
310+
----------
311+
scene : Scene
312+
The scene to render (must already be initialized with a canvas).
313+
min_fps : float
314+
Minimum acceptable frames per second (median).
315+
frames : int
316+
Number of frames to measure (after warmup).
317+
warmup : int
318+
Number of frames to discard before measurement.
319+
label : str, optional
320+
A descriptive label for the assertion error message.
321+
"""
322+
import statistics
323+
import time
324+
325+
assert scene is not None, "Scene is None"
326+
assert scene.canvas is not None, "Scene has no canvas"
327+
328+
# Warmup: pipeline creation, shader compilation, buffer uploads
329+
for _ in range(warmup):
330+
with scene._render_mutex:
331+
scene._render_objects(to_canvas=False)
332+
333+
# Measure individual frame times
334+
frame_times = []
335+
for _ in range(frames):
336+
t0 = time.perf_counter()
337+
with scene._render_mutex:
338+
scene._render_objects(to_canvas=False)
339+
frame_times.append(time.perf_counter() - t0)
340+
341+
median_time = statistics.median(frame_times)
342+
measured_fps = 1.0 / median_time if median_time > 0 else float("inf")
343+
max_frame_time = 1.0 / min_fps
344+
345+
desc = f" [{label}]" if label else ""
346+
assert median_time <= max_frame_time, (
347+
f"Scene{desc} too slow: {measured_fps:.1f} fps "
348+
f"(median frame {median_time*1000:.1f}ms), "
349+
f"minimum required: {min_fps} fps ({max_frame_time*1000:.0f}ms/frame). "
350+
f"Frame times: min={min(frame_times)*1000:.1f}ms, "
351+
f"max={max(frame_times)*1000:.1f}ms, "
352+
f"p90={sorted(frame_times)[int(len(frame_times)*0.9)]*1000:.1f}ms"
353+
)
354+
355+
356+
def assert_min_fps(webgpu_env, scene, min_fps=60, *, frames=20, warmup=5, label=None):
357+
"""Standalone convenience wrapper around WebGPUTestEnv.assert_min_fps.
358+
359+
Use this in parametrized tests or when you want a functional call style::
360+
361+
from webgpu.testing import assert_min_fps
362+
363+
def test_my_scene(webgpu_env):
364+
scene = build_scene()
365+
assert_min_fps(webgpu_env, scene, min_fps=60, label="basic triangle")
366+
367+
See :meth:`WebGPUTestEnv.assert_min_fps` for parameter docs.
368+
"""
369+
webgpu_env.assert_min_fps(scene, min_fps, frames=frames, warmup=warmup, label=label)
370+
285371

286372
# ---------------------------------------------------------------------------
287373
# Pytest fixtures

0 commit comments

Comments
 (0)