# m3 重跑调研报告 (GLM · 2026-08-18)

对应任务: 2026-08-17 深夜 SA3/ACE/m3 三引擎 benchmark 中 m3 暴露的早停截断与 OOM 问题。
产物目录: `~/shared/2026-08-17/engine-benchmark/` (v1 原样保留 + v2 新增)。

## TL;DR

1. **早停不是脚本"假 done"** —— 是引擎侧 by-design 的 EOS 提前收尾: `audio_duration` 只是**上限**,
   模型采到 `<|audio_end|>` 即停 (模型 README 原文如此)。benchmark 脚本轮询逻辑无超时、无假完成,
   `actual_sec` (ffprobe) 一直是真实值; 问题在于 `done` 语义不区分"完整"与"提前收尾"。
2. **"9 次 OOM" 修正为 8 次** —— 8 次是 `LM+RVQ must fit on the device together` RuntimeError
   (GPU1 被 qwen-ear ~21.7GB 挤占, 放置阶段即失败); 第 9 个 (8f88f5d55ff5) 实为 `lyrics 为空` 的
   ValueError, 与显存无关。
3. **修复三件套** (全部验证通过): ① 请求级 `suppress_eos` 参数 (默认关, 默认行为与上游逐位一致);
   ② `/generate` 提交前 free VRAM 不足 → **409 fail-fast** (阈值按组件实测 footprint + KV 动态计算);
   ③ `/status` 显式 `early_stop` 标记, 短音频不再伪装正常 done。
4. **v2 重跑 9/9 时长达标** (suppress_eos=true, 同 prompt 集 / seed=42 / steps=30): 8s→8.0,
   30s→30.0, 90s→90.0 (详见 §C)。**诚实声明**: 自然结束点之后的段落是屏蔽 EOS 的强制续写,
   时长达标 ≠ 音乐性与自然收尾等价 (见 §C.3)。

---

## A1. 早停归因 (逐条 + 证据)

### 引擎机制 (代码走读)

| 环节 | 代码位置 | 行为 |
|---|---|---|
| duration→帧 | `music3-env/.../minimax_music3/encoders.py:287` | `max_frames = int(audio_duration × frame_rate)`, `frame_rate=25` (`modular_pipeline.py:39-45`), 上限 9000 帧=360s (`_MAX_AUDIO_FRAMES`, encoders.py:43; server 侧 `MUSIC3_MAX_DURATION=360` 同源) |
| AR 早停条件① | `encoders.py:335-336` (改动前) | `if sampled == _AUDIO_END_TOKEN_ID (151670): break` —— **EOS 提前收尾, 与请求时长无关** |
| AR 早停条件② | `encoders.py:344` | `len(frame_hiddens) >= max_frames: break` —— 到达请求帧预算 |
| 滑窗拼接 | `before_denoise.py:28-29` | 200 帧窗 / 100 帧跳; 重叠区 172 latent 混合 (`denoise.py:37-39`), 解码侧按 hop 裁剪 (`decoders.py:80-86`)。滑窗只影响去噪/拼接, **不产生早停** |
| 官方契约 | 模型 `README.md:143` | "max_new_tokens sets the **maximum** number of audio frames at 25 fps. **Generation may finish before this limit when the model emits an end-of-audio token.**" |

### 4 条短缺逐条归因 (v1, `/tmp/music3-server.log` 08-17 段)

| 格 | requested | actual (ffprobe) | tqdm 实跑/计划 | 帧数 (×25fps) | 归因 | 证据 (server log 行号) |
|---|---|---|---|---|---|---|
| mid mixed | 30s | **21.0s** | 150/210 | 525 | EOS@525帧 | :680-681 `shape=(925184,2)` (20.98s) |
| long mixed | 90s | **21.0s** | 150/630 | 525 | EOS@525帧 | :757-758 `shape=(925184,2)` (20.98s) |
| long instrumental | 90s | **34.3s** | 240/630 | ~857 | EOS@~857帧 | :735-736 `shape=(1511424,2)` (34.27s) |
| long vocal | 90s | **40.3s** | 300/630 | ~1008 | EOS@~1008帧 | :788-789 `shape=(1778176,2)` (40.32s) |

对照 (正常封顶, 非 EOS): mid instrumental :658-659 `210/210 → 30.02s`; mid vocal :706-707 `210/210 → 30.02s`。

**关键证据 —— EOS 是 (prompt, lyrics, seed) 的确定函数**: 两次 mixed 请求 (30s 与 90s) 输出
**逐样本同长** (`925184` samples = 20.98s), seed 同为 42 —— 帧预算不同 (750 vs 2250) 而截断点相同,
证明截断由 EOS 决定, 与请求时长无关。**"duration 加余量+trim" 无法修复此类截断** (加余量只抬上限,
EOS 照样在 525 帧触发)。

**截断值成因解释**:
- **mixed 21.0s ×2**: prompt 要求 "emotional female vocal" 而 lyrics=`[Instrumental]`
  (归一化为 `[start]\n[instrumental]`) —— 条件自相矛盾, 模型在 525 帧处确定性结束。
- **instrumental 34.3s**: 该 (prompt, lyrics, seed) 组合的自然乐章长度 ~857 帧; 30s 请求在 750 帧
  封顶先停 (所以 v1 mid instrumental 是"正常 30s", 只是没跑到 EOS 点而已)。
- **vocal 40.3s**: `LYRICS_ZH` 仅 4 行短词, 唱尽即 EOS (~1008 帧) —— 歌词量决定可唱时长。

### 脚本侧复核 (gen_all.py)

`/home/kai/engine_bench/gen_all.py:56-62`: `while True: sleep(5)` 轮询 KAP status,
`completed` 即取文件 —— **无轮询超时、无"把半成品当完成"逻辑**。v1 `wall_sec` 全部落在
5s 轮询网格上 (30.1/60.1/80.1/90.1/105.1, 见 `run.log`) —— "固定 60/90/105s" 是网格量化假象,
不是超时截断。文件来自 `:5112/file/<tid>`, server 只在 pipe() 完整返回后才写盘置 done。

---

## A2. OOM 根因

**机制链** (每步有代码/日志证据):
1. `MUSIC3_CPU_OFFLOAD=1` → 5 个组件挂 `UserCustomOffloadHook` 常驻 CPU, 按需上卡
   (server 日志每任务后 `5 hooks offloaded`)。
2. AR 阶段特殊: 循环里直接调 `language_model.model(...)` 等子模块, 绕过顶层 hook, 故
   `encoders.py:300-306` **手动触发 `pre_forward` 放置 LM 与 RVQ**。
3. 放置判定在 `components_manager.py:189-190`: `module_size < free − 3GB (memory_reserve_margin)`;
   空间不够时搜索可下放组合 (:206-227), 找不到则 **"offloading all models"** (:231-234,
   即 v1 日志里每次 OOM 前的 `no combination of models to offload to cpu is found`)。
4. 于是出现"为放 RVQ 把 LM 挤回 CPU"→ 两模型设备不同 → `encoders.py:308-312` 主动抛
   `RuntimeError: The language model and the RVQ depth decoder must fit on the device together...`。

**尺寸账** (磁盘实测): LM (Qwen3 系, bf16, 4 shards) **17.19GB** + RVQ depth decoder **1.29GB**
→ 放置需 free ≥ **21.5GB**; 运行峰值再加 KV cache (每帧 2 行×36 层×4096×2(KV)×2B ≈1.18MB/帧:
30s≈0.9GB, 90s≈2.65GB) + 激活。

**当时邻居**: GPU1 上的 qwen-ear `llama-server` ~21.7GB
(`/tmp/m3-server-release-fix-prompt.md:9` "同卡 21.7GB 的 llama-server (qwen-ear)";
KAP `generate.ts:59` 实证 `free 5158MiB < need 22528MiB`) + ComfyUI 缓存。GPU1 仅 24GB,
free 长期 3–18GB 波动 → 00:01–00:12 提交的 8 个任务全在放置阶段失败; **00:13 起邻居退场**
(free 回到 20.9→24.4GB), 同参数 9 格全部成功 —— 这就是"有时 OOM 有时好的"复现条件。

**Hermes 清单修正**: 9 个任务号中 **8 个**是上述 RuntimeError (57d330018e74/3b9082500cf3/
9b1c578165a9/ec22f5dfef7c/3bebf15cf82b/6c1f60dc8912/00e5b0963073/b7f7a17fa110, log 行号
188/232/274/371/421/470/519/568); **8f88f5d55ff5 是 `ValueError: lyrics must be a non-empty
string, got ''`** (log:321, 某探测请求没带歌词), 与显存无关。

**最小空闲需求 (实测锚定)**: 8s/30s → **21.5GB**; 90s → **22.2GB**; 360s → 30.1GB (超 24GB 单卡,
物理不可行 → 应拒绝)。独占 3090 时 free ≈ 23.8–24.4GB (ComfyUI python 常驻 ~1.1GB 上下文)。

**server 死因**: 不是本次 bug —— 本机有自治代理周期性 SIGTERM music3-server
(`/tmp/m3-vram-deadlock-fix-prompt.md` 已记录该噪音), 最后活动 00:25 后被杀。

---

## B. 修复 (最小 diff, 全部验证)

### B1+B3. 早停: 请求级 `suppress_eos` (默认关)
- `diff-encoders.patch` (12 行): `MUSIC3_SUPPRESS_EOS=1` 时把 `<|audio_end|>` 也 mask 排,
  采样器永远采不到 EOS → 生成跑满 `audio_duration` 帧预算。env 由 server 在 `gen_lock`
  (单任务串行) 内 set/restore, 无并发竞争。**默认不设 → 原行为逐位不变**。
- `diff-music3-server.patch`: `/generate` 新增 `suppress_eos` (bool, 默认 false; KAP zod
  白名单不透传该字段, 故 benchmark 直连 :5112); `/status` 新增 `early_stop` / `requested_sec` /
  `suppress_eos` —— 实际时长短缺 >5% 显式标记, 不再静默。

### B2. OOM fail-fast: 提交前 409
`required_free_gb(duration) = max(LM+RVQ+3.0, LM+RVQ+KV(duration)+1.0)`, footprint 取自已加载
组件实测值; pipe 未加载退保守 22GB; `MUSIC3_MIN_FREE_GB` 可覆盖 (0=禁用)。free 不足 → **409** +
明细 JSON, 不再让任务半途崩。**未用 `disable_auto_cpu_offload`** (会永久摘钩)。

### 验证记录 (2026-08-18 实测)

| # | 测试 | 结果 |
|---|---|---|
| 1 | 360s 请求 (KV 10.6GB, 需 30.08GB > free 24.21GB) | **HTTP 409** + `required_free_gb: 30.08`, queue=0, 无任务创建 ✓ |
| 2 | **B1 验收: 30s mixed + suppress_eos** | `duration_sec 30.02, early_stop false` ✓ (冷页缓存首跑 281.5s; 暖缓存复跑 77.3s —— 首跑慢是当日冷启动 load 160.9s 的同一原因, 与抑制无关) |
| 3 | 30s mixed 默认 (对照) | `duration_sec 20.98, early_stop true` —— 与 v1 完全一致 (v1: 20.98s/59.6s), **默认行为未变** ✓ |
| 4 | GPU 监控 (对照跑) | AR 期 19.1GB@83% util (LM+RVQ 同驻), denoise 期 7.5GB@100% ✓ |
| 5 | server 重启 | `model_loaded: true`, sampling_rate 44100, ready on :5112 ✓ |

---

## C. v2 重跑结果

条件: 直连 :5112, **suppress_eos=true**, prompt 集 / seed=42 / num_inference_steps=30 与 v1 逐字
一致; GPU1 独占 (每格提交前校验 free ≥22GB, 出现 ComfyUI 瞬时邻居时等待窗口); 产物
`results_m3_v2.json` + `m3-v2-*.wav` (v1 全部保留)。

### C.1 主表 (9 条)

| 时长 | 类型 | requested | actual_sec (ffprobe) | wall_sec | gen_seconds | v1 自然EOS | 达标 |
|---|---|---|---|---|---|---|---|
| short | instrumental | 8s | **8.0** | 30.0 | 26.0 | 8.0 | ✅ |
| short | mixed | 8s | **8.0** | 30.0 | 26.0 | 8.0 | ✅ |
| short | vocal | 8s | **8.0** | 30.0 | 26.1 | 8.0 | ✅ |
| mid | instrumental | 30s | **30.0** | 80.1 | 76.8 | 30.0 | ✅ |
| mid | mixed | 30s | **30.0** | 80.0 | 77.3 | **21.0** | ✅ (v1 ❌) |
| mid | vocal | 30s | **30.0** | 80.0 | 76.9 | 30.0 | ✅ |
| long | instrumental | 90s | **90.1** | 215.1 | 213.9 | **34.3** | ✅ (v1 ❌) |
| long | mixed | 90s | **90.1** | 215.1 | 214.9 | **21.0** | ✅ (v1 ❌) |
| long | vocal | 90s | **90.1** | 220.1 | 215.4 | **40.3** | ✅ (v1 ❌) |

**9/9 达标**, `srv_early_stop` 全部 false。v1 短缺的 4 格 (mixed×2, long instrumental/vocal) 全部补齐。
重跑期间 ComfyUI 两次瞬时占卡 (free 跌至 ~15.4GB), 脚本等待窗口后自动续跑 (run_m3_v2.log `[wait]` 行),
server 侧 409 兜底未再触发 —— 与设计一致。

### C.2 说明
- wall_sec 为提交→下载完成的全链路实测 (5s 轮询网格)。
- `srv_early_stop` 全部为 false (server 侧判定), ffprobe 复核 actual_sec。

### C.3 诚实声明 (不得粉饰)
1. **suppress_eos 是"强制时长"模式**: v1 的 21.0/34.3/40.3s 是引擎自然收尾 (歌词/条件决定的
   EOS), 不是故障。v2 达标是屏蔽 EOS 的结果 —— 自然结束点之后的段落为强制续写, 其音乐性
   (是否拖沓/循环/质量衰减) 需人工试听评估, 本报告不做质量背书。
2. **生产建议**: 要高质量长曲, 正确做法是给足歌词量 (v1 vocal 40.3s 即 4 行词唱尽);
   suppress_eos 适合 BGM/垫乐等"必须凑时长"的场景, 属于请求级 opt-in, 默认不改变任何现有行为。
3. 90s KV+峰值 ≈22.2GB, 逼近 24GB 卡上限 —— 90s 以上 (≤360s) 在单 3090 上多数时长会被
   fail-fast 拒绝 (物理不可行), 如实拒绝优于半途崩。

---

## 附: 变更文件清单

| 文件 | 变更 | 备份 |
|---|---|---|
| `/opt/music3-env/.../minimax_music3/encoders.py` | +12 行 EOS 抑制 (env 门控, 默认关) | `encoders.py.bak-0818` |
| `/opt/music3-server.py` | suppress_eos 参数 / early_stop 字段 / 409 fail-fast | `music3-server.py.bak-0818` |
| `/home/kai/engine_bench/gen_m3_v2.py` | 新增: v2 重跑脚本 (复用 v1 prompt 集) | — (新文件) |
| `/home/kai/engine_bench/update_index_m3.py` | 新增: index.html 仅更新 m3 列 | — (新文件) |
| `~/shared/2026-08-17/engine-benchmark/results_m3_v2.json` | 新增: v2 结果 | v1 `results.json` 未动 |
| `~/shared/2026-08-17/engine-benchmark/index.html` | m3 列/卡片更新为 v2 | ACE/SA3 单元格字节不变 |

完整 diff: `/home/kai/engine_bench/diff-encoders.patch`, `/home/kai/engine_bench/diff-music3-server.patch`。
未触碰: KAP 主线代码、SA3/ACE 产物、`disable_auto_cpu_offload`。
