从源码到实测:vLLM-Ascend Prefix Cache 如何复用前缀,又能快多少?

第一篇文章追踪了一次请求从 Prefill、Decode 到结束和块回收的过程;第二篇把这些执行过程放进服务场景,整理了调度、缓存和并行等特性的分工。这一篇选择其中一个具体问题继续深入:一份文档已经处理过,再围绕它提出另一个问题,模型究竟能少算什么?

先给出实测发现:在 Qwen3-0.6B、单 NPU、Eager、单请求的配置下,1K 前缀没有表现出稳定的整请求收益;扩大到 16K 后,首 token 等待从 318.51 ms 降到 47.28 ms,但固定生成 64 个 token 的总耗时只减少约 11.1%。 前缀命中比例、首 token 加速比与整请求收益,是三个不同的指标。

下面先用对照实验确认复用,再沿本地源码解释“怎样找到可复用前缀”,最后分析冷、热缓存的时间差。输入采用可控的重复文本;结论限定于这套配置,不代表自然文档质量或多用户吞吐。实验完成于 2026-10-11、2026-10-13,本文整理于 2026-10-14。

1. 相同的是文档,不一定是问题和答案

假设一个资料问答服务先后收到两个请求:

A:[共同文档] + 问题 A:请简要描述缓存的作用。
B:[共同文档] + 问题 B:重复输入可以省下什么计算?

A 和 B 的后续问题不同,但输入开头的文档相同。A 完成 Prefill 时,已经为文档中的 token 计算了各层的 K、V。若这些状态仍在缓存中,且模型等计算条件兼容,B 就有机会直接复用共同前缀的 KV,继续处理自己的问题。

KV Cache 保存的是模型计算得到的数值张量;Prefix Cache 让相同前缀对应的这些张量能够跨请求复用。 它没有把“这篇文档的答案”存下来。B 仍然要处理不同的后缀,并逐步生成自己的回答。复用主要减少重复的 Prefill 工作,不能用命中比例直接推算整次请求的加速比例。vLLM Automatic Prefix Caching

A:处理共同文档 → 建立前缀 KV → 处理问题 A → 生成答案 A → 请求结束
                              │
                         可复用内容仍在
                              │
                              ▼
B:查找共同前缀 → 复用已有 KV → 处理问题 B → 生成答案 B

这也连接了第一篇中的块回收问题:A 结束意味着它不再占用这些块的请求引用,并不必然意味着缓存内容立即被覆盖。能否继续命中,要看相应内容和缓存索引是否仍然有效。请求释放、缓存保留与后续淘汰,需要分别观察。

2. 先把“共同前缀”固定下来

实验不能只说“两段文字看起来一样”。模型实际接收的是 token ID 序列,中文字符数也不等于 token 数。为了让输入关系可检查,脚本直接构造并保存 token IDs。

具体做法是将一段关于推理缓存的短文本分词,重复并截取为 1,024 个 token 的固定共同段,然后分别拼接 A、B 的问题后缀。这个重复文本是机制测试输入,用来控制变量,不用于评价文档理解质量。

A:     [共同段:1,024 tokens] + [后缀 A]
B:     [共同段:1,024 tokens] + [后缀 B]
B′:    [共同段中改 1 个 token] + [后缀 B]

构造时不使用聊天模板,后缀也直接以 token IDs 拼接,避免把整段字符串重新分词时改变边界。A、B 保证共享这 1,024 个 token;本次检查保存的输入发现,两个后缀开头还共享 2 个 token,因此实际最长共同前缀为 1,026 个 token。预设共同段长度、实际共同前缀长度与最终缓存命中数,需要分别记录。

B′ 只修改 B 的一个早期 token:默认是下标 17,也就是第 18 个位置,长度和后缀保持不变。它用于观察前缀改变后的命中行为。即使修改点后面的许多 token 相同,它们所处的历史上下文已经不同,不能把它们当作一段可以跳过去重新接上的普通精确前缀缓存。vLLM 的缓存标识会结合当前块及此前前缀的信息。前缀缓存设计

3. 用四组条件区分缓存命中与运行预热

本轮使用 Qwen3-0.6B,在单 NPU、TP=1、PP=1 条件下离线执行。脚本设置 Eager、同步调度、每次一个请求,使用贪心采样并固定生成 3 个 token。实际 block size 为 128。Chunked Prefill 保持启用,调度 token 预算为 2,048,大于本轮的 1,038-token 输入;它不是本轮改变的变量。运行版本记录如下:

项目实验环境
平台与设备HiDevLab A3;npu-smi 上报型号 Ascend910,每芯片 HBM 65536 MB;实验只使用可见逻辑设备 0
驱动26.1.1(10 月 13 日性能实验的 driver/version.info)
CANN Toolkit9.1.0,内部版本 V100R001C11SPC001B243(10 月 13 日安装信息)
Python3.12.13
PyTorch / torch-npu2.10.0+cpu / 2.10.0.post4
vLLM / vLLM-Ascend0.26.0+empty / 0.26.0rc1
Transformers5.14.1
vLLM 运行源码提交568afb3a13806beb53bb2e6bd518269357b237c0
vLLM-Ascend 运行源码提交f2f74a16c3c50a76f4349d807918e83edec1e35c

设备型号按原始上报值记录,不根据平台名称推断芯片后缀。10 月 13 日两轮性能实验的驱动与 Toolkit 记录一致;10 月 11 日机制实验只保存了 npu-smi 的 26.1.1 版本信息,未单独采集 Toolkit 安装版本。实际固件版本未采集。证据摘录与三轮配置对照见附录 A。

两个运行仓库检查时均无未提交改动。模型来自环境中已有的本地文件,下载记录只标记了 master,尚未取得模型与 tokenizer 的不可变 revision;本轮保存了权重、配置和 tokenizer 文件的 SHA256,用于识别实际使用的文件。

对照分为 OFF 和 ON 两个独立进程,以切换引擎的前缀缓存配置。ON 进程内部保持同一个引擎,按组重置前缀缓存并顺序提交请求:

条件缓存设置与请求顺序要回答的问题
OFF关闭 Prefix Cache,分别执行 B 和 B′两个输入各自的参考输出是什么?
COLD开启缓存,重置成功后执行 B开关已打开、但目标前缀尚未准备时,报告什么命中?
WARM独立重置后先完整执行 A,再执行 BA 留下的前缀能否被 B 复用?
PREFIX-CHANGED独立重置后先完整执行 A,再执行 B′修改前缀后,命中会发生怎样的变化?

每个进程都先执行一个无关的短请求用于预热。在 ON 进程中,只有 reset_prefix_cache() 明确返回 True 后,才进入相应对照组;请求按顺序完成,避免仍有活跃请求干扰重置。

实际执行顺序可以直接展开为:

关闭缓存的进程:加载模型 → 预热 → B → B′ → 退出

开启缓存的进程:加载模型 → 预热
    ├─ 清缓存 → B             观察冷缓存
    ├─ 清缓存 → A 完成 → B    观察 A 留下的前缀能否复用
    └─ 清缓存 → A 完成 → B′   观察修改前缀后的行为

图中的三组按从上到下的顺序执行,不是并发运行。A 与随后 B 之间不清缓存;不同对照组之间才清缓存。 如果不在第二组前清理,第一组的 B 自己也可能留下可复用内容,就无法清楚说明后一次命中来自 A。

例如,执行完 A 后再执行 B,保存的请求结果报告:输入 1,038 token,命中 1,024 token,输出文本为 可以省下。关闭缓存和冷缓存时,同一个 B 也生成 可以省下,只是命中数为 0。文本只有这么短,是因为实验固定生成 3 token,并没有要求输出完整回答。

输入构造、执行命令与结果文件的对应关系见附录 B:复现实验与证据。完整操作过程随实验附件保存。

这里的 COLD 指前缀缓存状态冷,不是模型冷启动。清理前缀索引不等于卸载模型,也不等于把设备中的所有 KV 字节清零。模型加载、算子预热与前缀命中属于不同过程,所以“第二次更快”本身不足以证明 Prefix Cache 生效。本轮脚本先观察机制,不计时。

输出比较也必须固定输入:COLD_B、WARM_B 对照 OFF_B;修改后的 B′ 对照 OFF_Bprime。A、B、B′ 是不同请求,不要求它们生成相同答案。即使这些样本的输出完全一致,也只说明本次固定条件下的对照结果。

4. 本次实验保存的请求结果能告诉我们什么?

本次实验里,脚本每发出一个请求,都会等它完成生成,再把返回结果中的字段整理成一份 JSON。因此,这份文件记录的是一次完整请求的结果摘要,不是模型第一轮 Prefill 或每轮执行的日志。

例如,先运行 A,再运行 B,保存的 WARM_B 原始记录包含以下字段(节选;文件位置见附录 B):

{
  "input_tokens": 1038,
  "num_cached_tokens": 1024,
  "output_token_ids": [73670, 65770, 16872],
  "output_text": "可以省下",
  "finished": true
}

这段记录的意思是:B 输入了 1038 个 token,框架报告命中 1024 个 token,最后生成了 3 个 token,对应文本为“可以省下”,请求已经结束。 它告诉我们这次请求的输入、命中和输出,但没有列出第一轮、第二轮分别处理了多少 token,也没有记录每轮耗时。

完整 JSON 还包含请求标识、输入 token IDs 和结束原因等信息。各字段的用途如下:

字段含义与用途
label、request_id区分对照条件,并把结果对应到具体请求
input_tokens实际提交的输入 token 数,包含共同段与后缀
input_token_ids、reported_prompt_token_ids检查提交的输入与框架报告的输入是否一致
num_cached_tokens框架返回的请求级命中 token 统计
cache_metric_available判断当前版本是否提供命中统计;缺失值保留为 null,不能当成零命中
output_token_ids、output_text保存生成结果;跨条件比较优先核对 token IDs
finished、finish_reason检查请求是否结束,以及结束原因

那么,1038 - 1024 = 14 能说明什么?这里要分清:

  • **记录中直接看到的:**输入长度为 1038,框架报告的前缀缓存命中数为 1024。
  • **根据这两个数字推算的:**还有 14 个输入 token 没有被命中的前缀缓存覆盖,需要继续处理。

这个减法有意义,但 “剩余 14 个输入 token”不等于“日志已经显示某一轮执行了 14 个 token”。调度器会决定每轮安排多少工作,剩余输入可能受到本轮 token 预算等限制;仅凭请求结束后的这份摘要,看不出它们具体怎样分轮执行。要确认每轮实际安排了多少,才需要查看 Scheduler 的逐轮调度记录。

同样,14 也不代表整个请求的全部计算量。处理这 14 个 token 时,Attention 仍然需要使用前面缓存的 KV,前面 1024 个 token 的上下文没有消失;随后继续生成答案,还会进行 Decode。token 数只是长度计数,不能直接当作模型的运算量或耗时。

所以,本次保存的记录能核对“输入是什么、报告命中了多少、生成了什么”。它没有展示逐轮任务和缓存块的引用过程,也没有保存 logits(模型对候选下一 token 的打分)或计时结果。因此,本章只据这些记录解释请求级命中和输出对照,不据此声称每轮执行量、候选打分或提速幅度已经得到验证。

5. 源码怎样判断并找到可复用前缀

先用一个资料问答的例子说明为什么需要这套机制。假设两个人先后提交了同一份很长的产品说明书,只是各自的问题不同:

A:[同一份长说明书] + “这个产品有哪些主要功能?”
B:[同一份长说明书] + “使用时有哪些注意事项?”

这里假设模型和配置相同,输入从开头到说明书末尾的 token 序列一致;如果说明书前面还有系统提示等内容,那些内容也必须相同。这是说明动机的例子,实际实验使用的是第 2 节构造的固定 token 输入。

A 处理说明书时,已经计算了对应的 KV。B 到来后,如果再把相同说明书从头计算一遍,这部分 Prefill 工作就重复了。我们希望 B 能接着使用 A 留下的这段计算结果,把后续工作用于自己的问题和回答。

但框架接到 B 时,并不会自动知道“它应该接着用 A 的哪部分结果”。缓存里可能留着许多请求的 KV,框架需要先弄清三件事:

  1. **内容对不对:**B 开头这段输入,是否与缓存对应的前缀相同?
  2. **结果还在不在、在哪里:**对应的 KV 是否仍可复用,存在哪些缓存块中?
  3. **能接着用到哪里:**从输入开头算起,连续多少个 token 的结果可以复用?

比如,缓存只保留了说明书前半部分的 KV,B 就不能把整份说明书都算作已经处理过。因此,光知道“以前处理过这份文档”还不够,还要找到现在确实可用的那部分结果。

这就是本章要追踪的源码动机:让框架能够识别相同前缀、找到对应 KV,并确定可以跳过多少重复的前缀计算。 实现上,可以把缓存索引理解为一份查找目录:框架为每个完整 token 块生成包含前文信息的“指纹”(哈希),在处理 A 的过程中登记“指纹 → 缓存块”;B 到来后生成自己的指纹,从第一块开始依次查找,找到就累计,遇到未命中或查完允许范围就停止,返回可复用的块和对应的 token 数。

哈希用来识别要找的前缀,索引用来定位缓存块,连续命中的块数决定能复用多长。 下面沿这条主线,把我们的 A→B 实验对应到具体代码。

本节依据本地阅读的 vLLM 提交 84030bbe3d74d99bad477a3d2e37a973ccd8865c;源码链接指向公开仓库的同一固定提交,入口名称与行号以该提交为准。它与第 3 节远程运行提交不同。范围限定为本次小模型所对应的普通单组 Full Attention、相同模型及兼容配置、无外部 KV connector 的完整块路径。

5.1 为完整块生成标识

这里的“块”,先理解为按固定数量 token 划分的一小段输入。本次实验的块大小是 128,也就是每 128 个连续 token 分成一组。它按 token 数划分,不要求刚好是一句话或一个自然段。

以 B 的 1038 个输入 token 为例,分组如下(按输入顺序示意):

第 1 块:第    1~128  个 token,共 128 个
第 2 块:第  129~256  个 token,共 128 个
……
第 8 块:第  897~1024 个 token,共 128 个
尾部:   第 1025~1038 个 token,共  14 个,尚未满一块

“完整块”就是这一组已经凑满 128 个 token。 因而本例有 8 个完整块和一个未满块的尾部;128 是本次实验的配置,并非所有模型和部署都固定使用这个值。

还要区分输入与存储:输入块是这一段 token,KV 缓存块则用于存放对应 token 的 K、V 数值。 本例每个 KV 块有 128 个 token 的存储槽位,各层有相应的 KV 存储;并不是一个块只有 128 个数。给输入块生成标识,是为了之后能根据相同前缀找到对应的 KV 缓存块。尾部的 14 个 token 仍需正常计算、保存 KV,只是在本节的完整块哈希路径中,暂时不为这段未满块的尾部生成块标识。

生成哈希的用途,是把“我要找哪段前缀的 KV”变成一个方便查表的键。 可以把这个键理解为根据输入内容算出的“查找编号”。假设 A 已经完成,B 随后到来,第一块的 128 个 token 和相关条件相同,查找过程可以这样理解(h0、P0 均为示意名称,省略缓存组信息):

A 完成后:缓存目录中有 h0 → P0,P0 对应已计算好的 KV
B 到来时:相同前缀 → 算出同样的 h0 → 查目录 → 找到 P0 → 复用 KV

B 根据已有输入 token 就能生成这个查找键,不需要先重新运行模型计算这段 KV。哈希本身也不表示缓存一定存在:如果目录里已经找不到对应块,就不能通过这个键复用旧结果。因此,生成哈希解决“查什么”,随后查缓存索引才解决“有没有、在哪里”。

Request.update_block_hashes 调用请求保存的 _block_hasher,将生成的哈希加入 request.block_hashes。具体闭包由 get_request_block_hasher 创建;它在第 842 行取出一块 token IDs:

block_tokens = request.all_token_ids[start_token_idx:end_token_idx]
block_hash = hash_block_tokens(
    caching_hash_fn, prev_block_hash_value, block_tokens, extra_keys
)

循环只处理完整块,不足一块就停止。以本次块大小 128、B 输入 1038 为例,初始输入能生成 8 个完整块的标识;最后 14 个 token 暂不构成第 9 个完整块。

hash_block_tokens 的关键算式位于第 648 行:

hash_function((parent_block_hash, curr_block_token_ids_tuple, extra_keys))

三个组成部分分别是:前一块的哈希、本块的 token IDs,以及 LoRA 等附加条件的标识。本次固定模型的纯文本对照中,附加条件保持一致。哈希可以理解为用于查表的指纹;它既不是 KV 内容,也不是实际存放 KV 的物理块号。

其中 parent_block_hash 将各块串起来。下面的 h0、h1 是示意名称,不是采集到的真实哈希值:

h0 = hash(统一初始值, token[0:128],    附加条件)
h1 = hash(h0,         token[128:256],  附加条件)
h2 = hash(h1,         token[256:384],  附加条件)
...
h7 = hash(h6,         token[896:1024], 附加条件)

因此,h1 不只依赖第二块的文字,还包含前面第一块的信息;同理,h7 的标识联系着从开头到第 1024 个 token 的前缀。这样做与因果 Attention 的语义一致:一个位置的计算状态可能受前面上下文影响,后面文字相同并不足以保证其 KV 可以直接复用。

5.2 A 留下的块如何进入查找索引

BlockPool.cache_full_blocks使用请求预先生成的哈希,为缓存完整块登记索引;其调用的 _insert_block_hash 在第 638 行执行:

self.cached_block_hash_to_block.insert(block_hash_with_group_id, block)

可以把它理解为下面的概念映射,P0、P1 只是示意物理块,并非本轮测得的块号:

(h0, 缓存组) → 缓存块 P0
(h1, 缓存组) → 缓存块 P1
...
(h7, 缓存组) → 缓存块 P7

索引在请求处理过程中维护,并不是必须等请求结束才登记;登记元数据的时刻也不能直接当作设备计算完成的时刻。本实验明确等 A 完整返回,再提交 B,因而先准备好了 A 的计算结果。

A 结束时,free_blocks 会减少块引用,并将不再被请求占用的块放回可分配队列;它不必立即清除这些块的缓存索引。只要相关索引与内容仍有效,后来的 B 就可能命中;被淘汰或显式重置后则不能继续依靠该条记录复用。

这里能看出为什么“算出哈希”不等于“缓存命中”:首次 A 一样能算出 h0~h7,但当时索引里没有相应块;B 后来用相同的标识查表,才有机会找到 A 留下的块。

5.3 判断是否允许查找,再逐块查表

本地阅读版本把开关判断提取为 prefix_cache_lookup_enabled:

return self.enable_caching and not request.skip_reading_prefix_cache

允许查缓存需要同时满足两个条件:引擎开启缓存,而且当前请求没有要求跳过缓存读取。允许查找和实际命中是两件事。 即使允许查找,缓存里也可能没有对应的前缀。

get_computed_blocks(request) 接收一个请求对象,用它描述的输入和缓存标识查找可复用内容。若不允许查找,这个提交中的函数直接返回空块集合和零计数;允许查找时,核心调用是:

computed_blocks, num_new_computed_tokens, _ = (
    self.coordinator.find_longest_cache_hit(
        request.block_hashes, max_cache_hit_length
    )
)

这段摘录用 _ 省略了本轮不展开的第三项管理信息。可以把核心调用读成一句话:拿着当前请求的块标识,在允许的长度范围内,寻找最长的可复用前缀。

request.block_hashes 是用于查找的块标识序列,包含前缀关系;它不是设备中的 KV 数值本身。max_cache_hit_length 限制本次查找的最长范围,完整块和末端计算等边界也会影响最终命中长度,5.4 节将结合实际输入说明。

单组路径中,coordinator 把查找转交给 FullAttentionManager.find_longest_cache_hit。在 single_type_kv_cache_manager.py 第 771 行,普通完整块分支的实际循环是:

for block_hash in itertools.islice(full_block_hashes, max_length // block_size):
    cached_block = block_pool.get_cached_block(block_hash, kv_cache_group_ids)
    if not cached_block:
        break
    for computed, cached in zip(computed_blocks, cached_block):
        computed.append(cached)
hit_length = len(computed_blocks[0]) * block_size

这段循环的行为很具体:从第一个块开始查,查到就加入命中列表,第一次没查到就停止。zip 在这里处理缓存组;本轮只需关注单组里按顺序累积的块。

其中 BlockPool.get_cached_block 在 block_pool.py 第 217 行真正查询索引:

block = self.cached_block_hash_to_block.get_one_block(block_hash_with_group_id)

未找到返回 None;找到返回缓存块管理对象。“最长前缀”是通过从开头累计连续命中的块得到的,不需要先与 A 做一次全文比较、算出共同前缀是 1026。
请添加图片描述

5.4 用这条源码链解释 1024 与 0

B 与 A 的前 8 个完整块 token IDs 相同,在同一引擎和相同附加条件下会得到相同的 h0~h7。A 完成后相关缓存块仍可复用,B 就能连续找到 8 个块,命中长度为 8 × 128 = 1024。

本例不是“查到第 9 块失败”:B 的初始输入只有 8 个完整块,查找上限 1038−1=1037 也最多容纳 8 个完整块。因此查完这 8 个候选后就结束,最后 14 个 token 中即使还有 2 个与 A 相同,也不能形成额外完整块命中。查找上限保留末端重算空间的细节可另作边界实验。

B′ 修改的是下标 17,即第一个块内部的 token。本块 token 变化会改变 h0,并通过父哈希影响后续块的标识。对本次缓存内容,查第一个块就未命中,循环立即 break,所以返回 0;不会跳过第一个块,再从后面相同的文字重新开始复用。

以上是本地源码对现象的解释;本轮只采集了请求级命中,没有记录每个真实哈希、查表事件或物理块号。

5.5 返回的是块与长度,随后才决定计算任务

最终,get_computed_blocks 将结果整理后返回。首轮关注前两项:

返回值本轮如何理解
blocks找到的可复用块的管理对象,描述要复用哪些缓存块
num_new_computed_tokens此次查找确认可以视为已计算的 token 数;这里的“已计算”来自缓存复用,不是这个函数刚执行了一次模型前向

这段逻辑运行在主机侧,处理缓存管理信息,不在这里计算 NPU Attention。找到块之后,调度器使用命中长度安排剩余工作,模型执行时再读取相应 KV。

下面的简图连接缓存查找与调度。它依据本地阅读版本的普通新请求路径整理,箭头表示处理先后与数据传递,省略中间辅助调用和其他模型分支;关键源码入口见附录 D。这张图是静态阅读结果,不是逐轮运行日志。

请添加图片描述

例如,将 B 的输入长度 1,038 和热缓存命中长度 1,024 代入减法,会得到初步待算量 14;再经过预算限制和分配检查,才形成实际调度任务。因此,读源码时应分别找到“命中长度从哪里返回”与“本轮计算量在哪里写入任务单”。

6. 首轮机制实验的结果

OFF 与 ON 两次进程均成功结束,ON 进程中的三次缓存重置均返回 True。原始 JSON 已保存,并核对传输前后哈希及各组输入关系。A、B、B′ 的实际输入长度均为 1,038 token。与问题直接相关的结果如下:

条件与输入请求级命中 token 数生成的 3 个 token ID
OFF_B0[73670, 65770, 16872]
COLD_B0[73670, 65770, 16872]
A 完成后的 WARM_B1,024[73670, 65770, 16872]
OFF_Bprime0[65770, 16872, 100768]
A 完成后的 PREFIX_CHANGED_Bprime0[65770, 16872, 100768]

这组结果把“开启缓存”和“实际复用”区分开了:COLD_B 虽然开启缓存,仍报告零命中;同一引擎先完成 A 后,WARM_B 报告命中 1,024 个 token。A、B 的实际共同前缀为 1,026 个 token,按实际 block size 128,只够组成 8 个完整块,即 1,024 个 token。共享内容长度没有直接变成命中长度,结果还受到完整块边界的约束。

B 的输入: [128][128][128][128][128][128][128][128] [剩余 14]
            └──────── 8 个完整块,共 1,024 token ────────┘
                         本轮缓存命中                未命中

实际共同前缀还延伸到剩余部分的前 2 个 token,
但这 2 个 token 不足以构成另一个完整的 128-token 块。

B′ 只改变共同段中下标 17 的 token,本次报告零命中。它与自己的 OFF 对照输出一致;B 的三个缓存条件也得到相同的 3 个输出 token。这支持本次样本中的输出一致性,尚不能推广为任意输入、长输出或所有执行配置下的验证结论。

虽然 1,038 - 1,024 = 14,这个差值不能写成“日志证明首轮只计算了 14 个 token”。目前拿到的是请求级命中统计,还没有逐轮调度证据,也没有逐个缓存块的引用与释放记录。接口实验已回答“是否复用”;要继续确认“复用怎样改变实际安排的计算”,还需沿 Scheduler 补充运行观测。

这轮机制实验没有计时或采集 profiler,不能从它推算加速比。2026-10-13 另行执行的性能实验见下一节;两轮使用相同输入,但性能实验把 B 的输出长度固定为 64 token。

本轮的实验脚本、执行过程和结果核对摘要,统一列在附录 B。

7. 命中 1024 个 token,实际能快多少?

1038 个输入 token 中有 1024 个命中,比例约为 98.7%。但这个比例数的是输入长度,一次请求还包含调度、模型执行和逐步生成答案,不能直接把它当成时间节省比例。

7.1 把模型预热与前缀缓存预热分开

模型预热是先执行请求,尽量排除初始化和首次执行的影响;前缀缓存预热是先处理 A,把 B 所需的前缀 KV 留在缓存中。模型已经预热,不代表 B 必然命中;清掉前缀缓存,也不等于重新加载模型。

冷、热两组都开启 Prefix Cache,都先完整运行同一个 A,只改变 B 到来前是否保留缓存:

冷组:清前缀缓存 → A 完成 → 再清前缀缓存 → [计时 B]
热组:清前缀缓存 → A 完成 → 保留前缀缓存 → [计时 B]

A 和清缓存不计入 B 的耗时。每次独立启动先做 3 对预热,再做 8 对正式测量,冷、热顺序交替;共 3 次启动、24 对正式请求。B 固定 1038 输入、64 输出,A 固定输出 3 token。模型、软件和执行方式沿用第 3 节,使用 BF16,max_model_len 与 token 预算均为 2048;完整命令见附录 B。

7.2 计时范围:从提交请求到收到输出

脚本使用 A3 本机的单调时钟,在调用 add_request 前开始计时,每次 step() 返回后立即记时间,再检查新增输出:

提交 B                  收到第 1 个 token                   收到第 64 个 token / 完成
  │                            │                                      │
  └──── 首 token 等待 TTFT ────┘                                      │
                               └────── 首到末 token 的输出耗时 ───────┘
  └────────────────────── 请求总耗时 ─────────────────────────────────┘

本次每个输出事件恰好新增一个 token,末 token 与完成事件同时返回。后续 token 的平均耗时 TPOT,按“首到末输出耗时 ÷ 63”计算。

这里测量的是本机引擎接口可见的等待时间,包含主机处理、调度、模型执行、进程间通信和输出处理,不含模型加载、分词、HTTP 或 SSH 网络往返。它不是纯 Prefill/Decode 算子的耗时。本地源码中,add_request 提交请求,step 获取并整理结果;远程使用含进程间通信的 SyncMPClient。版本定位见附录 D。

7.3 1K 的结果:命中明确,收益有限

包括预热在内的 66 个 B 请求全部通过核对:冷组命中 0,热组命中 1024;每个 B 都生成相同的 64 个 token。下面只统计每次 8 对正式请求,数值为各组中位数:

独立运行冷 TTFT热 TTFT冷总耗时热总耗时
0138.29 ms38.52 ms2195.37 ms2217.57 ms
0238.55 ms37.42 ms2150.13 ms2154.59 ms
0338.51 ms38.21 ms2175.07 ms2179.84 ms

第二次的热 TTFT 中位数降低约 2.9%,说明并非完全没有改善信号。但逐对比较,三次运行中热 TTFT 更快的对数分别为 4/8、7/8、4/8,整请求耗时也没有跨启动一致的改善。因此,这个 1K 样本确认了缓存命中和小幅首 token 改善信号,尚未表现为稳定的整请求加速。 各段耗时、配对差值和范围保留在附录 C,不根据小差异断言缓存一定更快或更慢。

7.4 为什么 98.7% 命中,没有带来相近的时间节省?

Prefix Cache 复用了前缀 KV,没有替 B 生成后面的 64 个答案 token。处理 B 的新 token 时,Attention 仍要使用历史 KV;后续生成步骤也仍然存在。

本次冷组 TTFT 约 38 ms,首到末输出却约 2.1 秒,大部分等待发生在首 token 之后。以第一次运行的冷组中位数作量级示意,TTFT 只占总耗时约 1.7%;即使假设把这一段全部消除、后续时间不变,总时间也只减少约 1.7%。这是说明时间构成的假设,不是实测收益或其他负载的上限。

至于 TTFT 本身为什么改善较小,仅凭接口计时还不能确认原因:小输入的可节省工作、主机固定开销、计算形状与运行波动都可能参与其中,需要 profiler 才能进一步归因。下一节先检验一个更直接的假设:把可复用前缀加长,收益是否更容易显现?

8. 输入扩大到 16K,结果会改变吗?

上一节的模型只有 0.6B,输入约 1K。冷请求的 Prefill 本身可能就不长,缓存省下的工作反映到接口时间上也可能很小。于是,这一轮保持模型与输出长度不变,只把可以复用的共同前缀扩大到 1K、4K、8K、16K,观察冷、热差距是否随之扩大。

8.1 保持比较公平

四档共同前缀分别有 1024、4096、8192、16384 个 token,加上各自 14-token 的问题后缀,B 的实际输入长度为 1038、4110、8206、16398。长前缀由原来的 1024-token 段重复构造;它是用于控制长度的合成输入,不是长文档质量测试。

为了容纳最大输入,统一把 max_model_len 和 max_num_batched_tokens 改为 18432,1K 也在这套配置下重新测量。因此,本节表格不直接混用上一节 2048 配置的结果。其余主要条件仍是 Qwen3-0.6B、单 NPU、BF16、Eager、同步调度、并发 1,B 固定输出 64 token。

每次独立启动都测四种长度,每个长度先预热 3 对,再测 8 对;三次启动改变长度的先后顺序,每对冷、热也交替先后。总计 96 对正式请求,每个长度各 24 对。冷组仍是“A 完成后清缓存再测 B”,热组是“A 完成后保留缓存再测 B”。

三次的四档测量全部通过检查:冷组均命中 0,热组分别命中 1024/4096/8192/16384。同一长度下,包含预热的全部 B 均输出相同的 64 个 token,跨三次启动也一致;不同长度可以生成不同内容。原始事件、396 次成功 reset、输入、配置与统计均已复核。完整命令和日志见 附录 B 的长输入实验。

8.2 长输入下,首 token 收益变得明确

下表按每个长度分别合并三次启动,给出每组 24 个正式请求的中位数;加速比定义为“冷 TTFT 中位数 ÷ 热 TTFT 中位数”,不是配对加速比的中位数。

共同前缀冷 TTFT热 TTFT首 token 加速比热组 TTFT 更快的配对数
1K37.28 ms37.36 ms约 1.00 倍9 / 24
4K49.17 ms39.55 ms1.24 倍24 / 24
8K111.08 ms42.07 ms2.64 倍24 / 24
16K318.51 ms47.28 ms6.74 倍24 / 24

这次不只是合并中位数好看:4K、8K、16K 的每一对正式请求,热组 TTFT 都更短。16K 在三次独立启动中的冷/热中位数分别为 319.23/46.88、317.92/47.70、318.34/48.17 ms,趋势得到重复。1K 仍然没有明确优势。

请添加图片描述

图中蓝色为冷缓存、橙色为热缓存;点为每组 24 个请求的中位数,误差线表示全部样本的最小~最大值,不是置信区间。三次独立运行的分组结果与每对差值保存在 附录 C 所列的审计与统计文件 中;没有把每一对请求当成一次独立部署。

8.3 首 token 快了 6.74 倍,整段回答呢?

16K 这一档的请求总耗时中位数,从冷组 2410.80 ms 降到热组 2142.90 ms,按两个中位数计算减少约 11.1%,总耗时加速比约 1.13 倍。24 对中热组总耗时全部更短,配对节省时间的中位数约为 273.71 ms。

但首 token 到末 token 的输出耗时中位数,冷、热仍分别约为 2091.79 / 2096.08 ms;平均 TPOT 约为 33.20 / 33.27 ms。这部分没有出现类似首 token 的明显提速。

16K 冷缓存: [首 token 等待约 319 ms] [后续 63 个 token 约 2.09 s]
16K 热缓存: [首 token 等待约  47 ms] [后续 63 个 token 约 2.10 s]

这是按各段中位数取整的量级示意,各段中位数之和不要求恰好等于总耗时中位数。它说明:这组实验中,明确的收益主要表现为更早收到第一个 token,后续答案仍需逐步生成。 上一节约 1.7% 的时间占比只属于原来的 1K 工作负载;到了本节 16K,冷组 TTFT 占总耗时约 13.2%,可节省的时间空间已经改变。

8.4 回到源码:少算前缀,但后缀仍要使用上下文

第 5 节的链式哈希与完整块查找,在 16K 输入上遵循同样规则。A/B 实际共享 16386 个开头 token,按 128 一块,报告复用 128 个完整块,即 16384 个 token。B 仍有 14 个输入 token 不在命中覆盖内;这依然只是输入与命中的差值,不是本轮采集到的 Scheduler 逐轮任务。

为何热缓存的时间也从约 37 ms 增长到 47 ms?源码至少能说明:命中前缀并没有让历史 KV 消失。 本地 Ascend 提交 cb79183… 的 attention_v1.py:817 在缓存命中路径取出缓存视图和块表,并保留 seq_lens_list 作为 KV 的上下文长度;第 855 行附近 则按元数据记录的有效 query 数量,截取已经准备好的 query 张量。哪些输入需要重新计算,由此前的调度决定。

冷请求:为长前缀计算状态 → 处理问题后缀 → 开始输出
热请求:找到长前缀的 KV → 后缀使用已有 KV → 开始输出
                           ↑
                    历史上下文仍参与 Attention

本地是静态阅读依据;远程实际运行的 Ascend 提交仍为 f2f74a16…,对应缓存/上下文参数处理在第 1238~1269 行,query 截取在第 1294~1298 行。具体分支未做逐轮 trace,不能把静态图当作完整运行日志。

这说明热请求仍有随上下文变化的工作,但不能据此把那约 10 ms 的增长全部归因于读取 KV。API 时间还包括输入处理、哈希、调度、IPC 和输出处理;本轮没有 profiler。计时脚本的完整输入比对发生在每次时间戳之后,不影响首次 TTFT,却也可能影响后续输出间隔。

至此,长度对照支持了一个有边界的结论:在这套小模型、单请求、Eager 配置下,1K 输入不容易显现缓存收益;前缀扩大到 4K~16K 后,首 token 收益明显并能够重复,16K 的总耗时也得到改善。 这仍是合成重复输入的延迟实验,不是多用户吞吐或自然长文档质量结论。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐