Commit ca291c3de2e99379d65d048eb962a0ee47780a80

Authored by yibo
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__ 等运行时/构建产物。
.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,'&lt;')}</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,'&lt;')}</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
  1 +++ a/minimax-h3/prompt.txt
  1 +清晨的中式庭院,一位穿浅蓝色棉麻衬衫的年轻女子坐在木质长廊上,手里捧着一杯冒着热气的茶。她微笑着说 "Good morning, the tea smells wonderful today." 阳光透过竹叶洒在她脸上,微风吹动她的头发,远处有清脆的鸟鸣声。
... ...
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)
... ...