TripoSplat-WebGPU / src /encoderLabMain.tsx
Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
13.1 kB
import { StrictMode, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { createVaeEncoderSliceManifest } from './models/triposplat/manifests'
import { compositeRgbaOnBlack, imageBitmapToRgba } from './models/triposplat/preprocess'
import { compareFloat32, type TensorComparison } from './models/triposplat/tensorMath'
import { TripoSplatWebGPUModel } from './models/triposplat/TripoSplatWebGPUModel'
import type { OrtRunTimings, OrtWorkerStatus } from './runtime/OrtWorkerClient'
const DEFAULT_MODEL = '/models/triposplat/flux2_vae_encoder.onnx'
const DEFAULT_FIXTURE = '/fixtures/generated/flux2-vae-fp32'
interface EncoderLabResult {
passed: boolean
executionProvider: string
modelLoadMs: number
endToEndEncodeMs: number
runtime: OrtRunTimings
comparison: TensorComparison
preprocessing: ByteComparison
runs: EncoderLabRun[]
medianInferenceMs: number
medianEndToEndEncodeMs: number
modelTransferBytes?: number
memory: {
beforeLoad: BrowserMemorySnapshot
afterLoad: BrowserMemorySnapshot
afterRuns: BrowserMemorySnapshot
}
environment: BrowserEnvironment
tolerance: { maxAbsoluteError: number; minimumCosineSimilarity: number }
}
interface EncoderLabRun {
run: number
endToEndEncodeMs: number
runtime: OrtRunTimings
comparison: TensorComparison
}
interface ByteComparison {
count: number
mismatched: number
maxAbsoluteError: number
meanAbsoluteError: number
passed: boolean
}
interface BrowserMemorySnapshot {
source: 'measureUserAgentSpecificMemory' | 'performance.memory' | 'unavailable'
bytes?: number
usedJsHeapBytes?: number
totalJsHeapBytes?: number
jsHeapLimitBytes?: number
}
interface BrowserEnvironment {
userAgent: string
platform: string
crossOriginIsolated: boolean
webgpu: boolean
adapter?: {
vendor?: string
architecture?: string
device?: string
description?: string
}
}
type PerformanceWithMemory = Performance & {
memory?: {
usedJSHeapSize: number
totalJSHeapSize: number
jsHeapSizeLimit: number
}
}
type NavigatorWithGpu = Navigator & {
gpu?: {
requestAdapter: () => Promise<{
info?: {
vendor?: string
architecture?: string
device?: string
description?: string
}
} | null>
}
}
const BENCHMARK_RUNS = 3
declare global {
interface Window {
__TRIPOSPLAT_ENCODER_RESULT__?: EncoderLabResult
}
}
async function fetchFloat32(url: string): Promise<Float32Array> {
const response = await fetch(url)
if (!response.ok) throw new Error(`Could not fetch ${url}: HTTP ${response.status}`)
const buffer = await response.arrayBuffer()
if (buffer.byteLength % 4 !== 0) throw new Error(`${url} is not aligned float32 data.`)
return new Float32Array(buffer)
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
function compareBytes(reference: Uint8ClampedArray, candidate: Uint8ClampedArray): ByteComparison {
if (reference.length !== candidate.length) {
throw new Error(`Prepared RGB length ${candidate.length} does not match Python ${reference.length}.`)
}
let mismatched = 0
let maxAbsoluteError = 0
let absoluteErrorSum = 0
for (let index = 0; index < reference.length; index += 1) {
const error = Math.abs(reference[index] - candidate[index])
if (error !== 0) mismatched += 1
maxAbsoluteError = Math.max(maxAbsoluteError, error)
absoluteErrorSum += error
}
const meanAbsoluteError = absoluteErrorSum / reference.length
return {
count: reference.length,
mismatched,
maxAbsoluteError,
meanAbsoluteError,
passed: mismatched === 0,
}
}
async function measureBrowserMemory(): Promise<BrowserMemorySnapshot> {
const browserPerformance = performance as PerformanceWithMemory
// `measureUserAgentSpecificMemory()` can interrupt a live ORT worker in
// Chromium. Keep the parity gate non-invasive and report the narrower main
// realm heap counter with its limitation instead.
if (browserPerformance.memory) {
return {
source: 'performance.memory',
usedJsHeapBytes: browserPerformance.memory.usedJSHeapSize,
totalJsHeapBytes: browserPerformance.memory.totalJSHeapSize,
jsHeapLimitBytes: browserPerformance.memory.jsHeapSizeLimit,
}
}
return { source: 'unavailable' }
}
async function inspectBrowserEnvironment(): Promise<BrowserEnvironment> {
const gpu = (navigator as NavigatorWithGpu).gpu
const adapter = gpu ? await gpu.requestAdapter() : null
const info = adapter?.info
return {
userAgent: navigator.userAgent,
platform: navigator.platform,
crossOriginIsolated: self.crossOriginIsolated,
webgpu: Boolean(gpu),
adapter: info ? {
vendor: info.vendor,
architecture: info.architecture,
device: info.device,
description: info.description,
} : undefined,
}
}
async function contentLength(url: string): Promise<number | undefined> {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return undefined
const value = Number(response.headers.get('content-length'))
return Number.isFinite(value) && value >= 0 ? value : undefined
} catch {
return undefined
}
}
export function EncoderLab() {
const modelRef = useRef<TripoSplatWebGPUModel | null>(null)
const [modelUrl, setModelUrl] = useState(DEFAULT_MODEL)
const [fixtureUrl, setFixtureUrl] = useState(DEFAULT_FIXTURE)
const [status, setStatus] = useState('Ready to run the recorded PyTorch fixture.')
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<EncoderLabResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => () => {
const model = modelRef.current
modelRef.current = null
if (model) void model.dispose()
}, [])
const run = async () => {
if (busy) return
setBusy(true)
setError(null)
setResult(null)
delete window.__TRIPOSPLAT_ENCODER_RESULT__
let executionProvider = 'unknown'
const onRuntimeStatus = (event: OrtWorkerStatus) => {
setStatus(event.message)
if (event.executionProvider) executionProvider = event.executionProvider
}
try {
const previous = modelRef.current
modelRef.current = null
if (previous) await previous.dispose()
const model = new TripoSplatWebGPUModel({
graphs: createVaeEncoderSliceManifest(modelUrl, 'float32'),
allowWasmFallback: false,
onRuntimeStatus,
})
modelRef.current = model
setStatus('Fetching the prepared image, explicit epsilon and PyTorch reference…')
const [imageResponse, preparedResponse, epsilon, reference, environment] = await Promise.all([
fetch(`${fixtureUrl}/source.png`),
fetch(`${fixtureUrl}/prepared.png`),
fetchFloat32(`${fixtureUrl}/epsilon.f32`),
fetchFloat32(`${fixtureUrl}/feature2.f32`),
inspectBrowserEnvironment(),
])
if (!imageResponse.ok) throw new Error(`Could not fetch fixture image: HTTP ${imageResponse.status}`)
if (!preparedResponse.ok) throw new Error(`Could not fetch prepared reference: HTTP ${preparedResponse.status}`)
const imageBlob = await imageResponse.blob()
const preparedBitmap = await createImageBitmap(await preparedResponse.blob())
const preparedReference = (() => {
try {
return compositeRgbaOnBlack(imageBitmapToRgba(preparedBitmap)).data
} finally {
preparedBitmap.close()
}
})()
const manifest = model.graphs.vaeEncoder?.manifest
const transferLengths = manifest
? await Promise.all([
contentLength(manifest.graphUrl),
...(manifest.externalData ?? []).map(({ url }) => contentLength(url)),
])
: []
const modelTransferBytes = transferLengths.length > 0 && transferLengths.every((value) => value !== undefined)
? transferLengths.reduce<number>((total, value) => total + (value ?? 0), 0)
: undefined
const beforeLoad = await measureBrowserMemory()
const loadStarted = performance.now()
await model.load()
const modelLoadMs = performance.now() - loadStarted
const afterLoad = await measureBrowserMemory()
const runs: EncoderLabRun[] = []
let preparedCandidate: Uint8ClampedArray | undefined
for (let run = 1; run <= BENCHMARK_RUNS; run += 1) {
const bitmap = await createImageBitmap(imageBlob)
const encodeStarted = performance.now()
const encoded = await (async () => {
try {
return await model.encode(bitmap, {
vaeNoise: epsilon,
onProgress: (progress) => setStatus(`Run ${run}/${BENCHMARK_RUNS}: ${progress.message}`),
})
} finally {
bitmap.close()
}
})()
const endToEndEncodeMs = performance.now() - encodeStarted
if (!encoded.feature2 || !encoded.timings.vaeEncoder) {
throw new Error('The VAE slice returned no feature2 tensor or runtime timings.')
}
if (!preparedCandidate) preparedCandidate = new Uint8ClampedArray(encoded.preparedImage.data)
runs.push({
run,
endToEndEncodeMs,
runtime: encoded.timings.vaeEncoder,
comparison: compareFloat32(reference, encoded.feature2),
})
}
const afterRuns = await measureBrowserMemory()
const finalRun = runs[runs.length - 1]
if (!preparedCandidate) throw new Error('Browser preprocessing returned no prepared RGB image.')
const preprocessing = compareBytes(preparedReference, preparedCandidate)
const tolerance = { maxAbsoluteError: 0.006, minimumCosineSimilarity: 0.99999 }
const nextResult: EncoderLabResult = {
passed: preprocessing.passed && runs.every(({ comparison }) =>
comparison.finite &&
comparison.maxAbsoluteError <= tolerance.maxAbsoluteError &&
comparison.cosineSimilarity >= tolerance.minimumCosineSimilarity),
executionProvider,
modelLoadMs,
endToEndEncodeMs: finalRun.endToEndEncodeMs,
runtime: finalRun.runtime,
comparison: finalRun.comparison,
preprocessing,
runs,
medianInferenceMs: median(runs.map(({ runtime }) => runtime.inferenceMs)),
medianEndToEndEncodeMs: median(runs.map(({ endToEndEncodeMs }) => endToEndEncodeMs)),
modelTransferBytes,
memory: { beforeLoad, afterLoad, afterRuns },
environment,
tolerance,
}
window.__TRIPOSPLAT_ENCODER_RESULT__ = nextResult
setResult(nextResult)
setStatus(nextResult.passed ? 'PASS: WebGPU output matches the recorded PyTorch tensor.' : 'FAIL: tensor drift exceeds tolerance.')
} catch (caught) {
const message = caught instanceof Error ? caught.message : String(caught)
setError(message)
setStatus('Encoder validation failed.')
} finally {
setBusy(false)
}
}
return (
<main>
<h1>TripoSplat Flux VAE · WebGPU parity</h1>
<p>This page runs the recorded alpha-present source image through browser preprocessing, then sends the resulting 1024² tensor and explicit epsilon through ONNX Runtime WebGPU.</p>
<label>ONNX graph <input value={modelUrl} onChange={(event) => setModelUrl(event.target.value)} /></label>
<label>Fixture directory <input value={fixtureUrl} onChange={(event) => setFixtureUrl(event.target.value)} /></label>
<button type="button" disabled={busy} onClick={() => void run()}>{busy ? 'Running…' : 'Run parity gate'}</button>
<p role="status" data-testid="encoder-status">{status}</p>
{error ? <pre className="error" data-testid="encoder-error">{error}</pre> : null}
{result ? <pre data-testid="encoder-result">{JSON.stringify(result, null, 2)}</pre> : null}
</main>
)
}
const style = document.createElement('style')
style.textContent = `
:root { color: #ececf3; background: #101014; font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
body { margin: 0; }
main { max-width: 920px; margin: 0 auto; padding: 48px 24px; }
h1 { font: 600 28px/1.2 system-ui, sans-serif; }
label { display: grid; gap: 6px; margin: 18px 0; }
input { box-sizing: border-box; width: 100%; padding: 10px; color: inherit; background: #1b1b22; border: 1px solid #3a3a48; border-radius: 6px; }
button { padding: 10px 16px; color: #08080a; background: #f8cf00; border: 0; border-radius: 6px; font-weight: 700; cursor: pointer; }
button:disabled { opacity: .55; cursor: wait; }
pre { overflow: auto; padding: 16px; background: #18181f; border-radius: 8px; }
.error { color: #ff9b9b; }
`
document.head.append(style)
createRoot(document.getElementById('root')!).render(
<StrictMode>
<EncoderLab />
</StrictMode>,
)