On-device vision transformers: TFLite on Android
Meta description: How to wire TFLite delegate hierarchy, calibrate INT8 quantization, pre-allocate tensor arena, and tune XNNPACK threading for Vision Transformers on Android.
Tags: android mobile architecture kotlin kmp
TL;DR
Running a quantized Vision Transformer on-device is achievable on mid-range Android hardware — but only if you wire the delegate hierarchy correctly, calibrate INT8 quantization with a representative dataset, pre-allocate your tensor arena, and tune XNNPACK thread count against your thermal budget. Skip any one of these and you will either crash on NNAPI, bleed latency on GPU transitions, or cook your users’ Snapdragon 7-series into throttle territory within 30 seconds.
The delegate selection hierarchy: what most teams get wrong
Most teams treat TFLite delegate selection as a binary NNAPI-on/NNAPI-off switch. In production, the runtime attempts delegation in a priority chain, and each fallback has measurable cost.
| Delegate | Best For | Failure Mode | Fallback |
|---|---|---|---|
| NNAPI | DSP/NPU offload, Qualcomm Hexagon | Op coverage gaps, driver bugs | GPU |
| GPU Delegate | Parallelizable FP16 ops | Thermal, memory bandwidth | XNNPACK |
| XNNPACK | ARM NEON optimized CPU ops | None — always available | Interpreter |
NNAPI looks attractive on paper, but Vision Transformer attention layers frequently contain ops that NNAPI delegates partially — triggering a split-graph execution that costs more than a full CPU path. Always validate op coverage before committing to NNAPI in production.
val options = Interpreter.Options().apply {
val nnapi = NnApiDelegate.Options().apply {
executionPreference = NnApiDelegate.Options.EXECUTION_PREFERENCE_SUSTAINED_SPEED
allowFp16 = true
}
addDelegate(NnApiDelegate(nnapi))
// GPU fallback if NNAPI coverage is incomplete
addDelegate(GpuDelegate(GpuDelegate.Options().apply {
inferencePreference = GpuDelegate.Options.INFERENCE_PREFERENCE_SUSTAINED_SPEED
}))
}
val interpreter = Interpreter(modelBuffer, options)
Validate both NNAPI and GPU delegate coverage after initialization. If either fails to cover the full graph, TFLite silently falls back to the next tier — log the number of resolved input tensors and cross-reference with your expected graph shape. For GPU delegates specifically, wrap initialization in a try-catch and inspect GpuDelegate’s compatibility check. Driver-level failures on some Android 12 OEM builds surface as silent no-ops, not exceptions. Silence is not success.
INT8 quantization: calibration is the whole game
Post-training INT8 quantization without a representative calibration dataset ships accuracy regressions disguised as a latency optimization. The calibration set should cover the realistic distribution of inference inputs your production model will see — not ImageNet defaults.
In my experience, teams that use 100–500 representative samples from their actual domain see accuracy degradation under 1% versus FP32. Teams that skip calibration entirely and use synthetic or generic data regularly see 5–8% drops on tail distributions. The exact numbers depend on your architecture and domain shift, but the directional risk is consistent.
def representative_dataset():
for image in calibration_samples: # domain-specific images
yield [image.astype(np.float32)]
converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
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.int8
converter.inference_output_type = tf.int8
Tensor arena pre-allocation: eliminating GC pressure at inference time
The tensor arena is TFLite’s scratch memory for intermediate activations. If you let the runtime allocate it lazily, you trigger garbage collection during inference — invisible in benchmarks, catastrophic in production when the GC pause lands mid-frame.
Pre-allocate the arena at model load time by calling allocateTensors() immediately after interpreter construction, not on the first inference call. For Vision Transformers with sequence lengths typical of mobile-sized patches, arena sizes in the 20–60 MB range are common. Profile with Interpreter.getInputTensor(0).numBytes() and walk the graph to size it correctly.
XNNPACK threading: the thermal equation on Snapdragon 7-series
On Snapdragon 7-series silicon, XNNPACK scales well up to 4 threads for throughput — beyond that, you gain diminishing returns while accelerating thermal throttling. For sustained inference workloads (camera-feed analysis, real-time classification), 2–3 threads is the production-validated sweet spot.
val xnnpackOptions = XNNPackDelegate.Options().apply {
numThreads = 3 // thermal-safe for sustained inference on 7-series
}
options.addDelegate(XNNPackDelegate(xnnpackOptions))
Default thread counts optimize for peak benchmark numbers, not sustained real-world performance. Here’s what that looks like across a sample of mid-range devices:
| Device | SoC | Android | Driver | Safe thread count |
|---|---|---|---|---|
| Pixel 7a | Tensor G2 | 14 | Adreno 660 | 3 |
| Galaxy A54 | Exynos 1380 | 14 | Mali-G68 | 2 |
| Moto G84 | Snapdragon 695 | 13 | Adreno 619 | 3 |
| Redmi Note 12 | Snapdragon 685 | 13 | Adreno 610 | 2 |
Treat these as starting points. Instrument thermal state via PowerManager.getThermalHeadroom() to detect throttle onset dynamically — the difference between a smooth experience and a device that starts dropping frames after 30 seconds is usually just a thread or two.
Conclusion
The same four mistakes keep showing up in production deployments: shipping NNAPI without validating op coverage, running INT8 conversion without a domain-specific calibration set, allocating tensor arena lazily, and leaving XNNPACK thread count at whatever the default is. Each one individually is a 10-minute fix. Together they’re the difference between an on-device model that works and one that you end up blaming on the hardware.
Get these right and mid-range Android hardware is more capable than most teams expect.