llama.cpp 源码完全拆解(四):llama_context 与记忆系统——KV Cache、slot 与批处理如何组织一次解码

22112 字
111 分钟
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 回收把浪费压住。

PagedAttention 系统概览:块表如何把逻辑连续的 KV 映射到不连续的物理块
PagedAttention 系统概览:块表如何把逻辑连续的 KV 映射到不连续的物理块

上图(来源: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 直接按这个下标切片。少了一层翻译,代价是分配策略必须更小心。

vAttention 的地址转换示意:虚拟内存如何把非连续物理页呈现为连续 KV
vAttention 的地址转换示意:虚拟内存如何把非连续物理页呈现为连续 KV

上图(来源: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 指定上下文长度。构造函数的处理是:

cparams.n_ctx = params.n_ctx == 0 ? hparams.n_ctx_train : params.n_ctx;

n_ctx == 0 表示「用模型训练时的上下文长度」,这个值来自 GGUF 里的 n_ctx_train 元数据。接下来有一个容易被忽略的对齐:

// ref: https://github.com/ggml-org/llama.cpp/pull/17046#discussion_r2503085732
cparams.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)。关键分支在这里:

if (cparams.kv_unified) {
cparams.n_ctx_seq = cparams.n_ctx;
} else {
cparams.n_ctx_seq = cparams.n_ctx / cparams.n_seq_max;
cparams.n_ctx_seq = GGML_PAD(cparams.n_ctx_seq, 256);
if (cparams.n_ctx_seq == 0) {
throw std::runtime_error("n_ctx_seq == 0");
}
if (cparams.n_ctx != cparams.n_ctx_seq * cparams.n_seq_max) {
cparams.n_ctx = cparams.n_ctx_seq * cparams.n_seq_max;
LLAMA_LOG_WARN("%s: n_ctx is not divisible by n_seq_max - rounding down to %u\n", __func__, cparams.n_ctx);
}
}
  • 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 的构造函数里:

n_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 里:

uint32_t n_tokens = ubatch.n_tokens;
uint32_t n_seqs = 1;
if (n_stream > 1) {
GGML_ASSERT(n_tokens % ubatch.n_seqs_unq == 0);
n_seqs = ubatch.n_seqs_unq;
n_tokens = n_tokens / n_seqs;
}

非 unified 模式下,一个 ubatch 里的 token 被按序列等分,每个序列在自己的 stream 里找槽位。

n_batch 与 n_ubatch:逻辑批次与物理微批

另外一个影响内存的数字是 n_batch:

// with causal attention, the batch size is limited by the context size
cparams.n_batch = cparams.causal_attn ? std::min(cparams.n_ctx, params.n_batch) : params.n_batch;
cparams.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 构造函数里除了记忆模块,还有一个必须一起看的分配:

// graph outputs buffer
{
if (output_reserve(params.n_seq_max) < params.n_seq_max) {
throw std::runtime_error("failed to reserve initial output buffer");
}
LLAMA_LOG_INFO("%s: %10s output buffer size = %8.2f MiB\n", __func__,
ggml_backend_buffer_name (buf_output.get()),
ggml_backend_buffer_get_size(buf_output.get()) / 1024.0 / 1024.0);
}

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 GB32 层 × 32768 单元 × 2(K/V)× 1024 元素 × 1.0625 字节
计算缓冲区≈ 0.3–1 GB按 n_ubatch 预留,注意力中间量是大头
输出缓冲区≈ 0.6 MBn_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):

struct llama_memory_i {
using layer_filter_cb = std::function<bool(int32_t il)>;
using layer_reuse_cb = std::function<int32_t(int32_t il)>;
using layer_share_cb = std::function<int32_t(int32_t il)>;
// split the input batch into a set of ubatches and verify that they can fit into the cache
virtual llama_memory_context_ptr init_batch(
llama_batch_allocr & balloc,
uint32_t n_ubatch,
bool embd_all) = 0;
// simulate full cache, used for allocating worst-case compute buffers
virtual llama_memory_context_ptr init_full() = 0;
// prepare for any pending memory updates, such as shifts, copies, etc.
virtual llama_memory_context_ptr init_update(llama_context * lctx, bool optimize) = 0;
virtual bool get_can_shift() const = 0;
virtual void clear(bool data) = 0;
virtual bool seq_rm (llama_seq_id seq_id, llama_pos p0, llama_pos p1) = 0;
virtual void seq_cp (llama_seq_id seq_id_src, llama_seq_id seq_id_dst, llama_pos p0, llama_pos p1) = 0;
virtual void seq_keep(llama_seq_id seq_id) = 0;
virtual void seq_add (llama_seq_id seq_id, llama_pos p0, llama_pos p1, llama_pos shift) = 0;
virtual void seq_div (llama_seq_id seq_id, llama_pos p0, llama_pos p1, int d) = 0;
virtual llama_pos seq_pos_min(llama_seq_id seq_id) const = 0;
virtual llama_pos seq_pos_max(llama_seq_id seq_id) const = 0;
virtual std::map<ggml_backend_buffer_type_t, size_t> memory_breakdown() const = 0;
virtual void state_write(llama_io_write_i & io, llama_seq_id seq_id = -1, llama_state_seq_flags flags = 0) const = 0;
virtual void state_read (llama_io_read_i & io, llama_seq_id seq_id = -1, llama_state_seq_flags flags = 0) = 0;
};

接口的每一组方法都对应一类真实需求:

三个 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() 里?接口注释写得很明确:

// the only method that should mutate the memory and the memory context is llama_memory_i::apply()
struct llama_memory_context_i {
virtual ~llama_memory_context_i() = default;
// consume the current ubatch from the context and proceed to the next one
virtual bool next() = 0;
// apply the memory state for the current ubatch to the memory object
virtual bool apply() = 0;
virtual const llama_ubatch & get_ubatch() const = 0;
virtual llama_memory_status get_status() const = 0;
};

这是两阶段提交。看 llama_kv_cache::prepare 就明白为什么必须这么做:

llama_kv_cache::slot_info_vec_t llama_kv_cache::prepare(const std::vector<llama_ubatch> & ubatches) {
llama_kv_cache::slot_info_vec_t res;
struct state_t {
slot_info sinfo;
std::vector<uint32_t> v_heads_old;
std::vector<llama_kv_cells> v_cells;
};
// remember the old state of the cells so we can restore it in the end
std::vector<state_t> states;
bool success = true;
for (const auto & ubatch : ubatches) {
// only find a suitable slot for the ubatch. don't modify the cells yet
const auto sinfo_new = find_slot(ubatch, false);
if (sinfo_new.empty()) {
success = false;
break;
}
res.push_back(sinfo_new);
// store the old state of the cells in the recovery stack
{
state_t state = { sinfo_new, v_heads, {} };
for (uint32_t s = 0; s < sinfo_new.n_stream(); ++s) {
auto & cells = v_cells[sinfo_new.strm[s]];
state.v_cells.push_back(cells.cp(sinfo_new.idxs[s]));
}
states.push_back(std::move(state));
}
// now emplace the ubatch
apply_ubatch(sinfo_new, ubatch);
}
// iterate backwards and restore the cells to their original state
for (auto it = states.rbegin(); it != states.rend(); ++it) {
const auto & sinfo = it->sinfo;
for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {
auto & cells = v_cells[sinfo.strm[s]];
auto & head = v_heads[sinfo.strm[s]];
cells.set(sinfo.idxs[s], it->v_cells[s]);
head = it->v_heads_old[s];
}
}
if (!success) {
return {};
}
return res;
}

读懂这段代码的关键是「试探—记录—回滚」三步:

  1. 对每个 ubatch 调 find_slot(此时只找位置,不写单元)。
  2. 在 apply_ubatch 真正写入之前,先把受影响的单元的旧状态(位置、序列归属)和 stream 的头指针存进 states。
  3. 全部 ubatch 都找到槽位之后,倒着遍历 states 把单元恢复原状。

也就是说,prepare 结束时单元数组的值与调用前完全一样——它只是在「推演」如果把整批 token 都放进去,能不能放下。真正的写入发生在稍后 llama_kv_cache_context::apply() 里:

bool 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 里的处理正好配合这个语义:

case LLAMA_MEMORY_STATUS_FAILED_PREPARE:
{
if (!did_optimize) {
did_optimize = true;
if (memory_update(true)) {
LLAMA_LOG_DEBUG("%s: retrying batch size %d after cache optimization\n", __func__, balloc->get_n_tokens());
continue;
}
}
LLAMA_LOG_WARN("%s: failed to find a memory slot for batch of size %d\n", __func__, balloc->get_n_tokens());
return 1;
}

第一次失败时先试一次 memory_update(true)——这是「优化」模式,会清理掉那些已不被任何序列引用的单元并做一次紧凑化(defrag),然后再重试一次。仍然失败才返回 1(注意:返回 1 表示「空间不足」,返回 -1/-2 表示「参数错误或计算失败」)。这套「失败—优化—重试」的结构在服务端表现为:用户看到的一次失败请求之后,下一次同样的请求可能就成功了。

llama_memory_context_i 在 decode 循环里的位置

llama_context::decode 的主干(src/llama-context.cpp)把上面这些接口串起来:

sched_reserve();
bool did_optimize = false;
// handle any pending shifts/copies
memory_update(false);
llama_memory_context_ptr mctx;
while (true) {
mctx = memory->init_batch(*balloc, cparams.n_ubatch, embd_all);
if (!mctx) {
return -2;
}
switch (mctx->get_status()) {
case LLAMA_MEMORY_STATUS_SUCCESS: { } break;
case LLAMA_MEMORY_STATUS_FAILED_PREPARE: /* 优化后重试 */ break;
/* ... */
}
break;
}

然后是逐 ubatch 的循环:

do {
const auto & ubatch = mctx->get_ubatch();
/* 统计本 ubatch 的输出数 */
...
auto * res = process_ubatch(ubatch, ctx_type_to_graph_type(cparams.ctx_type), mctx.get(), ret);
...
} while (mctx->next());

顺序上有一处容易读错:mctx->next() 推进到下一个 ubatch,而 apply() 是在 process_ubatch 的开头被调用的:

llm_graph_result * llama_context::process_ubatch(const llama_ubatch & ubatch, llm_graph_type gtype, llama_memory_context_i * mctx, ggml_status & ret) {
if (mctx && !mctx->apply()) {
LLAMA_LOG_ERROR("%s: failed to apply memory context\n", __func__);
ret = GGML_STATUS_FAILED;
return nullptr;
}
...
}

所以每个 ubatch 的时序是:取出 ubatch → 把它写进缓存(apply)→ 用这个缓存状态建图并计算。「先写入再计算」的顺序是必要的:注意力要查的 KV 必须已经在缓存里。

KV 缓存的物理布局:一层的缓存长什么样

llama_kv_cache 的构造函数(src/llama-kv-cache.cpp)是理解布局的最好入口。它的整体结构是:遍历模型的每一层,为需要缓存的层创建一对 K/V 张量,把张量按缓冲区类型(buffer type)分组放进不同的 ggml context,最后一次性分配。

为什么每层一张三维张量

核心的那几行:

ggml_context * ctx = ctx_for_buft(buft);
if (!ctx) {
throw std::runtime_error("failed to create ggml context for kv cache");
}
const bool has_k = true;
const bool has_v = !is_mla;
ggml_tensor * k = has_k ? ggml_new_tensor_3d(ctx, type_k, n_embd_k_gqa, kv_size, n_stream) : nullptr;
ggml_tensor * v = has_v ? ggml_new_tensor_3d(ctx, type_v, n_embd_v_gqa, kv_size, n_stream) : nullptr;
has_k && ggml_format_name(k, "cache_%sk_l%d", name_tag, il);
has_v && ggml_format_name(v, "cache_%sv_l%d", name_tag, il);
std::vector<ggml_tensor *> k_stream;
std::vector<ggml_tensor *> v_stream;
for (uint32_t s = 0; s < n_stream; ++s) {
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);
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);
}
map_layer_ids[il] = layers.size();
layers.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:

v_trans(v_trans),

它的值来自调用方:!cparams.flash_attn。也就是说 V 缓存在不启用 Flash Attention 时是转置存储的。相关代码在 get_v:

ggml_tensor * llama_kv_cache::get_v(ggml_context * ctx, int32_t il, uint32_t n_kv, const slot_info & sinfo) const {
const uint64_t kv_size = get_size();
const uint64_t n_embd_v_gqa = v->ne[0];
const uint32_t ns = sinfo.s1 - sinfo.s0 + 1;
if (!v_trans) {
// note: v->nb[1] <= v->nb[2]
return ggml_view_4d(ctx, v,
hparams.n_embd_head_v(il), hparams.n_head_kv(il), n_kv, ns,
ggml_row_size(v->type, hparams.n_embd_head_v(il)), // v->nb[1]
ggml_row_size(v->type, n_embd_v_gqa), // v->nb[2]
ggml_row_size(v->type, n_embd_v_gqa*kv_size), // v->nb[3]
ggml_row_size(v->type, n_embd_v_gqa*kv_size)*sinfo.s0);
}
// note: v->nb[1] > v->nb[2]
return ggml_view_4d(ctx, v,
n_kv, hparams.n_head_kv(il), hparams.n_embd_head_v(il), ns,
ggml_row_size(v->type, kv_size*hparams.n_embd_head_v(il)), // v->nb[1]
ggml_row_size(v->type, kv_size), // v->nb[2]
ggml_row_size(v->type, kv_size*n_embd_v_gqa), // v->nb[3]
ggml_row_size(v->type, kv_size*n_embd_v_gqa)*sinfo.s0);
}

两段返回的视图形状不一样:不转置时是 [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 里也能看到这个差别的后果:

ggml_tensor * llama_kv_cache::build_input_v_idxs(ggml_context * ctx, const llama_ubatch & ubatch) const {
const uint32_t n_tokens = ubatch.n_tokens;
ggml_tensor * v_idxs;
if (!v_trans) {
v_idxs = ggml_new_tensor_1d(ctx, GGML_TYPE_I64, n_tokens);
} else {
v_idxs = ggml_new_tensor_1d(ctx, GGML_TYPE_I64, n_tokens*hparams.n_embd_v_gqa_max());
}
...
}

转置时索引张量要大 n_embd_v_gqa 倍,因为每个 token 的每一维分量都需要一个独立的写入下标(见 set_input_v_idxs 那一层嵌套循环)。这是转置布局的代价:写入时要展开成 n_embd_v_gqa 个「token × 分量」的地址。

哪些层参与缓存

不是所有层都有 KV 缓存。构造循环里有两道过滤:

for (uint32_t il = 0; il < n_layer; il++) {
if (!hparams.has_kv(il)) {
LLAMA_LOG_DEBUG("%s: layer %3d: does not have KV cache\n", __func__, il);
continue;
}
if (filter && !filter(il)) {
LLAMA_LOG_DEBUG("%s: layer %3d: filtered\n", __func__, il);
continue;
}
...
}

hparams.has_kv(il) 排除了循环层(Mamba 一类)、以及 MTP 的 nextn 层(在某些上下文中);filter 是从 create_memory 传进来的回调,用于混合架构里区分「注意力层」和「循环层」:

if (arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE || arch == LLM_ARCH_QWEN4EXP || arch == LLM_ARCH_MINIMAX_01) {
filter_attn = [&](uint32_t il) {
return il < hparams.n_layer() && !hparams.is_recr(il);
};
filter_recr = [&](uint32_t il) {
return il < hparams.n_layer() && hparams.is_recr(il);
};
...
}

同一份模型参数,filter_attn 让 KV 缓存只建在非循环层上,filter_recr 让循环状态池只建在循环层上。create_memory 最后返回的是 llama_memory_hybrid——一个内部同时持有 KV 缓存和循环状态池的复合对象。

还有一条 reuse 通路:

if (reuse) {
LLAMA_LOG_DEBUG("%s: reusing layers:\n", __func__);
for (uint32_t il = 0; il < n_layer; il++) {
const int32_t il_reuse = reuse(il);
if (il_reuse < 0) {
LLAMA_LOG_DEBUG("%s: - layer %3d: no reuse\n", __func__, il);
continue;
}
...
map_layer_ids[il] = map_layer_ids[il_reuse];
LLAMA_LOG_DEBUG("%s: - layer %3d: reuse layer %d, is_swa = %d\n", __func__, il, il_reuse, hparams.is_swa(il));
}
}

reuse 让第 il 层在 map_layer_ids 里指向另一层的张量。map_layer_ids 是「模型层号 → 缓存层号」的映射,get_k/cpy_k 一进来就查它:

ggml_tensor * llama_kv_cache::get_k(ggml_context * ctx, int32_t il, uint32_t n_kv, const slot_info & sinfo) const {
const int32_t ikv = map_layer_ids.at(il);
auto * k = layers[ikv].k;
...
}

于是两层共享同一张 K 张量——写入一次,两层都读到。这在结构压缩模型(多层共享 KV,例如 YOCO 那类跨层复用的设计)上是必须的,站内 YOCO 完全拆解:Decoder-Decoder 架构如何让 KV Cache 只存一次 讲的正是这种「只存一份跨层 KV」的架构。

缓冲区分配与日志

张量建好之后是分配:

// allocate tensors and initialize the buffers to avoid NaNs in the padding
for (auto & [buft, ctx] : ctx_map) {
ggml_backend_buffer_t buf;
if (hparams.no_alloc) {
buf = ggml_backend_buft_alloc_buffer(buft, /*size =*/ 0); // dummy buffer
for (ggml_tensor * t = ggml_get_first_tensor(ctx.get()); t != nullptr; t = ggml_get_next_tensor(ctx.get(), t)) {
t->buffer = buf; // set dummy buffer for KV cache so that the backend scheduler won't try to allocate it
}
} else {
buf = ggml_backend_alloc_ctx_tensors_from_buft(ctx.get(), buft); // real buffer
}
if (!buf) {
throw std::runtime_error("failed to allocate buffer for kv cache");
}
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);
ggml_backend_buffer_clear(buf, 0);
ctxs_bufs.emplace_back(std::move(ctx), buf);
}

三个细节:

  • 按 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):不真分配,只给张量挂一个假缓冲,让调度器知道「这块不用你管」。

分配完成后有一行日志值得记住,因为它是排查显存问题的第一手信息:

LLAMA_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__,
(float)(memory_size_k + memory_size_v) / (1024.0f * 1024.0f), kv_size, (int) layers.size(), n_seq_max, n_stream,
ggml_type_name(type_k), (float)memory_size_k / (1024.0f * 1024.0f),
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。

ggml 量化矩阵乘的块布局:低比特权重按块存储,kernel 边解码边计算
ggml 量化矩阵乘的块布局:低比特权重按块存储,kernel 边解码边计算

上图(来源: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 更不敏感)。

KVQuant 论文中的 K/V 分布对比:Key 与 Value 的离群值分布形态差异
KVQuant 论文中的 K/V 分布对比:Key 与 Value 的离群值分布形态差异

上图(来源: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 专属逻辑:

const bool is_mla = hparams.is_mla();
...
const bool has_k = true;
const bool has_v = !is_mla;

以及层间头维一致性检查:

if (n_embd_head_k_all == 0) {
n_embd_head_k_all = (int32_t) hparams.n_embd_head_k(il);
} else if (n_embd_head_k_all > 0 && n_embd_head_k_all != (int32_t) hparams.n_embd_head_k(il)) {
n_embd_head_k_all = -1;
}
if (!is_mla) {
if (n_embd_head_v_all == 0) {
n_embd_head_v_all = (int32_t) hparams.n_embd_head_v(il);
} else if (n_embd_head_v_all > 0 && n_embd_head_v_all != (int32_t) hparams.n_embd_head_v(il)) {
n_embd_head_v_all = -1;
}
}

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 一份的记账结构:

class llama_kv_cells {
public:
using seq_set_t = std::bitset<LLAMA_MAX_SEQ>;
void reset() {
for (uint32_t i = 0; i < pos.size(); ++i) {
pos[i] = -1;
ext[i].reset();
shift[i] = 0;
seq[i].reset();
}
has_shift = false;
used.clear();
for (uint32_t s = 0; s < LLAMA_MAX_SEQ; ++s) {
seq_pos[s].clear();
}
}
...

从这段 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_shiftbool是否有挂起的平移

「空」的定义与 used 集合

bool is_empty(uint32_t i) const {
assert(i < pos.size());
assert((pos[i] < 0 && pos[i] == -1) || pos[i] >= 0);
return pos[i] == -1;
}
uint32_t get_used() const {
return used.size();
}
// the index of the first cell that is used
// return 0 if no cells are used
uint32_t used_min() const {
return used.empty() ? 0 : *used.begin();
}
// the index of the last cell that is used + 1
// return 0 if no cells are used
uint32_t used_max_p1() const {
return used.empty() ? 0 : *used.rbegin() + 1;
}

位置值为 -1 就是「空」,不需要额外的占用位图。used 是一个有序集合(std::set 或类似结构),提供三个查询:总数(get_used)、最小下标、最大下标 +1。后两个是给 find_slot 和 [TAG_KV_CACHE_VARIABLE] 的启发式用的——它们让分配器知道「已经用到了哪里」,从而判断该从头扫还是从当前头指针扫。

used_max_p1() 还有一处重要用途,在 get_n_kv:

uint32_t llama_kv_cache::get_n_kv(const slot_info & sinfo) const {
uint32_t result = 0;
// heads must be 0 for the cache to be empty
for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {
const auto & cells = v_cells[sinfo.strm[s]];
result = std::max(std::min(cells.size(), std::max(n_pad_cur, GGML_PAD(cells.used_max_p1(), n_pad_cur))), result);
}
return result;
}

get_n_kv 回答的是「这次注意力的 K/V 视图要开多长」。最自然是开满 kv_size,但那样每个 token 的注意力都要扫过整个缓存——包括后面那些根本没写过的空单元。这个函数给了一个保守上界:已用单元的最大下标 +1(再按 n_pad 对齐,并至少留 n_pad)。注意它是按 stream 取最大值,因为多个 stream 的视图要共存一张张量。

这就是 llama_kv_cache_context 里那个 n_kv 成员的来历:

// a heuristic, to avoid attending the full cache if it is not yet utilized
// as the cache gets filled, the benefit from this heuristic disappears
int32_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 里每次写入都要问到这个问题:

// note: we want to preserve the invariant that all positions between [pos_min, pos_max] for each sequence
// will be present in the cache. so we have to purge any position which is less than those we would overwrite
// ref: https://github.com/ggml-org/llama.cpp/pull/13746#issuecomment-2916057092
for (uint32_t s = 0; s < LLAMA_MAX_SEQ; ++s) {
if (seq_pos_max_rm[s] == -1) {
continue;
}
GGML_ASSERT(s < seq_to_stream.size());
auto & cells = v_cells[seq_to_stream[s]];
if (cells.seq_pos_min(s) <= seq_pos_max_rm[s]) {
LLAMA_LOG_DEBUG("%s: purging positions [%d, %d] of sequence %d from KV cache\n",
__func__, cells.seq_pos_min(s), seq_pos_max_rm[s], s);
seq_rm(s, cells.seq_pos_min(s), seq_pos_max_rm[s] + 1);
}
}

这段代码是整个 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 是引用计数式删除的典型:

if (cells.seq_has(i, seq_id) && cells.seq_rm(i, seq_id)) {
if (new_head == cells.size()) {
new_head = i;
}
}

seq_rm(i, seq_id) 返回 true 意味着这个单元不再被任何序列引用,于是它被释放。而在 seq_rm 的调用者(llama_kv_cache::seq_rm)里,释放的单元下标被用来更新头指针:

uint32_t new_head = cells.size();
for (uint32_t i = 0; i < cells.size(); ++i) {
if (!cells.pos_in(i, p0, p1)) {
continue;
}
if (cells.seq_has(i, seq_id) && cells.seq_rm(i, seq_id)) {
if (new_head == cells.size()) {
new_head = i;
}
}
}
// If we freed up a slot, set head to it so searching can start there.
if (new_head != cells.size() && new_head < head) {
head = new_head;
}

头指针前移:一旦释放了比当前头更靠前的单元,就把头指针挪过去,下次 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 时:

for (uint32_t i = 0; i < cells.size(); ++i) {
if (!cells.pos_in(i, p0, p1)) {
continue;
}
if (cells.seq_has(i, seq_id_src)) {
cells.seq_add(i, seq_id_dst);
}
}

共享模式下只是加一个引用位——同一个单元被两条序列同时引用,零拷贝,两条序列的后续生成各自往新单元写。而当 n_stream > 1(--no-kv-unified)时,两条序列在不同的 stream 里,无法共享单元,于是必须真的把数据抄过去:

GGML_ASSERT(is_full && "seq_cp() is only supported for full KV buffers");
...
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 旋转到新位置上:

if (do_shift) {
if (!get_can_shift()) {
GGML_ABORT("The current KV cache / model configuration does not support K-shift");
}
LLAMA_LOG_DEBUG("%s: applying K-shift\n", __func__);
// apply K-shift if needed
if (hparams.rope_type != LLAMA_ROPE_TYPE_NONE) {
ggml_backend_sched_reset(sched);
auto * res = lctx->get_gf_res_reserve();
res->reset();
auto * gf = build_graph_shift(res, lctx);
if (!ggml_backend_sched_alloc_graph(sched, gf)) {
LLAMA_LOG_ERROR("%s: failed to allocate compute graph for K-shift\n", __func__);
return updated;
}
res->set_inputs(nullptr);
if (lctx->graph_compute(gf, false) != GGML_STATUS_SUCCESS) {
LLAMA_LOG_ERROR("%s: failed to compute K-shift\n", __func__);
return updated;
}
updated = true;
}
...
}

K-shift 是一个独立的计算图:它遍历缓存里每一层的 K 张量,用 ggml_rope_ext 按 shift 量原地旋转。相关代码在 build_rope_shift:

ggml_tensor * llama_kv_cache::build_rope_shift(
const llama_cparams & cparams,
ggml_context * ctx,
ggml_tensor * cur,
ggml_tensor * shift,
ggml_tensor * rot,
ggml_tensor * factors,
float freq_base,
float freq_scale,
uint32_t il) const {
ggml_tensor * tmp;
if (ggml_is_quantized(cur->type)) {
// dequantize to f32 -> RoPE -> quantize back
tmp = ggml_cast(ctx, cur, GGML_TYPE_F32);
// rotate back
tmp = llama_mul_mat_hadamard(ctx, tmp, rot);
tmp = ggml_rope_ext(ctx, tmp,
shift, factors, n_rot, rope_type, n_ctx_orig, freq_base, freq_scale,
yarn_ext_factor, yarn_attn_factor, yarn_beta_fast, yarn_beta_slow);
// rotate fwd
tmp = llama_mul_mat_hadamard(ctx, tmp, rot);
tmp = ggml_cpy(ctx, tmp, cur);
} else {
// we rotate only the first n_rot dimensions
tmp = ggml_rope_ext_inplace(ctx, cur,
shift, factors, n_rot, rope_type, n_ctx_orig, freq_base, freq_scale,
yarn_ext_factor, yarn_attn_factor, yarn_beta_fast, yarn_beta_slow);
}
return tmp;
}

两条分支的对比揭示了量化 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 用在「切换到一条新对话、把旧的全扔掉」的场景:

void llama_kv_cache::seq_keep(llama_seq_id seq_id) {
...
if (cells.seq_keep(i, seq_id)) {
...
}
}

保留指定序列,释放其它所有单元。

引用计数带来的一个隐含约束

由于单元是被多条序列共同引用的,clear 的语义要分清楚:

virtual void clear(bool data) = 0;

参数 data 决定「是否连数据一起清掉」。clear(false) 只重置记账(所有单元标记为空),clear(true) 还会把张量里的字节清零。state_read 里用到了这个区别:

// a whole-context restore replaces every stream, so the cache is emptied once here
// clear() resets all streams at once, so doing it per stream below would keep only the last one
if (seq_id == -1) {
clear(true);
}

注释解释了为什么 clear 必须在循环外调用一次:它是「一次性重置所有 stream」的语义,如果在 per-stream 循环里调用,每次都会把前面的 stream 一起清掉,只有最后一个幸存。

批次切分:llama_batch_allocr 如何把逻辑批次变成 ubatch

init_batch 的第一步不是找槽位,而是切分:

llama_memory_context_ptr llama_kv_cache::init_batch(
llama_batch_allocr & balloc,
uint32_t n_ubatch,
bool embd_all) {
do {
balloc.split_reset();
std::vector<llama_ubatch> ubatches;
while (true) {
auto ubatch = n_stream == 1 ? balloc.split_simple(n_ubatch) : embd_all ? balloc.split_seq(n_ubatch) : balloc.split_equal(n_ubatch, true, 0);
if (ubatch.n_tokens == 0) {
break;
}
ubatches.push_back(std::move(ubatch)); // NOLINT
}
if (balloc.get_n_used() < balloc.get_n_tokens()) {
// failed to find a suitable split
break;
}
auto sinfos = prepare(ubatches);
if (sinfos.empty()) {
break;
}
return std::make_unique<llama_kv_cache_context>(
this, std::move(sinfos), std::move(ubatches));
} while (false);
return std::make_unique<llama_kv_cache_context>(LLAMA_MEMORY_STATUS_FAILED_PREPARE);
}

三种切分策略按条件选择:

  • 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 里那几行:

if (n_stream > 1) {
GGML_ASSERT(n_tokens % ubatch.n_seqs_unq == 0);
n_seqs = ubatch.n_seqs_unq;
n_tokens = n_tokens / n_seqs;
}

槽位分配是「每个序列单独找一段连续空间」,因而要求每个序列在 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):

struct llama_ubatch {
bool equal_seqs() const {
return b_equal_seqs != 0;
}
bool is_pos_2d() const {
return n_pos >= 3;
}
bool is_mixed() const {
return type != nullptr;
}
uint32_t b_equal_seqs; // note: this is a boolean, but we use an int32_t for alignment
uint32_t n_tokens; // total tokens (n_seq_tokens * n_seqs)
uint32_t n_seq_tokens; // tokens per sequence set
uint32_t n_seqs; // sequence sets in the ubatch
uint32_t n_seqs_unq; // unique sequence ids in the ubatch
uint32_t n_pos; // number of position inputs for each token/embedding
llama_token * token; // [n_tokens] | i | id, token
float * embd; // [n_embd, n_tokens] | i | embd
float * embd_state; // [n_embd_state, n_tokens] | i | hidden state carried over from a previous stage (e.g. MTP)
llama_pos * pos; // [n_tokens*n_pos] | i | pos
int32_t * n_seq_id; // [n_tokens] | i | -
llama_seq_id ** seq_id; // [n_tokens] | s | s0, s1, seq_id
llama_seq_id * seq_id_unq; // [n_seqs_unq] | s | seq_id
int32_t * seq_idx; // [LLAMA_MAX_SEQ] | - | seq_idx
int8_t * output; // [n_tokens] | i | -
int8_t * type; // [n_tokens] | i | - (mixed ubatch only, 0 - token, 1 - embd)
...
};

几个字段之间的关系值得理清:

  • 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 坐标正是按这个布局:
if (ubatch.is_pos_2d()) {
ext.x = ubatch.pos[i + ubatch.n_tokens*2];
ext.y = ubatch.pos[i + ubatch.n_tokens];
}

每个 token 在缓存里都要记「额外信息」

apply_ubatch 的第二个动作是写 ext:

cells.pos_set(idx, ubatch.pos[i]);
if (ubatch.is_pos_2d() || ubatch.token || hparams.ple_n_heads > 0) {
llama_kv_cell_ext ext;
if (ubatch.is_pos_2d()) {
ext.x = ubatch.pos[i + ubatch.n_tokens*2];
ext.y = ubatch.pos[i + ubatch.n_tokens];
}
const bool is_embd = !ubatch.token || (ubatch.is_mixed() && ubatch.type[i]);
if (!is_embd) {
ext.tok = ubatch.token[i];
} else if (hparams.ple_n_heads > 0) {
// embd batch (multimodal input) has no token ids, need to pad it with the correct ID for PLE layers
ext.tok = hparams.ple_image_token_id != 0
? (llama_token) hparams.ple_image_token_id
: (llama_token) hparams.ple_eos_token_id;
}
cells.ext_set(idx, ext);
}

为什么缓存要记 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 里最长的单个函数之一。

头指针的启发式调整

const auto & cells = v_cells[seq_to_stream[seq_id]];
uint32_t head_cur = v_heads[seq_to_stream[seq_id]];
// if we have enough unused cells before the current head ->
// better to start searching from the beginning of the cache, hoping to fill it
if (head_cur > cells.get_used() + 2*n_tokens) {
head_cur = 0;
}
if (n_tokens > cells.size()) {
LLAMA_LOG_ERROR("%s: n_tokens = %d > size = %u\n", __func__, n_tokens, cells.size());
return { };
}

v_heads 是每个 stream 的头指针,表示「上次分配结束的位置」。默认从那里往后扫(环形),但有一个例外:如果头指针之前的空洞足够大(head_cur > used + 2*n_tokens,即「已用单元数 + 两倍要写入的 token 数」仍小于头指针位置),就改从 0 开始扫。这条启发式的目的是填补前面的空洞,避免缓存尾部被撑满而前面大片空着。

第二段检查是硬约束:一个 ubatch 每 stream 要写的 token 数不能超过缓存总容量。

非连续槽位与连续槽位

接下来是两种扫描模式:

uint32_t n_tested = 0;
// for continuous slots, we test that all tokens in the ubatch fit, starting from the current head
// for non-continuous slots, we test the tokens one by one
const uint32_t n_test = cont ? n_tokens : 1;
while (true) {
if (head_cur + n_test > cells.size()) {
n_tested += cells.size() - head_cur;
head_cur = 0;
continue;
}
for (uint32_t i = 0; i < n_test; i++) {
const auto idx = head_cur;
head_cur++;
n_tested++;
// can we use this cell? either:
// - the cell is empty
// - the cell is occupied only by one sequence:
// - (disabled) mask causally, if the sequence is the same as the one we are inserting
// - mask SWA, using current max pos for that sequence in the cache
// always insert in the cell with minimum pos
bool can_use = cells.is_empty(idx);
if (!can_use && cells.seq_count(idx) == 1) {
const llama_pos pos_cell = cells.pos_get(idx);
// (disabled) causal mask
// note: it's better to purge any "future" tokens beforehand
//if (cells.seq_has(idx, seq_id)) {
// can_use = pos_cell >= pos;
//}
if (!can_use) {
const llama_seq_id seq_id_cell = cells.seq_get(idx);
// SWA mask
if (llama_hparams::is_masked_swa(n_swa, swa_type, pos_cell, cells.seq_pos_max(seq_id_cell) + 1)) {
can_use = true;
}
}
}
if (can_use) {
res.idxs[s].push_back(idx);
} else {
if (cont) {
break;
}
}
}
if (res.idxs[s].size() == n_tokens) {
break;
}
if (cont) {
res.idxs[s].clear();
}
if (n_tested >= cells.size()) {
//LLAMA_LOG_ERROR("%s: failed to find a slot for %d tokens\n", __func__, n_tokens);
return { };
}
}

逐点拆解:

环形推进: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 的分支仍留在代码里,但已经没有调用者。

可用的判定有三条路径:

  1. 单元为空(cells.is_empty(idx)):最理想,直接用。
  2. 单元被单一序列占用,且该占用已被 SWA 掩码判定为「窗口外」:说明这块位置对那条序列已经没有意义(超出了它的滑动窗口),可以据为己有。
  3. 被多条序列占用(cells.seq_count(idx) > 1):不可用。因为多条序列共享意味着「这块数据还有人要」,覆盖会破坏别人。

注释里特意说明了被注释掉的因果掩码分支:

// (disabled) causal mask
// note: it's better to purge any "future" tokens beforehand
//if (cells.seq_has(idx, seq_id)) {
// can_use = pos_cell >= pos;
//}

早期版本允许「覆盖自己序列里位置更靠后的单元」(因为因果注意力不看未来,那些位置对当前 token 无用)。后来被禁用,理由是「最好事先把这些未来 token 清掉」——靠 apply_ubatch 的 purge 机制统一处理,而不是在分配器里做隐式覆盖。这就是前面那段 purge 代码的由来。

SWA 掩码:is_masked_swa 的判据

第 2 条路径依赖 llama_hparams::is_masked_swa:

if (llama_hparams::is_masked_swa(n_swa, swa_type, pos_cell, cells.seq_pos_max(seq_id_cell) + 1)) {
can_use = true;
}

参数含义: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 的汇总

扫描完成后有一段一致性检查:

res.s0 = std::min<uint32_t>(res.s0, seq_to_stream[seq_id]);
res.s1 = std::max<uint32_t>(res.s1, seq_to_stream[seq_id]);
res.strm[s] = seq_to_stream[seq_id];
res.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):

// for each ubatch, create a slot_info that contains information about where the ubatch should be inserted in the
// KV cells. for example, cell indices for each token, such that: token[i] -> goes to cells[idxs[i]]
struct slot_info {
// data for ggml_set_rows
using idx_vec_t = std::vector<uint32_t>;
// number of streams: ns = s1 - s0 + 1
uint32_t s0;
uint32_t s1;
std::vector<llama_seq_id> strm; // [ns]
std::vector<idx_vec_t> idxs; // [ns]
uint32_t head() const {
GGML_ASSERT(idxs.size() == 1);
GGML_ASSERT(!idxs[0].empty());
return idxs[0][0];
}
size_t size() const {
GGML_ASSERT(idxs.size() == strm.size());
GGML_ASSERT(!idxs.empty());
return idxs[0].size();
}
size_t n_stream() const {
return strm.size();
}
// check if indices are contiguous starting from head()
bool is_contiguous() const {
if (idxs.empty() || idxs[0].empty()) {
return true;
}
if (idxs.size() > 1) {
return false;
}
const uint32_t h = idxs[0][0];
for (size_t i = 0; i < idxs[0].size(); ++i) {
if (idxs[0][i] != h + i) {
return false;
}
}
return true;
}
};

is_contiguous() 是图构建时的判断依据:连续槽位可以用一次 ggml_view 切片拿到整段 K/V,非连续则必须走 ggml_set_rows 的散射写入。这条判断直接决定了后续是「廉价切片」还是「昂贵的索引写入」。

写入缓存:cpy_k/cpy_v 与 ggml_set_rows

找到槽位之后,新算出的 K/V 要写进缓存。写入是用 ggml_set_rows 表达的——这是 ggml 里「按行索引散射写入」的算子:

ggml_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 {
const int32_t ikv = map_layer_ids.at(il);
ggml_tensor * k = layers[ikv].k;
const int64_t n_embd_head = k_cur->ne[0];
const int64_t n_head = k_cur->ne[1];
const int64_t n_tokens = k_cur->ne[2];
const int64_t n_embd_gqa = n_embd_head*n_head;
// we can merge dims 0 and 1
// TODO: add ggml helper function for this?
GGML_ASSERT(ggml_row_size(k_cur->type, n_embd_head) == k_cur->nb[1]);
k_cur = ggml_view_2d(ctx, k_cur, n_embd_gqa, n_tokens, k_cur->nb[2], 0);
const int64_t n_stream = k->ne[2];
if (n_stream > 1) {
const int64_t kv_size = get_size();
assert(n_embd_gqa == k->ne[0]);
assert(kv_size == k->ne[1]);
// merge the buffer across all streams because the idxs are global
k = ggml_reshape_2d(ctx, k, n_embd_gqa, kv_size*n_stream);
}
// store the current K values into the cache
return ggml_set_rows(ctx, k, k_cur, k_idxs);
}

几个设计点:

为什么要 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 里:

void llama_kv_cache::set_input_k_idxs(ggml_tensor * dst, const llama_ubatch * ubatch, const slot_info & sinfo) const {
const uint32_t n_tokens = ubatch->n_tokens;
GGML_ASSERT(n_tokens == (int64_t) sinfo.size()*sinfo.n_stream());
GGML_ASSERT(ggml_backend_buffer_is_host(dst->buffer));
int64_t * data = (int64_t *) dst->data;
for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {
const int64_t offs = sinfo.strm[s]*get_size();
for (uint32_t i = 0; i < sinfo.size(); ++i) {
data[s*sinfo.size() + i] = offs + sinfo.idxs[s][i];
}
}
}

索引值 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 的影响所在:

ggml_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 {
...
// take this branch when FA is enabled (the V cache is not transposed)
if (!v_trans) {
v_cur = ggml_view_2d(ctx, v_cur, n_embd_gqa, n_tokens, v_cur->nb[2], 0);
if (n_stream > 1) {
v = ggml_reshape_2d(ctx, v, n_embd_gqa, kv_size*n_stream);
}
return ggml_set_rows(ctx, v, v_cur, v_idxs);
}
// 转置路径:先保证 v_cur 连续
if (ggml_row_size(v_cur->type, n_embd_gqa) == v_cur->nb[2]) {
// we can merge dims 0, 1 and 2
v_cur = ggml_reshape_2d(ctx, v_cur, n_embd_gqa, n_tokens);
} else {
// otherwise -> make a copy to get contiguous data
v_cur = ggml_cont_2d (ctx, v_cur, n_embd_gqa, n_tokens);
}
// [TAG_V_CACHE_VARIABLE]
if (n_embd_gqa < v->ne[0]) {
v_cur = ggml_pad(ctx, v_cur, v->ne[0] - n_embd_gqa, 0, 0, 0);
}
// in this branch the v_idxs are constructed in such a way that each row is a single head element
ggml_tensor * v_view = ggml_reshape_2d(ctx, v, 1, ggml_nelements(v));
v_cur = ggml_reshape_2d(ctx, v_cur, 1, ggml_nelements(v_cur));
return ggml_set_rows(ctx, v_view, v_cur, v_idxs);
}

转置路径的最后三行把 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 是这一块的入口:

void llama_kv_cache::set_input_kq_mask(ggml_tensor * dst, const llama_ubatch * ubatch, bool causal_attn) const {
const uint32_t n_tokens = ubatch->n_tokens;
GGML_ASSERT(ggml_backend_buffer_is_host(dst->buffer));
const int64_t n_kv = dst->ne[0];
const int64_t n_stream = dst->ne[3]; // num streams in the current ubatch
GGML_ASSERT(n_tokens%n_stream == 0);
// n_tps == n_tokens_per_stream
const int64_t n_tps = n_tokens/n_stream;
// see llama_non_causal_type
// only the SWA cache (or the SWA layers of a single cache) become non-causal
if (!causal_attn && hparams.non_causal_type == LLAMA_NON_CAUSAL_TYPE_SWA_ONLY) {
causal_attn = swa_type == LLAMA_SWA_TYPE_NONE;
}
...
if (dst->type == GGML_TYPE_F16) {
set_input_kq_mask_impl<ggml_fp16_t>(args, (ggml_fp16_t *) dst->data, causal_attn);
} else {
set_input_kq_mask_impl<float>(args, (float *) dst->data, causal_attn);
}
}

掩码张量的形状可以从 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」这四个布尔条件变成编译期常量:

template<typename T, bool causal, bool swa, bool is_2d, bool alibi>
static void set_input_kq_mask_impl(const args_set_input_kq_mask & args, T * data) {
...
const T mask_keep = llama_cast<T>(0.0f);
const T mask_drop = llama_cast<T>(-INFINITY);
...
}
template<typename T, bool causal, bool swa, bool is_2d>
static void set_input_kq_mask_impl(const args_set_input_kq_mask & args, T * data) {
const bool alibi = args.hparams.use_alibi;
if (alibi) {
set_input_kq_mask_impl<T, causal, swa, is_2d, true> (args, data);
} else {
set_input_kq_mask_impl<T, causal, swa, is_2d, false>(args, data);
}
}
// ... 另外三层同理,逐层把 is_2d / swa / causal 展开

四层展开意味着最多 16 种组合,每种都是一份独立编译的循环体。为什么要这么麻烦?因为内层循环是 n_kv × n_tokens × n_head 的三重循环,对长上下文来说动辄几千万次迭代。把 if (causal) 提到编译期能省掉每次迭代的分支判断;把 swa 提到编译期能省掉「窗口大小」的比较。这是典型的「用编译期复杂度换运行期速度」。

掩码循环里的关键优化

内层循环有一段值得细看的注释与逻辑:

const uint64_t idst = n_kv*i;
// for tokens of the same sequence, the mask is mostly the same, so we can reuse it
// the only cells that could change are the ones that are with similar positions as the
// ones in the batch (i.e. due to causal masking, SWA, etc.)
// keep track of those cells and shortcut the loop to save time
// note: this optimization is not compatible with Alibi position encoding
// ref: https://github.com/ggml-org/llama.cpp/pull/18842
bool prev = false;
auto & idxs = seq_idxs[seq_id];
if (!alibi) {
if (seq_srct.find(seq_id) != seq_srct.end()) {
const uint32_t srct = seq_srct[seq_id];
...

这段优化的原理是:同一个序列的相邻 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 不驱逐「低权重」单元,它驱逐的是「窗口外」单元——后者有明确的位置判据,前者需要统计信息。

attention sink 现象:无论序列多长,最初的几个 token 始终占据大量注意力
attention sink 现象:无论序列多长,最初的几个 token 始终占据大量注意力

上图(来源:arXiv 论文《Efficient Streaming Language Models with Attention Sinks》,图 4)是 attention sink 的量化结果:可学习 sink 机制如何让前几个 token 分摊注意力。放在这里的意义是说明「llama.cpp 为什么不像 H2O 那样做重击者驱逐」——滑动窗口 + 位置判据足够简单且可证明(窗口外的 token 在数学上确实不参与注意力),而基于重要性统计的驱逐需要额外的观测窗口和打分,与「纯 CPU 端侧推理,越简单越好」的目标不符。

apply_ubatch:把槽位变成真实的记账变更

prepare 里的试探写完就回滚了,真正生效的是 apply() 路径下的 apply_ubatch:

void llama_kv_cache::apply_ubatch(const slot_info & sinfo, const llama_ubatch & ubatch) {
// TODO: refactor [TAG_KV_CACHE_SHARE_CELLS]
if (other) {
return;
}
// keep track of the max sequence position that we would overwrite with this ubatch
// for non-SWA cache, this would be always empty
llama_seq_id seq_pos_max_rm[LLAMA_MAX_SEQ];
for (uint32_t s = 0; s < LLAMA_MAX_SEQ; ++s) {
seq_pos_max_rm[s] = -1;
}
assert(ubatch.n_tokens == sinfo.n_stream()*sinfo.size());
for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {
for (uint32_t ii = 0; ii < sinfo.size(); ++ii) {
const uint32_t i = s*sinfo.size() + ii;
auto & cells = v_cells[sinfo.strm[s]];
const auto idx = sinfo.idxs[s][ii];
if (!cells.is_empty(idx)) {
assert(cells.seq_count(idx) == 1);
const llama_seq_id seq_id = cells.seq_get(idx);
const llama_pos pos = cells.pos_get(idx);
seq_pos_max_rm[seq_id] = std::max(seq_pos_max_rm[seq_id], pos);
cells.rm(idx);
}
cells.pos_set(idx, ubatch.pos[i]);
/* ... 写 ext ... */
for (int32_t s = 0; s < ubatch.n_seq_id[i]; s++) {
cells.seq_add(idx, ubatch.seq_id[i][s]);
}
}
}
/* ... purge 被破坏的连续性 ... */
// move the head at the end of the slot
for (uint32_t s = 0; s < sinfo.n_stream(); ++s) {
auto & head = v_heads[sinfo.strm[s]];
head = sinfo.idxs[s].back() + 1;
}
}

流程拆开看:

  1. 记录被覆盖的位置。如果目标单元非空(说明是 SWA 回收或环形绕回),取出它原本属于哪个序列、位置是多少,把最大值记进 seq_pos_max_rm[seq_id]。
  2. 清空该单元(cells.rm(idx))。
  3. 写入新的位置、ext 和序列归属。
  4. purge 检查(前面那一段)。
  5. 推进头指针: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 拷贝:

bool updated = false;
auto * sched = lctx->get_sched();
if (!sc_info.empty()) {
assert(n_stream > 1 && "stream copy should never happen with a single stream");
llama_synchronize(lctx);
const size_t n_copy = sc_info.ssrc.size();
for (size_t i = 0; i < n_copy; ++i) {
const auto ssrc = sc_info.ssrc[i];
const auto sdst = sc_info.sdst[i];
assert(ssrc < n_stream);
assert(sdst < n_stream);
LLAMA_LOG_DEBUG("%s: copying KV buffer: stream %d to stream %d\n", __func__, ssrc, sdst);
assert(ssrc != sdst);
for (uint32_t il = 0; il < layers.size(); ++il) {
const auto & layer = layers[il];
ggml_backend_tensor_copy(layer.k_stream[ssrc], layer.k_stream[sdst]);
if (layer.v_stream[ssrc]) {
ggml_backend_tensor_copy(layer.v_stream[ssrc], layer.v_stream[sdst]);
}
}
}
}
if (do_shift) {
if (!get_can_shift()) {
GGML_ABORT("The current KV cache / model configuration does not support K-shift");
}
/* ... 建 K-shift 图并执行 ... */
for (uint32_t s = 0; s < n_stream; ++s) {
auto & cells = v_cells[s];
cells.reset_shift();
}
}

注意 llama_synchronize(lctx) 这一句:ggml_backend_tensor_copy 直接把一个张量拷到另一个张量,如果在途的异步计算还在读源张量就会读到半截数据。所以拷贝前必须先同步。这类「异步计算与内存管理操作之间的顺序」问题,在服务端框架里对应的是 KV 传输与重算的冲突(站内 SGLang 源码完全拆解(十二):Disaggregation——Prefill 与 Decode 如何拆到两台机器、KV 如何跨实例传输 讲的是跨机的同一类问题)。

cells.reset_shift() 在 K-shift 之后清零所有单元的 shift 值——因为平移已经物理施加到张量上了。

get_can_shift 的条件

bool llama_kv_cache::get_can_shift() const {
// Step35 uses per-layer RoPE dims; K-shift assumes a single global n_rot.
if (model.arch == LLM_ARCH_STEP35) {
return false;
}
if (hparams.n_pos_per_embd() > 1) {
return false;
}
return true;
}

两个否决条件:

  • 逐层不同的 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:

void llama_kv_cache::state_write(llama_io_write_i & io, llama_seq_id seq_id, llama_state_seq_flags flags) const {
/* ... 计算每个 stream 要写哪些单元区间 ... */
for (uint32_t s = 0; s < n_stream; ++s) {
...
// check the cell is not SWA-masked
if (add_cell && seq_id != -1) {
const bool is_masked = llama_hparams::is_masked_swa(n_swa, swa_type, cells.pos_get(i), cells.seq_pos_max(seq_id));
add_cell = !is_masked;
}
if (add_cell) {
++cell_count;
if (cell_range_begin == cells.size()) {
cell_range_begin = i;
}
} else {
if (cell_range_begin != cells.size()) {
cr.data.emplace_back(cell_range_begin, i);
cell_range_begin = cells.size();
}
}
}
if (cell_range_begin != cells.size()) {
cr.data.emplace_back(cell_range_begin, cells.size());
}
// DEBUG CHECK: Sum of cell counts in ranges should equal the total cell count
uint32_t cell_count_check = 0;
for (const auto & range : cr.data) {
cell_count_check += range.second - range.first;
}
GGML_ASSERT(cell_count == cell_count_check);
io.write(&cell_count, sizeof(cell_count));
// skip empty streams
if (cell_count == 0) {
continue;
}
state_write_meta(io, cr, seq_id);
state_write_data(io, cr);
}

三个要点:

其一,只写「连续区间」而不是逐个单元。 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),确保「按区间统计的单元数」等于「逐个统计的单元数」。这是防区间收集逻辑出错的保险。

元信息与数据分开写

void llama_kv_cache::state_write_meta(llama_io_write_i & io, const cell_ranges_t & cr, llama_seq_id seq_id) const {
const auto & cells = v_cells[cr.strm];
for (const auto & range : cr.data) {
for (uint32_t i = range.first; i < range.second; ++i) {
std::vector<llama_seq_id> seq_ids;
for (llama_seq_id cur = 0; cur < (int) n_seq_max; ++cur) {
if (cur == seq_id || seq_id == -1) {
if (cells.seq_has(i, cur)) {
seq_ids.push_back(cur);
}
}
}
const llama_pos pos = cells.pos_get(i);
const uint32_t n_seq_id = seq_ids.size();
io.write(&pos, sizeof(pos));
io.write(&n_seq_id, sizeof(n_seq_id));
if (has_cell_ext()) {
const llama_kv_cell_ext ext = cells.ext_get(i);
io.write(&ext, sizeof(ext));
}
for (const auto & seq_id : seq_ids) {
io.write(&seq_id, sizeof(seq_id));
}
}
}
}
void llama_kv_cache::state_write_data(llama_io_write_i & io, const cell_ranges_t & cr) const {
const auto & cells = v_cells[cr.strm];
const uint32_t v_trans = this->v_trans ? 1 : 0;
const uint32_t n_layer = layers.size();
io.write(&v_trans, sizeof(v_trans));
io.write(&n_layer, sizeof(n_layer));
io.write(&n_rot_k, sizeof(n_rot_k));
io.write(&n_rot_v, sizeof(n_rot_v));
// Iterate and write all the keys first, each row is a cell
// Get whole range at a time
for (const auto & layer : layers) {
const uint32_t il = layer.il;
...

每个单元写 pos + n_seq_id + (可能的)ext + 若干 seq_id。数据部分则先写四个头字段:

  • v_trans:V 是否转置。恢复时必须一致,否则张量布局不匹配。
  • n_layer:层数。不一致说明模型换了。
  • n_rot_k / n_rot_v:K/V 的旋转维数(Hadamard 旋转路径用)。恢复时必须一致,否则旋转坐标系不匹配。

这四个字段在 state_read_data 里被逐一校验,任一不符就报错返回:

if (n_layer != layers.size()) {
LLAMA_LOG_ERROR("%s: mismatched layer count (%u instead of %u)\n", __func__, n_layer, (uint32_t) layers.size());
return false;
}
if (cell_count > cells.size()) {
LLAMA_LOG_ERROR("%s: not enough cells in kv cache to restore state (%u > %u)\n", __func__, cell_count, cells.size());
return false;
}
if (this->v_trans != (bool) v_trans) {
LLAMA_LOG_ERROR("%s: incompatible V transposition\n", __func__);
return false;
}
if (n_rot_k_ref != n_rot_k) {
LLAMA_LOG_ERROR("%s: incompatible key rotation (%u instead of %u)\n", __func__, n_rot_k_ref, n_rot_k);
return false;
}
if (n_rot_v_ref != n_rot_v) {
LLAMA_LOG_ERROR("%s: incompatible value rotation (%u instead of %u)\n", __func__, n_rot_v_ref, n_rot_v);
return false;
}

这解释了一个实际的坑:用 -fa 保存的缓存不能用 -no-fa 恢复(v_trans 不同),开了 -ctk q4_0 保存的缓存不能用默认类型恢复(K 的量化类型与旋转维数可能不同)。状态文件的兼容性不只是「模型一致」,还包括「缓存配置一致」。

恢复:先找槽位,再写入

state_read_sinfo 是恢复的入口,它比写出更复杂,因为要处理「恢复到哪里」:

void llama_kv_cache::state_read_sinfo(
llama_io_read_i & io,
llama_seq_id seq_id,
llama_state_seq_flags flags,
slot_info_vec_t * sinfos_out,
const slot_info_vec_t * sinfos_in) {
if (other) {
return;
}
if ((flags & LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY) && swa_type == LLAMA_SWA_TYPE_NONE) {
return;
}
if (sinfos_out) {
sinfos_out->assign(n_stream, slot_info{});
}
if (sinfos_in && sinfos_in->size() != n_stream) {
throw std::runtime_error("failed to restore kv cache: mirrored slot layout has the wrong stream count");
}
uint32_t n_stream_cur;
io.read(&n_stream_cur, sizeof(n_stream_cur));
if (n_stream_cur != n_stream) {
throw std::runtime_error("n_stream mismatch");
}
// a whole-context restore replaces every stream, so the cache is emptied once here
// clear() resets all streams at once, so doing it per stream below would keep only the last one
if (seq_id == -1) {
clear(true);
}
for (uint32_t s = 0; s < n_stream; ++s) {
uint32_t cell_count;
io.read(&cell_count, sizeof(cell_count));
if (cell_count == 0) {
// a mirrored cache must be empty here as well, or the two no longer agree cell for cell
if (sinfos_in && !(*sinfos_in)[s].empty()) {
throw std::runtime_error("failed to restore kv cache: mirrored cache holds cells this one does not");
}
continue;
}
const uint32_t strm = seq_id == -1 ? s : seq_to_stream[seq_id];
slot_info sinfo;
bool res = true;
res = res && state_read_meta(io, strm, cell_count, sinfo, seq_id, sinfos_in ? &(*sinfos_in)[s] : nullptr);
try {
res = res && state_read_data(io, strm, cell_count, sinfo);
} catch (...) {
res = false;
}
if (!res) {
state_clear(seq_id, strm, sinfo);
throw std::runtime_error("failed to restore kv cache");
}
if (sinfos_out) {
(*sinfos_out)[s] = sinfo;
}
}
}

state_read_meta 里恢复单个序列时,会先清掉目标序列的旧数据,再用 find_slot 找位置:

if (dest_seq_id != -1) {
// single sequence
if (cell_count > cells.size()) {
LLAMA_LOG_ERROR("%s: not enough cells in kv cache\n", __func__);
return false;
}
seq_rm(dest_seq_id, -1, -1);
llama_batch_allocr balloc(hparams.n_pos_per_embd());
llama_ubatch ubatch = balloc.ubatch_reserve(cell_count, 1);
ubatch.seq_id_unq[0] = dest_seq_id;
...
} else {
sinfo = find_slot(ubatch, false);
if (sinfo.empty()) {
LLAMA_LOG_ERROR("%s: failed to find %d available cells in kv cache\n", __func__, cell_count);
return false;
}
}

这里有一个巧妙的复用:state_read_meta 构造了一个假的 ubatch(只填位置和序列 id,没有真实 token),然后用 apply_ubatch 把它写进记账。也就是说「恢复」走的是与正常推理完全相同的记账路径——不需要为恢复单独写一套单元维护逻辑。

镜像缓存:sinfos_in 的用途

state_read_sinfo 的最后两个参数 sinfos_out / sinfos_in 是给投机解码准备的。注释说得很清楚:

// state_read, plus the cells the restored tokens were placed in
// a cache that mirrors another one (the qwen4exp indexer) must not search for its own cells: two searches agree only by luck
// sinfos_out: if set, filled with the layout used; a stream with no cells leaves an empty entry
// sinfos_in : if set, the layout to use instead of searching. one entry per stream, cell count must match the blob

同一段代码里还有对应的校验:

if (sinfo_in) {
// this cache mirrors another one, so it takes that cache's layout instead of searching for its own cells
if (sinfo_in->empty() || sinfo_in->n_stream() != 1 || sinfo_in->idxs[0].size() != cell_count) {
LLAMA_LOG_ERROR("%s: mirrored slot layout holds %d cells, this cache restores %d\n", __func__,
sinfo_in->empty() ? 0 : (int) sinfo_in->idxs[0].size(), cell_count);
return false;
}
sinfo = *sinfo_in;
// the layout is cell indices, so it means the same in both caches only while their streams line up
sinfo.s0 = strm;
sinfo.s1 = strm;
sinfo.strm[0] = strm;
// seq_rm above freed exactly the cells this sequence held
// anything else in the way is a cache that had already drifted, which this restore must not hide
for (uint32_t i = 0; i < cell_count; ++i) {
const uint32_t idx = sinfo.idxs[0][i];
if (idx >= cells.size() || !cells.is_empty(idx)) {
LLAMA_LOG_ERROR("%s: cell %u of the mirrored slot layout is not free\n", __func__, idx);
return false;
}
}
}

场景是这样的:模型里存在两个必须逐单元对齐的缓存(注释里举的例子是 Qwen 的稀疏注意力索引器缓存与主注意力缓存)。恢复时如果两个缓存各自 find_slot,几乎不可能得到相同的单元布局——「两次搜索只在运气好时才一致」。所以做法是:第一个缓存恢复时记录它用的布局(sinfos_out),第二个缓存直接把这份布局拿来用(sinfos_in)并逐单元校验「这些格子是空的」。

这比「两个缓存各自搜索再祈祷一致」靠谱得多,代价是恢复顺序被固定:必须先恢复主缓存再恢复镜像缓存。投机解码里 draft 与 target 的缓存对齐(站内 SGLang 源码完全拆解(十一):投机解码——草稿生成、批量验证与 token 接受如何接进同一条前向)面对的是同类约束。

失败回滚

state_read_sinfo 在失败路径上有两个动作:先 state_clear(seq_id, strm, sinfo) 把这次尝试恢复的单元清掉,再抛异常。对应的 state_read_data 里也能看到镜像的清理逻辑:

// the cleared ranges mirror the write pattern of state_read_data() - keep both in sync
...
seq_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:

const layer_filter_cb filter_base = [&](int32_t il) {
if (filter && !filter(il)) {
return false;
}
return !model.hparams.is_swa(il);
};
const layer_filter_cb filter_swa = [&](int32_t il) {
if (filter && !filter(il)) {
return false;
}
return model.hparams.is_swa(il);
};
const uint32_t size_base = kv_size;
// note: the SWA cache is always padded to 256 for performance
// https://github.com/ggml-org/llama.cpp/issues/17037
uint32_t size_swa = GGML_PAD(std::min(size_base, hparams.n_swa*(unified ? n_seq_max : 1) + n_ubatch), 256);
// when using full-size SWA cache, we set the SWA cache size to be equal to the base cache size
if (swa_full) {
LLAMA_LOG_WARN("%s: using full-size SWA cache (ref: %s)\n",
__func__, "https://github.com/ggml-org/llama.cpp/pull/13194#issuecomment-2868343055");
size_swa = size_base;
}
kv_base = std::make_unique<llama_kv_cache>(
model, hparams, type_k, type_v,
v_trans, offload, unified, size_base, n_seq_max, n_pad,
0, LLAMA_SWA_TYPE_NONE, mem_other_base, filter_base, reuse, share);
kv_swa = std::make_unique<llama_kv_cache>(
model, hparams, type_k, type_v,
v_trans, offload, unified, size_swa, n_seq_max, n_pad,
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))\text{size\_swa} = \text{PAD}_{256}\left(\min\left(\text{size\_base},\; n_{swa} \times (unified ? n_{seq\_max} : 1) + n_{ubatch}\right)\right)

逐项解释:

  • 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 的方式,是把每个操作同时施加到两个缓存上:

bool llama_kv_cache_iswa::seq_rm(llama_seq_id seq_id, llama_pos p0, llama_pos p1) {
bool res = true;
res = res & kv_base->seq_rm(seq_id, p0, p1);
res = res & kv_swa ->seq_rm(seq_id, p0, p1);
return res;
}

clear、seq_cp、seq_keep、seq_add、seq_div 都是这个模式。这种「组合式实现」正是 llama_memory_i 抽象的价值:上下文层不需要知道底层是一个缓存还是两个,只要接口语义对就行。

init_batch 则不同——它必须同时在两个缓存里为新批次找到位置,而且要保证两边都成功:

llama_memory_context_ptr llama_kv_cache_iswa::init_batch(...) {
/* 先让 base 缓存找位置,再让 swa 缓存找;任一边失败则整体失败 */
}

对应的上下文对象是 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」的交错组合起来:

if (hparams.swa_type != LLAMA_SWA_TYPE_NONE) {
// Use hybrid-iswa for hybrid models with SWA
res = new llama_memory_hybrid_iswa(
/* model */ *this,
/* attn_type_k */ params.type_k,
/* attn_type_v */ params.type_v,
/* attn_v_trans */ !cparams.flash_attn,
/* attn_swa_full */ params.swa_full,
/* attn_kv_size */ cparams.n_ctx_seq,
/* attn_n_ubatch */ cparams.n_ubatch,
/* attn_n_pad */ 1,
/* recurrent_type_r */ GGML_TYPE_F32,
/* recurrent_type_s */ GGML_TYPE_F32,
/* recurrent_rs_size */ std::max((uint32_t) 1, cparams.n_seq_max),
/* n_seq_max */ cparams.n_seq_max,
/* n_rs_seq */ cparams.n_rs_seq,
/* offload */ cparams.offload_kqv,
/* unified */ cparams.kv_unified,
/* filter_attn */ std::move(filter_attn),
/* filter_recr */ std::move(filter_recr));
}

注意 recurrent_type_r/recurrent_type_s 都是 GGML_TYPE_F32:循环状态不用量化。原因是循环状态是一小块固定大小的矩阵(通常是 [n_embd, n_embd] 或更小),量化它省不了多少内存,却会累积每一轮迭代的误差——循环状态是自反馈的,误差会随步数放大。KV 缓存每轮都是「新写入 + 读取」,不存在累加放大,所以量化相对安全。这条区别是「KV 缓存量化」与「循环状态」在工程上被区别对待的根本原因。

与站内其他框架的对照

把 llama.cpp 的记忆系统与已讲过的两个服务端框架放在一起,能看出三条路线的取舍:

维度llama.cppvLLMSGLang
缓存抽象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_ubatch purge 逻辑的前提。
  • 分配机制: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)在图上长什么样。

参考资料

  1. llama.cpp 官方仓库(src/ 与 ggml/ 源码)
  2. src/llama-context.cpp:上下文构造、decode 主循环、sched_reserve 与 process_ubatch
  3. src/llama-context.h:llama_context 的成员与接口定义
  4. src/llama-kv-cache.cpp:KV 缓存构造、find_slot、apply_ubatch、set_input_kq_mask 与状态存取
  5. src/llama-kv-cache.h:llama_kv_cache 与 slot_info 接口
  6. src/llama-kv-cache-iswa.cpp:交错滑动窗口的两个缓存如何组合
  7. src/llama-kv-cells.h:llama_kv_cells 的单元记账实现
  8. src/llama-memory.h:llama_memory_i 与 llama_memory_context_i 抽象接口
  9. src/llama-batch.h:llama_ubatch 结构与批次切分接口
  10. src/llama-cparams.h:llama_cparams 全部运行时参数
  11. src/llama-model.cpp:create_memory 如何按架构选择记忆实现
  12. Efficient Memory Management for Large Language Model Serving with PagedAttention
  13. vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention
  14. KVQuant: Towards 10 Million Context Length LLM Inference with KV Cache Quantization
  15. Efficient Streaming Language Models with Attention Sinks
  16. llama.cpp 官方文档:KV 缓存量化与上下文参数说明

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

llama.cpp 源码完全拆解(四):llama_context 与记忆系统——KV Cache、slot 与批处理如何组织一次解码
https://pinghaoyang.com.cn/articles/insights/posts/llama-cpp-context-kv-cache/
作者
平昊阳
发布于
2026-10-19
许可协议
CC BY-NC-SA 4.0

评论区

访客信息
--°天气加载中

正在获取所在地区…

💨 风力--
💧 湿度--

正在读取天气

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
450
分类
29
标签
453
总字数
6,218,927
运行时长
0 天
最后活动
0 天前

文章目录