性能瓶颈详细分析
原始瓶颈分析(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 调用。
️ 遗留一致性风险: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 |
|
有效加速,无精度影响 |
| early_exit_threshold |
|
有效减少无效 shift,无精度影响 |
| 流程倒置(chroma 优先,Dejavu fallback) |
|
减少不必要的 Dejavu SQL |
_dejavu_query 阈值判断移至调用方 |
|
便于附加 aligned_count 诊断信息 |
dtw_min_cosine 过滤 |
|
pitch 变体假阳性过滤,accuracy→0 |
| chroma 查询路径改为 44100Hz+resample |
|
与 V1 入库向量不一致,pitch 变体崩溃 |
| ingest 路径改为 44100Hz+resample |
|
现有 DB 未重入库,未来入库存在管线不一致风险 |
Chroma 路径性能分析与优化记录
背景:分环节计时
在 service.py 和 evaluate_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_cens 的 hop_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_length 和 win_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) | 无 |
|
放弃 |
| hop_length + win_len_smooth 联动修正(2048+10) | 无 |
|
放弃 |
| ffmpeg pipe 替代临时文件 |
|
无 |
|
关键结论: chroma 路径的真正瓶颈(CQT 计算)无法通过 hop_length 绕过。若需进一步提速,方向应为:
- 限制音频最大时长(如 180s):直接减少 CQT 输入长度,需验证对长音频的去重精度影响
-
换用更快的 chroma 算法(如
chroma_stft):STFT 比 CQT 快,但对变调场景鲁棒性可能下降,需评测
待处理优化项
P2b(待重新评估)
低 cosine 候选跳过 DTW 的方向本身有价值,但安全边界需要基于实测 cosine 分布数据设阈值,不能依赖理论假设。参见"问题一"的详细分析。
P3(未实施)
减小 LATERAL inner LIMIT(当前 100),需先在完整测试集上验证不同 LIMIT 值下的 recall 边界。
P4(未实施)
_generate_hashes 用 struct.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 命中时跳过) | 无 |
|
| P2a | DTW 内 12 shift 无早退 | service.py:_best_shifted_dtw_similarity |
超阈值即退 | 无(binary 决策) |
|
| P2b | 低 cosine 候选仍做 DTW | service.py:_query_chroma |
cosine 最低门槛过滤 |
|
|
| 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 增大 |
|
|
| P6b | chroma 解码临时文件磁盘 I/O | extractor.py:extract_chroma_feature |
ffmpeg pipe → 内存直读 | 无 |
|