Running llama.cpp on Mobile

AIMobileOn-DeviceLLM
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

Shipping a 7B Q4 model added 4.2GB to our IPA and crashed on iPhone 12 at load time — jetsam before the first token. After switching to llama.cpp with a tiered 1.5B model for standard devices and optional 7B download for Pro hardware, on-device summarization worked offline for 89% of the fleet. llama.cpp is the pragmatic path to local LLMs on mobile: plain C++, GGUF quantizations, Metal on iOS and Vulkan/CPU on Android, no PyTorch runtime in your APK.

Why llama.cpp on mobile

Alternatives compared:

Runtime Pros Cons
llama.cpp Small binary, GGUF ecosystem, Metal/Vulkan Manual integration
ML Kit / MediaPipe Google-supported, simpler API Fewer model choices
ONNX Runtime GenAI Cross-platform Heavier, model prep pipeline
MLC LLM TVM-compiled, fast on Apple Build complexity

llama.cpp wins when you need control over model file delivery, quant format, and context length — and when you want one inference core across iOS and Android via shared C++.

Model selection and GGUF quantizations

Download from Hugging Face (TheBloke, bartowski quantizations):

Model Q4_K_M size Use case
Llama-3.2-1B-Instruct ~770 MB Broad iOS/Android support
Phi-3-mini-4k-instruct ~2.3 GB Better quality, flagship
Qwen2.5-3B-Instruct ~2.0 GB Strong multilingual
Gemma-2-2B-it ~1.6 GB Balanced
# Pull GGUF locally for testing
huggingface-cli download bartowski/Llama-3.2-1B-Instruct-GGUF \
  Llama-3.2-1B-Instruct-Q4_K_M.gguf --local-dir ./models

Validate with ./llama-cli -m model.gguf -p "Summarize: ..." -n 128 on desktop before mobile integration.

Memory and context budgeting

KV cache grows with context:

KV_memory ≈ 2 × n_layers × n_heads × head_dim × context_len × bytes_per_weight

Practical limits on mobile:

iOS: use os_proc_available_memory() before load; abort gracefully below threshold.

Android: ActivityManager.getMemoryInfo() — warn on lowMemory.

iOS integration.

Use official llama.cpp SPM package or bind via bridging header:

import llama

final class LocalLLM {
    private var model: OpaquePointer?
    private var context: OpaquePointer?

    func load(modelPath: String, contextLen: Int32 = 2048) throws {
        var params = llama_model_default_params()
        params.n_gpu_layers = 99  // Metal offload all layers
        guard let m = llama_load_model_from_file(modelPath, params) else {
            throw LLMError.loadFailed
        }
        model = m

        var ctxParams = llama_context_default_params()
        ctxParams.n_ctx = UInt32(contextLen)
        ctxParams.n_batch = 512
        guard let c = llama_new_context_with_model(m, ctxParams) else {
            throw LLMError.contextFailed
        }
        context = c
    }

    func generate(prompt: String, maxTokens: Int32 = 256) -> AsyncStream<String> {
        // Tokenize, llama_decode loop, stream via AsyncStream
    }
}

Run inference off main thread — Task.detached(priority: .userInitiated).

Ship model via:

Android integration.

Community wrappers: llama-android, LocalLLM bindings, or direct JNI to libllama.so:

class LlamaEngine(private val modelPath: String) {
    external fun loadModel(nGpuLayers: Int): Long
    external fun generate(contextPtr: Long, prompt: String, maxTokens: Int): String

    companion object {
        init { System.loadLibrary("llama") }
    }
}

Build libllama.so per ABI (arm64-v8a minimum; drop armeabi-v7a for LLM apps — too slow):

cmake -B build-android -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-26 \
  -DLLAMA_VULKAN=ON
cmake --build build-android --config Release

Vulkan acceleration on Adreno/Mali; CPU fallback when GPU busy.

Prompting small models.

Small models need tighter prompts:

<|begin_of_text|><|start_header_id|>system<|end_header_id|>
Summarize the following text in 3 bullet points. Be factual. Do not invent details.<|eot_id|>
<|start_header_id|>user<|end_header_id|>
{user_text}<|eot_id|>
<|start_header_id|>assistant<|end_header_id|>

Use model-specific chat templates (Llama 3, Phi-3, Qwen each differ). Wrong template = gibberish output.

Cap input length — truncate with sentence boundaries, not mid-word chop.

Production patterns.

Tiered delivery:

Streaming UI: emit tokens as generated — perceived latency drops even if total time is unchanged.

Cancellation: user navigates away → set abort flag on decode loop; llama.cpp supports context interruption patterns.

Thermal throttling: monitor ProcessInfo.processInfo.thermalState (iOS) — pause or reduce max tokens when .serious.

Analytics (privacy-safe): log tokens/sec, load time, OOM rate — not prompt content.

Performance benchmarks.

Device Model tok/s First token
iPhone 15 Pro Llama-3.2-1B Q4 ~85 ~180ms
iPhone 12 Llama-3.2-1B Q4 ~32 ~420ms
Pixel 8 Qwen2.5-3B Q4 Vulkan ~28 ~350ms
Mid-range Android Phi-3-mini Q4 ~12 ~900ms

Set user expectations — "on-device" doesn't mean ChatGPT speed on 2020 hardware.

Legal and store compliance.

Gate model load on available RAM and thermal state; expose "on-device AI" as opt-in in settings for users on older phones. Hash-verify downloaded GGUF files before load — CDN bitrot and partial downloads cause obscure crash loops. Log aggregate tok/s and OOM rate, never prompt text, in your analytics pipeline. App Store review notes should mention optional large downloads and battery impact. Maintain a matrix of supported models per device tier in docs support can reference. When Apple or Google updates OS GPU drivers, rerun benchmarks — Metal and Vulkan performance shifts between OS versions more than app releases.

Quantize to Q4_K_M minimum for usable speed on phone — Q8 models quality gains don't justify 3× latency on battery power.

JNI threading and Swift bridging

Never call llama_decode on the Android main thread — ANR within seconds. Pattern:

class LlamaEngine(private val modelPath: String) {
    private val scope = CoroutineScope(Dispatchers.Default.limitedParallelism(1))

    fun generate(prompt: String, onToken: (String) -> Unit) = scope.launch {
        nativeGenerate(modelPath, prompt) { token -> onToken(token) }
    }
}

iOS: wrap in Task.detached and expose AsyncStream<String> to SwiftUI. Single-flight inference queue — two concurrent generations on one model doubles RAM for KV caches.

Context window budgeting

Mobile apps crash from KV cache growth before users hit "max tokens." Cap n_ctx per device tier:

Tier RAM free n_ctx max output
Standard <3 GB 2048 256 tokens
Pro 4–6 GB 4096 512 tokens
Flagship >6 GB 8192 1024 tokens

Truncate input with summarization chunking — do not silently drop system prompt to fit user paste.

GGUF download integrity

Host models on CDN with SHA256 in manifest; verify hash before first load. Partial downloads from interrupted cellular corrupt inference — atomic rename after verify.

Token streaming UI patterns

Show first token within 500ms target via streaming; cancel button must call native abort to free KV cache immediately — users who navigate away mid-generation leak RAM otherwise.

Licensing notice in app

OSS model licenses (Llama, Apache) require attribution screen in About — legal review before App Store submit. Some corporate policies block Llama weights entirely — remote config disable.

Watchdog timer on generation

Kill inference after 120s wall clock — runaway generation on adversarial prompt drains battery. Return partial output with "truncated" flag.

Context cache clearing on background

iOS background task may kill app — persist generation state or discard cleanly; partial tokens in UI confuse users on resume.

App Store review notes for on-device LLM

Document model purpose (summarization not medical advice) in review notes — Apple requests clarity on on-device inference scope. Include battery impact disclaimer in App Review attachment.

Resources

Frequently asked questions

Which llama.cpp quantization should mobile apps use?

Q4_K_M is the usual starting point — good quality-to-size ratio for 3B–8B models on flagships. Q8_0 if you have RAM headroom and need better reasoning. Q2_K for experimentation only — quality drops sharply. Always benchmark on your minimum supported device, not just the dev phone.

How much RAM does a local LLM need on mobile?

Rough rule: model file size plus 20–40% overhead for KV cache and runtime. A 2GB Q4_K_M model needs ~2.5–3GB free RAM for comfortable context. iOS jetsam kills apps aggressively above memory limits — target 1–3B models for broad device support, 7B+ for Pro/flagship tiers only.

Is llama.cpp production-ready for consumer apps?

Yes for assisted features — summarization, rewrite, structured extraction with human review. Not for open-ended chat at 7B on older devices without tiered model delivery and graceful degradation. Wrap inference in timeouts, stream tokens to UI, and offer cloud fallback when device can't load model.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →