"""Measure the 3D view's cost on this machine, per stage and as a percentile. Stage 5 deliverable (``docs/architecture/studio-3d-stages.md``): the design document's performance row asks for p50/p95 of the *compose*, the *upload*, the *draw* and the total response latency, reported together with the resolution, the mesh and the GL context they were measured on. A single average would hide exactly the thing that matters here — the tail is where a display pipeline either keeps its frame rate or visibly stutters. What is measured, and why each one is separate: ``submit_ms`` what the **acquisition thread** pays per frame. This is the only number that can damage the measurement, so it is measured with the composer deliberately slowed down: the display slowing must not move this number at all. ``compose_ms`` the CPU worker: sample the UV field onto the vertices, deform, recompute normals. ``upload_ms`` the GPU buffer update for the sensing surface. ``paint_ms`` Qt's paint, including the widget's own bookkeeping. ``latency_ms`` from ``submit`` to the frame being on screen — the number an operator actually perceives, and the one that includes every queue in between. Run it with a real platform (offscreen cannot create a GL context):: QT_QPA_PLATFORM=windows python tools/scene_assets/bench_scene.py \\ --frames 600 --output docs/architecture/perf-report-windows-dev.json Exit codes: 0 = measured on a GPU, 3 = software renderer, 4 = no usable context. """ from __future__ import annotations import argparse import json import sys import time from pathlib import Path def _bootstrap_paths() -> None: here = Path(__file__).resolve() repo = here.parents[2] for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"): if path.is_dir() and str(path) not in sys.path: sys.path.insert(0, str(path)) def _percentiles(values: list[float]) -> dict[str, float]: if not values: return {"p50": 0.0, "p95": 0.0, "max": 0.0, "mean": 0.0, "n": 0} ordered = sorted(values) def pick(fraction: float) -> float: index = max(0, min(len(ordered) - 1, int(round(fraction * len(ordered))) - 1)) return float(ordered[index]) return { "p50": round(pick(0.50), 4), "p95": round(pick(0.95), 4), "max": round(float(ordered[-1]), 4), "mean": round(sum(ordered) / len(ordered), 4), "n": len(ordered), } def _grid_scene(target_vertices: int): """A plane grid with a bilinear binding, for the design document's budget. The design's performance row is written against "about 20k vertices and 40k triangles" for the sensing surface, and the shipped finger export is 1032 — so a report that only measured the PoC scene would answer a question nobody asked. This builds a grid of roughly ``target_vertices`` vertices whose UV domain *is* the grid, with the same measured pixel convention the real binding uses (``x = u(W-1)``, ``y = (1-v)(H-1)``) and a genuine four-tap bilinear table, so the sampling cost in the report is the real one rather than a nearest-neighbour stand-in. """ import numpy as np from orisys_app_shared.scene3d.contracts import ( CameraPreset, Material, MeshPart, SceneAssets, SurfaceBinding, freeze, ) from orisys_app_shared.scene3d.digest import ( field_domain_id as compute_domain_id, geometry_id as compute_geometry_id, ) from orisys_app_shared.scene3d.legacy import vertex_normals columns = max(2, int(round(target_vertices ** 0.5))) rows = max(2, int(round(target_vertices / columns))) count = rows * columns xs = np.linspace(-0.008, 0.008, columns, dtype=np.float64) ys = np.linspace(-0.008, 0.008, rows, dtype=np.float64) grid_x, grid_y = np.meshgrid(xs, ys) # A gentle dome, so the normals are not all identical and the shading path is used. radius = np.sqrt(grid_x**2 + grid_y**2) z = 0.001 * np.cos(np.clip(radius / 0.008, 0.0, 1.0) * np.pi / 2.0) positions = np.stack([grid_x, grid_y, z], axis=-1).reshape(-1, 3).astype(np.float32) faces: list[tuple[int, int, int]] = [] for i in range(rows - 1): for j in range(columns - 1): a = i * columns + j b = a + 1 c = a + columns d = c + 1 faces.append((a, c, b)) faces.append((b, c, d)) triangles = np.asarray(faces, dtype=np.uint32) normals = vertex_normals(positions.astype(np.float64), triangles.astype(np.int64)) height, width = rows, columns index = np.arange(count, dtype=np.int64) row_of = index // columns column_of = index % columns # Vertex (row, column) sits *between* pixels, so every vertex needs four taps. u = (column_of + 0.5) / columns v = 1.0 - (row_of + 0.5) / rows x = u * (width - 1) y = v * (height - 1) x0 = np.clip(np.floor(x).astype(np.int64), 0, width - 2) y0 = np.clip(np.floor(y).astype(np.int64), 0, height - 2) x1, y1 = x0 + 1, y0 + 1 fx = (x - x0).astype(np.float32) fy = (y - y0).astype(np.float32) indices = np.stack( [ y0 * width + x0, y0 * width + x1, y1 * width + x0, y1 * width + x1, ], axis=-1, ).astype(np.int32) weights = np.stack( [ (1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy, ], axis=-1, ).astype(np.float32) domain_id = compute_domain_id(shape_hw=(height, width), flip_v=True, source_scale=1.0) preset = CameraPreset( name="bench", eye_sensor_m=(0.02, -0.02, 0.03), target_sensor_m=(0.0, 0.0, 0.0), up_sensor=(0.0, 0.0, 1.0), vertical_fov_rad=0.7, ) part = MeshPart( part_id="sensing_surface", role="sensing", positions_sensor_m=freeze(positions, dtype=np.float32), normals_sensor=freeze(normals, dtype=np.float32), triangles=freeze(triangles, dtype=np.uint32), material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True), lod_id="lod0", ) geometry = compute_geometry_id((part,)) binding = SurfaceBinding( part_id=part.part_id, geometry_id=geometry, field_domain_id=domain_id, shape_hw=(height, width), sample_indices=freeze(indices, dtype=np.int32), sample_weights=freeze(weights, dtype=np.float32), ) bounds = np.stack([positions.min(axis=0), positions.max(axis=0)]).astype(np.float32) return SceneAssets( asset_id="bench-grid", asset_version="0", asset_hash=domain_id, geometry_id=geometry, parts=(part,), binding=binding, camera_presets=(preset,), bounds_sensor_m=freeze(bounds, dtype=np.float32), field_domain_id=domain_id, shape_hw=(height, width), ) def measure_cpu( *, frames: int, vertices: int = 0, package: Path | None = None, key_file: Path | None = None, key_id: str = "", verify_key: Path | None = None, ) -> tuple[dict, int]: """The composer's own cost, with no window in the process. This is the mode to quote for "how long does one frame take": in the GUI run the worker thread and the GUI thread compete for the GIL and for a vsync-throttled swap, so the composer's *wall* timers pick up 15-17 ms of waiting that has nothing to do with the arithmetic. Removing Qt from the process removes the contention, and what is left is the number an LOD decision should be made on. """ import numpy as np from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( DisplaySettings, FieldDescriptor, FrameIdentity, SurfaceFieldFrame, freeze, ) report: dict = { "mode": "cpu", "frames_requested": int(frames), "scene": ( f"package:{package}" if package is not None else (f"grid:{vertices}" if vertices else "synthetic-poc") ), } if package is not None: from orisys_app_shared.scene3d import protect from orisys_app_shared.scene3d.package import PackagedAssetLoader provider = ( protect.load_key_file(Path(key_file), key_id=key_id) if key_file is not None else None ) public = Path(verify_key).read_bytes() if verify_key is not None else None try: assets = PackagedAssetLoader().load( Path(package), None, provider, verify_key_pem=public ) except Exception as exc: # noqa: BLE001 report["verdict"] = "asset_rejected" report["reason"] = str(exc) return report, 4 elif vertices: assets = _grid_scene(int(vertices)) else: assets = synthetic.poc_scene() report["mesh"] = { "sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]), "sensing_triangles": int(assets.sensing_part().triangles.shape[0]), "field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])], } shape = tuple(assets.shape_hw) rng = np.random.default_rng(12345) scalar = freeze( (rng.random(shape) * 4.0).astype(np.float32), dtype=np.float32 ) valid = freeze(np.ones(shape, dtype=bool), dtype=np.bool_) frame = SurfaceFieldFrame( identity=FrameIdentity("bench", 1, 1, 0.0, generation=1), geometry_id=assets.geometry_id, field_domain_id=assets.field_domain_id, scalar=scalar, scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), valid=valid, ) composer = SceneComposer() settings = DisplaySettings() for _ in range(5): composer.compose(assets, frame, settings) totals: list[float] = [] samples: list[float] = [] normals: list[float] = [] walls: list[float] = [] for _ in range(int(frames)): started = time.perf_counter() composer.compose(assets, frame, settings) walls.append((time.perf_counter() - started) * 1000.0) stats = composer.last_stats totals.append(float(stats.total_ms)) samples.append(float(stats.sample_ms)) normals.append(float(stats.normals_ms)) report["timings_ms"] = { "compose": _percentiles(totals), "compose_sample": _percentiles(samples), "compose_normals": _percentiles(normals), "compose_wall": _percentiles(walls), } report["verdict"] = "ok" return report, 0 def measure( *, frames: int, profile: str, width: int, height: int, slow_compose_ms: float, max_fps: int, vertices: int = 0, package: Path | None = None, key_file: Path | None = None, key_id: str = "", verify_key: Path | None = None, ) -> tuple[dict, int]: """Run the assembled view and report what each stage cost.""" import numpy as np from PyQt5.QtWidgets import QApplication from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.view import Scene3DView configure_surface_format(profile) # type: ignore[arg-type] app = QApplication.instance() or QApplication(sys.argv) report: dict = { "profile_requested": profile, "frames_requested": int(frames), "viewport_px": [int(width), int(height)], "slow_compose_ms": float(slow_compose_ms), "max_fps_requested": int(max_fps), "scene": ( f"package:{package}" if package is not None else (f"grid:{vertices}" if vertices else "synthetic-poc") ), } view = Scene3DView() if not view.available: report["verdict"] = "no_view" report["reason"] = view.reason return report, 4 if package is not None: from orisys_app_shared.scene3d.package import PackagedAssetLoader from orisys_app_shared.scene3d import protect provider = ( protect.load_key_file(Path(key_file), key_id=key_id) if key_file is not None else None ) public = Path(verify_key).read_bytes() if verify_key is not None else None try: assets = PackagedAssetLoader().load( Path(package), None, provider, verify_key_pem=public ) except Exception as exc: # noqa: BLE001 report["verdict"] = "asset_rejected" report["reason"] = str(exc) view.close() return report, 4 elif vertices: assets = _grid_scene(int(vertices)) else: assets = synthetic.poc_scene() if not view.use_scene(assets, source_id="bench"): report["verdict"] = "asset_rejected" report["reason"] = str(view.last_diagnostic) return report, 4 view.resize(width, height) view.show() view.set_enabled(True) widget = view.gl_widget renderer = widget._renderer deadline = time.perf_counter() + 10.0 while time.perf_counter() < deadline and not renderer.part_ids: widget.repaint() app.processEvents() time.sleep(0.005) capabilities = widget.capabilities() if not renderer.part_ids or capabilities is None: report["verdict"] = "no_context" report["reason"] = str(widget.diagnostic()) view.close() return report, 4 report["context"] = { "profile_used": capabilities.profile, "vendor": capabilities.vendor, "renderer": capabilities.renderer, "gl_version": capabilities.gl_version, "glsl_version": capabilities.glsl_version, "software_renderer": bool(capabilities.software_renderer), "max_texture_size": int(capabilities.max_texture_size), "device_pixel_ratio": float(widget.devicePixelRatioF()), "qt_platform": capabilities.qt_platform, } report["mesh"] = { "sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]), "sensing_triangles": int(assets.sensing_part().triangles.shape[0]), "housing_triangles": int( assets.part("housing").triangles.shape[0] if assets.part("housing") else 0 ), "field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])], "estimated_gpu_bytes": int(getattr(renderer, "_estimated_gpu_bytes", 0) or 0), } coordinator = view.coordinator real_composer = coordinator._composer compose_records: list[tuple[float, float, float, float]] = [] compose_lock = __import__("threading").Lock() class Slow: """The composer, delayed: the point is that ``submit`` does not notice. The statistics are collected *here*, on the worker thread that produces them, immediately after ``compose`` returns. Reading ``last_stats`` from the GUI loop instead produces torn samples — the worker writes ``sample_ms``, then ``normals_ms``, then ``total_ms``, so a reader can see one frame's sampling next to the previous frame's normals, which reads as a plausible and completely wrong breakdown. """ def __init__(self, delay_s: float, inner) -> None: self._delay = delay_s self._inner = inner self.last_stats = getattr(inner, "last_stats", None) def compose(self, *args, **kwargs): started = time.perf_counter() result = self._inner.compose(*args, **kwargs) stats = getattr(self._inner, "last_stats", None) record = ( float(getattr(stats, "total_ms", 0.0)), float(getattr(stats, "sample_ms", 0.0)), float(getattr(stats, "normals_ms", 0.0)), (time.perf_counter() - started) * 1000.0, ) with compose_lock: compose_records.append(record) if self._delay: time.sleep(self._delay) return result def rest_frame(self, *args, **kwargs): return self._inner.rest_frame(*args, **kwargs) coordinator._composer = Slow(slow_compose_ms / 1000.0, real_composer) view.restore_settings(DisplaySettings(max_fps=max_fps)) shape = tuple(assets.shape_hw) ramp = np.linspace(0.0, 1.0, int(shape[0]) * int(shape[1]), dtype=np.float32) flow = np.stack([ramp, ramp], axis=-1).reshape(int(shape[0]), int(shape[1]), 2) class Packet: """A packet shaped like the finger adapter expects.""" def __init__(self, index: int) -> None: self.session_id = 1 self.frame_id = int(index) self.mono_t = float(index) / 30.0 self.flow = flow + (index % 7) * 0.01 self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8) # Measured by wrapping the two places that define the timeline: `present` is the # moment a composed frame becomes the one that should be on screen, and `paintGL` # is the moment it actually is. Anything measured by polling the widget's own # statistics would report the *last published* sample (they are rate-limited to one # per 500 ms), which is how a benchmark ends up reporting a constant p50 for a # stage it never sampled. submits: list[float] = [] latencies: list[float] = [] composes: list[float] = [] samples: list[float] = [] normals: list[float] = [] uploads: list[float] = [] paints: list[float] = [] submitted_at: dict[int, float] = {} presented_at: dict[int, float] = {} original_present = widget.present original_paint = widget.paintGL def present(frame): presented_at[int(frame.identity.frame_id)] = time.perf_counter() original_present(frame) def paint(): frame = widget._frame # the frame this paint is about to consume started = time.perf_counter() original_paint() paints.append((time.perf_counter() - started) * 1000.0) if frame is not None: key = int(frame.identity.frame_id) if key in submitted_at: latencies.append((time.perf_counter() - submitted_at[key]) * 1000.0) widget.present = present widget.paintGL = paint try: for index in range(int(frames)): started = time.perf_counter() view.submit_packet(Packet(index)) submits.append((time.perf_counter() - started) * 1000.0) submitted_at[index] = started view.tick() app.processEvents() # Synchronous paint, the way the GL tests drive the widget: a queued # `update()` may not be delivered by `processEvents()` for a GL widget, # which would leave every paint-stage number at zero. widget.repaint() uploads.append(float(getattr(renderer, "_last_upload_ms", 0.0) or 0.0)) finally: widget.present = original_present widget.paintGL = original_paint coordinator._composer = real_composer view.close() app.processEvents() from orisys_studio.scene3d.coordinator import MAX_INFLIGHT_COMPOSES with compose_lock: records = list(compose_records) composes = [item[0] for item in records] samples = [item[1] for item in records] normals = [item[2] for item in records] compose_wall = [item[3] for item in records] coordinator_stats = { "superseded_display_frames": int(coordinator.superseded_frames), "stale_frames": int(coordinator.stale_frames), "invalid_frames": int(coordinator.invalid_frames), "max_inflight_composes": int(MAX_INFLIGHT_COMPOSES), "mailbox_capacity": 1, "frames_presented": len(presented_at), } report["timings_ms"] = { "submit": _percentiles(submits), "compose": _percentiles(composes), # Which half of the compose: on a large sensing surface one of the two # dominates, and "compose" alone cannot say which. "compose_sample": _percentiles(samples), "compose_normals": _percentiles(normals), # Wall clock inside the composer, which includes the artificial delay: the # difference between this and ``compose`` is the delay the caller asked for. "compose_wall": _percentiles(compose_wall), "upload": _percentiles(uploads), "paint": _percentiles(paints), "latency": _percentiles(latencies), } report["compose_count"] = len(records) report["compose_note"] = ( "compose/compose_sample/compose_normals are the composer's own timers, " "collected on the worker thread; compose_wall adds the requested delay. " "latency is submit-to-paint and therefore includes the frame-rate limiter." ) report["display"] = coordinator_stats report["crypto_backend"] = _crypto_version() if capabilities.software_renderer: report["verdict"] = "software_renderer" return report, 3 report["verdict"] = "ok" return report, 0 def _crypto_version() -> str: try: from orisys_app_shared.scene3d.package import backend_version return str(backend_version()) except Exception: # noqa: BLE001 return "unavailable" def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--frames", type=int, default=600, help="frames to submit") parser.add_argument("--profile", default="desktop33", choices=("desktop33", "gles30")) parser.add_argument("--size", default="900x700", help="WxH of the 3D widget") parser.add_argument( "--slow-compose-ms", type=float, default=50.0, help="artificial composer delay: the submit budget must not move with it", ) parser.add_argument("--max-fps", type=int, default=30) parser.add_argument( "--vertices", type=int, default=0, help="benchmark a generated grid of about this many sensing vertices " "(the design document's budget is 20k); 0 = the synthetic PoC scene", ) parser.add_argument( "--package", type=Path, default=None, help="benchmark a released .oryscene package instead of a synthetic scene", ) parser.add_argument("--key-file", type=Path, default=None, help="key for a sealed package") parser.add_argument("--key-id", default="", help="key id that --key-file holds") parser.add_argument("--verify-key", type=Path, default=None, help="public key for a signed package") parser.add_argument("--output", type=Path, default=None, help="write the report here") parser.add_argument( "--cpu-only", action="store_true", help="measure the composer with no window in the process (no vsync, no GIL " "contention): the number to quote for the per-frame CPU cost", ) parser.add_argument("--json", action="store_true") args = parser.parse_args(argv) _bootstrap_paths() width, _, height = args.size.partition("x") if args.cpu_only: report, code = measure_cpu( frames=args.frames, vertices=args.vertices, package=args.package, key_file=args.key_file, key_id=args.key_id, verify_key=args.verify_key, ) else: report, code = measure( frames=args.frames, profile=args.profile, width=int(width), height=int(height or 700), slow_compose_ms=args.slow_compose_ms, max_fps=args.max_fps, vertices=args.vertices, package=args.package, key_file=args.key_file, key_id=args.key_id, verify_key=args.verify_key, ) report["exit_code"] = int(code) text = json.dumps(report, indent=2, ensure_ascii=False, default=str) if args.output is not None: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(text + "\n", encoding="utf-8") if args.json or args.output is None: print(text) else: timings = report.get("timings_ms", {}) print( f"verdict={report['verdict']} frames={report['frames_requested']} " f"renderer={(report.get('context') or {}).get('renderer', '')}" ) for stage in ( "submit", "compose", "compose_sample", "compose_normals", "compose_wall", "upload", "paint", "latency", ): if stage in timings: item = timings[stage] print( f" {stage:8s} p50={item['p50']:8.3f} p95={item['p95']:8.3f} " f"max={item['max']:8.3f} n={item['n']}" ) print(f" wrote {args.output}") return code if __name__ == "__main__": sys.exit(main())