Fish/Speech/KV/Cache推理优化记录


Fish Speech KV Cache 推理优化记录

相关贡献:Fish Speech PR #1312,已合并

我在给角色短剧制作日语配音时使用了 Fish Speech S2-Pro。它在我的参考音频和台词上,音色一致性比较可靠,但本地生成速度仍然影响使用体验。于是,我从实际推理过程入手,定位到了一处可以减少冗余工作的地方:每一步注意力计算接收到的 KV cache,包含了大量尚未使用的容量。

这次优化保留 KV cache 的完整预分配空间,只让注意力读取当前有效的前缀。在我提交的基准中,GV100 的原生 eager 推理吞吐达到原来的 4.60 倍,torch.compile 模式达到 1.97 倍;维护者随后在 H200 上独立测得 2.14 倍和 1.65 倍的提升。它们都是特定条件下的自回归生成吞吐结果,具体条件和验证范围会在后文说明。原始基准 · 维护者复测

1. 为什么从 Fish Speech 入手

我的使用场景是逐句生成角色对白,主要是用日语参考音频合成日语台词。这类任务既需要发音和情绪自然,也需要同一个角色连续说多句话时保持音色一致。

在我之前的使用中,Qwen3-TTS 有时会出现音色漂移。我为此接入了 RoleTone,给结果评分,低分就重新生成。这个办法可以减少一部分人工复听,但评分模型本身也有偏差,仍然需要人的判断。Fish S2-Pro 在我这组素材上的表现更稳定,复听和重生成的负担明显较小,因此我更愿意继续优化它的速度。

这里描述的是我的项目体验,不是统一测试集上的模型排名。参考音频、文本、采样配置和模型版本都会影响结果。对我的工作流来说,真正关心的是得到一条可用配音需要多久,这里面还包括重试和检查的时间。

2. Fish Speech 是怎样把文字变成语音的

Fish Speech 是项目名称,本文讨论的具体模型是 S2-Pro。理解这次优化,需要先知道它的两个主要部分:音频编解码器和 Dual-AR 生成模型。

音频编解码器把连续的波形压缩成离散 token。S2 使用 10 层 RVQ 码本,帧率约为 21 Hz:第一层侧重语义,后面九层补充声学细节。因此,一帧音频由一组码本 token 表示,而不是单个文字 token。生成完成后,编解码器再把这些 token 还原为波形。S2 技术报告:音频表示

Dual-AR 把生成过程拆成两个方向:

  • Slow AR 沿时间推进。 较大的 Transformer 读取文本和历史音频上下文,预测下一帧的主语义 token,并给出隐藏状态。
  • Fast AR 在当前帧内补全码本。 较小的 Transformer 根据这些条件,依次生成剩余声学 token。

这样就不需要让大模型沿时间轴逐个生成全部十层码本。Slow AR 主要承担跨帧建模,Fast AR 处理帧内细节。S2 技术报告:Dual-AR

flowchart TD
    A[参考音频] --> B[音频编码器]
    B --> C[参考音频 token]
    D[参考转写与目标台词] --> E[组合输入上下文]
    C --> E
    E --> F[Slow AR:预测主语义 token]
    F --> G[Fast AR:补全声学码本]
    G --> H[当前帧完整 token]
    H -->|作为后续上下文| F
    H --> I[音频解码器]
    I --> J[语音波形]

这是针对参考音频克隆场景的简化示意。参考声音以条件信息进入推理过程,使用时无需为每个角色重新训练模型。本文优化的位置是原生 PyTorch 自回归推理中的注意力与 KV cache 路径。官方推理流程

3. 与 Qwen3-TTS、IndexTTS 的关系

这几个项目都支持从参考音频生成新语音,但生成链路和控制方式有所不同。下面按公开实现作简化对比;它不表示同等硬件下的速度或音质排名。

项目与版本生成链路概括与角色配音有关的特点
Fish Speech S2-ProSlow AR 生成主语义 token,Fast AR 补全码本,再由 codec 解码支持参考音频克隆、文本内情绪标签、多说话人及多轮生成
Qwen3-TTS 12Hz 系列主干预测第一层码本,MTP 模块生成其余码本,因果解码器还原波形支持日语;Base 用于参考音频克隆,CustomVoice 和 VoiceDesign 分别面向预设音色控制、描述式音色设计
IndexTTS-2 / 2.5自回归模型生成语音表示,经过语义到梅尔谱的声学模块与声码器生成波形强调音色与情绪分离控制;2.5 增加日语等语言支持及假名、语速控制

来源:Fish Speech 官方说明 · Qwen3-TTS 官方模型说明及技术报告 · IndexTTS 官方说明及推理代码。对比资料核对于 2026-09-17。

4. 瓶颈:有因果 mask,仍然传入了完整缓存

自回归生成会反复读取历史信息。KV cache 将历史 token 的 Key 和 Value 保存起来,避免每一步重新计算历史投影。为了便于写入,程序可以事先按最大上下文长度分配缓存。

问题在于,缓存的分配容量和当前需要读取的长度,是两个不同的量。

在当时的原生实现中,KVCache.update() 返回完整的预分配 K/V;随后程序用 repeat_interleave 扩展 KV heads,再将完整长度的张量传入 PyTorch SDPA,也就是 scaled dot-product attention。因果 mask 会屏蔽未填充的尾部,但传入张量的 K 维仍然等于最大缓存容量。原始实现与问题记录

我测试的 S2-Pro 配置中,max_seq_len=32768,prompt 只有 234 个位置,生成结束时上下文也只有约 400 个位置。实际使用的缓存占比约为 0.7%~1.3%。

量当时的示例
物理 KV cache 容量32768 个位置
已填充的有效前缀约 234~400 个位置
优化前送入 SDPA 的 K/V 长度32768
优化后送入 SDPA 的 K/V 长度当前有效前缀长度

此外,模型使用 32 个 query heads、8 个 KV heads,原生路径需要把 KV heads 扩展四倍。在 FP16、head dimension 为 128 时,完整长度扩展后的 K 和 V 合计对应:

2 × 32 × 32768 × 128 × 2 bytes = 512 MiB / layer

这里是扩展张量的逻辑数据量估算,不是额外常驻显存,也不是实测 DRAM 流量;编译器和算子实现会影响实际开销。它说明了为什么要在 head 扩展之前减少长度。配置与估算依据

这次定位能够直接证明的是:程序扩展并传入了完整 K/V。不同 SDPA 后端如何处理 masked tail,不能仅凭 mask 或张量形状下结论,最终收益需要通过 A/B 测试确认。

5. 修改:保留容量,只读取 active prefix

我的方案保留完整缓存及原地写入逻辑,把有效长度 kv_len 沿生成调用链传下去,再同时收窄 mask、K 和 V 的长度。

阶段kv_len
Prompt prefillT,即 prompt 长度
第一次 decodeT + 1
后续第 i 次 decode,从 0 计数T + i + 1

关键操作可以简化成下面的伪代码。实际代码以 mask 的最后一维作为 attention 层的有效 K 长度:

# 先更新完整的物理缓存。
k, v = kv_cache.update(input_pos, k, v)

# 在扩展 heads 之前,只取已填充的前缀。
k = k[:, :, :kv_len]
v = v[:, :, :kv_len]
mask = causal_mask[None, None, input_pos, :kv_len]

k = k.repeat_interleave(n_head // n_local_heads, dim=1)
v = v.repeat_interleave(n_head // n_local_heads, dim=1)
output = scaled_dot_product_attention(q, k, v, attn_mask=mask)

源码位置:合并版本的 llama.py。

这相当于只读取仓库里已经放了东西的货架。预留的空间仍然存在,后续生成仍可继续写入。它也不同于滑动窗口:有效前缀内的历史没有被丢弃,只去掉尚未使用的尾部。

因此,这次修改不需要重新训练或量化,也没有通过缩小最大上下文来换取速度。主要收益来自减少 head 扩展与注意力路径中的冗余张量处理。训练使用的普通 full-sequence forward() 不走这条推理 KV cache 路径。PR 变更说明

最终实现还补齐了几处工程细节:

  • 长度传递。 正常生成路径直接从 Python 循环传入位置,避免仅为计算 kv_len 而逐步调用设备 tensor 的 .item();旧的直接调用方式仍有兼容回退。
  • 边界校验。 检查前缀长度和写入位置,防止越界或读取范围错误。CPU/CUDA 使用相应的异步断言路径;其他后端保留 eager 校验。因此,不能把最终实现概括成所有后端都完全没有同步。
  • 编译兼容。 添加 dynamic=True,并用测试检查前缀增长时的图复用。动态长度的编译行为需要专门验证。
  • 旧回调兼容。 判断自定义 decode 回调是否接收 kv_len,避免新增参数直接破坏已有调用方。

这些改动与测试都保留在最终 diff中。

6. 效果:我自己的基准与维护者的独立复测

下面将几组结果放在一起。每行只比较同一设备、同一路径下的优化前后;MPS 的服务诊断指标与 CUDA CLI 的统计口径不同,不作跨设备绝对速度比较。

设备与路径优化前优化后吞吐倍数测试来源
M4 Pro 48 GB,MPS FP164.08 frames/s8.31 frames/s2.04×我的服务诊断,max_seq_len=4096
Quadro GV100 32 GB,CUDA eager2.73 tok/s12.57 tok/s4.60×我的原生 CLI 基准
Quadro GV100 32 GB,torch.compile16.65 tok/s32.74 tok/s1.97×我的原生 CLI 基准
H200,CUDA eager9.22 tok/s19.73 tok/s2.14×维护者独立复测
H200,torch.compile68.60 tok/s113.33 tok/s1.65×维护者独立复测

我自己的 CUDA 测试使用相同模型、参考 tokens、目标文本、采样参数和 seed;每种模式生成四次,第一次作为 warm-up,报告后三次吞吐的中位数。编译模式的第一次还包含编译,因此不计入稳态比较。

CUDA 基准条件配置
模型与精度S2-Pro,FP16
硬件、系统Quadro GV100 32 GB,Ubuntu x86_64
软件Python 3.12,PyTorch 2.8.0+cu128,Triton 3.4.0
缓存容量、prompt 长度32768、234
Seed42
采样参数temperature=0.8、top_p=0.8、top_k=30
输出上限max_new_tokens=1024
对照提交基线 e5e2926,优化原型 c4146e7

由于 FP16 数值差异可能改变随机采样轨迹,生成长度略有不同,所以用生成 token 数除以时间进行比较,而不是只比较整次请求耗时。完整设置与逐次数据见原始 issue。

GV100 的峰值 CUDA reserved memory 也有所下降:

模式优化前优化后
Eager17.33 GB15.16 GB
torch.compile15.72 GB15.16 GB

这里按日志使用十进制 GB,指标为 torch.cuda.max_memory_reserved() / 1e9。它包括 PyTorch 分配器保留的空间,不能等同于模型权重大小或常驻 KV cache 大小;物理缓存容量在这次修改中仍然保留。

这些结果有明确的适用范围。 它们测量的是原生自回归生成路径,不能直接当作完整 TTS 服务的加速倍数。参考音频处理、codec 解码和服务开销还需要计入端到端耗时;它们也不代表 SGLang 等独立推理后端会获得相同收益。我的主要基准只使用了一组目标文本和参考音频,冷启动与冷编译时间没有纳入。

此外,GV100/MPS 数字来自提交时的优化原型,H200 数字来自维护者在 8 月 review 时检查的版本。后续又加入了校验、编译和兼容性修改。现有公开记录没有提供对最终合并快照的同条件完整重测,因此这里把数据明确标为各阶段的验证结果。

7. Review:加速之外,还要定义正确性

维护者认可了性能收益,但要求把回归测试保留在仓库里。这个要求很关键:注意力有效长度改变后,底层归约和编译执行可能产生浮点差异;在自回归采样中,一点差异又可能改变后续整条生成轨迹。

在 H200 的 FP16 对比中,维护者观察到前五个 decode 位置的 argmax 一致,但最大 logit 绝对差达到 0.078125;相同 seed 的完整生成中,630 个声学码元素有 170 个不同。这个比例描述的是离散输出差异,不能解读为“约 27% 的语音错误”,也不能单凭它认定音质下降。review 记录

我据此补充了不同层次的验证。最终合入的测试文件包含七个测试方法:

检查内容要回答的问题
Attention 输入形状SDPA 是否只收到有效前缀,而物理缓存仍保持完整容量?
Float32 数值比较与 argmaxactive-prefix 与完整缓存加 mask 的输出是否在明确容差内一致?
长度和位置边界最小、最大合法值以及非法长度、位置是否得到正确处理?
Prefill / decode 推进mask、K、V 的有效长度是否同步增长?
真实 AR 调用链生成形状、slow/fast logits、概率分布和 greedy tokens 是否满足约定?
旧 decode 回调不接收 kv_len 的调用方式是否仍能使用?
动态编译图小型测试中,多步 decode 是否复用同一个动态计算图?

数值测试采用 rtol=1e-5、atol=1e-6;生成回归使用 top_k=1 的确定性配置,检查 token 一致性。这里的“真实调用链”指自回归生成链,并不包含完整音频 codec 和 HTTP 服务。动态编译测试使用计数 backend,也不能替代所有 GPU、所有编译器版本上的性能测试。

我还对四组代表性 CUDA 输出进行了解码和人工试听,未听出明显退化。但试听样本有限,这不等于完成了大规模盲测或客观音质评估。数值测试、生成测试和试听分别提供不同层面的证据。