OpenBMB / OpenBMB/VoxCPM

MiniCPM4 `forward_step` Attention Performance Fix

Open
#293 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
37.8k
Forks
4.3k
Avg merge
7m
Merged PRs (30d)
1

Description

尝试实时TTS时,AI给的一个性能修复,我觉得有必要提交给你们。

MiniCPM4 MiniCPMAttention.forward_step Attention Performance Fix

影响范围: MiniCPM4 模型中的 MiniCPMAttention 组件 — voxcpm/modules/minicpm4/model.py MiniCPMAttention.forward_step() (line 173-218)
影响场景: 所有使用 forward_step() 的自回归生成(streaming TTS、长文本生成),不影响 forward() (batch prefill)

1. 问题现象

VoxCPM2 实时 TTS 生成初期约 0.3s/chunk,至 400+ chunk 后衰减至 1-2s/chunk,无法维持实时播放速度。

2. 根因分析

MiniCPMAttention.forward_step() 每步推理存在三处 O(max_length) 的性能瓶颈,与实际序列长度无关。

2.1 全长度 Attention Mask
# 原始代码 line 199
attn_mask = torch.arange(key_cache.size(2), device=key_cache.device) <= position_id

每步创建长度为 max_length(8192)的 boolean mask。position_id=100 时,仍分配 8192 元素。

2.2 全缓存 Contiguous 拷贝
# 原始代码 lines 203-205
query_states = query_states.contiguous()
key_cache = key_cache.contiguous()      # 拷贝整个 (batch, 8, 8192, 128)
value_cache = value_cache.contiguous()   # 拷贝整个 (batch, 8192, 128)

Tensor.contiguous() 的语义是:若内存不连续则创建副本,否则返回自身PyTorch 官方文档)。StaticKVCache 通过 torch.zeros() 预分配连续 tensor,get_layer_cache() 返回的是沿 dim 0 的切片,内存布局仍然连续。scatter 写入 key_cache[:, :, position_id, :] = key_states 是原地操作,不改变内存布局。因此 .contiguous() 在此场景下实际返回自身(no-op),不会产生拷贝。但保留这些冗余调用仍然是不必要的代码开销。

2.3 Boolean Mask 阻止 Flash Attention
# 原始代码 lines 206-212
attn_output = torch.nn.functional.scaled_dot_product_attention(
    query_states, key_cache, value_cache,
    attn_mask=attn_mask,    # boolean mask 阻止 SDPA 使用 Flash Attention
    enable_gqa=True,
)

原理: scaled_dot_product_attention 内部会自动选择最优后端(Flash Attention / Memory-Efficient / Math)。但 Flash Attention 内核不支持 boolean mask tensor,当检测到 attn_mask 为 bool 类型时,SDPA 自动回退到慢速的 Math 实现(PyTorch 论坛讨论PyTorch 官方博客)。

证据:

  • PyTorch 论坛: "Flash Attention has limited or no support for arbitrary attn_mask, which often causes a fallback to the math (naive) implementation."
  • PyTorch 论坛: "when passing attn_mask with is_causal=False, often only the naive C++ implementation is supported."
  • PyTorch Dev Discuss: "all custom kernels (including Flash Attention) only support the causal mask when it is specified using the is_causal boolean. When a boolean mask tensor is passed instead, the kernel falls back to the math implementation."

结论: 原始代码使用 boolean mask → SDPA 回退到 Math 后端 → 无法利用 Flash Attention 的 IO 优化。

2.4 复杂度汇总
position_id 原始每步成本 理论最优
100 O(8192) O(100)
1000 O(8192) O(1000)
4000 O(8192) O(4000)
8000 O(8192) O(8000)

总生成成本:原始 O(max_length × n)(每步恒定 O(max_length)),修复后 O(n²/2)(每步 O(position_id),由 patched 方案实际达成)。max_length=8192、n 较小时差距显著。

3. 修复方案

核心思路:只将 [0..position_id] 的 cache 切片传入 SDPA,因果性由切片范围保证,无需显式 mask,SDPA 可自动选择 Flash Attention。

因果性正确性证明:自回归模型中,位置 i 只需要关注位置 0..i 的信息。切片 [:, :, :position_id + 1, :] 精确选取了 [0, position_id] 范围内的 KV,因果约束天然满足,等价于一个下三角 mask 的效果。position_id=0 时切片为 [:1],SDPA 对单个 KV entry 的 softmax 退化为恒等映射,行为正确。

改动点 原始 修复后
Attention mask arange(max_length) <= position_id — O(max_length) 分配 删除 — 切片范围保证因果性
Cache 拷贝 .contiguous() 全量拷贝 — O(max_length × head_dim) 删除 — 切片 view 由 SDPA 按需处理
SDPA 输入 全量 key_cache, value_cache 切片 [:, :, :position_id+1, :] — 仅包含有效位置
Flash Attention 被 boolean mask 阻止,回退 Math 后端 无 mask → SDPA 自动选择 Flash Attention

4. 完整补丁

4.1 直接替换(修改库代码)

修改安装包中的源码文件(路径如 .venv/Lib/site-packages/voxcpm/modules/minicpm4/model.py),将 MiniCPMAttention.forward_step() 整个方法替换为:

def forward_step(
    self,
    hidden_states: torch.Tensor,
    position_emb: Tuple[torch.Tensor, torch.Tensor],
    position_id: int,
    kv_cache: Tuple[torch.Tensor, torch.Tensor],
) -> torch.Tensor:
    bsz, _ = hidden_states.size()

    query_states = self.q_proj(hidden_states)
    key_states = self.k_proj(hidden_states)
    value_states = self.v_proj(hidden_states)

    query_states = query_states.view(bsz, 1, self.num_heads, self.head_dim).transpose(1, 2)
    key_states = key_states.view(bsz, 1, self.num_key_value_heads, self.head_dim).transpose(1, 2)
    value_states = value_states.view(bsz, 1, self.num_key_value_heads, self.head_dim).transpose(1, 2)

    if position_emb is not None:
        cos, sin = position_emb
        query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)

    key_cache, value_cache = kv_cache
    key_cache[:, :, position_id, :] = key_states
    value_cache[:, :, position_id, :] = value_states

    attn_output = torch.nn.functional.scaled_dot_product_attention(
        query_states,
        key_cache[:, :, :position_id + 1, :],
        value_cache[:, :, :position_id + 1, :],
        enable_gqa=True,
    )

    attn_output = attn_output.transpose(1, 2).contiguous()
    attn_output = attn_output.reshape(bsz, self.num_heads * self.head_dim)
    return self.o_proj(attn_output)

Diff:

--- a/voxcpm/modules/minicpm4/model.py
+++ b/voxcpm/modules/minicpm4/model.py
@@ -196,16 +196,10 @@ class MiniCPMAttention(nn.Module):
         key_cache[:, :, position_id, :] = key_states
         value_cache[:, :, position_id, :] = value_states
 
-        attn_mask = torch.arange(key_cache.size(2), device=key_cache.device) <= position_id
-
-        # ref: https://github.com/pytorch/pytorch/issues/163597
-        # there is a bug in MPS for non-contiguous tensors, so we need to make them contiguous
-        query_states = query_states.contiguous()
-        key_cache = key_cache.contiguous()
-        value_cache = value_cache.contiguous()
         attn_output = torch.nn.functional.scaled_dot_product_attention(
             query_states,
-            key_cache,
-            value_cache,
-            attn_mask=attn_mask,
+            key_cache[:, :, :position_id + 1, :],
+            value_cache[:, :, :position_id + 1, :],
             enable_gqa=True,
         )
4.2 外部 Monkey-patch(无需修改库代码)

注意:voxcpm 2.0.2 在 Linux + CUDA + triton 环境下会通过 torch.compile(fullgraph=True) 编译 forward_step,编译后的计算图内联了原始代码,后续的 monkey-patch 不再生效。解决方案:加载时传入 optimize=False,或在 monkey-patch 后重新编译。Windows 环境因无 triton 不受影响。

from voxcpm import VoxCPM
from voxcpm.modules.minicpm4.model import MiniCPMAttention, apply_rotary_pos_emb

model = VoxCPM.from_pretrained("openbmb/VoxCPM2", optimize=False)

def _fast_forward_step(self, hidden_states, position_emb, position_id, kv_cache):
    bsz, _ = hidden_states.size()
    query_states = self.q_proj(hidden_states)
    key_states = self.k_proj(hidden_states)
    value_states = self.v_proj(hidden_states)

    query_states = query_states.view(bsz, 1, self.num_heads, self.head_dim).transpose(1, 2)
    key_states = key_states.view(bsz, 1, self.num_key_value_heads, self.head_dim).transpose(1, 2)
    value_states = value_states.view(bsz, 1, self.num_key_value_heads, self.head_dim).transpose(1, 2)

    if position_emb is not None:
        cos, sin = position_emb
        query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)

    key_cache, value_cache = kv_cache
    key_cache[:, :, position_id, :] = key_states
    value_cache[:, :, position_id, :] = value_states

    attn_output = torch.nn.functional.scaled_dot_product_attention(
        query_states,
        key_cache[:, :, :position_id + 1, :],
        value_cache[:, :, :position_id + 1, :],
        enable_gqa=True,
    )

    attn_output = attn_output.transpose(1, 2).contiguous()
    attn_output = attn_output.reshape(bsz, self.num_heads * self.head_dim)
    return self.o_proj(attn_output)

MiniCPMAttention.forward_step = _fast_forward_step

5. 兼容性

  • CUDA (Linux/Windows): 直接使用上述代码,无需额外修改。
  • MPS (macOS): 切片 view 可能非连续内存。如遇报错,在 SDPA 调用前对切片加 .contiguous()
attn_output = torch.nn.functional.scaled_dot_product_attention(
    query_states,
    key_cache[:, :, :position_id + 1, :].contiguous(),
    value_cache[:, :, :position_id + 1, :].contiguous(),
    enable_gqa=True,
)

拷贝量为 O(position_id),仍远小于原始的 O(max_length)。

  • forward() 方法不受影响: batch prefill 路径已使用 is_causal=True,不经过 forward_step()

6. 验证方法

6.1 测试环境
项目 规格
GPU NVIDIA GeForce RTX 4060 Laptop GPU (8 GB VRAM)
CUDA 12.4
PyTorch 2.6.0+cu124
Python 3.12.13
voxcpm 2.0.2
操作系统 Windows 11
推理精度 float16 (半精度)
6.2 Benchmark 脚本

使用独立 benchmark 脚本(bench_attention.py)进行对照测试,通过 monkey-patch 包装 forward_step 精确测量每个 autoregressive 位置的 attention 耗时。设置 min_len=3500 强制生成到高 position(>5000),以充分暴露两种实现的性能特征。

6.3 测试结果

测试文本:1520 字中文,inference_timesteps=6max_len=4096min_len=3500

指标 Baseline Patched 加速比
Wall time (s) 951.07 699.14 1.36x
AR positions 4096 3734 —¹
1st quartile attn (ms) 94.08 37.20 2.53x
4th quartile attn (ms) 95.60 64.74 1.48x
Slowdown ratio 1.02x 1.74x
Effective it/s 10.5 20.2 1.92x

采样点(单位:ms/step):

  • Baseline:1522:99.0 2546:101.5 3570:91.0 4594:97.5 5617:96.1
  • Patched:1522:28.6 2455:36.7 3389:61.2 4322:52.6 5255:69.2

¹ Patched 生成较少 positions(3734 vs 4096)是因为 Flash Attention 与 Math 后端的浮点精度存在微小差异(~1e-6),导致 stop token 判定时机不同。这不影响音频质量,正常生成(不设 min_len)时两者都会在自然结束点停止。

6.4 结果分析

Baseline 恒定 ~95ms/step(1st→4th quartile 比值 1.02x,近乎不变):boolean mask 阻止了 Flash Attention,SDPA 回退到 Math 后端。Math 后端对整个 max_length=8192 的 KV cache 做注意力计算,无论当前 position 是多少,每步成本都是 O(8192)。这在 position 较低时严重浪费算力——position=100 时仍在计算 8192 长度的注意力。

Patched 从 28ms 增长到 69ms(1st→4th quartile 比值 1.74x,随 position 线性增长):切片 [:position_id+1] 使 SDPA 使用 Flash Attention,成本为 O(position_id)。这是正确且最优的行为——低 position 计算量少(快),高 position 计算量大(慢),但始终使用高效的 Flash Attention 内核。即使在最高 position(~5255),patched 的 69ms 仍快于 baseline 的恒定 ~95ms。

核心收益

  1. Flash Attention 替代 Math 后端:同等 position 下 2.5-3.5x 加速(低 position 区间最为显著)。
  2. 消除无效计算:position=1500 时,baseline 对 8192 个位置做注意力,patched 只对 1501 个位置做。
  3. 端到端提升 36%:wall time 从 951s 降至 699s。
  4. 内存占用降低:不再每步创建 8192 长度的 boolean mask 和全量 contiguous 拷贝。

bench_attention.py

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in voxcpm/modules/minicpm4/model.py at MiniCPMAttention.forward_step() and review the proposed cache slicing and SDPA changes. Run bench_attention.py to compare the current and patched implementations, including the stated CUDA and MPS considerations. Done means preserving forward_step behavior while improving autoregressive attention performance without changing forward().

Written by the indexing model from the issue text.

Assessment

Tech stack
python, pytorch
Domain
audio-video-rtc, machine-learning, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.