Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

端侧大模型推理的内存管理

我们做端侧大模型,内存风险很少只来自模型文件本身。权重、KV 缓存(Key/Value Cache,保留注意力计算的历史键和值)、prefill(预填充) 阶段的临时张量、运行时编译产物、GPU/NPU 缓冲区,再加上应用自身的 Java 与 native 内存,会在不同阶段叠加出不同的峰值。所以模型能加载,还只是第一步:长上下文生成、并发会话、后台切换能否稳住,要分开验证。

源码基线:AOSP android-17.0.0_r1,内核 android17-6.18-2026-06_r6;平台为 Android 17 / API 37。

LiteRT、LiteRT-LM 和 MediaPipe LLM Inference 是独立发布的库,能力要按项目锁定的库版本和目标设备验证,Android API 级别推不出这些库的能力。2026-10-25 复核时,LiteRT CompiledModel Kotlin 文档示例依赖为 2.3.0,MediaPipe LLM Inference 已进入维护模式。虚拟地址与物理内存指标的区别可参阅 23.3 Native 与虚拟内存管理优化。设备可能使用其他内核分支。

1. 先把内存分项记清楚

1.1 六类常驻或阶段性内存

动手测内存之前,我们先把几个术语约定好。模型把文本切成离散单元来处理,这个单元叫 token;输入、中间值和输出保存在多维数组 tensor(张量) 里;delegate 是把部分计算图交给 GPU、NPU 等后端执行的运行时插件。prefill 一次处理完输入提示词并建立 KV 缓存(Key/Value Cache,保存每层注意力历史键和值的会话缓存),decode(解码) 随后逐个生成输出 token。

类别典型内容容易出现峰值的阶段主要控制量
模型权重embedding(把 token 转成向量)、attention(组合上下文信息)、FFN(逐位置前馈网络)、量化元数据文件映射、运行时初始化、delegate 准备参数量、位宽、混合精度、是否重排或复制
KV 缓存每层历史 token 的 Key/Valueprefill 结束、持续 decode上下文长度、批大小、KV 头数、元素类型
临时张量attention 分数、FFN 中间值、prefill 工作区首次运行、长提示词的 prefill分块大小、算子实现、缓冲区复用
编译与后端资源计算图、着色器、NPU 编译产物、命令缓冲区delegate 初始化、首次运行后端、AOT(提前编译)/JIT(设备运行时编译)、驱动实现
输入与输出tokenizer(把文本切成 token 的分词器)缓冲区、Bitmap、音频、输出 token多模态预处理、流式输出输入尺寸、并发请求、对象生命周期
应用基线ART 托管堆、native heap、线程栈、代码与 UI进程整个生命周期依赖、缓存、线程和页面状态

排查时我们要按阶段记录。一次 dumpsys meminfo 只反映采样那一刻的状态:至少要区分未加载模型、模型文件打开、运行时和 delegate 初始化完成、首次运行、prefill 峰值、稳定 decode、取消生成、关闭会话、关闭模型这几个点。表中六类内存的峰值时刻各不相同,中间峰值要靠这些分阶段的采样点才抓得到,加载完成或生成结束后的单点快照看不到它们。

端侧推理的分配归属

图:按分配归属和推理阶段分别采样:权重只是预算的一项,初始化、prefill 与多会话都可能形成峰值。

1.2 权重大小的理论下限

如果参数量为 (P),平均权重位宽为 (b),仅权重数据的理论下限是:

weight_min_bytes = P × b ÷ 8

下表按十进制参数量计算,再换算成 GiB,覆盖的只有权重数据本身;scale(量化缩放因子)、zero-point(非对称量化的零点偏移)、分组索引、padding(对齐填充)、分词器词表元数据、运行时副本和编译产物都要另算(embedding 权重若已计入参数量 P,不应再作为额外权重重复计数):

参数量FP16 下限8-bit 下限4-bit 下限
2B3.73 GiB1.86 GiB0.93 GiB
7B13.04 GiB6.52 GiB3.26 GiB
13B24.21 GiB12.11 GiB6.05 GiB

不过,“4-bit 模型”也可能保留 FP16/FP32 的归一化层、embedding 或输出层。分组量化要为每组保存 scale (把量化整数还原到数值范围的缩放因子),非对称方案通常还需要 zero-point。所以表中数字只是权重位数的换算结果;模型文件比它们大时,先对照上面这些附加项,再判断打包有没有问题。

量化只改变权重预算的一部分

图:权重位宽给出理论数据量;量化元数据、混合精度、运行时副本、KV 和工作区仍需另算。

选量化方案时,压缩率只是其中一个输入:算子是否被目标后端支持、是否触发 CPU 回退、加载时是否反量化或重排,都会改变运行时内存。精度变化还依赖模型、校准集、量化算法和任务,通用的 MMLU(多学科问答基准)降幅只能当参考,验收要回到应用自己的任务上。

1.3 KV 缓存要按模型配置计算

我们拿一个每层都保留完整 K/V、没有滑动窗口、逐 token 自回归生成的 decoder(自回归生成部分) 来算,KV 缓存的估算公式是:

kv_bytes = 2 × batch × layers × kv_heads × head_dim × cached_tokens × element_bytes

系数 2 对应 Key 和 Value 两份。这里要用 kv_heads:一个 attention head(注意力头) 就是一组并行处理注意力投影的单元。GQA(Grouped-Query Attention)让一组查询头共享 KV 头,MQA(Multi-Query Attention)则让更多查询头共用同一组 K/V,所以 KV 头数可能比 query 头数少,估算时以 kv_heads 为准,query head 数先放到一边。

举个有量级的例子:批大小 1、32 层、8 个 KV 头、每个头的维度(head dimension) 128、缓存 4096 个 token、每个元素 2 字节,结果是 512 MiB;只把 KV 头数改成 32,结果就变成 2 GiB。这两个数是公式演示,不对应某个产品模型。

KV 缓存按 KV 头数和 token 计算

图:查询头共享 KV 头时,应使用 kv_heads 预算。权重量化不会自动降低 KV 元素的字节数。

在这条公式之上,还要处理几项修正:

  • 部分层采用滑动窗口 attention 时,这些层保留的 token 数只能限制到窗口大小,global attention(全局注意力) 层仍可能保留完整上下文。
  • KV 量化只有在引擎明确支持时才会减少缓存;权重量化不会自动改变 KV 的元素类型。
  • 并发会话通常各自拥有 KV 缓存,共享同一份权重不代表共享会话状态。
  • 推测解码(speculative decoding)会增加草稿模型的权重和会话状态,内存收益要单独评估,速度收益只是速度收益。
  • 前缀缓存、分页 KV 或会话状态导出/导入都属于引擎能力,Android 端是否具备,要按所用引擎逐项确认。

引擎侧也有现实约束。MediaPipe LLM Inference 目前处于维护模式,官方建议新 Android 项目迁移到 LiteRT-LM Android Kotlin API。存量项目使用的 MediaPipe LlmInferenceOptions.maxTokens() 源码注释把 maxTokens 定义为输入与输出 token 的总上限,会话上下文的上界就由这个参数决定;具体内存仍要由模型结构和后端实测,迁移到 LiteRT-LM 之后,还要按新的模型格式与会话配置重新算一遍。

1.4 文件映射与运行时缓冲区

权重以只读文件映射加载时,VSS(虚拟地址映射总量) 先增加,RSS(当前驻留物理页) 和 PSS(按共享比例分摊的物理内存) 随后随缺页和回收变化,三者的区别可以回看开头的 23.3 一节。文件页可以被内核回收,也可以与其他进程共享页缓存;但后端仍可能为了对齐、布局转换、量化解码或设备私有格式,另外分配一份缓冲区。

所以我们观察内存时要分开记账,逐项看:模型文件映射的 VSS、RSS/PSS;native heap 与匿名映射;图形统计、DMA-BUF(跨设备共享缓冲区的内核对象) 或 memtrack(硬件内存记账)能看到的设备缓冲区;delegate 初始化前后的增量;第一次推理前后的增量。

还有一点容易算错:把模型文件大小、native heap 和 GPU 缓冲区直接相加,可能重复计算同一物理页。比较应用多进程总成本时,可用同一口径的 PSS 加总来分摊共享驻留页;PSS 可能不能完整覆盖 GPU/NPU 私有缓冲,仍须结合驱动、memtrack/DMA-BUF 与运行时统计,各分类用于归因而不机械相加。

文件映射与后端副本分别记账

图:文件体积、native heap 和设备缓冲不能直接相加;先确认是否描述同一物理页,再记录独立后端副本。

1.5 16 KiB 页与数据搬运的峰值

16 KiB 页设备会改变模型文件映射、原生分配器区域和小映射的页面粒度:自研加载器要避免写死 4096 字节对齐,文件 mmap() 的 offset(文件偏移) 必须遵守运行时页大小,APK/ELF 也要按官方 16 KiB 兼容要求构建。页变大并不会自动让每个 tensor 各占一个 16 KiB 页,变化的是内部碎片和驻留页的观察结果。

跨进程或跨硬件后端传递大 tensor 时,Binder(Android 进程间通信机制) 只传控制信息、句柄和小型元数据,数据本体优先走运行时张量缓冲区、SharedMemory、文件描述符,或满足消费端约束的 AHardwareBuffer。Java direct ByteBuffer 通常没有可访问的 Java 数组,native 地址要另外确认,arrayOffset() 给不出答案。即便共享缓冲区能被后端导入,我们也要用跟踪数据验证有没有发生 CPU staging copy(先复制到过渡缓冲区)、后端重排或额外设备副本。

共享张量仍需验证导入与同步

图:句柄减少 Binder 数据量;共享缓冲能否直用,取决于消费端的格式、同步与导入能力。

最后是线程问题:模型加载、delegate 初始化和首次编译不要放在主线程。在 Perfetto 里,除了计算 slice(一段带起止时间的跟踪事件),我们还要看 Binder 等待、Runnable 排队、page fault(缺页事件)、大块 memcpy 和后端返回码——数据搬运与初始化等待很容易被误判成“算子计算慢”。

2. 设备 RAM 与应用预算的关系

2.1 三种上限回答不同问题

我们向系统要预算时,先要分清三个上限各自回答的问题。ActivityManager.getMemoryClass() 与 getLargeMemoryClass() 描述的是应用 Java 托管堆容量的近似档位,native heap、线程栈、文件映射和图形缓冲区都在这个档位之外;系统在内存压力下还会根据进程重要性和整体压力回收或终止进程。

ActivityManager.MemoryInfo 中:

  • availMem 是系统层面的可用内存估算,源码注释明确说明它只是参考值;
  • threshold 是系统开始清理后台服务和其他进程时参考的阈值;
  • lowMemory 表示系统当前是否处于低内存状态。

这三个字段说的都是系统整体状态,应用自己能分到多少额度,它们回答不了。availMem >= model_file_size × 1.5 这类固定公式也会漏掉应用基线、KV 缓存、后端副本、进程优先级和并发工作集,做上线准入条件是不够的。

系统侧的控制在 system_server 与系统守护进程内:MemoryLimiter.java 和 LMKD/OOM-adj 控制都在那里,LMKD 是低内存终止守护进程,OOM-adj 是系统衡量进程可终止优先级的分值。普通应用没有公共 API 为自己设置 memory.high、memory.swap.max 或 OOM-adj,也不要通过反射或 shell 权限绕过系统策略。

2.2 为每个模型变体建立设备档案

可执行的预算来自目标设备测试。我们为每个模型变体建立设备档案,每条档案至少包含:

  • 模型与量化格式的不可变版本,以及运行时、delegate 与驱动版本;
  • ABI、Android 版本、页大小、SoC(片上系统) 和物理 RAM 档位;
  • 后端与回退结果;
  • 上下文上限、并发会话数、输入模态与尺寸;
  • 各阶段 PSS、RSS、VmHWM、native heap 和可见的图形/DMA 数据;
  • 冷加载、热加载、取消、释放和重新加载结果;
  • 发生 delegate 编译失败、mmap/allocator(内存分配器) 失败、Java OOM 或 LMK 的现场。

即便同一个模型,在两台 RAM 相同的设备上也可能是不同结果:驱动是否复制权重、NPU 编译策略、GPU 共享内存核算、厂商后台进程,都会影响可用余量。

2.3 降级项的独立配置

降级项建议分开配置,只留“完整模型”和“拒绝运行”两个状态是不够的:

维度可选动作需要重新验证的内容
模型参数更少或量化更强的变体精度、算子覆盖、加载峰值
上下文降低输入与输出 token 总上限业务可用性、KV 缓存峰值
会话限制并发、关闭闲置会话排队时延、取消语义
模态降低图像尺寸、限制图像/音频数量任务质量、预处理峰值
后端NPU/GPU 失败后按设备档案选择 GPU/CPU回退路径的内存与延迟
预热关闭后台预加载或 JIT 预热首次请求时延

每个组合都要有实测档案。量化更强的模型如果触发 CPU 回退,内存和时延都可能比原方案更差。

3. 运行时生命周期决定峰值

3.1 一个资源管理器负责模型、会话和缓冲区

实践中最容易出问题的,是模型、delegate、会话、KV 缓存、输入/输出缓冲区由不同代码路径各自关闭。我们应把它们交给同一个生命周期组件管理,释放顺序遵守运行时的所有权关系:

  1. 停止接收新请求;
  2. 请求取消正在生成的任务;
  3. 等待回调和后端工作结束;
  4. 关闭会话,释放 KV 缓存;
  5. 关闭 I/O、图像、音频和共享缓冲区;
  6. 关闭模型、delegate 与运行时;
  7. 清除只为该模型服务的分词器和业务缓存。

这里有一条硬约束:异步回调仍可能访问对象时,先等它结束,再关闭它持有的 native 资源句柄。框架实现 AutoCloseable 时,用 try-with-resources、Kotlin use 或显式 finally。处于维护模式的 MediaPipe LLM Inference 示例也把 LlmInference 与 LlmInferenceSession 放进受控的关闭范围;迁移到 LiteRT-LM 后,再按新 API 的所有权约定确认一遍关闭顺序。

异步推理资源的安全关闭顺序

图:先等待异步消费者结束,再按所有权逆序关闭资源;不能在后端仍执行时直接 munmap 权重。

3.2 模型切换时的双份驻留

模型切换是另一个容易漏算的峰值。“先加载新模型,成功后再释放旧模型”确实能降低切换失败的风险,但代价是双模型同时驻留。内存紧张的设备应采用以下过程:

  1. 保存可重建的会话文本和业务状态;
  2. 停止旧会话;
  3. 关闭旧模型,并记录释放后的资源对象数量、PSS/RSS 与 native 分配快照;
  4. 加载目标变体;
  5. 加载失败时进入更小变体或云端路径。

判断“旧资源已经关干净”也要小心:内存分配器、驱动缓存或文件页可能让数值延迟回落,要多看几份快照再下结论。若产品要求无缝切换,双模型窗口就必须作为独立场景进入预算,归入正常单模型峰值会低估它。

模型切换中的双份驻留窗口

图:无缝切换需要预算双模型重叠窗口;内存紧张时先保存状态、停止旧会话并关闭旧模型,再加载目标变体。

3.3 多会话与多模型的统一调度

同一进程中的推理入口应统一限制模型数、会话数、上下文 token 总量和多模态输入总量。并发是否可行要由后端文档和测试决定;“NPU 同一时刻只能运行一个任务”不是 Android 平台契约,不同 SoC、驱动和 delegate 可能采用排队、并发或回退。

闲置会话的处理也要明确:

  • 能快速从文本重建时,关闭会话通常比长期保存 KV 更稳妥;
  • 只有引擎提供稳定的状态导出/导入时,才考虑把 KV 或会话状态写入存储;
  • 会话状态可能含用户敏感内容,持久化需要加密、失效和删除规则;
  • 预热模型与预热会话分开配置,保留权重不要求保留全部 KV。

4. 平台提供的压力信号

4.1 Memory Advice 的弃用状态

挑选平台压力信号时,先排掉一个已经过时的选项。官方内存管理概述已将 Memory Advice API beta 标记为 deprecated(已弃用),不再推荐使用;它属于 AGDK(Android Game Development Kit)的实验性 native 库,也不是 Android 17 新增的平台内存接口。现有项目可以在迁移期保留观测;新推理管线的准入与降级信号,应建立在平台维护的接口上。接口状态与迁移去向详见 内存监控与线上治理。

4.2 onTrimMemory() 的释放语义

onTrimMemory() 只负责机会性释放。Android 14 起,系统不再发送大部分旧的 running trim level(运行中进程的压力级别);Android 15 将这些常量正式标记为已弃用。当前官方建议集中处理 TRIM_MEMORY_UI_HIDDEN 和 TRIM_MEMORY_BACKGROUND。

这段代码在回调中只派发释放任务,不在回调线程执行模型关闭或等待后端:

override fun onTrimMemory(level: Int) {
    when {
        level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
            inferenceExecutor.execute {
                inferenceController.cancelAndReleaseRebuildableState()
            }
        }
        level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
            inferenceExecutor.execute {
                inferenceController.releaseUiInputs()
            }
        }
    }
}

inferenceController 是应用自己的生命周期组件,不是平台类。这些释放任务还要去重,避免连续回调排队执行多次关闭。还要认清这个回调的性质:TRIM_MEMORY_UI_HIDDEN 表示 UI 已不可见,给的是释放 UI 输入与预览缓存的时机;系统是否已经出现内存压力、杀进程前会不会先通知,它都回答不了,所以会话文本和用户操作进度应在正常业务流程中持续保存。

4.3 MemoryInfo 的用途与局限

MemoryInfo 适合做信号,不适合做容量承诺。加载前可以读取 lowMemory、availMem 和当前进程状态,结合已经测得的设备档案决定是否禁用预热、选择较小模型或延后任务;但不要高频轮询,ActivityManager.getMemoryInfo() 的文档也建议优先使用 onTrimMemory() 回调。

Android 17 的 ProfilingTrigger 增加了 TRIGGER_TYPE_OOM 与 TRIGGER_TYPE_ANOMALY 等触发器。OOM 触发器请求系统生成 Java 堆转储,并要求自定义 UncaughtExceptionHandler 继续调用默认处理器;anomaly(异常) 触发器返回何种产物取决于异常类型。系统触发的分析不保证一定生成结果,结果只能交给通过 registerForAllProfilingResults() 注册的全局监听器。它们适合生产问题取证,不是同步查询“还剩多少字节”的接口;配置细节参阅 Android 17 ProfilingManager 文档。

5. CPU、GPU 与 NPU 的边界

5.1 NNAPI 的现状与迁移

后端选择上,NNAPI 已不再是新方案。NNAPI 在 Android 15 / API 35 已 deprecated,Android 17 仍保留接口用于兼容,但官方说明未来设备可能更多回到 CPU 后端,并建议迁移到可独立更新的高层运行时。新项目要避免通过 NNAPI 枚举设备来设计长期 NPU 调度。

当前 LiteRT CompiledModel API 提供 CPU、GPU 和 NPU 路径,2026-10-25 复核时文档中的 Maven 示例版本为 2.3.0;具体 NPU 支持仍依赖 SoC、运行时包、delegate、模型算子和分发方式。选择 Accelerator.NPU 之后,我们要记录子图分区、回退、初始化峰值和首轮峰值,整张计算图是否都在 NPU 上运行,就靠这些记录来回答。

后端选择仍要看分区与回退

图:选择 NPU 不证明整图都在 NPU 执行;失败回退要避免旧后端与新后端资源同时驻留。

对较大且目标 SoC 已知的模型,部分 LiteRT NPU 路径 与当前 LiteRT NPU 编译和分发文档 都提供 AOT 方案,用来减少设备侧初始化编译。当前路径还支持设备侧编译及编译缓存,缓存是否命中会显著改变初始化时间和内存。另外,AOT 产物、原始模型、AI Pack(按设备投递的 Play 资产包)和厂商运行时库可能在安装或加载阶段同时存在,这一阶段的峰值仍要按对应 SoC 与运行时版本测量。

5.2 共享缓冲区的导入条件

AHardwareBuffer 从 API 26 起提供跨进程和图形 API 可共享的缓冲区。格式、usage flags(使用方式标志)、尺寸和设备实现共同决定它能否分配或被某个消费者导入;API 29 起可先用 AHardwareBuffer_isSupported() 检查给定描述。

AHardwareBuffer 没有“所有 NPU 都可访问”的平台保证。即使 GPU 或 delegate 接受它,我们也要逐项核对:

  • CPU 锁定访问是否允许,是否会等待 fence(表示异步硬件操作完成的同步对象);
  • GPU/NPU 导入缓冲区时是否产生额外副本;
  • CPU 与设备缓存同步、访问权如何转换;
  • 缓冲区的引用计数和关闭时机;
  • memtrack (硬件内存记账接口)、DMA-BUF 与进程 PSS 分别如何核算。

OpenCL 同样要小心:Android NDK 并未把它纳入面向所有设备的稳定公共计算 API。某个推理库要求声明厂商 OpenCL 原生库,只说明该库在指定设备路径上的依赖,够不上 Android 17 的通用接口。

5.3 CPU 路径仍有映射和工作区

回到 CPU 路径:它可以直接访问进程地址空间中的权重和 tensor,通常不需要设备私有显存,但仍会占用文件映射、native arena(分配器批量管理的内存区)、线程栈、权重打包缓冲区和算子工作区。线程数加上去,栈地址空间预留和每线程临时缓冲区可能同时增加,所以并行度要按吞吐、峰值 PSS、温度和尾延迟共同确定。

6. 怎样测到可解释的峰值

6.1 VmRSS 与 VmHWM 的含义

测量峰值时最容易混用的两个值是 VmRSS 与 VmHWM。Debug.getRss() 在 r1 源码中有无参数的当前进程查询,以及 @hide 的 getRss(int pid, long[] outMemtrack) 重载,并不是带 flags 参数的接口;它不返回历史峰值;/proc/self/status 中 VmRSS 是当前 RSS,VmHWM 是进程生命周期内的 RSS high-water mark(历史峰值)。推理前后各读一次当前 RSS 再取差值,区间内冲上去又落回来的峰值会整段漏掉。

这个 Kotlin 函数用于低频读取关键的 /proc/self/status 字段。内存字段的单位是 KiB,Threads 表示线程数量:

import java.io.File

private val memoryStatusKeys = setOf(
    "VmSize", "VmRSS", "VmHWM", "RssAnon",
    "RssFile", "RssShmem", "VmSwap", "Threads"
)

fun readProcessStatus(): Map<String, Long> =
    File("/proc/self/status").useLines { lines ->
        lines.mapNotNull { line ->
            val separator = line.indexOf(':')
            if (separator <= 0) return@mapNotNull null

            val key = line.substring(0, separator)
            if (key !in memoryStatusKeys) return@mapNotNull null

            val value = line.substring(separator + 1)
                .trim()
                .substringBefore(' ')
                .toLongOrNull()
                ?: return@mapNotNull null
            key to value
        }.toMap()
    }

该函数适合在阶段交界处或异常触发时采样,不要在每生成一个 token 时调用。VmHWM 默认累计历史峰值,不会在每次推理时自动清零。固定内核的 proc.rst 也提供向 /proc/<pid>/clear_refs 写 5,把高水位重设为当前 RSS 的能力;实际可用性取决于内核配置、权限与 SELinux,不能假定所有应用设备都允许。比较单阶段峰值时,可用独立测试进程,或有明确阶段标记的 RSS/PSS 采样配合 Perfetto;若使用重置,需记录操作与重置时的基线。

当前 RSS 与历史峰值的采样边界

图:RSS 是当前值,HWM 默认是累计峰值;对比单阶段峰值需独立进程或有阶段标记的采样,并确认重置能力。

6.2 每个采样点保存同一组字段

建议统一保存:

  • 时间、场景、模型/会话 ID、上下文 token 数和输入尺寸;
  • 后端、delegate、回退结果与线程数;
  • VmSize、VmRSS、VmHWM、RssAnon、RssFile、RssShmem、VmSwap;
  • PSS 分类、native heap、Java heap、图形与代码;
  • 当前模型、会话、KV 缓存和 I/O 缓冲区数量;
  • 最近一次加载、编译、取消、关闭、内存分配器或 delegate 错误。

这组字段的语义,/proc 部分以 android17-6.18-2026-06_r6 的 proc.rst 为准。PSS 会按共享比例分摊,比 RSS 更适合比较多进程总成本;诊断单个分配来源时仍要看 maps(进程内存映射清单)、native 分配分析和运行时日志。

6.3 GPU 与 native 内存需要外部工具

到了 GPU 与 native 内存,我们要靠外部工具。Debug.java 中的 getGpuTotalUsageKb() 和 getGpuPrivateMemoryKb() 是 @hide(未纳入公开 SDK) 的系统接口,普通应用没有对应的 SDK 调用面,dumpsys gfxinfo 也算不上通用的 LLM GPU 内存计数器。测试环境可以组合:

  • adb shell dumpsys meminfo <package> 的 Graphics、GL、DMA/memtrack 分类;
  • Perfetto 的进程、内存和可用 GPU 计数器;
  • heapprofd(native 堆采样器)定位 native 分配调用栈;
  • delegate 或运行时自带的 profiler;
  • SoC 厂商工具,用于只在对应设备上解释驱动内存。

Native heap 统计与 Scudo 分配器的职责划分详见 23.3 Native 与虚拟内存管理优化。同一场景应保留完整工具版本和设备 build fingerprint(能够标识系统构建版本的字符串),避免直接横向比较厂商字段。

7. 内存压力下的动作顺序

7.1 按原因选择动作

现场可能原因优先动作
加载阶段 PSS 翻倍权重重排、delegate 编译、旧模型未关闭对比分阶段快照,检查双模型窗口与后端副本
prefill 峰值过高长提示词、临时 attention/FFN 工作区限制上下文、评估 chunked prefill(分块预填充)或更小模型
decode 随 token 线性增长KV 缓存持续扩张限制总 token、关闭闲置会话、验证 KV 量化支持
取消后内存不回落回调仍在运行、会话/模型未关闭、分配器保留核对资源所有者、等待后端、区分泄漏与可复用 arena
GPU delegate 失败后峰值更高回退时旧资源未释放或产生双后端资源记录回退顺序,关闭失败后端后再创建替代路径
无 Java OOM 但进程消失LMK、原生代码主动终止、驱动崩溃或系统限制查 ApplicationExitInfo(进程退出原因记录)、logcat、Vitals 与系统事件

表中各类峰值对应不同的资源,一个综合压力等级触发同一套降级,照顾不到这些不同的原因。压力信号到达后,控制器的动作顺序是:先阻止新任务,再取消或完成当前临界操作,然后释放可重建资源。安全底线是:不要在 native 后端执行中途随意 munmap() 权重或缓冲区,这会造成 use-after-free(资源释放后仍被访问) 或驱动错误。

7.2 保存可恢复状态

端侧对话的可恢复状态,通常是结构化消息、采样参数、模型版本和已提交的业务结果;KV 缓存只用于加速运行时生成。进程可能在没有回调的情况下被 LMKD 终止,因此重要状态应随交互增量保存,进程重建后再根据策略恢复模型和上下文。

8. 多进程的隔离收益与总账

把推理引擎放入私有子进程,收益是两个:推理子进程中的用户态原生崩溃通常可以隔离于 UI 进程;进程退出时会释放它拥有的运行时与映射。GPU/NPU 驱动故障、系统服务故障或整机资源压力仍可能跨进程影响 UI,进程边界并非对此的保证。代价则包括第二套 ART 与 native 内存基线、Binder 调用与序列化成本、模型可能重复映射,以及更复杂的恢复协议。进程隔离的通用取舍可参阅 23.5 大内存与多进程策略。

评估多进程时,我们要盯住总账:把所有应用进程的 PSS 加总起来看,主进程的下降往往只是内存发生了转移,总成本要靠这套加总数字说话。模型只在需要的进程初始化;大输入优先传文件描述符、SharedMemory 或受控的 HardwareBuffer,避免 Binder 内复制大数组。子进程死亡、任务幂等(同一请求重复执行不会产生额外副作用)、结果去重和版本不匹配,都要预先定义处理方式;用前台服务或频繁绑定服务提高进程存活率、来掩盖过高的内存,这条路也不要走。

另外,ProcessList.batchSetOomAdj() 和 LMKD 的私有控制包都留在系统自己手里——进程重要性由可见组件、服务状态和系统策略决定,伪造高优先级会挤压整机内存。

9. 测试覆盖与通过条件

9.1 设备与软件维度

设备与软件范围至少覆盖:

  • Android 14、15、16、17 中产品支持的版本;
  • 32/64 位 ABI,以及产品实际支持的 4 KiB/16 KiB 页设备;
  • 不同 RAM 档位、SoC 和 GPU/NPU 驱动;
  • 运行时和 delegate 的发布版本;
  • CPU、GPU、NPU 与每条允许的回退路径;
  • 冷启动、热启动和进程被系统重建。

9.2 场景维度

每个模型变体都要覆盖:

  • 最短、常用和允许的最大上下文;
  • 最大输出 token;
  • 单会话与允许的最大并发;
  • 多模态最大输入尺寸和数量;
  • 首次设备侧编译、AOT 产物、编译缓存命中与失效;
  • 生成中取消、前后台切换、旋转/配置变化;
  • 模型切换、后端失败与降级;
  • 多轮加载/关闭,直到 PSS/RSS 与 native 分配进入稳定区间;
  • 受控系统内存压力和 LMK 恢复。

循环次数由“是否进入稳定区间”和泄漏检测灵敏度决定,不使用统一的 10 次或 1000 次。模拟 onTrimMemory() 回调只能测试处理代码,代替不了真实内存压力、页回收、swap 和 LMKD 场景。

9.3 通过条件

通过条件来自每个设备档案,至少包含:

  • 所有阶段峰值未触发 Java/native OOM、系统内存限制或前台 LMK;
  • 最大上下文下没有未声明的后端回退;
  • 取消与关闭后,应释放的模型、会话和缓冲区实例归零,内存回到可解释的稳定区间;
  • 后台释放后能够恢复,用户输入和已确认结果不丢失;
  • 模型切换的双份窗口已被消除或明确计入预算;
  • 线上能够通过 Android Vitals、ApplicationExitInfo 和 Android 17 分析触发器追踪失败。

全文小结

回到整篇文章的主线:运行时、驱动和模型结构的差异,装不进一个全设备通用的安全系数里。我们要做的是把权重、KV 缓存、工作区、后端资源和应用基线分别量化,再用阶段化数据限定模型、上下文、会话和后端组合。Android 17 新增了 OOM 与 anomaly 分析触发器,但推理资源的所有权和恢复协议,仍要应用自己管理。

参考资料