On-device LLM scheduling: priority queues on Android
TL;DR
On-device LLMs introduce a new class of scheduling problem: multiple callers — foreground chat, background summarization, inline suggestions — contend for a single inference engine with no horizontal scaling escape hatch. The answer is a priority-aware scheduler with preemption, partial KV cache eviction, and a token-budget governor bound to Android’s ProcessLifecycleOwner. Done right, you serve foreground requests under 200ms time-to-first-token while keeping background jobs alive rather than canceled.
What most teams get wrong
Teams reaching for on-device inference treat the LLM runtime like a coroutine dispatcher — throw requests at it, let them queue, hope for the best. That works fine when you have one caller. It fails catastrophically when a background summarization job monopolizes the KV cache right as the user opens the chat screen.
The numbers tell a clear story. On a Tensor G3 chip, a 3B parameter model saturates ~85% of the NPU during active generation. A foreground chat request arriving during background inference sees first-token latency spike from ~140ms to over 1,400ms. That’s a 10× regression from a single design oversight.
The architecture: a three-layer scheduler
┌─────────────────────────────────────────┐
│ InferenceOrchestrator │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ PriorityQueue│ │ TokenBudget │ │
│ │ (min-heap) │ │ Governor │ │
│ └──────────────┘ └───────────────┘ │
│ │ │ │
│ ┌──────▼──────────────────▼──────────┐ │
│ │ PreemptionController │ │
│ │ (KV cache snapshot + eviction) │ │
│ └────────────────────────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ InferenceEngine │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────┘
Layer 1: priority queue with preemption
Each inference request is assigned a RequestPriority based on the caller type:
| Caller Type | Priority | Max Token Budget | Preemptible |
|---|---|---|---|
| Foreground Chat | CRITICAL (0) | Unlimited | No |
| Inline Suggestion | HIGH (1) | 128 tokens | No |
| Background Summary | NORMAL (2) | 512 tokens | Yes |
| Offline Indexing | LOW (3) | 1024 tokens | Yes |
The queue is a PriorityBlockingQueue<InferenceJob> backed by a min-heap on priority ordinal. When a CRITICAL job arrives mid-inference on a lower-priority job, the PreemptionController fires.
class PreemptionController(private val engine: InferenceEngine) {
fun preempt(current: InferenceJob, incoming: InferenceJob) {
if (incoming.priority < current.priority && current.isPreemptible) {
val snapshot = engine.snapshotKVCache(current.sessionId)
current.checkpointState = snapshot
engine.suspendGeneration(current.sessionId)
engine.resume(incoming.sessionId)
}
}
}
The key insight: you do not cancel the background job. You snapshot its KV cache state and park it. When the high-priority request completes, the background job resumes from the checkpoint. Cache eviction only occurs when memory pressure exceeds a configurable threshold — typically 70% of available LPDDR5 bandwidth.
Layer 2: partial KV cache eviction
Full cache eviction means re-prefilling the entire prompt context on resume — expensive. Partial eviction retains the static system-prompt portion of the KV cache (which never changes) and evicts only the dynamic conversation turns.
In my experience building production systems with on-device models, retaining the first N layers of the KV cache during preemption cuts re-prefill cost by 40–60% on prompts with fixed system instructions exceeding 512 tokens.
Layer 3: token-budget governor tied to process lifecycle
This is the piece most implementations miss. Android’s ProcessLifecycleOwner exposes ON_START, ON_STOP, and ON_RESUME events. Wire these directly into the governor:
class TokenBudgetGovernor(lifecycle: ProcessLifecycle) {
private var budgetMultiplier = 1.0f
init {
lifecycle.addObserver { event ->
budgetMultiplier = when (event) {
ON_RESUME -> 1.0f // full budget, foreground
ON_STOP -> 0.25f // aggressive throttle, background
else -> 0.5f
}
}
}
fun budgetFor(job: InferenceJob): Int =
(job.priority.baseBudget * budgetMultiplier).toInt()
}
When the app moves to background, the governor throttles token output to 25% of base budget. Battery savings are a side effect. The real goal is making sure the first foreground request hits the engine with full resources already reclaimed.
Latency results: before and after
| Scenario | Naive Queue | Priority Scheduler |
|---|---|---|
| Foreground chat, no background jobs | 138ms TTFT | 135ms TTFT |
| Foreground chat, background summary active | 1,420ms TTFT | 161ms TTFT |
| Inline suggestion under load | 890ms TTFT | 148ms TTFT |
| Background resume after preemption | N/A (canceled) | +22% vs fresh start |
The 22% overhead on background job resume is the cost of partial KV cache re-prefill. That is the right trade — a small penalty on the low-priority path in exchange for SLA compliance on the high-priority one.
Takeaways
-
Don’t cancel preemptible jobs — checkpoint them. Partial KV cache snapshots make resume cheap. Cancellation forces users to re-trigger background work, degrading overall throughput across a session.
-
Bind your token-budget governor to
ProcessLifecycleOwner, not a timer. Lifecycle events are authoritative signals about user intent. Timers are guesses. -
Size your priority tiers to your actual callers, not hypothetical ones. Four tiers — CRITICAL, HIGH, NORMAL, LOW — cover the full space of production on-device callers. More granularity adds scheduling overhead with negligible SLA benefit.