ch15 加载模型与初始化上下文

学习目标

读完本章,你应当能够:

  1. 说清"加载一个模型"在 native 层到底创建了哪四个对象(model / context / batch / sampler);
  2. 看懂 loadinit_contextnew_sampler 的调用链,以及 prepare 在其中的位置;
  3. 理解 llama_context_paramsn_ctx / n_batch / 线程数各自控制什么;
  4. 动手算n_ctx = 8192 时 KV cache 约占多少内存(并知道 GQA 能省几倍);
  5. 知道"backend 幂等初始化"为什么需要(g_backend_initialized 标志);
  6. 复述本章的一个真实事故:unload 后不把 g_batch 清零 → 二次释放(double free)
  7. 说清 prepare 失败路径为什么要"把所有已取得的资源全部归还"。

前置 :ch14(GGUF 格式)、ch13(JNI)

对应源码

  • lib/src/main/cpp/ai_chat.cpp(882 行;本章重点 load 87--104、init_context 106--135、
    new_sampler 137--143、prepare 145--171、unload 847--868、shutdown 870--882、
    init 62--85、全局状态 40--49、常量 28--35)

  • lib/.../internal/InferenceEngineImpl.ktloadModel 145--179、init 122--140)
    🧭 读本章前,请先确认

  • 前置章节(为什么需要)

    • ch14(GGUF) :本章读的 .gguf 文件里"有什么"上一章刚拆过------
      不知道"元数据和张量"的区别,就读不懂 load 到底把哪几 GB 读进了内存。
    • ch13(JNI) :本章看的全是 ai_chat.cpp 里的 C++ 代码,
      指针、nullptrlock_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,也不会自己"想问题"。

本章要做的事,用一句话说就是:

把这个"睡着"的模型从磁盘搬进内存、把它叫醒,再给它摆一张"工作台"。

拆开是两步:

  1. 加载模型 = 叫醒大脑llama.cpp 打开 .gguf 文件,读它的头、
    把里面那几 GB 的权重数据放到内存里,最后交给你一个 llama_model * 指针。
    这个 llama_model 就是模型的**"大脑"**------它装着全部"知识"(训练好的权重数字)。
    一个大脑只需要叫醒一次,叫醒之后它一直知道"自己知道什么"。
  2. 初始化上下文 = 摆工作台 。光有大脑还不够:大脑干活时需要一张草稿纸
    用来记"刚才聊到哪了、前面说过哪些词"------这就是 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 侧的三层检查

  1. 状态检查 :必须是 Initialized(native 库已加载);
  2. 文件检查:存在 / 是文件 / 可读;
  3. 返回码检查loadprepare 返回非 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 initshutdown 必须配对

✅ 取自本工程 ------ 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_paramsload_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 == nullptrreturn 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 为什么 loadprepare 分开

因为 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 = 4096 vs 8192:KV cache 正好差一倍(线性关系,见 15.5);
  • n_batch = 128 vs 512:一句 1000 token 的话,前者要喂 8 趟、后者只喂 2 趟,
    prefill 阶段体感差好几倍;
  • n_threads = 2 vs 4:逐字吐字速度大约 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_contextinit_context(g_model) 需要 g_model
2 g_batchllama_batch_init(BATCH_SIZE, 0, 1) 独立
3 g_chat_templatescommon_chat_templates_init(g_model, "") 需要 g_model
4 g_samplernew_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() }------

unloadshutdown 是两个不同的释放层级

层级 释放什么
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 freeg_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-209llama_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 如果日志/内存数字对不上,按顺序排查

  1. logcat 里根本没有 ai-chat 的日志 :过滤 tag 写错了。
    原生日志 tag 是 ai-chat(不是 llama 也不是 AI Chat),
    或者先不过滤 tag、直接搜 Using 4 threads / Model loaded! 这两句关键字。
  2. NEON = 0 :这是在 x86_64 模拟器 上跑的正常现象(模拟器没有 ARM NEON 指令)。
    想看硬件加速,请用真机;模拟器上能验证"流程跑通"就够了。
  3. adb shell dumpsys meminfo ... | findstr 没输出 :命令里包名要和工程一致
    (本工程是 work.ai4easy.offlineai),确认 App 真的在跑;
    或者直接用 Android Studio 的 Profiler → Memory 图形界面看曲线,更直观。
  4. 加载小模型后内存只涨一点点 :正常------小模型 + 小 KV cache 本来就小。
    想看到"KV cache 占一大块"的效果,换个大一点的模型、或把 n_ctx 调大再对比。
  5. 做了 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 的调用链。
  • 我知道 loadprepare 为什么要分开。
  • 我能解释 mmap 是什么,以及它为什么让"加载看起来很快"。
  • 我知道线程数是怎么算出来的,以及为什么留 2 个核。
  • 我能解释"训练长度校验"为什么只是警告而不是报错。
  • 我能独立算出某个模型在给定 n_ctx 下的 KV cache 大小。
  • 我知道 GQA 能显著减小 KV cache。
  • 我能解释 g_backend_initialized 存在的理由。
  • 我能复述"g_model 被指针覆盖导致永久泄漏"这个 bug。
  • 我能复述"g_batch 不清零导致 double free"这个 bug。
  • 我理解 prepare 失败路径为什么要逆序释放全部资源。
  • 我知道 unloadshutdown 是两个不同层级的释放。

15.14 练习

练习 1:算 KV cache(动手)

一个模型:n_layer = 28n_head_kv = 4head_dim = 128n_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()
    • 或者悄悄破坏堆结构,导致之后某个无关地方崩溃(更难查)。
  • 清零后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

练习 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 执行:

    cpp 复制代码
    g_model = model;      // ← 旧指针被直接覆盖!
  • 覆盖之后,旧的那块内存的地址就永远丢失了 ------
    没有任何变量还记着它,程序再也无法 free 它。
    这就是"永久泄漏"(直到进程退出)。

  • 而且它是 GB 级的------重试几次就 OOM

  • 本工程的修复:166-167):

    cpp 复制代码
    llama_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);
  • ④ 里 unloadshutdown两个层级(模型 vs backend);
  • 所有释放都要置空(防 double free);
  • 顺序是逆序(后创建的先释放)。

这张图的价值 :遇到任何"内存/崩溃"问题,

先在这张图上定位"是哪一步的状态不对"------

比盲目看代码高效得多。这正是 ch22 诊断方法论的基础。


15.15 自测题(附答案)

难度递进说明:第 1--4 题是基础题 (判断对错);

第 5--7 题是应用题 (选择,KV cache 比例、释放顺序);

第 8--10 题是综合题 (两个 C++ 事故是本章核心,要能讲出"为什么")。

⚠️ 第 6 题 KV cache 计算务必自己动手算一遍 ,它决定"你的手机能跑多大模型"。

建议先合上书写一遍,再翻答案对。

一、判断对错

  1. "加载一个模型"只会创建一个对象。
  2. n_ctx 越大越好。
  3. unload 释放完资源后,必须把指针置空。
  4. prepare 失败时不用管,反正下次 load 会覆盖。

二、选择

  1. KV cache 的大小与什么成正比
    A. n_ctx(上下文长度) B. 模型文件体积 C. 手机内存 D. 无关
  2. 7B 模型(MHA)在 8K 上下文下,KV cache 大约多大?
    A. 0.5 GB B. 1 GB C. 约 4 GB D. 8 GB
  3. 释放资源的顺序应该是?
    A. 按创建顺序 B. 逆序(后创建的先释放) C. 随意 D. 先释放 model

三、简答

  1. 为什么 prepare 失败路径必须 释放 g_model
    (提示:下一次 load 会做什么?)
  2. 为什么 llama_batch_free(g_batch) 之后还要写 g_batch = llama_batch{}
  3. 为什么 n_threads 的上限设成 4

答案与解析

一、判断

  1. 。会创建四个g_model(权重)、g_context(KV cache + 缓冲)、
    g_batch(批容器)、g_sampler(采样器)+ g_chat_templates(模板)(15.1.1)。
  2. n_ctx 越大,KV cache 线性增长 (7B/8K 就要 4 GB)。
    而且超过模型的训练长度只会打警告(模型"没见过"那么长的上下文)(15.4.2、15.5)。
  3. 。释放后置空,能让"重复释放"变成安全的 no-op(防 double free)(15.8.2)。
  4. 。第一次失败不清理,g_model泄漏 ------
    而且下次 load直接覆盖指针 ,那块 GB 级内存永远找不回来(15.7.2)。

二、选择

  1. AKV = 2 × n_layer × n_ctx × (n_head_kv × head_dim) × 字节------
    n_ctx严格线性关系(15.5.2)。
  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。)
  3. B 。逆序(后创建的先释放)------因为后面的资源往往依赖前面的(15.7.3)。

三、简答(要点)

  1. 因为 g_model全局指针 。如果失败时不释放,
    下一次调用 load() 会执行 g_model = model;------旧指针被覆盖
    那块 GB 级内存再也没人能找到、再也无法释放永久泄漏 )。
    prepare 失败恰恰意味着"用户会重试",所以这条路径一定会被走到(15.7.2)。
  2. 因为 g_batch结构体 (不是指针):
    llama_batch_free 释放的是它内部的指针成员 ,而结构体本身还留着旧地址
    如果 unload 被调用两次(destroy() 在异常态会走 unload(); shutdown();),
    第二次 free 就是对同一块内存再释放一次double free
    赋成空结构体后,第二次释放的是 null,是安全操作(15.8.2)。
  3. 手机不是服务器(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 最核心的一段代码。

相关推荐
hai_android1 小时前
Kotlin / Android 常用函数使用示例手册
android·java·kotlin
hai_android3 小时前
Android MeasureSpec 详解
android·java·kotlin
ai2work5 小时前
ch11 持久化:DataStore + Gson 多会话
kotlin
JMchen8 小时前
实战案例:实现120fps流畅的渐变进度条
android·kotlin·canvas
传奇开心果编程1 天前
【Jetpack Compose基础语法学与练】第8课 rememberSaveable,页面旋转/系统重建保留状态
android·学习·ui·kotlin·android jetpack
传奇开心果编程1 天前
【Jetpack Compose基础语法学与练】第7课 if条件渲染 + 列表基础(LazyColumn)
学习·ui·kotlin·android jetpack
ai2work1 天前
ch12 系统能力接入:TTS、剪贴板、分享、文件选择器
kotlin
传奇开心果编程1 天前
【Jetpack Compose基础语法学与练】第9课 SideEffect副作用 + LaunchedEffect,处理异步任务和生命周期
android·学习·ui·kotlin·android jetpack
不要喷香水2 天前
00-总览与架构
kotlin·app·档案管理