学习目标
读完本章,你应当能够:
- 说清"加载一个模型"在 native 层到底创建了哪四个对象(model / context / batch / sampler);
- 看懂
load→init_context→new_sampler的调用链,以及prepare在其中的位置;- 理解
llama_context_params里n_ctx/n_batch/ 线程数各自控制什么;- 动手算 :
n_ctx = 8192时 KV cache 约占多少内存(并知道 GQA 能省几倍);- 知道"backend 幂等初始化"为什么需要(
g_backend_initialized标志);- 复述本章的一个真实事故:
unload后不把g_batch清零 → 二次释放(double free);- 说清
prepare失败路径为什么要"把所有已取得的资源全部归还"。前置 :ch14(GGUF 格式)、ch13(JNI)
对应源码:
lib/src/main/cpp/ai_chat.cpp(882 行;本章重点load87--104、init_context106--135、
new_sampler137--143、prepare145--171、unload847--868、shutdown870--882、
init62--85、全局状态 40--49、常量 28--35)
lib/.../internal/InferenceEngineImpl.kt(loadModel145--179、init122--140)
🧭 读本章前,请先确认前置章节(为什么需要) :
- ch14(GGUF) :本章读的
.gguf文件里"有什么"上一章刚拆过------
不知道"元数据和张量"的区别,就读不懂load到底把哪几 GB 读进了内存。- ch13(JNI) :本章看的全是
ai_chat.cpp里的 C++ 代码,
指针、nullptr、lock_guard这些 ch13 讲过,直接沿用。需要的基础(具体到概念级别) :
- 知道"内存 "是什么、"关机后内存里的东西就没了 "(读 附录 A.6.2 )------
本章全在算"这几个对象各占多少内存";- 会做乘除法 ------本章要算 KV cache 占多少内存(只是乘除,不用怕)。
(以上基础够用了;本章术语第一次出现都会解释。)
(生词列表见下一行。)本章会出现的生词 :
llama_model(模型对象)、llama_context(运行上下文)、
llama_batch(批量容器)、采样器(sampler) 、KV cache(键值缓存) 、
n_ctx(上下文长度) 、n_batch(批大小) 、线程数、mmap、
幂等(idempotent) 、double free(二次释放) 、智能指针(smart pointer)。
全部查 附录 B.6 。
以上生词不必提前背,读到哪个回来查即可。读法建议 :本章有两块"硬知识"------
① KV cache 的内存计算 (15.5,一定要自己动手算一遍 ,这是端侧推理最硬的约束);
② 释放后清零防 double free (15.8.2)。
其余是"四个对象怎么建起来"的流程,按顺序读即可。
本章导读上一章你认识了"模型文件里有什么"。这一章看"怎么把它装进内存开始干活"。
用开店来类比,加载一个模型要办四件事:
💡 类比:开一家餐厅
步骤 类比 对应的对象 ① 把菜囤进冷库 读权重(几 GB)进来 g_model② 支起工作台、算好能同时放多少盘菜 建运行环境(KV cache) g_context③ 准备好传菜的托盘 分配批量缓冲区 g_batch④ 请一位"选菜师"决定每步上哪道 建采样器 g_sampler四件事顺序有依赖 :没有冷库(model)就支不起工作台(context),
没有工作台就开不了张(sampler 需要 model)。
本章还要讲两个真实的 C++ 事故,它们都属于同一类:
"释放了,但没把指针清零" 。
这类 bug 的特点是------你第一次跑没事,第二次跑才崩 ,
所以特别难查。理解它,你就能避开一整类资源管理陷阱。
本章的核心数字 是 KV cache 的内存占用。
它直接决定"你的手机能不能跑这个模型",是端侧推理最硬的约束之一。
15.1 四个对象与它们的调用链
🧭 从零开场白:"加载模型"到底在干什么?
先把画面想清楚。你手机里那个
.gguf模型文件,躺在磁盘上的时候是"睡着的" ------它只是一堆按格式排好的 0 和 1(ch14 刚拆过:元数据 + 一堆张量权重)。
这堆 0 和 1 不能直接拿来聊天:它不会自己跑进 CPU,也不会自己"想问题"。
本章要做的事,用一句话说就是:
把这个"睡着"的模型从磁盘搬进内存、把它叫醒,再给它摆一张"工作台"。
拆开是两步:
- 加载模型 = 叫醒大脑 。
llama.cpp打开.gguf文件,读它的头、
把里面那几 GB 的权重数据放到内存里,最后交给你一个llama_model *指针。
这个llama_model就是模型的**"大脑"**------它装着全部"知识"(训练好的权重数字)。
一个大脑只需要叫醒一次,叫醒之后它一直知道"自己知道什么"。- 初始化上下文 = 摆工作台 。光有大脑还不够:大脑干活时需要一张草稿纸 ,
用来记"刚才聊到哪了、前面说过哪些词"------这就是llama_context(核心是 KV cache)。
大脑负责"记住知识",工作台负责"这一次对话的临时状态"。💡 为什么要分成 model 和 context 两个东西?
因为一个大脑可以配多张工作台 :理论上加载一次模型,就能同时开好几个
llama_context,相当于用同一个"脑子"同时跟两个人聊天、互不干扰。大脑(GB 级权重)只叫醒一次、大家共用;工作台(KV cache,几百 MB 到几 GB)每人一张。
本工程为了简单只配了一张工作台 (
g_context),但你要知道这个拆分是有意设计的。建立这个直觉后,下面看四个对象是怎么一个个建起来的。
15.1.1 四个全局对象
🧩 第一次见这四个对象,先从零讲清它们各是什么(后面全章都在用):
- 模型(
llama_model)= 训练好的那几 GB 权重数字。只加载一次,加载完就"知识就位"。- 上下文(
llama_context)= 模型"工作时的草稿纸" 。最关键的是里面的
KV cache(键值缓存) :模型每读一个词,就把"这个词和前面的词是什么关系"
记在草稿纸上,下次不用重新算 。草稿纸能写多长,就由n_ctx(上下文长度)决定。
💡 类比:你读一篇长文章做摘要,不可能把前面每段都重读一遍------
你会在草稿纸上记下每段的要点,KV cache 就是这张要点草稿纸。- batch(
llama_batch)= 一次递给模型的一摞词 。n_batch就是这摞最多多厚。- 采样器(sampler)= 从一堆候选词里挑下一个词的"抽签师" 。
模型读完你这句话,会算出"下一个词可能是哪些、各有多大概率",
采样器负责按规则(温度/top-k)从里面抽一个出来。ch19 详讲,这里只要知道它在。
✅ 取自本工程、已编译验证 ------ ai_chat.cpp:40-44
cpp
static llama_model * g_model;
static llama_context * g_context;
static llama_batch g_batch;
static common_chat_templates_ptr g_chat_templates;
static common_sampler * g_sampler;
| 对象 | 是什么 | 大小量级 |
|---|---|---|
g_model |
模型权重 (从 .gguf 读进来的那几 GB) |
GB 级 |
g_context |
运行上下文:KV cache + 计算缓冲区 | 几十 MB 到几 GB(取决于 n_ctx) |
g_batch |
一批 token 的容器(一次解码多少个) | 几百 KB |
g_chat_templates |
对话模板(ch17 用) | KB 级 |
g_sampler |
采样器(ch19 用) | KB 级 |
15.1.2 调用链
Kotlin: engine.loadModel(path)
│
├─ load(path) ← ① 只把权重读进内存(g_model)
│
└─ prepare()
├─ init_context() ← ② 建 KV cache + 计算缓冲(g_context)
├─ llama_batch_init() ← ③ 分配 batch 缓冲(g_batch)
├─ common_chat_templates_init() ← 读对话模板(g_chat_templates)
└─ new_sampler() ← ④ 建采样器(g_sampler)
对应到 Kotlin 侧(✅ InferenceEngineImpl.kt:145-179,简化):
kotlin
override suspend fun loadModel(pathToModel: String) =
withContext(llamaDispatcher) {
check(_state.value is InferenceEngine.State.Initialized) { ... }
try {
Log.i(TAG, "Checking access to model file... \n$pathToModel")
File(pathToModel).let {
require(it.exists()) { "File not found" }
require(it.isFile) { "Not a valid file" }
require(it.canRead()) { "Cannot read file" }
}
_readyForSystemPrompt = false
_state.value = InferenceEngine.State.LoadingModel
load(pathToModel).let {
if (it != 0) throw UnsupportedArchitectureException()
}
prepare().let {
if (it != 0) throw IOException("Failed to prepare resources")
}
_readyForSystemPrompt = true
_cancelGeneration = false
_state.value = InferenceEngine.State.ModelReady
} catch (e: Exception) {
_state.value = InferenceEngine.State.Error(e)
throw e
}
}
注意 Kotlin 侧的三层检查:
- 状态检查 :必须是
Initialized(native 库已加载); - 文件检查:存在 / 是文件 / 可读;
- 返回码检查 :
load和prepare返回非 0 就抛异常。
📌 为什么 native 用"返回错误码"而不是"抛异常"?
C++ 里跨 JNI 边界抛 Java 异常比较麻烦(要
env->Throw且要小心栈展开)。本工程的选择是返回
Int错误码 ,由 Kotlin 侧翻译成异常(UnsupportedArchitectureException/
IOException)------在语言边界上做一次"错误模型转换"。这是 JNI 编程的常见做法,也解释了 ch13 看到的那些返回
jint的函数。
15.2 backend 初始化:为什么需要"幂等"标志
15.2.1 问题
init() 要做两件全局性的初始化(✅ ai_chat.cpp:62-85):
cpp
extern "C"
JNIEXPORT void JNICALL
Java_com_arm_aichat_internal_InferenceEngineImpl_init(JNIEnv *env, jobject /*unused*/, jstring nativeLibDir) {
std::lock_guard<std::mutex> lock(g_llama_mutex);
if (g_backend_initialized) {
LOGi("%s: Backend already initialized, skip duplicate init", __func__);
return;
}
// Set llama log handler to Android
llama_log_set(aichat_android_log_callback, nullptr);
// Loading all CPU backend variants
const auto *path_to_backend = env->GetStringUTFChars(nativeLibDir, 0);
LOGi("Loading backends from %s", path_to_backend);
ggml_backend_load_all_from_path(path_to_backend);
env->ReleaseStringUTFChars(nativeLibDir, path_to_backend);
// Initialize backends
llama_backend_init();
g_backend_initialized = true;
LOGi("Backend initiated; Log handler set.");
}
ggml_backend_load_all_from_path(...)------ 动态加载 backend 的.so
(本工程 CMake 开了GGML_BACKEND_DL=ON,所以 backend 是运行时加载的);llama_backend_init()------ llama.cpp 的全局初始化。
15.2.2 为什么需要 g_backend_initialized
✅ 取自本工程 ------ ai_chat.cpp:51-60(注释就是答案)
cpp
/**
* GGML backend 是否已初始化。
*
* 为什么要这个标志:Kotlin 侧 InferenceEngineImpl.destroy() 会把单例置空,
* 之后再 getInstance() 会 new 一个新实例,其 init{} 块会**再次**调用本 init()。
* 若上一次 destroy() 已经执行过 shutdown()(llama_backend_free),重复
* ggml_backend_load_all_from_path + llama_backend_init 会造成 backend 重复注册、
* 内存增长。这里做成幂等:已初始化就直接返回,只有 shutdown() 才复位标志。
*/
static bool g_backend_initialized = false;
翻译成人话,场景是这样的:
1. App 启动 → getInstance() → new InferenceEngineImpl() → init() → 初始化 backend ✅
2. 用户切换模型 → destroy() → shutdown() → llama_backend_free() → 标志复位 ✅
3. 再 getInstance() → new 一个新实例 → init() → 如果无脑再初始化 → ❌ backend 重复注册
所以 init() 做成幂等:已初始化就直接返回。
🧩 "幂等(idempotent)"是什么?一句话 :
同一个动作做一遍和做十遍,结果一模一样 。
生活类比:按电梯按钮------按一下电梯就来,狂按十下电梯也不会更快来,结果一样。
本章的
init()就是这样:调一次初始化、调十次也还是"已初始化",不会重复建第二份。好处是上层"想用就调,不用先查有没有",不用担心重复调用出问题。
15.2.3 init 与 shutdown 必须配对
✅ 取自本工程 ------ ai_chat.cpp:870-882
cpp
extern "C"
JNIEXPORT void JNICALL
Java_com_arm_aichat_internal_InferenceEngineImpl_shutdown(JNIEnv *, jobject /*unused*/) {
std::lock_guard<std::mutex> lock(g_llama_mutex);
// 与 init() 配对:只有真正释放过 backend 才允许下次重新初始化
if (!g_backend_initialized) {
LOGi("%s: Backend not initialized, nothing to free", __func__);
return;
}
llama_backend_free();
g_backend_initialized = false;
}
两边都做了幂等判断:
| 函数 | 判断 |
|---|---|
init |
已初始化 → 跳过 |
shutdown |
未初始化 → 跳过 |
💡 "配对且幂等"是一个通用模式 :
无论调用多少次
init/shutdown,最终状态都是确定的。这让上层可以放心地"需要时调一下",不用精确追踪当前状态。
对比 ch12 的 TTS
shutdown()(也有isShutdown标志)------同一思路。
15.3 load:把权重读进内存
✅ 取自本工程、已编译验证 ------ ai_chat.cpp:87-104
cpp
extern "C"
JNIEXPORT jint JNICALL
Java_com_arm_aichat_internal_InferenceEngineImpl_load(JNIEnv *env, jobject, jstring jmodel_path) {
std::lock_guard<std::mutex> lock(g_llama_mutex);
llama_model_params model_params = llama_model_default_params();
const auto *model_path = env->GetStringUTFChars(jmodel_path, 0);
LOGd("%s: Loading model from: \n%s\n", __func__, model_path);
auto *model = llama_model_load_from_file(model_path, model_params);
env->ReleaseStringUTFChars(jmodel_path, model_path);
if (!model) {
return 1;
}
g_model = model;
return 0;
}
三个要点:
| 要点 | 说明 |
|---|---|
llama_model_default_params() |
用默认参数(mmap 加载,见下) |
llama_model_load_from_file(...) |
真正读权重;失败返回 nullptr |
| 返回 1 表示失败 | Kotlin 侧据此抛异常 |
深入:llama_model_load_from_file 内部到底替你做了什么(四步拆解)
🧩 这是什么(大白话) :
它是 llama.cpp 提供的一个函数。你给它两样东西------模型文件的路径 和一组加载参数 ,
它就返回一个
llama_model *指针。文件里每个字节怎么排、量化值怎么还原、内存怎么布局,你都不用关心,它在内部按这个顺序全帮你做掉:
打开 .gguf 文件 → 读 GGUF 头(知道有几层、几个头、什么量化......ch14) → 按头里记录的布局分配内存 / 建立 mmap 映射 → 把每一层的张量权重"读"进那块内存 → 打包成一个 llama_model 对象,返回指针🧩 为什么必须用它、不能自己写?
模型权重有几 GB,而
.gguf是 llama.cpp 自己的二进制格式。想自己写加载代码,就得先吃透 GGUF 格式、知道每种量化(q4_k_m 等)怎么还原成
计算要用的数、还得把张量摆成 CPU 喜欢的内存布局。
这相当于为了读一本电子书,先自己手写一个电子书阅读器 ------不现实。
所以全世界的 llama.cpp 应用都调这同一个函数。
📝 最小示例(需自行验证)------把工程代码剥到只剩骨头,"只做加载"的最小骨架:
cpp
#include "llama.h"
int main() {
llama_model_params params = llama_model_default_params(); // 用默认参数
llama_model *model = llama_model_load_from_file("model.gguf", params);
if (model == nullptr) { // 失败:文件不存在 / 损坏 / 内存不够
fprintf(stderr, "load failed\n");
return 1;
}
// ......这里才能开始用模型推理......
llama_model_free(model); // 用完记得释放(15.7/15.8 讲为什么顺序重要)
return 0;
}
对照工程真身(✅ ai_chat.cpp:87-104):工程只是多了把 jstring 转成 C 字符串、
加了把锁、把指针存进全局变量 g_model------核心就是这三行 :
default_params → load_from_file → 判空。
⏱️ 加载要多久?先给个直觉 :
一个 4 GB 的模型文件,从手机 UFS 存储读到内存,大约要 5--15 秒
(取决于存储速度、是否走 mmap 按需读)。
所以本工程加载期间会把 UI 状态切成
LoadingModel、给用户转圈提示(
InferenceEngineImpl.kt里的_state.value = ...LoadingModel)------加载是真的慢,不是 App 卡死了 。你自己做加载界面时,一定要放进度条,
别让用户以为程序死了。
加载失败会怎样、怎么兜底(四步拆解)
🧩 这是什么 :调
llama_model_load_from_file可能失败,返回nullptr。背后原因通常是这几类:
- 文件不存在 / 路径写错(用户选了个不存在的文件);
- 文件损坏 / 没下完整(下载到一半断网);
- GGUF 版本不兼容(太老的 gguf 格式,新版 llama.cpp 不认);
- 内存不够(手机只剩 1 GB 空闲,模型却要吃 4 GB)。
🧩 为什么必须好好兜底?
零基础用户最常犯的错就是"选错文件 / 下坏文件 / 手机内存满了"。
如果 App 直接闪退,用户只会觉得"又崩了",根本不知道该去关后台还是重下模型。
💡 类比:加载失败像开车打不着火 ------好车的仪表盘会告诉你"油箱空了"
还是"电瓶没电了",而不是只黑着屏不说话。具体的错误提示能帮人快速解决问题。
本工程的兜底分两层:
- Kotlin 侧先做文件级检查 (✅
InferenceEngineImpl.kt:126-130):
require(it.exists()) { "File not found" }、require(it.isFile)、require(it.canRead())
------把"不存在 / 不是文件 / 没权限"这几类在调 native 之前就拦下,错误信息很具体; - native 侧再做返回值检查 (
ai_chat.cpp:99-101):model == nullptr就return 1,
Kotlin 侧把非 0 翻译成UnsupportedArchitectureException。
📝 最小示例(需自行验证)------加载时逐个可能的失败点,给出人话提示:
cpp
llama_model *model = llama_model_load_from_file(path, params);
if (model == nullptr) {
if (access(path, R_OK) != 0) return "文件不存在或读不了";
// 否则多半是:损坏 / 版本不兼容 / 内存不足
return "模型加载失败:文件损坏或内存不足,请重下模型并关闭后台应用";
}
工程真身就是 load 函数里那句 if (!model) return 1;------
工程选择把"具体原因"交给日志(LOGe/LOGi),把"行不行"交给返回码。
15.3.1 为什么 load 和 prepare 分开
因为 load 只做一件事:把权重读进来。
而 prepare 还要建 context / batch / template / sampler------
这些是"运行环境",和"权重"是两类东西。
分开的好处:
| 好处 | 说明 |
|---|---|
| 职责清晰 | 出错时能立刻知道是"文件读不进来"还是"运行环境建不起来" |
| 可复用 | benchModel(ch01 见过的基准测试)就是自己 init_context 出独立的 context,不碰 g_context |
| 便于中间做状态反馈 | Kotlin 侧在两步之间可以更新 _state(LoadingModel → ...) |
15.3.2 mmap:为什么加载不一定要"读几 GB"
llama_model_default_params() 默认开启 mmap(内存映射):
权重文件被映射 到进程地址空间,但不立刻全部读进物理内存 ;
只有真正访问到某块时才从磁盘按需载入(操作系统的页缓存机制)。
好处:
- 加载快(看起来"瞬间"完成,其实只是建立了映射);
- 内存省(不活跃的部分可能一直留在磁盘上);
- 多个进程可以共享同一份文件的物理内存。
代价:
- 第一次推理时可能因为"缺页"而卡顿;
- 文件的读取速度影响推理速度。
📌 这也解释了 ch10 说的"释放模型要归还 GB 级 mmap "------
llama_model_free不只是free(),还要解除映射。
15.4 init_context:搭工作台
✅ 取自本工程、已编译验证 ------ ai_chat.cpp:106-135
cpp
static llama_context *init_context(llama_model *model, const int n_ctx = DEFAULT_CONTEXT_SIZE) {
if (!model) {
LOGe("%s: model cannot be null", __func__);
return nullptr;
}
// Multi-threading setup
const int n_threads = std::max(N_THREADS_MIN, std::min(N_THREADS_MAX,
(int) sysconf(_SC_NPROCESSORS_ONLN) -
N_THREADS_HEADROOM));
LOGi("%s: Using %d threads", __func__, n_threads);
// Context parameters setup
llama_context_params ctx_params = llama_context_default_params();
const int trained_context_size = llama_model_n_ctx_train(model);
if (n_ctx > trained_context_size) {
LOGw("%s: Model was trained with only %d context size! Enforcing %d context size...",
__func__, trained_context_size, n_ctx);
}
ctx_params.n_ctx = n_ctx;
ctx_params.n_batch = BATCH_SIZE;
ctx_params.n_ubatch = BATCH_SIZE;
ctx_params.n_threads = n_threads;
ctx_params.n_threads_batch = n_threads;
auto *context = llama_init_from_model(g_model, ctx_params);
if (context == nullptr) {
LOGe("%s: llama_new_context_with_model() returned null)", __func__);
}
return context;
}
🧩
llama_context_params这三个参数分别管什么(0 基础版)建工作台时要填一张"参数表"(
llama_context_params)。三个最重要的:
参数 工程里的值 管什么 n_ctx(上下文长度)8192 工作台能摊开多大------模型一次最多"记住"多少个 token n_batch(批大小)512 一次能往工作台搬多少个 token------影响"读完你这句话"有多快 n_threads(线程数)2~4 自动定 几个工人同时干活------影响逐字往外吐的速度 💡 类比(记住这三个就够):
n_ctx= 工作台的大小。越大能摊开的资料越多(聊得越长不忘事),但占地方(内存)也越大;n_batch= 一趟能搬多少文件到工作台 。一次多搬省时间(prefill 快),
但一次搬太多工人拿不动、还占手;n_threads= 几个工人同时干活 。人多手快,但人挤了会抢工具(内存带宽),
人太多还会把屋子烤热(发热降频)。🧩 为什么这三个值要这样权衡?
n_ctx设太小 → 聊几句就"忘事";设太大 → KV cache 线性涨内存(15.5 会算),
手机内存不够会加载失败;n_batch设太小 → 用户一长句话要分好几趟喂,prefill 慢半拍;设太大 →
一次申请的中间缓冲也大,低端机可能吃不消;n_threads设太小 → 推理慢;设太大 → 抢带宽 + 发热,反而更慢、还卡 UI。📝 感受一下这几个参数的量级(需自行验证,建立感觉即可):
n_ctx = 4096vs8192:KV cache 正好差一倍(线性关系,见 15.5);n_batch = 128vs512:一句 1000 token 的话,前者要喂 8 趟、后者只喂 2 趟,
prefill 阶段体感差好几倍;n_threads = 2vs4:逐字吐字速度大约 1.5~2 倍;再往上加到 8 线程,提升就很小了。工程真身就是
init_context里那几行ctx_params.n_ctx = ...; n_batch = ...; n_threads = ...。
15.4.1 线程数怎么定的
cpp
const int n_threads = std::max(N_THREADS_MIN, std::min(N_THREADS_MAX,
(int) sysconf(_SC_NPROCESSORS_ONLN) -
N_THREADS_HEADROOM));
读作(从里到外):
sysconf(_SC_NPROCESSORS_ONLN) ← 机器上的 CPU 核数
− N_THREADS_HEADROOM ← 留 2 个核给系统/UI(别把手机全占了)
min(..., N_THREADS_MAX) ← 最多用 4 个(超过通常收益很小)
max(..., N_THREADS_MIN) ← 至少 2 个
常量(✅ ai_chat.cpp:28-30):
cpp
constexpr int N_THREADS_MIN = 2;
constexpr int N_THREADS_MAX = 4;
constexpr int N_THREADS_HEADROOM = 2;
💡 为什么最多只用 4 个线程?
手机上推理的瓶颈常常是内存带宽 ,不是核心数。
开太多线程反而互相抢带宽、还会挤占系统资源导致 UI 卡顿。
留 2 个核(HEADROOM)就是给前台体验的。
这个"留余量"的思路和 ch10 讲的"别把资源用尽"是一致的。
15.4.2 上下文长度与训练长度的校验
cpp
const int trained_context_size = llama_model_n_ctx_train(model);
if (n_ctx > trained_context_size) {
LOGw("... Model was trained with only %d context size! ...");
}
读模型的训练长度 ,如果我们要用的 n_ctx 比它大,打个警告。
📌 为什么只是警告而不是报错?
因为技术上能跑 (KV cache 可以开更大),只是超出部分模型"没见过" ,
输出质量可能下降。所以是"提醒你注意",不是"禁止"。
这解释了 ch14 练习 4 为什么要展示"训练上下文长度"------
它是判断"这个模型适不适合我的长文本需求"的依据。
15.4.3 其他参数
| 参数 | 值 | 作用 |
|---|---|---|
n_ctx |
DEFAULT_CONTEXT_SIZE = 8192 |
KV cache 的容量(能记多少 token) |
n_batch |
BATCH_SIZE = 512 |
一次最多提交多少个 token 给 llama_decode |
n_ubatch |
512 | 底层微批大小 |
n_threads |
见上 | 生成(逐 token)时的线程数 |
n_threads_batch |
同 n_threads |
批处理(prompt 处理)时的线程数 |
常量(✅ ai_chat.cpp:32-34):
cpp
constexpr int DEFAULT_CONTEXT_SIZE = 8192;
constexpr int OVERFLOW_HEADROOM = 4;
constexpr int BATCH_SIZE = 512;
15.5 KV cache 占多少内存(动手算)
15.5.1 先搞懂:KV cache 是什么、为什么要会算它
🧩 这是什么(大白话) :
KV cache(键值缓存)是模型"聊天时用来记笔记"的一块内存。
你跟 AI 一来一回,它不可能每吐一个字就把前面所有对话从头再算一遍 ------
那既慢又费电。所以它把"前面说过的每个词,和上下文是什么关系"
先算好、存起来,新词来了直接查笔记。这块存笔记的地方就是 KV cache。
💡 类比 :KV cache 像你随身带的小笔记本 。
聊天时你把对方刚说的要点随手记在本子上,免得他问第二遍你还得从头回忆。
本子越大(
n_ctx越大),能记下的聊天历史越长、越不容易"忘事";但本子越大,塞进书包占的地方也越大(内存)。
🧩 为什么必须会算它多大?
模型权重 + KV cache 才是这个 App 真正吃的内存 。很多人只看模型文件 2GB 就以为占 2GB,
实际加上 KV cache 可能再涨几百 MB 到几 GB。
手机内存不够时,要么加载直接失败,要么系统把 App 当后台杀掉。
会估算 KV cache,你就能反过来选
n_ctx:内存小就用 4096,内存大才上 8192。
📝 先体会一下数量级(需自行验证,精确公式见 15.5.2):
假设每层 KV 数据大约占 0.1 MB,一个 32 层的模型、n_ctx = 8192:
32 层 × 0.1 MB × (8192 / 1024) ≈ 256 MB
真实公式下面更精细,但思路一模一样 :层数 × 每层每 token 成本 × token 数。
工程真身就是 15.4 里那句 ctx_params.n_ctx = DEFAULT_CONTEXT_SIZE------
调这个值,KV cache 就跟着线性变。
15.5.2 公式
KV cache 字节数 = 2 × n_layer × n_ctx × n_embd_kv × bytes_per_element
↑ ↑ ↑
K 和 V 上下文长度 KV 的隐藏维度
其中:
n_embd_kv = n_head_kv × head_dim
2------ 每个 token 要存 K(Key) 和 V(Value) 两份;n_layer------ 每一层都有自己的一份 KV;bytes_per_element------ 精度,f16 是 2 字节(本工程默认)。
🧩 K 和 V 到底是什么?(0 基础一句话)
你不用深究数学,只要知道:模型读每个词时,会给它算两组小数 ------
K 像"这个词的问题/身份 标签",V 像"这个词的内容 "。
往后读新词时,模型用 K 去"问"前面每个词"你和我相关吗",再把相关的 V 加权拼起来。
K 和 V 都要存下来,这就是公式开头乘 2 的原因。 具体数学见 ch18/ch19,这里只需记账。
15.5.3 例子一:7B 模型(MHA,无 GQA)
参数(典型 Llama-7B):
| 参数 | 值 |
|---|---|
n_layer |
32 |
n_head_kv |
32(等于 attention 头数,即 MHA) |
head_dim |
128 |
n_ctx |
8192 |
| 精度 | f16(2 字节) |
计算:
n_embd_kv = 32 × 128 = 4096
KV = 2 × 32 × 8192 × 4096 × 2 字节
= 4,294,967,296 字节
≈ 4 GB
15.5.4 例子二:8B 模型(GQA)
现代模型(如 Llama-3-8B)用了 GQA(Grouped Query Attention) ------
多个 query 头共享 同一组 KV 头,于是 n_head_kv 大幅减少:
| 参数 | 值 |
|---|---|
n_layer |
32 |
n_head_kv |
8(而 attention 头是 32) |
head_dim |
128 |
n_ctx |
8192 |
计算:
n_embd_kv = 8 × 128 = 1024
KV = 2 × 32 × 8192 × 1024 × 2 字节
= 1,073,741,824 字节
≈ 1 GB
GQA 让 KV cache 缩小了 4 倍(32 → 8)------这就是它被广泛采用的原因。
15.5.5 结论表
| 模型 | n_ctx |
KV cache(f16) |
|---|---|---|
| 7B MHA | 8192 | 约 4 GB |
| 8B GQA(n_head_kv=8) | 8192 | 约 1 GB |
| 同上 | 4096 | 约 0.5 GB |
| 同上 | 2048 | 约 0.25 GB |
⚠️ 结论:
n_ctx和 KV cache 是线性关系。想省内存?降
n_ctx(代价是"聊更短就开始忘事")。这也是 ch18 要讲"上下文移窗"的背景------窗口满了必须腾地方。
📌 怎么查自己的模型这些参数?用 ch14 的
gguf_header_reader.py,或者看本工程解析出的
DimensionsInfo.blockCount(层数)和AttentionInfo.headCountKv(KV 头数)。
15.6 new_sampler:请一位"选菜师"
✅ 取自本工程 ------ ai_chat.cpp:137-143
cpp
static common_sampler *new_sampler() {
common_params_sampling sparams;
sparams.temp = g_sampler_temp;
sparams.top_p = g_sampler_top_p;
sparams.top_k = g_sampler_top_k;
return common_sampler_init(g_model, sparams);
}
它读取三个全局采样参数 (✅ :46-49):
cpp
// F3:采样参数(由 Kotlin 设置页经 setSamplingParams 注入;prepare 建 sampler 时读取)
static float g_sampler_temp = DEFAULT_SAMPLER_TEMP; // 0.3f,与 AppSettings.DEFAULT_TEMPERATURE 对齐
static float g_sampler_top_p = 0.9f; // 与 AppSettings.DEFAULT_TOP_P 对齐
static int32_t g_sampler_top_k = 40; // 与 AppSettings.DEFAULT_TOP_K 对齐
📌 注意注释里的"与 AppSettings.XXX 对齐 "------
这是 ch01 讲过的"跨语言常量要互相标注"。Kotlin 侧默认值
(
AppSettings.kt:62-64)和这里是同一组数,改了必须同步。
采样器的详细用法(temperature / top-k / top-p)见 ch19。
15.7 prepare:一次建齐 + 失败清理
✅ 取自本工程、已编译验证 ------ ai_chat.cpp:145-171
cpp
extern "C"
JNIEXPORT jint JNICALL
Java_com_arm_aichat_internal_InferenceEngineImpl_prepare(JNIEnv * /*env*/, jobject /*unused*/) {
std::lock_guard<std::mutex> lock(g_llama_mutex);
auto *context = init_context(g_model);
if (!context) { return 1; }
g_context = context;
g_batch = llama_batch_init(BATCH_SIZE, 0, 1);
g_chat_templates = common_chat_templates_init(g_model, "");
g_sampler = new_sampler();
if (!g_sampler) {
// 失败路径必须把已经取得的资源全部归还,否则会留下:
// - 泄漏的 g_model(此前的实现就漏了它,下次 load 直接覆盖指针 → 永久泄漏)
// - 已 free 但仍指向旧内存的 g_batch(下次 unload 会二次 free / use-after-free)
LOGe("%s: Failed to create sampler", __func__);
common_chat_templates_ptr().swap(g_chat_templates);
llama_batch_free(g_batch);
g_batch = llama_batch{}; // 已释放,清零避免二次 free
llama_free(g_context);
g_context = nullptr;
llama_model_free(g_model);
g_model = nullptr;
return 2;
}
return 0;
}
15.7.1 成功路径(四步建齐)
| 顺序 | 创建 | 依赖 |
|---|---|---|
| 1 | g_context ← init_context(g_model) |
需要 g_model |
| 2 | g_batch ← llama_batch_init(BATCH_SIZE, 0, 1) |
独立 |
| 3 | g_chat_templates ← common_chat_templates_init(g_model, "") |
需要 g_model |
| 4 | g_sampler ← new_sampler() |
需要 g_model + 采样参数 |
15.7.2 失败路径(本节的精华)
注释直接点出了两个历史 bug:
失败路径必须把已经取得的资源全部归还,否则会留下:
- 泄漏的
g_model(此前的实现就漏了它,下次load直接覆盖指针 → 永久泄漏)- 已 free 但仍指向旧内存的
g_batch(下次unload会二次 free / use-after-free)
逐条看修复:
bug ①:g_model 泄漏
cpp
llama_model_free(g_model); // ← 补上了这一句
g_model = nullptr;
为什么"下次 load 会覆盖指针 = 永久泄漏"?
因为 g_model 是个全局指针。如果这里不释放它,
下一次 load 会执行 g_model = model;(ai_chat.cpp:102)------
旧指针就被覆盖了,那块 GB 级内存永远找不回来了。
bug ②:g_batch 二次 free
cpp
llama_batch_free(g_batch);
g_batch = llama_batch{}; // 已释放,清零避免二次 free
为什么必须清零?
因为 g_batch 是个结构体(不是指针) 。
llama_batch_free 释放的是它内部 的指针成员,但结构体本身还在,
那些成员仍然指向已释放的内存。
如果不清零,下一次 unload 里的 llama_batch_free(g_batch) 会对
同一块内存再释放一次 → double free(C 运行时会检测到并 abort,或悄悄破坏堆)。
15.7.3 释放顺序
注意顺序是逆着创建顺序:
创建:context → batch → templates → sampler
释放:templates → batch → context → model
📌 "后创建的先释放" 是资源管理的通用原则(栈式)。
因为后面的资源往往依赖前面的------反着来最安全。
顺便注意
common_chat_templates_ptr().swap(g_chat_templates)这一句:
g_chat_templates是个智能指针 (common_chat_templates_ptr),用
swap到一个临时对象再让它析构,等价于"手动提前释放"。
🏗️ 为什么"后建的先拆"?拆房子的类比释放顺序为什么必须反过来?想象拆一栋房子:
你得先拆室内装修(context:挂画、家具、灯),再拆房子主体(model) 。
如果反过来------先把房子主体拆了,装修就悬在空中 ,再去拆装修必然塌。
代码里也一样:
context是"建在 model 之上"的(llama_init_from_model用了 model),context 内部拿着 model 的引用。先
llama_model_free(model)再llama_free(context),等于 context 手里攥着一块已经被系统收走的内存,再 free 它就是灾难。
💥 "double free(二次释放)"到底是什么?
C++ 里
free/llama_free是把一块内存"还给操作系统"。第一次还完,那块内存就不归你了------操作系统随时可能把它分给别的代码用 。
如果你又 free 一次 ,等于要求系统"把别人正在用的那块内存再还一次"------
第一次释放后指针没清零、第二次又拿旧地址去释放,就是 double free。
后果:要么 C 运行时直接
abort()崩溃;要么更糟------悄悄把别人的内存弄坏 ,让你在一个八竿子打不着的地方随机崩溃,极难查。
📝 用最小例子看"正确顺序"和"错误顺序"差在哪(需自行验证):
cpp// ✅ 正确:先拆装修,再拆主体 llama_free(context); // 先释放 context llama_model_free(model); // 再释放 model // ❌ 错误:先拆主体 llama_model_free(model); // model 内存已归还 llama_free(context); // context 还指着 model → 野指针 → double free / 崩工程真身就是
unload(:857-867)和prepare失败路径(:160-167)里那组逆序
free------顺序和"创建顺序"严格相反。
15.7.4 返回码
| 返回 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | init_context 失败 |
| 2 | g_sampler 创建失败 |
Kotlin 侧统一转成异常(IOException("Failed to prepare resources"))。
15.8 unload:释放 + 清零
15.8.1 代码
✅ 取自本工程、已编译验证 ------ ai_chat.cpp:847-868
cpp
extern "C"
JNIEXPORT void JNICALL
Java_com_arm_aichat_internal_InferenceEngineImpl_unload(JNIEnv * /*unused*/, jobject /*unused*/) {
std::lock_guard<std::mutex> lock(g_llama_mutex);
// Reset long-term & short-term states
reset_long_term_states();
reset_short_term_states();
// Free up resources
common_sampler_free(g_sampler);
g_sampler = nullptr;
g_chat_templates.reset();
llama_batch_free(g_batch);
// 关键:释放后清零。Kotlin 侧 destroy() 在异常态会走 `unload(); shutdown();`,
// 若 g_batch 仍保留已释放的结构,二次 llama_batch_free 就是 double free。
g_batch = llama_batch{};
llama_free(g_context);
g_context = nullptr;
llama_model_free(g_model);
g_model = nullptr;
}
15.8.2 核心原则:释放后立刻清零
看这四组"释放 + 置空":
cpp
common_sampler_free(g_sampler); g_sampler = nullptr;
g_chat_templates.reset(); // (智能指针 reset 即为置空)
llama_batch_free(g_batch); g_batch = llama_batch{};
llama_free(g_context); g_context = nullptr;
llama_model_free(g_model); g_model = nullptr;
注释(:861-862)解释了为什么必须清零:
Kotlin 侧
destroy()在异常态会走unload(); shutdown();,若
g_batch仍保留已释放的结构,二次llama_batch_free就是 double free。
也就是说:unload 可能被调用两次 (正常路径一次、异常路径又走一次),
清零让第二次调用变成"安全的 no-op"。
🔬 跟着内存走一遍(具体输入 → 逐步执行)
假设 App 在"模型已加载"时出了异常,
destroy()走异常分支,连续调了两次unload:
第一次 unload: llama_batch_free(g_batch) // 把 g_batch 内部那两个数组指针 free 掉 g_batch = llama_batch{}; // 清零:成员指针全变 null llama_free(g_context); g_context = nullptr; llama_model_free(g_model); g_model = nullptr; 第二次 unload(异常路径又来一次): llama_batch_free(g_batch) // g_batch 已是全 0 结构体 → 内部指针全 null // free(null) 是安全空操作,什么也不发生 ✅ llama_free(nullptr); llama_model_free(nullptr); // 同理,安全 no-op对比一下如果当初没写
g_batch = llama_batch{}:第二次
llama_batch_free(g_batch)手里还攥着第一次已经 free 过的旧地址 ------对同一块内存 free 第二次,就是 double free 💥。
这就是"释放后立刻清零"这条铁律的全部意义。
15.8.3 对比 Kotlin 侧的 destroy
✅ 取自本工程 ------ InferenceEngineImpl.kt:323-337
kotlin
override fun destroy() {
_cancelGeneration = true
runBlocking(llamaDispatcher) {
_readyForSystemPrompt = false
when(_state.value) {
is InferenceEngine.State.Uninitialized -> {}
is InferenceEngine.State.Initialized -> shutdown()
else -> { unload(); shutdown() }
}
}
llamaScope.cancel()
// 置空单例:否则后续 getInstance 会拿到已 shutdown 的"僵尸"实例。
// 注意锁对象必须是 Companion,与 getInstance 中 synchronized(this) 的锁一致
synchronized(Companion) { instance = null }
}
注意 else -> { unload(); shutdown() }------
unload 和 shutdown 是两个不同的释放层级:
| 层级 | 释放什么 |
|---|---|
unload() |
模型相关:sampler / templates / batch / context / model |
shutdown() |
backend 全局:llama_backend_free() |
而 is Uninitialized -> {} / is Initialized -> shutdown() 两个分支说明:
如果从来没加载过模型,就只 shutdown backend,不 unload(避免操作未初始化的资源)。
15.9 完整生命周期图
把本章串起来:
┌─────────────────────────────────────────┐
getInstance() ──→│ new InferenceEngineImpl() │
│ init{} → init(nativeLibDir) │
│ → ggml_backend_load_all_from_path │
│ → llama_backend_init │
│ → g_backend_initialized = true │
└────────────────┬────────────────────────┘
│ loadModel(path)
▼
┌─────────────────────────────────────────┐
│ load(path) → g_model │
│ prepare() → g_context │
│ g_batch │
│ g_chat_templates │
│ g_sampler │
│ 状态 → ModelReady │
└────────────────┬────────────────────────┘
│ (使用中:ch16 生成、ch17 模板、ch18 移窗、ch19 采样)
▼
┌─────────────────────────────────────────┐
│ cleanUp() → unload() │
│ 释放 + 全部置空 │
└────────────────┬────────────────────────┘
│ destroy()
▼
┌─────────────────────────────────────────┐
│ shutdown() → llama_backend_free() │
│ g_backend_initialized=false │
│ instance = null │
└─────────────────────────────────────────┘
💡 记住这张图 :本工程所有"加载/卸载"相关的问题,
都可以在这张图上找到对应的环节。
15.10 常见错误与排查
| 现象 / 报错 | 原因 | 解决 |
|---|---|---|
| 加载后立刻崩溃 | 忘了先 init()(backend 没初始化) |
确保状态是 Initialized 再 load |
UnsupportedArchitectureException |
load 返回非 0(模型架构不支持/文件坏) |
换模型或检查文件完整性 |
IOException: Failed to prepare resources |
prepare 返回非 0 |
看 LOGe 日志定位是 context 还是 sampler 失败 |
| 内存占用远超模型文件大小 | 忘了算 KV cache | 用 15.5 的公式估算(7B/8K ≈ 4GB) |
| 换模型几次后内存涨到 OOM | g_model 泄漏(旧指针被覆盖) |
prepare 失败路径要 free g_model(15.7.2) |
| 随机崩溃 + 堆破坏警告 | double free (g_batch 没清零) |
释放后 g_batch = llama_batch{}(15.8.2) |
| backend 重复注册 / 内存持续增长 | init 没做幂等 |
用 g_backend_initialized 判断 |
| 转屏/切后台后模型还在,内存没降 | cleanUp 没被调用(ch10) |
检查 onCleared / onAppBackgrounded |
| 推理很慢 | n_threads 太低,或线程被系统抢 |
看 LOGi("Using %d threads") 实际值 |
| 长文本效果变差 | n_ctx 超过模型训练长度 |
看训练长度警告(15.4.2) |
15.11 动手验证:加载模型并打印信息
15.11.1 看系统信息
本工程在引擎初始化后会打印系统信息(✅ InferenceEngineImpl.kt:133):
kotlin
Log.i(TAG, "Native library loaded! System info: \n${systemInfo()}")
systemInfo() 对应 ai_chat.cpp:205-209 的 llama_print_system_info()。
用 logcat 过滤 InferenceEngineImpl 即可看到:
InferenceEngineImpl: Native library loaded! System info:
AVX = 0 | AVX2 = 0 | ... | NEON = 1 | ...
看什么 :NEON 是否为 1(ARM 的 SIMD 指令集,为 1 说明用上了硬件加速)。
15.11.2 看线程数与加载过程
过滤 ai-chat(native 侧的日志 tag):
ai-chat: init_context: Using 4 threads
ai-chat: Init context size: 8192
ai-chat: Model loaded!
看什么:实际用了几个线程(对照 15.4.1 的算法)。
15.11.3 观察内存
用 Android Studio 的 Profiler(Memory 面板),或:
bash
adb shell dumpsys meminfo work.ai4easy.offlineai | findstr /i "TOTAL Pss"
分别记录:加载前 、加载一个小模型后 、加载一个大模型后 。
你会看到内存跃升------那多出来的部分里,有相当一块是 KV cache(不是模型文件大小)。
15.11.4 验证"释放后清零"的价值
这是一个推演实验:把 unload 里的 g_batch = llama_batch{} 注释掉,
然后思考:什么情况下会崩溃?
答案 :destroy() 在异常态 会走 unload(); shutdown();------
如果 unload 被调两次,第二次的 llama_batch_free(g_batch) 就是 double free。
(正常情况下只看一次 unload,所以这个 bug 很隐蔽。)
15.11.5 如果日志/内存数字对不上,按顺序排查
- logcat 里根本没有
ai-chat的日志 :过滤 tag 写错了。
原生日志 tag 是ai-chat(不是llama也不是AI Chat),
或者先不过滤 tag、直接搜Using 4 threads/Model loaded!这两句关键字。 NEON = 0:这是在 x86_64 模拟器 上跑的正常现象(模拟器没有 ARM NEON 指令)。
想看硬件加速,请用真机;模拟器上能验证"流程跑通"就够了。adb shell dumpsys meminfo ... | findstr没输出 :命令里包名要和工程一致
(本工程是work.ai4easy.offlineai),确认 App 真的在跑;
或者直接用 Android Studio 的 Profiler → Memory 图形界面看曲线,更直观。- 加载小模型后内存只涨一点点 :正常------小模型 + 小 KV cache 本来就小。
想看到"KV cache 占一大块"的效果,换个大一点的模型、或把n_ctx调大再对比。 - 做了 15.11.4 的推演实验 :那是"想一想",不用真的改代码去崩溃 ;
真要验证 double free,改完记得把g_batch = llama_batch{}恢复。
15.12 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| 四个对象 | model(权重)/ context(KV cache)/ batch / sampler | :40-44 |
| backend 幂等初始化 | 已初始化就跳过 | g_backend_initialized(:60) |
init/shutdown 配对 |
两边都幂等 | :67-70、:876-879 |
load |
只读权重(mmap) | :97 |
llama_model_load_from_file |
失败返回 nullptr | :99-101 |
init_context |
建 KV cache + 计算缓冲 | :106-135 |
| 线程数 | 核数 − 2,夹在 [2, 4] |
:113-115 |
| 训练长度校验 | n_ctx > 训练长度 时警告 |
:120-124 |
n_ctx / n_batch |
8192 / 512 | :32-34 |
| KV cache 公式 | 2 × n_layer × n_ctx × (n_head_kv×head_dim) × 字节 |
7B/8K ≈ 4GB、GQA 8B/8K ≈ 1GB |
| GQA | 多 query 头共享 KV 头,KV 省数倍 | n_head_kv 8 vs 32 |
new_sampler |
读 g_sampler_* 建采样器 |
:137-143 |
prepare |
四步建齐 | :145-171 |
| 失败路径清理 | 全部归还(含 g_model!) |
:156-169 |
| 释放后清零 | 避免 double free | g_batch = llama_batch{}(:863) |
| 逆序释放 | 后创建的先释放 | :857-867 |
destroy |
unload + shutdown + 置空单例 |
:323-337 |
15.13 本章你学会了什么
逐条自测。任何一条打不了勾,回到对应小节再看一遍。
- 我能说出加载模型创建的四个对象及各自作用。
- 我能画出
loadModel → load → prepare → init_context/new_sampler的调用链。 - 我知道
load和prepare为什么要分开。 - 我能解释 mmap 是什么,以及它为什么让"加载看起来很快"。
- 我知道线程数是怎么算出来的,以及为什么留 2 个核。
- 我能解释"训练长度校验"为什么只是警告而不是报错。
- 我能独立算出某个模型在给定
n_ctx下的 KV cache 大小。 - 我知道 GQA 能显著减小 KV cache。
- 我能解释
g_backend_initialized存在的理由。 - 我能复述"
g_model被指针覆盖导致永久泄漏"这个 bug。 - 我能复述"
g_batch不清零导致 double free"这个 bug。 - 我理解
prepare失败路径为什么要逆序释放全部资源。 - 我知道
unload和shutdown是两个不同层级的释放。
15.14 练习
练习 1:算 KV cache(动手)
一个模型:n_layer = 28、n_head_kv = 4、head_dim = 128、n_ctx = 8192、f16。
它的 KV cache 大约多大?如果 n_ctx 降到 4096 呢?
解题思路提示
先算 n_embd_kv = n_head_kv × head_dim,再代入公式
2 × n_layer × n_ctx × n_embd_kv × 2 字节。
参考答案要点
n_ctx = 8192:
n_embd_kv = 4 × 128 = 512
KV = 2 × 28 × 8192 × 512 × 2 字节
= 2×28 = 56
= 56 × 8192 = 458,752
= 458,752 × 512 = 234,881,024
= 234,881,024 × 2 = 469,762,048 字节
≈ 448 MB ≈ 0.44 GB
n_ctx = 4096 :线性减半 → 约 224 MB。
要点:
n_head_kv = 4很小(强 GQA),所以即使 28 层、8K 上下文也只有 0.44GB;n_ctx与 KV 是严格线性关系------降一半就省一半。- 对比 15.5.3 的 7B MHA(
n_head_kv = 32):同样 8K,前者 4GB、这个只有 0.44GB,
差了约 9 倍(32/4 = 8 倍来自 GQA,层数略有差异)。
实用结论:
挑模型时,除了看参数量和量化,还要看
n_head_kv------它决定了 KV cache 大小,进而决定"能不能开长上下文"。
这就是 GQA 在端侧特别重要的原因。
练习 2:解释"释放后清零"为什么能防 double free
以 g_batch 为例,说明不清零时会怎样,以及清零后为什么安全。
解题思路提示
llama_batch 是结构体还是指针?llama_batch_free 释放的是它的哪部分?
g_batch = llama_batch{} 把成员变成了什么?
参考答案要点
g_batch是结构体 (llama_batch),不是指针。它内部有指向堆内存的指针成员
(token 数组、logits 数组等)。llama_batch_free(g_batch)释放的是指针成员指向的堆内存 ,
但结构体里的那些成员仍然保存着旧地址 (g_batch变量本身还在)。- 不清零 :如果
unload被调用第二次,llama_batch_free(g_batch)会拿这些
旧地址再释放一次 → double free 。- C 运行时(glibc 等)有检测机制,可能直接
abort(); - 或者悄悄破坏堆结构,导致之后某个无关地方崩溃(更难查)。
- C 运行时(glibc 等)有检测机制,可能直接
- 清零后 :
g_batch = llama_batch{}把g_batch的所有成员归零 ,
第二次llama_batch_free释放的是全 null 的指针 ------
释放 null 是安全的 no-op。 - 什么时候会调用两次? 注释(
:861-862)说得清楚:
Kotlin 侧destroy()在异常态 会走unload(); shutdown();。 - 通用原则 : 释放资源后,立刻把句柄置空/置无效。
这样"重复释放"就变成"安全的空操作",而不是崩溃。
这个原则在本书出现多次:
- ch10:
cleanupScope.cancel()后置空; - ch12:
pendingFlush?.cancel(); pendingFlush = null; - ch15:所有 native 指针释放后置
nullptr。
- ch10:
练习 3:prepare 失败路径为什么必须释放 g_model
如果 g_sampler 创建失败时不释放 g_model,会发生什么?为什么说这是"永久泄漏"?
解题思路提示
g_model 是全局指针。下一次调用 load 时它会被怎样处理?
那块旧内存还能被找到吗?
参考答案要点
-
g_model是全局指针 ,指向llama_model_load_from_file返回的那块内存(GB 级)。 -
如果
prepare失败时不释放 它,g_model仍然指向那块内存。 -
用户重试加载时,Kotlin 侧再调
load(),ai_chat.cpp:102执行:cppg_model = model; // ← 旧指针被直接覆盖! -
覆盖之后,旧的那块内存的地址就永远丢失了 ------
没有任何变量还记着它,程序再也无法free它。
这就是"永久泄漏"(直到进程退出)。 -
而且它是 GB 级的------重试几次就 OOM。
-
本工程的修复 (
:166-167):cppllama_model_free(g_model); g_model = nullptr; // 顺便置空,下次判断更安全 -
注释里明确说这是"此前的实现就漏了它"------真实修过的 bug。
更深一层的原因:
prepare失败的直接原因 是 sampler 建不起来,用户会重试;- 而"重试"正是触发这个泄漏的条件。
- 所以失败路径的资源清理,重要性不亚于成功路径 ------
因为失败往往意味着"用户还会再来一次"。
推广 :任何"初始化到一半失败"的场景,都要问:
已经拿到的资源,都在失败路径里归还了吗?
练习 4:为什么线程数上限是 4
本工程把 n_threads 限制在 [2, 4]。请从"手机这个特定环境"的角度解释,
而不是从"CPU 一般规律"的角度。
解题思路提示
手机和服务器有什么不同?推理的瓶颈通常是什么(算力还是内存带宽)?
如果 App 把 CPU 全占了,用户的体验会怎样?
参考答案要点
原因一:推理瓶颈常在内存带宽,不在核心数
大模型的每步计算都要把大量权重从内存搬到 CPU。
当线程多到一定程度,内存带宽被抢满 ,
再加线程收益极小而开销上升(线程调度、同步)。
原因二:手机要和 UI 抢 CPU
手机是交互设备。如果推理把 8 个核全占了:
- 界面滑动卡顿;
- 系统可能降频(发热);
- 用户会觉得"这个 App 让手机变卡了"。
本工程用N_THREADS_HEADROOM = 2主动留 2 个核给系统和 UI。
原因三:发热与续航
手机散热能力远不如服务器。长时间满负载会触发热降频 ,
最终速度可能还不如少开几个线程("跑得慢但稳")。
原因四:收益递减
从 1→2 线程接近翻倍,2→4 有明显提升,4→8 提升很小。
N_THREADS_MAX = 4 是在"性能 vs 体验"之间取的平衡点。
本工程的取舍总结:
| 目标 | 手段 |
|---|---|
| 别让 UI 卡 | 留 2 个核(HEADROOM) |
| 别过度并行 | 上限 4(MAX) |
| 别低于最低可用 | 下限 2(MIN) |
延伸思考 :
这个"上限 4"是写死在编译期的 (constexpr)。
更精细的做法是按设备动态调整(旗舰机可以多开,低端机少开),
甚至根据温度回退。本工程选了"简单且够用"的方案------
这又是一次"够了就好"的务实决策。
练习 5:画出"加载 → 使用 → 卸载"的完整生命周期
不看 15.9 的图,凭记忆画出从 getInstance() 到 destroy() 的完整流程,
标注每一步创建的/释放的资源。
解题思路提示
四段:① 引擎创建(init backend)→ ② 加载(load + prepare)→
③ 使用(ch16--ch19 的内容)→ ④ 卸载(cleanUp → unload / destroy → shutdown)。
注意 ④ 有两种粒度。
参考答案要点
① getInstance()
├─ new InferenceEngineImpl()
├─ init{} → System.loadLibrary("ai-chat")
├─ init(nativeLibDir)
│ ├─ ggml_backend_load_all_from_path(dir)
│ ├─ llama_backend_init()
│ └─ g_backend_initialized = true
└─ 状态: Uninitialized → Initializing → Initialized
② loadModel(path)
├─ 文件检查(存在/是文件/可读)
├─ load(path) → g_model 创建 【消费 GB 级】
├─ prepare()
│ ├─ init_context → g_context 【KV cache,最大的一块】
│ ├─ llama_batch_init → g_batch
│ ├─ common_chat_templates_init → g_chat_templates
│ └─ new_sampler → g_sampler
└─ 状态: LoadingModel → ModelReady
③ 使用中(ch16 生成 / ch17 模板 / ch18 移窗 / ch19 采样)
setSystemPrompt / sendUserPrompt / generateNextToken ...
④ 释放(两种粒度)
├─ cleanUp() [切模型 / 切后台 / onCleared]
│ └─ unload() → 释放 sampler/templates/batch/context/model 并全部置空
│ 状态 → Initialized
└─ destroy() [引擎彻底销毁]
├─ unload() + shutdown() 或 仅 shutdown()
├─ shutdown() → llama_backend_free(); g_backend_initialized = false
├─ llamaScope.cancel()
└─ instance = null
关键检查点:
- ② 里哪一步失败都要清理(
prepare失败路径,15.7.2); - ④ 里
unload与shutdown是两个层级(模型 vs backend); - 所有释放都要置空(防 double free);
- 顺序是逆序(后创建的先释放)。
这张图的价值 :遇到任何"内存/崩溃"问题,
先在这张图上定位"是哪一步的状态不对"------
比盲目看代码高效得多。这正是 ch22 诊断方法论的基础。
15.15 自测题(附答案)
难度递进说明:第 1--4 题是基础题 (判断对错);
第 5--7 题是应用题 (选择,KV cache 比例、释放顺序);
第 8--10 题是综合题 (两个 C++ 事故是本章核心,要能讲出"为什么")。
⚠️ 第 6 题 KV cache 计算务必自己动手算一遍 ,它决定"你的手机能跑多大模型"。
建议先合上书写一遍,再翻答案对。
一、判断对错
- "加载一个模型"只会创建一个对象。
n_ctx越大越好。unload释放完资源后,必须把指针置空。prepare失败时不用管,反正下次load会覆盖。
二、选择
- KV cache 的大小与什么成正比 ?
A.n_ctx(上下文长度) B. 模型文件体积 C. 手机内存 D. 无关 - 7B 模型(MHA)在 8K 上下文下,KV cache 大约多大?
A. 0.5 GB B. 1 GB C. 约 4 GB D. 8 GB - 释放资源的顺序应该是?
A. 按创建顺序 B. 逆序(后创建的先释放) C. 随意 D. 先释放 model
三、简答
- 为什么
prepare失败路径必须 释放g_model?
(提示:下一次load会做什么?) - 为什么
llama_batch_free(g_batch)之后还要写g_batch = llama_batch{}? - 为什么
n_threads的上限设成 4?
答案与解析
一、判断
- ❌ 错 。会创建四个 :
g_model(权重)、g_context(KV cache + 缓冲)、
g_batch(批容器)、g_sampler(采样器)+g_chat_templates(模板)(15.1.1)。 - ❌ 错 。
n_ctx越大,KV cache 线性增长 (7B/8K 就要 4 GB)。
而且超过模型的训练长度只会打警告(模型"没见过"那么长的上下文)(15.4.2、15.5)。 - ✅ 对 。释放后置空,能让"重复释放"变成安全的 no-op(防 double free)(15.8.2)。
- ❌ 错 。第一次失败不清理,
g_model会泄漏 ------
而且下次load会直接覆盖指针 ,那块 GB 级内存永远找不回来(15.7.2)。
二、选择
- A 。
KV = 2 × n_layer × n_ctx × (n_head_kv × head_dim) × 字节------
与n_ctx是严格线性关系(15.5.2)。 - C 。算一遍:
2 × 32(层) × 8192(ctx) × 4096(kv维度) × 2 字节 = 4 GB(15.5.3)。
⚠️ 这就是为什么 8 GB 内存的手机跑 7B 很勉强。
(注意:GQA 模型如 Llama-3-8B 只要约 1 GB,因为n_head_kv = 8。) - B 。逆序(后创建的先释放)------因为后面的资源往往依赖前面的(15.7.3)。
三、简答(要点)
- 因为
g_model是全局指针 。如果失败时不释放,
下一次调用load()会执行g_model = model;------旧指针被覆盖 ,
那块 GB 级内存再也没人能找到、再也无法释放 (永久泄漏 )。
而prepare失败恰恰意味着"用户会重试",所以这条路径一定会被走到(15.7.2)。 - 因为
g_batch是结构体 (不是指针):
llama_batch_free释放的是它内部的指针成员 ,而结构体本身还留着旧地址 。
如果unload被调用两次(destroy()在异常态会走unload(); shutdown();),
第二次free就是对同一块内存再释放一次 → double free 。
赋成空结构体后,第二次释放的是 null,是安全操作(15.8.2)。 - 手机不是服务器(15.4.1):
① 推理瓶颈常在内存带宽 而非核心数,线程太多收益极小;
② 要把 CPU 留给系统和 UI (本工程还主动留 2 个核:N_THREADS_HEADROOM);
③ 手机散热有限 ,长时间满负载会热降频;
④ 收益递减:1→2 接近翻倍,2→4 明显,4→8 提升很小。
评分建议 :第 6 题(KV cache 计算)和第 8/9 题(两个 C++ 事故)是本章核心。
第 6 题一定要会算------它决定"你的手机能跑多大的模型"。
下一章 :ch16 推理主循环:解码、采样、流式吐字。
这一章你把模型装进了内存。下一章看"怎么让它开始说话 ":
prefill 与 decode 的区别、batch 与 position、
什么时候停(EOG / 长度上限)、
以及"多字节字符被切断怎么办"(UTF-8 缓存)。
那是整个 App 最核心的一段代码。