Files
ykcheungab b1cd37148c 删掉回放的五个转发、重复的空格快捷键与重复的等待循环。
回放调用点直接走 replay_ctl,场景工具的路径引导收敛到 _paths,panel_snapshot
复用 panel_check 的 _wait_for,report_from 收成模块私有的 _report_from。
2026-10-07 12:25:48 +08:00

665 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
# Tests load this tool by file location, so add our own directory to the path.
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _paths import bootstrap_paths # noqa: E402
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,
)
from orisys_app_shared.scene3d.digest import (
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 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
from PySide6.QtWidgets import QApplication
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 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)
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())