diff --git a/.gitignore b/.gitignore index b5d090184..8842f78b6 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,7 @@ scripts/tests/__pycache__/ # Local SwiftPM scratch directories other than .build (e.g. .build-strict for the strict-concurrency build). .build*/ + +# PerfBench run output (scripts/perf/run_bench.sh) +perf/results/* +!perf/results/.gitkeep diff --git a/Examples/PerfBench/.gitignore b/Examples/PerfBench/.gitignore new file mode 100644 index 000000000..3793b5ae9 --- /dev/null +++ b/Examples/PerfBench/.gitignore @@ -0,0 +1,4 @@ +*.xcodeproj +.build/ +DerivedData/ +results/ diff --git a/Examples/PerfBench/README.md b/Examples/PerfBench/README.md new file mode 100644 index 000000000..0e50f9e82 --- /dev/null +++ b/Examples/PerfBench/README.md @@ -0,0 +1,123 @@ +# PerfBench + +A benchmark app for the engine on macOS, iOS and visionOS. It builds a fixed set of scenes one after +another, warms each up, records every frame through the engine's stats recorder, and writes one +JSON Lines file per scene plus a `summary.json` for the run. `scripts/perf/run_bench.sh` builds it, +runs it on a Mac or a paired device, collects the run folder and compares it with the stored +baseline for that device model. + +The point is repeatability: the same scenes, the same drawable size, the same camera motion, the +engine stats and the GPU pass timer on, in a release build, so a number from before a change can be +compared with a number from after it. + +## Scenes + +| id | What it measures | +|---|---| +| `cube` | One lit, shadowed cube on a plane: the fixed per-frame cost of the pass chain, and the scene to judge image quality on a headset. | +| `primitives-1k` | 1024 individual cubes and spheres, 4 point lights, cascaded shadows: per-entity CPU cost and encoder overhead. | +| `primitives-10k` | 10 000 primitives merged by the static batcher: batched draw cost and light-loop cost at scale. | +| `lights-64` | 1024 batched primitives with 64 point lights: the per-pixel light loop. | +| `postfx` | The 1k grid with SSAO, bloom, depth of field and SMAA: the full-screen pass chain. | +| `animation-16` | 16 skinned characters running: the animation system and GPU skinning. | +| `stadium` | The stadium and grass assets: material binds and textures. | +| `gaussian` | A small Gaussian splat plus primitives: the splat cull, depth-key and radix-sort passes. | + +Assets come from `Tests/UntoldEngineRenderTests/Resources`; the project copies the `Models` and +`Animations` folders and the splat file into the app bundle. + +On macOS and iOS the camera orbits each scene (`BenchCameraOrbit`). On visionOS the head is the +camera, so the scenes are built three metres in front of the user and the orbit is ignored; keep +still during a run, or accept the head motion as part of the measurement. + +Frames are display-paced on every platform (the display's maximum refresh rate on macOS and iOS, +the compositor on visionOS), so a scene that fits in a frame reports the refresh interval as its +frame time. Read the cost of such a scene from `meanGPUExecutionMs`, the per-pass GPU means and the +per-system CPU times in the snapshots; frame time and the over-budget count become the signal only +when a scene no longer fits. A frame counts as over budget when it took more than one and a half +refresh intervals, that is when a refresh was skipped. + +On a Mac with several displays the bench opens on the fastest one, whichever screen has the +keyboard focus, or on one running at the rate given with `--refresh`. Times taken at different +refresh rates are not comparable (the GPU stretches its work over the slack a slower display +leaves), so the refresh rate is part of a baseline's configuration and the comparer skips a +baseline recorded at another rate. The Mac16,5 baseline is recorded at 120 Hz, on the built-in +display. The window shows the fixed 1920 by 1080 drawable one drawable pixel per device pixel, so +the window server does not rescale it on the GPU that is being measured. + +## Running + +```bash +brew install xcodegen # once + +scripts/perf/run_bench.sh macos # this Mac +scripts/perf/run_bench.sh visionos --device "Javier's Apple Vision Pro" +scripts/perf/run_bench.sh ios --device + +scripts/perf/run_bench.sh macos --scenes primitives-1k,postfx --seconds 10 +scripts/perf/run_bench.sh macos --update-baseline # store this run as the baseline +scripts/perf/run_bench.sh visionos --device --xctrace "Metal System Trace" +``` + +Runs land in `perf/results/-/` with `.jsonl`, `summary.json` and the +console log. The comparison prints one block per scene and exits non-zero on a regression (10 % +on frame-time, GPU-time and per-system CPU metrics, 0.5 points on the over-budget and +missed-deadline rates, 20 % on a per-pass GPU minimum). A GPU or CPU time also has to move by +more than an absolute slack to count (0.5 ms for a frame's minimum GPU time, 0.25 ms for a pass, +0.1 ms for a per-system CPU mean). + +Single runs are noisy on a machine that idles most of each frame: CPU and GPU clocks drift between +runs, other activity makes the window server skip refreshes, and the same build can differ by 20 +to 50 % in a per-pass or per-system time. `--repeat 3` runs the scene set three times and the +comparer aggregates by the minimum of each time metric and of each rate (interference only ever +inflates them, and a real regression raises them in every run); record the baseline the same way. + +What to trust on a Mac: the per-system CPU means (`timingMeanMs`) repeat within a few percent, and +so does everything in the CPU-bound scenes (`primitives-10k`). GPU times of the light scenes do +not: with the GPU idle most of the frame they follow its clock state, and one frame's minimum GPU +time measured 0.8 and 2.2 ms in consecutive runs of the same build. The comparer therefore judges +GPU times only when the baseline shows the GPU busy for at least half the frame, and prints them +as information otherwise; judge GPU cost on the headset, where the GPU is busy and run-to-run +noise is under 10 %. + +The app can also be opened in Xcode (`xcodegen generate`, then `PerfBench.xcodeproj`) and started +from its Start button; it reads its configuration from the environment: + +| Variable | Meaning | Default | +|---|---|---| +| `UNTOLD_BENCH_SCENES` | Comma-separated scene ids, or `all` | `all` | +| `UNTOLD_BENCH_WARMUP` | Seconds rendered before recording, per scene | `3` | +| `UNTOLD_BENCH_SECONDS` | Seconds recorded per scene | `15` | +| `UNTOLD_BENCH_OUTPUT` | Output directory | `Documents/PerfBench` | +| `UNTOLD_BENCH_RUN_ID` | Run folder name | timestamp | +| `UNTOLD_BENCH_LABEL` | Free text stored in the summary | empty | +| `UNTOLD_BENCH_KEEP_OPEN` | `1` keeps the app open after the last scene | exit | +| `UNTOLD_BENCH_AUTOSTART` | `0` waits for the Start button | start on launch | +| `UNTOLD_BENCH_IMMERSION` | `full` or `mixed` (visionOS) | `full` | +| `UNTOLD_BENCH_PER_FRAME` | `0` writes one line per second instead of per frame | per frame | +| `UNTOLD_BENCH_AA` | `fxaa`, `smaa`, `msaa` or `none` for every scene (the post-FX scene keeps SMAA) | engine default, FXAA | +| `UNTOLD_BENCH_REFRESH` | macOS: run on a connected display with this maximum refresh rate (`run_bench.sh --refresh`) | the fastest connected display | + +On a device the app exits when the run is done so that `devicectl ... --console` returns; the run +folder is then pulled from the app's Documents container. With the app opened from Xcode, set +`UNTOLD_BENCH_KEEP_OPEN=1` in the scheme to read the report on screen. + +## What a run records + +Every line of `.jsonl` is a full `EngineStatsSnapshot` (frame timing, per-system CPU time, +GPU pass times, culling, streaming, batching, memory, thermal state, compositor deadline margins on +visionOS, hitch histogram), and the last line is the `EngineStatsRecordingSummary` for the scene. +`summary.json` holds the device (model, OS, GPU), the render configuration (platform, viewport, +view count, texture layout, foveation, immersion) and every scene's summary and last snapshot. +See `docs/API/UsingProfiler.md` for the meaning of each field. + +## Reading a regression + +1. Frame p95/p99 up, GPU time flat: CPU-side. Open the run's `jsonl` and look at `timing`: which + system grew (`animationMs`, `scenegraphMs`, ...), or `encodeMs` for draw submission. +2. GPU time up: `gpuPasses` says which pass. Reproduce the scene in the app from Xcode and take a + GPU frame capture of that pass. +3. Missed deadlines up on visionOS with everything else flat: look at `compositor.inputSlackMs` + (late update phase) and `timing.semaphoreWaitMs` (GPU pacing the CPU), then a Metal System + Trace with `--xctrace`. +4. `worstThermalState` above 1: the device throttled; rerun cool before believing the numbers. diff --git a/Examples/PerfBench/Sources/PerfBenchCore/BenchConfig.swift b/Examples/PerfBench/Sources/PerfBenchCore/BenchConfig.swift new file mode 100644 index 000000000..055d18e34 --- /dev/null +++ b/Examples/PerfBench/Sources/PerfBenchCore/BenchConfig.swift @@ -0,0 +1,83 @@ +// +// BenchConfig.swift +// PerfBench +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation + +/// Run configuration, read from the environment so the driver script and `devicectl` can set it. +/// +/// | Variable | Meaning | Default | +/// |---|---|---| +/// | `UNTOLD_BENCH_SCENES` | Comma-separated scene ids, or `all` | `all` | +/// | `UNTOLD_BENCH_WARMUP` | Seconds rendered before recording starts, per scene | `3` | +/// | `UNTOLD_BENCH_SECONDS` | Seconds recorded per scene | `15` | +/// | `UNTOLD_BENCH_OUTPUT` | Output directory | `/PerfBench` | +/// | `UNTOLD_BENCH_RUN_ID` | Run folder name | timestamp | +/// | `UNTOLD_BENCH_LABEL` | Free text stored in the summary (commit, branch, note) | empty | +/// | `UNTOLD_BENCH_KEEP_OPEN` | `1` keeps the app running after the last scene | exit when done | +/// | `UNTOLD_BENCH_AUTOSTART` | `0` waits for the Start button (visionOS) | start on launch | +/// | `UNTOLD_BENCH_IMMERSION` | `full` or `mixed` (visionOS) | `full` | +/// | `UNTOLD_BENCH_PER_FRAME` | `0` records one line per second instead of per frame | per frame | +/// | `UNTOLD_BENCH_AA` | `fxaa`, `smaa`, `msaa` or `none`: anti-aliasing for every scene (the post-FX scene keeps SMAA) | engine default (FXAA) | +/// | `UNTOLD_BENCH_REFRESH` | macOS: run on a connected display with this maximum refresh rate, for example `120` | the fastest connected display | +public struct BenchConfig: Sendable { + public var sceneIDs: [String] + public var warmupSeconds: Double + public var measureSeconds: Double + public var outputDirectory: URL + public var runID: String + public var label: String + public var exitWhenDone: Bool + public var autoStart: Bool + public var immersion: String + public var perFrame: Bool + /// Anti-aliasing mode name from the environment, or nil to leave the engine's default. + public var antiAliasing: String? + /// Refresh rate of the display to run on (macOS), or nil for the fastest connected display. + public var refreshHz: Int? + + public static func fromEnvironment(_ environment: [String: String] = ProcessInfo.processInfo.environment) -> BenchConfig { + func double(_ key: String, _ fallback: Double) -> Double { + guard let raw = environment[key], let value = Double(raw), value > 0 else { return fallback } + return value + } + let scenesRaw = environment["UNTOLD_BENCH_SCENES"] ?? "all" + let sceneIDs = scenesRaw == "all" + ? BenchScenes.all.map(\.id) + : scenesRaw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + let outputDirectory = environment["UNTOLD_BENCH_OUTPUT"].map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? documents.appendingPathComponent("PerfBench", isDirectory: true) + + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + + return BenchConfig( + sceneIDs: sceneIDs, + warmupSeconds: double("UNTOLD_BENCH_WARMUP", 3.0), + measureSeconds: double("UNTOLD_BENCH_SECONDS", 15.0), + outputDirectory: outputDirectory, + runID: environment["UNTOLD_BENCH_RUN_ID"] ?? formatter.string(from: Date()), + label: environment["UNTOLD_BENCH_LABEL"] ?? "", + exitWhenDone: environment["UNTOLD_BENCH_KEEP_OPEN"] != "1", + autoStart: environment["UNTOLD_BENCH_AUTOSTART"] != "0", + immersion: environment["UNTOLD_BENCH_IMMERSION"] ?? "full", + perFrame: environment["UNTOLD_BENCH_PER_FRAME"] != "0", + antiAliasing: environment["UNTOLD_BENCH_AA"].map { $0.lowercased() }, + refreshHz: environment["UNTOLD_BENCH_REFRESH"].flatMap { Int($0) }.flatMap { $0 > 0 ? $0 : nil } + ) + } + + public var runDirectory: URL { + outputDirectory.appendingPathComponent(runID, isDirectory: true) + } +} diff --git a/Examples/PerfBench/Sources/PerfBenchCore/BenchResults.swift b/Examples/PerfBench/Sources/PerfBenchCore/BenchResults.swift new file mode 100644 index 000000000..17681026a --- /dev/null +++ b/Examples/PerfBench/Sources/PerfBenchCore/BenchResults.swift @@ -0,0 +1,136 @@ +// +// BenchResults.swift +// PerfBench +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import Metal +import UntoldEngine + +/// What the run was measured on. Baselines are keyed by `model`. +public struct BenchDeviceInfo: Codable, Sendable { + public var model: String + public var osVersion: String + public var gpuName: String + public var processorCount: Int + public var physicalMemoryBytes: UInt64 + public var thermalStateAtStart: Int + public var isLowPowerMode: Bool + + public static func current() -> BenchDeviceInfo { + BenchDeviceInfo( + model: modelIdentifier(), + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + gpuName: MTLCreateSystemDefaultDevice()?.name ?? "unknown", + processorCount: ProcessInfo.processInfo.processorCount, + physicalMemoryBytes: ProcessInfo.processInfo.physicalMemory, + thermalStateAtStart: ProcessInfo.processInfo.thermalState.rawValue, + isLowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled + ) + } + + private static func modelIdentifier() -> String { + #if os(macOS) + let key = "hw.model" + #else + let key = "hw.machine" + #endif + var size = 0 + sysctlbyname(key, nil, &size, nil, 0) + guard size > 0 else { return "unknown" } + var buffer = [CChar](repeating: 0, count: size) + sysctlbyname(key, &buffer, &size, nil, 0) + return String(cString: buffer) + } +} + +/// Render settings the app chose, so a baseline is never compared across different ones. +public struct BenchRenderSettings: Codable, Sendable { + public var platform: String + public var viewportWidth: Int = 0 + public var viewportHeight: Int = 0 + public var viewCount: Int = 1 + public var layout: String = "single" + public var foveation: Bool = false + public var renderQuality: Double = 1.0 + public var immersion: String = "none" + /// Whether presentation is paced by the display. True everywhere: on macOS and iOS the view + /// presents at the display's maximum refresh rate, on visionOS the compositor paces frames. + /// Frame time is therefore bounded below by the refresh interval; GPU time, per-pass and + /// per-system times are the cost metrics for scenes that fit in a frame. + public var vsync: Bool = true + /// Refresh rate the frame budget is derived from (0 when unknown). + public var displayRefreshHz: Double = 0.0 + /// Anti-aliasing mode the scenes ran with (the post-FX scene always uses SMAA). + public var antiAliasing: String = "fxaa" + + public init(platform: String) { + self.platform = platform + } +} + +public struct BenchSceneResult: Codable, Sendable { + public var id: String + public var title: String + public var notes: String + /// Recording file, relative to the run directory. + public var file: String + public var warmupSeconds: Double + public var measureSeconds: Double + public var summary: EngineStatsRecordingSummary + /// The last published snapshot of the scene, for the compositor, hitch and pass details. + public var lastSnapshot: EngineStatsSnapshot +} + +public struct BenchRunSummary: Codable, Sendable { + public var runID: String + public var label: String + public var startedAt: String + public var finishedAt: String = "" + public var device: BenchDeviceInfo + public var render: BenchRenderSettings + public var scenes: [BenchSceneResult] = [] + + public init(runID: String, label: String, device: BenchDeviceInfo, render: BenchRenderSettings) { + self.runID = runID + self.label = label + startedAt = ISO8601DateFormatter().string(from: Date()) + self.device = device + self.render = render + } + + /// Writes `summary.json` into `directory` and returns the encoded data. + @discardableResult + public func write(to directory: URL) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.nonConformingFloatEncodingStrategy = .convertToString(positiveInfinity: "inf", negativeInfinity: "-inf", nan: "nan") + let data = try encoder.encode(self) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent("summary.json")) + return data + } + + /// One line per scene for the console. + public var consoleReport: String { + var lines: [String] = [] + lines.append("PerfBench \(runID) on \(device.model) (\(device.gpuName), \(device.osVersion)) \(render.platform) \(render.viewportWidth)x\(render.viewportHeight) x\(render.viewCount)") + for scene in scenes { + let s = scene.summary + let miss = s.deadlineSamples > 0 + ? String(format: " deadlineMiss %.2f%%", Double(s.missedDeadlines) / Double(s.deadlineSamples) * 100) + : "" + lines.append(String( + format: " %@: frames %d | mean %.2f p95 %.2f p99 %.2f worst %.2f ms | over budget %d | gpu %.2f ms%@ | thermal %d", + scene.id, s.frames, s.meanFrameMs, s.p95FrameMs, s.p99FrameMs, s.worstFrameMs, + s.framesOverBudget, s.meanGPUExecutionMs, miss, s.worstThermalState + )) + } + return lines.joined(separator: "\n") + } +} diff --git a/Examples/PerfBench/Sources/PerfBenchCore/BenchRunner.swift b/Examples/PerfBench/Sources/PerfBenchCore/BenchRunner.swift new file mode 100644 index 000000000..b90c20243 --- /dev/null +++ b/Examples/PerfBench/Sources/PerfBenchCore/BenchRunner.swift @@ -0,0 +1,259 @@ +// +// BenchRunner.swift +// PerfBench +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Combine +import Foundation +import QuartzCore +import simd +import UntoldEngine + +/// Drives the scenes one after another from the engine's update callback: +/// build → warm up → record → tear down, then writes `summary.json` and reports. +/// +/// `update(deltaTime:)` runs on whatever thread the engine calls its game update from: the main +/// thread on macOS and iOS, the XR render thread on visionOS. The state machine therefore lives +/// on that thread and only the observable UI properties are published on the main thread. +public final class BenchRunner: ObservableObject, @unchecked Sendable { + public enum Phase: Equatable { + case idle + case building(Int) + case warmup(Int) + case recording(Int) + case tearingDown(Int) + case finished + } + + /// UI mirrors of the state machine, always set on the main thread. + @Published public private(set) var phase: Phase = .idle + @Published public private(set) var statusText: String = "Ready" + @Published public private(set) var report: String = "" + + public let config: BenchConfig + public var render: BenchRenderSettings + /// Where scenes are built. Zero on macOS and iOS; a point in front of the user on visionOS. + public var sceneOrigin: simd_float3 = .zero + /// Whether the runner moves the camera along each scene's orbit (off on visionOS). + public var drivesCamera = true + public var onFinished: ((BenchRunSummary) -> Void)? + + private let scenes: [BenchScene] + private var summary: BenchRunSummary + /// The state machine's own state, touched only from the update thread. + private var state: Phase = .idle + private var phaseDeadline: Double = 0.0 + private var sceneStart: Double = 0.0 + private var cameraEntity: EntityID? + private let startLock = NSLock() + private var startRequested = false + private var started = false + private var lastStatus = "" + + public init(config: BenchConfig, render: BenchRenderSettings) { + self.config = config + self.render = render + scenes = BenchScenes.scenes(withIDs: config.sceneIDs) + summary = BenchRunSummary(runID: config.runID, label: config.label, device: .current(), render: render) + } + + public var isRunning: Bool { + switch phase { + case .idle, .finished: return false + default: return true + } + } + + /// Requests the run; the next `update` starts it on the engine's update thread. Safe to call + /// from any thread and more than once. + public func start() { + startLock.lock() + startRequested = true + startLock.unlock() + } + + private func beginRunIfRequested() { + startLock.lock() + let requested = startRequested && !started + if requested { started = true } + startLock.unlock() + guard requested else { return } + + guard !scenes.isEmpty else { + setStatus("No scenes match \(config.sceneIDs)") + setState(.finished) + return + } + try? FileManager.default.createDirectory(at: config.runDirectory, withIntermediateDirectories: true) + setEngine(.metrics(.enabled)) + GPUPassTiming.shared.setEnabled(true) + setEngineStatsLogging(enabled: false) + if render.displayRefreshHz > 0 { + // Display-paced frames sit right at the refresh interval, so "over budget" means a + // refresh was skipped: more than one and a half intervals. + EngineStatsMonitor.shared.frameBudgetMs = 1.5 * 1000.0 / render.displayRefreshHz + } + if let mode = config.antiAliasing { + switch mode { + case "none": setRendering(.antiAliasing(.none)) + case "fxaa": setRendering(.antiAliasing(.fxaa)) + case "smaa": setRendering(.antiAliasing(.smaa)) + case "msaa": setRendering(.antiAliasing(.msaa)) + default: print("PERFBENCH unknown UNTOLD_BENCH_AA value \(mode); keeping the engine default") + } + } + render.antiAliasing = String(describing: antiAliasingMode) + summary.render = render + gameMode = true + setState(.building(0)) + } + + /// Call from the engine's game update callback every frame, on the engine's update thread. + public func update(deltaTime _: Float) { + beginRunIfRequested() + let now = CACurrentMediaTime() + switch state { + case .idle, .finished: + return + + case let .building(index): + let scene = scenes[index] + setStatus("Building \(scene.id)") + setSceneReady(false) + scene.build(origin: sceneOrigin) + cameraEntity = CameraSystem.shared.activeCamera + setSceneReady(true) + EngineStatsMonitor.shared.reset() + sceneStart = now + phaseDeadline = now + config.warmupSeconds + setState(.warmup(index)) + + case let .warmup(index): + driveCamera(scenes[index], elapsed: now - sceneStart) + setStatus(String(format: "%@: warming up %.0fs", scenes[index].id, phaseDeadline - now)) + if now >= phaseDeadline { + EngineStatsMonitor.shared.reset() + let file = config.runDirectory.appendingPathComponent("\(scenes[index].id).jsonl") + do { + try startEngineStatsRecording(to: file, interval: config.perFrame ? .perFrame : .perSecond) + } catch { + setStatus("Recording failed: \(error)") + } + phaseDeadline = now + config.measureSeconds + setState(.recording(index)) + } + + case let .recording(index): + driveCamera(scenes[index], elapsed: now - sceneStart) + setStatus(String(format: "%@: recording %.0fs", scenes[index].id, phaseDeadline - now)) + if now >= phaseDeadline { + finishScene(index) + phaseDeadline = now + 0.75 + setState(.tearingDown(index)) + } + + case let .tearingDown(index): + if now >= phaseDeadline { + let next = index + 1 + if next < scenes.count { + setState(.building(next)) + } else { + finishRun() + } + } + } + } + + // MARK: - Internals + + private func setState(_ newState: Phase) { + state = newState + publishOnMain { $0.phase = newState } + } + + private func setStatus(_ text: String) { + guard text != lastStatus else { return } + lastStatus = text + publishOnMain { $0.statusText = text } + } + + /// Runs `body` on the main thread, synchronously when already there, so SwiftUI sees the change. + private func publishOnMain(_ body: @escaping @Sendable (BenchRunner) -> Void) { + if Thread.isMainThread { + body(self) + } else { + DispatchQueue.main.async { body(self) } + } + } + + private func driveCamera(_ scene: BenchScene, elapsed: Double) { + guard drivesCamera, let cameraEntity else { return } + let orbit = scene.orbit + let eye = sceneOrigin + orbit.eye(at: Float(elapsed)) + cameraLookAt(entityId: cameraEntity, eye: eye, target: sceneOrigin + orbit.center, up: simd_float3(0, 1, 0)) + } + + private func finishScene(_ index: Int) { + let scene = scenes[index] + let recording = stopEngineStatsRecording() ?? EngineStatsRecordingSummary() + let snapshot = getEngineStatsSnapshot() + if render.viewportWidth == 0 { + render.viewportWidth = snapshot.compositor.viewTextureWidth + render.viewportHeight = snapshot.compositor.viewTextureHeight + render.viewCount = max(1, snapshot.compositor.viewCount) + summary.render = render + } + summary.scenes.append(BenchSceneResult( + id: scene.id, + title: scene.title, + notes: scene.notes, + file: "\(scene.id).jsonl", + warmupSeconds: config.warmupSeconds, + measureSeconds: config.measureSeconds, + summary: recording, + lastSnapshot: snapshot + )) + scene.teardown() + cameraEntity = nil + setSceneReady(false) + destroyAllEntities() + // Frames keep rendering while destroys finalize; give the engine a camera so it does not + // log a missing active camera every frame until the next scene builds its own. + let placeholder = createEntity() + setEntityName(entityId: placeholder, name: "Bench Idle Camera") + createGameCamera(entityId: placeholder) + cameraLookAt(entityId: placeholder, eye: simd_float3(0, 2, 6), target: .zero, up: simd_float3(0, 1, 0)) + setCamera(.active(placeholder)) + } + + private func finishRun() { + summary.finishedAt = ISO8601DateFormatter().string(from: Date()) + let text: String + do { + let data = try summary.write(to: config.runDirectory) + text = summary.consoleReport + print(text) + // One line the driver script can pick out of the console stream. + if let json = String(data: data, encoding: .utf8) { + print("PERFBENCH_SUMMARY_JSON " + json.replacingOccurrences(of: "\n", with: " ")) + } + print("PERFBENCH_DONE \(config.runDirectory.path)") + } catch { + text = "Failed to write summary: \(error)" + print(text) + print("PERFBENCH_FAILED") + } + fflush(stdout) + publishOnMain { $0.report = text } + setStatus("Done") + setState(.finished) + let finished = summary + let handler = onFinished + publishOnMain { _ in handler?(finished) } + } +} diff --git a/Examples/PerfBench/Sources/PerfBenchCore/BenchScenes.swift b/Examples/PerfBench/Sources/PerfBenchCore/BenchScenes.swift new file mode 100644 index 000000000..51c217e2b --- /dev/null +++ b/Examples/PerfBench/Sources/PerfBenchCore/BenchScenes.swift @@ -0,0 +1,331 @@ +// +// BenchScenes.swift +// PerfBench +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import simd +import SwiftUI +import UntoldEngine + +/// How the runner moves the camera on platforms where the camera is not the user's head. +public struct BenchCameraOrbit: Sendable { + public var center: simd_float3 + public var radius: Float + public var height: Float + /// Seconds for one full orbit. + public var period: Float + + public init(center: simd_float3, radius: Float, height: Float, period: Float = 12.0) { + self.center = center + self.radius = radius + self.height = height + self.period = period + } + + public func eye(at seconds: Float) -> simd_float3 { + let angle = 2.0 * Float.pi * seconds / period + return center + simd_float3(radius * cos(angle), height, radius * sin(angle)) + } +} + +/// One benchmark scene. `build` creates every entity relative to `origin`; the runner destroys +/// them afterwards, so `teardown` only has to undo global settings the scene changed. Scenes are +/// built from the engine's update callback, on the main thread or the XR render thread. +public protocol BenchScene { + var id: String { get } + var title: String { get } + var notes: String { get } + /// Camera orbit for macOS and iOS. On visionOS the head is the camera and the orbit is ignored. + var orbit: BenchCameraOrbit { get } + func build(origin: simd_float3) + func teardown() +} + +public extension BenchScene { + func teardown() {} +} + +public enum BenchScenes { + public static let all: [BenchScene] = [ + SingleCubeScene(), + PrimitivesScene(id: "primitives-1k", gridSize: 32, spacing: 1.0, batched: false, pointLights: 4), + PrimitivesScene(id: "primitives-10k", gridSize: 100, spacing: 1.0, batched: true, pointLights: 4), + PrimitivesScene(id: "lights-64", gridSize: 32, spacing: 1.0, batched: true, pointLights: 64), + PostFXScene(), + AnimationScene(), + StadiumScene(), + GaussianScene(), + ] + + public static func scenes(withIDs ids: [String]) -> [BenchScene] { + ids.compactMap { id in all.first { $0.id == id } } + } +} + +// MARK: - Shared helpers + +enum BenchSceneBuilder { + static func makeCamera(eye: simd_float3, target: simd_float3) { + let camera = createEntity() + setEntityName(entityId: camera, name: "Bench Camera") + createGameCamera(entityId: camera) + cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0)) + setCamera(.active(camera)) + } + + static func makeSun(pitch: Float = -50.0, intensity: Float = 1.5) { + let sun = createEntity() + setEntityName(entityId: sun, name: "Bench Sun") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: pitch, axis: simd_float3(1, 0, 0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.94, 0.86))) + setLight(entityId: sun, .intensity(intensity)) + setLight(entityId: sun, .directional(.active)) + } + + static func makePointLights(count: Int, around center: simd_float3, radius: Float, height: Float) { + for index in 0 ..< count { + let light = createEntity() + setEntityName(entityId: light, name: "Bench Point \(index)") + createPointLight(entityId: light) + let angle = 2.0 * Float.pi * Float(index) / Float(max(1, count)) + translateTo(entityId: light, position: center + simd_float3(radius * cos(angle), height, radius * sin(angle))) + let hue = Double(index % 12) / 12.0 + let rgb = Color(hue: hue, saturation: 0.55, brightness: 1.0) + setLight(entityId: light, .color(Self.rgb(rgb))) + setLight(entityId: light, .intensity(6.0)) + setLight(entityId: light, .point(.range(8.0))) + } + } + + static func rgb(_ color: Color) -> simd_float3 { + #if canImport(AppKit) + let native = NSColor(color).usingColorSpace(.deviceRGB) ?? NSColor.white + return simd_float3(Float(native.redComponent), Float(native.greenComponent), Float(native.blueComponent)) + #else + var r: CGFloat = 1, g: CGFloat = 1, b: CGFloat = 1, a: CGFloat = 1 + UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) + return simd_float3(Float(r), Float(g), Float(b)) + #endif + } + + static func makeGrid( + origin: simd_float3, + gridSize: Int, + spacing: Float, + batched: Bool, + namePrefix: String + ) -> [EntityID] { + let cube = BasicPrimitives.createCube(extent: spacing * 0.35) + let sphere = BasicPrimitives.createSphere(extent: spacing * 0.22, segments: [24, 12]) + let half = Float(gridSize) * spacing * 0.5 + var entities: [EntityID] = [] + entities.reserveCapacity(gridSize * gridSize) + for z in 0 ..< gridSize { + for x in 0 ..< gridSize { + let entity = createEntity() + setEntityName(entityId: entity, name: "\(namePrefix)_\(x)_\(z)") + let useSphere = (x + z) % 3 == 0 + setEntityMeshDirect( + entityId: entity, + meshes: useSphere ? sphere : cube, + assetName: useSphere ? "bench_sphere" : "bench_cube" + ) + let y = 0.15 * sin(Float(x) * 0.35) * cos(Float(z) * 0.35) + translateTo( + entityId: entity, + position: origin + simd_float3(Float(x) * spacing - half, y, Float(z) * spacing - half) + ) + let hue = Double((x * 7 + z * 3) % 16) / 16.0 + updateMaterialColor(entityId: entity, color: Color(hue: hue, saturation: 0.6, brightness: 0.85)) + updateMaterialRoughness(entityId: entity, roughness: useSphere ? 0.25 : 0.7) + if batched { + setEntityStaticBatchComponent(entityId: entity) + } + entities.append(entity) + } + } + if batched { + setBatching(.enabled(true)) + generateBatches() + } + return entities + } +} + +// MARK: - Scenes + +/// One cube and a sun: the fixed cost of a frame with almost nothing in it, and the scene to judge +/// image quality on a headset (edge stability, shadow swim, reprojection) without any load. +final class SingleCubeScene: BenchScene { + let id = "cube" + let title = "A single cube" + let notes = "One lit, shadowed cube in front of the viewer; measures the frame's fixed per-frame cost." + let orbit = BenchCameraOrbit(center: simd_float3(0, 0.5, 0), radius: 4.0, height: 1.5, period: 14.0) + + func build(origin: simd_float3) { + BenchSceneBuilder.makeCamera(eye: origin + orbit.eye(at: 0), target: origin + simd_float3(0, 0.5, 0)) + BenchSceneBuilder.makeSun() + let cube = createEntity() + setEntityName(entityId: cube, name: "Bench Cube") + setEntityMeshDirect(entityId: cube, meshes: BasicPrimitives.createCube(extent: 0.5), assetName: "bench_single_cube") + translateTo(entityId: cube, position: origin + simd_float3(0, 0.5, 0)) + updateMaterialColor(entityId: cube, color: Color(hue: 0.58, saturation: 0.6, brightness: 0.9)) + updateMaterialRoughness(entityId: cube, roughness: 0.6) + let floor = createEntity() + setEntityName(entityId: floor, name: "Bench Floor") + setEntityMeshDirect(entityId: floor, meshes: BasicPrimitives.createPlane(width: 6.0, depth: 6.0), assetName: "bench_floor") + translateTo(entityId: floor, position: origin) + updateMaterialColor(entityId: floor, color: Color(hue: 0.1, saturation: 0.15, brightness: 0.8)) + updateMaterialRoughness(entityId: floor, roughness: 0.9) + } +} + +/// A grid of individual primitives: draw-call and per-entity CPU cost, shadows, a few lights. +final class PrimitivesScene: BenchScene { + let id: String + let gridSize: Int + let spacing: Float + let batched: Bool + let pointLights: Int + + init(id: String, gridSize: Int, spacing: Float, batched: Bool, pointLights: Int) { + self.id = id + self.gridSize = gridSize + self.spacing = spacing + self.batched = batched + self.pointLights = pointLights + } + + var title: String { + "\(gridSize * gridSize) primitives\(batched ? ", static batching" : ""), \(pointLights) point lights" + } + + var notes: String { + batched + ? "Cubes and spheres merged by the static batcher; measures batched draw cost and light-loop cost." + : "One entity and draw per primitive; measures per-entity CPU cost and encoder overhead." + } + + var orbit: BenchCameraOrbit { + let extent = Float(gridSize) * spacing + return BenchCameraOrbit(center: .zero, radius: extent * 0.7, height: extent * 0.35, period: 14.0) + } + + func build(origin: simd_float3) { + BenchSceneBuilder.makeCamera(eye: origin + orbit.eye(at: 0), target: origin) + BenchSceneBuilder.makeSun() + _ = BenchSceneBuilder.makeGrid(origin: origin, gridSize: gridSize, spacing: spacing, batched: batched, namePrefix: id) + let extent = Float(gridSize) * spacing + BenchSceneBuilder.makePointLights(count: pointLights, around: origin, radius: extent * 0.4, height: 2.0) + } + + func teardown() { + if batched { + setBatching(.enabled(false)) + } + } +} + +/// The 1k primitive grid with SSAO, bloom, depth of field and SMAA on: the post-processing chain. +final class PostFXScene: BenchScene { + let id = "postfx" + let title = "1024 primitives with SSAO, bloom, depth of field and SMAA" + let notes = "Same geometry as primitives-1k; the difference is the full-screen pass chain." + let orbit = BenchCameraOrbit(center: .zero, radius: 22.0, height: 11.0, period: 14.0) + private var previousAntiAliasing: AntiAliasingMode = .fxaa + + func build(origin: simd_float3) { + BenchSceneBuilder.makeCamera(eye: origin + orbit.eye(at: 0), target: origin) + BenchSceneBuilder.makeSun() + _ = BenchSceneBuilder.makeGrid(origin: origin, gridSize: 32, spacing: 1.0, batched: false, namePrefix: id) + BenchSceneBuilder.makePointLights(count: 4, around: origin, radius: 12.0, height: 2.0) + previousAntiAliasing = antiAliasingMode + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.smaa)) + setPostFX(.ssao(.enabled(true))) + setPostFX(.ssao(.quality(.balanced))) + setPostFX(.bloomThreshold(.enabled(true))) + setPostFX(.bloomComposite(.enabled(true))) + setPostFX(.depthOfField(.enabled(true))) + } + + func teardown() { + setPostFX(.depthOfField(.enabled(false))) + setPostFX(.bloomComposite(.enabled(false))) + setPostFX(.bloomThreshold(.enabled(false))) + setPostFX(.ssao(.enabled(false))) + // Restore the engine's mode (FXAA by default); forcing .none here left every later scene + // without anti-aliasing, which showed as shimmering edges on the headset. + setRendering(.antiAliasing(previousAntiAliasing)) + } +} + +/// Sixteen skinned characters playing a clip: animation sampling, joint updates and the deformation pass. +final class AnimationScene: BenchScene { + let id = "animation-16" + let title = "16 skinned characters running" + let notes = "redplayer.untold with the running clip; measures the animation system and GPU skinning." + let orbit = BenchCameraOrbit(center: simd_float3(0, 1, 0), radius: 11.0, height: 3.0, period: 14.0) + + func build(origin: simd_float3) { + BenchSceneBuilder.makeCamera(eye: origin + orbit.eye(at: 0), target: origin + simd_float3(0, 1, 0)) + BenchSceneBuilder.makeSun() + AnimationSystem.shared.isEnabled = true + for index in 0 ..< 16 { + let entity = createEntity() + setEntityName(entityId: entity, name: "Bench Player \(index)") + setEntityMesh(entityId: entity, filename: "redplayer", withExtension: "untold") + setEntityAnimations(entityId: entity, filename: "running", withExtension: "untold", name: "running") + changeAnimation(entityId: entity, name: "running") + let x = Float(index % 4) * 2.2 - 3.3 + let z = Float(index / 4) * 2.2 - 3.3 + translateTo(entityId: entity, position: origin + simd_float3(x, 0, z)) + } + } +} + +/// A textured, multi-material asset with a ground plane: material binds and texture bandwidth. +final class StadiumScene: BenchScene { + let id = "stadium" + let title = "Stadium and grass (textured assets)" + let notes = "stadium.untold and grass.untold from the render-test resources." + let orbit = BenchCameraOrbit(center: .zero, radius: 60.0, height: 22.0, period: 18.0) + + func build(origin: simd_float3) { + BenchSceneBuilder.makeCamera(eye: origin + orbit.eye(at: 0), target: origin) + BenchSceneBuilder.makeSun(pitch: -40.0) + let stadium = createEntity() + setEntityName(entityId: stadium, name: "Bench Stadium") + setEntityMesh(entityId: stadium, filename: "stadium", withExtension: "untold") + translateTo(entityId: stadium, position: origin) + let grass = createEntity() + setEntityName(entityId: grass, name: "Bench Grass") + setEntityMesh(entityId: grass, filename: "grass", withExtension: "untold") + translateTo(entityId: grass, position: origin) + } +} + +/// A Gaussian splat next to primitives: the splat cull, depth-key and radix-sort passes every frame. +final class GaussianScene: BenchScene { + let id = "gaussian" + let title = "Gaussian splat with primitives" + let notes = "test_gaussians.ply (small) plus a 16x16 grid; exercises the sort pipeline, not splat count." + let orbit = BenchCameraOrbit(center: .zero, radius: 10.0, height: 4.0, period: 14.0) + + func build(origin: simd_float3) { + BenchSceneBuilder.makeCamera(eye: origin + orbit.eye(at: 0), target: origin) + BenchSceneBuilder.makeSun() + let splat = createEntity() + setEntityName(entityId: splat, name: "Bench Gaussian") + setEntityGaussian(entityId: splat, filename: "test_gaussians", withExtension: "ply") + translateTo(entityId: splat, position: origin + simd_float3(0, 1, 0)) + _ = BenchSceneBuilder.makeGrid(origin: origin, gridSize: 16, spacing: 1.0, batched: false, namePrefix: id) + } +} diff --git a/Examples/PerfBench/Sources/iOS/Info.plist b/Examples/PerfBench/Sources/iOS/Info.plist new file mode 100644 index 000000000..7807bfba1 --- /dev/null +++ b/Examples/PerfBench/Sources/iOS/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIFileSharingEnabled + + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/PerfBench/Sources/iOS/PerfBenchApp.swift b/Examples/PerfBench/Sources/iOS/PerfBenchApp.swift new file mode 100644 index 000000000..c56987678 --- /dev/null +++ b/Examples/PerfBench/Sources/iOS/PerfBenchApp.swift @@ -0,0 +1,104 @@ +// +// PerfBenchApp.swift +// PerfBench (iOS) +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import MetalKit +import SwiftUI +import UntoldEngine + +@main +struct PerfBenchApp: App { + var body: some SwiftUI.Scene { + WindowGroup { + BenchRootView() + } + } +} + +@MainActor +private final class BenchHost: ObservableObject { + let renderer: UntoldRenderer? + let runner: BenchRunner + let config: BenchConfig + + init() { + config = BenchConfig.fromEnvironment() + var render = BenchRenderSettings(platform: "iOS") + render.layout = "single" + render.displayRefreshHz = Double(UIScreen.main.maximumFramesPerSecond) + runner = BenchRunner(config: config, render: render) + + let view = MTKView() + view.preferredFramesPerSecond = UIScreen.main.maximumFramesPerSecond + if let device = MTLCreateSystemDefaultDevice(), let created = UntoldRenderer.createiOS(device: device, view: view) { + renderer = created + let scale = UIScreen.main.nativeScale + let bounds = UIScreen.main.nativeBounds + runner.render.viewportWidth = Int(max(bounds.width, bounds.height) / scale * scale) + runner.render.viewportHeight = Int(min(bounds.width, bounds.height) / scale * scale) + created.setupCallbacks( + gameUpdate: { [runner] deltaTime in + runner.update(deltaTime: deltaTime) + }, + handleInput: {} + ) + } else { + renderer = nil + } + + runner.onFinished = { [config] _ in + if config.exitWhenDone { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exit(0) + } + } + } + } +} + +private struct BenchRootView: View { + @StateObject private var host = BenchHost() + + var body: some View { + ZStack(alignment: .topLeading) { + if let renderer = host.renderer { + SceneView(renderer: renderer) + .ignoresSafeArea() + } else { + Text("Metal is not available").padding() + } + BenchStatusOverlay(runner: host.runner) + } + .onAppear { + if host.config.autoStart { + host.runner.start() + } + } + } +} + +private struct BenchStatusOverlay: View { + @ObservedObject var runner: BenchRunner + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("PerfBench").font(.headline) + Text(runner.statusText).font(.caption).monospaced() + if runner.phase == .idle { + Button("Start") { runner.start() } + } + if !runner.report.isEmpty { + Text(runner.report).font(.caption2).monospaced() + } + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(16) + } +} diff --git a/Examples/PerfBench/Sources/macOS/AppDelegate.swift b/Examples/PerfBench/Sources/macOS/AppDelegate.swift new file mode 100644 index 000000000..849aa21fa --- /dev/null +++ b/Examples/PerfBench/Sources/macOS/AppDelegate.swift @@ -0,0 +1,162 @@ +// +// AppDelegate.swift +// PerfBench (macOS) +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import AppKit +import MetalKit +import simd +import SwiftUI +import UntoldEngine + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + /// Fixed drawable size so runs on different displays compare; contents scale is pinned to 1. + static let drawableSize = CGSize(width: 1920, height: 1080) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var runner: BenchRunner! + + /// The display the bench runs on: one whose maximum refresh rate is the requested one, else + /// the fastest connected display. Not `NSScreen.main`, which is the screen with keyboard + /// focus and so depends on where the terminal that launched the bench happens to be. + private static func benchScreen(requestedHz: Int?) -> NSScreen? { + let screens = NSScreen.screens + if let requestedHz { + if let match = screens.first(where: { $0.maximumFramesPerSecond == requestedHz }) { + return match + } + let rates = screens.map { String($0.maximumFramesPerSecond) }.joined(separator: ", ") + print("PERFBENCH_WARNING no connected display runs at \(requestedHz) Hz (connected: \(rates) Hz); using the fastest one") + } + return screens.max { $0.maximumFramesPerSecond < $1.maximumFramesPerSecond } + } + + func applicationDidFinishLaunching(_: Notification) { + let config = BenchConfig.fromEnvironment() + let screen = Self.benchScreen(requestedHz: config.refreshHz) + var render = BenchRenderSettings(platform: "macOS") + render.viewportWidth = Int(Constants.drawableSize.width) + render.viewportHeight = Int(Constants.drawableSize.height) + render.layout = "single" + render.vsync = true + render.displayRefreshHz = Double(screen?.maximumFramesPerSecond ?? 60) + runner = BenchRunner(config: config, render: render) + + guard let renderer = UntoldRenderer.create() else { + print("PERFBENCH_FAILED renderer") + NSApp.terminate(nil) + return + } + self.renderer = renderer + let view = renderer.metalView + // Fixed drawable size, the same recipe the engine's render tests use: no auto-resizing, + // an explicit drawable size and viewport, and one resize report so the engine rebuilds + // its viewport-sized resources on the first frame. + view.autoResizeDrawable = false + view.frame = NSRect(origin: .zero, size: Constants.drawableSize) + renderer.mtkView(view, drawableSizeWillChange: Constants.drawableSize) + view.drawableSize = Constants.drawableSize + (view.layer as? CAMetalLayer)?.contentsScale = 1.0 + renderInfo.viewPort = simd_float2(Float(Constants.drawableSize.width), Float(Constants.drawableSize.height)) + + // The drawable keeps its fixed pixel size. The window shows it one drawable pixel per + // device pixel, so the window server presents it without rescaling, and is scaled down + // further only if the chosen display is smaller than that, so it never hangs over onto a + // neighbouring display that refreshes at another rate. + let styleMask: NSWindow.StyleMask = [.titled, .closable] + let backingScale = max(1.0, screen?.backingScaleFactor ?? 1.0) + var contentSize = CGSize( + width: Constants.drawableSize.width / backingScale, + height: Constants.drawableSize.height / backingScale + ) + if let screen { + let visible = screen.visibleFrame + let framed = NSWindow.frameRect(forContentRect: NSRect(origin: .zero, size: contentSize), styleMask: styleMask) + let chromeHeight = framed.height - contentSize.height + let fit = min(1.0, visible.width / contentSize.width, (visible.height - chromeHeight) / contentSize.height) + contentSize = CGSize(width: floor(contentSize.width * fit), height: floor(contentSize.height * fit)) + } + window = NSWindow( + contentRect: NSRect(origin: .zero, size: contentSize), + styleMask: styleMask, + backing: .buffered, + defer: false + ) + window.title = "Untold PerfBench" + // Windowed presentation hands drawables back at the display rate whatever the layer's + // sync setting, so run at the display's maximum refresh and derive the budget from it. + var viewOptions = UntoldViewOptions.default + viewOptions.preferredFramesPerSecond = Int(render.displayRefreshHz) + window.contentView = NSHostingView(rootView: BenchView(renderer: renderer, runner: runner, viewOptions: viewOptions)) + if let screen { + // Centre the window on the chosen display; the display it sits on paces its frames. + let visible = screen.visibleFrame + window.setFrameOrigin(NSPoint( + x: visible.midX - window.frame.width / 2, + y: visible.midY - window.frame.height / 2 + )) + } else { + window.center() + } + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.runner.update(deltaTime: deltaTime) + }, + handleInput: {} + ) + + runner.onFinished = { _ in + if config.exitWhenDone { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NSApp.terminate(nil) + } + } + } + + if config.autoStart { + runner.start() + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } +} + +private struct BenchView: View { + let renderer: UntoldRenderer + @ObservedObject var runner: BenchRunner + let viewOptions: UntoldViewOptions + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer, options: viewOptions) + VStack(alignment: .leading, spacing: 6) { + Text("PerfBench").font(.headline) + Text(runner.statusText).font(.caption).monospaced() + if !runner.isRunning, runner.phase == .idle { + Button("Start") { runner.start() } + } + if !runner.report.isEmpty { + Text(runner.report).font(.caption2).monospaced() + } + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(16) + } + } +} diff --git a/Examples/PerfBench/Sources/macOS/Info.plist b/Examples/PerfBench/Sources/macOS/Info.plist new file mode 100644 index 000000000..37ff065f5 --- /dev/null +++ b/Examples/PerfBench/Sources/macOS/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHighResolutionCapable + + + diff --git a/Examples/PerfBench/Sources/macOS/main.swift b/Examples/PerfBench/Sources/macOS/main.swift new file mode 100644 index 000000000..85d7b34d0 --- /dev/null +++ b/Examples/PerfBench/Sources/macOS/main.swift @@ -0,0 +1,22 @@ +// +// main.swift +// PerfBench (macOS) +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import AppKit + +// Line-buffer stdout so the driver script sees progress and the last lines survive a crash. +setvbuf(stdout, nil, _IOLBF, 0) + +// Top-level code is not main-actor isolated in Swift 5 language mode; the app is. +MainActor.assumeIsolated { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +} diff --git a/Examples/PerfBench/Sources/visionOS/Info.plist b/Examples/PerfBench/Sources/visionOS/Info.plist new file mode 100644 index 000000000..8dff7794c --- /dev/null +++ b/Examples/PerfBench/Sources/visionOS/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSSupportsOpeningDocumentsInPlace + + NSWorldSensingUsageDescription + PerfBench renders the benchmark scenes in your space. + UIApplicationSceneManifest + + UIApplicationPreferredDefaultSceneSessionRole + UIWindowSceneSessionRoleApplication + UIApplicationSupportsMultipleScenes + + + UIFileSharingEnabled + + + diff --git a/Examples/PerfBench/Sources/visionOS/PerfBenchApp.swift b/Examples/PerfBench/Sources/visionOS/PerfBenchApp.swift new file mode 100644 index 000000000..3081d642a --- /dev/null +++ b/Examples/PerfBench/Sources/visionOS/PerfBenchApp.swift @@ -0,0 +1,169 @@ +// +// PerfBenchApp.swift +// PerfBench (visionOS) +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CompositorServices +import Foundation +import simd +import SwiftUI +import UntoldEngine +import UntoldEngineXR + +/// Keeps the XR runtime and its render thread alive for the life of the app. +@MainActor +final class XRHolder { + static let shared = XRHolder() + var xr: UntoldEngineXR? + var renderThread: Thread? + /// The layer configuration the compositor was given, recorded into the run summary. + var layout = "dedicated" + var foveation = false +} + +/// Matches the engine's current expectations: dedicated per-eye textures, no foveation. +/// The runner records these values so baselines never mix configurations. +struct PerfBenchLayerConfiguration: CompositorLayerConfiguration { + func makeConfiguration(capabilities _: LayerRenderer.Capabilities, configuration: inout LayerRenderer.Configuration) { + configuration.layout = .dedicated + configuration.isFoveationEnabled = false + configuration.colorFormat = .bgra8Unorm_srgb + } +} + +@MainActor +final class BenchHost: ObservableObject { + static let shared = BenchHost() + + let config: BenchConfig + let runner: BenchRunner + + private init() { + config = BenchConfig.fromEnvironment() + var render = BenchRenderSettings(platform: "visionOS") + render.layout = XRHolder.shared.layout + render.foveation = XRHolder.shared.foveation + render.immersion = config.immersion + render.displayRefreshHz = 90.0 + runner = BenchRunner(config: config, render: render) + // Scenes are built three metres in front of the user, slightly below eye level. + runner.sceneOrigin = simd_float3(0.0, -0.8, -3.0) + runner.drivesCamera = false + runner.onFinished = { [config] _ in + if config.exitWhenDone { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + exit(0) + } + } + } + } + + var immersionStyle: ImmersionStyle { + config.immersion == "mixed" ? .mixed : .full + } + + var isMixedImmersion: Bool { + config.immersion == "mixed" + } +} + +@main +struct PerfBenchApp: App { + @State private var immersionStyle: ImmersionStyle = BenchHost.shared.immersionStyle + + var body: some SwiftUI.Scene { + WindowGroup { + ContentView() + } + .windowStyle(.plain) + .defaultSize(width: 640, height: 420) + + ImmersiveSpace(id: "PerfBenchSpace") { + CompositorLayer(configuration: PerfBenchLayerConfiguration()) { layerRenderer in + Task { @MainActor in + startXR(layerRenderer: layerRenderer) + } + } + } + .immersionStyle(selection: $immersionStyle, in: .mixed, .full) + } + + @MainActor + private func startXR(layerRenderer: LayerRenderer) { + guard XRHolder.shared.xr == nil else { return } + guard let xr = UntoldEngineXR(layerRenderer: layerRenderer) else { + print("PERFBENCH_FAILED xr") + return + } + XRHolder.shared.xr = xr + let host = BenchHost.shared + let runner = host.runner + // The XR module's UntoldImmersionMode cannot be named here (the module and its class share + // a name), so let the parameter type pick the enum. + xr.setImmersionMode(xrImmersionMode: host.isMixedImmersion ? .mixed : .full) + xr.setupCallbacks( + gameUpdate: { deltaTime in + // Runs on the XR render thread, inside the frame's update phase, which is where the + // engine expects scene mutations from; the runner publishes its UI state to main. + runner.update(deltaTime: deltaTime) + }, + handleInput: {} + ) + let thread = Thread { + xr.start() + xr.runLoop() + } + thread.name = "XR Render Thread" + thread.qualityOfService = .userInteractive + XRHolder.shared.renderThread = thread + thread.start() + if host.config.autoStart { + // Give the compositor a few frames before the first scene is built. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + host.runner.start() + } + } + } +} + +struct ContentView: View { + @Environment(\.openImmersiveSpace) private var openImmersiveSpace + @ObservedObject private var runner = BenchHost.shared.runner + @State private var opened = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Untold PerfBench").font(.extraLargeTitle).fontWeight(.bold) + Text(runner.statusText).font(.title3).monospaced() + if !runner.report.isEmpty { + ScrollView { + Text(runner.report).font(.caption).monospaced() + } + .frame(maxHeight: 200) + } + HStack { + Button("Open Space") { open() }.disabled(opened) + Button("Start") { runner.start() }.disabled(!opened || runner.phase != .idle) + } + } + .padding(40) + .task { + if BenchHost.shared.config.autoStart { + open() + } + } + } + + private func open() { + guard !opened else { return } + opened = true + Task { + await openImmersiveSpace(id: "PerfBenchSpace") + } + } +} diff --git a/Examples/PerfBench/project.yml b/Examples/PerfBench/project.yml new file mode 100644 index 000000000..7e5a8decc --- /dev/null +++ b/Examples/PerfBench/project.yml @@ -0,0 +1,116 @@ +name: PerfBench + +# The engine is referenced by path so the benchmark always measures the checkout it sits in. +packages: + UntoldEngine: + path: ../.. + +options: + bundleIdPrefix: com.untoldengine.perfbench + deploymentTarget: + macOS: "14.0" + iOS: "17.0" + visionOS: "2.0" + +settings: + base: + # The engine uses Float16/simd_half types that do not exist on x86_64; build arm64 only. + ARCHS: arm64 + ONLY_ACTIVE_ARCH: "YES" + SWIFT_VERSION: 5.0 + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Automatic + MTL_HEADER_SEARCH_PATHS: "$(inherited) $(SRCROOT)/../../Sources/UntoldEngineShaderSupport/include" + configs: + Debug: + SWIFT_OPTIMIZATION_LEVEL: -Onone + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym + Release: + SWIFT_COMPILATION_MODE: wholemodule + SWIFT_OPTIMIZATION_LEVEL: -O + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym + +# Benchmark assets come straight from the engine's render-test resources; the engine looks for +# Models//. and Animations//. inside the bundle, and finds a +# file at the bundle root by name. +targetTemplates: + BenchApp: + type: application + sources: + - path: Sources/PerfBenchCore + - path: ../../Tests/UntoldEngineRenderTests/Resources/Models + type: folder + buildPhase: resources + - path: ../../Tests/UntoldEngineRenderTests/Resources/Animations + type: folder + buildPhase: resources + - path: ../../Tests/UntoldEngineRenderTests/Resources/Guassians/test_gaussians.ply + buildPhase: resources + dependencies: + - package: UntoldEngine + product: UntoldEngine + - package: UntoldEngine + product: UntoldEngineShaderSupport + +targets: + PerfBench-macOS: + templates: [BenchApp] + platform: macOS + sources: + - path: Sources/macOS + excludes: ["Info.plist"] + settings: + base: + PRODUCT_NAME: PerfBench + PRODUCT_BUNDLE_IDENTIFIER: com.untoldengine.perfbench.macos + INFOPLIST_FILE: Sources/macOS/Info.plist + + PerfBench-iOS: + templates: [BenchApp] + platform: iOS + sources: + - path: Sources/iOS + excludes: ["Info.plist"] + settings: + base: + PRODUCT_NAME: PerfBench + PRODUCT_BUNDLE_IDENTIFIER: com.untoldengine.perfbench.ios + INFOPLIST_FILE: Sources/iOS/Info.plist + TARGETED_DEVICE_FAMILY: "1,2" + SUPPORTED_INTERFACE_ORIENTATIONS: UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + PerfBench-visionOS: + templates: [BenchApp] + platform: visionOS + sources: + - path: Sources/visionOS + excludes: ["Info.plist"] + dependencies: + - package: UntoldEngine + product: UntoldEngineXR + settings: + base: + PRODUCT_NAME: PerfBench + PRODUCT_BUNDLE_IDENTIFIER: com.untoldengine.perfbench.visionos + INFOPLIST_FILE: Sources/visionOS/Info.plist + +schemes: + PerfBench-macOS: + build: + targets: + PerfBench-macOS: all + run: + config: Release + PerfBench-iOS: + build: + targets: + PerfBench-iOS: all + run: + config: Release + PerfBench-visionOS: + build: + targets: + PerfBench-visionOS: all + run: + config: Release diff --git a/Package.swift b/Package.swift index a8d0e7be1..ecff90d5a 100644 --- a/Package.swift +++ b/Package.swift @@ -119,8 +119,10 @@ let package = Package( resources: engineResources, swiftSettings: [ // Compile engine stats collection only in debug by default. - // Release builds can still opt in explicitly with -DENGINE_STATS_ENABLED. - .define("ENGINE_STATS_ENABLED", .when(configuration: .debug)), + // Compiled into every configuration; collection is a runtime switch + // (setEngineStatsCollection(enabled:), UNTOLD_STATS=1) that defaults to on in + // debug builds and off in release builds. + .define("ENGINE_STATS_ENABLED"), .swiftLanguageMode(.v6), ], linkerSettings: [ @@ -163,7 +165,7 @@ let package = Package( // Must mirror the engine flag so #if ENGINE_STATS_ENABLED blocks in this // target are active; without it, xrFrameStartTime is never declared and // finalizeXRStatsAndMonitors receives 0.0, producing garbage frameTotalMs. - .define("ENGINE_STATS_ENABLED", .when(configuration: .debug)), + .define("ENGINE_STATS_ENABLED"), .swiftLanguageMode(.v6), ], linkerSettings: [ diff --git a/Sources/UntoldEngine/Profiling/EngineProfiler.swift b/Sources/UntoldEngine/Profiling/EngineProfiler.swift index 10981beb4..bce0b3758 100644 --- a/Sources/UntoldEngine/Profiling/EngineProfiler.swift +++ b/Sources/UntoldEngine/Profiling/EngineProfiler.swift @@ -67,6 +67,11 @@ public final class EngineProfiler: @unchecked Sendable { signposts.endScope(scope) } + public func emitEvent(_ event: ProfileEvent) { + guard isEnabled else { return } + signposts.emitEvent(event) + } + public func attach(to commandBuffer: MTLCommandBuffer, label: String? = nil) { guard isEnabled else { return } gpuMetrics.attach(to: commandBuffer, label: label) diff --git a/Sources/UntoldEngine/Profiling/EngineSignposts.swift b/Sources/UntoldEngine/Profiling/EngineSignposts.swift index 5a357e5c5..c8f8a74c5 100644 --- a/Sources/UntoldEngine/Profiling/EngineSignposts.swift +++ b/Sources/UntoldEngine/Profiling/EngineSignposts.swift @@ -40,6 +40,29 @@ public enum ProfileScope { case gaussianDepth case gaussianSort case gaussianDraw + + // Compositor Services frame phases (visionOS) + case compositorUpdate + case compositorWaitForInput + case compositorSubmission + + // Per-system CPU work inside the Update scope + case scenegraph + case extensionsUpdate + case lod + case animation + case scripting + case physics + case customSystems + case gameUpdate +} + +/// Point events (no duration) that mark something worth seeing on the Instruments timeline. +public enum ProfileEvent { + /// The GPU finished a frame after the compositor's rendering deadline. + case missedDeadline + /// `ProcessInfo.thermalState` changed between two frames. + case thermalStateChanged } final class EngineSignposts { @@ -51,6 +74,8 @@ final class EngineSignposts { private static let cullingLog = OSLog(subsystem: subsystem, category: "Culling") private static let streamingLog = OSLog(subsystem: subsystem, category: "Streaming") private static let batchingLog = OSLog(subsystem: subsystem, category: "Batching") + private static let compositorLog = OSLog(subsystem: subsystem, category: "Compositor") + private static let systemsLog = OSLog(subsystem: subsystem, category: "Systems") private static let gaussianLog = OSLog(subsystem: subsystem, category: "Gaussian") // One stable signpost ID per scope. @@ -65,6 +90,19 @@ final class EngineSignposts { private static let geometryStreamingID = OSSignpostID(log: streamingLog) private static let batchingTickID = OSSignpostID(log: batchingLog) private static let batchingRebuildID = OSSignpostID(log: batchingLog) + private static let compositorUpdateID = OSSignpostID(log: compositorLog) + private static let compositorWaitForInputID = OSSignpostID(log: compositorLog) + private static let compositorSubmissionID = OSSignpostID(log: compositorLog) + private static let missedDeadlineID = OSSignpostID(log: compositorLog) + private static let thermalStateChangedID = OSSignpostID(log: frameLog) + private static let scenegraphID = OSSignpostID(log: systemsLog) + private static let extensionsUpdateID = OSSignpostID(log: systemsLog) + private static let lodID = OSSignpostID(log: systemsLog) + private static let animationID = OSSignpostID(log: systemsLog) + private static let scriptingID = OSSignpostID(log: systemsLog) + private static let physicsID = OSSignpostID(log: systemsLog) + private static let customSystemsID = OSSignpostID(log: systemsLog) + private static let gameUpdateID = OSSignpostID(log: systemsLog) private static let gaussianCullID = OSSignpostID(log: gaussianLog) private static let gaussianDepthID = OSSignpostID(log: gaussianLog) private static let gaussianSortID = OSSignpostID(log: gaussianLog) @@ -80,6 +118,15 @@ final class EngineSignposts { os_signpost(.end, log: log, name: name, signpostID: id) } + func emitEvent(_ event: ProfileEvent) { + switch event { + case .missedDeadline: + os_signpost(.event, log: Self.compositorLog, name: "MissedDeadline", signpostID: Self.missedDeadlineID) + case .thermalStateChanged: + os_signpost(.event, log: Self.frameLog, name: "ThermalStateChanged", signpostID: Self.thermalStateChangedID) + } + } + private func descriptor(for scope: ProfileScope) -> (OSLog, OSSignpostID, StaticString) { switch scope { case .frame: return (Self.frameLog, Self.frameID, "Frame") @@ -97,6 +144,17 @@ final class EngineSignposts { case .gaussianDepth: return (Self.gaussianLog, Self.gaussianDepthID, "GaussianDepth") case .gaussianSort: return (Self.gaussianLog, Self.gaussianSortID, "GaussianSort") case .gaussianDraw: return (Self.gaussianLog, Self.gaussianDrawID, "GaussianDraw") + case .compositorUpdate: return (Self.compositorLog, Self.compositorUpdateID, "CompositorUpdate") + case .compositorWaitForInput: return (Self.compositorLog, Self.compositorWaitForInputID, "CompositorWaitForInput") + case .compositorSubmission: return (Self.compositorLog, Self.compositorSubmissionID, "CompositorSubmission") + case .scenegraph: return (Self.systemsLog, Self.scenegraphID, "Scenegraph") + case .extensionsUpdate: return (Self.systemsLog, Self.extensionsUpdateID, "ExtensionsUpdate") + case .lod: return (Self.systemsLog, Self.lodID, "LOD") + case .animation: return (Self.systemsLog, Self.animationID, "Animation") + case .scripting: return (Self.systemsLog, Self.scriptingID, "Scripting") + case .physics: return (Self.systemsLog, Self.physicsID, "Physics") + case .customSystems: return (Self.systemsLog, Self.customSystemsID, "CustomSystems") + case .gameUpdate: return (Self.systemsLog, Self.gameUpdateID, "GameUpdate") } } } diff --git a/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift b/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift index fea12154e..3debc1484 100644 --- a/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift +++ b/Sources/UntoldEngine/Profiling/EngineStatsFormatter.swift @@ -56,9 +56,15 @@ private func expandedEngineStatsString(_ snapshot: EngineStatsSnapshot) -> Strin let texBudgetMB = formatMB(snapshot.memory.textureBudgetBytes) let memPct = String(format: "%.0f%%", snapshot.memory.utilizationPercent * 100) let pressure = snapshot.memory.isUnderPressure ? " PRESSURE" : "" + let compositorLine = compositorStatsLine(snapshot) + let gpuPassLine = gpuPassStatsLine(snapshot) + let hitchLine = hitchStatsLine(snapshot) + let gpuAllocMB = formatMB(snapshot.memory.gpuAllocatedBytes) + let availableMB = snapshot.memory.availableMemoryBytes > 0 ? " | avail \(formatMB(snapshot.memory.availableMemoryBytes))mb" : "" return """ Frame \(snapshot.frameIndex) | CPU \(formatMs(snapshot.timing.smoothedFrameMs))ms (\(formatFPS(frameMs: snapshot.timing.smoothedFrameMs)) fps, smoothed) GPU \(formatMs(snapshot.timing.gpuExecutionMs))ms exec / \(formatFPS(frameMs: snapshot.timing.gpuFrameCadenceMs)) fps cadence [\(bottleneck)] Timing: frame \(formatMs(snapshot.timing.frameTotalMs))ms (raw CPU) | update \(formatMs(snapshot.timing.updateMs))ms | render \(formatMs(snapshot.timing.renderTotalMs))ms | cull \(formatMs(snapshot.timing.cullingMs))ms | stream \(formatMs(snapshot.timing.streamingRegionMs + snapshot.timing.geometryStreamingMs))ms | batchTick \(formatMs(snapshot.timing.batchingTickMs))ms | batchRebuild \(formatMs(snapshot.timing.batchingRebuildMs))ms + Systems: scenegraph \(formatMs(snapshot.timing.scenegraphMs))ms | extensions \(formatMs(snapshot.timing.extensionsUpdateMs))ms | lod \(formatMs(snapshot.timing.lodMs))ms | animation \(formatMs(snapshot.timing.animationMs))ms | scripting \(formatMs(snapshot.timing.scriptingMs))ms | physics \(formatMs(snapshot.timing.physicsMs))ms (\(snapshot.timing.physicsStepCount) steps) | custom \(formatMs(snapshot.timing.customSystemsMs))ms | game \(formatMs(snapshot.timing.gameUpdateMs))ms | semWait \(formatMs(snapshot.timing.semaphoreWaitMs))ms Render: draws \(snapshot.render.drawCallsTotal) (opaque \(snapshot.render.drawCallsOpaque), transparent \(snapshot.render.drawCallsTransparent), shadow \(snapshot.render.drawCallsShadow), batched \(snapshot.render.drawCallsBatched)) | triangles \(snapshot.render.trianglesTotal) | visible \(snapshot.render.visibleInstances) Culling: frustum \(snapshot.culling.frustumPassed)/\(snapshot.culling.frustumTested) failed \(snapshot.culling.frustumFailed) | occlusion \(snapshot.culling.occlusionPassed)/\(snapshot.culling.occlusionTested) failed \(snapshot.culling.occlusionFailed) | usedHZB \(snapshot.culling.usedHZB) validHZB \(snapshot.culling.hzbIsValid) Streaming: loaded \(snapshot.streaming.loadedStreamingEntities) loading \(snapshot.streaming.loadingStreamingEntities) unloaded \(snapshot.streaming.unloadedStreamingEntities) | active \(snapshot.streaming.activeLoads) | nearby \(snapshot.streaming.nearbyEntitiesQueried) candidates \(snapshot.streaming.loadCandidates) slots \(snapshot.streaming.availableLoadSlots) | backlog \(snapshot.streaming.pendingLoadBacklog) | pendingUploads \(snapshot.streaming.pendingUploadCount) | gateMs \(formatMs(snapshot.streaming.blockedByGateMs)) @@ -66,10 +72,50 @@ private func expandedEngineStatsString(_ snapshot: EngineStatsSnapshot) -> Strin TileReps: resident full/lod/hlod \(snapshot.streaming.residentFullTileRepresentations)/\(snapshot.streaming.residentLODRepresentations)/\(snapshot.streaming.residentHLODRepresentations) | visible full/lod/hlod \(snapshot.streaming.visibleFullTileRepresentations)/\(snapshot.streaming.visibleLODRepresentations)/\(snapshot.streaming.visibleHLODRepresentations) | overlap visible full+lod/full+hlod/lod+hlod \(snapshot.streaming.fullAndLODVisibleOverlapTiles)/\(snapshot.streaming.fullAndHLODVisibleOverlapTiles)/\(snapshot.streaming.lodAndHLODVisibleOverlapTiles) residentFull+fallback \(snapshot.streaming.fullAndFallbackResidentOverlapTiles) | fades \(snapshot.streaming.activeTileRepresentationFades) waiting \(snapshot.streaming.waitingTileRepresentationFades) TileRenderCost: visible full/lod/hlod \(snapshot.render.tileFullVisibleInstances)/\(snapshot.render.tileLODVisibleInstances)/\(snapshot.render.tileHLODVisibleInstances) | draws full/lod/hlod \(snapshot.render.tileFullDrawsEstimate)/\(snapshot.render.tileLODDrawsEstimate)/\(snapshot.render.tileHLODDrawsEstimate) | tris full/lod/hlod \(snapshot.render.tileFullTrianglesEstimate)/\(snapshot.render.tileLODTrianglesEstimate)/\(snapshot.render.tileHLODTrianglesEstimate) Batching: groups \(snapshot.batching.batchGroupCount) | batchedMeshes \(snapshot.batching.batchedMeshCount) | dirty \(snapshot.batching.dirtyCellsBeforePrune)→\(snapshot.batching.dirtyCellsAfterPrune) | defWork \(snapshot.batching.deferredByWorkBudget) skipComplex \(snapshot.batching.skippedByComplexityGuard) | dispatched \(snapshot.batching.dispatchedBuilds)→\(snapshot.batching.lastRebuildOutputBatchCount) groups | rebuilds/s \(snapshot.batching.rebuildsThisSecond) | rebuildMs \(formatMs(snapshot.batching.lastRebuildCostMs)) - Memory: mesh \(meshMB)/\(meshBudgetMB)mb | tex \(texMB)/\(texBudgetMB)mb | total \(memPct) | entities \(snapshot.memory.trackedEntityCount)\(pressure) + Memory: mesh \(meshMB)/\(meshBudgetMB)mb | tex \(texMB)/\(texBudgetMB)mb | total \(memPct) | entities \(snapshot.memory.trackedEntityCount)\(pressure) | gpuAlloc \(gpuAllocMB)mb\(availableMB) | thermal \(snapshot.memory.thermalStateName)\(hitchLine)\(compositorLine)\(gpuPassLine) """ } +/// One line of frame-time distribution, prefixed with a newline. Empty until a frame was sampled. +private func hitchStatsLine(_ snapshot: EngineStatsSnapshot) -> String { + let h = snapshot.hitches + guard h.framesSampled > 0 else { return "" } + let rate = String(format: "%.2f%%", h.overBudgetRate * 100) + var buckets: [String] = [] + for (index, count) in h.histogram.enumerated() { + if index < EngineHitchStats.bucketUpperBoundsMs.count { + buckets.append("<\(formatBound(EngineHitchStats.bucketUpperBoundsMs[index])) \(count)") + } else { + buckets.append(">=\(formatBound(EngineHitchStats.bucketUpperBoundsMs.last ?? 0)) \(count)") + } + } + return "\nHitches: budget \(formatMs(h.frameBudgetMs))ms | over \(h.framesOverBudget)/\(h.framesSampled) (\(rate)) | lastSec \(h.framesOverBudgetLastSecond)/\(h.framesLastSecond) over, worst \(formatMs(h.worstFrameMsLastSecond))ms | hist \(buckets.joined(separator: " | "))" +} + +private func formatBound(_ value: Double) -> String { + value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value) +} + +/// One line with the heaviest GPU passes of the last resolved frame, prefixed with a newline. +/// Empty when the GPU pass timer is off. +private func gpuPassStatsLine(_ snapshot: EngineStatsSnapshot) -> String { + let g = snapshot.gpuPasses + guard !g.passes.isEmpty else { return "" } + let shown = g.passes.sorted { $0.totalMs > $1.totalMs }.prefix(12) + let entries = shown.map { "\($0.label) \(formatMs($0.totalMs))" }.joined(separator: " | ") + return "\nGPU passes (\(g.passes.count), sum \(formatMs(g.totalMs))ms): \(entries)" +} + +/// One line of Compositor Services frame accounting, prefixed with a newline so it can be appended +/// to the expanded block. Empty when the frame was not rendered through Compositor Services. +private func compositorStatsLine(_ snapshot: EngineStatsSnapshot) -> String { + let c = snapshot.compositor + guard c.viewCount > 0 else { return "" } + let missRate = String(format: "%.2f%%", c.missedDeadlineRate * 100) + let missed = c.missedDeadline ? " MISSED" : "" + return "\nCompositor: views \(c.viewCount) @ \(c.viewTextureWidth)x\(c.viewTextureHeight) | update \(formatMs(c.updateMs))ms | inputSlack \(formatMs(c.inputSlackMs))ms | submit \(formatMs(c.submissionMs))ms | semWait \(formatMs(snapshot.timing.semaphoreWaitMs))ms | deadlineMargin \(formatMs(c.deadlineMarginMs))ms\(missed) | presentMargin \(formatMs(c.presentationMarginMs))ms | missed \(c.missedDeadlineCount)/\(c.deadlineSampleCount) (\(missRate)) | noAnchor \(c.missingAnchorCount)" +} + private func formatMB(_ bytes: Int) -> String { String(format: "%.0f", Double(bytes) / (1024 * 1024)) } diff --git a/Sources/UntoldEngine/Profiling/EngineStatsMonitor.swift b/Sources/UntoldEngine/Profiling/EngineStatsMonitor.swift index d296cd59b..e1155e8f2 100644 --- a/Sources/UntoldEngine/Profiling/EngineStatsMonitor.swift +++ b/Sources/UntoldEngine/Profiling/EngineStatsMonitor.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation +import os import QuartzCore /// The time the GPU spent on a frame's command buffer beyond the previous frame's: the part @@ -30,6 +31,10 @@ public enum EngineStatsLoggingProfile: Equatable { public final class EngineStatsMonitor: @unchecked Sendable { public static let shared = EngineStatsMonitor() + /// Runtime switch for stats collection. Read on every frame-loop call site without taking the + /// monitor lock; defaults to on in debug builds and off in release builds, or to `UNTOLD_STATS=1`. + private let collecting: OSAllocatedUnfairLock + #if ENGINE_STATS_ENABLED private let lock = NSLock() private var currentSnapshot: EngineStatsSnapshot = .init() @@ -45,6 +50,27 @@ public final class EngineStatsMonitor: @unchecked Sendable { private var _lastGPUCompletionTime: Double = 0.0 // 0 = no completion seen yet private var _lastGPUCommandBufferEndTime: CFTimeInterval = 0.0 // gpuEndTime of the last frame recorded + // Compositor deadline accounting (visionOS) — written from addCompletedHandler, read at completeFrame() + private var _latestDeadlineMarginMs: Double = 0.0 + private var _latestPresentationMarginMs: Double = 0.0 + private var _latestMissedDeadline: Bool = false + private var _missedDeadlineCount: Int = 0 + private var _deadlineSampleCount: Int = 0 + private var _missingAnchorCount: Int = 0 + + // Hitch accounting (frame-time histogram, over-budget counts, one-second window) + private var _frameBudgetMs: Double = 1000.0 / 60.0 + private var _hitches = EngineHitchStats() + private var _windowStartSeconds: Double = 0.0 + private var _windowStarted = false + private var _windowFrames: Int = 0 + private var _windowFramesOverBudget: Int = 0 + private var _windowWorstFrameMs: Double = 0.0 + private var _lastThermalState: Int = -1 + + /// Optional JSON Lines recorder fed with every published snapshot. + private var recorder: EngineStatsRecorder? + // 30-frame rolling average for smoothed CPU frame time private let kSmoothingWindow = 30 private var _frameMsBuffer: [Double] = .init(repeating: 0.0, count: 30) @@ -53,11 +79,30 @@ public final class EngineStatsMonitor: @unchecked Sendable { #endif private init() { + let environmentValue = ProcessInfo.processInfo.environment["UNTOLD_STATS"] + let initialState: Bool + if let environmentValue { + initialState = environmentValue == "1" + } else { + #if DEBUG + initialState = true + #else + initialState = false + #endif + } + collecting = OSAllocatedUnfairLock(initialState: initialState) #if ENGINE_STATS_ENABLED lastLogTime = CACurrentMediaTime() #endif } + /// Whether the monitor collects and publishes stats. When false every per-frame call returns + /// at once and `snapshot()` keeps returning the last published frame. + public var isCollecting: Bool { + get { collecting.withLock { $0 } } + set { collecting.withLock { $0 = newValue } } + } + public var enableLogging: Bool { get { #if ENGINE_STATS_ENABLED @@ -77,6 +122,40 @@ public final class EngineStatsMonitor: @unchecked Sendable { } } + /// Frame budget for the hitch counts (`EngineHitchStats`). Defaults to 60 Hz; the visionOS + /// runtime sets it to the 90 Hz period. Changing it does not rewrite past counts. + public var frameBudgetMs: Double { + get { + #if ENGINE_STATS_ENABLED + lock.lock() + defer { lock.unlock() } + return _frameBudgetMs + #else + return 1000.0 / 60.0 + #endif + } + set { + #if ENGINE_STATS_ENABLED + lock.lock() + _frameBudgetMs = max(0.1, newValue) + lock.unlock() + #endif + } + } + + /// Attaches a recorder that receives every published snapshot, replacing any earlier one. + func attachRecorder(_ newRecorder: EngineStatsRecorder?) -> EngineStatsRecorder? { + #if ENGINE_STATS_ENABLED + lock.lock() + let previous = recorder + recorder = newRecorder + lock.unlock() + return previous + #else + return newRecorder + #endif + } + public var loggingProfile: EngineStatsLoggingProfile { get { #if ENGINE_STATS_ENABLED @@ -142,16 +221,19 @@ public final class EngineStatsMonitor: @unchecked Sendable { public func beginFrame(timestampSeconds: Double = CACurrentMediaTime()) { #if ENGINE_STATS_ENABLED + guard isCollecting else { return } lock.lock() currentSnapshot.frameIndex &+= 1 currentSnapshot.timestampSeconds = timestampSeconds currentSnapshot.timing = .init() + currentSnapshot.compositor = .init() lock.unlock() #endif } public func update(_ updater: (inout EngineStatsSnapshot) -> Void) { #if ENGINE_STATS_ENABLED + guard isCollecting else { return } lock.lock() updater(¤tSnapshot) lock.unlock() @@ -162,6 +244,7 @@ public final class EngineStatsMonitor: @unchecked Sendable { /// Safe to call from any thread. public func recordGPUCompletion(executionMs: Double) { #if ENGINE_STATS_ENABLED + guard isCollecting else { return } let now = CACurrentMediaTime() lock.lock() let cadenceMs = _lastGPUCompletionTime > 0 @@ -191,14 +274,54 @@ public final class EngineStatsMonitor: @unchecked Sendable { #endif } + /// Called from MTLCommandBuffer.addCompletedHandler on visionOS with the GPU completion time + /// measured against the compositor's rendering deadline and presentation time for that frame. + /// Safe to call from any thread. + public func recordCompositorCompletion(deadlineMarginMs: Double, presentationMarginMs: Double) { + #if ENGINE_STATS_ENABLED + guard isCollecting else { return } + lock.lock() + _latestDeadlineMarginMs = deadlineMarginMs + _latestPresentationMarginMs = presentationMarginMs + _latestMissedDeadline = deadlineMarginMs < 0.0 + _deadlineSampleCount += 1 + if deadlineMarginMs < 0.0 { + _missedDeadlineCount += 1 + } + lock.unlock() + #else + _ = deadlineMarginMs + _ = presentationMarginMs + #endif + } + + /// Called once per frame that is presented without a fresh device anchor (visionOS). + public func recordMissingAnchor() { + #if ENGINE_STATS_ENABLED + guard isCollecting else { return } + lock.lock() + _missingAnchorCount += 1 + lock.unlock() + #endif + } + /// Publishes the current frame so API readers can safely consume it next frame. public func completeFrame() { #if ENGINE_STATS_ENABLED + guard isCollecting else { return } lock.lock() // Pull in latest GPU timing from async handler currentSnapshot.timing.gpuExecutionMs = _latestGPUExecutionMs currentSnapshot.timing.gpuFrameCadenceMs = _latestGPUFrameCadenceMs + // Pull in the latest compositor deadline sample and the cumulative counters + currentSnapshot.compositor.deadlineMarginMs = _latestDeadlineMarginMs + currentSnapshot.compositor.presentationMarginMs = _latestPresentationMarginMs + currentSnapshot.compositor.missedDeadline = _latestMissedDeadline + currentSnapshot.compositor.missedDeadlineCount = _missedDeadlineCount + currentSnapshot.compositor.deadlineSampleCount = _deadlineSampleCount + currentSnapshot.compositor.missingAnchorCount = _missingAnchorCount + // Update 30-frame rolling average for CPU frame time let frameMs = currentSnapshot.timing.frameTotalMs _frameMsBuffer[_frameMsBufferIndex] = frameMs @@ -212,8 +335,50 @@ public final class EngineStatsMonitor: @unchecked Sendable { ? sum / Double(_frameMsFilled) : frameMs + // Hitch accounting: cumulative histogram and over-budget count, plus a one-second window + _hitches.frameBudgetMs = _frameBudgetMs + _hitches.framesSampled += 1 + let overBudget = frameMs > _frameBudgetMs + if overBudget { + _hitches.framesOverBudget += 1 + } + _hitches.histogram[EngineHitchStats.bucketIndex(forFrameMs: frameMs)] += 1 + + let now = currentSnapshot.timestampSeconds + if !_windowStarted { + _windowStarted = true + _windowStartSeconds = now + } + if now - _windowStartSeconds >= 1.0 { + _hitches.framesLastSecond = _windowFrames + _hitches.framesOverBudgetLastSecond = _windowFramesOverBudget + _hitches.worstFrameMsLastSecond = _windowWorstFrameMs + _windowFrames = 0 + _windowFramesOverBudget = 0 + _windowWorstFrameMs = 0.0 + _windowStartSeconds = now + } + _windowFrames += 1 + if overBudget { + _windowFramesOverBudget += 1 + } + _windowWorstFrameMs = max(_windowWorstFrameMs, frameMs) + currentSnapshot.hitches = _hitches + + // Thermal transitions are rare and worth a mark on the Instruments timeline + let thermalState = currentSnapshot.memory.thermalState + let thermalChanged = _lastThermalState >= 0 && thermalState != _lastThermalState + _lastThermalState = thermalState + publishedSnapshot = currentSnapshot + let recordedSnapshot = currentSnapshot + let activeRecorder = recorder lock.unlock() + + if thermalChanged { + EngineProfiler.shared.emitEvent(.thermalStateChanged) + } + activeRecorder?.enqueue(recordedSnapshot) #endif } @@ -235,6 +400,19 @@ public final class EngineStatsMonitor: @unchecked Sendable { _latestGPUExecutionMs = 0.0 _latestGPUFrameCadenceMs = 0.0 _lastGPUCompletionTime = 0.0 + _latestDeadlineMarginMs = 0.0 + _latestPresentationMarginMs = 0.0 + _latestMissedDeadline = false + _missedDeadlineCount = 0 + _deadlineSampleCount = 0 + _missingAnchorCount = 0 + _hitches = EngineHitchStats(frameBudgetMs: _frameBudgetMs) + _windowStartSeconds = 0.0 + _windowStarted = false + _windowFrames = 0 + _windowFramesOverBudget = 0 + _windowWorstFrameMs = 0.0 + _lastThermalState = -1 _frameMsBuffer = .init(repeating: 0.0, count: kSmoothingWindow) _frameMsBufferIndex = 0 _frameMsFilled = 0 @@ -244,6 +422,7 @@ public final class EngineStatsMonitor: @unchecked Sendable { public func tick() { #if ENGINE_STATS_ENABLED + guard isCollecting else { return } let now = CACurrentMediaTime() var shouldLog = false var snapshotToLog: EngineStatsSnapshot = .init() @@ -290,6 +469,17 @@ public func setEngineStatsLogging(enabled: Bool) { EngineStatsMonitor.shared.enableLogging = enabled } +/// Turns stats collection on or off at runtime. Collection is on by default in debug builds and +/// off in release builds (`UNTOLD_STATS=1` in the environment turns it on anywhere). Turning it on +/// in a release build is how device measurements are taken. +public func setEngineStatsCollection(enabled: Bool) { + EngineStatsMonitor.shared.isCollecting = enabled +} + +public func isEngineStatsCollectionEnabled() -> Bool { + EngineStatsMonitor.shared.isCollecting +} + public func setEngineStatsLogging( enabled: Bool, profile: EngineStatsLoggingProfile, diff --git a/Sources/UntoldEngine/Profiling/EngineStatsRecorder.swift b/Sources/UntoldEngine/Profiling/EngineStatsRecorder.swift new file mode 100644 index 000000000..cf619c097 --- /dev/null +++ b/Sources/UntoldEngine/Profiling/EngineStatsRecorder.swift @@ -0,0 +1,249 @@ +// +// EngineStatsRecorder.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation + +public enum EngineStatsRecordingInterval: Sendable { + /// One line per published frame. Large files, exact hitch positions. + case perFrame + /// One line per second (the last snapshot of each second). The summary still covers every frame. + case perSecond +} + +public enum EngineStatsRecordingError: Error { + /// Engine stats are not compiled into this build (`ENGINE_STATS_ENABLED`), so there is nothing to record. + case statsNotCompiledIn + case cannotCreateFile(URL) +} + +/// Whole-run numbers written as the last line of a recording and returned by `stopEngineStatsRecording()`. +public struct EngineStatsRecordingSummary: Codable, Equatable, Sendable { + public var frames: Int = 0 + public var durationSeconds: Double = 0.0 + public var frameBudgetMs: Double = 0.0 + public var meanFrameMs: Double = 0.0 + public var p50FrameMs: Double = 0.0 + public var p95FrameMs: Double = 0.0 + public var p99FrameMs: Double = 0.0 + public var worstFrameMs: Double = 0.0 + public var framesOverBudget: Int = 0 + public var meanGPUExecutionMs: Double = 0.0 + /// Fastest GPU frame of the run. Clock drift on a lightly loaded GPU only inflates frames, + /// so the minimum is the number to compare across runs; the mean tracks the clock state. + public var minGPUExecutionMs: Double = 0.0 + /// Compositor deadline misses and samples over the run (visionOS), zero elsewhere. + public var missedDeadlines: Int = 0 + public var deadlineSamples: Int = 0 + /// Mean GPU time per pass label over the frames in which the pass appeared. + public var gpuPassMeanMs: [String: Double] = [:] + /// Minimum GPU time per pass label. GPU clocks drift on a lightly loaded device, which only + /// ever inflates a pass; the minimum is the robust number to compare across runs. + public var gpuPassMinMs: [String: Double] = [:] + /// Mean of the per-frame CPU timing fields (`EngineTimingStats` and the compositor phases), + /// keyed by field name: what a CPU-side optimization changes. + public var timingMeanMs: [String: Double] = [:] + public var worstThermalState: Int = 0 + public var peakGPUAllocatedBytes: Int = 0 + + public init() {} +} + +/// Writes engine stats snapshots to a JSON Lines file off the render thread. +/// +/// Each line is `{"type":"frame","frame":{...EngineStatsSnapshot...}}`; the last line written by +/// `stop()` is `{"type":"summary","summary":{...EngineStatsRecordingSummary...}}`. Use +/// `startEngineStatsRecording(to:interval:)` and `stopEngineStatsRecording()` rather than this +/// type directly; the monitor feeds it every published snapshot. +public final class EngineStatsRecorder: @unchecked Sendable { + private struct Line: Codable { + var type: String + var frame: EngineStatsSnapshot? + var summary: EngineStatsRecordingSummary? + } + + public let url: URL + public let interval: EngineStatsRecordingInterval + + private let queue = DispatchQueue(label: "com.untoldengine.profiling.recorder", qos: .utility) + private let handle: FileHandle + private let encoder: JSONEncoder + private var isStopped = false + + // Accumulated on the queue + private var frameTimes: [Double] = [] + private var gpuExecutionSum = 0.0 + private var gpuExecutionSamples = 0 + private var gpuExecutionMin = Double.greatestFiniteMagnitude + private var gpuPassSums: [String: (sum: Double, samples: Int)] = [:] + private var gpuPassMins: [String: Double] = [:] + private var timingSums: [String: Double] = [:] + private var timingSamples = 0 + private var firstTimestamp: Double? + private var lastTimestamp: Double = 0.0 + private var lastWrittenSecond: Double = -1.0 + private var lastSnapshot: EngineStatsSnapshot? + private var summary = EngineStatsRecordingSummary() + + init(url: URL, interval: EngineStatsRecordingInterval) throws { + self.url = url + self.interval = interval + let fileManager = FileManager.default + try? fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + guard fileManager.createFile(atPath: url.path, contents: nil), let handle = try? FileHandle(forWritingTo: url) else { + throw EngineStatsRecordingError.cannotCreateFile(url) + } + self.handle = handle + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.nonConformingFloatEncodingStrategy = .convertToString(positiveInfinity: "inf", negativeInfinity: "-inf", nan: "nan") + } + + /// Called by the monitor with each published snapshot; returns immediately. + func enqueue(_ snapshot: EngineStatsSnapshot) { + queue.async { [self] in + guard !isStopped else { return } + accumulate(snapshot) + switch interval { + case .perFrame: + write(Line(type: "frame", frame: snapshot, summary: nil)) + case .perSecond: + let second = snapshot.timestampSeconds.rounded(.down) + if second != lastWrittenSecond { + lastWrittenSecond = second + write(Line(type: "frame", frame: snapshot, summary: nil)) + } + } + } + } + + /// Writes the summary line, closes the file and returns the summary. Idempotent. + @discardableResult + public func stop() -> EngineStatsRecordingSummary { + queue.sync { [self] in + guard !isStopped else { return summary } + isStopped = true + summary = makeSummary() + write(Line(type: "summary", frame: nil, summary: summary)) + try? handle.close() + return summary + } + } + + // MARK: - Queue-only helpers + + private func accumulate(_ snapshot: EngineStatsSnapshot) { + frameTimes.append(snapshot.timing.frameTotalMs) + if snapshot.timing.gpuExecutionMs > 0 { + gpuExecutionSum += snapshot.timing.gpuExecutionMs + gpuExecutionSamples += 1 + gpuExecutionMin = min(gpuExecutionMin, snapshot.timing.gpuExecutionMs) + } + for pass in snapshot.gpuPasses.passes { + let entry = gpuPassSums[pass.label] ?? (0.0, 0) + gpuPassSums[pass.label] = (entry.sum + pass.totalMs, entry.samples + 1) + gpuPassMins[pass.label] = min(gpuPassMins[pass.label] ?? .greatestFiniteMagnitude, pass.totalMs) + } + let t = snapshot.timing + let c = snapshot.compositor + let fields: [(String, Double)] = [ + ("updateMs", t.updateMs), ("renderTotalMs", t.renderTotalMs), ("renderPrepMs", t.renderPrepMs), + ("encodeMs", t.encodeMs), ("submitMs", t.submitMs), ("cullingMs", t.cullingMs), + ("streamingRegionMs", t.streamingRegionMs), ("geometryStreamingMs", t.geometryStreamingMs), + ("batchingTickMs", t.batchingTickMs), ("semaphoreWaitMs", t.semaphoreWaitMs), + ("scenegraphMs", t.scenegraphMs), ("extensionsUpdateMs", t.extensionsUpdateMs), ("lodMs", t.lodMs), + ("animationMs", t.animationMs), ("scriptingMs", t.scriptingMs), ("physicsMs", t.physicsMs), + ("customSystemsMs", t.customSystemsMs), ("gameUpdateMs", t.gameUpdateMs), + ("compositorUpdateMs", c.updateMs), ("compositorInputSlackMs", c.inputSlackMs), + ("compositorSubmissionMs", c.submissionMs), ("compositorDeadlineMarginMs", c.deadlineMarginMs), + ] + for (name, value) in fields { + timingSums[name, default: 0.0] += value + } + timingSamples += 1 + if snapshot.timestampSeconds > 0 { + if firstTimestamp == nil { + firstTimestamp = snapshot.timestampSeconds + } + lastTimestamp = snapshot.timestampSeconds + } + lastSnapshot = snapshot + } + + private func makeSummary() -> EngineStatsRecordingSummary { + var result = EngineStatsRecordingSummary() + result.frames = frameTimes.count + if let first = firstTimestamp { + result.durationSeconds = max(0.0, lastTimestamp - first) + } + if !frameTimes.isEmpty { + let sorted = frameTimes.sorted() + result.meanFrameMs = frameTimes.reduce(0.0, +) / Double(frameTimes.count) + result.p50FrameMs = sorted[Int(Double(sorted.count - 1) * 0.50)] + result.p95FrameMs = sorted[Int(Double(sorted.count - 1) * 0.95)] + result.p99FrameMs = sorted[Int(Double(sorted.count - 1) * 0.99)] + result.worstFrameMs = sorted[sorted.count - 1] + } + if let last = lastSnapshot { + result.frameBudgetMs = last.hitches.frameBudgetMs + result.framesOverBudget = frameTimes.filter { $0 > last.hitches.frameBudgetMs }.count + result.missedDeadlines = last.compositor.missedDeadlineCount + result.deadlineSamples = last.compositor.deadlineSampleCount + } + if gpuExecutionSamples > 0 { + result.meanGPUExecutionMs = gpuExecutionSum / Double(gpuExecutionSamples) + result.minGPUExecutionMs = gpuExecutionMin + } + for (label, entry) in gpuPassSums where entry.samples > 0 { + result.gpuPassMeanMs[label] = entry.sum / Double(entry.samples) + } + result.gpuPassMinMs = gpuPassMins + if timingSamples > 0 { + for (name, sum) in timingSums { + result.timingMeanMs[name] = sum / Double(timingSamples) + } + } + result.worstThermalState = lastSnapshotMaxThermal + result.peakGPUAllocatedBytes = peakGPUAllocated + return result + } + + private var lastSnapshotMaxThermal = 0 + private var peakGPUAllocated = 0 + + private func write(_ line: Line) { + if let frame = line.frame { + lastSnapshotMaxThermal = max(lastSnapshotMaxThermal, frame.memory.thermalState) + peakGPUAllocated = max(peakGPUAllocated, frame.memory.gpuAllocatedBytes) + } + guard var data = try? encoder.encode(line) else { return } + data.append(0x0A) + handle.write(data) + } +} + +/// Starts writing every published engine stats snapshot to `url` as JSON Lines. Replaces an +/// active recording (which is stopped and summarised first). Throws when engine stats are not +/// compiled in or the file cannot be created. +public func startEngineStatsRecording(to url: URL, interval: EngineStatsRecordingInterval = .perSecond) throws { + #if ENGINE_STATS_ENABLED + let recorder = try EngineStatsRecorder(url: url, interval: interval) + EngineStatsMonitor.shared.attachRecorder(recorder)?.stop() + #else + _ = url + _ = interval + throw EngineStatsRecordingError.statsNotCompiledIn + #endif +} + +/// Stops the active recording, writes its summary line and returns the summary; nil when nothing was recording. +@discardableResult +public func stopEngineStatsRecording() -> EngineStatsRecordingSummary? { + EngineStatsMonitor.shared.attachRecorder(nil)?.stop() +} diff --git a/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift b/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift index 0ceb1821a..b38ce54d5 100644 --- a/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift +++ b/Sources/UntoldEngine/Profiling/EngineStatsSnapshot.swift @@ -11,7 +11,7 @@ import Foundation import simd -public struct EngineTimingStats { +public struct EngineTimingStats: Codable, Sendable { public var frameTotalMs: Double = 0.0 /// CPU frame time averaged over the last 30 frames — use this for smooth FPS display. public var smoothedFrameMs: Double = 0.0 @@ -43,6 +43,24 @@ public struct EngineTimingStats { public var geometryStreamingMs: Double = 0.0 public var batchingTickMs: Double = 0.0 public var batchingRebuildMs: Double = 0.0 + /// CPU time spent waiting for a free in-flight command buffer slot before encoding. + /// Persistently non-zero means the GPU (or the compositor) is pacing the CPU. + public var semaphoreWaitMs: Double = 0.0 + + // Per-system CPU time inside updateMs. Systems that did not run this frame stay at zero. + public var scenegraphMs: Double = 0.0 + public var extensionsUpdateMs: Double = 0.0 + public var lodMs: Double = 0.0 + public var animationMs: Double = 0.0 + public var scriptingMs: Double = 0.0 + /// All fixed-step physics updates this frame, summed. + public var physicsMs: Double = 0.0 + /// Number of fixed-step physics updates this frame (0 when the accumulator did not reach a step). + public var physicsStepCount: Int = 0 + /// All fixed-step custom system updates this frame, summed. + public var customSystemsMs: Double = 0.0 + /// The app's game update callback. + public var gameUpdateMs: Double = 0.0 public init( frameTotalMs: Double = 0.0, @@ -61,7 +79,17 @@ public struct EngineTimingStats { batchingTickMs: Double = 0.0, batchingRebuildMs: Double = 0.0, presentationDeltaMs: Double = 0.0, - skippedFrames: Int = 0 + skippedFrames: Int = 0, + semaphoreWaitMs: Double = 0.0, + scenegraphMs: Double = 0.0, + extensionsUpdateMs: Double = 0.0, + lodMs: Double = 0.0, + animationMs: Double = 0.0, + scriptingMs: Double = 0.0, + physicsMs: Double = 0.0, + physicsStepCount: Int = 0, + customSystemsMs: Double = 0.0, + gameUpdateMs: Double = 0.0 ) { self.frameTotalMs = frameTotalMs self.smoothedFrameMs = smoothedFrameMs @@ -80,10 +108,88 @@ public struct EngineTimingStats { self.batchingRebuildMs = batchingRebuildMs self.presentationDeltaMs = presentationDeltaMs self.skippedFrames = skippedFrames + self.semaphoreWaitMs = semaphoreWaitMs + self.scenegraphMs = scenegraphMs + self.extensionsUpdateMs = extensionsUpdateMs + self.lodMs = lodMs + self.animationMs = animationMs + self.scriptingMs = scriptingMs + self.physicsMs = physicsMs + self.physicsStepCount = physicsStepCount + self.customSystemsMs = customSystemsMs + self.gameUpdateMs = gameUpdateMs } } -public struct EngineRenderStats { +/// Compositor Services frame accounting. Only populated on visionOS, where the compositor +/// hands the app a deadline for every frame; on other platforms every field stays at its default. +/// +/// The per-frame values describe the last completed frame. The GPU-side values (deadline and +/// presentation margins) come from the command buffer completion handler and therefore describe +/// the most recently *completed* command buffer, which can lag the CPU frame by one or two frames. +public struct EngineCompositorStats: Codable, Sendable { + /// CPU time between `startUpdate()` and `endUpdate()`. + public var updateMs: Double = 0.0 + /// Time left until `optimalInputTime` when the update phase ended. + /// Negative means the update phase ran past the compositor's optimal input time. + public var inputSlackMs: Double = 0.0 + /// CPU time between `startSubmission()` and the command buffer commit. + public var submissionMs: Double = 0.0 + /// Time between the GPU finishing the frame and the compositor's rendering deadline. + /// Negative means the deadline was missed and the compositor reprojected an older frame. + public var deadlineMarginMs: Double = 0.0 + /// Time between the GPU finishing the frame and the frame's presentation time. + public var presentationMarginMs: Double = 0.0 + /// Whether the most recently completed frame missed its rendering deadline. + public var missedDeadline: Bool = false + /// Frames whose GPU work finished after the rendering deadline since the monitor was reset. + public var missedDeadlineCount: Int = 0 + /// Frames with a GPU completion sample since the monitor was reset (denominator for the miss rate). + public var deadlineSampleCount: Int = 0 + /// Frames rendered without a fresh device anchor since the monitor was reset. + public var missingAnchorCount: Int = 0 + /// Number of views (eyes) rendered this frame. Zero outside Compositor Services. + public var viewCount: Int = 0 + /// Drawable size per view in pixels. + public var viewTextureWidth: Int = 0 + public var viewTextureHeight: Int = 0 + + /// Fraction of sampled frames that missed the rendering deadline, in the range 0...1. + public var missedDeadlineRate: Double { + guard deadlineSampleCount > 0 else { return 0.0 } + return Double(missedDeadlineCount) / Double(deadlineSampleCount) + } + + public init( + updateMs: Double = 0.0, + inputSlackMs: Double = 0.0, + submissionMs: Double = 0.0, + deadlineMarginMs: Double = 0.0, + presentationMarginMs: Double = 0.0, + missedDeadline: Bool = false, + missedDeadlineCount: Int = 0, + deadlineSampleCount: Int = 0, + missingAnchorCount: Int = 0, + viewCount: Int = 0, + viewTextureWidth: Int = 0, + viewTextureHeight: Int = 0 + ) { + self.updateMs = updateMs + self.inputSlackMs = inputSlackMs + self.submissionMs = submissionMs + self.deadlineMarginMs = deadlineMarginMs + self.presentationMarginMs = presentationMarginMs + self.missedDeadline = missedDeadline + self.missedDeadlineCount = missedDeadlineCount + self.deadlineSampleCount = deadlineSampleCount + self.missingAnchorCount = missingAnchorCount + self.viewCount = viewCount + self.viewTextureWidth = viewTextureWidth + self.viewTextureHeight = viewTextureHeight + } +} + +public struct EngineRenderStats: Codable, Sendable { public var drawCallsTotal: Int = 0 public var drawCallsOpaque: Int = 0 public var drawCallsTransparent: Int = 0 @@ -138,7 +244,7 @@ public struct EngineRenderStats { } } -public struct EngineCullingStats { +public struct EngineCullingStats: Codable, Sendable { public var frustumTested: Int = 0 public var frustumPassed: Int = 0 public var frustumFailed: Int = 0 @@ -181,7 +287,7 @@ public struct EngineCullingStats { } } -public struct EngineStreamingStats { +public struct EngineStreamingStats: Codable, Sendable { public var activeLoads: Int = 0 public var loadCandidates: Int = 0 public var pendingLoadBacklog: Int = 0 @@ -294,7 +400,7 @@ public struct EngineStreamingStats { } } -public struct EngineBatchingStats { +public struct EngineBatchingStats: Codable, Sendable { public var batchGroupCount: Int = 0 public var batchedMeshCount: Int = 0 public var rebuildsThisSecond: Int = 0 @@ -335,7 +441,7 @@ public struct EngineBatchingStats { } } -public struct EngineMemoryStats { +public struct EngineMemoryStats: Codable, Sendable { public var meshMemoryBytes: Int = 0 public var textureMemoryBytes: Int = 0 public var geometryBudgetBytes: Int = 0 @@ -343,6 +449,23 @@ public struct EngineMemoryStats { public var utilizationPercent: Double = 0.0 public var isUnderPressure: Bool = false public var trackedEntityCount: Int = 0 + /// Bytes the Metal device currently has allocated for this process (`MTLDevice.currentAllocatedSize`). + public var gpuAllocatedBytes: Int = 0 + /// Memory the process may still allocate before the system terminates it (`os_proc_available_memory`). + /// Zero on platforms that do not report it (macOS). + public var availableMemoryBytes: Int = 0 + /// `ProcessInfo.ThermalState` raw value: 0 nominal, 1 fair, 2 serious, 3 critical. + public var thermalState: Int = 0 + + public var thermalStateName: String { + switch thermalState { + case 0: return "nominal" + case 1: return "fair" + case 2: return "serious" + case 3: return "critical" + default: return "unknown" + } + } public init( meshMemoryBytes: Int = 0, @@ -363,7 +486,7 @@ public struct EngineMemoryStats { } } -public struct EngineStatsSnapshot { +public struct EngineStatsSnapshot: Codable, Sendable { public var frameIndex: UInt64 = 0 public var timestampSeconds: Double = 0.0 public var timing: EngineTimingStats = .init() @@ -372,6 +495,11 @@ public struct EngineStatsSnapshot { public var streaming: EngineStreamingStats = .init() public var batching: EngineBatchingStats = .init() public var memory: EngineMemoryStats = .init() + public var compositor: EngineCompositorStats = .init() + /// Per-pass GPU timings of the last resolved frame (see `GPUPassTiming`). Empty when the timer is off. + public var gpuPasses: GPUPassTimingSnapshot = .init() + /// Frame-time distribution and over-budget counts since the monitor was reset. + public var hitches: EngineHitchStats = .init() public init( frameIndex: UInt64 = 0, @@ -381,7 +509,10 @@ public struct EngineStatsSnapshot { culling: EngineCullingStats = .init(), streaming: EngineStreamingStats = .init(), batching: EngineBatchingStats = .init(), - memory: EngineMemoryStats = .init() + memory: EngineMemoryStats = .init(), + compositor: EngineCompositorStats = .init(), + gpuPasses: GPUPassTimingSnapshot = .init(), + hitches: EngineHitchStats = .init() ) { self.frameIndex = frameIndex self.timestampSeconds = timestampSeconds @@ -391,5 +522,58 @@ public struct EngineStatsSnapshot { self.streaming = streaming self.batching = batching self.memory = memory + self.compositor = compositor + self.gpuPasses = gpuPasses + self.hitches = hitches + } +} + +/// Frame-time distribution since the monitor was reset, plus the last completed one-second window. +/// Mean and percentile numbers hide single long frames; these counts do not. +public struct EngineHitchStats: Codable, Sendable { + /// Upper bounds of the histogram buckets in milliseconds; the last bucket is open-ended. + /// 11.2 and 16.8 sit just above the 90 Hz and 60 Hz frame periods. + public static let bucketUpperBoundsMs: [Double] = [4.0, 8.0, 11.2, 16.8, 33.4, 100.0] + + /// Frame budget used for the over-budget counts. 16.67 ms at 60 Hz, 11.11 ms at 90 Hz. + public var frameBudgetMs: Double = 1000.0 / 60.0 + public var framesSampled: Int = 0 + public var framesOverBudget: Int = 0 + /// Frames, frames over budget and worst frame time in the last completed one-second window. + public var framesLastSecond: Int = 0 + public var framesOverBudgetLastSecond: Int = 0 + public var worstFrameMsLastSecond: Double = 0.0 + /// Cumulative frame count per bucket. Bucket `i` holds frames below `bucketUpperBoundsMs[i]`; + /// the final bucket holds everything at or above the last bound. + public var histogram: [Int] = Array(repeating: 0, count: EngineHitchStats.bucketUpperBoundsMs.count + 1) + + public var overBudgetRate: Double { + guard framesSampled > 0 else { return 0.0 } + return Double(framesOverBudget) / Double(framesSampled) + } + + public static func bucketIndex(forFrameMs frameMs: Double) -> Int { + for (index, bound) in bucketUpperBoundsMs.enumerated() where frameMs < bound { + return index + } + return bucketUpperBoundsMs.count + } + + public init( + frameBudgetMs: Double = 1000.0 / 60.0, + framesSampled: Int = 0, + framesOverBudget: Int = 0, + framesLastSecond: Int = 0, + framesOverBudgetLastSecond: Int = 0, + worstFrameMsLastSecond: Double = 0.0, + histogram: [Int] = Array(repeating: 0, count: EngineHitchStats.bucketUpperBoundsMs.count + 1) + ) { + self.frameBudgetMs = frameBudgetMs + self.framesSampled = framesSampled + self.framesOverBudget = framesOverBudget + self.framesLastSecond = framesLastSecond + self.framesOverBudgetLastSecond = framesOverBudgetLastSecond + self.worstFrameMsLastSecond = worstFrameMsLastSecond + self.histogram = histogram } } diff --git a/Sources/UntoldEngine/Profiling/GPUPassTiming.swift b/Sources/UntoldEngine/Profiling/GPUPassTiming.swift index d7b919eb7..af89d0f38 100644 --- a/Sources/UntoldEngine/Profiling/GPUPassTiming.swift +++ b/Sources/UntoldEngine/Profiling/GPUPassTiming.swift @@ -24,7 +24,7 @@ public final class GPUPassTiming: @unchecked Sendable { /// One pass's time per frame, in milliseconds: a render pass's vertex and fragment stages; /// a compute or blit pass has only `fragmentMs`, the whole pass. - public struct PassTime: Sendable, Equatable { + public struct PassTime: Codable, Sendable, Equatable { public let label: String public let vertexMs: Double public let fragmentMs: Double diff --git a/Sources/UntoldEngine/Profiling/GPUPassTimingSnapshot.swift b/Sources/UntoldEngine/Profiling/GPUPassTimingSnapshot.swift new file mode 100644 index 000000000..c459a4871 --- /dev/null +++ b/Sources/UntoldEngine/Profiling/GPUPassTimingSnapshot.swift @@ -0,0 +1,40 @@ +// +// GPUPassTimingSnapshot.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation + +/// The per-pass GPU times of the last frame `GPUPassTiming` resolved, as the engine stats +/// carry them: a copy taken when the frame's stats are published. +public struct GPUPassTimingSnapshot: Codable, Equatable, Sendable { + /// A number that changes with each resolved frame; zero before the first. + public var frameIndex: Int = 0 + /// The frame's passes, heaviest first. Empty when the timer is off. + public var passes: [GPUPassTiming.PassTime] = [] + /// Sum of the passes' times. Passes can overlap on the GPU, so this is an upper bound on + /// the frame's GPU time. + public var totalMs: Double = 0.0 + + public init(frameIndex: Int = 0, passes: [GPUPassTiming.PassTime] = []) { + self.frameIndex = frameIndex + self.passes = passes + totalMs = passes.reduce(0) { $0 + $1.totalMs } + } + + /// The time of the pass with this label, or nil when the frame had none. + public func ms(for label: String) -> Double? { + passes.first { $0.label == label }?.totalMs + } + + /// The timer's last resolved frame. + public static func current() -> GPUPassTimingSnapshot { + let frame = GPUPassTiming.shared.lastFrame() + return GPUPassTimingSnapshot(frameIndex: frame.index, passes: frame.passes) + } +} diff --git a/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift b/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift index a46d3d6eb..8a863a17b 100644 --- a/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift +++ b/Sources/UntoldEngine/Profiling/RenderStatsCollector.swift @@ -33,6 +33,9 @@ public final class RenderStatsCollector: @unchecked Sendable { #if ENGINE_STATS_ENABLED private let lock = NSLock() private var stats = RenderDrawStats() + /// Sampled from the monitor at `reset()` (frame start, render thread) so the per-draw path + /// reads one Bool instead of taking a lock. Draw recording happens on the same thread. + private var isCollecting = false #endif private init() {} @@ -41,6 +44,7 @@ public final class RenderStatsCollector: @unchecked Sendable { #if ENGINE_STATS_ENABLED lock.lock() stats = .init() + isCollecting = EngineStatsMonitor.shared.isCollecting lock.unlock() #endif } @@ -96,6 +100,7 @@ public final class RenderStatsCollector: @unchecked Sendable { batched: Bool ) { #if ENGINE_STATS_ENABLED + guard isCollecting else { return } let safeElementCount = max(0, elementCount) let safeInstanceCount = max(1, instanceCount) let triangles = triangleCount(for: primitiveType, elementCount: safeElementCount) * safeInstanceCount diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift index e80f6d21e..aa0b1301c 100644 --- a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift +++ b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift @@ -246,6 +246,7 @@ public extension RenderPassContext { descriptor.depthAttachment.loadAction = .load descriptor.depthAttachment.storeAction = .store + GPUPassTiming.shared.attach(descriptor, label) guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { handleError(.renderPassCreationFailed, label) return diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index ba656e4e9..479b7e8a5 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -5360,7 +5360,7 @@ public enum RenderPasses { { let width = min(sourceDepth.width, opaqueDepthSnapshot.width) let height = min(sourceDepth.height, opaqueDepthSnapshot.height) - if width > 0, height > 0, let blitEncoder = commandBuffer.makeBlitCommandEncoder() { + if width > 0, height > 0, let blitEncoder = GPUPassTiming.shared.makeBlitEncoder(commandBuffer, "GaussianDepthCopy") { blitEncoder.label = "Copy Opaque Depth for Gaussian Occlusion" blitEncoder.copy( from: sourceDepth, diff --git a/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift b/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift index e3c6f0a88..f57ccb226 100644 --- a/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift +++ b/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift @@ -74,6 +74,7 @@ public final class SampleRenderExtension: RenderExtension, @unchecked Sendable { clearColor.w ) + GPUPassTiming.shared.attach(descriptor, "SampleExtension") guard let encoder = context.commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return } diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 6f76b8e9c..5526e76b3 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -322,6 +322,9 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } private func publishEngineStats(frameStartTime: Double) { + // Gathering the per-frame snapshot walks visible entities and batch groups; skip all of + // it when collection is off (the default in release builds). + guard EngineStatsMonitor.shared.isCollecting else { return } let frameTotalMs: Double if let dt = timeSinceLastUpdate as Float?, dt > 0 { frameTotalMs = Double(dt) * 1000.0 @@ -342,11 +345,20 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { let tileRenderCosts = auditVisibleTileRenderCosts() let memStats = MemoryBudgetManager.shared.getStats() let frame = currentFrameTime + let gpuPasses = GPUPassTimingSnapshot.current() + let gpuAllocatedBytes = renderInfo.device?.currentAllocatedSize ?? 0 + let thermalState = ProcessInfo.processInfo.thermalState.rawValue + #if os(iOS) || os(visionOS) || os(tvOS) + let availableMemoryBytes = Int(os_proc_available_memory()) + #else + let availableMemoryBytes = 0 + #endif EngineStatsMonitor.shared.update { snapshot in snapshot.timing.frameTotalMs = frameTotalMs snapshot.timing.presentationDeltaMs = frame.deltaTime * 1000.0 snapshot.timing.skippedFrames = frame.skippedFrames + snapshot.gpuPasses = gpuPasses snapshot.render.drawCallsTotal = drawStats.drawCallsTotal snapshot.render.drawCallsOpaque = drawStats.drawCallsOpaque @@ -438,6 +450,9 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { snapshot.memory.utilizationPercent = Double(memStats.utilizationPercent) snapshot.memory.isUnderPressure = memStats.isUnderPressure snapshot.memory.trackedEntityCount = memStats.trackedEntityCount + snapshot.memory.gpuAllocatedBytes = gpuAllocatedBytes + snapshot.memory.availableMemoryBytes = availableMemoryBytes + snapshot.memory.thermalState = thermalState } EngineStatsMonitor.shared.completeFrame() } @@ -644,22 +659,28 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { #endif EngineProfiler.shared.beginScope(.update) calculateDeltaTime(presentationTime: presentationTime, nominalPeriod: nominalPeriod) - traverseSceneGraph() + profileSystem(.scenegraph, \.scenegraphMs) { + traverseSceneGraph() + } handleInputCallback?() let extensionUpdateContext = makeEngineExtensionUpdateContext() - RenderExtensionRegistry.shared.updateExtensions( - deltaTime: timeSinceLastUpdate, - context: extensionUpdateContext - ) - EngineExtensionRegistry.shared.updateExtensions( - deltaTime: timeSinceLastUpdate, - context: extensionUpdateContext - ) + profileSystem(.extensionsUpdate, \.extensionsUpdateMs) { + RenderExtensionRegistry.shared.updateExtensions( + deltaTime: timeSinceLastUpdate, + context: extensionUpdateContext + ) + EngineExtensionRegistry.shared.updateExtensions( + deltaTime: timeSinceLastUpdate, + context: extensionUpdateContext + ) + } // 3. LOD selection (decides which representation is active, checks residency) - LODSystem.shared.update(deltaTime: fixedStep) - GaussianLODSystem.shared.update(deltaTime: fixedStep) + profileSystem(.lod, \.lodMs) { + LODSystem.shared.update(deltaTime: fixedStep) + GaussianLODSystem.shared.update(deltaTime: fixedStep) + } // 4. Flush events (residency and LOD change events are processed) SystemEventBus.shared.flushEvents() @@ -686,17 +707,23 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { OctreeSystem.shared.updateDirtyBounds() if gameMode == true { - AnimationSystem.shared.update(timeSinceLastUpdate) + profileSystem(.animation, \.animationMs) { + AnimationSystem.shared.update(timeSinceLastUpdate) + } // USC scripts (runs every frame in Play mode) - USCSystem.shared.update(timeSinceLastUpdate) + profileSystem(.scripting, \.scriptingMs) { + USCSystem.shared.update(timeSinceLastUpdate) + } // fixed‐timestep physics physicsAccumulator += timeSinceLastUpdate let maxSteps = 5 var steps = 0 while physicsAccumulator >= fixedStep, steps < maxSteps { - updatePhysicsSystem(deltaTime: fixedStep) + profileSystem(.physics, \.physicsMs) { + updatePhysicsSystem(deltaTime: fixedStep) + } EngineExtensionRegistry.shared.fixedUpdateExtensions( deltaTime: fixedStep, context: extensionUpdateContext @@ -705,13 +732,23 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { deltaTime: fixedStep, context: extensionUpdateContext ) - updateCustomSystems(deltaTime: fixedStep) + profileSystem(.customSystems, \.customSystemsMs) { + updateCustomSystems(deltaTime: fixedStep) + } physicsAccumulator -= fixedStep steps += 1 } + #if ENGINE_STATS_ENABLED + let physicsSteps = steps + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.physicsStepCount = physicsSteps + } + #endif // user game update - gameUpdateCallback?(timeSinceLastUpdate) + profileSystem(.gameUpdate, \.gameUpdateMs) { + gameUpdateCallback?(timeSinceLastUpdate) + } frameUpdateCallback?(currentFrameTime) } @@ -883,6 +920,31 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } /// XR path finalization hook. Call this once after XR submission for the frame. + /// Runs `body` inside a profiler signpost scope and, when engine stats are compiled in, adds its + /// CPU time to the `EngineTimingStats` field selected by `keyPath`. Systems that run several times + /// per frame (fixed-step physics) accumulate. + @inline(__always) + private func profileSystem( + _ scope: ProfileScope, + _ keyPath: WritableKeyPath, + _ body: () -> Void + ) { + EngineProfiler.shared.beginScope(scope) + #if ENGINE_STATS_ENABLED + let start = CACurrentMediaTime() + #endif + body() + #if ENGINE_STATS_ENABLED + let elapsedMs = (CACurrentMediaTime() - start) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing[keyPath: keyPath] += elapsedMs + } + #else + _ = keyPath + #endif + EngineProfiler.shared.endScope(scope) + } + public func finalizeXRStatsAndMonitors(frameStartTime: Double) { #if ENGINE_STATS_ENABLED publishEngineStats(frameStartTime: frameStartTime) diff --git a/Sources/UntoldEngine/Systems/GaussianSystem.swift b/Sources/UntoldEngine/Systems/GaussianSystem.swift index 587f75bcd..0fe512479 100644 --- a/Sources/UntoldEngine/Systems/GaussianSystem.swift +++ b/Sources/UntoldEngine/Systems/GaussianSystem.swift @@ -308,7 +308,7 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) { return } - guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { return } + guard let computeEncoder = GPUPassTiming.shared.makeComputeEncoder(commandBuffer, "GaussianCull") else { return } computeEncoder.label = "Gaussian Frustum Culling" // Chunked (.untoldgs) entities cull chunk by chunk and are fitted to the budget; their @@ -844,7 +844,7 @@ public func executeGaussianPreprocess(_ commandBuffer: MTLCommandBuffer) { let uniformQuotas = GaussianDebugOptions.shared.disableScreenWeightedQuotas let levelSwitches = GaussianPagingFrameSwitches() - guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { return } + guard let computeEncoder = GPUPassTiming.shared.makeComputeEncoder(commandBuffer, "GaussianPreprocess") else { return } computeEncoder.label = "Gaussian Preprocess" // Zero the shared set's append counter on this same serial encoder, so the reset can never @@ -1174,7 +1174,7 @@ public func executeRadixSort(_ commandBuffer: MTLCommandBuffer) { // Single compute encoder for all passes. Dispatches within one encoder execute // sequentially on the GPU, so no inter-encoder synchronisation is needed. - guard let enc = commandBuffer.makeComputeCommandEncoder() else { return } + guard let enc = GPUPassTiming.shared.makeComputeEncoder(commandBuffer, "RadixSort") else { return } enc.label = "Radix Sort" for pass in 0 ..< 4 { diff --git a/Sources/UntoldEngine/Systems/GraphBuilder.swift b/Sources/UntoldEngine/Systems/GraphBuilder.swift index 76774a7aa..93325117f 100644 --- a/Sources/UntoldEngine/Systems/GraphBuilder.swift +++ b/Sources/UntoldEngine/Systems/GraphBuilder.swift @@ -957,8 +957,11 @@ func makeSceneRenderTargetAccess( guard let encoderDescriptor = makeSceneRenderPassDescriptor( copying: descriptor, actions: actions - ), let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: encoderDescriptor) - else { + ) else { + return nil + } + GPUPassTiming.shared.attach(encoderDescriptor, label ?? "SceneTarget") + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: encoderDescriptor) else { return nil } encoder.label = label diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index 06b69cf95..2784ad871 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -34,7 +34,16 @@ func UpdateRenderingSystem(in view: MTKView) { } // Wait for available command buffer slot to prevent unbounded memory growth + #if ENGINE_STATS_ENABLED + let semaphoreWaitStart = CACurrentMediaTime() + #endif commandBufferSemaphore.wait() + #if ENGINE_STATS_ENABLED + let semaphoreWaitMs = (CACurrentMediaTime() - semaphoreWaitStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.semaphoreWaitMs = semaphoreWaitMs + } + #endif if let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() { #if ENGINE_STATS_ENABLED diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 14be05ca4..aedf7e98b 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -481,8 +481,10 @@ public func setEngine(_ property: EngineProperty) { assetBasePath = url case .metrics(.enabled): enableEngineMetrics = true + setEngineStatsCollection(enabled: true) case .metrics(.disabled): enableEngineMetrics = false + setEngineStatsCollection(enabled: false) } } diff --git a/Sources/UntoldEngineAR/UntoldEngineAR.swift b/Sources/UntoldEngineAR/UntoldEngineAR.swift index d73f97873..12ce33520 100644 --- a/Sources/UntoldEngineAR/UntoldEngineAR.swift +++ b/Sources/UntoldEngineAR/UntoldEngineAR.swift @@ -117,6 +117,7 @@ commandBufferSemaphore.signal() return } + GPUPassTiming.shared.beginFrame(device: renderInfo.device) commandBuffer.label = "AR Command Buffer" @@ -166,6 +167,7 @@ EngineProfiler.shared.attach(to: commandBuffer, label: "ARFrame") // Finalize rendering here & push the command buffer to the GPU + GPUPassTiming.shared.endFrame(commandBuffer) commandBuffer.commit() } diff --git a/Sources/UntoldEngineXR/UntoldEngineXR.swift b/Sources/UntoldEngineXR/UntoldEngineXR.swift index 0aa7d18de..1541656a7 100644 --- a/Sources/UntoldEngineXR/UntoldEngineXR.swift +++ b/Sources/UntoldEngineXR/UntoldEngineXR.swift @@ -315,6 +315,8 @@ } renderer = untoldrenderer + // Hitch counts measure against the compositor's nominal 90 Hz period. + EngineStatsMonitor.shared.frameBudgetMs = 1000.0 / 90.0 } @MainActor @@ -786,6 +788,7 @@ EngineStatsMonitor.shared.beginFrame(timestampSeconds: xrFrameStartTime) RenderStatsCollector.shared.reset() #endif + EngineProfiler.shared.beginScope(.compositorUpdate) // Snapshot loading gate once per frame to keep update/submission behavior consistent. let loading = AssetLoadingGate.shared.isLoadingAny @@ -824,6 +827,16 @@ } // 6. Call endupdate() to mark the end of the update phase + EngineProfiler.shared.endScope(.compositorUpdate) + #if ENGINE_STATS_ENABLED + let updateEndTime = CACurrentMediaTime() + let optimalInputTimeCA = Self.mediaTime(of: timing.optimalInputTime) + EngineStatsMonitor.shared.update { snapshot in + snapshot.compositor.updateMs = (updateEndTime - xrFrameStartTime) * 1000.0 + // Negative slack means the update phase ran past the compositor's optimal input time. + snapshot.compositor.inputSlackMs = (optimalInputTimeCA - updateEndTime) * 1000.0 + } + #endif frame.endUpdate() // 7. Call wait(until:tolerace) to puase your render loop until the optimal rendering time @@ -835,9 +848,11 @@ // the wait's length earlier and the head is predicted from earlier; the automatic // mode waits while the frames meet the deadline and not while they do not. let waitLengthMs = max(0, Self.milliseconds(LayerRenderer.Clock().now.duration(to: timing.optimalInputTime))) + EngineProfiler.shared.beginScope(.compositorWaitForInput) if waitsForOptimalInput(skippedWaitMs: waitLengthMs) { LayerRenderer.Clock().wait(until: timing.optimalInputTime, tolerance: .zero) } + EngineProfiler.shared.endScope(.compositorWaitForInput) #if ENGINE_STATS_ENABLED let xrWaitMs = (CACurrentMediaTime() - xrWaitStart) * 1000.0 EngineStatsMonitor.shared.update { snapshot in @@ -857,11 +872,13 @@ // 8. Call startSubmission() to mark the start of submission phase frame.startSubmission() + EngineProfiler.shared.beginScope(.compositorSubmission) #if ENGINE_STATS_ENABLED let xrSubmissionStart = CACurrentMediaTime() #endif var shouldEndSubmission = true defer { + EngineProfiler.shared.endScope(.compositorSubmission) if shouldEndSubmission, layerRenderer.state == .running { frame.endSubmission() } @@ -879,6 +896,9 @@ } guard !drawables.isEmpty else { #if ENGINE_STATS_ENABLED + EngineStatsMonitor.shared.update { snapshot in + snapshot.compositor.submissionMs = (CACurrentMediaTime() - xrSubmissionStart) * 1000.0 + } renderer.finalizeXRStatsAndMonitors(frameStartTime: xrFrameStartTime) #else renderer.finalizeXRStatsAndMonitors(frameStartTime: 0.0) @@ -907,6 +927,7 @@ // The frame's presentation time is checked on its first drawable (the built-in // display); a capture drawable shares the timing. var presentationChecked = false + var anchorMissingThisFrame = false for drawable in drawables { // 10. Fetch the predicted device anchor from ARKit using the frameTiming information, and // apply the anchor to your frame @@ -938,12 +959,14 @@ drawable.deviceAnchor = anchor } else if let cachedAnchor = lastValidDeviceAnchor { missingAnchorFrameCount += 1 + anchorMissingThisFrame = true if shouldLogAnchorDiagnostics() { printXRAnchorDiagnostics(message: "XR device anchor missing; using cached anchor") } drawable.deviceAnchor = cachedAnchor } else { missingAnchorFrameCount += 1 + anchorMissingThisFrame = true if shouldLogAnchorDiagnostics() { printXRAnchorDiagnostics(message: "XR device anchor missing; no cached anchor available") } @@ -964,10 +987,17 @@ presentCleared(drawable) } } + if anchorMissingThisFrame { + // Once per frame: a capture drawable shares the frame's anchor. + #if ENGINE_STATS_ENABLED + EngineStatsMonitor.shared.recordMissingAnchor() + #endif + } #if ENGINE_STATS_ENABLED let xrEncodeMs = (CACurrentMediaTime() - xrSubmissionStart) * 1000.0 EngineStatsMonitor.shared.update { snapshot in snapshot.timing.encodeMs = xrEncodeMs + snapshot.compositor.submissionMs = xrEncodeMs } renderer.finalizeXRStatsAndMonitors(frameStartTime: xrFrameStartTime) #else @@ -1183,6 +1213,11 @@ commandBufferSemaphore.wait() } let semaphoreWaitMs = (CACurrentMediaTime() - semaphoreWaitStart) * 1000.0 + #if ENGINE_STATS_ENABLED + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.semaphoreWaitMs += semaphoreWaitMs + } + #endif if semaphoreWaitMs > 16.0, shouldLogXRStallDiagnostics() { printXRCommandBufferStallDiagnostics(waitMs: semaphoreWaitMs, phase: "acquired") } @@ -1211,6 +1246,16 @@ print("✓ Updated VisionOS viewport to: \(actualViewPort)") } } + #if ENGINE_STATS_ENABLED + let viewCount = drawable.views.count + let viewTextureWidth = drawable.colorTextures.first?.width ?? 0 + let viewTextureHeight = drawable.colorTextures.first?.height ?? 0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.compositor.viewCount = viewCount + snapshot.compositor.viewTextureWidth = viewTextureWidth + snapshot.compositor.viewTextureHeight = viewTextureHeight + } + #endif // Update visible entity list only when not loading (avoids reading mutating ECS data). // When loading, we render from the last-known-good visible list. @@ -1334,9 +1379,20 @@ // The frame's GPU time beyond the previous frame's: the two overlap on the // GPU, and the command buffer's own span would count the overlap twice. EngineStatsMonitor.shared.recordGPUCompletion(gpuStartTime: cb.gpuStartTime, gpuEndTime: cb.gpuEndTime) + // The GPU's margin against the compositor's deadlines, for the stats; gpuEndTime + // is zero where the driver does not report it. + if countsDeadline, cb.gpuEndTime > 0.0 { + EngineStatsMonitor.shared.recordCompositorCompletion( + deadlineMarginMs: (deadline - cb.gpuEndTime) * 1000.0, + presentationMarginMs: (presentation - cb.gpuEndTime) * 1000.0 + ) + } #endif if countsDeadline { self?.noteGPUCompletion(at: cb.gpuEndTime, start: cb.gpuStartTime, deadline: deadline, presentation: presentation) + if cb.gpuEndTime > deadline { + EngineProfiler.shared.emitEvent(.missedDeadline) + } } commandBufferSemaphore.signal() } diff --git a/Tests/UntoldEngineRenderTests/EngineStatsMonitorTests.swift b/Tests/UntoldEngineRenderTests/EngineStatsMonitorTests.swift index 8ba16bd8c..0a7b2f1bf 100644 --- a/Tests/UntoldEngineRenderTests/EngineStatsMonitorTests.swift +++ b/Tests/UntoldEngineRenderTests/EngineStatsMonitorTests.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import QuartzCore +import simd @testable import UntoldEngine import XCTest @@ -240,4 +241,342 @@ final class EngineStatsMonitorTests: XCTestCase { XCTAssertFalse(overlay.contains("nan"), "Output should not contain NaN") XCTAssertFalse(compact.contains("nan"), "Output should not contain NaN") } + + // MARK: - Compositor frame accounting + + func testRecordCompositorCompletion_publishesLatestMarginAndCumulativeCounts() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.recordCompositorCompletion(deadlineMarginMs: -1.5, presentationMarginMs: 2.0) + EngineStatsMonitor.shared.recordCompositorCompletion(deadlineMarginMs: 3.0, presentationMarginMs: 5.5) + EngineStatsMonitor.shared.completeFrame() + + let published = getEngineStatsSnapshot() + XCTAssertEqual(published.compositor.deadlineMarginMs, 3.0) + XCTAssertEqual(published.compositor.presentationMarginMs, 5.5) + XCTAssertFalse(published.compositor.missedDeadline) + XCTAssertEqual(published.compositor.missedDeadlineCount, 1) + XCTAssertEqual(published.compositor.deadlineSampleCount, 2) + XCTAssertEqual(published.compositor.missedDeadlineRate, 0.5, accuracy: 1e-9) + } + + func testRecordCompositorCompletion_negativeMargin_flagsMissedDeadline() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.recordCompositorCompletion(deadlineMarginMs: -0.25, presentationMarginMs: 1.0) + EngineStatsMonitor.shared.completeFrame() + + let published = getEngineStatsSnapshot() + XCTAssertTrue(published.compositor.missedDeadline) + XCTAssertEqual(published.compositor.missedDeadlineCount, 1) + XCTAssertEqual(published.compositor.deadlineSampleCount, 1) + XCTAssertEqual(published.compositor.missedDeadlineRate, 1.0, accuracy: 1e-9) + } + + func testCompositorCounters_survive_beginFrame_butPerFrameFieldsReset() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.update { snapshot in + snapshot.compositor.updateMs = 4.0 + snapshot.compositor.inputSlackMs = 1.25 + snapshot.compositor.viewCount = 2 + } + EngineStatsMonitor.shared.recordMissingAnchor() + EngineStatsMonitor.shared.recordCompositorCompletion(deadlineMarginMs: -2.0, presentationMarginMs: 0.5) + EngineStatsMonitor.shared.completeFrame() + + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 2.0) + let inProgress = getEngineStatsSnapshotInProgress() + XCTAssertEqual(inProgress.compositor.updateMs, 0.0) + XCTAssertEqual(inProgress.compositor.inputSlackMs, 0.0) + XCTAssertEqual(inProgress.compositor.viewCount, 0) + + EngineStatsMonitor.shared.completeFrame() + let published = getEngineStatsSnapshot() + XCTAssertEqual(published.compositor.missedDeadlineCount, 1) + XCTAssertEqual(published.compositor.deadlineSampleCount, 1) + XCTAssertEqual(published.compositor.missingAnchorCount, 1) + // The latest GPU sample carries over until a newer completion arrives. + XCTAssertEqual(published.compositor.deadlineMarginMs, -2.0) + } + + func testReset_clearsCompositorCounters() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.recordMissingAnchor() + EngineStatsMonitor.shared.recordCompositorCompletion(deadlineMarginMs: -2.0, presentationMarginMs: 0.5) + EngineStatsMonitor.shared.completeFrame() + + EngineStatsMonitor.shared.reset() + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 2.0) + EngineStatsMonitor.shared.completeFrame() + + let published = getEngineStatsSnapshot() + XCTAssertEqual(published.compositor.missedDeadlineCount, 0) + XCTAssertEqual(published.compositor.deadlineSampleCount, 0) + XCTAssertEqual(published.compositor.missingAnchorCount, 0) + XCTAssertEqual(published.compositor.deadlineMarginMs, 0.0) + XCTAssertFalse(published.compositor.missedDeadline) + } + + func testFormatEngineStatsOverlay_containsSystemsLine() { + var snapshot = EngineStatsSnapshot() + snapshot.timing.animationMs = 1.5 + snapshot.timing.physicsMs = 0.75 + snapshot.timing.physicsStepCount = 2 + snapshot.timing.gameUpdateMs = 0.25 + + let overlay = formatEngineStatsOverlay(snapshot) + XCTAssertTrue(overlay.contains("Systems: "), overlay) + XCTAssertTrue(overlay.contains("animation 1.50ms"), overlay) + XCTAssertTrue(overlay.contains("physics 0.75ms (2 steps)"), overlay) + XCTAssertTrue(overlay.contains("game 0.25ms"), overlay) + } + + func testTimingSemaphoreWaitMs_roundTripsThroughSnapshot() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.semaphoreWaitMs = 0.75 + } + EngineStatsMonitor.shared.completeFrame() + + XCTAssertEqual(getEngineStatsSnapshot().timing.semaphoreWaitMs, 0.75) + } + + func testFormatEngineStats_compositorLine_onlyForCompositorFrames() { + var snapshot = EngineStatsSnapshot() + XCTAssertFalse(formatEngineStatsOverlay(snapshot).contains("Compositor:")) + + snapshot.compositor.viewCount = 2 + snapshot.compositor.viewTextureWidth = 2048 + snapshot.compositor.viewTextureHeight = 1984 + snapshot.compositor.deadlineMarginMs = -0.5 + snapshot.compositor.missedDeadline = true + snapshot.compositor.missedDeadlineCount = 3 + snapshot.compositor.deadlineSampleCount = 300 + snapshot.timing.semaphoreWaitMs = 0.1 + + let overlay = formatEngineStatsOverlay(snapshot) + XCTAssertTrue(overlay.contains("Compositor: views 2 @ 2048x1984"), overlay) + XCTAssertTrue(overlay.contains("deadlineMargin -0.50ms MISSED"), overlay) + XCTAssertTrue(overlay.contains("missed 3/300 (1.00%)"), overlay) + XCTAssertTrue(overlay.contains("semWait 0.10ms"), overlay) + } + + // MARK: - Hitch accounting + + func testHitches_countOverBudgetFramesAndFillHistogram() { + EngineStatsMonitor.shared.frameBudgetMs = 10.0 + defer { EngineStatsMonitor.shared.frameBudgetMs = 1000.0 / 60.0 } + + for (index, frameMs) in [5.0, 12.0, 50.0].enumerated() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: Double(index) * 0.01) + EngineStatsMonitor.shared.update { $0.timing.frameTotalMs = frameMs } + EngineStatsMonitor.shared.completeFrame() + } + + let hitches = getEngineStatsSnapshot().hitches + XCTAssertEqual(hitches.frameBudgetMs, 10.0) + XCTAssertEqual(hitches.framesSampled, 3) + XCTAssertEqual(hitches.framesOverBudget, 2) + XCTAssertEqual(hitches.overBudgetRate, 2.0 / 3.0, accuracy: 1e-9) + // Buckets: <4, <8, <11.2, <16.8, <33.4, <100, >=100 + XCTAssertEqual(hitches.histogram, [0, 1, 0, 1, 0, 1, 0]) + } + + func testHitches_bucketIndex_boundaries() { + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 0.0), 0) + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 3.99), 0) + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 4.0), 1) + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 11.1), 2) + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 16.7), 3) + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 100.0), 6) + XCTAssertEqual(EngineHitchStats.bucketIndex(forFrameMs: 5000.0), 6) + } + + func testHitches_oneSecondWindowRollsOver() { + EngineStatsMonitor.shared.frameBudgetMs = 10.0 + defer { EngineStatsMonitor.shared.frameBudgetMs = 1000.0 / 60.0 } + + let frames: [(seconds: Double, ms: Double)] = [(0.0, 20.0), (0.5, 5.0), (1.2, 5.0)] + for frame in frames { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: frame.seconds) + EngineStatsMonitor.shared.update { $0.timing.frameTotalMs = frame.ms } + EngineStatsMonitor.shared.completeFrame() + } + + let hitches = getEngineStatsSnapshot().hitches + XCTAssertEqual(hitches.framesLastSecond, 2) + XCTAssertEqual(hitches.framesOverBudgetLastSecond, 1) + XCTAssertEqual(hitches.worstFrameMsLastSecond, 20.0) + XCTAssertEqual(hitches.framesSampled, 3) + } + + func testReset_clearsHitchesButKeepsBudget() { + EngineStatsMonitor.shared.frameBudgetMs = 11.0 + defer { EngineStatsMonitor.shared.frameBudgetMs = 1000.0 / 60.0 } + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.update { $0.timing.frameTotalMs = 30.0 } + EngineStatsMonitor.shared.completeFrame() + + EngineStatsMonitor.shared.reset() + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 2.0) + EngineStatsMonitor.shared.completeFrame() + + let hitches = getEngineStatsSnapshot().hitches + XCTAssertEqual(hitches.frameBudgetMs, 11.0) + XCTAssertEqual(hitches.framesSampled, 1) + XCTAssertEqual(hitches.framesOverBudget, 0) + } + + func testFormatEngineStatsOverlay_containsHitchesAndThermal() { + var snapshot = EngineStatsSnapshot() + snapshot.hitches.framesSampled = 100 + snapshot.hitches.framesOverBudget = 4 + snapshot.hitches.frameBudgetMs = 11.11 + snapshot.memory.thermalState = 2 + snapshot.memory.gpuAllocatedBytes = 300 * 1024 * 1024 + + let overlay = formatEngineStatsOverlay(snapshot) + XCTAssertTrue(overlay.contains("Hitches: budget 11.11ms | over 4/100 (4.00%)"), overlay) + XCTAssertTrue(overlay.contains("<11.2 "), overlay) + XCTAssertTrue(overlay.contains("thermal serious"), overlay) + XCTAssertTrue(overlay.contains("gpuAlloc 300mb"), overlay) + } + + // MARK: - Codable and recording + + func testSnapshot_codableRoundTrip() throws { + var snapshot = EngineStatsSnapshot() + snapshot.frameIndex = 99 + snapshot.timing.animationMs = 1.25 + snapshot.culling.selectedHZBMipSize = simd_int2(256, 128) + snapshot.compositor.deadlineMarginMs = -0.5 + snapshot.gpuPasses = GPUPassTimingSnapshot(frameIndex: 3, passes: [GPUPassTiming.PassTime(label: "Light Pass", vertexMs: 0.5, fragmentMs: 1.5)]) + snapshot.hitches.histogram[2] = 7 + + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(EngineStatsSnapshot.self, from: data) + + XCTAssertEqual(decoded.frameIndex, 99) + XCTAssertEqual(decoded.timing.animationMs, 1.25) + XCTAssertEqual(decoded.culling.selectedHZBMipSize, simd_int2(256, 128)) + XCTAssertEqual(decoded.compositor.deadlineMarginMs, -0.5) + XCTAssertEqual(decoded.gpuPasses, snapshot.gpuPasses) + XCTAssertEqual(decoded.hitches.histogram[2], 7) + } + + func testRecorder_perFrame_writesFramesAndSummary() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("untold-stats-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: url) } + + EngineStatsMonitor.shared.frameBudgetMs = 15.0 + defer { EngineStatsMonitor.shared.frameBudgetMs = 1000.0 / 60.0 } + try startEngineStatsRecording(to: url, interval: .perFrame) + + for (index, frameMs) in [10.0, 20.0, 30.0].enumerated() { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 100.0 + Double(index) * 0.5) + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.frameTotalMs = frameMs + snapshot.gpuPasses = GPUPassTimingSnapshot(passes: [GPUPassTiming.PassTime(label: "Model Pass", vertexMs: 0, fragmentMs: frameMs / 4)]) + } + EngineStatsMonitor.shared.recordGPUCompletion(executionMs: frameMs / 2) + EngineStatsMonitor.shared.completeFrame() + } + + let summary = try XCTUnwrap(stopEngineStatsRecording()) + XCTAssertEqual(summary.frames, 3) + XCTAssertEqual(summary.meanFrameMs, 20.0, accuracy: 1e-9) + XCTAssertEqual(summary.worstFrameMs, 30.0) + XCTAssertEqual(summary.p50FrameMs, 20.0) + XCTAssertEqual(summary.framesOverBudget, 2) + XCTAssertEqual(summary.frameBudgetMs, 15.0) + XCTAssertEqual(summary.durationSeconds, 1.0, accuracy: 1e-9) + XCTAssertEqual(summary.meanGPUExecutionMs, 10.0, accuracy: 1e-9) + XCTAssertEqual(summary.minGPUExecutionMs, 5.0, accuracy: 1e-9) + XCTAssertEqual(summary.gpuPassMeanMs["Model Pass"] ?? 0, 5.0, accuracy: 1e-9) + XCTAssertEqual(summary.gpuPassMinMs["Model Pass"] ?? 0, 2.5, accuracy: 1e-9) + XCTAssertEqual(summary.timingMeanMs["updateMs"] ?? -1, 0.0, accuracy: 1e-9) + XCTAssertEqual(summary.timingMeanMs["semaphoreWaitMs"] ?? -1, 0.0, accuracy: 1e-9) + XCTAssertNil(stopEngineStatsRecording(), "Stopping twice must not report a second recording") + + let lines = try String(contentsOf: url, encoding: .utf8) + .split(separator: "\n", omittingEmptySubsequences: true) + XCTAssertEqual(lines.count, 4, "3 frame lines + 1 summary line") + + let decoder = JSONDecoder() + struct Line: Decodable { + var type: String + var frame: EngineStatsSnapshot? + var summary: EngineStatsRecordingSummary? + } + let decoded = try lines.map { try decoder.decode(Line.self, from: Data($0.utf8)) } + XCTAssertEqual(decoded.map(\.type), ["frame", "frame", "frame", "summary"]) + XCTAssertEqual(decoded[1].frame?.timing.frameTotalMs, 20.0) + XCTAssertEqual(decoded[3].summary, summary) + } + + func testRecorder_perSecond_writesOneLinePerSecond() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("untold-stats-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: url) } + + try startEngineStatsRecording(to: url, interval: .perSecond) + for seconds in [10.0, 10.3, 10.9, 11.1, 11.5] { + EngineStatsMonitor.shared.beginFrame(timestampSeconds: seconds) + EngineStatsMonitor.shared.update { $0.timing.frameTotalMs = 8.0 } + EngineStatsMonitor.shared.completeFrame() + } + let summary = try XCTUnwrap(stopEngineStatsRecording()) + XCTAssertEqual(summary.frames, 5, "The summary covers every frame, not only the written ones") + + let lines = try String(contentsOf: url, encoding: .utf8) + .split(separator: "\n", omittingEmptySubsequences: true) + XCTAssertEqual(lines.count, 3, "one line for second 10, one for second 11, one summary") + } + + // MARK: - Runtime collection switch + + func testCollectionOff_ignoresFrameCallsAndKeepsLastPublishedFrame() { + let wasCollecting = isEngineStatsCollectionEnabled() + defer { setEngineStatsCollection(enabled: wasCollecting) } + + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 1.0) + EngineStatsMonitor.shared.update { $0.timing.updateMs = 2.5 } + EngineStatsMonitor.shared.completeFrame() + XCTAssertEqual(getEngineStatsSnapshot().frameIndex, 1) + + setEngineStatsCollection(enabled: false) + XCTAssertFalse(isEngineStatsCollectionEnabled()) + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 2.0) + EngineStatsMonitor.shared.update { $0.timing.updateMs = 9.0 } + EngineStatsMonitor.shared.recordGPUCompletion(executionMs: 4.0) + EngineStatsMonitor.shared.completeFrame() + + let published = getEngineStatsSnapshot() + XCTAssertEqual(published.frameIndex, 1, "Frames must not advance while collection is off") + XCTAssertEqual(published.timing.updateMs, 2.5) + XCTAssertEqual(published.timing.gpuExecutionMs, 0.0) + + setEngineStatsCollection(enabled: true) + EngineStatsMonitor.shared.beginFrame(timestampSeconds: 3.0) + EngineStatsMonitor.shared.update { $0.timing.updateMs = 1.0 } + EngineStatsMonitor.shared.completeFrame() + XCTAssertEqual(getEngineStatsSnapshot().frameIndex, 2) + XCTAssertEqual(getEngineStatsSnapshot().timing.updateMs, 1.0) + } + + func testSetEngineMetrics_alsoTogglesCollection() { + let wasCollecting = isEngineStatsCollectionEnabled() + let wasMetrics = enableEngineMetrics + defer { + setEngineStatsCollection(enabled: wasCollecting) + enableEngineMetrics = wasMetrics + } + + setEngine(.metrics(.disabled)) + XCTAssertFalse(isEngineStatsCollectionEnabled()) + XCTAssertFalse(enableEngineMetrics) + + setEngine(.metrics(.enabled)) + XCTAssertTrue(isEngineStatsCollectionEnabled()) + XCTAssertTrue(enableEngineMetrics) + } } diff --git a/Tests/UntoldEngineRenderTests/GPUPassStatsTests.swift b/Tests/UntoldEngineRenderTests/GPUPassStatsTests.swift new file mode 100644 index 000000000..1c113487a --- /dev/null +++ b/Tests/UntoldEngineRenderTests/GPUPassStatsTests.swift @@ -0,0 +1,58 @@ +// +// GPUPassStatsTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// The engine stats carry the GPU pass timer's last frame: with the timing on, the snapshot +/// lists the frame's passes and the overlay prints them; off, it lists none. +@MainActor +final class GPUPassStatsTests: BaseRenderSetup { + override func tearDown() async throws { + setRendering(.gpuPassTiming(.disabled)) + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 1, 4), target: .zero, up: simd_float3(0, 1, 0)) + let box = createEntity() + setEntityMeshDirect(entityId: box, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Box") + translateTo(entityId: box, position: simd_float3(0, 0.5, 0)) + let sun = createEntity() + createDirLight(entityId: sun) + rotateTo(entityId: sun, pitch: -40, yaw: 30, roll: 0) + } + + private func drawFrames(_ count: Int) { + for _ in 0 ..< count { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + } + + func testTheStatsCarryTheTimedPasses() throws { + try XCTSkipUnless(renderInfo.device.supportsCounterSampling(.atStageBoundary), "no stage-boundary sampling here") + drawFrames(3) + XCTAssertTrue(getEngineStatsSnapshot().gpuPasses.passes.isEmpty, "off by default") + + setRendering(.gpuPassTiming(.enabled)) + drawFrames(10) + let stats = getEngineStatsSnapshot() + XCTAssertFalse(stats.gpuPasses.passes.isEmpty, "the stats carry the frame's passes") + XCTAssertEqual(stats.gpuPasses.totalMs, stats.gpuPasses.passes.reduce(0) { $0 + $1.totalMs }, accuracy: 1e-9) + XCTAssertGreaterThan(stats.gpuPasses.totalMs, 0) + XCTAssertTrue(formatEngineStatsOverlay(stats).contains("GPU passes ("), formatEngineStatsOverlay(stats)) + } +} diff --git a/docs/API/UsingProfiler.md b/docs/API/UsingProfiler.md index 006e3e489..1defe3d7e 100644 --- a/docs/API/UsingProfiler.md +++ b/docs/API/UsingProfiler.md @@ -39,6 +39,7 @@ With `profile: .verbose`, the engine logs a multi-line snapshot every interval: ``` Frame 1234 | CPU 12.34ms (81.0 fps, smoothed) GPU 8.45ms exec / 90.0 fps cadence [GPU-bound] Timing: frame 12.34ms (raw CPU) | update 1.23ms | render 8.45ms | cull 0.45ms | stream 2.34ms | batchTick 0.12ms | batchRebuild 0.00ms +Systems: scenegraph 0.05ms | extensions 0.02ms | lod 0.10ms | animation 0.60ms | scripting 0.01ms | physics 0.30ms (1 steps) | custom 0.00ms | game 0.12ms | semWait 0.00ms Render: draws 45 (opaque 32, transparent 3, shadow 8, batched 28) | triangles 125000 | visible 89 Culling: frustum 234/512 failed 278 | occlusion 198/234 failed 36 | usedHZB true validHZB true Streaming: loaded 847 loading 3 unloaded 12 | active 3 | nearby 124 candidates 5 slots 4 | backlog 0 | pendingUploads 3 | gateMs 0.00 @@ -46,9 +47,41 @@ Streaming: tick=true workMs 1.23 | evictions 0 | avgLoadMs 45.67 | applyMs 0.89 TileReps: resident full/lod/hlod 24/8/2 | visible full/lod/hlod 18/5/1 | overlap visible full+lod/full+hlod/lod+hlod 0/0/0 residentFull+fallback 0 | fades 0 waiting 0 TileRenderCost: visible full/lod/hlod 18/5/1 | draws full/lod/hlod 22/6/1 | tris full/lod/hlod 84000/9000/1200 Batching: groups 132 | batchedMeshes 916 | dirty 0→0 | defWork 0 skipComplex 2 | dispatched 0→0 groups | rebuilds/s 0 | rebuildMs 0.00 -Memory: mesh 312/512mb | tex 198/512mb | total 50% | entities 847 +Memory: mesh 312/512mb | tex 198/512mb | total 50% | entities 847 | gpuAlloc 910mb | avail 2400mb | thermal nominal +Hitches: budget 11.11ms | over 3/5400 (0.06%) | lastSec 0/90 over, worst 9.80ms | hist <4 0 | <8 1200 | <11.2 4190 | <16.8 8 | <33.4 2 | <100 0 | >=100 0 +Compositor: views 2 @ 2048x1984 | update 2.10ms | inputSlack 1.45ms | submit 3.20ms | semWait 0.00ms | deadlineMargin 2.35ms | presentMargin 6.80ms | missed 3/5400 (0.06%) | noAnchor 0 ``` +**Compositor line** (visionOS only, absent elsewhere) — how the frame did against the Compositor Services deadlines. This is the number that matters on Apple Vision Pro: a frame whose GPU work finishes after the rendering deadline is reprojected by the compositor and felt as judder, whatever the average frame rate says. + +| Field | What it tells you | +|---|---| +| `views W@HxV` | Views (eyes) rendered and the per-view drawable size. Changes with foveation and render quality. | +| `update` | CPU time between `startUpdate()` and `endUpdate()`: game update, input, streaming ticks. | +| `inputSlack` | Time left until the compositor's `optimalInputTime` when the update phase ended. Negative means the update ran late and the submission started behind schedule. | +| `submit` | CPU time from `startSubmission()` to the command buffer commit, both eyes. Includes `semWait`. | +| `semWait` | Time spent waiting for a free in-flight command buffer. Persistently non-zero means the GPU is pacing the CPU. Also reported on macOS and iOS as `timing.semaphoreWaitMs`. | +| `deadlineMargin` | Time between the GPU finishing the most recently completed frame and that frame's `renderingDeadline`. Negative (and `MISSED`) means the compositor did not get the frame in time. Comes from the command buffer completion handler, so it can lag the CPU frame by one or two frames. | +| `presentMargin` | Same, against the frame's `presentationTime`. | +| `missed a/b (rate)` | Frames that missed the deadline over frames sampled since the monitor was reset. Steady-state target: well under 1 in 1000. | +| `noAnchor` | Frames presented without a fresh device anchor since reset (tracking gaps). | + +Programmatic access: `getEngineStatsSnapshot().compositor` (`EngineCompositorStats`), including `missedDeadlineRate`. + +**Systems line** — where the `update` time goes, per engine system. Each value is the CPU time of that system this frame; `physics` and `custom` sum every fixed step and the step count is shown. The same scopes are emitted as signposts in the `Systems` category, so Instruments shows them as lanes without symbolication. + +| Field | What it tells you | +|---|---| +| `scenegraph` | World-transform propagation. Non-zero only when something moved; large values mean many dirty transforms or a deep hierarchy scan. | +| `extensions` | Render and engine extension `update` hooks. | +| `lod` | Mesh and Gaussian LOD selection (interval and camera-displacement gated). | +| `animation` | Clip sampling, blending, root motion, IK, pose and joint matrix updates for every animated entity. | +| `scripting` | USC script execution. | +| `physics` | Fixed-step physics, summed over the steps taken this frame. More than one step per frame at 90 Hz means the frame is late. | +| `custom` | Registered custom systems, summed over the fixed steps. | +| `game` | The app's `gameUpdate` callback. | +| `semWait` | Time waiting for a free in-flight command buffer before encoding (see the Compositor line). | + **Streaming line 1** — entity counts and slot pressure: | Field | What it tells you | @@ -110,6 +143,20 @@ Memory: mesh 312/512mb | tex 198/512mb | total 50% | entities 847 | `tex X/Ymb` | Texture memory used vs texture budget. | | `total X%` | Combined utilization across both pools. | | `PRESSURE` | Appears when either pool hits ≥ 85 % utilization. | +| `gpuAlloc` | Bytes the Metal device has allocated for the process (`MTLDevice.currentAllocatedSize`): drawables, render targets, streamed meshes and textures together. | +| `avail` | Memory the process may still allocate before the system terminates it (`os_proc_available_memory`). iOS and visionOS only. | +| `thermal` | `ProcessInfo.thermalState`: nominal, fair, serious or critical. A `ThermalStateChanged` signpost event marks every transition. A benchmark that ends in `serious` is a failed run whatever the frame times say. | + +**Hitches line** — the frame-time distribution since the monitor was reset. Means and percentiles hide single long frames; these counts do not. + +| Field | What it tells you | +|---|---| +| `budget` | Frame budget for the over-budget counts. 16.67 ms by default; the visionOS runtime sets 11.11 ms (90 Hz). Change it with `EngineStatsMonitor.shared.frameBudgetMs`. | +| `over a/b (rate)` | Frames over budget out of all frames sampled. | +| `lastSec x/y over, worst` | The last completed one-second window: frames over budget, frames, and the worst frame time. Use this for a live overlay. | +| `hist` | Cumulative frame count per bucket. The bucket bounds (4, 8, 11.2, 16.8, 33.4, 100 ms) straddle the 90 Hz and 60 Hz periods, so the `<11.2`/`<16.8` split shows how many frames just missed 90 Hz. | + +Programmatic access: `getEngineStatsSnapshot().hitches` (`EngineHitchStats`, with `overBudgetRate`). --- @@ -126,6 +173,60 @@ print("Update: \(frameStats.timing.updateMs) ms") print("Render: \(frameStats.timing.renderTotalMs) ms") ``` +## Recording A Run To Disk + +Every field of `EngineStatsSnapshot` is `Codable`. A recorder writes the published snapshots to a JSON Lines file on a utility queue, so a benchmark can dump a whole run and a script can compare it against a baseline: + +```swift +let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("perf/starter-scene.jsonl") +try startEngineStatsRecording(to: url, interval: .perSecond) // or .perFrame +// ... run the scene ... +let summary = stopEngineStatsRecording() +print(summary?.p99FrameMs ?? 0, summary?.missedDeadlines ?? 0) +``` + +Each line is `{"type":"frame","frame":{...}}` with the full snapshot; `.perFrame` writes every frame, `.perSecond` the last frame of each second. The final line is `{"type":"summary","summary":{...}}` (`EngineStatsRecordingSummary`): frame count and duration, mean, p50, p95, p99 and worst frame time, frames over budget, mean GPU execution time, compositor deadline misses, the mean GPU time per pass label, the worst thermal state seen and the peak GPU allocation. The summary always covers every frame, whatever the interval. On a device, pull the file with `xcrun devicectl device copy from`. + +`startEngineStatsRecording` throws `EngineStatsRecordingError.statsNotCompiledIn` in builds without `ENGINE_STATS_ENABLED`. + +## Benchmarking A Build + +`Examples/PerfBench` is a benchmark app for macOS, iOS and visionOS that runs a fixed set of scenes with the stats and the GPU pass timer on, records every frame with the recorder above and writes a `summary.json` per run. `scripts/perf/run_bench.sh ` builds it, runs it (on a paired device through `devicectl`), pulls the results into `perf/results/` and compares them with the baseline stored under `perf/baselines//` (`--update-baseline` writes one). See `Examples/PerfBench/README.md`. + +## GPU Pass Timing + +`GPUPassTiming` times every render, compute and blit pass of the frame on the GPU itself, from `MTLCounterSampleBuffer` timestamps at the stage boundaries: a render pass's vertex and fragment stages, a compute or blit pass as a whole. It is off by default, and a GPU without stage-boundary sampling measures nothing. + +Enable it from the settings API: + +```swift +setRendering(.gpuPassTiming(.enabled)) +``` + +Read the recent frames' averages or the last resolved frame from the timer, or the last resolved frame through the engine stats snapshot: + +```swift +for pass in GPUPassTiming.shared.averages() { // heaviest first + print("\(pass.label): vertex \(pass.vertexMs) ms, fragment \(pass.fragmentMs) ms") +} +let last = getEngineStatsSnapshot().gpuPasses // GPUPassTimingSnapshot +print(last.ms(for: "Shadows") ?? 0) +``` + +The verbose stats output adds one line with the twelve heaviest passes of the last resolved frame: + +``` +GPU passes (9, sum 6.21ms): Forward 3.10 | Shadows 0.62 | SSAO 0.55 | Post 0.40 | HZB 0.12 | ... +``` + +| Field | What it tells you | +|---|---| +| `label` | The pass's name in the timer. The same label in one frame (one pass per eye on the headset, one per shadow cascade) adds up. | +| `sum` | Sum of all pass times. Passes can overlap on the GPU, so this is an upper bound on the command buffer's GPU time. | + +Results come from the command buffer's completion handler, so the snapshot describes the most recently *completed* frame. + ## OOC And Asset Triage Mode High-volume instrumentation categories are disabled by default: @@ -376,6 +477,7 @@ When metrics are enabled, the engine emits signpost scopes: - `RenderPrep` - `Encode` - `Submit` +- `CompositorUpdate`, `CompositorWaitForInput`, `CompositorSubmission` (visionOS, category `Compositor`), plus a `MissedDeadline` point event whenever the GPU finishes a frame after the compositor's rendering deadline. Put the Metal System Trace GPU track next to this lane to see whether a miss was CPU-late (submission ends after the optimal input time) or GPU-late. To inspect timeline data: @@ -387,10 +489,10 @@ To inspect timeline data: ## Build Configuration Notes - `EngineProfiler` is available in all configs, but disabled by default until enabled at runtime. -- `EngineStats` collection is compiled in debug by default (`ENGINE_STATS_ENABLED`). -- For release profiling with `EngineStats`, build with `-DENGINE_STATS_ENABLED`. +- `EngineStats` collection is compiled into every configuration (`ENGINE_STATS_ENABLED`) and switched at runtime: on by default in debug builds, off by default in release builds. Turn it on with `setEngine(.metrics(.enabled))` (which also enables the profiler), `setEngineStatsCollection(enabled: true)`, or `UNTOLD_STATS=1` in the environment. This is how device measurements are taken from a release build. +- When collection is off, every per-frame call returns immediately, draw counting is skipped, and the per-frame snapshot is not gathered; `getEngineStatsSnapshot()` keeps returning the last published frame. -If `ENGINE_STATS_ENABLED` is not compiled in, `getEngineStatsSnapshot()` returns default values and `setEngineStatsLogging(...)` is effectively a no-op. +If `ENGINE_STATS_ENABLED` is removed from the package settings, `getEngineStatsSnapshot()` returns default values and `setEngineStatsLogging(...)` is effectively a no-op. ## Category Toggle Notes diff --git a/perf/baselines/Mac16_5/macOS/animation-16.json b/perf/baselines/Mac16_5/macOS/animation-16.json new file mode 100644 index 000000000..c8f063e75 --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/animation-16.json @@ -0,0 +1,440 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "animation-16.jsonl", + "id": "animation-16", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 16, + "frustumTested": 16, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 16, + "occlusionTested": 16, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1799, + "gpuPasses": { + "frameIndex": 1799, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.012083 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.012708 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.102 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.127083 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.058375 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.270292 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.169583 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.076709 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 0.473792 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.073833 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.084708 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.0705 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.2585 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.285833 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.601083 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.501208 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.496625 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.08475 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.036083 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.0195 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.010625 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.010541 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.042125 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.348167 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.011375 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.00975 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.010459 + } + ], + "passesSkipped": 0, + "totalMs": 4.25829 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 120, + "framesOverBudget": 1, + "framesOverBudgetLastSecond": 1, + "framesSampled": 1800, + "histogram": [ + 0, + 85, + 1714, + 1, + 0, + 0, + 0 + ], + "worstFrameMsLastSecond": 13.796666637063026 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 649805824, + "isUnderPressure": false, + "meshMemoryBytes": 1326600, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 786432, + "thermalState": 0, + "trackedEntityCount": 17, + "utilizationPercent": 4.5756933104712516e-05 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 16, + "drawCallsShadow": 32, + "drawCallsTotal": 55, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 21037, + "visibleInstances": 16 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 17, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844262.2853940418, + "timing": { + "animationMs": 0.17341668717563152, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0008749775588512421, + "cullingMs": 0.04299997817724943, + "customSystemsMs": 0.0003332970663905144, + "encodeMs": 0.4212500061839819, + "extensionsUpdateMs": 0.000625033862888813, + "frameTotalMs": 8.460583165287971, + "gameUpdateMs": 0.01241674181073904, + "geometryStreamingMs": 0.0011665979400277138, + "gpuExecutionMs": 2.2618749644607306, + "gpuFrameCadenceMs": 8.281083428300917, + "lodMs": 0.001249951310455799, + "physicsMs": 0.00100000761449337, + "physicsStepCount": 1, + "renderPrepMs": 0.04670827183872461, + "renderTotalMs": 7.536666584201157, + "scenegraphMs": 0.013083335943520069, + "scriptingMs": 0.00037497375160455704, + "semaphoreWaitMs": 0.0003332970663905144, + "smoothedFrameMs": 8.354476342598597, + "streamingRegionMs": 0.00100000761449337, + "submitMs": 0.008499948307871819, + "updateMs": 0.20879169460386038 + } + }, + "measureSeconds": 15, + "notes": "redplayer.untold with the running clip; measures the animation system and GPU skinning.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.98854420823045, + "frameBudgetMs": 12.5, + "frames": 5396, + "framesOverBudget": 8, + "gpuPassMeanMs": { + "Block Scan Pass": 0.022452563959955506, + "Compact and Stream Pass": 0.01394364738598441, + "Copy Opaque Depth for Gaussian Occlusion": 0.032583199331848646, + "Copy Opaque Depth for HZB": 0.03617071269487763, + "G-Buffer + Light Pass (TBDR)": 0.3598435540089086, + "Gaussian Pass": 0.11289490367483279, + "Grid Pass": 0.07198865979955452, + "HZB Build Mip 0": 0.11781574053452108, + "HZB Build Mip 1": 0.0965010857461025, + "HZB Build Mip 10": 0.028331292940522515, + "HZB Build Mip 2": 0.04045328309232477, + "HZB Build Mip 3": 0.036473774749721945, + "HZB Build Mip 4": 0.02983165072302554, + "HZB Build Mip 5": 0.026809998888271253, + "HZB Build Mip 6": 0.030125085094549555, + "HZB Build Mip 7": 0.024716972191323697, + "HZB Build Mip 8": 0.02379029342984408, + "HZB Build Mip 9": 0.021600150890868603, + "HZB Occlusion Culling pass": 0.02651586748329624, + "Local Scan Pass": 0.021684521714922113, + "Mark Visible Pass": 0.0301986987750557, + "Output Transform Pass": 0.22674751002227123, + "Post-Processing Pass": 0.3289736664810697, + "Pre Composite Pass": 0.11232453674832976, + "Shadow Cascade 0": 0.1105933868816008, + "Shadow Cascade 1": 0.0794014713729853, + "Transparency Pass": 0.042753628619153716 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.016166, + "Compact and Stream Pass": 0.007959, + "Copy Opaque Depth for Gaussian Occlusion": 0.021625, + "Copy Opaque Depth for HZB": 0.025083, + "G-Buffer + Light Pass (TBDR)": 0.295875, + "Gaussian Pass": 0.088041, + "Grid Pass": 0.055667, + "HZB Build Mip 0": 0.056417, + "HZB Build Mip 1": 0.02125, + "HZB Build Mip 10": 0.009083, + "HZB Build Mip 2": 0.011333, + "HZB Build Mip 3": 0.010333, + "HZB Build Mip 4": 0.009958, + "HZB Build Mip 5": 0.009666, + "HZB Build Mip 6": 0.01, + "HZB Build Mip 7": 0.009292, + "HZB Build Mip 8": 0.00925, + "HZB Build Mip 9": 0.009167, + "HZB Occlusion Culling pass": 0.020333, + "Local Scan Pass": 0.010917, + "Mark Visible Pass": 0.008209, + "Output Transform Pass": 0.171833, + "Post-Processing Pass": 0.20979199999999998, + "Pre Composite Pass": 0.063917, + "Shadow Cascade 0": 0.075292, + "Shadow Cascade 1": 0.072667, + "Transparency Pass": 0.018375 + }, + "meanFrameMs": 8.336217815522105, + "meanGPUExecutionMs": 1.3271008954198829, + "minGPUExecutionMs": 1.0357083519920707, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.0005555555555555556, + "p50FrameMs": 8.332124911248684, + "p95FrameMs": 8.65854136645794, + "p99FrameMs": 9.313291870057583, + "peakGPUAllocatedBytes": 649805824, + "timingMeanMs": { + "animationMs": 0.15608755300007035, + "batchingTickMs": 0.0005866262650259283, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.03389899771034502, + "customSystemsMs": 8.899067063661967e-05, + "encodeMs": 0.28558319239396734, + "extensionsUpdateMs": 0.00033027311848164535, + "gameUpdateMs": 0.007790945828972622, + "geometryStreamingMs": 0.007614091578653582, + "lodMs": 0.0010145839529695547, + "physicsMs": 0.00036931876559587873, + "renderPrepMs": 0.03821946137403871, + "renderTotalMs": 2.553726041029828, + "scenegraphMs": 0.012343069569569995, + "scriptingMs": 0.0001886686176459658, + "semaphoreWaitMs": 7.111291801707241e-05, + "streamingRegionMs": 0.0005917752298444062, + "submitMs": 0.004427610784064217, + "updateMs": 0.18600326663211741 + }, + "worstFrameMs": 13.796666637063026, + "worstThermalState": 1 + }, + "title": "16 skinned characters running", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/cube.json b/perf/baselines/Mac16_5/macOS/cube.json new file mode 100644 index 000000000..1bb98a371 --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/cube.json @@ -0,0 +1,440 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "cube.jsonl", + "id": "cube", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 2, + "frustumTested": 2, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 2, + "occlusionTested": 2, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1763, + "gpuPasses": { + "frameIndex": 1764, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.009916 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.138834 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.017333 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.0095 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.025166 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.163625 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.06575 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.064834 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 0.743542 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.067709 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.0775 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.069959 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.354875 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.420458 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.585459 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.467958 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 1.036667 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.2185 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.035875 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.011042 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.010208 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.014875 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.01525 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.015125 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.013125 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.0105 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.009875 + } + ], + "passesSkipped": 0, + "totalMs": 4.67346 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 121, + "framesOverBudget": 41, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1764, + "histogram": [ + 0, + 347, + 1308, + 101, + 8, + 0, + 0 + ], + "worstFrameMsLastSecond": 9.72854159772396 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 484409344, + "isUnderPressure": false, + "meshMemoryBytes": 6308, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 4, + "utilizationPercent": 1.3659743558491755e-07 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 2, + "drawCallsShadow": 4, + "drawCallsTotal": 13, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 55, + "visibleInstances": 2 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 4, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": true, + "updateWorkMs": 0.012993812561035156, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844167.8094951252, + "timing": { + "animationMs": 0.000500003807246685, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0007083872333168983, + "cullingMs": 0.029291724786162376, + "customSystemsMs": 0, + "encodeMs": 0.2549584023654461, + "extensionsUpdateMs": 0.0003334134817123413, + "frameTotalMs": 8.392499759793282, + "gameUpdateMs": 0.0076249707490205765, + "geometryStreamingMs": 0.013416633009910583, + "gpuExecutionMs": 2.504000090993941, + "gpuFrameCadenceMs": 4.919999977573752, + "lodMs": 0.0015416881069540977, + "physicsMs": 0, + "physicsStepCount": 0, + "renderPrepMs": 0.03175006713718176, + "renderTotalMs": 8.35104170255363, + "scenegraphMs": 0.0033333199098706245, + "scriptingMs": 0.0003334134817123413, + "semaphoreWaitMs": 8.323695510625839e-05, + "smoothedFrameMs": 8.443654235452414, + "streamingRegionMs": 0.00045832712203264236, + "submitMs": 0.006125075742602348, + "updateMs": 0.01750001683831215 + } + }, + "measureSeconds": 15, + "notes": "One lit, shadowed cube in front of the viewer; measures the frame's fixed per-frame cost.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.983347166678868, + "frameBudgetMs": 12.5, + "frames": 5360, + "framesOverBudget": 44, + "gpuPassMeanMs": { + "Block Scan Pass": 0.024624993885491855, + "Compact and Stream Pass": 0.01679879321845464, + "Copy Opaque Depth for Gaussian Occlusion": 0.039220551417454175, + "Copy Opaque Depth for HZB": 0.04220511228460256, + "G-Buffer + Light Pass (TBDR)": 0.2972883885491937, + "Gaussian Pass": 0.138653429683157, + "Grid Pass": 0.09281012311977704, + "HZB Build Mip 0": 0.16089371428571395, + "HZB Build Mip 1": 0.10892386325736508, + "HZB Build Mip 10": 0.019424422335600863, + "HZB Build Mip 2": 0.05274613896609231, + "HZB Build Mip 3": 0.03989399319727893, + "HZB Build Mip 4": 0.03195254365079368, + "HZB Build Mip 5": 0.028550215986394626, + "HZB Build Mip 6": 0.02662751195108395, + "HZB Build Mip 7": 0.022798389455782345, + "HZB Build Mip 8": 0.020674705782312962, + "HZB Build Mip 9": 0.021773750972762636, + "HZB Occlusion Culling pass": 0.027827942618384442, + "Local Scan Pass": 0.019224730362117082, + "Mark Visible Pass": 0.015428300453514707, + "Output Transform Pass": 0.2240532551417458, + "Post-Processing Pass": 0.2881363451917733, + "Pre Composite Pass": 0.11820964146748203, + "Shadow Cascade 0": 0.08338528293496386, + "Shadow Cascade 1": 0.08078163201778764, + "Transparency Pass": 0.05468355753196216 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.004125, + "Compact and Stream Pass": 0.0025, + "Copy Opaque Depth for Gaussian Occlusion": 0.008583, + "Copy Opaque Depth for HZB": 0.01075, + "G-Buffer + Light Pass (TBDR)": 0.097917, + "Gaussian Pass": 0.033333, + "Grid Pass": 0.020958, + "HZB Build Mip 0": 0.027542, + "HZB Build Mip 1": 0.009458, + "HZB Build Mip 10": 0.003041, + "HZB Build Mip 2": 0.004917, + "HZB Build Mip 3": 0.004084, + "HZB Build Mip 4": 0.003667, + "HZB Build Mip 5": 0.0035, + "HZB Build Mip 6": 0.003708, + "HZB Build Mip 7": 0.003125, + "HZB Build Mip 8": 0.00325, + "HZB Build Mip 9": 0.003167, + "HZB Occlusion Culling pass": 0.007709, + "Local Scan Pass": 0.00325, + "Mark Visible Pass": 0.00275, + "Output Transform Pass": 0.058625, + "Post-Processing Pass": 0.07483300000000001, + "Pre Composite Pass": 0.030917, + "Shadow Cascade 0": 0.015916, + "Shadow Cascade 1": 0.015416, + "Transparency Pass": 0.013416 + }, + "meanFrameMs": 8.333307047012365, + "meanGPUExecutionMs": 1.303349569048515, + "minGPUExecutionMs": 0.381499994546175, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.0, + "p50FrameMs": 8.333041332662106, + "p95FrameMs": 8.883541449904442, + "p99FrameMs": 9.314791299402714, + "peakGPUAllocatedBytes": 484409344, + "timingMeanMs": { + "animationMs": 0.0007290052922649516, + "batchingTickMs": 0.001079623749513441, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.038593961275960494, + "customSystemsMs": 0.0001386184979620118, + "encodeMs": 0.20409198898252928, + "extensionsUpdateMs": 0.000575330159740983, + "gameUpdateMs": 0.011471163155822153, + "geometryStreamingMs": 0.003770089649672066, + "lodMs": 0.0014400517620884695, + "physicsMs": 0.000369235769742065, + "renderPrepMs": 0.04223693835804185, + "renderTotalMs": 1.8123834715354152, + "scenegraphMs": 0.003838760753417334, + "scriptingMs": 0.0002957248639207508, + "semaphoreWaitMs": 7.805472705513239e-05, + "streamingRegionMs": 0.0009835297905555812, + "submitMs": 0.005416226081757082, + "updateMs": 0.02529062325537271 + }, + "worstFrameMs": 9.37245786190033, + "worstThermalState": 1 + }, + "title": "A single cube", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/gaussian.json b/perf/baselines/Mac16_5/macOS/gaussian.json new file mode 100644 index 000000000..c2c86f862 --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/gaussian.json @@ -0,0 +1,461 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "gaussian.jsonl", + "id": "gaussian", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 256, + "frustumTested": 256, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 256, + "occlusionTested": 256, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1790, + "gpuPasses": { + "frameIndex": 1791, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.010625 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.012291 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.017667 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.011041 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.045083 + }, + { + "count": 1, + "label": "Gaussian Frustum Culling", + "ms": 0.170667 + }, + { + "count": 1, + "label": "Gaussian Preprocess", + "ms": 0.037666 + }, + { + "count": 1, + "label": "Radix Sort", + "ms": 0.778042 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.161292 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.102083 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.099041 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 0.720042 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.069458 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.078958 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.059875 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.254791 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.276541 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.587833 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.520333 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.841042 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.124292 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.017875 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.012375 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.014041 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.015292 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.013666 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.018625 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.012083 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.011791 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.012333 + } + ], + "passesSkipped": 0, + "totalMs": 5.106743999999997 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 120, + "framesOverBudget": 8, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1791, + "histogram": [ + 2, + 223, + 1547, + 18, + 1, + 0, + 0 + ], + "worstFrameMsLastSecond": 9.170708246529102 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 648036352, + "isUnderPressure": false, + "meshMemoryBytes": 3306392, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 258, + "utilizationPercent": 7.159871165640652e-05 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 256, + "drawCallsShadow": 512, + "drawCallsTotal": 776, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 155141, + "visibleInstances": 256 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 257, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844299.8383583336, + "timing": { + "animationMs": 0.001249951310455799, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0017499551177024841, + "cullingMs": 0.1647083554416895, + "customSystemsMs": 0.000250060111284256, + "encodeMs": 1.9362083403393626, + "extensionsUpdateMs": 0.0014167744666337967, + "frameTotalMs": 7.903208024799824, + "gameUpdateMs": 0.025833374820649624, + "geometryStreamingMs": 0.0017082784324884415, + "gpuExecutionMs": 2.427999977953732, + "gpuFrameCadenceMs": 8.486666716635227, + "lodMs": 0.0031249364838004112, + "physicsMs": 0.0011667143553495407, + "physicsStepCount": 1, + "renderPrepMs": 0.24391664192080498, + "renderTotalMs": 5.851083318702877, + "scenegraphMs": 0.0642499653622508, + "scriptingMs": 0.00041676685214042664, + "semaphoreWaitMs": 0.00029173679649829865, + "smoothedFrameMs": 8.333355514332652, + "streamingRegionMs": 0.0014166580513119698, + "submitMs": 0.009249895811080933, + "updateMs": 0.11195836123079062 + } + }, + "measureSeconds": 15, + "notes": "test_gaussians.ply (small) plus a 16x16 grid; exercises the sort pipeline, not splat count.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.991625000024214, + "frameBudgetMs": 12.5, + "frames": 5385, + "framesOverBudget": 15, + "gpuPassMeanMs": { + "Block Scan Pass": 0.02421204355108874, + "Compact and Stream Pass": 0.029281818537130095, + "Copy Opaque Depth for Gaussian Occlusion": 0.03593344394868936, + "Copy Opaque Depth for HZB": 0.05135692749581711, + "G-Buffer + Light Pass (TBDR)": 0.438270947016174, + "Gaussian Frustum Culling": 0.171407202453988, + "Gaussian Pass": 0.1537357412158393, + "Gaussian Preprocess": 0.06619478447794515, + "Grid Pass": 0.12522488510875632, + "HZB Build Mip 0": 0.0902804397556912, + "HZB Build Mip 1": 0.041502462520821806, + "HZB Build Mip 10": 0.014925597989949769, + "HZB Build Mip 2": 0.026688952248750685, + "HZB Build Mip 3": 0.031467893947806774, + "HZB Build Mip 4": 0.026024952248750746, + "HZB Build Mip 5": 0.023056857621440618, + "HZB Build Mip 6": 0.01982140591848129, + "HZB Build Mip 7": 0.01747851814628703, + "HZB Build Mip 8": 0.01600489447236184, + "HZB Build Mip 9": 0.015302365717476271, + "HZB Occlusion Culling pass": 0.05977950977107777, + "Local Scan Pass": 0.03221377746793091, + "Mark Visible Pass": 0.018610533780011217, + "Output Transform Pass": 0.1990434280955025, + "Post-Processing Pass": 0.2481776529705715, + "Pre Composite Pass": 0.10816299832682676, + "Radix Sort": 0.7867843714445051, + "Shadow Cascade 0": 0.1690453305415969, + "Shadow Cascade 1": 0.15621918704634266, + "Transparency Pass": 0.066608662576687 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.01625, + "Compact and Stream Pass": 0.010084, + "Copy Opaque Depth for Gaussian Occlusion": 0.021125, + "Copy Opaque Depth for HZB": 0.024583, + "G-Buffer + Light Pass (TBDR)": 0.318834, + "Gaussian Frustum Culling": 0.058959, + "Gaussian Pass": 0.081958, + "Gaussian Preprocess": 0.0325, + "Grid Pass": 0.059958, + "HZB Build Mip 0": 0.048, + "HZB Build Mip 1": 0.015792, + "HZB Build Mip 10": 0.009166, + "HZB Build Mip 2": 0.010833, + "HZB Build Mip 3": 0.010334, + "HZB Build Mip 4": 0.009875, + "HZB Build Mip 5": 0.00975, + "HZB Build Mip 6": 0.00975, + "HZB Build Mip 7": 0.009333, + "HZB Build Mip 8": 0.009292, + "HZB Build Mip 9": 0.009125, + "HZB Occlusion Culling pass": 0.037042, + "Local Scan Pass": 0.011917, + "Mark Visible Pass": 0.009708, + "Output Transform Pass": 0.167334, + "Post-Processing Pass": 0.200375, + "Pre Composite Pass": 0.062, + "Radix Sort": 0.533541, + "Shadow Cascade 0": 0.097667, + "Shadow Cascade 1": 0.093667, + "Transparency Pass": 0.011375 + }, + "meanFrameMs": 8.333323082303849, + "meanGPUExecutionMs": 1.8180134096268588, + "minGPUExecutionMs": 1.2316666543483734, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.0, + "p50FrameMs": 8.333167061209679, + "p95FrameMs": 8.930874988436699, + "p99FrameMs": 9.434250183403492, + "peakGPUAllocatedBytes": 648036352, + "timingMeanMs": { + "animationMs": 0.0009103042116468314, + "batchingTickMs": 0.001127441097892368, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.11790775978882931, + "customSystemsMs": 0.00012860055836265603, + "encodeMs": 1.859141663168762, + "extensionsUpdateMs": 0.0006795734392560767, + "gameUpdateMs": 0.01293486251708222, + "geometryStreamingMs": 0.02553174058870019, + "lodMs": 0.0015418950818905162, + "physicsMs": 0.0004083394429241532, + "renderPrepMs": 0.16663679606159698, + "renderTotalMs": 6.152122420307126, + "scenegraphMs": 0.05520608989487732, + "scriptingMs": 0.00031436396621039415, + "semaphoreWaitMs": 6.0568416262586405e-05, + "streamingRegionMs": 0.0010959262431407533, + "submitMs": 0.006463444699277209, + "updateMs": 0.07882539661490523 + }, + "worstFrameMs": 11.251416988670826, + "worstThermalState": 1 + }, + "title": "Gaussian splat with primitives", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/lights-64.json b/perf/baselines/Mac16_5/macOS/lights-64.json new file mode 100644 index 000000000..0d4d6c6e7 --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/lights-64.json @@ -0,0 +1,454 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "lights-64.jsonl", + "id": "lights-64", + "lastSnapshot": { + "batching": { + "batchGroupCount": 237, + "batchedMeshCount": 1005, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 1024, + "frustumTested": 1024, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 1024, + "occlusionTested": 1024, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1791, + "gpuPasses": { + "frameIndex": 1792, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.010833 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.1365 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.018791 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.011167 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.044834 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.164084 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.021083 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.020917 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 0", + "ms": 0.235167 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 1", + "ms": 0.141167 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 2.955583 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.074792 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.085458 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.073584 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.268458 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.308291 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.574833 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.497791 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.411583 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.094542 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.016792 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.031916 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.02575 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.025834 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.020208 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.010709 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.009834 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.00975 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.044 + } + ], + "passesSkipped": 0, + "totalMs": 6.344250999999999 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 121, + "framesOverBudget": 21, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1792, + "histogram": [ + 12, + 377, + 1381, + 20, + 2, + 0, + 0 + ], + "worstFrameMsLastSecond": 10.48029214143753 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 521289728, + "isUnderPressure": false, + "meshMemoryBytes": 12216512, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 1089, + "utilizationPercent": 0.0002645440981723368 + }, + "render": { + "drawCallsBatched": 708, + "drawCallsOpaque": 256, + "drawCallsShadow": 471, + "drawCallsTotal": 734, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 589609, + "visibleInstances": 1024 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 1089, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844224.7386120835, + "timing": { + "animationMs": 0.0009583309292793274, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0012082746252417564, + "cullingMs": 0.4218750400468707, + "customSystemsMs": 0.0002916203811764717, + "encodeMs": 2.7517499402165413, + "extensionsUpdateMs": 0.0010833609849214554, + "frameTotalMs": 8.37116688489914, + "gameUpdateMs": 0.024583423510193825, + "geometryStreamingMs": 0.001249951310455799, + "gpuExecutionMs": 4.813999985344708, + "gpuFrameCadenceMs": 7.537500001490116, + "lodMs": 0.002541695721447468, + "physicsMs": 0.0009167706593871117, + "physicsStepCount": 1, + "renderPrepMs": 0.4271250218153, + "renderTotalMs": 6.866625044494867, + "scenegraphMs": 0.21483330056071281, + "scriptingMs": 0.00037497375160455704, + "semaphoreWaitMs": 0.00024994369596242905, + "smoothedFrameMs": 8.33468739874661, + "streamingRegionMs": 0.0014583347365260124, + "submitMs": 0.011499971151351929, + "updateMs": 0.2521249698475003 + } + }, + "measureSeconds": 15, + "notes": "Cubes and spheres merged by the static batcher; measures batched draw cost and light-loop cost.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.98351312498562, + "frameBudgetMs": 12.5, + "frames": 5354, + "framesOverBudget": 79, + "gpuPassMeanMs": { + "Batched Shadow Cascade 0": 0.1531333002232144, + "Batched Shadow Cascade 1": 0.13491696037946424, + "Block Scan Pass": 0.01826797104677051, + "Compact and Stream Pass": 0.011982729398663657, + "Copy Opaque Depth for Gaussian Occlusion": 0.02700119603399443, + "Copy Opaque Depth for HZB": 0.02822664475920684, + "G-Buffer + Light Pass (TBDR)": 1.1334411456090647, + "Gaussian Pass": 0.10325921529745041, + "Grid Pass": 0.0631845918708241, + "HZB Build Mip 0": 0.09173902946175637, + "HZB Build Mip 1": 0.052637156373937614, + "HZB Build Mip 10": 0.04649044419642854, + "HZB Build Mip 2": 0.02736995100222724, + "HZB Build Mip 3": 0.02466683172804537, + "HZB Build Mip 4": 0.012903492917847047, + "HZB Build Mip 5": 0.020387609065155816, + "HZB Build Mip 6": 0.021847905382436264, + "HZB Build Mip 7": 0.012499878186968854, + "HZB Build Mip 8": 0.01175424419263455, + "HZB Build Mip 9": 0.01678505779036827, + "HZB Occlusion Culling pass": 0.03757376503340754, + "Local Scan Pass": 0.013360756681514492, + "Mark Visible Pass": 0.011910181514476632, + "Output Transform Pass": 0.15240274900849865, + "Post-Processing Pass": 0.19064210254957492, + "Pre Composite Pass": 0.08491572917847044, + "Shadow Cascade 0": 0.020235757812500112, + "Shadow Cascade 1": 0.019511370535714216, + "Transparency Pass": 0.04635344872521249 + }, + "gpuPassMinMs": { + "Batched Shadow Cascade 0": 0.047542, + "Batched Shadow Cascade 1": 0.047625, + "Block Scan Pass": 0.005958, + "Compact and Stream Pass": 0.003792, + "Copy Opaque Depth for Gaussian Occlusion": 0.009167, + "Copy Opaque Depth for HZB": 0.010542, + "G-Buffer + Light Pass (TBDR)": 0.489167, + "Gaussian Pass": 0.033916, + "Grid Pass": 0.021083, + "HZB Build Mip 0": 0.025875, + "HZB Build Mip 1": 0.008958, + "HZB Build Mip 10": 0.004, + "HZB Build Mip 2": 0.005583, + "HZB Build Mip 3": 0.004959, + "HZB Build Mip 4": 0.004375, + "HZB Build Mip 5": 0.004667, + "HZB Build Mip 6": 0.004209, + "HZB Build Mip 7": 0.004166, + "HZB Build Mip 8": 0.004125, + "HZB Build Mip 9": 0.004, + "HZB Occlusion Culling pass": 0.01325, + "Local Scan Pass": 0.004291, + "Mark Visible Pass": 0.004041, + "Output Transform Pass": 0.064292, + "Post-Processing Pass": 0.080584, + "Pre Composite Pass": 0.037583, + "Shadow Cascade 0": 0.007291, + "Shadow Cascade 1": 0.007083, + "Transparency Pass": 0.006541 + }, + "meanFrameMs": 8.35187965550532, + "meanGPUExecutionMs": 2.681186688594097, + "minGPUExecutionMs": 0.8315000450238585, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.004451864218141347, + "p50FrameMs": 8.332333527505398, + "p95FrameMs": 10.111291892826557, + "p99FrameMs": 10.926375165581703, + "peakGPUAllocatedBytes": 521289728, + "timingMeanMs": { + "animationMs": 0.0008185886848939745, + "batchingTickMs": 0.0009953640230335856, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.3587278798139619, + "customSystemsMs": 0.00014062815397132021, + "encodeMs": 2.513197367079556, + "extensionsUpdateMs": 0.0006384489633885104, + "gameUpdateMs": 0.01211711695302707, + "geometryStreamingMs": 0.08937721978345353, + "lodMs": 0.001560981500982973, + "physicsMs": 0.00039004755989504843, + "renderPrepMs": 0.3622730954332017, + "renderTotalMs": 6.440203588421615, + "scenegraphMs": 0.1957935311438426, + "scriptingMs": 0.000292684703559496, + "semaphoreWaitMs": 7.57543850299657e-05, + "streamingRegionMs": 0.0009685822798309223, + "submitMs": 0.006461370519725694, + "updateMs": 0.22005491036520716 + }, + "worstFrameMs": 15.957541763782501, + "worstThermalState": 1 + }, + "title": "1024 primitives, static batching, 64 point lights", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/postfx.json b/perf/baselines/Mac16_5/macOS/postfx.json new file mode 100644 index 000000000..368d4abc2 --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/postfx.json @@ -0,0 +1,468 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "postfx.jsonl", + "id": "postfx", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 1024, + "frustumTested": 1024, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 1024, + "occlusionTested": 1024, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1566, + "gpuPasses": { + "frameIndex": 1567, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.011125 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.158416 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.10575 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.011542 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.041125 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.16525 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.294834 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.288542 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 1.038916 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.12 + }, + { + "count": 1, + "label": "SSAO Low-Res Pass", + "ms": 0.21425 + }, + { + "count": 1, + "label": "SSAO Bilateral Blur Horizontal", + "ms": 0.075333 + }, + { + "count": 1, + "label": "SSAO Bilateral Blur Vertical", + "ms": 0.073375 + }, + { + "count": 1, + "label": "SSAO Upsample Pass", + "ms": 0.153666 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.013708 + }, + { + "count": 17, + "label": "Post-Processing Pass", + "ms": 3.493875 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.063958 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.309834 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.210958 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.204542 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.24675 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.076791 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.029708 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.0185 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.01825 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.013209 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.012167 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.01225 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.026542 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.036083 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.046083 + } + ], + "passesSkipped": 0, + "totalMs": 7.585332 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 104, + "framesOverBudget": 180, + "framesOverBudgetLastSecond": 17, + "framesSampled": 1567, + "histogram": [ + 0, + 163, + 1162, + 239, + 3, + 0, + 0 + ], + "worstFrameMsLastSecond": 16.42637513577938 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 649543680, + "isUnderPressure": false, + "meshMemoryBytes": 12096992, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 1029, + "utilizationPercent": 0.00026195592363364995 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 1024, + "drawCallsShadow": 1771, + "drawCallsTotal": 2821, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 555291, + "visibleInstances": 1024 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 1029, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844243.5180470002, + "timing": { + "animationMs": 0.0008333008736371994, + "batchingRebuildMs": 0, + "batchingTickMs": 0.00100000761449337, + "cullingMs": 0.3377916291356087, + "customSystemsMs": 0.00016670674085617065, + "encodeMs": 6.973666721023619, + "extensionsUpdateMs": 0.0007499475032091141, + "frameTotalMs": 15.14795795083046, + "gameUpdateMs": 0.012999982573091984, + "geometryStreamingMs": 0.001625041477382183, + "gpuExecutionMs": 6.401333375833929, + "gpuFrameCadenceMs": 15.771374921314418, + "lodMs": 0.0014583347365260124, + "physicsMs": 0.0009166542440652847, + "physicsStepCount": 1, + "renderPrepMs": 0.34129165578633547, + "renderTotalMs": 7.4769583297893405, + "scenegraphMs": 0.18804159481078386, + "scriptingMs": 0.00029173679649829865, + "semaphoreWaitMs": 0.00020838342607021332, + "smoothedFrameMs": 9.689130509893099, + "streamingRegionMs": 0.0010416842997074127, + "submitMs": 0.006375019438564777, + "updateMs": 0.21154165733605623 + } + }, + "measureSeconds": 15, + "notes": "Same geometry as primitives-1k; the difference is the full-screen pass chain.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.988691041595303, + "frameBudgetMs": 12.5, + "frames": 5078, + "framesOverBudget": 268, + "gpuPassMeanMs": { + "Block Scan Pass": 0.016791121666666617, + "Compact and Stream Pass": 0.010696342222222164, + "Copy Opaque Depth for Gaussian Occlusion": 0.03043023728813562, + "Copy Opaque Depth for HZB": 0.0704777583333334, + "G-Buffer + Light Pass (TBDR)": 0.6823227672222218, + "Gaussian Pass": 0.10395511864406802, + "Grid Pass": 0.0628181477777777, + "HZB Build Mip 0": 0.08223004944444451, + "HZB Build Mip 1": 0.03629073833333339, + "HZB Build Mip 10": 0.035733034460753016, + "HZB Build Mip 2": 0.025889020455873824, + "HZB Build Mip 3": 0.017737188888888922, + "HZB Build Mip 4": 0.021801633333333372, + "HZB Build Mip 5": 0.021231206312098197, + "HZB Build Mip 6": 0.01984189888953832, + "HZB Build Mip 7": 0.01701800055555558, + "HZB Build Mip 8": 0.019563307422559902, + "HZB Build Mip 9": 0.016972367222222246, + "HZB Occlusion Culling pass": 0.03396250444444437, + "Local Scan Pass": 0.012392883333333242, + "Mark Visible Pass": 0.028384498404594817, + "Output Transform Pass": 0.06262132144944468, + "Post-Processing Pass": 1.2024988947983626, + "Pre Composite Pass": 0.08031273056691998, + "SSAO Bilateral Blur Horizontal": 0.04136317111111118, + "SSAO Bilateral Blur Vertical": 0.039189195555555494, + "SSAO Low-Res Pass": 0.12646419777777784, + "SSAO Upsample Pass": 0.05283953722222209, + "Shadow Cascade 0": 0.13434482111111065, + "Shadow Cascade 1": 0.18597818953414122, + "Transparency Pass": 0.012661851111111068 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.00575, + "Compact and Stream Pass": 0.003583, + "Copy Opaque Depth for Gaussian Occlusion": 0.009416, + "Copy Opaque Depth for HZB": 0.011041, + "G-Buffer + Light Pass (TBDR)": 0.214834, + "Gaussian Pass": 0.038708, + "Grid Pass": 0.022166, + "HZB Build Mip 0": 0.021958, + "HZB Build Mip 1": 0.008375, + "HZB Build Mip 10": 0.004084, + "HZB Build Mip 2": 0.005417, + "HZB Build Mip 3": 0.004333, + "HZB Build Mip 4": 0.004333, + "HZB Build Mip 5": 0.004291, + "HZB Build Mip 6": 0.00425, + "HZB Build Mip 7": 0.004459, + "HZB Build Mip 8": 0.004125, + "HZB Build Mip 9": 0.004125, + "HZB Occlusion Culling pass": 0.01275, + "Local Scan Pass": 0.00425, + "Mark Visible Pass": 0.003625, + "Output Transform Pass": 0.028208, + "Post-Processing Pass": 0.43987499999999996, + "Pre Composite Pass": 0.043583, + "SSAO Bilateral Blur Horizontal": 0.013375, + "SSAO Bilateral Blur Vertical": 0.01275, + "SSAO Low-Res Pass": 0.039833, + "SSAO Upsample Pass": 0.017166, + "Shadow Cascade 0": 0.041375, + "Shadow Cascade 1": 0.065083, + "Transparency Pass": 0.004209 + }, + "meanFrameMs": 8.333933495450765, + "meanGPUExecutionMs": 3.5483235186095246, + "minGPUExecutionMs": 1.0989999864250422, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.0, + "p50FrameMs": 8.92999954521656, + "p95FrameMs": 9.83795803040266, + "p99FrameMs": 10.318499989807606, + "peakGPUAllocatedBytes": 649543680, + "timingMeanMs": { + "animationMs": 0.0008530315244570374, + "batchingTickMs": 0.0009025456125123634, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.31976046098861843, + "customSystemsMs": 0.0001238672606026133, + "encodeMs": 6.7039999065066995, + "extensionsUpdateMs": 0.0006735873951887091, + "gameUpdateMs": 0.00968270902780609, + "geometryStreamingMs": 0.08635819378671133, + "lodMs": 0.001383241679933336, + "physicsMs": 0.0003575232242130571, + "renderPrepMs": 0.323188425326306, + "renderTotalMs": 7.674766087244886, + "scenegraphMs": 0.18948585077769692, + "scriptingMs": 0.00030275531268368167, + "semaphoreWaitMs": 5.1406107642614374e-05, + "streamingRegionMs": 0.000903566122158534, + "submitMs": 0.008056688756268058, + "updateMs": 0.2094099783183386 + }, + "worstFrameMs": 12.461916543543339, + "worstThermalState": 0 + }, + "title": "1024 primitives with SSAO, bloom, depth of field and SMAA", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/primitives-10k.json b/perf/baselines/Mac16_5/macOS/primitives-10k.json new file mode 100644 index 000000000..699ed7a2d --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/primitives-10k.json @@ -0,0 +1,454 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "primitives-10k.jsonl", + "id": "primitives-10k", + "lastSnapshot": { + "batching": { + "batchGroupCount": 1016, + "batchedMeshCount": 9992, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 98, + "frustumPassed": 9902, + "frustumTested": 10000, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 9902, + "occlusionTested": 9902, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 511, + "gpuPasses": { + "frameIndex": 511, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.074667 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.0105 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.013542 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.007417 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.031166 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.090166 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.012083 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.011833 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 0", + "ms": 0.243333 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 1", + "ms": 1.0925 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 2.880375 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.076709 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.086625 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.073708 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.274541 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.32125 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.338334 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.258125 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.266708 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.143375 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.076291 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.029041 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.009791 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.009292 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.013959 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.024458 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.023375 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.028416 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.008083 + } + ], + "passesSkipped": 0, + "totalMs": 6.529663 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 33, + "framesOverBudget": 512, + "framesOverBudgetLastSecond": 33, + "framesSampled": 512, + "histogram": [ + 0, + 0, + 0, + 0, + 459, + 53, + 0 + ], + "worstFrameMsLastSecond": 49.29812625050545 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 615792640, + "isUnderPressure": false, + "meshMemoryBytes": 118162936, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 10005, + "utilizationPercent": 0.002558775246143341 + }, + "render": { + "drawCallsBatched": 2203, + "drawCallsOpaque": 1024, + "drawCallsShadow": 1187, + "drawCallsTotal": 2218, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 4404421, + "visibleInstances": 9902 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 10005, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844205.3797718752, + "timing": { + "animationMs": 0.0007499475032091141, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0009583309292793274, + "cullingMs": 3.3223750069737434, + "customSystemsMs": 0.00020838342607021332, + "encodeMs": 17.848874907940626, + "extensionsUpdateMs": 0.0005415640771389008, + "frameTotalMs": 28.73341739177704, + "gameUpdateMs": 0.01779163721948862, + "geometryStreamingMs": 0.009624985978007317, + "gpuExecutionMs": 5.3587500005960464, + "gpuFrameCadenceMs": 35.51700001116842, + "lodMs": 0.002374988980591297, + "physicsMs": 0.0007083872333168983, + "physicsStepCount": 1, + "renderPrepMs": 3.3261249773204327, + "renderTotalMs": 21.287124953232706, + "scenegraphMs": 1.6666667070239782, + "scriptingMs": 0.00029173679649829865, + "semaphoreWaitMs": 8.335337042808533e-05, + "smoothedFrameMs": 32.94579014182091, + "streamingRegionMs": 0.0011249212548136711, + "submitMs": 0.007999944500625134, + "updateMs": 1.6991666052490473 + } + }, + "measureSeconds": 15, + "notes": "Cubes and spheres merged by the static batcher; measures batched draw cost and light-loop cost.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.9517183334101, + "frameBudgetMs": 12.5, + "frames": 1487, + "framesOverBudget": 1487, + "gpuPassMeanMs": { + "Batched Shadow Cascade 0": 0.17825651663405093, + "Batched Shadow Cascade 1": 0.7603268336798328, + "Block Scan Pass": 0.024165120582120586, + "Compact and Stream Pass": 0.020559439271255038, + "Copy Opaque Depth for Gaussian Occlusion": 0.030378459459459467, + "Copy Opaque Depth for HZB": 0.050253981288981245, + "G-Buffer + Light Pass (TBDR)": 1.9149631372141385, + "Gaussian Pass": 0.13682269028340088, + "Grid Pass": 0.09452987044534406, + "HZB Build Mip 0": 0.07958764864864873, + "HZB Build Mip 1": 0.026129937629937598, + "HZB Build Mip 10": 0.01702957534246576, + "HZB Build Mip 2": 0.0432766777546777, + "HZB Build Mip 3": 0.01601565280665279, + "HZB Build Mip 4": 0.014263883575883578, + "HZB Build Mip 5": 0.013355647749510776, + "HZB Build Mip 6": 0.019155825831702524, + "HZB Build Mip 7": 0.011564436590436588, + "HZB Build Mip 8": 0.012598835758835757, + "HZB Build Mip 9": 0.01913976516634052, + "HZB Occlusion Culling pass": 0.0431023970893971, + "Local Scan Pass": 0.0296052085020243, + "Mark Visible Pass": 0.02274509589041089, + "Output Transform Pass": 0.17996928274428262, + "Post-Processing Pass": 0.24296467206477754, + "Pre Composite Pass": 0.10154883198380561, + "Shadow Cascade 0": 0.02623490998043056, + "Shadow Cascade 1": 0.022121970645792598, + "Transparency Pass": 0.06293870850202417 + }, + "gpuPassMinMs": { + "Batched Shadow Cascade 0": 0.007916, + "Batched Shadow Cascade 1": 0.252417, + "Block Scan Pass": 0.005958, + "Compact and Stream Pass": 0.0045, + "Copy Opaque Depth for Gaussian Occlusion": 0.00875, + "Copy Opaque Depth for HZB": 0.010709, + "G-Buffer + Light Pass (TBDR)": 0.791625, + "Gaussian Pass": 0.0345, + "Grid Pass": 0.021, + "HZB Build Mip 0": 0.0245, + "HZB Build Mip 1": 0.008416, + "HZB Build Mip 10": 0.003958, + "HZB Build Mip 2": 0.00525, + "HZB Build Mip 3": 0.0045, + "HZB Build Mip 4": 0.004291, + "HZB Build Mip 5": 0.00425, + "HZB Build Mip 6": 0.004833, + "HZB Build Mip 7": 0.003958, + "HZB Build Mip 8": 0.003916, + "HZB Build Mip 9": 0.00425, + "HZB Occlusion Culling pass": 0.014291, + "Local Scan Pass": 0.004459, + "Mark Visible Pass": 0.00525, + "Output Transform Pass": 0.052166, + "Post-Processing Pass": 0.06725, + "Pre Composite Pass": 0.037041, + "Shadow Cascade 0": 0.00725, + "Shadow Cascade 1": 0.007083, + "Transparency Pass": 0.013584 + }, + "meanFrameMs": 29.341946090426063, + "meanGPUExecutionMs": 3.7823439841478166, + "minGPUExecutionMs": 1.3440833427011967, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 1.0, + "p50FrameMs": 27.334416285157204, + "p95FrameMs": 44.51800137758255, + "p99FrameMs": 46.47258296608925, + "peakGPUAllocatedBytes": 615792640, + "timingMeanMs": { + "animationMs": 0.0008555071567826112, + "batchingTickMs": 0.0010858603794625414, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 3.318322593713674, + "customSystemsMs": 0.00025379577055875105, + "encodeMs": 17.50744474406929, + "extensionsUpdateMs": 0.0006876681339342584, + "gameUpdateMs": 0.01776172319495002, + "geometryStreamingMs": 0.7852661931337934, + "lodMs": 0.0023582122130281773, + "physicsMs": 0.0010090370778925717, + "renderPrepMs": 3.322330646142291, + "renderTotalMs": 20.946014973560523, + "scenegraphMs": 1.6524055140507699, + "scriptingMs": 0.00030895919437229873, + "semaphoreWaitMs": 6.131764674777927e-05, + "streamingRegionMs": 0.0009366543492234429, + "submitMs": 0.008077683541848351, + "updateMs": 1.6883045266240515 + }, + "worstFrameMs": 52.26687341928482, + "worstThermalState": 1 + }, + "title": "10000 primitives, static batching, 4 point lights", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/primitives-1k.json b/perf/baselines/Mac16_5/macOS/primitives-1k.json new file mode 100644 index 000000000..0f9b4e6ba --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/primitives-1k.json @@ -0,0 +1,440 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "primitives-1k.jsonl", + "id": "primitives-1k", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 1024, + "frustumTested": 1024, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 1024, + "occlusionTested": 1024, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1763, + "gpuPasses": { + "frameIndex": 1763, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.010625 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.013583 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.017875 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.011125 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.044916 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.253416 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.173542 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.196125 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 1.042625 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.073792 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.083791 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.070292 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.263917 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.281417 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.5992919999999999 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.509625 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.472958 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.127 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.025416 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.011209 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.011417 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.047292 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.035583 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.135458 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.119042 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.026167 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.027791 + } + ], + "passesSkipped": 0, + "totalMs": 4.6852909999999985 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 120, + "framesOverBudget": 54, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1764, + "histogram": [ + 0, + 899, + 742, + 116, + 7, + 0, + 0 + ], + "worstFrameMsLastSecond": 12.467624619603157 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 486244352, + "isUnderPressure": false, + "meshMemoryBytes": 12096992, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 1029, + "utilizationPercent": 0.00026195592363364995 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 1024, + "drawCallsShadow": 1752, + "drawCallsTotal": 2783, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 555025, + "visibleInstances": 1024 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 1029, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844186.5905053335, + "timing": { + "animationMs": 0.00045832712203264236, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0003334134817123413, + "cullingMs": 0.292458338662982, + "customSystemsMs": 0.00020826701074838638, + "encodeMs": 6.116041564382613, + "extensionsUpdateMs": 0.0004584435373544693, + "frameTotalMs": 10.521125048398972, + "gameUpdateMs": 0.006875023245811462, + "geometryStreamingMs": 0.00045832712203264236, + "gpuExecutionMs": 3.0760832596570253, + "gpuFrameCadenceMs": 9.218958322890103, + "lodMs": 0.0009583309292793274, + "physicsMs": 0.0005832407623529434, + "physicsStepCount": 1, + "renderPrepMs": 0.2948334440588951, + "renderTotalMs": 6.492000073194504, + "scenegraphMs": 0.1805000938475132, + "scriptingMs": 0.00012491364032030106, + "semaphoreWaitMs": 4.1676685214042664e-05, + "smoothedFrameMs": 8.327631931751966, + "streamingRegionMs": 0.000500003807246685, + "submitMs": 0.00500003807246685, + "updateMs": 0.1990000018849969 + } + }, + "measureSeconds": 15, + "notes": "One entity and draw per primitive; measures per-entity CPU cost and encoder overhead.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.989601250039414, + "frameBudgetMs": 12.5, + "frames": 5352, + "framesOverBudget": 68, + "gpuPassMeanMs": { + "Block Scan Pass": 0.018543396111110992, + "Compact and Stream Pass": 0.011531038333333288, + "Copy Opaque Depth for Gaussian Occlusion": 0.028812248322147755, + "Copy Opaque Depth for HZB": 0.03326566888888886, + "G-Buffer + Light Pass (TBDR)": 0.7018186250000001, + "Gaussian Pass": 0.10534677069351235, + "Grid Pass": 0.06810618000000003, + "HZB Build Mip 0": 0.08437727181208061, + "HZB Build Mip 1": 0.049286751118568256, + "HZB Build Mip 10": 0.059105730005672194, + "HZB Build Mip 2": 0.044664948545861345, + "HZB Build Mip 3": 0.02510751106069204, + "HZB Build Mip 4": 0.022463375496313123, + "HZB Build Mip 5": 0.025950102098695416, + "HZB Build Mip 6": 0.022216137024608527, + "HZB Build Mip 7": 0.013755887583892628, + "HZB Build Mip 8": 0.01628909055555556, + "HZB Build Mip 9": 0.0297931118568233, + "HZB Occlusion Culling pass": 0.035576116666666595, + "Local Scan Pass": 0.013333217777777748, + "Mark Visible Pass": 0.020517254679523575, + "Output Transform Pass": 0.20398793959731595, + "Post-Processing Pass": 0.2552045027964206, + "Pre Composite Pass": 0.09516925055928413, + "Shadow Cascade 0": 0.1383836977777775, + "Shadow Cascade 1": 0.20502874333333335, + "Transparency Pass": 0.04059532333333333 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.006166, + "Compact and Stream Pass": 0.004208, + "Copy Opaque Depth for Gaussian Occlusion": 0.014958, + "Copy Opaque Depth for HZB": 0.016167, + "G-Buffer + Light Pass (TBDR)": 0.351958, + "Gaussian Pass": 0.055417, + "Grid Pass": 0.036083, + "HZB Build Mip 0": 0.04175, + "HZB Build Mip 1": 0.014375, + "HZB Build Mip 10": 0.004541, + "HZB Build Mip 2": 0.008625, + "HZB Build Mip 3": 0.00575, + "HZB Build Mip 4": 0.004792, + "HZB Build Mip 5": 0.005, + "HZB Build Mip 6": 0.004583, + "HZB Build Mip 7": 0.004333, + "HZB Build Mip 8": 0.004375, + "HZB Build Mip 9": 0.004458, + "HZB Occlusion Culling pass": 0.016708, + "Local Scan Pass": 0.004375, + "Mark Visible Pass": 0.004167, + "Output Transform Pass": 0.109625, + "Post-Processing Pass": 0.137542, + "Pre Composite Pass": 0.052542, + "Shadow Cascade 0": 0.041333, + "Shadow Cascade 1": 0.069208, + "Transparency Pass": 0.020458 + }, + "meanFrameMs": 8.337625573234012, + "meanGPUExecutionMs": 1.7444122379142541, + "minGPUExecutionMs": 1.1410833103582263, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.0005555555555555556, + "p50FrameMs": 7.939125411212444, + "p95FrameMs": 9.882958605885506, + "p99FrameMs": 10.568708181381226, + "peakGPUAllocatedBytes": 486244352, + "timingMeanMs": { + "animationMs": 0.0006732389865280598, + "batchingTickMs": 0.0008415963060946928, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.3064088379496488, + "customSystemsMs": 0.00012618211460196308, + "encodeMs": 6.3185848223358025, + "extensionsUpdateMs": 0.0005421625205279748, + "gameUpdateMs": 0.009831282262267416, + "geometryStreamingMs": 0.08381067341866179, + "lodMs": 0.001257362369743803, + "physicsMs": 0.0003589665797441131, + "renderPrepMs": 0.30966892793422496, + "renderTotalMs": 7.690429835218108, + "scenegraphMs": 0.17636950204348903, + "scriptingMs": 0.0002504671742552079, + "semaphoreWaitMs": 7.613308121443994e-05, + "streamingRegionMs": 0.0008686811068198747, + "submitMs": 0.007038572056075377, + "updateMs": 0.19522061420685843 + }, + "worstFrameMs": 14.32604156434536, + "worstThermalState": 1 + }, + "title": "1024 primitives, 4 point lights", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/Mac16_5/macOS/stadium.json b/perf/baselines/Mac16_5/macOS/stadium.json new file mode 100644 index 000000000..4056e40df --- /dev/null +++ b/perf/baselines/Mac16_5/macOS/stadium.json @@ -0,0 +1,440 @@ +{ + "device": { + "gpuName": "Apple M4 Max", + "isLowPowerMode": false, + "model": "Mac16,5", + "osVersion": "Version 27.0 (Build 26A428)", + "physicalMemoryBytes": 137438953472, + "processorCount": 16, + "thermalStateAtStart": 0 + }, + "label": "baseline 88737f37+120hz", + "render": { + "antiAliasing": "fxaa", + "displayRefreshHz": 120, + "foveation": false, + "immersion": "none", + "layout": "single", + "platform": "macOS", + "renderQuality": 1, + "viewCount": 1, + "viewportHeight": 1080, + "viewportWidth": 1920, + "vsync": true + }, + "runIDs": [ + "20261003-195536-macos-r1", + "20261003-195808-macos-r2", + "20261003-200040-macos-r3" + ], + "scene": { + "file": "stadium.jsonl", + "id": "stadium", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": 0, + "deadlineSampleCount": 0, + "inputSlackMs": 0, + "missedDeadline": false, + "missedDeadlineCount": 0, + "missingAnchorCount": 0, + "presentationMarginMs": 0, + "submissionMs": 0, + "updateMs": 0, + "viewCount": 0, + "viewTextureHeight": 0, + "viewTextureWidth": 0 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 16, + "frustumTested": 16, + "hzbIsValid": true, + "hzbMipCount": 11, + "occlusionFailed": 0, + "occlusionPassed": 16, + "occlusionTested": 16, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 1920, + 1080 + ], + "usedHZB": true + }, + "frameIndex": 1788, + "gpuPasses": { + "frameIndex": 1788, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.010291 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.012291 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.071542 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.144958 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.02675 + }, + { + "count": 1, + "label": "Grid Pass", + "ms": 0.306458 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.159833 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.089625 + }, + { + "count": 1, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 1.173125 + }, + { + "count": 1, + "label": "Copy Opaque Depth for HZB", + "ms": 0.074 + }, + { + "count": 1, + "label": "Transparency Pass", + "ms": 0.084 + }, + { + "count": 1, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.071 + }, + { + "count": 1, + "label": "Gaussian Pass", + "ms": 0.258875 + }, + { + "count": 1, + "label": "Pre Composite Pass", + "ms": 0.29075 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 0.384459 + }, + { + "count": 1, + "label": "Output Transform Pass", + "ms": 0.291833 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.478792 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.069708 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.031791 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.025042 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.020917 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.01025 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.011167 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.19175 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.010125 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.010083 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.014458 + } + ], + "passesSkipped": 0, + "totalMs": 4.323873000000001 + }, + "hitches": { + "frameBudgetMs": 12.5, + "framesLastSecond": 121, + "framesOverBudget": 10, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1789, + "histogram": [ + 0, + 96, + 1675, + 17, + 1, + 0, + 0 + ], + "worstFrameMsLastSecond": 9.337208233773708 + }, + "memory": { + "availableMemoryBytes": 0, + "geometryBudgetBytes": 27707694120, + "gpuAllocatedBytes": 649609216, + "isUnderPressure": false, + "meshMemoryBytes": 322788, + "textureBudgetBytes": 18471796080, + "textureMemoryBytes": 378368, + "thermalState": 0, + "trackedEntityCount": 9, + "utilizationPercent": 1.5183276445895899e-05 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 8, + "drawCallsShadow": 8, + "drawCallsTotal": 23, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 4997, + "visibleInstances": 16 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 9, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 844281.0595357502, + "timing": { + "animationMs": 0.0009166542440652847, + "batchingRebuildMs": 0, + "batchingTickMs": 0.005666748620569706, + "cullingMs": 0.038041616789996624, + "customSystemsMs": 0, + "encodeMs": 0.351916765794158, + "extensionsUpdateMs": 0.000500003807246685, + "frameTotalMs": 8.311250247061253, + "gameUpdateMs": 0.008208444342017174, + "geometryStreamingMs": 0.0009166542440652847, + "gpuExecutionMs": 2.7520000003278255, + "gpuFrameCadenceMs": 8.237000089138746, + "lodMs": 0.0014583347365260124, + "physicsMs": 0, + "physicsStepCount": 0, + "renderPrepMs": 0.04100007936358452, + "renderTotalMs": 8.400749997235835, + "scenegraphMs": 0.015041674487292767, + "scriptingMs": 0.00016659032553434372, + "semaphoreWaitMs": 0, + "smoothedFrameMs": 8.329130616039038, + "streamingRegionMs": 0.0007083872333168983, + "submitMs": 0.007999944500625134, + "updateMs": 0.036375015042722225 + } + }, + "measureSeconds": 15, + "notes": "stadium.untold and grass.untold from the render-test resources.", + "runs": 3, + "summary": { + "deadlineSamples": 0, + "durationSeconds": 14.987728500040248, + "frameBudgetMs": 12.5, + "frames": 5386, + "framesOverBudget": 16, + "gpuPassMeanMs": { + "Block Scan Pass": 0.019228991662034418, + "Compact and Stream Pass": 0.012779119510839354, + "Copy Opaque Depth for Gaussian Occlusion": 0.032403011117287356, + "Copy Opaque Depth for HZB": 0.04107674485825462, + "G-Buffer + Light Pass (TBDR)": 0.5617387384529767, + "Gaussian Pass": 0.16160004391328503, + "Grid Pass": 0.07212819810795754, + "HZB Build Mip 0": 0.11587203835464151, + "HZB Build Mip 1": 0.0813373188647747, + "HZB Build Mip 10": 0.014886748881431799, + "HZB Build Mip 2": 0.02622177987770991, + "HZB Build Mip 3": 0.024026843400447445, + "HZB Build Mip 4": 0.022640781302170305, + "HZB Build Mip 5": 0.020364775727069375, + "HZB Build Mip 6": 0.02049250528658881, + "HZB Build Mip 7": 0.015425479688369518, + "HZB Build Mip 8": 0.014543933222036736, + "HZB Build Mip 9": 0.013502186978297191, + "HZB Occlusion Culling pass": 0.028939623679822134, + "Local Scan Pass": 0.021952868113522574, + "Mark Visible Pass": 0.013879614653243852, + "Output Transform Pass": 0.13940085754034504, + "Post-Processing Pass": 0.19658906566499748, + "Pre Composite Pass": 0.11626767648693709, + "Shadow Cascade 0": 0.10606010394663712, + "Shadow Cascade 1": 0.09222167505592856, + "Transparency Pass": 0.06527798554752631 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.005916, + "Compact and Stream Pass": 0.00325, + "Copy Opaque Depth for Gaussian Occlusion": 0.022292, + "Copy Opaque Depth for HZB": 0.025167, + "G-Buffer + Light Pass (TBDR)": 0.455708, + "Gaussian Pass": 0.089042, + "Grid Pass": 0.053209, + "HZB Build Mip 0": 0.05525, + "HZB Build Mip 1": 0.022125, + "HZB Build Mip 10": 0.005166, + "HZB Build Mip 2": 0.008042, + "HZB Build Mip 3": 0.007042, + "HZB Build Mip 4": 0.00625, + "HZB Build Mip 5": 0.005542, + "HZB Build Mip 6": 0.00475, + "HZB Build Mip 7": 0.004875, + "HZB Build Mip 8": 0.004292, + "HZB Build Mip 9": 0.004375, + "HZB Occlusion Culling pass": 0.01025, + "Local Scan Pass": 0.004292, + "Mark Visible Pass": 0.003792, + "Output Transform Pass": 0.077292, + "Post-Processing Pass": 0.131458, + "Pre Composite Pass": 0.064708, + "Shadow Cascade 0": 0.019209, + "Shadow Cascade 1": 0.030833, + "Transparency Pass": 0.031084 + }, + "meanFrameMs": 8.333272981043491, + "meanGPUExecutionMs": 1.4801193189998476, + "minGPUExecutionMs": 1.1152916122227907, + "missedDeadlineRate": 0.0, + "missedDeadlines": 0, + "overBudgetRate": 0.0, + "p50FrameMs": 8.332042023539543, + "p95FrameMs": 8.808458223938942, + "p99FrameMs": 9.345541708171368, + "peakGPUAllocatedBytes": 649609216, + "timingMeanMs": { + "animationMs": 0.0008147364844032706, + "batchingTickMs": 0.0008376465921700166, + "compositorDeadlineMarginMs": 0, + "compositorInputSlackMs": 0, + "compositorSubmissionMs": 0, + "compositorUpdateMs": 0, + "cullingMs": 0.03751553562674495, + "customSystemsMs": 0.00010619590936864027, + "encodeMs": 0.2550494674101679, + "extensionsUpdateMs": 0.0004566858409093895, + "gameUpdateMs": 0.009244901030161146, + "geometryStreamingMs": 0.006028660080713335, + "lodMs": 0.001345088088166634, + "physicsMs": 0.00037187186956538315, + "renderPrepMs": 0.04072039139093743, + "renderTotalMs": 0.9207651158794761, + "scenegraphMs": 0.01515303173506167, + "scriptingMs": 0.0002624515732195753, + "semaphoreWaitMs": 9.726613509154147e-05, + "streamingRegionMs": 0.000850423967334119, + "submitMs": 0.005688510123074354, + "updateMs": 0.03357507755153781 + }, + "worstFrameMs": 9.437541477382183, + "worstThermalState": 1 + }, + "title": "Stadium and grass (textured assets)", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/README.md b/perf/baselines/README.md new file mode 100644 index 000000000..af93877ea --- /dev/null +++ b/perf/baselines/README.md @@ -0,0 +1,10 @@ +# Performance baselines + +One folder per device model (as `hw.model` on macOS or `hw.machine` elsewhere, e.g. `Mac16,6`, +`RealityDevice17,1`, `iPhone18,1`), one folder per platform inside it, one JSON per benchmark scene. +Each file is the scene result exactly as `Examples/PerfBench` wrote it plus the render +configuration it was measured with, so `scripts/perf/compare_baseline.py` refuses to compare runs +that differ in platform, texture layout, foveation, viewport or view count. + +Write a baseline with `scripts/perf/run_bench.sh --update-baseline` from a known-good +commit, in release, on a cool device, and record the commit in the `label` field. diff --git a/perf/baselines/RealityDevice17_1/visionOS/animation-16.json b/perf/baselines/RealityDevice17_1/visionOS/animation-16.json new file mode 100644 index 000000000..d28372db0 --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/animation-16.json @@ -0,0 +1,442 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "animation-16.jsonl", + "id": "animation-16", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -2.833958332757902, + "deadlineSampleCount": 1382, + "inputSlackMs": 6.817667000177607, + "missedDeadline": true, + "missedDeadlineCount": 1280, + "missingAnchorCount": 0, + "presentationMarginMs": 12.632333000510698, + "submissionMs": 3.73687500041342, + "updateMs": 0.3573750000214204, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 0, + "frustumPassed": 16, + "frustumTested": 16, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 2, + "occlusionPassed": 14, + "occlusionTested": 16, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 1381, + "gpuPasses": { + "frameIndex": 1382, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.005292 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.008584 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.006291 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.00525 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.020209 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 0.870416 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.089917 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.084583 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 4.381167 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.310875 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 4.942875 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 0.230375 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 0.250876 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.8577919999999999 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 1.992334 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 2.439416 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.8035 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.23 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.113167 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.011958 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.007875 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.006417 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.006667 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.006125 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.00625 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.005417 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.0065 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 1.333708 + } + ], + "passesSkipped": 0, + "totalMs": 20.033836000000004 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 91, + "framesOverBudget": 0, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1382, + "histogram": [ + 0, + 0, + 1120, + 262, + 0, + 0, + 0 + ], + "worstFrameMsLastSecond": 11.89329195767641 + }, + "memory": { + "availableMemoryBytes": 4201002776, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 1219428352, + "isUnderPressure": false, + "meshMemoryBytes": 1326600, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 786432, + "thermalState": 0, + "trackedEntityCount": 17, + "utilizationPercent": 0.0010666975285857916 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 28, + "drawCallsShadow": 32, + "drawCallsTotal": 72, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 28602, + "visibleInstances": 14 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 17, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3216.991029333334, + "timing": { + "animationMs": 0.277416666449426, + "batchingRebuildMs": 0, + "batchingTickMs": 0.000749999799154466, + "cullingMs": 0.21691666688639089, + "customSystemsMs": 0.00016666672308929265, + "encodeMs": 2.9987083335072384, + "extensionsUpdateMs": 0.00029166676540626213, + "frameTotalMs": 11.270499788224697, + "gameUpdateMs": 0.008208333383663557, + "geometryStreamingMs": 0.0006249997568374965, + "gpuExecutionMs": 9.911124999689491, + "gpuFrameCadenceMs": 11.38370833314184, + "lodMs": 0.001541666733828606, + "physicsMs": 0.0008750002962187864, + "physicsStepCount": 1, + "renderPrepMs": 0.2221666668447142, + "renderTotalMs": 3.6008750003020396, + "scenegraphMs": 4.166668077232316e-05, + "scriptingMs": 0.0002083334038616158, + "semaphoreWaitMs": 0.00020833294911426492, + "smoothedFrameMs": 11.137198656797409, + "streamingRegionMs": 0.0006250002115848474, + "submitMs": 0.013875000149710104, + "updateMs": 0.2949999998236308 + } + }, + "measureSeconds": 15, + "notes": "redplayer.untold with the running clip; measures the animation system and GPU skinning.", + "runs": 1, + "summary": { + "deadlineSamples": 1382, + "durationSeconds": 14.981311125000047, + "frameBudgetMs": 16.666666666666668, + "frames": 1382, + "framesOverBudget": 0, + "gpuPassMeanMs": { + "Block Scan Pass": 0.017164214905933464, + "Compact and Stream Pass": 0.005944423299565833, + "Copy Opaque Depth for Gaussian Occlusion": 0.2896682879884227, + "Copy Opaque Depth for HZB": 0.22344759913169335, + "Environment Pass": 0.9002116302460202, + "G-Buffer + Light Pass (TBDR)": 4.284270515918964, + "Gaussian Pass": 4.807984657742393, + "HZB Build Mip 0": 0.8283389934876988, + "HZB Build Mip 1": 0.39028920767004327, + "HZB Build Mip 10": 0.01038294283646891, + "HZB Build Mip 11": 0.010585959479015986, + "HZB Build Mip 2": 0.10569839073806091, + "HZB Build Mip 3": 0.045369081765557165, + "HZB Build Mip 4": 0.018454094066570212, + "HZB Build Mip 5": 0.014787602749638244, + "HZB Build Mip 6": 0.018440398697539814, + "HZB Build Mip 7": 0.011034144717800341, + "HZB Build Mip 8": 0.010573271345875598, + "HZB Build Mip 9": 0.011269972503617973, + "HZB Occlusion Culling pass": 0.02961548769898696, + "Local Scan Pass": 0.017673649059334273, + "Mark Visible Pass": 0.01916184081041967, + "Output Transform Pass": 1.3425493863965257, + "Post-Processing Pass": 0.7685868878437047, + "Pre Composite Pass": 1.693612640376265, + "Shadow Cascade 0": 0.27436969898697533, + "Shadow Cascade 1": 0.07417337192474661, + "Transparency Pass": 0.24281674312590418 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.003083, + "Compact and Stream Pass": 0.00175, + "Copy Opaque Depth for Gaussian Occlusion": 0.21529199999999998, + "Copy Opaque Depth for HZB": 0.164458, + "Environment Pass": 0.533959, + "G-Buffer + Light Pass (TBDR)": 3.249126, + "Gaussian Pass": 3.6644170000000003, + "HZB Build Mip 0": 0.29425, + "HZB Build Mip 1": 0.081166, + "HZB Build Mip 10": 0.003125, + "HZB Build Mip 11": 0.002709, + "HZB Build Mip 2": 0.018583, + "HZB Build Mip 3": 0.00725, + "HZB Build Mip 4": 0.003792, + "HZB Build Mip 5": 0.003334, + "HZB Build Mip 6": 0.0035, + "HZB Build Mip 7": 0.003, + "HZB Build Mip 8": 0.002875, + "HZB Build Mip 9": 0.003125, + "HZB Occlusion Culling pass": 0.007208, + "Local Scan Pass": 0.003209, + "Mark Visible Pass": 0.002083, + "Output Transform Pass": 0.811917, + "Post-Processing Pass": 0.447459, + "Pre Composite Pass": 1.084208, + "Shadow Cascade 0": 0.06275, + "Shadow Cascade 1": 0.049792, + "Transparency Pass": 0.17820799999999998 + }, + "meanFrameMs": 10.85632275277816, + "meanGPUExecutionMs": 8.639747949832195, + "minGPUExecutionMs": 6.31533333353218, + "missedDeadlines": 1280, + "p50FrameMs": 11.059791781008244, + "p95FrameMs": 11.42862532287836, + "p99FrameMs": 11.76329143345356, + "peakGPUAllocatedBytes": 1219428352, + "timingMeanMs": { + "animationMs": 0.2657349855257074, + "batchingTickMs": 0.00069154003150866, + "compositorDeadlineMarginMs": -1.340198615510018, + "compositorInputSlackMs": 7.1115707040599325, + "compositorSubmissionMs": 3.298293807287805, + "compositorUpdateMs": 0.35871050410267413, + "cullingMs": 0.18095218282501652, + "customSystemsMs": 8.728292619489156e-05, + "encodeMs": 2.6229902315516154, + "extensionsUpdateMs": 0.0003238965222429356, + "gameUpdateMs": 0.007762029668395091, + "geometryStreamingMs": 0.009260522186270909, + "lodMs": 0.0012786119162409035, + "physicsMs": 0.0005318982135779939, + "renderPrepMs": 0.18580963579715398, + "renderTotalMs": 3.176967528942377, + "scenegraphMs": 2.0260493634619067e-05, + "scriptingMs": 0.00013663771967678624, + "semaphoreWaitMs": 0.00020094670111833372, + "streamingRegionMs": 0.0006878316461632747, + "submitMs": 0.013461257837007681, + "updateMs": 0.2827730945455516 + }, + "worstFrameMs": 12.327499687671661, + "worstThermalState": 0 + }, + "title": "16 skinned characters running", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/RealityDevice17_1/visionOS/gaussian.json b/perf/baselines/RealityDevice17_1/visionOS/gaussian.json new file mode 100644 index 000000000..e213fdf6b --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/gaussian.json @@ -0,0 +1,463 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "gaussian.jsonl", + "id": "gaussian", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -6.736625665780593, + "deadlineSampleCount": 1384, + "inputSlackMs": 1.788499333542859, + "missedDeadline": true, + "missedDeadlineCount": 1384, + "missingAnchorCount": 0, + "presentationMarginMs": 8.596167000632704, + "submissionMs": 7.8452916663991346, + "updateMs": 0.2030833334174531, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 61, + "frustumPassed": 195, + "frustumTested": 256, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 35, + "occlusionPassed": 160, + "occlusionTested": 195, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 1383, + "gpuPasses": { + "frameIndex": 1384, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.005125 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.009292 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.00875 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.005041 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.033209 + }, + { + "count": 1, + "label": "Gaussian Frustum Culling", + "ms": 0.054416 + }, + { + "count": 1, + "label": "Gaussian Preprocess", + "ms": 0.018875 + }, + { + "count": 1, + "label": "Radix Sort", + "ms": 0.1835 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 1.013166 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.271209 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.119709 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 3.0368329999999997 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.308709 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 4.041083 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 0.29274999999999995 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 0.313916 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.611083 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 1.7698340000000001 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 1.3630840000000002 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.569041 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.328083 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.033625 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.01375 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.008167 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.008333 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.0805 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.004541 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.00725 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.0055 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.005333 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 0.005042 + } + ], + "passesSkipped": 0, + "totalMs": 15.528749000000003 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 101, + "framesOverBudget": 0, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1384, + "histogram": [ + 0, + 0, + 1144, + 240, + 0, + 0, + 0 + ], + "worstFrameMsLastSecond": 10.492250323295593 + }, + "memory": { + "availableMemoryBytes": 4160485144, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 1218838528, + "isUnderPressure": false, + "meshMemoryBytes": 3306392, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 258, + "utilizationPercent": 0.0016691276105120778 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 318, + "drawCallsShadow": 487, + "drawCallsTotal": 819, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 165062, + "visibleInstances": 160 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 257, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": true, + "updateWorkMs": 0.15795230865478516, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3254.540743333334, + "timing": { + "animationMs": 0.0003333329914312344, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0004166668077232316, + "cullingMs": 0.1537499997539271, + "customSystemsMs": 0.00029166676540626213, + "encodeMs": 7.274541666902223, + "extensionsUpdateMs": 0.00016666672308929265, + "frameTotalMs": 10.301792062819004, + "gameUpdateMs": 0.003000000106112566, + "geometryStreamingMs": 0.15875000008236384, + "gpuExecutionMs": 8.68554166663671, + "gpuFrameCadenceMs": 10.233916666493315, + "lodMs": 0.0005833330760651734, + "physicsMs": 0.0008333336154464632, + "physicsStepCount": 1, + "renderPrepMs": 0.29374999985520844, + "renderTotalMs": 7.739958333331742, + "scenegraphMs": 4.166668077232316e-05, + "scriptingMs": 8.333336154464632e-05, + "semaphoreWaitMs": 0, + "smoothedFrameMs": 9.997920785099268, + "streamingRegionMs": 0.0011249999261053745, + "submitMs": 0.008291666745208204, + "updateMs": 0.008458333013550146 + } + }, + "measureSeconds": 15, + "notes": "test_gaussians.ply (small) plus a 16x16 grid; exercises the sort pipeline, not splat count.", + "runs": 1, + "summary": { + "deadlineSamples": 1384, + "durationSeconds": 14.979403208333224, + "frameBudgetMs": 16.666666666666668, + "frames": 1384, + "framesOverBudget": 0, + "gpuPassMeanMs": { + "Block Scan Pass": 0.011961762283236945, + "Compact and Stream Pass": 0.006527545520231221, + "Copy Opaque Depth for Gaussian Occlusion": 0.3624899963872825, + "Copy Opaque Depth for HZB": 0.38835822615606946, + "Environment Pass": 2.3854589299132978, + "G-Buffer + Light Pass (TBDR)": 3.5958231885838114, + "Gaussian Frustum Culling": 0.05844728395953748, + "Gaussian Pass": 4.564357692919073, + "Gaussian Preprocess": 0.04501878323699417, + "HZB Build Mip 0": 0.6431993236994223, + "HZB Build Mip 1": 0.25817919797687844, + "HZB Build Mip 10": 0.00885002673410406, + "HZB Build Mip 11": 0.007857567919075164, + "HZB Build Mip 2": 0.07612587355491329, + "HZB Build Mip 3": 0.032062777456647436, + "HZB Build Mip 4": 0.016710989161849696, + "HZB Build Mip 5": 0.01336624566473988, + "HZB Build Mip 6": 0.01051318786127168, + "HZB Build Mip 7": 0.011143021676300567, + "HZB Build Mip 8": 0.00879717196531793, + "HZB Build Mip 9": 0.008912185693641633, + "HZB Occlusion Culling pass": 0.03814640679190751, + "Local Scan Pass": 0.011526166184971036, + "Mark Visible Pass": 0.015515843208092487, + "Output Transform Pass": 2.5153854920520216, + "Post-Processing Pass": 1.5623745354046252, + "Pre Composite Pass": 1.7415976719653186, + "Radix Sort": 0.22116919725433468, + "Shadow Cascade 0": 0.2810928923410407, + "Shadow Cascade 1": 0.14455482008670492, + "Transparency Pass": 0.4148671661849711 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.005791, + "Compact and Stream Pass": 0.003458, + "Copy Opaque Depth for Gaussian Occlusion": 0.298334, + "Copy Opaque Depth for HZB": 0.281042, + "Environment Pass": 0.950624, + "G-Buffer + Light Pass (TBDR)": 2.569125, + "Gaussian Frustum Culling": 0.030583, + "Gaussian Pass": 3.600334, + "Gaussian Preprocess": 0.012292, + "HZB Build Mip 0": 0.292583, + "HZB Build Mip 1": 0.072083, + "HZB Build Mip 10": 0.003917, + "HZB Build Mip 11": 0.003958, + "HZB Build Mip 2": 0.0205, + "HZB Build Mip 3": 0.008458, + "HZB Build Mip 4": 0.005208, + "HZB Build Mip 5": 0.004667, + "HZB Build Mip 6": 0.004541, + "HZB Build Mip 7": 0.004125, + "HZB Build Mip 8": 0.004083, + "HZB Build Mip 9": 0.004208, + "HZB Occlusion Culling pass": 0.023125, + "Local Scan Pass": 0.006, + "Mark Visible Pass": 0.003208, + "Output Transform Pass": 1.0965, + "Post-Processing Pass": 0.6399159999999999, + "Pre Composite Pass": 1.187375, + "Radix Sort": 0.162667, + "Shadow Cascade 0": 0.117542, + "Shadow Cascade 1": 0.117291, + "Transparency Pass": 0.30237400000000003 + }, + "meanFrameMs": 10.839754066193164, + "meanGPUExecutionMs": 9.670346098266757, + "minGPUExecutionMs": 8.224291666465433, + "missedDeadlines": 1384, + "p50FrameMs": 11.066083796322346, + "p95FrameMs": 11.42279151827097, + "p99FrameMs": 11.525999754667282, + "peakGPUAllocatedBytes": 1218838528, + "timingMeanMs": { + "animationMs": 0.000380178226277235, + "batchingTickMs": 0.00043337547463571653, + "compositorDeadlineMarginMs": -6.7243956517037695, + "compositorInputSlackMs": 2.9305160144494526, + "compositorSubmissionMs": 7.7148168954715794, + "compositorUpdateMs": 0.08528540462983683, + "cullingMs": 0.15845493135925423, + "customSystemsMs": 5.762283117712675e-05, + "encodeMs": 7.133637885358584, + "extensionsUpdateMs": 0.00020484103850773963, + "gameUpdateMs": 0.003939968686637978, + "geometryStreamingMs": 0.028001053699505705, + "lodMs": 0.0007987415749132218, + "physicsMs": 0.00027899205709959484, + "renderPrepMs": 0.29731123555555083, + "renderTotalMs": 7.602881593213611, + "scenegraphMs": 1.1741330168881882e-05, + "scriptingMs": 8.227962486035338e-05, + "semaphoreWaitMs": 0.00021507706951577168, + "streamingRegionMs": 0.00041323459256375046, + "submitMs": 0.008283146676057044, + "updateMs": 0.009626023605003845 + }, + "worstFrameMs": 11.5880835801363, + "worstThermalState": 0 + }, + "title": "Gaussian splat with primitives", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/RealityDevice17_1/visionOS/lights-64.json b/perf/baselines/RealityDevice17_1/visionOS/lights-64.json new file mode 100644 index 000000000..92b1f9297 --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/lights-64.json @@ -0,0 +1,456 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "lights-64.jsonl", + "id": "lights-64", + "lastSnapshot": { + "batching": { + "batchGroupCount": 160, + "batchedMeshCount": 998, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -15.230249666728923, + "deadlineSampleCount": 1008, + "inputSlackMs": 2.7427083332440816, + "missedDeadline": true, + "missedDeadlineCount": 1008, + "missingAnchorCount": 0, + "presentationMarginMs": 0.23600000031365198, + "submissionMs": 7.797291666520323, + "updateMs": 0.04279166660126066, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 390, + "frustumPassed": 634, + "frustumTested": 1024, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 0, + "occlusionPassed": 634, + "occlusionTested": 634, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 1008, + "gpuPasses": { + "frameIndex": 1008, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.003083 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.005542 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.003709 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.002833 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.47825 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 1.859417 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.432583 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.268209 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 0", + "ms": 1.047167 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 1", + "ms": 0.078333 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 8.159957 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 1.086667 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 1.1079590000000001 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.221541 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 1.7159589999999998 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.142417 + }, + { + "count": 4, + "label": "Post-Processing Pass", + "ms": 1.687875 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 1.431791 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.218458 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.068875 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.020375 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.008042 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.004583 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.003459 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.003209 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.002833 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.002666 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.002708 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.002916 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 0.002875 + } + ], + "passesSkipped": 0, + "totalMs": 21.074290999999995 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 69, + "framesOverBudget": 368, + "framesOverBudgetLastSecond": 23, + "framesSampled": 1009, + "histogram": [ + 0, + 0, + 527, + 114, + 368, + 0, + 0 + ], + "worstFrameMsLastSecond": 24.13112483918667 + }, + "memory": { + "availableMemoryBytes": 4431935472, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 950435840, + "isUnderPressure": false, + "meshMemoryBytes": 12216512, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 1089, + "utilizationPercent": 0.006167120300233364 + }, + "render": { + "drawCallsBatched": 560, + "drawCallsOpaque": 292, + "drawCallsShadow": 320, + "drawCallsTotal": 626, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 676702, + "visibleInstances": 634 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 1089, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3179.435419125001, + "timing": { + "animationMs": 0.00029166676540626213, + "batchingRebuildMs": 0, + "batchingTickMs": 0.00037499967220355757, + "cullingMs": 0.42887500012511737, + "customSystemsMs": 8.333336154464632e-05, + "encodeMs": 7.080291666625271, + "extensionsUpdateMs": 0.00016666672308929265, + "frameTotalMs": 22.563042119145393, + "gameUpdateMs": 0.0034583331398607697, + "geometryStreamingMs": 0.00029166631065891124, + "gpuExecutionMs": 16.381249999994907, + "gpuFrameCadenceMs": 14.512083333556802, + "lodMs": 0.0006250002115848474, + "physicsMs": 0.0003333334461785853, + "physicsStepCount": 1, + "renderPrepMs": 0.4311249999773281, + "renderTotalMs": 7.699708333348099, + "scenegraphMs": 0, + "scriptingMs": 8.333336154464632e-05, + "semaphoreWaitMs": 0.00016666672308929265, + "smoothedFrameMs": 14.815133282293877, + "streamingRegionMs": 0.00025000008463393897, + "submitMs": 0.013750000107393134, + "updateMs": 0.011125000128231477 + } + }, + "measureSeconds": 15, + "notes": "Cubes and spheres merged by the static batcher; measures batched draw cost and light-loop cost.", + "runs": 1, + "summary": { + "deadlineSamples": 1008, + "durationSeconds": 14.987337458333513, + "frameBudgetMs": 16.666666666666668, + "frames": 1009, + "framesOverBudget": 368, + "gpuPassMeanMs": { + "Batched Shadow Cascade 0": 0.2532643700396827, + "Batched Shadow Cascade 1": 0.11571489682539686, + "Block Scan Pass": 0.007390117063492058, + "Compact and Stream Pass": 0.0051341081349206355, + "Copy Opaque Depth for Gaussian Occlusion": 0.33655539583333316, + "Copy Opaque Depth for HZB": 0.40744785218253987, + "Environment Pass": 2.0645481240079353, + "G-Buffer + Light Pass (TBDR)": 8.582363366071426, + "Gaussian Pass": 1.1439629246031748, + "HZB Build Mip 0": 0.30076717857142865, + "HZB Build Mip 1": 0.11410662698412691, + "HZB Build Mip 10": 0.0067944474206349265, + "HZB Build Mip 11": 0.006298321428571432, + "HZB Build Mip 2": 0.032749855158730225, + "HZB Build Mip 3": 0.023960984126984147, + "HZB Build Mip 4": 0.011490265873015921, + "HZB Build Mip 5": 0.012497401785714315, + "HZB Build Mip 6": 0.012387390873015913, + "HZB Build Mip 7": 0.008110161706349237, + "HZB Build Mip 8": 0.007422881944444459, + "HZB Build Mip 9": 0.007170648809523823, + "HZB Occlusion Culling pass": 0.12865727976190466, + "Local Scan Pass": 0.019534911706349203, + "Mark Visible Pass": 0.006209677579365068, + "Output Transform Pass": 1.455160399801587, + "Post-Processing Pass": 1.509264252976193, + "Pre Composite Pass": 1.1407327579365074, + "Shadow Cascade 0": 0.6395881319444449, + "Shadow Cascade 1": 0.04183736210317452, + "Transparency Pass": 0.422881324404762 + }, + "gpuPassMinMs": { + "Batched Shadow Cascade 0": 0.134666, + "Batched Shadow Cascade 1": 0.064958, + "Block Scan Pass": 0.003083, + "Compact and Stream Pass": 0.001916, + "Copy Opaque Depth for Gaussian Occlusion": 0.20175, + "Copy Opaque Depth for HZB": 0.193625, + "Environment Pass": 1.02, + "G-Buffer + Light Pass (TBDR)": 7.120583, + "Gaussian Pass": 0.740166, + "HZB Build Mip 0": 0.1245, + "HZB Build Mip 1": 0.056667, + "HZB Build Mip 10": 0.002292, + "HZB Build Mip 11": 0.002375, + "HZB Build Mip 2": 0.016791, + "HZB Build Mip 3": 0.005875, + "HZB Build Mip 4": 0.003166, + "HZB Build Mip 5": 0.002875, + "HZB Build Mip 6": 0.002667, + "HZB Build Mip 7": 0.002334, + "HZB Build Mip 8": 0.002375, + "HZB Build Mip 9": 0.002417, + "HZB Occlusion Culling pass": 0.021542, + "Local Scan Pass": 0.003333, + "Mark Visible Pass": 0.002166, + "Output Transform Pass": 1.340708, + "Post-Processing Pass": 1.339251, + "Pre Composite Pass": 0.9803740000000001, + "Shadow Cascade 0": 0.15425, + "Shadow Cascade 1": 0.007375, + "Transparency Pass": 0.206666 + }, + "meanFrameMs": 14.886921370444437, + "meanGPUExecutionMs": 15.176517733127838, + "minGPUExecutionMs": 11.818458333436865, + "missedDeadlines": 1008, + "p50FrameMs": 11.178291402757168, + "p95FrameMs": 23.426249623298645, + "p99FrameMs": 24.04904179275036, + "peakGPUAllocatedBytes": 950697984, + "timingMeanMs": { + "animationMs": 0.00031260323789455524, + "batchingTickMs": 0.00040853155158264925, + "compositorDeadlineMarginMs": -16.69341216863311, + "compositorInputSlackMs": 2.590362763797133, + "compositorSubmissionMs": 11.865231830203268, + "compositorUpdateMs": 0.209173769413372, + "cullingMs": 0.42086083581184014, + "customSystemsMs": 6.318136924952527e-05, + "encodeMs": 7.197634002321812, + "extensionsUpdateMs": 0.00018372151004847386, + "gameUpdateMs": 0.004249339268945607, + "geometryStreamingMs": 0.10710922529877034, + "lodMs": 0.0007553270692742724, + "physicsMs": 0.00033448959647444665, + "renderPrepMs": 0.42333576973533354, + "renderTotalMs": 7.812540758176239, + "scenegraphMs": 1.2925337135799393e-05, + "scriptingMs": 6.982986340425586e-05, + "semaphoreWaitMs": 0.0001228939554521032, + "streamingRegionMs": 0.00039461512932691996, + "submitMs": 0.013954781973654455, + "updateMs": 0.009802362078278169 + }, + "worstFrameMs": 24.261249229311943, + "worstThermalState": 0 + }, + "title": "1024 primitives, static batching, 64 point lights", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/RealityDevice17_1/visionOS/postfx.json b/perf/baselines/RealityDevice17_1/visionOS/postfx.json new file mode 100644 index 000000000..9b5a38de6 --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/postfx.json @@ -0,0 +1,470 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "postfx.jsonl", + "id": "postfx", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -38.794958333255636, + "deadlineSampleCount": 480, + "inputSlackMs": 1.2464579999686975, + "missedDeadline": true, + "missedDeadlineCount": 480, + "missingAnchorCount": 0, + "presentationMarginMs": -23.46220833260304, + "submissionMs": 28.190375000122003, + "updateMs": 0.08170833325493732, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 386, + "frustumPassed": 638, + "frustumTested": 1024, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 0, + "occlusionPassed": 638, + "occlusionTested": 638, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 479, + "gpuPasses": { + "frameIndex": 480, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.005583 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.008042 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.007583 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.005167 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.035958 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 0.843041 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.211625 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.137708 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 2.427958 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 1.947625 + }, + { + "count": 2, + "label": "SSAO Low-Res Pass", + "ms": 0.362625 + }, + { + "count": 2, + "label": "SSAO Bilateral Blur Horizontal", + "ms": 0.194542 + }, + { + "count": 2, + "label": "SSAO Bilateral Blur Vertical", + "ms": 0.25312500000000004 + }, + { + "count": 2, + "label": "SSAO Upsample Pass", + "ms": 2.897625 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 0.013416 + }, + { + "count": 34, + "label": "Post-Processing Pass", + "ms": 11.681208999999999 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.218499 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 0.7410829999999999 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.022334 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 0.957416 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.300084 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.299 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.1015 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.01925 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.013666 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.01125 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.010916 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.010542 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.009708 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.010625 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.011542 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 0.010958 + } + ], + "passesSkipped": 0, + "totalMs": 24.781205000000014 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 30, + "framesOverBudget": 480, + "framesOverBudgetLastSecond": 30, + "framesSampled": 480, + "histogram": [ + 0, + 0, + 0, + 0, + 415, + 65, + 0 + ], + "worstFrameMsLastSecond": 42.714498937129974 + }, + "memory": { + "availableMemoryBytes": 4195186456, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 1219690496, + "isUnderPressure": false, + "meshMemoryBytes": 12096992, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 1029, + "utilizationPercent": 0.0061067845672369 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 1276, + "drawCallsShadow": 1406, + "drawCallsTotal": 2734, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 535982, + "visibleInstances": 638 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 1029, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3198.2040140416675, + "timing": { + "animationMs": 0.0003333334461785853, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0005000001692678779, + "cullingMs": 0.4344166663940996, + "customSystemsMs": 0.00029166676540626213, + "encodeMs": 27.494250000017928, + "extensionsUpdateMs": 0.0004583330337482039, + "frameTotalMs": 30.27733415365219, + "gameUpdateMs": 0.006250000296859071, + "geometryStreamingMs": 0.000749999799154466, + "gpuExecutionMs": 20.031125000059546, + "gpuFrameCadenceMs": 30.650583333226677, + "lodMs": 0.0008749998414714355, + "physicsMs": 0.0006666664376098197, + "physicsStepCount": 2, + "renderPrepMs": 0.43741666650021216, + "renderTotalMs": 28.151375000106782, + "scenegraphMs": 0, + "scriptingMs": 0.00012500004231696948, + "semaphoreWaitMs": 0.00029166676540626213, + "smoothedFrameMs": 30.30298613011837, + "streamingRegionMs": 0.0005000001692678779, + "submitMs": 0.022250000256462954, + "updateMs": 0.01316666657658061 + } + }, + "measureSeconds": 15, + "notes": "Same geometry as primitives-1k; the difference is the full-screen pass chain.", + "runs": 1, + "summary": { + "deadlineSamples": 480, + "durationSeconds": 14.949997541667017, + "frameBudgetMs": 16.666666666666668, + "frames": 480, + "framesOverBudget": 480, + "gpuPassMeanMs": { + "Block Scan Pass": 0.00886802916666666, + "Compact and Stream Pass": 0.00561138333333333, + "Copy Opaque Depth for Gaussian Occlusion": 0.23134908333333334, + "Copy Opaque Depth for HZB": 1.0492304541666664, + "Environment Pass": 0.873899308333333, + "G-Buffer + Light Pass (TBDR)": 2.7901557041666654, + "Gaussian Pass": 0.7766141416666662, + "HZB Build Mip 0": 1.0227512312500004, + "HZB Build Mip 1": 0.3360696083333331, + "HZB Build Mip 10": 0.017559981250000006, + "HZB Build Mip 11": 0.01537577916666669, + "HZB Build Mip 2": 0.09242353958333328, + "HZB Build Mip 3": 0.031174102083333356, + "HZB Build Mip 4": 0.02747478749999999, + "HZB Build Mip 5": 0.018948395833333305, + "HZB Build Mip 6": 0.016273160416666686, + "HZB Build Mip 7": 0.019034716666666625, + "HZB Build Mip 8": 0.011546800000000003, + "HZB Build Mip 9": 0.012437656250000003, + "HZB Occlusion Culling pass": 0.04354598125, + "Local Scan Pass": 0.016924583333333333, + "Mark Visible Pass": 0.012942591666666654, + "Output Transform Pass": 1.0484280041666678, + "Post-Processing Pass": 13.109209222916661, + "Pre Composite Pass": 1.1284165895833334, + "SSAO Bilateral Blur Horizontal": 0.28158334999999995, + "SSAO Bilateral Blur Vertical": 0.27868895416666695, + "SSAO Low-Res Pass": 0.46180608750000013, + "SSAO Upsample Pass": 0.5839662395833337, + "Shadow Cascade 0": 0.21624673125, + "Shadow Cascade 1": 0.17070012083333333, + "Transparency Pass": 0.031535918749999954 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.003125, + "Compact and Stream Pass": 0.001875, + "Copy Opaque Depth for Gaussian Occlusion": 0.207959, + "Copy Opaque Depth for HZB": 0.45845800000000003, + "Environment Pass": 0.763833, + "G-Buffer + Light Pass (TBDR)": 2.270375, + "Gaussian Pass": 0.7132499999999999, + "HZB Build Mip 0": 0.25975, + "HZB Build Mip 1": 0.066625, + "HZB Build Mip 10": 0.002833, + "HZB Build Mip 11": 0.002875, + "HZB Build Mip 2": 0.015833, + "HZB Build Mip 3": 0.006209, + "HZB Build Mip 4": 0.003667, + "HZB Build Mip 5": 0.003417, + "HZB Build Mip 6": 0.00325, + "HZB Build Mip 7": 0.002916, + "HZB Build Mip 8": 0.002917, + "HZB Build Mip 9": 0.002916, + "HZB Occlusion Culling pass": 0.025625, + "Local Scan Pass": 0.003292, + "Mark Visible Pass": 0.002542, + "Output Transform Pass": 0.868958, + "Post-Processing Pass": 11.373708999999998, + "Pre Composite Pass": 0.9875419999999999, + "SSAO Bilateral Blur Horizontal": 0.185167, + "SSAO Bilateral Blur Vertical": 0.188, + "SSAO Low-Res Pass": 0.314375, + "SSAO Upsample Pass": 0.445875, + "Shadow Cascade 0": 0.107541, + "Shadow Cascade 1": 0.132375, + "Transparency Pass": 0.011249 + }, + "meanFrameMs": 31.28584201137225, + "meanGPUExecutionMs": 19.981782291662853, + "minGPUExecutionMs": 18.543749999935244, + "missedDeadlines": 480, + "p50FrameMs": 30.11266700923443, + "p95FrameMs": 39.66212645173073, + "p99FrameMs": 40.0385819375515, + "peakGPUAllocatedBytes": 1219690496, + "timingMeanMs": { + "animationMs": 0.0004596354150028977, + "batchingTickMs": 0.0007462673674278145, + "compositorDeadlineMarginMs": -38.396516233518696, + "compositorInputSlackMs": 2.592095411804962, + "compositorSubmissionMs": 28.11446336806303, + "compositorUpdateMs": 0.23982569445119375, + "cullingMs": 0.4409387152795337, + "customSystemsMs": 0.00026085067759140657, + "encodeMs": 27.374460243056358, + "extensionsUpdateMs": 0.0004658854085922333, + "gameUpdateMs": 0.00820182292879205, + "geometryStreamingMs": 0.10402178819693593, + "lodMs": 0.0011569444419971358, + "physicsMs": 0.0007064236162553547, + "renderPrepMs": 0.44416015625188265, + "renderTotalMs": 28.041801128490345, + "scenegraphMs": 5.57291703747372e-05, + "scriptingMs": 0.00022855902273022366, + "semaphoreWaitMs": 0.0003861110978202002, + "streamingRegionMs": 0.0007277777607820705, + "submitMs": 0.02447526041654176, + "updateMs": 0.017672916671547984 + }, + "worstFrameMs": 42.714498937129974, + "worstThermalState": 0 + }, + "title": "1024 primitives with SSAO, bloom, depth of field and SMAA", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/RealityDevice17_1/visionOS/primitives-10k.json b/perf/baselines/RealityDevice17_1/visionOS/primitives-10k.json new file mode 100644 index 000000000..acd4ab57f --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/primitives-10k.json @@ -0,0 +1,456 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "primitives-10k.jsonl", + "id": "primitives-10k", + "lastSnapshot": { + "batching": { + "batchGroupCount": 1394, + "batchedMeshCount": 9736, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -65.67608333261887, + "deadlineSampleCount": 262, + "inputSlackMs": 1.1027496666429215, + "missedDeadline": true, + "missedDeadlineCount": 262, + "missingAnchorCount": 0, + "presentationMarginMs": -50.20983299937143, + "submissionMs": 52.0494583333857, + "updateMs": 0.10137499975826358, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 4619, + "frustumPassed": 5381, + "frustumTested": 10000, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 0, + "occlusionPassed": 5381, + "occlusionTested": 5381, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 261, + "gpuPasses": { + "frameIndex": 262, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.011333 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.011084 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.019625 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.084458 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.082667 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 2.120042 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.382042 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.023542 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 0", + "ms": 1.81975 + }, + { + "count": 1, + "label": "Batched Shadow Cascade 1", + "ms": 3.424 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 12.207208 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 0.535667 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 0.5744579999999999 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.776875 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 2.151042 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.091209 + }, + { + "count": 4, + "label": "Post-Processing Pass", + "ms": 2.984293 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 2.6821669999999997 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.491208 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.119709 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.036708 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.016875 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.01125 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.009292 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.008875 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.007958 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.007375 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.008125 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.007333 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 0.007708 + } + ], + "passesSkipped": 0, + "totalMs": 31.71387799999999 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 18, + "framesOverBudget": 262, + "framesOverBudgetLastSecond": 18, + "framesSampled": 262, + "histogram": [ + 0, + 0, + 0, + 0, + 0, + 262, + 0 + ], + "worstFrameMsLastSecond": 67.50479340553284 + }, + "memory": { + "availableMemoryBytes": 4287215600, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 1040629760, + "isUnderPressure": false, + "meshMemoryBytes": 118162936, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 10005, + "utilizationPercent": 0.05965082719922066 + }, + "render": { + "drawCallsBatched": 4484, + "drawCallsOpaque": 2014, + "drawCallsShadow": 2788, + "drawCallsTotal": 4816, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 6110134, + "visibleInstances": 5381 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 10005, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3160.216490083334, + "timing": { + "animationMs": 0.0006666664376098197, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0006249997568374965, + "cullingMs": 3.5952083330812457, + "customSystemsMs": 0.00029166676540626213, + "encodeMs": 48.08974999968996, + "extensionsUpdateMs": 0.0005833335308125243, + "frameTotalMs": 57.1155846118927, + "gameUpdateMs": 0.010124999789695721, + "geometryStreamingMs": 0.0009166669769911095, + "gpuExecutionMs": 24.658333333263727, + "gpuFrameCadenceMs": 56.06120833317618, + "lodMs": 0.0012083332876500208, + "physicsMs": 0.0011666666068776976, + "physicsStepCount": 3, + "renderPrepMs": 3.598333333229675, + "renderTotalMs": 51.9683749998876, + "scenegraphMs": 4.166668077232316e-05, + "scriptingMs": 0.00012500004231696948, + "semaphoreWaitMs": 0.0010833332453330513, + "smoothedFrameMs": 57.40352335075537, + "streamingRegionMs": 0.0008749998414714355, + "submitMs": 0.08258333309640875, + "updateMs": 0.020666666841862025 + } + }, + "measureSeconds": 15, + "notes": "Cubes and spheres merged by the static batcher; measures batched draw cost and light-loop cost.", + "runs": 1, + "summary": { + "deadlineSamples": 262, + "durationSeconds": 14.942803291666678, + "frameBudgetMs": 16.666666666666668, + "frames": 262, + "framesOverBudget": 262, + "gpuPassMeanMs": { + "Batched Shadow Cascade 0": 1.962467236641222, + "Batched Shadow Cascade 1": 2.4294020000000014, + "Block Scan Pass": 0.04958334732824424, + "Compact and Stream Pass": 0.033677267175572505, + "Copy Opaque Depth for Gaussian Occlusion": 0.6801869961832059, + "Copy Opaque Depth for HZB": 0.5353362099236639, + "Environment Pass": 2.4222603206106865, + "G-Buffer + Light Pass (TBDR)": 11.660146629770999, + "Gaussian Pass": 2.0366230076335885, + "HZB Build Mip 0": 0.5209620000000003, + "HZB Build Mip 1": 0.14610338167938935, + "HZB Build Mip 10": 0.008687969465648851, + "HZB Build Mip 11": 0.00725304580152672, + "HZB Build Mip 2": 0.03748377099236641, + "HZB Build Mip 3": 0.018716595419847312, + "HZB Build Mip 4": 0.013278923664122142, + "HZB Build Mip 5": 0.009485515267175575, + "HZB Build Mip 6": 0.009927312977099242, + "HZB Build Mip 7": 0.011579988549618322, + "HZB Build Mip 8": 0.008711984732824429, + "HZB Build Mip 9": 0.007642805343511454, + "HZB Occlusion Culling pass": 0.07528783969465648, + "Local Scan Pass": 0.05468525954198474, + "Mark Visible Pass": 0.03252848091603053, + "Output Transform Pass": 3.0403080687022914, + "Post-Processing Pass": 3.4833246221374035, + "Pre Composite Pass": 1.286415732824428, + "Shadow Cascade 0": 0.3938967977099238, + "Shadow Cascade 1": 0.02350952671755726, + "Transparency Pass": 0.5657903740458013 + }, + "gpuPassMinMs": { + "Batched Shadow Cascade 0": 0.807583, + "Batched Shadow Cascade 1": 0.663583, + "Block Scan Pass": 0.006375, + "Compact and Stream Pass": 0.006333, + "Copy Opaque Depth for Gaussian Occlusion": 0.275375, + "Copy Opaque Depth for HZB": 0.212167, + "Environment Pass": 1.174458, + "G-Buffer + Light Pass (TBDR)": 4.509124, + "Gaussian Pass": 0.840334, + "HZB Build Mip 0": 0.208084, + "HZB Build Mip 1": 0.066583, + "HZB Build Mip 10": 0.002708, + "HZB Build Mip 11": 0.002666, + "HZB Build Mip 2": 0.0185, + "HZB Build Mip 3": 0.007875, + "HZB Build Mip 4": 0.004708, + "HZB Build Mip 5": 0.00375, + "HZB Build Mip 6": 0.00325, + "HZB Build Mip 7": 0.003, + "HZB Build Mip 8": 0.002625, + "HZB Build Mip 9": 0.002958, + "HZB Occlusion Culling pass": 0.03975, + "Local Scan Pass": 0.007709, + "Mark Visible Pass": 0.010292, + "Output Transform Pass": 1.445333, + "Post-Processing Pass": 1.467748, + "Pre Composite Pass": 0.99525, + "Shadow Cascade 0": 0.185458, + "Shadow Cascade 1": 0.008875, + "Transparency Pass": 0.226416 + }, + "meanFrameMs": 57.45885860999577, + "meanGPUExecutionMs": 23.920441793899315, + "minGPUExecutionMs": 10.405249999621446, + "missedDeadlines": 262, + "p50FrameMs": 55.72950094938278, + "p95FrameMs": 66.43162667751312, + "p99FrameMs": 67.05595552921295, + "peakGPUAllocatedBytes": 1040760832, + "timingMeanMs": { + "animationMs": 0.0006878180490712944, + "batchingTickMs": 0.0009365457749169299, + "compositorDeadlineMarginMs": -66.27768256946342, + "compositorInputSlackMs": 0.3223713371207434, + "compositorSubmissionMs": 51.90651160940794, + "compositorUpdateMs": 1.4903959923869743, + "cullingMs": 3.5855416666724333, + "customSystemsMs": 0.000438931295299971, + "encodeMs": 47.954657283680575, + "extensionsUpdateMs": 0.000649332091702039, + "gameUpdateMs": 0.013623091636241355, + "geometryStreamingMs": 0.8752247137431195, + "lodMs": 0.0014977735527490048, + "physicsMs": 0.0013177481280881485, + "renderPrepMs": 3.5893078880405502, + "renderTotalMs": 51.829421437648605, + "scenegraphMs": 7.617684800850089e-05, + "scriptingMs": 0.00021374046788968587, + "semaphoreWaitMs": 0.0008880407110939446, + "streamingRegionMs": 0.0009414758193879815, + "submitMs": 0.08328053435545496, + "updateMs": 0.027050731541957255 + }, + "worstFrameMs": 67.50479340553284, + "worstThermalState": 0 + }, + "title": "10000 primitives, static batching, 4 point lights", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/RealityDevice17_1/visionOS/primitives-1k.json b/perf/baselines/RealityDevice17_1/visionOS/primitives-1k.json new file mode 100644 index 000000000..3193a8268 --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/primitives-1k.json @@ -0,0 +1,442 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "primitives-1k.jsonl", + "id": "primitives-1k", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -30.46691666577317, + "deadlineSampleCount": 451, + "inputSlackMs": 7.543500666542968, + "missedDeadline": true, + "missedDeadlineCount": 451, + "missingAnchorCount": 0, + "presentationMarginMs": -15.000665999195917, + "submissionMs": 24.73650000001726, + "updateMs": 0.6501666666736128, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 392, + "frustumPassed": 632, + "frustumTested": 1024, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 0, + "occlusionPassed": 632, + "occlusionTested": 632, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 450, + "gpuPasses": { + "frameIndex": 451, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.101375 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.015458 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.013209 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.00825 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.0485 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 2.143375 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.564167 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.353667 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 8.798709 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 0.45899999999999996 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 0.489125 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.6054999999999999 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 1.749084 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.667833 + }, + { + "count": 4, + "label": "Post-Processing Pass", + "ms": 2.887292 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 2.519167 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.488 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 0.119416 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.036792 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.016458 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.058125 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.01075 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.009792 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.008708 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.008916 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.008667 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.007417 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 0.008 + } + ], + "passesSkipped": 0, + "totalMs": 23.204751999999996 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 30, + "framesOverBudget": 451, + "framesOverBudgetLastSecond": 30, + "framesSampled": 451, + "histogram": [ + 0, + 0, + 0, + 0, + 273, + 178, + 0 + ], + "worstFrameMsLastSecond": 34.597042948007584 + }, + "memory": { + "availableMemoryBytes": 4517214840, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 911851520, + "isUnderPressure": false, + "meshMemoryBytes": 12098984, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 0, + "thermalState": 0, + "trackedEntityCount": 1030, + "utilizationPercent": 0.006107790395617485 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 1264, + "drawCallsShadow": 1407, + "drawCallsTotal": 2685, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 534082, + "visibleInstances": 632 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 1030, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": true, + "updateWorkMs": 0.5769729614257812, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3141.365684208334, + "timing": { + "animationMs": 0.00037500012695090845, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0004583330337482039, + "cullingMs": 0.3672916664072545, + "customSystemsMs": 0.00037500012695090845, + "encodeMs": 24.082291666672972, + "extensionsUpdateMs": 0.0004583334884955548, + "frameTotalMs": 33.99183228611946, + "gameUpdateMs": 0.005833333034388488, + "geometryStreamingMs": 0.577875000089989, + "gpuExecutionMs": 16.580041666657053, + "gpuFrameCadenceMs": 34.097041666427685, + "lodMs": 0.0007916664799267892, + "physicsMs": 0.0005833335308125243, + "physicsStepCount": 2, + "renderPrepMs": 0.36999999974796083, + "renderTotalMs": 24.655666666603793, + "scenegraphMs": 0, + "scriptingMs": 0.00012500004231696948, + "semaphoreWaitMs": 0.00037500012695090845, + "smoothedFrameMs": 33.311411241690315, + "streamingRegionMs": 0.0014583333722839598, + "submitMs": 0.019999999949504854, + "updateMs": 0.015458333109563682 + } + }, + "measureSeconds": 15, + "notes": "One entity and draw per primitive; measures per-entity CPU cost and encoder overhead.", + "runs": 1, + "summary": { + "deadlineSamples": 451, + "durationSeconds": 14.96570225000005, + "frameBudgetMs": 16.666666666666668, + "frames": 451, + "framesOverBudget": 451, + "gpuPassMeanMs": { + "Block Scan Pass": 0.0307488292682926, + "Compact and Stream Pass": 0.011940394678492242, + "Copy Opaque Depth for Gaussian Occlusion": 0.6903882815964524, + "Copy Opaque Depth for HZB": 0.5778049866962304, + "Environment Pass": 2.7257868580931257, + "G-Buffer + Light Pass (TBDR)": 7.964256365853655, + "Gaussian Pass": 2.1016451130820393, + "HZB Build Mip 0": 0.5712761662971174, + "HZB Build Mip 1": 0.27005534589800456, + "HZB Build Mip 10": 0.011621113082039914, + "HZB Build Mip 11": 0.009926467849223939, + "HZB Build Mip 2": 0.05599044124168513, + "HZB Build Mip 3": 0.0468118203991131, + "HZB Build Mip 4": 0.023930226164079804, + "HZB Build Mip 5": 0.013746871396895795, + "HZB Build Mip 6": 0.013092864745011087, + "HZB Build Mip 7": 0.010953414634146327, + "HZB Build Mip 8": 0.014037217294900225, + "HZB Build Mip 9": 0.008771691796008863, + "HZB Occlusion Culling pass": 0.05589048558758313, + "Local Scan Pass": 0.059680725055432426, + "Mark Visible Pass": 0.20499348337028828, + "Output Transform Pass": 2.587873133037694, + "Post-Processing Pass": 2.839885365853662, + "Pre Composite Pass": 1.7759434789356974, + "Shadow Cascade 0": 0.5171597605321507, + "Shadow Cascade 1": 0.3531939356984475, + "Transparency Pass": 0.609366780487805 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.007125, + "Compact and Stream Pass": 0.004084, + "Copy Opaque Depth for Gaussian Occlusion": 0.40425, + "Copy Opaque Depth for HZB": 0.35862499999999997, + "Environment Pass": 1.027459, + "G-Buffer + Light Pass (TBDR)": 4.216417, + "Gaussian Pass": 1.3574169999999999, + "HZB Build Mip 0": 0.314625, + "HZB Build Mip 1": 0.076542, + "HZB Build Mip 10": 0.004125, + "HZB Build Mip 11": 0.004458, + "HZB Build Mip 2": 0.023334, + "HZB Build Mip 3": 0.010833, + "HZB Build Mip 4": 0.006708, + "HZB Build Mip 5": 0.00575, + "HZB Build Mip 6": 0.005125, + "HZB Build Mip 7": 0.00475, + "HZB Build Mip 8": 0.004541, + "HZB Build Mip 9": 0.00475, + "HZB Occlusion Culling pass": 0.03375, + "Local Scan Pass": 0.007417, + "Mark Visible Pass": 0.003958, + "Output Transform Pass": 1.85425, + "Post-Processing Pass": 2.023876, + "Pre Composite Pass": 1.078668, + "Shadow Cascade 0": 0.176542, + "Shadow Cascade 1": 0.224541, + "Transparency Pass": 0.38391600000000004 + }, + "meanFrameMs": 33.33338233590655, + "meanGPUExecutionMs": 16.34904471543319, + "minGPUExecutionMs": 12.308041666983627, + "missedDeadlines": 451, + "p50FrameMs": 33.331625163555145, + "p95FrameMs": 34.56937521696091, + "p99FrameMs": 34.797459840774536, + "peakGPUAllocatedBytes": 911851520, + "timingMeanMs": { + "animationMs": 0.00042322616921868146, + "batchingTickMs": 0.0006124353254934233, + "compositorDeadlineMarginMs": -30.18120756933401, + "compositorInputSlackMs": 8.22262140058785, + "compositorSubmissionMs": 24.644189855882235, + "compositorUpdateMs": 0.21925120101832665, + "cullingMs": 0.3799433665962696, + "customSystemsMs": 0.00023447892836337919, + "encodeMs": 23.977214246117644, + "extensionsUpdateMs": 0.0004326496767431256, + "gameUpdateMs": 0.007540650398179036, + "geometryStreamingMs": 0.09422671841287283, + "lodMs": 0.0011136363635345324, + "physicsMs": 0.0007246858845437782, + "renderPrepMs": 0.38259303400467165, + "renderTotalMs": 24.55664412416974, + "scenegraphMs": 2.8640059748767234e-05, + "scriptingMs": 0.00015169993509704188, + "semaphoreWaitMs": 0.0004522357631179565, + "streamingRegionMs": 0.0006164079842344417, + "submitMs": 0.0193138396232769, + "updateMs": 0.016351626027345325 + }, + "worstFrameMs": 34.94216501712799, + "worstThermalState": 0 + }, + "title": "1024 primitives, 4 point lights", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/baselines/RealityDevice17_1/visionOS/stadium.json b/perf/baselines/RealityDevice17_1/visionOS/stadium.json new file mode 100644 index 000000000..c77d402f7 --- /dev/null +++ b/perf/baselines/RealityDevice17_1/visionOS/stadium.json @@ -0,0 +1,442 @@ +{ + "device": { + "gpuName": "Apple M5 GPU", + "isLowPowerMode": false, + "model": "RealityDevice17,1", + "osVersion": "Version 27.0 (Build 24M362)", + "physicalMemoryBytes": 16501948416, + "processorCount": 10, + "thermalStateAtStart": 0 + }, + "label": "", + "render": { + "displayRefreshHz": 90, + "foveation": false, + "immersion": "full", + "layout": "dedicated", + "platform": "visionOS", + "renderQuality": 1, + "viewCount": 2, + "viewportHeight": 1984, + "viewportWidth": 2048, + "vsync": true + }, + "runIDs": [ + "20260920-221408" + ], + "scene": { + "file": "stadium.jsonl", + "id": "stadium", + "lastSnapshot": { + "batching": { + "batchGroupCount": 0, + "batchedMeshCount": 0, + "deferredByWorkBudget": 0, + "dirtyCellsAfterPrune": 0, + "dirtyCellsBeforePrune": 0, + "dispatchedBuilds": 0, + "lastRebuildCostMs": 0, + "lastRebuildInputMeshCount": 0, + "lastRebuildOutputBatchCount": 0, + "rebuildsThisSecond": 0, + "skippedByComplexityGuard": 0 + }, + "compositor": { + "deadlineMarginMs": -2.1203749997766863, + "deadlineSampleCount": 1350, + "inputSlackMs": 7.856874999561114, + "missedDeadline": true, + "missedDeadlineCount": 1337, + "missingAnchorCount": 0, + "presentationMarginMs": 13.345958333502494, + "submissionMs": 3.7457500002346933, + "updateMs": 0.09112500038099824, + "viewCount": 2, + "viewTextureHeight": 1984, + "viewTextureWidth": 2048 + }, + "culling": { + "frustumFailed": 4, + "frustumPassed": 12, + "frustumTested": 16, + "hzbIsValid": true, + "hzbMipCount": 12, + "occlusionFailed": 0, + "occlusionPassed": 12, + "occlusionTested": 12, + "optimizedFrustumPath": true, + "selectedHZBMipLevel": 0, + "selectedHZBMipSize": [ + 2048, + 1984 + ], + "usedHZB": true + }, + "frameIndex": 1349, + "gpuPasses": { + "frameIndex": 1350, + "passes": [ + { + "count": 1, + "label": "Mark Visible Pass", + "ms": 0.001875 + }, + { + "count": 1, + "label": "Local Scan Pass", + "ms": 0.003209 + }, + { + "count": 1, + "label": "Block Scan Pass", + "ms": 0.003083 + }, + { + "count": 1, + "label": "Compact and Stream Pass", + "ms": 0.00175 + }, + { + "count": 1, + "label": "HZB Occlusion Culling pass", + "ms": 0.006458 + }, + { + "count": 2, + "label": "Environment Pass", + "ms": 0.772667 + }, + { + "count": 1, + "label": "Shadow Cascade 0", + "ms": 0.196791 + }, + { + "count": 1, + "label": "Shadow Cascade 1", + "ms": 0.055542 + }, + { + "count": 2, + "label": "G-Buffer + Light Pass (TBDR)", + "ms": 5.481084 + }, + { + "count": 2, + "label": "Copy Opaque Depth for Gaussian Occlusion", + "ms": 0.26979200000000003 + }, + { + "count": 2, + "label": "Gaussian Pass", + "ms": 5.971333 + }, + { + "count": 2, + "label": "Copy Opaque Depth for HZB", + "ms": 0.230833 + }, + { + "count": 2, + "label": "Transparency Pass", + "ms": 0.246249 + }, + { + "count": 2, + "label": "Pre Composite Pass", + "ms": 1.5205000000000002 + }, + { + "count": 2, + "label": "Post-Processing Pass", + "ms": 1.163792 + }, + { + "count": 2, + "label": "Output Transform Pass", + "ms": 1.8846669999999999 + }, + { + "count": 1, + "label": "HZB Build Mip 0", + "ms": 0.915542 + }, + { + "count": 1, + "label": "HZB Build Mip 1", + "ms": 1.307125 + }, + { + "count": 1, + "label": "HZB Build Mip 2", + "ms": 0.064042 + }, + { + "count": 1, + "label": "HZB Build Mip 3", + "ms": 0.021541 + }, + { + "count": 1, + "label": "HZB Build Mip 4", + "ms": 0.009666 + }, + { + "count": 1, + "label": "HZB Build Mip 5", + "ms": 0.008417 + }, + { + "count": 1, + "label": "HZB Build Mip 6", + "ms": 0.004917 + }, + { + "count": 1, + "label": "HZB Build Mip 7", + "ms": 0.00475 + }, + { + "count": 1, + "label": "HZB Build Mip 8", + "ms": 0.008125 + }, + { + "count": 1, + "label": "HZB Build Mip 9", + "ms": 0.00475 + }, + { + "count": 1, + "label": "HZB Build Mip 10", + "ms": 0.003875 + }, + { + "count": 1, + "label": "HZB Build Mip 11", + "ms": 0.002833 + } + ], + "passesSkipped": 0, + "totalMs": 20.165208 + }, + "hitches": { + "frameBudgetMs": 16.666666666666668, + "framesLastSecond": 91, + "framesOverBudget": 0, + "framesOverBudgetLastSecond": 0, + "framesSampled": 1350, + "histogram": [ + 0, + 0, + 747, + 603, + 0, + 0, + 0 + ], + "worstFrameMsLastSecond": 12.280167080461979 + }, + "memory": { + "availableMemoryBytes": 4202526488, + "geometryBudgetBytes": 1188546144, + "gpuAllocatedBytes": 1220050944, + "isUnderPressure": false, + "meshMemoryBytes": 322788, + "textureBudgetBytes": 792364096, + "textureMemoryBytes": 1426304, + "thermalState": 0, + "trackedEntityCount": 9, + "utilizationPercent": 0.0008829738944768906 + }, + "render": { + "drawCallsBatched": 0, + "drawCallsOpaque": 14, + "drawCallsShadow": 10, + "drawCallsTotal": 36, + "drawCallsTransparent": 0, + "tileFullDrawsEstimate": 0, + "tileFullTrianglesEstimate": 0, + "tileFullVisibleInstances": 0, + "tileHLODDrawsEstimate": 0, + "tileHLODTrianglesEstimate": 0, + "tileHLODVisibleInstances": 0, + "tileLODDrawsEstimate": 0, + "tileLODTrianglesEstimate": 0, + "tileLODVisibleInstances": 0, + "trianglesTotal": 9574, + "visibleInstances": 12 + }, + "streaming": { + "activeLoads": 0, + "activeTileRepresentationFades": 0, + "availableLoadSlots": 3, + "averageAsyncLoadMs": 0, + "blockedByGateMs": 0, + "cachedMeshResources": 0, + "evictionsPerformed": 0, + "fullAndFallbackResidentOverlapTiles": 0, + "fullAndHLODVisibleOverlapTiles": 0, + "fullAndLODVisibleOverlapTiles": 0, + "lastApplyLoadedMeshMs": 0, + "loadCandidates": 0, + "loadedStreamingEntities": 0, + "loadingStreamingEntities": 0, + "lod0VisibilityWarnings": 0, + "lod0VisibilityWarningsNoFallback": 0, + "lod0VisibilityWarningsWithFallback": 0, + "lodAndHLODVisibleOverlapTiles": 0, + "nearbyEntitiesQueried": 9, + "pendingLoadBacklog": 0, + "pendingUploadCount": 0, + "residentFullTileRepresentations": 0, + "residentHLODRepresentations": 0, + "residentLODRepresentations": 0, + "residentMeshEntities": 0, + "tileRepresentationGapWarnings": 0, + "tileSwapWarnings": 0, + "tilesSkippedByHierarchyGate": 0, + "unloadedStreamingEntities": 0, + "updateTriggered": false, + "updateWorkMs": 0, + "visibleFullTileRepresentations": 0, + "visibleHLODRepresentations": 0, + "visibleLODRepresentations": 0, + "waitingTileRepresentationFades": 0 + }, + "timestampSeconds": 3235.7675083750005, + "timing": { + "animationMs": 0.000749999799154466, + "batchingRebuildMs": 0, + "batchingTickMs": 0.0006666668923571706, + "cullingMs": 0.37191666660874034, + "customSystemsMs": 0, + "encodeMs": 2.7073749997725827, + "extensionsUpdateMs": 0.0004583334884955548, + "frameTotalMs": 11.107166297733784, + "gameUpdateMs": 0.007791666575940326, + "geometryStreamingMs": 0.0022500003069581, + "gpuExecutionMs": 10.007333333305723, + "gpuFrameCadenceMs": 10.78837500017471, + "lodMs": 0.0014583333722839598, + "physicsMs": 0, + "physicsStepCount": 0, + "renderPrepMs": 0.38037500007703784, + "renderTotalMs": 3.5397500000726723, + "scenegraphMs": 4.166668077232316e-05, + "scriptingMs": 0.00012500004231696948, + "semaphoreWaitMs": 0.00279166670225095, + "smoothedFrameMs": 11.069122143089771, + "streamingRegionMs": 0.003958333309128648, + "submitMs": 0.014875000033498509, + "updateMs": 0.01633333340578247 + } + }, + "measureSeconds": 15, + "notes": "stadium.untold and grass.untold from the render-test resources.", + "runs": 1, + "summary": { + "deadlineSamples": 1350, + "durationSeconds": 14.97752700000001, + "frameBudgetMs": 16.666666666666668, + "frames": 1350, + "framesOverBudget": 0, + "gpuPassMeanMs": { + "Block Scan Pass": 0.016301361481481487, + "Compact and Stream Pass": 0.007529556296296306, + "Copy Opaque Depth for Gaussian Occlusion": 0.30882368592592563, + "Copy Opaque Depth for HZB": 0.2794284755555557, + "Environment Pass": 0.7963780133333337, + "G-Buffer + Light Pass (TBDR)": 5.132751643703702, + "Gaussian Pass": 5.7152756637037045, + "HZB Build Mip 0": 1.0730258325925917, + "HZB Build Mip 1": 0.4230878992592602, + "HZB Build Mip 10": 0.006089136296296281, + "HZB Build Mip 11": 0.005904990370370355, + "HZB Build Mip 2": 0.09143363111111125, + "HZB Build Mip 3": 0.0330848222222222, + "HZB Build Mip 4": 0.01311335037037035, + "HZB Build Mip 5": 0.012206418518518532, + "HZB Build Mip 6": 0.009849549629629658, + "HZB Build Mip 7": 0.0066391866666666586, + "HZB Build Mip 8": 0.0064832918518518525, + "HZB Build Mip 9": 0.006743079259259267, + "HZB Occlusion Culling pass": 0.023170334074074057, + "Local Scan Pass": 0.016142856296296283, + "Mark Visible Pass": 0.008499859259259312, + "Output Transform Pass": 1.1224651266666679, + "Post-Processing Pass": 0.6517526577777771, + "Pre Composite Pass": 1.8236114577777767, + "Shadow Cascade 0": 0.22461109333333293, + "Shadow Cascade 1": 0.06608987925925913, + "Transparency Pass": 0.3017350548148145 + }, + "gpuPassMinMs": { + "Block Scan Pass": 0.003, + "Compact and Stream Pass": 0.001583, + "Copy Opaque Depth for Gaussian Occlusion": 0.231874, + "Copy Opaque Depth for HZB": 0.187459, + "Environment Pass": 0.703416, + "G-Buffer + Light Pass (TBDR)": 4.500667, + "Gaussian Pass": 4.914333, + "HZB Build Mip 0": 0.2965, + "HZB Build Mip 1": 0.102458, + "HZB Build Mip 10": 0.002292, + "HZB Build Mip 11": 0.002208, + "HZB Build Mip 2": 0.020584, + "HZB Build Mip 3": 0.006583, + "HZB Build Mip 4": 0.0035, + "HZB Build Mip 5": 0.003125, + "HZB Build Mip 6": 0.002875, + "HZB Build Mip 7": 0.002458, + "HZB Build Mip 8": 0.002417, + "HZB Build Mip 9": 0.002459, + "HZB Occlusion Culling pass": 0.005875, + "Local Scan Pass": 0.003083, + "Mark Visible Pass": 0.001833, + "Output Transform Pass": 0.8092090000000001, + "Post-Processing Pass": 0.435416, + "Pre Composite Pass": 1.1879170000000001, + "Shadow Cascade 0": 0.058541, + "Shadow Cascade 1": 0.051958, + "Transparency Pass": 0.202625 + }, + "meanFrameMs": 11.110879719533303, + "meanGPUExecutionMs": 9.428379753083357, + "minGPUExecutionMs": 7.7004999998280255, + "missedDeadlines": 1337, + "p50FrameMs": 11.1304996535182, + "p95FrameMs": 12.030958198010921, + "p99FrameMs": 12.311374768614769, + "peakGPUAllocatedBytes": 1220050944, + "timingMeanMs": { + "animationMs": 0.0008779320968541368, + "batchingTickMs": 0.000939753085899779, + "compositorDeadlineMarginMs": -2.213074943188918, + "compositorInputSlackMs": 7.239739201973458, + "compositorSubmissionMs": 3.6446945370341752, + "compositorUpdateMs": 0.11379490740317258, + "cullingMs": 0.3425976543224318, + "customSystemsMs": 0.00014083333183036842, + "encodeMs": 2.6439105555541405, + "extensionsUpdateMs": 0.0004915123574594165, + "gameUpdateMs": 0.009909660492566117, + "geometryStreamingMs": 0.008929259251831186, + "lodMs": 0.001771358020387534, + "physicsMs": 0.0006381481428737364, + "renderPrepMs": 0.3516029012391502, + "renderTotalMs": 3.467874907407383, + "scenegraphMs": 4.922839724275508e-05, + "scriptingMs": 0.00019058641858767564, + "semaphoreWaitMs": 0.0016395061801530472, + "streamingRegionMs": 0.0009050925954970471, + "submitMs": 0.018261388887805406, + "updateMs": 0.021978734572527442 + }, + "worstFrameMs": 12.962458655238152, + "worstThermalState": 0 + }, + "title": "Stadium and grass (textured assets)", + "warmupSeconds": 3 + } +} \ No newline at end of file diff --git a/perf/results/.gitkeep b/perf/results/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/perf/compare_baseline.py b/scripts/perf/compare_baseline.py new file mode 100755 index 000000000..462a91f55 --- /dev/null +++ b/scripts/perf/compare_baseline.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Compare PerfBench run summaries against the stored baseline for the same device model. + +Baselines live in perf/baselines///.json and hold the scene result as the +app wrote it, so a baseline is only ever compared with the same platform, layout, foveation and +viewport. Exit status 1 means at least one metric regressed past its threshold. + +Several summaries may be given (repeated runs): every time metric and every rate is aggregated by +its minimum across runs. On a lightly loaded machine CPU and GPU clocks drift between runs, and +other activity on the machine makes it skip refreshes; both only ever inflate a time or a rate, so +the minimum over a few runs is the stable number, and a real regression raises it in every run. + +GPU times of a scene that leaves the GPU mostly idle follow the GPU's clock state more than the +workload (the same build measured 0.8 and 2.2 ms for one frame in consecutive runs). They are +judged only when the baseline shows the GPU busy for at least --gpu-busy-fraction of the frame, as +it is on a headset, and reported as information otherwise. A GPU or CPU time also only counts as +changed when it moves by more than the relative tolerance and by more than an absolute slack. + + compare_baseline.py perf/results//summary.json [more summary.json ...] + [--baselines DIR] [--update] + [--time-tolerance 0.10] [--rate-tolerance 0.005] [--pass-tolerance 0.20] + [--gpu-slack-ms 0.5] [--pass-slack-ms 0.25] [--cpu-slack-ms 0.1] + [--gpu-busy-fraction 0.5] +""" +import argparse +import json +import os +import sys + +FRAME_METRICS = [ + ("p95FrameMs", "p95 frame ms"), + ("p99FrameMs", "p99 frame ms"), + ("meanFrameMs", "mean frame ms"), + ("minGPUExecutionMs", "min GPU ms"), +] +# Reported, never judged: it follows the GPU clock state more than the workload. +INFO_METRICS = [("meanGPUExecutionMs", "mean GPU ms")] +# Per-system CPU means worth a line each; the rest are compared silently. +TIMING_FIELDS = [ + "updateMs", "encodeMs", "cullingMs", "scenegraphMs", "animationMs", "physicsMs", + "scriptingMs", "gameUpdateMs", "semaphoreWaitMs", "compositorUpdateMs", "compositorSubmissionMs", +] + + +def load(path): + with open(path) as f: + return json.load(f) + + +def safe_model(name): + return "".join(c if c.isalnum() or c in "-_." else "_" for c in name) + + +def rate(numerator, denominator): + return (numerator / denominator) if denominator else 0.0 + + +# Render settings a baseline must share with a run to be compared with it, with the value assumed +# for records written before the field existed. +CONFIG_FIELDS = [ + ("platform", None), ("layout", None), ("foveation", None), ("viewportWidth", None), + ("viewportHeight", None), ("viewCount", None), ("antiAliasing", "fxaa"), + # Frames are display-paced, and the GPU stretches its work over the slack a slower display + # leaves, so times taken at different refresh rates are not comparable. + ("displayRefreshHz", 0.0), +] + + +def config_key(render): + return tuple(render.get(name, default) for name, default in CONFIG_FIELDS) + + +def config_difference(a, b): + """The render settings in which two records differ, as text.""" + parts = [] + for name, default in CONFIG_FIELDS: + left, right = a.get(name, default), b.get(name, default) + if left != right: + parts.append(f"{name} {left} vs {right}") + return ", ".join(parts) + + +def scene_rates(summary): + """(over-budget rate, missed-deadline rate) of a scene summary, single run or aggregate.""" + over = summary.get("overBudgetRate") + if over is None: + over = rate(summary.get("framesOverBudget", 0), summary.get("frames", 0)) + missed = summary.get("missedDeadlineRate") + if missed is None: + missed = rate(summary.get("missedDeadlines", 0), summary.get("deadlineSamples", 0)) + return over, missed + + +def aggregate(scenes): + """Fold the same scene from several runs into one record: min of times and of rates.""" + first = json.loads(json.dumps(scenes[0])) + s = first["summary"] + for key, _ in FRAME_METRICS + INFO_METRICS: + s[key] = min(x["summary"].get(key, 0.0) for x in scenes) + s["worstFrameMs"] = min(x["summary"].get("worstFrameMs", 0.0) for x in scenes) + frames = sum(x["summary"].get("frames", 0) for x in scenes) + s["frames"] = frames + s["framesOverBudget"] = sum(x["summary"].get("framesOverBudget", 0) for x in scenes) + s["missedDeadlines"] = sum(x["summary"].get("missedDeadlines", 0) for x in scenes) + s["deadlineSamples"] = sum(x["summary"].get("deadlineSamples", 0) for x in scenes) + # The counts above are totals, for the record; the rates that are judged are the best run's. + s["overBudgetRate"] = min(scene_rates(x["summary"])[0] for x in scenes) + s["missedDeadlineRate"] = min(scene_rates(x["summary"])[1] for x in scenes) + for field in ("gpuPassMeanMs", "gpuPassMinMs", "timingMeanMs"): + merged = {} + for x in scenes: + for label, value in x["summary"].get(field, {}).items(): + merged[label] = min(merged.get(label, value), value) + s[field] = merged + s["worstThermalState"] = max(x["summary"].get("worstThermalState", 0) for x in scenes) + first["runs"] = len(scenes) + return first + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("summaries", nargs="+") + ap.add_argument("--baselines", default=os.path.join(os.path.dirname(__file__), "..", "..", "perf", "baselines")) + ap.add_argument("--update", action="store_true", help="write these runs as the baseline") + ap.add_argument("--time-tolerance", type=float, default=0.10, help="relative slack for frame and system time metrics") + ap.add_argument("--rate-tolerance", type=float, default=0.005, help="absolute slack for over-budget and missed-deadline rates") + ap.add_argument("--pass-tolerance", type=float, default=0.20, help="relative slack for per-pass GPU minimums") + ap.add_argument("--gpu-slack-ms", type=float, default=0.5, help="a frame's minimum GPU time must also move by more than this") + ap.add_argument("--pass-slack-ms", type=float, default=0.25, help="a per-pass GPU minimum must also move by more than this") + ap.add_argument("--cpu-slack-ms", type=float, default=0.1, help="a per-system CPU mean must also move by more than this") + ap.add_argument("--gpu-busy-fraction", type=float, default=0.5, + help="judge GPU times only when the baseline's mean GPU time is at least this fraction of its mean frame time") + args = ap.parse_args() + + runs = [load(p) for p in args.summaries] + first = runs[0] + for run in runs[1:]: + if config_key(run["render"]) != config_key(first["render"]) or run["device"]["model"] != first["device"]["model"]: + print("summaries come from different devices or render configurations; refusing to aggregate") + return 2 + scenes_by_id = {} + for run in runs: + for scene in run["scenes"]: + scenes_by_id.setdefault(scene["id"], []).append(scene) + scenes = [aggregate(group) for group in scenes_by_id.values()] + + model = safe_model(first["device"]["model"]) + platform = first["render"]["platform"] + base_dir = os.path.join(args.baselines, model, platform) + r = first["render"] + print(f"{len(runs)} run(s), label={first.get('label','')!r} device={first['device']['model']} platform={platform} " + f"viewport={r.get('viewportWidth')}x{r.get('viewportHeight')} x{r.get('viewCount')} refresh={r.get('displayRefreshHz')}") + + if args.update: + os.makedirs(base_dir, exist_ok=True) + for scene in scenes: + record = {"render": first["render"], "device": first["device"], "label": first.get("label", ""), + "runIDs": [run["runID"] for run in runs], "scene": scene} + with open(os.path.join(base_dir, f"{scene['id']}.json"), "w") as f: + json.dump(record, f, indent=2, sort_keys=True) + print(f"baseline written to {base_dir} for {len(scenes)} scene(s) from {len(runs)} run(s)") + return 0 + + regressions = 0 + for scene in scenes: + path = os.path.join(base_dir, f"{scene['id']}.json") + s = scene["summary"] + over, missed = scene_rates(s) + head = (f"{scene['id']}: frames {s.get('frames',0)} p95 {s.get('p95FrameMs',0):.2f} p99 {s.get('p99FrameMs',0):.2f} " + f"gpu {s.get('meanGPUExecutionMs',0):.2f} overBudget {over*100:.2f}% missed {missed*100:.2f}%") + if not os.path.exists(path): + print(f"{head} [no baseline]") + continue + base = load(path) + if config_key(base["render"]) != config_key(first["render"]): + difference = config_difference(base["render"], first["render"]) + print(f"{head} [baseline has a different render configuration ({difference}), skipped]") + continue + b = base["scene"]["summary"] + bover, bmissed = scene_rates(b) + print(head) + + # With the GPU idle most of the frame its times follow the clock state, not the workload. + gpu_busy = rate(b.get("meanGPUExecutionMs", 0.0), b.get("meanFrameMs", 0.0)) >= args.gpu_busy_fraction + + def judge(label, now, ref, tolerance, unit="ms", relative=True, slack=0.0, informational=False): + nonlocal regressions + if relative: + if ref <= 0: + return + delta = (now - ref) / ref + if informational: + print(f" {label:28s} {now:9.3f} vs {ref:9.3f} {unit} {delta*100:+7.1f}% (info, GPU mostly idle)") + return + moved = abs(now - ref) > slack + flag = "REGRESSION" if delta > tolerance and moved else ("better" if delta < -tolerance and moved else "ok") + print(f" {label:28s} {now:9.3f} vs {ref:9.3f} {unit} {delta*100:+7.1f}% {flag}") + else: + delta = now - ref + flag = "REGRESSION" if delta > tolerance else "ok" + print(f" {label:28s} {now*100:8.2f}% vs {ref*100:8.2f}% {delta*100:+6.2f}pt {flag}") + if flag == "REGRESSION": + regressions += 1 + + for key, label in FRAME_METRICS: + is_gpu = key == "minGPUExecutionMs" + judge(label, s.get(key, 0.0), b.get(key, 0.0), args.time_tolerance, + slack=args.gpu_slack_ms if is_gpu else 0.0, informational=is_gpu and not gpu_busy) + for key, label in INFO_METRICS: + now, ref = s.get(key, 0.0), b.get(key, 0.0) + if ref > 0: + print(f" {label:28s} {now:9.3f} vs {ref:9.3f} ms {(now-ref)/ref*100:+7.1f}% (info)") + judge("overBudgetRate", over, bover, args.rate_tolerance, relative=False) + judge("missedDeadlineRate", missed, bmissed, args.rate_tolerance, relative=False) + tnow, tref = s.get("timingMeanMs", {}), b.get("timingMeanMs", {}) + for field in TIMING_FIELDS: + if field in tnow and field in tref and tref[field] >= 0.02: + judge(f"cpu {field}", tnow[field], tref[field], args.time_tolerance, slack=args.cpu_slack_ms) + pnow, pref = s.get("gpuPassMinMs", {}), b.get("gpuPassMinMs", {}) + untimed = sorted(label for label in pref if label not in pnow and pref[label] >= 0.05) + if untimed: + # The baseline knows passes under labels this run never produced (a baseline recorded + # by another timer, or passes renamed since): those are not judged, and say so. + shown = ", ".join(untimed[:4]) + (", ..." if len(untimed) > 4 else "") + print(f" gpu passes not judged: {len(untimed)} baseline label(s) this run did not time ({shown}); re-record the baseline") + for label in sorted(pref): + if label in pnow and pref[label] >= 0.05: + judge(f"gpu min {label[:20]}", pnow[label], pref[label], args.pass_tolerance, + slack=args.pass_slack_ms, informational=not gpu_busy) + if regressions: + print(f"{regressions} regression(s)") + return 1 + print("no regressions") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/perf/run_bench.sh b/scripts/perf/run_bench.sh new file mode 100755 index 000000000..1d2f5e480 --- /dev/null +++ b/scripts/perf/run_bench.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Builds and runs Examples/PerfBench on macOS, an iOS device or an Apple Vision Pro, collects the +# run folder (JSON Lines per scene plus summary.json) and compares it against the stored baseline. +# +# Usage: +# scripts/perf/run_bench.sh macos [options] +# scripts/perf/run_bench.sh ios --device [options] +# scripts/perf/run_bench.sh visionos --device [options] +# +# Options: +# --scenes a,b,c scene ids (default: all) +# --seconds N recorded seconds per scene (default: 15) +# --warmup N warm-up seconds per scene (default: 3) +# --out DIR where run folders land (default: perf/results) +# --label TEXT stored in summary.json (default: git describe) +# --config Debug|Release (default: Release) +# --repeat N run the whole scene set N times and aggregate (minimum of each time and rate) +# --refresh N macOS: run on a connected display whose maximum refresh rate is N Hz +# (default: the fastest connected display). Baselines are keyed by the rate. +# --xctrace TEMPLATE also record an Instruments trace, e.g. "Metal System Trace" (device runs) +# --no-compare skip the baseline comparison +# --update-baseline write this run as the new baseline for the device model +# +# Requirements: xcodegen (brew install xcodegen), Xcode with the platform SDKs, a paired device for +# device runs (xcrun devicectl list devices). + +set -euo pipefail + +PLATFORM="${1:-}" +[ -n "$PLATFORM" ] || { sed -n '2,24p' "$0"; exit 2; } +shift + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +BENCH_DIR="$REPO/Examples/PerfBench" +OUT_DIR="$REPO/perf/results" +BASELINES="$REPO/perf/baselines" +SCENES="all" +SECONDS_PER_SCENE=15 +WARMUP=3 +LABEL="$(git -C "$REPO" describe --always --dirty 2>/dev/null || echo unknown)" +CONFIG=Release +DEVICE="" +XCTRACE_TEMPLATE="" +COMPARE=1 +UPDATE_BASELINE=0 +REPEAT=1 +REFRESH="" + +while [ $# -gt 0 ]; do + case "$1" in + --scenes) SCENES="$2"; shift 2 ;; + --seconds) SECONDS_PER_SCENE="$2"; shift 2 ;; + --warmup) WARMUP="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + --label) LABEL="$2"; shift 2 ;; + --config) CONFIG="$2"; shift 2 ;; + --device) DEVICE="$2"; shift 2 ;; + --repeat) REPEAT="$2"; shift 2 ;; + --refresh) REFRESH="$2"; shift 2 ;; + --xctrace) XCTRACE_TEMPLATE="$2"; shift 2 ;; + --no-compare) COMPARE=0; shift ;; + --update-baseline) UPDATE_BASELINE=1; shift ;; + *) echo "unknown option $1"; exit 2 ;; + esac +done + +RUN_ID="$(date +%Y%m%d-%H%M%S)-$PLATFORM" +DERIVED="$BENCH_DIR/.build/DerivedData" +mkdir -p "$OUT_DIR" + +case "$PLATFORM" in + macos) SCHEME="PerfBench-macOS"; DESTINATION="platform=macOS,arch=arm64" ;; + ios) SCHEME="PerfBench-iOS"; [ -n "$DEVICE" ] || { echo "--device is required for ios"; exit 2; }; DESTINATION="id=$DEVICE" ;; + visionos) SCHEME="PerfBench-visionOS"; [ -n "$DEVICE" ] || { echo "--device is required for visionos"; exit 2; }; DESTINATION="id=$DEVICE" ;; + *) echo "platform must be macos, ios or visionos"; exit 2 ;; +esac + +echo "== Generating project" +(cd "$BENCH_DIR" && xcodegen generate --quiet) + +echo "== Building $SCHEME ($CONFIG)" +xcodebuild build \ + -project "$BENCH_DIR/PerfBench.xcodeproj" \ + -scheme "$SCHEME" \ + -configuration "$CONFIG" \ + -destination "$DESTINATION" \ + -derivedDataPath "$DERIVED" \ + -allowProvisioningUpdates \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \ + -quiet + +SUMMARIES=() +for ((REP = 1; REP <= REPEAT; REP++)); do +if [ "$REPEAT" -gt 1 ]; then RUN_ID="$(date +%Y%m%d-%H%M%S)-$PLATFORM-r$REP"; echo "== Repeat $REP of $REPEAT"; fi +case "$PLATFORM" in + macos) + APP="$DERIVED/Build/Products/$CONFIG/PerfBench.app" + echo "== Running $APP" + UNTOLD_BENCH_SCENES="$SCENES" UNTOLD_BENCH_SECONDS="$SECONDS_PER_SCENE" UNTOLD_BENCH_WARMUP="$WARMUP" \ + UNTOLD_BENCH_OUTPUT="$OUT_DIR" UNTOLD_BENCH_RUN_ID="$RUN_ID" UNTOLD_BENCH_LABEL="$LABEL" \ + UNTOLD_STATS=1 UNTOLD_BENCH_REFRESH="$REFRESH" \ + "$APP/Contents/MacOS/PerfBench" | tee "$OUT_DIR/$RUN_ID.log" | grep -v "^PERFBENCH_SUMMARY_JSON" + ;; + + ios|visionos) + if [ "$PLATFORM" = ios ]; then + APP="$DERIVED/Build/Products/$CONFIG-iphoneos/PerfBench.app"; BUNDLE_ID="com.untoldengine.perfbench.ios" + else + APP="$DERIVED/Build/Products/$CONFIG-xros/PerfBench.app"; BUNDLE_ID="com.untoldengine.perfbench.visionos" + fi + echo "== Installing on $DEVICE" + xcrun devicectl device install app --device "$DEVICE" "$APP" --quiet + ENV_JSON=$(printf '{"UNTOLD_BENCH_SCENES":"%s","UNTOLD_BENCH_SECONDS":"%s","UNTOLD_BENCH_WARMUP":"%s","UNTOLD_BENCH_RUN_ID":"%s","UNTOLD_BENCH_LABEL":"%s","UNTOLD_STATS":"1"}' \ + "$SCENES" "$SECONDS_PER_SCENE" "$WARMUP" "$RUN_ID" "$LABEL") + TRACE_PID="" + if [ -n "$XCTRACE_TEMPLATE" ]; then + TRACE="$OUT_DIR/$RUN_ID.trace" + echo "== Recording '$XCTRACE_TEMPLATE' to $TRACE" + xcrun xctrace record --template "$XCTRACE_TEMPLATE" --device "$DEVICE" --output "$TRACE" \ + --launch -- "$BUNDLE_ID" >"$OUT_DIR/$RUN_ID.xctrace.log" 2>&1 & + TRACE_PID=$! + echo " (xctrace launched the app; environment is taken from the DEVICECTL defaults, use the app's Start button if it did not autostart)" + else + echo "== Launching $BUNDLE_ID (console attached until the app exits)" + xcrun devicectl device process launch --device "$DEVICE" --console --terminate-existing \ + --environment-variables "$ENV_JSON" "$BUNDLE_ID" | tee "$OUT_DIR/$RUN_ID.log" | grep -v "^PERFBENCH_SUMMARY_JSON" || true + fi + if [ -n "$TRACE_PID" ]; then + wait "$TRACE_PID" || true + fi + echo "== Copying results" + xcrun devicectl device copy from --device "$DEVICE" --domain-type appDataContainer --domain-identifier "$BUNDLE_ID" \ + --source "Documents/PerfBench/$RUN_ID" --destination "$OUT_DIR/$RUN_ID" --quiet || { + echo " copy failed; the run may still be on the device under Documents/PerfBench/$RUN_ID"; } + ;; +esac + +RUN_DIR="$OUT_DIR/$RUN_ID" +[ -f "$RUN_DIR/summary.json" ] || { echo "no summary.json in $RUN_DIR"; exit 1; } +echo "== Results: $RUN_DIR" +SUMMARIES+=("$RUN_DIR/summary.json") +done + +if [ "$UPDATE_BASELINE" = 1 ]; then + python3 "$REPO/scripts/perf/compare_baseline.py" "${SUMMARIES[@]}" --baselines "$BASELINES" --update +elif [ "$COMPARE" = 1 ]; then + python3 "$REPO/scripts/perf/compare_baseline.py" "${SUMMARIES[@]}" --baselines "$BASELINES" +fi