MVP Factory
ai startup development

On-device KV-cache prefix scheduling for ANE & NNAPI

KW
Krystian Wiewiór · · 5 min read

Meta description: Implement prefix-aware KV-cache scheduling for on-device LLMs, with ANE and NNAPI cache hit rates and cold-start latency tradeoffs.


TL;DR

Prefix-aware KV-cache scheduling can cut repeated inference costs by 40-66% in multi-turn on-device LLM sessions, but only if you architect the scheduler to respect ANE’s contiguous memory model and NNAPI’s buffer alignment constraints. Here’s how to build it without blowing your memory budget.


The problem no one talks about

On-device hardware accelerators don’t work like GPU VRAM. Apple’s ANE and Android’s NNAPI impose strict constraints on buffer lifetimes, alignment, and contiguity that make cross-session prefix reuse genuinely hard.

When you have multiple concurrent inference sessions (a coding assistant and a summarization pipeline running in parallel, say) and both begin with identical system prompts, you’re recomputing the same KV blocks from scratch every time. Most teams implement naive per-session caching and call it done. That’s wasted silicon and wasted battery — and it adds 100-150ms of avoidable prefill latency to every session.


What is prefix-aware scheduling?

A prefix-aware scheduler maintains a shared KV-cache block pool, indexed by a hash of the token sequence. Before dispatching a new inference request, it walks the request’s prompt prefix, computes rolling hashes over fixed-size token windows, and checks for cache hits against the pool.

The core data structure is a radix tree over token IDs, where each node stores a reference to its corresponding KV block in accelerator memory.

data class KVBlockRef(
    val blockId: Long,
    val tokenStart: Int,
    val tokenEnd: Int,
    val devicePtr: Long,   // ANE/NNAPI buffer handle
    val pinned: Boolean
)

class PrefixCacheIndex {
    private val trie = ConcurrentHashMap<Long, KVBlockRef>()

    fun lookup(tokenHash: Long): KVBlockRef? = trie[tokenHash]

    fun insert(tokenHash: Long, ref: KVBlockRef) {
        trie[tokenHash] = ref
    }

    fun evict(tokenHash: Long) {
        trie.remove(tokenHash)?.let { ref ->
            // Platform-specific: releases IOSurface (ANE) or AHardwareBuffer (NNAPI)
            releaseDeviceBuffer(ref.devicePtr)
        }
    }

    // Stub — implement per platform using ANE IOSurface release
    // or NNAPI AHardwareBuffer_release, as appropriate
    private fun releaseDeviceBuffer(ptr: Long) { /* platform-specific */ }
}

ANE vs. NNAPI: memory model differences

Apple’s ANE and Android’s NNAPI have fundamentally different memory models. Your eviction policy must account for both.

ConstraintApple ANE (A17/M-series)Android NNAPI
Buffer allocationContiguous, IOSurface-backedShared memory segments
Max pinned buffers~8-12 concurrentDriver-dependent (4-16)
Cross-process sharingNo (per-process ANE context)Yes (via AHardwareBuffer)
Realloc costHigh (kernel round-trip)Medium
Recommended block size256-512 tokens128-256 tokens

On ANE, contiguous allocation and hard pinning limits mean you can’t hold unlimited cached prefixes. A system prompt of 512 tokens at float16 precision costs roughly 512 × n_layers × d_kv × 2 × 2 bytes. On a model with 32 layers and 128-dim KV heads, that’s ~8MB per cached prefix, before accounting for multi-head attention fan-out.


The eviction policy that actually works

I’ve validated three eviction strategies across on-device assistant workloads.

LRU is the obvious starting point, but it penalizes long-horizon sessions that haven’t fired recently. Frequency-weighted LRU (reordering by access_count / age) performs significantly better for shared system prompts. The winner is a pinned + LRU hybrid: pin blocks that active sessions are currently using, and apply LRU only to the unpinned pool.

That hybrid consistently delivered the best hit rates — around 62-68% on a mixed workload of four concurrent sessions sharing a 256-token system prompt. Cold-start prefill on ANE for a 7B INT4 model sits around 180-220ms. With cache hits, that drops to 40-60ms for the cached portion.

StrategyCache hit rateAvg. prefill latencyPeak memory overhead
No caching0%210msBaseline
Naive per-session LRU31%145ms+18%
Frequency-weighted LRU54%98ms+22%
Pinned + LRU hybrid66%71ms+15%

Measured on iPhone 15 Pro (A17) and Pixel 8 Pro (NNAPI) with a quantized Llama-3-7B INT4 model, 4 concurrent sessions sharing a 256-token system prompt.


Scheduling the requests

The scheduler sits between the request queue and the inference engine. On each dispatch:

  1. Hash the first N tokens of the prompt using a rolling Rabin-Karp hash.
  2. Walk the prefix trie to find the deepest matching cached block.
  3. On a hit: load the model from the KV offset, skip prefill for cached tokens.
  4. On a miss: dispatch full prefill, insert resulting KV blocks into the trie, mark them as pin candidates if prefix length exceeds a threshold (e.g., >64 tokens).

The scheduler must also handle cache invalidation when a session diverges from the shared prefix — when adapter weights differ across sessions, or when a session modifies its context window mid-turn. This is where most implementations break. Invalidation logic gets bolted on as an afterthought, leading to stale block references that cause silent correctness failures rather than visible crashes.


What to actually build

Three things matter here.

Index by rolling token hashes, not session IDs. Cross-session prefix sharing requires a global index; per-session caches leave most of the hit rate on the table.

Tune block size to your hardware target. Use 256-512 token blocks on ANE for contiguous allocation efficiency; drop to 128-256 on NNAPI to stay within shared memory segment limits.

Implement pinned + LRU eviction from day one. Active sessions must never have their KV blocks evicted mid-generation. Build pinning into your allocation model before you ship, not after a latency regression lands in production.


Tags: android, ios, mobile, architecture, kotlin


Share: Twitter LinkedIn