Commit e4a5d5cf34bc07b1e66e0d51d42e5a55270a55b4
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 动态判定的相关变更
Showing
21 changed files
with
614 additions
and
11 deletions
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 > logs/api-$PORT.log 2>&1 < |
| 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 | ... | ... |