Optimizing creation and inference
On the previous page you learned to measure where the time goes. This page is the next step: what to do with that number. 🚀
There are two different costs, and each has its own levers:
- Creating the task (
Detector.create(...)/Detector(...)): downloading the model and building the ONNX Runtime session. It happens once, but it is what the user waits through before seeing anything. - Every
predict(): decoding the image, preprocessing, running the model, decoding the output. It happens on every frame.
Every number here was measured
Unless stated otherwise, the numbers come from headless Chromium (Playwright) on a 12-logical-core machine, WASM backend, with a YOLO11n-seg 640×640 (11.7 MB). Each value is the median of 5 to 12 runs after one warm-up run. On a phone the absolute numbers differ; the ratios tend to hold.
Creation: keep the model in the browser
On a page load, the biggest creation cost is almost always downloading the model. It is megabytes, and the server does not always send cache headers the browser honours for a file that size.
The cache option keeps the model in the browser's Cache Storage. From the
second visit on, the bytes come from disk, not the network:
import { Detector } from "@mauriciobenjamin700/ort-vision-sdk-web";
const det = await Detector.create("/models/yolov8n.v1.onnx", {
cache: true,
});
const result = (await det.predict("/images/street.jpg"))[0];
for (const d of result) console.log(d.className, d.confidence, d.bbox.asXyxy());
cache: true uses the DEFAULT_MODEL_CACHE bucket ("ort-vision-sdk-models").
A string picks another name: cache: "my-app-models".
The URL is the key, and nothing expires on its own
If you publish a new model at the same URL, returning visitors keep the
old one. Version the URL (yolov8n.v2.onnx, ?v=2), change the bucket
name, or delete the bucket with caches.delete(name).
Where there is no Cache Storage, nothing breaks
Cache Storage only exists in a secure context (https:// or localhost)
and may be blocked (private window, site data disabled). In those cases
the SDK just downloads the model from the network, as if cache had not
been asked for.
Creation: download the runtime alongside the model
Besides the model, a page's first session downloads and compiles the ONNX
Runtime runtime, a .wasm of several megabytes. It used to start only after
the model download finished, so the page's two largest downloads ran one after
the other.
The SDK now starts the runtime while the model downloads. You change nothing. Measured with bandwidth capped at 5 MB/s (11.7 MB model, 12.8 MB runtime), on the page's first creation:
| First creation | |
|---|---|
| Before (one after the other) | 5020–5145 ms |
| Now (in parallel) | 2648–2656 ms |
Not using WebGPU? Download half the runtime
The default onnxruntime-web import brings the runtime with WebGPU: 25.9 MB
(6.0 MB gzipped). The onnxruntime-web/wasm subpath brings WASM only: 12.8 MB
(3.3 MB gzipped). If your app runs on WASM only, point the import at it. In
Vite, in vite.config.ts:
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
alias: [{ find: /^onnxruntime-web$/, replacement: "onnxruntime-web/wasm" }],
},
});
The SDK uses that build too, since it imports onnxruntime-web. Ask for
providers: ["wasm"] in the tasks: the WASM-only build has no WebGPU to offer.
Creation: optimize the graph once, at build time
Every ONNX Runtime session runs the graph optimizer before it can infer: it folds constants, fuses operators, removes redundant nodes. That is most of the cost of building a session, and it produces the same result every time.
So you can pay it once, in your build, with the Python SDK:
from ort_vision_sdk import optimize_model
optimize_model("yolov8n.onnx", "yolov8n.opt.onnx")
Ship yolov8n.opt.onnx instead of the original. Done! The web SDK reads, in
the file's metadata, the mark optimize_model left, and builds the session
without running the optimizer again. You change no line in the browser:
import { Segmenter } from "@mauriciobenjamin700/ort-vision-sdk-web";
const seg = await Segmenter.create("/models/yolov8n-seg.opt.onnx");
| File | Session creation (ORT only) | Full Segmenter.create |
Inference |
|---|---|---|---|
| Original | 29.4 ms | 52.6 ms | 65.9 ms |
optimize_model(...) |
12.4 ms | 33.6 ms | 65.2 ms |
Inference does not change: the graph is the same one ORT would build on its own, just built earlier.
Why "extended" and not "all"
The default is level="extended". Beyond that, ORT applies layout
transformations chosen for the instruction set of the CPU doing the
optimizing (AVX2, AVX-512…), which the browser's WASM does not have. That
is why "all" is not offered at all. "basic" exists for constant folding
only.
Technical details
- The export's metadata (
names,imgsz…) is preserved; the mark is theGRAPH_OPTIMIZATION_KEYkey ("ort_vision_sdk.graph_optimization"). - An explicit
graphOptimizationLevelinsessionOptionsalways wins over the mark. - With
readMetadata: falsethe mark is not read, and ORT optimizes as usual. extendedfusions usecom.microsoftoperators that every CPU and WASM build implements. On WebGPU, some have no kernel: on the YOLO11n-seg, ORT placed 2 nodes on the CPU for the optimized file and none for the original, which costs GPU↔CPU copies on every inference. The SDK warns in the console when a pre-optimized model is headed for WebGPU. For WebGPU, ship the original export.- The Python SDK does not apply the mark on its own: it only reads metadata
once the session exists. The optimized file runs normally; to skip the
repeated optimization, pass
SessionOptionswithgraph_optimization_level = ORT_DISABLE_ALL.
Creation: per-provider options
Each execution provider has its own options, and some change creation and
inference time a lot. Pass an object (web) or a (name, options) pair
(Python) instead of the name:
import { Detector } from "@mauriciobenjamin700/ort-vision-sdk-web";
const det = await Detector.create("/models/yolov8n.onnx", {
providers: [{ name: "webgpu", preferredLayout: "NHWC" }, "wasm"],
});
console.log(det.session.providers); // ["webgpu", "wasm"] — always names
from ort_vision_sdk import Detector
det = Detector(
"yolov8n.onnx",
providers=[
("tensorrt", {"trt_engine_cache_enable": True, "trt_engine_cache_path": "./trt"}),
("cuda", {"cudnn_conv_algo_search": "HEURISTIC"}),
"cpu",
],
)
print(det.session.requested_providers)
# ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
In Python, the name in a pair accepts the usual aliases ("cuda",
"tensorrt"…). The options reach ONNX Runtime untouched.
The TensorRT engine cache pays the most
Without a cache, TensorRT rebuilds its engine in every process, and for
a large model that takes minutes. With trt_engine_cache_enable, only the
first process pays.
Inference: quantize to INT8
Quantizing swaps float32 weights and activations for 8-bit integers. The model
gets ~3× smaller, and the CPU runs integer kernels, which are much cheaper. It
is also a build step in the Python SDK, with the [quantize] extra:
from pathlib import Path
from ort_vision_sdk import quantize_model
calibration = sorted(Path("calibration/").glob("*.jpg"))
quantize_model("yolov8n-seg.onnx", "yolov8n-seg.int8.onnx", calibration)
The calibration images set each activation's int8 range, and go through the
same preprocessing as predict(): letterbox for detection and
segmentation, resize and normalization for classification. The task comes from
the task field of the Ultralytics metadata, or from task="detect" |
"segment" | "classify".
Measured on the YOLO11n-seg (per-channel QDQ, 24 calibration images):
| FP32 | INT8 | |
|---|---|---|
| File | 11.7 MB | 3.5 MB |
| Inference, native CPU (Python ORT) | 87 ms | 30.5 ms |
Full predict(), Python SDK |
37.5 ms | 16.8 ms |
| Inference, WASM (4 threads) | 68.4 ms | 52.7 ms |
Validate accuracy on your own data
Quantization costs some accuracy, and how much depends on the model and on how representative the calibration images are. On this model the main detection stayed the same (confidence 0.663 → 0.659), but measure on your validation set before shipping.
In the browser, INT8 runs on WASM
ONNX Runtime Web's WebGPU backend cannot run this format's quantized
operators: its DequantizeLinear rejects the quantized bias.
quantize_model marks the file, and the web SDK drops webgpu from the
providers of a marked model, with a console warning. On WASM, which is
where INT8 pays, it runs normally.
Why not chain it with optimize_model
For an INT8 model, ORT applies fusions at load time that the offline
extended level does not reproduce. Measured on WASM: the pre-optimized
INT8 model was created 79 ms faster, but every inference got 19% slower
(60.8 vs 51.1 ms). optimize_model raises a UserWarning when given a
quantized model.
Inference: WASM threads
The WASM backend uses several threads, but only when the page is isolated
(crossOriginIsolated === true). Without isolation, it runs on one thread.
Measured on the YOLO11n-seg:
env.wasm.numThreads |
Inference |
|---|---|
| 1 (page not isolated) | 221 ms |
| 4 (ORT default) | 65 ms |
| 6 | 49 ms |
| 10 | 58 ms |
Two lessons here:
- Isolating the page is worth 3.4×. It is the biggest win on this whole page, and it changes no line of your code: it is two HTTP headers.
- ORT's default caps at 4 threads. On this machine, half the cores was best; going past that got worse.
To isolate, serve the page with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
And, if you want to tune the threads, do it before the first session:
import { env } from "onnxruntime-web";
import { Detector } from "@mauriciobenjamin700/ort-vision-sdk-web";
env.wasm.numThreads = Math.max(1, Math.floor(navigator.hardwareConcurrency / 2));
const det = await Detector.create("/models/yolov8n.onnx", { providers: ["wasm"] });
Why the SDK does not do this for you
env.wasm is global configuration of onnxruntime-web, which belongs
to your app (it is a peer dependency), and the best number depends on the
device and on what else runs on the page. The SDK leaves it alone.
COEP has a price
With require-corp, every cross-origin resource (CDN image, font, script)
must come with Cross-Origin-Resource-Policy or CORS. Check before turning
it on in production.
Inference: two predict calls in flight
With env.wasm.proxy = true, inference runs in a worker, and the main thread is
free while it happens. You can put that time to use: while the model infers
frame N, the SDK already decodes and preprocesses frame N+1.
Just keep two predict() calls going. The SDK queues the runs of one
session (ONNX Runtime Web refuses two at once), so only the model step waits
its turn:
import { env } from "onnxruntime-web";
import { Segmenter } from "@mauriciobenjamin700/ort-vision-sdk-web";
env.wasm.proxy = true;
const seg = await Segmenter.create("/models/yolov8n-seg.onnx", { providers: ["wasm"] });
await seg.warmup();
const frames: HTMLCanvasElement[] = [...document.querySelectorAll("canvas")];
let next = 0;
async function worker(): Promise<void> {
while (next < frames.length) {
const frame = frames[next++]!;
const result = (await seg.predict(frame))[0];
console.log(result.length, "instances");
}
}
await Promise.all([worker(), worker()]);
Measured on a 1080p frame, with the proxy:
predict() in flight |
ms per frame |
|---|---|
| 1 | 82–86 |
| 2 | 70–73 |
| 3 | 72 |
Three gain nothing more: the model itself becomes the bottleneck. Without
env.wasm.proxy, inference holds the main thread and two in flight gain
nothing (84.6 vs 86 ms), but they do not break either.
Inference: hand the video over directly
In a camera loop, pass the HTMLVideoElement (or a VideoFrame) straight to
predict(), without drawing it onto a canvas first:
import { Detector } from "@mauriciobenjamin700/ort-vision-sdk-web";
const video = document.querySelector("video")!;
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true });
await new Promise((resolve) => video.addEventListener("loadeddata", resolve, { once: true }));
await video.play();
const det = await Detector.create("/models/yolov8n.onnx", { cache: true });
await det.warmup();
async function loop(): Promise<void> {
const result = (await det.predict(video))[0];
console.log(result.length, "objects", result.speed);
requestAnimationFrame(loop);
}
loop();
The decoded frame is the one on screen at the time of the call. A video with no
frame yet (readyState < 2) throws ImageLoadError, explaining that it must
wait for the loadeddata event.
With a camera or screen capture (srcObject is a MediaStream), the SDK does
not even read the frame back from the GPU: it draws it onto a copy and
preprocesses from that. The RGB pixels are only built if something reads
origImg, croppedImage or segmentedImage. Measured on a 1080p frame,
load went from 8.5–10.7 ms to 2.0–2.5 ms. The same holds for an
alpha-less VideoFrame (I420, NV12, RGBX…) and for JPEG.
Why only these sources
Preprocessing straight from the copy is identical to the normal path only
when no pixel is translucent, and usually only reading the pixels tells.
MediaStream frames, alpha-less VideoFrames and JPEGs cannot carry
alpha, so they skip the read. A <video> playing a file may have alpha
(VP9), so it is still read right away.
What the SDK already does for you
Some optimizations ask nothing of you. They are worth knowing about, because
they explain numbers in speed:
- Preprocessing without an extra copy. When the input is drawable (image,
canvas,
ImageBitmap, video,Blob, URL), the pipeline resizes straight from the canvas the image was decoded onto. Measured on a 1080p frame,preprocesswent from ~10 ms to ~5 ms. An image with transparency takes the old path, because compositing its alpha would change the colours. croppedImageandsegmentedImageon demand. The per-detection crops are only built when you read the field. If you only use box and class, they never cost anything.- Faster postprocessing, with output bit-identical to before: decode 25–54% faster, NMS 28–40%, mask assembly 71% on the web; in Python, decode 43–80%, NMS 78–87% (up to 512 boxes per class) and masks 81%.
warmup()in both SDKs. In Python it pays the CUDA arena allocation, cuDNN's algorithm search and the TensorRT engine before the first request.
Reusing the same RGBImage across frames? Crops are built right away
If you pass your own RGBImage and rewrite its buffer on every frame, a
crop built later would read the next frame. So with a caller's RGBImage,
the SDK builds the crops immediately. On-demand applies only when the
SDK allocated the pixels itself.
Recap
- Creation:
cache: truetakes the network out of the way from the second visit on; the runtime now downloads alongside the model (−48% on the first creation);onnxruntime-web/wasmhalves the runtime;optimize_modelat build time cuts ~60% of session building (for WASM); per-provider options via an object or a(name, options)pair. ✅ - Inference:
quantize_modelmakes the model ~3× smaller and 2.2× faster in Python; isolate the page (COOP/COEP) for up to 3.4× on WASM; withenv.wasm.proxy, twopredict()calls in flight yield ~15% more. - Video: pass the
HTMLVideoElementstraight topredict(); with a camera,loaddrops ~75%. - The SDK already avoids copies in preprocessing and builds crops only when you ask for them.
See also
- Inference cost: measure before you optimize.
- Web guide:
env.wasm.proxyto move inference off the main thread.