MVP Factory
ai startup development

On-device vision transformers: TFLite on Android

KW
Krystian Wiewiór · · 5 min read

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.

DelegateBest ForFailure ModeFallback
NNAPIDSP/NPU offload, Qualcomm HexagonOp coverage gaps, driver bugsGPU
GPU DelegateParallelizable FP16 opsThermal, memory bandwidthXNNPACK
XNNPACKARM NEON optimized CPU opsNone — always availableInterpreter

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:

DeviceSoCAndroidDriverSafe thread count
Pixel 7aTensor G214Adreno 6603
Galaxy A54Exynos 138014Mali-G682
Moto G84Snapdragon 69513Adreno 6193
Redmi Note 12Snapdragon 68513Adreno 6102

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.


Share: Twitter LinkedIn