Compile a Model for Android (DSL → StableHLO → IREE)
This guide walks the compiled path end to end, using SmolLM2-135M as the worked example — the same model the eager-path tutorial uses, so you can compare both paths on identical ground. For why you’d pick this path over eager, see Eager vs. Compiled on Android.
Prerequisites
-
Docker, and the
SKaiNET-iree-toolchainrepo cloned as a sibling checkout (or letbin/iree-runauto-build the images on first use). -
A GGUF checkpoint for the model you’re exporting.
-
An export harness.
:llm-inference:smollm2’s `SmolLm2ExportHarnessis the reference implementation this guide follows — if you’re exporting a different model, start by copying its shape (see "Writing your own export harness" below) rather than starting from scratch.
Step 1: Export to StableHLO + externalized weights
Run the export task (or the equivalent for your own harness):
SMOLLM2_GGUF=/path/to/SmolLM2-135M-Instruct-Q8_0.gguf \
SMOLLM2_OUT_DIR=build/mlir \
./gradlew :llm-inference:smollm2:exportSmolLm2
This traces the DSL model (LlamaNetworkLoader.fromWeights), strips
per-layer KV caches (a single fixed-seq forward pass needs none — see
the harness’s own doc comment for why KVCache.update() isn’t
traceable), appends the DSL’s in-graph argMax so the compiled function
returns small token ids instead of a full [seq, vocab] logits tensor,
and emits two files: <model>-gen.mlir (the StableHLO graph) and
<model>.safetensors (every weight, externalized — not baked into the
graph).
The exported function follows a fixed contract:
tensor<1xSEQxi32> → tensor<SEQxi32> — token ids in, one predicted
next-token id per position out. llm-runtime/iree-android’s runtime is
written against exactly this contract; if your own harness’s export
doesn’t match it, `IreeRedecodeDecoder won’t be able to drive it — see
IREE Android Runtime API for the contract in full.
Step 2: Weights to .irpa
cd build/mlir
iree-run compiler convert-parameters \
--parameters=<model>.safetensors --output=<model>.irpa
This is architecture-agnostic tooling, not SKaiNET-specific — .irpa is
IREE’s own parameter-archive format. One .irpa serves every ABI and
every compile target (CPU or GPU) — it’s just tensor bytes, not machine
code.
Step 3: Compile to a .vmfb, once per ABI
Unlike the .irpa, the .vmfb is architecture-specific machine
code — you need a separate compile per Android ABI you ship:
iree-run compiler compile-cpu <model>-gen.mlir --target arm64 --out <model>-gen-arm64.vmfb
iree-run compiler compile-cpu <model>-gen.mlir --target arm32 --out <model>-gen-arm32.vmfb
--target arm64 compiles for aarch64-linux-android29, cortex-a76,
with dotprod; --target arm32 compiles for
armv7a-linux-androideabi29, cortex-a55, NEON only. Both are defined
in the toolchain’s compile-cpu subcommand — see
docker/compiler/entrypoint.sh in SKaiNET-iree-toolchain if you need
a different CPU target than either default.
|
Also compile a |
GPU (Vulkan), if your export supports it
iree-run compiler compile-vulkan <model>-gen.mlir --out <model>-gen-vulkan.vmfb
One Vulkan vmfb (portable SPIR-V) serves every ABI — GPU compute doesn’t
have the per-ABI machine-code problem CPU codegen does. This is not
guaranteed to succeed: SmolLM2’s own export currently fails to compile
for Vulkan (a real iree-compile 3.11.0 codegen gap on the
token-embedding stablehlo.gather — see
IREE Android Runtime API for the exact error). Try it;
if it fails, local-task (CPU) still works and is what this guide
verifies end to end.
Step 4: Get the JNI runtime .so
If you’re targeting the redecode contract from Step 1, you don’t need
to build anything here — llm-runtime:iree-android already ships
pre-built `.so`s for both ABIs (checked in, not built per-model). Add it
as a dependency:
implementation("sk.ainet.transformers:skainet-transformers-runtime-iree-android:0.40.0")
You only need to touch llm-runtime/iree-android/native/ yourself if
you’re changing the runtime’s contract (e.g. supporting the two-graph
KV-cache pattern — see Eager vs. Compiled on Android).
In that case, native/build-iree-redecode.sh <abi> [--vulkan] cross-builds
via the same toolchain image; the module’s own README documents the
--link flags required (external-weight vmfbs need three IREE targets
beyond the runtime default — iree_modules_io_parameters_parameters,
iree_io_parameter_index, iree_io_parameter_index_provider,
iree_io_formats_irpa_irpa — because the weights are bound from a
.irpa at session-create time, not baked into the vmfb).
Step 5: Bundle and run
Copy the .vmfb(s) and .irpa into your app’s assets:
app/src/main/assets/
<model>/
<model>-gen-arm64.vmfb
<model>-gen-arm32.vmfb
<model>.irpa
Then drive it:
val abi = Build.SUPPORTED_ABIS.first { it == "arm64-v8a" || it == "armeabi-v7a" }
val vmfbAsset = if (abi == "arm64-v8a") "<model>/<model>-gen-arm64.vmfb" else "<model>/<model>-gen-arm32.vmfb"
val decoder = IreeRedecodeDecoder.fromAssets(
context,
vmfbAsset = vmfbAsset,
irpaAsset = "<model>/<model>.irpa",
functionName = "module.<model>", // matches the MLIR func name from Step 1
seq = 24, // matches the seq the export was traced at
cacheDirName = "skainet_<model>",
)
val generated = decoder.generate(promptTokenIds, eosTokenId = tokenizer.eosTokenId)
decoder.close()
fromAssets copies both files to filesDir on first run (IREE needs
real file paths, not asset streams) and creates the native session —
call it off the main thread.
Writing your own export harness
If you’re exporting a model other than SmolLM2, don’t start from a blank
file — copy SmolLm2ExportHarness’s shape
(`llm-inference/smollm2/src/jvmMain/). The parts that are genuinely
model-specific: which loader builds the DSL graph
(LlamaNetworkLoader.fromWeights for llama-family checkpoints —
substitute the right one for your architecture), and the checkpoint
path/seq passed in. The parts that are architecture-agnostic and should
be copied close to verbatim: stripping KVCache before tracing,
appending ectx.ops.argMax(logits, dim = -1)
ectx.ops.squeeze(idx, 0), ConstantMaterializationPolicy.ExternalAlways,
and the bf16 safetensors writer.
Common problems
| Problem | What to check |
|---|---|
|
A real codegen gap in IREE 3.11.0 for token-embedding gather patterns, not something you can fix from the export side. Use |
|
Confirm you cross-built the runtime |
Output is garbage / wrong shape |
Verify against a |
App crashes or hangs loading the |
Confirm the |
Need a different |
|
See also
-
Eager vs. Compiled on Android — why this path exists alongside the eager one
-
IREE Android Runtime API — full API reference and the redecode graph contract
-
Android Getting Started — the eager-path equivalent of this tutorial