MVP Factory
ai startup development

On-device LLM scheduling: priority queues on Android

KW
Krystian Wiewiór · · 5 min read

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 TypePriorityMax Token BudgetPreemptible
Foreground ChatCRITICAL (0)UnlimitedNo
Inline SuggestionHIGH (1)128 tokensNo
Background SummaryNORMAL (2)512 tokensYes
Offline IndexingLOW (3)1024 tokensYes

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

ScenarioNaive QueuePriority Scheduler
Foreground chat, no background jobs138ms TTFT135ms TTFT
Foreground chat, background summary active1,420ms TTFT161ms TTFT
Inline suggestion under load890ms TTFT148ms TTFT
Background resume after preemptionN/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

  1. 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.

  2. Bind your token-budget governor to ProcessLifecycleOwner, not a timer. Lifecycle events are authoritative signals about user intent. Timers are guesses.

  3. 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.


Share: Twitter LinkedIn