性能瓶颈详细分析.md 19 KB

性能瓶颈详细分析

原始瓶颈分析(V1 基线)

P1|Dejavu fallback 时音频被解码两次(最高优先级)

位置:service.py:155-179

问题描述:

_query_chroma() 内部调用 extract_chroma_matrix()extract_chroma_feature()extractor.py:66-98):

  • 启动 ffmpeg subprocess,以 22050Hz 输出临时 WAV
  • librosa.load() 加载

若 chromagram 未命中,紧接着 _dejavu_query() 调用 fingerprint_audio()dejavu_fingerprinter.py:184):

  • 再次启动 ffmpeg subprocess,以 44100Hz 输出临时 WAV
  • librosa.load() 再次加载

两个采样率不同,不能复用,但两次 ffmpeg + librosa 完全串行。对同一个输入文件,在 fallback 路径下重复了完整的 I/O + 解码开销。


P2|_best_shifted_dtw_similarity 无条件执行 240 次 DTW(高优先级)

位置:service.py:360-365, service.py:250-255

问题描述:

# service.py:360-365(V1)
def _best_shifted_dtw_similarity(query, candidate):
    return max(
        _dtw_similarity(np.roll(query, -shift, axis=0), candidate)
        for shift in range(12)
    )

每次调用:

  • cdist(query.T, candidate.T, "euclidean") → 128×128=16,384 个欧氏距离(scipy C 层)
  • _dtw_dp(cost) → 纯 Python 在 128×128 矩阵上填 DP 表

调用路径:dtw_rerank_top_k=20 个候选 × 12 个 pitch shift = 240 次 DTW,每次处理 16,384 个格。

两个子问题:

P2a 无阈值早退:对于每个候选,只要某个 shift 的相似度已超过 duplicate_threshold,剩余 shift 不影响最终二元决策,但当前 max() 生成器会继续算完所有 12 个。

P2b 所有 20 个候选都做 DTW:cosine 召回后排名靠后的候选(如 rank 15-20)cosine 相似度可能已很低(比如 0.3),仍然花费完整 12×DTW。从 HNSW 召回的 cosine 值与 DTW 相似度高度正相关,对明显不相似的候选做 DTW 纯属浪费。


P3|LATERAL HNSW 扫描 inner LIMIT 与实际需求不匹配(中优先级)

位置:service.py:207-228

CROSS JOIN LATERAL (
    SELECT song_id, feature_vector
    FROM composition_feature
    ORDER BY feature_vector <=> s.vec
    LIMIT %s   -- top_k = 100
) cf

每路 shift 各扫描 100 行,12 路共扫描最多 1200 行。但 DTW rerank 只需要 top dtw_rerank_top_k=20 个唯一 song_id

实际上只有当 12 路扫描各自带来 distinct song_id 时才需要更大的 LIMIT。在库规模适中时,每路前 20-30 行已足够覆盖需要参与 DTW 的候选,100 行的 inner LIMIT 使 HNSW 扫描做了 3-5 倍的冗余工作。

注意:减小 inner LIMIT 可能降低 cosine 召回阶段的 recall(某个真实 duplicate 可能只在某个特定 shift 的第 30-60 位出现)。需结合测试集评估安全边界。


P4|_generate_hashes 使用 Python 字符串拼接做 SHA1(中优先级)

位置:dejavu_fingerprinter.py:157-181

h = hashlib.sha1(f"{freq1}|{freq2}|{t_delta}".encode())
yield (h.hexdigest()[:FINGERPRINT_REDUCTION].encode(), t1)

一首 120 秒的音频,峰值数量通常在数千个,fan_value=10 产生的 hash 对可达数万条:

  • Python f-string 格式化(str → bytes)在热循环中开销显著
  • hexdigest()[:20].encode() 产生 20 字节 ASCII hex,再次 encode 为 bytes

可替换为 struct.pack('>HHH', freq1, freq2, t_delta) 直接作为 SHA1 输入,避免字符串分配。但这会改变 hash 值,需要重新入库所有已有指纹,属于有迁移成本的优化。


P5|每次查询独立建立数据库连接(中低优先级)

位置:service.py:102, service.py:129, service.py:230, service.py:293

ingest()_query_chroma()_dejavu_query() 各自调用 psycopg.connect(),每次都经历 TCP 握手 + PostgreSQL 认证。在高频调用场景下,连接建立开销不可忽视。

当前 _query_chroma() 已经在单个 with conn 块内复用连接执行两条 SQL(cosine 召回 + 特征取回),是正确做法。但跨方法的连接复用缺失。


P2 优化实施记录

实施目标

针对 P1(二次解码)和 P2(DTW 开销)进行优化,同时调整整体查询流程(Chromagram 优先,Dejavu 作为 fallback)。

实际落地内容

① numba JIT 加速 DTW DP(已保留,有效)

将纯 Python 的 _dtw_dp@numba.njit(cache=True) 编译:

@numba.njit(cache=True)
def _dtw_dp(cost: np.ndarray) -> float:
    ...

效果:128×128 DP 填表从 Python 层降到 native 代码,单次 DTW 约快 10-30×。

② early_exit_threshold 减少无效 shift(已保留,有效)

def _best_shifted_dtw_similarity(query, candidate, early_exit_threshold=1.1):
    best = 0.0
    for shift in range(12):
        sim = _dtw_similarity(np.roll(query, -shift, axis=0), candidate)
        if sim > best:
            best = sim
        if best >= early_exit_threshold:
            break
    return best

调用时传入 duplicate_threshold=0.85,确认为 duplicate 后立即跳出剩余 shift 计算。

③ 查询流程倒置:Chromagram 优先,Dejavu 作为 fallback(已保留)

V1 流程:Dejavu 先做精确指纹匹配(短路) → 未命中再走 Chromagram+DTW P2 流程:Chromagram+DTW 先走 → 未命中再做 Dejavu 指纹兜底

理由:Chromagram 覆盖绝大多数场景(含 pitch shift、tempo 变化),Dejavu 只对 chorus_only/trim_intro 等片段场景有独特价值。倒置后 Dejavu 仅在 fallback 路径触发,减少不必要的指纹查询 SQL。

_dejavu_query 的阈值判断移至调用方(已保留)

V1 中 _dejavu_query 内部做阈值判断,返回 None 或命中结果。 P2 改为始终返回最佳匹配(不过滤),由 query() 决策:达阈值返回 duplicate,未达阈值将 aligned_count 附加到 chromagram top1 供评估记录。


P2 实施中的问题与修正

问题一:dtw_min_cosine 过滤导致 pitch 变体全部崩溃

错误实现(已删除):

# CompositionConfig
dtw_min_cosine: float = _env_float("COMPOSITION_DTW_MIN_COSINE", 0.5)

# _query_chroma 内
rerank_ids = [
    sid for sid, sim in top[:self.config.dtw_rerank_top_k]
    if sim >= self.config.dtw_min_cosine   # ← 问题所在
]

错误假设: "cosine(已含 12 路 pitch shift)< 0.5 时 DTW 不可能达到 0.85 级别"

实际情况: 入库时对 chroma 做了主音(tonic)对齐(np.roll(chroma, -tonic, axis=0)),理论上 pitch shift 后的 tonic 也会对齐到 0,两者应相似。但 tonic 检测对真实音频并不稳定——pitch-shifted 音频的频谱内容发生变化,argmax(chroma.sum(axis=1)) 可能偏移,导致对齐方向不一致。最终 12 路 cosine 全部低于 0.5,整批 pitch 样本被过滤掉,永远进不了 DTW。

测试表现:

pitch_down1:       accuracy=0.0, total=5
pitch_up1:         accuracy=0.0, total=5
pitch_up1_tempo_slow: accuracy=0.0, total=10
pitch_up2:         accuracy=0.0, total=5

修复: 删除 dtw_min_cosine 过滤,所有 top-K 候选统一进入 DTW。

注:P2b 子问题"低 cosine 候选跳过 DTW"的优化方向本身有价值,但安全边界需要基于实测数据而非理论假设。对 pitch 变体,即使打开 12 路 cosine 后分数仍可能低于直觉预期,因此若未来重新引入此优化,必须先在完整测试集上验证各变体 cosine 分布,再设置阈值。


问题二:音频解码路径切换导致 chroma 向量与 DB 不一致

错误实现(已回退):

P2 为实现"共用一次 44100Hz 解码",将查询路径改为:

# query()
samples, sr = load_audio(audio_path)   # ffmpeg → 44100Hz
candidates = self._query_chroma(samples, sr, top_k)

# _query_chroma()
chroma = extract_chroma_matrix_from_samples(samples, sr)
# 内部:librosa.resample(samples, orig_sr=44100, target_sr=22050)

与入库路径的差异:

路径 ffmpeg 目标采样率 librosa 加载
V1 入库 & 查询(extract_chroma_feature 22050Hz sr=22050,无 resample
P2 查询(extract_chroma_matrix_from_samples 44100Hz sr=44100,再 librosa.resample → 22050Hz

问题背景: DB 未重新入库,存储的是 V1 管线生成的向量。查询改用 P2 管线,两者数值不完全一致。

影响为何集中在 pitch 变体: codec/tempo/lowpass 等变体的 chroma 与原曲高度相似,管线差异引入的误差在阈值以内仍能通过 DTW;pitch shift 变体本身依赖精确的 chroma 对齐(tonic 检测 + 12 路旋转),数值差异可能导致 tonic 检测结果偏移,使向量完全错位。

修复: _query_chroma 回退使用 extract_chroma_matrix(audio_path)(22050Hz 直接路径),与 V1 入库一致。

# 修复后的 query()
def query(self, audio_path: str, top_k: int = 100):
    # chroma 用与入库一致的 22050Hz 路径
    candidates = self._query_chroma(audio_path, top_k)

    # 只有 chroma 未命中时才触发 44100Hz 解码(做 Dejavu fallback)
    if self.config.dejavu_enabled and not self.candidates_indicate_duplicate(candidates):
        samples, sr = load_audio(audio_path)
        match = self._dejavu_query(samples, sr)
        ...

附加收益: 此结构比原 P2 的"提前共用解码"更优——chroma 命中时根本不做 44100Hz 解码,Dejavu 解码只在 fallback 路径触发,多数情况下节省了一次完整的 ffmpeg+librosa 调用。


:warning:️ 遗留一致性风险:ingest 路径仍使用 P2 管线

当前 ingest() 仍使用 load_audio (44100Hz) + extract_chroma_feature_from_samples

# service.py: ingest()(现状)
samples, sr = load_audio(audio_path)
feature = extract_chroma_feature_from_samples(samples, sr)

现状: DB 中已有歌曲均为 V1 管线(22050Hz),查询也使用 V1 管线,当前一致、评测正常。

风险: 若未来入库新歌曲(不重建整库),新歌将用 P2 管线生成向量,与老歌向量不同源,DB 中会存在两种管线混用的向量。跨管线查询时 pitch 变体等敏感场景可能出现不一致。

建议: 若重建整库,将 ingest() 中的 extract_chroma_feature_from_samples 回退为 extract_chroma_feature(audio_path),保持入库/查询完全同源。或者重建整库后统一切换到 P2 管线(两者均可,关键是保持一致)。


P2 最终状态汇总

优化项 状态 说明
numba JIT DTW DP :white_check_mark: 保留 有效加速,无精度影响
early_exit_threshold :white_check_mark: 保留 有效减少无效 shift,无精度影响
流程倒置(chroma 优先,Dejavu fallback) :white_check_mark: 保留 减少不必要的 Dejavu SQL
_dejavu_query 阈值判断移至调用方 :white_check_mark: 保留 便于附加 aligned_count 诊断信息
dtw_min_cosine 过滤 :x: 删除 pitch 变体假阳性过滤,accuracy→0
chroma 查询路径改为 44100Hz+resample :x: 回退 与 V1 入库向量不一致,pitch 变体崩溃
ingest 路径改为 44100Hz+resample :warning:️ 保留但有风险 现有 DB 未重入库,未来入库存在管线不一致风险

Chroma 路径性能分析与优化记录

背景:分环节计时

service.pyevaluate_composition.py 中加入了分环节计时(timings dict),将 query_time_ms 拆解为以下子项写入 CSV:

字段 含义
chroma_extract_ms 音频解码 + chroma_cens 特征提取
db_cosine_ms 数据库 12 路 HNSW Cosine 查询
db_fetch_ms 数据库取 DTW 候选向量
dtw_ms DTW 精排计算
dejavu_decode_ms Dejavu 路径音频解码(仅 fallback 时有值)
dejavu_fingerprint_ms Dejavu 指纹提取
dejavu_db_ms Dejavu 数据库指纹碰撞查询

实测分布(100 条样本,hop=512 基线):

chroma_extract_ms   均值 808ms  中位 694ms  P90 1489ms  max 1822ms
db_cosine_ms        均值  68ms  中位  63ms  P90   86ms  max  145ms
dtw_ms              均值  20ms  中位  18ms  P90   25ms  max  193ms
dejavu_fingerprint_ms  均值 2488ms(仅 40 条触发)

chroma_extract_ms 与音频时长呈线性关系(300-600s 的长曲约 1400ms),确认 CQT 计算量是 chroma 路径的主要瓶颈,Dejavu 路径的主要瓶颈是指纹提取(2488ms 均值)。


P6a|hop_length 增大优化(尝试后放弃)

方案:chroma_censhop_length 从 512 增大到 2048/4096,期望等比例降低 CQT 帧数从而提速。

实测结论:两个独立问题导致方案失败。

问题一:速度几乎没有改善

librosa CQT 的计算量由信号长度决定,不由输出帧数决定。CQT 内部对整段音频做多分辨率滤波,hop_length 只控制"每隔多少样本取一次输出",不影响滤波本身的运算量。hop 翻 4 倍,耗时基本不变。

问题二:精度严重下降(hop=4096,win_len_smooth 未修正)

chroma_cens 内部的 Hanning 平滑窗覆盖时长 = win_len_smooth × hop_length / sr

  • hop=512,win=41:覆盖 ~0.95s → 合理(和弦级别分辨率)
  • hop=4096,win=41(未修正):覆盖 ~7.6s → 把和弦变化全部抹平

测试结果(hop=4096,win_len_smooth 未修正):

pitch_down1:          accuracy=0.2(基线 1.0)
pitch_up1:            accuracy=0.4(基线 1.0)
pitch_up2:            accuracy=0.0(基线 1.0)
F1: 0.8235(基线 0.9855)

尝试修正: 加入 win_len_smooth 按 hop_length 等比缩小(round(41 * 512 / hop_length)),在 .env 中以 COMPOSITION_CHROMA_WIN_LEN_SMOOTH=0(自动)形式配置,并在 CompositionConfig.__post_init__ 中计算实际值。

hop=2048,win=10 修正后测试结果:

pitch_down1:          accuracy=0.0(基线 1.0)
pitch_up1:            accuracy=0.2(基线 1.0)
pitch_up1_tempo_slow: accuracy=0.3(基线 1.0)
pitch_up2:            accuracy=0.0(基线 1.0)

精度仍严重下降,且速度依然没有改善。

根本原因: win_len_smooth=10 过小,CENS 内部多尺度量化步骤(依次使用 win、win/2、win/4...)退化,音高细节损失。加之 CQT 本身计算量不受 hop_length 影响,此方案在速度和精度两个维度均无收益。

现状: hop_lengthwin_len_smooth 参数仍保留在配置中(值恢复为 512/auto=41),供未来实验,但不作为性能优化手段使用。


P6b|ffmpeg pipe 替代临时文件(已实施)

原流程(3步,含磁盘 I/O):

subprocess(ffmpeg → tmp.wav 落盘)
librosa.load(tmp.wav)          ← 磁盘读
os.remove(tmp.wav)             ← 磁盘删除

新流程(1步,全内存):

# extractor.py: _load_audio_via_pipe()
cmd = ["ffmpeg", "-y", "-i", audio_path,
       "-ar", "22050", "-ac", "1", "-f", "f32le", "pipe:1"]
result = subprocess.run(cmd, capture_output=True)
return np.frombuffer(result.stdout, dtype=np.float32)

ffmpeg 输出裸 f32le PCM,np.frombuffer 直接读入 float32 数组,省去 WAV 文件头解析、磁盘写入和读取、临时文件管理。

代码变化:

  • 删除 import tempfile
  • 删除 _normalize_audio_ffmpeg()(写 WAV 到磁盘)
  • extract_chroma_feature() 简化为:文件存在性检查 → _load_audio_via_pipe()extract_chroma_feature_from_samples()

精度影响: 无(输出的 PCM 样本与原流程完全一致)。

速度收益: 消除磁盘 I/O,对每条查询有固定收益(约 100-200ms),对长音频效果尤为明显(避免大文件落盘)。CQT 计算量不变,无法通过此方案解决主要瓶颈。


P6 汇总

方案 速度收益 精度影响 状态
hop_length 增大(4096) :x: F1 -0.15,pitch 变体全面崩溃 放弃
hop_length + win_len_smooth 联动修正(2048+10) :x: pitch 变体仍严重下降 放弃
ffmpeg pipe 替代临时文件 :white_check_mark: ~100-200ms 固定收益 :white_check_mark: 已实施

关键结论: chroma 路径的真正瓶颈(CQT 计算)无法通过 hop_length 绕过。若需进一步提速,方向应为:

  1. 限制音频最大时长(如 180s):直接减少 CQT 输入长度,需验证对长音频的去重精度影响
  2. 换用更快的 chroma 算法(如 chroma_stft):STFT 比 CQT 快,但对变调场景鲁棒性可能下降,需评测

待处理优化项

P2b(待重新评估)

低 cosine 候选跳过 DTW 的方向本身有价值,但安全边界需要基于实测 cosine 分布数据设阈值,不能依赖理论假设。参见"问题一"的详细分析。

P3(未实施)

减小 LATERAL inner LIMIT(当前 100),需先在完整测试集上验证不同 LIMIT 值下的 recall 边界。

P4(未实施)

_generate_hashesstruct.pack 替换字符串 SHA1,有迁移成本(需重建指纹库)。

P5(未实施)

引入 psycopg_pool 连接池,减少高频调用下的连接建立开销。

P6c|chroma CQT 主瓶颈后续方向(未实施)

P6a 确认 hop_length 无法加速 CQT,真正减少 CQT 计算量的方向:

方向一:限制音频最大时长(推荐先试)

CQT 计算量与输入时长完全线性,截断至前 N 秒可直接等比例提速。测试集中位时长 224s、最大 598s,若截断至 180s,长曲可节省 40-70% 的 chroma 提取耗时。需验证截断后 trim_intro/chorus_only 等片段变体的召回是否受影响。

方向二:换用 chroma_stft 替代 chroma_cens

chroma_stft 基于 STFT(FFT),比 CQT 快得多。代价是频率分辨率从对数变为线性,对变调场景的鲁棒性可能下降。需在完整测试集上对比 pitch 变体的精度表现,再决定是否切换。


汇总对比

编号 问题 影响位置 优化方向 精度影响 状态
P1 Dejavu fallback 二次音频解码 service.py:query() lazy decode(chroma 命中时跳过) :white_check_mark: 已通过流程重构解决
P2a DTW 内 12 shift 无早退 service.py:_best_shifted_dtw_similarity 超阈值即退 无(binary 决策) :white_check_mark: 已实施
P2b 低 cosine 候选仍做 DTW service.py:_query_chroma cosine 最低门槛过滤 :warning:️ pitch 变体易误伤,需基于分布数据设阈值 :x: 尝试后回退,待重新评估
P3 LATERAL inner LIMIT=100 冗余 service.py:_query_chroma SQL 按实际需求缩小 需评测 recall 未实施
P4 SHA1 字符串热循环 dejavu_fingerprinter.py:_generate_hashes struct.pack 需重建指纹库 未实施
P5 无连接复用/池化 全局 psycopg_pool 未实施
P6a chroma CQT 计算量随音频时长线性增长 extractor.py:chroma_cens hop_length 增大 :x: CQT 计算量不受 hop_length 影响,精度严重下降 :x: 方案无效,放弃
P6b chroma 解码临时文件磁盘 I/O extractor.py:extract_chroma_feature ffmpeg pipe → 内存直读 :white_check_mark: 已实施