ab Arjun Basandrai
Projects  /  2026

Birdly v2

PyTorch · ONNX Runtime · TensorRT · Python · FastAPI · Google Cloud Run

A ConvNeXt-Tiny classifier for 236 bird species, with CPU and GPU runtime benchmarks, a FastAPI service on Cloud Run, and a Next.js frontend.

86.59% top-1 accuracy 236 bird species
730.66 images/s TensorRT FP16, batch 32
~12 requests/s on Cloud Run 2 vCPU, one instance

Motivation

Birdly is a bird species classifier and was one of my first machine learning projects which I built in late 2022 during the first semester for my undergrad. It was the project that ultimately got me into machine learning. Now, after almost 4 years and having learned a lot more about ML since, I decided to revisit this project and improve on it.

The first version had many issues like using an unecessarily large model, an unecessarily large dataset, and test data leakage to name a few. So, I decided to rebuild the project from scratch, starting with a new dataset and a new model, and then building a full inference service with a frontend.

In this version, I covered the following:

  • dataset construction from iNaturalist Open Data
  • ConvNeXt-Tiny training and evaluation
  • CPU and GPU inference benchmarking
  • FP16 and INT8 experiments
  • ONNX Runtime, TensorRT, and Torch JIT compilation with torch.compile
  • a FastAPI inference service
  • concurrency and reliability testing
  • deployment to Google Cloud Run
  • a Next.js frontend deployed on Vercel

The first version of Birdly was built to classify all of the roughly 1250 bird species of India, but for this version I changed the training target.

The final model covers 236 bird species of New York State, United States and reached 86.59% top-1 accuracy, 96.75% top-5 accuracy, 0.8655 macro F1, and 0.8653 MCC on a 4,712-image test set.

Architecture

Architecture diagram. iNaturalist Open Data flows through a Polars and DVC pipeline into a validated manifest, then PyTorch training tracked in MLflow, producing a ConvNeXt-Tiny checkpoint. The checkpoint branches into CPU benchmarks (PyTorch eager, torch.compile, ONNX Runtime FP32 selected for CPU service, ONNX Runtime INT8 tested but not selected) and GPU benchmarks (PyTorch FP32, PyTorch FP16, ONNX Runtime CUDA, and TensorRT FP16 as the best GPU benchmark but not deployed). The deployed path is browser, Next.js on Vercel, a FastAPI container on Google Cloud Run running ONNX Runtime FP32 on CPU, and the model prediction.

Dataset

This time I decided to use the iNaturalist Open Data dataset instead of scraping manually.

These main source files are large enough that I did not want the data preparation process to depend on loading full tables into memory. So, I used Polars streaming for the main filtering and join jobs.

I also used DVC to track and version control the processed data artifacts.

Diagram of the data pipeline. observations.csv, photos.csv, and taxa.csv go through Polars streaming filters for bird taxonomy, research grade, a New York bounding box, anomaly score, and image license. Species eligibility, per-species sampling, train, validation, and test splits, and a global training supplement follow. The result is a validated manifest of 236 species, 233,796 rows, and 4,712 test images.

Taxonomy and observation filtering

Birdly currently targets species found in New York state.

The geographic filter uses a bounding box covering the state:

  • latitude: 40.45 to 45.05
  • longitude: -79.80 to -71.80

I kept only research-grade observations and restricted to active species in the bird lineage.

I also filtered based on the iNaturalist anomaly score. Observations with an anomaly score below 1.0 were excluded.

The allowed image licenses were restricted to CC0 and CC-BY.

Sampling

The main sampling conditions I used were:

  • maximum 1,000 images per species
  • minimum 300 total observations per species
  • minimum 50 observations from the target region

If a species did not have enough New York observations for training, global observations were added to supplement the training split. But, validation and test images were intentionally kept regional.

I did this to keep the evaluation set closer to the intended use case but still allowing the training set to use more available data if required.

I also sampled only one image per observation to avoid duplicates. However, if training set did not have sufficient images after global supplementation, I allowed further supplementation with multiple images per observation using observation-level round-robin sampling. Like before, I did not allow any supplementation for validation or test splits.

The final processed manifest contained about 234,000 images across 236 species.

The test set contained 4,712 images.

Model training

The final model uses a ConvNeXt-Tiny backbone with a 224 × 224 input.

Training experiments were tracked with MLflow. Each run recorded its parameters, metrics, and model artifacts.

Screenshot of MLflow showing the main Birdly training runs, with the selected final run.

The final FP32 checkpoint produced the following results on the test set:

MetricResult
Top-1 accuracy86.59%
Top-5 accuracy96.75%
Macro F10.8656
Macro precision0.8713
Macro recall0.8658
MCC0.8653
NLL0.5699
ECE0.0611

Top-1 accuracy, macro F1, macro precision, and macro recall were all similar. So, that’s a good sign that the model is performing equally well across the classes.

I also measured NLL and expected calibration error because prediction confidence is later used by the API.

The final FP32 model had an ECE of approximately 6.1 percentage points. So, the model’s confidence is reasonably well calibrated. While there is certainly room for improvement, this is good enough for my intended use case.

Inference baseline

Now that the model was trained, it was time to benchmark inference.

The main measurements I used were:

  • model-only latency
  • end-to-end latency
  • throughput
  • RAM or VRAM usage
  • model load time
  • cold inference time

Model-only timing measures just the forward pass.

End-to-end timing also includes image decoding and preprocessing.

CPU baseline

The first CPU backend was PyTorch eager FP32.

At batch size 1:

CPU threadsModel p50End-to-end p50
1236.74 ms245.56 ms
2132.62 ms138.97 ms
489.49 ms94.59 ms

The four-thread result of approximately 95 ms end to end was clearly the best and so I chose it as the main CPU reference point.

GPU baseline

I ran the GPU benchmarks on an RTX 3050 Laptop GPU.

Model-only FP32 results:

BatchMean (ms)p50 (ms)p95 (ms)p99 (ms)Throughput (img/s)Peak VRAM (MiB)
19.218.7512.5612.70108.57130.16
211.6011.9414.3014.43172.37141.37
418.4918.3619.2319.81216.37164.63
835.6035.5736.0536.13224.75212.87
1668.4868.4369.1569.99233.64310.34
32137.86137.89138.70138.83232.13504.27

End-to-end FP32 results:

BatchMean (ms)p50 (ms)p95 (ms)p99 (ms)Throughput (img/s)Peak VRAM (MiB)
116.7116.6918.5620.3159.85130.16
222.6722.1727.6528.7588.21141.37
442.0041.8044.8545.1295.24164.63
879.2278.2885.1885.97100.99212.87
16155.72156.15161.44162.10102.75310.34
32329.51328.77349.52354.8497.11504.27

End-to-end throughput peaked at batch size 16 with a value of 102.75 images/s.

torch.compile

With the baseline established, I experimented with torch.compile to see if it could improve CPU or GPU performance. So, I tested torch.compile on both CPU and GPU.

Compilation introduced a substantial one-time startup cost:

DeviceThreadsCompile + first inference
RTX 3050 Laptop GPU—38.52 s
CPU188.71 s
CPU279.66 s
CPU478.26 s

These measurements include compilation and the first inference. Once compiled, the GPU results improved as batch size increased, but batch-1 inference (which is what I expect for the serving workload) was actually slower than eager execution.

GPU results

BatchEager model p50Compiled model p50Eager throughputCompiled throughput
18.75 ms10.75 ms108.57 img/s92.98 img/s
211.94 ms9.73 ms172.37 img/s202.28 img/s
418.36 ms16.93 ms216.37 img/s235.05 img/s
835.57 ms31.73 ms224.75 img/s252.30 img/s
1668.43 ms61.26 ms233.64 img/s260.73 img/s
32137.89 ms124.29 ms232.13 img/s257.44 img/s

At batch 16, compilation increased model throughput from 233.64 to 260.73 images/s, which is an improvement of about 11.6%. Batch 32 also showed a similar improvement. At batch 1, however, p50 latency increased from 8.75 ms to 10.75 ms.

The improvement became smaller when preprocessing and the rest of the inference path were included:

BatchEager E2E throughputCompiled E2E throughput
159.85 img/s75.25 img/s
495.24 img/s104.46 img/s
8100.99 img/s106.77 img/s
16102.75 img/s107.18 img/s
3297.11 img/s103.90 img/s

The compiled run reached 107.18 images/s end-to-end at batch 16, versus 260.73 images/s for model execution alone. So, accelerating the model did not directly cause the same improvement for the complete inference path.

CPU results

The CPU result was less useful for the serving workload. Since the API would process individual requests rather than large offline batches, batch-1 latency was the main comparison that I cared about:

ThreadsEager E2E p50Compiled E2E p50
1245.56 ms254.21 ms
2138.97 ms144.08 ms
494.59 ms93.92 ms

Compiled model was slower with one and two threads. And at four threads, the difference was less than 1 ms. The compiled four-thread run measured 93.92 ms p50 and 10.58 images/s at batch 1.

CPU batch-1 latency did not improve enough to justify the startup cost.

torch.compile was therefore useful only for sustained GPU batch inference, where it gave roughly 10–12% higher model throughput at larger batches. But, it was not a good fit for the CPU serving path I was targeting since batch-1 performance was effectively unchanged and compilation was expensive. So, I kept eager PyTorch as the reference implementation and moved on to testing inference-specific runtimes.

ONNX Runtime on CPU

Now, I exported the model to ONNX and benchmarked ONNX Runtime on CPU.

At batch size 1:

ThreadsPyTorch E2E p50ONNX Runtime E2E p50
1245.56 ms210.82 ms
2138.97 ms111.97 ms
494.59 ms63.87 ms

Compared with the original PyTorch FP32 baseline, ONNX Runtime reduced batch-1 end-to-end p50 latency from 94.59 ms to 63.87 ms at four CPU threads, while increasing throughput from 10.56 to 15.60 images/s.

ONNX Runtime eventually became the CPU backend used by the API.

Grouped bar chart of batch-1 end-to-end CPU latency in milliseconds at 1, 2, and 4 threads. At four threads PyTorch eager FP32 takes 94.59 ms, ONNX Runtime FP32 takes 63.87 ms, a reduction of about 32 percent, and ONNX Runtime INT8 takes 70.65 ms, which is slower than FP32. With one thread the values are 245.56, 210.82, and 206.90 ms, and with two threads 138.97, 111.97, and 115.59 ms.

INT8 quantization

For my next trick, I created a statically quantized INT8 ONNX model.

The quality loss from this was thankfully very low:

MetricFP32INT8
Top-186.59%85.42%
Top-596.75%96.14%
Macro F10.86560.8539
MCC0.86530.8536
NLL0.56990.6504
ECE0.06110.0692

Top-1 accuracy fell by approximately 1.17 percentage points. Whereas top-5 accuracy fell by just 0.62 percentage points. Since the Birdly US service returns up to five predictions, this was a small enough loss to be acceptable.

But was there a speedup in inference?

ThreadsONNX FP32ONNX INT8
1210.82 ms206.90 ms
2111.97 ms115.59 ms
463.87 ms70.65 ms

INT8 was slightly faster with one thread but slower with two and four threads.

Reduced precision alone was not enough to reduce inference latency in my case. The quantized model was therefore not used for the main CPU deployment.

GPU inference

I tested four main GPU inference paths:

  • PyTorch FP32
  • PyTorch FP16
  • ONNX Runtime FP32 CUDA
  • TensorRT FP16
MetricPyTorch FP32PyTorch FP16ONNX Runtime CUDATensorRT FP16
B1 model p508.80 ms13.65 ms9.31 ms3.62 ms
B1 model throughput113.53 img/s67.39 img/s103.52 img/s246.59 img/s
B1 E2E p5015.40 ms20.95 ms14.50 ms10.32 ms
B1 E2E throughput63.48 img/s45.96 img/s67.34 img/s97.08 img/s
ㅤ
B32 model p50137.31 ms69.67 ms215.59 ms43.24 ms
B32 model throughput233.06 img/s459.37 img/s148.41 img/s730.66 img/s
B32 E2E p50319.97 ms306.58 ms395.67 ms267.35 ms
B32 E2E throughput99.97 img/s104.11 img/s80.43 img/s117.81 img/s
MetricPyTorch FP32PyTorch FP16TensorRT FP16ONNX INT8
Top-1 accuracy86.59%86.63% (+0.04 pp)86.63% (+0.04 pp)85.42% (−1.17 pp)
Top-5 accuracy96.75%96.75% (+0.00 pp)96.73% (−0.02 pp)96.14% (−0.62 pp)
Macro F10.86560.8660 (+0.0004)0.8660 (+0.0004)0.8539 (−0.0116)
MCC0.86530.8658 (+0.0004)0.8658 (+0.0004)0.8536 (−0.0117)
NLL0.56990.5699 (−0.00005)0.5698 (−0.00007)0.6504 (+0.0805)
ECE0.06110.0621 (+0.0009)0.0624 (+0.0013)0.0692 (+0.0080)

PyTorch FP16

FP16 largely preserves model quality.

Top-1 accuracy was 86.629%, compared with 86.587% for FP32.

The difference is too small to be treated as a meaningful accuracy change.

But batch size 1 inference was significantly slower than FP32:

  • FP16 model p50: 13.65 ms

Even though larger batches were much better.

At batch size 32:

  • model p50: 69.67 ms
  • throughput: 459.37 images/s
  • VRAM: 356.9 MiB

So, FP16 was more useful for large batch sizes than for single-image inference jobs.

ONNX Runtime CUDA

ONNX Runtime CUDA did not improve over the other PyTorch GPU paths.

At batch size 32:

  • model p50: 215.59 ms
  • model throughput: 148.41 images/s
  • end-to-end throughput: 80.43 images/s

So, I did not use this backend further.

TensorRT FP16

TensorRT gave the best GPU measurements.

Batch sizeModel p50Throughput
13.62 ms246.59 img/s
25.25 ms380.44 img/s
48.31 ms481.52 img/s
814.43 ms553.30 img/s
1626.22 ms612.39 img/s
3243.24 ms730.66 img/s

The TensorRT engine was about 56.1 MiB.

Line chart of model-only GPU throughput in images per second against batch size. TensorRT FP16 rises from 246.59 at batch size 1 to 730.66 at batch size 32. PyTorch FP16 rises from 67.39 to 459.37, PyTorch FP32 from 108.57 to 232.13, and ONNX Runtime CUDA from 103.52 to 148.41.

The selected runtimes were therefore:

HardwareRuntime
CPUONNX Runtime FP32
GPUTensorRT FP16

API

I implemented the inference API with FastAPI.

It exposes:

  • POST /predict
  • GET /health
  • GET /metrics

I load the model once at application startup.

A prediction request passes through:

  1. upload validation
  2. image decoding
  3. preprocessing
  4. ONNX Runtime inference
  5. probability calculation
  6. confidence filtering
  7. response generation
Flow diagram of POST /predict: request validation, image decode, preprocessing to 224 by 224, bounded inference concurrency with a thread pool, ONNX Runtime FP32 on CPU, conversion of logits to probabilities, a confidence filter, and a JSON response. Metrics and logging run on a side path.

The service also includes the following:

  • 10 MiB upload limit
  • maximum image dimensions of 4096 × 4096
  • JPEG, PNG, and WebP support
  • animated-image rejection
  • 30-second request/inference timeout
  • bounded inference concurrency
  • CORS restrictions
  • IP-based rate limiting
  • Prometheus-compatible metrics
  • non-root container execution

The production rate limit is 15 predictions per minute per IP.

Local load testing

I used load tests to measure how inference concurrency affected the API.

The tests used:

  • ONNX Runtime FP32
  • two ONNX CPU threads
  • 30-second test windows
  • virtual-user counts of 1, 2, 4, 8, and 16

Inference concurrency 1

Throughput stayed close to 7–8 requests per second even as the number of clients increased.

The extra requests primarily waited for the inference slot.

Inference concurrency 8

The highest measured throughput was about 16.5 requests per second.

The improvement over concurrency 4 was much smaller than the improvement from concurrency 1 to 4.

Two aligned line charts against virtual users 1, 2, 4, 8, and 16 for inference concurrency 1, 2, 4, and 8. Requests per second: concurrency 1 stays near 7 to 8, concurrency 4 reaches about 15, and concurrency 8 peaks at 16.52. p50 latency at 16 virtual users is 1996.89 ms for concurrency 1, 1540.09 ms for 2, 1021.44 ms for 4, and 975.79 ms for 8.

I used these results to avoid setting inference concurrency higher than the CPU could use effectively.

Reliability tests

The local reliability suite covered 18 cases.

These included:

  • valid image
  • corrupted file
  • unsupported content type
  • unsupported image format
  • empty upload
  • oversized upload
  • malformed multipart request
  • concurrent valid requests
  • mixed valid and invalid requests
  • burst traffic
  • rate limiting
  • queue timeout
  • missing model
  • metrics updates
  • process restart and recovery

All 18 local tests passed.

I later ran the applicable tests against the Cloud Run deployment as well.

The remote tests passed as well.

Cloud Run deployment

The deployment image only contains the FastAPI service code, ONNX Runtime CPU, FP32 ONNX model, class mapping, preprocessing metadata, and the required Python dependencies.

It does not include PyTorch, CUDA, TensorRT, training code, or training data.

The model bundle was about 107.45 MiB and idle container memory was approximately 260.8 MiB.

The API runs on Google Cloud Run.

The service can scale to zero because I expect Birdly to receive relatively little traffic.

A warm /health request from my laptop took 202 ms on average.

After the service had scaled to zero, the same request took 6.48 seconds on average.

The expected scale-to-zero cold-start overhead was therefore about 6.3 seconds.

Comparison of a warm /health request, about 202 milliseconds, with a cold request after scale-to-zero, 6.48 seconds. The cold-start overhead is about 6.28 seconds.

I kept minimum instances at zero because avoiding idle compute cost was more important for me than removing the first-request delay.

Cloud Run tuning

The Cloud Run service has 2 vCPUs, 2 GiB RAM, and maximum one instance.

I tested various configurations by varying ONNX Runtime thread count, Birdly inference concurrency, and Cloud Run request concurrency

The selected backend settings were:

  • ONNX CPU threads: 2
  • inference concurrency: 4
  • Cloud Run request concurrency: 16

I ran a final load test with these settings.

Virtual usersRequests/secp50
17.67126.77 ms
211.39171.59 ms
411.99327.05 ms
812.00660.44 ms
1611.511373.23 ms
Two aligned line charts against virtual users 1, 2, 4, 8, and 16 for five Cloud Run configurations of ONNX thread count and inference concurrency, plus a small chart comparing Cloud Run request concurrency 4 and 16. The selected configuration, 2 ONNX threads with inference concurrency 4, reaches 11.99 requests per second at 4 virtual users and 12.00 at 8, with p50 latency of 327.05 ms and 660.44 ms.

The instance sustained approximately 12 requests per second in the load tests.

The main results are:

AreaResult
Species236
Test images4,712
Top-1 accuracy86.59%
Top-5 accuracy96.75%
Macro F10.8656
CPU runtimeONNX Runtime FP32
CPU batch-1 E2E63.87 ms at 4 threads locally
GPU runtimeTensorRT FP16
TensorRT batch-1 model latency3.62 ms
TensorRT batch-32 throughput730.66 img/s
Local reliability tests18/18 passed
Cloud Run throughputabout 12 req/s
Warm health requestabout 202 ms
Cold health request6.48 s

Frontend

The frontend is a Next.js application deployed to Vercel.

The user flow is:

  1. upload an image
  2. select a square crop
  3. submit the crop
  4. view the returned predictions

Since cropping happens in the browser, only the selected crop is sent to the backend.

Screenshot of the Birdly frontend showing an uploaded bird photograph with the interactive square crop.

The result list supports between zero and five predictions.

Screenshot of the Birdly result view showing the common name, scientific name, and confidence for a bird photograph.

The deployed request path is:

Diagram of the deployed request path: the browser calls the Next.js frontend on Vercel, which calls the FastAPI container on Google Cloud Run, which runs ONNX Runtime FP32 on CPU and returns a prediction.

Raspberry Pi deployment

I also checked whether the INT8 ONNX model could run on a Raspberry Pi Zero W v1.1.

The board only has a single-core ARMv6 CPU with 512 MB RAM

But, the immediate problem was runtime support.

There is no normal ONNX Runtime Python package for that ARMv6 environment.

Cross-compiling ONNX Runtime or moving the model to another runtime would have required additional work for an old hardware target, so I stopped the experiment there.

The Pi Zero W was not useful for running the current classifier, but it can still be used as a camera node.

I then tried deploying the classifier on a Raspberry Pi 5 with 1 GB RAM. Unlike the Zero W, the Pi 5 supports ONNX Runtime on ARM64, so I was able to run the same FP32 model through a FastAPI service. I set it up as a systemd service with one inference worker and a limit of two active prediction requests.

I also ran load and reliability tests on the Pi 5. With one virtual user, it handled 3.43 requests per second with a p50 latency of 284.53 ms. With two virtual users, throughput increased to 3.65 requests per second, with a p50 of 538.70 ms. At four virtual users, most excess requests returned HTTP 503 instead of increasing successful throughput. The reliability tests passed with the two-request limit.

Observations

Runtime choice changed inference performance substantially without changing the trained model.

On CPU, moving from PyTorch eager to ONNX Runtime reduced batch-1 end-to-end latency from about 95 ms to 64 ms at four threads.

On GPU, TensorRT reduced model-only batch-1 latency to 3.62 ms and increased batch-32 throughput to more than 730 images per second.

Reduced precision did not behave the same way on every target.

FP16 was useful for GPU throughput.

INT8 maintained reasonable model quality but did not improve CPU latency on the tested Ryzen system.

The service benchmarks also showed that single-request latency alone was not enough to choose serving settings. Inference concurrency improved throughput until CPU contention reduced the benefit.

Model-only and end-to-end measurements were also materially different. Once TensorRT reduced the forward pass to a few milliseconds, preprocessing became a much larger part of total latency.

Line chart comparing TensorRT FP16 model-only throughput with end-to-end throughput across batch sizes 1 to 32. Model-only throughput grows from 246.59 to 730.66 images per second, while end-to-end throughput flattens near 115 to 118 images per second from batch size 4, reaching 117.81 at batch size 32. End-to-end p50 latency rises from 10.32 ms at batch size 1 to 267.35 ms at batch size 32.

Cloud deployment also added a tradeoff. Scale-to-zero reduced idle cost but added about 6.3 seconds to the first request after the service had been inactive.

Next project →
Adaptive Resampling based Training for Imbalanced Classification