Medindo o custo da inferência
Você tem o modelo rodando. A próxima pergunta é sempre a mesma: por que demorou isso? 🐌
Um predict() de 2 segundos pode ser um forward pass pesado — ou pode ser um
download de 12 MB que aconteceu uma vez e nunca mais. As duas situações pedem
ações opostas, e sem número você não sabe qual das duas está vendo.
Todo envelope de resultado carrega um speed com o tempo de cada etapa.
O básico
from ort_vision_sdk import Detector
det = Detector("yolov8n.onnx", labels="coco")
results = det.predict("street.jpg")
print(results[0].speed)
# {"load": 84.2, "preprocess": 11.7, "inference": 118.9, "postprocess": 6.4}
Valores em milissegundos. Mesma coisa no browser:
import { Detector } from "@mauriciobenjamin700/ort-vision-sdk-web";
const det = await Detector.create("/models/yolov8n.onnx");
const results = await det.predict("/images/street.jpg");
console.log(results[0].speed);
// { load: 84.2, preprocess: 11.7, inference: 118.9, postprocess: 6.4 }
Mesmo formato nos dois SDKs
Python devolve um dict[str, float], TypeScript devolve um objeto
Speed. As chaves e as fronteiras de medição são idênticas — um
dashboard consegue tratar os dois do mesmo jeito.
O que cada etapa significa
| Chave | Cobre |
|---|---|
load |
Ler/baixar a entrada e decodificar para RGBImage/ndarray |
preprocess |
Letterbox/resize, normalização e empacotamento do tensor |
inference |
O forward pass no ONNX Runtime |
postprocess |
Decodificar a saída — NMS, montagem de máscara, top-k |
preprocess, inference e postprocess são exatamente as três chaves que o
Ultralytics reporta, medidas nas mesmas fronteiras. load é nosso: o
predict() aceita caminho, bytes ou URL e faz a decodificação por dentro. Se
esse custo entrasse em preprocess, a leitura ficaria mentirosa — numa
primeira chamada com cache frio, load costuma ser a maior fatia de todas.
As etapas se somam ao total, mas não ao seu relógio
As quatro etapas ladrilham o predict() inteiro, sem buracos. O que elas
não incluem é o Detector(...)/Detector.create(...) — carregar o
modelo e subir a sessão ORT acontece antes, uma única vez, e não aparece
no speed.
No Python, preprocess encolhe quando a imagem é bem maior que a entrada
Uma redução de 2x ou mais roda em dois passos: primeiro uma redução inteira por média de bloco, e só então a reamostragem para o tamanho exato. Letterbox de 1920x1080 para 640x640 caiu de 7,8 ms para 3,5 ms; de 4K, de 29,8 ms para 10,5 ms. Abaixo de 2x nada muda — nem o custo, nem os pixels.
Onde a redução se aplica, os pixels entregues ao modelo mudam, e portanto as detecções também. Não é qualidade trocada por velocidade, mas também não é qualidade ganha: contra uma referência LANCZOS, a média de bloco vence em conteúdo fotográfico e perde quando o período do conteúdo ressoa com o fator de redução — listras de 2 px a cada 6 linhas, reduzidas por 3, são o pior caso. Em seis tipos de conteúdo o placar ficou 3 a 3.
Lendo o resultado
speed = results[0].speed
total = sum(speed.values())
for stage, ms in speed.items():
print(f"{stage:12} {ms:7.1f} ms {ms / total:5.1%}")
load 84.2 ms 38.5%
preprocess 11.7 ms 5.3%
inference 118.9 ms 54.3%
postprocess 6.4 ms 2.9%
Três leituras que aparecem na prática:
loaddomina — está buscando a imagem pela rede a cada chamada. Decodifique uma vez e passe o array/RGBImagejá pronto.inferencedomina — é o modelo mesmo. Quantize, reduza oinputSize, ou confira se o provider que você pediu foi de fato o que o ORT resolveu (session.providers).postprocessdomina — quase sempre NMS comconf_thresholdbaixo demais deixando milhares de candidatos passarem.
Medindo o seu pipeline junto
Seu app raramente é só predict(). Se você recorta uma ROI entre uma detecção
e uma classificação, esse recorte também custa — e o speed de cada
predict() não enxerga o que acontece entre eles.
O SpeedTimer é a mesma peça que os tasks usam por dentro, exportada para
você medir com as mesmas fronteiras:
from ort_vision_sdk.core import SpeedTimer
timer = SpeedTimer()
image = load_my_image(path)
timer.stage("load")
roi = detector.predict(image)[0]
timer.stage("inference")
crop = crop_to_box(image, roi.boxes.xyxy[0])
timer.stage("postprocess")
print(timer.speed())
import { SpeedTimer } from "@mauriciobenjamin700/ort-vision-sdk-web";
const timer = new SpeedTimer();
const image = await loadMyImage(url);
timer.stage("load");
const roi = (await detector.predict(image))[0];
timer.stage("inference");
const crop = cropToBox(image, roi.boxes.xyxy);
timer.stage("postprocess");
console.log(timer.speed());
Cada stage() fecha o intervalo anterior e credita o tempo ao nome dado —
sem pares de start/stop para esquecer. Chamar o mesmo nome duas vezes
acumula, então dá para somar duas passadas de inferência na mesma chave.
Recapitulando
results[0].speedtrazload,preprocess,inferenceepostprocessem milissegundos, em Python e no browser. ✅- As três chaves do Ultralytics medem as mesmas fronteiras;
loadé nossa, porque opredict()decodifica a entrada por dentro. - Carregar o modelo não está no
speed— é custo de inicialização. SpeedTimermede as etapas do seu próprio pipeline com as mesmas regras.
Aquecimento (warmup) — só no Web
A primeira inferência de uma sessão não é representativa: o WebGPU compila os shaders nela e o backend WASM materializa suas arenas, o que num celular transforma o primeiro frame em segundos enquanto os seguintes ficam em dezenas de milissegundos.
Detector, Segmenter, Classifier e DetectClassify expõem warmup(), que
roda o modelo com um tensor zerado. Chame enquanto o spinner de carregamento
ainda está na tela — o custo vai para onde o usuário já está esperando:
const det = await Detector.create("/models/yolov8n.onnx");
await det.warmup(); // uma passada basta no WASM
await det.warmup(2); // WebGPU às vezes só assenta na segunda
Num pipeline fundido vale mais
DetectClassify carrega dois modelos e a ponte no mesmo grafo, então a
primeira inferência compila tudo de uma vez. É onde o aquecimento paga mais.