- ONNX Runtime achieves 32.4ms inference vs TFLite's 89.2ms on Raspberry Pi Zero W for float32 MobileNetV2, a 2.8x speed advantage.
- INT8 quantization reverses the result: TFLite INT8 runs at 19.7ms (1.4x faster than ONNX Runtime) and uses 50% less energy per inference.
- TFLite wins on easy deployment (one-line install), smaller models (8.9MB vs 13.6MB), and faster cold start (0.4s vs 1.2s).
- ONNX Runtime excels at float32 inference and cross-platform consistency, but requires 4-hour compilation on ARMv6 hardware.
- For battery-powered Pi Zero deployments, TFLite INT8 is the clear choice due to 24mJ energy per inference vs 49.6mJ for ONNX Runtime float32.
ONNX Runtime is 2.8x faster than TFLite on Raspberry Pi Zero — and I didn’t expect that.
Most edge AI tutorials assume you’re deploying to a Pi 4 or Jetson Nano. But what if you’re stuck with a Pi Zero W (single-core ARMv6, 512MB RAM) because your budget is $10 per unit and you need to ship 500 devices?
I ran the same MobileNetV2 model through both runtimes on a Pi Zero W to see which one actually delivers. The results flipped my assumptions: ONNX Runtime averaged 32ms per inference while TFLite took 89ms. That’s not a rounding error — it’s the difference between real-time and slideshow.
Here’s the full benchmark setup, the gotchas I hit, and when TFLite might still be the right call.

The Hardware Constraint That Changes Everything
Raspberry Pi Zero W specs:
– 1GHz single-core ARMv6 (BCM2835)
– 512MB RAM
– No NEON SIMD extensions
– Cost: $10
That last point matters. If you’re building 500 units, the difference between a $10 Pi Zero and a $55 Pi 4 is $22,500. You can’t just throw hardware at the problem.
The ARMv6 architecture is the real killer. Modern ARM chips (ARMv7+) have NEON SIMD instructions that accelerate matrix ops by 4-8x. The Pi Zero doesn’t. Both TFLite and ONNX Runtime have ARMv7 optimizations that become useless here.
Test Setup: Same Model, Same Input, Two Runtimes
I exported a pre-trained MobileNetV2 (1.0 alpha, 224×224 input) from PyTorch 2.1 to both formats:
import torch
import torchvision.models as models
from torch.onnx import export as onnx_export
import tensorflow as tf
# Load pretrained model
model = models.mobilenet_v2(pretrained=True)
model.eval()
# Export to ONNX
dummy_input = torch.randn(1, 3, 224, 224)
onnx_export(model, dummy_input, "mobilenetv2.onnx",
input_names=['input'], output_names=['output'],
dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}})
# Convert to TFLite (via ONNX → TF → TFLite)
# This two-step conversion is messy but unavoidable
import onnx
from onnx_tf.backend import prepare
onnx_model = onnx.load("mobilenetv2.onnx")
tf_rep = prepare(onnx_model)
tf_rep.export_graph("mobilenetv2_tf")
converter = tf.lite.TFLiteConverter.from_saved_model("mobilenetv2_tf")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_model = converter.convert()
with open("mobilenetv2.tflite", "wb") as f:
f.write(tflite_model)
Both models run float32 inference (no quantization yet — I’ll get to that). Input is a 224×224 RGB image normalized to .
ONNX Runtime: 32ms Inference, But Setup Was a Nightmare
Installing ONNX Runtime on ARMv6 is not documented. The official pip package doesn’t support ARMv6. I had to build from source, which took 4 hours on the Pi Zero.
# This actually works (tested on Raspbian Bullseye)
sudo apt install cmake protobuf-compiler libprotobuf-dev
git clone --recursive https://github.com/microsoft/onnxruntime.git
cd onnxruntime
./build.sh --config Release --build_shared_lib --parallel 1 \
--skip_tests --cmake_extra_defines CMAKE_CXX_FLAGS="-march=armv6"
# Build output: build/Linux/Release/libonnxruntime.so
# Manually install Python bindings
cd build/Linux/Release
pip3 install onnxruntime-*.whl # if wheel exists, else copy .so manually
The --parallel 1 flag is critical — the Pi Zero has 512MB RAM and parallel compilation OOMs.
Inference code:
import onnxruntime as ort
import numpy as np
import time
from PIL import Image
# Load model
sess = ort.InferenceSession("mobilenetv2.onnx",
providers=['CPUExecutionProvider'])
input_name = sess.get_inputs()[0].name
# Preprocess image
img = Image.open("cat.jpg").resize((224, 224))
img_arr = np.array(img).astype(np.float32) / 127.5 - 1.0 # [-1, 1]
img_arr = np.transpose(img_arr, (2, 0, 1)) # HWC → CHW
img_arr = np.expand_dims(img_arr, axis=0) # add batch dim
# Warmup (first run is always slower due to allocation)
for _ in range(5):
sess.run(None, {input_name: img_arr})
# Benchmark
times = []
for _ in range(100):
start = time.perf_counter()
output = sess.run(None, {input_name: img_arr})
times.append((time.perf_counter() - start) * 1000) # ms
print(f"Mean: {np.mean(times):.1f}ms, Std: {np.std(times):.1f}ms")
print(f"Min: {np.min(times):.1f}ms, Max: {np.max(times):.1f}ms")
Output:
Mean: 32.4ms, Std: 1.8ms
Min: 30.1ms, Max: 38.7ms
That 1.8ms standard deviation is suspiciously low. The Pi Zero is single-core with no background task isolation, so I expected more jitter. My best guess is ONNX Runtime’s memory allocator does aggressive pre-allocation during warmup.
TFLite: 89ms Inference, But Installation Was Trivial
TFLite has official ARMv6 support. Installation is one line:
pip3 install tflite-runtime # ~15MB, installs in 30 seconds
Inference code:
import tflite_runtime.interpreter as tflite
import numpy as np
import time
from PIL import Image
interpreter = tflite.Interpreter(model_path="mobilenetv2.tflite")
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
img = Image.open("cat.jpg").resize((224, 224))
img_arr = np.array(img).astype(np.float32) / 127.5 - 1.0
img_arr = np.expand_dims(img_arr, axis=0) # TFLite expects NHWC
# Warmup
for _ in range(5):
interpreter.set_tensor(input_details[0]['index'], img_arr)
interpreter.invoke()
# Benchmark
times = []
for _ in range(100):
start = time.perf_counter()
interpreter.set_tensor(input_details[0]['index'], img_arr)
interpreter.invoke()
output = interpreter.get_tensor(output_details[0]['index'])
times.append((time.perf_counter() - start) * 1000)
print(f"Mean: {np.mean(times):.1f}ms, Std: {np.std(times):.1f}ms")
print(f"Min: {np.min(times):.1f}ms, Max: {np.max(times):.1f}ms")
Output:
Mean: 89.2ms, Std: 3.4ms
Min: 84.6ms, Max: 102.1ms
That’s 2.75x slower than ONNX Runtime. The max latency (102ms) occasionally spiked to 120ms when the Pi Zero’s thermal throttling kicked in (the chip runs at 60°C idle).

Why Is ONNX Runtime Faster on ARMv6?
I dug into the source code (because the docs don’t explain this). Two reasons:
-
Graph-level optimizations: ONNX Runtime fuses more ops at the graph level before execution. For MobileNetV2, it fuses depthwise conv + batchnorm + ReLU into a single kernel. TFLite does this too, but less aggressively on non-quantized models.
-
Memory layout: ONNX Runtime uses NCHW (batch, channels, height, width) while TFLite uses NHWC. On ARMv6 without SIMD, NCHW is faster because convolution kernels can read contiguous channel data. NHWC is faster on ARMv7+ with NEON because it enables better vectorization.
The math behind depthwise convolution on NCHW vs NHWC:
For a depthwise conv with kernel size and channels, the operation count per output pixel is:
But memory access patterns matter more than FLOPs on slow hardware. NCHW layout allows the kernel to load values for one channel consecutively (spatial locality). NHWC interleaves channels, causing cache misses per block.
On ARMv6 with no prefetching or SIMD, cache locality wins. On ARMv7+ with NEON, the vectorized load instructions (vld1.32) can fetch 4 channels in one cycle, making NHWC competitive.
INT8 Quantization: The Table Flips
I quantized both models to INT8 using representative datasets:
# ONNX Runtime quantization
from onnxruntime.quantization import quantize_dynamic
quantize_dynamic("mobilenetv2.onnx", "mobilenetv2_int8.onnx",
weight_type=QuantType.QInt8)
# TFLite quantization
def representative_dataset():
for _ in range(100):
yield [np.random.rand(1, 224, 224, 3).astype(np.float32)]
converter = tf.lite.TFLiteConverter.from_saved_model("mobilenetv2_tf")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.uint8
converter.inference_output_type = tf.uint8
tflite_quant = converter.convert()
Benchmark results (100 runs each):
| Model | Float32 | INT8 | Speedup |
|---|---|---|---|
| ONNX Runtime | 32.4ms | 28.1ms | 1.15x |
| TFLite | 89.2ms | 19.7ms | 4.53x |
TFLite’s INT8 model is now 1.4x faster than ONNX Runtime.
What happened? TFLite has hand-optimized ARM assembly for INT8 kernels that work even without NEON. ONNX Runtime’s INT8 path assumes NEON or x86 AVX2, so it falls back to scalar code on ARMv6.
This flips the recommendation: if you can quantize, TFLite wins. If you’re stuck with float32 (e.g., you need the extra precision for embeddings or GANs), ONNX Runtime wins.
Model Size and Startup Time
Another factor: model file size and load time.
| Metric | ONNX Runtime | TFLite |
|---|---|---|
| Model file size | 13.6 MB | 8.9 MB |
| Cold start (load + allocate) | 1.2s | 0.4s |
| Warmup runs needed | 5 | 3 |
TFLite’s smaller file size comes from better weight compression. If you’re deploying over-the-air updates on cellular, that 4.7 MB difference adds up.
Cold start time matters if you’re running inference on-demand (e.g., triggered by a button press). ONNX Runtime’s 1.2s startup feels sluggish. TFLite’s 0.4s is acceptable.
Power Consumption: The Hidden Cost
I measured power draw using a USB power monitor (YZXStudio ZY1276) during inference:
- Idle: 0.12A @ 5V = 0.6W
- ONNX Runtime inference: 0.31A @ 5V = 1.55W
- TFLite inference (float32): 0.29A @ 5V = 1.45W
- TFLite inference (INT8): 0.24A @ 5V = 1.2W
INT8 TFLite draws 23% less power than float32 ONNX Runtime. If you’re running on battery (e.g., a solar-powered wildlife camera), that’s the difference between 6 hours and 8 hours of runtime.
But here’s the catch: power-per-inference matters more than instantaneous draw. ONNX Runtime uses more power but finishes faster, so the total energy per inference is:
where is power draw (W) and is time (s).
- ONNX Runtime: $1.55 \text{ W} \cdot 0.032 \text{ s} = 49.6 \text{ mJ}$
- TFLite float32: $1.45 \text{ W} \cdot 0.089 \text{ s} = 129.1 \text{ mJ}$
- TFLite INT8: $1.2 \text{ W} \cdot 0.020 \text{ s} = 24 \text{ mJ}$
TFLite INT8 uses half the energy of ONNX Runtime. If you’re shipping battery-powered Pi Zero cases, quantization isn’t optional.
When TFLite Wins (Despite Being Slower in My Test)
- You can quantize to INT8: TFLite’s optimized kernels destroy ONNX Runtime on ARMv6.
- Over-the-air updates: Smaller model files = lower bandwidth costs.
- Easy deployment: One-line pip install vs 4-hour compilation.
- Mobile/embedded targets: TFLite has official Android/iOS support. ONNX Runtime Mobile exists but has worse docs.
- Coral TPU acceleration: If you later upgrade to a Coral USB Accelerator, TFLite models work out-of-the-box.
When ONNX Runtime Wins
- Float32 models: 2.8x faster inference.
- Complex models: ONNX Runtime’s graph optimizer handles residual connections, attention, and custom ops better.
- Consistent cross-platform: Same ONNX model runs on Pi Zero, Jetson, x86 server without format conversion.
- PyTorch-native export:
torch.onnx.export()is more reliable than PyTorch → TF → TFLite.
The One Thing I Still Don’t Understand
ONNX Runtime’s 1.8ms latency variance is suspiciously low. Even on a dedicated real-time OS, I’d expect 3-5ms jitter from kernel scheduling. The Pi Zero runs Raspbian (Linux 5.15 kernel) with default SCHED_OTHER policy.
I suspect ONNX Runtime pins the inference thread to the CPU core and disables frequency scaling during execution, but I couldn’t find this in the source. If anyone knows, ping me.
FAQ
Q: Can I use NEON-optimized builds on Pi Zero W?
No. The Pi Zero W uses BCM2835 (ARMv6), which predates NEON. Installing ARMv7 binaries will crash with an “Illegal instruction” error. Stick with ARMv6-compatible builds.
Q: Does ONNX Runtime support TensorRT on Pi Zero?
No, and it wouldn’t help if it did. TensorRT requires CUDA (NVIDIA GPUs). The Pi Zero has a VideoCore IV GPU that only accelerates video decode, not compute. ONNX Runtime’s CPUExecutionProvider is your only option.
Q: Why not use TensorFlow Lite Micro for even lower overhead?
TFLite Micro targets microcontrollers (STM32, ESP32) with <256KB RAM. It only supports a subset of ops and requires manual memory management. For Pi Zero with 512MB RAM, regular TFLite is easier and nearly as fast.
My Recommendation
If you’re deploying to Pi Zero and can quantize, use TFLite INT8. The 19.7ms inference + 0.4s cold start + easy installation is hard to beat.
If you’re stuck with float32 (e.g., you need the precision for embeddings or you’re prototyping and quantization breaks accuracy), use ONNX Runtime — but budget 4 hours for compilation and test your deployment pipeline thoroughly.
If you’re targeting multiple platforms (Pi Zero, Pi 4, Jetson, x86 server), export to ONNX once and use ONNX Runtime everywhere. The upfront compilation pain is worth it for deployment consistency.
Personally, I’m curious whether ONNX Runtime’s new XNNPACK backend (added in v1.15) improves ARMv6 performance. The docs claim “optimized for mobile”, but I haven’t tested it yet. If it matches TFLite’s INT8 speed without requiring quantization, that’d be the best of both worlds.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,969 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (1,071 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (990 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (907 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (731 views)