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
Architecture, yes — core count, no. The .vmfb never encodes how many cores it will run on:
iree-compile decides tiling and workgroups from shapes and the target ISA, and the device maps
those workgroups onto cores at run time (Step 5). The skainet.schedule attribute the export
harnesses put into the module header states which axes are independent and is advisory.
--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
Cores are chosen here, not in Step 3: IreeRedecodeDecoder.fromAssets(…, taskTopologyGroupCount
= n) — or the SKAINET_TASK_GROUPS environment variable it defaults to — sets the local-task
worker group count when the device is created; leave it null for IREE’s own topology detection.
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