668 lines
25 KiB
Python
668 lines
25 KiB
Python
"""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 PySide6.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())
|