diff --git a/CHANGELOG.md b/CHANGELOG.md index 93435ef..a451eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # 变更记录 +## 2026-08-20(夜,又续五)— 832×480 高分辨率 5s/10s/15s 实测 + 揪出一个真实并发 bug + +**背景**:用户想知道高分辨率(832×480,线上标准档)配 fl2va 首尾帧条件下,5s/10s/15s 三档能不能跑。 +之前「15 秒时长实测」那节测的是纯文生视频(无关键帧),这次是同一个真实任务 +(`ed3663dcda7f`)的 prompt + 首尾帧图,换到 832×480,按用户要求通过 console(8080)正式提交、 +可追溯,而不是绕开它直接打 api.py。 + +### 实测结果 + +| 时长 | 帧数 | rows | 结果 | 峰值显存 | 耗时 | +|---|---|---|---|---|---| +| 5s | 124 | 14430 | ✅ 正常 | 12.95G | 649s | +| 10s | 243 | 28080 | ❌ OOM | 撞到 ~20.3G 才崩 | 26s 后失败 | +| 15s | 362 | 41730 | ❌ OOM | 撞到 ~19G 才崩 | 24s 后失败 | + +**结论**:832×480 加了首尾帧条件后,可行边界和纯文生视频时的判断一致——只有 5s(124帧) 稳, +10s/15s 都撞显存墙。fl2va 的关键帧编码本身没有显著推高显存(12.95G vs 纯文生视频基准 12.2-12.3G), +真正的墙还是 rows(分辨率×帧数),`api.py` 现在的 `ROWS_LIMIT=20000` 挡住 10s/15s 这两档是对的, +不是误伤。 + +为了做这组实测,`api.py` 的 `ROWS_LIMIT` 改成可被环境变量 `H3_ROWS_LIMIT` 覆盖(默认值仍是 +20000,不影响线上),测试期间临时把 GPU1 从 ref2va 切成 fl2va、GPU1/GPU2 都带上放宽后的限制, +凑够三个 fl2va 槽位并发测三档;测完已经把 GPU1 切回 ref2va、GPU2/GPU3 的限制改回默认值, +四卡恢复到 GPU0/1=ref2va、GPU2/3=fl2va 的标准基线,`ready:4/4` 验证过。 + +### 顺带揪出的真实 bug:`_try_submit` 的并发提交竞态 + +测 15s 那组时,`console` 的任务详情页显示后端是 8011,但我提交时 gateway 明明说选的是 8010—— +查下来是同一条任务被**提交给 gateway 两次**,两张卡上各生成了一条独立记录、各自跑了一遍、 +各自 OOM 了一次。根因:`create_video()` 首次提交、`get_video()` 轮询时顺手提交、 +`reconciler_loop()` 每 5 秒扫一遍,三条路都会在 `gateway_id` 还是 `NULL` 的窗口里调 +`_try_submit(cid)`;`await client.post(...)` 让出控制权的那段时间里,另外两条路完全可能也读到 +"还没提交过",于是并发发出第二个 POST。console 只记得住最后一次 `UPDATE` 写回的 `gateway_id`, +另一条产物变成孤儿任务——真实存在、真烧了一遍 GPU,但没人知道它在跑、也没人能停它。 +这次是因为我自己写的后台轮询脚本在提交后几乎立刻开始高频 poll,把这个本来就存在的竞态窗口 +撞开了,不是新引入的问题。 + +**修法**:`server.py` 加一个进程内的 `_SUBMITTING: set[str]`,`_try_submit` 一开始检查 +`cid in _SUBMITTING`,通过后立刻 `add`、结束时 `finally` 里 `discard`——两行判断中间没有 +`await`,在单进程单事件循环里天然原子,足够堵住这个窗口,不需要引入数据库锁。 +已重启两个 console 进程(8080 带弹性调度器、8081 子路径那个)验证生效。 + +### 顺带修的小问题:任务详情页没有显式"时长"字段 + +只显示帧数(比如"362帧"),用户得自己心算 362/24≈15 秒才知道对应哪一档。在"分辨率/帧数"和 +"Turbo/步数"之间加了一行"时长",按 24fps 折算成秒数直接显示。纯前端展示改动,`req.frames` +本来就在返回的数据里,不是数据缺失,是没往界面上摆。 + +--- + ## 2026-08-20(夜,又续四)— h3-console 公网访问改走 qwen.wudaima.com/console 子路径 **背景**:`gateway.wudaima.com` 那条独立域名线路确认走不通(缺口在 60.205.214.169 那台 frp diff --git a/h3-console/server.py b/h3-console/server.py index 3095cc5..681a4ad 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -154,37 +154,54 @@ def _row_view(row) -> dict: return view +# create_video 首次提交、get_video 轮询时顺手提交、reconciler_loop 每 5 秒扫描,三条路都会在 +# gateway_id 还是 NULL 的窗口里调 _try_submit(cid)。三个协程共享同一个事件循环,`await +# client.post(...)` 让出控制权的那段时间里,另外两条路完全可能也读到「还没提交过」, +# 于是同一个 cid 被 POST 给 gateway 两次——两张卡各烧一遍 GPU,console 只记得住最后一次 +# UPDATE 写回的 gateway_id,另一条产物是真实存在但没人知道、也没人能停的孤儿任务。 +# 2026-08-20 实测踩到过:一个 15s/832×480 的探测任务真的在两张卡上各 OOM 了一次。 +# 修法:进程内占坑(不是数据库锁——server.py 单进程单事件循环,内存里一个 set 就够, +# 两行判断中间没有 await,天然原子,不会被其它协程插进来)。 +_SUBMITTING: set[str] = set() + + async def _try_submit(cid: str): """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。""" + if cid in _SUBMITTING: + return # 已经有一次提交在飞行中,等它写完 gateway_id 再说,不要并发再发一次 with db() as con: row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone() if row is None or row["gateway_id"] is not None: return # 任务不存在,或已经提交过 - req = json.loads(row["req_json"]) + _SUBMITTING.add(cid) try: - resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req) - except httpx.HTTPError: - return # gateway 暂时连不上,下一轮再试 - now = time.time() - if resp.status_code < 400: - job = resp.json() - gateway_id = job["id"] - job["id"] = cid # 对外只暴露 console 自己的 id - with db() as con: - con.execute( - "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?", - (gateway_id, job.get("backend"), job.get("status", "queued"), - now, json.dumps(job, ensure_ascii=False), cid), - ) - elif resp.status_code in (503, 429): - return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试 - else: - # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败 - err_view = {"id": cid, "status": "failed", "error": resp.text, - "req": req, "partition": row["partition"], "created_at": row["created_at"]} - with db() as con: - con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", - (now, json.dumps(err_view, ensure_ascii=False), cid)) + req = json.loads(row["req_json"]) + try: + resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req) + except httpx.HTTPError: + return # gateway 暂时连不上,下一轮再试 + now = time.time() + if resp.status_code < 400: + job = resp.json() + gateway_id = job["id"] + job["id"] = cid # 对外只暴露 console 自己的 id + with db() as con: + con.execute( + "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?", + (gateway_id, job.get("backend"), job.get("status", "queued"), + now, json.dumps(job, ensure_ascii=False), cid), + ) + elif resp.status_code in (503, 429): + return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试 + else: + # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败 + err_view = {"id": cid, "status": "failed", "error": resp.text, + "req": req, "partition": row["partition"], "created_at": row["created_at"]} + with db() as con: + con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", + (now, json.dumps(err_view, ensure_ascii=False), cid)) + finally: + _SUBMITTING.discard(cid) async def _refresh_from_gateway(cid: str, gateway_id: str): diff --git a/h3-console/static/index.html b/h3-console/static/index.html index 88ead57..9963d51 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -593,6 +593,7 @@ async function refreshDetail() {
Prompt
${(req.prompt || '').replace(/
分区
${j.partition || '—'}
分辨率/帧数
${req.width || '?'}×${req.height || '?'} × ${req.frames || '?'}帧
+
时长
${req.frames ? (req.frames / 24).toFixed(1) + ' 秒(按 24fps 折算)' : '?'}
Turbo/步数
${req.turbo === false ? '关' : '开'} / ${req.steps ?? '?'} 步,seed=${req.seed ?? 0}
后端
${j.backend || '(尚未分配)'}
提交时间
${fmtTime(j.created_at)}