DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Wiring LoRA Adapters into Mobile Inference: Dynamic Adapter Loading for Task-Specialized On-Device LLMs on Android

---
title: "Dynamic LoRA Adapter Loading for On-Device LLMs on Android"
published: true
description: "Hot-swap LoRA adapters into a quantized base model at runtime on Android using llama.cpp, a Kotlin JNI bridge, and a priority-based swap scheduler  without reloading model weights."
tags: android, kotlin, mobile, architecture
canonical_url: https://mvpfactory.co/blog/dynamic-lora-adapter-loading-android
---
Enter fullscreen mode Exit fullscreen mode

What We Will Build

Let me show you a pattern I use in every production on-device LLM project: one quantized base model that loads once at app launch, with lightweight LoRA adapters that hot-swap between tasks at runtime. By the end of this article you will have a working Kotlin JNI bridge, an LRU adapter cache, and a priority-aware scheduler that keeps UI inference snappy even when background jobs are competing for adapter slots.


Prerequisites

  • Android project targeting API 26+
  • llama.cpp compiled as a shared library with JNI bindings
  • A quantized base model in GGUF format (Q4_K_M, ~4 GB for 7B)
  • LoRA adapter files in GGUF format (general.type = adapter, adapter.type = lora)
  • Basic familiarity with Kotlin coroutines and JNI

Step 1 — Choose Your Rank

Before writing a line of code, pick your adapter rank. This decision shapes your memory budget for the whole feature.

Rank Use case Adapter size (7B base) Memory overhead
r=4 Ultra-mobile ~8 MB Negligible
r=8 Balanced ~16 MB Low
r=16 Capable ~32 MB Moderate
r=32 Desktop-tier ~64 MB High — avoid on mobile

Use r=8 for mobile. At ~16 MB per adapter you can cache three simultaneously on devices with 6+ GB RAM, and the task specialization is real — not cosmetic.


Step 2 — The JNI Bridge

Here is the minimal setup to get this working. The adapter lifecycle needs to be first-class in Kotlin. Three JNI calls are all you need:

object AdapterHandle {
    external fun attach(modelPtr: Long, adapterPath: String, scale: Float): Long
    external fun detach(modelPtr: Long, adapterHandle: Long)
    external fun clear(modelPtr: Long)
}
Enter fullscreen mode Exit fullscreen mode

On the C++ side, attach calls llama_model_apply_lora_from_file and returns an opaque handle. Keep adapter handles as Long in Kotlin — do not serialize or persist them across JVM restarts.


Step 3 — The Adapter Cache

A bounded LRU cache manages which adapters stay hot in memory:

class AdapterCache(private val modelPtr: Long, private val maxSlots: Int = 3) {
    private val lru = LinkedHashMap<String, Long>(maxSlots, 0.75f, true)

    fun acquire(task: String, path: String): Long {
        return lru.getOrPut(task) {
            if (lru.size >= maxSlots) evictLru()
            AdapterHandle.attach(modelPtr, path, 1.0f)
        }
    }

    private fun evictLru() {
        val victim = lru.entries.first()
        AdapterHandle.detach(modelPtr, victim.value)
        lru.remove(victim.key)
    }
}
Enter fullscreen mode Exit fullscreen mode

The base model never reloads — only the ~16 MB delta weight swaps. Measured cold-swap latency on a Pixel 8 Pro lands under 50 ms at r=8, which is imperceptible behind a loading indicator.


Step 4 — Priority-Based Swap Scheduling

The docs do not mention this, but a plain LRU cache will fail you in production the moment foreground requests start contending with background prefetch. A two-tier priority queue solves it cleanly:

class AdapterScheduler(private val cache: AdapterCache) {
    private val foreground = PriorityBlockingQueue<AdapterRequest>()
    private val background = PriorityBlockingQueue<AdapterRequest>()

    suspend fun submit(request: AdapterRequest): Flow<Token> {
        val queue = if (request.isForeground) foreground else background
        queue.offer(request)
        return awaitSlot(request) // suspends until a cache slot is available
    }
}
Enter fullscreen mode Exit fullscreen mode

awaitSlot suspends the coroutine until the cache has a free or preemptible slot, then calls cache.acquire under a mutex. When foreground demand arrives and all slots are full, it evicts the lowest-priority background adapter.


Gotchas

Mismatched adapter hash. A mismatch between adapter and base model causes silent garbage output — not a crash. The base model hash is embedded in the GGUF header. Validate it at attach time. This is the gotcha that will save you hours of debugging hallucinations you'll initially blame on rank.

Persisting adapter handles. Long handles from attach are raw C++ pointers. Serialize them to disk and you get a segfault on next launch. Treat them as session-scoped only.

Skipping the scheduler in dev. LRU works fine in development. It breaks under foreground/background contention in production. Add priority-aware eviction before you ship, not after your first production incident.

r=32 on mobile. At ~64 MB per adapter with multiple slots, you will be the OOM killer's favorite target. Stay at r=8 unless you are specifically targeting high-RAM tablets.


Conclusion

The architecture is straightforward once you see it: one quantized base at ~4 GB, a JNI bridge over three llama.cpp calls, an LRU cache capped at three slots, and a two-tier priority scheduler that keeps UI inference deterministic. Most teams reach for multiple fine-tuned models and end up with a 4 GB APK and an active OOM killer. This pattern keeps your bundle lean and your swap latency under 50 ms.

For adapter training, the PEFT library and the llama.cpp LoRA docs are your next stops.

Top comments (0)