从源码到实测: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 Toolkit | 9.1.0,内部版本V100R001C11SPC001B243(10 月 13 日安装信息) |
| Python | 3.12.13 |
| PyTorch / torch-npu | 2.10.0+cpu / 2.10.0.post4 |
| vLLM / vLLM-Ascend | 0.26.0+empty / 0.26.0rc1 |
| Transformers | 5.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,再执行 B | A 留下的前缀能否被 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,框架需要先弄清三件事:
- **内容对不对:**B 开头这段输入,是否与缓存对应的前缀相同?
- **结果还在不在、在哪里:**对应的 KV 是否仍可复用,存在哪些缓存块中?
- **能接着用到哪里:**从输入开头算起,连续多少个 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 → 复用 KVB 根据已有输入 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:
returnself.enable_cachingandnotrequest.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 行,普通完整块分支的实际循环是:
forblock_hashinitertools.islice(full_block_hashes,max_length//block_size):cached_block=block_pool.get_cached_block(block_hash,kv_cache_group_ids)ifnotcached_block:breakforcomputed,cachedinzip(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_B | 0 | [73670, 65770, 16872] |
| COLD_B | 0 | [73670, 65770, 16872] |
| A 完成后的 WARM_B | 1,024 | [73670, 65770, 16872] |
| OFF_Bprime | 0 | [65770, 16872, 100768] |
| A 完成后的 PREFIX_CHANGED_Bprime | 0 | [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 | 冷总耗时 | 热总耗时 |
|---|---|---|---|---|
| 01 | 38.29 ms | 38.52 ms | 2195.37 ms | 2217.57 ms |
| 02 | 38.55 ms | 37.42 ms | 2150.13 ms | 2154.59 ms |
| 03 | 38.51 ms | 38.21 ms | 2175.07 ms | 2179.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 更快的配对数 |
|---|---|---|---|---|
| 1K | 37.28 ms | 37.36 ms | 约 1.00 倍 | 9 / 24 |
| 4K | 49.17 ms | 39.55 ms | 1.24 倍 | 24 / 24 |
| 8K | 111.08 ms | 42.07 ms | 2.64 倍 | 24 / 24 |
| 16K | 318.51 ms | 47.28 ms | 6.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 的总耗时也得到改善。这仍是合成重复输入的延迟实验,不是多用户吞吐或自然长文档质量结论。