Operating it

Performance and parallelism

Workers, threads and pages in flight — what each one overlaps, what onnxruntime-web allows, and what the measurements say to set.

Work can overlap at three levels. They compose, and each is set explicitly:

LevelSettingWhat it overlapsReloads models?
Pages in one pipelinepagesInFlight (run option)Stages waiting on different things: pdf.js parsing, a network call, a GPU queueNo
One stagemaxConcurrency (the stage), stages[id].maxConcurrency (run option, lowers it only)Several calls of a stage that can take them, e.g. a cloud NERNo
RealmscreateScaleDpWorkerPool({ size, threadsPerWorker })CPU-bound inference, one worker per core groupEach worker loads its own copy

Plus the session-level settings, per stage, through StageDescriptor.runtime.executionProviders, e.g. GLiNER on WebGPU and OCR on WASM.

What onnxruntime-web allows

Some of this is fixed by the runtime, not by this library:

  • One inference per session at a time. A second run() on a busy session fails with "Session already started". Stages holding a session therefore declare maxConcurrency: 1, and stages sharing one claim its name through resources() (every PaddleOCR stage on the full service shares ort:paddle). The scheduler queues them, oldest page first.
  • numThreads is per realm, fixed at the first session. Two stages in one worker cannot have different thread counts, and changing it later is ignored. A different thread count means a different worker, which is why the pool takes threadsPerWorker.
  • Threads need cross-origin isolation. Without COOP/COEP headers ORT runs single-threaded and says nothing. client.configure() resolves with { numThreads, crossOriginIsolated, webgpu }; check it rather than assume. A site on GitHub Pages cannot set those headers.
  • In one worker, CPU inference does not overlap. Two WASM inferences share one thread pool. pagesInFlight > 1 only gains where a stage waits on something other than that pool.

Measured

Full results, charted, are on the Benchmarks page. pnpm bench runs render → DBNet → PaddleRecognizer → FaceDetector over 12 pages per setting, warmed up first so downloads and session creation are excluded, and writes the numbers to bench/results/. Apple M5 Pro, 18 cores, headless Chromium:

Not cross-origin isolated (threads unavailable):

WorkersPages in flightPages/sFirst pagep95 page
110.521.9 s1.9 s
120.522.4 s3.9 s
211.002.0 s2.0 s
411.672.4 s2.4 s
421.303.0 s4.9 s

Cross-origin isolated:

Workers × threadsPages in flightPages/sFirst page
1 × 110.521.9 s
1 × 411.250.8 s
2 × 211.521.3 s
4 × 111.562.6 s
2 × 411.991.0 s
4 × 413.301.2 s
4 × 442.181.7 s

What that says:

  • Workers are the lever. Four workers gave 3.2× without isolation, and with isolation 4 × 4 gave 6.4×.
  • More workers beat more threads per worker at the same total thread count (4 × 1 > 2 × 2 > 1 × 4). Each worker owns its sessions, so there is nothing to contend on.
  • Threads are what bring the first page in sooner: 1 × 4 returns its first page in 0.8 s against 1.9 s.
  • Extra pages in flight only cost here. Every stage is a CPU-bound model, so a second page competes for the same threads. Throughput drops and latency doubles. Raise it only for a pipeline with a stage that waits on the network or the GPU.

Your hardware and pipeline will differ. Run pnpm bench, or BENCH_ISOLATED=1 pnpm bench, on the machines you care about.

Choosing settings

resolveParallelism encodes the measurements:

import { probeEnvironment, resolveParallelism } from '@stabrise/scaledp'
import { createScaleDpWorkerPool } from '@stabrise/scaledp/worker'

const p = resolveParallelism('auto', await probeEnvironment())
// isolated, 18 cores  -> { workers: 4, threadsPerWorker: 4, pagesInFlight: 1 }
// not isolated        -> { workers: 4, threadsPerWorker: 1, pagesInFlight: 1 }
// WebGPU              -> { workers: 1, pagesInFlight: 3, executionProviders: ['webgpu', 'wasm'] }

const pool = createScaleDpWorkerPool({
    createWorker: () => new Worker(new URL('./scaledp.worker.ts', import.meta.url), { type: 'module' }),
    size: p.workers,
    threadsPerWorker: p.threadsPerWorker,
    pagesPerWorker: p.pagesInFlight,
})
await pool.configure({ executionProviders: p.executionProviders })
const rows = await pool.run(stages, [{ contentRef: 'doc', path }]).result

Pin anything with a partial preset: resolveParallelism({ workers: 2 }, env). 'conservative' is one worker and one page.

SituationSet
Not isolated (GitHub Pages, embeds)A pool of min(4, cores − 1) workers, 1 thread each, 1 page each
IsolatedA pool of 4 workers, min(4, (cores − 1) / 4) threads each
First page as fast as possibleFewer workers, more threads: 1 × 4 halves time-to-first-page
Cloud NER (a custom network-bound stage)maxConcurrency: 4 on it, resources() → [], pagesInFlight ≥ 3
WebGPUOne worker; GPU stages via runtime.executionProviders: ['webgpu', 'wasm']; pagesInFlight 2–3 (not yet benchmarked)
Low memoryFewer workers — each one loads every model in the pipeline

What the pool costs

  • Every worker parses the PDF and loads every model it reaches. A model a stage never reaches, because it is disabled or no page matches its when, is never loaded in any worker.
  • At a barrier every page comes back to the coordinator and goes out again after it. Prefer barriers late in the pipeline, after the image columns are dropped (keepInputData: false).
  • execution_time is per worker batch. Use row_time, or the stage:end events, for per-page timing.

On this page