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.

GGUF checkpoint

Export harness:
trace DSL model,
strip KV cache,
append argMax tail

.mlir
(StableHLO)

weights safetensors

iree-convert-parameters

.irpa
(portable, one file, all ABIs)

iree-compile
--target arm64 / arm32

arm64 .vmfb

arm32 .vmfb
(machine code, per ABI)

Android app assets

IreeRedecodeDecoder.fromAssets(...)

Prerequisites

  • Docker, and the SKaiNET-iree-toolchain repo cloned as a sibling checkout (or let bin/iree-run auto-build the images on first use).

  • A GGUF checkpoint for the model you’re exporting.

  • An export harness. :llm-inference:smollm2’s `SmolLm2ExportHarness is 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 --target host vmfb and run it through iree-run compiler run-module on your dev machine before shipping anything to a device. It’s real x86_64/aarch64-host machine code you can actually execute locally — the arm64/arm32 Android builds can’t run on a typical dev workstation at all, so --target host is your only way to catch a broken export (wrong shapes, garbage output) before it’s on a phone. See IREE Android Runtime API for how this was verified for SmolLM2.

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

iree-compile fails on the Vulkan target with a vector.step / gather legalization error

A real codegen gap in IREE 3.11.0 for token-embedding gather patterns, not something you can fix from the export side. Use local-task (CPU) instead; see IREE Android Runtime API.

nativeCreate fails at runtime with a parameter-related error

Confirm you cross-built the runtime .so with the four --link flags from Step 4 (external-weight vmfbs specifically need them) — the default iree_runtime_unified alone is not enough.

Output is garbage / wrong shape

Verify against a --target host build first (the Tip in Step 3) — this isolates "the export is wrong" from "the on-device build/runtime is wrong," which are otherwise easy to conflate when you can only test on a device.

App crashes or hangs loading the .irpa

Confirm the .irpa file actually finished copying to filesDir (it can be hundreds of MB — a 135M-parameter model at bf16 is already ~300 MiB) before IreeRedecodeSession tries to open it. `fromAssets’s copy is synchronous, so this usually means it was called on a coroutine that got cancelled mid-copy.

Need a different seq than what was exported

seq is baked into the compiled graph’s shape — there’s no runtime resize. Re-export (Step 1) with a different seq and re-compile (Step 3); you can’t change it from the Android side.

See also