Inference Performance¶
Tune sleap-nn inference for throughput on GPUs. Numbers in this doc come
from a benchmark on an NVIDIA A40 (sm_86, CUDA 12.8, torch 2.9.1) using
the project's standard test fixtures. Treat them as relative speedups,
not absolute ceilings — your numbers will scale with backbone size, video
resolution, and GPU class.
The bench script that produced these numbers lives at
scratch/2026-04-30-inference-refactor-implementation/cuda_bench/run_cuda_bench.py
and can be re-run to validate your own setup.
TL;DR¶
Recommended defaults
--device cuda— the only flag that matters for large videos.--batch_size 4is a good starting point; raise to 8 / 16 if VRAM allows.- FP16 delivers the biggest single-flag win (~1.5× on UNets) once
it's exposed via the CLI. For now, opt in by constructing the
predictor in Python with
use_fp16=True(see FP16). torch.compileadds another ~1.2-1.3×, but pays a 0.5-3 s compile cost — only worth it on long videos. Opt in withuse_compile=True.paf_workers=0is the right default. Workers are a net loss for typical bottom-up workloads at fixture-checkpoint scale.--upload_batchescopies each batch to the GPU before preprocessing, so resizing and top-down cropping don't hold up the GPU: about 2× on a top-down pair (see Batches on the GPU).- On NVIDIA, channels-last (
--memory_format channels_last) roughly doubled a top-down pair's speed on an A100. It changes results slightly (about as much as TF32 does), not accuracy (see Channels-last).
Backbone-level throughput¶
Forward-pass latency measured on the A40 at batch_size=4,
(B, 1, C, H, W) input shape. Numbers are per-call (ms / batch).
| Model type | Eager | torch.compile |
FP16 autocast | fuse_layers |
|---|---|---|---|---|
single_instance |
1.20 ms | 0.93 ms (1.29×) | 0.84 ms (1.43×) | 1.20 ms (1.00×) |
centroid |
2.48 ms | 1.96 ms (1.27×) | 1.61 ms (1.54×) | 2.48 ms (1.00×) |
bottomup |
3.59 ms | 2.94 ms (1.22×) | 2.32 ms (1.55×) | 3.59 ms (1.00×) |
multi_class_bottomup |
1.86 ms | 1.70 ms (1.10×) | 1.62 ms (1.15×) | 1.85 ms (1.01×) |
Headline observations:
- FP16 wins across the board on UNet-based heads — 1.43× to 1.55× on the three core model types. Zero-cost speedup; turn it on by default on CUDA.
torch.compileis consistently positive but adds a one-time compilation cost (0.5–3 s per model). Long videos amortize that easily; short clips don't.fuse_layersis a no-op (1.00×) on these UNets. The shipped default (use_fp16=False, use_compile=False, fuse_layers=False) is fine for cold-start; revisitfuse_layersonly if you've profiled Conv-BN fusion specifically helping your backbone.
End-to-end throughput¶
Full Predictor.predict_streaming(VideoProvider(small_robot.mp4)) on the
same A40, eager only, no opt-ins. Includes preprocessing, model forward,
postprocessing, and (for top-down) the second-stage centered-instance
inference.
| Model type | Frames | Wall time | fps | ms / frame |
|---|---|---|---|---|
single_instance |
100 | 0.44 s | 228 | 4.4 |
centroid_only |
100 | 0.43 s | 231 | 4.3 |
topdown |
100 | 1.05 s | 95 | 10.5 |
bottomup |
100 | 0.73 s | 137 | 7.3 |
About these numbers
small_robot.mp4 is a small 320×560 video and the fixture
checkpoints are minimal UNets (~1-3 MB ckpts). On a production-sized
backbone (deeper UNet / ConvNext / SwinT), absolute fps drops but
relative speedups from FP16 + torch.compile are larger.
Per-flag deep dive¶
FP16¶
Enable CUDA-only; on MPS the kernels exist but tensor cores don't, so there's no speedup (and we warn).
from sleap_nn.inference import Predictor
predictor = Predictor.from_model_paths(
["models/my_model/"],
device="cuda",
)
predictor.layer.backend.use_fp16 = True # opt-in
| Reason to enable | Reason to skip |
|---|---|
| Long videos on CUDA where ~1.5× matters | Mac (MPS): no speedup |
| VRAM-constrained — FP16 also halves activation memory | Tasks with very tight accuracy budgets — FP16 introduces ~4e-3 numerical drift vs FP32 (autocast policy) |
The drift typically doesn't affect keypoint coordinates beyond
sub-pixel noise. We've seen max |Δ| ≤ 0.001 px on every fixture in
the parity bench, but always benchmark your own checkpoint before
shipping FP16 to production.
torch.compile¶
Mode: reduce-overhead (CUDA-graph capture), dynamic=False.
| Reason to enable | Reason to skip |
|---|---|
| Long videos (>1000 frames) where compile cost amortizes | Notebook / interactive use — the 0.5–3 s compile cost dominates short runs |
| Multiple inference passes on the same model in the same process | One-shot inference where you'll never reuse the compiled module |
Static shapes
dynamic=False means the compiled graph is locked to one input
shape. If your batches have varying spatial dims, set
dynamic=True (slower but tolerant), or pre-pad to a uniform
shape — which the new flow's sizematcher does automatically when
max_height / max_width are set in training config.
Batches on the GPU (upload_batches)¶
sleap-nn predict --upload_batches (or upload_batches=True on
Predictor.from_model_paths / sleap_nn.inference.predict) copies each
uint8 batch from the provider to the GPU the (first) model runs on, before
layer.predict. Without it (the default) the layer gets a numpy batch, and
the sizematcher, input scaling and top-down crop extraction run on the CPU;
only the networks run on the GPU. It applies to trained checkpoints in
sleap-nn predict; sleap-nn track (the legacy pipeline) and exported
ONNX/TensorRT models are unchanged. Off a CUDA device it does nothing (and
logs a warning saying so).
sleap-nn predict -i video.mp4 -m models/centroid/ -m models/centered_instance/ \
--device cuda --batch_size 16 --upload_batches
Measured on an NVIDIA A100 40 GB (torch 2.6, sleap-nn 0.3.3) with a top-down pair (centroid at input scale 0.25, output stride 2; centered-instance on 320 px crops at scale 0.5, max stride 32, 24 filters), a 1280×1024 5-animal video, batch 16, fp32:
| resize | crops | networks + peaks | total ms / frame | fps | GPU busy | |
|---|---|---|---|---|---|---|
numpy batch (upload_batches=False) |
3.79 | 1.42 | 4.78 | 10.0 | 100 | 38% |
| uint8 batch on the GPU | 0.04 | 0.07 | 4.50 | 4.6 | 226 | 89% |
Same animals found; 90% of keypoints bit-identical. The rest differ
because the GPU's uint8 resize rounds differently from the CPU's: on an
RTX 6000 Ada, 5–13% of resized pixels came out 1 grey level apart (never
more). That's why it is opt-in: without it, results are exactly as before.
Channels-last (memory_format)¶
sleap-nn predict --memory_format channels_last (or
memory_format="channels_last" on Predictor.from_model_paths /
sleap_nn.inference.predict) stores the weights of every model in the
composition channels-last (both models of a top-down pair, a tiled layer's
inner model, ...). cuDNN's fastest convolution kernels on NVIDIA GPUs use
that layout; with channels-first weights the activations are converted
around every convolution. Trained checkpoints only: exported ONNX/TensorRT
models ignore it, with a warning.
sleap-nn predict -i video.mp4 -m models/centroid/ -m models/centered_instance/ \
--device cuda --batch_size 16 --upload_batches --memory_format channels_last
It is also a TorchBackend field (TorchBackend(memory_format=...)), and
setting it on a built backend converts the model right away.
Same A100 and top-down pair as above, batches on the GPU, fps:
| default layout | channels-last | |
|---|---|---|
| fp32, batch 16 | 226 | 493 |
| fp32, batch 64 | 190 | 570 |
| fp32, batch 16, TF32 off | 191 | 251 |
It changes results slightly. On Ampere and newer NVIDIA GPUs, PyTorch
lets cuDNN run fp32 convolutions in TF32 by default
(torch.backends.cudnn.allow_tf32), so the default layout already uses
TF32, and the channels-last kernels round differently. Over 6,000 frames
(29,322 animals), compared with the default layout:
| animals not found again | keypoints moved > 1 px | keypoints bit-identical | |
|---|---|---|---|
| channels-last | 18 | 3.7% | 0% |
| default layout with TF32 off | 22 | 3.7% | 0% |
| channels-last vs default layout, both TF32 off | 1 | 0.18% | 78% |
So channels-last changes results about as much as turning TF32 off does. A keypoint that moves by more than 1 px moves by one confidence-map pixel (about 3.4 px here); the median keypoint moves 0.006 px. Accuracy is the same: on 100 hand-labelled frames, all four (either layout, TF32 on or off) had a median error of 3.81–3.83 px and missed the same 8 animals.
There is no bit-identical channels-last mode. With TF32 off it is still
faster than the default layout with TF32 off (251 vs 191 fps) and much
closer to it, but not identical: with TF32 off, even changing the batch
size from 16 to 64 moves 1.8% of keypoints by more than 1 px (no animal
lost). To turn TF32 off, set torch.backends.cudnn.allow_tf32 = False in
Python before predicting, or run NVIDIA_TF32_OVERRIDE=0 sleap-nn predict ...
(an NVIDIA library setting: same speed as the Python setting, 99% of
keypoints bit-identical to it).
| Reason to enable | Reason to skip |
|---|---|
| NVIDIA GPUs (tensor-core convolutions) | Results must match earlier runs bit for bit |
| AMD (ROCm): no difference measured | |
| CPU / MPS: not measured |
fuse_layers (Conv-BN fusion)¶
Disabled by default. The post-PR-26 measurement on every fixture in this repo shows a 1.00× speedup — i.e., no measurable benefit on these UNets. Conv-BN fusion is valuable when:
- The backbone has many
Conv2d → BatchNorm2dpairs in series (CARE-style decoders, large ConvNexts). - Inference is so cheap that eager-mode Python overhead dominates.
Neither applies to the shipped sleap-nn UNets. Leave fuse_layers=False.
paf_workers (bottom-up CPU grouping pool)¶
Enables a multi-process pool for the CPU-bound part of bottom-up inference (PAF grouping after the GPU finishes peak finding and PAF scoring).
paf_workers |
fps on the bench | Notes |
|---|---|---|
| 0 | 153 | Inline, no pool. The right default. |
| 2 | 28 | 5× slower — spawn + IPC overhead dominates |
| 4 | 25 | Worse, more workers, more overhead |
Workers help only when CPU grouping is the bottleneck
Workers help if:
- The video is long enough to amortize spawn cost (
>=1000frames) - The GPU stage produces many peaks per frame (dense scenes, crowded multi-animal videos)
- The grouping stage measurably dominates wall time when serialized
For the typical small-multi-animal pipeline benchmarked here, the GPU stage is the bottleneck and CPU grouping is well under 1 ms / frame — workers can't parallelize what isn't there to parallelize.
Backbone fusion / Conv-BN¶
See fuse_layers above. Not the same as torch.compile's graph fusion.
Workflow recipes¶
"I want the fastest correct predictions on a long video"¶
from sleap_nn.inference import Predictor
predictor = Predictor.from_model_paths(
["models/centroid/", "models/centered_instance/"],
device="cuda",
batch_size=8,
upload_batches=True,
memory_format="channels_last", # NVIDIA
)
backend = predictor.layer.backend # or .centroid_layer.backend for top-down
backend.use_fp16 = True
backend.use_compile = True
labels = predictor.predict("long_video.mp4")
labels.save("predictions.slp")
Expected speedup vs eager-CPU: 20–50× on CUDA for a 5-minute video.
"I want a quick sanity check on a 10-frame clip"¶
Skip FP16 + compile — both add overhead that dominates short runs.
"I'm on a Mac, MPS"¶
FP16 silently has no effect on MPS (warning logged). torch.compile
on MPS is unreliable — the new flow disables it for you with a clear
warning. Expect ~2-3× speedup over CPU; not 20× like CUDA.
"Multi-animal bottom-up on a crowded video"¶
predictor = Predictor.from_model_paths(
["models/bottomup/"],
device="cuda",
batch_size=4,
paf_workers=4, # try 2 / 4 / 8; measure on your data
)
paf_workers > 0 is the one place you may need to experiment per
dataset. Start at 0; raise only if profiling shows CPU grouping
dominates GPU work.
When to re-benchmark¶
The numbers above are from one A40 + fixture checkpoints. If you're deploying to:
- A different GPU class (H100 / A100 / 3090 / 4090): FP16 speedup can be 1.7-2× on data-center cards with stronger tensor cores. Compile speedup roughly stays in the 1.2–1.4× band.
- A production-sized backbone (deeper UNet, ConvNext, SwinT): FP16
gains grow; compile cost grows linearly with graph size;
fuse_layersmay finally start mattering. - Variable-resolution input (different videos with different
shapes): turn
dynamic=Trueon compile or skip it.
Re-run the bench:
SLEAP_NN_REPO=/path/to/sleap-nn \
python scratch/2026-04-30-inference-refactor-implementation/cuda_bench/run_cuda_bench.py
Output lands in the same folder as a timestamped log file.
Parity guarantee¶
The new inference flow has been validated bit-exactly against the legacy
Predictor.from_model_paths flow on CUDA, MPS, and CPU across every
fixture model type × multiple sources:
| Fixture | Source | Max keypoint Δ vs legacy |
|---|---|---|
single_instance |
small_robot.mp4 |
0.000000 px |
single_instance |
minimal_instance.pkg.slp |
0.000000 px |
topdown |
small_robot.mp4 |
0.000916 px |
topdown |
minimal_instance.pkg.slp |
0.000000 px |
bottomup |
small_robot.mp4 |
0.000000 px |
bottomup |
minimal_instance.pkg.slp |
0.000000 px |
This parity is locked in by tests/inference/test_parity_vs_legacy.py,
which runs on every CI build.