Skip to main content

litert-runtime

Configures and implements LiteRT (.tflite) on-device inference on Android in Kotlin using the CompiledModel API on CPU and GPU. Use this skill for any Android LiteRT or .tflite task, including Gradle setup, image classification or vision/audio/embedding helpers, TensorBuffer reuse, CPU memory optimization, Bitmap preprocessing, AndroidViewModel GPU-to-CPU fallback with warm-up on a single-thread dispatcher, LiteRT in Google Play services (play-services-litert), migrating from legacy org.tensorflow.lite.Interpreter, GpuDelegate, or play-services-tflite, and latency benchmarking. Don't use for running generative text LLMs with .litertlm files or for Python model conversion.

Source facts

Repository
android/skills
Last source activity
October 29, 2026 at 10:10
Detected SKILL.md language
English
Stars
10,008
Forks
650

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
5 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
litert-runtime
description
Configures and implements LiteRT (.tflite) on-device inference on Android in Kotlin using the CompiledModel API on CPU and GPU. Use this skill for any Android LiteRT or .tflite task, including Gradle setup, image classification or vision/audio/embedding helpers, TensorBuffer reuse, CPU memory optimization, Bitmap preprocessing, AndroidViewModel GPU-to-CPU fallback with warm-up on a single-thread dispatcher, LiteRT in Google Play services (play-services-litert), migrating from legacy org.tensorflow.lite.Interpreter, GpuDelegate, or play-services-tflite, and latency benchmarking. Don't use for running generative text LLMs with .litertlm files or for Python model conversion.
license
Complete terms in LICENSE.txt
metadata
{"author":"Google LLC","last-updated":"2026-10-28","keywords":["LiteRT","CompiledModel","tflite","Android app","GPU","TensorBuffer"]}
This skill provides step-by-step guidance for building an Android app that runs a `.tflite` model with the [LiteRT CompiledModel API](https://ai.google.dev/edge/litert/android) (`com.google.ai.edge.litert:litert` 2.x; [source repository](https://github.com/google-ai-edge/litert/blob/main/README.md)) on the CPU or the GPU, with the runtime bundled in the APK or from Google Play services. Don't use the deprecated `org.tensorflow.lite.Interpreter` API. For open text language models using `.litertlm` files, suggest the `litert-lm` skill. ## Prerequisites - A Kotlin Android project (`minSdk` must be `24` or higher, as declared by the LiteRT 2.3.0 AARs). - The runtime that Step 1 picks: the Google Play services runtime (`com.google.android.gms:play-services-litert`, the default; see [LiteRT in Google Play services](references/play-services.md)), or the bundled runtime (`implementation("com.google.ai.edge.litert:litert:2.3.0")` and `implementation("com.google.ai.edge.litert:litert-gpu:2.3.0")` from Google Maven). For the bundled runtime, from 2.3.0 the GPU accelerator is the second artifact: with `litert` alone the app builds, and `CompiledModel.create` with `CompiledModel.Options(Accelerator.GPU)` throws `LiteRtException` because the APK has no GPU accelerator library. With `CompiledModel.Options(Accelerator.GPU, Accelerator.CPU)` it doesn't throw: the model runs on the CPU while the app reports the GPU, and only Logcat shows it (`GPU accelerator could not be loaded and registered`). No manifest entry is needed: `litert-api`, which `litert` depends on, declares the OpenCL driver libraries (`uses-native-library`) in its own manifest. - A verified `.tflite` model and its input contract (input dimensions, mean, standard deviation, channel order, and tensor layout). For models with a verified recipe, inspect the [LiteRT model recipes](https://github.com/google-ai-edge/litert-samples/blob/main/models/README.md). ## Critical mandates - **Single-thread confinement** : One single-thread executor or confined coroutine dispatcher must own the `CompiledModel` instance. Creation, inference, and closing must execute on that same background thread, never on the main thread. - **Buffer lifecycle** : Allocate `TensorBuffer` instances once using `createInputBuffers` and `createOutputBuffers`, reuse them across inferences, and close all input and output buffers before closing the `CompiledModel`. - **Synchronous timing** : `model.run` enqueues work on the GPU and returns before GPU execution completes. Always measure `model.run` and output buffer readback (`outputs[0].readFloat`) together. - **Warm-up and exception safety** : Execute one warm-up inference inside the helper constructor's `init` block so first-run GPU shader compilation doesn't block the first user interaction, and call `close` in `catch` before rethrowing so neither `CompiledModel` nor `TensorBuffer` handles leak if buffer allocation or warm-up throws. - **Runtime choice** : Before Step 1, you must read [LiteRT in Google Play services](references/play-services.md) and choose the runtime. Use the Google Play services runtime unless the app needs the NPU, custom operators, or a Support library class that this skill can't replace (such as `MetadataExtractor`), or also targets devices without Google Play services. Never put both runtimes in the same build variant. - **Migration** : When the app already uses `org.tensorflow.lite` code, you must read the "Migrating from legacy TFLite Interpreter" section in full before you edit any file. Migrated code must not import any `org.tensorflow.lite` class (including `DataType`), every legacy GPU choice (`GpuDelegate`, or the Support library's `Model.Device.GPU`) becomes `CompiledModel.Options(Accelerator.GPU, Accelerator.CPU)`, and an NNAPI choice maps to that same GPU option. ## Detailed steps ### Step 1: Configure Gradle and place the model file Search the project for `libs.versions.toml`, `gradle.properties`, `app/build.gradle.kts`, and any existing `org.tensorflow.lite` imports before editing build files. Then pick the runtime: - **Google Play services runtime (default)** : Use it unless one of the bundled runtime cases applies, also when the request doesn't name a runtime. You must read [LiteRT in Google Play services](references/play-services.md) now: its dependencies replace the ones in this step, and its `Environment` replaces the `environment` property of Step 2. Use version `17.0.0` exactly, and run `./gradlew assembleDebug` (not `--dry-run`) after you add it. Switch to the bundled runtime only when that build reports `Could not find com.google.android.gms:play-services-litert:17.0.0`, and tell the user, also when the user asked for Google Play services. Never leave the build broken. - **Bundled runtime** : Use it only when the app needs the NPU, custom operators, or a Support library class that this skill can't replace (such as `MetadataExtractor`), when the app also targets devices without Google Play services, or when the user asks for it. `FileUtil`, `ImageProcessor`, `TensorImage`, and `TensorLabel` don't count: replace them as [LiteRT in Google Play services](references/play-services.md) describes. For the bundled runtime, configure `minSdk` and add the LiteRT runtime and GPU accelerator dependencies in `app/build.gradle.kts`: android { defaultConfig { minSdk = 24 } } dependencies { implementation("com.google.ai.edge.litert:litert:2.3.0") implementation("com.google.ai.edge.litert:litert-gpu:2.3.0") } Place bundled models at `app/src/main/assets/model.tflite` (AGP stores `.tflite` files uncompressed by default). Deliver a model that exceeds the base APK size limit through [Play for On-Device AI](references/android/google/play/on-device-ai.md) as an AI pack, or download it into `context.filesDir` (`bundletool --local-testing` installs AI packs locally without Google Play). Read an install-time AI pack through `AssetManager`. Load a fast-follow or on-demand AI pack or a downloaded file from its on-disk `filePath` using `CompiledModel.create(filePath, CompiledModel.Options(accelerator), environment)` in place of the asset call in Step 2. Obtain the pack's directory from the AI Delivery library (`com.google.android.play:ai-delivery`) using `AiPackManagerFactory.getInstance(context).getPackLocation(name)?.assetsPath()`, which returns `null` until the pack has finished downloading. With 2.3.0, nothing is added to `gradle.properties`: `litert` 2.3.0 declares the namespace `com.google.ai.edge.litert.impl` and its dependency `litert-api` 2.3.0 declares `com.google.ai.edge.litert`, so AGP 9.x builds the project. With 2.2.0 or 2.1.6, both libraries declare `com.google.ai.edge.litert`, and AGP 9.x stops at `processDebugMainManifest` (AGP 8.x reports it as a warning and builds); for those two versions, add `android.uniquePackageNames=false` to `gradle.properties`, and remove it when moving to 2.3.0 because it also hides the same clash between any other two libraries. Excluding `litert-api` instead doesn't work: `CompiledModel` and `Accelerator` are in `litert-api`, and the build then fails at `compileDebugKotlin`. ### Step 2: Implement the inference helper class Isolate `CompiledModel` and its `TensorBuffer` instances in a dedicated `AutoCloseable` helper class free of Android UI types: import android.content.Context import com.google.ai.edge.litert.Accelerator import com.google.ai.edge.litert.CompiledModel import com.google.ai.edge.litert.Environment import com.google.ai.edge.litert.TensorBuffer const val INPUT_SIZE = 224 * 224 * 3 private val environment by lazy { Environment.create() } class Classifier(context: Context, accelerator: Accelerator) : AutoCloseable { private val model = CompiledModel.create( context.assets, "model.tflite", CompiledModel.Options(accelerator), environment ) private val inputs = mutableListOf<TensorBuffer>() private val outputs = mutableListOf<TensorBuffer>() init { try { inputs += model.createInputBuffers() outputs += model.createOutputBuffers() infer(FloatArray(INPUT_SIZE)) } catch (e: Exception) { close() throw e } } fun infer(input: FloatArray): FloatArray { inputs[0].writeFloat(input) model.run(inputs, outputs) return outputs[0].readFloat() } override fun close() { (inputs + outputs).forEach { it.close() } model.close() } } `Accelerator.CPU` requires nothing from the device. `Accelerator.GPU` compiles the graph for the GPU; when the GPU can't execute the model, the constructor throws `LiteRtException` (from `create` for an operator the GPU doesn't support, and on the Android emulator from `create` or from buffer allocation). A missing asset or a file that isn't a valid model also throws `LiteRtException` from `create`. The buffers are created once with the model, reused for every inference, and closed before the model. One `Environment` serves every model in the process and stays open, so LiteRT loads the GPU accelerator library once rather than on every `create` call. The constructor ends with one warm-up inference (the first GPU run includes shader compilation) and closes the model and its buffers if any step in `init` throws. `INPUT_SIZE` is the model's input element count: `writeFloat` throws on a longer array and writes a shorter one without an error, so a wrong size produces an incorrect result. For `int8` or `uint8` tensors, use `writeInt8` and `readInt8` on `TensorBuffer` instead of `writeFloat` and `readFloat`. When memory matters, use the following options: - **Load from a path** : `create(context.assets, ...)` keeps a copy of the asset's bytes in the native heap while the model is open. Copy the asset once into `context.noBackupFilesDir` (a downloaded or AI pack file is already on disk) and call `create(filePath, ...)` so the file is mapped and reclaimable. Version the staged filename, bump the version when the bundled model changes, and delete the previous version's file. Copy to a temporary file and rename it so a copy interrupted by process death is never loaded: import android.content.Context import com.google.ai.edge.litert.Accelerator import com.google.ai.edge.litert.CompiledModel import java.io.File fun stageModelAsset( context: Context, assetName: String = "model.tflite", version: String = "v1" ): File = File(context.noBackupFilesDir, "$version-$assetName").also { file -> if (!file.isFile) { context.noBackupFilesDir.listFiles()?.forEach { if (it.name.endsWith("-$assetName")) it.delete() } val tmp = File("${file.path}.tmp") context.assets.open(assetName).use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } check(tmp.renameTo(file)) } } fun memoryOptions( context: Context, accelerator: Accelerator = Accelerator.CPU, assetName: String = "model.tflite", version: String = "v1" ): CompiledModel.Options = CompiledModel.Options(accelerator).apply { val prefix = "$assetName-" val cache = File(context.codeCacheDir, "$prefix$version.xnnpack_cache") context.codeCacheDir.listFiles()?.forEach { if (it.name.startsWith(prefix) && it.name.endsWith(".xnnpack_cache") && it != cache) { it.delete() } } cpuOptions = CompiledModel.CpuOptions( numThreads = 4, xnnPackFlags = 0x23, xnnPackWeightCachePath = cache.path ) } - **Weight cache (`xnnPackWeightCachePath`)**: Written on the first load (\~1x an FP32 or \~2x an FP16 model's file size) and mapped on later loads, moving packed FP32 weights off the heap. Version the cache filename and delete outdated caches. - **Transient indirection buffer (`xnnPackFlags = 0x23`)** : Default `0x3` plus flag `0x20`, replacing per-operator convolution pointer tables with one shared buffer (byte-identical outputs, \~1% latency overhead at 4 threads, \~3% single-threaded). Because the value replaces the default, `0x20` alone leaves `int8` and `uint8` operators to fallback kernels. `cpuOptions` is ignored on the GPU. - **Close after a task**: Closing frees the runtime workspace; recreating with a warm cache takes \~20 ms, whereas the first inference after any load pays a full warm-up run, so close when the next use isn't imminent. ### Step 3: Wire the ViewModel and Compose screen Drive the helper from a single-thread executor inside an `AndroidViewModel`, and fall back to `Accelerator.CPU` when GPU compilation fails: import android.app.Application import androidx.lifecycle.AndroidViewModel import com.google.ai.edge.litert.Accelerator import com.google.ai.edge.litert.LiteRtException import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class UiState( val ready: Boolean = false, val accelerator: Accelerator = Accelerator.CPU, val result: List<Float>? = null, val error: String? = null ) class MainViewModel(app: Application) : AndroidViewModel(app) { private val executor = Executors.newSingleThreadExecutor() private val scope = CoroutineScope( SupervisorJob() + executor.asCoroutineDispatcher() ) private var classifier: Classifier? = null private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() fun load(preferred: Accelerator = Accelerator.GPU) { scope.launch { if (classifier != null) return@launch val appContext = getApplication<Application>() val selected = try { classifier = Classifier(appContext, preferred) preferred } catch (e: LiteRtException) { if (preferred != Accelerator.CPU) { try { classifier = Classifier(appContext, Accelerator.CPU) Accelerator.CPU } catch (cpuError: LiteRtException) { _uiState.value = UiState(error = cpuError.message) return@launch } } else { _uiState.value = UiState(error = e.message) return@launch } } _uiState.value = UiState(ready = true, accelerator = selected) } } fun classify(input: FloatArray) { scope.launch { try { classifier?.infer(input)?.let { result -> _uiState.update { it.copy(result = result.toList(), error = null) } } } catch (e: LiteRtException) { _uiState.update { it.copy(error = e.message) } } } } override fun onCleared() { scope.launch { classifier?.close() }.invokeOnCompletion { scope.cancel() } executor.shutdown() } } One single-thread executor owns the model: creation, inference, and closing execute only on it, never on the main thread. The `ViewModel` keeps its own scope because `viewModelScope` is canceled before `onCleared` runs, so a close launched there wouldn't run; `onCleared` cancels `scope` once `close` completes. `load` is a no-op when a model is already loaded across configuration changes; a failed `load` leaves `classifier` `null`. `classify` reports any `LiteRtException` (in package `com.google.ai.edge.litert`) in `error` and keeps the model. The following Compose screen takes the picture (`bitmap`) from the photo picker or CameraX and runs `preprocess` in the click handler (a few milliseconds for 224x224; move `preprocess` onto the executor for images exceeding 224x224). `viewModel` and `collectAsStateWithLifecycle` come from `androidx.lifecycle:lifecycle-viewmodel-compose` and `androidx.lifecycle:lifecycle-runtime-compose`: import android.graphics.Bitmap import androidx.compose.foundation.layout.Column import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.google.ai.edge.litert.Accelerator @Composable fun MainScreen( bitmap: Bitmap, viewModel: MainViewModel = viewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.load(Accelerator.GPU) } Column { state.error?.let { Text(it) } Button( onClick = { viewModel.classify(preprocess(bitmap)) }, enabled = state.ready ) { Text("Run (${state.accelerator})") } state.result?.let { scores -> Text("Top class: ${scores.indices.maxBy { i -> scores[i] }}") } } } ### Step 4: Preprocess inputs to match the model contract Convert the `Bitmap` into the model's input with `preprocess`. Match the model's exact input dimensions, normalization mean and standard deviation, channel order, and memory layout (this example uses NHWC, RGB, scaled to `-1.0..1.0`). Mismatched `mean` or `std` values produce corrupted predictions that look like a broken model: import android.graphics.Bitmap fun preprocess( bitmap: Bitmap, size: Int = 224,
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub