Commit ca291c3de2e99379d65d048eb962a0ee47780a80
0 parents
Initial commit: GPU 服务器工作区(LLM 部署脚本 + MiniMax-H3 视频生成 + h3-console 管理台)
包含 llama.cpp 部署脚本、MiniMax-H3 探查/运行脚本、h3-console Web 管理台源码, 以及运维文档(CLAUDE.md/HANDOFF.md/llm-setup.md/CHANGELOG.md)。 排除生成的图片/视频/音频产物、.venv、__pycache__ 等运行时/构建产物。
Showing
40 changed files
with
4500 additions
and
0 deletions
.gitignore
0 → 100644
| 1 | +++ a/.gitignore | |
| 1 | +# Python | |
| 2 | +__pycache__/ | |
| 3 | +*.pyc | |
| 4 | +.venv/ | |
| 5 | + | |
| 6 | +# 生成的图片/视频/音频(上传、模型出片),不是源码 | |
| 7 | +h3-console/assets/* | |
| 8 | +minimax-h3/outputs/ | |
| 9 | +*.mp4 | |
| 10 | +*.mov | |
| 11 | +*.mkv | |
| 12 | +*.avi | |
| 13 | +*.wav | |
| 14 | +*.mp3 | |
| 15 | + | |
| 16 | +# 运行时状态,不是源码 | |
| 17 | +*.sqlite3 | |
| 18 | +logs/ | |
| 19 | +*.log | |
| 20 | + | |
| 21 | +# macOS | |
| 22 | +.DS_Store | ... | ... |
CHANGELOG.md
0 → 100644
| 1 | +++ a/CHANGELOG.md | |
| 1 | +# 变更记录 | |
| 2 | + | |
| 3 | +## 2026-08-20(晚)— h3-console 任务详情页加「重跑」按钮 | |
| 4 | + | |
| 5 | +**结果**:`/task.html/<id>` 详情页状态行旁加了「重跑」按钮。**第一版直接后台再 `POST /v1/videos` | |
| 6 | +一次,用户反馈不对**——应该是把参数带回新建任务表单让人看一眼再手动提交,不是静默重新提交。 | |
| 7 | +改成:点击后把这条任务当初的 `req`(prompt/分辨率/帧数/turbo/steps/seed/首尾帧或参考图 URL) | |
| 8 | +灌回对应的 tab(文生/首尾帧/参考图,按 req 里有没有 `first_frame_url`/`reference_image_urls` | |
| 9 | +自动判断)、跳到 `/newtask.html`,用户确认无误后自己点「提交」——跟手动新建任务走的是同一条 | |
| 10 | +路径、同一次人工确认。**没改后端**——`server.py` 的 `POST /v1/videos` 还是原来那个入口, | |
| 11 | +只是前端多了一步"回填表单"而不是直接调用。只改了 `static/index.html`(新增 | |
| 12 | +`prefillCreate()`/`setThumbs()` + detail 模板里的按钮),纯静态文件, | |
| 13 | +直接 `cat > .../console/static/index.html` 覆盖就生效,不需要重启 console 进程。 | |
| 14 | + | |
| 15 | +⚠️ 用这个按钮验证时顺手发现任务 `8755c5600378` 失败是**参数校验拒绝**,不是 bug: | |
| 16 | +`width=832,height=480` 配 `frames=362` 被 api.py 拒了(`frames` 上限 124,跟上一节 | |
| 17 | +「15 秒时长实测」结论一致——832×480 这个分辨率撑不住 362 帧)。重跑会用同样的参数、 | |
| 18 | +同样会失败,这是预期行为(按用户原话"一样的配置"重跑),**不是**这个按钮该吞掉或改的错误; | |
| 19 | +真要让这条任务跑通,需要换成 448×256 或降帧数,属于用户自己改参数重新提交的范畴。 | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## 2026-08-20(下午)— h3-console 部署上线,弹性 fl2va/ref2va 调度全链路验证通过 | |
| 24 | + | |
| 25 | +**结果**:`h3-console` 从本地实现推进到部署(服务器 `/data/models/minimax-h3/console/`,端口 8080), | |
| 26 | +用真实 GPU 完整验证了一遍"提交 fl2va 任务 → 排队 → 自动 flip 一张空闲卡 → 任务自动提交并跑完", | |
| 27 | +过程中揪出并修好两个真实 bug(细节见 `h3-console/README.md`「部署过程中揪出的两个真实 bug」): | |
| 28 | + | |
| 29 | +1. **PID 提取从来没匹配上过**:`ss -lptn` 输出里 pid 嵌在 `users:(("python",pid=NNNN,...))` 里, | |
| 30 | + 不是独立空格分隔 token,`tok.startswith("pid=")` 永远不命中,`kill` 从来没真的执行——旧进程 | |
| 31 | + 全须没被杀,新进程因端口冲突立刻退出。改用正则 `re.findall(r"pid=(\d+)", ...)`。 | |
| 32 | +2. **调度器不知道"已经在切换中"**:api.py 冷启动 13-20 秒跨了不止一个 10 秒 tick,中间几轮 | |
| 33 | + 误判需求没满足,一个 `pending=1` 的请求把三张卡都切了过去。加 `flipping` 字典记账修复。 | |
| 34 | +3. (操作事故,非代码 bug)**`pkill -f "<pattern>"` 杀了自己的 SSH 会话**——命令行本身包含被 | |
| 35 | + 搜索的文本,`start-all.sh` 注释早警告过这个坑,这次真踩了一遍。以后一律用 `ss` 按端口找 PID。 | |
| 36 | + | |
| 37 | +四卡已恢复到干净的全 ref2va 基线状态(测试期间临时 flip 的那张卡已经切回去、验证过 `ready:4/4`)。 | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 41 | +## 2026-08-20(上午)— 15 秒时长实测 + 新增 h3-console 管理台(设计+本地实现) | |
| 42 | + | |
| 43 | +**结果**:两件事都完成,console 本地跑通并冒烟测试通过(部署见上一节)。 | |
| 44 | + | |
| 45 | +### 15 秒时长边界实测 | |
| 46 | + | |
| 47 | +MiniMax-H3 官方规格 4–15 秒(362 帧/24fps)不是脚本瞎设的限制——`t2va.py`/`api.py` 里的 | |
| 48 | +124 帧上限是**我们自己测出的显存边界**,不是抄错官方示例(`API.md` 早就写明这点)。 | |
| 49 | +实测 362 帧在不同分辨率下: | |
| 50 | + | |
| 51 | +| 分辨率 | 结果 | 峰值显存 | 单步耗时 | | |
| 52 | +|---|---|---|---| | |
| 53 | +| 448×256 | ✅ 正常 | 12.0G | ~90s | | |
| 54 | +| 576×320 | ✅ 但吃紧(震荡换入换出) | 14.5G | 217s(慢 2.4×) | | |
| 55 | +| 640×384 | ❌ OOM | 撞到 20.6G 才崩 | — | | |
| 56 | +| 832×480(线上默认) | ❌ OOM(空卡也不行) | 撞到 20.65G 才崩 | — | | |
| 57 | + | |
| 58 | +**结论**:15 秒能做到,但线上默认的 832×480 做不到,实用上限在 448×256~576×320 之间。 | |
| 59 | +测试过程中临时停了 GPU3 的 ref2va 后端腾显存(这是测试机,`CLAUDE.md` 里明确允许), | |
| 60 | +测完已恢复,四卡 `ready: 4/4`。 | |
| 61 | + | |
| 62 | +### 被纠正的两个误解(用户转述 ChatGPT 的建议) | |
| 63 | + | |
| 64 | +1. "该先怀疑 Turbo 4-step 加速而不是 NF4"——**方向对**,我们自己的 A/B 数据支持:同一套 | |
| 65 | + NF4 权重,只关 Turbo、步数 4→50,帧间差异从 16.99 降到 7.366,音频从 −34dB 升到 −27dB, | |
| 66 | + 量化没变、画质肉眼可判断变好,说明 Turbo/步数是比 NF4 量化更大的画质杠杆。 | |
| 67 | +2. "该下载原版 BF16/FP16 权重,88GB 显存够跑更高精度"——**这条路已经被昨天的实验堵死, | |
| 68 | + 不是没试过**:bf16 在 sm_75 上无 attention kernel(`RuntimeError: No available kernel`), | |
| 69 | + fp16 会让模型输出溢出变黑屏(已用 A/B 实验证伪)。这台硬件上 compute dtype 只能是 fp32, | |
| 70 | + 跟权重存储精度是 NF4 还是 BF16 无关——bitsandbytes NF4 本来就是反量化到 fp32 再算。 | |
| 71 | + "88GB=4×22G 独立显存,H3 不支持模型并行"这条误解也再次被纠正(`README.md` 第四节早有结论)。 | |
| 72 | + 下载 BF16 权重(约 NF4 的 3-4 倍体积)在 5.9MB/s 出口带宽下要 4-5 小时,换来的只是去掉 | |
| 73 | + NF4 约 9% 的量化误差,性价比远不如已经验证过的"关 Turbo、加步数"。 | |
| 74 | + | |
| 75 | +### 新增 h3-console/(设计文档 + 本地实现,未部署) | |
| 76 | + | |
| 77 | +需求:给 minimax-h3 的四后端 API 加 Web 管理台(文生/首尾帧/参考图三个界面 + 上传 + 任务列表 + | |
| 78 | +按需 fl2va/ref2va 弹性分卡)。设计文档 `h3-console/DESIGN.md`、实现见 `h3-console/README.md`。 | |
| 79 | +架构上不重做 `gateway.py` 已经做好的调度/限流/健康聚合,只加它没有的 Web UI/上传/持久化/ | |
| 80 | +弹性分卡决策。本地端到端冒烟测试通过(上传→提交→轮询→下载全链路 + 浏览器 UI 手测), | |
| 81 | +弹性 partition 调度器(kill+restart GPU 后端切分区)只能在服务器上验证,还没测。 | |
| 82 | + | |
| 83 | +--- | |
| 84 | + | |
| 85 | +## 2026-08-18/19 — MiniMax-H3 文生视频从零到出片 | |
| 86 | + | |
| 87 | +**结果**:成功。两档都出片并通过 ffprobe + 逐像素 + 音频样本验收。 | |
| 88 | +**但环境未恢复**——见文末「遗留」。 | |
| 89 | + | |
| 90 | +### 最重要的一条:`minimax-h3/README.md` 原第一节是错的 | |
| 91 | + | |
| 92 | +原文写「`torch.bfloat16` → `torch.float16`(致命)」,把改用 fp16 当作**必须的修正**。 | |
| 93 | +**实测相反:改成 fp16 才是这条线一直出不了片的原因。** | |
| 94 | + | |
| 95 | +对照实验(448×256×28f,唯一变量是 `torch_dtype`): | |
| 96 | + | |
| 97 | +| | fp32 | fp16 | | |
| 98 | +|---|---|---| | |
| 99 | +| 视频像素 | mean 140.9 **std 50.6** | mean 0 **std 0** | | |
| 100 | +| 音频 | finite | NaN | | |
| 101 | +| 结果 | **正常出片 ✅** | **124 帧纯黑 ❌** | | |
| 102 | + | |
| 103 | +根因:模型按 bf16 训练(bf16 有 fp32 的动态范围),sm_75 无 bf16, | |
| 104 | +fp16 的 65504 上限装不下——`dit.blocks.0` 输入 absmax 89 就放大到 38176,50 层残差累积必爆。 | |
| 105 | + | |
| 106 | +### 被推翻的假设(六条,都实测过) | |
| 107 | + | |
| 108 | +| 原以为 | 实际 | | |
| 109 | +|---|---| | |
| 110 | +| fp16 是 Turing 上的正确选择 | **fp16 出全黑视频**,fp32 才行 | | |
| 111 | +| `--fp32-accum` 是降级第一档 | 开关生效但**输出逐字节相同**,对此故障完全不对症 | | |
| 112 | +| NF4 合成 probe 通过 ⇒ 真实模型可用 | 单层不溢出**推不出** 50 层残差不溢出 | | |
| 113 | +| `condition_proj` 的 inf 是根因 | 修好后问题依旧——它只是同一个 bug 的一个实例(库里有 100 处 `.to(x.dtype)`) | | |
| 114 | +| 钳位到 fp16 上限可规避溢出 | **反而更糟**:残差 `x + attn(...)` 中 x 恰为最大可表示值,再加任何正数立刻 inf | | |
| 115 | +| m3e / sd-forge 无 systemd ⇒ 手工拉起、恢复有风险 | 它们由 **PM2**(God Daemon PID 1467)托管,`kill` 会被 60 秒内拉回,必须 `pm2 stop` | | |
| 116 | + | |
| 117 | +### 实测数据(fp32,单任务) | |
| 118 | + | |
| 119 | +| 分辨率 | 步数 | 耗时 | 每步 | 峰值显存 | 像素 std | | |
| 120 | +|---|---|---|---|---|---| | |
| 121 | +| 448×256×28f | 4 turbo | 62 s | 15.5 s | 16.3 G | 50.6 | | |
| 122 | +| 640×384×61f | 4 turbo | 171 s | 42.8 s | 11.4 G | 51.7 | | |
| 123 | +| **832×480×124f** | **4 turbo** | **556 s** | **107 s** | **12.3 G** | **57.85** | | |
| 124 | +| **832×480×124f** | **50 全质量** | **5364 s** | **107.3 s** | **9.1 G** | **59.68** | | |
| 125 | + | |
| 126 | +**每步 107 s 在 turbo 与 50 步下完全一致**,是这台机器 fp32 全分辨率的稳定值。 | |
| 127 | +加载:首次从机械盘读 fp32 权重 238 s;page cache 命中后 12–20 s。 | |
| 128 | +fp16 是 36.8 s/步(快 2.9×)但**根本出不了片**——不是速度取舍。 | |
| 129 | + | |
| 130 | +### 产物验收 | |
| 131 | + | |
| 132 | +- Turbo:7,859,869 字节 · 帧间差异 **16.99** · 音频 mean −34.0 dB / rms 0.0199 / 非零 100% | |
| 133 | +- 50 步:6,726,194 字节 · 帧间差异 **7.366** · 音频 mean −27.2 dB / max −9.2 dB | |
| 134 | +- 两者均 832×480 · 24 fps · 124 帧 · 5.17 s · 含 aac 32 kHz 立体声 | |
| 135 | + | |
| 136 | +### 顺带修掉的 | |
| 137 | + | |
| 138 | +1. **`t2va.py` 退出码不可信**:原脚本只查 audio,**从不检查 video**,全黑视频照样打印 `[done]` 并返回 **0**。 | |
| 139 | + 已补 video 有限性 + 纯色检查(std < 1.0 判失败),坏产物**退出码 3**。 | |
| 140 | +2. **`--vram-reserve` 按 dtype 自适应**:fp32=14 / fp16=5。实测 `vram_limit` 7.3 G 时全分辨率跑通,19.3 G 时 OOM。 | |
| 141 | +3. **`PYTORCH_ALLOC_CONF=expandable_segments:True`** 写进 `run.sh`——22 G 卡上碎片曾达 2.49 G 并直接压垮运行。 | |
| 142 | +4. **4 个 probe-*.py 补齐到服务器**:README 第九节要求「换硬件后先跑 probe-* 四个」,但服务器上原本只有 3 个脚本。 | |
| 143 | + | |
| 144 | +### 新增 | |
| 145 | + | |
| 146 | +- `minimax-h3/api.py` + `api-run.sh`:异步任务式 HTTP API(提交拿 id → 轮询 → 下载)。 | |
| 147 | + worker 在写文件前自验像素 std 与音频有限性,**不会返回"成功但没用"的产物**。 | |
| 148 | +- `minimax-h3/API.md`:调用说明、参数范围与理由、错误码、Python 轮询示例。 | |
| 149 | +- `HANDOFF.md`:交接文档,`CLAUDE.md` 已加入口指引。 | |
| 150 | +- `deploy-scripts/RESTORE-GPU.md`:停机/恢复 runbook(含 PM2 那条更正)。 | |
| 151 | + | |
| 152 | +### 方法教训 | |
| 153 | + | |
| 154 | +- **诊断配置必须与故障配置逐项一致**:前几轮用中文 prompt 诊断、英文默认 prompt 复现, | |
| 155 | + 溢出点不同,定位出的路径在真实故障里不触发,白跑几轮。 | |
| 156 | +- **自检要守在会失败的位置**:查 `.to(fp16)` **之前**的 fp32 张量永远显示"正常",溢出恰在转换处。 | |
| 157 | +- **`cmd1 && cmd2 &` 的 `&` 作用于整条链**:`cat > file && ... &` 会让文件写入与执行竞争, | |
| 158 | + 曾因此跑了一个 0 字节脚本还以为它成功。上传与启动要分两步。 | |
| 159 | +- **`pgrep -f "gunicorn.*7590"` 会匹配到自己那条 SSH 命令行**,照着 kill 会误杀; | |
| 160 | + 用 `nvidia-smi --query-compute-apps=pid` 反查。 | |
| 161 | +- **`ffmpeg -af volumedetect` 输出易被 grep 漏掉**,误报"无音频流";用 PyAV 解码算 rms 更可靠。 | |
| 162 | +- **prompt 必须从文件读**:三层 shell 会吃掉引号,传进去是空串,报错是 | |
| 163 | + `cannot reshape tensor of 0 elements`——看着像 transformers 的 bug,实际是输入为空。 | |
| 164 | + | |
| 165 | +### ⚠️ 遗留(未完成) | |
| 166 | + | |
| 167 | +1. ~~四个服务仍是停的~~ → **已决策,不是遗留项**。2026-08-19 16:40 用户明确「停止 llm38」「只留着 h3 即可」, | |
| 168 | + 所以 `llm38` / `whisper` / `m3e` / `sd-forge` 停着即为目标状态;仅 H3 API(8010 + 8011)保留运行。 | |
| 169 | + 四者的恢复命令备在 `deploy-scripts/RESTORE-GPU.md`,需要时再起。 | |
| 170 | +2. **llm38 与 H3 共存的显存约束(将来恢复时才需处理)**:llm38 跑 256K 需四卡各约 14.3 G, | |
| 171 | + 而 GPU1 现被 8010 实例占着只剩 8.95 G。共存需 `--parallel 1 --ctx-size 262144` | |
| 172 | + (单请求仍 256K,并发降为 1,省 16 G),或先停一个 H3 实例。 | |
| 173 | +3. **`t2va.py:47` 的 `max(6.0, ...)` 地板值**:空闲显存 < 8 G 时会申请一个不存在的额度而非报错。串行跑绕开了,多负载共存时会咬人。 | |
| 174 | +4. **API 无鉴权**,与这台机器上其他服务一致;任务状态存在内存,进程重启即丢(本次已实际发生)。 | |
| 175 | + | |
| 176 | +## 2026-08-18 — 单请求上下文 32K → 256K(llm38) | |
| 177 | + | |
| 178 | +**结果**:成功。中断 **11 秒**(17:47:14 停 → 17:47:25 就绪,远快于预估的 2 分钟,因为 CUDA JIT 缓存已热)。 | |
| 179 | + | |
| 180 | +### 用户问的那个问题:不,上限不是 128K | |
| 181 | + | |
| 182 | +GGUF 元数据里 **`qwen35.context_length = 262144`**——模型原生 **256K**,之前所有文档写的 128K 都是低估。 | |
| 183 | + | |
| 184 | +```bash | |
| 185 | +python3 /home/admin/qwen38-deploy/ggufmeta.py /data/models/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf | |
| 186 | +``` | |
| 187 | + | |
| 188 | +`llm-setup.md` 旧表里只列到 `--parallel 2 --ctx-size 262144`(128K/请求),那不是硬件极限,也不是模型极限,只是当时没查元数据。 | |
| 189 | + | |
| 190 | +### 改了什么 | |
| 191 | + | |
| 192 | +只动两个参数,其余(模型、镜像、采样参数、健康检查、挂载)逐字未变: | |
| 193 | + | |
| 194 | +| | 改前 | 改后 | | |
| 195 | +|---|---|---| | |
| 196 | +| `--ctx-size`(总池) | 131072 | **524288** | | |
| 197 | +| `--parallel`(槽位) | 4 | **2** | | |
| 198 | +| 单请求上限 | 32K | **256K** | | |
| 199 | +| 并发槽位 | 4 | 2 | | |
| 200 | + | |
| 201 | +**不需要 YaRN**:524288 是总池,被 2 槽均分后每槽 262144 = 正好等于 `n_ctx_train`,没有外插。`llm-setup.md` 第 65 行「524288 (YaRN 2x)」把总池和单请求上限混为一谈了,已订正。 | |
| 202 | + | |
| 203 | +### 实测数据 | |
| 204 | + | |
| 205 | +| 指标 | 改前(32K×4) | 改后(256K×2) | | |
| 206 | +|------|-------------|---------------| | |
| 207 | +| `n_ctx_slot` | 32768 | **262144** | | |
| 208 | +| 显存(llm38 自身) | 29.4 GB | **57.3 GB** | | |
| 209 | +| 四卡合计占用(含 whisper) | 32.7 GB | **61.4 GB / 88 GB** | | |
| 210 | +| 生成速度(短上下文) | 23.4 tok/s | 23.4 tok/s(无变化) | | |
| 211 | +| 生成速度(150K 上下文) | — | **16.7 tok/s** | | |
| 212 | +| 提示处理(11K 提示) | 1491 tok/s | 1491 tok/s | | |
| 213 | +| 提示处理(150K 提示) | — | **979 tok/s** | | |
| 214 | + | |
| 215 | +四卡分布:GPU0 15264 / GPU1 14310 / GPU2 16620 / GPU3 15254 MiB。**GPU2 只剩 5.4 GB 最紧**,起新负载时避开它。 | |
| 216 | + | |
| 217 | +### 验收:大海捞针(150K token) | |
| 218 | + | |
| 219 | +把一条真信息埋在 4000 条填充记录的第 1800 条,提示总长 **149,880 token**——这个长度连**改前的 131072 总池都放不下**,是硬证明而非软验证。 | |
| 220 | + | |
| 221 | +- `truncated = 0`(关键:没有静默截断) | |
| 222 | +- 精确取出「QX-7741-KM / 周敏」,thinking 里明确引用了「条目1800」 | |
| 223 | +- 中文连贯无乱码 | |
| 224 | +- 3 路并发全部正常返回,healthcheck `healthy` / FailingStreak=0 | |
| 225 | + | |
| 226 | +### 代价,明说 | |
| 227 | + | |
| 228 | +1. **并发从 4 降到 2。** 第 3 个请求不会被拒,会排队。公网 `qwen.wudaima.com` 若真有多人同时用,排队会变明显。 | |
| 229 | +2. **长提示很贵。** 256K 提示光 prefill 就约 **4.5 分钟**(按 979 tok/s 算),且期间独占一个槽位。150K 那次端到端 2m43s。 | |
| 230 | +3. **显存余量从 55 GB 掉到 26 GB。** minimax-h3 是按「空闲显存 − 2 GB」自适应的,这条线的可用显存被同步压缩了,两边共用一台机器时要注意。 | |
| 231 | + | |
| 232 | +### 回滚 | |
| 233 | + | |
| 234 | +旧容器**改名保留**没删: | |
| 235 | + | |
| 236 | +```bash | |
| 237 | +docker rm -f llm38 && docker rename llm38_prev llm38 && docker start llm38 # 约 15 秒 | |
| 238 | +``` | |
| 239 | + | |
| 240 | +切换脚本 `/home/admin/qwen38-deploy/ctx256k.sh` 自带失败自动回滚(容器起不来或 8 分钟不健康就自动退回)。**确认稳定后再 `docker rm llm38_prev`**——`/` 分区只剩 29 GB。 | |
| 241 | + | |
| 242 | +### 想要 256K × 4 并发?可以,但没做 | |
| 243 | + | |
| 244 | +`-ctk q8_0 -ctv q8_0` 把 KV 量化到 8 bit,KV 从 64 GiB 降到 32 GiB,256K×4 就放得下。没做的原因:用户要的是最大上下文不是最大并发,而量化 KV 会引入质量变化,不值得在一次变更里叠两个变量。要做的话单独做,单独测。 | |
| 245 | + | |
| 246 | +## 2026-08-18 — MiniMax-H3 文生视频部署(进行中) | |
| 247 | + | |
| 248 | +**结果**:环境就绪并通过全部兼容性实测;**模型下载中(33 GB / 6 MB/s),尚未完成首次出片**。 | |
| 249 | +详见 `minimax-h3/README.md`。 | |
| 250 | + | |
| 251 | +**服务器侧**:`/data/models/minimax-h3/`。**系统级零改动**——没装 CUDA、没动驱动、 | |
| 252 | +没碰 `/usr`、没改 `~/.local`。venv 用 `--system-site-packages` 复用已有的 | |
| 253 | +`torch 2.9.0+cu128`(其 `arch_list` 含 `sm_75`),只额外装了 bitsandbytes 0.50.1 和 diffsynth。 | |
| 254 | + | |
| 255 | +### 被推翻的假设 | |
| 256 | + | |
| 257 | +| 原以为 | 实际 | | |
| 258 | +|--------|------| | |
| 259 | +| **照抄官方 NF4 示例即可** | ❌ 三处必改:全篇 `torch.bfloat16` → `float16`;`vram_limit` 取的是 `mem_get_info()[1]`(**总**显存),四张卡都有常驻服务,必须改 `[0]`(空闲);low-vram 示例的 `offload_device="disk"` 在 7200rpm 机械盘上是 87 秒/步的 I/O 灾难,改 CPU 内存 offload。 | | |
| 260 | +| **bf16 只是慢一点** | ❌ sm_75 上 bf16 **没有任何高效 attention kernel**(EFFICIENT 和 FLASH 都报 `No available kernel`),PyTorch **不报错**、静默掉到 MATH 后端物化整个注意力矩阵。14k token 序列必 OOM,且症状伪装成显存不足而非 dtype 问题。**光测 `bf16 matmul` 会得出错误结论**——它是能跑的(cuBLAS 升位模拟)。 | | |
| 261 | +| **4×22 GB 可以合起来跑一个大任务** | ❌ DiffSynth 的序列并行(`utils/xfuser/`)只被 `wan_video.py` 和 `mova_audio_video.py` 引用,H3 pipeline 零引用。叠加四卡无 P2P + 跨 NUMA,只能做**独立 worker 并发**,且并发数由**内存**(每 worker 约 32 GB / 共 128 GB → 2–3 个)而非显存卡死。 | | |
| 262 | +| **Turing 上只能走 llama.cpp** | ❌ 那是 LLM 的结论。PyTorch + bitsandbytes NF4 在 sm_75 上实测可用(bnb 4-bit 的最低要求正好是 sm_75)。 | | |
| 263 | +| **`nohup` 足够让下载脱离 SSH** | ❌ 不够,SSH 通道关闭仍会带走进程,必须 `setsid`(手册第三节其实写了,没照做)。另外**开两个下载进程会互相破坏同一个 `._____temp` 分片**——出口带宽是瓶颈,并行毫无收益。 | | |
| 264 | + | |
| 265 | +### 实测数据(RTX 2080 Ti / sm_75 / torch 2.9.0+cu128) | |
| 266 | + | |
| 267 | +``` | |
| 268 | +SDPA bfloat16 EFFICIENT → No available kernel SDPA float16 EFFICIENT → OK | |
| 269 | +SDPA bfloat16 FLASH → No available kernel SDPA float16 FLASH → 仅 sm80–sm121 | |
| 270 | +SDPA bfloat16 MATH → OK(朴素实现,会 OOM) | |
| 271 | + | |
| 272 | +真实 H3 形状 S=14100 H=56 D=128 的 fp16 SDPA EFFICIENT → 0.21s | |
| 273 | +fp16 GEMM 有效算力 → 54.3 TFLOPS(张量核满血) | |
| 274 | +fp16 深链数值稳定性 → finite | |
| 275 | +NF4 fp16 前向 → finite,rel_err 0.0913;prequantized 重载 → match=True | |
| 276 | +``` | |
| 277 | + | |
| 278 | +DiT 是稠密 30.9B(50 层 / hidden 5376 / 56×128 heads / ffn 14336,无 MoE), | |
| 279 | +480×832×124 帧约 14,100 token、830 TFLOP/步。故 50 步估 20–35 分钟, | |
| 280 | +**Turbo 4-step LoRA 估 2–4 分钟**——LoRA 可叠在 NF4 上(`AutoWrappedQuantizedModule` | |
| 281 | +继承 `LoRAHotLoadMixin`,在量化输出之后做加法,不需反量化基座)。 | |
| 282 | + | |
| 283 | +### 环境里两个坑 | |
| 284 | + | |
| 285 | +- `~/.local` 的 **bitsandbytes 0.45.1 是坏的**(`'NoneType' object has no attribute | |
| 286 | + 'cquantize_blockwise_fp16_nf4'`),venv 内装 0.50.1 覆盖。 | |
| 287 | +- **不要设 `PYTHONNOUSERSITE=1`**:这台机器的 `torch` / `transformers` / `av` 都在 | |
| 288 | + `~/.local`,屏蔽掉会连 torch 一起丢。venv 的 site-packages 本就排在它前面。 | |
| 289 | + | |
| 290 | +### 容器变更(用户授权,可逆) | |
| 291 | + | |
| 292 | +停了 `llm`(旧 llama.cpp,unhealthy 5 天,已被 `llm38` 取代)——恢复 `docker start llm`。 | |
| 293 | +`whisper` 停后**已恢复**(只用单卡,停它零收益)。**`llm38` 全程未碰**。 | |
| 294 | +启动参数备份在 `/data/models/minimax-h3/backup/`。 | |
| 295 | + | |
| 296 | +## 2026-08-18 — Qwen3.6-27B → Qwen3.8-27B | |
| 297 | + | |
| 298 | +**结果**:成功。中断 1 分 51 秒(17:03:14 停 → 17:05:05 就绪)。 | |
| 299 | + | |
| 300 | +### 起因 | |
| 301 | + | |
| 302 | +用户想部署 `Qwen/Qwen3.8-27B-FP8`。**结论是 FP8 版本装不了**,改用 GGUF。 | |
| 303 | + | |
| 304 | +### 为什么 FP8 不行(三道坎,第一道是硬的) | |
| 305 | + | |
| 306 | +1. 2080 Ti 是 Turing,compute capability **7.5**。FP8 原生需 Ada(8.9)/Hopper(9.0);vLLM 的 `fp8_marlin` 回退内核要求 Ampere(8.0+)。7.5 连回退路径都够不着——不是配置能绕的,是芯片里没有那个电路。 | |
| 307 | +2. Turing 不支持 bf16,而 FP8 权重的 scale 用 bf16 存。(机器上现有 vLLM compose 全带 `--dtype=half` 就是这堵墙留下的痕迹。) | |
| 308 | +3. 即便前两条不存在,机器上最新的 vLLM 镜像是 v0.10.2(11 个月前),不认识 Qwen3.8 的 Gated DeltaNet + Gated Attention 混合架构。 | |
| 309 | + | |
| 310 | +### 最终方案 | |
| 311 | + | |
| 312 | +`bartowski/Qwen3.8-27B-GGUF` 的 **Q5_K_M**(19.32 GiB / 20,752,787,040 字节),llama.cpp **b10481** 镜像,容器名 `llm38`,端口 8000。 | |
| 313 | + | |
| 314 | +选 Q5_K_M 而非平移 IQ4_XS:四卡 88 GB 只用了 29 GB,余量充足,质量提升是白拿的。 | |
| 315 | + | |
| 316 | +### 过程中被推翻的三个假设 | |
| 317 | + | |
| 318 | +对抗性验证阶段推翻了三条原本打算照做的事,都值得记住: | |
| 319 | + | |
| 320 | +| 原计划 | 为什么错 | | |
| 321 | +|--------|---------| | |
| 322 | +| **新旧模型并存在 8000/8001 上 A/B 对比** | ❌ GPU3 只剩 10.7 GB(whisper 常驻 3.2 GB),而 llama.cpp 默认 tensor-split 按**总显存**而非**剩余显存**均分,不加 `-ts` 必 OOM。更要命的是现有服务的 context checkpoint 是**惰性分配**的(149.626 MiB × 4 slot × 8,上限约 4.7 GB),新容器吃光余量后**公网服务会在某次多轮长对话时突然 OOM 挂掉**。改为串行切换。 | | |
| 323 | +| **沿用 Qwen3.6 的采样参数** | ❌ Qwen3.8 thinking 模式官方推荐 `temp 1.0 / top-k 20 / min-p 0.0`;照抄 3.6 的 `0.7/40/0.01` 会让 thinking 质量下降。且 llama.cpp 默认是 `top_k=40 / min_p=0.05`,不显式写就是错的。 | | |
| 324 | +| **靠 MTP 推测解码提速** | ❌ `--spec-type draft-mtp` 在 sm_75 上对 hybrid 模型**根本不激活**(llama.cpp issue #24670)。网上「+33~39%」的说法在这台机器上吃不到。而且 MTP 头是独立 sidecar 文件,bartowski 仓库根本没有。 | | |
| 325 | + | |
| 326 | +### 差点踩中的静默陷阱 | |
| 327 | + | |
| 328 | +**llama.cpp build 必须 ≥ b10450**(DeltaNet CUDA 修复 commit `ece963f4`)。b10419~b10450 的症状是**加载正常、显存正常、tok/s 正常、输出全是乱码**——机器上原有的 b9570 正好在雷区里。 | |
| 329 | + | |
| 330 | +上线前验证:`docker run --rm --entrypoint /app/llama-server <镜像> --version`,实得 `build 10481, commit 25ae3a9b3`。 | |
| 331 | + | |
| 332 | +### 实测数据 | |
| 333 | + | |
| 334 | +| 指标 | Qwen3.6-27B IQ4_XS | Qwen3.8-27B Q5_K_M | | |
| 335 | +|------|-------------------|-------------------| | |
| 336 | +| 权重 | 15.77 GB | 19.32 GB | | |
| 337 | +| 生成速度 | 28 tok/s | **23.4 tok/s** | | |
| 338 | +| 显存(四卡合计) | 29.3 GB | **29.4 GB** | | |
| 339 | +| 上下文 | 131072 | 131072 | | |
| 340 | + | |
| 341 | +权重大了 3.5 GB 显存却几乎没变:Qwen3.8 的 64 层里只有 16 层是标准注意力(`full_attention_interval=4`),48 层是 Gated DeltaNet,KV cache 只有同规格纯注意力模型的约 27%。 | |
| 342 | + | |
| 343 | +四卡分布:GPU0 7448 / GPU1 7336 / GPU2 8110 / GPU3 6532 MiB。 | |
| 344 | + | |
| 345 | +### 验证结果 | |
| 346 | + | |
| 347 | +- SHA256 `e731e180460b906f373294a4e2de10541e80ee676af7f8c949a84dbb6ed3caa8` 与官方逐字节一致 | |
| 348 | +- 中文输出语义连贯,**无乱码**(本次最大风险,已排除) | |
| 349 | +- healthcheck `healthy` / FailingStreak=0 | |
| 350 | +- 5 轮连续请求显存恒定 29426 MiB,无 CUDA graphs 泄漏 | |
| 351 | +- 下游硬编码旧模型名 `Qwen_Qwen3.6-27B-IQ4_XS.gguf` 仍返回 200 + 正常内容(服务端不校验 model 字段) | |
| 352 | + | |
| 353 | +### 顺带修掉的 | |
| 354 | + | |
| 355 | +镜像自带的 healthcheck 写死 `curl localhost:8080`,而服务在 8000,导致旧容器从启动起连续失败 16321 次、常年显示 `unhealthy`。新容器用 `--health-cmd` 覆盖指向 8000。**这个假红灯之前会掩盖真故障。** | |
| 356 | + | |
| 357 | +### 遗留项 | |
| 358 | + | |
| 359 | +- **单请求上下文实际是 32K 不是 128K**:`--ctx-size 131072` 是总池,被 `--parallel 4` 均分。要单请求 128K 需改成 `--parallel 2 --ctx-size 262144`(约 42 GB,显存够)。**待用户决定。** | |
| 360 | +- 多模态 `mmproj-Qwen3.8-27B-f16.gguf`(885 MB)已下载在同目录,但**未挂载**,首轮保持纯文本。需要图像输入时加 `--mmproj` 重启。 | |
| 361 | +- CUDA graphs 被 `GGML_CUDA_DISABLE_GRAPHS=1` 关掉以规避 Turing 显存泄漏(issue #25835),上游修复后可 A/B 试着开。 | |
| 362 | + | |
| 363 | +### 同期发现的其他问题(未处理) | |
| 364 | + | |
| 365 | +- `/` 分区仅剩 29 GB(87%),docker 镜像和日志都写这里。 | |
| 366 | +- nginx 的 `4016`/`4018` 两个前端后端 `4017`/`4019`/`4020` **全部无监听**,聊天应用是坏的。 | |
| 367 | +- FRP 的 `gateway.wudaima.com → 8080` 和 `comfyui.wudaima.com → 8188` 两条隧道空挂,本地无服务。 | |
| 368 | +- 公网 `qwen.wudaima.com` **无鉴权**;SSH 22 经 FRP 映射到公网 6000 且允许密码登录。 | ... | ... |
CLAUDE.md
0 → 100644
| 1 | +++ a/CLAUDE.md | |
| 1 | +# CLAUDE.md | |
| 2 | + | |
| 3 | +本目录是**公司 GPU 服务器 192.168.110.67(4 × RTX 2080 Ti 22G)的工作区**。真正的服务和模型都在那台远程机器上,这里只放手册、脚本和记录。 | |
| 4 | + | |
| 5 | +目前有两条并行的工作线: | |
| 6 | + | |
| 7 | +| 目录 | 工作线 | 状态 | | |
| 8 | +|------|--------|------| | |
| 9 | +| `llm-setup.md` / `deploy-scripts/` / `CHANGELOG.md` | **LLM 推理服务**(llama.cpp,对外 8000 端口) | Qwen3.8-27B 已上线 | | |
| 10 | +| `minimax-h3/` | **MiniMax-H3 FL2VA 文生视频+音频**(NF4 量化,sm_75) | 探查/调试中 | | |
| 11 | + | |
| 12 | +两条线共用同一批 GPU,**改动前先 `nvidia-smi` 看余量**——LLM 服务常驻约 29 GB / 88 GB,剩下的才是可用的。下面第「硬件的三条硬限制」一节对两条线都适用(尤其 bf16 不可用这条,`minimax-h3/scripts/probe-turing-caps.py` 就在探这个)。 | |
| 13 | + | |
| 14 | +## 接入 | |
| 15 | + | |
| 16 | +```bash | |
| 17 | +ssh admin@192.168.110.67 # 密码 P@ssw0rd | |
| 18 | +``` | |
| 19 | + | |
| 20 | +没装 `sshpass`。建议用 ControlMaster 复用连接(socket 路径必须短,否则超 Unix domain socket 长度限制): | |
| 21 | + | |
| 22 | +```bash | |
| 23 | +# 一次性建连(用 expect 喂密码),之后所有命令走复用 socket 免密 | |
| 24 | +ssh -o ControlMaster=yes -o ControlPath=~/.ssh/cm67 -o ControlPersist=30m -N admin@192.168.110.67 & | |
| 25 | +ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 '<命令>' | |
| 26 | +``` | |
| 27 | + | |
| 28 | +sudo 用 `echo "P@ssw0rd" | sudo -S -p "" <命令>`。注意 `/data` 是 root 属主,admin 直接 `mkdir` 会失败。 | |
| 29 | + | |
| 30 | +## 先读这个 | |
| 31 | + | |
| 32 | +**`HANDOFF.md` 是每次进来的入口**——现在是什么状态、哪些服务停着、下一步该干什么、 | |
| 33 | +已经踩过哪些坑。它比本文件更新得勤,因为它记的是"此刻"。 | |
| 34 | + | |
| 35 | +## 权威文档 | |
| 36 | + | |
| 37 | +**`llm-setup.md` 是这台机器的完整运维手册**——模型清单、启动命令、显存核算、回滚流程、FRP/nginx 映射、硬件限制全在里面。动手前先读它,不要凭记忆回答。 | |
| 38 | + | |
| 39 | +⚠️ **本地这份是服务器上 `/home/admin/llm-setup.md` 的副本,两边会漂移。** 以服务器上那份为准(SSH 进去的人读的是它)。同步: | |
| 40 | + | |
| 41 | +```bash | |
| 42 | +# 从服务器拉最新 | |
| 43 | +ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 'cat /home/admin/llm-setup.md' > llm-setup.md | |
| 44 | +# 推回服务器(改完本地后) | |
| 45 | +cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 'cat > /home/admin/llm-setup.md' | |
| 46 | +``` | |
| 47 | + | |
| 48 | +## 硬件的三条硬限制 | |
| 49 | + | |
| 50 | +细节见 `llm-setup.md` 第二、三节,这里只列最容易白费功夫的: | |
| 51 | + | |
| 52 | +1. **4 × RTX 2080 Ti = Turing / compute capability 7.5**。装不了 FP8 模型(原生需 Ada 8.9+,vLLM 的 `fp8_marlin` 回退也需 Ampere 8.0+),不支持 bf16。 | |
| 53 | + **LLM** 走 GGUF + llama.cpp 是唯一实际路径。**扩散/视频模型**不受此限——PyTorch + bitsandbytes NF4 在 sm_75 上实测可用,见 `minimax-h3/`。 | |
| 54 | + bf16 的坑比想象中深:sm_75 上 bf16 **没有任何高效 attention kernel**,PyTorch 不报错、静默掉到 MATH 后端物化整个注意力矩阵,长序列表现为莫名 OOM。全链路改 fp16 才行。 | |
| 55 | +2. **llama.cpp build 有下限**。qwen35 架构需 ≥ b10450,低于此**加载正常、显存正常、tok/s 正常、但输出全是乱码**。上线前必须 `--version` 查 build 号。 | |
| 56 | +3. **网络**:`huggingface.co` 与 `ghcr.io` 的 blob 层都不通,改用 `hf-mirror.com` 和 `ghcr.nju.edu.cn`。出口带宽约 5.9 MB/s 封顶,**并行下载不会更快,只会互相抢**——下载必须用 `curl -C -` 断点续传 + `setsid nohup` 脱离 SSH 会话。 | |
| 57 | + | |
| 58 | +## 目录约定(迁移工作内容时按此放) | |
| 59 | + | |
| 60 | +一个**工作负载**一个顶层目录,目录名 = 服务/模型的短名。目录内固定三样: | |
| 61 | + | |
| 62 | +``` | |
| 63 | +<workload>/ | |
| 64 | + README.md # 这个负载是什么、跑在哪张卡、怎么起、怎么停、怎么回滚 | |
| 65 | + scripts/ # 服务器上对应目录的副本(保持文件名一致,方便双向 diff) | |
| 66 | + notes.md # 可选:踩坑记录、被推翻的假设、实测数据 | |
| 67 | +``` | |
| 68 | + | |
| 69 | +顶层只放跨负载的东西:本文件、`CHANGELOG.md`(按时间倒序,一次变更一节)、`llm-setup.md`(整机手册)。 | |
| 70 | + | |
| 71 | +**迁移进来的工作必须补三件事**,否则等于没迁: | |
| 72 | + | |
| 73 | +1. 在下面「当前负载」表里加一行(占哪张卡、多少显存、对外端口)。 | |
| 74 | +2. 在 `CHANGELOG.md` 顶部加一节,写清**结果**、**被推翻的假设**、**实测数据**——CHANGELOG 的价值在踩坑记录,不在流水账。 | |
| 75 | +3. 说明脚本对应服务器上的哪个绝对路径。本地全是副本,**服务器上那份才是运行的那份**,会漂移。 | |
| 76 | + | |
| 77 | +## 当前负载 | |
| 78 | + | |
| 79 | +| 目录 | 跑什么 | 卡 | 显存 | 对外 | | |
| 80 | +|------|--------|----|------|------| | |
| 81 | +| `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` | | |
| 82 | +| `minimax-h3/` | MiniMax-H3 FL2VA/Ref2VA NF4 文生视频,`api.py` 常驻四实例(GPU0-3,端口 8010-8013)+ `gateway.py` 调度层(端口 8000) | 四卡各一进程 | 单卡 fp32 峰值约 9-14 GB(视时长/分辨率),另占 CPU 内存约 32 GB/worker | 8000(内部,gateway.py) | | |
| 83 | +| `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化 + fl2va/ref2va 弹性调度器 | 不占显存(自己不跑模型,管理 minimax-h3 的四卡) | — | 8080 → `gateway.wudaima.com`(隧道已配好,公网访问未验证) | | |
| 84 | +| (常驻) | whisper | GPU3 | 3.2 GB | — | | |
| 85 | + | |
| 86 | +排卡前先 `nvidia-smi` 看实际余量:GPU3 被 whisper 占着,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,起新负载不加 `-ts` 容易把公网服务挤 OOM。 | |
| 87 | + | |
| 88 | +⚠️ **2026-08-18 上下文提到 256K 后余量大幅收紧**:四卡合计已用 61.4 / 88 GB,只剩约 26 GB,**GPU2 最紧只剩 5.4 GB**。minimax-h3 按「空闲显存 − 2 GB」自适应,等于它的可用显存被同步压缩了。两条线同时跑之前先看 `nvidia-smi`。 | |
| 89 | + | |
| 90 | +## 部署脚本 | |
| 91 | + | |
| 92 | +`deploy-scripts/` 下是服务器 `/home/admin/qwen38-deploy/` 的副本: | |
| 93 | + | |
| 94 | +| 脚本 | 作用 | | |
| 95 | +|------|------| | |
| 96 | +| `cutover.sh` | 切换到 Qwen3.8(校验完整性 → 停旧容器 → 起新容器 → 等健康) | | |
| 97 | +| `restore.sh` | **一键回滚**到 Qwen3.6,约 3-5 分钟 | | |
| 98 | +| `download.sh` | 断点续传下载模型 | | |
| 99 | +| `pull2.sh` | 从 NJU 代理拉镜像并 retag | | |
| 100 | +| `ctx256k.sh` | 把单请求上下文切到 256K(旧容器改名保留,失败自动回滚) | | |
| 101 | +| `ggufmeta.py` | 读 GGUF 元数据(查模型原生上下文上限、层数、KV 头维度)——**别凭记忆猜上下文上限,跑这个** | | |
| 102 | + | |
| 103 | +回滚依赖三样东西,**都不要删**:镜像 tag `llamacpp-rollback:b9570`、模型文件 `/data/models/Qwen/Qwen3.6-27B-GGUF/`、脚本 `restore.sh`。 | |
| 104 | + | |
| 105 | +## MiniMax-H3 脚本 | |
| 106 | + | |
| 107 | +`minimax-h3/scripts/` 对应服务器 `/data/models/minimax-h3/scripts/`(`run.sh` 在 | |
| 108 | +`/data/models/minimax-h3/run.sh`)。**权威文档是 `minimax-h3/README.md`**——为什么官方示例 | |
| 109 | +照抄跑不起来、Turing 的 dtype/attention/量化实测结论、多卡为什么只能做独立 worker。 | |
| 110 | + | |
| 111 | +其中 `probe-*.py` 四件套是当初得出兼容性结论的探测脚本。 | |
| 112 | +**换硬件或升级 torch / bitsandbytes 后,先跑这四个再动别的。** | |
| 113 | + | |
| 114 | +## 当前状态(2026-08-18) | |
| 115 | + | |
| 116 | +对外服务 `http://qwen.wudaima.com`(仅 HTTP,**无鉴权**),经 FRP 指向本机 8000,跑 Qwen3.8-27B-Q5_K_M。 | |
| 117 | + | |
| 118 | +**上下文:单请求 256K**(`--parallel 2 --ctx-size 524288`,每槽 262144)。256K 是**模型原生上限**——GGUF 元数据 `qwen35.context_length = 262144`,不是 128K;早期文档里的 128K 是低估。已用 150K token 大海捞针实测通过(`truncated=0`)。代价是并发从 4 降到 2,且 256K 提示光 prefill 约 4.5 分钟。 | |
| 119 | + | |
| 120 | +回滚:`docker rm -f llm38 && docker rename llm38_prev llm38 && docker start llm38`(约 15 秒)。旧容器 `llm38_prev` 停着保留,**稳定后再删**(`/` 只剩 29 GB)。 | |
| 121 | + | |
| 122 | +⚠️ 已知安全问题:公网可无鉴权直调 27B 推理;SSH 22 端口经 FRP 映射到公网 6000 且允许密码登录、admin 在 sudo 组。 | |
| 123 | + | |
| 124 | +## 这是一台测试机(2026-08-18 用户明确) | |
| 125 | + | |
| 126 | +**这台机器的用途就是测试各种模型,不是生产环境。** 显存不够时**直接停 llm38 给别的负载让路是允许的、也是预期做法**,不需要为此设计复杂的共存方案。 | |
| 127 | + | |
| 128 | +这条推翻了本文件早期版本里「8000 是对公网服务的,停服务是对外可见动作」的前提。**不要再把显存争抢当成需要精巧规避的约束**——串行跑就行: | |
| 129 | + | |
| 130 | +```bash | |
| 131 | +docker stop llm38 # 让出 ~57 GB,四卡各回到约 21 GB 空闲 | |
| 132 | +# ... 跑 minimax-h3 / 别的模型 ... | |
| 133 | +docker start llm38 # 跑完起回来,约 15 秒 | |
| 134 | +``` | |
| 135 | + | |
| 136 | +副作用:这样测出来的数字才是**干净的**(不被抢占扭曲),比在争抢状态下测更有参考价值。 | |
| 137 | + | |
| 138 | +## 操作纪律 | |
| 139 | + | |
| 140 | +- **改动线上服务前先确认回滚路径可用**。停服务本身不敏感(测试机),但起不回来会很麻烦。 | |
| 141 | +- 大文件下载和镜像拉取一律后台跑(`setsid nohup`),不要占着前台等。 | |
| 142 | +- **不要凭记忆回答这台机器的事。** 先读 `llm-setup.md` 和 `CHANGELOG.md`——里面记的坑(build 号下限、FP8 不可行、MTP 不激活)都是已经花过时间验证的,重复踩一遍是纯浪费。 | |
| 143 | +- 多个进程可能同时在这个目录里干活。改共享文件(本文件、`CHANGELOG.md`、`llm-setup.md`)用追加/局部编辑,不要整文件覆写。 | ... | ... |
HANDOFF.md
0 → 100644
| 1 | +++ a/HANDOFF.md | |
| 1 | +# 交接文档 · 2026-08-18 | |
| 2 | + | |
| 3 | +> 给下次进来的人(或进程)。**先读这份,再读 `CLAUDE.md`。** | |
| 4 | +> 权威细节在 `llm-setup.md`(整机)和 `minimax-h3/README.md`(视频线), | |
| 5 | +> 时间线在 `CHANGELOG.md`。这里只写「现在是什么状态、下一步该干什么」。 | |
| 6 | + | |
| 7 | +## 一句话 | |
| 8 | + | |
| 9 | +今天做了两件事:**llm38 单请求上下文 32K → 256K**(已完成、已验收、已恢复), | |
| 10 | +**MiniMax-H3 文生视频从零部署到出片 + 封了 HTTP API**(已出片、已验收,**服务当前在跑,环境未恢复**)。 | |
| 11 | + | |
| 12 | +## 2026-08-19 16:30 更新:断连 13 小时后重连 | |
| 13 | + | |
| 14 | +- **50 步全质量已完成并验收**(6.4 MB · 5364 s · 107.3 s/步 · 峰值 9.1 G · 帧间差异 7.366 · 音频 −27.2 dB)。 | |
| 15 | + 两档都出片,详见 `minimax-h3/README.md` 验收记录。 | |
| 16 | +- **断连原因是本地网络**:Mac 到 192.168.110.x 的路由被 VPN 隧道(`utun7` / `198.18.0.1`)劫持。 | |
| 17 | + 期间 `nc -z` 对**所有**端口都返回成功(连 9999 和只听 127.0.0.1 的 7860 也"通")—— | |
| 18 | + **那是假阳性,别据此判断服务状态**。用 `ping` + `route -n get` 确认路径。 | |
| 19 | +- **期间另有进程在这台机器上工作**:两个 H3 API 实例常驻(8010 → GPU1 13.0 G / 8011 → GPU2 7.7 G, | |
| 20 | + 两者 `/health` 均 ok),`api.py` 被改过(新增 `PARTITION` 字段),新增 `restart-8010.sh`, | |
| 21 | + `outputs/` 里有 6 个非本次生成的视频。 | |
| 22 | + | |
| 23 | +## ★ 当前是用户指定的目标状态:只留 H3 ★ | |
| 24 | + | |
| 25 | +**2026-08-19 16:40 用户明确:「停止 llm38」「只留着 h3 即可」。** | |
| 26 | +所以下面这四个**停着是对的,不要"顺手"起回来**: | |
| 27 | + | |
| 28 | +| 服务 | 状态 | 说明 | | |
| 29 | +|---|---|---| | |
| 30 | +| `llm38`(8000) | **停** | 配置已是 256K 单请求上下文并验收过,需要时 `docker start llm38` | | |
| 31 | +| `whisper`(8002) | **停** | `docker start whisper` | | |
| 32 | +| PM2 `m3e`(7590) | **停** | `/usr/local/node/bin/pm2 start m3e` | | |
| 33 | +| PM2 `sd-forge`(7860) | **停** | `/usr/local/node/bin/pm2 start sd-forge` | | |
| 34 | +| **H3 API 8010 / 8011** | **运行中** | 唯一保留的负载,占 GPU1+GPU2 共 20.8 G | | |
| 35 | + | |
| 36 | +GPU0 与 GPU3 全空(各 22002 MiB)。恢复命令详见 `deploy-scripts/RESTORE-GPU.md`。 | |
| 37 | + | |
| 38 | +> ⚠️ 要恢复 llm38 时注意:它跑 256K 需**四卡各约 14.3 G**,而 GPU1 现被 8010 占着只剩 8.95 G。 | |
| 39 | +> 要共存就用 `--parallel 1 --ctx-size 262144`(单请求仍 256K,并发降为 1,省 16 G), | |
| 40 | +> 或先停一个 H3 实例。 | |
| 41 | + | |
| 42 | +## ⚠️ 立刻要知道的三件事 | |
| 43 | + | |
| 44 | +1. **四个服务当前是停的,而这是用户要的状态**(见上一节),不是未完成的收尾。 | |
| 45 | + 需要时按 `deploy-scripts/RESTORE-GPU.md` 恢复,恢复后验 7590 / 7860 / 8000 / 8002 四个端口。 | |
| 46 | +2. **`m3e` 和 `sd-forge` 由 PM2 托管**(God Daemon PID 1467)。`kill` 它们**没用**, | |
| 47 | + 60 秒内会被拉回来;必须 `/usr/local/node/bin/pm2 stop m3e sd-forge`。恢复是 `pm2 start`。 | |
| 48 | + pm2 不在默认 PATH 里,非交互 shell 下写全路径。 | |
| 49 | +3. **一条 50 步全质量视频还在跑**(GPU2,22:22 启动,约 90 分钟,预计 23:50 左右完), | |
| 50 | + H3 的 **API 服务也在跑**(GPU1,端口 8010)。恢复环境前先确认这两个结束/停掉。 | |
| 51 | + | |
| 52 | +## 状态速查 | |
| 53 | + | |
| 54 | +| 东西 | 状态 | 位置 | | |
| 55 | +|---|---|---| | |
| 56 | +| llm38(Qwen3.8-27B) | **已停**。配置已改成 256K 单请求上下文,验收过 | `deploy-scripts/ctx256k.sh` | | |
| 57 | +| llm38 旧容器 `llm38_prev` | 保留着(32K 配置),确认稳定后可 `docker rm` | `/` 只剩 29 G,别忘 | | |
| 58 | +| whisper / m3e / sd-forge | **已停** | `deploy-scripts/RESTORE-GPU.md` | | |
| 59 | +| MiniMax-H3 权重 34 GB | 下载完 + 字节级校验 `ALL GOOD` | `/data/models/minimax-h3/models/` | | |
| 60 | +| H3 出片 | **成功**,已验收(见下) | `outputs/t2va_20260818-223136.mp4` | | |
| 61 | +| H3 API | **在跑**,GPU1:8010 | `minimax-h3/API.md` | | |
| 62 | +| 50 步全质量 | **进行中**,结果未验 | `logs/real-full50.log` | | |
| 63 | + | |
| 64 | +## 今天最重要的一条结论 | |
| 65 | + | |
| 66 | +**`minimax-h3/README.md` 原第一节写「`bfloat16` → `float16`(致命)」是错的。 | |
| 67 | +改成 fp16 才是这条线一直出不了片的原因。** | |
| 68 | + | |
| 69 | +对照实验(唯一变量是 dtype,448×256×28f): | |
| 70 | + | |
| 71 | +| | fp32 | fp16 | | |
| 72 | +|---|---|---| | |
| 73 | +| 视频 | mean 140.9 **std 50.6** | mean 0 **std 0** | | |
| 74 | +| 音频 | finite | NaN | | |
| 75 | +| 结果 | **出片 ✅** | **纯黑 ❌** | | |
| 76 | + | |
| 77 | +原因:模型按 bf16 训练(bf16 有 fp32 的动态范围),sm_75 没有 bf16, | |
| 78 | +fp16 的 65504 上限装不下——`dit.blocks.0` 输入 absmax 89 就放大到 38176,50 层残差累积必爆。 | |
| 79 | + | |
| 80 | +**逐点修 fp16 是行不通的**,我试了四条弯路全部失败(`--fp32-accum` 无效、替换 forward 绕过换入、 | |
| 81 | +转换处溢出、钳位到上限反而自造故障),细节和实测数据都在 `minimax-h3/README.md`。 | |
| 82 | +`t2va.py` 的 `--dtype` 现在**默认 fp32**,保留 `fp16` 只为复现故障。 | |
| 83 | + | |
| 84 | +## 验收数据(都是实测,没有推测) | |
| 85 | + | |
| 86 | +**H3 出片**:832×480 · 24fps · 124 帧 · 5.17 s · 7.5 MB · | |
| 87 | +像素 std 57.85 · **帧间差异 16.99(证明是动态视频)** · | |
| 88 | +音频 aac 32 kHz 立体声 mean −34.0 dB / rms 0.0199(**证明不是静音**)。 | |
| 89 | + | |
| 90 | +**性能**(fp32,单任务):全分辨率 Turbo 4 步约 **107 s/步**,峰值显存 **12.2 G**, | |
| 91 | +首次加载 238 s,page cache 命中后 12–20 s。fp16 是 36.8 s/步但出不了片。 | |
| 92 | + | |
| 93 | +**llm38 256K**:`n_ctx_slot = 262144`(模型原生上限,GGUF 元数据 `qwen35.context_length`), | |
| 94 | +150 K token 大海捞针 `truncated=0` 通过,显存 57.3 G,并发从 4 降到 2, | |
| 95 | +256 K 提示 prefill 约 4.5 分钟。 | |
| 96 | + | |
| 97 | +## 下一步(按优先级) | |
| 98 | + | |
| 99 | +1. **收尾**:等 50 步跑完 → 验收(`bash /tmp/verify-out.sh <文件>`,脚本在服务器 `/tmp`, | |
| 100 | + 要长期用得挪到 `scripts/`)→ 补 `README.md` 验收记录 → **恢复四个服务并验端口**。 | |
| 101 | +2. **决定 llm38 与 H3 的共存策略**。两者显存放不下(57.3 + 12.3 > 单卡余量)。 | |
| 102 | + 选项:H3 按需起停;或 llm38 降到 `--parallel 1 --ctx-size 262144`(保 256 K 单请求, | |
| 103 | + 并发降为 1,省 16 G);或换卡。**这是产品决策,需要用户拍板。** | |
| 104 | +3. **安全**:`qwen.wudaima.com` 公网无鉴权可直调 27B;H3 API 也无鉴权。 | |
| 105 | + SSH 22 经 FRP 映射到公网 6000 且允许密码登录、admin 在 sudo 组。**都没动,等授权。** | |
| 106 | +4. 可选:`t2va.py:47` 的 `max(6.0, ...)` 地板值在空闲显存 < 8 G 时会要一个不存在的额度, | |
| 107 | + 应改成显式报错。串行跑绕开了,多负载共存时会咬人。 | |
| 108 | + | |
| 109 | +## 踩过的坑(别重踩) | |
| 110 | + | |
| 111 | +- **退出码不可信**:原 `t2va.py` 只查 audio,全黑视频照样 `[done]` + 退出码 0。 | |
| 112 | + 已补 video 纯色检查,坏产物退出码 3。API 的 worker 也会验 std 和音频有限性。 | |
| 113 | +- **诊断配置必须和故障配置逐项一致**:我用中文 prompt 诊断、英文默认 prompt 复现, | |
| 114 | + 溢出点不同,白跑几轮。 | |
| 115 | +- **prompt 必须从文件读**:`ssh … bash -c "…"` 三层 shell 会吃掉引号,传进去是空串, | |
| 116 | + 报错是 `cannot reshape tensor of 0 elements`——看着像 transformers 的 bug,实际是输入为空。 | |
| 117 | +- **`cmd1 && cmd2 &` 的 `&` 作用于整条链**:`cat > file && ... &` 会让文件写入和执行竞争, | |
| 118 | + 我因此跑了一个 0 字节的脚本还以为它成功了。上传和启动要分两步。 | |
| 119 | +- **`pgrep -f "gunicorn.*7590"` 会匹配到你自己那条 SSH 命令行**,照着 kill 会误杀。 | |
| 120 | + 用 `nvidia-smi --query-compute-apps=pid` 反查。 | |
| 121 | +- **`ffmpeg -af volumedetect` 的输出容易被 grep 漏掉**,误报"无音频流"。 | |
| 122 | + 用 PyAV 直接解码算 rms 更可靠。 | |
| 123 | +- **合成小张量的 probe 测不出真问题**:`probe-nf4-fp16.py` 验证「单层 NF4 fp16 不溢出」通过, | |
| 124 | + 但推不出「50 层残差不溢出」。 | |
| 125 | + | |
| 126 | +## 本地与服务器的对应 | |
| 127 | + | |
| 128 | +| 本地 | 服务器 | | |
| 129 | +|---|---| | |
| 130 | +| `deploy-scripts/` | `/home/admin/qwen38-deploy/` | | |
| 131 | +| `llm-setup.md` | `/home/admin/llm-setup.md` | | |
| 132 | +| `minimax-h3/scripts/`、`API.md`、`README.md` | `/data/models/minimax-h3/`(`run.sh`/`api.py`/`realrun.sh` 在顶层) | | |
| 133 | + | |
| 134 | +**服务器那份才是运行的那份**,两边会漂移。今天就因为只在服务器上修了导入、 | |
| 135 | +本地那份没同步,重新生成脚本时把旧 bug 带了回去,白跑一轮。 | ... | ... |
deploy-scripts/RESTORE-GPU.md
0 → 100644
| 1 | +++ a/deploy-scripts/RESTORE-GPU.md | |
| 1 | +# 腾显存 / 恢复清单 | |
| 2 | + | |
| 3 | +> 生成于 2026-08-18,为 minimax-h3 部署腾显存前的现场快照。 | |
| 4 | +> 用户已授权「部署 H3 时其他占显存的模型都可以停」,但**恢复方式必须先记全再动手**。 | |
| 5 | + | |
| 6 | +## 现场(停机前) | |
| 7 | + | |
| 8 | +| PID/容器 | 服务 | 端口 | 显存 | 恢复方式 | | |
| 9 | +|---|---|---|---|---| | |
| 10 | +| 容器 `llm38` | Qwen3.8-27B llama.cpp | 8000 | **57.3 GB** | `docker start` | | |
| 11 | +| 容器 `whisper` | 语音转写 | 8002 | **3.2 GB** | `docker start` | | |
| 12 | +| PM2 app `m3e` | m3e embedding gunicorn | 7590 | 668 MiB | `pm2 start m3e` | | |
| 13 | +| PM2 app `sd-forge` | SD WebUI Forge | 7860(仅本机) | 174 MiB | `pm2 start sd-forge` | | |
| 14 | + | |
| 15 | +**用户 2026-08-18 明确指示:需要显存时四个全停。** 这是测试机,不是生产环境。 | |
| 16 | +合计腾出 **61.4 GB**,四卡各回到约 21.5 GB 空闲。 | |
| 17 | + | |
| 18 | +### ★ 后两个是 PM2 托管的,不是手工拉起 ★ | |
| 19 | + | |
| 20 | +**2026-08-18 实测更正**:本文件早期版本写它们「无 systemd 单元、手工拉起、是恢复环节的主要风险点」——**错了**。 | |
| 21 | +它们由 **PM2 God Daemon(PID 1467, v5.3.1)** 托管,`pm2 list` 里叫 `m3e` 和 `sd-forge`。 | |
| 22 | + | |
| 23 | +后果很实际:**`kill <pid>` 是没用的,PM2 会在几秒内把它们拉回来**(实测 kill 后 60 秒内自动重启,显存又被占回去)。必须用 `pm2 stop`。 | |
| 24 | +好消息是恢复也因此变简单——一条 `pm2 start` 即可,不需要手抄启动命令。 | |
| 25 | + | |
| 26 | +pm2 二进制在 **`/usr/local/node/bin/pm2`**(不在默认 PATH 里,SSH 非交互 shell 下要写全路径)。 | |
| 27 | + | |
| 28 | +PM2 还托管着 5 条 frpc 隧道(`frpc-ssh` / `frpc-qwen-client` / `frpc-m3e-client` / `frpc-sd-client` / `frpc-comfyui-client`)和几个 stopped 的条目(`qwen` / `deepseek` / `deepseek-14b`)。**隧道不占显存,不要停。** | |
| 29 | + | |
| 30 | +## 一键全停 | |
| 31 | + | |
| 32 | +```bash | |
| 33 | +docker stop llm38 whisper | |
| 34 | +/usr/local/node/bin/pm2 stop m3e sd-forge # ← 必须用 pm2,kill 会被自动拉回 | |
| 35 | +sleep 6 | |
| 36 | +nvidia-smi --query-compute-apps=pid,used_memory --format=csv,noheader # 应为空 | |
| 37 | +nvidia-smi --query-gpu=index,memory.free --format=csv,noheader # 实测四卡各 22002 MiB | |
| 38 | +``` | |
| 39 | +实测结果(2026-08-18 18:58):**四卡各 22002 MiB 空闲,合计 88 GB,计算进程为零。** | |
| 40 | + | |
| 41 | +> ⚠️ 别用 `pgrep -f "gunicorn.*7590"` 找 PID——**它会匹配到你自己那条 SSH 命令行**,照着 kill 会误杀。 | |
| 42 | +> 用 `nvidia-smi --query-compute-apps=pid` 报出来的 PID 反查才可靠。 | |
| 43 | + | |
| 44 | +## 恢复命令 | |
| 45 | + | |
| 46 | +### llm38(docker,首选) | |
| 47 | +```bash | |
| 48 | +docker start llm38 | |
| 49 | +# 验证(约 15 秒) | |
| 50 | +curl -sf http://127.0.0.1:8000/health | |
| 51 | +docker logs llm38 2>&1 | grep n_ctx_slot | tail -1 # 应为 n_ctx_slot = 262144 | |
| 52 | +docker inspect llm38 --format '{{.State.Health.Status}}' # healthy | |
| 53 | +``` | |
| 54 | +起不来时的兜底:`/home/admin/qwen38-deploy/ctx256k.sh`(自带失败回滚), | |
| 55 | +或旧配置容器 `llm38_prev`:`docker rm -f llm38 && docker rename llm38_prev llm38 && docker start llm38` | |
| 56 | + | |
| 57 | +### whisper(docker) | |
| 58 | +```bash | |
| 59 | +docker start whisper | |
| 60 | +ss -tln | grep 8002 | |
| 61 | +``` | |
| 62 | + | |
| 63 | +### m3e embedding + SD WebUI Forge(PM2) | |
| 64 | +```bash | |
| 65 | +/usr/local/node/bin/pm2 start m3e sd-forge | |
| 66 | +/usr/local/node/bin/pm2 list # 两者应为 online | |
| 67 | +ss -tln | grep -E '7590|7860' # 7590 全网 / 7860 仅 127.0.0.1 | |
| 68 | +``` | |
| 69 | +> 手工拉起的命令留档备查(**正常情况下用不到,PM2 里有配置**): | |
| 70 | +> - m3e:cwd `/home/admin/llm/embedding`, | |
| 71 | +> `/home/admin/.cache/pypoetry/virtualenvs/embedding-LbDC8KEi-py3.10/bin/python .../bin/gunicorn -w 1 -b 0.0.0.0:7590 --timeout 300 embedding.app:app --log-level info` | |
| 72 | +> - sd-forge:cwd `/data/models/stable-diffusion-webui-forge`,`bash webui.sh --api` | |
| 73 | + | |
| 74 | +## 收工检查 | |
| 75 | + | |
| 76 | +```bash | |
| 77 | +nvidia-smi --query-compute-apps=pid,used_memory --format=csv,noheader | |
| 78 | +ss -tln | grep -E '7590|7860|8000|8002' # 四个端口都要在 | |
| 79 | +curl -sf http://127.0.0.1:8000/health && echo OK | |
| 80 | +``` | ... | ... |
deploy-scripts/ctx256k.sh
0 → 100755
| 1 | +++ a/deploy-scripts/ctx256k.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# 把 llm38 的单请求上下文从 32K 提到 256K(模型原生上限)。 | |
| 3 | +# 手法:--parallel 4 --ctx-size 131072 → --parallel 2 --ctx-size 524288 | |
| 4 | +# 旧容器改名保留而非删除,起不来就秒退回去。 | |
| 5 | +set -uo pipefail | |
| 6 | +NEWCTX=524288; NEWPAR=2 | |
| 7 | + | |
| 8 | +echo "[1/4] 备份旧容器(改名保留,不删)" | |
| 9 | +docker rm -f llm38_prev 2>/dev/null | |
| 10 | +docker stop llm38 >/dev/null && docker rename llm38 llm38_prev | |
| 11 | +echo " 旧容器已停并改名 llm38_prev $(date '+%H:%M:%S')" | |
| 12 | + | |
| 13 | +rollback() { | |
| 14 | + echo "❌ 新配置未就绪,回滚到 32K" | |
| 15 | + docker rm -f llm38 2>/dev/null | |
| 16 | + docker rename llm38_prev llm38 && docker start llm38 | |
| 17 | + for i in $(seq 1 60); do | |
| 18 | + curl -sf http://127.0.0.1:8000/health >/dev/null 2>&1 && { echo " ✅ 已回滚就绪 $(date '+%H:%M:%S')"; exit 1; } | |
| 19 | + sleep 5 | |
| 20 | + done | |
| 21 | + echo " ⚠️ 回滚后仍未就绪,人工介入:docker logs llm38"; exit 2 | |
| 22 | +} | |
| 23 | + | |
| 24 | +echo "[2/4] 起新容器 parallel=$NEWPAR ctx-size=$NEWCTX(单请求 $((NEWCTX/NEWPAR/1024))K)" | |
| 25 | +docker run -d \ | |
| 26 | + --name llm38 \ | |
| 27 | + --restart unless-stopped \ | |
| 28 | + --gpus all \ | |
| 29 | + -p 8000:8000 \ | |
| 30 | + -v /data/models:/jsxc \ | |
| 31 | + -v /data/nvcache38:/root/.nv \ | |
| 32 | + -e GGML_CUDA_DISABLE_GRAPHS=1 \ | |
| 33 | + -e CUDA_CACHE_MAXSIZE=2147483648 \ | |
| 34 | + --health-cmd "curl -fsS http://localhost:8000/health || exit 1" \ | |
| 35 | + --health-interval 30s --health-timeout 5s \ | |
| 36 | + --health-retries 3 --health-start-period 600s \ | |
| 37 | + llamacpp:b10481 \ | |
| 38 | + -m /jsxc/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf \ | |
| 39 | + --alias qwen3.8-27b \ | |
| 40 | + --host 0.0.0.0 --port 8000 \ | |
| 41 | + -ngl 99 -sm layer -fa on \ | |
| 42 | + --ctx-size $NEWCTX --no-context-shift \ | |
| 43 | + --batch-size 2048 --ubatch-size 512 \ | |
| 44 | + --parallel $NEWPAR --jinja --seed 3407 \ | |
| 45 | + --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0 \ | |
| 46 | + || rollback | |
| 47 | + | |
| 48 | +echo "[3/4] 等待就绪(KV 变大,分配比之前慢;最多 8 分钟)" | |
| 49 | +for i in $(seq 1 96); do | |
| 50 | + if ! docker ps --format '{{.Names}}' | grep -qx llm38; then | |
| 51 | + echo " 容器退出了,尾部日志:"; docker logs --tail 25 llm38 2>&1; rollback | |
| 52 | + fi | |
| 53 | + if curl -sf http://127.0.0.1:8000/health >/dev/null 2>&1; then | |
| 54 | + echo " ✅ 就绪于第 $((i*5)) 秒 $(date '+%H:%M:%S')"; break | |
| 55 | + fi | |
| 56 | + [ "$i" = 96 ] && { docker logs --tail 25 llm38 2>&1; rollback; } | |
| 57 | + sleep 5 | |
| 58 | +done | |
| 59 | + | |
| 60 | +echo "[4/4] 实际生效值" | |
| 61 | +docker logs llm38 2>&1 | grep -E "n_ctx_slot|n_slots" | tail -2 | |
| 62 | +nvidia-smi --query-gpu=index,memory.used,memory.free --format=csv,noheader | |
| 63 | +echo "旧容器 llm38_prev 保留着,确认没问题后 docker rm llm38_prev" | ... | ... |
deploy-scripts/cutover.sh
0 → 100755
| 1 | +++ a/deploy-scripts/cutover.sh | |
| 1 | +#!/bin/bash | |
| 2 | +# Qwen3.6 -> Qwen3.8 串行切换。失败请跑 restore.sh | |
| 3 | +set -e | |
| 4 | +M=/data/models/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf | |
| 5 | +E=20752787040 | |
| 6 | + | |
| 7 | +echo "[G3] 校验模型完整性..." | |
| 8 | +SZ=$(stat -c %s "$M") | |
| 9 | +[ "$SZ" = "$E" ] || { echo "❌ 字节数不符: $SZ != $E,中止"; exit 1; } | |
| 10 | +echo " ✅ $SZ 字节,完整" | |
| 11 | + | |
| 12 | +echo "[1/3] 停止 Qwen3.6 (llm)... 中断从此刻开始" | |
| 13 | +date '+%H:%M:%S' | |
| 14 | +docker stop llm | |
| 15 | + | |
| 16 | +echo "[2/3] 启动 Qwen3.8 (llm38) 于 8000..." | |
| 17 | +docker rm -f llm38 2>/dev/null || true | |
| 18 | +mkdir -p /data/nvcache38 2>/dev/null || true | |
| 19 | +docker run -d \ | |
| 20 | + --name llm38 \ | |
| 21 | + --restart unless-stopped \ | |
| 22 | + --gpus all \ | |
| 23 | + -p 8000:8000 \ | |
| 24 | + -v /data/models:/jsxc \ | |
| 25 | + -v /data/nvcache38:/root/.nv \ | |
| 26 | + -e GGML_CUDA_DISABLE_GRAPHS=1 \ | |
| 27 | + -e CUDA_CACHE_MAXSIZE=2147483648 \ | |
| 28 | + --health-cmd "curl -fsS http://localhost:8000/health || exit 1" \ | |
| 29 | + --health-interval 30s --health-timeout 5s \ | |
| 30 | + --health-retries 3 --health-start-period 600s \ | |
| 31 | + llamacpp:b10481 \ | |
| 32 | + -m /jsxc/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf \ | |
| 33 | + --alias qwen3.8-27b \ | |
| 34 | + --host 0.0.0.0 --port 8000 \ | |
| 35 | + -ngl 99 -sm layer -fa on \ | |
| 36 | + --ctx-size 131072 --no-context-shift \ | |
| 37 | + --batch-size 2048 --ubatch-size 512 \ | |
| 38 | + --parallel 4 --jinja --seed 3407 \ | |
| 39 | + --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0 | |
| 40 | + | |
| 41 | +echo "[3/3] 等待就绪(首次含 JIT 编译,最多 10 分钟)..." | |
| 42 | +for i in $(seq 1 60); do | |
| 43 | + if curl -sf http://127.0.0.1:8000/health >/dev/null 2>&1; then | |
| 44 | + echo " ✅ 就绪于第 $((i*10)) 秒"; date '+%H:%M:%S'; exit 0 | |
| 45 | + fi | |
| 46 | + sleep 10 | |
| 47 | +done | |
| 48 | +echo "❌ 超时未就绪。查 docker logs llm38;回滚跑 restore.sh"; exit 1 | ... | ... |
deploy-scripts/download.sh
0 → 100755
| 1 | +++ a/deploy-scripts/download.sh | |
| 1 | +#!/bin/bash | |
| 2 | +MODEL_DIR=/data/models/Qwen/Qwen3.8-27B-GGUF | |
| 3 | +FILE=Qwen3.8-27B-Q5_K_M.gguf | |
| 4 | +URL="https://hf-mirror.com/bartowski/Qwen3.8-27B-GGUF/resolve/main/$FILE" | |
| 5 | +EXPECT=20752787040 | |
| 6 | +for i in $(seq 1 60); do | |
| 7 | + echo "[$(date '+%F %T')] --- attempt $i ---" | |
| 8 | + curl -sL -C - --retry 5 --retry-delay 5 --connect-timeout 30 -o "$MODEL_DIR/$FILE" "$URL" | |
| 9 | + SZ=$(stat -c %s "$MODEL_DIR/$FILE" 2>/dev/null || echo 0) | |
| 10 | + if [ "$SZ" = "$EXPECT" ]; then echo "[$(date '+%F %T')] DOWNLOAD_COMPLETE size=$SZ"; exit 0; fi | |
| 11 | + echo "[$(date '+%F %T')] incomplete: $SZ / $EXPECT" | |
| 12 | + sleep 5 | |
| 13 | +done | |
| 14 | +echo "DOWNLOAD_FAILED"; exit 1 | ... | ... |
deploy-scripts/ggufmeta.py
0 → 100644
| 1 | +++ a/deploy-scripts/ggufmeta.py | |
| 1 | +import struct,sys | |
| 2 | +f=open(sys.argv[1],'rb') | |
| 3 | +def rd(n): return f.read(n) | |
| 4 | +def u32(): return struct.unpack('<I',rd(4))[0] | |
| 5 | +def u64(): return struct.unpack('<Q',rd(8))[0] | |
| 6 | +assert rd(4)==b'GGUF' | |
| 7 | +ver=u32(); nt=u64(); nkv=u64() | |
| 8 | +def s(): | |
| 9 | + return rd(u64()).decode('utf-8','replace') | |
| 10 | +def val(t): | |
| 11 | + if t==0: return struct.unpack('<B',rd(1))[0] | |
| 12 | + if t==1: return struct.unpack('<b',rd(1))[0] | |
| 13 | + if t==2: return struct.unpack('<H',rd(2))[0] | |
| 14 | + if t==3: return struct.unpack('<h',rd(2))[0] | |
| 15 | + if t==4: return u32() | |
| 16 | + if t==5: return struct.unpack('<i',rd(4))[0] | |
| 17 | + if t==6: return struct.unpack('<f',rd(4))[0] | |
| 18 | + if t==7: return struct.unpack('<?',rd(1))[0] | |
| 19 | + if t==8: return s() | |
| 20 | + if t==9: | |
| 21 | + et=u32(); n=u64() | |
| 22 | + out=[s() if et==8 else val(et) for _ in range(n)] # 必须全部消费掉,否则后续偏移错位 | |
| 23 | + return out if n<=8 else f'<array n={n}, 首项 {out[0]!r}>' | |
| 24 | + if t==10: return u64() | |
| 25 | + if t==11: return struct.unpack('<q',rd(8))[0] | |
| 26 | + if t==12: return struct.unpack('<d',rd(8))[0] | |
| 27 | + raise ValueError(t) | |
| 28 | +print(f'gguf_version={ver} tensors={nt} kv={nkv}') | |
| 29 | +for _ in range(nkv): | |
| 30 | + k=s(); t=u32(); v=val(t) | |
| 31 | + if any(x in k for x in ('context_length','rope','yarn','attention','block_count','head_count','embedding_length','architecture','name','key_length','value_length','interval','linear','ssm','file_type')): | |
| 32 | + print(f'{k} = {v}') | ... | ... |
deploy-scripts/pull2.sh
0 → 100755
| 1 | +++ a/deploy-scripts/pull2.sh | |
| 1 | +#!/bin/bash | |
| 2 | +SRC=ghcr.nju.edu.cn/ggml-org/llama.cpp:server-cuda | |
| 3 | +echo "[$(date '+%F %T')] pulling $SRC" | |
| 4 | +if docker pull "$SRC"; then | |
| 5 | + docker tag "$SRC" ghcr.io/ggml-org/llama.cpp:server-cuda | |
| 6 | + echo "[$(date '+%F %T')] PULL_COMPLETE + retagged" | |
| 7 | +else | |
| 8 | + echo "[$(date '+%F %T')] PULL_FAILED" | |
| 9 | +fi | ... | ... |
deploy-scripts/restore.sh
0 → 100755
| 1 | +++ a/deploy-scripts/restore.sh | |
| 1 | +#!/bin/bash | |
| 2 | +# 一键回滚到 Qwen3.6-27B(b9570 镜像)。由 docker inspect llm 1:1 还原并校验。 | |
| 3 | +set -e | |
| 4 | +echo "[restore] 清理 llm38..." | |
| 5 | +docker rm -f llm38 2>/dev/null || true | |
| 6 | +echo "[restore] 清理残留 llm..." | |
| 7 | +docker rm -f llm 2>/dev/null || true | |
| 8 | +echo "[restore] 用回滚镜像重建 llm..." | |
| 9 | +docker run -d --name llm --restart unless-stopped --gpus all \ | |
| 10 | + -p 8000:8000 -v /data/models:/jsxc \ | |
| 11 | + llamacpp-rollback:b9570 \ | |
| 12 | + -m /jsxc/Qwen/Qwen3.6-27B-GGUF/Qwen_Qwen3.6-27B-IQ4_XS.gguf \ | |
| 13 | + --host 0.0.0.0 --port 8000 --seed 3407 \ | |
| 14 | + --temp 0.7 --top-p 0.95 --min-p 0.01 --top-k 40 \ | |
| 15 | + --jinja --batch-size 4096 --ubatch-size 1024 \ | |
| 16 | + --ctx-size 131072 --no-context-shift | |
| 17 | +echo "[restore] 等待健康..." | |
| 18 | +for i in $(seq 1 60); do | |
| 19 | + if curl -sf http://127.0.0.1:8000/health >/dev/null 2>&1; then echo "[restore] OK 已恢复"; exit 0; fi | |
| 20 | + sleep 10 | |
| 21 | +done | |
| 22 | +echo "[restore] 超时,请查 docker logs llm"; exit 1 | ... | ... |
h3-console/DESIGN.md
0 → 100644
| 1 | +++ a/h3-console/DESIGN.md | |
| 1 | +# H3 Console 需求 / 设计文档(草案,待确认) | |
| 2 | + | |
| 3 | +给 MiniMax-H3 视频生成加一个 Web 管理台:上传 → 生成 → 预览,外加模型/资产/任务/调度的统一视图。 | |
| 4 | +本文档先定范围和方案,**代码还没写**,确认后再动手。 | |
| 5 | + | |
| 6 | +--- | |
| 7 | + | |
| 8 | +## 一、现状(已有什么,不用重建) | |
| 9 | + | |
| 10 | +服务器 `/data/models/minimax-h3/` 已经有一套两层服务,本目录 console 是**加在它前面的第三层**,不是替换: | |
| 11 | + | |
| 12 | +``` | |
| 13 | +浏览器 | |
| 14 | + │ | |
| 15 | + ▼ | |
| 16 | +[新增] H3 Console(Web UI + 上传/资产/任务持久化) ← 本文档要建的东西 | |
| 17 | + │ 内部调用 | |
| 18 | + ▼ | |
| 19 | +gateway.py(已有,端口 8000) ← 四后端调度:选最闲的、按 fl2va/ref2va 分流、429 顶回去 | |
| 20 | + │ | |
| 21 | + ├─ api.py @ 8010 (GPU1, ref2va) | |
| 22 | + ├─ api.py @ 8011 (GPU2, ref2va) | |
| 23 | + ├─ api.py @ 8012 (GPU0, ref2va) | |
| 24 | + └─ api.py @ 8013 (GPU3, ref2va) | |
| 25 | +``` | |
| 26 | + | |
| 27 | +`gateway.py` 已经做了:调度(选队列最短的健康后端)、分区路由(有首末帧→fl2va,否则→ref2va)、 | |
| 28 | +限流(`H3_MAX_QUEUE`,满了直接 429)、健康聚合、任务查询/下载代理。**这部分不重做。** | |
| 29 | + | |
| 30 | +`gateway.py` **没有**:Web 界面、文件上传(现在参考图/首末帧只接受 URL,`_fetch_image` 内部用 | |
| 31 | +`urllib.request.urlopen` 拉取)、任务持久化(`JOBS` 是内存 dict,进程重启就丢,`API.md` 里写明了)、 | |
| 32 | +模型/资产的统一视图。这些是 console 要补的。 | |
| 33 | + | |
| 34 | +**一个关键约束,直接决定架构**:四个 `api.py` 后端**现在全部锁死在 `ref2va` 分区** | |
| 35 | +(`start-all.sh` 写死 `H3_PARTITION=ref2va` ×4),**没有一个在跑 `fl2va`**。 | |
| 36 | +所以你要的三个界面里,"上传首尾帧生成视频"这个功能**现在提交上去会 503** | |
| 37 | +(gateway.py: `没有就绪的 fl2va 实例`)。这个必须先决定怎么办,见下面「需要你拍板」。 | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 41 | +## 二、范围 | |
| 42 | + | |
| 43 | +### 三个生成界面 | |
| 44 | +1. **文生视频**:输入 prompt + 参数(分辨率/帧数/步数/turbo/seed)→ 提交 → 走 ref2va(不给参考图退回纯文生)。 | |
| 45 | +2. **首尾帧生视频**(FL2VA):上传首帧图(+可选尾帧)→ 提交 → 走 fl2va。 | |
| 46 | +3. **多参考图生视频**(Ref2VA):上传任意多张参考图 → 提交 → 走 ref2va。 | |
| 47 | + | |
| 48 | +### 统一管理视图 | |
| 49 | +- **任务列表**:状态(排队/运行/完成/失败)、进度、耗时、峰值显存、缩略图,点开内联播放 `<video>`。 | |
| 50 | +- **资产库**:上传过的参考图/首尾帧 + 生成出的视频,统一存一份、可复用(同一张图不用每次重传)。 | |
| 51 | +- **模型信息**:当前有哪些 partition(fl2va/ref2va)、Turbo LoRA 版本、各 GPU 上跑的是哪个 partition——只读展示,不是"切换模型"(原因见下)。 | |
| 52 | +- **调度视图**:四个后端的实时健康/队列深度(`gateway.py /health` 已经给了,UI 只是可视化)。 | |
| 53 | +- **参数预设**:把 API.md 里那几档实测能跑的参数(832×480×124f turbo、448×256×362f turbo…)存成可选预设,别让用户瞎填撞 OOM。 | |
| 54 | + | |
| 55 | +### 明确不做(YAGNI) | |
| 56 | +- 不做用户账号体系,暂不加鉴权(问题 3 已拍板,跑通了再加)。 | |
| 57 | +- 不改 `api.py` 本身(不做"进程内热切 partition"——DiT+LoRA 配套,热切等于重新构建 pipeline, | |
| 58 | + 和"杀进程重启"成本几乎一样,没必要在 `api.py` 里加这个复杂度,console 用进程级重启就够)。 | |
| 59 | +- 不做视频编辑/剪辑,只做生成+预览+下载。 | |
| 60 | + | |
| 61 | +--- | |
| 62 | + | |
| 63 | +## 四点五、弹性 partition 调度器(新增,替换原先"静态锁一张卡"的方案) | |
| 64 | + | |
| 65 | +### 为什么不能复用 gateway.py 现成的调度逻辑 | |
| 66 | +`gateway.py` 只会**在已经 ready 的后端里**按 partition 挑最闲的(`health_all()` 读每个后端 `/health` | |
| 67 | +上报的 `partition` 字段)。它**不会**主动让某个后端换 partition——这件事没人做。console 要做的就是这层。 | |
| 68 | + | |
| 69 | +### 谁来当"总闸" | |
| 70 | +console 新增一个**后台循环**(`asyncio` 任务,仅在部署到服务器、`H3_ENABLE_SUPERVISOR=1` 时启用; | |
| 71 | +本地 Mac 开发时关掉,因为这一层要直接管服务器上的进程,本地没有意义模拟): | |
| 72 | + | |
| 73 | +``` | |
| 74 | +每 10 秒一轮: | |
| 75 | + 1. 读 console 自己 sqlite 里「排队中 + 运行中」的任务,按 partition 分两堆计数: | |
| 76 | + fl2va_pending, ref2va_pending | |
| 77 | + (partition 是提交时 console 自己按规则算好存下的:给了首尾帧 URL → fl2va,否则 → ref2va, | |
| 78 | + 跟 gateway.py 里 `want = "fl2va" if ... else "ref2va"` 完全一致的判断,不重新发明) | |
| 79 | + 2. 读 gateway.py /health,按 partition 分两组: | |
| 80 | + fl2va_ready = [backend for backend in health if partition==fl2va] | |
| 81 | + ref2va_ready = [backend for backend in health if partition==ref2va] | |
| 82 | + 3. 扩容判断(一轮最多做一次 flip,避免抖动): | |
| 83 | + if fl2va_pending > len(fl2va_ready) 且 ref2va_ready 里存在 idle(queued==0 且 running==0)的后端: | |
| 84 | + 挑一个 idle 的 ref2va 后端,flip 成 fl2va | |
| 85 | + elif ref2va_pending > len(ref2va_ready) 且 fl2va_ready 里存在 idle 的后端: | |
| 86 | + 挑一个 idle 的 fl2va 后端,flip 成 ref2va | |
| 87 | + 4. 缩容加一道保险:一个后端要「连续 idle ≥ 30 秒」才允许被 flip 走, | |
| 88 | + 防止一个请求刚处理完、下一个还没提交上来这种间隙里被误判成"没需求"来回抖。 | |
| 89 | +``` | |
| 90 | + | |
| 91 | +**flip 一个后端的具体动作**(和我今天手动测试用的手法完全一样,复用现成脚本,不新写进程管理代码): | |
| 92 | +```bash | |
| 93 | +PID=$(ss -lptn "sport = :$PORT" | grep -o "pid=[0-9]*" | cut -d= -f2) | |
| 94 | +kill $PID # 只杀这一个端口对应的进程,不动其余三个 | |
| 95 | +setsid nohup ./api-run.sh $GPU $PORT $NEW_PARTITION > logs/api-$PORT.log 2>&1 < /dev/null & | |
| 96 | +# 等 /health 返回 model=ready 再纳入调度(disk offload 模式下实测约 13-20 秒,page cache 命中很快) | |
| 97 | +``` | |
| 98 | +**只允许 flip 一个 `queued==0 且 running==0` 的空闲后端**——绝不杀正在生成的任务(会丢掉几分钟到十几分钟的进度)。 | |
| 99 | + | |
| 100 | +### GPU/端口/初始 partition 映射(写死在 console 配置里,对应现有 `start-all.sh`) | |
| 101 | +``` | |
| 102 | +GPU1 → 8010 GPU2 → 8011 GPU0 → 8012 GPU3 → 8013 | |
| 103 | +``` | |
| 104 | +初始状态延用现状:4 张全 ref2va(`start-all.sh` 已经这么起了,console 启动时只做"接管监控", | |
| 105 | +不需要在 console 里重新实现"冷启动 4 个后端"这部分,那是 `start-all.sh` 的职责,边界清楚)。 | |
| 106 | + | |
| 107 | +### 这层为什么只能在服务器上跑,不能本地模拟 | |
| 108 | +kill+restart 操作的是服务器上真实的 GPU 进程。本地 Mac 没有这几张卡、没有这几个进程, | |
| 109 | +硬造一个"本地模拟版"没有意义(YAGNI)——三个生成界面、上传、任务列表这些跟 GPU 无关的部分 | |
| 110 | +本地对着远程 `gateway.py`(问题 4:内网直连)就能测完;弹性调度这部分只能等部署到服务器后现场验证。 | |
| 111 | + | |
| 112 | +--- | |
| 113 | + | |
| 114 | +## 三、四个问题的决定(2026-08-20 已拍板) | |
| 115 | + | |
| 116 | +1. **fl2va 怎么分卡:按需弹性,不是固定一张卡。** | |
| 117 | + 有几个 fl2va 任务排队,就用几张卡跑 fl2va;demand 降下去,卡要能flip 回 ref2va。 | |
| 118 | + 这比原先设计的方案 (c)(静态锁死一张卡)复杂得多,见下面「四点五」的具体设计。 | |
| 119 | +2. **端口**:console 挂 **8080**,走现成的 `gateway.wudaima.com` 隧道,不用改 FRP。 | |
| 120 | +3. **鉴权**:暂不加,整体跑通了再加。 | |
| 121 | +4. **本地开发连接方式**:内网直连 `http://192.168.110.67:8000`(选项 B)。 | |
| 122 | + | |
| 123 | +--- | |
| 124 | + | |
| 125 | +## 四、技术方案 | |
| 126 | + | |
| 127 | +### 后端:Python + FastAPI(复用 `api.py`/`gateway.py` 已经在用的框架,不引入新依赖) | |
| 128 | +- **`console/server.py`**: | |
| 129 | + - 反代 `gateway.py` 的 `/v1/videos*` 接口(生成/查询/下载),不重复实现调度逻辑。 | |
| 130 | + - 新增 `/v1/assets`:上传图片/视频,落盘到 `console/assets/`,返回一个可被 `api.py` 现有的 | |
| 131 | + `first_frame_url`/`reference_image_urls`(URL 字段)使用的本地 URL——**console 自己起一个静态文件服务**, | |
| 132 | + 上传后返回 `http://<console>/assets/<id>.jpg`,直接喂给 `_fetch_image()`(它本来就是通用 `urlopen`, | |
| 133 | + 不关心 URL 来自哪)。这样不用改 `api.py`/`gateway.py` 一行代码。 | |
| 134 | + - 新增 SQLite(stdlib `sqlite3`,不引入 ORM)持久化任务记录——**console 自己维护一份影子记录**, | |
| 135 | + 定期轮询 `gateway.py /v1/videos` 同步状态,解决"进程重启任务历史就丢"的问题。 | |
| 136 | + 这是新增持久层,不是替换 `api.py` 里的内存 `JOBS`(那个继续管真正的推理状态,改它风险更高)。 | |
| 137 | + - 只读接口:`/v1/models`(列 partition/LoRA 配置,读 `PARTITIONS` 那份静态信息或直接读 `api.py` 源码里的常量)、 | |
| 138 | + `/v1/backends`(转发 `gateway.py /health`)。 | |
| 139 | +- 鉴权:`Depends()` 校验 `Authorization: Bearer <token>`,token 走环境变量,见上面问题 3。 | |
| 140 | + | |
| 141 | +### 前端:单页面,原生 HTML/JS(不引入 React/Vue 构建链——三个表单 + 一个任务列表,够不上框架) | |
| 142 | +- `console/static/index.html`:顶部 tab 切三个生成界面,下面任务列表(轮询 `/v1/videos` 刷新, | |
| 143 | + `done` 状态内联 `<video src=".../file">`)。 | |
| 144 | +- 上传用原生 `<input type=file>` + `fetch(FormData)`,不需要额外库。 | |
| 145 | +- 这条完全符合"能一行搞定就别加依赖"——纯 fetch/DOM,没有构建步骤,`python -m http.server` 都能测。 | |
| 146 | + | |
| 147 | +### 目录结构(新增 `h3-console/` 工作负载,符合仓库既有约定) | |
| 148 | +``` | |
| 149 | +h3-console/ | |
| 150 | + DESIGN.md # 本文档 | |
| 151 | + README.md # 完成后补:跑在哪张卡/端口、怎么起停、怎么回滚(现在还没有,按约定完成后必须补) | |
| 152 | + server.py # FastAPI 主程序 | |
| 153 | + static/index.html # 单页前端 | |
| 154 | + assets/ # 上传的图 + 一份任务持久化 sqlite(.gitignore 掉,不进版本库) | |
| 155 | + run.sh # 本地/服务器通用启动脚本 | |
| 156 | +``` | |
| 157 | + | |
| 158 | +### 部署路径 | |
| 159 | +1. **本地 Mac 先跑通**:`server.py` 里 `H3_GATEWAY_URL` 指向 `http://192.168.110.67:8000`(问题 4 选 B), | |
| 160 | + 本地 `python server.py` 起在比如 `:8090`,浏览器 `localhost:8090` 测三个界面 + 任务列表。 | |
| 161 | + 这一步验证 UI/上传/轮询逻辑,不需要动服务器任何东西。 | |
| 162 | +2. **确认没问题后部署到服务器**:整个 `h3-console/` 目录 scp 到 `/data/models/minimax-h3/console/`, | |
| 163 | + `H3_GATEWAY_URL` 改成 `http://127.0.0.1:8000`(同机,不用走网络),起在 `8080`(问题 2)。 | |
| 164 | + FRP 那条 `gateway.wudaima.com` 隧道不用改,直接生效。 | |
| 165 | +3. 按仓库约定:部署完在 `h3-console/README.md` 补验收记录、在顶层 `CHANGELOG.md` 加一节。 | |
| 166 | + | |
| 167 | +--- | |
| 168 | + | |
| 169 | +## 五、开放风险 / 后续会撞到的坑(先写下来,免得到时候当新发现浪费时间) | |
| 170 | + | |
| 171 | +- **上传接口 = 新的攻击面**:任意文件上传要做基本校验(文件类型、大小上限、图片能否被 PIL 打开), | |
| 172 | + 这属于"输入校验在信任边界",不是可以偷懒的部分。 | |
| 173 | +- **console 自己的 SQLite 和 `api.py` 内存态可能短暂不一致**(轮询间隔内)——UI 上任务状态允许有几秒延迟, | |
| 174 | + 不追求强一致,够用。 | |
| 175 | +- **磁盘**:`/ 只剩 29G`(CLAUDE.md 记录),`/data` 也 83% 满(300G 剩)。资产库和生成视频都落 `/data`, | |
| 176 | + 上传接口要做磁盘余量检查/清理策略,不然堆满了会像 llm38_prev 那样变成"不敢删"的包袱。 | |
| 177 | +- **弹性调度的抖动风险**:如果 fl2va/ref2va 需求犬牙交错地来(比如每隔 20 秒各来一个), | |
| 178 | + 30 秒 idle 门槛可能不够、来回 flip,每次 flip 有 13-20 秒重载开销。先用这个简单阈值上线, | |
| 179 | + 真出现抖动了再加更复杂的判断(比如「最近 N 次 flip 频率过高就临时锁定」),不预先过度设计。 | |
| 180 | +- **flip 期间那个端口短暂离线**:kill 到新进程 ready 之间的 13-20 秒,`gateway.py /health` 会看到 | |
| 181 | + 它 `down`,此时 `gateway.py` 自己的调度会跳过它选别的后端——这是既有行为,不用 console 额外处理。 | |
| 182 | + | |
| 183 | +--- | |
| 184 | + | |
| 185 | +## 六、下一步 | |
| 186 | + | |
| 187 | +四个问题已确认(见第三节),开始写代码:先本地跑通三个生成界面 + 上传 + 任务列表(对着远程 | |
| 188 | +`gateway.py` 直连),你看过没问题后部署到服务器,弹性调度器在服务器上现场验证。 | ... | ... |
h3-console/README.md
0 → 100644
| 1 | +++ a/h3-console/README.md | |
| 1 | +# H3 Console | |
| 2 | + | |
| 3 | +给 `minimax-h3/` 的四后端 API(`gateway.py` + 4× `api.py`)加一层 Web 管理台: | |
| 4 | +左侧任务记录列表 + 右侧新建/详情,上传首尾帧/参考图 → 提交生成 → 点开任务看状态和结果。 | |
| 5 | +不重做调度,只加 gateway.py 没有的东西(Web UI、文件上传、任务持久化、按需 fl2va/ref2va 弹性分卡)。 | |
| 6 | + | |
| 7 | +需求/架构决策见 [`DESIGN.md`](DESIGN.md)——**先读那份**,尤其「四点五、弹性 partition 调度器」。 | |
| 8 | + | |
| 9 | +## 页面结构 | |
| 10 | + | |
| 11 | +左侧栏固定:顶部一条**新建任务**(选中态=当前在填新表单),下面是任务记录列表,按提交时间倒序, | |
| 12 | +点哪条就直接跳到那条的详情。右侧是新建表单(三个 tab:文生/首尾帧/参考图)或任务详情二选一。 | |
| 13 | + | |
| 14 | +URL 是真实可分享的路径,不是纯前端状态: | |
| 15 | +- `/newtask.html`(或裸 `/`)— 新建任务页 | |
| 16 | +- `/task.html/<任务id>` — 某个任务的详情页,刷新、直接打开、发链接给别人都能定位到同一条 | |
| 17 | + | |
| 18 | +这三条路径背后是**同一个** `static/index.html`,`server.py` 里 `/task.html/{tid}` 和 `/newtask.html` | |
| 19 | +两条路由原样返回这份文件,靠前端 JS 读 `location.pathname` 决定渲染哪个视图——不是三个页面文件, | |
| 20 | +省得同一份侧边栏/样式在多个 html 里重复维护。提交完新任务会自动跳到 `/task.html/<新id>`。 | |
| 21 | + | |
| 22 | +## 状态(2026-08-20) | |
| 23 | + | |
| 24 | +**已部署到服务器,端口 8080,跑在 `/data/models/minimax-h3/console/`,弹性调度器全链路验证通过。** | |
| 25 | + | |
| 26 | +本地冒烟测试:上传图片 → 拒绝非图片文件 → 提交生成任务 → 轮询到 `done` → 下载代理拿到合法 mp4 → | |
| 27 | +sqlite 影子记录核对无误 → 浏览器打开首页、三个 tab 切换、任务列表内联播放,均正常。 | |
| 28 | + | |
| 29 | +**服务器上用真实 GPU 做的弹性调度全链路验证**(这部分本地测不了,见 DESIGN.md「四点五」): | |
| 30 | +上传首帧图 → 提交 fl2va 任务(当时四卡全 ref2va)→ console 正确返回 `queued`(不是 503)→ | |
| 31 | +supervisor 30 秒后选中一个空闲 ref2va 后端 flip 成 fl2va → reconciler 在新后端 ready 后自动把 | |
| 32 | +排队的任务提交上去 → 任务 `running` → `done` → 通过 console 下载代理拿到合法 mp4。全程无人工干预。 | |
| 33 | + | |
| 34 | +### 部署过程中揪出的两个真实 bug(修完才让上面这条链路跑通) | |
| 35 | + | |
| 36 | +1. **`_flip_backend` 里的 PID 提取从来没匹配上过**:`ss -lptn` 的输出里 pid 不是独立的空格分隔 | |
| 37 | + token,是嵌在 `users:(("python",pid=NNNN,fd=14))` 里面的——`tok.startswith("pid=")` 永远不命中, | |
| 38 | + `kill` 从来没真的执行。后果是"flip"只是在已被占用的端口上起了一个新 uvicorn 进程,因端口冲突 | |
| 39 | + 立刻退出,旧进程全程没受影响——不是数据损坏,但功能是假的。改用 `re.findall(r"pid=(\d+)", ...)`。 | |
| 40 | +2. **弹性调度器不知道"已经在切换中",会对同一个需求连续 flip 好几张卡**:api.py 冷启动要 | |
| 41 | + 13-20 秒,跨了不止一个 10 秒的 tick。中间几轮 tick 看到目标分区的 ready 数还是 0, | |
| 42 | + 误判成需求没满足,接着把第二张、第三张卡也切了过去——一个 `pending=1` 的请求切了三张卡。 | |
| 43 | + 加了 `flipping: dict[port, (目标分区, 发起时间)]` 记账,效果数(ready + 正在切换中的) | |
| 44 | + 满足需求就不再触发新的 flip,60 秒还没起来算超时放弃。 | |
| 45 | +3. **`pkill -f "<pattern>"` 杀掉了自己所在的 SSH 会话**:这条不是 console 代码的 bug,是我操作 | |
| 46 | + 服务器时踩的坑——`start-all.sh` 注释里其实早就警告过这个("pgrep -f 会匹配到自己那条命令行"), | |
| 47 | + 这次是真的踩了一遍:`pkill -f "uvicorn server:app.*8080"` 这条命令的完整命令行本身就包含 | |
| 48 | + 被搜索的那串文本,`pkill -f` 把自己所在的 shell 一起杀了,SSH 连接直接断,命令链后半截全没执行。 | |
| 49 | + 之后全程改用 `ss -lptn "sport = :PORT" | grep -o "pid=..."` 按端口精确定位再 kill,不再用 `pkill -f`。 | |
| 50 | + | |
| 51 | +## 怎么起(本地开发) | |
| 52 | + | |
| 53 | +```bash | |
| 54 | +cd h3-console | |
| 55 | +./run.sh # 默认 H3_GATEWAY_URL=http://192.168.110.67:8000,端口 8090 | |
| 56 | +``` | |
| 57 | + | |
| 58 | +浏览器打开 `http://localhost:8090`。三个 tab(文生视频/首尾帧/参考图)+ 任务列表都在首页。 | |
| 59 | + | |
| 60 | +⚠️ **本地开发时上传功能不完整**:`H3_CONSOLE_PUBLIC_BASE` 没配的话 `/v1/assets` 会直接报错—— | |
| 61 | +因为上传图片的 URL 最终要被服务器上的 `api.py` worker 反向拉取,本地 Mac 猜不出自己对服务器 | |
| 62 | +可达的地址。要在本地测完整的首尾帧/参考图流程,配成 Mac 在局域网里的地址: | |
| 63 | + | |
| 64 | +```bash | |
| 65 | +H3_CONSOLE_PUBLIC_BASE=http://<你的 Mac 局域网 IP>:8090 ./run.sh | |
| 66 | +``` | |
| 67 | + | |
| 68 | +前提是服务器能反向连回你的 Mac(同一局域网通常没问题;接了会劫持路由的 VPN 就不行, | |
| 69 | +`HANDOFF.md` 里记过一次这个坑:`nc -z` 会给假阳性,出问题用 `ping`+`route -n get` 确认路径)。 | |
| 70 | +不配这个变量时,文生视频(不需要上传)那个 tab 可以正常测。 | |
| 71 | + | |
| 72 | +## 部署到服务器(已完成,2026-08-20) | |
| 73 | + | |
| 74 | +```bash | |
| 75 | +rsync -av --exclude='.venv' --exclude='__pycache__' --exclude='console.sqlite3' --exclude='assets/*' \ | |
| 76 | + h3-console/ admin@192.168.110.67:/data/models/minimax-h3/console/ | |
| 77 | +ssh admin@192.168.110.67 | |
| 78 | +cd /data/models/minimax-h3/console | |
| 79 | +H3_GATEWAY_URL=http://127.0.0.1:8000 \ | |
| 80 | +H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 \ | |
| 81 | +H3_ENABLE_SUPERVISOR=1 \ | |
| 82 | +PORT=8080 \ | |
| 83 | +setsid nohup ./run.sh > console.log 2>&1 < /dev/null & | |
| 84 | +``` | |
| 85 | + | |
| 86 | +对外访问用现成的 FRP 隧道 `gateway.wudaima.com`(配置已存在、一直空挂,见 DESIGN.md 问题2)—— | |
| 87 | +这一步还没验证公网访问,只验证过局域网内 `http://192.168.110.67:8080` 直连。 | |
| 88 | + | |
| 89 | +重启/重新部署时**不要用 `pkill -f`**(见上面「揪出的坑」第3条,会误杀自己的 SSH 会话),改用: | |
| 90 | +```bash | |
| 91 | +PID=$(ss -lptn "sport = :8080" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u) | |
| 92 | +[ -n "$PID" ] && kill $PID | |
| 93 | +``` | |
| 94 | + | |
| 95 | +**部署后验过**:`curl http://192.168.110.67:8080/v1/backends` 看到 4/4 ready;提交一个 fl2va | |
| 96 | +首尾帧任务,`console.log` 里能看到 `[supervisor] flip port=... ref2va -> fl2va`,30 秒后(空闲 | |
| 97 | +判定)触发一次、只触发一次,新后端 ready 后任务自动提交并跑完,下载代理拿到合法 mp4。 | |
| 98 | + | |
| 99 | +## 回滚 | |
| 100 | + | |
| 101 | +console 是独立进程,不碰 `gateway.py`/`api.py` 一行代码,出问题直接 `kill` console 进程即可, | |
| 102 | +四个 H3 后端和 gateway 不受影响(不像 `deploy-scripts/` 那套需要镜像级回滚)。 | |
| 103 | + | |
| 104 | +## 已知限制 | |
| 105 | + | |
| 106 | +- 鉴权:暂未加(`DESIGN.md` 问题3,跑通了再加)。部署到公网前必须补,否则和现有服务一样是 | |
| 107 | + 无鉴权可直调,风险更高——console 多了写文件(上传)的能力。 | |
| 108 | +- 任务持久化是 console 自己的 sqlite,和 `api.py` 内存态之间有轮询间隔的短暂不一致(几秒级), | |
| 109 | + 不追求强一致。 | |
| 110 | +- 磁盘:`/` 只剩 29G,`/data` 83% 满,上传接口目前只做单文件 20MB 上限校验,没做磁盘余量检查, | |
| 111 | + 真到磁盘紧张时会和其他生成任务一样直接写失败,没有额外保护——按 DESIGN.md「开放风险」记录的, | |
| 112 | + 真出问题再加。 | ... | ... |
h3-console/run.sh
0 → 100755
| 1 | +++ a/h3-console/run.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# 本地/服务器通用启动脚本。 | |
| 3 | +# 本地开发:./run.sh (H3_GATEWAY_URL 默认指向 192.168.110.67:8000) | |
| 4 | +# 服务器部署:H3_GATEWAY_URL=http://127.0.0.1:8000 H3_ENABLE_SUPERVISOR=1 PORT=8080 \ | |
| 5 | +# H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 ./run.sh | |
| 6 | +set -euo pipefail | |
| 7 | +cd "$(dirname "$0")" | |
| 8 | +[ -d .venv ] || python3 -m venv .venv | |
| 9 | +source .venv/bin/activate | |
| 10 | +pip install -q fastapi uvicorn python-multipart pillow httpx | |
| 11 | +export H3_GATEWAY_URL="${H3_GATEWAY_URL:-http://192.168.110.67:8000}" | |
| 12 | +export H3_CONSOLE_PUBLIC_BASE="${H3_CONSOLE_PUBLIC_BASE:-}" | |
| 13 | +PORT="${PORT:-8090}" | |
| 14 | +exec uvicorn server:app --host 0.0.0.0 --port "$PORT" | ... | ... |
h3-console/server.py
0 → 100644
| 1 | +++ a/h3-console/server.py | |
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""H3 Console:给 gateway.py 加一层 Web UI + 上传 + 任务持久化。 | |
| 3 | + | |
| 4 | +不重做调度——gateway.py 已经做了选后端/分区路由/限流,这里只反代它的 | |
| 5 | +/v1/videos* 接口,加三样它没有的东西:文件上传(返回可喂给 api.py 的 URL)、 | |
| 6 | +SQLite 任务影子记录(gateway.py 的 JOBS 在后端进程内存里,进程重启就丢)、 | |
| 7 | +静态前端。 | |
| 8 | + | |
| 9 | +弹性 partition 调度器(kill+restart 某个 GPU 后端切 fl2va/ref2va)只在 | |
| 10 | +H3_ENABLE_SUPERVISOR=1 时启用——这一层直接操作服务器上的真实进程,本地 | |
| 11 | +Mac 开发环境没有意义,见 DESIGN.md 「四点五」。 | |
| 12 | +""" | |
| 13 | +import os, re, sqlite3, time, uuid, json, asyncio, subprocess | |
| 14 | +from pathlib import Path | |
| 15 | +from typing import Optional | |
| 16 | + | |
| 17 | +import httpx | |
| 18 | +from fastapi import FastAPI, HTTPException, UploadFile, File, Request | |
| 19 | +from fastapi.responses import StreamingResponse, FileResponse | |
| 20 | +from fastapi.staticfiles import StaticFiles | |
| 21 | +from PIL import Image | |
| 22 | + | |
| 23 | +BASE = Path(__file__).resolve().parent | |
| 24 | +ASSETS = BASE / "assets" | |
| 25 | +ASSETS.mkdir(exist_ok=True) | |
| 26 | +DB_PATH = BASE / "console.sqlite3" | |
| 27 | + | |
| 28 | +GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000") | |
| 29 | +CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README | |
| 30 | +MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB,够单张参考图/首尾帧,防止占满磁盘 | |
| 31 | +ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" | |
| 32 | + | |
| 33 | +# GPU/端口映射,对应服务器上 start-all.sh 写死的分配。仅 supervisor 用得到。 | |
| 34 | +BACKEND_TOPOLOGY = [ | |
| 35 | + {"gpu": 1, "port": 8010}, | |
| 36 | + {"gpu": 2, "port": 8011}, | |
| 37 | + {"gpu": 0, "port": 8012}, | |
| 38 | + {"gpu": 3, "port": 8013}, | |
| 39 | +] | |
| 40 | +H3_ROOT = Path("/data/models/minimax-h3") # 仅服务器上存在,本地开发不会用到 | |
| 41 | + | |
| 42 | +app = FastAPI(title="H3 Console") | |
| 43 | +client = httpx.AsyncClient(timeout=30.0) | |
| 44 | + | |
| 45 | + | |
| 46 | +def db(): | |
| 47 | + con = sqlite3.connect(DB_PATH) | |
| 48 | + con.row_factory = sqlite3.Row | |
| 49 | + return con | |
| 50 | + | |
| 51 | + | |
| 52 | +def init_db(): | |
| 53 | + with db() as con: | |
| 54 | + con.execute(""" | |
| 55 | + CREATE TABLE IF NOT EXISTS jobs ( | |
| 56 | + id TEXT PRIMARY KEY, | |
| 57 | + gateway_id TEXT, | |
| 58 | + partition TEXT NOT NULL, | |
| 59 | + backend TEXT, | |
| 60 | + status TEXT NOT NULL, | |
| 61 | + req_json TEXT NOT NULL, | |
| 62 | + created_at REAL NOT NULL, | |
| 63 | + updated_at REAL NOT NULL, | |
| 64 | + last_known_json TEXT | |
| 65 | + ) | |
| 66 | + """) | |
| 67 | + | |
| 68 | + | |
| 69 | +init_db() | |
| 70 | + | |
| 71 | + | |
| 72 | +# ---------------- 资产上传 ---------------- | |
| 73 | +# api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用 | |
| 74 | +# urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」, | |
| 75 | +# 不改 api.py 一行代码。 | |
| 76 | +@app.post("/v1/assets") | |
| 77 | +async def upload_asset(file: UploadFile = File(...)): | |
| 78 | + ext = Path(file.filename or "").suffix.lower() | |
| 79 | + if ext not in (".jpg", ".jpeg", ".png", ".webp"): | |
| 80 | + raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)") | |
| 81 | + body = await file.read() | |
| 82 | + if len(body) > MAX_UPLOAD_BYTES: | |
| 83 | + raise HTTPException(413, f"文件超过 {MAX_UPLOAD_BYTES // 1024 // 1024}MB 上限") | |
| 84 | + aid = uuid.uuid4().hex[:16] | |
| 85 | + path = ASSETS / f"{aid}{ext}" | |
| 86 | + path.write_bytes(body) | |
| 87 | + try: | |
| 88 | + Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制 | |
| 89 | + except Exception: | |
| 90 | + path.unlink(missing_ok=True) | |
| 91 | + raise HTTPException(400, "文件不是有效图片") | |
| 92 | + return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}"} | |
| 93 | + | |
| 94 | + | |
| 95 | +def _public_base() -> str: | |
| 96 | + if CONSOLE_PUBLIC_BASE: | |
| 97 | + return CONSOLE_PUBLIC_BASE.rstrip("/") | |
| 98 | + # 本地开发时没配这个环境变量会直接报错提示——见 README「已知限制」: | |
| 99 | + # 上传图片的 URL 必须是 api.py 所在服务器能反向拉取到的地址,本地 Mac 默认猜不出这个地址。 | |
| 100 | + raise HTTPException( | |
| 101 | + 500, "未设置 H3_CONSOLE_PUBLIC_BASE:上传接口需要知道自己对外可访问的地址," | |
| 102 | + "这个地址会被服务器上的 api.py worker 反向拉取,见 README。") | |
| 103 | + | |
| 104 | + | |
| 105 | +app.mount("/assets", StaticFiles(directory=str(ASSETS)), name="assets") | |
| 106 | + | |
| 107 | + | |
| 108 | +# ---------------- 生成任务:console 自己是排队的主人,gateway.py 只是"当前有空的执行者" ---------------- | |
| 109 | +# 关键点:gateway.py 对没有就绪后端的 partition 直接 503(`没有就绪的 fl2va 实例`),不排队。 | |
| 110 | +# 如果 console 只是把这个 503 原样透传给用户,"有几个任务排队就用几张卡跑 fl2va"就无从谈起—— | |
| 111 | +# 请求在 503 那一步就死了,根本没机会被弹性调度器看到、更别说触发 flip。 | |
| 112 | +# 所以 console 自己维护身份(console 生成的 id)和排队状态:提交时先落 sqlite 记一条 status=queued, | |
| 113 | +# 尝试提交给 gateway;提交不上(503/429)就留在本地队列里,交给下面的 reconciler_loop 每隔几秒重试, | |
| 114 | +# 弹性调度器看到的 pending 计数正是这些「还没提交上去」的本地排队任务。 | |
| 115 | +def _partition_of(req: dict) -> str: | |
| 116 | + # 和 gateway.py 里 `want = "fl2va" if (...) else "ref2va"` 保持完全一致的判断, | |
| 117 | + # 不要各写一套、两边判断分岔的话 console 统计出来的 pending 计数就和 gateway 实际路由对不上。 | |
| 118 | + return "fl2va" if (req.get("first_frame_url") or req.get("last_frame_url")) else "ref2va" | |
| 119 | + | |
| 120 | + | |
| 121 | +def _row_view(row) -> dict: | |
| 122 | + """把一行 sqlite 记录还原成对外的任务视图,用 console 自己的 id,不暴露 gateway 内部 id。""" | |
| 123 | + if row["last_known_json"]: | |
| 124 | + view = json.loads(row["last_known_json"]) | |
| 125 | + else: | |
| 126 | + view = {} | |
| 127 | + view["id"] = row["id"] | |
| 128 | + view["status"] = row["status"] | |
| 129 | + view["partition"] = row["partition"] | |
| 130 | + view["backend"] = row["backend"] | |
| 131 | + view.setdefault("req", json.loads(row["req_json"])) | |
| 132 | + view["created_at"] = row["created_at"] | |
| 133 | + # gateway.py/api.py 原样带过来的 `poll`/`download` 字段用的是 gateway 内部 id(不是 console | |
| 134 | + # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 | |
| 135 | + # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 | |
| 136 | + if "poll" in view: | |
| 137 | + view["poll"] = f"/v1/videos/{row['id']}" | |
| 138 | + if "download" in view: | |
| 139 | + view["download"] = f"/v1/videos/{row['id']}/file" | |
| 140 | + return view | |
| 141 | + | |
| 142 | + | |
| 143 | +async def _try_submit(cid: str): | |
| 144 | + """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。""" | |
| 145 | + with db() as con: | |
| 146 | + row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone() | |
| 147 | + if row is None or row["gateway_id"] is not None: | |
| 148 | + return # 任务不存在,或已经提交过 | |
| 149 | + req = json.loads(row["req_json"]) | |
| 150 | + try: | |
| 151 | + resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req) | |
| 152 | + except httpx.HTTPError: | |
| 153 | + return # gateway 暂时连不上,下一轮再试 | |
| 154 | + now = time.time() | |
| 155 | + if resp.status_code < 400: | |
| 156 | + job = resp.json() | |
| 157 | + gateway_id = job["id"] | |
| 158 | + job["id"] = cid # 对外只暴露 console 自己的 id | |
| 159 | + with db() as con: | |
| 160 | + con.execute( | |
| 161 | + "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?", | |
| 162 | + (gateway_id, job.get("backend"), job.get("status", "queued"), | |
| 163 | + now, json.dumps(job, ensure_ascii=False), cid), | |
| 164 | + ) | |
| 165 | + elif resp.status_code in (503, 429): | |
| 166 | + return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试 | |
| 167 | + else: | |
| 168 | + # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败 | |
| 169 | + err_view = {"id": cid, "status": "failed", "error": resp.text, | |
| 170 | + "req": req, "partition": row["partition"], "created_at": row["created_at"]} | |
| 171 | + with db() as con: | |
| 172 | + con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", | |
| 173 | + (now, json.dumps(err_view, ensure_ascii=False), cid)) | |
| 174 | + | |
| 175 | + | |
| 176 | +async def _refresh_from_gateway(cid: str, gateway_id: str): | |
| 177 | + """已经提交给 gateway 的任务,去问一下最新状态,刷新本地记录。""" | |
| 178 | + try: | |
| 179 | + resp = await client.get(f"{GATEWAY_URL}/v1/videos/{gateway_id}") | |
| 180 | + except httpx.HTTPError: | |
| 181 | + return | |
| 182 | + if resp.status_code != 200: | |
| 183 | + return # 后端进程重启后 gateway 会 404(它自己没状态),sqlite 里的 last_known_json 就是最后已知状态 | |
| 184 | + job = resp.json() | |
| 185 | + job["id"] = cid | |
| 186 | + with db() as con: | |
| 187 | + con.execute( | |
| 188 | + "UPDATE jobs SET status=?, backend=?, updated_at=?, last_known_json=? WHERE id=?", | |
| 189 | + (job.get("status"), job.get("backend"), time.time(), json.dumps(job, ensure_ascii=False), cid), | |
| 190 | + ) | |
| 191 | + | |
| 192 | + | |
| 193 | +@app.post("/v1/videos", status_code=202) | |
| 194 | +async def create_video(req: dict): | |
| 195 | + partition = _partition_of(req) | |
| 196 | + cid = uuid.uuid4().hex[:12] | |
| 197 | + now = time.time() | |
| 198 | + synth = {"id": cid, "status": "queued", "req": req, "partition": partition, "created_at": now} | |
| 199 | + with db() as con: | |
| 200 | + con.execute( | |
| 201 | + "INSERT INTO jobs (id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) " | |
| 202 | + "VALUES (?,NULL,?,NULL,'queued',?,?,?,?)", | |
| 203 | + (cid, partition, json.dumps(req, ensure_ascii=False), now, now, json.dumps(synth, ensure_ascii=False)), | |
| 204 | + ) | |
| 205 | + await _try_submit(cid) # 尽力立即提交一次;提交不上就留给 reconciler_loop | |
| 206 | + with db() as con: | |
| 207 | + row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone() | |
| 208 | + return _row_view(row) | |
| 209 | + | |
| 210 | + | |
| 211 | +@app.get("/v1/videos/{jid}") | |
| 212 | +async def get_video(jid: str): | |
| 213 | + with db() as con: | |
| 214 | + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() | |
| 215 | + if row is None: | |
| 216 | + raise HTTPException(404, "无此任务") | |
| 217 | + if row["gateway_id"]: | |
| 218 | + await _refresh_from_gateway(jid, row["gateway_id"]) | |
| 219 | + else: | |
| 220 | + await _try_submit(jid) # 还在本地排队时,顺手趁轮询这一下也试着提交 | |
| 221 | + with db() as con: | |
| 222 | + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() | |
| 223 | + return _row_view(row) | |
| 224 | + | |
| 225 | + | |
| 226 | +@app.get("/v1/videos/{jid}/file") | |
| 227 | +async def download_video(jid: str, request: Request): | |
| 228 | + with db() as con: | |
| 229 | + row = con.execute("SELECT gateway_id, backend, status FROM jobs WHERE id=?", (jid,)).fetchone() | |
| 230 | + if row is None: | |
| 231 | + raise HTTPException(404, "无此任务") | |
| 232 | + if not row["gateway_id"] or not row["backend"]: | |
| 233 | + raise HTTPException(409, "任务还在本地排队,尚未分配到可用后端") | |
| 234 | + | |
| 235 | + # 不经过 gateway.py 转发:它自己那层 `StreamingResponse(urlopen(...))` 不认 Range 请求, | |
| 236 | + # 探测 Range 一律回 200 而不是 206,还不带 Content-Length/Accept-Ranges——`<video>` | |
| 237 | + # 标签播放前先探一次 Range 拿时长/可否拖动,探不到就一直卡在 readyState=0 播不出来 | |
| 238 | + # (下载能成功是因为普通 GET 不需要这些,只有网页里的播放器会被这个坑绊住)。 | |
| 239 | + # api.py 自己那层是 Starlette `FileResponse`,原生支持 Range;console 既然已经从 | |
| 240 | + # gateway 的响应里存了 `backend`(真实拥有这条任务的那个 GPU 后端),直接绕过 gateway | |
| 241 | + # 这层转发、把 Range 头透传给 api.py,把它的状态码/头原样带回去,问题在这一层就解决, | |
| 242 | + # 不用碰 gateway.py/api.py 一行代码。 | |
| 243 | + upstream_url = f"{row['backend']}/v1/videos/{row['gateway_id']}/file" | |
| 244 | + headers = {} | |
| 245 | + if "range" in request.headers: | |
| 246 | + headers["range"] = request.headers["range"] | |
| 247 | + req = client.build_request("GET", upstream_url, headers=headers) | |
| 248 | + upstream = await client.send(req, stream=True) | |
| 249 | + if upstream.status_code >= 400: | |
| 250 | + body = await upstream.aread() | |
| 251 | + await upstream.aclose() | |
| 252 | + raise HTTPException(upstream.status_code, body.decode(errors="replace")) | |
| 253 | + | |
| 254 | + async def stream(): | |
| 255 | + async for chunk in upstream.aiter_bytes(): | |
| 256 | + yield chunk | |
| 257 | + await upstream.aclose() | |
| 258 | + | |
| 259 | + passthrough = {k: v for k, v in upstream.headers.items() | |
| 260 | + if k.lower() in ("content-length", "content-range", "accept-ranges", "content-type")} | |
| 261 | + return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough) | |
| 262 | + | |
| 263 | + | |
| 264 | +@app.get("/v1/videos") | |
| 265 | +async def list_videos(limit: int = 50): | |
| 266 | + # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, | |
| 267 | + # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, | |
| 268 | + # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 | |
| 269 | + with db() as con: | |
| 270 | + rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() | |
| 271 | + return {"jobs": [_row_view(r) for r in rows]} | |
| 272 | + | |
| 273 | + | |
| 274 | +@app.get("/v1/backends") | |
| 275 | +async def backends(): | |
| 276 | + resp = await client.get(f"{GATEWAY_URL}/health") | |
| 277 | + resp.raise_for_status() | |
| 278 | + return resp.json() | |
| 279 | + | |
| 280 | + | |
| 281 | +@app.get("/v1/models") | |
| 282 | +def models(): | |
| 283 | + # 静态信息,对应服务器上 api.py 里的 PARTITIONS 常量。只读展示,不做运行时切换。 | |
| 284 | + return { | |
| 285 | + "partitions": { | |
| 286 | + "fl2va": {"dit": "minimax-h3-fl2va-nf4.safetensors", | |
| 287 | + "lora": "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors", | |
| 288 | + "desc": "首末帧条件生成"}, | |
| 289 | + "ref2va": {"dit": "minimax-h3-ref2va-nf4.safetensors", | |
| 290 | + "lora": "minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors", | |
| 291 | + "desc": "文生视频 / 多参考图生成"}, | |
| 292 | + }, | |
| 293 | + "presets": [ | |
| 294 | + {"label": "标准档 832×480×124f(5.17s)", "width": 832, "height": 480, "frames": 124}, | |
| 295 | + {"label": "长时长档 448×256×362f(15s)", "width": 448, "height": 256, "frames": 362}, | |
| 296 | + ], | |
| 297 | + } | |
| 298 | + | |
| 299 | + | |
| 300 | +# ---------------- 弹性 partition 调度器(仅服务器部署时启用) ---------------- | |
| 301 | +def _pending_counts() -> dict: | |
| 302 | + with db() as con: | |
| 303 | + rows = con.execute( | |
| 304 | + "SELECT partition, COUNT(*) c FROM jobs WHERE status IN ('queued','running') GROUP BY partition" | |
| 305 | + ).fetchall() | |
| 306 | + return {r["partition"]: r["c"] for r in rows} | |
| 307 | + | |
| 308 | + | |
| 309 | +async def _flip_backend(gpu: int, port: int, new_partition: str): | |
| 310 | + # 和今天手动测试时用的手法一致:按端口查 PID、杀、用现成的 api-run.sh 重启到新分区。 | |
| 311 | + # 不新写进程管理代码,复用已经在跑的脚本。 | |
| 312 | + # 注意:ss 的输出里 pid 不是独立的空格分隔 token,是嵌在 `users:(("python",pid=NNNN,fd=14))` | |
| 313 | + # 里面的——按空格 split 后拿 `tok.startswith("pid=")` 永远不会命中,得用正则从整段里抠出来 | |
| 314 | + # (手动测试时用的是 `grep -o "pid=[0-9]*"`,这里必须做等价的事,不能简化成 split)。 | |
| 315 | + pid_out = subprocess.run(["ss", "-lptn", f"sport = :{port}"], capture_output=True, text=True).stdout | |
| 316 | + for pid in re.findall(r"pid=(\d+)", pid_out): | |
| 317 | + subprocess.run(["kill", pid]) | |
| 318 | + subprocess.Popen( | |
| 319 | + ["setsid", "nohup", "./api-run.sh", str(gpu), str(port), new_partition], | |
| 320 | + cwd=str(H3_ROOT), stdout=open(H3_ROOT / "logs" / f"api-{port}.log", "a"), | |
| 321 | + stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True, | |
| 322 | + ) | |
| 323 | + | |
| 324 | + | |
| 325 | +async def supervisor_loop(): | |
| 326 | + idle_since: dict[int, float] = {} | |
| 327 | + # 一个 flip 从 kill 到新进程报 ready 要 13-20 秒(api.py 冷启动实测),远长于 10 秒的 tick 间隔。 | |
| 328 | + # 如果不记「已经在切换中」,接下来两三轮 tick 会看到 fl2va_ready 还是 0(新进程没起来), | |
| 329 | + # 误判成需求仍未满足,再多切几张卡——今天调试时真实撞到过:一个 pending=1 的请求把三张卡都切了 | |
| 330 | + # 过去(而且当时 kill 那步还有个 bug 没真的杀掉旧进程,新进程绑端口失败悄悄退出,见下)。 | |
| 331 | + flipping: dict[int, tuple[str, float]] = {} # port -> (目标 partition, 发起时间) | |
| 332 | + while True: | |
| 333 | + await asyncio.sleep(10) | |
| 334 | + try: | |
| 335 | + health = (await client.get(f"{GATEWAY_URL}/health")).json()["backends"] | |
| 336 | + pending = _pending_counts() | |
| 337 | + now = time.time() | |
| 338 | + | |
| 339 | + for port in list(flipping): | |
| 340 | + target, started = flipping[port] | |
| 341 | + b = next((x for x in health if x["backend"].endswith(f":{port}")), None) | |
| 342 | + if b and b.get("partition") == target and b.get("status") == "ok": | |
| 343 | + flipping.pop(port) # 切换完成,新分区已经在 health 里报 ready 了 | |
| 344 | + elif now - started > 60: | |
| 345 | + flipping.pop(port) # 60 秒还没起来,当作这次尝试失败,让下一轮按实际情况重新决定 | |
| 346 | + | |
| 347 | + fl2va_ready = [b for b in health if b.get("partition") == "fl2va" and b.get("status") == "ok"] | |
| 348 | + ref2va_ready = [b for b in health if b.get("partition") == "ref2va" and b.get("status") == "ok"] | |
| 349 | + fl2va_inflight = sum(1 for t, _ in flipping.values() if t == "fl2va") | |
| 350 | + ref2va_inflight = sum(1 for t, _ in flipping.values() if t == "ref2va") | |
| 351 | + | |
| 352 | + def idle_backends(group): | |
| 353 | + out = [] | |
| 354 | + for b in group: | |
| 355 | + port = int(b["backend"].rsplit(":", 1)[1]) | |
| 356 | + if port in flipping: | |
| 357 | + continue # 已经在切换中,不能再选它 | |
| 358 | + if b.get("queued", 0) == 0 and b.get("running", 0) == 0: | |
| 359 | + idle_since.setdefault(port, now) | |
| 360 | + if now - idle_since[port] >= 30: | |
| 361 | + out.append(port) | |
| 362 | + else: | |
| 363 | + idle_since.pop(port, None) | |
| 364 | + return out | |
| 365 | + | |
| 366 | + fl2va_pending = pending.get("fl2va", 0) | |
| 367 | + ref2va_pending = pending.get("ref2va", 0) | |
| 368 | + print(f"[supervisor] tick: pending={pending} " | |
| 369 | + f"fl2va={len(fl2va_ready)}(+{fl2va_inflight} 切换中) " | |
| 370 | + f"ref2va={len(ref2va_ready)}(+{ref2va_inflight} 切换中)", flush=True) | |
| 371 | + | |
| 372 | + if fl2va_pending > len(fl2va_ready) + fl2va_inflight: | |
| 373 | + candidates = idle_backends(ref2va_ready) | |
| 374 | + if candidates: | |
| 375 | + port = candidates[0] | |
| 376 | + gpu = next(t["gpu"] for t in BACKEND_TOPOLOGY if t["port"] == port) | |
| 377 | + print(f"[supervisor] flip port={port} gpu={gpu} ref2va -> fl2va", flush=True) | |
| 378 | + flipping[port] = ("fl2va", now) | |
| 379 | + idle_since.pop(port, None) | |
| 380 | + await _flip_backend(gpu, port, "fl2va") | |
| 381 | + elif ref2va_pending > len(ref2va_ready) + ref2va_inflight: | |
| 382 | + candidates = idle_backends(fl2va_ready) | |
| 383 | + if candidates: | |
| 384 | + port = candidates[0] | |
| 385 | + gpu = next(t["gpu"] for t in BACKEND_TOPOLOGY if t["port"] == port) | |
| 386 | + print(f"[supervisor] flip port={port} gpu={gpu} fl2va -> ref2va", flush=True) | |
| 387 | + flipping[port] = ("ref2va", now) | |
| 388 | + idle_since.pop(port, None) | |
| 389 | + await _flip_backend(gpu, port, "ref2va") | |
| 390 | + except Exception as e: | |
| 391 | + print(f"[supervisor] tick failed: {e}", flush=True) | |
| 392 | + | |
| 393 | + | |
| 394 | +# ---------------- 常驻对账循环(本地/服务器都跑,跟 GPU 无关) ---------------- | |
| 395 | +# 两件事:本地排队中的任务(gateway_id 还是空)隔几秒重试提交一次;已提交的任务刷新最新状态。 | |
| 396 | +# 这是任务列表能实时更新、以及「没后端时先排队而不是直接报错」这两件事真正生效的地方。 | |
| 397 | +async def reconciler_loop(): | |
| 398 | + while True: | |
| 399 | + await asyncio.sleep(5) | |
| 400 | + try: | |
| 401 | + with db() as con: | |
| 402 | + pending = con.execute( | |
| 403 | + "SELECT id FROM jobs WHERE gateway_id IS NULL AND status='queued'" | |
| 404 | + ).fetchall() | |
| 405 | + in_flight = con.execute( | |
| 406 | + "SELECT id, gateway_id FROM jobs WHERE gateway_id IS NOT NULL AND status IN ('queued','running')" | |
| 407 | + ).fetchall() | |
| 408 | + for r in pending: | |
| 409 | + await _try_submit(r["id"]) | |
| 410 | + for r in in_flight: | |
| 411 | + await _refresh_from_gateway(r["id"], r["gateway_id"]) | |
| 412 | + except Exception as e: | |
| 413 | + print(f"[reconciler] tick failed: {e}", flush=True) | |
| 414 | + | |
| 415 | + | |
| 416 | +@app.on_event("startup") | |
| 417 | +async def _startup(): | |
| 418 | + asyncio.create_task(reconciler_loop()) | |
| 419 | + if ENABLE_SUPERVISOR: | |
| 420 | + asyncio.create_task(supervisor_loop()) | |
| 421 | + print("[console] elastic partition supervisor enabled", flush=True) | |
| 422 | + else: | |
| 423 | + print("[console] supervisor disabled (H3_ENABLE_SUPERVISOR!=1) — fl2va/ref2va allocation is whatever the backends currently report", flush=True) | |
| 424 | + | |
| 425 | + | |
| 426 | +# 任务详情/新建各自有一个真实的 URL,方便复制粘贴分享、刷新不丢状态——都是同一个单页应用 | |
| 427 | +# (static/index.html 自己那份 JS 读 location.pathname 决定显示哪个视图),这两条路由只是让 | |
| 428 | +# 浏览器地址栏里出现符合预期的路径,不是两个不同的页面文件。必须放在 StaticFiles 挂载之前, | |
| 429 | +# 不然 `/task.html/xxx` 会被当成一个不存在的静态文件直接 404。 | |
| 430 | +@app.get("/task.html/{tid}") | |
| 431 | +async def task_page(tid: str): | |
| 432 | + return FileResponse(str(BASE / "static" / "index.html")) | |
| 433 | + | |
| 434 | + | |
| 435 | +@app.get("/newtask.html") | |
| 436 | +async def newtask_page(): | |
| 437 | + return FileResponse(str(BASE / "static" / "index.html")) | |
| 438 | + | |
| 439 | + | |
| 440 | +app.mount("/", StaticFiles(directory=str(BASE / "static"), html=True), name="static") | ... | ... |
h3-console/static/index.html
0 → 100644
| 1 | +++ a/h3-console/static/index.html | |
| 1 | +<!doctype html> | |
| 2 | +<html lang="zh"> | |
| 3 | +<head> | |
| 4 | +<meta charset="utf-8"> | |
| 5 | +<title>H3 Console</title> | |
| 6 | +<style> | |
| 7 | + :root { color-scheme: light dark; } | |
| 8 | + * { box-sizing: border-box; } | |
| 9 | + body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 0; display: flex; height: 100vh; overflow: hidden; } | |
| 10 | + | |
| 11 | + /* ---- 左侧任务记录 ---- */ | |
| 12 | + #sidebar { width: 300px; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } | |
| 13 | + #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } | |
| 14 | + #sidebar-header h1 { font-size: 16px; margin: 0; } | |
| 15 | + #job-list { flex: 1; overflow-y: auto; } | |
| 16 | + .job-row { padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } | |
| 17 | + .job-row:hover { background: rgba(128,128,128,.08); } | |
| 18 | + .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } | |
| 19 | + .job-row.new-row { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #4a7; } | |
| 20 | + .job-row .jr-top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; } | |
| 21 | + .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 22 | + .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; } | |
| 23 | + #backends { font-size: 11px; opacity: .75; padding: 10px 16px; border-top: 1px solid #ddd; white-space: pre-wrap; } | |
| 24 | + | |
| 25 | + /* ---- 右侧主区 ---- */ | |
| 26 | + #main { flex: 1; overflow-y: auto; padding: 24px 32px; } | |
| 27 | + .tabs { display: flex; gap: 8px; margin: 0 0 16px; } | |
| 28 | + .tabs button { padding: 8px 16px; border: 1px solid #888; background: transparent; border-radius: 6px; cursor: pointer; } | |
| 29 | + .tabs button.active { background: #4a7; color: white; border-color: #4a7; } | |
| 30 | + fieldset { border: 1px solid #ccc; border-radius: 8px; margin-bottom: 16px; max-width: 640px; } | |
| 31 | + label { display: block; margin: 8px 0 4px; font-size: 13px; opacity: .8; } | |
| 32 | + input, select, textarea { padding: 6px; width: 100%; box-sizing: border-box; font-family: inherit; font-size: 14px; } | |
| 33 | + textarea { resize: vertical; min-height: 200px; } | |
| 34 | + .row { display: flex; gap: 12px; } | |
| 35 | + .row > div { flex: 1; } | |
| 36 | + button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; } | |
| 37 | + button.submit:disabled { opacity: .5; cursor: default; } | |
| 38 | + .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; } | |
| 39 | + .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; } | |
| 40 | + .thumbrow img { height: 60px; border-radius: 4px; } | |
| 41 | + .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } | |
| 42 | + .err { color: #c33; font-size: 13px; margin-top: 8px; } | |
| 43 | + | |
| 44 | + /* ---- 任务详情 ---- */ | |
| 45 | + #detail { max-width: 640px; } | |
| 46 | + #detail .back { font-size: 13px; cursor: pointer; opacity: .7; margin-bottom: 12px; display: inline-block; } | |
| 47 | + #detail .back:hover { opacity: 1; } | |
| 48 | + #detail h2 { font-size: 16px; margin: 0 0 4px; } | |
| 49 | + #detail .dl { display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px; font-size: 13px; margin: 16px 0; } | |
| 50 | + #detail .dl dt { opacity: .6; } | |
| 51 | + #detail .dl dd { margin: 0; word-break: break-all; } | |
| 52 | + #detail video { max-width: 100%; margin-top: 12px; border-radius: 6px; } | |
| 53 | + #detail .download { display: inline-block; margin-top: 10px; font-size: 13px; } | |
| 54 | +</style> | |
| 55 | +</head> | |
| 56 | +<body> | |
| 57 | + | |
| 58 | +<div id="sidebar"> | |
| 59 | + <div id="sidebar-header"> | |
| 60 | + <h1>H3 Console</h1> | |
| 61 | + </div> | |
| 62 | + <div id="job-list"></div> | |
| 63 | + <div id="backends"></div> | |
| 64 | +</div> | |
| 65 | + | |
| 66 | +<div id="main"> | |
| 67 | + | |
| 68 | +<div id="create-view"> | |
| 69 | + <div class="tabs"> | |
| 70 | + <button class="active" data-tab="text">文生视频</button> | |
| 71 | + <button data-tab="fl2va">首尾帧生视频</button> | |
| 72 | + <button data-tab="ref2va">多参考图生视频</button> | |
| 73 | + </div> | |
| 74 | + | |
| 75 | + <!-- 文生视频 --> | |
| 76 | + <fieldset id="tab-text"> | |
| 77 | + <legend>文生视频</legend> | |
| 78 | + <label>Prompt</label> | |
| 79 | + <textarea id="text-prompt" rows="10" placeholder="清晨的中式庭院,一位女子坐在长廊上喝茶..."></textarea> | |
| 80 | + <div class="row"> | |
| 81 | + <div><label>分辨率</label><select id="text-res" class="res-select"></select></div> | |
| 82 | + <div><label>时长</label><select id="text-dur" class="dur-select"></select></div> | |
| 83 | + </div> | |
| 84 | + <div class="hint" id="text-hint"></div> | |
| 85 | + <div class="row"> | |
| 86 | + <div><label>Turbo</label><select id="text-turbo" class="turbo-select" data-steps="text-steps"><option value="true" selected>开(4步,快)</option><option value="false">关(多步,慢但更稳)</option></select></div> | |
| 87 | + <div><label>步数</label><select id="text-steps"></select></div> | |
| 88 | + <div><label>Seed</label><select id="text-seed"> | |
| 89 | + <option value="0" selected>0</option> | |
| 90 | + <option value="42">42</option> | |
| 91 | + <option value="123">123</option> | |
| 92 | + <option value="2024">2024</option> | |
| 93 | + <option value="random">随机</option> | |
| 94 | + </select></div> | |
| 95 | + </div> | |
| 96 | + <button class="submit" onclick="submitJob('text')">提交</button> | |
| 97 | + <div class="err" id="text-err"></div> | |
| 98 | + </fieldset> | |
| 99 | + | |
| 100 | + <!-- 首尾帧 --> | |
| 101 | + <fieldset id="tab-fl2va" style="display:none"> | |
| 102 | + <legend>首尾帧生视频(FL2VA)</legend> | |
| 103 | + <label>Prompt</label> | |
| 104 | + <textarea id="fl2va-prompt" rows="10" placeholder="..."></textarea> | |
| 105 | + <label>首帧图</label> | |
| 106 | + <input id="fl2va-first" type="file" accept="image/*" onchange="uploadTo('fl2va-first')"> | |
| 107 | + <label>尾帧图(可选)</label> | |
| 108 | + <input id="fl2va-last" type="file" accept="image/*" onchange="uploadTo('fl2va-last')"> | |
| 109 | + <div class="thumbrow" id="fl2va-thumbs"></div> | |
| 110 | + <div class="row"> | |
| 111 | + <div><label>分辨率</label><select id="fl2va-res" class="res-select"></select></div> | |
| 112 | + <div><label>时长</label><select id="fl2va-dur" class="dur-select"></select></div> | |
| 113 | + </div> | |
| 114 | + <div class="hint" id="fl2va-hint"></div> | |
| 115 | + <div class="row"> | |
| 116 | + <div><label>Turbo</label><select id="fl2va-turbo" class="turbo-select" data-steps="fl2va-steps"><option value="true" selected>开(4步,快)</option><option value="false">关(多步,慢但更稳)</option></select></div> | |
| 117 | + <div><label>步数</label><select id="fl2va-steps"></select></div> | |
| 118 | + <div><label>Seed</label><select id="fl2va-seed"> | |
| 119 | + <option value="0" selected>0</option> | |
| 120 | + <option value="42">42</option> | |
| 121 | + <option value="123">123</option> | |
| 122 | + <option value="2024">2024</option> | |
| 123 | + <option value="random">随机</option> | |
| 124 | + </select></div> | |
| 125 | + </div> | |
| 126 | + <button class="submit" onclick="submitJob('fl2va')">提交</button> | |
| 127 | + <div class="err" id="fl2va-err"></div> | |
| 128 | + </fieldset> | |
| 129 | + | |
| 130 | + <!-- 参考图 --> | |
| 131 | + <fieldset id="tab-ref2va" style="display:none"> | |
| 132 | + <legend>多参考图生视频(Ref2VA)</legend> | |
| 133 | + <label>Prompt</label> | |
| 134 | + <textarea id="ref2va-prompt" rows="10" placeholder="..."></textarea> | |
| 135 | + <label>参考图(可多选,没有张数上限,越多序列越长越慢)</label> | |
| 136 | + <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti()"> | |
| 137 | + <div class="thumbrow" id="ref2va-thumbs"></div> | |
| 138 | + <div class="row"> | |
| 139 | + <div><label>分辨率</label><select id="ref2va-res" class="res-select"></select></div> | |
| 140 | + <div><label>时长</label><select id="ref2va-dur" class="dur-select"></select></div> | |
| 141 | + </div> | |
| 142 | + <div class="hint" id="ref2va-hint"></div> | |
| 143 | + <div class="row"> | |
| 144 | + <div><label>Turbo</label><select id="ref2va-turbo" class="turbo-select" data-steps="ref2va-steps"><option value="true" selected>开(4步,快)</option><option value="false">关(多步,慢但更稳)</option></select></div> | |
| 145 | + <div><label>步数</label><select id="ref2va-steps"></select></div> | |
| 146 | + <div><label>Seed</label><select id="ref2va-seed"> | |
| 147 | + <option value="0" selected>0</option> | |
| 148 | + <option value="42">42</option> | |
| 149 | + <option value="123">123</option> | |
| 150 | + <option value="2024">2024</option> | |
| 151 | + <option value="random">随机</option> | |
| 152 | + </select></div> | |
| 153 | + </div> | |
| 154 | + <button class="submit" onclick="submitJob('ref2va')">提交</button> | |
| 155 | + <div class="err" id="ref2va-err"></div> | |
| 156 | + </fieldset> | |
| 157 | +</div> | |
| 158 | + | |
| 159 | +<div id="detail" style="display:none"></div> | |
| 160 | + | |
| 161 | +</div> | |
| 162 | + | |
| 163 | +<script> | |
| 164 | +const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; | |
| 165 | +let jobsCache = []; | |
| 166 | + | |
| 167 | +// URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, | |
| 168 | +// server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, | |
| 169 | +// 这里只是从地址栏读出当前该显示哪个视图,方便复制链接分享、刷新页面不丢状态。 | |
| 170 | +function tidFromPath() { | |
| 171 | + const m = location.pathname.match(/^\/task\.html\/(.+)$/); | |
| 172 | + return m ? decodeURIComponent(m[1]) : null; | |
| 173 | +} | |
| 174 | +let selectedId = tidFromPath(); | |
| 175 | + | |
| 176 | +document.querySelectorAll('.tabs button').forEach(btn => btn.onclick = () => { | |
| 177 | + document.querySelectorAll('.tabs button').forEach(b => b.classList.remove('active')); | |
| 178 | + btn.classList.add('active'); | |
| 179 | + ['text','fl2va','ref2va'].forEach(t => document.getElementById('tab-'+t).style.display = (t===btn.dataset.tab ? 'block' : 'none')); | |
| 180 | +}); | |
| 181 | + | |
| 182 | +function showCreate(pushUrl = true) { | |
| 183 | + selectedId = null; | |
| 184 | + if (pushUrl) history.pushState(null, '', '/newtask.html'); | |
| 185 | + document.getElementById('create-view').style.display = 'block'; | |
| 186 | + document.getElementById('detail').style.display = 'none'; | |
| 187 | + renderSidebar(); | |
| 188 | +} | |
| 189 | + | |
| 190 | +// 只支持两档分辨率、三档时长——都是实测过边界的组合,不开放自由填数字撞 OOM。 | |
| 191 | +// 见 h3-console/README.md 和顶层 CHANGELOG「15 秒时长边界实测」: | |
| 192 | +// 832×480 只在 5s 档稳定,15s 会 OOM;448×256 是 15s 档实测最稳的一档(峰值 12.0G)。 | |
| 193 | +// 10s(243帧) 没专门测过,帧数按 17n+5 公式换算,pipeline 会自动 snap,能提交但结果未验证。 | |
| 194 | +const RESOLUTIONS = [ | |
| 195 | + { value: '832x480', width: 832, height: 480, label: '832×480(标准档)' }, | |
| 196 | + { value: '448x256', width: 448, height: 256, label: '448×256(长时长档)' }, | |
| 197 | +]; | |
| 198 | +const DURATIONS = [ | |
| 199 | + { value: '124', frames: 124, label: '5s(124帧,实测)' }, | |
| 200 | + { value: '243', frames: 243, label: '10s(243帧,未实测)' }, | |
| 201 | + { value: '362', frames: 362, label: '15s(362帧,实测)' }, | |
| 202 | +]; | |
| 203 | +// Turbo LoRA 是 4 步蒸馏的,步数选项跟着 Turbo 开关联动——这两个字段本来就是耦合的, | |
| 204 | +// 分开选很容易选出"关 Turbo 却只给4步"这种没意义的组合。 | |
| 205 | +const STEPS_BY_TURBO = { | |
| 206 | + true: [{ value: '4', label: '4步(推荐,Turbo LoRA 就是4步蒸馏的)' }, | |
| 207 | + { value: '8', label: '8步(协议允许上限,未专门测过)' }], | |
| 208 | + false: [{ value: '20', label: '20步' }, | |
| 209 | + { value: '50', label: '50步(实测全质量,约9.6倍耗时)' }], | |
| 210 | +}; | |
| 211 | + | |
| 212 | +// 跟 api.py 的 _rows()/ROWS_LIMIT 保持一致——前端只是把后端已经在强制的这条线提前到选择阶段, | |
| 213 | +// 不是另一套独立规则,两边算法对不上就会出现"前端放行、后端拒绝"或反过来,所以数字改动两边一起改。 | |
| 214 | +function rows(width, height, frames) { | |
| 215 | + const latentFrames = Math.floor((Math.max(frames, 5) - 5) / 17) * 5 + 2; | |
| 216 | + return latentFrames * width * height / 1024; | |
| 217 | +} | |
| 218 | +const ROWS_LIMIT = 20000; | |
| 219 | + | |
| 220 | +function fillSelect(select, options) { | |
| 221 | + select.innerHTML = options.map(o => `<option value="${o.value}">${o.label}</option>`).join(''); | |
| 222 | +} | |
| 223 | + | |
| 224 | +function syncDurOptions(prefix) { | |
| 225 | + const resSelect = document.getElementById(prefix + '-res'); | |
| 226 | + const durSelect = document.getElementById(prefix + '-dur'); | |
| 227 | + const hint = document.getElementById(prefix + '-hint'); | |
| 228 | + if (!resSelect || !durSelect) return; | |
| 229 | + const res = RESOLUTIONS.find(r => r.value === resSelect.value); | |
| 230 | + const prevValue = durSelect.value; | |
| 231 | + const allowed = DURATIONS.filter(d => rows(res.width, res.height, d.frames) <= ROWS_LIMIT); | |
| 232 | + fillSelect(durSelect, allowed); | |
| 233 | + durSelect.value = allowed.some(d => d.value === prevValue) ? prevValue : allowed[0].value; | |
| 234 | + if (hint) { | |
| 235 | + const dropped = DURATIONS.filter(d => !allowed.includes(d)).map(d => d.label); | |
| 236 | + hint.textContent = dropped.length | |
| 237 | + ? `⚠ ${res.label} 分辨率下,${dropped.join('、')} 会撞显存上限,已不可选。` | |
| 238 | + : `${res.label} 分辨率下,${DURATIONS.map(d => d.label).join('/')} 均可选。`; | |
| 239 | + } | |
| 240 | +} | |
| 241 | + | |
| 242 | +function initParamSelects(prefix) { | |
| 243 | + const res = document.getElementById(prefix + '-res'); | |
| 244 | + const dur = document.getElementById(prefix + '-dur'); | |
| 245 | + if (res) fillSelect(res, RESOLUTIONS); | |
| 246 | + if (dur) fillSelect(dur, DURATIONS); | |
| 247 | + if (res) { syncDurOptions(prefix); res.onchange = () => syncDurOptions(prefix); } | |
| 248 | + | |
| 249 | + const turboSelect = document.getElementById(prefix + '-turbo'); | |
| 250 | + if (turboSelect) { | |
| 251 | + const stepsSelect = document.getElementById(turboSelect.dataset.steps); | |
| 252 | + const syncSteps = () => fillSelect(stepsSelect, STEPS_BY_TURBO[turboSelect.value]); | |
| 253 | + turboSelect.onchange = syncSteps; | |
| 254 | + syncSteps(); | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +['text', 'fl2va', 'ref2va'].forEach(initParamSelects); | |
| 259 | + | |
| 260 | +async function uploadFile(file) { | |
| 261 | + const fd = new FormData(); fd.append('file', file); | |
| 262 | + const r = await fetch('/v1/assets', { method: 'POST', body: fd }); | |
| 263 | + if (!r.ok) throw new Error(await r.text()); | |
| 264 | + return await r.json(); | |
| 265 | +} | |
| 266 | + | |
| 267 | +async function uploadTo(inputId) { | |
| 268 | + const el = document.getElementById(inputId); | |
| 269 | + if (!el.files[0]) return; | |
| 270 | + try { | |
| 271 | + const res = await uploadFile(el.files[0]); | |
| 272 | + uploaded[inputId] = res.url; | |
| 273 | + const thumbs = document.getElementById('fl2va-thumbs'); | |
| 274 | + const img = document.createElement('img'); img.src = res.url; thumbs.appendChild(img); | |
| 275 | + } catch (e) { document.getElementById('fl2va-err').textContent = '上传失败: ' + e.message; } | |
| 276 | +} | |
| 277 | + | |
| 278 | +async function uploadMulti() { | |
| 279 | + const el = document.getElementById('ref2va-refs'); | |
| 280 | + const thumbs = document.getElementById('ref2va-thumbs'); | |
| 281 | + for (const f of el.files) { | |
| 282 | + try { | |
| 283 | + const res = await uploadFile(f); | |
| 284 | + uploaded['ref2va-refs'].push(res.url); | |
| 285 | + const img = document.createElement('img'); img.src = res.url; thumbs.appendChild(img); | |
| 286 | + } catch (e) { document.getElementById('ref2va-err').textContent = '上传失败: ' + e.message; } | |
| 287 | + } | |
| 288 | +} | |
| 289 | + | |
| 290 | +// 分辨率/时长/Turbo/步数/Seed 五个字段,三个 tab 结构完全一样,抽出来一份读取逻辑。 | |
| 291 | +function readParamSelects(prefix) { | |
| 292 | + const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value); | |
| 293 | + const dur = DURATIONS.find(d => d.value === document.getElementById(prefix + '-dur').value); | |
| 294 | + const seedRaw = document.getElementById(prefix + '-seed').value; | |
| 295 | + return { | |
| 296 | + width: res.width, height: res.height, frames: dur.frames, | |
| 297 | + turbo: document.getElementById(prefix + '-turbo').value === 'true', | |
| 298 | + steps: +document.getElementById(prefix + '-steps').value, | |
| 299 | + seed: seedRaw === 'random' ? Math.floor(Math.random() * 2**31) : +seedRaw, | |
| 300 | + }; | |
| 301 | +} | |
| 302 | + | |
| 303 | +async function submitJob(kind) { | |
| 304 | + const errBox = document.getElementById(kind + '-err'); errBox.textContent = ''; | |
| 305 | + let body = {}; | |
| 306 | + if (kind === 'text') { | |
| 307 | + body = { prompt: document.getElementById('text-prompt').value, ...readParamSelects('text') }; | |
| 308 | + } else if (kind === 'fl2va') { | |
| 309 | + if (!uploaded['fl2va-first']) { errBox.textContent = '请先上传首帧图'; return; } | |
| 310 | + body = { | |
| 311 | + prompt: document.getElementById('fl2va-prompt').value, | |
| 312 | + first_frame_url: uploaded['fl2va-first'], | |
| 313 | + last_frame_url: uploaded['fl2va-last'] || undefined, | |
| 314 | + ...readParamSelects('fl2va'), | |
| 315 | + }; | |
| 316 | + } else if (kind === 'ref2va') { | |
| 317 | + if (uploaded['ref2va-refs'].length === 0) { errBox.textContent = '请先上传至少一张参考图'; return; } | |
| 318 | + body = { | |
| 319 | + prompt: document.getElementById('ref2va-prompt').value, | |
| 320 | + reference_image_urls: uploaded['ref2va-refs'], | |
| 321 | + ...readParamSelects('ref2va'), | |
| 322 | + }; | |
| 323 | + } | |
| 324 | + if (!body.prompt) { errBox.textContent = '请填写 prompt'; return; } | |
| 325 | + try { | |
| 326 | + const r = await fetch('/v1/videos', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); | |
| 327 | + if (!r.ok) throw new Error(await r.text()); | |
| 328 | + const job = await r.json(); | |
| 329 | + await refreshJobs(); | |
| 330 | + selectJob(job.id); // 提交完直接跳到这条任务的详情,能立刻看到排队/运行状态 | |
| 331 | + } catch (e) { errBox.textContent = '提交失败: ' + e.message; } | |
| 332 | +} | |
| 333 | + | |
| 334 | +// 重跑:不直接提交,而是把这条任务当初的 req 灌回"新建任务"表单(对应的 tab、参数、 | |
| 335 | +// 已上传的图片 URL 都原样带过去),跳到 /newtask.html,用户看一眼确认无误后自己点提交—— | |
| 336 | +// 跟正常新建任务走的是同一条路径、同一次人工确认,不是绕过表单直接后台再发一次请求。 | |
| 337 | +function setThumbs(elId, urls) { | |
| 338 | + const box = document.getElementById(elId); box.innerHTML = ''; | |
| 339 | + urls.forEach(u => { if (!u) return; const img = document.createElement('img'); img.src = u; box.appendChild(img); }); | |
| 340 | +} | |
| 341 | + | |
| 342 | +function prefillCreate(req) { | |
| 343 | + const kind = (req.first_frame_url || req.last_frame_url) ? 'fl2va' | |
| 344 | + : (req.reference_image_urls || []).length ? 'ref2va' : 'text'; | |
| 345 | + | |
| 346 | + document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === kind)); | |
| 347 | + ['text', 'fl2va', 'ref2va'].forEach(t => document.getElementById('tab-' + t).style.display = (t === kind ? 'block' : 'none')); | |
| 348 | + | |
| 349 | + document.getElementById(kind + '-prompt').value = req.prompt || ''; | |
| 350 | + | |
| 351 | + const resOpt = RESOLUTIONS.find(o => o.width === req.width && o.height === req.height); | |
| 352 | + if (resOpt) document.getElementById(kind + '-res').value = resOpt.value; | |
| 353 | + syncDurOptions(kind); // 按刚设好的分辨率重新过滤时长选项,再决定原来的档位还能不能选 | |
| 354 | + const durSel = document.getElementById(kind + '-dur'); | |
| 355 | + const durOpt = DURATIONS.find(o => o.frames === req.frames); | |
| 356 | + if (durOpt && [...durSel.options].some(o => o.value === durOpt.value)) durSel.value = durOpt.value; | |
| 357 | + | |
| 358 | + const turboSel = document.getElementById(kind + '-turbo'); | |
| 359 | + turboSel.value = req.turbo === false ? 'false' : 'true'; | |
| 360 | + turboSel.onchange(); // 联动刷新步数选项,必须在设置步数值之前调用 | |
| 361 | + const stepsSel = document.getElementById(kind + '-steps'); | |
| 362 | + if (req.steps != null && [...stepsSel.options].some(o => o.value === String(req.steps))) { | |
| 363 | + stepsSel.value = String(req.steps); | |
| 364 | + } | |
| 365 | + const seedSel = document.getElementById(kind + '-seed'); | |
| 366 | + if (req.seed != null) { | |
| 367 | + if (![...seedSel.options].some(o => o.value === String(req.seed))) { | |
| 368 | + const opt = document.createElement('option'); | |
| 369 | + opt.value = String(req.seed); opt.textContent = req.seed + '(原任务的 seed)'; | |
| 370 | + seedSel.insertBefore(opt, seedSel.firstChild); | |
| 371 | + } | |
| 372 | + seedSel.value = String(req.seed); | |
| 373 | + } | |
| 374 | + | |
| 375 | + if (kind === 'fl2va') { | |
| 376 | + uploaded['fl2va-first'] = req.first_frame_url || null; | |
| 377 | + uploaded['fl2va-last'] = req.last_frame_url || null; | |
| 378 | + setThumbs('fl2va-thumbs', [req.first_frame_url, req.last_frame_url]); | |
| 379 | + } else if (kind === 'ref2va') { | |
| 380 | + uploaded['ref2va-refs'] = (req.reference_image_urls || []).slice(); | |
| 381 | + setThumbs('ref2va-thumbs', uploaded['ref2va-refs']); | |
| 382 | + } | |
| 383 | + | |
| 384 | + showCreate(); | |
| 385 | +} | |
| 386 | + | |
| 387 | +async function rerunJob(id) { | |
| 388 | + try { | |
| 389 | + const r = await fetch(`/v1/videos/${id}`); | |
| 390 | + if (!r.ok) throw new Error(await r.text()); | |
| 391 | + const req = (await r.json()).req || {}; | |
| 392 | + prefillCreate(req); | |
| 393 | + } catch (e) { alert('重跑失败: ' + e.message); } | |
| 394 | +} | |
| 395 | + | |
| 396 | +function statusLabel(s) { | |
| 397 | + const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败' }; | |
| 398 | + return `<span class="status-${s}">${map[s] || s}</span>`; | |
| 399 | +} | |
| 400 | + | |
| 401 | +function timeAgo(ts) { | |
| 402 | + if (!ts) return ''; | |
| 403 | + const s = Math.max(0, Math.round(Date.now()/1000 - ts)); | |
| 404 | + if (s < 60) return s + '秒前'; | |
| 405 | + if (s < 3600) return Math.round(s/60) + '分钟前'; | |
| 406 | + return Math.round(s/3600) + '小时前'; | |
| 407 | +} | |
| 408 | + | |
| 409 | +function fmtTime(ts) { | |
| 410 | + if (!ts) return '—'; | |
| 411 | + return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false }); | |
| 412 | +} | |
| 413 | + | |
| 414 | +function renderSidebar() { | |
| 415 | + const box = document.getElementById('job-list'); box.innerHTML = ''; | |
| 416 | + | |
| 417 | + const newRow = document.createElement('div'); | |
| 418 | + newRow.className = 'job-row new-row' + (selectedId === null ? ' selected' : ''); | |
| 419 | + newRow.onclick = () => showCreate(); | |
| 420 | + newRow.innerHTML = `<span>+</span><span>新建任务</span>`; | |
| 421 | + box.appendChild(newRow); | |
| 422 | + | |
| 423 | + jobsCache.forEach(j => { | |
| 424 | + const row = document.createElement('div'); | |
| 425 | + row.className = 'job-row' + (j.id === selectedId ? ' selected' : ''); | |
| 426 | + row.onclick = () => selectJob(j.id); | |
| 427 | + const prompt = j.req ? (j.req.prompt || '(无 prompt)') : ''; | |
| 428 | + row.innerHTML = ` | |
| 429 | + <div class="jr-top"><span>${j.id}</span>${statusLabel(j.status)}</div> | |
| 430 | + <div class="jr-prompt" title="${prompt}">${prompt}</div> | |
| 431 | + <div class="jr-time">${timeAgo(j.created_at)}</div>`; | |
| 432 | + box.appendChild(row); | |
| 433 | + }); | |
| 434 | +} | |
| 435 | + | |
| 436 | +async function selectJob(id, pushUrl = true) { | |
| 437 | + selectedId = id; | |
| 438 | + if (pushUrl) history.pushState(null, '', '/task.html/' + encodeURIComponent(id)); | |
| 439 | + document.getElementById('create-view').style.display = 'none'; | |
| 440 | + document.getElementById('detail').style.display = 'block'; | |
| 441 | + renderSidebar(); | |
| 442 | + await refreshDetail(); | |
| 443 | +} | |
| 444 | + | |
| 445 | +// 浏览器前进/后退要同步这个状态,不然 URL 变了但页面没跟着变 | |
| 446 | +window.addEventListener('popstate', () => { | |
| 447 | + const tid = tidFromPath(); | |
| 448 | + if (tid) selectJob(tid, false); else showCreate(false); | |
| 449 | +}); | |
| 450 | + | |
| 451 | +async function refreshDetail() { | |
| 452 | + if (!selectedId) return; | |
| 453 | + const box = document.getElementById('detail'); | |
| 454 | + try { | |
| 455 | + const r = await fetch(`/v1/videos/${selectedId}`); | |
| 456 | + if (!r.ok) { box.innerHTML = `<div class="back" onclick="showCreate()">← 新建任务</div><div class="err">找不到这个任务</div>`; return; } | |
| 457 | + const j = await r.json(); | |
| 458 | + const req = j.req || {}; | |
| 459 | + | |
| 460 | + let resultHtml = ''; | |
| 461 | + if (j.status === 'done') { | |
| 462 | + resultHtml = `<video controls src="/v1/videos/${j.id}/file"></video> | |
| 463 | + <a class="download" href="/v1/videos/${j.id}/file" download>下载 mp4${j.size_bytes ? ' ('+(j.size_bytes/1024/1024).toFixed(1)+'MB)' : ''}</a>`; | |
| 464 | + } else if (j.status === 'failed') { | |
| 465 | + resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'<')}</div>`; | |
| 466 | + } else if (j.status === 'running') { | |
| 467 | + resultHtml = `<div>已运行 ${j.elapsed_seconds || 0} 秒(共 ${j.total_steps || req.steps || '?'} 步,当前第 ${j.step || 0} 步)</div>`; | |
| 468 | + } else { | |
| 469 | + resultHtml = `<div>排队中,等待可用的 ${j.partition || ''} 后端…</div>`; | |
| 470 | + } | |
| 471 | + | |
| 472 | + let refThumbs = ''; | |
| 473 | + const imgs = []; | |
| 474 | + if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]); | |
| 475 | + if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]); | |
| 476 | + (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u])); | |
| 477 | + if (imgs.length) refThumbs = `<div class="thumbrow">${imgs.map(([label,u]) => `<img src="${u}" title="${label}">`).join('')}</div>`; | |
| 478 | + | |
| 479 | + box.innerHTML = ` | |
| 480 | + <div class="back" onclick="showCreate()">← 新建任务</div> | |
| 481 | + <h2>${j.id}</h2> | |
| 482 | + <div>${statusLabel(j.status)} <button class="submit" style="padding:4px 12px;margin-left:8px" onclick="rerunJob('${j.id}')">重跑</button></div> | |
| 483 | + <dl class="dl"> | |
| 484 | + <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'<')}</dd> | |
| 485 | + <dt>分区</dt><dd>${j.partition || '—'}</dd> | |
| 486 | + <dt>分辨率/帧数</dt><dd>${req.width || '?'}×${req.height || '?'} × ${req.frames || '?'}帧</dd> | |
| 487 | + <dt>Turbo/步数</dt><dd>${req.turbo === false ? '关' : '开'} / ${req.steps ?? '?'} 步,seed=${req.seed ?? 0}</dd> | |
| 488 | + <dt>后端</dt><dd>${j.backend || '(尚未分配)'}</dd> | |
| 489 | + <dt>提交时间</dt><dd>${fmtTime(j.created_at)}</dd> | |
| 490 | + <dt>开始时间</dt><dd>${fmtTime(j.started_at)}</dd> | |
| 491 | + <dt>完成时间</dt><dd>${fmtTime(j.finished_at)}</dd> | |
| 492 | + <dt>峰值显存</dt><dd>${j.peak_vram_gb ? j.peak_vram_gb + ' GB' : '—'}</dd> | |
| 493 | + </dl> | |
| 494 | + ${refThumbs} | |
| 495 | + ${resultHtml} | |
| 496 | + `; | |
| 497 | + } catch (e) { /* 暂时连不上,保留当前显示,下一轮再试 */ } | |
| 498 | +} | |
| 499 | + | |
| 500 | +async function refreshJobs() { | |
| 501 | + try { | |
| 502 | + const r = await fetch('/v1/videos?limit=50'); const d = await r.json(); | |
| 503 | + jobsCache = d.jobs || []; | |
| 504 | + renderSidebar(); | |
| 505 | + // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段 | |
| 506 | + // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉—— | |
| 507 | + // 播放到一半被这个 5 秒轮询打断、进度条弹回 0,这就是"下载和播放好像有问题"的真正原因, | |
| 508 | + // 不是文件本身坏了(文件本身一直是好的,见 README「已实测排查过的坑」)。 | |
| 509 | + const cached = jobsCache.find(j => j.id === selectedId); | |
| 510 | + if (selectedId && (!cached || cached.status === 'queued' || cached.status === 'running')) { | |
| 511 | + refreshDetail(); | |
| 512 | + } | |
| 513 | + } catch (e) { /* 后端暂时连不上,静默跳过这一轮,下次轮询再试 */ } | |
| 514 | +} | |
| 515 | + | |
| 516 | +async function refreshBackends() { | |
| 517 | + try { | |
| 518 | + const r = await fetch('/v1/backends'); const d = await r.json(); | |
| 519 | + document.getElementById('backends').textContent = | |
| 520 | + `后端: ${d.ready}/${d.total} ready · 排队 ${d.queued} · 运行中 ${d.running}\n` + | |
| 521 | + d.backends.map(b => `${b.backend.replace('http://127.0.0.1:','')} gpu${b.gpu} [${b.partition||'?'}] ${b.status}`).join('\n'); | |
| 522 | + } catch (e) {} | |
| 523 | +} | |
| 524 | + | |
| 525 | +// 刷新页面(或直接打开一条分享出去的 /task.html/<id> 链接)时,按当前 URL 决定初始视图 | |
| 526 | +if (selectedId) { | |
| 527 | + document.getElementById('create-view').style.display = 'none'; | |
| 528 | + document.getElementById('detail').style.display = 'block'; | |
| 529 | + refreshDetail(); | |
| 530 | +} | |
| 531 | + | |
| 532 | +refreshJobs(); refreshBackends(); | |
| 533 | +setInterval(refreshJobs, 5000); | |
| 534 | +setInterval(refreshBackends, 8000); | |
| 535 | +</script> | |
| 536 | +</body> | |
| 537 | +</html> | ... | ... |
llm-setup.md
0 → 100644
| 1 | +++ a/llm-setup.md | |
| 1 | +# 本地大模型服务配置总览 | |
| 2 | + | |
| 3 | +> 最后更新:2026-08-18 | |
| 4 | +> 机器角色:本地 GPU 服务器,运行 Qwen/DeepSeek 大模型,通过 FRP 反向代理对外提供服务 | |
| 5 | +> 硬件:4 × RTX 2080 Ti 22G(**Turing / compute capability 7.5**)· 125G 内存 · Ubuntu 22.04 · 驱动 570.153.02 / CUDA 12.8 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 〇、当前运行状态(2026-08-18) | |
| 10 | + | |
| 11 | +| 服务 | 端口 | 容器/进程 | 模型 | | |
| 12 | +|------|------|-----------|------| | |
| 13 | +| **大模型 API** | 8000 | 容器 `llm38` | **Qwen3.8-27B-Q5_K_M**(llama.cpp b10481) | | |
| 14 | +| 语音转写 | 8002 | 容器 `whisper` | whisper-server | | |
| 15 | +| 文本嵌入 | 7590 | gunicorn (PID 1616) | M3E-base | | |
| 16 | +| 文生图 | 7860(仅本机) | venv (PID 1919) | SD WebUI Forge | | |
| 17 | + | |
| 18 | +**实测指标**:生成 23.4 tok/s(150K 上下文时 16.7)· 显存 **57.3 GB / 88 GB**(四卡)· **单请求上下文 262144(256K)** | |
| 19 | + | |
| 20 | +> 容器名从 `llm` 改成了 `llm38`。旧的 `llm` 容器保留为 `Exited (0)` 状态作回滚锚点,**不要删**。 | |
| 21 | + | |
| 22 | +--- | |
| 23 | + | |
| 24 | +## 一、整体架构 | |
| 25 | + | |
| 26 | +``` | |
| 27 | +外部用户 | |
| 28 | + | | |
| 29 | + v | |
| 30 | +qwen.wudaima.com (FRP 公网域名,无鉴权 ⚠) | |
| 31 | + | | |
| 32 | + v | |
| 33 | +FRP 服务端 60.205.214.169:7009 | |
| 34 | + | | |
| 35 | + v | |
| 36 | +本机 127.0.0.1:8000 | |
| 37 | + | | |
| 38 | + +-- [当前] llama.cpp b10481 (容器 llm38) | |
| 39 | + | +-- Qwen3.8-27B-Q5_K_M.gguf | |
| 40 | + | | |
| 41 | + +-- [历史] llama.cpp b9570 (容器 llm) <- 回滚用,已停 | |
| 42 | + | +-- Qwen3.6-27B-IQ4_XS.gguf | |
| 43 | + | | |
| 44 | + +-- [历史] vLLM 引擎 (compose.72b/32b.yaml) | |
| 45 | + +-- Qwen2.5-72B-Instruct-GPTQ-Int4 | |
| 46 | + +-- DeepSeek-R1-Distill-Qwen-32B-AWQ | |
| 47 | +``` | |
| 48 | + | |
| 49 | +**注意**:8000 端口同一时间只能跑一个模型服务。 | |
| 50 | + | |
| 51 | +--- | |
| 52 | + | |
| 53 | +## 二、★ 这台机器的硬件约束(2080 Ti = Turing SM 7.5)★ | |
| 54 | + | |
| 55 | +> 这一节是 2026-08-18 部署 Qwen3.8 时踩出来的,**新增模型前必读**,能省掉大量试错。 | |
| 56 | + | |
| 57 | +### 2.1 绝对做不了的事 | |
| 58 | + | |
| 59 | +| 事项 | 原因 | | |
| 60 | +|------|------| | |
| 61 | +| **跑 FP8 量化模型** | FP8 原生需要 Ada(8.9)/Hopper(9.0);vLLM 的 `fp8_marlin` 回退内核也要求 Ampere(8.0+)。SM 7.5 两条路都够不着,无法绕过 | | |
| 62 | +| **用 bf16** | Turing 不支持。现有 vLLM compose 全都写 `--dtype=half` 就是这个原因 | | |
| 63 | +| **用 `server-cuda13` 镜像** | CUDA 13 运行时要求驱动 580+,本机是 570.153.02,会报 `forward compatibility was attempted on non supported HW` | | |
| 64 | +| **MTP 推测解码提速** | `--spec-type draft-mtp` 在 sm_75 上对 hybrid(SSM+attention) 模型不激活(llama.cpp issue #24670)。网上「+33~39% 速度」的说法在本机吃不到 | | |
| 65 | +| **1M 上下文** | 需约 92.8 GB,超过物理 85.9 GB。**注意 262144 是 `n_ctx_train`,超过它才需要 YaRN 外插**——`--ctx-size 524288 --parallel 2` 是每槽 262144,属原生范围,不涉及 YaRN(旧版本此行写「524288 (YaRN 2x)」,把总池和单请求上限混淆了,已订正) | | |
| 66 | + | |
| 67 | +### 2.2 能做但有坑 | |
| 68 | + | |
| 69 | +| 事项 | 说明 | | |
| 70 | +|------|------| | |
| 71 | +| **llama.cpp build 下限** | 跑 Qwen3.8/qwen35 架构**必须 ≥ b10450**(DeltaNet CUDA 修复 commit `ece963f4`)。b10419~b10450 的症状是**加载正常、显存正常、tok/s 正常、输出全是乱码** —— 极具迷惑性,务必先查 build 号 | | |
| 72 | +| **CUDA graphs** | PR #25749 给 Volta/Turing 开了 CUDA graphs,但 issue #25835 报告显存逐轮增长直至 OOM。**本机部署已设 `GGML_CUDA_DISABLE_GRAPHS=1` 规避**,上游修复后可 A/B 试着去掉 | | |
| 73 | +| **JIT 编译** | SM 7.5 在官方镜像里只有 PTX 没有 SASS,冷启动要驱动现编 1~2 分钟。已挂 `/data/nvcache38:/root/.nv` 持久化缓存 | | |
| 74 | +| **prefill 偏慢** | GDN chunked kernel 未合入(PR #26001),走逐 token 路径。**不要拿 3090/4090 的 pp 数字做验收标准** | | |
| 75 | +| **KV cache 量化** | Turing 上 cc < Ada 时 `--cache-type-k/v` 会触发额外 f16 转换开销,收益常被吃掉。显存够就保持 f16 | | |
| 76 | +| **`-sm` 切分模式** | 四卡无 P2P(`nvidia-smi topo -p2p r` 全 CNS)且 GPU0/1 与 GPU2/3 跨 NUMA。**必须用 `-sm layer`**,`row`/`tensor` 会走主机内存性能灾难 | | |
| 77 | +| **默认 tensor-split 陷阱** | llama.cpp 按**总显存**而非**剩余显存**均分。GPU0 有 embedding+SD 占约 840 MiB、GPU3 有 whisper 占 3258 MiB,起第二个大模型时必须显式 `-ts` 或 `--device`,否则 GPU3 先 OOM | | |
| 78 | + | |
| 79 | +### 2.3 能跑 Turing 的证据(供以后怀疑时复查) | |
| 80 | + | |
| 81 | +- `ggml/src/ggml-cuda/CMakeLists.txt` 中 `list(APPEND CMAKE_CUDA_ARCHITECTURES 75-virtual 80-virtual 86-real)` 在所有 CUDA 版本判断**之外**,无条件执行 | |
| 82 | +- `.devops/cuda.Dockerfile` 用 `CUDA_DOCKER_ARCH=default`,即走上面的默认列表 | |
| 83 | +- 容器日志 `ARCHS = 500,610,700,750,800,860,890,900,1200`,750 在内 | |
| 84 | +- Gated DeltaNet 算子 `gated_delta_net.cu` 全文无任何架构判断,纯 fp32 + warp reduce | |
| 85 | +- Flash Attention 的 `ggml_cuda_fattn_mma_get_config_turing()` 前四条就是 `CONFIG_CASE(256,256,...)`,正是 Qwen3.8 的 head_dim=256 | |
| 86 | + | |
| 87 | +--- | |
| 88 | + | |
| 89 | +## 三、★ 网络约束(下载模型/镜像前必读)★ | |
| 90 | + | |
| 91 | +| 目标 | 状态 | 替代 | | |
| 92 | +|------|------|------| | |
| 93 | +| `huggingface.co` | ❌ 不通(超时) | ✅ `hf-mirror.com` | | |
| 94 | +| `ghcr.io` blob 层 | ❌ 拉取空转重试 | ✅ `ghcr.nju.edu.cn`(南大代理,拉完 `docker tag` 回原名) | | |
| 95 | +| Docker Hub | ✅ 已配镜像加速 | `/etc/docker/daemon.json` 中 registry-mirrors | | |
| 96 | + | |
| 97 | +**出口带宽约 5.9 MB/s 封顶**(实测:换域名并发也突破不了,是本机出口上限而非对端限速)。20GB 模型约需 60 分钟,**并行下载不会更快,只会互相抢**。下载务必用断点续传(`curl -C -`)+ `setsid nohup` 脱离 SSH 会话。 | |
| 98 | + | |
| 99 | +--- | |
| 100 | + | |
| 101 | +## 四、模型文件位置 | |
| 102 | + | |
| 103 | +所有模型在 `/data/models/`(1.8T 盘,当前用 1.4T): | |
| 104 | + | |
| 105 | +| 模型 | 路径 | 说明 | | |
| 106 | +|------|------|------| | |
| 107 | +| **Qwen3.8-27B-GGUF** | `/data/models/Qwen/Qwen3.8-27B-GGUF/` | **当前在跑**。Q5_K_M 19.32GiB + mmproj-f16 885MB | | |
| 108 | +| Qwen3.6-27B-GGUF | `/data/models/Qwen/Qwen3.6-27B-GGUF/` | 回滚目标,IQ4_XS | | |
| 109 | +| Qwen3-Coder-Next-GGUF | `/data/models/Qwen/Qwen3-Coder-Next-GGUF/` | | | |
| 110 | +| Qwen3-30B-A3B-Instruct-2507 | `/data/models/Qwen/Qwen3-30B-A3B-Instruct-2507/` | | | |
| 111 | +| Qwen2.5-72B-Instruct-GPTQ-Int4 | `/data/models/Qwen/Qwen2.5-72B-Instruct-GPTQ-Int4/` | vLLM 用 | | |
| 112 | +| Qwen-72B-Chat-Int4 / QwQ-32B-Preview | `/data/models/Qwen/` | 旧版 | | |
| 113 | +| DeepSeek-R1-Distill-Qwen-32B(-AWQ) / 14B / 7B | `/data/models/deepseek-ai/`、`/data/models/DeepSeek-R1-Distill-Qwen-32B-AWQ/` | | | |
| 114 | +| GLM-4.7-Flash-GGUF / gpt-oss-120b-GGUF | `/data/models/unsloth/` | 未部署 | | |
| 115 | +| M3E 嵌入模型 | `/home/admin/llm/m3e-base/` | 7590 端口在用 | | |
| 116 | + | |
| 117 | +--- | |
| 118 | + | |
| 119 | +## 五、★ 当前部署:Qwen3.8-27B ★ | |
| 120 | + | |
| 121 | +**不走 compose,用 `docker run` 脚本管理**,脚本都在 `/home/admin/qwen38-deploy/`: | |
| 122 | + | |
| 123 | +| 脚本 | 作用 | | |
| 124 | +|------|------| | |
| 125 | +| `cutover.sh` | 切换到 Qwen3.8(校验 sha256/字节 → 停 llm → 起 llm38 → 等健康) | | |
| 126 | +| `restore.sh` | **一键回滚到 Qwen3.6**,约 3-5 分钟 | | |
| 127 | +| `download.sh` | 断点续传下载模型(60 次重试) | | |
| 128 | +| `pull2.sh` | 从 NJU 代理拉镜像并 retag | | |
| 129 | + | |
| 130 | +### 5.1 启动命令 | |
| 131 | + | |
| 132 | +```bash | |
| 133 | +docker run -d --name llm38 --restart unless-stopped --gpus all \ | |
| 134 | + -p 8000:8000 \ | |
| 135 | + -v /data/models:/jsxc \ | |
| 136 | + -v /data/nvcache38:/root/.nv \ | |
| 137 | + -e GGML_CUDA_DISABLE_GRAPHS=1 \ | |
| 138 | + -e CUDA_CACHE_MAXSIZE=2147483648 \ | |
| 139 | + --health-cmd "curl -fsS http://localhost:8000/health || exit 1" \ | |
| 140 | + --health-interval 30s --health-timeout 5s \ | |
| 141 | + --health-retries 3 --health-start-period 600s \ | |
| 142 | + llamacpp:b10481 \ | |
| 143 | + -m /jsxc/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf \ | |
| 144 | + --alias qwen3.8-27b \ | |
| 145 | + --host 0.0.0.0 --port 8000 \ | |
| 146 | + -ngl 99 -sm layer -fa on \ | |
| 147 | + --ctx-size 524288 --no-context-shift \ | |
| 148 | + --batch-size 2048 --ubatch-size 512 \ | |
| 149 | + --parallel 2 --jinja --seed 3407 \ | |
| 150 | + --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0 | |
| 151 | +``` | |
| 152 | + | |
| 153 | +### 5.2 每个非显然参数的理由 | |
| 154 | + | |
| 155 | +| 参数 | 为什么 | | |
| 156 | +|------|--------| | |
| 157 | +| `llamacpp:b10481` | 本地钉死的固定 tag(指向 5ebaf50acac4)。**不用浮动的 `server-cuda`**,避免以后被静默换成不兼容版本 | | |
| 158 | +| `GGML_CUDA_DISABLE_GRAPHS=1` | 规避 Turing CUDA graphs 显存泄漏(#25835) | | |
| 159 | +| `-v /data/nvcache38:/root/.nv` | 持久化 JIT 缓存,省掉每次冷启 1~2 分钟 | | |
| 160 | +| `--health-cmd ...:8000/health` | **覆盖镜像内写死的 `localhost:8080`**。旧 `llm` 容器常年 unhealthy(FailingStreak 16000+)就是这个原因,不覆盖会掩盖真故障 | | |
| 161 | +| `--health-start-period 600s` | 首次加载含 JIT + 20GB mmap,实测 82 秒,给足宽限 | | |
| 162 | +| `--alias qwen3.8-27b` | 给下游稳定的 model id,避免文件名变更打穿调用方 | | |
| 163 | +| `-ngl 99` | 该 build 的 `-ngl` 默认 `auto`、`--fit` 默认 `on`,不显式指定会为了塞下 ctx 把层甩到 CPU,**速度崩塌且不报错** | | |
| 164 | +| `-sm layer` | 见 2.2 节,四卡无 P2P | | |
| 165 | +| `-fa on` | Turing 有 head_dim=256 专用调优表,可放心开 | | |
| 166 | +| `--batch-size 2048 --ubatch-size 512` | 比 3.6 的 4096/1024 保守;ubatch 直接决定 compute buffer 大小 | | |
| 167 | +| 采样参数 | Qwen3.8 thinking 模式官方推荐 `temp 1.0 / top-p 0.95 / top-k 20 / min-p 0.0`。llama.cpp 默认是 `top_k=40 / min_p=0.05`,**不写就是错的**;也不要照抄 3.6 的 `0.7/40/0.01` | | |
| 168 | +| 不挂 `--mmproj` | 首轮纯文本。mmproj 文件已下好在同目录,需要图像输入时再加 | | |
| 169 | +| 不用 `--spec-type draft-mtp` | 见 2.1 节,sm_75 上不激活 | | |
| 170 | + | |
| 171 | +### 5.3 显存实测(ctx=131072) | |
| 172 | + | |
| 173 | +``` | |
| 174 | +GPU0 7448 MiB GPU1 7336 MiB GPU2 8110 MiB GPU3 6532 MiB | |
| 175 | +llm38 合计 29426 MiB / 88 GB | |
| 176 | +``` | |
| 177 | + | |
| 178 | +**低于 25 GB 说明有层落到 CPU 了**,回查 `-ngl` 与 `--fit`。 | |
| 179 | + | |
| 180 | +> KV cache 之所以小:64 层里只有 16 层是标准注意力(`full_attention_interval=4`),48 层是 Gated DeltaNet,KV 只有同规格纯注意力模型的约 27%。所以还有很大上下文余量:262144 约需 42 GB,524288 约需 59 GB。 | |
| 181 | + | |
| 182 | + | |
| 183 | +### 5.4 Thinking 模式与上下文语义(实测) | |
| 184 | + | |
| 185 | +**Thinking 默认开启**,思考过程走独立的 `reasoning_content` 字段,不混进 `content`: | |
| 186 | + | |
| 187 | +```json | |
| 188 | +{"messages":[...]} // 默认开启 | |
| 189 | +{"messages":[...],"chat_template_kwargs":{"enable_thinking":false}} // 关闭 | |
| 190 | +``` | |
| 191 | +模板另支持 `reasoning_effort` 调节思考深度。 | |
| 192 | + | |
| 193 | +**★ `--ctx-size` 是总池不是单请求上限 ★** | |
| 194 | + | |
| 195 | +`--ctx-size` 给的是**总池**,被 `--parallel` 均分才是单请求上限。当前 `--ctx-size 524288 --parallel 2` → 每槽 **262144**(日志 `n_ctx_slot = 262144`)。 | |
| 196 | + | |
| 197 | +**模型原生上限是 262144,不是 131072。** 权威来源是 GGUF 元数据本身,别信二手说法: | |
| 198 | + | |
| 199 | +```bash | |
| 200 | +python3 /home/admin/qwen38-deploy/ggufmeta.py /data/models/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf | |
| 201 | +# qwen35.context_length = 262144 ← 原生上限 | |
| 202 | +# qwen35.block_count = 65 (64 真实层 + 1 个未用的 nextn/MTP 层) | |
| 203 | +# qwen35.full_attention_interval = 4 (64 层里只有 16 层是标准注意力) | |
| 204 | +# qwen35.attention.head_count_kv = 4 / key_length = 256 / value_length = 256 | |
| 205 | +``` | |
| 206 | + | |
| 207 | +**KV 用量可以手算**:16 注意力层 × 4 KV 头 × 256 维 × 2(K+V)× 2 字节 = **64 KiB/token**。乘上总池 token 数就是 KV 显存,实测与估算吻合到 1 GB 以内。 | |
| 208 | + | |
| 209 | +| 配置 | 单请求上限 | 并发 | KV | 实测/估算总显存 | | |
| 210 | +|------|-----------|------|-----|---------------| | |
| 211 | +| `--parallel 4 --ctx-size 131072`(旧) | 32K | 4 | 8 GiB | 29.4 GB(实测) | | |
| 212 | +| `--parallel 2 --ctx-size 524288`(**当前**) | **256K** | 2 | 32 GiB | **57.3 GB(实测)** | | |
| 213 | +| `--parallel 1 --ctx-size 262144` | 256K | 1 | 16 GiB | ~38 GB | | |
| 214 | +| `--parallel 4 --ctx-size 1048576` | 256K | 4 | 64 GiB | ~86 GB ❌ 放不下 | | |
| 215 | +| 同上 + `-ctk q8_0 -ctv q8_0` | 256K | 4 | 32 GiB | ~55 GB(未验证) | | |
| 216 | + | |
| 217 | +**长上下文的真实成本**(实测,别只看显存放不放得下): | |
| 218 | + | |
| 219 | +| 提示长度 | 提示处理速度 | 光 prefill 耗时 | | |
| 220 | +|---------|------------|---------------| | |
| 221 | +| 11K | 1491 tok/s | 7.4 s | | |
| 222 | +| 150K | 979 tok/s | 153 s | | |
| 223 | +| 256K(外推) | ~950 tok/s | **~4.5 分钟** | | |
| 224 | + | |
| 225 | +prefill 期间独占一个槽位(共 2 个)。生成速度也随上下文下降:短上下文 23.4 tok/s,150K 时 16.7 tok/s。 | |
| 226 | + | |
| 227 | +**切换脚本**:`/home/admin/qwen38-deploy/ctx256k.sh`,自带失败自动回滚。 | |
| 228 | + | |
| 229 | +--- | |
| 230 | + | |
| 231 | +### 5.5 如何调用(对外 API) | |
| 232 | + | |
| 233 | +**OpenAI 兼容**。model id 是 `qwen3.8-27b`(`--alias` 固定,不随权重文件名变)。 | |
| 234 | + | |
| 235 | +| 入口 | 地址 | 适用 | | |
| 236 | +|---|---|---| | |
| 237 | +| 公网 | `http://qwen.wudaima.com/v1` | 外网/办公网,经 FRP,仅 HTTP | | |
| 238 | +| 内网 | `http://192.168.110.67:8000/v1` | 同局域网,直连更快 | | |
| 239 | +| 本机 | `http://localhost:8000/v1` | SSH 进服务器后 | | |
| 240 | + | |
| 241 | +**无鉴权**——API key 随便填或不填都通(见第七节安全告警)。 | |
| 242 | + | |
| 243 | +```bash | |
| 244 | +curl http://qwen.wudaima.com/v1/chat/completions -H "Content-Type: application/json" -d '{ | |
| 245 | + "model": "qwen3.8-27b", | |
| 246 | + "messages": [{"role":"user","content":"你好"}], | |
| 247 | + "max_tokens": 1024 | |
| 248 | +}' | |
| 249 | +``` | |
| 250 | + | |
| 251 | +```python | |
| 252 | +from openai import OpenAI | |
| 253 | +client = OpenAI(base_url="http://qwen.wudaima.com/v1", api_key="none") # key 必填但不校验 | |
| 254 | +r = client.chat.completions.create(model="qwen3.8-27b", | |
| 255 | + messages=[{"role":"user","content":"你好"}], max_tokens=1024) | |
| 256 | +print(r.choices[0].message.content) | |
| 257 | +``` | |
| 258 | + | |
| 259 | +**四个调用方必须知道的点**: | |
| 260 | + | |
| 261 | +1. **thinking 内容在 `reasoning_content` 字段,不在 `content`**。`max_tokens` 是两者之和——给小了会导致思考没结束就被截断,`content` 返回**空字符串**。要正常输出,`max_tokens` 给足(≥512),或用 `"chat_template_kwargs":{"enable_thinking":false}` 关掉思考。 | |
| 262 | +2. **采样参数别自己传**。服务端已按 Qwen3.8 官方 thinking 推荐值启动(`temp 1.0 / top-p 0.95 / top-k 20 / min-p 0.0`),请求里再传会覆盖掉,容易传成 llama.cpp 的默认值(`top_k 40 / min_p 0.05`)而降质。 | |
| 263 | +3. **`model` 字段不校验**。传错名字也返回 200 + 正常内容,所以别指望用它做健康检查——用 `/health` 或 `/v1/models`。 | |
| 264 | +4. **长提示很贵**。256K 提示光 prefill 约 4.5 分钟且独占 1/2 槽位,客户端超时要放宽(`timeout=900`)。 | |
| 265 | + | |
| 266 | +常用端点:`/health`(存活)、`/v1/models`(模型 + `n_ctx`/`n_ctx_train`)、`/props`(`total_slots`、build 号)、`/v1/chat/completions`、`/v1/completions`。 | |
| 267 | + | |
| 268 | +--- | |
| 269 | + | |
| 270 | +## 六、★ 回滚 ★ | |
| 271 | + | |
| 272 | +```bash | |
| 273 | +bash /home/admin/qwen38-deploy/restore.sh | |
| 274 | +``` | |
| 275 | + | |
| 276 | +会清掉 `llm38`、用 `llamacpp-rollback:b9570` 镜像重建 `llm` 跑回 Qwen3.6-27B-IQ4_XS,约 3-5 分钟。 | |
| 277 | + | |
| 278 | +**回滚依赖三样东西,都不要删**: | |
| 279 | +1. 镜像 tag `llamacpp-rollback:b9570`(image id 9dad14fcc497) | |
| 280 | +2. 模型文件 `/data/models/Qwen/Qwen3.6-27B-GGUF/Qwen_Qwen3.6-27B-IQ4_XS.gguf` | |
| 281 | +3. 脚本 `/home/admin/qwen38-deploy/restore.sh` | |
| 282 | + | |
| 283 | +FRP/nginx 无需改动 —— 域名始终指向 `127.0.0.1:8000`。 | |
| 284 | + | |
| 285 | +--- | |
| 286 | + | |
| 287 | +## 七、模型切换操作(vLLM 历史配置) | |
| 288 | + | |
| 289 | +> ⚠ `/home/admin/jiansuan/` 下的 vLLM compose 镜像是 v0.8.4 / v0.10.2(11~16 个月前),**不认识 Qwen3.5+ 的新架构**,只能跑 Qwen2.5 / DeepSeek 这些老模型。 | |
| 290 | + | |
| 291 | +### 7.1 配置文件目录 | |
| 292 | + | |
| 293 | +``` | |
| 294 | +/home/admin/jiansuan/ | |
| 295 | +├── vllm/ | |
| 296 | +│ ├── compose.72b.yaml # Qwen2.5-72B (vLLM) | |
| 297 | +│ ├── compose.32b.yaml # DeepSeek-32B (vLLM) | |
| 298 | +│ ├── compose.coder.30b.yaml # Qwen3-Coder-30B (vLLM) | |
| 299 | +│ ├── compose.7b.1m.yaml # 7B 超长上下文 (vLLM, 只用 GPU2/3) | |
| 300 | +│ └── compose.yaml # 基础模板(sleep,不自动启动) | |
| 301 | +└── llama_cpp/ | |
| 302 | + └── compose-qwen3-coder-next.yaml | |
| 303 | +``` | |
| 304 | + | |
| 305 | +所有 compose 都用 `container_name: llm` + `ports: 8000:8000`,**同一时间只能跑一个**。切换前必须先 `docker stop llm38`。 | |
| 306 | + | |
| 307 | +### 7.2 各 vLLM 配置要点 | |
| 308 | + | |
| 309 | +| 配置 | 模型 | 关键参数 | | |
| 310 | +|------|------|----------| | |
| 311 | +| `compose.72b.yaml` | Qwen2.5-72B-GPTQ-Int4 | `--dtype=half -tp 4 --gpu-memory-utilization 0.98 --max-model-len 32768` | | |
| 312 | +| `compose.32b.yaml` | DeepSeek-R1-Distill-32B-AWQ | `--enable-reasoning --reasoning-parser deepseek_r1 -tp 4 --max-model-len 65536` | | |
| 313 | +| `compose.coder.30b.yaml` | Qwen3-Coder-30B-A3B | `--enable-auto-tool-choice --tool-call-parser hermes -tp 4 --max-model-len 131072` | | |
| 314 | +| `compose.7b.1m.yaml` | Qwen2.5-7B-1M | `--max-model-len 614400 --kv-cache-dtype fp8 -tp 2`,只用 GPU2/3 | | |
| 315 | + | |
| 316 | +镜像统一为 `registry.cn-hangzhou.aliyuncs.com/jiansuan/vllm-openai:v0.8.4`,挂载 `/data/models:/root/models`。 | |
| 317 | + | |
| 318 | +### 7.3 切换示例 | |
| 319 | + | |
| 320 | +```bash | |
| 321 | +cd /home/admin/jiansuan | |
| 322 | +docker stop llm38 # 先停当前的 Qwen3.8 | |
| 323 | +docker compose -f vllm/compose.72b.yaml up -d | |
| 324 | +curl http://localhost:8000/v1/models # 预期 qwen2.5-72b | |
| 325 | +``` | |
| 326 | + | |
| 327 | +--- | |
| 328 | + | |
| 329 | +## 八、反向代理配置 | |
| 330 | + | |
| 331 | +### 8.1 FRP 内网穿透 | |
| 332 | + | |
| 333 | +**目录**:`/home/admin/llm/frp/` · **服务端**:`60.205.214.169:7009` · **由 PM2 管理**(God Daemon PID 1467) | |
| 334 | + | |
| 335 | +| 配置文件 | section | 域名 | 本地端口 | 状态 | | |
| 336 | +|----------|---------|------|----------|------| | |
| 337 | +| `frpc.qwen.ini` | `[qwen-llm]` | `qwen.wudaima.com` | 8000 | ✅ 大模型 API | | |
| 338 | +| `frpc.qwen.ini` | `[qwen-gw-20260610]` | `gateway.wudaima.com` | 8080 | ⚠ **8080 无监听,隧道空挂** | | |
| 339 | +| `frpc.ini` | `[m3e]` | `m3e.embedding.wudaima.com` | 7590 | ✅ 嵌入服务 | | |
| 340 | +| `sd.frpc.ini` | `[stable-diffusion-web]` | `stable-diffusion.wudaima.com` | 7860 | ✅ SD WebUI | | |
| 341 | +| `comfyui.frpc.ini` | `[comfyui-web]` | `comfyui.wudaima.com` | 8188 | ⚠ **8188 无监听,隧道空挂** | | |
| 342 | +| `ssh.frpc.ini` | `[ssh]` | — (tcp) | 22 → 公网 6000 | ⚠ **SSH 暴露公网** | | |
| 343 | + | |
| 344 | +```bash | |
| 345 | +pm2 list # 需先 export PATH 或用绝对路径 | |
| 346 | +pm2 logs frpc-qwen-client --lines 50 | |
| 347 | +``` | |
| 348 | + | |
| 349 | +> ⚠ **安全**:`qwen.wudaima.com` 无任何鉴权,公网可直接调用本机 27B 推理。`gateway.wudaima.com → 8080` 那条隧道看名字本是做网关鉴权用的,但 8080 一直没有服务。SSH 映射到公网 6000 且允许密码登录、admin 在 sudo 组。 | |
| 350 | + | |
| 351 | +### 8.2 Nginx | |
| 352 | + | |
| 353 | +**配置目录**:`/etc/nginx/conf.d/` | |
| 354 | + | |
| 355 | +| 文件 | 监听 | 静态根 | 后端 | | |
| 356 | +|------|------|--------|------| | |
| 357 | +| `farm.conf` | `:4016` | `/home/admin/chat/chatbot-front/dist` | `127.0.0.1:4017` + `127.0.0.1:4020` | | |
| 358 | +| `manage.conf` | `:4018` | `/home/admin/chat/manage-front/dist` | `127.0.0.1:4019` + `127.0.0.1:4020` | | |
| 359 | + | |
| 360 | +> ⚠ **已知故障**:`4017` / `4019` / `4020` **三个后端端口全部无监听**。两个前端静态页能打开,但任何 API 请求都会 502。聊天应用目前是坏的。 | |
| 361 | + | |
| 362 | +### 8.3 AutoSSH | |
| 363 | + | |
| 364 | +`/etc/systemd/system/autossh.service` —— 状态 `active` + `enabled`,把本机 22 反向代理到 AWS: | |
| 365 | + | |
| 366 | +```bash | |
| 367 | +autossh -N -M 0 -R 10044:localhost:22 ubuntu@2404:c2c0:8522:c100:f7b:8b85:5fec:e174 | |
| 368 | +``` | |
| 369 | + | |
| 370 | +--- | |
| 371 | + | |
| 372 | +## 九、LLM Farm 应用栈 | |
| 373 | + | |
| 374 | +**目录**:`/data/models/llmfarm/` · Compose:`docker-compose.yml` | |
| 375 | + | |
| 376 | +服务:`chatbot-frontend`(:10000) · `chatbot-backend` · `chatbot-mysql`(:3306, 密码 `Chain.123456`) · `chatbot-chain` · `chatbot-chroma`(:8000 仅容器内网) · `chatbot-management-frontend`(:9999) · `chatbot-management-backend` | |
| 377 | + | |
| 378 | +> Chroma 也用 8000,但只在 Docker 内部网络;大模型容器绑的是 `0.0.0.0:8000`,不冲突。 | |
| 379 | + | |
| 380 | +--- | |
| 381 | + | |
| 382 | +## 十、常用命令速查 | |
| 383 | + | |
| 384 | +```bash | |
| 385 | +# ---- 状态 ---- | |
| 386 | +nvidia-smi | |
| 387 | +docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" | |
| 388 | +docker logs llm38 --tail 100 -f | |
| 389 | +curl -s http://localhost:8000/v1/models | python3 -m json.tool | |
| 390 | +curl -s http://localhost:8000/health | |
| 391 | + | |
| 392 | +# ---- 对话测试(model 字段可任意,服务端不校验)---- | |
| 393 | +cat > /tmp/t.json <<'EOF' | |
| 394 | +{"messages":[{"role":"user","content":"你好"}],"max_tokens":200} | |
| 395 | +EOF | |
| 396 | +curl -s http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" -d @/tmp/t.json | |
| 397 | + | |
| 398 | +# ---- 新模型上线前必查(血泪教训)---- | |
| 399 | +docker run --rm --entrypoint /app/llama-server <镜像> --version # build 必须 >= 10450 | |
| 400 | +docker run --rm --entrypoint /app/llama-server <镜像> --help | grep -- '--某参数' # 新版对未知参数是 throw 不是忽略 | |
| 401 | +sha256sum /data/models/.../xxx.gguf # 与 HF 官方值比对 | |
| 402 | + | |
| 403 | +# ---- 显存核对 ---- | |
| 404 | +nvidia-smi --query-gpu=index,memory.used,memory.free --format=csv | |
| 405 | +nvidia-smi --query-compute-apps=pid,used_memory --format=csv | |
| 406 | + | |
| 407 | +# ---- 回滚 ---- | |
| 408 | +bash /home/admin/qwen38-deploy/restore.sh | |
| 409 | + | |
| 410 | +# ---- 外部验证 ---- | |
| 411 | +curl -s http://qwen.wudaima.com/v1/models | python3 -m json.tool | |
| 412 | +``` | |
| 413 | + | |
| 414 | +--- | |
| 415 | + | |
| 416 | +## 十一、目录速查表 | |
| 417 | + | |
| 418 | +| 用途 | 路径 | | |
| 419 | +|------|------| | |
| 420 | +| 模型文件 | `/data/models/` | | |
| 421 | +| **Qwen3.8 部署脚本** | `/home/admin/qwen38-deploy/` | | |
| 422 | +| JIT 缓存 | `/data/nvcache38/` | | |
| 423 | +| vLLM / llama.cpp Compose | `/home/admin/jiansuan/vllm/`、`/home/admin/jiansuan/llama_cpp/` | | |
| 424 | +| FRP 客户端 | `/home/admin/llm/frp/` | | |
| 425 | +| Nginx 配置 | `/etc/nginx/conf.d/` | | |
| 426 | +| AutoSSH 服务 | `/etc/systemd/system/autossh.service` | | |
| 427 | +| LLM Farm Compose | `/data/models/llmfarm/docker-compose.yml` | | |
| 428 | +| 本备忘文档 | `/home/admin/llm-setup.md` | | |
| 429 | + | |
| 430 | +--- | |
| 431 | + | |
| 432 | +## 十二、注意事项 | |
| 433 | + | |
| 434 | +1. **上新模型前先读第二、三节**。FP8 装不了、build 有下限、HF 连不上 —— 这三条能省掉大半天。 | |
| 435 | + | |
| 436 | +2. **`unhealthy` 不再是"正常现象"**。旧文档说 llama.cpp 容器常显示 unhealthy 不影响使用 —— 那是因为镜像内 healthcheck 写死了 `localhost:8080` 而服务在 8000。`llm38` 已用 `--health-cmd` 覆盖,现在 `healthy` 是真的健康、`unhealthy` 是真的有问题。 | |
| 437 | + | |
| 438 | +3. **FRP 域名固定**:`qwen.wudaima.com` 始终指向 `127.0.0.1:8000`,切换模型后域名不变,但 `/v1/models` 返回的 model id 会变。服务端**不校验**请求里的 model 字段,下游硬编码旧模型名也不会报错。 | |
| 439 | + | |
| 440 | +4. **磁盘**:`/` 只剩 29G(87%),docker 镜像和日志都写这里,留意别写满。`/data` 还有 353G。 | |
| 441 | + | |
| 442 | +5. **GPU 占用基线**(起大模型前先看):GPU0 有 embedding(668M)+SD(174M),GPU3 有 whisper(3258M)。这些常驻进程会让默认 tensor-split 算错,见 2.2 节。 | |
| 443 | + | |
| 444 | +6. **不要同时起两个 27B**。四卡总量够,但计算争用会让对外服务吞吐腰斩,且 GPU3 余量不足会 OOM。要并行验证请用 `--device CUDA0,CUDA1,CUDA2` 避开 GPU3 并把 ctx 砍到 32768。 | ... | ... |
minimax-h3/API.md
0 → 100644
| 1 | +++ a/minimax-h3/API.md | |
| 1 | +# MiniMax-H3 视频生成 API | |
| 2 | + | |
| 3 | +服务在服务器上:`/data/models/minimax-h3/api.py` + `api-run.sh`(本地是副本)。 | |
| 4 | + | |
| 5 | +## 为什么是异步任务式,不是同步接口 | |
| 6 | + | |
| 7 | +| 事实 | 后果 | | |
| 8 | +|---|---| | |
| 9 | +| 模型加载 240 s(首次,fp32 权重从机械盘读;page cache 命中 20 s) | 不能每请求加载 | | |
| 10 | +| 单条视频 Turbo 4 步约 **7 分钟**,50 步约 **90 分钟** | 同步 HTTP 必然超时 | | |
| 11 | +| 一条生成占 **12.3 G 显存 + 约 32 G 内存** | 22 G 卡上同时只能跑一条 | | |
| 12 | + | |
| 13 | +所以接口是:**提交任务拿 id → 轮询状态 → 下载文件**。并发提交会排队,不会 OOM。 | |
| 14 | + | |
| 15 | +## 启动 | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +cd /data/models/minimax-h3 | |
| 19 | +./api-run.sh 1 8010 # GPU_ID=1, PORT=8010 | |
| 20 | +# 后台跑: | |
| 21 | +setsid nohup ./api-run.sh 1 8010 > logs/api.log 2>&1 < /dev/null & | |
| 22 | +``` | |
| 23 | + | |
| 24 | +⚠️ **显存前提**:这条服务常驻约 12.3 G。llm38 跑 256K 上下文时占 57.3 G(四卡各约 14.3 G), | |
| 25 | +两者在同一张卡上放不下。跑视频前先 `docker stop llm38`(这是测试机,见工作区 `CLAUDE.md`), | |
| 26 | +或把 H3 放到 llm38 没用满的卡上并自行核算。恢复用 `qwen38-deploy/RESTORE-GPU.md`。 | |
| 27 | + | |
| 28 | +## 接口 | |
| 29 | + | |
| 30 | +### `GET /health` | |
| 31 | +```json | |
| 32 | +{"status":"ok","model":"ready","gpu":"1","queued":0,"running":0} | |
| 33 | +``` | |
| 34 | +`status` 在模型加载完成前是 `loading`。**别在 `loading` 时提交任务**——会排队但看不出进度。 | |
| 35 | + | |
| 36 | +### `POST /v1/videos` → `202` | |
| 37 | + | |
| 38 | +```bash | |
| 39 | +curl -X POST http://192.168.110.67:8010/v1/videos \ | |
| 40 | + -H "Content-Type: application/json" \ | |
| 41 | + -d '{ | |
| 42 | + "prompt": "一只橘猫趴在洒满阳光的窗台上,慢慢眨眼,尾巴轻轻摆动", | |
| 43 | + "width": 832, "height": 480, "frames": 124, | |
| 44 | + "steps": 4, "turbo": true, "seed": 7 | |
| 45 | + }' | |
| 46 | +``` | |
| 47 | + | |
| 48 | +```json | |
| 49 | +{"id":"ca0f2f095ddd","status":"queued","queue_position":1, | |
| 50 | + "eta_seconds":428,"poll":"/v1/videos/ca0f2f095ddd"} | |
| 51 | +``` | |
| 52 | + | |
| 53 | +| 字段 | 默认 | 范围 | 说明 | | |
| 54 | +|---|---|---|---| | |
| 55 | +| `prompt` | 必填 | 1–2000 字符 | 中英文都行;含人物台词时用英文引号包住那句 | | |
| 56 | +| `width` | 832 | 64–**832** | | | |
| 57 | +| `height` | 480 | 64–**480** | | | |
| 58 | +| `frames` | 124 | 8–**124** | 24 fps,124 帧 ≈ 5.17 秒 | | |
| 59 | +| `steps` | 4 | 1–50 | turbo 时应 ≤8 | | |
| 60 | +| `turbo` | true | | true=4 步蒸馏 LoRA;false=全质量多步 | | |
| 61 | +| `seed` | 0 | 0–2³¹−1 | 同 seed 同参数结果可复现 | | |
| 62 | +| `flow_shift` | 自动 | | 不传则 turbo=6.0 / 非 turbo=12.0 | | |
| 63 | + | |
| 64 | +**上界不是随手写的**:832×480×124f 是实测跑通的最高档(峰值 12.2 G)。再大在 22 G 卡上 OOM, | |
| 65 | +所以在入口就挡住,而不是让它跑 7 分钟后崩掉。 | |
| 66 | + | |
| 67 | +错误码: | |
| 68 | +- `422` 参数越界或缺失(pydantic 校验) | |
| 69 | +- `400` `turbo=true` 且 `steps>8`(Turbo LoRA 是 4 步蒸馏的,多步没意义) | |
| 70 | + | |
| 71 | +### `GET /v1/videos/{id}` | |
| 72 | + | |
| 73 | +```json | |
| 74 | +{"id":"ca0f2f095ddd","status":"running","step":0,"total_steps":4, | |
| 75 | + "elapsed_seconds":132, ...} | |
| 76 | +``` | |
| 77 | + | |
| 78 | +`status`: `queued` → `running` → `done` / `failed`。 | |
| 79 | +完成后多出 `file`、`size_bytes`、`peak_vram_gb`、`download`。 | |
| 80 | + | |
| 81 | +### `GET /v1/videos/{id}/file` | |
| 82 | +返回 mp4。任务未完成时返回 `409`。 | |
| 83 | + | |
| 84 | +### `GET /v1/videos?limit=50` | |
| 85 | +列出最近任务。 | |
| 86 | + | |
| 87 | +## 完整调用示例(Python) | |
| 88 | + | |
| 89 | +```python | |
| 90 | +import time, requests | |
| 91 | +API = "http://192.168.110.67:8010" | |
| 92 | + | |
| 93 | +r = requests.post(f"{API}/v1/videos", json={ | |
| 94 | + "prompt": "清晨的中式庭院,一位女子坐在长廊上喝茶,阳光透过竹叶", | |
| 95 | + "steps": 4, "turbo": True, "seed": 42, | |
| 96 | +}, timeout=30) | |
| 97 | +r.raise_for_status() | |
| 98 | +jid = r.json()["id"] | |
| 99 | +print("任务", jid, "预计", r.json()["eta_seconds"], "秒") | |
| 100 | + | |
| 101 | +while True: | |
| 102 | + j = requests.get(f"{API}/v1/videos/{jid}", timeout=30).json() | |
| 103 | + if j["status"] in ("done", "failed"): | |
| 104 | + break | |
| 105 | + print(j["status"], j.get("elapsed_seconds", 0), "s") | |
| 106 | + time.sleep(20) | |
| 107 | + | |
| 108 | +if j["status"] == "failed": | |
| 109 | + raise SystemExit("失败: " + j["error"]) | |
| 110 | + | |
| 111 | +open(f"{jid}.mp4", "wb").write( | |
| 112 | + requests.get(f"{API}/v1/videos/{jid}/file", timeout=300).content) | |
| 113 | +print("已保存", j["size_bytes"], "字节,峰值显存", j["peak_vram_gb"], "G") | |
| 114 | +``` | |
| 115 | + | |
| 116 | +## 服务端会自己验产物 | |
| 117 | + | |
| 118 | +**写出文件不等于成功**——fp16 时代踩过:全黑视频照样写出 24 KB 的 mp4 并返回退出码 0。 | |
| 119 | +所以 worker 在写文件前会检查: | |
| 120 | + | |
| 121 | +- 抽 8 帧算像素 `std`,`< 1.0` 判为纯色 → 任务标 `failed` | |
| 122 | +- 音频张量含非有限值 → 任务标 `failed` | |
| 123 | + | |
| 124 | +这两条不通过时**不会**给你一个"成功但没用"的文件。 | |
| 125 | + | |
| 126 | +## 已知限制 | |
| 127 | + | |
| 128 | +- **无鉴权**,和这台机器上其他服务一致。放到公网前必须加。 | |
| 129 | +- **单 worker 串行**。要并发就每张空闲卡起一个实例(不同 PORT),自己在上层做负载。 | |
| 130 | +- 任务状态存在**内存**里,进程重启就丢;已生成的 mp4 文件还在 `outputs/`。 | |
| 131 | + 真要持久化就把 `JOBS` 换成 sqlite——目前按需再说。 | |
| 132 | +- `turbo=false` 时会 `clear_lora()`,任务结束后再挂回来。**混合提交 turbo/非 turbo 时 | |
| 133 | + 每次切换都有 LoRA 装卸开销**,批量跑同一种模式更快。 | ... | ... |
minimax-h3/README.md
0 → 100644
| 1 | +++ a/minimax-h3/README.md | |
| 1 | +# MiniMax-H3 视频生成部署(4 × RTX 2080 Ti) | |
| 2 | + | |
| 3 | +在 `192.168.110.67` 上用 DiffSynth-Studio 跑 MiniMax-H3 FL2VA(文生视频 + 原生立体声)。 | |
| 4 | + | |
| 5 | +> **状态(2026-08-18)**:环境已装好并通过全部兼容性实测;模型下载中(33 GB / 6 MB/s); | |
| 6 | +> 尚未完成首次出片。验收结果见本文末「验收记录」,未填即表示还没跑通。 | |
| 7 | + | |
| 8 | +服务器侧根目录 **`/data/models/minimax-h3/`**,本目录是其脚本副本 + 决策记录。 | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 一、为什么不能照抄官方示例 | |
| 13 | + | |
| 14 | +官方 [MiniMax-H3.md](https://github.com/modelscope/DiffSynth-Studio/blob/main/docs/en/Model_Details/MiniMax-H3.md) | |
| 15 | +的示例在这台机器上**跑不起来**,有三处必须改。 | |
| 16 | + | |
| 17 | +### 1. `torch.bfloat16` → `torch.float16`(致命) | |
| 18 | + | |
| 19 | +官方示例的 `vram_config` 与 `torch_dtype` 全用 bf16。sm_75 上实测: | |
| 20 | + | |
| 21 | +``` | |
| 22 | +[ERR] SDPA bfloat16 EFFICIENT_ATTENTION → RuntimeError: No available kernel | |
| 23 | +[ERR] SDPA bfloat16 FLASH_ATTENTION → RuntimeError: No available kernel | |
| 24 | +[OK ] SDPA bfloat16 MATH → 只剩朴素实现 | |
| 25 | +[OK ] SDPA float16 EFFICIENT_ATTENTION → 可用 | |
| 26 | +``` | |
| 27 | + | |
| 28 | +bf16 不是"慢一点",是**没有任何高效 attention kernel**。PyTorch 会静默掉到 MATH 后端, | |
| 29 | +在 H3 的 ~14,100 token 序列上要物化 `14100 × 14100 × 56` 的注意力矩阵,必然 OOM。 | |
| 30 | + | |
| 31 | +FP16 路径实测(真实 H3 形状 S=14100 H=56 D=128): | |
| 32 | + | |
| 33 | +``` | |
| 34 | +SDPA EFFICIENT fp16 → OK, 0.21s | |
| 35 | +fp16 GEMM 有效算力 → 54.3 TFLOPS (2080Ti fp16 张量核满血) | |
| 36 | +fp16 深链数值稳定性 → finite | |
| 37 | +``` | |
| 38 | + | |
| 39 | +**注意**:`llm-setup.md` 说 "Turing…不支持 bf16,跑 GGUF 走 llama.cpp 是唯一实际路径" —— | |
| 40 | +前半句对,后半句对扩散模型不成立。PyTorch + bitsandbytes NF4 在 sm_75 上是通的,见下。 | |
| 41 | + | |
| 42 | +### 2. `vram_limit` 取错了下标(会 OOM) | |
| 43 | + | |
| 44 | +```python | |
| 45 | +vram_limit=torch.cuda.mem_get_info("cuda")[1] / (1024 ** 3) - 2 # 官方:[1] = 总显存 | |
| 46 | +vram_limit=torch.cuda.mem_get_info("cuda")[0] / (1024 ** 3) - 2 # 本机:[0] = 空闲显存 | |
| 47 | +``` | |
| 48 | + | |
| 49 | +`mem_get_info()` 返回 `(free, total)`。这台机器四张卡都有常驻服务(llm38 / embedding / SD / whisper), | |
| 50 | +按总显存 21.5G 要预算,实际只有 12–15G 可用,必 OOM。 | |
| 51 | + | |
| 52 | +### 3. `offload_device="disk"` 不能用 | |
| 53 | + | |
| 54 | +`model_inference_low_vram/` 那组示例把权重 offload 到磁盘。本机 `/data` 是 | |
| 55 | +**7200rpm 机械盘**(HGST HUS726020ALA610,实测顺序读 184 MB/s)。 | |
| 56 | +NF4 DiT 16 GB,每个去噪步都要过一遍,光 I/O 就 **87 秒/步**。 | |
| 57 | + | |
| 58 | +改用 CPU 内存 offload:物理内存 128 GB,可用 123 GB,单 worker 常驻约 32 GB,宽裕。 | |
| 59 | + | |
| 60 | +--- | |
| 61 | + | |
| 62 | +## 二、attention backend:只用 PyTorch SDPA | |
| 63 | + | |
| 64 | +| Backend | sm_75 | 处置 | | |
| 65 | +|---|---|---| | |
| 66 | +| FlashAttention 2 / 3 / 4 | ❌ 仅 sm_80–sm_121 | **不装** | | |
| 67 | +| SageAttention | ❌ 需 sm_80+ | **不装** | | |
| 68 | +| xFormers | ⚠️ 系统已装 0.0.33,fp16 可用 | 不用(见下) | | |
| 69 | +| PyTorch SDPA | ✅ EFFICIENT(cutlass)后端 | **采用** | | |
| 70 | + | |
| 71 | +DiffSynth 的 `diffsynth/core/attention/attention.py` 里有自动优先级, | |
| 72 | +**xformers 排在 torch 前面会被自动选中**。实测: | |
| 73 | + | |
| 74 | +``` | |
| 75 | +不设环境变量 → selected = xformers | |
| 76 | +DIFFSYNTH_ATTENTION_IMPLEMENTATION=torch → selected = torch | |
| 77 | +``` | |
| 78 | + | |
| 79 | +两者 fp16 下都能跑,但锁死 `torch` 保证可复现。`run.sh` 已导出该变量。 | |
| 80 | + | |
| 81 | +H3 的 DiT 只用最干净的 attention 调用(无 mask / 无 causal / 无 sliding window, | |
| 82 | +见 `minimax_h3_dit.py:79`),不会踩 flex_attention 需要 torch.compile 的坑。 | |
| 83 | + | |
| 84 | +--- | |
| 85 | + | |
| 86 | +## 三、NF4 量化在 Turing 上成立 | |
| 87 | + | |
| 88 | +NF4 走 bitsandbytes 的 `Linear4bit`。bitsandbytes 的 4-bit 最低要求正好是 | |
| 89 | +**sm_75**,2080 Ti 卡在支持线上;其 CMakeLists 的架构列表明确含 `75`。 | |
| 90 | + | |
| 91 | +**服务器上原有的 bitsandbytes 0.45.1 是坏的**(在 `~/.local`): | |
| 92 | + | |
| 93 | +``` | |
| 94 | +AttributeError: 'NoneType' object has no attribute 'cquantize_blockwise_fp16_nf4' | |
| 95 | +``` | |
| 96 | + | |
| 97 | +CUDA 动态库没加载上。已在 venv 内装 0.50.1 覆盖。实测通过: | |
| 98 | + | |
| 99 | +``` | |
| 100 | +[OK ] NF4 fp16 forward out=(1024,14336) finite=True rel_err=0.0913 | |
| 101 | +[OK ] NF4 prequantized reload match=True ← 这正是加载 .safetensors 时走的分支 | |
| 102 | +``` | |
| 103 | + | |
| 104 | +--- | |
| 105 | + | |
| 106 | +## 四、多卡:H3 不支持模型并行 | |
| 107 | + | |
| 108 | +DiffSynth 的序列并行(`diffsynth/utils/xfuser/`)**只被 `wan_video.py` 和 | |
| 109 | +`mova_audio_video.py` 引用**;`minimax_h3_audio_video.py` 里零引用 xfuser / torch.distributed。 | |
| 110 | + | |
| 111 | +叠加硬件事实(`llm-setup.md` 第 2.2 节):四卡无 P2P(`topo -p2p r` 全 CNS), | |
| 112 | +GPU0/1 与 GPU2/3 跨 NUMA。 | |
| 113 | + | |
| 114 | +> **结论:不要试图把 4×22G 当成一张 88G 卡。** | |
| 115 | +> 多卡只能做**独立 worker 并发**(每卡一个进程),不能拆单个任务。 | |
| 116 | + | |
| 117 | +并发数上限由**内存**而非显存决定:每 worker 常驻约 32 GB(DiT NF4 16 GB + | |
| 118 | +文本编码器 NF4 14.3 GB + VAE 1.8 GB),128 GB 内存 → **最多 2–3 个 worker**。 | |
| 119 | + | |
| 120 | +--- | |
| 121 | + | |
| 122 | +## 五、模型与算力 | |
| 123 | + | |
| 124 | +DiT 是**稠密 30.9B**(50 层 / hidden 5376 / 56 heads × 128 / ffn 14336,无 MoE)。 | |
| 125 | +480×832×124 帧 → 视频 latent 35×30×52,patch(1,2,2) → **≈14,100 token**。 | |
| 126 | +每步约 830 TFLOP。 | |
| 127 | + | |
| 128 | +| 文件 | 大小 | | |
| 129 | +|---|---| | |
| 130 | +| `minimax-h3-fl2va-nf4.safetensors` | 15.98 GB | | |
| 131 | +| `minimax-h3-text-encoder-nf4.safetensors`(Qwen3-VL 32B) | 14.27 GB | | |
| 132 | +| `video_vae_nf4.safetensors` | 1.50 GB | | |
| 133 | +| `audio_vae_nf4.safetensors` | 0.26 GB | | |
| 134 | +| `minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors`(LoRA) | 1.29 GB | | |
| 135 | +| **合计** | **33.3 GB** | | |
| 136 | + | |
| 137 | +Turbo LoRA 能叠在 NF4 上——`core/vram/layers.py:445` 的 | |
| 138 | +`AutoWrappedQuantizedModule(AutoTorchModule, LoRAHotLoadMixin)` 在量化输出**之后**做 | |
| 139 | +`out += x@A@B`,不需要反量化基座权重。这是最大的性能杠杆: | |
| 140 | + | |
| 141 | +| 方案 | 步数 | 单条 5 秒视频(估算) | | |
| 142 | +|---|---|---| | |
| 143 | +| NF4 全量 | 50 | 20–35 分钟 | | |
| 144 | +| NF4 + Turbo LoRA | 4 | **2–4 分钟** | | |
| 145 | + | |
| 146 | +> 文件名里的 `bf16` 只是 LoRA 权重的存储精度,加载时会按 `pipe.torch_dtype` 转成 fp16,不影响。 | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## 六、安装了什么(以及没动什么) | |
| 151 | + | |
| 152 | +**全部落在 `/data/models/minimax-h3/`。系统级零改动。** | |
| 153 | + | |
| 154 | +- 没装 CUDA、没动驱动(570.153.02 保持)、没碰 `/usr`、没改 `~/.local` | |
| 155 | +- 系统 `nvcc` 是 11.5,很老,但**推理用不到**——pip 的 torch wheel 自带 CUDA runtime | |
| 156 | +- venv 用 `--system-site-packages` 复用已验证的 `torch 2.9.0+cu128` | |
| 157 | + (其 `arch_list` 含 `sm_75`),省 3 GB 下载 | |
| 158 | +- **只装了 bitsandbytes 0.50.1 + diffsynth(`-e . --no-deps`)**,其余依赖系统已齐 | |
| 159 | +- **不要设 `PYTHONNOUSERSITE=1`**:`torch` / `transformers` / `av` 都住在 `~/.local`, | |
| 160 | + 屏蔽掉会连 torch 一起丢。venv 的 site-packages 本来就排在 `~/.local` 前面, | |
| 161 | + 我们的 bnb 0.50.1 自然覆盖坏掉的 0.45.1 | |
| 162 | + | |
| 163 | +### 容器变更(用户已授权,可逆) | |
| 164 | + | |
| 165 | +| 容器 | 动作 | 恢复 | | |
| 166 | +|---|---|---| | |
| 167 | +| `llm`(旧 llama.cpp,unhealthy 5 天,已被 llm38 取代) | **已停** | `docker start llm` | | |
| 168 | +| `whisper` | 停后**已恢复** | — | | |
| 169 | +| `llm38`(Qwen3.8-27B,经 FRP 对外供 `qwen.wudaima.com`) | **全程未碰** | — | | |
| 170 | + | |
| 171 | +启动参数完整备份在 `/data/models/minimax-h3/backup/`(`llm.inspect.json` / | |
| 172 | +`whisper.inspect.json` / `restore-notes.txt`)。两者都是 `restart=unless-stopped`, | |
| 173 | +手动 stop 不会被自动拉起。 | |
| 174 | + | |
| 175 | +--- | |
| 176 | + | |
| 177 | +## 七、目录与路径 | |
| 178 | + | |
| 179 | +| 用途 | 路径 | | |
| 180 | +|---|---| | |
| 181 | +| 根目录 | `/data/models/minimax-h3/` | | |
| 182 | +| venv | `/data/models/minimax-h3/venv/` | | |
| 183 | +| DiffSynth 源码 | `/data/models/minimax-h3/DiffSynth-Studio/`(HEAD `0361581`) | | |
| 184 | +| **模型权重** | `/data/models/minimax-h3/models/` | | |
| 185 | +| **日志** | `/data/models/minimax-h3/logs/` | | |
| 186 | +| **输出视频** | `/data/models/minimax-h3/outputs/` | | |
| 187 | +| 脚本 | `/data/models/minimax-h3/scripts/` | | |
| 188 | +| 容器备份 | `/data/models/minimax-h3/backup/` | | |
| 189 | + | |
| 190 | +## 八、启动命令 | |
| 191 | + | |
| 192 | +```bash | |
| 193 | +cd /data/models/minimax-h3 | |
| 194 | + | |
| 195 | +./run.sh 1 # GPU1,Turbo 4-step,480x832x124f(默认) | |
| 196 | +./run.sh 1 --no-turbo --steps 50 # 全质量 50 步 | |
| 197 | +./run.sh 2 --prompt "..." --seed 7 | |
| 198 | +./run.sh 1 --fp32-accum # 若出现 NaN/伪影:关 fp16 累加(约慢一倍) | |
| 199 | +``` | |
| 200 | + | |
| 201 | +多 worker(每卡一个独立进程,**内存上限 2–3 个**): | |
| 202 | + | |
| 203 | +```bash | |
| 204 | +./run.sh 0 --prompt "..." & # GPU0(NUMA0) | |
| 205 | +./run.sh 1 --prompt "..." & # GPU1(NUMA0) | |
| 206 | +./run.sh 2 --prompt "..." & # GPU2(NUMA1) | |
| 207 | +./run.sh 3 --prompt "..." & # GPU3(NUMA1) | |
| 208 | +``` | |
| 209 | + | |
| 210 | +`run.sh` 会按 GPU 编号选 NUMA 节点绑核(GPU0/1→node0,GPU2/3→node1), | |
| 211 | +但服务器上**没装 `numactl`**,目前这段会自动跳过。装了会更快。 | |
| 212 | + | |
| 213 | +## 九、脚本 | |
| 214 | + | |
| 215 | +| 脚本 | 作用 | | |
| 216 | +|---|---| | |
| 217 | +| `run.sh` | 启动器:设环境变量 + 选 GPU + 落日志 | | |
| 218 | +| `t2va.py` | 推理主体(fp16 / SDPA / CPU offload) | | |
| 219 | +| `download.py` | 用 DiffSynth 自己的 `ModelConfig` 下载,保证落盘布局与加载时解析一致 | | |
| 220 | +| `verify.py` | 逐文件比对字节数 vs ModelScope + 解析 safetensors 头 | | |
| 221 | +| `probe-turing-caps.py` | sm_75 能力探测(bf16/fp16 × 各 attention 后端) | | |
| 222 | +| `probe-h3-shapes.py` | 真实 H3 形状下的 attention + GEMM 吞吐 | | |
| 223 | +| `probe-nf4-fp16.py` | NF4 fp16 前向 + prequantized 重载 | | |
| 224 | +| `probe-attention-backend.py` | 验证 DiffSynth 实际选中的后端 | | |
| 225 | + | |
| 226 | +换硬件或升级 torch/bnb 后,**先跑 probe-* 四个再动别的**。 | |
| 227 | + | |
| 228 | +## 十、下载注意 | |
| 229 | + | |
| 230 | +出口带宽 **5.9 MB/s 封顶**(`llm-setup.md` 第三节),33 GB 约 **95 分钟**。 | |
| 231 | + | |
| 232 | +- 必须 `setsid`,**只用 `nohup` 不够**——SSH 通道关闭时进程仍会被带走(本次踩过) | |
| 233 | +- **不要开两个下载进程**:并行不会更快(出口带宽是瓶颈),只会争抢同一个 | |
| 234 | + `._____temp` 分片互相破坏(本次踩过,事后用 `verify.py` 校验) | |
| 235 | +- ModelScope 可达;HuggingFace / ghcr 不通 | |
| 236 | + | |
| 237 | +## 十一、失败时的降级顺序 | |
| 238 | + | |
| 239 | +1. fp16 出 NaN → `--fp32-accum`(关 fp16 累加,约慢一倍) | |
| 240 | +2. 仍不行 → 把 norm / adaln 提到 fp32 | |
| 241 | +3. 显存不够 → 调小 `--vram-reserve`,或换 pruned 权重 | |
| 242 | + (`minimax-h3-fl2va-pruned-nf4.safetensors`,9.76 GB / 18.7B,但 **Turbo LoRA 与之不兼容**,层数不同) | |
| 243 | + | |
| 244 | +--- | |
| 245 | + | |
| 246 | +## ★ 2026-08-18 实测推翻本文档第一节:fp16 是故障本身,不是修复 ★ | |
| 247 | + | |
| 248 | +本 README 第一节写「`torch.bfloat16` → `torch.float16`(致命)」,把改用 fp16 当作**必须的修正**。 | |
| 249 | +**实测结论相反:改成 fp16 才是这条线出不了片的根本原因。** | |
| 250 | + | |
| 251 | +### 对照实验(唯一变量是 dtype) | |
| 252 | + | |
| 253 | +同分辨率 448×256×28f、同 prompt、同权重、同 Turbo LoRA,只改 `torch_dtype`: | |
| 254 | + | |
| 255 | +| | fp32 | fp16 | | |
| 256 | +|---|---|---| | |
| 257 | +| 视频像素 | mean=140.9 **std=50.6** max=255 | mean=0 **std=0** max=0 | | |
| 258 | +| 音频 | **finite=True** | finite=False | | |
| 259 | +| 生成耗时 | 62s | 40s | | |
| 260 | +| 峰值显存 | 16.3G | 16.3G | | |
| 261 | +| 结果 | **正常出片 ✅** | **124 帧纯黑 ❌** | | |
| 262 | + | |
| 263 | +模型、权重、管线、Turbo LoRA、VAE 全部正常——**唯一的问题是 fp16 的动态范围装不下这个模型**。 | |
| 264 | + | |
| 265 | +### 为什么之前的 probe 没测出来 | |
| 266 | + | |
| 267 | +`probe-nf4-fp16.py` / `probe-h3-shapes.py` 用的是**合成小张量**,测的是「NF4 fp16 前向不报错、数值有限」。 | |
| 268 | +这类测试测不出真实模型在 **50 层残差累积**后的量级。实测 `dit.blocks.0` 输入 absmax 89 → 输出 38176, | |
| 269 | +逼近 fp16 上限 65504;再往后必然溢出。**「单层不溢出」推不出「50 层不溢出」。** | |
| 270 | + | |
| 271 | +### 溢出机理:`.to(x.dtype)` 这个模式在库里反复出现 | |
| 272 | + | |
| 273 | +```python | |
| 274 | +# minimax_h3_dit.py:61-67 | |
| 275 | +def _modulate_scale_shift(x, shift, scale, indices): | |
| 276 | + return (x * (1.0 + scale.index_select(0, indices)) + shift.index_select(0, indices)).to(x.dtype) | |
| 277 | +def _modulate_gate(x, gate, other, indices): | |
| 278 | + return (x + gate.index_select(0, indices) * other).to(x.dtype) | |
| 279 | +``` | |
| 280 | + | |
| 281 | +库作者自己的注释说「index_select on the AdaLN params can promote the expression」—— | |
| 282 | +表达式确实被提升到 fp32 算,但**结果又降回 fp16**,恰好丢掉了提升带来的动态范围。 | |
| 283 | +DiT 有 50 个 block × 每个 2 次 = **100 个这样的降回点**。`condition_proj` 那里是同一个病灶的另一个实例 | |
| 284 | +(fp32 真值 absmax 79554 > 65504)。 | |
| 285 | + | |
| 286 | +### 逐点修 fp16 是行不通的(四条弯路,都实测过) | |
| 287 | + | |
| 288 | +1. `--fp32-accum` —— 开关生效(`fp16_reduced_accum` True→False)但**输出逐字节相同**。 | |
| 289 | + 它管的是 reduced-precision reduction,与这里的溢出无关。README 第十一节把它列为降级第一档,**对此故障不对症**。 | |
| 290 | +2. 替换 `AutoWrappedLinear.forward` —— 会绕过 `preparing()`/onload 换入机制。 | |
| 291 | +3. 只替换 `linear_forward`、最后仍 `.to(fp16)` —— **无效**:fp32 算得对,溢出发生在那次类型转换上。 | |
| 292 | + (自检若只查转换前的张量,会永远显示"正常"——踩过。) | |
| 293 | +4. 钳位到 65504 再降回 —— **反而更糟**:`blocks.0` 是 `h = x + attn(norm1(x))`, | |
| 294 | + x 恰好等于 fp16 最大可表示值时再加任何正数立刻变 inf(实测 3.45% 非有限)。钳位不留余量等于自造故障。 | |
| 295 | + | |
| 296 | +每修一处,NaN 就移到下一处——因为问题是全局的动态范围,不是某几个点。 | |
| 297 | + | |
| 298 | +### 另一个方法教训:诊断配置必须与故障配置逐项一致 | |
| 299 | + | |
| 300 | +前几轮诊断用的是中文 prompt「一只橘猫…」,而实际失败的是 `t2va.py` 的**英文默认 prompt**。 | |
| 301 | +不同文本 → 不同嵌入量级 → 溢出点不同。定位出的路径在真实故障里根本不触发,白跑了几轮。 | |
| 302 | + | |
| 303 | +### 退出码不可信 | |
| 304 | + | |
| 305 | +原 `t2va.py` 只检查 `audio` 是否非有限,**从不检查 video**,全黑视频照样打印 `[done]` 并返回 **0**。 | |
| 306 | +已补上 video 的有限性 + 纯色检查(std < 1.0 判失败),坏产物以**退出码 3** 退出。 | |
| 307 | + | |
| 308 | +--- | |
| 309 | + | |
| 310 | +## 冒烟测试前置检查(2026-08-18 17:59,llm38 切 256K 之后) | |
| 311 | + | |
| 312 | +环境侧已全部验证,剩下的唯一变量是权重本身。 | |
| 313 | + | |
| 314 | +| 检查项 | 结果 | | |
| 315 | +|---|---| | |
| 316 | +| attention 后端 @ 真实 DiT 形状 (1,56,14100,128) fp16 | ✅ SDPA 选中,`finite=True`(xformers 可用但被 env 钉死为 torch) | | |
| 317 | +| bitsandbytes 版本 | ✅ **0.50.1**(成功压住 `~/.local` 里坏的 0.45.1) | | |
| 318 | +| NF4 fp16 前向 + 预量化重载 | ✅ `rel_err=0.0913`,reload `match=True` | | |
| 319 | +| 已落盘 3 文件字节 + safetensors 头 | ✅ video_vae / audio_vae / turbo LoRA 全对 | | |
| 320 | + | |
| 321 | +### ⚠️ `t2va.py:47` 的地板值现在是个雷 | |
| 322 | + | |
| 323 | +```python | |
| 324 | +vram_limit = max(6.0, free_gb - args.vram_reserve) | |
| 325 | +``` | |
| 326 | + | |
| 327 | +这个 `6.0` 地板是在**每卡还有 12–15 GB 空闲**时写的。2026-08-18 llm38 上下文提到 256K 后(KV 从 8 GiB 涨到 32 GiB),空闲显存腰斩,地板值开始**反过来盖掉安全余量**: | |
| 328 | + | |
| 329 | +| | 空闲 | 实得 `vram_limit` | 余量 | | | |
| 330 | +|---|---|---|---|---| | |
| 331 | +| GPU0 | 6.58G | 6.00G | +0.58G | 勉强,无缓冲 | | |
| 332 | +| **GPU1** | **7.51G** | 6.00G | **+1.51G** | 勉强,最宽裕 → **冒烟测试用这张** | | |
| 333 | +| GPU2 | 5.25G | 6.00G | **−0.75G** | ❌ **必 OOM**:地板要的比卡上有的还多 | | |
| 334 | +| GPU3 | 6.59G | 6.00G | +0.59G | 勉强,无缓冲 | | |
| 335 | + | |
| 336 | +`--vram-reserve` **调不动这个地板**——`max()` 在外层。 | |
| 337 | + | |
| 338 | +### ✅ 正解:跑 H3 之前先停 llm38 | |
| 339 | + | |
| 340 | +用户 2026-08-18 明确:**这是测试机,不是生产环境,停 llm38 让路是预期做法**(见工作区 `CLAUDE.md`)。所以上面整张表都不必绕,串行跑就行: | |
| 341 | + | |
| 342 | +```bash | |
| 343 | +docker stop llm38 # 让出约 57 GB | |
| 344 | +./run.sh 1 ... # 四卡各约 21 GB 空闲 → vram_limit 约 19 G,地板值失效 | |
| 345 | +docker start llm38 # 跑完起回来,约 15 秒 | |
| 346 | +``` | |
| 347 | + | |
| 348 | +这样测出来的还是**干净数字**(无抢占),比在争抢状态下测更有参考价值。 | |
| 349 | + | |
| 350 | +**地板值仍值得修**(低优先级):`max(6.0, ...)` 在放不下时会去要一个不存在的额度而不是报错。真要多负载共存时才会再咬人;目前串行跑绕开了。 | |
| 351 | + | |
| 352 | +### 脚本漂移(已修) | |
| 353 | + | |
| 354 | +本 README 第九节列了 8 个脚本、并说「换硬件后先跑 probe-* 四个」,但**服务器上原本只有 3 个**(`download.py` / `t2va.py` / `verify.py`),4 个 probe 从没上过服务器,那条指示在服务器上无法执行。已补齐到 `/data/models/minimax-h3/scripts/`。 | |
| 355 | + | |
| 356 | +> 另注:`run.sh` 在服务器上位于 `/data/models/minimax-h3/run.sh`(顶层),本地副本放在 `scripts/` 下,两边路径不一致。README 第八节的 `cd /data/models/minimax-h3 && ./run.sh 1` 以服务器为准。 | |
| 357 | + | |
| 358 | +--- | |
| 359 | + | |
| 360 | +## 验收记录 | |
| 361 | + | |
| 362 | +**2026-08-18 22:31 首次成功出片。** 配置:`dtype=fp32` · Turbo 4-step · 832×480×124f · GPU1 · `vram_limit=7.3G` | |
| 363 | + | |
| 364 | +### 产物验证(ffprobe + 逐像素 + 音频样本) | |
| 365 | + | |
| 366 | +| 项目 | 实测 | | |
| 367 | +|---|---| | |
| 368 | +| 文件 | `outputs/t2va_20260818-223136.mp4` **7,859,869 字节 (7.5 MB)** | | |
| 369 | +| 视频流 | h264 **832×480** · 24 fps · **124 帧** · 5.167 s | | |
| 370 | +| 实际解码帧数 | **124**(与声明一致,无截断) | | |
| 371 | +| 像素 | mean 69.33 · **std 57.85** · 全域 0–255 | | |
| 372 | +| **帧间平均差异** | **16.99** → 真实动态视频,非静止图 | | |
| 373 | +| 音频流 | aac · **32000 Hz · 双声道** · 163 帧 · 5.175 s | | |
| 374 | +| 音频电平 | mean **−34.0 dB** · max **−14.9 dB**(静音应为 −91 dB) | | |
| 375 | +| 音频样本 | absmax 0.1799 · rms 0.0199 · **非零样本 100%** | | |
| 376 | + | |
| 377 | +> 验证方法上的教训:`ffmpeg -af volumedetect -f null -` 的输出容易被 grep 漏掉, | |
| 378 | +> 误报成"无音频流"。**直接用 PyAV 解码样本算 rms** 更可靠。 | |
| 379 | + | |
| 380 | +### 性能与显存(fp32,全部实测) | |
| 381 | + | |
| 382 | +| 分辨率 | 步数 | 生成耗时 | 每步 | 峰值显存 | 结果 | | |
| 383 | +|---|---|---|---|---|---| | |
| 384 | +| 448×256×28f | 4 (turbo) | 62 s | 15.5 s | 16.3 G | ✅ std 50.6 | | |
| 385 | +| 640×384×61f | 4 (turbo) | 171 s | 42.8 s | 11.4 G | ✅ std 51.7 | | |
| 386 | +| **832×480×124f** | **4 (turbo)** | **553 s** | **107 s** | **12.2 G** | ✅ std 66.8 | | |
| 387 | +| 832×480×124f | 4 (turbo),真实 prompt | 556 s | 138.9 s | 12.3 G | ✅ **本次验收** | | |
| 388 | + | |
| 389 | +> 同配置两次每步耗时 107 s vs 138.9 s,差异来自并发:验收那次有另一条 50 步任务 | |
| 390 | +> 在别的卡上同时跑,抢 CPU 与内存带宽(权重 offload 在 CPU 侧)。**单任务跑约 107 s/步。** | |
| 391 | + | |
| 392 | +**加载耗时**:首次从机械盘读 fp32 权重 **238 s**;page cache 命中后 fp16 只要 **12–15 s**。 | |
| 393 | +反复调试时第二次之后极快,不必每次预留 6 分钟。 | |
| 394 | + | |
| 395 | +### 与 fp16 的对照(唯一变量是 dtype) | |
| 396 | + | |
| 397 | +| | fp32 | fp16 | | |
| 398 | +|---|---|---| | |
| 399 | +| 每步耗时 | 107 s | 36.8 s(快 2.9×) | | |
| 400 | +| 峰值显存 | 12.2 G | 15.9 G | | |
| 401 | +| **产物** | **正常出片** | **124 帧纯黑 / 音频 NaN** | | |
| 402 | + | |
| 403 | +fp16 更快更省显存,但**根本出不了片**。这不是速度取舍,是能用与不能用的区别。详见上一节。 | |
| 404 | + | |
| 405 | +### 启动命令(已验证) | |
| 406 | + | |
| 407 | +```bash | |
| 408 | +cd /data/models/minimax-h3 | |
| 409 | +echo "你的提示词" > prompt.txt # 从文件读,避开多层 shell 引号 | |
| 410 | +./realrun.sh turbo # Turbo 4-step,全分辨率,约 10 分钟 | |
| 411 | +./realrun.sh full50 # 50 步全质量,约 90 分钟 | |
| 412 | +``` | |
| 413 | + | |
| 414 | +直接调用也可(`--vram-reserve` 现按 dtype 自适应,fp32 默认 14): | |
| 415 | + | |
| 416 | +```bash | |
| 417 | +./run.sh 1 --prompt "$(cat prompt.txt)" --seed 42 | |
| 418 | +``` | |
| 419 | + | |
| 420 | +> **prompt 必须从文件读。** 直接在 `ssh … bash -c "…"` 里传含引号的 prompt 会被 | |
| 421 | +> 三层 shell 吃掉,传进去是空串,报错是 | |
| 422 | +> `RuntimeError: cannot reshape tensor of 0 elements into shape [1, 0, -1, 128]` | |
| 423 | +> ——看着像 transformers 的 bug,实际是**输入为空**。0 元素张量崩在 reshape 是这个的典型指纹。 | |
| 424 | + | |
| 425 | +### 50 步全质量(2026-08-18 23:51 完成,已验收) | |
| 426 | + | |
| 427 | +同 prompt、同 seed,`--no-turbo --steps 50`: | |
| 428 | + | |
| 429 | +| 项目 | 实测 | | |
| 430 | +|---|---| | |
| 431 | +| 文件 | `outputs/t2va_20260818-235141.mp4` **6,726,194 字节 (6.4 MB)** | | |
| 432 | +| 生成耗时 | **5364 s(89 分钟)· 107.3 s/步** | | |
| 433 | +| 峰值显存 | **9.1 G**(比 turbo 的 12.3 G 更低——无 LoRA 层) | | |
| 434 | +| 视频 | h264 832×480 · 24 fps · **124 帧** · 5.167 s | | |
| 435 | +| 像素 | mean 71.63 · **std 59.68** · 全域 0–255 | | |
| 436 | +| 帧间平均差异 | **7.366** | | |
| 437 | +| 音频 | mean **−27.2 dB** · max **−9.2 dB** · 331776 样本 | | |
| 438 | + | |
| 439 | +**与 Turbo 4-step 的对比**: | |
| 440 | + | |
| 441 | +| | Turbo 4-step | 50 步全质量 | | |
| 442 | +|---|---|---| | |
| 443 | +| 耗时 | 556 s | **5364 s(9.6×)** | | |
| 444 | +| 峰值显存 | 12.3 G | 9.1 G | | |
| 445 | +| 像素 std | 57.85 | 59.68 | | |
| 446 | +| 帧间差异 | **16.99** | **7.366** | | |
| 447 | +| 音频电平 | −34.0 / −14.9 dB | **−27.2 / −9.2 dB** | | |
| 448 | + | |
| 449 | +50 步的帧间差异明显更小、音频更响。**帧间差异小不等于质量差**——更多去噪步数带来更平滑的时序连贯性; | |
| 450 | +Turbo 的 16.99 里有一部分是 4 步蒸馏残留的帧间抖动。哪个观感更好需要人眼判断, | |
| 451 | +这两条客观指标只能说明**两者都是真实动态视频,且 50 步的时序更稳**。 | |
| 452 | + | |
| 453 | +**每步耗时 107.3 s 与单任务 turbo 的 107 s 完全一致**——证明这个数字是这台机器上 | |
| 454 | +fp32 全分辨率的稳定值,不随步数或 LoRA 变化。 | |
| 455 | + | |
| 456 | + | |
| 457 | + | ... | ... |
minimax-h3/api-run.sh
0 → 100644
| 1 | +++ a/minimax-h3/api-run.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# 启动 MiniMax-H3 视频 API。用法: ./api-run.sh [GPU_ID] [PORT] [PARTITION] | |
| 3 | +# PARTITION=fl2va 收首末帧;ref2va 收任意多张参考图。一个进程一个分区,各占一张卡。 | |
| 4 | +set -uo pipefail | |
| 5 | +B=/data/models/minimax-h3 | |
| 6 | +GPU="${1:-1}"; PORT="${2:-8010}"; export H3_PARTITION="${3:-fl2va}" | |
| 7 | +export CUDA_VISIBLE_DEVICES="$GPU" | |
| 8 | +export DIFFSYNTH_ATTENTION_IMPLEMENTATION=torch | |
| 9 | +export DIFFSYNTH_MODEL_BASE_PATH="$B/models" | |
| 10 | +export MODELSCOPE_CACHE="$B/.mscache" | |
| 11 | +export PYTORCH_ALLOC_CONF=expandable_segments:True | |
| 12 | +export PYTHONUNBUFFERED=1 | |
| 13 | +cd "$B" | |
| 14 | +echo "[api] GPU=$GPU PORT=$PORT PARTITION=$H3_PARTITION 首次加载约 240 秒(fp32 权重从机械盘读)" | |
| 15 | +exec "$B/venv/bin/python" -m uvicorn api:app --host 0.0.0.0 --port "$PORT" --workers 1 | ... | ... |
minimax-h3/api.py
0 → 100644
| 1 | +++ a/minimax-h3/api.py | |
| 1 | +"""MiniMax-H3 文生视频 HTTP API(异步任务式)。 | |
| 2 | + | |
| 3 | +为什么不是同步接口:模型加载 238s、单条视频 7 分钟(50 步 90 分钟), | |
| 4 | +同步 HTTP 必然超时。所以是「提交任务拿 id → 轮询状态 → 下载文件」。 | |
| 5 | + | |
| 6 | +为什么是串行单 worker:一条生成占 ~12.3G 显存 + ~32G CPU 内存, | |
| 7 | +一张 22G 卡同时只能跑一条。并发提交会排队,不会 OOM。 | |
| 8 | +""" | |
| 9 | +import os, sys, time, uuid, threading, queue, traceback, io, urllib.request | |
| 10 | +from typing import Optional, Literal | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +BASE = Path("/data/models/minimax-h3") | |
| 14 | +OUT = BASE / "outputs"; OUT.mkdir(exist_ok=True) | |
| 15 | +# 一个进程只跑一个分区。两套权重的 DiT、processor、Turbo LoRA 三样都不同, | |
| 16 | +# 同进程装两套要么重建 pipe(每次重读 16G 权重),要么常驻 77G 内存。 | |
| 17 | +# 每卡一个进程本来就是这台机器唯一的并发方式(H3 不支持模型并行),顺手就把分区分开了。 | |
| 18 | +PARTITION = os.environ.get("H3_PARTITION", "fl2va") | |
| 19 | +# 没上 GPU 的那部分权重存哪。cpu 是每进程一份 40G 匿名内存,并发被 125G 内存卡在 3 个; | |
| 20 | +# disk 让权重留在 safetensors 文件里(layers.py 里 offload() 就是 .to("meta")), | |
| 21 | +# 每次前向从文件读进显存,走 OS 页缓存——多个进程读同一个文件,物理内存里只有一份。 | |
| 22 | +OFFLOAD = os.environ.get("H3_OFFLOAD", "cpu") | |
| 23 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION", "torch") | |
| 24 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", str(BASE / "models")) | |
| 25 | +os.environ.setdefault("MODELSCOPE_CACHE", str(BASE / ".mscache")) | |
| 26 | +os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True") | |
| 27 | + | |
| 28 | +import torch | |
| 29 | +from fastapi import FastAPI, HTTPException | |
| 30 | +from fastapi.responses import FileResponse | |
| 31 | +from pydantic import BaseModel, Field, model_validator | |
| 32 | + | |
| 33 | +def _rows(width: int, height: int, frames: int) -> float: | |
| 34 | + """序列行数:潜帧数 x 宽 x 高 / 1024。真正决定显存/算力的量,帧数和分辨率是同一笔预算。""" | |
| 35 | + latent_frames = (max(frames, 5) - 5) // 17 * 5 + 2 | |
| 36 | + return latent_frames * width * height / 1024 | |
| 37 | + | |
| 38 | +# 22G 卡的显存墙按 rows 算,不是单独卡 frames——实测边界(2026-08-20 long15s.sh): | |
| 39 | +# 832x480x124f rows=14430 稳定基准;448x256x362f rows=11984、576x320x362f rows=19260 都能跑 | |
| 40 | +# (后者峰值 14.5G,换入换出吃紧但没崩);640x384x362f rows=25680 OOM(撞到 20.6G)。 | |
| 41 | +# 20000 卡在 19260(能跑)和 25680(OOM)之间,留安全余量。 | |
| 42 | +ROWS_LIMIT = 20000 | |
| 43 | + | |
| 44 | +# ---------------- 任务模型 ---------------- | |
| 45 | +class GenReq(BaseModel): | |
| 46 | + prompt: str = Field(min_length=1, max_length=2000) | |
| 47 | + # 上界取模型自己的默认档 1344x768。pipeline 本身不限分辨率,只把长宽向上取整到 32 的倍数, | |
| 48 | + # 真正的墙是这张 22G 卡:序列行数按 h*w/1024 每潜帧算,832x480 是 390 行/帧 x 37 帧 = 14430 行, | |
| 49 | + # 1344x768 是 1008 行/帧 = 37296 行,注意力是平方级,涨 2.6 倍行数就是涨约 6.7 倍算力。 | |
| 50 | + # 832x480 带三张参考图实测峰值 13.7G,往上哪一档撞墙没测过,超了就是这条任务 failed,不会拖死服务。 | |
| 51 | + width: int = Field(832, ge=64, le=1344) | |
| 52 | + height: int = Field(480, ge=64, le=768) | |
| 53 | + # 上界 362=15s(协议规格上限),是否真能生成看下面 _check_rows——832x480 撞了还是会被拒。 | |
| 54 | + frames: int = Field(124, ge=8, le=362) | |
| 55 | + steps: int = Field(4, ge=1, le=50) | |
| 56 | + turbo: bool = True | |
| 57 | + seed: int = Field(0, ge=0, le=2**31 - 1) | |
| 58 | + flow_shift: Optional[float] = None | |
| 59 | + # 首末帧条件(FL2VA)。给了首帧,这一段就从那张画面长出去——同一间屋子、同一个人。 | |
| 60 | + # 不给就是纯文生视频,屋子和人由模型自己编,跨条对不上。 | |
| 61 | + first_frame_url: Optional[str] = None | |
| 62 | + last_frame_url: Optional[str] = None | |
| 63 | + # 参考图(Ref2VA)。首帧只管这一段从哪开始,参考图全程参与注意力,管的是「中间别乱改」。 | |
| 64 | + # 张数没有硬上限,代价是序列变长:一张按 ref_image_short_edge 缩放后折成 token, | |
| 65 | + # 短边 480 时约 390 行,而目标视频本身才 14430 行。 | |
| 66 | + reference_image_urls: list[str] = [] | |
| 67 | + ref_image_short_edge: int = Field(480, ge=64, le=2048) | |
| 68 | + | |
| 69 | + @model_validator(mode="after") | |
| 70 | + def _check_rows(self): | |
| 71 | + rows = _rows(self.width, self.height, self.frames) | |
| 72 | + if rows > ROWS_LIMIT: | |
| 73 | + raise ValueError(f"分辨率×帧数超出显存安全上限(rows={rows:.0f} > {ROWS_LIMIT});" | |
| 74 | + f"降低 frames 或分辨率,例如 448x256/576x320 可到 362 帧,832x480 建议 ≤124 帧") | |
| 75 | + return self | |
| 76 | + | |
| 77 | +class Job(BaseModel): | |
| 78 | + id: str | |
| 79 | + status: Literal["queued", "loading", "running", "done", "failed"] | |
| 80 | + req: GenReq | |
| 81 | + created_at: float | |
| 82 | + started_at: Optional[float] = None | |
| 83 | + finished_at: Optional[float] = None | |
| 84 | + step: int = 0 | |
| 85 | + total_steps: int = 0 | |
| 86 | + file: Optional[str] = None | |
| 87 | + size_bytes: Optional[int] = None | |
| 88 | + error: Optional[str] = None | |
| 89 | + peak_vram_gb: Optional[float] = None | |
| 90 | + | |
| 91 | +JOBS: dict[str, Job] = {} | |
| 92 | +Q: "queue.Queue[str]" = queue.Queue() | |
| 93 | +LOCK = threading.Lock() | |
| 94 | +STATE = {"model": "loading", "loaded_at": None, "gpu": os.environ.get("CUDA_VISIBLE_DEVICES", "?")} | |
| 95 | + | |
| 96 | +# ---------------- 推理 worker ---------------- | |
| 97 | +# DiT、processor、Turbo LoRA 三样成套,跨分区混用会对不上层。 | |
| 98 | +# ref2v 的 Turbo LoRA 只有 v0.1,fl2v 那个是 v1.0 768p——这一项两边成色不同。 | |
| 99 | +PARTITIONS = { | |
| 100 | + "fl2va": {"dit": "minimax-h3-fl2va-nf4.safetensors", | |
| 101 | + "processor": "FL2VA/processor/", | |
| 102 | + "lora": "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"}, | |
| 103 | + # ref2v 的 bf16 版实测出 124 帧噪点:同尺寸同 seed,挂上它是噪点、摘掉就出画面, | |
| 104 | + # 换 flow_shift 无效(见 docs/H3 的二分表)。改用 comfyui 版试另一套键名。 | |
| 105 | + "ref2va": {"dit": "minimax-h3-ref2va-nf4.safetensors", | |
| 106 | + "processor": "Ref2VA/processor/", | |
| 107 | + "lora": "minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors"}, | |
| 108 | +} | |
| 109 | + | |
| 110 | +def _turbo_lora_config(): | |
| 111 | + from diffsynth.pipelines.minimax_h3_audio_video import ModelConfig | |
| 112 | + return ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 113 | + origin_file_pattern=PARTITIONS[PARTITION]["lora"]) | |
| 114 | + | |
| 115 | +def _build_pipe(): | |
| 116 | + from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 117 | + part = PARTITIONS[PARTITION] | |
| 118 | + DT = torch.float32 # fp16 在 sm_75 上出全黑视频,见 README | |
| 119 | + # 常驻显存里留给权重多少 GB,剩下的全是激活。默认公式是按 832x480 调的(留 14G 给激活,实测峰值 13.7G)。 | |
| 120 | + # 分辨率往上走就得调低它:1280x720 在默认值下要 22.3G、卡只有 21.49G,差约 1G 撞 OOM; | |
| 121 | + # 调低后权重多从内存流几趟,慢一点,换来激活空间。用 H3_VRAM_LIMIT 覆盖。 | |
| 122 | + free = torch.cuda.mem_get_info("cuda")[0] / 1024**3 | |
| 123 | + vram_limit = float(os.environ.get("H3_VRAM_LIMIT", max(4.0, free - 14.0))) | |
| 124 | + offload_dt = "disk" if OFFLOAD == "disk" else DT | |
| 125 | + vc = dict(offload_dtype=offload_dt, offload_device=OFFLOAD, | |
| 126 | + onload_dtype=offload_dt, onload_device=OFFLOAD, | |
| 127 | + preparing_dtype=DT, preparing_device="cuda", | |
| 128 | + computation_dtype=DT, computation_device="cuda") | |
| 129 | + NF4 = "DiffSynth-Studio/MiniMax-H3-NF4" | |
| 130 | + pipe = MiniMaxH3Pipeline.from_pretrained( | |
| 131 | + torch_dtype=DT, device="cuda", | |
| 132 | + model_configs=[ModelConfig(model_id=NF4, origin_file_pattern=f, **vc) for f in | |
| 133 | + [part["dit"], "minimax-h3-text-encoder-nf4.safetensors", | |
| 134 | + "video_vae_nf4.safetensors", "audio_vae_nf4.safetensors"]], | |
| 135 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3", | |
| 136 | + origin_file_pattern=part["processor"]), | |
| 137 | + vram_limit=vram_limit) | |
| 138 | + pipe.load_lora(pipe.dit, _turbo_lora_config()) | |
| 139 | + return pipe | |
| 140 | + | |
| 141 | +def _fetch_image(url): | |
| 142 | + from PIL import Image | |
| 143 | + with urllib.request.urlopen(url, timeout=60) as resp: | |
| 144 | + return Image.open(io.BytesIO(resp.read())).convert("RGB") | |
| 145 | + | |
| 146 | +def _keyframes(req): | |
| 147 | + """取首末帧图,缩到出片尺寸。都没给就返回 (None, None),pipeline 退回纯文生视频。""" | |
| 148 | + from PIL import Image | |
| 149 | + images, indices = [], [] | |
| 150 | + for index, url in ((0, req.first_frame_url), (-1, req.last_frame_url)): | |
| 151 | + if url is None: | |
| 152 | + continue | |
| 153 | + images.append(_fetch_image(url).resize((req.width, req.height), Image.LANCZOS)) | |
| 154 | + indices.append(index) | |
| 155 | + return (images, indices) if images else (None, None) | |
| 156 | + | |
| 157 | +def _references(req): | |
| 158 | + """参考图按请求顺序取下来。尺寸交给 pipeline 按 ref_image_short_edge 归一,这里不动。""" | |
| 159 | + if not req.reference_image_urls: | |
| 160 | + return None | |
| 161 | + return [{"type": "image", "image": _fetch_image(url)} for url in req.reference_image_urls] | |
| 162 | + | |
| 163 | +def worker(): | |
| 164 | + from diffsynth.utils.data.audio_video import write_video_audio | |
| 165 | + import numpy as np | |
| 166 | + t0 = time.time() | |
| 167 | + pipe = _build_pipe() | |
| 168 | + STATE["model"] = "ready"; STATE["loaded_at"] = time.time() | |
| 169 | + print(f"[api] 模型就绪,耗时 {time.time()-t0:.0f}s", flush=True) | |
| 170 | + | |
| 171 | + while True: | |
| 172 | + jid = Q.get() | |
| 173 | + j = JOBS[jid] | |
| 174 | + try: | |
| 175 | + j.status = "running"; j.started_at = time.time() | |
| 176 | + j.total_steps = j.req.steps | |
| 177 | + torch.cuda.reset_peak_memory_stats() | |
| 178 | + fs = j.req.flow_shift if j.req.flow_shift is not None else (6.0 if j.req.turbo else 12.0) | |
| 179 | + if not j.req.turbo: | |
| 180 | + pipe.clear_lora() | |
| 181 | + keyframes, keyframe_indices = _keyframes(j.req) | |
| 182 | + video, audio = pipe(prompt=j.req.prompt, height=j.req.height, width=j.req.width, | |
| 183 | + num_frames=j.req.frames, num_inference_steps=j.req.steps, | |
| 184 | + seed=j.req.seed, flow_shift=fs, | |
| 185 | + keyframes=keyframes, keyframe_indices=keyframe_indices, | |
| 186 | + references=_references(j.req), | |
| 187 | + ref_image_short_edge=j.req.ref_image_short_edge) | |
| 188 | + # 产物必须验过才算成功——全黑视频也能写出文件(踩过) | |
| 189 | + a = np.stack([np.asarray(f.convert("RGB"), dtype=np.float32) | |
| 190 | + for f in video[:: max(1, len(video)//8)]]) | |
| 191 | + if a.std() < 1.0: | |
| 192 | + raise RuntimeError(f"生成结果近乎纯色 (std={a.std():.3f}),判为失败") | |
| 193 | + if torch.is_tensor(audio) and not torch.isfinite(audio).all(): | |
| 194 | + raise RuntimeError("音频含非有限值,判为失败") | |
| 195 | + out = OUT / f"api_{jid}.mp4" | |
| 196 | + write_video_audio(video=video, audio=audio, output_path=str(out), | |
| 197 | + fps=24, audio_sample_rate=32000) | |
| 198 | + j.file = str(out); j.size_bytes = out.stat().st_size | |
| 199 | + j.peak_vram_gb = round(torch.cuda.max_memory_allocated()/1024**3, 2) | |
| 200 | + j.status = "done" | |
| 201 | + except Exception as e: | |
| 202 | + j.status = "failed"; j.error = f"{type(e).__name__}: {e}" | |
| 203 | + traceback.print_exc() | |
| 204 | + finally: | |
| 205 | + j.finished_at = time.time() | |
| 206 | + if not j.req.turbo: # 下一个任务可能要 turbo,把 LoRA 挂回去 | |
| 207 | + try: | |
| 208 | + pipe.load_lora(pipe.dit, _turbo_lora_config()) | |
| 209 | + except Exception: | |
| 210 | + traceback.print_exc() | |
| 211 | + Q.task_done() | |
| 212 | + | |
| 213 | +# ---------------- HTTP ---------------- | |
| 214 | +app = FastAPI(title="MiniMax-H3 Video API", version="1.0") | |
| 215 | + | |
| 216 | +@app.on_event("startup") | |
| 217 | +def _start(): | |
| 218 | + threading.Thread(target=worker, daemon=True).start() | |
| 219 | + | |
| 220 | +@app.get("/health") | |
| 221 | +def health(): | |
| 222 | + return {"status": "ok" if STATE["model"] == "ready" else "loading", | |
| 223 | + "model": STATE["model"], "gpu": STATE["gpu"], "partition": PARTITION, | |
| 224 | + "offload": OFFLOAD, "queued": Q.qsize(), | |
| 225 | + "running": sum(1 for j in JOBS.values() if j.status == "running")} | |
| 226 | + | |
| 227 | +@app.post("/v1/videos", status_code=202) | |
| 228 | +def create(req: GenReq): | |
| 229 | + if req.turbo and req.steps > 8: | |
| 230 | + raise HTTPException(400, "turbo 模式步数应 ≤8(LoRA 是 4 步蒸馏的);要多步请传 turbo=false") | |
| 231 | + jid = uuid.uuid4().hex[:12] | |
| 232 | + with LOCK: | |
| 233 | + JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time()) | |
| 234 | + Q.put(jid) | |
| 235 | + # 耗时的自变量是序列行数(潜帧数 x 宽 x 高 / 1024),不是分辨率——分辨率和帧数是同一笔预算。 | |
| 236 | + # 基准点 832x480x124f = 14430 行,4 步实测 692 秒 = 4 x 132 + 163 | |
| 237 | + #(163 是文本编码 + 参考图编码 + VAE 解码,它也跟帧数走,所以一起按行数缩放)。 | |
| 238 | + # 回代:1280x736x56f(15640 行)估 749 / 实测 736;832x480x22f(2730 行)估 131 / 实测 96。 | |
| 239 | + # 注意力是平方级,行数越大这个线性估计偏得越小。 | |
| 240 | + rows = _rows(req.width, req.height, req.frames) | |
| 241 | + est = round((req.steps * 132 + 163) * rows / 14430) | |
| 242 | + return {"id": jid, "status": "queued", "queue_position": Q.qsize(), | |
| 243 | + "eta_seconds": est, "poll": f"/v1/videos/{jid}"} | |
| 244 | + | |
| 245 | +@app.get("/v1/videos/{jid}") | |
| 246 | +def get(jid: str): | |
| 247 | + j = JOBS.get(jid) | |
| 248 | + if not j: raise HTTPException(404, "无此任务") | |
| 249 | + d = j.model_dump() | |
| 250 | + if j.status == "running" and j.started_at: | |
| 251 | + d["elapsed_seconds"] = round(time.time() - j.started_at) | |
| 252 | + if j.status == "done": | |
| 253 | + d["download"] = f"/v1/videos/{jid}/file" | |
| 254 | + return d | |
| 255 | + | |
| 256 | +@app.get("/v1/videos/{jid}/file") | |
| 257 | +def download(jid: str): | |
| 258 | + j = JOBS.get(jid) | |
| 259 | + if not j: raise HTTPException(404, "无此任务") | |
| 260 | + if j.status != "done": raise HTTPException(409, f"任务尚未完成(当前 {j.status})") | |
| 261 | + return FileResponse(j.file, media_type="video/mp4", filename=f"{jid}.mp4") | |
| 262 | + | |
| 263 | +@app.get("/v1/videos") | |
| 264 | +def listjobs(limit: int = 50): | |
| 265 | + js = sorted(JOBS.values(), key=lambda x: x.created_at, reverse=True)[:limit] | |
| 266 | + return {"jobs": [{"id": j.id, "status": j.status, "created_at": j.created_at, | |
| 267 | + "size_bytes": j.size_bytes, "error": j.error} for j in js]} | ... | ... |
minimax-h3/prompt.txt
0 → 100644
minimax-h3/realrun.sh
0 → 100644
| 1 | +++ a/minimax-h3/realrun.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# 真实测试:从文件读 prompt,避开多层 shell 引号 | |
| 3 | +set -uo pipefail | |
| 4 | +cd /data/models/minimax-h3 | |
| 5 | +P="$(cat prompt.txt)" | |
| 6 | +echo "[prompt] ${#P} 字符: ${P:0:40}..." | |
| 7 | +case "$1" in | |
| 8 | + turbo) ./run.sh 1 --prompt "$P" --seed 42 ;; | |
| 9 | + full50) ./run.sh 2 --no-turbo --steps 50 --prompt "$P" --seed 42 ;; | |
| 10 | +esac | ... | ... |
minimax-h3/restart-8010.sh
0 → 100644
| 1 | +++ a/minimax-h3/restart-8010.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# 重启 8010 的 ref2va 实例,把常驻权重压到 4G 换激活空间,验 1280x720 能不能跑。 | |
| 3 | +set -uo pipefail | |
| 4 | +cd /data/models/minimax-h3 | |
| 5 | +PID=$(ss -lptn "sport = :8010" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u) | |
| 6 | +[ -n "$PID" ] && kill "$PID" | |
| 7 | +sleep 8 | |
| 8 | +export H3_VRAM_LIMIT=4 | |
| 9 | +setsid nohup ./api-run.sh 1 8010 ref2va > logs/api-8010.log 2>&1 < /dev/null & | |
| 10 | +sleep 12 | |
| 11 | +pgrep -af uvicorn | ... | ... |
minimax-h3/run.sh
0 → 100644
| 1 | +++ a/minimax-h3/run.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# MiniMax-H3 FL2VA NF4 launcher. usage: ./run.sh <GPU_ID> [args for t2va.py ...] | |
| 3 | +# ./run.sh 1 # turbo 4-step, 480x832x124f | |
| 4 | +# ./run.sh 1 --no-turbo --steps 50 # full quality | |
| 5 | +# ./run.sh 2 --prompt "..." --seed 7 | |
| 6 | +set -euo pipefail | |
| 7 | +B=/data/models/minimax-h3 | |
| 8 | +GPU="${1:-1}"; shift || true | |
| 9 | + | |
| 10 | +export CUDA_VISIBLE_DEVICES="$GPU" | |
| 11 | +export DIFFSYNTH_ATTENTION_IMPLEMENTATION=torch # sm_75: no flash/sage; pin instead of auto-picking xformers | |
| 12 | +export DIFFSYNTH_MODEL_BASE_PATH="$B/models" | |
| 13 | +export MODELSCOPE_CACHE="$B/.mscache" | |
| 14 | +export PYTHONUNBUFFERED=1 | |
| 15 | +# 22GB 卡上激活与权重贴着上限,碎片会直接压垮它(实测 reserved-but-unallocated 达 2.49GB) | |
| 16 | +export PYTORCH_ALLOC_CONF=expandable_segments:True | |
| 17 | +# NOTE: deliberately NOT setting PYTHONNOUSERSITE -- the venv's site-packages already | |
| 18 | +# precedes ~/.local in sys.path, so our bitsandbytes 0.50.1 shadows the broken 0.45.1 there. | |
| 19 | + | |
| 20 | +# GPU0/1 sit on NUMA0, GPU2/3 on NUMA1 -- keep the CPU-offloaded weights on the near node. | |
| 21 | +case "$GPU" in 0|1) NODE=0 ;; 2|3) NODE=1 ;; *) NODE="" ;; esac | |
| 22 | +BIND=(); if [ -n "$NODE" ] && command -v numactl >/dev/null 2>&1; then BIND=(numactl --cpunodebind="$NODE" --membind="$NODE"); fi | |
| 23 | + | |
| 24 | +LOG="$B/logs/gpu${GPU}_$(date +%Y%m%d-%H%M%S).log" | |
| 25 | +echo "[run] GPU=$GPU numa=${NODE:-none} log=$LOG" | |
| 26 | +"${BIND[@]}" "$B/venv/bin/python" "$B/scripts/t2va.py" "$@" 2>&1 | tee "$LOG" | ... | ... |
minimax-h3/scripts/diag-nan.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/diag-nan.py | |
| 1 | +"""定位 NaN 的产生位置:对 dit / text_encoder 挂 forward hook, | |
| 2 | +找出第一个『输入有限、输出非有限』的模块。""" | |
| 3 | +import os, sys, time, torch | |
| 4 | +sys.argv = [sys.argv[0]] | |
| 5 | +BASE = "/data/models/minimax-h3" | |
| 6 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION", "torch") | |
| 7 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 8 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 9 | + | |
| 10 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 11 | + | |
| 12 | +DTYPE = torch.float16 | |
| 13 | +free_gb = torch.cuda.mem_get_info("cuda")[0] / 1024**3 | |
| 14 | +vram_limit = max(6.0, free_gb - 2.0) | |
| 15 | +print(f"[env] free={free_gb:.1f}G vram_limit={vram_limit:.1f}G", flush=True) | |
| 16 | + | |
| 17 | +vc = dict(offload_dtype=DTYPE, offload_device="cpu", onload_dtype=DTYPE, onload_device="cpu", | |
| 18 | + preparing_dtype=DTYPE, preparing_device="cuda", | |
| 19 | + computation_dtype=DTYPE, computation_device="cuda") | |
| 20 | +NF4 = "DiffSynth-Studio/MiniMax-H3-NF4" | |
| 21 | +t0 = time.time() | |
| 22 | +pipe = MiniMaxH3Pipeline.from_pretrained( | |
| 23 | + torch_dtype=DTYPE, device="cuda", | |
| 24 | + model_configs=[ | |
| 25 | + ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-fl2va-nf4.safetensors", **vc), | |
| 26 | + ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-text-encoder-nf4.safetensors", **vc), | |
| 27 | + ModelConfig(model_id=NF4, origin_file_pattern="video_vae_nf4.safetensors", **vc), | |
| 28 | + ModelConfig(model_id=NF4, origin_file_pattern="audio_vae_nf4.safetensors", **vc), | |
| 29 | + ], | |
| 30 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3", origin_file_pattern="FL2VA/processor/"), | |
| 31 | + vram_limit=vram_limit) | |
| 32 | +print(f"[load] {time.time()-t0:.0f}s", flush=True) | |
| 33 | + | |
| 34 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 35 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 36 | + | |
| 37 | +def finite(x): | |
| 38 | + if torch.is_tensor(x): | |
| 39 | + return bool(torch.isfinite(x).all()), (float(x.abs().max()) if x.numel() and x.is_floating_point() else None) | |
| 40 | + return True, None | |
| 41 | + | |
| 42 | +hits = [] # 第一个坏掉的模块 | |
| 43 | +seen = set() | |
| 44 | +def mk(name): | |
| 45 | + def hook(mod, inp, out): | |
| 46 | + if name in seen: return | |
| 47 | + ok_in = all(finite(t)[0] for t in inp if torch.is_tensor(t)) | |
| 48 | + outs = out if isinstance(out, (tuple, list)) else (out,) | |
| 49 | + for o in outs: | |
| 50 | + ok, mx = finite(o) | |
| 51 | + if not ok: | |
| 52 | + seen.add(name) | |
| 53 | + hits.append((name, type(mod).__name__, ok_in, mx)) | |
| 54 | + return | |
| 55 | + return hook | |
| 56 | + | |
| 57 | +handles = [] | |
| 58 | +for tag, mod in (("dit", pipe.dit), ("txt", pipe.text_encoder)): | |
| 59 | + for n, m in mod.named_modules(): | |
| 60 | + if n: # 跳过根 | |
| 61 | + handles.append(m.register_forward_hook(mk(f"{tag}.{n}"))) | |
| 62 | +print(f"[hook] {len(handles)} 个模块已挂钩", flush=True) | |
| 63 | + | |
| 64 | +video, audio = pipe(prompt="一只橘猫在窗台上打哈欠,阳光洒进来", | |
| 65 | + height=480, width=832, num_frames=124, | |
| 66 | + num_inference_steps=4, seed=42, flow_shift=6.0) | |
| 67 | +for h in handles: h.remove() | |
| 68 | + | |
| 69 | +print("\n===== 结果 =====", flush=True) | |
| 70 | +for t, nm in (("video", video), ("audio", audio)): | |
| 71 | + if torch.is_tensor(nm): | |
| 72 | + print(f"{t}: shape={tuple(nm.shape)} dtype={nm.dtype} finite={bool(torch.isfinite(nm).all())} " | |
| 73 | + f"absmax={float(nm.abs().max()) if nm.is_floating_point() else 'n/a'}") | |
| 74 | + else: | |
| 75 | + print(f"{t}: type={type(nm)}") | |
| 76 | +print(f"\n第一批非有限输出的模块(共 {len(hits)},只列前 15):") | |
| 77 | +for n, cls, ok_in, mx in hits[:15]: | |
| 78 | + print(f" {n} [{cls}] 输入有限={ok_in} 输出absmax={mx}") | |
| 79 | +if not hits: | |
| 80 | + print(" 无 —— dit/text_encoder 内部全程有限,问题在 VAE 解码或后处理") | ... | ... |
minimax-h3/scripts/diag3.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/diag3.py | |
| 1 | +"""定位 NaN 的产生位置:对 dit / text_encoder 挂 forward hook, | |
| 2 | +找出第一个『输入有限、输出非有限』的模块。""" | |
| 3 | +import os, sys, time, torch | |
| 4 | +sys.argv = [sys.argv[0]] | |
| 5 | +BASE = "/data/models/minimax-h3" | |
| 6 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION", "torch") | |
| 7 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 8 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 9 | + | |
| 10 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 11 | + | |
| 12 | +DTYPE = torch.float16 | |
| 13 | +free_gb = torch.cuda.mem_get_info("cuda")[0] / 1024**3 | |
| 14 | +vram_limit = max(6.0, free_gb - 2.0) | |
| 15 | +print(f"[env] free={free_gb:.1f}G vram_limit={vram_limit:.1f}G", flush=True) | |
| 16 | + | |
| 17 | +vc = dict(offload_dtype=DTYPE, offload_device="cpu", onload_dtype=DTYPE, onload_device="cpu", | |
| 18 | + preparing_dtype=DTYPE, preparing_device="cuda", | |
| 19 | + computation_dtype=DTYPE, computation_device="cuda") | |
| 20 | +NF4 = "DiffSynth-Studio/MiniMax-H3-NF4" | |
| 21 | +t0 = time.time() | |
| 22 | +pipe = MiniMaxH3Pipeline.from_pretrained( | |
| 23 | + torch_dtype=DTYPE, device="cuda", | |
| 24 | + model_configs=[ | |
| 25 | + ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-fl2va-nf4.safetensors", **vc), | |
| 26 | + ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-text-encoder-nf4.safetensors", **vc), | |
| 27 | + ModelConfig(model_id=NF4, origin_file_pattern="video_vae_nf4.safetensors", **vc), | |
| 28 | + ModelConfig(model_id=NF4, origin_file_pattern="audio_vae_nf4.safetensors", **vc), | |
| 29 | + ], | |
| 30 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3", origin_file_pattern="FL2VA/processor/"), | |
| 31 | + vram_limit=vram_limit) | |
| 32 | +print(f"[load] {time.time()-t0:.0f}s", flush=True) | |
| 33 | + | |
| 34 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 35 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 36 | + | |
| 37 | +# 与 t2va.py 一致的修复:整条路径(含 LoRA)抬到 fp32 | |
| 38 | +def _fp32_all(mod): | |
| 39 | + mod.computation_dtype = torch.float32 | |
| 40 | + orig = mod.forward | |
| 41 | + def fwd(x, *a, **kw): | |
| 42 | + return orig(x.float(), *a, **kw).to(x.dtype) | |
| 43 | + mod.forward = fwd | |
| 44 | +_fp32_all(pipe.dit.condition_proj) | |
| 45 | +print("[fix ] condition_proj 全路径 -> fp32", flush=True) | |
| 46 | + | |
| 47 | +def finite(x): | |
| 48 | + if torch.is_tensor(x): | |
| 49 | + return bool(torch.isfinite(x).all()), (float(x.abs().max()) if x.numel() and x.is_floating_point() else None) | |
| 50 | + return True, None | |
| 51 | + | |
| 52 | +hits = [] # 第一个坏掉的模块 | |
| 53 | +seen = set() | |
| 54 | +def mk(name): | |
| 55 | + def hook(mod, inp, out): | |
| 56 | + if name in seen: return | |
| 57 | + ok_in = all(finite(t)[0] for t in inp if torch.is_tensor(t)) | |
| 58 | + outs = out if isinstance(out, (tuple, list)) else (out,) | |
| 59 | + for o in outs: | |
| 60 | + ok, mx = finite(o) | |
| 61 | + if not ok: | |
| 62 | + seen.add(name) | |
| 63 | + ins=[float(t.abs().max()) for t in inp if torch.is_tensor(t) and t.is_floating_point() and t.numel()] | |
| 64 | + hits.append((name, type(mod).__name__, ok_in, mx, max(ins) if ins else None)) | |
| 65 | + return | |
| 66 | + return hook | |
| 67 | + | |
| 68 | +handles = [] | |
| 69 | +for tag, mod in (("dit", pipe.dit), ("txt", pipe.text_encoder)): | |
| 70 | + for n, m in mod.named_modules(): | |
| 71 | + if n: # 跳过根 | |
| 72 | + handles.append(m.register_forward_hook(mk(f"{tag}.{n}"))) | |
| 73 | +print(f"[hook] {len(handles)} 个模块已挂钩", flush=True) | |
| 74 | + | |
| 75 | +video, audio = pipe(prompt="一只橘猫在窗台上打哈欠,阳光洒进来", | |
| 76 | + height=480, width=832, num_frames=124, | |
| 77 | + num_inference_steps=1, seed=42, flow_shift=6.0) | |
| 78 | +for h in handles: h.remove() | |
| 79 | + | |
| 80 | +print("\n===== 结果 =====", flush=True) | |
| 81 | +for t, nm in (("video", video), ("audio", audio)): | |
| 82 | + if torch.is_tensor(nm): | |
| 83 | + print(f"{t}: shape={tuple(nm.shape)} dtype={nm.dtype} finite={bool(torch.isfinite(nm).all())} " | |
| 84 | + f"absmax={float(nm.abs().max()) if nm.is_floating_point() else 'n/a'}") | |
| 85 | + else: | |
| 86 | + print(f"{t}: type={type(nm)}") | |
| 87 | +print(f"\n第一批非有限输出的模块(共 {len(hits)},只列前 15):") | |
| 88 | +print(">>> 真源头(输入有限=True):") | |
| 89 | +for h in hits: | |
| 90 | + if h[2]: print(f" ★ {h[0]} [{h[1]}] 输入absmax={h[4]} 输出absmax={h[3]}") | |
| 91 | +print(">>> 下游受害者(前 5):") | |
| 92 | +for h in [x for x in hits if not x[2]][:5]: | |
| 93 | + print(f" {h[0]} [{h[1]}]") | |
| 94 | +if not hits: | |
| 95 | + print(" 无 —— dit/text_encoder 内部全程有限,问题在 VAE 解码或后处理") | ... | ... |
minimax-h3/scripts/diag4.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/diag4.py | |
| 1 | +"""用与实际失败运行完全一致的配置定位 NaN:同 prompt、同修复、同显存参数。""" | |
| 2 | +import os, time, torch | |
| 3 | +BASE="/data/models/minimax-h3" | |
| 4 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION","torch") | |
| 5 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 6 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 7 | +os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF","expandable_segments:True") | |
| 8 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 9 | + | |
| 10 | +PROMPT = ("A girl is very happy, she is speaking in english: " | |
| 11 | + "“I enjoy working with Diffsynth-Studio, it's a perfect framework.”") | |
| 12 | +DT=torch.float16 | |
| 13 | +free=torch.cuda.mem_get_info("cuda")[0]/1024**3 | |
| 14 | +vram_limit=max(6.0, free-5.0) # 与 --vram-reserve 5 一致 | |
| 15 | +print(f"[env] free={free:.1f}G vram_limit={vram_limit:.1f}G", flush=True) | |
| 16 | +vc=dict(offload_dtype=DT,offload_device="cpu",onload_dtype=DT,onload_device="cpu", | |
| 17 | + preparing_dtype=DT,preparing_device="cuda",computation_dtype=DT,computation_device="cuda") | |
| 18 | +NF4="DiffSynth-Studio/MiniMax-H3-NF4" | |
| 19 | +pipe=MiniMaxH3Pipeline.from_pretrained(torch_dtype=DT,device="cuda", | |
| 20 | + model_configs=[ModelConfig(model_id=NF4,origin_file_pattern=p,**vc) for p in | |
| 21 | + ["minimax-h3-fl2va-nf4.safetensors","minimax-h3-text-encoder-nf4.safetensors", | |
| 22 | + "video_vae_nf4.safetensors","audio_vae_nf4.safetensors"]], | |
| 23 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3",origin_file_pattern="FL2VA/processor/"), | |
| 24 | + vram_limit=vram_limit) | |
| 25 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 26 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 27 | + | |
| 28 | +FP16_MAX=65504.0 | |
| 29 | +def _fix(mod, tag): | |
| 30 | + mod.computation_dtype = torch.float32 | |
| 31 | + orig = mod.forward | |
| 32 | + def fwd(x,*a,**kw): | |
| 33 | + out = orig(x.float(),*a,**kw) | |
| 34 | + print(f'[cp] fp32 out absmax={float(out.abs().max()):.1f} finite={bool(torch.isfinite(out).all())} dtype={out.dtype}', flush=True) | |
| 35 | + return out | |
| 36 | + mod.forward=fwd | |
| 37 | +_fix(pipe.dit.condition_proj, "condition_proj") | |
| 38 | +print("[fix ] condition_proj fp32+clamp 已装", flush=True) | |
| 39 | + | |
| 40 | +# 先单独查文本编码器输出——它是整条条件链的源头 | |
| 41 | +te_out={} | |
| 42 | +def te_hook(m,i,o): | |
| 43 | + t = o[0] if isinstance(o,(tuple,list)) else o | |
| 44 | + if torch.is_tensor(t) and t.is_floating_point(): | |
| 45 | + te_out["absmax"]=float(t.abs().max()); te_out["finite"]=bool(torch.isfinite(t).all()) | |
| 46 | + te_out["shape"]=tuple(t.shape); te_out["dtype"]=str(t.dtype) | |
| 47 | +h_te = pipe.text_encoder.register_forward_hook(te_hook) | |
| 48 | + | |
| 49 | +hits=[]; seen=set() | |
| 50 | +def mk(name): | |
| 51 | + def hook(mod, inp, out): | |
| 52 | + if name in seen: return | |
| 53 | + ok_in = all(bool(torch.isfinite(t).all()) for t in inp if torch.is_tensor(t) and t.is_floating_point()) | |
| 54 | + outs = out if isinstance(out,(tuple,list)) else (out,) | |
| 55 | + for o in outs: | |
| 56 | + if torch.is_tensor(o) and o.is_floating_point() and not torch.isfinite(o).all(): | |
| 57 | + seen.add(name) | |
| 58 | + ins=[float(t.abs().max()) for t in inp if torch.is_tensor(t) and t.is_floating_point() and t.numel()] | |
| 59 | + fin=torch.isfinite(o) | |
| 60 | + hits.append((name, type(mod).__name__, ok_in, | |
| 61 | + float(o[fin].abs().max()) if fin.any() else float("nan"), | |
| 62 | + max(ins) if ins else None, | |
| 63 | + float((~fin).float().mean())*100)) | |
| 64 | + return | |
| 65 | + return hook | |
| 66 | +handles=[] | |
| 67 | +for tag,mod in (("dit",pipe.dit),("txt",pipe.text_encoder), | |
| 68 | + ("vvae",pipe.video_vae),("avae",pipe.audio_vae)): | |
| 69 | + for n,m in mod.named_modules(): | |
| 70 | + if n: handles.append(m.register_forward_hook(mk(f"{tag}.{n}"))) | |
| 71 | +print(f"[hook] {len(handles)} 个模块", flush=True) | |
| 72 | + | |
| 73 | +video, audio = pipe(prompt=PROMPT, height=480,width=832,num_frames=124, | |
| 74 | + num_inference_steps=1, seed=0, flow_shift=6.0) | |
| 75 | +for h in handles: h.remove() | |
| 76 | +h_te.remove() | |
| 77 | + | |
| 78 | +print("\n===== 结果 =====") | |
| 79 | +print("[文本编码器输出]", te_out) | |
| 80 | +import numpy as np | |
| 81 | +try: | |
| 82 | + a=np.stack([np.asarray(f.convert("RGB"),dtype=np.float32) for f in video[::20]]) | |
| 83 | + print(f"[video] frames={len(video)} mean={a.mean():.4f} std={a.std():.4f}") | |
| 84 | +except Exception as e: | |
| 85 | + print("[video]", type(video), e) | |
| 86 | +print(f"[audio] finite={bool(torch.isfinite(audio).all())}") | |
| 87 | +print(f"\n>>> 真源头(输入有限=True),共 {len([h for h in hits if h[2]])} 个:") | |
| 88 | +for h in hits: | |
| 89 | + if h[2]: print(f" ★ {h[0]} [{h[1]}] 输入absmax={h[4]} 有限部分absmax={h[3]} 非有限占比={h[5]:.4f}%") | |
| 90 | +print(f">>> 受害者总数 {len([h for h in hits if not h[2]])},前 5:") | |
| 91 | +for h in [x for x in hits if not x[2]][:5]: print(f" {h[0]} [{h[1]}]") | ... | ... |
minimax-h3/scripts/download.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/download.py | |
| 1 | +"""Pre-download MiniMax-H3 NF4 weights via DiffSynth's own ModelConfig, | |
| 2 | +so the on-disk layout matches exactly what the pipeline later resolves. | |
| 3 | + | |
| 4 | +已下过的会被 download_if_necessary() 跳过,重跑是幂等的。 | |
| 5 | +两个分区:FL2VA 收首末帧,Ref2VA 收任意多张参考图/参考视频, | |
| 6 | +DiT 与 processor 各一套,VAE 和文本编码器共用。""" | |
| 7 | +import os, time, traceback | |
| 8 | +from diffsynth.core.loader import ModelConfig | |
| 9 | + | |
| 10 | +JOBS = [ # small first, so a load smoke-test can start early | |
| 11 | + ("MiniMax/MiniMax-H3", "FL2VA/processor/"), | |
| 12 | + ("DiffSynth-Studio/MiniMax-H3-NF4", "audio_vae_nf4.safetensors"), | |
| 13 | + ("DiffSynth-Studio/MiniMax-H3-NF4", "video_vae_nf4.safetensors"), | |
| 14 | + ("lightx2v/Minimax-h3-Turbo", "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"), | |
| 15 | + ("DiffSynth-Studio/MiniMax-H3-NF4", "minimax-h3-text-encoder-nf4.safetensors"), | |
| 16 | + ("DiffSynth-Studio/MiniMax-H3-NF4", "minimax-h3-fl2va-nf4.safetensors"), | |
| 17 | + # Ref2VA:参考图模式。Turbo LoRA 只有 v0.1,FL2V 那个是 v1.0 768p。 | |
| 18 | + ("MiniMax/MiniMax-H3", "Ref2VA/processor/"), | |
| 19 | + ("lightx2v/Minimax-h3-Turbo", "minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors"), | |
| 20 | + ("DiffSynth-Studio/MiniMax-H3-NF4", "minimax-h3-ref2va-nf4.safetensors"), | |
| 21 | + # 上面那个 bf16 版实测出噪点(二分见 docs/H3)。comfyui 版是另一套键名,试它能不能被认。 | |
| 22 | + ("lightx2v/Minimax-h3-Turbo", "minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors"), | |
| 23 | +] | |
| 24 | + | |
| 25 | +for i, (mid, pat) in enumerate(JOBS, 1): | |
| 26 | + t0 = time.time() | |
| 27 | + print(f"[{i}/{len(JOBS)}] {mid} :: {pat}", flush=True) | |
| 28 | + for attempt in range(1, 6): | |
| 29 | + try: | |
| 30 | + ModelConfig(model_id=mid, origin_file_pattern=pat).download_if_necessary() | |
| 31 | + print(f" done in {time.time()-t0:.0f}s", flush=True) | |
| 32 | + break | |
| 33 | + except Exception: | |
| 34 | + print(f" attempt {attempt} failed:\n{traceback.format_exc()}", flush=True) | |
| 35 | + time.sleep(15) | |
| 36 | + else: | |
| 37 | + print(f" GIVING UP on {mid}::{pat}", flush=True) | |
| 38 | +print("ALL DOWNLOADS FINISHED", flush=True) | ... | ... |
minimax-h3/scripts/probe-attention-backend.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/probe-attention-backend.py | |
| 1 | +import os, sys, torch | |
| 2 | +from diffsynth.core.attention import attention_forward | |
| 3 | +A = sys.modules[attention_forward.__module__] | |
| 4 | +print("DIFFSYNTH_ATTENTION_IMPLEMENTATION env =", os.environ.get("DIFFSYNTH_ATTENTION_IMPLEMENTATION")) | |
| 5 | +print("selected =", A.ATTENTION_IMPLEMENTATION, | |
| 6 | + "| xformers=", A.XFORMERS_AVAILABLE, "flash2=", A.FLASH_ATTN_2_AVAILABLE, | |
| 7 | + "flash3=", A.FLASH_ATTN_3_AVAILABLE, "flash4=", A.FLASH_ATTN_4_AVAILABLE, "sage=", A.SAGE_ATTN_AVAILABLE) | |
| 8 | +dev="cuda:1" | |
| 9 | +q=torch.randn(1,56,14100,128,device=dev,dtype=torch.float16) | |
| 10 | +o=A.attention_forward(q,q,q) | |
| 11 | +print("[OK] attention_forward fp16 S=14100:", tuple(o.shape), "finite=", bool(torch.isfinite(o).all())) | ... | ... |
minimax-h3/scripts/probe-cp.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/probe-cp.py | |
| 1 | +"""摸清 dit.condition_proj 的溢出幅度:权重/输入/fp32 参考输出的量级。""" | |
| 2 | +import os, time, torch | |
| 3 | +BASE="/data/models/minimax-h3" | |
| 4 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION","torch") | |
| 5 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 6 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 7 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 8 | +DT=torch.float16 | |
| 9 | +free=torch.cuda.mem_get_info("cuda")[0]/1024**3 | |
| 10 | +vc=dict(offload_dtype=DT,offload_device="cpu",onload_dtype=DT,onload_device="cpu", | |
| 11 | + preparing_dtype=DT,preparing_device="cuda",computation_dtype=DT,computation_device="cuda") | |
| 12 | +NF4="DiffSynth-Studio/MiniMax-H3-NF4" | |
| 13 | +pipe=MiniMaxH3Pipeline.from_pretrained(torch_dtype=DT,device="cuda", | |
| 14 | + model_configs=[ModelConfig(model_id=NF4,origin_file_pattern=p,**vc) for p in | |
| 15 | + ["minimax-h3-fl2va-nf4.safetensors","minimax-h3-text-encoder-nf4.safetensors", | |
| 16 | + "video_vae_nf4.safetensors","audio_vae_nf4.safetensors"]], | |
| 17 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3",origin_file_pattern="FL2VA/processor/"), | |
| 18 | + vram_limit=max(6.0,free-2.0)) | |
| 19 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 20 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 21 | + | |
| 22 | +cp = pipe.dit.condition_proj | |
| 23 | +print("[cp] type:", type(cp).__name__) | |
| 24 | +for n,p_ in cp.named_parameters(recurse=True): | |
| 25 | + print(f" param {n}: shape={tuple(p_.shape)} dtype={p_.dtype} absmax={float(p_.abs().max()):.4g}") | |
| 26 | +for n,b in cp.named_buffers(recurse=True): | |
| 27 | + print(f" buf {n}: {tuple(b.shape)} {b.dtype}") | |
| 28 | + | |
| 29 | +cap={} | |
| 30 | +def hook(m,i,o): | |
| 31 | + x=i[0] | |
| 32 | + cap["x"]=x.detach() | |
| 33 | + cap["o"]=o.detach() if torch.is_tensor(o) else None | |
| 34 | +h=cp.register_forward_hook(hook) | |
| 35 | +try: | |
| 36 | + pipe(prompt="测试", height=480,width=832,num_frames=124, | |
| 37 | + num_inference_steps=1, seed=42, flow_shift=6.0) | |
| 38 | +except Exception as e: | |
| 39 | + print("[gen] 提前中断(预期):", type(e).__name__, str(e)[:120]) | |
| 40 | +h.remove() | |
| 41 | + | |
| 42 | +x=cap.get("x") | |
| 43 | +if x is None: | |
| 44 | + print("!! 没抓到输入"); raise SystemExit(1) | |
| 45 | +print(f"\n[输入] shape={tuple(x.shape)} dtype={x.dtype} absmax={float(x.abs().max()):.6g} " | |
| 46 | + f"mean={float(x.float().mean()):.4g} std={float(x.float().std()):.4g} finite={bool(torch.isfinite(x).all())}") | |
| 47 | +o=cap.get("o") | |
| 48 | +if o is not None: | |
| 49 | + fin=torch.isfinite(o) | |
| 50 | + print(f"[fp16 输出] absmax={float(o[fin].abs().max()) if fin.any() else float('nan'):.6g} " | |
| 51 | + f"非有限占比={100*(~fin).float().mean():.2f}%") | |
| 52 | + | |
| 53 | +# fp32 参考 | |
| 54 | +W=None;B=None | |
| 55 | +for n,p_ in cp.named_parameters(recurse=True): | |
| 56 | + if n.endswith("weight"): W=p_ | |
| 57 | + if n.endswith("bias"): B=p_ | |
| 58 | +if W is not None: | |
| 59 | + ref=torch.nn.functional.linear(x.float(), W.float(), B.float() if B is not None else None) | |
| 60 | + print(f"[fp32 参考] absmax={float(ref.abs().max()):.6g} (fp16 上限 65504)") | |
| 61 | + print(f" → 超出 fp16 上限的元素占比 {100*(ref.abs()>65504).float().mean():.4f}%") | ... | ... |
minimax-h3/scripts/probe-h3-shapes.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/probe-h3-shapes.py | |
| 1 | +import torch, time | |
| 2 | +dev="cuda:1" | |
| 3 | +torch.cuda.set_device(dev) | |
| 4 | +from torch.nn.attention import SDPBackend, sdpa_kernel | |
| 5 | +S, H, D = 14100, 56, 128 # real H3 shape @ 480x832x124f | |
| 6 | +for dt in (torch.float16,): | |
| 7 | + q=k=v=torch.randn(1,H,S,D,device=dev,dtype=dt) | |
| 8 | + for be,nm in ((SDPBackend.EFFICIENT_ATTENTION,"EFFICIENT"),): | |
| 9 | + try: | |
| 10 | + with sdpa_kernel(be): | |
| 11 | + torch.cuda.synchronize(); t0=time.time() | |
| 12 | + o=torch.nn.functional.scaled_dot_product_attention(q,k,v) | |
| 13 | + torch.cuda.synchronize() | |
| 14 | + print(f"[OK ] SDPA {nm} fp16 S={S} H={H} D={D}: {o.shape} {time.time()-t0:.2f}s peak={torch.cuda.max_memory_allocated(dev)/2**30:.2f}G") | |
| 15 | + except Exception as e: | |
| 16 | + print(f"[ERR] SDPA {nm} fp16 D={D}: {type(e).__name__}: {str(e)[:200]}") | |
| 17 | + del q,k,v | |
| 18 | + torch.cuda.empty_cache(); torch.cuda.reset_peak_memory_stats(dev) | |
| 19 | + | |
| 20 | +# fp16 GEMM throughput on this card (H3 block-ish shape) | |
| 21 | +a=torch.randn(14100,5376,device=dev,dtype=torch.float16); b=torch.randn(5376,28672,device=dev,dtype=torch.float16) | |
| 22 | +for _ in range(2): c=a@b | |
| 23 | +torch.cuda.synchronize(); t0=time.time() | |
| 24 | +for _ in range(5): c=a@b | |
| 25 | +torch.cuda.synchronize() | |
| 26 | +fl=2*14100*5376*28672*5 | |
| 27 | +print(f"fp16 GEMM effective: {fl/(time.time()-t0)/1e12:.1f} TFLOPS") | |
| 28 | +# fp16 overflow sanity on a deep-ish accumulation | |
| 29 | +x=torch.randn(4096,4096,device=dev,dtype=torch.float16)*4 | |
| 30 | +y=x | |
| 31 | +for i in range(20): y=torch.nn.functional.layer_norm(y@x/64,(4096,)) | |
| 32 | +print("fp16 deep-chain finite:", bool(torch.isfinite(y).all())) | ... | ... |
minimax-h3/scripts/probe-nf4-fp16.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/probe-nf4-fp16.py | |
| 1 | +import os, torch | |
| 2 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION","torch") | |
| 3 | +import bitsandbytes as bnb | |
| 4 | +print("bnb", bnb.__version__) | |
| 5 | +dev="cuda:1" | |
| 6 | +# --- NF4 quantize + forward in FP16 on sm_75 --- | |
| 7 | +lin = torch.nn.Linear(5376, 14336, bias=False, dtype=torch.float16) | |
| 8 | +q = bnb.nn.Linear4bit(5376, 14336, bias=False, compute_dtype=torch.float16, quant_type="nf4", compress_statistics=True) | |
| 9 | +q.weight = bnb.nn.Params4bit(lin.weight.data, requires_grad=False, quant_type="nf4", compress_statistics=True) | |
| 10 | +q = q.to(dev) | |
| 11 | +x = torch.randn(1024, 5376, device=dev, dtype=torch.float16) | |
| 12 | +y = q(x) | |
| 13 | +ref = torch.nn.functional.linear(x, lin.weight.to(dev)) | |
| 14 | +err = (y.float()-ref.float()).abs().mean().item()/ref.float().abs().mean().item() | |
| 15 | +print(f"[OK ] NF4 fp16 forward: out={tuple(y.shape)} dtype={y.dtype} finite={bool(torch.isfinite(y).all())} rel_err={err:.4f}") | |
| 16 | +# --- prequantized round-trip (what DiffSynth actually does when loading .safetensors) --- | |
| 17 | +qs = q.weight.quant_state.as_dict(packed=True) | |
| 18 | +w2 = bnb.nn.Params4bit.from_prequantized(data=q.weight.data, quantized_stats={k:(v.to(dev) if torch.is_tensor(v) else v) for k,v in qs.items()}, requires_grad=False, device=dev) | |
| 19 | +q2 = bnb.nn.Linear4bit(5376,14336,bias=False,compute_dtype=torch.float16,quant_type="nf4") | |
| 20 | +q2.weight = w2; q2 = q2.to(dev) | |
| 21 | +y2 = q2(x) | |
| 22 | +print(f"[OK ] NF4 prequantized reload: match={torch.allclose(y,y2)} dtype={y2.dtype}") | |
| 23 | +# --- DiffSynth attention backend selection --- | |
| 24 | +import sys | |
| 25 | +from diffsynth.core.attention import attention_forward | |
| 26 | +A = sys.modules[attention_forward.__module__] | |
| 27 | +print("ATTENTION_IMPLEMENTATION =", A.ATTENTION_IMPLEMENTATION, | |
| 28 | + "| xformers_avail =", A.XFORMERS_AVAILABLE, "| flash2 =", A.FLASH_ATTN_2_AVAILABLE, "| sage =", A.SAGE_ATTN_AVAILABLE) | |
| 29 | +o = A.attention_forward(*[torch.randn(1,56,4096,128,device=dev,dtype=torch.float16)]*3) | |
| 30 | +print("[OK ] diffsynth attention_forward fp16:", tuple(o.shape), "finite=", bool(torch.isfinite(o).all())) | ... | ... |
minimax-h3/scripts/probe-turing-caps.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/probe-turing-caps.py | |
| 1 | +import torch, subprocess, sys, os | |
| 2 | +print("torch", torch.__version__, "cuda", torch.version.cuda) | |
| 3 | +print("arch_list", torch.cuda.get_arch_list()) | |
| 4 | +print("n_gpu", torch.cuda.device_count()) | |
| 5 | +for i in range(torch.cuda.device_count()): | |
| 6 | + free, total = torch.cuda.mem_get_info(i) | |
| 7 | + p = torch.cuda.get_device_properties(i) | |
| 8 | + print(f" gpu{i} {p.name} sm_{p.major}{p.minor} free={free/2**30:.1f}G total={total/2**30:.1f}G") | |
| 9 | + | |
| 10 | +dev = "cuda:0" | |
| 11 | +def t(name, fn): | |
| 12 | + try: | |
| 13 | + r = fn(); print(f"[OK ] {name}: {r}") | |
| 14 | + except Exception as e: | |
| 15 | + print(f"[ERR] {name}: {type(e).__name__}: {str(e)[:180]}") | |
| 16 | + | |
| 17 | +t("fp16 matmul", lambda: (torch.randn(512,512,device=dev,dtype=torch.float16) @ torch.randn(512,512,device=dev,dtype=torch.float16)).float().abs().mean().item()) | |
| 18 | +t("bf16 matmul", lambda: (torch.randn(512,512,device=dev,dtype=torch.bfloat16) @ torch.randn(512,512,device=dev,dtype=torch.bfloat16)).float().abs().mean().item()) | |
| 19 | +t("bf16 layernorm", lambda: torch.nn.functional.layer_norm(torch.randn(8,512,device=dev,dtype=torch.bfloat16),(512,)).float().abs().mean().item()) | |
| 20 | + | |
| 21 | +from torch.nn.attention import SDPBackend, sdpa_kernel | |
| 22 | +q=lambda dt: torch.randn(1,8,1024,64,device=dev,dtype=dt) | |
| 23 | +for dt in (torch.float16, torch.bfloat16): | |
| 24 | + for be in (SDPBackend.FLASH_ATTENTION, SDPBackend.EFFICIENT_ATTENTION, SDPBackend.MATH): | |
| 25 | + def f(dt=dt,be=be): | |
| 26 | + with sdpa_kernel(be): | |
| 27 | + return tuple(torch.nn.functional.scaled_dot_product_attention(q(dt),q(dt),q(dt)).shape) | |
| 28 | + t(f"SDPA {str(dt).split('.')[-1]:9s} {be.name}", f) | |
| 29 | +def auto(): | |
| 30 | + return tuple(torch.nn.functional.scaled_dot_product_attention(q(torch.float16),q(torch.float16),q(torch.float16)).shape) | |
| 31 | +t("SDPA fp16 AUTO", auto) | |
| 32 | + | |
| 33 | +print("--- installed pkgs of interest ---") | |
| 34 | +import importlib.metadata as md | |
| 35 | +for name in ["transformers","accelerate","diffusers","bitsandbytes","torchao","safetensors","einops","modelscope","av","torchaudio","torchcodec","librosa","peft","xformers","flash_attn","sageattention","imageio","imageio-ffmpeg","numpy"]: | |
| 36 | + try: print(f" {name}=={md.version(name)}") | |
| 37 | + except Exception: print(f" {name}: NOT INSTALLED") | |
| 38 | +print("--- ffmpeg ---") | |
| 39 | +print(subprocess.run(["bash","-lc","ffmpeg -version 2>&1|head -1; which ffmpeg"],capture_output=True,text=True).stdout) | ... | ... |
minimax-h3/scripts/probe2.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/probe2.py | |
| 1 | +"""量清楚 condition_proj 的 fp32 真值幅度:决定是局部修还是整条路径抬 fp32。""" | |
| 2 | +import os, torch, torch.nn.functional as F | |
| 3 | +BASE="/data/models/minimax-h3" | |
| 4 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION","torch") | |
| 5 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 6 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 7 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 8 | +DT=torch.float16 | |
| 9 | +free=torch.cuda.mem_get_info("cuda")[0]/1024**3 | |
| 10 | +vc=dict(offload_dtype=DT,offload_device="cpu",onload_dtype=DT,onload_device="cpu", | |
| 11 | + preparing_dtype=DT,preparing_device="cuda",computation_dtype=DT,computation_device="cuda") | |
| 12 | +NF4="DiffSynth-Studio/MiniMax-H3-NF4" | |
| 13 | +pipe=MiniMaxH3Pipeline.from_pretrained(torch_dtype=DT,device="cuda", | |
| 14 | + model_configs=[ModelConfig(model_id=NF4,origin_file_pattern=p,**vc) for p in | |
| 15 | + ["minimax-h3-fl2va-nf4.safetensors","minimax-h3-text-encoder-nf4.safetensors", | |
| 16 | + "video_vae_nf4.safetensors","audio_vae_nf4.safetensors"]], | |
| 17 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3",origin_file_pattern="FL2VA/processor/"), | |
| 18 | + vram_limit=max(6.0,free-2.0)) | |
| 19 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 20 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 21 | + | |
| 22 | +cp = pipe.dit.condition_proj | |
| 23 | +class Stop(Exception): pass | |
| 24 | +res={} | |
| 25 | +orig_lf = cp.linear_forward | |
| 26 | +orig_lora = cp.lora_forward | |
| 27 | +def lf(x, weight, bias): | |
| 28 | + o32 = F.linear(x.float(), weight.float(), bias.float() if bias is not None else None) | |
| 29 | + res["x_absmax"]=float(x.abs().max()); res["x_dtype"]=str(x.dtype) | |
| 30 | + res["lin32_absmax"]=float(o32.abs().max()) | |
| 31 | + res["lin32_over_fp16"]=float((o32.abs()>65504).float().mean())*100 | |
| 32 | + res["lin32"]=o32 | |
| 33 | + return o32 | |
| 34 | +def lora(x, out): | |
| 35 | + o = out | |
| 36 | + for A,B in zip(cp.lora_A_weights, cp.lora_B_weights): | |
| 37 | + o = o + x.float() @ A.T.to(device=x.device,dtype=torch.float32) @ B.T.to(device=x.device,dtype=torch.float32) | |
| 38 | + res["n_lora"]=len(cp.lora_A_weights) | |
| 39 | + res["final32_absmax"]=float(o.abs().max()) | |
| 40 | + res["final32_over_fp16"]=float((o.abs()>65504).float().mean())*100 | |
| 41 | + res["p999"]=float(torch.quantile(o.abs().flatten().float(), 0.999)) | |
| 42 | + raise Stop() | |
| 43 | +cp.linear_forward=lf; cp.lora_forward=lora | |
| 44 | +try: | |
| 45 | + pipe(prompt="一只橘猫在窗台上打哈欠", height=480,width=832,num_frames=124, | |
| 46 | + num_inference_steps=1, seed=42, flow_shift=6.0) | |
| 47 | +except Stop: | |
| 48 | + pass | |
| 49 | +print("\n===== condition_proj fp32 真值 =====") | |
| 50 | +for k in ["x_dtype","x_absmax","lin32_absmax","lin32_over_fp16","n_lora","final32_absmax","final32_over_fp16","p999"]: | |
| 51 | + if k in res: print(f" {k:20s} = {res[k]}") | |
| 52 | +print(f" fp16 上限 = 65504") | ... | ... |
minimax-h3/scripts/run.sh
0 → 100755
| 1 | +++ a/minimax-h3/scripts/run.sh | |
| 1 | +#!/usr/bin/env bash | |
| 2 | +# MiniMax-H3 FL2VA NF4 launcher. usage: ./run.sh <GPU_ID> [args for t2va.py ...] | |
| 3 | +# ./run.sh 1 # turbo 4-step, 480x832x124f | |
| 4 | +# ./run.sh 1 --no-turbo --steps 50 # full quality | |
| 5 | +# ./run.sh 2 --prompt "..." --seed 7 | |
| 6 | +set -euo pipefail | |
| 7 | +B=/data/models/minimax-h3 | |
| 8 | +GPU="${1:-1}"; shift || true | |
| 9 | + | |
| 10 | +export CUDA_VISIBLE_DEVICES="$GPU" | |
| 11 | +export DIFFSYNTH_ATTENTION_IMPLEMENTATION=torch # sm_75: no flash/sage; pin instead of auto-picking xformers | |
| 12 | +export DIFFSYNTH_MODEL_BASE_PATH="$B/models" | |
| 13 | +export MODELSCOPE_CACHE="$B/.mscache" | |
| 14 | +export PYTHONUNBUFFERED=1 | |
| 15 | +# NOTE: deliberately NOT setting PYTHONNOUSERSITE -- the venv's site-packages already | |
| 16 | +# precedes ~/.local in sys.path, so our bitsandbytes 0.50.1 shadows the broken 0.45.1 there. | |
| 17 | + | |
| 18 | +# GPU0/1 sit on NUMA0, GPU2/3 on NUMA1 -- keep the CPU-offloaded weights on the near node. | |
| 19 | +case "$GPU" in 0|1) NODE=0 ;; 2|3) NODE=1 ;; *) NODE="" ;; esac | |
| 20 | +BIND=(); if [ -n "$NODE" ] && command -v numactl >/dev/null 2>&1; then BIND=(numactl --cpunodebind="$NODE" --membind="$NODE"); fi | |
| 21 | + | |
| 22 | +LOG="$B/logs/gpu${GPU}_$(date +%Y%m%d-%H%M%S).log" | |
| 23 | +echo "[run] GPU=$GPU numa=${NODE:-none} log=$LOG" | |
| 24 | +"${BIND[@]}" "$B/venv/bin/python" "$B/scripts/t2va.py" "$@" 2>&1 | tee "$LOG" | ... | ... |
minimax-h3/scripts/t2va.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/t2va.py | |
| 1 | +#!/usr/bin/env python | |
| 2 | +"""MiniMax-H3 FL2VA (NF4) Text -> Video+Audio on RTX 2080 Ti (Turing / sm_75). | |
| 3 | + | |
| 4 | +Turing deltas vs the official example (all deliberate): | |
| 5 | + * FP16 everywhere, never BF16 -- sm_75 has no bf16 SDPA kernel at all | |
| 6 | + (EFFICIENT and FLASH both report "No available kernel"), so bf16 silently | |
| 7 | + falls back to the MATH backend and OOMs on a ~14k-token sequence. | |
| 8 | + * DIFFSYNTH_ATTENTION_IMPLEMENTATION=torch -- FlashAttention 2/3/4 and | |
| 9 | + SageAttention are sm_80+. xformers works here but would be auto-selected | |
| 10 | + ahead of torch; pin the backend so runs are reproducible. | |
| 11 | + * vram_limit from mem_get_info()[0] (FREE) not [1] (TOTAL) -- other processes | |
| 12 | + share these GPUs. | |
| 13 | + * offload to "cpu", never "disk" -- /data is a 7200rpm HDD (184 MB/s). | |
| 14 | +""" | |
| 15 | +import os, sys, time, argparse | |
| 16 | + | |
| 17 | +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 18 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION", "torch") | |
| 19 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", os.path.join(BASE, "models")) | |
| 20 | + | |
| 21 | +p = argparse.ArgumentParser() | |
| 22 | +p.add_argument("--prompt", default="A girl is very happy, she is speaking in english: " | |
| 23 | + "“I enjoy working with Diffsynth-Studio, it's a perfect framework.”") | |
| 24 | +p.add_argument("--height", type=int, default=480) | |
| 25 | +p.add_argument("--width", type=int, default=832) | |
| 26 | +p.add_argument("--frames", type=int, default=124) | |
| 27 | +p.add_argument("--steps", type=int, default=4) | |
| 28 | +p.add_argument("--flow-shift", type=float, default=None, help="default 6.0 with turbo, 12.0 without") | |
| 29 | +p.add_argument("--seed", type=int, default=0) | |
| 30 | +p.add_argument("--turbo", action="store_true", default=True) | |
| 31 | +p.add_argument("--no-turbo", dest="turbo", action="store_false") | |
| 32 | +p.add_argument("--dtype", choices=["fp32","fp16"], default="fp32", | |
| 33 | + help="fp32=唯一能在 sm_75 上出片的选择(默认);fp16 只用于复现全黑故障") | |
| 34 | +p.add_argument("--no-fp32-residual", dest="fp32_residual", action="store_false", | |
| 35 | + help="关闭 DiT 残差流的 fp32 兜底(默认开启;关掉在 sm_75 上会出全黑视频)") | |
| 36 | +p.add_argument("--no-fp32-cond", dest="fp32_cond", action="store_false", | |
| 37 | + help="关闭 condition_proj 的 fp32 兜底(默认开启;关掉在 sm_75 上会出全黑视频)") | |
| 38 | +p.add_argument("--fp32-accum", action="store_true", | |
| 39 | + help="disable fp16 reduced-precision reduction (~2x slower, use if output is NaN/artifacted)") | |
| 40 | +p.add_argument("--vram-reserve", type=float, default=None, | |
| 41 | + help="留给激活的显存(GB)。默认按 dtype 自适应:fp32=14(实测全分辨率峰值 12.2G),fp16=5") | |
| 42 | +p.add_argument("--out", default=None) | |
| 43 | +args = p.parse_args() | |
| 44 | + | |
| 45 | +import torch | |
| 46 | +if args.fp32_accum: | |
| 47 | + torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False | |
| 48 | + | |
| 49 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 50 | +from diffsynth.utils.data.audio_video import write_video_audio | |
| 51 | + | |
| 52 | +# dtype 决定这条线能不能出片。实测(2026-08-18, sm_75, 448x256x28f, 唯一变量是 dtype): | |
| 53 | +# fp32 -> 视频 std=50.6、音频 finite ✅ fp16 -> 124 帧纯黑、音频 NaN ❌ | |
| 54 | +# 原因是 fp16 的动态范围装不下这个 bf16 训练的模型:dit.blocks.0 输入 absmax 89 | |
| 55 | +# 已放大到 38176,50 层残差累积后必然突破 fp16 上限 65504。 | |
| 56 | +# bf16 在 sm_75 上不可用(无硬件支持),所以 fp32 是这张卡上唯一能出片的选择。 | |
| 57 | +# 保留 --dtype fp16 只为复现/对照,不要用它出片。 | |
| 58 | +DTYPE = {"fp32": torch.float32, "fp16": torch.float16}[args.dtype] | |
| 59 | +free_gb = torch.cuda.mem_get_info("cuda")[0] / 1024**3 # [0]=free, official example wrongly uses [1]=total | |
| 60 | +if args.vram_reserve is None: | |
| 61 | + # fp32 的激活是 fp16 的两倍,权重要多让位。实测 832x480x124f fp32: | |
| 62 | + # vram_limit 7.3G 时峰值 12.2G,跑通;vram_limit 19.3G 时 OOM。 | |
| 63 | + args.vram_reserve = 14.0 if DTYPE is torch.float32 else 5.0 | |
| 64 | +vram_limit = max(4.0, free_gb - args.vram_reserve) | |
| 65 | +if vram_limit + 1.0 > free_gb: | |
| 66 | + print(f"[warn] 空闲显存仅 {free_gb:.1f}G,vram_limit={vram_limit:.1f}G 几乎没有余量," | |
| 67 | + f"很可能 OOM。先停掉占显存的服务(见 qwen38-deploy/RESTORE-GPU.md)", flush=True) | |
| 68 | + | |
| 69 | +prop = torch.cuda.get_device_properties(0) | |
| 70 | +print(f"[env] GPU={prop.name} sm_{prop.major}{prop.minor} free={free_gb:.1f}G vram_limit={vram_limit:.1f}G", flush=True) | |
| 71 | +print(f"[env] dtype={DTYPE} attention={os.environ['DIFFSYNTH_ATTENTION_IMPLEMENTATION']} " | |
| 72 | + f"fp16_reduced_accum={torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction}", flush=True) | |
| 73 | +assert prop.major == 7, "this script is tuned for Turing; re-check dtype choices on other archs" | |
| 74 | + | |
| 75 | +vram_config = dict( | |
| 76 | + offload_dtype=DTYPE, offload_device="cpu", # not "disk": /data is a spinning HDD | |
| 77 | + onload_dtype=DTYPE, onload_device="cpu", | |
| 78 | + preparing_dtype=DTYPE, preparing_device="cuda", | |
| 79 | + computation_dtype=DTYPE, computation_device="cuda", | |
| 80 | +) | |
| 81 | +NF4 = "DiffSynth-Studio/MiniMax-H3-NF4" | |
| 82 | + | |
| 83 | +t0 = time.time() | |
| 84 | +pipe = MiniMaxH3Pipeline.from_pretrained( | |
| 85 | + torch_dtype=DTYPE, device="cuda", | |
| 86 | + model_configs=[ | |
| 87 | + ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-fl2va-nf4.safetensors", **vram_config), | |
| 88 | + ModelConfig(model_id=NF4, origin_file_pattern="minimax-h3-text-encoder-nf4.safetensors", **vram_config), | |
| 89 | + ModelConfig(model_id=NF4, origin_file_pattern="video_vae_nf4.safetensors", **vram_config), | |
| 90 | + ModelConfig(model_id=NF4, origin_file_pattern="audio_vae_nf4.safetensors", **vram_config), | |
| 91 | + ], | |
| 92 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3", origin_file_pattern="FL2VA/processor/"), | |
| 93 | + vram_limit=vram_limit, | |
| 94 | +) | |
| 95 | +print(f"[load] pipeline ready in {time.time()-t0:.0f}s", flush=True) | |
| 96 | + | |
| 97 | +if args.turbo: | |
| 98 | + # LoRA rides on the NF4 layers: AutoWrappedQuantizedModule inherits LoRAHotLoadMixin, | |
| 99 | + # so lora_forward() adds x@A@B to the quantized output -- no base dequantisation needed. | |
| 100 | + pipe.load_lora(pipe.dit, ModelConfig( | |
| 101 | + model_id="lightx2v/Minimax-h3-Turbo", | |
| 102 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 103 | +# --- sm_75 必需:condition_proj 在 fp16 下累加溢出 -> inf -> 全管线 NaN -> 全黑视频 --- | |
| 104 | +# 实测(2026-08-18):该层输入 absmax≈17808、权重 absmax≈4.66、K=5120, | |
| 105 | +# fp16 累加器(上限 65504)有 0.04% 的输出溢出成 inf,随后污染下游 999 个模块。 | |
| 106 | +# 它只是 (1,5120)x(5376,5120) 的一次投影,改走 fp32 的算力开销可忽略。 | |
| 107 | +# 注意:--fp32-accum 对此无效——溢出发生在累加器量级,不是 reduced-precision reduction。 | |
| 108 | +def _force_fp32_linear(mod, tag): | |
| 109 | + """该层在 fp32 里算,且**输出保持 fp32**流入 token_refiner。 | |
| 110 | + | |
| 111 | + 实测(2026-08-18, sm_75): | |
| 112 | + 文本编码器输出 absmax 15328(有限、健康,源头不在它); | |
| 113 | + condition_proj 的 fp32 真值 absmax 可达 79554,而 fp16 上限 65504。 | |
| 114 | + 产生的 inf 会污染下游 2985 个模块,最终 124 帧纯黑(且原脚本退出码为 0)。 | |
| 115 | + 根因是 Turing 无 bf16:模型按 bf16 训练(bf16 有 fp32 的动态范围), | |
| 116 | + fp16 装不下这条条件路径。 | |
| 117 | + | |
| 118 | + 走过的四条弯路,都记下来免得后人重走: | |
| 119 | + 1) 替换整个 forward —— 绕过 preparing/onload 换入机制。 | |
| 120 | + 2) 只换 linear_forward、最后仍 `.to(fp16)` —— 无效:fp32 算得对, | |
| 121 | + 溢出发生在那次类型转换上。自检若只查转换前的张量会永远显示"正常"。 | |
| 122 | + 3) 钳位到 65504 再降回 fp16 —— **反而更糟**:token_refiner.blocks.0 是 | |
| 123 | + `h = x + attn(norm1(x))`,x 恰好等于 fp16 最大可表示值时, | |
| 124 | + 再加任何正数立刻变 inf(实测 3.45% 非有限)。钳位不留余量等于自造故障。 | |
| 125 | + 4) 输出保持 fp32 但 vram_limit=19.3G —— 数值对了但 CUDA OOM。 | |
| 126 | + 配 `--vram-reserve 5`(vram_limit 16.3G) + PYTORCH_ALLOC_CONF=expandable_segments:True | |
| 127 | + 后峰值 17.2G,有余量。 | |
| 128 | + | |
| 129 | + 库里 _embed() 在 token_refiner 之后本来就有 `text_embed.to(dtype)`, | |
| 130 | + 那时已归一化、量级回落,那次转换是安全的。 | |
| 131 | + 下游 RMSNorm 会因 dtype 不一致发性能警告(非错误),可忽略。 | |
| 132 | + """ | |
| 133 | + mod.computation_dtype = torch.float32 | |
| 134 | + orig = mod.forward | |
| 135 | + def fwd(x, *a, **kw): | |
| 136 | + out = orig(x.float(), *a, **kw) | |
| 137 | + if not torch.isfinite(out).all(): | |
| 138 | + print(f"[warn] {tag} 即使 fp32 仍非有限", flush=True) | |
| 139 | + return out # 保持 fp32:既不降回也不钳位 | |
| 140 | + mod.forward = fwd | |
| 141 | + return mod | |
| 142 | + | |
| 143 | +# --- sm_75 必需 #2:DiT 残差流保持 fp32 --- | |
| 144 | +# 实测(2026-08-18):dit.blocks.0 输入 absmax 89.25 → 输出 38176,且有极少量元素溢出。 | |
| 145 | +# 50 个 block × 每个 2 次残差相加 = 100 个溢出点,累积后必然爆 fp16 的 65504。 | |
| 146 | +# | |
| 147 | +# 病灶与 condition_proj 同构,见 minimax_h3_dit.py:61-67: | |
| 148 | +# def _modulate_gate(x, gate, other, indices): | |
| 149 | +# return (x + gate.index_select(0, indices) * other).to(x.dtype) | |
| 150 | +# ^^^^^^^^^^ 提升到 fp32 算完又降回 fp16 | |
| 151 | +# 库作者自己的注释也写了 "index_select on the AdaLN params can promote the expression", | |
| 152 | +# 但把结果降回 fp16 恰恰丢掉了提升带来的动态范围。 | |
| 153 | +# | |
| 154 | +# 做法是标准混合精度:**累加器(残差流)fp32,喂给子模块的张量仍 fp16**。 | |
| 155 | +# _modulate_gate -> 返回 fp32(它的输出就是残差流,下游是 RMSNorm,容忍混合 dtype) | |
| 156 | +# _modulate_scale_shift -> 显式返回 fp16(它的输出喂给 attn/mlp 的 Linear4bit,必须是 fp16) | |
| 157 | +# 额外显存约 300 MB(残差流 (seq,5376) 一份 fp32),实测峰值仍在 16 GB 以内。 | |
| 158 | +def _patch_dit_residual_fp32(): | |
| 159 | + from diffsynth.models import minimax_h3_dit as _m | |
| 160 | + | |
| 161 | + def _mss(x, shift, scale, indices): | |
| 162 | + return (x.float() * (1.0 + scale.index_select(0, indices).float()) | |
| 163 | + + shift.index_select(0, indices).float()).to(torch.float16) | |
| 164 | + | |
| 165 | + def _mg(x, gate, other, indices): | |
| 166 | + # 不降回 fp16:这一份就是残差流 | |
| 167 | + return x.float() + gate.index_select(0, indices).float() * other.float() | |
| 168 | + | |
| 169 | + _m._modulate_scale_shift = _mss | |
| 170 | + _m._modulate_gate = _mg | |
| 171 | + print("[fix ] DiT 残差流已切到 fp32(_modulate_gate 不再降回 fp16)", flush=True) | |
| 172 | + | |
| 173 | +# 这两个补丁是 fp16 专用的挽救措施(最终证明救不回来,见 README)。 | |
| 174 | +# fp32 下必须失效,否则强制 .to(float16) 会与 fp32 权重冲突: | |
| 175 | +# RuntimeError: mat1 and mat2 must have the same dtype, but got Half and Float | |
| 176 | +if args.fp32_residual and DTYPE is torch.float16: | |
| 177 | + _patch_dit_residual_fp32() | |
| 178 | + | |
| 179 | +if args.fp32_cond and DTYPE is torch.float16: | |
| 180 | + _force_fp32_linear(pipe.dit.condition_proj, "dit.condition_proj") | |
| 181 | + print("[fix ] dit.condition_proj 已切到 fp32 计算(规避 sm_75 fp16 累加溢出)", flush=True) | |
| 182 | + | |
| 183 | +flow_shift = args.flow_shift if args.flow_shift is not None else (6.0 if args.turbo else 12.0) | |
| 184 | + | |
| 185 | +print(f"[gen ] {args.width}x{args.height} {args.frames}f steps={args.steps} " | |
| 186 | + f"turbo={args.turbo} flow_shift={flow_shift} seed={args.seed}", flush=True) | |
| 187 | +t1 = time.time() | |
| 188 | +video, audio = pipe( | |
| 189 | + prompt=args.prompt, | |
| 190 | + height=args.height, width=args.width, num_frames=args.frames, | |
| 191 | + num_inference_steps=args.steps, seed=args.seed, flow_shift=flow_shift, | |
| 192 | +) | |
| 193 | +gen_s = time.time() - t1 | |
| 194 | + | |
| 195 | +import torch as _t | |
| 196 | +_bad = [] | |
| 197 | +if not _t.isfinite(audio if _t.is_tensor(audio) else _t.tensor(0.)).all(): | |
| 198 | + _bad.append("audio 含非有限值") | |
| 199 | +# video 是 PIL 帧列表;原脚本从不检查它,导致全黑视频也返回 0 —— 实测踩过 | |
| 200 | +def _video_stats(v): | |
| 201 | + import numpy as _np | |
| 202 | + if _t.is_tensor(v): | |
| 203 | + a = v.float() | |
| 204 | + return float(a.mean()), float(a.std()), bool(_t.isfinite(a).all()) | |
| 205 | + try: | |
| 206 | + arrs = [_np.asarray(f.convert("RGB"), dtype=_np.float32) for f in v[:: max(1, len(v)//8)]] | |
| 207 | + a = _np.stack(arrs) | |
| 208 | + return float(a.mean()), float(a.std()), True | |
| 209 | + except Exception: | |
| 210 | + return None, None, True | |
| 211 | +_mean, _std, _fin = _video_stats(video) | |
| 212 | +if not _fin: | |
| 213 | + _bad.append("video 含非有限值") | |
| 214 | +if _std is not None and _std < 1.0: | |
| 215 | + _bad.append(f"video 近乎纯色 (std={_std:.3f}, mean={_mean:.3f})") | |
| 216 | +if _bad: | |
| 217 | + print("[FAIL] " + ";".join(_bad), flush=True) | |
| 218 | + | |
| 219 | +out = args.out or os.path.join(BASE, "outputs", f"t2va_{time.strftime('%Y%m%d-%H%M%S')}.mp4") | |
| 220 | +os.makedirs(os.path.dirname(out), exist_ok=True) | |
| 221 | +write_video_audio(video=video, audio=audio, output_path=out, fps=24, audio_sample_rate=32000) | |
| 222 | + | |
| 223 | +print(f"[done] generation {gen_s:.0f}s ({gen_s/max(args.steps,1):.1f}s/step), " | |
| 224 | + f"peak VRAM {torch.cuda.max_memory_allocated()/1024**3:.1f}G", flush=True) | |
| 225 | +print(f"[out ] {out} ({os.path.getsize(out)/1024**2:.1f} MB)", flush=True) | |
| 226 | +if _bad: | |
| 227 | + sys.exit(3) # 不要用退出码 0 报告一个坏产物 | ... | ... |
minimax-h3/scripts/test-fp32.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/test-fp32.py | |
| 1 | +"""关键对照实验:小分辨率下 fp32 端到端能否出片。 | |
| 2 | +若 fp32 出片而 fp16 全黑 → 确认是动态范围问题,而非模型/权重/流程问题。 | |
| 3 | +小分辨率是为了让 fp32 装得下(seq_len 从 ~14100 降到 ~800)。""" | |
| 4 | +import os, sys, time, torch, numpy as np | |
| 5 | +BASE="/data/models/minimax-h3" | |
| 6 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION","torch") | |
| 7 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 8 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 9 | +os.environ.setdefault("PYTORCH_ALLOC_CONF","expandable_segments:True") | |
| 10 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 11 | + | |
| 12 | +DT = torch.float32 if os.environ.get("TEST_DTYPE","fp32")=="fp32" else torch.float16 | |
| 13 | +H = int(os.environ.get("TEST_H", 256)); W = int(os.environ.get("TEST_W", 448)); F_ = int(os.environ.get("TEST_F", 28)) | |
| 14 | +print(f"[cfg] dtype={DT} {W}x{H} {F_}f", flush=True) | |
| 15 | +free=torch.cuda.mem_get_info("cuda")[0]/1024**3 | |
| 16 | +vc=dict(offload_dtype=DT,offload_device="cpu",onload_dtype=DT,onload_device="cpu", | |
| 17 | + preparing_dtype=DT,preparing_device="cuda",computation_dtype=DT,computation_device="cuda") | |
| 18 | +NF4="DiffSynth-Studio/MiniMax-H3-NF4" | |
| 19 | +t0=time.time() | |
| 20 | +pipe=MiniMaxH3Pipeline.from_pretrained(torch_dtype=DT,device="cuda", | |
| 21 | + model_configs=[ModelConfig(model_id=NF4,origin_file_pattern=p,**vc) for p in | |
| 22 | + ["minimax-h3-fl2va-nf4.safetensors","minimax-h3-text-encoder-nf4.safetensors", | |
| 23 | + "video_vae_nf4.safetensors","audio_vae_nf4.safetensors"]], | |
| 24 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3",origin_file_pattern="FL2VA/processor/"), | |
| 25 | + vram_limit=max(4.0,free-float(os.environ.get("TEST_RESERVE",5)))) | |
| 26 | +print(f"[load] {time.time()-t0:.0f}s", flush=True) | |
| 27 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 28 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 29 | + | |
| 30 | +PROMPT=("A girl is very happy, she is speaking in english: " | |
| 31 | + "“I enjoy working with Diffsynth-Studio, it's a perfect framework.”") | |
| 32 | +t1=time.time() | |
| 33 | +video,audio = pipe(prompt=PROMPT,height=H,width=W,num_frames=F_, | |
| 34 | + num_inference_steps=4,seed=0,flow_shift=6.0) | |
| 35 | +print(f"[gen ] {time.time()-t1:.0f}s peak={torch.cuda.max_memory_allocated()/1024**3:.1f}G", flush=True) | |
| 36 | +a=np.stack([np.asarray(f.convert("RGB"),dtype=np.float32) for f in video[::5]]) | |
| 37 | +print(f"[video] frames={len(video)} mean={a.mean():.4f} std={a.std():.4f} max={a.max():.1f}", flush=True) | |
| 38 | +print(f"[audio] finite={bool(torch.isfinite(audio).all())}", flush=True) | |
| 39 | +print("[VERDICT] " + ("出片正常 ✅" if a.std()>1.0 else "仍为纯色 ❌"), flush=True) | ... | ... |
minimax-h3/scripts/test-patch.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/test-patch.py | |
| 1 | +import os, torch, torch.nn.functional as F | |
| 2 | +BASE="/data/models/minimax-h3" | |
| 3 | +os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION","torch") | |
| 4 | +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", f"{BASE}/models") | |
| 5 | +os.environ.setdefault("MODELSCOPE_CACHE", f"{BASE}/.mscache") | |
| 6 | +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig | |
| 7 | +DT=torch.float16 | |
| 8 | +free=torch.cuda.mem_get_info("cuda")[0]/1024**3 | |
| 9 | +vc=dict(offload_dtype=DT,offload_device="cpu",onload_dtype=DT,onload_device="cpu", | |
| 10 | + preparing_dtype=DT,preparing_device="cuda",computation_dtype=DT,computation_device="cuda") | |
| 11 | +NF4="DiffSynth-Studio/MiniMax-H3-NF4" | |
| 12 | +pipe=MiniMaxH3Pipeline.from_pretrained(torch_dtype=DT,device="cuda", | |
| 13 | + model_configs=[ModelConfig(model_id=NF4,origin_file_pattern=p,**vc) for p in | |
| 14 | + ["minimax-h3-fl2va-nf4.safetensors","minimax-h3-text-encoder-nf4.safetensors", | |
| 15 | + "video_vae_nf4.safetensors","audio_vae_nf4.safetensors"]], | |
| 16 | + processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3",origin_file_pattern="FL2VA/processor/"), | |
| 17 | + vram_limit=max(6.0,free-2.0)) | |
| 18 | +pipe.load_lora(pipe.dit, ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", | |
| 19 | + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")) | |
| 20 | + | |
| 21 | +cp = pipe.dit.condition_proj | |
| 22 | +stats = {"calls":0, "inf_out":0} | |
| 23 | +orig_lf = cp.linear_forward # 改 linear_forward 而不是 forward: | |
| 24 | + # 这样 onload/preparing/LoRA 全套机制保持不变 | |
| 25 | +def lf(x, weight, bias): | |
| 26 | + stats["calls"] += 1 | |
| 27 | + out = F.linear(x.float(), weight.float(), bias.float() if bias is not None else None) | |
| 28 | + if not torch.isfinite(out).all(): stats["inf_out"] += 1 | |
| 29 | + return out.to(x.dtype) | |
| 30 | +cp.linear_forward = lf | |
| 31 | +print("[patch] linear_forward 已替换", flush=True) | |
| 32 | + | |
| 33 | +video, audio = pipe(prompt="一只橘猫在窗台上打哈欠", height=480, width=832, | |
| 34 | + num_frames=124, num_inference_steps=1, seed=42, flow_shift=6.0) | |
| 35 | +print(f"[stat] condition_proj 被调用 {stats['calls']} 次,其中 fp32 仍非有限 {stats['inf_out']} 次", flush=True) | |
| 36 | +import numpy as np | |
| 37 | +a=np.stack([np.asarray(f.convert("RGB"),dtype=np.float32) for f in video[::20]]) | |
| 38 | +print(f"[video] frames={len(video)} mean={a.mean():.4f} std={a.std():.4f} max={a.max():.1f}", flush=True) | |
| 39 | +print(f"[audio] finite={bool(torch.isfinite(audio).all())} absmax={float(audio.abs().max()):.4g}", flush=True) | ... | ... |
minimax-h3/scripts/verify.py
0 → 100644
| 1 | +++ a/minimax-h3/scripts/verify.py | |
| 1 | +"""Verify every downloaded weight: exact byte size vs ModelScope, and a safetensors header parse.""" | |
| 2 | +import os, sys, json, struct, urllib.request | |
| 3 | + | |
| 4 | +BASE = os.environ.get("DIFFSYNTH_MODEL_BASE_PATH", "/data/models/minimax-h3/models") | |
| 5 | +WANT = { | |
| 6 | + "DiffSynth-Studio/MiniMax-H3-NF4": ["minimax-h3-fl2va-nf4.safetensors", | |
| 7 | + "minimax-h3-ref2va-nf4.safetensors", | |
| 8 | + "minimax-h3-text-encoder-nf4.safetensors", | |
| 9 | + "video_vae_nf4.safetensors", "audio_vae_nf4.safetensors"], | |
| 10 | + "lightx2v/Minimax-h3-Turbo": ["minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors", | |
| 11 | + "minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors"], | |
| 12 | +} | |
| 13 | +PROCESSORS = ["MiniMax/MiniMax-H3/FL2VA/processor", "MiniMax/MiniMax-H3/Ref2VA/processor"] | |
| 14 | + | |
| 15 | +def remote_sizes(mid): | |
| 16 | + u = f"https://www.modelscope.cn/api/v1/models/{mid}/repo/files?Recursive=true" | |
| 17 | + d = json.load(urllib.request.urlopen(u, timeout=60)) | |
| 18 | + return {f["Path"]: f.get("Size", 0) for f in d.get("Data", {}).get("Files", []) if f.get("Type") == "blob"} | |
| 19 | + | |
| 20 | +def header_ok(path): | |
| 21 | + with open(path, "rb") as f: | |
| 22 | + n = struct.unpack("<Q", f.read(8))[0] | |
| 23 | + if not (0 < n < 200_000_000): return False, f"bad header len {n}" | |
| 24 | + hdr = json.loads(f.read(n)) | |
| 25 | + end = max(v["data_offsets"][1] for k, v in hdr.items() if k != "__metadata__") | |
| 26 | + expect = 8 + n + end | |
| 27 | + actual = os.path.getsize(path) | |
| 28 | + return expect == actual, f"tensors={len(hdr)} expect={expect} actual={actual}" | |
| 29 | + | |
| 30 | +bad = 0 | |
| 31 | +for mid, files in WANT.items(): | |
| 32 | + rs = remote_sizes(mid) | |
| 33 | + for fn in files: | |
| 34 | + p = os.path.join(BASE, mid, fn) | |
| 35 | + if not os.path.exists(p): | |
| 36 | + print(f"MISSING {mid}/{fn}"); bad += 1; continue | |
| 37 | + got, want = os.path.getsize(p), rs.get(fn, -1) | |
| 38 | + size_ok = (got == want) | |
| 39 | + ok, detail = header_ok(p) | |
| 40 | + flag = "OK " if (size_ok and ok) else "BAD " | |
| 41 | + if not (size_ok and ok): bad += 1 | |
| 42 | + print(f"{flag} {fn}\n size {got} vs remote {want} {'==' if size_ok else '!= MISMATCH'} | header {'ok' if ok else 'BROKEN'} ({detail})") | |
| 43 | + | |
| 44 | +need = ["tokenizer.json", "tokenizer_config.json", "preprocessor_config.json", "chat_template.json", "vocab.json", "merges.txt"] | |
| 45 | +for processor in PROCESSORS: | |
| 46 | + pdir = os.path.join(BASE, processor) | |
| 47 | + missing = [f for f in need if not os.path.exists(os.path.join(pdir, f))] | |
| 48 | + print((f"OK {processor}: all present" if not missing else f"BAD {processor} missing: {missing}")) | |
| 49 | + bad += len(missing) | |
| 50 | +print("\nRESULT:", "ALL GOOD" if bad == 0 else f"{bad} PROBLEM(S)") | |
| 51 | +sys.exit(1 if bad else 0) | ... | ... |