TripoSplat-WebGPU / src /workers /onnxWorker.ts
Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
19.7 kB
/// <reference lib="WebWorker" />
import * as ort from 'onnxruntime-web/webgpu'
import { assertModelManifest } from '../runtime/modelManifest'
import type { OnnxModelManifest } from '../runtime/modelManifest'
import type {
OrtConfigureRuntimeResult,
OrtExecutionProvider,
OrtLoadSessionRequest,
OrtLoadSessionResult,
OrtRunSessionRequest,
OrtRunSessionResult,
OrtRuntimeConfiguration,
OrtSessionLoadOptions,
OrtValueMetadata,
OrtWorkerOperation,
OrtWorkerReply,
OrtWorkerRequest,
OrtWorkerResultMap,
OrtWorkerStage,
OrtWorkerStatus,
SerializedWorkerError,
} from '../runtime/OrtWorkerClient'
import type { TensorPayload, TensorPayloadMap } from '../runtime/tensors'
import {
assertTensorPayloadMap,
createTensorPayload,
tensorPayloadTransferables,
} from '../runtime/tensors'
const workerScope = self as DedicatedWorkerGlobalScope
interface LoadedSession {
session: ort.InferenceSession
executionProvider: OrtExecutionProvider
metadata: Omit<OrtLoadSessionResult, 'loadMs'>
loadMs: number
}
interface SessionRecord {
fingerprint: string
loading: Promise<LoadedSession>
runTail: Promise<void>
disposed: boolean
}
const sessions = new Map<string, SessionRecord>()
let runtimeConfiguration: OrtConfigureRuntimeResult | undefined
function serializeError(error: unknown): SerializedWorkerError {
if (error instanceof Error) {
return { name: error.name, message: error.message, stack: error.stack }
}
return { name: 'Error', message: String(error) }
}
function postMessageSafe(message: OrtWorkerReply | OrtWorkerStatus, transfer: Transferable[] = []): void {
workerScope.postMessage(message, transfer)
}
function postStatus(
stage: OrtWorkerStage,
message: string,
requestId?: string,
sessionId?: string,
executionProvider?: OrtExecutionProvider,
progress?: number,
): void {
postMessageSafe({
type: 'status',
stage,
message,
timestampMs: Date.now(),
requestId,
sessionId,
executionProvider,
progress,
})
}
function postSuccess<Operation extends OrtWorkerOperation>(
operation: Operation,
requestId: string,
result: OrtWorkerResultMap[Operation],
transfer: Transferable[] = [],
): void {
const reply = {
type: 'reply',
operation,
requestId,
ok: true,
result,
} as OrtWorkerReply
postMessageSafe(reply, transfer)
}
function postError(operation: OrtWorkerOperation, requestId: string, error: unknown): void {
const reply = {
type: 'reply',
operation,
requestId,
ok: false,
error: serializeError(error),
} as OrtWorkerReply
postMessageSafe(reply)
}
function assertSessionId(sessionId: unknown): asserts sessionId is string {
if (typeof sessionId !== 'string' || sessionId.trim().length === 0 || sessionId.includes('\0')) {
throw new TypeError('sessionId must be a non-empty string without null characters.')
}
}
function defaultRuntimeConfiguration(): OrtConfigureRuntimeResult {
const baseUrl = new URL(`${import.meta.env.BASE_URL}ort/`, workerScope.location.origin).href
return {
wasmThreads: workerScope.crossOriginIsolated
? Math.max(1, Math.min(4, workerScope.navigator.hardwareConcurrency || 2))
: 1,
wasmSimd: true,
wasmPaths: {
mjs: new URL('ort-wasm-simd-threaded.asyncify.mjs', baseUrl).href,
wasm: new URL('ort-wasm-simd-threaded.asyncify.wasm', baseUrl).href,
},
}
}
function normalizeRuntimeConfiguration(configuration: OrtRuntimeConfiguration): OrtConfigureRuntimeResult {
const defaults = defaultRuntimeConfiguration()
const wasmThreads = configuration.wasmThreads ?? defaults.wasmThreads
if (!Number.isInteger(wasmThreads) || wasmThreads < 1) {
throw new RangeError('wasmThreads must be a positive integer.')
}
const wasmSimd = configuration.wasmSimd ?? defaults.wasmSimd
if (
typeof wasmSimd !== 'boolean'
&& wasmSimd !== 'fixed'
&& wasmSimd !== 'relaxed'
) {
throw new TypeError("wasmSimd must be boolean, 'fixed', or 'relaxed'.")
}
let wasmPaths: OrtConfigureRuntimeResult['wasmPaths']
if (configuration.wasmPaths === undefined) {
wasmPaths = defaults.wasmPaths
} else if (typeof configuration.wasmPaths === 'string') {
if (configuration.wasmPaths.trim().length === 0) {
throw new TypeError('wasmPaths prefix must be non-empty.')
}
wasmPaths = new URL(configuration.wasmPaths, workerScope.location.href).href
} else {
if (configuration.wasmPaths.mjs === undefined && configuration.wasmPaths.wasm === undefined) {
throw new TypeError('wasmPaths must include at least one of mjs or wasm.')
}
wasmPaths = {
mjs: configuration.wasmPaths.mjs === undefined
? undefined
: new URL(configuration.wasmPaths.mjs, workerScope.location.href).href,
wasm: configuration.wasmPaths.wasm === undefined
? undefined
: new URL(configuration.wasmPaths.wasm, workerScope.location.href).href,
}
}
return { wasmThreads, wasmSimd, wasmPaths }
}
function configureRuntime(
configuration: OrtRuntimeConfiguration,
requestId?: string,
): OrtConfigureRuntimeResult {
const normalized = normalizeRuntimeConfiguration(configuration)
if (runtimeConfiguration) {
if (JSON.stringify(runtimeConfiguration) !== JSON.stringify(normalized)) {
throw new Error('ONNX Runtime is already configured; start a new worker to use different WASM settings.')
}
return runtimeConfiguration
}
postStatus('runtime-configuring', 'Configuring ONNX Runtime.', requestId)
ort.env.wasm.numThreads = normalized.wasmThreads
ort.env.wasm.simd = normalized.wasmSimd
ort.env.wasm.wasmPaths = normalized.wasmPaths
runtimeConfiguration = normalized
postStatus('runtime-ready', 'ONNX Runtime is configured.', requestId)
return normalized
}
function ensureRuntimeConfigured(requestId: string): OrtConfigureRuntimeResult {
return runtimeConfiguration ?? configureRuntime({}, requestId)
}
function toMetadata(metadata: readonly ort.InferenceSession.ValueMetadata[]): OrtValueMetadata[] {
return metadata.map((value) => value.isTensor
? {
name: value.name,
isTensor: true,
type: value.type,
shape: Array.from(value.shape),
}
: { name: value.name, isTensor: false })
}
function sessionFingerprint(request: OrtLoadSessionRequest): string {
return JSON.stringify({ manifest: request.manifest, options: request.options ?? {} })
}
function createSessionOptions(
manifest: OnnxModelManifest,
options: OrtSessionLoadOptions | undefined,
provider: OrtExecutionProvider,
): ort.InferenceSession.SessionOptions {
const common: ort.InferenceSession.SessionOptions = {
graphOptimizationLevel: options?.graphOptimizationLevel ?? 'all',
preferredOutputLocation: 'cpu',
externalData: manifest.externalData?.map(({ path, url }) => ({ path, data: url })),
}
if (options?.freeDimensionOverrides) {
common.freeDimensionOverrides = options.freeDimensionOverrides
}
if (options?.logSeverityLevel !== undefined) {
common.logSeverityLevel = options.logSeverityLevel
}
if (provider === 'wasm') {
common.executionProviders = ['wasm']
return common
}
const webgpu: ort.InferenceSession.WebGpuExecutionProviderOption = {
name: 'webgpu',
preferredLayout: options?.webgpu?.preferredLayout,
forceCpuNodeNames: options?.webgpu?.forceCpuNodeNames,
validationMode: options?.webgpu?.validationMode,
}
common.executionProviders = [webgpu]
if (options?.enableGraphCapture !== undefined) {
common.enableGraphCapture = options.enableGraphCapture
}
return common
}
async function loadOrtSession(request: OrtLoadSessionRequest, requestId: string): Promise<LoadedSession> {
ensureRuntimeConfigured(requestId)
const { sessionId, manifest, options } = request
const startedAt = performance.now()
postStatus('session-loading', `Loading ONNX session '${sessionId}'.`, requestId, sessionId, 'webgpu')
const heartbeatStartedAt = performance.now()
const heartbeat = setInterval(() => {
const seconds = Math.floor((performance.now() - heartbeatStartedAt) / 1000)
postStatus(
'session-loading',
`Loading ONNX session '${sessionId}' (${seconds}s elapsed).`,
requestId,
sessionId,
'webgpu',
)
}, 1000)
let session: ort.InferenceSession
let executionProvider: OrtExecutionProvider = 'webgpu'
try {
try {
session = await ort.InferenceSession.create(
manifest.graphUrl,
createSessionOptions(manifest, options, 'webgpu'),
)
} catch (webgpuError) {
if (!options?.allowWasmFallback) {
throw webgpuError
}
executionProvider = 'wasm'
postStatus(
'session-fallback',
`WebGPU could not load '${sessionId}'; retrying with WASM.`,
requestId,
sessionId,
'wasm',
)
try {
session = await ort.InferenceSession.create(
manifest.graphUrl,
createSessionOptions(manifest, options, 'wasm'),
)
} catch (wasmError) {
throw new Error(
`Could not create '${sessionId}' with WebGPU (${String(webgpuError)}) or WASM (${String(wasmError)}).`,
)
}
}
} finally {
clearInterval(heartbeat)
}
const loadMs = performance.now() - startedAt
const metadata: Omit<OrtLoadSessionResult, 'loadMs'> = {
sessionId,
executionProvider,
inputNames: Array.from(session.inputNames),
outputNames: Array.from(session.outputNames),
inputMetadata: toMetadata(session.inputMetadata),
outputMetadata: toMetadata(session.outputMetadata),
}
postStatus(
'session-ready',
`ONNX session '${sessionId}' is ready.`,
requestId,
sessionId,
executionProvider,
1,
)
return { session, executionProvider, metadata, loadMs }
}
async function loadSession(request: OrtLoadSessionRequest, requestId: string): Promise<OrtLoadSessionResult> {
assertSessionId(request.sessionId)
assertModelManifest(request.manifest)
const fingerprint = sessionFingerprint(request)
const existing = sessions.get(request.sessionId)
if (existing) {
if (existing.fingerprint !== fingerprint) {
throw new Error(`Session '${request.sessionId}' is already loaded with a different manifest or options.`)
}
const loaded = await existing.loading
postStatus(
'session-ready',
`ONNX session '${request.sessionId}' was already loaded.`,
requestId,
request.sessionId,
loaded.executionProvider,
1,
)
return { ...loaded.metadata, loadMs: loaded.loadMs }
}
const record: SessionRecord = {
fingerprint,
loading: Promise.resolve(undefined as never),
runTail: Promise.resolve(),
disposed: false,
}
record.loading = loadOrtSession(request, requestId).catch((error: unknown) => {
if (sessions.get(request.sessionId) === record) {
sessions.delete(request.sessionId)
}
throw error
})
sessions.set(request.sessionId, record)
const loaded = await record.loading
return { ...loaded.metadata, loadMs: loaded.loadMs }
}
function toOrtTensor(payload: TensorPayload): ort.Tensor {
switch (payload.type) {
case 'float32':
return new ort.Tensor('float32', payload.data, payload.dims)
case 'float16':
return new ort.Tensor('float16', payload.data, payload.dims)
case 'int32':
return new ort.Tensor('int32', payload.data, payload.dims)
case 'int64':
return new ort.Tensor('int64', payload.data, payload.dims)
}
}
async function outputTensorPayload(name: string, tensor: ort.Tensor): Promise<TensorPayload> {
const data = await tensor.getData(true)
switch (tensor.type) {
case 'float32': {
if (!(data instanceof Float32Array)) {
throw new TypeError(`Output '${name}' declared float32 but returned a different storage type.`)
}
const copy = new Float32Array(data.length)
copy.set(data)
return createTensorPayload('float32', copy, tensor.dims)
}
case 'float16': {
if (!(data instanceof Uint16Array)) {
throw new TypeError(`Output '${name}' declared float16 but returned a different storage type.`)
}
const copy = new Uint16Array(data.length)
copy.set(data)
return createTensorPayload('float16', copy, tensor.dims)
}
case 'int32': {
if (!(data instanceof Int32Array)) {
throw new TypeError(`Output '${name}' declared int32 but returned a different storage type.`)
}
const copy = new Int32Array(data.length)
copy.set(data)
return createTensorPayload('int32', copy, tensor.dims)
}
case 'int64': {
if (!(data instanceof BigInt64Array)) {
throw new TypeError(`Output '${name}' declared int64 but returned a different storage type.`)
}
const copy = new BigInt64Array(data.length)
copy.set(data)
return createTensorPayload('int64', copy, tensor.dims)
}
default:
throw new TypeError(
`Output '${name}' uses unsupported dtype '${tensor.type}'. Supported worker payloads are float32, float16, int32, and int64.`,
)
}
}
function enqueueSessionRun<T>(record: SessionRecord, task: () => Promise<T>): Promise<T> {
const result = record.runTail.then(task)
record.runTail = result.then(() => undefined, () => undefined)
return result
}
async function runSession(request: OrtRunSessionRequest, requestId: string): Promise<OrtRunSessionResult> {
assertSessionId(request.sessionId)
assertTensorPayloadMap(request.inputs, 'request.inputs')
const record = sessions.get(request.sessionId)
if (!record || record.disposed) {
throw new Error(`ONNX session '${request.sessionId}' is not loaded.`)
}
postStatus('inference-queued', `Queued inference for '${request.sessionId}'.`, requestId, request.sessionId)
return enqueueSessionRun(record, async () => {
if (record.disposed) {
throw new Error(`ONNX session '${request.sessionId}' was disposed before inference started.`)
}
const loaded = await record.loading
const requestedOutputs = request.outputs === undefined ? undefined : Array.from(request.outputs)
if (requestedOutputs) {
const available = new Set(loaded.session.outputNames)
const seen = new Set<string>()
for (const output of requestedOutputs) {
if (typeof output !== 'string' || output.trim().length === 0 || seen.has(output)) {
throw new TypeError('Requested output names must be unique, non-empty strings.')
}
if (!available.has(output)) {
throw new Error(
`Session '${request.sessionId}' has no output '${output}'. Available outputs: ${loaded.session.outputNames.join(', ')}.`,
)
}
seen.add(output)
}
}
const feeds: Record<string, ort.Tensor> = {}
for (const [name, payload] of Object.entries(request.inputs)) {
feeds[name] = toOrtTensor(payload)
}
const totalStartedAt = performance.now()
const inferenceStartedAt = performance.now()
postStatus(
'inference-running',
`Running inference for '${request.sessionId}'.`,
requestId,
request.sessionId,
loaded.executionProvider,
)
let ortOutputs: ort.InferenceSession.ReturnType
try {
const runOptions: ort.InferenceSession.RunOptions = request.tag ? { tag: request.tag } : {}
ortOutputs = requestedOutputs === undefined
? await loaded.session.run(feeds, runOptions)
: await loaded.session.run(feeds, requestedOutputs, runOptions)
} finally {
for (const tensor of Object.values(feeds)) {
tensor.dispose()
}
}
const inferenceMs = performance.now() - inferenceStartedAt
const readbackStartedAt = performance.now()
const entries = Object.entries(ortOutputs)
postStatus(
'outputs-reading',
`Reading ${entries.length} output tensor${entries.length === 1 ? '' : 's'} from '${request.sessionId}'.`,
requestId,
request.sessionId,
loaded.executionProvider,
entries.length === 0 ? 1 : 0,
)
const outputEntries = await Promise.all(entries.map(async ([name, tensor], index) => {
try {
const payload = await outputTensorPayload(name, tensor)
postStatus(
'outputs-reading',
`Read output '${name}' from '${request.sessionId}'.`,
requestId,
request.sessionId,
loaded.executionProvider,
entries.length === 0 ? 1 : (index + 1) / entries.length,
)
return [name, payload] as const
} finally {
tensor.dispose()
}
}))
const outputs = Object.fromEntries(outputEntries) as TensorPayloadMap
const readbackMs = performance.now() - readbackStartedAt
const totalMs = performance.now() - totalStartedAt
postStatus(
'inference-complete',
`Inference for '${request.sessionId}' completed.`,
requestId,
request.sessionId,
loaded.executionProvider,
1,
)
return {
sessionId: request.sessionId,
outputs,
timings: { inferenceMs, readbackMs, totalMs },
}
})
}
async function disposeSession(sessionId: string, requestId?: string): Promise<boolean> {
assertSessionId(sessionId)
const record = sessions.get(sessionId)
if (!record) {
return false
}
record.disposed = true
sessions.delete(sessionId)
postStatus('session-disposing', `Disposing ONNX session '${sessionId}'.`, requestId, sessionId)
await record.runTail
let loaded: LoadedSession | undefined
try {
loaded = await record.loading
} catch {
// A failed load has no session resources left to release. Preserve disposal semantics.
}
if (loaded) {
await loaded.session.release()
}
postStatus('session-disposed', `Disposed ONNX session '${sessionId}'.`, requestId, sessionId, undefined, 1)
return true
}
async function disposeAll(requestId: string): Promise<string[]> {
const sessionIds = Array.from(sessions.keys())
postStatus('worker-disposing', `Disposing ${sessionIds.length} ONNX session(s).`, requestId)
await Promise.all(sessionIds.map((sessionId) => disposeSession(sessionId, requestId)))
postStatus('worker-disposed', 'All ONNX sessions are disposed.', requestId, undefined, undefined, 1)
return sessionIds
}
async function dispatch(request: OrtWorkerRequest): Promise<void> {
try {
switch (request.type) {
case 'configure-runtime': {
const result = configureRuntime(request.payload, request.requestId)
postSuccess(request.type, request.requestId, result)
return
}
case 'load-session': {
const result = await loadSession(request.payload, request.requestId)
postSuccess(request.type, request.requestId, result)
return
}
case 'run-session': {
const result = await runSession(request.payload, request.requestId)
postSuccess(request.type, request.requestId, result, tensorPayloadTransferables(result.outputs))
return
}
case 'dispose-session': {
const disposed = await disposeSession(request.payload.sessionId, request.requestId)
postSuccess(request.type, request.requestId, { sessionId: request.payload.sessionId, disposed })
return
}
case 'dispose-all': {
const disposedSessionIds = await disposeAll(request.requestId)
postSuccess(request.type, request.requestId, { disposedSessionIds })
return
}
}
} catch (error) {
postError(request.type, request.requestId, error)
}
}
workerScope.onmessage = (event: MessageEvent<OrtWorkerRequest>) => {
void dispatch(event.data)
}