diff --git a/examples/experimental/gpu-trace-viewer/app.ts b/examples/experimental/gpu-trace-viewer/app.ts index 63d034a684..dc6e517a92 100644 --- a/examples/experimental/gpu-trace-viewer/app.ts +++ b/examples/experimental/gpu-trace-viewer/app.ts @@ -2,38 +2,48 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors -import {Buffer, type Binding, type Device, type RenderBundle} from '@luma.gl/core'; +import {ColumnPanel, type Panel} from '@deck.gl-community/panels'; +import {type Binding, Buffer, type Device, type RenderBundle} from '@luma.gl/core'; import type {AnimationProps} from '@luma.gl/engine'; import {AnimationLoopTemplate, Computation, Model} from '@luma.gl/engine'; import { + type CompiledGPUCommandGraph, DispatchCommandBuffer, DrawCommandBuffer, GPUCommandGraph, + type GPUCommandGraphEncoding, + GPUCommandGraphInspector, + type GPUCommandGraphInspectorObservation, GPUHierarchyLayout, GPUIndexedRangeCompaction, GPUReadbackRing, + type GPUReadbackTicket, GPUVisibilityWorkflow, - GraphVectorView, - type CompiledGPUCommandGraph, type GraphBufferHandle, type GraphBufferUse, - type GPUReadbackTicket + GraphVectorView } from '@luma.gl/experimental'; -import {ColumnPanel, type Panel} from '@deck.gl-community/panels'; import { ExamplePanelManager, makeExamplePanelHostHtml, makeHtmlCustomPanel } from '../../example-panels'; +import {GPUCommandGraphInspectorPanel} from '../../gpu-command-graph-inspector-panel'; +import { + getTraceAllocationStats, + getTraceCapacityContract, + getTraceWorkloadCounters, + type TraceAllocationStats +} from './trace-benchmark'; import { getTraceCapacityOptions, getTraceDependencyCapacityOptions, - makeTraceDataset, isTraceDensityMode, + makeTraceDataset, TRACE_COLLAPSED_STATE, + TRACE_DENSITY_BIN_COUNT, TRACE_DEPENDENCY_BATCH_CAPACITY, TRACE_DEPENDENCY_BATCH_RECORD_WORD_LENGTH, - TRACE_DENSITY_BIN_COUNT, TRACE_DURATION, TRACE_EXPANDED_STATE, TRACE_FILTER_ERRORS_ONLY, @@ -55,10 +65,10 @@ import { import { getBatchVisibilityShader, getCandidateDensityShader, + getCandidateDependencyVisibilityShader, getCandidatePassDispatchShader, getCandidatePickShader, getCandidateVisibilityShader, - getCandidateDependencyVisibilityShader, getDensityClearShader, getDependencyBatchVisibilityShader, getFocusFrontierClearShader, @@ -87,6 +97,25 @@ const VIEW_UNIFORM_BYTE_LENGTH = 80; const MAXIMUM_FOCUS_DEPTH = 4; const INVALID_SPAN_INDEX = TRACE_INVALID_SPAN_INDEX; const STATUS_NAMES = ['ok', 'waiting', 'active', 'error'] as const; +const TRACE_GRAPH_ID = 'gpu-hierarchical-trace-command-graph'; +const TRACE_INSPECTOR_COUNTER_LABELS = { + spans: 'Spans', + dependencies: 'Dependencies', + 'candidate-span-batches': 'Candidate span batches', + 'candidate-span-percent': 'Candidate span %', + 'candidate-dependency-batches': 'Candidate dependency batches', + 'candidate-dependency-percent': 'Candidate dependency %', + 'visible-spans': 'Visible spans', + 'visible-span-percent': 'Visible span %', + 'visible-dependencies': 'Visible dependencies', + 'persistent-bytes': 'Persistent bytes', + 'largest-buffer-bytes': 'Largest buffer bytes', + 'collapsed-processes': 'Collapsed processes', + 'density-mode': 'Density mode', + 'filter-active': 'Filter active', + 'focus-active': 'Focus active', + 'pick-active': 'Pick active' +} as const; type TraceViewParameters = { timeMin: number; @@ -149,6 +178,45 @@ type TraceGraphResources = { dependencyCount: number; }; +function getTraceResourceBuffers(resources: TraceGraphResources): Array<{byteLength: number}> { + return [ + resources.drawCommands.buffer, + resources.candidateDispatchCommands.buffer, + resources.exactCandidateDispatchCommands.buffer, + resources.densityCandidateDispatchCommands.buffer, + resources.pickCandidateDispatchCommands.buffer, + resources.candidateDependencyDispatchCommands.buffer, + resources.spans, + resources.spanBatchIndex, + resources.candidateBatchIds, + resources.visibleSpanIds, + resources.dependencies, + resources.dependencyBatchIndex, + resources.candidateDependencyBatchIds, + resources.parentSpans, + resources.outgoingOffsets, + resources.outgoingNeighbors, + resources.incomingOffsets, + resources.incomingNeighbors, + resources.processStates, + resources.threadStates, + resources.threadHeights, + resources.threadOffsets, + resources.selectedSeeds, + resources.selectedSeedCount, + resources.focusTraversalState, + resources.reachedSpans, + resources.dependencyResults, + resources.spanVisibility, + resources.visibleDependencyIds, + resources.densityBins, + resources.pickResult, + ...Array.from({length: resources.readbackRing.slotCount}, () => ({ + byteLength: resources.readbackRing.byteLength + })) + ]; +} + type TraceComputePassBinding = { name: string; buffer: GraphBufferHandle; @@ -166,10 +234,18 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe readonly densityModel: Model; readonly viewUniformBuffer: Buffer; readonly panels: ExamplePanelManager; + readonly graphInspector = new GPUCommandGraphInspector({maxSamples: 90}); readonly capacityOptions: number[]; readonly dependencyCapacityOptions: number[]; private resources: TraceGraphResources | null = null; + private graphObservation: GPUCommandGraphInspectorObservation | null = null; + private readonly gpuTimingReadbackTimers = new Set>(); + private allocationStats: TraceAllocationStats = { + bufferCount: 0, + persistentByteLength: 0, + largestBufferByteLength: 0 + }; private spanCapacity = DEFAULT_CAPACITY; private dependencyCapacity = DEFAULT_DEPENDENCY_CAPACITY; private enabledMask = 0b111; @@ -203,7 +279,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe private viewportWidth = 1; private canvas: HTMLCanvasElement | null = null; private statsElement: HTMLElement | null = null; - private nodesElement: HTMLElement | null = null; + private inspectorPanel: GPUCommandGraphInspectorPanel | null = null; private capacityElement: HTMLElement | null = null; private selectionElement: HTMLElement | null = null; @@ -265,10 +341,23 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe const visibilityGeneration = (this.frameIndex % 0xfffffffe) + 1; resources.focusTraversalState.write(Uint32Array.of(this.focusDepth, visibilityGeneration)); this.writeViewUniforms(width, height, pick, visibilityGeneration, resources.dependencyCount); - const encoding = resources.compiled.encode(device.commandEncoder, {parameters: this.view}); + const encoding = this.graphObservation?.encode(device.commandEncoder, { + parameters: this.view + }); + if (!encoding) { + return; + } this.encodeTimeMilliseconds = encoding.stats.cpuEncodeTimeMilliseconds; this.frameIndex++; + if ( + (this.frameIndex === 1 || this.frameIndex % 60 === 0) && + encoding.canReadGPUTimings && + this.graphObservation + ) { + this.scheduleGPUTimingReadback(this.graphObservation, encoding); + } if (pick) { + this.recordWorkloadCounters(true); const readbackTicket = resources.readbackRing.tryAcquire(); if (readbackTicket) { this.pendingPick = null; @@ -292,6 +381,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe } else { this.droppedTelemetrySampleCount++; } + this.recordWorkloadCounters(Boolean(pick)); const dependencyCandidateReadbackTicket = resources.readbackRing.tryAcquire(); if (dependencyCandidateReadbackTicket) { dependencyCandidateReadbackTicket.copyFrom( @@ -420,6 +510,11 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe const resources = this.createResources(dataset); resources.renderBundle = this.createRenderBundle(resources); resources.compiled = this.createGraph(resources, dataset); + this.graphObservation = this.graphInspector.observeGraph(resources.compiled); + this.allocationStats = getTraceAllocationStats([ + this.viewUniformBuffer, + ...getTraceResourceBuffers(resources) + ]); this.resources = resources; this.compileCount++; this.compileTimeMilliseconds = performance.now() - started; @@ -427,6 +522,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe this.sampledDependencyCount = 0; this.sampledCandidateBatchCount = 0; this.sampledCandidateDependencyBatchCount = 0; + this.recordWorkloadCounters(); this.updateInspector(); } @@ -592,7 +688,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe dataset: TraceDatasetData ): CompiledGPUCommandGraph { const graph = new GPUCommandGraph(this.device, { - id: 'gpu-hierarchical-trace-command-graph' + id: TRACE_GRAPH_ID }); const handles = { uniforms: importTraceBuffer(graph, 'view-uniforms', this.viewUniformBuffer), @@ -1210,6 +1306,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe (_, groupIndex) => values[groupIndex * 4 + 1] ?? 0 ); this.sampledDependencyCount = values[resources.groups.length * 4 + 1] ?? 0; + this.recordWorkloadCounters(); this.updateInspector(); } catch { // Device loss and cancellation release the ring slot without affecting rendering. @@ -1226,6 +1323,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe return; } this.sampledCandidateBatchCount = new Uint32Array(bytes.buffer, bytes.byteOffset, 1)[0]; + this.recordWorkloadCounters(); this.updateInspector(); } catch { // Device loss and cancellation release the ring slot without affecting rendering. @@ -1246,6 +1344,7 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe bytes.byteOffset, 1 )[0]; + this.recordWorkloadCounters(); this.updateInspector(); } catch { // Device loss and cancellation release the ring slot without affecting rendering. @@ -1290,10 +1389,16 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe } private destroyResources(): void { + for (const timer of this.gpuTimingReadbackTimers) { + clearTimeout(timer); + } + this.gpuTimingReadbackTimers.clear(); const resources = this.resources; if (!resources) { return; } + this.graphObservation?.detach(); + this.graphObservation = null; resources.compiled.destroy(); resources.renderBundle.destroy(); resources.drawCommands.destroy(); @@ -1360,18 +1465,25 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe makeHtmlCustomPanel({ id: 'gpu-trace-stats', title: 'Live GPU graph', - html: '
', + html: '
', onRender: root => { this.capacityElement = root.querySelector('[data-capacity]'); this.selectionElement = root.querySelector('[data-selection]'); this.statsElement = root.querySelector('[data-stats]'); - this.nodesElement = root.querySelector('[data-nodes]'); + this.inspectorPanel = new GPUCommandGraphInspectorPanel( + root.querySelector('[data-command-graph-inspector]')!, + { + graphLabels: {[TRACE_GRAPH_ID]: 'Trace interaction + LOD + draw'}, + counterLabels: TRACE_INSPECTOR_COUNTER_LABELS + } + ); this.updateInspector(); return () => { this.capacityElement = null; this.selectionElement = null; this.statsElement = null; - this.nodesElement = null; + this.inspectorPanel?.destroy(); + this.inspectorPanel = null; }; } }) @@ -1602,7 +1714,12 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe return; } if (this.capacityElement) { - this.capacityElement.innerHTML = `${formatCount(this.spanCapacity)} spans · ${formatCount(resources.spanBatchCount)} batches · ${formatCount(resources.dependencyCount)}/${formatCount(this.dependencyCapacity)} dependencies · graph compile #${this.compileCount} (${this.compileTimeMilliseconds.toFixed(1)} ms)`; + const capacityContract = getTraceCapacityContract( + this.spanCapacity, + this.dependencyCapacity, + this.device.limits + ); + this.capacityElement.innerHTML = `${formatCount(this.spanCapacity)} spans · ${formatCount(resources.spanBatchCount)} batches · ${formatCount(resources.dependencyCount)}/${formatCount(this.dependencyCapacity)} dependencies · graph compile #${this.compileCount} (${this.compileTimeMilliseconds.toFixed(1)} ms)
${formatBytes(this.allocationStats.persistentByteLength)} persistent in ${formatCount(this.allocationStats.bufferCount)} buffers · largest ${formatBytes(this.allocationStats.largestBufferByteLength)} · source contract ${capacityContract.fitsDeviceLimits ? 'fits device' : 'exceeds device'}`; } if (this.selectionElement) { this.selectionElement.textContent = @@ -1636,14 +1753,56 @@ export default class GPUTraceViewerAnimationLoopTemplate extends AnimationLoopTe Physical allocations${stats.physicalTransientBufferCount}/${stats.logicalTransientBufferCount} `; } - if (this.nodesElement) { - this.nodesElement.innerHTML = stats.nodeOrder - .map( - (node, index) => - `
${String(index + 1).padStart(2, '0')} ${node}
` - ) - .join(''); + this.inspectorPanel?.update(this.graphInspector.getSnapshot(), resources.compiled.id); + } + + private recordWorkloadCounters(pickActive = this.pendingPick !== null): void { + const resources = this.resources; + const observation = this.graphObservation; + if (!resources || !observation) { + return; } + observation.recordCounters( + getTraceWorkloadCounters({ + spanCount: resources.spanCount, + dependencyCount: resources.dependencyCount, + spanBatchCount: resources.spanBatchCount, + candidateSpanBatchCount: this.sampledCandidateBatchCount, + dependencyBatchCount: resources.dependencyBatchCount, + candidateDependencyBatchCount: this.sampledCandidateDependencyBatchCount, + visibleSpanCount: this.sampledVisibleCounts.reduce((sum, count) => sum + count, 0), + visibleDependencyCount: this.sampledDependencyCount, + collapsedProcessCount: this.processStates.filter(state => state === TRACE_COLLAPSED_STATE) + .length, + densityMode: isTraceDensityMode(this.view.timeMin, this.view.timeMax, this.viewportWidth), + filterActive: + this.activeFilterMask !== 0 || + this.minimumDuration > 0 || + this.enabledMask !== 0b111 || + this.statusMask !== (1 << TRACE_STATUS_COUNT) - 1, + focusActive: this.focusOnly && this.selectedSpanIndex !== INVALID_SPAN_INDEX, + pickActive, + allocation: this.allocationStats + }) + ); + } + + private scheduleGPUTimingReadback( + observation: GPUCommandGraphInspectorObservation, + encoding: GPUCommandGraphEncoding + ): void { + const timer = setTimeout(() => { + this.gpuTimingReadbackTimers.delete(timer); + if (this.graphObservation !== observation) { + return; + } + void observation.recordGPUTimings(encoding).then(() => { + if (this.graphObservation === observation) { + this.updateInspector(); + } + }); + }, 0); + this.gpuTimingReadbackTimers.add(timer); } private getVisibleLaneCount(): number { diff --git a/examples/experimental/gpu-trace-viewer/trace-benchmark.ts b/examples/experimental/gpu-trace-viewer/trace-benchmark.ts new file mode 100644 index 0000000000..b9a9eb6121 --- /dev/null +++ b/examples/experimental/gpu-trace-viewer/trace-benchmark.ts @@ -0,0 +1,184 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {TRACE_DEPENDENCY_RECORD_WORD_LENGTH, TRACE_SPAN_RECORD_WORD_LENGTH} from './trace-data'; + +const UINT32_BYTE_LENGTH = Uint32Array.BYTES_PER_ELEMENT; + +/** Standard scale points used to compare trace interaction costs across devices and changes. */ +export const TRACE_BENCHMARK_CAPACITIES = [250_000, 1_000_000, 4_000_000, 10_000_000] as const; + +/** Stable interaction scenarios whose dispatch and allocation behavior form the 10M contract. */ +export const TRACE_BENCHMARK_SCENARIOS = [ + { + id: 'exact-expanded', + density: false, + collapsed: false, + filtered: false, + focused: false, + picking: false + }, + { + id: 'exact-collapsed', + density: false, + collapsed: true, + filtered: false, + focused: false, + picking: false + }, + { + id: 'exact-filtered', + density: false, + collapsed: false, + filtered: true, + focused: false, + picking: false + }, + { + id: 'exact-focused', + density: false, + collapsed: false, + filtered: false, + focused: true, + picking: false + }, + { + id: 'exact-picking', + density: false, + collapsed: false, + filtered: false, + focused: false, + picking: true + }, + {id: 'density', density: true, collapsed: false, filtered: false, focused: false, picking: false} +] as const; + +export type TraceBenchmarkScenario = (typeof TRACE_BENCHMARK_SCENARIOS)[number]; +export type TraceBenchmarkScenarioId = TraceBenchmarkScenario['id']; + +/** Device-limit report for the example's current monolithic source allocations. */ +export type TraceCapacityContract = { + spanCapacity: number; + dependencyCapacity: number; + spanBufferByteLength: number; + dependencyBufferByteLength: number; + largestSourceBufferByteLength: number; + maxStorageBufferBindingSize: number; + maxBufferSize: number; + fitsStorageBufferBindingSize: boolean; + fitsMaxBufferSize: boolean; + fitsDeviceLimits: boolean; +}; + +/** Persistent GPU-buffer accounting independent of command-graph transient allocations. */ +export type TraceAllocationStats = { + bufferCount: number; + persistentByteLength: number; + largestBufferByteLength: number; +}; + +export type TraceWorkloadCounterProps = { + spanCount: number; + dependencyCount: number; + spanBatchCount: number; + candidateSpanBatchCount: number; + dependencyBatchCount: number; + candidateDependencyBatchCount: number; + visibleSpanCount: number; + visibleDependencyCount: number; + collapsedProcessCount: number; + densityMode: boolean; + filterActive: boolean; + focusActive: boolean; + pickActive: boolean; + allocation: TraceAllocationStats; +}; + +/** Calculates the exact source-buffer limit required by one demonstration configuration. */ +export function getTraceCapacityContract( + spanCapacity: number, + dependencyCapacity: number, + limits: {maxStorageBufferBindingSize: number; maxBufferSize: number} +): TraceCapacityContract { + validateCount(spanCapacity); + validateCount(dependencyCapacity); + validateCount(limits.maxStorageBufferBindingSize); + validateCount(limits.maxBufferSize); + const spanBufferByteLength = spanCapacity * TRACE_SPAN_RECORD_WORD_LENGTH * UINT32_BYTE_LENGTH; + const dependencyBufferByteLength = + dependencyCapacity * TRACE_DEPENDENCY_RECORD_WORD_LENGTH * UINT32_BYTE_LENGTH; + const largestSourceBufferByteLength = Math.max(spanBufferByteLength, dependencyBufferByteLength); + const fitsStorageBufferBindingSize = + largestSourceBufferByteLength <= limits.maxStorageBufferBindingSize; + const fitsMaxBufferSize = largestSourceBufferByteLength <= limits.maxBufferSize; + return Object.freeze({ + spanCapacity, + dependencyCapacity, + spanBufferByteLength, + dependencyBufferByteLength, + largestSourceBufferByteLength, + maxStorageBufferBindingSize: limits.maxStorageBufferBindingSize, + maxBufferSize: limits.maxBufferSize, + fitsStorageBufferBindingSize, + fitsMaxBufferSize, + fitsDeviceLimits: fitsStorageBufferBindingSize && fitsMaxBufferSize + }); +} + +/** Counts unique persistent buffers without conflating them with graph-owned transient storage. */ +export function getTraceAllocationStats( + buffers: readonly {byteLength: number}[] +): TraceAllocationStats { + const uniqueBuffers = Array.from(new Set(buffers)); + let persistentByteLength = 0; + let largestBufferByteLength = 0; + for (const buffer of uniqueBuffers) { + validateCount(buffer.byteLength); + persistentByteLength += buffer.byteLength; + largestBufferByteLength = Math.max(largestBufferByteLength, buffer.byteLength); + } + return Object.freeze({ + bufferCount: uniqueBuffers.length, + persistentByteLength, + largestBufferByteLength + }); +} + +/** Publishes a stable scalar vocabulary for inspector histories and benchmark assertions. */ +export function getTraceWorkloadCounters( + props: TraceWorkloadCounterProps +): Readonly> { + return Object.freeze({ + spans: props.spanCount, + dependencies: props.dependencyCount, + 'candidate-span-batches': props.candidateSpanBatchCount, + 'candidate-span-percent': getPercentage(props.candidateSpanBatchCount, props.spanBatchCount), + 'candidate-dependency-batches': props.candidateDependencyBatchCount, + 'candidate-dependency-percent': getPercentage( + props.candidateDependencyBatchCount, + props.dependencyBatchCount + ), + 'visible-spans': props.visibleSpanCount, + 'visible-span-percent': getPercentage(props.visibleSpanCount, props.spanCount), + 'visible-dependencies': props.visibleDependencyCount, + 'persistent-bytes': props.allocation.persistentByteLength, + 'largest-buffer-bytes': props.allocation.largestBufferByteLength, + 'collapsed-processes': props.collapsedProcessCount, + 'density-mode': Number(props.densityMode), + 'filter-active': Number(props.filterActive), + 'focus-active': Number(props.focusActive), + 'pick-active': Number(props.pickActive) + }); +} + +function getPercentage(count: number, total: number): number { + return total > 0 ? (count / total) * 100 : 0; +} + +function validateCount(value: number): void { + // Trace capacities, device limits, and buffer lengths must be nonnegative safe integers. + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(); + } +} diff --git a/test/examples/gpu-trace-viewer.node.spec.ts b/test/examples/gpu-trace-viewer.node.spec.ts index c0cfeee0e6..3dfde6f1b8 100644 --- a/test/examples/gpu-trace-viewer.node.spec.ts +++ b/test/examples/gpu-trace-viewer.node.spec.ts @@ -5,12 +5,19 @@ import test from 'test/utils/vitest-tape'; import {WgslReflect} from 'wgsl_reflect'; import {getTraceRow, makeDeckTraceData} from '../../examples/deck/gpu-culled-trace/trace-data'; +import { + getTraceAllocationStats, + getTraceCapacityContract, + getTraceWorkloadCounters, + TRACE_BENCHMARK_CAPACITIES, + TRACE_BENCHMARK_SCENARIOS +} from '../../examples/experimental/gpu-trace-viewer/trace-benchmark'; import { getTraceCapacityOptions, getTraceDependencyCapacityOptions, isTraceDensityMode, - makeTraceDependencyBatches, makeTraceDataset, + makeTraceDependencyBatches, makeTraceGroups, makeTraceSpanBatches, TRACE_CROSS_PROCESS_DEPENDENCY, @@ -22,18 +29,18 @@ import { TRACE_PARENT_DEPENDENCY_FLAG, TRACE_PROCESS_COUNT, TRACE_SAME_PROCESS_DEPENDENCY, - TRACE_SPAN_RECORD_WORD_LENGTH, TRACE_SPAN_BATCH_RECORD_WORD_LENGTH, + TRACE_SPAN_RECORD_WORD_LENGTH, TRACE_THREAD_COUNT, TRACE_THREADS_PER_PROCESS } from '../../examples/experimental/gpu-trace-viewer/trace-data'; import { getBatchVisibilityShader, getCandidateDensityShader, + getCandidateDependencyVisibilityShader, getCandidatePassDispatchShader, getCandidatePickShader, getCandidateVisibilityShader, - getCandidateDependencyVisibilityShader, getDensityClearShader, getDependencyBatchVisibilityShader, getFocusFrontierClearShader, @@ -103,6 +110,95 @@ test('GPU trace capacity options adapt to negotiated WebGPU buffer limits', t => t.end(); }); +test('GPU trace supremacy contract exposes standard scales and interaction scenarios', t => { + t.deepEqual( + TRACE_BENCHMARK_CAPACITIES, + [250_000, 1_000_000, 4_000_000, 10_000_000], + 'capacity scales remain stable for comparable benchmark runs' + ); + t.deepEqual( + TRACE_BENCHMARK_SCENARIOS.map(scenario => scenario.id), + [ + 'exact-expanded', + 'exact-collapsed', + 'exact-filtered', + 'exact-focused', + 'exact-picking', + 'density' + ], + 'interaction scenarios cover hierarchy, filtering, focus, picking, and adaptive LOD' + ); + t.equal( + new Set(TRACE_BENCHMARK_SCENARIOS.map(scenario => scenario.id)).size, + TRACE_BENCHMARK_SCENARIOS.length, + 'scenario identifiers are unique' + ); + t.end(); +}); + +test('GPU trace capacity contract makes the monolithic 10M limit explicit', t => { + const portable = getTraceCapacityContract(10_000_000, 10_000_000, { + maxStorageBufferBindingSize: 128 * 1024 * 1024, + maxBufferSize: 256 * 1024 * 1024 + }); + t.equal(portable.spanBufferByteLength, 320_000_000, '10M spans require a 320 MB source buffer'); + t.equal( + portable.dependencyBufferByteLength, + 160_000_000, + '10M dependencies require a 160 MB source buffer' + ); + t.equal(portable.fitsDeviceLimits, false, 'portable limits reject the monolithic 10M source'); + + const maximum = getTraceCapacityContract(10_000_000, 10_000_000, { + maxStorageBufferBindingSize: 1024 * 1024 * 1024, + maxBufferSize: 1024 * 1024 * 1024 + }); + t.equal(maximum.fitsDeviceLimits, true, 'maximum-context limits admit the same source layout'); + t.end(); +}); + +test('GPU trace workload counters report persistent memory and proportional work', t => { + const firstBuffer = {byteLength: 320}; + const allocation = getTraceAllocationStats([ + firstBuffer, + firstBuffer, + {byteLength: 160}, + {byteLength: 40} + ]); + t.deepEqual( + allocation, + {bufferCount: 3, persistentByteLength: 520, largestBufferByteLength: 320}, + 'allocation accounting deduplicates shared buffer identities' + ); + const counters = getTraceWorkloadCounters({ + spanCount: 1000, + dependencyCount: 400, + spanBatchCount: 10, + candidateSpanBatchCount: 2, + dependencyBatchCount: 8, + candidateDependencyBatchCount: 2, + visibleSpanCount: 50, + visibleDependencyCount: 12, + collapsedProcessCount: 1, + densityMode: false, + filterActive: true, + focusActive: true, + pickActive: false, + allocation + }); + t.equal(counters['candidate-span-percent'], 20, 'span work is reported as a candidate ratio'); + t.equal( + counters['candidate-dependency-percent'], + 25, + 'dependency work is reported as a candidate ratio' + ); + t.equal(counters['visible-span-percent'], 5, 'visible output is normalized by source size'); + t.equal(counters['persistent-bytes'], 520, 'persistent memory uses exact buffer accounting'); + t.equal(counters['filter-active'], 1, 'interaction modes are exposed as numeric counters'); + t.equal(counters['pick-active'], 0, 'inactive interaction modes remain explicit'); + t.end(); +}); + test('GPU trace LOD switches at a stable trace-time-per-pixel threshold', t => { t.equal(isTraceDensityMode(0, 150, 2048), false, 'wide viewport keeps exact spans'); t.equal(isTraceDensityMode(0, 150, 1), true, 'zoomed-out viewport uses density bins'); diff --git a/test/examples/gpu-trace-viewer.spec.ts b/test/examples/gpu-trace-viewer.spec.ts index b080009612..6e791f5b9a 100644 --- a/test/examples/gpu-trace-viewer.spec.ts +++ b/test/examples/gpu-trace-viewer.spec.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors -import {describe, expect, test} from 'vitest'; import type {AnimationProps} from '@luma.gl/engine'; import {getWebGPUTestDevice} from '@luma.gl/test-utils'; +import {describe, expect, test} from 'vitest'; import GPUTraceViewerAnimationLoopTemplate from '../../examples/experimental/gpu-trace-viewer/app'; import { TRACE_COLLAPSED_STATE, @@ -70,6 +70,15 @@ describe('GPU hierarchical trace viewer', () => { dependencyCapacity: number; compileCount: number; frameIndex: number; + pendingPick: {x: number; y: number; requestIdentifier: number} | null; + graphInspector: { + getSnapshot: () => { + graphs: Array<{ + encodingCount: number; + counters: Array<{id: string; latestValue: number}>; + }>; + }; + }; }; expect(state.resources.spanCount).toBe(4096); expect(state.resources.spanBatchCount).toBeGreaterThan(0); @@ -93,6 +102,26 @@ describe('GPU hierarchical trace viewer', () => { viewer.onRender({device, time: 6000, width: 2048, height: 1} as AnimationProps); device.submit(); + const graphInspection = state.graphInspector.getSnapshot().graphs[0]; + expect(graphInspection.encodingCount).toBe(1); + expect( + graphInspection.counters.find(counter => counter.id === 'persistent-bytes')?.latestValue + ).toBeGreaterThan(0); + expect( + graphInspection.counters.find(counter => counter.id === 'largest-buffer-bytes')?.latestValue + ).toBeGreaterThan(0); + expect( + graphInspection.counters.find(counter => counter.id === 'candidate-span-percent') + ?.latestValue + ).toBe(0); + state.pendingPick = {x: 0, y: 0, requestIdentifier: 1}; + viewer.onRender({device, time: 6000, width: 2048, height: 1} as AnimationProps); + device.submit(); + expect( + state.graphInspector + .getSnapshot() + .graphs[0].counters.find(counter => counter.id === 'pick-active')?.latestValue + ).toBe(1); const firstFrame = await state.resources.drawCommands.buffer.readAsync(); const firstCounts = new Uint32Array( firstFrame.buffer,