正在获取所在地区…
正在读取天气
音乐
暂未播放
llama.cpp 源码完全拆解(四):llama_context 与记忆系统——KV Cache、slot 与批处理如何组织一次解码
前三篇把 llama.cpp 的「静态部分」讲完了。第一篇铺开整张地图:ggml/ 是自包含的张量库,张量是惰性的图节点,后端用「注册表 → 设备 → 缓冲区类型 → 缓冲区」四层句柄描述,ggml_backend_sched 负责把一张图切到多块设备上执行。第二篇把「惰性张量」和「切图」落到真实代码:ggml_tensor/ggml_context/ggml_cgraph 的逐字段含义、ggml_gallocr 如何在图执行前就算出每个中间张量的地址、调度器如何做五 pass 切图。第三篇补上权重的落点:GGUF 的元数据字典和张量信息表如何解析成「张量名 → 文件偏移」的索引,mmap 如何让文件的字节直接成为进程地址空间的一部分,以及 -ngl/-ot 背后的张量放置七步决策。
这三篇反复依赖一个前提,却始终没有正面展开:权重装载完成之后,谁来记住对话的历史? 权重是只读的、大小固定的;而推理过程中每一步都在产生新的 Key 和 Value,它们必须被存下来供后续所有 token 查询。这块存储(KV Cache)的大小随上下文长度线性增长,在多序列服务场景下还会被多个会话共享和争抢;它既要能被高效地随机读写,又要支持「某段历史被删掉」「两条序列共享同一段前缀」「显存不够时把最老的一段挪走」这类操作。第三篇结束时留下的问题正是这个:llama_context 初始化时如何根据权重占用算出 KV 缓存与计算缓冲区的预算?llama_memory_i 这个接口为什么要把「KV 缓存」和「循环状态」抽象成同一种东西?
这一篇回答这些问题。它沿四个文件走完全程:src/llama-context.cpp(约 169 KB,上下文主体与 decode 主循环)、src/llama-kv-cache.cpp(约 99 KB,KV 缓存的分配与存取)、src/llama-kv-cache.h(约 17 KB,类接口与 slot_info 结构)、src/llama-memory.h(约 5 KB,记忆抽象层)、src/llama-batch.h(约 12 KB,llama_ubatch 的结构)。
具体覆盖:
- 上下文初始化:
n_ctx如何被对齐到 256 的倍数、n_ctx_seq与n_seq_max的关系、为什么n_batch在有因果注意力时被n_ctx夹住。 - 记忆抽象:
llama_memory_i的接口为什么长这样,llama_memory_context_i的两阶段(next/apply)设计解决了什么问题。 - KV 缓存的物理布局:为什么每个层一张
[n_embd_k_gqa, kv_size, n_stream]的三维张量,为什么 V 缓存会被转置,n_stream与n_seq_max的区别。 - 单元记账:
llama_kv_cells用什么结构记录每个单元的占用、位置、序列归属,以及「位置集合」这个看起来多余的成员为什么必须存在。 - 一次解码的完整流程:
llama_context::decode从memory_update到init_batch到process_ubatch的骨架,balloc如何把逻辑批次切成 ubatch。 - 槽位分配:
find_slot的环形扫描与「连续槽位」要求,apply_ubatch为什么要回滚(purge)已写位置。 - 掩码构造:
set_input_kq_mask如何在 CPU 上把整个注意力掩码算好,SWA 掩码是怎么在同一个循环里完成的。 - 状态存取:
state_write/state_read如何把 KV 缓存序列化,为什么只写 SWA 窗口内的单元,以及镜像缓存(draft/target)的对齐要求。 - 量化与 SWA 的存储代价:
--cache-type-k/v如何与权重量化共用块布局,-ctk q8_0 -ctv q4_0这类配置到底省了多少。
为什么 KV 缓存不是「一块内存」而是一个子系统
在 vLLM 的讲法里,KV 缓存常被描述成「一堆固定大小的块 + 一张块表」,PagedAttention 用操作系统的分页思想把碎片问题解决了。那套讲法成立的前提是:缓存的管理者可以自由地把任意一段历史放进任意一个块,注意力 kernel 通过块表间接寻址。
llama.cpp 走的是另一条路。它没有块表,也没有页级间接寻址;它维护的是一个扁平的单元数组,每个单元对应一个 token 的位置,层与层之间共享同一套单元编号。这个选择与它的目标场景直接相关:
- 端侧与单机:上下文长度通常在几千到几十万 token 之间,缓存只有几百 MB 到几 GB,碎片化的代价远小于间接寻址带来的 kernel 复杂度。
- CPU 优先:llama.cpp 的主力后端是 CPU,注意力在 CPU 上靠
ggml的通用算子实现。间接寻址意味着每一步都要查表,这在 CPU 上是纯开销。 - 多后端一致:同一份 KV 布局要同时服务于 CPU、CUDA、Metal、Vulkan、SYCL。布局越简单,后端实现越容易对齐。
代价是管理逻辑必须自己承担碎片问题。llama.cpp 的做法是:把整个缓存看成一个环形缓冲区,用一个「头指针」顺序分配,靠 SWA 掩码和位置回滚来回收。这套机制全部集中在本文要讲的四个文件里。
它与站内已讲过的两条路线形成对照。PagedAttention 完全拆解 讲的是 vLLM 用块表 + 写时复制实现按需分配;vAttention 完全拆解:用 CUDA 虚拟内存保住连续 KV Cache 讲的是用 CUDA 虚拟内存把物理页映射成逻辑连续,从而既保住连续布局又不需要预留。llama.cpp 的单元数组更接近后者——它是逻辑连续的,但物理上不做页映射,而是靠环形复用和 SWA 回收把浪费压住。

上图(来源:arXiv 论文《Efficient Memory Management for Large Language Model Serving with PagedAttention》,图 1)是 vLLM 的经典结构:左侧每个序列持有一张逻辑块表,右侧是物理块池。llama.cpp 的选择是把这个间接层拿掉:llama_kv_cache 里每一层只有一张 [n_embd_k_gqa, kv_size, n_stream] 的三维张量,token 在缓存里的位置就是它在张量第二维上的下标,注意力 kernel 直接按这个下标切片。少了一层翻译,代价是分配策略必须更小心。

上图(来源:arXiv 论文《vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention》,图 2)展示了第三条路线:用 CUDA 虚拟内存(cuMemMap)把物理页映射到一段保留的虚拟地址空间上,对 kernel 而言缓存始终是连续的,而对分配器而言物理页可以任意拼接。llama.cpp 既不做块表也不做虚拟映射,它用的是最朴素的一种:预留整块连续空间,用环形复用把「已死」的单元重新利用。这三种方案的取舍会在读完 find_slot 与 apply_ubatch 之后变得很清楚。
上下文初始化:三个数字决定了整块内存的形状
llama_context 的构造函数(src/llama-context.cpp)里,前半段是把 llama_context_params 里的用户输入翻译成内部参数 cparams,后半段才是真正的分配。理解 KV 缓存的大小,只需要盯住三个数字:n_ctx、n_ctx_seq、n_seq_max。它们之间的关系决定了缓存张量的第二维长度。
n_ctx 与 n_ctx_seq:一个缓存,多少条序列
用户通过 llama_context_params.n_ctx 指定上下文长度。构造函数的处理是:
1cparams.n_ctx = params.n_ctx == 0 ? hparams.n_ctx_train : params.n_ctx;n_ctx == 0 表示「用模型训练时的上下文长度」,这个值来自 GGUF 里的 n_ctx_train 元数据。接下来有一个容易被忽略的对齐:
1// ref: https://github.com/ggml-org/llama.cpp/pull/17046#discussion_r25030857322cparams.n_ctx = GGML_PAD(cparams.n_ctx, 256);为什么要对齐到 256 的倍数?因为 KV 缓存是被分成块(block)存储的——权重量化块、K/V 量化块都按 256 个元素一组。如果缓存长度不是 256 的倍数,最后那个不完整的块需要额外的边界处理,而所有的量化读写路径都假定「块的个数是整数」。对齐到 256 让「单元数 = 块数 × 256」这个等式恒成立,量化 kernel 不需要处理尾巴。
然后是序列维。llama.cpp 支持在同一个上下文里跑多条序列(多轮对话、并行采样、beam search 都靠它),n_seq_max 就是上限(编译期常量 LLAMA_MAX_SEQ 为 256)。关键分支在这里:
1if (cparams.kv_unified) {2 cparams.n_ctx_seq = cparams.n_ctx;3} else {4 cparams.n_ctx_seq = cparams.n_ctx / cparams.n_seq_max;5 cparams.n_ctx_seq = GGML_PAD(cparams.n_ctx_seq, 256);6
7 if (cparams.n_ctx_seq == 0) {8 throw std::runtime_error("n_ctx_seq == 0");9 }10
11 if (cparams.n_ctx != cparams.n_ctx_seq * cparams.n_seq_max) {12 cparams.n_ctx = cparams.n_ctx_seq * cparams.n_seq_max;13 LLAMA_LOG_WARN("%s: n_ctx is not divisible by n_seq_max - rounding down to %u\n", __func__, cparams.n_ctx);14 }15}kv_unified == true(默认,即--no-kv-unified的反面):所有序列共享整块缓存,n_ctx_seq = n_ctx。总容量就是n_ctx个单元,谁先用谁占,序列之间没有硬隔离。kv_unified == false(--no-kv-unified):缓存被平分给n_seq_max条序列,每条序列独立持有n_ctx_seq = n_ctx / n_seq_max个单元。
这两种模式的区别会一直影响到 n_stream。在 llama_kv_cache 的构造函数里:
1n_seq_max(n_seq_max), n_stream(unified ? 1 : n_seq_max),也就是说:非 unified 模式下,缓存的第三维(stream 维)等于序列数,每条序列在自己的那段张量里独占一片空间;unified 模式下只有 1 个 stream,所有序列在同一片空间里抢单元。
为什么要分 stream 而不是简单地靠掩码隔离?因为掩码只能控制「谁看得见谁」,不能控制「谁占用哪个单元」。如果 n_seq_max = 4 而用户希望四条序列各自有确定长度的上下文(例如四条独立的对话,每条 8K),那么预留 32K 的共享空间意味着某一条序列可能把空间吃光。分成 4 个 stream、每个 8K 单元,就给每条序列一个硬上限。代价是空间不能互相借用:三条空闲序列的 24K 单元不能被第四条用掉。
多了一个 stream 就多了一层循环。find_slot 里:
1uint32_t n_tokens = ubatch.n_tokens;2uint32_t n_seqs = 1;3
4if (n_stream > 1) {5 GGML_ASSERT(n_tokens % ubatch.n_seqs_unq == 0);6
7 n_seqs = ubatch.n_seqs_unq;8 n_tokens = n_tokens / n_seqs;9}非 unified 模式下,一个 ubatch 里的 token 被按序列等分,每个序列在自己的 stream 里找槽位。
n_batch 与 n_ubatch:逻辑批次与物理微批
另外一个影响内存的数字是 n_batch:
1// with causal attention, the batch size is limited by the context size2cparams.n_batch = cparams.causal_attn ? std::min(cparams.n_ctx, params.n_batch) : params.n_batch;3
4cparams.n_ubatch = std::min(cparams.n_batch, params.n_ubatch == 0 ? params.n_batch : params.n_ubatch);按定义,n_batch 是一次 llama_decode 调用最多接受的 token 数(逻辑批次),n_ubatch 是其中真正一起送进计算图的大小(物理微批)。两者的关系是 n_ubatch ≤ n_batch。
为什么一次调用要拆成多个微批?因为计算图的内存是按 n_ubatch 预留的,不是按 n_batch。用户想一次喂 8K token 的 prompt,但如果 n_ubatch = 512,那么这 8K 会被切成 16 个微批依次执行,而计算缓冲区只需要容纳 512 个 token 的中间激活。这就是「分块预填充」在 llama.cpp 里的对应物——站内 Sarathi-Serve 完全拆解:分块 Prefill 与无停顿批处理如何兼顾吞吐和尾延迟 讲的是服务端的同一件事:把长 prompt 切成块,避免它独占一个迭代。
n_batch 被 min(n_ctx, ...) 夹住这一条也值得说明:如果一次调用能送的 token 数超过上下文长度,那么多出来的 token 必然没有地方放(每个 token 在缓存里占一个单元),所以直接截断是唯一合理的选择。
输出缓冲区:与 KV 缓存无关的另一块大内存
llama_context 构造函数里除了记忆模块,还有一个必须一起看的分配:
1// graph outputs buffer2{3 if (output_reserve(params.n_seq_max) < params.n_seq_max) {4 throw std::runtime_error("failed to reserve initial output buffer");5 }6
7 LLAMA_LOG_INFO("%s: %10s output buffer size = %8.2f MiB\n", __func__,8 ggml_backend_buffer_name (buf_output.get()),9 ggml_backend_buffer_get_size(buf_output.get()) / 1024.0 / 1024.0);10}buf_output 是 logits/embeddings 的输出缓冲区,大小是 n_outputs × n_vocab × sizeof(float)。对一个 15 万词表的模型,每个输出位置就要 600 KB;如果一次要输出 512 个位置,就是 300 MB。这块内存只在需要输出 logits 的 token 上分配(output_reserve 会做重排),所以它与 KV 缓存是两笔独立的账。
把三笔账放在一起,一个 8B 模型在 n_ctx = 32768、n_seq_max = 1、-ctk q8_0 -ctv q8_0 下的显存占用大致是:
| 项目 | 大小 | 说明 |
|---|---|---|
| 权重(Q4_K_M,8B) | ≈ 4.9 GB | 第三篇讲过,-ngl 决定放几层到显存 |
| KV 缓存(q8_0,32K) | ≈ 2.3 GB | 32 层 × 32768 单元 × 2(K/V)× 1024 元素 × 1.0625 字节 |
| 计算缓冲区 | ≈ 0.3–1 GB | 按 n_ubatch 预留,注意力中间量是大头 |
| 输出缓冲区 | ≈ 0.6 MB | n_outputs × n_vocab × 4 |
数字随模型结构(层数、KV 头数、头维)变化,但比例关系是稳定的:权重是第一大头,KV 缓存是第二大头,而且它是唯一一个随上下文长度线性增长的。同样 32K 上下文,K/V 用 f16 存储要 4.3 GB,用 q8_0 是 2.3 GB,用 q4_0 只剩 1.2 GB——第三篇讲的块量化布局在 KV 缓存上同样适用,这正是为什么 -ctk/-ctv 在长上下文场景里值得开:它把第二大头直接砍半甚至砍到四分之一。
记忆抽象层:为什么需要一个 llama_memory_i
把 KV 缓存直接写成 llama_context 的一个成员,在早期版本的 llama.cpp 里就是这么做的(那时叫 llama_kv_cache,是一个结构体)。但 2025 年之后,模型架构里出现了越来越多不用 KV 缓存的东西:
- 循环架构(Mamba、RWKV、Qwen3-Next 的线性注意力层):不保存历史 token 的 K/V,而是保存一个固定大小的循环状态矩阵。
- 混合架构(Falcon-H1、Nemotron-H、Qwen3.5):一部分层是注意力层(要 KV 缓存),一部分层是循环层(要状态),同一个模型里两种记忆并存。
- SWA 混合(Gemma 3、gpt-oss):一部分层是全注意力,一部分层是滑动窗口注意力,两者的缓存长度不同。
如果 llama_context 只认「KV 缓存」这一种记忆,那么每加一种架构就要改一遍上下文、图构建和调度器。llama.cpp 的解法是抽出一个只包含「记忆对外能做的事」的接口(src/llama-memory.h):
1struct llama_memory_i {2 using layer_filter_cb = std::function<bool(int32_t il)>;3 using layer_reuse_cb = std::function<int32_t(int32_t il)>;4 using layer_share_cb = std::function<int32_t(int32_t il)>;5
6 // split the input batch into a set of ubatches and verify that they can fit into the cache7 virtual llama_memory_context_ptr init_batch(8 llama_batch_allocr & balloc,9 uint32_t n_ubatch,10 bool embd_all) = 0;11
12 // simulate full cache, used for allocating worst-case compute buffers13 virtual llama_memory_context_ptr init_full() = 0;14
15 // prepare for any pending memory updates, such as shifts, copies, etc.16 virtual llama_memory_context_ptr init_update(llama_context * lctx, bool optimize) = 0;17
18 virtual bool get_can_shift() const = 0;19
20 virtual void clear(bool data) = 0;21
22 virtual bool seq_rm (llama_seq_id seq_id, llama_pos p0, llama_pos p1) = 0;23 virtual void seq_cp (llama_seq_id seq_id_src, llama_seq_id seq_id_dst, llama_pos p0, llama_pos p1) = 0;24 virtual void seq_keep(llama_seq_id seq_id) = 0;25 virtual void seq_add (llama_seq_id seq_id, llama_pos p0, llama_pos p1, llama_pos shift) = 0;26 virtual void seq_div (llama_seq_id seq_id, llama_pos p0, llama_pos p1, int d) = 0;27
28 virtual llama_pos seq_pos_min(llama_seq_id seq_id) const = 0;29 virtual llama_pos seq_pos_max(llama_seq_id seq_id) const = 0;30
31 virtual std::map<ggml_backend_buffer_type_t, size_t> memory_breakdown() const = 0;32
33 virtual void state_write(llama_io_write_i & io, llama_seq_id seq_id = -1, llama_state_seq_flags flags = 0) const = 0;34 virtual void state_read (llama_io_read_i & io, llama_seq_id seq_id = -1, llama_state_seq_flags flags = 0) = 0;35};接口的每一组方法都对应一类真实需求:
三个 layer 回调解决「同一模型里不同层要不同的记忆」。layer_filter_cb 决定哪一层属于这个记忆实例;layer_reuse_cb 让某一层直接复用另一层的记忆(MLP 层不需要自己的 KV 缓存,或者 GQA 里多个查询头共享同一个 KV 头);layer_share_cb 更进一步,让两个记忆实例共享同一块张量(投机解码里 draft 模型复用 target 的部分层)。这三个回调的组合能力,解释了为什么 llama_kv_cache 的构造函数要收一长串参数——它不是在配一个缓存,而是在配一个「哪些层参与、如何参与」的规格。
init_batch / init_full / init_update 三个入口对应三种不同的使用场合:
init_batch(balloc, n_ubatch, embd_all):一次真实的批次。它要做两件事——把批次切成能装下的 ubatch,以及为每个 ubatch 找到槽位。返回的llama_memory_context_i里带着切分结果和槽位信息。init_full():模拟一个「缓存全满」的场景,只用来给计算缓冲区预留最坏情况的大小。sched_reserve()里调用的就是这个。init_update(lctx, optimize):处理挂起的记忆变更(K-shift、stream 拷贝),或者在上一次失败后做一次「优化」(清掉无用单元)再重试。
seq_* 五个操作是多序列能力的核心,它们的语义与用途在下一节展开。
为什么 mutation 只允许发生在 apply() 里?接口注释写得很明确:
1// the only method that should mutate the memory and the memory context is llama_memory_i::apply()2struct llama_memory_context_i {3 virtual ~llama_memory_context_i() = default;4
5 // consume the current ubatch from the context and proceed to the next one6 virtual bool next() = 0;7
8 // apply the memory state for the current ubatch to the memory object9 virtual bool apply() = 0;10
11 virtual const llama_ubatch & get_ubatch() const = 0;12 virtual llama_memory_status get_status() const = 0;13};这是两阶段提交。看 llama_kv_cache::prepare 就明白为什么必须这么做:
1llama_kv_cache::slot_info_vec_t llama_kv_cache::prepare(const std::vector<llama_ubatch> & ubatches) {2 llama_kv_cache::slot_info_vec_t res;3
4 struct state_t {5 slot_info sinfo;6 std::vector<uint32_t> v_heads_old;7 std::vector<llama_kv_cells> v_cells;8 };9
10 // remember the old state of the cells so we can restore it in the end11 std::vector<state_t> states;12
13 bool success = true;14
15 for (const auto & ubatch : ubatches) {16 // only find a suitable slot for the ubatch. don't modify the cells yet17 const auto sinfo_new = find_slot(ubatch, false);18 if (sinfo_new.empty()) {19 success = false;20 break;21 }22
23 res.push_back(sinfo_new);24
25 // store the old state of the cells in the recovery stack26 {27 state_t state = { sinfo_new, v_heads, {} };28 for (uint32_t s = 0; s < sinfo_new.n_stream(); ++s) {29 auto & cells = v_cells[sinfo_new.strm[s]];30 state.v_cells.push_back(cells.cp(sinfo_new.idxs[s]));31 }32 states.push_back(std::move(state));33 }34
35 // now emplace the ubatch36 apply_ubatch(sinfo_new, ubatch);37 }38
39 // iterate backwards and restore the cells to their original state40 for (auto it = states.rbegin(); it != states.rend(); ++it) {41 const auto & sinfo = it->sinfo;42
43 for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {44 auto & cells = v_cells[sinfo.strm[s]];45 auto & head = v_heads[sinfo.strm[s]];46
47 cells.set(sinfo.idxs[s], it->v_cells[s]);48 head = it->v_heads_old[s];49 }50 }51
52 if (!success) {53 return {};54 }55
56 return res;57}读懂这段代码的关键是「试探—记录—回滚」三步:
- 对每个 ubatch 调
find_slot(此时只找位置,不写单元)。 - 在
apply_ubatch真正写入之前,先把受影响的单元的旧状态(位置、序列归属)和 stream 的头指针存进states。 - 全部 ubatch 都找到槽位之后,倒着遍历
states把单元恢复原状。
也就是说,prepare 结束时单元数组的值与调用前完全一样——它只是在「推演」如果把整批 token 都放进去,能不能放下。真正的写入发生在稍后 llama_kv_cache_context::apply() 里:
1bool llama_kv_cache::apply_ubatch(...) // 由 apply() 调用为什么要这么绕?因为一个逻辑批次里的多个 ubatch 必须同时放得下。假设 n_batch = 2048、n_ubatch = 512,那么一次 llama_decode 会产生 4 个 ubatch。如果缓存只剩 600 个空单元,那么第一个 ubatch(512)能放下,第二个(512)放不下。此时正确行为是「整批失败并返回错误」,而不是「先写了 512 个 token 再报错」——因为调用方会认为这次 llama_decode 完全失败,缓存里不应该留下半截状态。
llama_context::decode 里的处理正好配合这个语义:
1case LLAMA_MEMORY_STATUS_FAILED_PREPARE:2 {3 if (!did_optimize) {4 did_optimize = true;5
6 if (memory_update(true)) {7 LLAMA_LOG_DEBUG("%s: retrying batch size %d after cache optimization\n", __func__, balloc->get_n_tokens());8
9 continue;10 }11 }12
13 LLAMA_LOG_WARN("%s: failed to find a memory slot for batch of size %d\n", __func__, balloc->get_n_tokens());14
15 return 1;16 }第一次失败时先试一次 memory_update(true)——这是「优化」模式,会清理掉那些已不被任何序列引用的单元并做一次紧凑化(defrag),然后再重试一次。仍然失败才返回 1(注意:返回 1 表示「空间不足」,返回 -1/-2 表示「参数错误或计算失败」)。这套「失败—优化—重试」的结构在服务端表现为:用户看到的一次失败请求之后,下一次同样的请求可能就成功了。
llama_memory_context_i 在 decode 循环里的位置
llama_context::decode 的主干(src/llama-context.cpp)把上面这些接口串起来:
1sched_reserve();2
3bool did_optimize = false;4
5// handle any pending shifts/copies6memory_update(false);7
8llama_memory_context_ptr mctx;9
10while (true) {11 mctx = memory->init_batch(*balloc, cparams.n_ubatch, embd_all);12 if (!mctx) {13 return -2;14 }15
16 switch (mctx->get_status()) {17 case LLAMA_MEMORY_STATUS_SUCCESS: { } break;18 case LLAMA_MEMORY_STATUS_FAILED_PREPARE: /* 优化后重试 */ break;19 /* ... */20 }21
22 break;23}然后是逐 ubatch 的循环:
1do {2 const auto & ubatch = mctx->get_ubatch();3 /* 统计本 ubatch 的输出数 */4 ...5 auto * res = process_ubatch(ubatch, ctx_type_to_graph_type(cparams.ctx_type), mctx.get(), ret);6 ...7} while (mctx->next());顺序上有一处容易读错:mctx->next() 推进到下一个 ubatch,而 apply() 是在 process_ubatch 的开头被调用的:
1llm_graph_result * llama_context::process_ubatch(const llama_ubatch & ubatch, llm_graph_type gtype, llama_memory_context_i * mctx, ggml_status & ret) {2 if (mctx && !mctx->apply()) {3 LLAMA_LOG_ERROR("%s: failed to apply memory context\n", __func__);4 ret = GGML_STATUS_FAILED;5 return nullptr;6 }7 ...8}所以每个 ubatch 的时序是:取出 ubatch → 把它写进缓存(apply)→ 用这个缓存状态建图并计算。「先写入再计算」的顺序是必要的:注意力要查的 KV 必须已经在缓存里。
KV 缓存的物理布局:一层的缓存长什么样
llama_kv_cache 的构造函数(src/llama-kv-cache.cpp)是理解布局的最好入口。它的整体结构是:遍历模型的每一层,为需要缓存的层创建一对 K/V 张量,把张量按缓冲区类型(buffer type)分组放进不同的 ggml context,最后一次性分配。
为什么每层一张三维张量
核心的那几行:
1ggml_context * ctx = ctx_for_buft(buft);2if (!ctx) {3 throw std::runtime_error("failed to create ggml context for kv cache");4}5
6const bool has_k = true;7const bool has_v = !is_mla;8
9ggml_tensor * k = has_k ? ggml_new_tensor_3d(ctx, type_k, n_embd_k_gqa, kv_size, n_stream) : nullptr;10ggml_tensor * v = has_v ? ggml_new_tensor_3d(ctx, type_v, n_embd_v_gqa, kv_size, n_stream) : nullptr;11
12has_k && ggml_format_name(k, "cache_%sk_l%d", name_tag, il);13has_v && ggml_format_name(v, "cache_%sv_l%d", name_tag, il);14
15std::vector<ggml_tensor *> k_stream;16std::vector<ggml_tensor *> v_stream;17
18for (uint32_t s = 0; s < n_stream; ++s) {19 k_stream.push_back(has_k ? ggml_view_2d(ctx, k, n_embd_k_gqa, kv_size, k->nb[1], s*k->nb[2]) : nullptr);20 v_stream.push_back(has_v ? ggml_view_2d(ctx, v, n_embd_v_gqa, kv_size, v->nb[1], s*v->nb[2]) : nullptr);21}22
23map_layer_ids[il] = layers.size();24
25layers.push_back({ il, k, v, k_stream, v_stream, });逐项拆解:
n_embd_k_gqa是「K 投影在 GQA 之后的总维度」,等于n_head_kv × n_embd_head_k。注意不是n_head × n_embd_head_k——GQA 已经把查询头数压到了 KV 头数(站内 GQA 完全拆解 讲过为什么这么做能省 8 倍内存)。这样一个 8B 模型(32 层、8 个 KV 头、头维 128)的每层 K 缓存是8 × 128 = 1024个元素宽。kv_size就是缓存能容纳的 token 数,等于cparams.n_ctx_seq(构造时传入)。它是每个 stream 的长度,不是总长度。n_stream上一节讲过:unified 时为 1,否则等于n_seq_max。has_v = !is_mla:MLA(DeepSeek 的低秩 KV 压缩)把 V 也压缩进了同一个潜向量,不再需要独立的 V 缓存。站内 MLA 完全拆解 讲过这个设计的来龙去脉。这一行说明 MLA 在 llama.cpp 里是作为「只有 K 缓存的层」被处理的。
张量用三维 [n_embd_k_gqa, kv_size, n_stream] 而不是把 stream 折进第二维,原因是 ggml 的 ggml_view_2d 需要一个「行步长」(nb[1])来切片:k_stream[s] 是第 s 个 stream 的二维视图,用 s*k->nb[2] 做偏移。这样在单 stream 场景下视图就是整块张量,零开销;多 stream 时每个 stream 也能得到一张连续的二维表,供 cpy_k/get_k 直接切片。
V 缓存为什么会被转置
构造函数开头有一个参数 v_trans:
1v_trans(v_trans),它的值来自调用方:!cparams.flash_attn。也就是说 V 缓存在不启用 Flash Attention 时是转置存储的。相关代码在 get_v:
1ggml_tensor * llama_kv_cache::get_v(ggml_context * ctx, int32_t il, uint32_t n_kv, const slot_info & sinfo) const {2 const uint64_t kv_size = get_size();3 const uint64_t n_embd_v_gqa = v->ne[0];4
5 const uint32_t ns = sinfo.s1 - sinfo.s0 + 1;6
7 if (!v_trans) {8 // note: v->nb[1] <= v->nb[2]9 return ggml_view_4d(ctx, v,10 hparams.n_embd_head_v(il), hparams.n_head_kv(il), n_kv, ns,11 ggml_row_size(v->type, hparams.n_embd_head_v(il)), // v->nb[1]12 ggml_row_size(v->type, n_embd_v_gqa), // v->nb[2]13 ggml_row_size(v->type, n_embd_v_gqa*kv_size), // v->nb[3]14 ggml_row_size(v->type, n_embd_v_gqa*kv_size)*sinfo.s0);15 }16
17 // note: v->nb[1] > v->nb[2]18 return ggml_view_4d(ctx, v,19 n_kv, hparams.n_head_kv(il), hparams.n_embd_head_v(il), ns,20 ggml_row_size(v->type, kv_size*hparams.n_embd_head_v(il)), // v->nb[1]21 ggml_row_size(v->type, kv_size), // v->nb[2]22 ggml_row_size(v->type, kv_size*n_embd_v_gqa), // v->nb[3]23 ggml_row_size(v->type, kv_size*n_embd_v_gqa)*sinfo.s0);24}两段返回的视图形状不一样:不转置时是 [head_dim, n_head_kv, n_kv, ns],转置时是 [n_kv, n_head_kv, head_dim, ns]。注释里那句「v->nb[1] > v->nb[2]」点出了物理差别:转置存储时,每个 head 的所有 token 的同一维分量是连续存放的。
为什么非 Flash Attention 需要这个布局?因为传统的注意力实现(ggml 的 mul_mat 路径)对 V 的处理是「用注意力权重矩阵(形状 [n_kv, n_tokens])去乘 V」。要让这次乘法在内存上高效,V 应该按 [n_kv, head_dim] 的「token 主序」排列,而不是 [head_dim, n_kv]。转置存储把 kv_size 放到了行步长最小的位置,正好满足这个要求。Flash Attention 自己控制访存顺序,不需要这个转置,所以 v_trans = false 时可以省掉转置带来的额外拷贝逻辑。
build_input_v_idxs 里也能看到这个差别的后果:
1ggml_tensor * llama_kv_cache::build_input_v_idxs(ggml_context * ctx, const llama_ubatch & ubatch) const {2 const uint32_t n_tokens = ubatch.n_tokens;3
4 ggml_tensor * v_idxs;5
6 if (!v_trans) {7 v_idxs = ggml_new_tensor_1d(ctx, GGML_TYPE_I64, n_tokens);8 } else {9 v_idxs = ggml_new_tensor_1d(ctx, GGML_TYPE_I64, n_tokens*hparams.n_embd_v_gqa_max());10 }11 ...12}转置时索引张量要大 n_embd_v_gqa 倍,因为每个 token 的每一维分量都需要一个独立的写入下标(见 set_input_v_idxs 那一层嵌套循环)。这是转置布局的代价:写入时要展开成 n_embd_v_gqa 个「token × 分量」的地址。
哪些层参与缓存
不是所有层都有 KV 缓存。构造循环里有两道过滤:
1for (uint32_t il = 0; il < n_layer; il++) {2 if (!hparams.has_kv(il)) {3 LLAMA_LOG_DEBUG("%s: layer %3d: does not have KV cache\n", __func__, il);4 continue;5 }6
7 if (filter && !filter(il)) {8 LLAMA_LOG_DEBUG("%s: layer %3d: filtered\n", __func__, il);9 continue;10 }11 ...12}hparams.has_kv(il) 排除了循环层(Mamba 一类)、以及 MTP 的 nextn 层(在某些上下文中);filter 是从 create_memory 传进来的回调,用于混合架构里区分「注意力层」和「循环层」:
1if (arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE || arch == LLM_ARCH_QWEN4EXP || arch == LLM_ARCH_MINIMAX_01) {2 filter_attn = [&](uint32_t il) {3 return il < hparams.n_layer() && !hparams.is_recr(il);4 };5 filter_recr = [&](uint32_t il) {6 return il < hparams.n_layer() && hparams.is_recr(il);7 };8 ...9}同一份模型参数,filter_attn 让 KV 缓存只建在非循环层上,filter_recr 让循环状态池只建在循环层上。create_memory 最后返回的是 llama_memory_hybrid——一个内部同时持有 KV 缓存和循环状态池的复合对象。
还有一条 reuse 通路:
1if (reuse) {2 LLAMA_LOG_DEBUG("%s: reusing layers:\n", __func__);3
4 for (uint32_t il = 0; il < n_layer; il++) {5 const int32_t il_reuse = reuse(il);6
7 if (il_reuse < 0) {8 LLAMA_LOG_DEBUG("%s: - layer %3d: no reuse\n", __func__, il);9 continue;10 }11 ...12 map_layer_ids[il] = map_layer_ids[il_reuse];13
14 LLAMA_LOG_DEBUG("%s: - layer %3d: reuse layer %d, is_swa = %d\n", __func__, il, il_reuse, hparams.is_swa(il));15 }16}reuse 让第 il 层在 map_layer_ids 里指向另一层的张量。map_layer_ids 是「模型层号 → 缓存层号」的映射,get_k/cpy_k 一进来就查它:
1ggml_tensor * llama_kv_cache::get_k(ggml_context * ctx, int32_t il, uint32_t n_kv, const slot_info & sinfo) const {2 const int32_t ikv = map_layer_ids.at(il);3 auto * k = layers[ikv].k;4 ...5}于是两层共享同一张 K 张量——写入一次,两层都读到。这在结构压缩模型(多层共享 KV,例如 YOCO 那类跨层复用的设计)上是必须的,站内 YOCO 完全拆解:Decoder-Decoder 架构如何让 KV Cache 只存一次 讲的正是这种「只存一份跨层 KV」的架构。
缓冲区分配与日志
张量建好之后是分配:
1// allocate tensors and initialize the buffers to avoid NaNs in the padding2for (auto & [buft, ctx] : ctx_map) {3 ggml_backend_buffer_t buf;4 if (hparams.no_alloc) {5 buf = ggml_backend_buft_alloc_buffer(buft, /*size =*/ 0); // dummy buffer6 for (ggml_tensor * t = ggml_get_first_tensor(ctx.get()); t != nullptr; t = ggml_get_next_tensor(ctx.get(), t)) {7 t->buffer = buf; // set dummy buffer for KV cache so that the backend scheduler won't try to allocate it8 }9 } else {10 buf = ggml_backend_alloc_ctx_tensors_from_buft(ctx.get(), buft); // real buffer11 }12 if (!buf) {13 throw std::runtime_error("failed to allocate buffer for kv cache");14 }15
16 LLAMA_LOG_INFO("%s: %10s KV buffer size = %8.2f MiB\n", __func__, ggml_backend_buffer_name(buf), ggml_backend_buffer_get_size(buf) / 1024.0 / 1024.0);17
18 ggml_backend_buffer_clear(buf, 0);19 ctxs_bufs.emplace_back(std::move(ctx), buf);20}三个细节:
- 按 buffer type 分组:
ctx_map的键是ggml_backend_buffer_type_t,用strcmp比较名字保证顺序确定(见构造开头的ggml_backend_buft_comparator)。一层在 CPU 上、另一层在 GPU 上时,它们进的是不同的 ggml context,各自分配。这也是-ngl只把部分层放到显存时 KV 缓存会被拆成多块的原因。 ggml_backend_buffer_clear(buf, 0):把整块缓存清零。注释说「to avoid NaNs in the padding」——未写入的单元里是垃圾数据,而注意力在掩码没盖住的地方可能读到它们(例如 SWA 窗口边界、n_pad填充的尾部单元)。清零是最省事的保险。hparams.no_alloc分支来自--no-alloc(内存估算模式,常见于llama_model_fit_params之类的容量规划 API):不真分配,只给张量挂一个假缓冲,让调度器知道「这块不用你管」。
分配完成后有一行日志值得记住,因为它是排查显存问题的第一手信息:
1LLAMA_LOG_INFO("%s: size = %7.2f MiB (%6u cells, %3d layers, %2u/%u seqs), K (%s): %7.2f MiB, V (%s): %7.2f MiB\n", __func__,2 (float)(memory_size_k + memory_size_v) / (1024.0f * 1024.0f), kv_size, (int) layers.size(), n_seq_max, n_stream,3 ggml_type_name(type_k), (float)memory_size_k / (1024.0f * 1024.0f),4 ggml_type_name(type_v), (float)memory_size_v / (1024.0f * 1024.0f));它把「多少单元、多少层、多少序列/多少 stream、K 用什么类型多大、V 用什么类型多大」一次打印全。启动日志里看到 KV buffer size = 1120.00 MiB 这类输出,可以直接对照这张表判断是哪一项超了预期。
一个真实的尺寸计算
以 Llama-3-8B 这类结构为例(32 层、8 个 KV 头、头维 128、n_embd_k_gqa = n_embd_v_gqa = 1024),上下文取 n_ctx = 32768、unified、单序列:
- 单元数
kv_size = 32768(已是 256 的倍数,无需上取整)。 - 每层每 stream:K 是
1024 × 32768,V 同样是1024 × 32768。 - 用
f16:1024 × 32768 × 2 字节 = 64 MiB,K+V 是 128 MiB,乘 32 层 = 4096 MiB。 - 用
q8_0(每 32 个元素一个 2 字节 scale,即 34/32 = 1.0625 字节/元素):1024 × 32768 × 1.0625 ≈ 34 MiB,K+V 是 68 MiB,乘 32 层 ≈ 2176 MiB。 - 用
q4_0(每 32 个元素 1 字节 scale + 16 字节数据 = 18/32 = 0.5625 字节/元素):乘 32 层 ≈ 1152 MiB。

上图(来源:llama.cpp 官方仓库 media/matmul.png)展示 ggml 里量化矩阵乘的块布局。它与 KV 缓存的联系在于:KV 缓存量化复用同一套块结构。缓存张量声明成 q8_0 或 q4_0 之后,写入时走的就是 quantize_row_q8_0 一类函数,读出时由 vec_dot 家族在计算中就地解码——与权重路径共用代码,因而不需要为 KV 缓存单独实现一套量化 kernel。这也解释了 GGML_ASSERT(kv_size % n_pad == 0) 与 n_ctx 的 256 对齐:块结构要求单元总数是块大小的整数倍。
这就是 -ctk q8_0 -ctv q8_0 能把 32K 上下文的缓存从 4 GB 压到 2.1 GB 的算术来源。为什么 K 和 V 可以分别指定类型?因为两者的量化敏感度不同——站内 KIVI 完全拆解:非对称 2-bit KV Cache 量化,为什么 Key 按通道、Value 按 token 用实验说明:Key 的分布按通道集中(离群值固定在少数通道上),适合按通道量化;Value 的分布按 token 集中,适合按 token 量化。llama.cpp 的块量化是「沿每个 32 元素块共享一个 scale」,与 KIVI 的结论方向一致,实践上常见的配置是把两者都设成 q8_0(近乎无损),激进一点把 V 设为 q4_0(V 更不敏感)。

上图(来源:arXiv 论文《KVQuant: Towards 10 Million Context Length LLM Inference with KV Cache Quantization》,图 1)直观展示了 K 与 V 的分布差异。把它放在这里是解释「为什么 -ctk 和 -ctv 是两个独立开关」:如果两种张量的数值分布形态相同,一个开关就够了;正因为它们的离群值结构不同,才需要分别选择位宽。llama.cpp 的块量化实现(ggml 里的 quantize_row_q8_0 一类)对两者使用同一套代码路径,差别只体现在用户给的类型标识上。
MLA 层的特殊处理
构造函数里还有一处 MLA 专属逻辑:
1const bool is_mla = hparams.is_mla();2...3const bool has_k = true;4const bool has_v = !is_mla;以及层间头维一致性检查:
1if (n_embd_head_k_all == 0) {2 n_embd_head_k_all = (int32_t) hparams.n_embd_head_k(il);3} else if (n_embd_head_k_all > 0 && n_embd_head_k_all != (int32_t) hparams.n_embd_head_k(il)) {4 n_embd_head_k_all = -1;5}6
7if (!is_mla) {8 if (n_embd_head_v_all == 0) {9 n_embd_head_v_all = (int32_t) hparams.n_embd_head_v(il);10 } else if (n_embd_head_v_all > 0 && n_embd_head_v_all != (int32_t) hparams.n_embd_head_v(il)) {11 n_embd_head_v_all = -1;12 }13}n_embd_head_k_all/n_embd_head_v_all 的语义是:「如果所有参与缓存的层头维都相同,就记下这个值;否则置 -1」。它被用于 [TAG_V_CACHE_VARIABLE] 那一处——当 v_trans && hparams.is_n_embd_v_gqa_variable()(各层 V 维度不一致)时,告警说「V 缓存被填充到最大维度」。也就是说,如果层间头维不一致,V 缓存要按最大维度分配,多出来的部分纯属浪费,只有 Flash Attention 能避开(它按实际维度切片)。
这也是理解默认参数行为的线索之一:llama_context_default_params 把 flash_attn_type 的默认值设成 LLAMA_FLASH_ATTN_TYPE_AUTO,上下文构造时再跑一次 resolve_fused_ops 的探测(cparams.auto_fa 分支)——只有融合注意力算子确实落在该层所属的设备上时才启用,否则自动退回非融合路径。
单元记账:llama_kv_cells 如何记住每个位置
张量只是字节,真正的「记忆」在 llama-kv-cells.h 里。llama_kv_cells 是每个 stream 一份的记账结构:
1class llama_kv_cells {2public:3 using seq_set_t = std::bitset<LLAMA_MAX_SEQ>;4
5 void reset() {6 for (uint32_t i = 0; i < pos.size(); ++i) {7 pos[i] = -1;8 ext[i].reset();9 shift[i] = 0;10 seq[i].reset();11 }12
13 has_shift = false;14
15 used.clear();16
17 for (uint32_t s = 0; s < LLAMA_MAX_SEQ; ++s) {18 seq_pos[s].clear();19 }20 }21 ...从这段 reset 可以倒推出它持有的全部状态:
| 成员 | 类型 | 含义 |
|---|---|---|
pos | [kv_size] int32 | 每个单元存的位置值;-1 表示空 |
seq | [kv_size] bitset<256> | 每个单元被哪些序列引用 |
shift | [kv_size] int32 | 待应用的 K-shift 量(RoPE 位置平移) |
ext | [kv_size] llama_kv_cell_ext | 额外信息:M-RoPE 的 x/y 坐标、token id |
used | 有序集合 | 当前被占用的单元下标 |
seq_pos | [256] set<pos> | 每条序列持有哪些位置 |
has_shift | bool | 是否有挂起的平移 |
「空」的定义与 used 集合
1bool is_empty(uint32_t i) const {2 assert(i < pos.size());3 assert((pos[i] < 0 && pos[i] == -1) || pos[i] >= 0);4
5 return pos[i] == -1;6}7
8uint32_t get_used() const {9 return used.size();10}11
12// the index of the first cell that is used13// return 0 if no cells are used14uint32_t used_min() const {15 return used.empty() ? 0 : *used.begin();16}17
18// the index of the last cell that is used + 119// return 0 if no cells are used20uint32_t used_max_p1() const {21 return used.empty() ? 0 : *used.rbegin() + 1;22}位置值为 -1 就是「空」,不需要额外的占用位图。used 是一个有序集合(std::set 或类似结构),提供三个查询:总数(get_used)、最小下标、最大下标 +1。后两个是给 find_slot 和 [TAG_KV_CACHE_VARIABLE] 的启发式用的——它们让分配器知道「已经用到了哪里」,从而判断该从头扫还是从当前头指针扫。
used_max_p1() 还有一处重要用途,在 get_n_kv:
1uint32_t llama_kv_cache::get_n_kv(const slot_info & sinfo) const {2 uint32_t result = 0;3
4 // heads must be 0 for the cache to be empty5 for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {6 const auto & cells = v_cells[sinfo.strm[s]];7
8 result = std::max(std::min(cells.size(), std::max(n_pad_cur, GGML_PAD(cells.used_max_p1(), n_pad_cur))), result);9 }10
11 return result;12}get_n_kv 回答的是「这次注意力的 K/V 视图要开多长」。最自然是开满 kv_size,但那样每个 token 的注意力都要扫过整个缓存——包括后面那些根本没写过的空单元。这个函数给了一个保守上界:已用单元的最大下标 +1(再按 n_pad 对齐,并至少留 n_pad)。注意它是按 stream 取最大值,因为多个 stream 的视图要共存一张张量。
这就是 llama_kv_cache_context 里那个 n_kv 成员的来历:
1// a heuristic, to avoid attending the full cache if it is not yet utilized2// as the cache gets filled, the benefit from this heuristic disappears3int32_t n_kv;注释说得很实在:缓存没满时这个启发式省时间,缓存满了就没用了。但在「刚起一个新序列,上下文才 100 个 token」这种最常见的场景里,它把注意力的扫描长度从 32768 压到 128,收益是几百倍。
seq_pos:为什么每个序列要单独维护一个位置集合
seq_pos 这个成员最容易让人觉得多余——pos 数组里不是已经有位置了吗?遍历一遍不就得到「这条序列有哪些位置」了?
问题在于复杂度。假设一个序列持有 30000 个位置分散在 32768 个单元里,要在 apply_ubatch 里回答「这条序列的最小位置是多少」,遍历 pos 是 O(kv_size);而位置集合的 begin() 是 O(1)。apply_ubatch 里每次写入都要问到这个问题:
1// note: we want to preserve the invariant that all positions between [pos_min, pos_max] for each sequence2// will be present in the cache. so we have to purge any position which is less than those we would overwrite3// ref: https://github.com/ggml-org/llama.cpp/pull/13746#issuecomment-29160570924for (uint32_t s = 0; s < LLAMA_MAX_SEQ; ++s) {5 if (seq_pos_max_rm[s] == -1) {6 continue;7 }8
9 GGML_ASSERT(s < seq_to_stream.size());10
11 auto & cells = v_cells[seq_to_stream[s]];12
13 if (cells.seq_pos_min(s) <= seq_pos_max_rm[s]) {14 LLAMA_LOG_DEBUG("%s: purging positions [%d, %d] of sequence %d from KV cache\n",15 __func__, cells.seq_pos_min(s), seq_pos_max_rm[s], s);16
17 seq_rm(s, cells.seq_pos_min(s), seq_pos_max_rm[s] + 1);18 }19}这段代码是整个 KV 管理里最需要解释的一处。它说的是:
同一个序列在缓存里的位置必须是连续的区间。「序列 A 在缓存里存在的位置是 {5,6,7,8}」这种状态是合法的;「存在的位置是 {5,7,9}」是非法的。为什么?因为 RoPE 的位置编码与相对距离直接绑定,注意力看到的是「位置 5 的 token」和「位置 7 的 token」,如果位置 6 的内容被别的序列覆盖掉了,那么后续 token 看到的历史里就出现了一个洞——模型会认为「位置 6 的内容是我没见过的」,而实际上它曾经存在过。
所以当 apply_ubatch 要覆盖某个单元、而那个单元原本属于某个序列时,必须先记录「被覆盖的最大位置」seq_pos_max_rm。覆盖完成后,检查该序列的 seq_pos_min 是否落在这个范围内:
- 如果
seq_pos_min(s) <= seq_pos_max_rm[s],说明「新写入的位置超过了旧的最小位置」,那么从seq_pos_min到seq_pos_max_rm这段位置的连续性被破坏了。此时调用seq_rm(s, pos_min, pos_max_rm + 1),把这条序列在整段范围内的所有位置全部删掉。
这是一种「宁可多删,不留洞」的保守策略:既然区间被切断了,就把断点之前的部分整体丢弃,让序列退化成「只有较新的那段连续历史」。
这段逻辑只对 SWA 缓存有意义。普通缓存里,覆盖发生在环形绕回时(头指针转了一圈撞上旧数据),此时 seq_pos_max_rm 记下的正是被撞掉的那条序列的最大位置,purge 保证它不会留下洞。对 SWA 缓存,find_slot 会主动把窗口外的旧单元判定为「可用」,于是覆盖经常发生,purge 也就经常触发。这就是注释里「for non-SWA cache, this would be always empty」的含义——不过实际上环形绕回时也会非空。
位置集合上的操作
llama_kv_cells 提供了一批基于 seq_pos 的操作,llama_memory_i 的 seq_* 接口正是它们的包装:
seq_add(i, seq_id):单元 i 被 seq_id 引用,把位置加进该序列的集合。seq_rm(i, seq_id):解除引用,返回「是否已无人引用」(是则可以真正释放)。seq_has(i, seq_id):查询归属。seq_pos_min(seq_id)/seq_pos_max(seq_id):集合的最小/最大位置,O(1)。seq_pos_tok_le(seq_id, p):集合里 ≤ p 的位置个数(用于 M-RoPE 的二维位置计算)。
seq_rm 是引用计数式删除的典型:
1if (cells.seq_has(i, seq_id) && cells.seq_rm(i, seq_id)) {2 if (new_head == cells.size()) {3 new_head = i;4 }5}seq_rm(i, seq_id) 返回 true 意味着这个单元不再被任何序列引用,于是它被释放。而在 seq_rm 的调用者(llama_kv_cache::seq_rm)里,释放的单元下标被用来更新头指针:
1uint32_t new_head = cells.size();2
3for (uint32_t i = 0; i < cells.size(); ++i) {4 if (!cells.pos_in(i, p0, p1)) {5 continue;6 }7
8 if (cells.seq_has(i, seq_id) && cells.seq_rm(i, seq_id)) {9 if (new_head == cells.size()) {10 new_head = i;11 }12 }13}14
15// If we freed up a slot, set head to it so searching can start there.16if (new_head != cells.size() && new_head < head) {17 head = new_head;18}头指针前移:一旦释放了比当前头更靠前的单元,就把头指针挪过去,下次 find_slot 从那里开始扫。这条规则让「删除一段历史后立刻重新插入」能复用刚空出来的空间,而不是绕到缓存尾部去。
五个 seq 操作各自的用途
llama_memory_i 接口上的五个序列操作,对应五种真实操作:
| 操作 | 签名 | 用途 |
|---|---|---|
seq_rm | (seq_id, p0, p1) | 删除某序列在位置区间 [p0, p1) 内的所有单元 |
seq_cp | (src, dst, p0, p1) | 把 src 序列的区间复制给 dst(并行采样、分支生成) |
seq_keep | (seq_id) | 只保留该序列,其余全部释放(清空其他对话) |
seq_add | (seq_id, p0, p1, shift) | 区间内位置整体平移 shift(长文本滚动) |
seq_div | (seq_id, p0, p1, d) | 区间内位置整体除以 d(上下文压缩、位置重塑) |
seq_cp 值得单独看,因为它是真正的数据拷贝而不是改记账。当 n_stream == 1 时:
1for (uint32_t i = 0; i < cells.size(); ++i) {2 if (!cells.pos_in(i, p0, p1)) {3 continue;4 }5 if (cells.seq_has(i, seq_id_src)) {6 cells.seq_add(i, seq_id_dst);7 }8}共享模式下只是加一个引用位——同一个单元被两条序列同时引用,零拷贝,两条序列的后续生成各自往新单元写。而当 n_stream > 1(--no-kv-unified)时,两条序列在不同的 stream 里,无法共享单元,于是必须真的把数据抄过去:
1GGML_ASSERT(is_full && "seq_cp() is only supported for full KV buffers");2...3 v_cells[s1].seq_add(i, seq_id_dst);这就是 llama.cpp 里「写时复制」的对应物,只不过它的粒度是整个 stream 而不是块:共享发生在 unified 模式的单元级引用计数上,代价是各序列共享同一段空间;隔离发生在非 unified 模式的 stream 级,代价是拷贝。
seq_add 与 seq_div 是「不重算就能改变历史位置」的工具。seq_add 给区间内所有位置加一个偏移量,并设置 shift——下一轮图构建时会插入一个 K-shift 步骤,把缓存里的 Key 用 RoPE 旋转到新位置上:
1if (do_shift) {2 if (!get_can_shift()) {3 GGML_ABORT("The current KV cache / model configuration does not support K-shift");4 }5
6 LLAMA_LOG_DEBUG("%s: applying K-shift\n", __func__);7
8 // apply K-shift if needed9 if (hparams.rope_type != LLAMA_ROPE_TYPE_NONE) {10 ggml_backend_sched_reset(sched);11
12 auto * res = lctx->get_gf_res_reserve();13
14 res->reset();15
16 auto * gf = build_graph_shift(res, lctx);17 if (!ggml_backend_sched_alloc_graph(sched, gf)) {18 LLAMA_LOG_ERROR("%s: failed to allocate compute graph for K-shift\n", __func__);19 return updated;20 }21
22 res->set_inputs(nullptr);23
24 if (lctx->graph_compute(gf, false) != GGML_STATUS_SUCCESS) {25 LLAMA_LOG_ERROR("%s: failed to compute K-shift\n", __func__);26 return updated;27 }28
29 updated = true;30 }31 ...32}K-shift 是一个独立的计算图:它遍历缓存里每一层的 K 张量,用 ggml_rope_ext 按 shift 量原地旋转。相关代码在 build_rope_shift:
1ggml_tensor * llama_kv_cache::build_rope_shift(2 const llama_cparams & cparams,3 ggml_context * ctx,4 ggml_tensor * cur,5 ggml_tensor * shift,6 ggml_tensor * rot,7 ggml_tensor * factors,8 float freq_base,9 float freq_scale,10 uint32_t il) const {11 ggml_tensor * tmp;12
13 if (ggml_is_quantized(cur->type)) {14 // dequantize to f32 -> RoPE -> quantize back15 tmp = ggml_cast(ctx, cur, GGML_TYPE_F32);16
17 // rotate back18 tmp = llama_mul_mat_hadamard(ctx, tmp, rot);19
20 tmp = ggml_rope_ext(ctx, tmp,21 shift, factors, n_rot, rope_type, n_ctx_orig, freq_base, freq_scale,22 yarn_ext_factor, yarn_attn_factor, yarn_beta_fast, yarn_beta_slow);23
24 // rotate fwd25 tmp = llama_mul_mat_hadamard(ctx, tmp, rot);26
27 tmp = ggml_cpy(ctx, tmp, cur);28 } else {29 // we rotate only the first n_rot dimensions30 tmp = ggml_rope_ext_inplace(ctx, cur,31 shift, factors, n_rot, rope_type, n_ctx_orig, freq_base, freq_scale,32 yarn_ext_factor, yarn_attn_factor, yarn_beta_fast, yarn_beta_slow);33 }34
35 return tmp;36}两条分支的对比揭示了量化 KV 缓存的一个隐藏成本:量化过的 Key 不能直接原地旋转(RoPE 是线性变换,但量化块里的数据无法在不解码的情况下旋转),必须「反量化 → 旋转 → 重新量化」三步走。未量化的路径则是 ggml_rope_ext_inplace——在 fp16/fp32 上直接改内存。中间那两次 llama_mul_mat_hadamard 是旋转坐标系的处理(对应站内 QuaRot 完全拆解:旋转坐标系根除激活离群值 讲的 Hadamard 旋转),用于把量化友好的坐标系恢复成 RoPE 期望的坐标系。
seq_div 的用途是压缩历史:把位置整体除以 d,例如把 0..1000 的位置映射成 0..500,让「相对距离」变紧凑。这可以用来把超长历史塞进更短的上下文——把位置密度减半,模型看到的历史就「压缩」成了原来一半的长度。它不是免费的:pos 变成非整数需要取整,且 RoPE 的相对距离关系被改变,语义会有损失。
seq_keep 用在「切换到一条新对话、把旧的全扔掉」的场景:
1void llama_kv_cache::seq_keep(llama_seq_id seq_id) {2 ...3 if (cells.seq_keep(i, seq_id)) {4 ...5 }6}保留指定序列,释放其它所有单元。
引用计数带来的一个隐含约束
由于单元是被多条序列共同引用的,clear 的语义要分清楚:
1virtual void clear(bool data) = 0;参数 data 决定「是否连数据一起清掉」。clear(false) 只重置记账(所有单元标记为空),clear(true) 还会把张量里的字节清零。state_read 里用到了这个区别:
1// a whole-context restore replaces every stream, so the cache is emptied once here2// clear() resets all streams at once, so doing it per stream below would keep only the last one3if (seq_id == -1) {4 clear(true);5}注释解释了为什么 clear 必须在循环外调用一次:它是「一次性重置所有 stream」的语义,如果在 per-stream 循环里调用,每次都会把前面的 stream 一起清掉,只有最后一个幸存。
批次切分:llama_batch_allocr 如何把逻辑批次变成 ubatch
init_batch 的第一步不是找槽位,而是切分:
1llama_memory_context_ptr llama_kv_cache::init_batch(2 llama_batch_allocr & balloc,3 uint32_t n_ubatch,4 bool embd_all) {5 do {6 balloc.split_reset();7
8 std::vector<llama_ubatch> ubatches;9 while (true) {10 auto ubatch = n_stream == 1 ? balloc.split_simple(n_ubatch) : embd_all ? balloc.split_seq(n_ubatch) : balloc.split_equal(n_ubatch, true, 0);11
12 if (ubatch.n_tokens == 0) {13 break;14 }15
16 ubatches.push_back(std::move(ubatch)); // NOLINT17 }18
19 if (balloc.get_n_used() < balloc.get_n_tokens()) {20 // failed to find a suitable split21 break;22 }23
24 auto sinfos = prepare(ubatches);25 if (sinfos.empty()) {26 break;27 }28
29 return std::make_unique<llama_kv_cache_context>(30 this, std::move(sinfos), std::move(ubatches));31 } while (false);32
33 return std::make_unique<llama_kv_cache_context>(LLAMA_MEMORY_STATUS_FAILED_PREPARE);34}三种切分策略按条件选择:
split_simple(n_ubatch)(单 stream,最常见):按顺序抓n_ubatch个 token,不看序列边界。split_equal(n_ubatch, true, 0)(多 stream,要求每条序列的 token 等分):把每条序列的 token 平均分给每个 ubatch。split_seq(n_ubatch)(多 stream 且embd_all,即非因果注意力或全输出):按序列切,保证一条序列的 token 不被拆散。
为什么多 stream 时要按序列等分?回看 find_slot 里那几行:
1if (n_stream > 1) {2 GGML_ASSERT(n_tokens % ubatch.n_seqs_unq == 0);3
4 n_seqs = ubatch.n_seqs_unq;5 n_tokens = n_tokens / n_seqs;6}槽位分配是「每个序列单独找一段连续空间」,因而要求每个序列在 ubatch 里占同样多的 token。split_equal 就是为了满足这个前置条件。
为什么 embd_all 时要按序列切而不是等分?因为非因果注意力(BERT 类编码器)或全输出模式(embeddings)下,注意力要看到整条序列的全部 token,如果一条序列被拆到两个 ubatch 里,第二个 ubatch 计算时缺了第一个的部分——结果就错了。所以必须整条序列一起算。
balloc.get_n_used() < balloc.get_n_tokens() 这个检查的含义是:「切分没有覆盖全部 token」——如果存在某些 token 无论如何都塞不进任何 ubatch(例如单条序列长度超过 n_ubatch),就判定整体失败。
llama_ubatch 的结构
切分产物是 llama_ubatch(src/llama-batch.h):
1struct llama_ubatch {2 bool equal_seqs() const {3 return b_equal_seqs != 0;4 }5
6 bool is_pos_2d() const {7 return n_pos >= 3;8 }9
10 bool is_mixed() const {11 return type != nullptr;12 }13
14 uint32_t b_equal_seqs; // note: this is a boolean, but we use an int32_t for alignment15 uint32_t n_tokens; // total tokens (n_seq_tokens * n_seqs)16 uint32_t n_seq_tokens; // tokens per sequence set17 uint32_t n_seqs; // sequence sets in the ubatch18 uint32_t n_seqs_unq; // unique sequence ids in the ubatch19 uint32_t n_pos; // number of position inputs for each token/embedding20
21 llama_token * token; // [n_tokens] | i | id, token22 float * embd; // [n_embd, n_tokens] | i | embd23 float * embd_state; // [n_embd_state, n_tokens] | i | hidden state carried over from a previous stage (e.g. MTP)24 llama_pos * pos; // [n_tokens*n_pos] | i | pos25 int32_t * n_seq_id; // [n_tokens] | i | -26 llama_seq_id ** seq_id; // [n_tokens] | s | s0, s1, seq_id27 llama_seq_id * seq_id_unq; // [n_seqs_unq] | s | seq_id28 int32_t * seq_idx; // [LLAMA_MAX_SEQ] | - | seq_idx29 int8_t * output; // [n_tokens] | i | -30 int8_t * type; // [n_tokens] | i | - (mixed ubatch only, 0 - token, 1 - embd)31 ...32};几个字段之间的关系值得理清:
n_tokens = n_seq_tokens × n_seqs。n_seqs是「序列组」数量,n_seq_tokens是每个组里的 token 数。这个「组」的概念支持一个 token 同时属于多条序列(例如两条序列共享同一段 prompt 时,同一列 token 会被两组共享)。seq_id是二维的:seq_id[i][s]是第 i 个 token 的第 s 个序列 id,n_seq_id[i]是它的序列个数。这与llama_batch的公开接口一致(一个 token 可以同时属于多条序列)。output标记该 token 是否需要输出 logits/embeddings。只有被标记的 token 才占输出缓冲区的位置。type只在「混合 ubatch」里非空:一个 ubatch 里同时有真 token 和多模态 embedding,type[i]区分两者。ASSERT_EMBD_OR_TOKEN宏保证需要纯 token 或纯 embedding 的代码不会被混合批次误入。pos是n_tokens × n_pos的一维数组:n_pos = 1时就是普通一维位置;M-RoPE(多模态)时n_pos = 3,分别存「序列位置 / y / x」,这就是is_pos_2d()判断n_pos >= 3的来历。apply_ubatch里取 x/y 坐标正是按这个布局:
1if (ubatch.is_pos_2d()) {2 ext.x = ubatch.pos[i + ubatch.n_tokens*2];3 ext.y = ubatch.pos[i + ubatch.n_tokens];4}每个 token 在缓存里都要记「额外信息」
apply_ubatch 的第二个动作是写 ext:
1cells.pos_set(idx, ubatch.pos[i]);2
3if (ubatch.is_pos_2d() || ubatch.token || hparams.ple_n_heads > 0) {4 llama_kv_cell_ext ext;5
6 if (ubatch.is_pos_2d()) {7 ext.x = ubatch.pos[i + ubatch.n_tokens*2];8 ext.y = ubatch.pos[i + ubatch.n_tokens];9 }10
11 const bool is_embd = !ubatch.token || (ubatch.is_mixed() && ubatch.type[i]);12
13 if (!is_embd) {14 ext.tok = ubatch.token[i];15 } else if (hparams.ple_n_heads > 0) {16 // embd batch (multimodal input) has no token ids, need to pad it with the correct ID for PLE layers17 ext.tok = hparams.ple_image_token_id != 018 ? (llama_token) hparams.ple_image_token_id19 : (llama_token) hparams.ple_eos_token_id;20 }21
22 cells.ext_set(idx, ext);23}为什么缓存要记 token id?因为有些层需要「知道这个位置原本是什么 token」。三个例子:
- PLE(per-layer embeddings,Gemma 3n 一类):某几层不吃 hidden state,而是直接查一个「token → 向量」的表。缓存里保存 token id 后,即使这个位置是在几轮之前写入的,也能在后续的前向里重新查到它的嵌入。
- M-RoPE(Qwen2-VL 一类):多模态输入的位置是二维的(图中的 x/y),
ext.x/ext.y必须随缓存保留,否则后续 token 无法正确计算与图像块之间的相对位置。 - n-gram 输入嵌入:
get_prev_tokens需要知道「这个位置之前的 n 个 token 分别是什么」,同样依赖ext.tok。
state_write_meta 里那段 if (has_cell_ext()) 就是在序列化这些额外信息——KV 缓存的状态文件不只是 K/V 张量,还包括这套单元格记账。
槽位分配:find_slot 的环形扫描
现在到最核心的一段代码。find_slot 的任务是:给定一个 ubatch,在缓存里找一段位置放它的 token。它有三种扫描模式(连续槽位、非连续槽位、按 stream 分组),整个函数是 llama-kv-cache.cpp 里最长的单个函数之一。
头指针的启发式调整
1const auto & cells = v_cells[seq_to_stream[seq_id]];2
3uint32_t head_cur = v_heads[seq_to_stream[seq_id]];4
5// if we have enough unused cells before the current head ->6// better to start searching from the beginning of the cache, hoping to fill it7if (head_cur > cells.get_used() + 2*n_tokens) {8 head_cur = 0;9}10
11if (n_tokens > cells.size()) {12 LLAMA_LOG_ERROR("%s: n_tokens = %d > size = %u\n", __func__, n_tokens, cells.size());13 return { };14}v_heads 是每个 stream 的头指针,表示「上次分配结束的位置」。默认从那里往后扫(环形),但有一个例外:如果头指针之前的空洞足够大(head_cur > used + 2*n_tokens,即「已用单元数 + 两倍要写入的 token 数」仍小于头指针位置),就改从 0 开始扫。这条启发式的目的是填补前面的空洞,避免缓存尾部被撑满而前面大片空着。
第二段检查是硬约束:一个 ubatch 每 stream 要写的 token 数不能超过缓存总容量。
非连续槽位与连续槽位
接下来是两种扫描模式:
1uint32_t n_tested = 0;2
3// for continuous slots, we test that all tokens in the ubatch fit, starting from the current head4// for non-continuous slots, we test the tokens one by one5const uint32_t n_test = cont ? n_tokens : 1;6
7while (true) {8 if (head_cur + n_test > cells.size()) {9 n_tested += cells.size() - head_cur;10 head_cur = 0;11 continue;12 }13
14 for (uint32_t i = 0; i < n_test; i++) {15 const auto idx = head_cur;16
17 head_cur++;18 n_tested++;19
20 // can we use this cell? either:21 // - the cell is empty22 // - the cell is occupied only by one sequence:23 // - (disabled) mask causally, if the sequence is the same as the one we are inserting24 // - mask SWA, using current max pos for that sequence in the cache25 // always insert in the cell with minimum pos26 bool can_use = cells.is_empty(idx);27
28 if (!can_use && cells.seq_count(idx) == 1) {29 const llama_pos pos_cell = cells.pos_get(idx);30
31 // (disabled) causal mask32 // note: it's better to purge any "future" tokens beforehand33 //if (cells.seq_has(idx, seq_id)) {34 // can_use = pos_cell >= pos;35 //}36
37 if (!can_use) {38 const llama_seq_id seq_id_cell = cells.seq_get(idx);39
40 // SWA mask41 if (llama_hparams::is_masked_swa(n_swa, swa_type, pos_cell, cells.seq_pos_max(seq_id_cell) + 1)) {42 can_use = true;43 }44 }45 }46
47 if (can_use) {48 res.idxs[s].push_back(idx);49 } else {50 if (cont) {51 break;52 }53 }54 }55
56 if (res.idxs[s].size() == n_tokens) {57 break;58 }59
60 if (cont) {61 res.idxs[s].clear();62 }63
64 if (n_tested >= cells.size()) {65 //LLAMA_LOG_ERROR("%s: failed to find a slot for %d tokens\n", __func__, n_tokens);66 return { };67 }68}逐点拆解:
环形推进:if (head_cur + n_test > cells.size()) 时把 head_cur 归零。n_tested 记录总共检查了多少个单元,一旦达到 cells.size() 说明绕了一整圈还是没找到——返回空,表示失败。
cont(连续)模式:n_test = n_tokens,要求整批 token 落在一段连续的单元里;一旦遇到不可用的单元就 break 并清空已收集的索引,从下一个位置重新试。非连续模式:n_test = 1,逐个单元试,可用的就收进 res.idxs[s],不可用的跳过——这允许一个 ubatch 的 token 分散在缓存各处。
值得注意代码里的一处事实:当前 master 上 find_slot 的两个调用点(prepare 与 state_read_meta)都传 cont = false,也就是分配一律按非连续来做;连续性不再由分配器保证,而是事后由 sinfo.is_contiguous() 判定,作为图构建时的一个优化分支。cont = true 的分支仍留在代码里,但已经没有调用者。
可用的判定有三条路径:
- 单元为空(
cells.is_empty(idx)):最理想,直接用。 - 单元被单一序列占用,且该占用已被 SWA 掩码判定为「窗口外」:说明这块位置对那条序列已经没有意义(超出了它的滑动窗口),可以据为己有。
- 被多条序列占用(
cells.seq_count(idx) > 1):不可用。因为多条序列共享意味着「这块数据还有人要」,覆盖会破坏别人。
注释里特意说明了被注释掉的因果掩码分支:
1// (disabled) causal mask2// note: it's better to purge any "future" tokens beforehand3//if (cells.seq_has(idx, seq_id)) {4// can_use = pos_cell >= pos;5//}早期版本允许「覆盖自己序列里位置更靠后的单元」(因为因果注意力不看未来,那些位置对当前 token 无用)。后来被禁用,理由是「最好事先把这些未来 token 清掉」——靠 apply_ubatch 的 purge 机制统一处理,而不是在分配器里做隐式覆盖。这就是前面那段 purge 代码的由来。
SWA 掩码:is_masked_swa 的判据
第 2 条路径依赖 llama_hparams::is_masked_swa:
1if (llama_hparams::is_masked_swa(n_swa, swa_type, pos_cell, cells.seq_pos_max(seq_id_cell) + 1)) {2 can_use = true;3}参数含义:n_swa 是滑动窗口大小,pos_cell 是该单元记录的位置,seq_pos_max(seq_id_cell) + 1 是该序列当前的最大位置 +1。判定为 masked 意味着「这个位置已经落在滑动窗口之外」——滑动窗口注意力只看最近 n_swa 个位置,更早的都被掩掉了,所以那块缓存可以复用。
这条规则让 SWA 缓存永远不会增长到满:只要窗口是 n_swa = 4096,缓存实际需要的单元数就在 n_swa 附近,多出来的空间会自动被回收。这就是为什么 -fa + SWA 模型能在固定内存下跑无限长上下文——站内 SWA 滑窗注意力完全拆解:只加 4 个 attention sink,零训练反超后训练线性注意力 从算法侧讲过这个性质,这里看到的是它在内存管理上的实现。
多 stream 的汇总
扫描完成后有一段一致性检查:
1res.s0 = std::min<uint32_t>(res.s0, seq_to_stream[seq_id]);2res.s1 = std::max<uint32_t>(res.s1, seq_to_stream[seq_id]);3
4res.strm[s] = seq_to_stream[seq_id];5res.idxs[s].reserve(n_tokens);res.s0/res.s1 记录本次 ubatch 涉及的 stream 区间(闭区间),后面 get_k/get_v 里的 ns = sinfo.s1 - sinfo.s0 + 1 就是视图的第四维长度。这意味着一次 ubatch 的注意力视图覆盖从最小 stream 到最大 stream 的连续范围——如果 ubatch 里的序列恰好落在 stream 5 和 stream 7,那么视图会跨越 5、6、7 三个 stream,即使 stream 6 里没有本次的 token。这是个简单但略浪费的设计(用 sinfo.strm[s] 记录真实归属,视图却要跨越),好处是视图索引计算简单。
slot_info 的结构
扫描产物是 slot_info(src/llama-kv-cache.h):
1// for each ubatch, create a slot_info that contains information about where the ubatch should be inserted in the2// KV cells. for example, cell indices for each token, such that: token[i] -> goes to cells[idxs[i]]3struct slot_info {4 // data for ggml_set_rows5 using idx_vec_t = std::vector<uint32_t>;6
7 // number of streams: ns = s1 - s0 + 18 uint32_t s0;9 uint32_t s1;10
11 std::vector<llama_seq_id> strm; // [ns]12 std::vector<idx_vec_t> idxs; // [ns]13
14 uint32_t head() const {15 GGML_ASSERT(idxs.size() == 1);16 GGML_ASSERT(!idxs[0].empty());17
18 return idxs[0][0];19 }20
21 size_t size() const {22 GGML_ASSERT(idxs.size() == strm.size());23 GGML_ASSERT(!idxs.empty());24
25 return idxs[0].size();26 }27
28 size_t n_stream() const {29 return strm.size();30 }31
32 // check if indices are contiguous starting from head()33 bool is_contiguous() const {34 if (idxs.empty() || idxs[0].empty()) {35 return true;36 }37 if (idxs.size() > 1) {38 return false;39 }40 const uint32_t h = idxs[0][0];41 for (size_t i = 0; i < idxs[0].size(); ++i) {42 if (idxs[0][i] != h + i) {43 return false;44 }45 }46 return true;47 }48};is_contiguous() 是图构建时的判断依据:连续槽位可以用一次 ggml_view 切片拿到整段 K/V,非连续则必须走 ggml_set_rows 的散射写入。这条判断直接决定了后续是「廉价切片」还是「昂贵的索引写入」。
写入缓存:cpy_k/cpy_v 与 ggml_set_rows
找到槽位之后,新算出的 K/V 要写进缓存。写入是用 ggml_set_rows 表达的——这是 ggml 里「按行索引散射写入」的算子:
1ggml_tensor * llama_kv_cache::cpy_k(ggml_context * ctx, ggml_tensor * k_cur, ggml_tensor * k_idxs, int32_t il, const slot_info & sinfo) const {2 const int32_t ikv = map_layer_ids.at(il);3
4 ggml_tensor * k = layers[ikv].k;5
6 const int64_t n_embd_head = k_cur->ne[0];7 const int64_t n_head = k_cur->ne[1];8 const int64_t n_tokens = k_cur->ne[2];9
10 const int64_t n_embd_gqa = n_embd_head*n_head;11
12 // we can merge dims 0 and 113 // TODO: add ggml helper function for this?14 GGML_ASSERT(ggml_row_size(k_cur->type, n_embd_head) == k_cur->nb[1]);15
16 k_cur = ggml_view_2d(ctx, k_cur, n_embd_gqa, n_tokens, k_cur->nb[2], 0);17
18 const int64_t n_stream = k->ne[2];19
20 if (n_stream > 1) {21 const int64_t kv_size = get_size();22
23 assert(n_embd_gqa == k->ne[0]);24 assert(kv_size == k->ne[1]);25
26 // merge the buffer across all streams because the idxs are global27 k = ggml_reshape_2d(ctx, k, n_embd_gqa, kv_size*n_stream);28 }29
30 // store the current K values into the cache31 return ggml_set_rows(ctx, k, k_cur, k_idxs);32}几个设计点:
为什么要 ggml_view_2d 把 dim 0 和 1 合并? 新算出的 K 是 [n_embd_head, n_head, n_tokens] 的三维张量。缓存里的存储是「每个 token 一行 n_embd_gqa 个元素」,所以要把前两维压平成一维 n_embd_gqa。GGML_ASSERT(ggml_row_size(k_cur->type, n_embd_head) == k_cur->nb[1]) 保证 head 之间没有额外的填充——只有在布局本来就紧密时才允许这么合并视图。这一行 assert 也解释了为什么 n_head_kv 与 n_embd_head_k 的乘积必须正好等于缓存行宽。
多 stream 时为什么要把张量 reshape 成二维? 注释写得很直接:「merge the buffer across all streams because the idxs are global」。在 set_input_k_idxs 里:
1void llama_kv_cache::set_input_k_idxs(ggml_tensor * dst, const llama_ubatch * ubatch, const slot_info & sinfo) const {2 const uint32_t n_tokens = ubatch->n_tokens;3 GGML_ASSERT(n_tokens == (int64_t) sinfo.size()*sinfo.n_stream());4
5 GGML_ASSERT(ggml_backend_buffer_is_host(dst->buffer));6 int64_t * data = (int64_t *) dst->data;7
8 for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {9 const int64_t offs = sinfo.strm[s]*get_size();10
11 for (uint32_t i = 0; i < sinfo.size(); ++i) {12 data[s*sinfo.size() + i] = offs + sinfo.idxs[s][i];13 }14 }15}索引值 offs + sinfo.idxs[s][i] 是全局单元号(stream × kv_size + stream 内的下标)。如果张量还是三维的,ggml_set_rows 的行号就无法直接表达「跨 stream」的概念;reshape 成 [n_embd_gqa, kv_size*n_stream] 之后,行号 0..(kv_size×n_stream-1) 与全局单元号一一对应。
cpy_v 有两条路径——这正好是 v_trans 的影响所在:
1ggml_tensor * llama_kv_cache::cpy_v(ggml_context * ctx, ggml_tensor * v_cur, ggml_tensor * v_idxs, int32_t il, const slot_info & sinfo) const {2 ...3 // take this branch when FA is enabled (the V cache is not transposed)4 if (!v_trans) {5 v_cur = ggml_view_2d(ctx, v_cur, n_embd_gqa, n_tokens, v_cur->nb[2], 0);6 if (n_stream > 1) {7 v = ggml_reshape_2d(ctx, v, n_embd_gqa, kv_size*n_stream);8 }9 return ggml_set_rows(ctx, v, v_cur, v_idxs);10 }11
12 // 转置路径:先保证 v_cur 连续13 if (ggml_row_size(v_cur->type, n_embd_gqa) == v_cur->nb[2]) {14 // we can merge dims 0, 1 and 215 v_cur = ggml_reshape_2d(ctx, v_cur, n_embd_gqa, n_tokens);16 } else {17 // otherwise -> make a copy to get contiguous data18 v_cur = ggml_cont_2d (ctx, v_cur, n_embd_gqa, n_tokens);19 }20
21 // [TAG_V_CACHE_VARIABLE]22 if (n_embd_gqa < v->ne[0]) {23 v_cur = ggml_pad(ctx, v_cur, v->ne[0] - n_embd_gqa, 0, 0, 0);24 }25
26 // in this branch the v_idxs are constructed in such a way that each row is a single head element27 ggml_tensor * v_view = ggml_reshape_2d(ctx, v, 1, ggml_nelements(v));28 v_cur = ggml_reshape_2d(ctx, v_cur, 1, ggml_nelements(v_cur));29
30 return ggml_set_rows(ctx, v_view, v_cur, v_idxs);31}转置路径的最后三行把 V 缓存和待写数据都压成「一行一个元素」(第二维是 1,第二维长度等于元素总数)。这就是前面提过的代价的具体形态:转置存储下,一个 token 的 V 值要按「head 分量」逐个写到相隔 kv_size 的位置上,所以索引张量会膨胀 n_embd_v_gqa 倍,写入也被拆成元素级。[TAG_V_CACHE_VARIABLE] 那处 ggml_pad 则是处理层间 V 维度不一致:把较窄的层补零到缓存行宽。
掩码构造:set_input_kq_mask 在 CPU 上做的事
注意力掩码不是画出来的,是逐元素算出来的。llama_kv_cache::set_input_kq_mask 是这一块的入口:
1void llama_kv_cache::set_input_kq_mask(ggml_tensor * dst, const llama_ubatch * ubatch, bool causal_attn) const {2 const uint32_t n_tokens = ubatch->n_tokens;3
4 GGML_ASSERT(ggml_backend_buffer_is_host(dst->buffer));5
6 const int64_t n_kv = dst->ne[0];7 const int64_t n_stream = dst->ne[3]; // num streams in the current ubatch8
9 GGML_ASSERT(n_tokens%n_stream == 0);10
11 // n_tps == n_tokens_per_stream12 const int64_t n_tps = n_tokens/n_stream;13
14 // see llama_non_causal_type15 // only the SWA cache (or the SWA layers of a single cache) become non-causal16 if (!causal_attn && hparams.non_causal_type == LLAMA_NON_CAUSAL_TYPE_SWA_ONLY) {17 causal_attn = swa_type == LLAMA_SWA_TYPE_NONE;18 }19 ...20 if (dst->type == GGML_TYPE_F16) {21 set_input_kq_mask_impl<ggml_fp16_t>(args, (ggml_fp16_t *) dst->data, causal_attn);22 } else {23 set_input_kq_mask_impl<float>(args, (float *) dst->data, causal_attn);24 }25}掩码张量的形状可以从 dst->ne 读出来:第一维是 n_kv(缓存长度),第四维是 n_stream(本次 ubatch 涉及的 stream 数)。整体是 [n_kv, n_tokens, n_head, n_stream],即「每个 (查询 token, 缓存位置, 头) 组合一个允许/禁止标志」。数据类型是 f16 或 f32——用浮点存掩码是因为注意力实现把掩码加到注意力分数上:允许的位置加 0,禁止的位置加 -INFINITY。
模板化的编译期分派
set_input_kq_mask_impl 一层套一层模板,这是为了把「因果 / SWA / 二维位置 / ALiBi」这四个布尔条件变成编译期常量:
1template<typename T, bool causal, bool swa, bool is_2d, bool alibi>2static void set_input_kq_mask_impl(const args_set_input_kq_mask & args, T * data) {3 ...4 const T mask_keep = llama_cast<T>(0.0f);5 const T mask_drop = llama_cast<T>(-INFINITY);6 ...7}8
9template<typename T, bool causal, bool swa, bool is_2d>10static void set_input_kq_mask_impl(const args_set_input_kq_mask & args, T * data) {11 const bool alibi = args.hparams.use_alibi;12 if (alibi) {13 set_input_kq_mask_impl<T, causal, swa, is_2d, true> (args, data);14 } else {15 set_input_kq_mask_impl<T, causal, swa, is_2d, false>(args, data);16 }17}18// ... 另外三层同理,逐层把 is_2d / swa / causal 展开四层展开意味着最多 16 种组合,每种都是一份独立编译的循环体。为什么要这么麻烦?因为内层循环是 n_kv × n_tokens × n_head 的三重循环,对长上下文来说动辄几千万次迭代。把 if (causal) 提到编译期能省掉每次迭代的分支判断;把 swa 提到编译期能省掉「窗口大小」的比较。这是典型的「用编译期复杂度换运行期速度」。
掩码循环里的关键优化
内层循环有一段值得细看的注释与逻辑:
1const uint64_t idst = n_kv*i;2
3// for tokens of the same sequence, the mask is mostly the same, so we can reuse it4// the only cells that could change are the ones that are with similar positions as the5// ones in the batch (i.e. due to causal masking, SWA, etc.)6// keep track of those cells and shortcut the loop to save time7// note: this optimization is not compatible with Alibi position encoding8// ref: https://github.com/ggml-org/llama.cpp/pull/188429bool prev = false;10
11auto & idxs = seq_idxs[seq_id];12
13if (!alibi) {14 if (seq_srct.find(seq_id) != seq_srct.end()) {15 const uint32_t srct = seq_srct[seq_id];16 ...这段优化的原理是:同一个序列的相邻 token,掩码几乎相同。因果掩码下,位置 p 的 token 看得到 0..p,位置 p+1 的 token 看得到 0..p+1——两者只差一个位置。SWA 下,窗口滑动只影响窗口两端的少数单元。所以代码记录了「本序列上一次计算掩码的 token」以及「它与当前 token 之间可能变化的单元集合」,在内层循环里跳过那些不可能变化的单元,直接复用上一行的结果。
seq_srct 是「序列 → 源 token」的映射,seq_idxs 是每个序列在本 batch 里的 token 下标列表。这套机制在长上下文 prefill 里收益很大:一个 8192 token 的 prompt 一次算完掩码是 8192 × 8192 ≈ 6700 万次比较,加上复用能压到接近「每行只处理变化的那几个单元」。
SWA 掩码与「已填满」的判断
SWA 掩码在同一个循环里完成。核心判据是 llama_hparams::is_masked_swa:给定单元位置和各序列当前最大位置,判断该位置是否在窗口之外。在掩码层面,窗口外的位置被填 -INFINITY;在 find_slot 层面(前面那一节),同样的判据被用来判断单元能否被复用。同一个函数同时服务于「掩掉」和「回收」两件事,这是 SWA 在 llama.cpp 里最省事的一处设计。
掩码构造完之后,图里就能用上它了。注意 n_kv 用的是 get_n_kv(sinfo) 给出的保守上界,而不是满缓存长度——掩码张量也因此只覆盖 n_kv 列,未用到的部分根本不在张量里。这是前面那个启发式的第二个收益:掩码的内存和计算量都随之缩小。

上图(来源:arXiv 论文《Efficient Streaming Language Models with Attention Sinks》,图 3)展示了一个关键事实:注意力分布高度稀疏,绝大多数位置的权重接近零。这与 llama.cpp 的掩码设计呼应——无论掩码如何构造,注意力计算本身才是大头,而缓存里大量位置其实没被真正用到。站内 H2O 完全拆解:抓住 KV Cache 里的「重击者」 讲的重击者现象与 StreamingLLM 完全拆解:attention sink 与大模型的无限流式生成 讲的 attention sink 都建立在这张图的观察上。llama.cpp 不驱逐「低权重」单元,它驱逐的是「窗口外」单元——后者有明确的位置判据,前者需要统计信息。

上图(来源:arXiv 论文《Efficient Streaming Language Models with Attention Sinks》,图 4)是 attention sink 的量化结果:可学习 sink 机制如何让前几个 token 分摊注意力。放在这里的意义是说明「llama.cpp 为什么不像 H2O 那样做重击者驱逐」——滑动窗口 + 位置判据足够简单且可证明(窗口外的 token 在数学上确实不参与注意力),而基于重要性统计的驱逐需要额外的观测窗口和打分,与「纯 CPU 端侧推理,越简单越好」的目标不符。
apply_ubatch:把槽位变成真实的记账变更
prepare 里的试探写完就回滚了,真正生效的是 apply() 路径下的 apply_ubatch:
1void llama_kv_cache::apply_ubatch(const slot_info & sinfo, const llama_ubatch & ubatch) {2 // TODO: refactor [TAG_KV_CACHE_SHARE_CELLS]3 if (other) {4 return;5 }6
7 // keep track of the max sequence position that we would overwrite with this ubatch8 // for non-SWA cache, this would be always empty9 llama_seq_id seq_pos_max_rm[LLAMA_MAX_SEQ];10 for (uint32_t s = 0; s < LLAMA_MAX_SEQ; ++s) {11 seq_pos_max_rm[s] = -1;12 }13
14 assert(ubatch.n_tokens == sinfo.n_stream()*sinfo.size());15
16 for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {17 for (uint32_t ii = 0; ii < sinfo.size(); ++ii) {18 const uint32_t i = s*sinfo.size() + ii;19
20 auto & cells = v_cells[sinfo.strm[s]];21
22 const auto idx = sinfo.idxs[s][ii];23
24 if (!cells.is_empty(idx)) {25 assert(cells.seq_count(idx) == 1);26
27 const llama_seq_id seq_id = cells.seq_get(idx);28 const llama_pos pos = cells.pos_get(idx);29
30 seq_pos_max_rm[seq_id] = std::max(seq_pos_max_rm[seq_id], pos);31
32 cells.rm(idx);33 }34
35 cells.pos_set(idx, ubatch.pos[i]);36 /* ... 写 ext ... */37
38 for (int32_t s = 0; s < ubatch.n_seq_id[i]; s++) {39 cells.seq_add(idx, ubatch.seq_id[i][s]);40 }41 }42 }43
44 /* ... purge 被破坏的连续性 ... */45
46 // move the head at the end of the slot47 for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {48 auto & head = v_heads[sinfo.strm[s]];49
50 head = sinfo.idxs[s].back() + 1;51 }52}流程拆开看:
- 记录被覆盖的位置。如果目标单元非空(说明是 SWA 回收或环形绕回),取出它原本属于哪个序列、位置是多少,把最大值记进
seq_pos_max_rm[seq_id]。 - 清空该单元(
cells.rm(idx))。 - 写入新的位置、ext 和序列归属。
- purge 检查(前面那一段)。
- 推进头指针:
head = sinfo.idxs[s].back() + 1——头指针移到本批写入的最后一个单元之后。
第 5 步有一个细节:头指针被设成「最后一个写入单元 + 1」,而不是绕过整个槽位区间。如果 sinfo.idxs[s] 是 [10, 11, 12],头指针变成 13;如果它是 [10, 500, 501],头指针变成 502。这把「离散分配的碎片」也考虑进去了。
head()(slot_info 的成员)返回 idxs[0][0],用于 is_contiguous() 的判断;而写入顺序按 sinfo.idxs[s][ii] 遍历,保证 idxs 里的第 ii 个索引对应 ubatch 里第 ii 个 token。
状态:has_shift 与 stream 拷贝
update() 处理两类挂起的变更。第一类是 K-shift(前面讲过)。第二类是 stream 拷贝:
1bool updated = false;2
3auto * sched = lctx->get_sched();4
5if (!sc_info.empty()) {6 assert(n_stream > 1 && "stream copy should never happen with a single stream");7
8 llama_synchronize(lctx);9
10 const size_t n_copy = sc_info.ssrc.size();11
12 for (size_t i = 0; i < n_copy; ++i) {13 const auto ssrc = sc_info.ssrc[i];14 const auto sdst = sc_info.sdst[i];15
16 assert(ssrc < n_stream);17 assert(sdst < n_stream);18
19 LLAMA_LOG_DEBUG("%s: copying KV buffer: stream %d to stream %d\n", __func__, ssrc, sdst);20
21 assert(ssrc != sdst);22
23 for (uint32_t il = 0; il < layers.size(); ++il) {24 const auto & layer = layers[il];25
26 ggml_backend_tensor_copy(layer.k_stream[ssrc], layer.k_stream[sdst]);27
28 if (layer.v_stream[ssrc]) {29 ggml_backend_tensor_copy(layer.v_stream[ssrc], layer.v_stream[sdst]);30 }31 }32 }33}34
35if (do_shift) {36 if (!get_can_shift()) {37 GGML_ABORT("The current KV cache / model configuration does not support K-shift");38 }39 /* ... 建 K-shift 图并执行 ... */40
41 for (uint32_t s = 0; s < n_stream; ++s) {42 auto & cells = v_cells[s];43
44 cells.reset_shift();45 }46}注意 llama_synchronize(lctx) 这一句:ggml_backend_tensor_copy 直接把一个张量拷到另一个张量,如果在途的异步计算还在读源张量就会读到半截数据。所以拷贝前必须先同步。这类「异步计算与内存管理操作之间的顺序」问题,在服务端框架里对应的是 KV 传输与重算的冲突(站内 SGLang 源码完全拆解(十二):Disaggregation——Prefill 与 Decode 如何拆到两台机器、KV 如何跨实例传输 讲的是跨机的同一类问题)。
cells.reset_shift() 在 K-shift 之后清零所有单元的 shift 值——因为平移已经物理施加到张量上了。
get_can_shift 的条件
1bool llama_kv_cache::get_can_shift() const {2 // Step35 uses per-layer RoPE dims; K-shift assumes a single global n_rot.3 if (model.arch == LLM_ARCH_STEP35) {4 return false;5 }6 if (hparams.n_pos_per_embd() > 1) {7 return false;8 }9 return true;10}两个否决条件:
- 逐层不同的 RoPE 维度(Step35 这类架构):K-shift 建图时假定「全局只有一个
n_rot」,逐层不同就无法用同一张图处理所有层。 n_pos_per_embd() > 1(M-RoPE 一类的二维位置):位置不是一维的,旋转轴不明确。
不支持时,update() 里直接 GGML_ABORT——即「你调了 seq_add 但这个模型不支持」。这是一种硬失败而非静默降级,好在正常推理路径不会碰到。
位置与上下文溢出的处理
llama.cpp 在解码循环里还有一段与 seq_rm 配合的「上下文已满」逻辑:当序列位置逼近 n_ctx 时,最老的部分会被删掉(seq_rm)或整体平移(seq_add)。具体策略由 llama_context 的使用方决定——common/ 里的采样循环会调用 llama_memory_seq_rm 来丢弃最老的一段。这解释了一个常见现象:长对话跑到上下文上限后,模型的「记忆」会突然从最早的部分开始消失,因为那段 KV 被显式删掉了,而不是被新 token 挤掉。
对比 vLLM 的做法:vLLM 在上下文满时对请求做抢占(preemption)——要么换出到 CPU(swap),要么整体重算(recompute)。站内 连续批处理完全拆解:Orca 的迭代级调度与选择性批处理 讲过抢占的动机。llama.cpp 的选择更朴素:它不做换出,也不做重算,而是让调用方决定丢哪一段历史。这与它的定位一致——单机、单用户、上下文预算由用户显式给定。
状态存取:把 KV 缓存存成文件
llama_context 提供 state_save_file / state_load_file,对应的能力是「把当前对话的缓存存盘,下次直接接着生成」——这就是 llama-server 的 slot 保存、以及各种「会话持久化」功能的底层。
入口是 llama_kv_cache::state_write:
1void llama_kv_cache::state_write(llama_io_write_i & io, llama_seq_id seq_id, llama_state_seq_flags flags) const {2 /* ... 计算每个 stream 要写哪些单元区间 ... */3
4 for (uint32_t s = 0; s < n_stream; ++s) {5 ...6 // check the cell is not SWA-masked7 if (add_cell && seq_id != -1) {8 const bool is_masked = llama_hparams::is_masked_swa(n_swa, swa_type, cells.pos_get(i), cells.seq_pos_max(seq_id));9
10 add_cell = !is_masked;11 }12
13 if (add_cell) {14 ++cell_count;15 if (cell_range_begin == cells.size()) {16 cell_range_begin = i;17 }18 } else {19 if (cell_range_begin != cells.size()) {20 cr.data.emplace_back(cell_range_begin, i);21 cell_range_begin = cells.size();22 }23 }24 }25
26 if (cell_range_begin != cells.size()) {27 cr.data.emplace_back(cell_range_begin, cells.size());28 }29
30 // DEBUG CHECK: Sum of cell counts in ranges should equal the total cell count31 uint32_t cell_count_check = 0;32 for (const auto & range : cr.data) {33 cell_count_check += range.second - range.first;34 }35 GGML_ASSERT(cell_count == cell_count_check);36
37 io.write(&cell_count, sizeof(cell_count));38
39 // skip empty streams40 if (cell_count == 0) {41 continue;42 }43
44 state_write_meta(io, cr, seq_id);45 state_write_data(io, cr);46}三个要点:
其一,只写「连续区间」而不是逐个单元。 cell_ranges_t 收集的是 [from, to) 的区间列表,state_write_data 对每个区间做一次整体 IO。原因是性能:一个 32K 上下文的缓存有 32 层 × 2 张量,逐个单元写就是十万次小块 IO;按区间写通常只有几次大块 IO。
其二,SWA 掩码的单元不写。 上面那段 is_masked_swa 的判断:滑动窗口外的单元根本不进状态文件。因为它们在恢复之后也不会参与注意力(同一个窗口判据会再次生效),存了也是浪费。这就是「SWA 缓存的状态文件比全注意力缓存小得多」的原因——一个 128K 上下文的 SWA 模型,状态文件可能只有窗口大小(几 K token)那么多。
其三,写入前有一段自检:GGML_ASSERT(cell_count == cell_count_check),确保「按区间统计的单元数」等于「逐个统计的单元数」。这是防区间收集逻辑出错的保险。
元信息与数据分开写
1void llama_kv_cache::state_write_meta(llama_io_write_i & io, const cell_ranges_t & cr, llama_seq_id seq_id) const {2 const auto & cells = v_cells[cr.strm];3
4 for (const auto & range : cr.data) {5 for (uint32_t i = range.first; i < range.second; ++i) {6 std::vector<llama_seq_id> seq_ids;7
8 for (llama_seq_id cur = 0; cur < (int) n_seq_max; ++cur) {9 if (cur == seq_id || seq_id == -1) {10 if (cells.seq_has(i, cur)) {11 seq_ids.push_back(cur);12 }13 }14 }15
16 const llama_pos pos = cells.pos_get(i);17 const uint32_t n_seq_id = seq_ids.size();18
19 io.write(&pos, sizeof(pos));20 io.write(&n_seq_id, sizeof(n_seq_id));21
22 if (has_cell_ext()) {23 const llama_kv_cell_ext ext = cells.ext_get(i);24 io.write(&ext, sizeof(ext));25 }26
27 for (const auto & seq_id : seq_ids) {28 io.write(&seq_id, sizeof(seq_id));29 }30 }31 }32}33
34void llama_kv_cache::state_write_data(llama_io_write_i & io, const cell_ranges_t & cr) const {35 const auto & cells = v_cells[cr.strm];36
37 const uint32_t v_trans = this->v_trans ? 1 : 0;38 const uint32_t n_layer = layers.size();39
40 io.write(&v_trans, sizeof(v_trans));41 io.write(&n_layer, sizeof(n_layer));42 io.write(&n_rot_k, sizeof(n_rot_k));43 io.write(&n_rot_v, sizeof(n_rot_v));44
45 // Iterate and write all the keys first, each row is a cell46 // Get whole range at a time47 for (const auto & layer : layers) {48 const uint32_t il = layer.il;49 ...每个单元写 pos + n_seq_id + (可能的)ext + 若干 seq_id。数据部分则先写四个头字段:
v_trans:V 是否转置。恢复时必须一致,否则张量布局不匹配。n_layer:层数。不一致说明模型换了。n_rot_k/n_rot_v:K/V 的旋转维数(Hadamard 旋转路径用)。恢复时必须一致,否则旋转坐标系不匹配。
这四个字段在 state_read_data 里被逐一校验,任一不符就报错返回:
1if (n_layer != layers.size()) {2 LLAMA_LOG_ERROR("%s: mismatched layer count (%u instead of %u)\n", __func__, n_layer, (uint32_t) layers.size());3 return false;4}5
6if (cell_count > cells.size()) {7 LLAMA_LOG_ERROR("%s: not enough cells in kv cache to restore state (%u > %u)\n", __func__, cell_count, cells.size());8 return false;9}10
11if (this->v_trans != (bool) v_trans) {12 LLAMA_LOG_ERROR("%s: incompatible V transposition\n", __func__);13 return false;14}15
16if (n_rot_k_ref != n_rot_k) {17 LLAMA_LOG_ERROR("%s: incompatible key rotation (%u instead of %u)\n", __func__, n_rot_k_ref, n_rot_k);18 return false;19}20
21if (n_rot_v_ref != n_rot_v) {22 LLAMA_LOG_ERROR("%s: incompatible value rotation (%u instead of %u)\n", __func__, n_rot_v_ref, n_rot_v);23 return false;24}这解释了一个实际的坑:用 -fa 保存的缓存不能用 -no-fa 恢复(v_trans 不同),开了 -ctk q4_0 保存的缓存不能用默认类型恢复(K 的量化类型与旋转维数可能不同)。状态文件的兼容性不只是「模型一致」,还包括「缓存配置一致」。
恢复:先找槽位,再写入
state_read_sinfo 是恢复的入口,它比写出更复杂,因为要处理「恢复到哪里」:
1void llama_kv_cache::state_read_sinfo(2 llama_io_read_i & io,3 llama_seq_id seq_id,4 llama_state_seq_flags flags,5 slot_info_vec_t * sinfos_out,6const slot_info_vec_t * sinfos_in) {7 if (other) {8 return;9 }10
11 if ((flags & LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY) && swa_type == LLAMA_SWA_TYPE_NONE) {12 return;13 }14
15 if (sinfos_out) {16 sinfos_out->assign(n_stream, slot_info{});17 }18
19 if (sinfos_in && sinfos_in->size() != n_stream) {20 throw std::runtime_error("failed to restore kv cache: mirrored slot layout has the wrong stream count");21 }22
23 uint32_t n_stream_cur;24 io.read(&n_stream_cur, sizeof(n_stream_cur));25 if (n_stream_cur != n_stream) {26 throw std::runtime_error("n_stream mismatch");27 }28
29 // a whole-context restore replaces every stream, so the cache is emptied once here30 // clear() resets all streams at once, so doing it per stream below would keep only the last one31 if (seq_id == -1) {32 clear(true);33 }34
35 for (uint32_t s = 0; s < n_stream; ++s) {36 uint32_t cell_count;37 io.read(&cell_count, sizeof(cell_count));38
39 if (cell_count == 0) {40 // a mirrored cache must be empty here as well, or the two no longer agree cell for cell41 if (sinfos_in && !(*sinfos_in)[s].empty()) {42 throw std::runtime_error("failed to restore kv cache: mirrored cache holds cells this one does not");43 }44 continue;45 }46
47 const uint32_t strm = seq_id == -1 ? s : seq_to_stream[seq_id];48
49 slot_info sinfo;50
51 bool res = true;52 res = res && state_read_meta(io, strm, cell_count, sinfo, seq_id, sinfos_in ? &(*sinfos_in)[s] : nullptr);53
54 try {55 res = res && state_read_data(io, strm, cell_count, sinfo);56 } catch (...) {57 res = false;58 }59
60 if (!res) {61 state_clear(seq_id, strm, sinfo);62 throw std::runtime_error("failed to restore kv cache");63 }64
65 if (sinfos_out) {66 (*sinfos_out)[s] = sinfo;67 }68 }69}state_read_meta 里恢复单个序列时,会先清掉目标序列的旧数据,再用 find_slot 找位置:
1if (dest_seq_id != -1) {2 // single sequence3 if (cell_count > cells.size()) {4 LLAMA_LOG_ERROR("%s: not enough cells in kv cache\n", __func__);5 return false;6 }7
8 seq_rm(dest_seq_id, -1, -1);9
10 llama_batch_allocr balloc(hparams.n_pos_per_embd());11
12 llama_ubatch ubatch = balloc.ubatch_reserve(cell_count, 1);13
14 ubatch.seq_id_unq[0] = dest_seq_id;15 ...16 } else {17 sinfo = find_slot(ubatch, false);18 if (sinfo.empty()) {19 LLAMA_LOG_ERROR("%s: failed to find %d available cells in kv cache\n", __func__, cell_count);20 return false;21 }22 }这里有一个巧妙的复用:state_read_meta 构造了一个假的 ubatch(只填位置和序列 id,没有真实 token),然后用 apply_ubatch 把它写进记账。也就是说「恢复」走的是与正常推理完全相同的记账路径——不需要为恢复单独写一套单元维护逻辑。
镜像缓存:sinfos_in 的用途
state_read_sinfo 的最后两个参数 sinfos_out / sinfos_in 是给投机解码准备的。注释说得很清楚:
1// state_read, plus the cells the restored tokens were placed in2// a cache that mirrors another one (the qwen4exp indexer) must not search for its own cells: two searches agree only by luck3// sinfos_out: if set, filled with the layout used; a stream with no cells leaves an empty entry4// sinfos_in : if set, the layout to use instead of searching. one entry per stream, cell count must match the blob同一段代码里还有对应的校验:
1if (sinfo_in) {2 // this cache mirrors another one, so it takes that cache's layout instead of searching for its own cells3 if (sinfo_in->empty() || sinfo_in->n_stream() != 1 || sinfo_in->idxs[0].size() != cell_count) {4 LLAMA_LOG_ERROR("%s: mirrored slot layout holds %d cells, this cache restores %d\n", __func__,5 sinfo_in->empty() ? 0 : (int) sinfo_in->idxs[0].size(), cell_count);6 return false;7 }8
9 sinfo = *sinfo_in;10
11 // the layout is cell indices, so it means the same in both caches only while their streams line up12 sinfo.s0 = strm;13 sinfo.s1 = strm;14 sinfo.strm[0] = strm;15
16 // seq_rm above freed exactly the cells this sequence held17 // anything else in the way is a cache that had already drifted, which this restore must not hide18 for (uint32_t i = 0; i < cell_count; ++i) {19 const uint32_t idx = sinfo.idxs[0][i];20
21 if (idx >= cells.size() || !cells.is_empty(idx)) {22 LLAMA_LOG_ERROR("%s: cell %u of the mirrored slot layout is not free\n", __func__, idx);23 return false;24 }25 }26}场景是这样的:模型里存在两个必须逐单元对齐的缓存(注释里举的例子是 Qwen 的稀疏注意力索引器缓存与主注意力缓存)。恢复时如果两个缓存各自 find_slot,几乎不可能得到相同的单元布局——「两次搜索只在运气好时才一致」。所以做法是:第一个缓存恢复时记录它用的布局(sinfos_out),第二个缓存直接把这份布局拿来用(sinfos_in)并逐单元校验「这些格子是空的」。
这比「两个缓存各自搜索再祈祷一致」靠谱得多,代价是恢复顺序被固定:必须先恢复主缓存再恢复镜像缓存。投机解码里 draft 与 target 的缓存对齐(站内 SGLang 源码完全拆解(十一):投机解码——草稿生成、批量验证与 token 接受如何接进同一条前向)面对的是同类约束。
失败回滚
state_read_sinfo 在失败路径上有两个动作:先 state_clear(seq_id, strm, sinfo) 把这次尝试恢复的单元清掉,再抛异常。对应的 state_read_data 里也能看到镜像的清理逻辑:
1// the cleared ranges mirror the write pattern of state_read_data() - keep both in sync2...3seq_rm(seq_id, -1, -1);注释点出了维护要点:清理的粒度必须与写入的粒度一致,否则会出现「恢复失败后留下半截数据」的状态。
交错滑动窗口:为什么要两个 KV 缓存
Gemma 3、gpt-oss、DeepSeek-V4 这类模型采用了交错滑动窗口(interleaved SWA / iSWA):模型的一部分层用全注意力(能看整个上下文),另一部分层用滑动窗口注意力(只看最近 n_swa 个位置)。上一节讲的 SWA 掩码适用于「整个模型都是 SWA」的情况;交错模型不能用同一份缓存,因为两类的层需要不同的容量。
llama.cpp 的解法是 llama_kv_cache_iswa——一个对象,内部持有两个 llama_kv_cache:
1const layer_filter_cb filter_base = [&](int32_t il) {2 if (filter && !filter(il)) {3 return false;4 }5
6 return !model.hparams.is_swa(il);7};8
9const layer_filter_cb filter_swa = [&](int32_t il) {10 if (filter && !filter(il)) {11 return false;12 }13
14 return model.hparams.is_swa(il);15};16
17const uint32_t size_base = kv_size;18
19// note: the SWA cache is always padded to 256 for performance20// https://github.com/ggml-org/llama.cpp/issues/1703721uint32_t size_swa = GGML_PAD(std::min(size_base, hparams.n_swa*(unified ? n_seq_max : 1) + n_ubatch), 256);22
23// when using full-size SWA cache, we set the SWA cache size to be equal to the base cache size24if (swa_full) {25 LLAMA_LOG_WARN("%s: using full-size SWA cache (ref: %s)\n",26 __func__, "https://github.com/ggml-org/llama.cpp/pull/13194#issuecomment-2868343055");27
28 size_swa = size_base;29}30
31kv_base = std::make_unique<llama_kv_cache>(32 model, hparams, type_k, type_v,33 v_trans, offload, unified, size_base, n_seq_max, n_pad,34 0, LLAMA_SWA_TYPE_NONE, mem_other_base, filter_base, reuse, share);35
36kv_swa = std::make_unique<llama_kv_cache>(37 model, hparams, type_k, type_v,38 v_trans, offload, unified, size_swa, n_seq_max, n_pad,39 hparams.n_swa, hparams.swa_type, mem_other_swa, filter_swa, reuse, share);三个关键点:
其一,两个缓存用同一个 llama_kv_cache 类,差别只在参数。 kv_base 传 n_swa = 0、swa_type = LLAMA_SWA_TYPE_NONE(无滑动窗口,全容量 size_base = kv_size);kv_swa 传真实的 n_swa 与 swa_type,容量只有 size_swa。同一份代码同时服务两种层,靠的就是构造函数那一长串参数(也解释了为什么这个构造函数参数这么多)。
其二,SWA 缓存的大小公式:
size_swa=PAD256(min(size_base,nswa×(unified?nseq_max:1)+nubatch))逐项解释:
n_swa:滑动窗口大小(token 数)。unified ? n_seq_max : 1:unified 模式下所有序列共享一个窗口空间,所以需要为n_seq_max条序列各留一个窗口;非 unified 模式下每个 stream 有自己的缓存,只需要一个窗口的量。+ n_ubatch:多留一个微批的余量。因为find_slot在窗口滑动的过程中需要「新写入n_ubatch个 token,同时回收等量旧单元」,两者之间有一个短暂的共存期——新单元还没写完时旧单元不能提前释放。余量保证不会出现「要写但没地方写」。min(size_base, ...):SWA 缓存不需要比全注意力缓存更大。PAD_256:与n_ctx同样的 256 对齐原因(块量化)。
举例:n_swa = 4096、n_ubatch = 512、单序列 unified,则 size_swa = PAD256(4096 + 512) = 4608 单元。相比 size_base = 32768,SWA 缓存只有 14% 的容量——交错模型的内存收益就来自这里:如果 32 层里有 24 层是 SWA,那 24 层的缓存从 32768 降到 4608,整体 KV 占用能省掉 60% 以上。
其三,swa_full 开关。params.swa_full(命令行 --swa-full)把 SWA 缓存也开成全尺寸。为什么要这个选项?因为小的 SWA 缓存虽然省内存,但每次窗口滑动都要回收和重写单元,带来额外的记账开销与潜在的 find_slot 失败。在内存充裕、追求性能时,全尺寸 SWA 缓存可以让滑动退化成纯粹的环形覆盖,减少管理开销。注释里给了 PR 链接作为出处,日志级别是 WARN 表示「这是一个需要用户知情的非常规配置」。
接口的批量转发
llama_kv_cache_iswa 实现 llama_memory_i 的方式,是把每个操作同时施加到两个缓存上:
1bool llama_kv_cache_iswa::seq_rm(llama_seq_id seq_id, llama_pos p0, llama_pos p1) {2 bool res = true;3
4 res = res & kv_base->seq_rm(seq_id, p0, p1);5 res = res & kv_swa ->seq_rm(seq_id, p0, p1);6
7 return res;8}clear、seq_cp、seq_keep、seq_add、seq_div 都是这个模式。这种「组合式实现」正是 llama_memory_i 抽象的价值:上下文层不需要知道底层是一个缓存还是两个,只要接口语义对就行。
init_batch 则不同——它必须同时在两个缓存里为新批次找到位置,而且要保证两边都成功:
1llama_memory_context_ptr llama_kv_cache_iswa::init_batch(...) {2 /* 先让 base 缓存找位置,再让 swa 缓存找;任一边失败则整体失败 */3}对应的上下文对象是 llama_kv_cache_iswa_context,它内部持有两个 llama_kv_cache_context,get_ubatch() / apply() / next() 也要在两边同步推进。图构建时,llm_graph_input_attn_kv_iswa 会同时从两个缓存取 K/V 视图,按层归属选择:SWA 层用 SWA 缓存的视图,其余层用 base 缓存的视图。
这套结构在混合架构上会再叠一层。create_memory 里可以看到 llama_memory_hybrid_iswa——它把「注意力 + 循环」的混合与「全注意力 + SWA」的交错组合起来:
1if (hparams.swa_type != LLAMA_SWA_TYPE_NONE) {2 // Use hybrid-iswa for hybrid models with SWA3 res = new llama_memory_hybrid_iswa(4 /* model */ *this,5 /* attn_type_k */ params.type_k,6 /* attn_type_v */ params.type_v,7 /* attn_v_trans */ !cparams.flash_attn,8 /* attn_swa_full */ params.swa_full,9 /* attn_kv_size */ cparams.n_ctx_seq,10 /* attn_n_ubatch */ cparams.n_ubatch,11 /* attn_n_pad */ 1,12 /* recurrent_type_r */ GGML_TYPE_F32,13 /* recurrent_type_s */ GGML_TYPE_F32,14 /* recurrent_rs_size */ std::max((uint32_t) 1, cparams.n_seq_max),15 /* n_seq_max */ cparams.n_seq_max,16 /* n_rs_seq */ cparams.n_rs_seq,17 /* offload */ cparams.offload_kqv,18 /* unified */ cparams.kv_unified,19 /* filter_attn */ std::move(filter_attn),20 /* filter_recr */ std::move(filter_recr));21}注意 recurrent_type_r/recurrent_type_s 都是 GGML_TYPE_F32:循环状态不用量化。原因是循环状态是一小块固定大小的矩阵(通常是 [n_embd, n_embd] 或更小),量化它省不了多少内存,却会累积每一轮迭代的误差——循环状态是自反馈的,误差会随步数放大。KV 缓存每轮都是「新写入 + 读取」,不存在累加放大,所以量化相对安全。这条区别是「KV 缓存量化」与「循环状态」在工程上被区别对待的根本原因。
与站内其他框架的对照
把 llama.cpp 的记忆系统与已讲过的两个服务端框架放在一起,能看出三条路线的取舍:
| 维度 | llama.cpp | vLLM | SGLang |
|---|---|---|---|
| 缓存抽象 | llama_memory_i(含循环状态、SWA、镜像) | KVCacheManager(只服务注意力) | KVPool + RadixCache |
| 物理布局 | 每层一张 [head_dim, kv_size, n_stream] 连续张量 | 物理块池 + 块表 | 页池 + 页表 |
| 共享粒度 | 单元级引用计数(unified)/ stream 级拷贝 | 块级写时复制 | 前缀树节点共享 |
| 前缀复用 | 靠 seq_cp 手工复制/引用 | 靠块表前缀命中 | RadixAttention 自动最长前缀匹配 |
| 回收策略 | 环形头指针 + SWA 掩码 + purge | 驱逐策略(LRU 等) | LRU + 前缀树 |
| 上下文压缩 | seq_rm / seq_div 由调用方决定 | 抢占(swap / recompute) | 抢占 + HiCache 三级缓存 |
差异的根源是定位:vLLM 与 SGLang 面向「多用户、高吞吐、集中调度」,缓存必须能自由拼装、能跨请求复用、能被抢占;llama.cpp 面向「单机、低并发、可预测」,缓存只需要简单、省内存、跨后端一致。
值得单独一提的是 llama.cpp 没有前缀缓存。它不做「自动识别两条请求的公共前缀并复用 KV」——多序列通过 seq_cp 显式共享,前缀复用的责任在调用方。这也是 llama-server 里「prompt cache」功能需要应用层配合的原因。站内 RadixAttention 完全拆解:用前缀树自动复用 KV Cache,让共享前缀只计算一次 讲的是自动化的那一端;前缀缓存淘汰完全拆解:为什么 14 种复杂算法赢不了 LRU 则解释了前缀缓存的淘汰策略为何重要。
小结
这一篇把 llama.cpp 的运行时记忆系统完整走了一遍。要点重新串一次:
- 预算的三个数字:
n_ctx(总容量,对齐 256)、n_ctx_seq(每序列容量,unified 时等于n_ctx)、n_seq_max/n_stream(序列数与物理分片数,非 unified 时一一对应)。它们决定了缓存张量的形状,也决定了「多条序列能否互相借用空间」。 - 记忆抽象:
llama_memory_i把 KV 缓存、循环状态、SWA 交错、镜像缓存统一到一组接口上;layer_filter_cb/layer_reuse_cb/layer_share_cb三个回调负责描述「哪些层属于这个记忆」。llama_memory_context_i用next/apply实现两阶段提交,使得「整批放不下」能干净地失败而不留下半截状态。 - 物理布局:每层一张
[n_embd_k_gqa, kv_size, n_stream]三维张量,stream 之间用ggml_view_2d切片;V 缓存只在非 Flash Attention 路径下转置存储;MLA 层没有独立的 V 缓存。 - 记账结构:
llama_kv_cells用pos(-1 表示空)+seq位图 +shift+ext+used集合 + 每序列的位置集合,既表达「谁占了这个单元」也表达「这条序列有哪些位置」,后者的 O(1) 最小值查询是apply_ubatchpurge 逻辑的前提。 - 分配机制:
find_slot用头指针做环形扫描,可用单元有三类(空、单序列所有且被 SWA 掩掉、被多序列引用则不可用);apply_ubatch写入时记录被覆盖的最大位置,并 purge 掉被破坏的位置连续性,保证「每条序列在缓存里的位置始终是连续区间」。 - 掩码与写入:
set_input_kq_mask在 CPU 上逐元素构造掩码,用模板展开四种模式组合、用「相邻 token 掩码几乎相同」的观察做行间复用;cpy_k/cpy_v用ggml_set_rows做散射写入,多 stream 时把张量 reshape 成二维以便用全局单元号索引。 - 状态存取:只写连续区间、只写 SWA 窗口内的单元、元信息中带
v_trans/n_layer/n_rot_k/n_rot_v四个兼容性字段;恢复时通过构造假 ubatch 复用正常记账路径,镜像缓存用sinfos_in强制对齐布局。 - SWA 与交错:
llama_kv_cache_iswa用一个对象持两个缓存,SWA 容量按n_swa × 序列数 + n_ubatch计算;--swa-full可换成全尺寸以减少管理开销。
用一句话概括这套设计的性格:它把复杂性集中在记账层(llama_kv_cells + find_slot + purge),而在数据层保持极简(连续张量、无块表、无间接寻址)。这个取舍让注意力 kernel 在五种后端上都只需要处理「连续张量 + 一个掩码」,代价是分配器必须自己处理碎片与连续性——这正是 llama-kv-cache.cpp 那 99 KB 代码在做的事。
下一篇进入 llama.cpp 源码完全拆解(五):图构建与注意力——llama-graph 如何把模型结构翻译成 ggml 图。前面的铺垫已经足够:第二篇讲了 ggml_cgraph 与调度器,第三篇讲了权重装载,这一篇讲了记忆系统。下一篇要看的是 src/llama-graph.cpp 与各模型架构的图构建实现:llm_graph_params 携带哪些信息进入建图、llm_graph_input_attn_kv 如何把这一篇讲的 K/V 视图和掩码接进注意力算子、MoE 层如何组织 MUL_MAT_ID、以及 MLA 与稀疏注意力(DSA)在图上长什么样。
参考资料
- llama.cpp 官方仓库(src/ 与 ggml/ 源码)
src/llama-context.cpp:上下文构造、decode主循环、sched_reserve与process_ubatchsrc/llama-context.h:llama_context的成员与接口定义src/llama-kv-cache.cpp:KV 缓存构造、find_slot、apply_ubatch、set_input_kq_mask与状态存取src/llama-kv-cache.h:llama_kv_cache与slot_info接口src/llama-kv-cache-iswa.cpp:交错滑动窗口的两个缓存如何组合src/llama-kv-cells.h:llama_kv_cells的单元记账实现src/llama-memory.h:llama_memory_i与llama_memory_context_i抽象接口src/llama-batch.h:llama_ubatch结构与批次切分接口src/llama-cparams.h:llama_cparams全部运行时参数src/llama-model.cpp:create_memory如何按架构选择记忆实现- Efficient Memory Management for Large Language Model Serving with PagedAttention
- vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention
- KVQuant: Towards 10 Million Context Length LLM Inference with KV Cache Quantization
- Efficient Streaming Language Models with Attention Sinks
- llama.cpp 官方文档:KV 缓存量化与上下文参数说明
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



