深入 Android 端侧 AI 推理的初始化与预热优化

去年在做端侧大模型落地时,我遇到一个让人抓狂的问题:用户点击 AI 功能按钮后,第一次推理耗时 3.2 秒才返回结果,但第二次同样的请求只要 400ms。这 8 倍的差距,逼得我系统性地拆解**端侧推理的”冷启动”**问题。

这事的本质和 App 冷启动优化一样:慢不是因为某个环节慢,而是一串同步阻塞操作叠在一起。下面按模型加载 → 引擎初始化 → KV Cache 三条线,逐一拆解延迟来源和治理手段。

延迟来源拆解

用 MediaPipe LLM Inference 跑 Gemma 2B 模型,在 Pixel 8 上实测各阶段耗时:

阶段耗时占比
模型文件加载(mmap)180ms5.6%
TFLite 运行时初始化120ms3.8%
模型解析与图构建450ms14.1%
GPU Delegate 初始化820ms25.6%
KV Cache 预填充(Prefill)1550ms48.4%
首次 Decode80ms2.5%
总计3200ms100%

GPU Delegate 是最大的单点开销。TFLite 的 GPU 后端在首次推理前需要编译 shader、分配显存缓冲,这个初始化是 lazy 的——只有实际跑推理时才触发,所以用户感知到的”卡”恰恰集中在这 820ms。

KV Cache 预填充占近一半时间。Prefill 阶段需要一次性处理整个 prompt 的所有 token,计算量是 Decode 阶段的几十倍。如果你的 system prompt 有 500 token,GPU 需要并行计算 500 次 Attention。这不是”加载慢”,是纯粹的计算量大。

模型加载:把 IO 和解析解耦

模型文件加载本身并不慢,慢的是”加载完等着解析,解析完等着初始化”这种串行模式。

mmap 映射替代完整读取

MediaPipe 默认用 mmap 加载 .bin 权重文件,这个选型是对的。但有个细节容易忽略:mmap 的预读取策略。

// 主动触发预读取,让系统按顺序预读后续页
madvise(addr, file_size, MADV_SEQUENTIAL | MADV_WILLNEED);

MADV_SEQUENTIAL 告诉内核你会顺序访问,MADV_WILLNEED 让内核提前把接下来的页读入 page cache。实测这个改动让模型加载阶段从 180ms 降到 110ms——原理是把磁盘 IO 从”用时才读”变成”提前预热”。

图构建与加载并行化

模型加载(IO 操作)和图构建(CPU 操作)完全可以并行:

// 并行加载模型权重和构建推理图
val weightJob = CoroutineScope(Dispatchers.IO).async {
    loadModelWeights(modelPath)  // mmap + madvise
}
val graphJob = CoroutineScope(Dispatchers.Default).async {
    buildInferenceGraph(config)  // 解析模型结构
}

val (weights, graph) = weightJob.await() to graphJob.await()
initializeEngine(weights, graph)

IO 线程和 CPU 线程互不竞争,总耗时取两者最大值加少量合并开销。实测这个优化带来的提升有限(约 60ms),因为真正的瓶颈在后面。

GPU Delegate 初始化:最容易被低估的成本

踩过的一个坑是:GPU Delegate 初始化慢,不是因为 GPU 性能差,而是 shader 编译和算子选择在首次推理时才触发

TFLite GPU Delegate 内部有一套算子匹配逻辑:对于模型的每个算子,它要检查能否在 GPU 上执行、生成对应的 GLSL/CL shader、编译并缓存。这个过程是 lazy 的,而且每次创建新的 Interpreter 都会重新走一遍

预热推理

在后台线程跑一次空的推理调用,提前触发 Delegate 初始化:

// 空 prompt 预热,触发 GPU Delegate 初始化和 shader 编译
lifecycleScope.launch(Dispatchers.Default) {
    interpreter.runWarmup()  // 内部执行一次 minimal forward pass
}

这个”空跑”耗时约 800ms,但它把代价从用户交互路径上移走了。关键一点:预热必须在同一个 Delegate 实例上做,新建 Interpreter 的预热无效。

Delegate 实例复用

更彻底的方案是保持 Interpreter 和 Delegate 长生命周期:

class InferenceService : Service() {
    private lateinit var interpreter: Interpreter

    override fun onCreate() {
        interpreter = createInterpreterWithWarmup()
    }
}

把 Interpreter 托管在 Service 中,进程存活期间只初始化一次。代价是常驻内存增加约 200-400MB(取决于模型大小),需要结合低内存回调做降级回收。

KV Cache 预填充:从事后计算到提前算好

Prefill 的 1.5 秒来自对 system prompt 的大量矩阵运算。优化方向很直接:把 system prompt 的 KV Cache 提前算好、缓存起来

方案一:在模型加载时预计算

// 启动阶段预计算 system prompt 的 KV Cache
savedStateHandle.setSavedStateProvider("kv_cache") {
    val kvCache = interpreter.prefillSystemPrompt(SYSTEM_PROMPT)
    kvCache.serializeToBundle()
}

首次启动仍然是慢的,但后续启动可以从缓存恢复。问题是:KV Cache 序列化后的体积很大(2B 模型约 50-80MB),读写本身有 IO 开销,而且跨进程恢复需要重建对象图。我实测下来,串行序列化加反序列化反而拖慢了整体时间。

方案二:Long-lived Context 模式

保持 Interpreter 长生命周期后,KV Cache 天然常驻内存。另一个好处是:用户连续对话时,历史轮次的 KV Cache 也不需要重复计算。这是目前我在项目中采用的方案:

class ChatSession(private val interpreter: Interpreter) {
    private var currentKvCache: KvCache? = null

    init {
        // 应用启动时预填充 system prompt
        currentKvCache = interpreter.prefill(SYSTEM_PROMPT)
    }

    suspend fun chat(userInput: String): String {
        // 每次对话仅计算新增 token 的 KV,增量追加
        val newCache = interpreter.decodeWithCache(
            userInput, currentKvCache
        )
        currentKvCache = newCache
        return interpreter.generate(newCache)
    }
}

效果与取舍

整套优化在 Pixel 8 上的效果:

指标优化前优化后
模型加载 + 解析750ms520ms
GPU Delegate 初始化820ms0ms(预热后)
KV Cache 预填充1550ms0ms(长驻)
首次推理总延迟3200ms80ms
常驻内存增加+380MB

3.2 秒变 80ms,代价是 380MB 内存。对于端侧 AI 这种低频但强感知的功能,用户对”点了没反应”的容忍度远低于”占点内存”。但如果你的应用本身已经是内存大户(比如社交 App 的图片缓存),就需要做动态决策,在低内存设备上降级为单次预热模式。

做决策时我用的启发式规则很简单:

val shouldKeepAlive = Runtime.getRuntime().maxMemory() > 6 * 1024 * 1024 * 1024L // 6GB+

8GB 以上设备长驻,6GB 以下设备用预热后释放的策略。

把端侧推理的初始化优化放到冷启动优化的框架里看,两者的方法论几乎一致:延迟初始化 → 提前初始化 → 常驻复用,每一步都在”资源占用”和”响应速度”之间做权衡。没有银弹,根据设备能力和业务场景做差异化策略就是最好的方案。