diff --git a/CHANGELOG.md b/CHANGELOG.md index c41f696..d4fd43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # 变更记录 +## 2026-08-24 — 下线旧 H3(minimax-h3)+ 确认 LLM 早已下线,GPU 显存全部还给 raylight + +**背景**:排查一次 raylight 的 CUDA OOM 时发现 GPU1 被另一进程占着 16.7GB、98%利用率—— +查出来是另一个并发会话手动启动了旧的 `minimax-h3/gateway.py`(8000)+ `api.py`(8010-8013) +四个 worker,在测试他们自己改的代码(cfg_scale/negative_prompt),跟 raylight 抢卡。用户决定 +既然生产已经全面切到 raylight,旧 H3 接口不再需要保留在线,直接下线(按端口 kill 停进程, +代码/权重/`start-all.sh` 都保留,需要能秒级拉回来)。 + +**顺带查到一个文档过期问题**:CLAUDE.md 一直写着 LLM(`llm38`)对外服务中,实际 +`docker ps -a` 显示这个容器 **6天前就已经 Exited 了**,不是本次操作停的——`qwen.wudaima.com` +早就已经打不通,只是没人更新文档。已经在 CLAUDE.md 补充说明。 + +**结果**:四张卡显存全部回收,现在这台机器上真正在跑的生产只有 `raylight/`。 + +**后续追踪(同一天)**:停完没多久 minimax-h3 又被重新拉起来了两次(同一个人还在测), +查了一圈没有 cron/systemd/supervisor 在自动重启,是手动 `./start-all.sh` 拉起来的。第二次 +停掉后,把 `start-all.sh`/`start-gateway.sh` 改名成 `.disabled-20260824` 后缀(脚本本身没删, +权重代码也没动),配一份 `DISABLED-README.md` 说明原因和恢复方法,防止顺手再跑一次。 +另外顺带查到"whisper 常驻"这条也是过期信息——现在实际常驻的是 `stable-diffusion-webui-forge` +和一个 embedding 服务(gunicorn:7590),跟这次任何工作都无关,已经一并停掉。 + ## 2026-08-24 — 多参考图生视频加上参考视频/独立音频参考 **结果**:查 ComfyUI `/object_info` 发现 `MiniMaxH3ReferenceToVideo` 节点其实支持 diff --git a/CLAUDE.md b/CLAUDE.md index bb82f9d..4a8f8e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,11 +84,15 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 'cat > /h |------|--------|----|------|------| | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` | | `raylight/` | **2026-08-22 起 h3-console 的默认后端**:ComfyUI + Raylight 插件,4卡当一个 FSDP 集群跑 MiniMax-H3(不是4个独立worker,任何时刻只处理1个请求)。`server.py`(8100)对外契约兼容旧 `gateway.py`+`api.py`,管理常驻 ComfyUI 进程(8188,仅内部) | 0-3 全部(FSDP集群) | 832×480×64帧实测峰值约20-21GB/卡;**124帧会OOM**,见 `raylight/README.md`「已知限制」 | 8100(内部,h3-console 的 `H3_GATEWAY_URL` 指向这) | -| `minimax-h3/` | **热备份**(2026-08-22 起不再默认启动,代码/权重保留,`start-all.sh` 还能秒级拉回)。MiniMax-H3 FL2VA/Ref2VA NF4 文生视频,`api.py` 四实例(GPU0-3,端口 8010-8013)+ `gateway.py` 调度层(端口 8000),四卡各自独立能同时处理4个请求 | 四卡各一进程(停着) | 单卡 fp32 峰值约 9-14 GB(视时长/分辨率),另占 CPU 内存约 32 GB/worker | 8000(停着) | +| `minimax-h3/` | **热备份(2026-08-24 起启动脚本被禁用)**。MiniMax-H3 FL2VA/Ref2VA NF4 文生视频,`api.py` 四实例(GPU0-3,端口 8010-8013)+ `gateway.py` 调度层(端口 8000),四卡各自独立能同时处理4个请求。代码/权重保留,但 `start-all.sh`/`start-gateway.sh` 已改名成 `.disabled-20260824`(见目录内 `DISABLED-README.md`)——2026-08-24 发现有人手动拉起来跟 raylight 撞 GPU,停了两次又被拉起来,直接禁掉启动脚本,别再凭记忆当"还能秒级拉回"用 | 四卡各一进程(停着,脚本已禁用) | 单卡 fp32 峰值约 9-14 GB(视时长/分辨率),另占 CPU 内存约 32 GB/worker | 8000(停着) | | `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化。两个进程共享同一份 sqlite/assets:**8080**(局域网直连,`BASE_PATH` 空)+ **8081**(`H3_CONSOLE_BASE_PATH=/console`,专供公网子路径用)。**2026-08-22 起两个实例的 `H3_ENABLE_SUPERVISOR` 都关了**——fl2va/ref2va 弹性调度器(按partition flip某张卡)是给 minimax-h3 那套"4个独立worker"架构设计的,raylight 的FSDP集群架构下没有"单卡分区"这个维度,开着会出错 | 不占显存(自己不跑模型) | — | 局域网 `http://192.168.110.67:8080`;公网 `http://qwen.wudaima.com/console`(frp `locations=/console` 分流,`gateway.wudaima.com` 那条独立域名确认走不通,缺口在 frp 服务端 60.205.214.169,见 CHANGELOG) | -| (常驻) | whisper | GPU3 | 3.2 GB | — | -排卡前先 `nvidia-smi` 看实际余量:GPU3 被 whisper 占着,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,起新负载不加 `-ts` 容易把公网服务挤 OOM。 +⚠️ **"(常驻)whisper GPU3 3.2GB"这条 2026-08-24 确认是过期信息**——当时机器上根本没有任何 +带"whisper"字样的进程。实际常驻的是 `stable-diffusion-webui-forge`(PID对应`/data/models/ +stable-diffusion-webui-forge`)和一个 embedding 服务(gunicorn:7590),跟这台服务器的 LLM/H3 +两条工作线都无关,2026-08-24 一并停掉了(详见 CHANGELOG)。排卡前还是先 `nvidia-smi` 看实际 +余量,不要照着这张表的旧数字猜,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**, +起新负载不加 `-ts` 容易把公网服务挤 OOM——不过 LLM(`llm38`) 目前也已停(见上)。 ⚠️ **2026-08-18 上下文提到 256K 后余量大幅收紧**:四卡合计已用 61.4 / 88 GB,只剩约 26 GB,**GPU2 最紧只剩 5.4 GB**。minimax-h3 按「空闲显存 − 2 GB」自适应,等于它的可用显存被同步压缩了。两条线同时跑之前先看 `nvidia-smi`。 @@ -126,6 +130,14 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 'cat > /h ⚠️ 已知安全问题:公网可无鉴权直调 27B 推理;SSH 22 端口经 FRP 映射到公网 6000 且允许密码登录、admin 在 sudo 组。 +**2026-08-24 更新:LLM(`llm38`)和旧 H3(`minimax-h3` 的 `gateway.py`+`api.py`)都已下线,不占显存**。 +`llm38` 容器 `docker ps -a` 显示 6 天前就已经停了(`Exited (0)`),不是本次操作停的,只是文档一直 +没更新——`qwen.wudaima.com` 现在实际上是打不通的,上面这段"对外服务中"的描述已经过期。 +`minimax-h3` 的四个 worker(8010-8013)+ `gateway.py`(8000)是本次手动按端口 `kill` 停的—— +之前被另一个并发会话手动启动过,跟 raylight 抢过 GPU(详见 `raylight/notes.md`/`CHANGELOG.md` +2026-08-24 那几条)。**现在这台机器上生产只跑 `raylight/`(h3-console 默认后端)**,代码/权重 +都还在,两条线要拉回来都还是走各自的 `start.sh`/`docker start llm38`,没有删任何东西。 + ## 这是一台测试机(2026-08-18 用户明确) **这台机器的用途就是测试各种模型,不是生产环境。** 显存不够时**直接停 llm38 给别的负载让路是允许的、也是预期做法**,不需要为此设计复杂的共存方案。 diff --git a/h3-console/server.py b/h3-console/server.py index 1cfe38d..b4c63e3 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -46,6 +46,23 @@ H3_ROOT = Path("/data/models/minimax-h3") # 仅服务器上存在,本地开å� app = FastAPI(title="H3 Console") client = httpx.AsyncClient(timeout=30.0) +# 2026-08-24:"重置GPU"/"停止运行中任务"都是硬重启整个raylight-server,进程重启期间端口 +# 直接连不上,这段时间提交新任务只会拿到一个费解的连接失败——干脆记一个"重置中"窗口, +# 提交请求直接给个说得清楚的409,不留给用户猜。25秒是给硬重启(~15-20秒)留够余量的估算值, +# 不是精确等到端口真正恢复健康。 +RESET_WINDOW_SECONDS = 25 +_reset_until = 0.0 + + +def _mark_resetting(): + global _reset_until + _reset_until = time.time() + RESET_WINDOW_SECONDS + + +def _check_not_resetting(): + remaining = _reset_until - time.time() + if remaining > 0: + raise HTTPException(409, f"GPU正在重置/服务重启中,还剩约{int(remaining)}秒,请稍后重试") # 所有 /v1/* 接口挂在这个 router 上而不是直接挂在 app 上,是为了能整体套一层 BASE_PATH # 前缀(app.include_router(router, prefix=BASE_PATH),见文件末尾)——BASE_PATH 留空时 # 前缀是空字符串,行为和直接挂在 app 上完全一样。 @@ -269,8 +286,52 @@ async def _refresh_from_gateway(cid: str, gateway_id: str): ) +async def _discover_from_gateway(): + """兜底:把绕过 console、直接打 GATEWAY_URL(raylight:8100 或老 gateway:8000)提交的任务 + 捞回来。两边后端都有 GET /v1/videos 全量列表(raylight 直接返回 list,老 gateway 包一层 + {"jobs":[...]}),本来就是给这个用的。2026-08-24 真出过一次:有人直连后端提交,console + 界面完全看不到,两边还因为都不知道对方在用哪张卡而抢 GPU。 + 没有反过来发现「新任务」的必要去重锁——本函数只在 reconciler_loop 里单协程顺序调用, + 不会跟自己并发。""" + try: + resp = await client.get(f"{GATEWAY_URL}/v1/videos", params={"limit": 200}) + except httpx.HTTPError: + return + if resp.status_code != 200: + return + body = resp.json() + remote = body.get("jobs", body) if isinstance(body, dict) else body + with db() as con: + known = {r["gateway_id"] for r in con.execute( + "SELECT gateway_id FROM jobs WHERE gateway_id IS NOT NULL")} + for summary in remote: + gid = summary.get("id") + if not gid or gid in known: + continue + try: + detail = await client.get(f"{GATEWAY_URL}/v1/videos/{gid}") + except httpx.HTTPError: + continue + if detail.status_code != 200: + continue + job = detail.json() + req = job.get("req", {}) + now = time.time() + job["id"] = gid # 发现的任务没有独立的 console id,直接用 gateway id 本身 + with db() as con: + con.execute( + "INSERT OR IGNORE INTO jobs " + "(id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (gid, gid, job.get("partition") or _partition_of(req), job.get("backend"), + job.get("status", "queued"), json.dumps(req, ensure_ascii=False), + job.get("created_at", now), now, json.dumps(job, ensure_ascii=False)), + ) + + @router.post("/v1/videos", status_code=202) async def create_video(req: dict): + _check_not_resetting() partition = _partition_of(req) cid = uuid.uuid4().hex[:12] now = time.time() @@ -297,6 +358,7 @@ async def cancel_video(jid: str): raise HTTPException(409, f"任务当前状态是 {row['status']},已经是终态,取消不了") now = time.time() if row["gateway_id"]: + was_running = row["status"] == "running" # 停"running"的任务=硬重启,见下面_mark_resetting # 已经提交给后端了,得让后端也知道要取消(后端只允许取消它自己还在排队的任务, # 如果已经开始跑了会返回409——这里直接把后端的错误透传出去,不假装取消成功了) try: @@ -305,6 +367,8 @@ async def cancel_video(jid: str): raise HTTPException(502, "取消请求发不到后端,稍后再试") if resp.status_code >= 400: raise HTTPException(resp.status_code, resp.text) + if was_running: + _mark_resetting() job = resp.json() job["id"] = jid with db() as con: @@ -437,6 +501,20 @@ async def backends(): return resp.json() +@router.post("/v1/admin/reset-gpu") +async def reset_gpu(): + # 通用重置入口,不挂在具体任务上——应对Raylight actor崩溃/OOM后不自愈、显存卡在接近 + # 满载但JOBS里没有running任务的情况(这时候"停止"按钮不会出现,需要这个兜底)。 + try: + resp = await client.post(f"{GATEWAY_URL}/admin/reset-gpu") + except httpx.HTTPError: + raise HTTPException(502, "重置请求发不到后端,稍后再试") + if resp.status_code >= 400: + raise HTTPException(resp.status_code, resp.text) + _mark_resetting() + return resp.json() + + @router.get("/v1/models") def models(): # 静态信息,对应服务器上 api.py 里的 PARTITIONS 常量。只读展示,不做运行时切换。 @@ -569,6 +647,7 @@ async def reconciler_loop(): await _try_submit(r["id"]) for r in in_flight: await _refresh_from_gateway(r["id"], r["gateway_id"]) + await _discover_from_gateway() except Exception as e: print(f"[reconciler] tick failed: {e}", flush=True) diff --git a/h3-console/static/index.html b/h3-console/static/index.html index 9f8f609..bef9a51 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -83,6 +83,7 @@
@@ -438,6 +439,7 @@ function readParamSelects(prefix) { async function submitJob(kind) { const errBox = document.getElementById(kind + '-err'); errBox.textContent = ''; + if (resetting) { errBox.textContent = 'GPU正在重置,请等重置完成后再提交'; return; } let body = {}; if (kind === 'text') { body = { prompt: document.getElementById('text-prompt').value, ...readParamSelects('text') }; @@ -569,21 +571,68 @@ async function rerunJob(id) { } catch (e) { alert('重跑失败: ' + e.message); } } +// 硬重启期间(停止运行中任务/重置显存)端口直接连不上,提交/轮询都会失败——用这个全局开关 +// 锁住提交按钮,轮询/health直到真正恢复,不让用户在这段时间瞎点提交撞一堆报错。 +let resetting = false; + +function setResetting(on) { + resetting = on; + document.querySelectorAll('button.submit').forEach(b => { + b.disabled = on; + if (on) { b.dataset.origText = b.dataset.origText || b.textContent; b.textContent = '重置中…'; } + else if (b.dataset.origText) { b.textContent = b.dataset.origText; } + }); + const btn = document.getElementById('reset-gpu-btn'); + if (btn) btn.disabled = on; +} + +async function waitForHealthy() { + // 重启那几秒端口没监听,fetch会直接抛异常——这是预期中的过程,不是失败,一直重试到连上为止 + while (true) { + await new Promise(res => setTimeout(res, 3000)); + try { + const r = await fetch(H3_BASE + '/v1/backends'); + if (r.ok) { const d = await r.json(); if (d.ready >= d.total) return; } + } catch (e) { /* 重启中,继续等 */ } + } +} + async function cancelJob(id, running) { // running=true 时是"停止":Raylight的Ray子进程不理会ComfyUI的/interrupt,唯一能真正把 // GPU收回来的办法是硬重启整个服务——所以要把这个代价明确告诉用户,不能假装是优雅停止。 const msg = running - ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间其他任务也会受影响。' + ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间无法提交新任务。' : '确定取消这条排队中的任务?'; if (!confirm(msg)) return; try { const r = await fetch(`${H3_BASE}/v1/videos/${id}/cancel`, { method: 'POST' }); if (!r.ok) throw new Error(await r.text()); + if (running) { + setResetting(true); + document.getElementById('backends').textContent = '⏳ 正在重置GPU,服务重启中,请稍候…'; + await waitForHealthy(); + setResetting(false); + await refreshBackends(); + } await refreshDetail(); await refreshJobs(); } catch (e) { alert((running ? '停止' : '取消') + '失败: ' + e.message); } } +async function resetGpu() { + if (!confirm('确定重置GPU?会硬重启整个服务,打断当前正在运行的任务(如果有),大约15-20秒,重置完成前无法提交新任务。')) return; + try { + const r = await fetch(`${H3_BASE}/v1/admin/reset-gpu`, { method: 'POST' }); + if (!r.ok) throw new Error(await r.text()); + } catch (e) { alert('触发重置失败: ' + e.message); return; } + setResetting(true); + document.getElementById('backends').textContent = '⏳ 正在重置GPU,服务重启中,请稍候…'; + await waitForHealthy(); + setResetting(false); + await refreshBackends(); + await refreshJobs(); +} + function statusLabel(s) { const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' }; return `${map[s] || s}`; diff --git a/raylight/scripts/server.py b/raylight/scripts/server.py index 0dce30d..f2a599e 100644 --- a/raylight/scripts/server.py +++ b/raylight/scripts/server.py @@ -569,6 +569,16 @@ def create_video(req: GenReq): "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL} +def _trigger_restart_async(): + # sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启, + # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。 + subprocess.Popen( + ["bash", "-c", "sleep 2 && bash /data/models/raylight/start.sh " + ">> /data/models/raylight/stop-restart.log 2>&1"], + start_new_session=True, + ) + + @app.post("/v1/videos/{jid}/cancel") def cancel_video(jid: str): j = JOBS.get(jid) @@ -591,17 +601,28 @@ def cancel_video(jid: str): j.finished_at = time.time() j.error = "用户手动停止(硬重启GPU——Raylight的Ray子进程收不到ComfyUI的/interrupt信号,见notes.md)" _persist(j) - # sleep 2 秒让这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启, - # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。 - subprocess.Popen( - ["bash", "-c", "sleep 2 && bash /data/models/raylight/start.sh " - ">> /data/models/raylight/stop-restart.log 2>&1"], - start_new_session=True, - ) + _trigger_restart_async() return j.model_dump() raise HTTPException(409, f"任务当前状态是 {j.status},无法取消/停止") +@app.post("/admin/reset-gpu") +def reset_gpu(): + # 通用重置——不依赖有没有具体任务在跑。已知上游bug:Raylight actor崩溃/OOM后不会自愈 + # (ensure_fresh_actors里needs_restart被无条件覆盖成False,见README),显存可能卡在 + # 接近满载但JOBS里根本没有running任务,这时候"停止"按钮不会出现(只在有running任务时才 + # 显示),需要一个不挂在具体任务上的兜底重置入口。 + with LOCK: + for j in JOBS.values(): + if j.status == "running": + j.status = "cancelled" + j.finished_at = time.time() + j.error = "用户手动重置GPU(硬重启,见 /v1/videos/{id}/cancel 同款说明)" + _persist(j) + _trigger_restart_async() + return {"status": "restarting", "message": "已触发服务重启,约15-20秒后GPU恢复空闲"} + + @app.get("/v1/videos/{jid}") def get_video(jid: str): j = JOBS.get(jid)