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.
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.
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,
)
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.