Commit e4a5d5cf34bc07b1e66e0d51d42e5a55270a55b4

Authored by yibo
1 parent ca291c3d

补齐 minimax-h3 本地镜像缺失文件 + console 修复"任务卡在假运行中" + rows 动态判定文档同步

- 从服务器同步 gateway.py、gateway-run.sh 等此前本地缺失的核心/调试脚本,修正与
  服务器的漂移(此前 push 的本地副本一直缺 gateway.py 这个核心文件)
- h3-console/server.py:_refresh_from_gateway 遇到 404 且任务未到终态时判 failed,
  不再让后端进程重启丢失的任务永远显示"运行中"
- h3-console/DESIGN.md、CHANGELOG.md 同步记录 fl2va/ref2va 默认基线改 2/2 分裂、
  frames 上限改按 rows 动态判定的相关变更
CHANGELOG.md
1 1 # 变更记录
2 2  
  3 +## 2026-08-20(夜,续)— console 修复"任务卡在假运行中":后端重启丢任务时正确判失败
  4 +
  5 +**背景**:用户报告任务 `b515f90547be` 详情页显示"运行中",但 `/v1/backends` 明明
  6 +`4/4 ready · 排队 0 · 运行中 0`,两边对不上。查下来是「frames 上限改按 rows 动态判定」
  7 +那次重启(另一进程在这台机器上并行操作,见上一节)把这条正在跑的任务从内存里一起清没了:
  8 +gateway 对它的 id 直接 404,但 `server.py` 的 `_refresh_from_gateway` 原来遇到非 200 一律
  9 +静默跳过、保留上一次已知状态——所以 status 永远停在 `running`,不会再更新,界面看着像在跑
  10 +实际已经死了,且**无法自愈**(除非有人手动改数据库)。
  11 +
  12 +**结果**:`_refresh_from_gateway` 里把 `404` 单独分支出来:gateway 完全不认识这个
  13 +`gateway_id`(通常是所在后端进程被重启,内存态清空)且任务还没到终态时,直接判 `failed`,
  14 +写清楚原因("任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展")。
  15 +其它非 200(后端暂时不可达之类)继续保留原状态、下一轮再试,不受影响。
  16 +验证:改完重启 console 进程后,`b515f90547be` 立刻从假"运行中"变成 `failed` 并带上错误信息。
  17 +
  18 +只改了 `h3-console/server.py` 这一个函数,**这次改动了后端逻辑,重启了 console 进程**
  19 +(不是静态文件那种直接 `cat >` 就生效的改动)——按 README 的安全重启法用 `ss` 按端口
  20 +找 PID kill,而不是 `pkill -f`。重启前确认了四张卡都 `ready` 且 `queued=0,running=0`,
  21 +不会打断正在跑的任务。
  22 +
  23 +---
  24 +
  25 +## 2026-08-20(夜)— frames 上限改按 rows 动态判定 + fl2va/ref2va 默认基线改 2/2
  26 +
  27 +**背景**:任务 `8755c5600378`(832×480×362帧)失败,表面看像"弹性调度没生效",查下来是两件
  28 +没关系的事叠加:调度本身是好的(`/v1/backends` 一直 `ready:4/4`),是 `api.py` 的
  29 +`frames: Field(124, ge=8, le=124)` 硬顶把这条请求拒了——跟分辨率无关,一刀切。但上一节
  30 +「15 秒时长实测」已经测出 448×256/576×320 这两档能扛住 362 帧,`api.py` 的校验一直没跟上。
  31 +
  32 +### 改动一:`frames` 上限从硬编码 124 改成按 `rows`(分辨率×帧数)动态判定
  33 +
  34 +`api.py` 新增 `_rows(width,height,frames)`(沿用 `create()` 里本来就有的 ETA 估算公式:
  35 +潜帧数×宽×高/1024)和 `ROWS_LIMIT=20000`,`frames` 字段上界放宽到 362(协议规格上限),
  36 +改用 `@model_validator` 校验 `rows`,超限才拒绝,理由写进错误信息里。
  37 +
  38 +**20000 这个阈值怎么来的**(全部来自「15 秒时长实测」那节的真实数据,不是拍脑袋):
  39 +`832×480×124f` rows=14430 稳定基准;`448×256×362f` rows=11984、`576×320×362f` rows=19260
  40 +都能跑(后者峰值 14.5G,吃紧但没崩);`640×384×362f` rows=25680 OOM(撞到 20.6G)。
  41 +20000 卡在 19260(能跑)和 25680(OOM)之间。
  42 +
  43 +**验证**:直接对 8010 发请求,`832×480×362f`→ 422,错误信息里带 `rows=41730 > 20000`;
  44 +`448×256×362f`→ 202 排队成功,实际开始 `running`(后来因为下面「改动二」的重启被中途打断,
  45 +不是这条校验本身的问题——校验通过、任务正常起跑这一步已经验证了)。
  46 +
  47 +同时把 console 前端(`h3-console/static/index.html`)的分辨率/时长两个下拉框做成真正联动:
  48 +选 832×480 时 10s/15s 直接从选项里消失,而不是像原来那样只放一行提示文字、照样能选中
  49 +一个提交必挂的组合。前端判定逻辑(`rows()`/`ROWS_LIMIT`)和 `api.py` 手动保持一致,
  50 +注释里写明了"两边数字要一起改"——纯静态文件,`cat >` 覆盖即生效,没重启 console。
  51 +
  52 +### 改动二:fl2va/ref2va 默认基线从「4 张全 ref2va」改成 2/2 固定分裂
  53 +
  54 +**结果**:`start-all.sh` 冷启动的默认分区改成 GPU0/1=ref2va、GPU2/3=fl2va(原来四张全
  55 +ref2va,等 fl2va 需求来了再让 console 的弹性调度器现 flip 一张,13-20 秒冷启动 + 最长
  56 +30 秒探测延迟)。改动只在 `start-all.sh` 里两行 `ref2va`→`fl2va`(对应端口 8011/8013),
  57 +GPU/端口映射本身没变。**调度器代码一行没动**——`h3-console/server.py` 的 supervisor 纯读
  58 +`/health` 实时状态做决策,没有"初始基线"这个概念,2/2 只是冷启动给它的起点,需求超出这个
  59 +基线时该怎么弹性 flip 还是怎么 flip,行为跟之前完全一样。已重启验证 `ready:4/4` 且分区
  60 +如预期(`gpu1: ref2va, gpu2: fl2va, gpu0: ref2va, gpu3: fl2va`)。
  61 +
  62 +⚠️ 这次重启顺带把改动一里那条正在跑的验证任务(448×256×362f,进度 266/574s)中途杀掉了——
  63 +是我自己的验证任务(prompt="test"),不是用户数据,无损失,提这一句只是记录"为什么明明验证
  64 +通过了却没看到最终产物"。
  65 +
3 66 ## 2026-08-20(晚)— h3-console 任务详情页加「重跑」按钮
4 67  
5 68 **结果**:`/task.html/<id>` 详情页状态行旁加了「重跑」按钮。**第一版直接后台再 `POST /v1/videos`
... ...
h3-console/DESIGN.md
... ... @@ -101,8 +101,11 @@ setsid nohup ./api-run.sh $GPU $PORT $NEW_PARTITION &gt; logs/api-$PORT.log 2&gt;&amp;1 &lt;
101 101 ```
102 102 GPU1 → 8010 GPU2 → 8011 GPU0 → 8012 GPU3 → 8013
103 103 ```
104   -初始状态延用现状:4 张全 ref2va(`start-all.sh` 已经这么起了,console 启动时只做"接管监控",
105   -不需要在 console 里重新实现"冷启动 4 个后端"这部分,那是 `start-all.sh` 的职责,边界清楚)。
  104 +初始状态(2026-08-20 起):GPU0/1 = ref2va、GPU2/3 = fl2va,2/2 固定分裂
  105 +(`start-all.sh` 已经这么起了,console 启动时只做"接管监控",不需要在 console 里重新实现
  106 +"冷启动 4 个后端"这部分,那是 `start-all.sh` 的职责,边界清楚)。之前是 4 张全 ref2va,
  107 +改成 2/2 是因为 fl2va 需求出现频率更高,全 ref2va 基线下每次 fl2va 任务几乎都要先等一次
  108 +13-20 秒的 flip 冷启动;调度器本身逻辑不变,超出 2/2 基线的需求仍然按上面的算法弹性 flip。
106 109  
107 110 ### 这层为什么只能在服务器上跑,不能本地模拟
108 111 kill+restart 操作的是服务器上真实的 GPU 进程。本地 Mac 没有这几张卡、没有这几个进程,
... ...
h3-console/server.py
... ... @@ -179,14 +179,34 @@ async def _refresh_from_gateway(cid: str, gateway_id: str):
179 179 resp = await client.get(f"{GATEWAY_URL}/v1/videos/{gateway_id}")
180 180 except httpx.HTTPError:
181 181 return
  182 + now = time.time()
  183 + if resp.status_code == 404:
  184 + # gateway 完全不认识这个 gateway_id——通常是所在后端进程被重启(比如手动 flip 分区
  185 + # 或崩溃),内存态清空,这条任务已经不会再有任何进展。不能像别的非 200 一样悄悄跳过,
  186 + # 那样 status 会永远卡在 queued/running,界面看着像在跑,实际已经死了、永远不会再更新。
  187 + with db() as con:
  188 + row = con.execute("SELECT last_known_json FROM jobs WHERE id=?", (cid,)).fetchone()
  189 + if row is None:
  190 + return
  191 + view = json.loads(row["last_known_json"]) if row["last_known_json"] else {"id": cid}
  192 + if view.get("status") in ("done", "failed"):
  193 + return # 已经是终态,不要覆盖
  194 + view["status"] = "failed"
  195 + view["error"] = "任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展"
  196 + with db() as con:
  197 + con.execute(
  198 + "UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?",
  199 + (now, json.dumps(view, ensure_ascii=False), cid),
  200 + )
  201 + return
182 202 if resp.status_code != 200:
183   - return # 后端进程重启后 gateway 会 404(它自己没状态),sqlite 里的 last_known_json 就是最后已知状态
  203 + return # 其它非 200(后端一时不可达等)多半是暂时的,保留原状态,下一轮再试
184 204 job = resp.json()
185 205 job["id"] = cid
186 206 with db() as con:
187 207 con.execute(
188 208 "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),
  209 + (job.get("status"), job.get("backend"), now, json.dumps(job, ensure_ascii=False), cid),
190 210 )
191 211  
192 212  
... ... @@ -226,7 +246,9 @@ async def get_video(jid: str):
226 246 @app.get("/v1/videos/{jid}/file")
227 247 async def download_video(jid: str, request: Request):
228 248 with db() as con:
229   - row = con.execute("SELECT gateway_id, backend, status FROM jobs WHERE id=?", (jid,)).fetchone()
  249 + row = con.execute(
  250 + "SELECT gateway_id, backend, status, last_known_json FROM jobs WHERE id=?", (jid,)
  251 + ).fetchone()
230 252 if row is None:
231 253 raise HTTPException(404, "无此任务")
232 254 if not row["gateway_id"] or not row["backend"]:
... ... @@ -244,12 +266,24 @@ async def download_video(jid: str, request: Request):
244 266 headers = {}
245 267 if "range" in request.headers:
246 268 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"))
  269 + try:
  270 + req = client.build_request("GET", upstream_url, headers=headers)
  271 + upstream = await client.send(req, stream=True)
  272 + except httpx.HTTPError:
  273 + upstream = None
  274 +
  275 + if upstream is None or upstream.status_code >= 400:
  276 + if upstream is not None:
  277 + await upstream.aclose()
  278 + # 后端进程重启会把 api.py 内存里的 JOBS 字典清空(这是它本来的设计,任务状态
  279 + # 不落盘),导致它忘了这条任务——但视频文件本身在磁盘上没被动过。console 和
  280 + # api.py 跑在同一台机器上,任务完成时已经把 `file` 路径存进了自己的 sqlite,
  281 + # 后端忘了就直接读文件兜底,不用等它想起来(它也不会想起来,重启就是清空)。
  282 + cached = json.loads(row["last_known_json"]) if row["last_known_json"] else {}
  283 + file_path = cached.get("file")
  284 + if file_path and os.path.exists(file_path):
  285 + return FileResponse(file_path, media_type="video/mp4")
  286 + raise HTTPException(404, "后端进程重启后忘了这条任务的记录,磁盘上也没找到对应文件")
253 287  
254 288 async def stream():
255 289 async for chunk in upstream.aiter_bytes():
... ...
minimax-h3/api-run.sh 100644 → 100755
minimax-h3/burst-test.py 0 → 100644
  1 +"""压并发:12 条同时投给调度层,看三件事。
  2 +
  3 +1. 派发会不会打偏——调度层是「读 health 挑最闲的」,同时提交时几个请求可能读到同一份快照。
  4 + 顺序提交测不出这个,必须真并发。
  5 +2. queue_position 和 eta_seconds 在排队时说了什么。
  6 +3. 队列有没有上限、超了会怎样。
  7 +"""
  8 +import json, time, urllib.request
  9 +from collections import Counter
  10 +from concurrent.futures import ThreadPoolExecutor
  11 +
  12 +GW = "http://127.0.0.1:8000"
  13 +N = 12
  14 +SPEC = {"prompt": "一只橘猫趴在洒满阳光的窗台上,慢慢眨眼", "frames": 22, "steps": 4, "turbo": True}
  15 +
  16 +
  17 +def call(path, data=None, timeout=60):
  18 + req = urllib.request.Request(GW + path, data=data,
  19 + headers={"Content-Type": "application/json"} if data else {})
  20 + with urllib.request.urlopen(req, timeout=timeout) as r:
  21 + return json.load(r)
  22 +
  23 +
  24 +def submit(i):
  25 + t = time.time()
  26 + job = call("/v1/videos", json.dumps(dict(SPEC, seed=100 + i)).encode())
  27 + return i, job, time.time() - t
  28 +
  29 +
  30 +t0 = time.time()
  31 +with ThreadPoolExecutor(max_workers=N) as pool:
  32 + results = sorted(pool.map(submit, range(N)))
  33 +print(f"12 条并发提交耗时 {time.time() - t0:.2f}s\n")
  34 +
  35 +for i, job, dt in results:
  36 + print(f" #{i:<2} {job['backend'][-4:]} queue_position={job['queue_position']} "
  37 + f"eta={job['eta_seconds']}s ({dt:.2f}s)")
  38 +
  39 +spread = Counter(job["backend"][-4:] for _, job, _ in results)
  40 +print(f"\n派发分布: {dict(sorted(spread.items()))} 理想是每个 {N // 4} 条")
  41 +
  42 +ids = [job["id"] for _, job, _ in results]
  43 +print("\n--- 轮询")
  44 +while True:
  45 + time.sleep(30)
  46 + states = [call(f"/v1/videos/{jid}") for jid in ids]
  47 + c = Counter(s["status"] for s in states)
  48 + print(f" {dict(c)}", flush=True)
  49 + if all(s["status"] in ("done", "failed") for s in states):
  50 + break
  51 +
  52 +total = time.time() - t0
  53 +print(f"\n12 条全部完成,墙钟 {total:.0f}s")
  54 +for s in sorted(states, key=lambda s: s["created_at"]):
  55 + took = s["finished_at"] - s["started_at"]
  56 + wait = s["started_at"] - s["created_at"]
  57 + print(f" {s['id'][:6]} {s['status']} 排队{wait:5.0f}s 执行{took:4.0f}s {s['backend'][-4:]}")
... ...
minimax-h3/concurrency-test.py 0 → 100644
  1 +"""四张卡同时跑同一条任务,量并发缩水。
  2 +
  3 +各端口和自己的单跑基线比:cpu 模式 692s,disk 模式 767s。
  4 +任务规格从 8011 上那条已完成的 826db7b3f08e 原样取,保证四条完全一致。
  5 +"""
  6 +import json, time, urllib.request
  7 +
  8 +# 8010/8011 是三小时前起的,跑的还是加 H3_OFFLOAD 之前的 api.py,/health 里没有 offload 字段,
  9 +# 所以模式和基线都写在这里。
  10 +MODE = {8010: "cpu", 8011: "cpu", 8012: "disk", 8013: "disk"}
  11 +BASELINE = {"cpu": 692, "disk": 767}
  12 +
  13 +
  14 +def call(port, path, body=None):
  15 + url = f"http://127.0.0.1:{port}{path}"
  16 + req = urllib.request.Request(url, data=body,
  17 + headers={"Content-Type": "application/json"} if body else {})
  18 + with urllib.request.urlopen(req, timeout=60) as r:
  19 + return json.load(r)
  20 +
  21 +
  22 +spec = json.dumps(call(8011, "/v1/videos/826db7b3f08e")["req"]).encode()
  23 +
  24 +jobs = {}
  25 +for port, mode in MODE.items():
  26 + jobs[port] = (call(port, "/v1/videos", spec)["id"], mode)
  27 + print(f"{port} ({mode}) -> {jobs[port][0]}", flush=True)
  28 +
  29 +print("--- 四条已同时投出,开始轮询", flush=True)
  30 +pending = dict(jobs)
  31 +while pending:
  32 + time.sleep(60)
  33 + for port in list(pending):
  34 + jid, mode = pending[port]
  35 + j = call(port, f"/v1/videos/{jid}")
  36 + if j["status"] in ("done", "failed"):
  37 + took = j["finished_at"] - j["started_at"]
  38 + base = BASELINE[mode]
  39 + print(f"{port} ({mode}) {j['status']} {took:.0f}s 基线{base}s "
  40 + f"缩水{(took/base-1)*100:+.1f}% 峰值显存{j.get('peak_vram_gb')}G "
  41 + f"{j.get('size_bytes')}字节", flush=True)
  42 + del pending[port]
  43 + else:
  44 + print(f" {port} {j['status']} {j.get('elapsed_seconds', 0)}s", flush=True)
... ...
minimax-h3/direct-backend-test.py 0 → 100644
  1 +"""验直连后端端口这条老路还通不通——调度层是加在旁边的,不该动到它。
  2 +
  3 +走的是 API.md 里那段完整调用示例的形状:POST 拿 id → 轮询 → 下载。
  4 +"""
  5 +import json, time, urllib.request
  6 +
  7 +API = "http://127.0.0.1:8010"
  8 +SPEC = {"prompt": "一只橘猫趴在洒满阳光的窗台上,慢慢眨眼", "frames": 22, "steps": 4,
  9 + "turbo": True, "seed": 99}
  10 +
  11 +
  12 +def call(path, data=None, timeout=60):
  13 + req = urllib.request.Request(API + path, data=data,
  14 + headers={"Content-Type": "application/json"} if data else {})
  15 + with urllib.request.urlopen(req, timeout=timeout) as r:
  16 + return json.load(r)
  17 +
  18 +
  19 +print("health:", call("/health"))
  20 +job = call("/v1/videos", json.dumps(SPEC).encode())
  21 +print("提交:", job)
  22 +
  23 +while True:
  24 + time.sleep(20)
  25 + j = call(f"/v1/videos/{job['id']}")
  26 + print(" ", j["status"], j.get("elapsed_seconds", 0), "s", flush=True)
  27 + if j["status"] in ("done", "failed"):
  28 + break
  29 +
  30 +if j["status"] == "failed":
  31 + raise SystemExit("失败: " + j["error"])
  32 +
  33 +with urllib.request.urlopen(f"{API}/v1/videos/{job['id']}/file", timeout=300) as r:
  34 + data = r.read()
  35 +print(f"下载 {len(data)} 字节,后端记录 {j['size_bytes']},一致={len(data) == j['size_bytes']}")
... ...
minimax-h3/gateway-run.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 起调度层。用法: ./gateway-run.sh [PORT]
  3 +# 后端列表用 H3_BACKENDS 覆盖,默认 8010,8011,8012,8013。
  4 +# 这一层不碰模型也不占显存,秒起秒停,随便重启。
  5 +set -uo pipefail
  6 +B=/data/models/minimax-h3
  7 +PORT="${1:-8000}"
  8 +export PYTHONUNBUFFERED=1
  9 +cd "$B"
  10 +echo "[gateway] PORT=$PORT 后端=${H3_BACKENDS:-8010,8011,8012,8013}"
  11 +exec "$B/venv/bin/python" -m uvicorn gateway:app --host 0.0.0.0 --port "$PORT" --workers 1
... ...
minimax-h3/gateway-test.py 0 → 100644
  1 +"""验调度层:往 :8000 投 5 条(比后端多一条),看是不是每卡一条、多出来那条排队。
  2 +
  3 +用短任务(22 帧)只是为了快,路由逻辑和长任务完全一样。
  4 +"""
  5 +import json, time, urllib.request
  6 +
  7 +GW = "http://127.0.0.1:8000"
  8 +SPEC = {"prompt": "一只橘猫趴在洒满阳光的窗台上,慢慢眨眼,尾巴轻轻摆动",
  9 + "width": 832, "height": 480, "frames": 22, "steps": 4, "turbo": True, "seed": 7}
  10 +
  11 +
  12 +def call(path, data=None, timeout=60):
  13 + req = urllib.request.Request(GW + path, data=data,
  14 + headers={"Content-Type": "application/json"} if data else {})
  15 + with urllib.request.urlopen(req, timeout=timeout) as r:
  16 + return json.load(r)
  17 +
  18 +
  19 +ids = []
  20 +for i in range(5):
  21 + job = call("/v1/videos", json.dumps(dict(SPEC, seed=7 + i)).encode())
  22 + ids.append(job["id"])
  23 + print(f"第{i+1}条 -> {job['backend']} id={job['id']} 队列位置={job['queue_position']}", flush=True)
  24 +
  25 +print("\n--- 派发结果")
  26 +used = {}
  27 +for jid in ids:
  28 + b = call(f"/v1/videos/{jid}")["backend"]
  29 + used.setdefault(b, []).append(jid)
  30 +for b, js in sorted(used.items()):
  31 + print(f"{b}: {len(js)} 条 {js}")
  32 +print(f"用上了 {len(used)}/4 个后端")
  33 +
  34 +print("\n--- 轮询到全部完成")
  35 +while True:
  36 + time.sleep(30)
  37 + states = [call(f"/v1/videos/{jid}") for jid in ids]
  38 + print(" " + " ".join(f"{s['id'][:6]}:{s['status']}" for s in states), flush=True)
  39 + if all(s["status"] in ("done", "failed") for s in states):
  40 + break
  41 +
  42 +print("\n--- 结果")
  43 +for s in states:
  44 + took = (s["finished_at"] - s["started_at"]) if s.get("started_at") else 0
  45 + print(f"{s['id']} {s['status']} {took:.0f}s {s.get('size_bytes')}字节 {s['backend']}")
  46 +
  47 +ok = [s for s in states if s["status"] == "done"]
  48 +if ok:
  49 + with urllib.request.urlopen(f"{GW}/v1/videos/{ok[0]['id']}/file", timeout=300) as r:
  50 + data = r.read()
  51 + print(f"\n经调度层下载 {ok[0]['id']}: {len(data)} 字节 "
  52 + f"(后端记录 {ok[0]['size_bytes']}) 一致={len(data) == ok[0]['size_bytes']}")
... ...
minimax-h3/gateway.py 0 → 100644
  1 +"""四个 H3 实例前面的调度层。
  2 +
  3 +api.py 是单 worker 串行,一个进程独占一张卡,所以并发的唯一办法是往不同端口投。
  4 +这一层把「投哪个端口」收掉:对外只有一个地址,接口和 api.py 逐字相同,
  5 +调用方原来怎么写现在还怎么写,只把端口换成 8000。
  6 +
  7 +自己不存任何状态。任务 id 归哪个后端,问一遍就知道——四个后端一次扫描的代价可以忽略,
  8 +换来的是这个进程随时能重启,不丢任何东西(api.py 的 JOBS 本来也在内存里,
  9 +调度层再存一份 id→后端 的映射,就是第二个会丢的地方)。
  10 +"""
  11 +import os, json, threading, urllib.request, urllib.error
  12 +from concurrent.futures import ThreadPoolExecutor
  13 +
  14 +from fastapi import FastAPI, HTTPException
  15 +from fastapi.responses import StreamingResponse
  16 +
  17 +BACKENDS = [f"http://127.0.0.1:{port.strip()}" for port in
  18 + os.environ.get("H3_BACKENDS", "8010,8011,8012,8013").split(",")]
  19 +POOL = ThreadPoolExecutor(max_workers=16)
  20 +# 最闲的那个后端已经压了这么多条时,再来的提交直接 429 顶回去。
  21 +# 没有这个上限的话队列是无界的:投 1000 条全部返回 202,最后一条两天后才开始跑,
  22 +# 调用方却收到「已接受」。宁可当场说满了。默认 4 条 ≈ 45 分钟积压。
  23 +MAX_QUEUE = int(os.environ.get("H3_MAX_QUEUE", "4"))
  24 +# 选后端是「读一遍各家队列深度,再往最闲的那个投」。这中间不能插进另一个提交,
  25 +# 否则同时来的请求会读到同一份快照、一起挑中同一个后端,全堆在一张卡上。
  26 +# 锁只圈住选 + 投这一段(约 30 毫秒),任务本身要跑十几分钟,序列化的代价可以忽略。
  27 +PICK = threading.Lock()
  28 +
  29 +
  30 +def fetch(url, data=None, timeout=30):
  31 + req = urllib.request.Request(url, data=data,
  32 + headers={"Content-Type": "application/json"} if data else {})
  33 + with urllib.request.urlopen(req, timeout=timeout) as resp:
  34 + return json.load(resp)
  35 +
  36 +
  37 +def health_all():
  38 + def one(backend):
  39 + try:
  40 + return dict(fetch(backend + "/health", timeout=5), backend=backend)
  41 + except Exception as e:
  42 + return {"backend": backend, "status": "down", "error": f"{type(e).__name__}: {e}"}
  43 + return list(POOL.map(one, BACKENDS))
  44 +
  45 +
  46 +def find_owner(jid):
  47 + """任务归谁,挨个问。后端对陌生 id 返回 404,urlopen 抛异常,接住当没有。"""
  48 + def one(backend):
  49 + try:
  50 + return backend, fetch(f"{backend}/v1/videos/{jid}", timeout=5)
  51 + except Exception:
  52 + return backend, None
  53 + for backend, job in POOL.map(one, BACKENDS):
  54 + if job is not None:
  55 + return backend, job
  56 + raise HTTPException(404, "无此任务")
  57 +
  58 +
  59 +app = FastAPI(title="MiniMax-H3 Gateway", version="1.0")
  60 +
  61 +
  62 +@app.get("/health")
  63 +def health():
  64 + backends = health_all()
  65 + ready = [b for b in backends if b.get("status") == "ok"]
  66 + return {"status": "ok" if ready else "unavailable",
  67 + "ready": len(ready), "total": len(backends),
  68 + "queued": sum(b.get("queued", 0) for b in ready),
  69 + "running": sum(b.get("running", 0) for b in ready),
  70 + "backends": backends}
  71 +
  72 +
  73 +# 同步 def,让 FastAPI 把它丢进线程池。写成 async def 会让下面这些阻塞的 HTTP 调用
  74 +# 卡住事件循环——那样确实也能歪打正着地把提交排成队,但任何一个后端卡住,
  75 +# 5 秒超时就会连轮询一起堵死。要串行就用锁明说。
  76 +@app.post("/v1/videos", status_code=202)
  77 +def create(req: dict):
  78 + # 给了首末帧就只能走 fl2va;其余都走 ref2va——ref2va 不给参考图就退回纯文生视频,
  79 + # 所以「没有首末帧」这一类不需要再细分。
  80 + want = "fl2va" if (req.get("first_frame_url") or req.get("last_frame_url")) else "ref2va"
  81 + with PICK:
  82 + candidates = [b for b in health_all()
  83 + if b.get("status") == "ok" and b.get("partition") == want]
  84 + if not candidates:
  85 + raise HTTPException(503, f"没有就绪的 {want} 实例")
  86 + # 排队最短的那个。queued 是还在队列里的,running 是正在跑的那条,两个都要算。
  87 + pick = min(candidates, key=lambda b: b["queued"] + b["running"])
  88 + ahead = pick["queued"] + pick["running"]
  89 + if ahead >= MAX_QUEUE:
  90 + raise HTTPException(429, f"队列满:{len(candidates)} 个 {want} 实例每个都压着 "
  91 + f"{ahead} 条(上限 {MAX_QUEUE})。等一会儿再投,"
  92 + f"或调大 H3_MAX_QUEUE")
  93 + try:
  94 + job = fetch(pick["backend"] + "/v1/videos", data=json.dumps(req).encode())
  95 + except urllib.error.HTTPError as e:
  96 + raise HTTPException(e.code, e.read().decode())
  97 + # eta_seconds 是这一条自己的执行时间,不含排队。前面还压着 ahead 条,
  98 + # 真正的等待还要加上它们——它们规格可能不同,这里不替调用方瞎乘。
  99 + return dict(job, backend=pick["backend"], queue_ahead=ahead, poll=f"/v1/videos/{job['id']}")
  100 +
  101 +
  102 +@app.get("/v1/videos/{jid}")
  103 +def get(jid: str):
  104 + backend, job = find_owner(jid)
  105 + return dict(job, backend=backend)
  106 +
  107 +
  108 +@app.get("/v1/videos/{jid}/file")
  109 +def download(jid: str):
  110 + backend, job = find_owner(jid)
  111 + if job["status"] != "done":
  112 + raise HTTPException(409, f"任务尚未完成(当前 {job['status']})")
  113 + resp = urllib.request.urlopen(f"{backend}/v1/videos/{jid}/file", timeout=300)
  114 + return StreamingResponse(resp, media_type="video/mp4",
  115 + headers={"Content-Disposition": f'attachment; filename="{jid}.mp4"'})
  116 +
  117 +
  118 +@app.get("/v1/videos")
  119 +def listjobs(limit: int = 50):
  120 + def one(backend):
  121 + try:
  122 + return [dict(j, backend=backend)
  123 + for j in fetch(f"{backend}/v1/videos?limit={limit}", timeout=5)["jobs"]]
  124 + except Exception:
  125 + return []
  126 + jobs = [job for part in POOL.map(one, BACKENDS) for job in part]
  127 + jobs.sort(key=lambda j: j["created_at"], reverse=True)
  128 + return {"jobs": jobs[:limit]}
... ...
minimax-h3/long15s.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 15 秒(362 帧 = 17*21+5)可行性验证。低分辨率先验证长时长通路,再考虑全分辨率。
  3 +# 用法: ./long15s.sh <GPU> <FRAMES> <W> <H> [额外参数...]
  4 +set -uo pipefail
  5 +cd /data/models/minimax-h3
  6 +P="$(cat prompt.txt)"
  7 +GPU="$1"; F="$2"; W="$3"; H="$4"; shift 4
  8 +./run.sh "$GPU" --frames "$F" --width "$W" --height "$H" --prompt "$P" --seed 42 "$@"
... ...
minimax-h3/pin-cache.sh 0 → 100755
  1 +#!/bin/bash
  2 +# 把 H3 权重的页缓存钉住:告诉内核 user.slice 下的 40 G 不许回收。
  3 +# 不钉的话,别的程序一申请大块内存,权重缓存就被丢掉,
  4 +# 之后每次前向都要回机械盘 /dev/sdb 读 34.4 G,单条耗时会成倍涨。
  5 +#
  6 +# 重跑安全。撤销:sudo rm /etc/systemd/system/user-.slice.d/h3-cache.conf && sudo systemctl daemon-reload
  7 +set -e
  8 +
  9 +echo "需要 admin 密码(sudo 用的是你自己的密码,不是 root 的)"
  10 +
  11 +sudo mkdir -p /etc/systemd/system/user-.slice.d
  12 +printf '[Slice]\nMemoryMin=40G\n' | sudo tee /etc/systemd/system/user-.slice.d/h3-cache.conf > /dev/null
  13 +sudo systemctl daemon-reload
  14 +sudo systemctl set-property user.slice MemoryMin=40G
  15 +sudo systemctl set-property "user-$(id -u).slice" MemoryMin=40G
  16 +
  17 +echo
  18 +echo "=== 生效情况(两行都应该是 42949672960,即 40 G)"
  19 +grep -H . /sys/fs/cgroup/user.slice/memory.min
  20 +grep -H . "/sys/fs/cgroup/user.slice/user-$(id -u).slice/memory.min"
  21 +
  22 +echo
  23 +echo "=== 当前被保护的量"
  24 +grep -H . /sys/fs/cgroup/user.slice/memory.current
... ...
minimax-h3/realrun.sh 100644 → 100755
minimax-h3/restart-8010.sh 100644 → 100755
minimax-h3/run-verify.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 把调度层临时换成 H3_MAX_QUEUE=1 跑一遍验证,跑完恢复默认。
  3 +set -uo pipefail
  4 +cd /data/models/minimax-h3
  5 +
  6 +restart_gateway() {
  7 + PID=$(ss -lptn "sport = :8000" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u)
  8 + [ -n "$PID" ] && { kill $PID; sleep 3; }
  9 + setsid nohup ./gateway-run.sh 8000 > logs/gateway.log 2>&1 < /dev/null &
  10 + sleep 5
  11 +}
  12 +
  13 +export H3_MAX_QUEUE=1
  14 +restart_gateway
  15 +echo "调度层已起,H3_MAX_QUEUE=1"
  16 +./venv/bin/python verify-fixes.py
  17 +
  18 +echo
  19 +echo "=== 恢复默认 H3_MAX_QUEUE"
  20 +unset H3_MAX_QUEUE
  21 +restart_gateway
  22 +curl -s http://127.0.0.1:8000/health | grep -o '"ready":[0-9]*'
... ...
minimax-h3/run.sh 100644 → 100755
minimax-h3/start-all.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 起全套:四张卡按默认分区起(GPU0/1 ref2va,GPU2/3 fl2va)+ 一个调度层。已经在跑的会先停掉。
  3 +# 用法: ./start-all.sh
  4 +#
  5 +# 停某个端口要按端口找 PID,不要用 pkill -f "port 8010"——bash -c 的命令行本身含这个模式,
  6 +# 会把自己的 ssh 会话一起杀掉(踩过,退出码 255)。
  7 +set -uo pipefail
  8 +cd /data/models/minimax-h3
  9 +
  10 +for PORT in 8000 8010 8011 8012 8013; do
  11 + PID=$(ss -lptn "sport = :$PORT" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u)
  12 + [ -n "$PID" ] && { echo "停 $PORT (PID $PID)"; kill $PID; }
  13 +done
  14 +sleep 8
  15 +
  16 +# GPU 与端口的对应写死在这里,改了要同步改 README。
  17 +# H3_OFFLOAD=disk 是关键:不设就是 cpu,每进程多占 40G 内存,四张卡只能起 3 个。
  18 +export H3_OFFLOAD=disk
  19 +# 2026-08-20:默认分区从「4 张全 ref2va」改成 2/2 固定分裂——GPU0/1 ref2va,GPU2/3 fl2va。
  20 +# console 的弹性调度器(h3-console/server.py 的 supervisor)仍然在跑,需求超出这个 2/2
  21 +# 基线时照样会临时 flip 别的卡;这里只是把冷启动默认值往实际更常见的 fl2va 需求上偏,
  22 +# 减少「提交 fl2va 任务却撞上 4 张全 ref2va」时那 13-20 秒 flip 冷启动 + 最长 30 秒探测延迟。
  23 +setsid nohup ./api-run.sh 1 8010 ref2va > logs/api-8010.log 2>&1 < /dev/null &
  24 +setsid nohup ./api-run.sh 2 8011 fl2va > logs/api-8011.log 2>&1 < /dev/null &
  25 +setsid nohup ./api-run.sh 0 8012 ref2va > logs/api-8012.log 2>&1 < /dev/null &
  26 +setsid nohup ./api-run.sh 3 8013 fl2va > logs/api-8013.log 2>&1 < /dev/null &
  27 +setsid nohup ./gateway-run.sh 8000 > logs/gateway.log 2>&1 < /dev/null &
  28 +
  29 +echo "等后端加载(disk 模式约 20 秒)"
  30 +for i in $(seq 1 30); do
  31 + sleep 5
  32 + READY=$(curl -s --max-time 5 http://127.0.0.1:8000/health | grep -o '"ready":[0-9]*' | cut -d: -f2)
  33 + echo " ready=${READY:-?}/4"
  34 + [ "${READY:-0}" = "4" ] && break
  35 +done
  36 +curl -s http://127.0.0.1:8000/health
  37 +echo
... ...
minimax-h3/start-gateway.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +set -uo pipefail
  3 +cd /data/models/minimax-h3
  4 +PID=$(ss -lptn "sport = :8000" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u)
  5 +[ -n "$PID" ] && { echo "停旧的 (PID $PID)"; kill "$PID"; sleep 3; }
  6 +setsid nohup ./gateway-run.sh 8000 > logs/gateway.log 2>&1 < /dev/null &
  7 +sleep 5
  8 +pgrep -af "gateway:app"
... ...
minimax-h3/submit-same.py 0 → 100644
  1 +"""把 8011 上某条已完成任务的请求原样投给另一个端口,用来做单变量对照。"""
  2 +import json, sys, urllib.request
  3 +
  4 +src_port, dst_port, job_id = sys.argv[1], sys.argv[2], sys.argv[3]
  5 +
  6 +with urllib.request.urlopen(f"http://127.0.0.1:{src_port}/v1/videos/{job_id}", timeout=30) as r:
  7 + req = json.load(r)["req"]
  8 +
  9 +body = json.dumps(req).encode()
  10 +post = urllib.request.Request(f"http://127.0.0.1:{dst_port}/v1/videos", data=body,
  11 + headers={"Content-Type": "application/json"})
  12 +with urllib.request.urlopen(post, timeout=30) as r:
  13 + print(json.load(r))
... ...
minimax-h3/verify-fixes.py 0 → 100644
  1 +"""验三处修复:eta 跟帧数走、队列满返回 429、派发仍然分散。
  2 +
  3 +跑之前要把调度层的 H3_MAX_QUEUE 调成 1,否则填满默认上限 4 要投 16 条、跑 6 分半。
  4 +用 22 帧的短任务,路由和限流逻辑与长任务完全一样。
  5 +"""
  6 +import json, urllib.request, urllib.error
  7 +from collections import Counter
  8 +from concurrent.futures import ThreadPoolExecutor
  9 +
  10 +GW = "http://127.0.0.1:8000"
  11 +CASES = [
  12 + ("832x480x124f 4步(基准,实测 767s)", {"width": 832, "height": 480, "frames": 124, "steps": 4}),
  13 + ("1280x736x56f 4步(实测 736s)", {"width": 1280, "height": 736, "frames": 56, "steps": 4}),
  14 + ("832x480x22f 4步(实测 96s)", {"width": 832, "height": 480, "frames": 22, "steps": 4}),
  15 +]
  16 +SPEC = {"prompt": "一只橘猫趴在洒满阳光的窗台上,慢慢眨眼", "turbo": True}
  17 +
  18 +
  19 +def post(body):
  20 + req = urllib.request.Request(GW + "/v1/videos", data=json.dumps(body).encode(),
  21 + headers={"Content-Type": "application/json"})
  22 + try:
  23 + with urllib.request.urlopen(req, timeout=60) as r:
  24 + return r.status, json.load(r)
  25 + except urllib.error.HTTPError as e:
  26 + return e.code, e.read().decode()
  27 +
  28 +
  29 +print("=== 1. eta 是否跟帧数走(改之前三档全报 691)")
  30 +results = [post(dict(SPEC, seed=200 + i, **spec)) for i, (_, spec) in enumerate(CASES)]
  31 +for (name, _), (code, body) in zip(CASES, results):
  32 + print(f" {name:38} -> eta={body['eta_seconds']}s {body['backend'][-4:]}")
  33 +
  34 +print("\n=== 2. 队列满是否 429(H3_MAX_QUEUE=1,前 3 条已占 3 张卡)")
  35 +with ThreadPoolExecutor(max_workers=4) as pool:
  36 + burst = list(pool.map(lambda i: post(dict(SPEC, seed=300 + i, frames=22, steps=4)), range(4)))
  37 +for i, (code, body) in enumerate(burst):
  38 + detail = body if isinstance(body, str) else f"{body['backend'][-4:]} queue_ahead={body['queue_ahead']}"
  39 + print(f" #{i} HTTP {code} {detail}")
  40 +
  41 +accepted = [b for c, b in results + burst if c == 202]
  42 +print(f"\n=== 3. 派发分布: {dict(Counter(b['backend'][-4:] for b in accepted))}")
  43 +print(f"接受 {len(accepted)} 条,拒绝 {len(results) + len(burst) - len(accepted)} 条")
... ...
minimax-h3/watch-8012.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 盯 disk offload 那条任务:耗时、内存、以及 rchar/read_bytes——
  3 +# rchar 是所有读(含页缓存命中),read_bytes 是真正落到存储层的。
  4 +# 两者差得越大说明页缓存命中越好;read_bytes 跟着涨就是在读机械盘,方案不成立。
  5 +set -uo pipefail
  6 +PID=$(pgrep -f "port 8012")
  7 +JOB="$1"
  8 +while true; do
  9 + S=$(curl -s --max-time 5 "http://127.0.0.1:8012/v1/videos/$JOB")
  10 + ST=$(echo "$S" | grep -o '"status":"[a-z]*"' | cut -d'"' -f4)
  11 + EL=$(echo "$S" | grep -o '"elapsed_seconds":[0-9]*' | cut -d: -f2)
  12 + RC=$(grep "^rchar:" /proc/$PID/io | tr -dc 0-9)
  13 + RB=$(grep "^read_bytes:" /proc/$PID/io | tr -dc 0-9)
  14 + PD=$(grep "^Private_Dirty:" /proc/$PID/smaps_rollup | tr -dc 0-9)
  15 + printf "%s %-8s 已跑%4ss rchar=%6.1fG read_bytes=%6.2fG 私有内存=%5.2fG\n" \
  16 + "$(date +%H:%M:%S)" "$ST" "${EL:-0}" \
  17 + "$(echo "$RC/1073741824" | bc -l)" "$(echo "$RB/1073741824" | bc -l)" \
  18 + "$(echo "$PD/1048576" | bc -l)"
  19 + case "$ST" in done|failed) echo "$S"; break;; esac
  20 + sleep 60
  21 +done
... ...