feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、 元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py` (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2 (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验; `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码; `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因; 工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe, `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁), 新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与 `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。 - Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性) + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准, 而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。 - Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/ compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过 (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失; 真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0, 改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内), 20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms); 长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min, 队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、 跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、 平台锁定不是终态诊断。 - Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档); GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象); `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra `protected-assets`)。
This commit is contained in:
@@ -0,0 +1,667 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user