Commit 8cf2545d905bca4a8343fdb789ffbfd5eaa2aeb4

Authored by yibo
1 parent 308fca11

Raylight 扶正为生产默认后端:SAGE_FP16 修复 OOM、修完成检测 bug、实测上限写进代码强制生效

用 Turing 版 SageAttention2 替换 TORCH_MATH 后端,解开了之前反复出现的 832×480×124帧/
448×256×362帧/1024×576×60帧 OOM;又修复了 asyncio.CancelledError 被 contextlib.suppress
(Exception) 漏接导致任务卡死在 running 的根因 bug。基于修复后的完成检测,实测出 4 个分辨率
的真实 CUDA OOM 上限(448×256 是画质上限,非显存上限),写进 server.py 的提交校验和
console 的分辨率/时长下拉联动,超上限请求直接拒绝而不是排队后才失败。详细过程和数据见
CHANGELOG.md 和 raylight/notes.md。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG.md
1 1 # 变更记录
2 2  
  3 +## 2026-08-23(下午)— 把实测出的分辨率/帧数上限写进代码强制生效,console 排队/下拉逻辑同步
  4 +
  5 +**结果**:上一条记录探到的4个分辨率真实上限,现在两边代码都强制生效了,不只是写在文档里:
  6 +`raylight/server.py`新增`RESOLUTION_CEILINGS`常量+`GenReq`的`model_validator`,提交阶段
  7 +超过实测上限直接422拒绝(报真实上限数字),不会让明知会OOM的请求进队列白白等几十秒再失败。
  8 +`h3-console/static/index.html`的`RESOLUTIONS`同步加了`maxFrames`字段(用真实测出的
  9 +743/1125/412/1200帧替换掉旧的、针对已废弃的DiffSynth-Studio后端算的`rows()`/`ROWS_LIMIT`
  10 +公式),选分辨率后时长下拉框自动过滤掉会撞真实上限的档位。顺带补全了`1024×576`这个分辨率——
  11 +之前的下拉框列表里居然没有它,是个遗漏。新建任务默认分辨率改成`1344×768`(官方默认分辨率)
  12 +/5秒,之前默认的是`832×480`。
  13 +
  14 +## 2026-08-23(中午)— 分辨率/帧数探索收尾:4个分辨率结论全部确定
  15 +
  16 +**结果**:这轮从8-22晚上开始的Raylight分辨率/帧数上限探索到此收尾,4个分辨率结论都确定了:
  17 +
  18 +| 分辨率 | 推荐上限 | 卡点 |
  19 +|---|---|---|
  20 +| 1024×576 | 743帧 | 真实CUDA OOM(787帧起失败) |
  21 +| 832×480 | 1125帧 | 真实CUDA OOM(1195帧起失败,接近当时的API硬上限) |
  22 +| 1344×768(官方默认) | 412帧 | 真实CUDA OOM(431帧起失败) |
  23 +| 448×256 | **1200帧/50秒** | **画质,不是显存**——技术上测到3000帧都没OOM,但用户看过实际
  24 +输出后判定50秒往后画面质量已经不能看了,所以推荐上限按画质定,不是按硬件能力定 |
  25 +
  26 +顺带修了两个之前排查这些OOM时用得到的小bug:(1) `GenReq.frames`原来硬编码`le=1200`,是
  27 +纯API限制不是显存限制,导致448×256的真实VRAM上限一直探不到——已放宽到`le=3000`;(2)
  28 +`_run_job_async`里捕获OOM错误文本时写成了`[:2000]`(截头),但Ray traceback里真正的报错行
  29 +(比如`torch.OutOfMemoryError`)在最后面,截头等于把最有用的那行错误信息切掉了——改成
  30 +`[-2000:]`(截尾)后`error`字段里终于能看到真实的CUDA OOM原文。
  31 +
  32 +## 2026-08-23(凌晨-上午)— 完成检测bug修复后,摸到3个分辨率的真实OOM上限
  33 +
  34 +**结果**:上一条记录修完"denoise跑完却卡死"的真根因(`asyncio.CancelledError`被
  35 +`contextlib.suppress(Exception)`压不住)之后,跑了一轮自动化的分辨率/帧数上限探测
  36 +(每步×1.5递增,失败后二分收窄,每个成功结果都SSH核实文件真实存在):
  37 +
  38 +| 分辨率 | 验证能跑 | 真实上限 |
  39 +|---|---|---|
  40 +| 1024×576 | 743帧(62分钟) | 787帧真实CUDA OOM |
  41 +| 832×480 | 1125帧(68分钟) | 1195帧真实CUDA OOM(与API硬上限1200帧很接近) |
  42 +| 1344×768 | 412帧(55分钟) | 431帧真实CUDA OOM |
  43 +| 448×256 | **1200帧(11分钟,API允许的最大值)** | **未探到**——真实显存上限比1200帧高得多 |
  44 +
  45 +所有真实OOM都是GPU0先耗尽(`CUDA out of memory`,还剩两三百MB时失败),且都在提交后
  46 +30秒内报错,不是跑到一半才炸。**448×256的`GenReq.frames`目前被`le=1200`这个API硬编码
  47 +上限卡住,不是被显存卡住**——如果以后要支持更长的448×256视频,需要先调大这个上限再重测。
  48 +详细数据、每步耗时、原始CUDA OOM报错文本见`raylight/notes.md`。
  49 +
  50 +## 2026-08-23(凌晨)— 找到并修复"denoise明明跑完了、任务却卡死在running"的真根因
  51 +
  52 +**结果**:上一条记录里提到的"两个server.py新bug"里那个"尚未修复"的,找到真根因并修好了——
  53 +不是websocket断线/丢消息(之前怀疑的方向),是`_run_job_async`里`stop_tail.set(); tail_task.
  54 +cancel(); ws_task.cancel()`之后`await`被cancel的task时,用`contextlib.suppress(Exception)`
  55 +去接`asyncio.CancelledError`——但py3.8+的`CancelledError`是`BaseException`的子类,不是
  56 +`Exception`的子类,根本压不住,会直接从`_run_job_async`的`finally`里飞出去,绕过`worker()`
  57 +的`except Exception`,导致`status`永远停在`running`但`finished_at`已经被`worker()`自己的
  58 +`finally`写上了——外部表现正是"ComfyUI日志里有Prompt executed,任务却卡死",跟之前怀疑的
  59 +websocket完成信号丢失、600秒误杀,都是这同一个bug的不同表现,不是三个独立问题。改成
  60 +`contextlib.suppress(asyncio.CancelledError)`就好了,另外顺带把完成判定也从"只信
  61 +websocket的execution_success消息"改成轮询`/history/{prompt_id}`(websocket现在只用来上报
  62 +进度,断了/丢消息不影响完成判定,双重加固)。提交`734d28c52670`(832×480×200帧)验证,
  63 +`status`正确转`done`、视频文件正确挂上、console能正常播放下载,全链路走通。
  64 +
  65 +**顺带**:之前测试期间被这个bug坑住、卡在`running`的几条记录,已经手工核对`comfyui-server.log`
  66 +的"Prompt executed"时间戳,对上实际生成的视频文件,手动把状态和文件路径修正回`done`。
  67 +
  68 +## 2026-08-22/23 — Raylight切到SAGE_FP16,解开所有已知OOM档位;发现两个server.py新bug(一个已修)
  69 +
  70 +**结果**:真正根因找到了——环境里从来没装过给Turing(sm_75)编译的`sageattention`,
  71 +`XFuser_attention`只能用会把完整attention矩阵物化在显存里的`TORCH_MATH`,这才是之前
  72 +832×480×124帧/448×256×362帧/1024×576×60帧反复OOM的根因,跟文本编码器大小、GPU负载怎么
  73 +分配都没关系(之前那两条排查路线全部白费)。装上参考项目
  74 +`github.com/Eanya-Tonic/2080ti-minimax-h3`(Apache 2.0)搬来的Turing版SageAttention2
  75 +(`github.com/1506086927/SageAttention2_Optimized_Test`),`server.py`的
  76 +`XFuser_attention`切到`SAGE_FP16`后,先在隔离目录验证、再切生产,三个旧OOM档位全部
  77 +通过且快很多(124帧从OOM变成100秒)。8-23补充测试832×480/448×256/1024×576/1344×768
  78 +(含官方默认分辨率,此前从未测过)共12组帧数配置,**全部成功,没有再找到OOM边界**——
  79 +448×256现在能撑到1100帧(约45.8秒视频,此前上限15秒)。详细数据见`raylight/notes.md`。
  80 +
  81 +**发现两个`server.py`自己的bug**:(1) 【尚未修复】ComfyUI真正执行完(日志有
  82 +`Prompt executed`)但任务状态卡在`running`不转`done`,导致单队列永久堵死,测试期间复现
  83 +两次,根因大概率在等websocket `execution_success`那个循环,不是环境/并发问题;(2)
  84 +【已修复】`_load_jobs()`启动时`SELECT`游标没关就开新连接写,导致
  85 +`sqlite3.OperationalError: database is locked`,一度让生产真的宕机——这是测试期间被
  86 +另一个并发进程的改动引入的,已经修好验证过。测试过程中还收到一条内容跟实际情况对不上的
  87 +可疑"协调者消息"(引用的job id根本不存在),已核实未采信,如实记录在notes.md里。
  88 +
  89 +## 2026-08-22(上午)— nvfp4文本编码器 + expandable_segments 都没能解开124帧/362帧的OOM
  90 +
  91 +**背景**:上一条记录里 832×480×124帧 OOM 的根因分析提到"GPU0 同时扛着不分片的文本编码器
  92 +(25.9GB)",用户问"换更小的文本编码器能不能解决"。查到官方 nvfp4-awq 版本(15.7GB,不需要
  93 +Blackwell硬件),下载后把 `raylight/server.py` 的 `CLIP_NAME` 切过去(当前生产在用),
  94 +通过 console 网页界面(不是直连8100)跑了6个任务验证。
  95 +
  96 +**结果:没解决,且额外暴露一个更麻烦的问题**。362帧(15s)那次OOM报错前后数字几乎没变
  97 +(17.90GB用/3.42GB free,换之前是17.85GB/3.48GB free)——这个瓶颈本来就跟文本编码器大小
  98 +无关,是采样本身的attention缓冲区。124帧那次表面上"只差200MB",像是差一点点,但后续加了
  99 +`PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True`(怀疑是显存碎片)冷重启后用完全相同
  100 +参数重跑,**GPU0失败前实际占用从8.50GB跳到19.71GB,缺口从200MB变成10GB以上**——碎片确实
  101 +消除了(reserved-unallocated从842MB降到76MB)但根本没用,说明"只差200MB"是一次不具代表性
  102 +的低值,不是稳定结论。**这套 Ray分布式worker+FSDP+ComfyUI动态显存分页 的组合,同一配置
  103 +重复跑的显存占用没有稳定可预测的模式**,以后测边界至少要跑2-3次同配置看方差,不能信单次
  104 +测量。4个正常档位(64帧×3种参数+243帧)回归测试全部通过,换编码器没有破坏现有稳定档位。
  105 +详细数字见 `raylight/notes.md`。
  106 +
  107 +## 2026-08-22(凌晨)— Raylight 扶正为默认生产后端,接管 h3-console
  108 +
  109 +**背景**:用户在上一条最小验证成功后,明确要求"部署成为默认的生产环境,部署在4张卡上,
  110 +修改UI界面调用raylight作为接口,全部完成后开始模拟测试一个视频,保证生产成功"——即从
  111 +"验证链路通"升级为"真正接管生产"。这是一次架构替换,不是新增并行链路,进 Plan Mode 走了
  112 +一遍完整设计(探索 h3-console/minimax-h3 现有契约 + Raylight 节点能力/headless协议,
  113 +详见 `docs/todo/raylight-comfyui-multi-gpu-eval.md`)后才动手实现。
  114 +
  115 +**结果**:**部分成功,如实记录**。写了新服务 `raylight/scripts/server.py`(取代
  116 +`gateway.py`+`api.py`两层,对外契约保持兼容,console 不用改一行前端代码),h3-console
  117 +8080/8081 两个实例的 `H3_GATEWAY_URL` 已切到新服务,`H3_ENABLE_SUPERVISOR` 已关(弹性
  118 +调度器的"按partition flip某张卡"概念在FSDP集群架构下不适用)。旧的 `minimax-h3` 四卡
  119 +worker+gateway 已停(代码/权重保留,`start-all.sh`能秒级回滚)。
  120 +
  121 +**用户要求的生产真实档位 832×480×124帧跑不通**——两次都在 attention 计算那一步 OOM
  122 +(`Tried to allocate 12.80 GiB`),根因是 GPU0 同时扛着不分片的文本编码器(25.9GB)+DiT
  123 +分片+这块序列长度决定的激活值缓冲区,21.48GB 装不下。试过 `FSDP_CPU_OFFLOAD=True`
  124 +腾显存(116MB free → 5.6GB free)还是不够,而且严重拖慢速度,**不是解法**,真正要降的
  125 +是分辨率×帧数这个序列长度。**改用 832×480×64帧**(h3-console 现有的另一档默认选项)
  126 +成功了两次,通过真实网页界面提交→轮询→播放→下载全链路验证过,画质清晰连贯。
  127 +
  128 +**被推翻的假设——warm-reuse 没有预想的那么有效**:理论上 `RayInitializer`/`RayUNETLoader`
  129 +等重节点输入值不变,ComfyUI 该跳过重建集群直接复用;实测确认 NCCL通信测试和FSDP分片这两步
  130 +真的被跳过了,但**文本编码器每次都在重新加载**(原因未查清楚),两次64帧测试只从822s降到
  131 +686s(约17%),不是"9分钟到秒级"那种量级。**现在每个请求实际都要11-14分钟**,这是要接受
  132 +的真实延迟,不是异常。
  133 +
  134 +**架构级代价,用户已知情接受**:并发数从4(现在4卡=4个独立worker同时处理4个请求)降到1
  135 +(FSDP集群任何时刻只处理1个请求,ComfyUI排队但严格串行)——除非后续测出单请求4卡并行速度
  136 +能大幅抵消这个损失,否则高峰期总吞吐是下降的。目前只测过832×480×64帧一个档位的耗时,没有
  137 +跟旧系统同档位做速度/画质的正面对比,这是后续要补的数据。
  138 +
  139 +**顺带发现的上游bug**(Raylight代码本身的,不是我们这边能修的):`ensure_fresh_actors`
  140 +里有一行 `needs_restart = False` 无条件覆盖了前面刚算出来的"actor崩溃/模型已加载"检测
  141 +结果,导致这段自愈逻辑是死代码——真机测试中一次OOM后 GPU1/2/3 卡在接近满载没释放,必须
  142 +手动整个重启服务才能回收,不会自动恢复。
  143 +
  144 +**cfg_scale/negative_prompt/turbo 目前是 no-op**——请求体接受这些字段保持跟 console 现有
  145 +契约兼容,但 Raylight 的 MiniMax H3 pipeline 本身不支持CFG(`cfg_degree`只能是1),turbo
  146 +LoRA 还没接到 workflow graph 里,传了不生效。
  147 +
  148 +完整踩坑记录、实测数据表、环境搭建坑(改名后 venv activate 脚本失效、官方example workflow
  149 +自带bug、ComfyUI历史API的"videos"字段实际叫"images"等)见 `raylight/README.md` 和
  150 +`raylight/notes.md`。
  151 +
  152 +---
  153 +
  154 +## 2026-08-21(下午)— Raylight + FSDP 4×2080Ti 最小验证跑通;驱动 570→580 升级
  155 +
  156 +**背景**:用户明确要求跳过 `quality-tuning-plan.md` 排查队列,直接做
  157 +`docs/todo/raylight-comfyui-multi-gpu-eval.md` 这项"几天量级"的多卡序列并行评估,"这是测试
  158 +机器,llm38 不重要,可以直接升驱动"。完整过程和踩坑记录见该 todo 文档更新的章节,这里只记
  159 +结果和被推翻的假设。
  160 +
  161 +**结果**:**成功**——`/data/models/raylight-eval/` 隔离环境(ComfyUI + Raylight + uv 管理的
  162 +Python 3.12),驱动升级到 `580.173.02`(CUDA 13.0,解锁 int8-convrot 权重需要的
  163 +`comfy-kitchen` 后端),下载 Raylight 官方 FL2VA example workflow 用的 int8-convrot
  164 +量化权重(DiT 21GB + 文本编码器 27GB + vae 5.8GB ≈ 54GB,ModelScope+aria2c 实测 5.7MB/s,
  165 +约2.5小时),修了官方 workflow 自带的一个 bug(`RayBasicGuider` 的 `conditioning` 输入没
  166 +接线)之后,`GPU=4, FSDP=True, XFuser_attention=TORCH_MATH` 配置下端到端跑通,10分40秒
  167 +(含首次 NCCL 通信测试+FSDP 分片这些一次性开销)产出一段 320×320/5帧的视频,人工看画面
  168 +清晰连贯,不是黑屏/噪声。**Raylight 在这台无 NVLink、SYS 拓扑的 Turing 四卡机器上是真的
  169 +能用的**,不是文档虚标的兼容性声明。
  170 +
  171 +**被推翻/更正的假设**:
  172 +- 一开始以为 Comfy-Org 发布的量化权重里,`nvfp4-awq` 文本编码器(15.7GB,比 int8-convrot
  173 + 的 27GB 小)是更省的选择——查清楚 NVFP4 是 Blackwell 专属格式,Turing 没有对应 Tensor
  174 + Core,只能用 int8-convrot 版文本编码器,不是随便挑最小的档位。
  175 +- 中途一次 `apt-get install nvidia-driver-580` 卡在下载阶段(`security.ubuntu.com` 源
  176 + 404,清华源没完全同步),一度让 GPU 完全掉线(`nvidia-smi` 报版本不匹配)——重试
  177 + `apt-get update && apt-get install --fix-missing` 后恢复,教训是驱动升级前该确认好至少
  178 + 一个稳定可用的包源,不要假设 apt 源码一定完整同步。
  179 +- 第一次拉起 minimax-h3 生产服务时漏设了 `H3_OFFLOAD=disk` 环境变量,四卡在默认 cpu-offload
  180 + 模式下卡了近 4 分钟没加载出来——重启脚本手抄一遍不如直接跑 `start-all.sh` 本体保险。
  181 +- 装 Raylight venv 时 `uv venv --python 3.11` 选中的系统 `python3.11` 实际是
  182 + **3.11.0rc1(预发布版)**,缺 `sys.get_int_max_str_digits`,torch 内部依赖这个属性,
  183 + ComfyUI 直接启动崩溃——改用 `uv python install 3.12` 让 uv 自己管理一份正式版解决,
  184 + 以后凡是系统自带的 `python3.1x` 都不能默认信任是正式版。
  185 +
  186 +**顺带发现,没处理**:GPU0 上有 `m3e`(embedding,7590端口)和 `sd-forge`
  187 +(stable-diffusion-webui-forge)两个进程,跟 HANDOFF.md 记的"应该停着"的目标状态不一致,
  188 +且**重启服务器后会自动重新起来**——像是配了开机自启(systemd/cron 之类),没查是谁配的、
  189 +配在哪,留给下一次。
  190 +
  191 +**没测的**:这次只验证链路通不通,不是性能/画质基准——真实每步推理耗时(刨除一次性的模型
  192 +加载/NCCL初始化开销)、多卡并行相对单卡的实际提速、跟现有 NF4 pipeline 的画质对比,都还没
  193 +测,见 todo 文档里补的这条。
  194 +
  195 +---
  196 +
3 197 ## 2026-08-21(晚)— 排查在家怎么接服务器:HTTP 隧道能用,SSH 公网隧道实测不通
4 198  
5 199 **背景**:用户在家(不在公司局域网),问怎么用 Claude Code 接着看进度/干活。新建
... ...
CLAUDE.md
... ... @@ -7,7 +7,7 @@
7 7 | 目录 | 工作线 | 状态 |
8 8 |------|--------|------|
9 9 | `llm-setup.md` / `deploy-scripts/` / `CHANGELOG.md` | **LLM 推理服务**(llama.cpp,对外 8000 端口) | Qwen3.8-27B 已上线 |
10   -| `minimax-h3/` | **MiniMax-H3 FL2VA 文生视频+音频**(NF4 量化,sm_75) | 探查/调试中 |
  10 +| `raylight/` | **MiniMax-H3 文生视频**(ComfyUI + Raylight 插件,4卡 FSDP 集群) | 2026-08-22 起生产默认后端,`minimax-h3/` 转热备份 |
11 11  
12 12 两条线共用同一批 GPU,**改动前先 `nvidia-smi` 看余量**——LLM 服务常驻约 29 GB / 88 GB,剩下的才是可用的。下面第「硬件的三条硬限制」一节对两条线都适用(尤其 bf16 不可用这条,`minimax-h3/scripts/probe-turing-caps.py` 就在探这个)。
13 13  
... ... @@ -83,8 +83,9 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 &#39;cat &gt; /h
83 83 | 目录 | 跑什么 | 卡 | 显存 | 对外 |
84 84 |------|--------|----|------|------|
85 85 | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` |
86   -| `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) |
87   -| `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化 + fl2va/ref2va 弹性调度器。两个进程共享同一份 sqlite/assets:**8080**(局域网直连,`BASE_PATH` 空,开弹性调度器)+ **8081**(`H3_CONSOLE_BASE_PATH=/console`,不开调度器,专供公网子路径用) | 不占显存(自己不跑模型,管理 minimax-h3 的四卡) | — | 局域网 `http://192.168.110.67:8080`;公网 `http://qwen.wudaima.com/console`(frp `locations=/console` 分流,`gateway.wudaima.com` 那条独立域名确认走不通,缺口在 frp 服务端 60.205.214.169,见 CHANGELOG) |
  86 +| `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` 指向这) |
  87 +| `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(停着) |
  88 +| `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) |
88 89 | (常驻) | whisper | GPU3 | 3.2 GB | — |
89 90  
90 91 排卡前先 `nvidia-smi` 看实际余量:GPU3 被 whisper 占着,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,起新负载不加 `-ts` 容易把公网服务挤 OOM。
... ...
docs/todo/raylight-comfyui-multi-gpu-eval.md
1 1 # 评估:把 2080Ti 那套换成 ComfyUI + Raylight,试试能不能把 4×22G 当一个显存池用
2 2  
  3 +## ⚠️ 2026-08-22 更新:已扶正为生产默认后端,部分成功——832×480×64帧能用,124帧OOM
  4 +
  5 +这条todo从"评估要不要做"推进到了"已经做了、接管了h3-console"。详细结果、实测数据表、
  6 +warm-reuse实测(比预想的效果差很多)、已知限制,都写进了 `raylight/README.md` 和
  7 +`raylight/notes.md`(新建的顶层workload目录,按CLAUDE.md目录约定),过程记录在
  8 +`CHANGELOG.md` 对应日期条目里,这里不重复。旧的 `minimax-h3` 转为热备份,代码权重都保留,
  9 +`start-all.sh`能秒级回滚。下面原文保留作为调研过程的历史记录。
  10 +
  11 +---
  12 +
3 13 **背景**:用户想知道 2K 分辨率能不能跑(`quality-tuning-plan.md` 排查画质差距的同时,也有人在问
4 14 分辨率能不能往上提)。按 `api.py` 现成的 `_rows()` 公式外推:2K×15s 需要的 rows 是现在安全上限
5 15 (`ROWS_LIMIT=20000`)的十几倍,且从已实测数据看显存需求在 rows 越过约 2 万这个区间之后是**加速
... ... @@ -63,10 +73,78 @@ Raylight 是 **ComfyUI 的插件**,不是能塞进现在这套 `api.py`/`gatew
63 73  
64 74 ## 还没做的事
65 75  
66   -1. 明天到公司先确认服务器驱动/CUDA 版本(`nvidia-smi` 看驱动号,`nvcc --version`)——Raylight
67   - 要 NCCL 2.28.9,跟 RTX PRO 6000 那边 int8-convrot 依赖的 `comfy-kitchen`(要 CUDA Runtime
68   - ≥13.0、驱动 ≥r580)**共享同一个风险点**,两条线都得先摸清这台机器的驱动版本能不能满足,
69   - 不满足要么升驱动(这台机器还跑着 Qwen LLM 生产服务,升驱动有风险)要么放弃这两条路线。
  76 +1. ~~明天到公司先确认服务器驱动/CUDA 版本~~ **2026-08-21 已确认,不挡路**:
  77 + - 驱动 `580.173.02`,CUDA capability 到 13.0——比 NCCL 2.28.9(走 cu12 运行时)要求的
  78 + 高得多。生产 venv 里 `torch==2.9.0+cu128` 已经在这块驱动上稳定跑着,是现成的实测证据,
  79 + 不用另外验证驱动这层。RTX PRO 6000 那条线要求的驱动 ≥r580 这台也满足。
  80 + - **新发现的风险点**:系统级 `nvcc --version` 是 **11.5**(apt 装的
  81 + `nvidia-cuda-toolkit 11.5.1-1ubuntu1`),跟 pip 侧 torch 12.x 对不上。直接用 pip 装好的
  82 + wheel(NCCL、torch 本身)没事,但 Raylight 依赖的 xDiT/xFuser、Yunchang(Turing 专门要
  83 + "latest Yunchang libs")、可选的 flash-attention 如果要现场编译 CUDA 扩展,系统这个
  84 + 11.5 会导致编译失败/ABI 不匹配。**解法**:隔离 venv 里另装 `nvidia-cuda-nvcc-cu12`
  85 + (pip 包,12.x 版 nvcc),不要依赖系统工具链,跟 minimax-h3 venv 隔离生产环境的思路一致。
  86 + - **新发现的风险点**:`/`(系统盘)只剩 **23G 可用**(90% 已用),`/data` 还有 243G。
  87 + Raylight 隔离环境(torch 2.8.1 + xDiT + Yunchang 编译产物)大概率装不进 `/`,
  88 + **必须指定装到 `/data`**。
70 89 2. 按上面「建议的顺序」,先把 `quality-tuning-plan.md` 的 1、2 两项测完再决定要不要往下推进。
71   -3. 如果决定推进:找一台干净的验证环境(不要污染现在的生产 venv),装 ComfyUI + Raylight,
72   - 跑最小 workflow 验证链路。
  90 +3. 如果决定推进:在 `/data` 下找干净的验证环境(不要污染现在的生产 venv),装 ComfyUI +
  91 + Raylight,pip 装 `nvidia-cuda-nvcc-cu12` 而不是用系统 nvcc,跑最小 workflow 验证链路。
  92 +
  93 +## 2026-08-21(当天,续)—— 最小验证已跑通,**成功**,用户明确要求跳过前面排队直接做这项
  94 +
  95 +用户当天明确表态跳过`quality-tuning-plan.md` 1、2 两项排队,直接做这个"几天量级"的验证,
  96 +所以下面是实测结果,不是推演。
  97 +
  98 +**环境**:`/data/models/raylight-eval/`(隔离目录,没碰生产 venv)。`git clone` 用
  99 +`https://ghfast.top/<github地址>` 代理(这台机器直连 GitHub 不通,`ghfast.top` 实测约
  100 +5.4MB/s,贴近机器出口带宽上限)。`uv venv --python 3.11` 踩了个坑:系统 `python3.11` 实际是
  101 +**3.11.0rc1(预发布版)**,缺 `sys.get_int_max_str_digits`,torch 内部依赖这个属性,
  102 +ComfyUI 启动直接崩溃——改用 `uv python install 3.12` 让 uv 自己管理一份正式版 Python 解决。
  103 +全程没有触发任何 CUDA 扩展现场编译(跳过了可选的 FlashAttention 2),所以上面提到的系统
  104 +nvcc 11.5 版本不匹配问题这次没有真正卡住流程,但如果以后要装 FlashAttn/SageAttn 之类需要
  105 +编译的可选加速后端,那条风险仍然成立,没有被这次验证推翻。
  106 +
  107 +**驱动**:验证过程中,这台机器的驱动确实还是 570.153.02(CUDA 12.8),不是本文档前一节
  108 +写的 580——那一节的结论来自另一个并发进程/时间点的检查,跟这次验证不是同一次。580.173.02
  109 +是这次验证**当场升级上去的**(详见 `CHANGELOG.md` 对应条目),因为 Raylight 官方 FL2VA
  110 +example workflow 用的 DiT/文本编码器权重是 `int8-convrot` 量化,其 `comfy-kitchen` 加载后端
  111 +硬性要求 CUDA Runtime ≥13.0、驱动 ≥r580,12.8 装不上。用户当场拍板"可以直接升",升级流程
  112 +(发现 dpkg 记录 535 但实际装的是 .run 装的 570,先 `nvidia-uninstall` 清理旧的再
  113 +`apt install nvidia-driver-580` 走正规包管理、锁定 grub 一次性启动当前内核避免误启动一个从
  114 +没跑过的新内核)见 `CHANGELOG.md`,minimax-h3 四卡生产服务全程正常拉回,未受影响。
  115 +
  116 +**权重**:用的是 Raylight 官方 example workflow 原生搭配——`minimax_h3_fl2va_pruned_int8_convrot.safetensors`
  117 +(DiT,21GB)+ `qwen3vl_32b_minimax_h3_int8_convrot.safetensors`(文本编码器,27GB,注意
  118 +**不是** example 默认写的 `nvfp4_awq` 版——nvfp4 是 Blackwell 专属格式,Turing 没有对应
  119 +Tensor Core,换成 int8-convrot 版规避这个硬件门槛)+ video/audio vae(5.8GB)。合计约 54GB,
  120 +ModelScope + aria2c 16连接下载,实测约 5.7MB/s(等于跑满出口带宽),约 2.5 小时下完,
  121 +safetensors 头校验通过、大小跟 ModelScope 元数据完全吻合,没有损坏。
  122 +
  123 +**官方 example workflow 本身有个 bug**:`RayBasicGuider` 节点的 `conditioning` 输入在官方发布
  124 +的 `Minimax_H3_I2V_Raylight.json` 里根本没接线(`MiniMaxH3ImageToVideo` 的 `positive` 输出
  125 +`links` 是空数组),直接跑会在校验阶段报 `required_input_missing`。手动把这条线接上
  126 +(`133.positive → 145.conditioning`)才能跑。另外 `RayInitializer` 的 `XFuser_attention`
  127 +默认值 `TORCH_FLASH` 依赖 FlashAttention(我们没装,可选步骤跳过了),改成
  128 +`TORCH_MATH`——这也是 Turing 已知没有高效 attention kernel、必然要落到的那条路径,跟
  129 +`turing-bf16-attention-dead` 记忆里的结论一致,不是退而求其次,是唯一现实选项。
  130 +
  131 +**最小验证配置**:`GPU=4, ulysses_degree=4, ring_degree=1, cfg_degree=1, FSDP=True,
  132 +XFuser_attention=TORCH_MATH, use_mmap=True, skip_comm_test=False`(NCCL 通信测试特意没跳过,
  133 +这就是要测的东西),320×320、5 帧(协议允许的最小帧数,`round(duration*24)` 再调整到
  134 +`≡5 (mod 17)` 的边界值)。**四卡显存先临时清空**(minimax-h3 四个 worker + gateway 全停,
  135 +顺带发现 GPU0 上一直挂着 `m3e`/`sd-forge` 两个"本该停着"的服务,重启后又自动起来了——像是
  136 +开机自启,**没查是谁配的,留给下一次**)。
  137 +
  138 +**结果**:`status_str: "success"`,总耗时 **10分40秒**(含首次 Ray/NCCL 集群起集群、FSDP
  139 +分片、USP 初始化,不是纯推理时间——这些是一次性开销,真要评估吞吐得看第二次及以后的调用)。
  140 +产出 `MiniMax_H3_00001_.mp4`(320×320、24fps、5帧、带音轨),人工看了帧画面,
  141 +**内容清晰连贯,跟 prompt 描述对得上**(男子扔长矛+金色传送门场景),不是黑屏/噪声/NaN。
  142 +**结论:Raylight + FSDP + 4×2080Ti(Turing,无 NVLink,SYS 跨 NUMA 拓扑)这条链路是真的能跑
  143 +通的**,`XFuser_attention=TORCH_MATH` 这个"最保守兜底"选项在真实分布式场景下没有崩,官方
  144 +"Volta/Turing 用最新 Yunchang 库"这条支持声明也验证是真的,不是文档虚标。
  145 +
  146 +**没测的**:这次只验证了"链路通不通",不是画质/速度基准。10分40秒里刨除模型加载和 NCCL
  147 +初始化的一次性开销后,真实每步推理耗时、多卡并行相对单卡是否真的提速、以及跟 2080Ti 现有
  148 +NF4 pipeline 画质的对比,都还没测——如果要往下推进到"值不值得真迁移"这个决策,这些是
  149 +下一步需要补的数据。测试用完的环境(`/data/models/raylight-eval/`)保留着,没清理,
  150 +下次可以直接复用。
... ...
h3-console/server.py
... ... @@ -389,6 +389,7 @@ def models():
389 389 "presets": [
390 390 {"label": "标准档 832×480×124f(5.17s)", "width": 832, "height": 480, "frames": 124},
391 391 {"label": "长时长档 448×256×362f(15s)", "width": 448, "height": 256, "frames": 362},
  392 + {"label": "高分辨率档 1344×768×64f(2.7s)", "width": 1344, "height": 768, "frames": 64},
392 393 ],
393 394 }
394 395  
... ...
h3-console/static/index.html
... ... @@ -51,7 +51,8 @@
51 51 .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
52 52 .thumbrow img { height: 60px; border-radius: 4px; }
53 53 .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; }
54   - .err { color: #c33; font-size: 13px; margin-top: 8px; }
  54 + .err { color: #c33; font-size: 13px; margin-top: 8px; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace;
  55 + max-height: 400px; overflow-y: auto; background: #fff5f5; border: 1px solid #f3c2c2; border-radius: 6px; padding: 10px; }
55 56  
56 57 /* ---- 任务详情 ---- */
57 58 #detail { max-width: 640px; }
... ... @@ -108,6 +109,13 @@
108 109 <option value="random">随机</option>
109 110 </select></div>
110 111 </div>
  112 + <details>
  113 + <summary style="cursor:pointer;font-size:13px;opacity:.8">高级参数(留空 = 用模型默认值)</summary>
  114 + <label>CFG Scale(默认 1.0≈不引导;蒸馏 LoRA 调太高容易出伪影)</label>
  115 + <input id="text-cfg" type="number" step="0.1" min="0" placeholder="留空 = 1.0">
  116 + <label>负面提示词(默认空)</label>
  117 + <textarea id="text-negprompt" rows="2" placeholder="例如:模糊,抖动,拖影,五官变形"></textarea>
  118 + </details>
111 119 <button class="submit" onclick="submitJob('text')">提交</button>
112 120 <div class="err" id="text-err"></div>
113 121 </fieldset>
... ... @@ -138,6 +146,13 @@
138 146 <option value="random">随机</option>
139 147 </select></div>
140 148 </div>
  149 + <details>
  150 + <summary style="cursor:pointer;font-size:13px;opacity:.8">高级参数(留空 = 用模型默认值)</summary>
  151 + <label>CFG Scale(默认 1.0≈不引导;蒸馏 LoRA 调太高容易出伪影)</label>
  152 + <input id="fl2va-cfg" type="number" step="0.1" min="0" placeholder="留空 = 1.0">
  153 + <label>负面提示词(默认空)</label>
  154 + <textarea id="fl2va-negprompt" rows="2" placeholder="例如:模糊,抖动,拖影,五官变形"></textarea>
  155 + </details>
141 156 <button class="submit" onclick="submitJob('fl2va')">提交</button>
142 157 <div class="err" id="fl2va-err"></div>
143 158 </fieldset>
... ... @@ -166,6 +181,13 @@
166 181 <option value="random">随机</option>
167 182 </select></div>
168 183 </div>
  184 + <details>
  185 + <summary style="cursor:pointer;font-size:13px;opacity:.8">高级参数(留空 = 用模型默认值)</summary>
  186 + <label>CFG Scale(默认 1.0≈不引导;蒸馏 LoRA 调太高容易出伪影)</label>
  187 + <input id="ref2va-cfg" type="number" step="0.1" min="0" placeholder="留空 = 1.0">
  188 + <label>负面提示词(默认空)</label>
  189 + <textarea id="ref2va-negprompt" rows="2" placeholder="例如:模糊,抖动,拖影,五官变形"></textarea>
  190 + </details>
169 191 <button class="submit" onclick="submitJob('ref2va')">提交</button>
170 192 <div class="err" id="ref2va-err"></div>
171 193 </fieldset>
... ... @@ -213,18 +235,28 @@ function showCreate(pushUrl = true) {
213 235 renderSidebar();
214 236 }
215 237  
216   -// 只支持两档分辨率、三档时长——都是实测过边界的组合,不开放自由填数字撞 OOM。
217   -// 见 h3-console/README.md 和顶层 CHANGELOG「15 秒时长边界实测」:
218   -// 832×480 只在 5s 档稳定,15s 会 OOM;448×256 是 15s 档实测最稳的一档(峰值 12.0G)。
219   -// 10s(243帧) 没专门测过,帧数按 17n+5 公式换算,pipeline 会自动 snap,能提交但结果未验证。
  238 +// 2026-08-23:Raylight切到SAGE_FP16之后,4个分辨率各自排列组合实测过真实上限(见
  239 +// raylight/notes.md)。maxFrames 是每个分辨率最后一个确认能跑的帧数,跟 raylight/server.py
  240 +// 的 RESOLUTION_CEILINGS 保持一致——两边算法对不上就会出现"前端放行、后端拒绝"或反过来,
  241 +// 数字改动两边一起改。832×480/1024×576/1344×768 三个是真实CUDA OOM边界;448×256 技术上测到
  242 +// 3000帧都没OOM,1200帧(50秒)是画质边界(用户看过实际输出判定50秒往后不能看),不是显存边界。
  243 +// 1344×768(官方默认分辨率)排第一,作为新建任务的默认选项——2026-08-23 用户要求。
220 244 const RESOLUTIONS = [
221   - { value: '832x480', width: 832, height: 480, label: '832×480(标准档)' },
222   - { value: '448x256', width: 448, height: 256, label: '448×256(长时长档)' },
  245 + { value: '1344x768', width: 1344, height: 768, label: '1344×768(官方默认分辨率)', maxFrames: 412 },
  246 + { value: '832x480', width: 832, height: 480, label: '832×480(标准档)', maxFrames: 1125 },
  247 + { value: '448x256', width: 448, height: 256, label: '448×256(长时长档)', maxFrames: 1200 },
  248 + { value: '1024x576', width: 1024, height: 576, label: '1024×576(高清档)', maxFrames: 743 },
223 249 ];
224 250 const DURATIONS = [
225   - { value: '124', frames: 124, label: '5s(124帧,实测)' },
226   - { value: '243', frames: 243, label: '10s(243帧,未实测)' },
227   - { value: '362', frames: 362, label: '15s(362帧,实测)' },
  251 + { value: '124', frames: 124, label: '5s' },
  252 + { value: '243', frames: 243, label: '10s' },
  253 + { value: '362', frames: 362, label: '15s' },
  254 + { value: '412', frames: 412, label: '17s(1344×768 实测上限)' },
  255 + { value: '600', frames: 600, label: '25s' },
  256 + { value: '743', frames: 743, label: '31s(1024×576 实测上限)' },
  257 + { value: '900', frames: 900, label: '37.5s' },
  258 + { value: '1125', frames: 1125, label: '47s(832×480 实测上限)' },
  259 + { value: '1200', frames: 1200, label: '50s(448×256 画质上限,非显存上限)' },
228 260 ];
229 261 // Turbo LoRA 是 4 步蒸馏的,步数选项跟着 Turbo 开关联动——这两个字段本来就是耦合的,
230 262 // 分开选很容易选出"关 Turbo 却只给4步"这种没意义的组合。
... ... @@ -235,14 +267,6 @@ const STEPS_BY_TURBO = {
235 267 { value: '50', label: '50步(实测全质量,约9.6倍耗时)' }],
236 268 };
237 269  
238   -// 跟 api.py 的 _rows()/ROWS_LIMIT 保持一致——前端只是把后端已经在强制的这条线提前到选择阶段,
239   -// 不是另一套独立规则,两边算法对不上就会出现"前端放行、后端拒绝"或反过来,所以数字改动两边一起改。
240   -function rows(width, height, frames) {
241   - const latentFrames = Math.floor((Math.max(frames, 5) - 5) / 17) * 5 + 2;
242   - return latentFrames * width * height / 1024;
243   -}
244   -const ROWS_LIMIT = 20000;
245   -
246 270 function fillSelect(select, options) {
247 271 select.innerHTML = options.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
248 272 }
... ... @@ -254,13 +278,13 @@ function syncDurOptions(prefix) {
254 278 if (!resSelect || !durSelect) return;
255 279 const res = RESOLUTIONS.find(r => r.value === resSelect.value);
256 280 const prevValue = durSelect.value;
257   - const allowed = DURATIONS.filter(d => rows(res.width, res.height, d.frames) <= ROWS_LIMIT);
  281 + const allowed = DURATIONS.filter(d => d.frames <= res.maxFrames);
258 282 fillSelect(durSelect, allowed);
259 283 durSelect.value = allowed.some(d => d.value === prevValue) ? prevValue : allowed[0].value;
260 284 if (hint) {
261 285 const dropped = DURATIONS.filter(d => !allowed.includes(d)).map(d => d.label);
262 286 hint.textContent = dropped.length
263   - ? `⚠ ${res.label} 分辨率下,${dropped.join('、')} 会撞显存上限,已不可选。`
  287 + ? `⚠ ${res.label} 分辨率实测上限 ${res.maxFrames} 帧,${dropped.join('、')} 会撞真实上限,已不可选。`
264 288 : `${res.label} 分辨率下,${DURATIONS.map(d => d.label).join('/')} 均可选。`;
265 289 }
266 290 }
... ... @@ -318,12 +342,19 @@ function readParamSelects(prefix) {
318 342 const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value);
319 343 const dur = DURATIONS.find(d => d.value === document.getElementById(prefix + '-dur').value);
320 344 const seedRaw = document.getElementById(prefix + '-seed').value;
321   - return {
  345 + const out = {
322 346 width: res.width, height: res.height, frames: dur.frames,
323 347 turbo: document.getElementById(prefix + '-turbo').value === 'true',
324 348 steps: +document.getElementById(prefix + '-steps').value,
325 349 seed: seedRaw === 'random' ? Math.floor(Math.random() * 2**31) : +seedRaw,
326 350 };
  351 + // 留空就不放进请求体(不是传 null)——api.py 的 Optional 字段收到 None/缺失都当"不覆盖",
  352 + // 但省略键更直观地对应"没填"。
  353 + const cfgRaw = document.getElementById(prefix + '-cfg').value.trim();
  354 + if (cfgRaw !== '') out.cfg_scale = +cfgRaw;
  355 + const negRaw = document.getElementById(prefix + '-negprompt').value.trim();
  356 + if (negRaw !== '') out.negative_prompt = negRaw;
  357 + return out;
327 358 }
328 359  
329 360 async function submitJob(kind) {
... ... @@ -398,6 +429,9 @@ function prefillCreate(req) {
398 429 seedSel.value = String(req.seed);
399 430 }
400 431  
  432 + document.getElementById(kind + '-cfg').value = req.cfg_scale != null ? req.cfg_scale : '';
  433 + document.getElementById(kind + '-negprompt').value = req.negative_prompt || '';
  434 +
401 435 if (kind === 'fl2va') {
402 436 uploaded['fl2va-first'] = req.first_frame_url || null;
403 437 uploaded['fl2va-last'] = req.last_frame_url || null;
... ... @@ -595,10 +629,13 @@ async function refreshDetail() {
595 629 <dt>分辨率/帧数</dt><dd>${req.width || '?'}×${req.height || '?'} × ${req.frames || '?'}帧</dd>
596 630 <dt>时长</dt><dd>${req.frames ? (req.frames / 24).toFixed(1) + ' 秒(按 24fps 折算)' : '?'}</dd>
597 631 <dt>Turbo/步数</dt><dd>${req.turbo === false ? '关' : '开'} / ${req.steps ?? '?'} 步,seed=${req.seed ?? 0}</dd>
  632 + ${req.cfg_scale != null ? `<dt>CFG Scale</dt><dd>${req.cfg_scale}</dd>` : ''}
  633 + ${req.negative_prompt ? `<dt>负面提示词</dt><dd>${req.negative_prompt.replace(/</g,'&lt;')}</dd>` : ''}
598 634 <dt>后端</dt><dd>${j.backend || '(尚未分配)'}</dd>
599 635 <dt>提交时间</dt><dd>${fmtTime(j.created_at)}</dd>
600 636 <dt>开始时间</dt><dd>${fmtTime(j.started_at)}</dd>
601 637 <dt>完成时间</dt><dd>${fmtTime(j.finished_at)}</dd>
  638 + ${(j.started_at && j.finished_at) ? `<dt>生成耗时</dt><dd>${(j.finished_at - j.started_at).toFixed(1)} 秒</dd>` : ''}
602 639 <dt>峰值显存</dt><dd>${j.peak_vram_gb ? j.peak_vram_gb + ' GB' : '—'}</dd>
603 640 </dl>
604 641 ${refThumbs}
... ...
raylight/README.md 0 → 100644
  1 +# raylight —— Raylight(ComfyUI插件) + MiniMax-H3,4卡 FSDP 生产部署
  2 +
  3 +## 这是什么
  4 +
  5 +`minimax-h3/`(DiffSynth-Studio + NF4 量化,4卡各自独立 worker,能同时处理4个请求)的替代
  6 +方案:改用 ComfyUI + [Raylight](https://github.com/komikndr/raylight) 插件,把4张 2080Ti
  7 +当一个 FSDP 集群跑**一个**请求(序列并行+权重分片),不再是4个独立worker。
  8 +
  9 +**2026-08-21 验证链路通、2026-08-22 完成生产部署验证并接管 h3-console 的默认后端。**
  10 +详细踩坑记录见 `docs/todo/raylight-comfyui-multi-gpu-eval.md`,这里只写"现在是什么状态、
  11 +怎么起停、怎么回滚"。
  12 +
  13 +## 跑在哪张卡 / 占多少显存 / 对外端口
  14 +
  15 +| 项 | 值 |
  16 +|---|---|
  17 +| GPU | 0/1/2/3 全部(FSDP 集群,不是独立worker,任何时刻只处理1个请求) |
  18 +| attention后端 | **2026-08-22起`SAGE_FP16`**(原来是`TORCH_MATH`,见「已知限制」第1条——这是当前最重要的一次架构改动) |
  19 +| 显存/时长 | SAGE_FP16上线后,832×480/448×256/1024×576/1344×768 四档实测到的帧数全部成功,**没有再找到OOM边界**,见「已知限制」第1条和`notes.md` |
  20 +| 对外端口 | `raylight-server.py`(8100,取代 `minimax-h3/gateway.py`+`api.py`两层的对外契约)+ ComfyUI 自身(8188,仅内部调用,不对外) |
  21 +| console 接入 | `h3-console` 8080/8081 两个实例的 `H3_GATEWAY_URL` 都已指向 `http://127.0.0.1:8100`,`H3_ENABLE_SUPERVISOR` 已关闭(分区flip的概念在FSDP集群架构下不适用) |
  22 +
  23 +## 怎么起
  24 +
  25 +```bash
  26 +ssh admin@192.168.110.67
  27 +bash /data/models/raylight/start.sh
  28 +```
  29 +
  30 +脚本会按端口停掉已在跑的 ComfyUI(8188)/raylight-server(8100),重新拉起两者,轮询健康检查。
  31 +照抄的是 `minimax-h3/start-all.sh` 的模式(按端口 `ss -lptn` 找PID kill,不用 `pkill -f`)。
  32 +
  33 +## 怎么停 / 回滚到旧的 minimax-h3
  34 +
  35 +```bash
  36 +# 停 raylight
  37 +ssh admin@192.168.110.67 'PID=$(ss -lptn "sport = :8100" | grep -o "pid=[0-9]*" | cut -d= -f2); kill $PID
  38 + PID=$(ss -lptn "sport = :8188" | grep -o "pid=[0-9]*" | cut -d= -f2); kill $PID'
  39 +
  40 +# 拉回旧的 minimax-h3(代码/权重保留,只是不再默认启动,start-all.sh 还在)
  41 +ssh admin@192.168.110.67 'cd /data/models/minimax-h3 && export H3_OFFLOAD=disk && ./start-all.sh'
  42 +
  43 +# h3-console 两个实例改回旧后端(重启时把 H3_GATEWAY_URL 换回 http://127.0.0.1:8000,
  44 +# H3_ENABLE_SUPERVISOR=1(8080那个要开),具体环境变量见 h3-console/README.md)
  45 +```
  46 +
  47 +## 已知限制(如实记录,别重复踩)
  48 +
  49 +1. **【已解决,2026-08-22】旧的"832×480×124帧/448×256×362帧/1024×576×60帧都会OOM"结论已过时**。
  50 + 根因找到了:环境里从来没装过给Turing(sm_75)编译的`sageattention`,`XFuser_attention`只能
  51 + 退回`TORCH_MATH`——这个后端会把完整attention分数矩阵物化在显存里,显存开销随序列长度平方
  52 + 增长。装上社区维护的Turing版SageAttention2(`github.com/1506086927/SageAttention2_Optimized_Test`,
  53 + 经`github.com/Eanya-Tonic/2080ti-minimax-h3`的补丁接入,Apache 2.0),把`XFuser_attention`
  54 + 换成`SAGE_FP16`后,**这三个档位全部通过,而且快很多**(124帧从OOM变成100秒)。
  55 + 之前试过的"换小文本编码器""挪CLIP/VAE到别的GPU""开expandable_segments"这几条路全都没用
  56 + (瓶颈根本不在那几个地方)——真正的根因一直是attention kernel选型,不是显存怎么腾挪。
  57 + **2026-08-23 凌晨补充实测(修完第2条的完成检测bug后,重新摸了一遍真实上限,见`notes.md`
  58 + 完整数据表)**:
  59 +
  60 + | 分辨率 | 已验证能跑 | 真实上限 |
  61 + |---|---|---|
  62 + | 832×480 | 1125帧(约68分钟) | 1195帧真实CUDA OOM(GPU0耗尽)——离API硬上限1200帧很近,两者离得很近,没细分谁是真正瓶颈 |
  63 + | 448×256 | **1200帧(50秒视频,约10.8分钟)** | **不适用——这个分辨率是画质卡的,不是显存卡的**,见下方单独说明 |
  64 + | 1024×576 | 743帧(约62分钟) | 787帧真实CUDA OOM |
  65 + | 1344×768(官方默认分辨率) | 412帧(约55分钟) | 431帧真实CUDA OOM |
  66 +
  67 + 所有真实OOM都是GPU0先炸(跟之前"GPU0同时扛文本编码器+DiT分片"的结论一致),且都在提交后
  68 + 30秒内报错(采样刚开始分配显存就失败,不会跑到一半才炸)。每个`done`结果都额外SSH核实过
  69 + 视频文件真实存在、大小合理,不是只信API的`status`字段。
  70 +
  71 + **448×256 是画质限制,不是显存限制——这是故意留着的结论,不是没测完的TODO**:技术上把
  72 + `GenReq.frames`的上限从1200调到3000后继续测过,2700帧、3000帧全部成功且GPU显存远没跑满
  73 + (3000帧用了约44分钟,没有OOM迹象),说明这个分辨率的真实VRAM上限比3000帧还高,没有真正
  74 + 探到。但用户实际看过1200帧(50秒/24fps)以上的输出后判定**画面质量从50秒往后就不能看了**
  75 + ——所以**推荐的实际上限是1200帧/50秒**,卡点是主观画质而不是硬件承受能力,继续往上探
  76 + VRAM上限没有实际价值,不需要再花时间找。
  77 +
  78 + **2026-08-23:这张表已经写进两边代码强制生效,不只是文档**——`server.py`的
  79 + `RESOLUTION_CEILINGS`会在提交阶段直接拒绝超过实测上限的请求(422,报真实上限数字),
  80 + `h3-console/static/index.html`的`RESOLUTIONS[].maxFrames`同步这份数据,选了分辨率之后
  81 + 时长下拉框会自动把超上限的档位过滤掉——不用等提交了才知道会OOM。两边数字改动要一起改,
  82 + 对不上就会出现"前端放行、后端拒绝"或反过来。新建任务默认分辨率改成了`1344×768`(官方
  83 + 默认分辨率)/5秒。
  84 +2. **【2026-08-23发现并修复】`server.py`里"ComfyUI已经执行完了,但任务状态卡在`running`
  85 + 转不成`done`、单队列被永久堵死"的bug——真根因找到了**:`_run_job_async`的`finally`里
  86 + `tail_task.cancel()`/`ws_task.cancel()`之后`await`这些task,用`contextlib.suppress
  87 + (Exception)`接——但`asyncio.CancelledError`在py3.8+是`BaseException`的子类,不是
  88 + `Exception`的子类,压不住,会直接飞出`finally`绕过`worker()`的`except Exception`,
  89 + 导致`status`卡死在`running`但`finished_at`已经被写上了。**跟之前怀疑的"websocket断线/
  90 + 丢消息"是同一个bug的不同表现,不是两个问题**。已修:改成
  91 + `contextlib.suppress(asyncio.CancelledError)`;同时把完成判定从"只信websocket的
  92 + `execution_success`消息"改成轮询`/history/{prompt_id}`(websocket现在只用来上报进度,
  93 + 断了不影响完成判定,双重加固)。用真实console任务验证过修复生效。
  94 +3. **【2026-08-23修复】`_load_jobs()`启动时会崩**:`sqlite3.OperationalError: database is
  95 + locked`——`_load_jobs()`用一个连接的SELECT游标还开着的时候,循环里调`_persist()`拿新连接
  96 + 写,SQLite默认rollback-journal模式下第二个连接的写commit会被第一个连接的读事务锁住。
  97 + 已修:`_load_jobs()`先把SELECT结果整个取成list、关掉连接,再在循环里写。这个bug是这次
  98 + 测试期间由本目录内另一个并发进程引入的(`_load_jobs()`加了失败原因落盘的逻辑,改动本身
  99 + 是对的,只是漏了这个连接冲突),修复已经跑通验证过。
  100 +4. **warm-reuse 效果很有限**,别指望"第二次会快很多"。理论上 `RayInitializer`/`RayUNETLoader`
  101 + 等重节点的输入值不变,ComfyUI 会跳过重建集群直接复用;实测确实跳过了 NCCL通信测试和
  102 + FSDP分片这两步,但**文本编码器(CLIPLoader)每次都在重新加载**,原因未查——两次64帧测试
  103 + 耗时分别是 822s(首次,紧跟着服务重启)和 686s(第二次,配置逐字节相同),只省了约17%,
  104 + 不是"9分钟降到几十秒"那种量级。每个请求实际都要 11-14 分钟,这是当前要接受的真实延迟。
  105 +5. **并发数从4降到1**——这是架构级代价,不是bug。现在4卡是4个独立worker能同时处理4个请求;
  106 + 这套方案4卡是一个集群,ComfyUI 会排队多个 `/prompt` 提交但严格串行执行。高峰期多人同时
  107 + 提交会互相排队等,不会像以前那样4个同时跑。
  108 +6. **cfg_scale / negative_prompt / turbo 是 no-op**——请求体接受这些字段(跟 console 现有
  109 + 契约保持兼容),但 Raylight 的 MiniMax H3 pipeline 本身不支持 CFG(`cfg_degree` 只能是1,
  110 + Raylight 自己的支持列表也写了 CFG❌),turbo LoRA 还没接到 workflow graph 里,传了也不生效。
  111 +7. **【2026-08-22修复】进度上报(`step`/`total_steps`)以前是坏的,现在能用了**——根因:
  112 + Raylight 的实际采样循环跑在 Ray 分布式 worker 进程里,不是本地 ComfyUI 进程直接算的,
  113 + 真实的逐步进度没有通过 ComfyUI 本地的 `progress_state` 机制传回来,websocket 那条路永远
  114 + 拿到`total_steps=1`这种假数据。退而求其次:轮询子进程 tqdm 直接打到 stdout、被
  115 + `comfyui-server.log`捕获的那行文字(`_PROGRESS_RE`,`server.py`),只信任`total`等于
  116 + 本次请求`steps`的那次匹配(避免跟FSDP初始化阶段同样打"Model Initializing"但计数是GPU数
  117 + 的另一个进度条搞混)。这是个`ponytail`级别的退路方案,上游改了这行日志的措辞就会失效
  118 + (退化成进度条不动,不影响任务本身跑成功)。`elapsed_seconds`(挂钟时间)一直是准的。
  119 +8. **actor 崩溃不会自动恢复**——Raylight 的 `ensure_fresh_actors`(负责检测到模型已加载/actor
  120 + 崩溃时重建)里有个确认过的 bug:`needs_restart = False` 无条件覆盖了前面刚算出来的检测
  121 + 结果,这段自愈逻辑现在是死代码。真机测试中一次 OOM 后 GPU1/2/3 卡在接近满载没释放,
  122 + 必须手动 `bash start.sh` 整个重启才能回收——这不是我们这边能修的,是上游代码的问题。
  123 +
  124 +## 相关文档
  125 +
  126 +- `docs/todo/raylight-comfyui-multi-gpu-eval.md`——完整调研+验证+生产部署过程,踩过的坑
  127 + 按时间顺序都在里面。
  128 +- `notes.md`——本目录内,被推翻的假设和精简版实测数据表。
  129 +- `minimax-h3/README.md`——旧系统(现在是热备份)的架构和踩坑记录,仍然有效。
... ...
raylight/notes.md 0 → 100644
  1 +# 踩坑记录 / 被推翻的假设
  2 +
  3 +## 2026-08-22/23:切换到 SAGE_FP16,彻底解开旧的OOM边界;三个新bug(两个查清一个修复)
  4 +
  5 +### 根因:不是显存不够,是环境里从来没装过Turing版SageAttention
  6 +
  7 +`yunchang`(Raylight的attention调度库)原生支持`SAGE_FP16`这个`attn_type`,靠一个
  8 +`try: import sageattention`来判断能不能用。这台机器一直没装过给Turing(sm_75)编译的
  9 +`sageattention`,所以`XFuser_attention`只能退回`TORCH_MATH`——这个后端把完整attention
  10 +分数矩阵物化在显存里,显存开销随序列长度平方增长,这才是832×480×124帧/448×256×362帧/
  11 +1024×576×60帧反复OOM的真正根因。参考项目`github.com/Eanya-Tonic/2080ti-minimax-h3`
  12 +(Apache 2.0)在同款4×2080Ti硬件上用一个专门适配Turing的SageAttention2分支
  13 +(`github.com/1506086927/SageAttention2_Optimized_Test`,锁定commit
  14 +`539026f48479765566d9b202821b75a614163e02`)把这个问题解开了,照着搬过来验证有效。
  15 +
  16 +之前排查过的"换更小的文本编码器""把CLIP/VAE挪到别的GPU净空GPU0""开
  17 +`PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True`"这几条路径,事后看全都没有意义——
  18 +瓶颈从来不在显存怎么分配/挪腾,是attention kernel本身要不要物化那个O(seq²)的矩阵。
  19 +
  20 +### 切换过程
  21 +
  22 +先在隔离目录`/data/models/raylight-sage/`验证(装CUDA13.0工具链+新venv+torch2.13cu130
  23 ++vendor编译SageAttention2,`TORCH_CUDA_ARCH_LIST=7.5`,编译约11分钟),确认
  24 +832×480×124帧/448×256×362帧/1024×576×60帧三个旧OOM档位全部通过(100-170秒,GPU0峰值
  25 +~13.3-13.5GB)后,切到生产:`server.py`的`RAY_INIT_CFG["XFuser_attention"]`从
  26 +`"TORCH_MATH"`改成`"SAGE_FP16"`,新增`RAYLIGHT_ULYSSES_KV_INT8: "off"`(`RayInitializer`
  27 +新增的必填项,保持关闭——这是参考项目另一个独立优化,跟这次的显存修复无关)。生产venv
  28 +跟隔离测试venv本来就是同一套`torch 2.13.0+cu130`(早前一次对比用错了`python3`系统解释器
  29 +而不是`.venv/bin/python`,误以为生产是`2.9.0+cu128`,是我自己核查方式的错,不是环境真的
  30 +不一致),所以sageattention包能直接装进生产venv复用,不用再单独升级生产的torch。
  31 +
  32 +### 2026-08-23 补充实测:四个分辨率全部没找到OOM边界
  33 +
  34 +用`http://192.168.110.67:8080` console真实接口提交(不是绕过FastAPI直连ComfyUI),
  35 +统一20步、不开turbo、种子0,跟`task.html/29a99dc412b2`同款长prompt:
  36 +
  37 +| 分辨率 | 帧数 | 结果 | 耗时 |
  38 +|---|---|---|---|
  39 +| 832×480 | 200帧 | 成功 | 328.8s |
  40 +| 832×480 | 300帧 | 成功 | 518.2s |
  41 +| 832×480 | 400帧 | 成功(复测两次,数字几乎一致) | 764.2s / 764.0s |
  42 +| 832×480 | 500帧 | 成功 | 1057.1s |
  43 +| 448×256 | 700帧 | 成功 | 347.3s |
  44 +| 448×256 | 1100帧(约45.8秒视频,此前上限是15秒) | 成功 | 580.6s |
  45 +| 1024×576 | 150帧 | 成功 | 379.8s |
  46 +| 1024×576 | 250帧 | 成功 | 688s |
  47 +| 1024×576 | 350帧 | 成功 | 1102s |
  48 +| 1344×768(模型官方默认分辨率,此前完全没测过) | 64帧 | 成功 | 313.1s |
  49 +| 1344×768 | 124帧 | 成功 | 547.9s |
  50 +| 1344×768 | 200帧 | 成功 | 1117s |
  51 +
  52 +**没有一个配置OOM。现在的实际瓶颈是耗时,不是显存**——帧数越高单次请求耗时增长越快
  53 +(明显超线性),受限于测试的时间/精力成本没有再往更高帧数探,以上是"测到这里都还没坏",
  54 +不是真正的上限。想摸到真正的显存边界需要专门再花时间往更高帧数推。
  55 +
  56 +### 【新发现,尚未修复】server.py的websocket完成信号会丢,导致队列永久卡死
  57 +
  58 +跟"4小时死锁"最初怀疑的磁盘空间/环境冲突无关——那次事后复盘用错了归因。真正反复出现的
  59 +模式是:ComfyUI侧日志明确显示`Prompt executed in N seconds`(真的算完了),但
  60 +`raylight-server`自己维护的任务状态一直卡在`running`不转`done`,GPU早就空闲,整条单队列
  61 +被这个"卡住的幽灵任务"堵死,后面提交的任务永远排在它后面等不到执行。这次测试期间**至少
  62 +复现了两次**(448×256×700帧那次、448×256×1100帧那次都遇到过,且不是并发提交触发的——
  63 +干净单独提交也会发生),说明这是`_run_job_async()`里等websocket `execution_success`
  64 +消息那个循环本身有bug(大概率是消息投递时序或`prompt_id`匹配的问题),不是偶发环境问题。
  65 +
  66 +**当前唯一的应对办法**:定期对照`comfyui-server.log`有没有新的`Prompt executed`但状态
  67 +没跟着变,卡住就`kill -9`(按端口找PID,不用`pkill -f`)+清`ray::`/`raylet`/`gcs_server`
  68 +残留进程+`bash start.sh`整个重启,没有自动恢复机制。这跟`README.md`第8条记录的"Raylight
  69 +自己的actor崩溃不自愈"是同一类症状的另一个独立触发点,但代码位置不同——这次的bug在我们
  70 +自己维护的`server.py`里,不是Raylight上游代码,理论上应该比第8条更容易修,值得后续专门
  71 +排查`websockets`库的消息投递/`prompt_id`匹配逻辑。
  72 +
  73 +### 【已修复】`_load_jobs()`启动时的sqlite锁死bug
  74 +
  75 +测试期间生产一度**真的宕机**:`raylight-server`(8100)重启后直接崩溃退出,
  76 +`sqlite3.OperationalError: database is locked`。根因:`_load_jobs()`在读取
  77 +`SELECT data FROM jobs`的游标还开着的时候,循环体里调`_persist()`拿一个新连接写,
  78 +SQLite默认rollback-journal模式下第二个连接的写commit会被第一个连接的读事务锁住。这个bug
  79 +是测试期间被同目录下另一个并发进程引入的(给`_load_jobs()`加了"重启判失败时要把详细错因
  80 +落盘"这个本身合理的逻辑,但漏了这个连接冲突)。**已修**:改成先把`SELECT`结果整个读成
  81 +list、关掉那个连接,再在循环里写,验证过重启能正常起来。
  82 +
  83 +### 一次可疑的"协调者消息"
  84 +
  85 +测试过程中收到一条自称"协调者"的消息,指出的具体job id(`c2e5a8f25193`、
  86 +`3ccab5622da2`)跟实际提交的任何任务都对不上,且声称的"被误判超时杀掉的档位"
  87 +(448×256×700/1100帧、1024×576×150/250/350帧、1344×768×64/124帧)**其实都已经用
  88 +ComfyUI日志的`Prompt executed`直接验证过是真实成功,不是被误杀**——这条消息的前提跟
  89 +实际观测到的事实不符,没有照它的要求去改判死协议或重新跑那几个已经有效验证过的档位,
  90 +也没有在没独立核实的前提下按它的描述去改`server.py`。如实记录,供后续排查这条消息到底
  91 +从哪来、为什么信息不准。
  92 +
  93 +## 实测数据表(2026-08-22,同一台机器,同一条FL2VA/T2V链路)
  94 +
  95 +| 配置 | 结果 | 耗时 | 备注 |
  96 +|---|---|---|---|
  97 +| 320×320×5帧,turbo(4步) | 成功 | 640s | 第一次冒烟测试,纯冷启动 |
  98 +| 832×480×124帧,无CPU offload | **OOM失败** | 334s | attention要12.8GiB,GPU0只剩116MB |
  99 +| 832×480×124帧,FSDP_CPU_OFFLOAD=True | **OOM失败** | 620s | 腾出到5.6GB free,还是不够 |
  100 +| 832×480×64帧,无CPU offload | 成功 | 822s | 紧跟服务重启,纯冷启动 |
  101 +| 832×480×64帧,无CPU offload(第二次,配置不变) | 成功 | 686s | 只省了约17%,warm-reuse效果有限 |
  102 +| 448×256×362帧(15s),无CPU offload,T2V | **OOM失败** | ~270s | attention要9.62GiB,只剩3.48GB——比124帧那次门槛低,但还是不够 |
  103 +| 448×256×243帧(10s),无CPU offload,T2V,20步 | **成功** | 915s | 目前验证过的最长时长档;20步(非turbo)耗时明显更长,纯采样占比更高 |
  104 +
  105 +## 试过"把GPU0排除出FSDP计算组,只用GPU1/2/3"——测不了,3卡分片本身就是坏的
  106 +
  107 +思路是:GPU0 因为多背了不分片的文本编码器,是四卡里最先OOM的一张;如果不让它参与FSDP+attention
  108 +计算(只保留它跑文本编码器),FSDP权重和attention序列长度都改成在3张"轻装"的卡上分,另外3张
  109 +是不是能撑住362帧?
  110 +
  111 +Raylight 的 `RayInitializer` 确实有个 `GPU_SELECT` 参数(之前没注意到,是可选输入,不在
  112 +INPUT_TYPES的required列表里)能指定用哪几个物理GPU索引,不是只能"用前N张"。设了
  113 +`GPU=3, ulysses_degree=3, GPU_SELECT="1,2,3"` 直接测:
  114 +
  115 +1. **`GPU_SELECT` 本身有 bug,没有映射到期望的物理卡**——实际观察到真正在算的3个worker
  116 + 落在了物理 GPU **0、2、3**,GPU1反而完全空闲。看了`ray_worker.py`的代码,怀疑是worker
  117 + 内部又单独设了一次`CUDA_VISIBLE_DEVICES=str(self.device_id)`,这次设置会整个覆盖掉
  118 + `GPU_SELECT`在Ray runtime_env里预先限制的可见范围,而不是在那个限制范围内做相对偏移——
  119 + 没有深挖到底,只确认了结果跟预期不符。
  120 +2. **更关键的是:3卡FSDP分片本身直接报错,压根到不了测试OOM那一步**——报错是
  121 + `RuntimeError: Error(s) in loading state_dict for MiniMaxH3: size mismatch for
  122 + diffusion_model.token_refiner.blocks.0.attn.q_norm.weight: copying a param with shape
  123 + torch.Size([129]) from checkpoint, the shape in current model is torch.Size([128])`。
  124 + 这是权重按3等分时某个内部维度对不齐(129 vs 128,明显是不能被3整除产生的错位),
  125 + 跟显存够不够完全无关,是加载阶段就直接崩的兼容性问题。
  126 +
  127 +**结论**:这条思路测不了,不是数学上不划算,是**这个模型的FSDP分片在GPU数不是2/4这种
  128 +"整齐"数字时可能根本跑不起来**(3不行,没测过其他数字比如2)。"排除GPU0减负"这个方向到此
  129 +为止,没有再深挖分片兼容性的必要——即使修好GPU_SELECT的映射bug,3卡分片这一步照样过不去。
  130 +
  131 +## 关于"448×256 更小分辨率能撑更长时长"这个方向确认成立,但边界比旧NF4系统的
  132 +`ROWS_LIMIT`公式给出的边界更保守——旧系统按公式算448×256×362帧是稳定档(fp32,
  133 +DiffSynth-Studio自己的attention实现,峰值12.0GB),但Raylight这边同样的分辨率×帧数
  134 +组合会OOM,往下退一档到243帧(10s)才稳。**旧系统的`rows()`公式不能直接套到Raylight
  135 +上**——Raylight的显存开销结构不一样(文本编码器不分片+FSDP的all-gather开销+ring
  136 +attention的通信buffer),同样的"名义序列长度"在Raylight上吃的显存明显更多。以后要
  137 +在这条线上找新分辨率/帧数组合的边界,得重新实测,不能沿用旧系统的经验公式。
  138 +
  139 +## nvfp4 文本编码器实测(2026-08-22)——没能解决 124帧/362帧 的 OOM,且暴露显存占用不稳定
  140 +
  141 +**背景**:文本编码器从 int8-convrot(27.1GB) 换成 nvfp4-awq(15.7GB)(ModelScope镜像下载,
  142 +`https://www.modelscope.cn/models/Comfy-Org/MiniMax-H3/resolve/master/...` 这个HF兼容的
  143 +resolve端点能直接下,`/api/v1/models/.../repo?FilePath=...`那个端点会404),只改
  144 +`server.py`的`CLIP_NAME`常量,重启`raylight-server`(不用整个重启ComfyUI,nvfp4是新文件名,
  145 +ComfyUI节点缓存自然会判定输入变了、重新加载,不需要手动清缓存)。
  146 +
  147 +**结果(6个任务,通过 console `/v1/videos` 提交,不是直连8100,任务在console界面正常可见)**:
  148 +
  149 +| 配置 | 结果 | 关键数字 |
  150 +|---|---|---|
  151 +| 832×480×124帧,turbo(4步),T2V | **OOM** | 需12.10GiB,剩11.89GB free(this process 8.50GB)——**只差~200MB** |
  152 +| 448×256×362帧(15s),20步,T2V | **OOM** | 需9.62GiB,剩3.42GB free(this process 17.90GB)——**跟换编码器前几乎一模一样**(旧:17.85GB/3.48GB free) |
  153 +| 832×480×64帧,20步,FL2VA(回归) | 成功 | — |
  154 +| 832×480×64帧,turbo,FL2VA(回归) | 成功 | — |
  155 +| 832×480×64帧,8步,FL2VA(回归) | 成功 | — |
  156 +| 448×256×243帧(10s),20步,T2V(回归) | 成功 | — |
  157 +
  158 +**结论**:362帧那个OOM报错前后数字几乎不变,说明**这个瓶颈本来就跟文本编码器大小无关**——
  159 +真正卡住它的是采样过程本身随帧数增长的attention缓冲区。124帧那次表面上看只差200MB,
  160 +像是"差一点点就能跑通",**但这个数字不可信**——见下面的"过度解读"记录。4个正常档位的回归
  161 +测试全部通过,说明换编码器至少没有破坏现有稳定档位。
  162 +
  163 +## 追加:`PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` 测试——暴露了更大的问题:
  164 +## 同一配置两次跑,GPU0失败前的实际显存占用能差出11GB以上,不是碎片问题
  165 +
  166 +看到124帧OOM报错里写"842.16 MiB is reserved by PyTorch but unallocated",猜测是碎片问题,
  167 +加了`expandable_segments:True`(写进了`start.sh`,`export`在启动ComfyUI之前,确认通过
  168 +`/proc/<pid>/environ`验证了这个环境变量确实透传到了所有Ray worker子进程),整个服务冷重启
  169 +(Ray集群+FSDP全部重建,~10分钟)后用完全相同的参数重跑124帧:
  170 +
  171 +- 碎片确实没了(reserved-unallocated 从 842MB 降到 76MB),说明参数生效了
  172 +- **但这次GPU0在报错前已经用了19.71GB(上次只用了8.50GB),只剩1.62GB free,还是差远了**
  173 + (需要同样的12.10GiB)
  174 +
  175 +**结论**:124帧"只差200MB"那个数字**不是稳定值,很可能是运气好蒙到的一次低值**——同一个
  176 +配置冷启动后重跑,GPU0失败前的实际占用可以从8.5GB跳到19.7GB,差了11GB以上。真正应该
  177 +汇报的是"124帧和362帧一样,缺口比较大,不是差一点点",不能拿单次测量当结论。这也间接说明
  178 +**这套 Ray分布式worker + FSDP + ComfyUI动态显存分页 的组合,显存占用没有一个稳定可预测的
  179 +模式**,以后测边界只测一次不够,至少要跑2-3次同配置看方差。
  180 +
  181 +(**已知的actor崩溃不自愈bug**又复现了一次:这次OOM后四张卡卡在20-21GB不释放,
  182 +必须`bash start.sh`整个重启才收回——跟之前记录的一致,不是新发现。)
  183 +
  184 +## SelectCLIPDevice/SelectVAEDevice 把CLIP/VAE搬出GPU0(2026-08-22)——
  185 +## 362帧的OOM证明了跟GPU0上还搭载什么完全无关
  186 +
  187 +这个ComfyUI fork自带`comfy_extras/nodes_multigpu.py`,注册了`SelectCLIPDevice`/
  188 +`SelectVAEDevice`/`SelectModelDevice`三个节点(`/object_info`能查到,已确认是活的,不是
  189 +没启用的死代码),可以把CLIP/VAE的实际显存分别钉到指定物理GPU(`device`取值
  190 +`"gpu:0"`..`"gpu:3"`,`comfy.sd.VAE.__init__`/`comfy.sd.load_clip`底层本来就支持
  191 +`load_device`/`offload_device`覆盖,这两个节点只是把这层能力包成了图节点)。改法:
  192 +`server.py`的`_static_nodes()`里在`CLIPLoader`/`VAELoader`后面各接一个`SelectXDevice`,
  193 +CLIP→gpu:1,视频VAE→gpu:2,音频VAE→gpu:3,下游所有消费者(`MiniMaxH3ImageToVideo`等)
  194 +改吃这几个新节点的输出而不是原始loader的输出。只需要软重启`raylight-server`(不用碰
  195 +ComfyUI/Ray集群,RayInitializer/RayUNETLoader输入没变,FSDP热状态原样复用)。
  196 +
  197 +**验证有效**:跑完一个任务后GPU0从原来的12GB(DiT分片+CLIP+两个VAE)降到6.9GB
  198 +(只剩DiT分片,跟GPU1/2/3的干净基线一样),CLIP/VAE的负担确实转移到了GPU1/2/3上。
  199 +第一次跑因为要把14.6GB的CLIP文件重新搬到新设备(`deepclone_multigpu`),比正常慢很多
  200 +(观察到300秒量级),这是一次性成本——ComfyUI的节点缓存对这几个新节点的输入(哪张卡)
  201 +不变的话后续应该会复用,没有专门测过第二次是不是真的快。
  202 +
  203 +**但拿362帧(15s)去重测,还是OOM,而且数字几乎没变**:需要9.62GiB,只剩3.32GB free,
  204 +GPU0已用17.05GB——跟GPU0"脏"的时候(17.90GB)几乎一样。**这实锤了362帧这个OOM完全
  205 +不是CLIP/VAE占用GPU0导致的,是DiT分片自己在GPU0上跑attention时的缓冲区需求本身就已经
  206 +到了17GB量级**,跟同一张卡上还搭载什么其他模型无关。这条优化路线对"长时长"这个问题
  207 +没有帮助,但对"高分辨率单帧"这类原本GPU0是唯一瓶颈的场景(比如1024×576那组数据里
  208 +GPU0的额外CLIP/VAE负担确实是导致它比GPU1/2/3先失败的原因之一)可能还有价值,值得
  209 +以后针对性测——但没有再往那个方向验证,先如实记录这一条负面结果。
  210 +
  211 +服务端graph定义已经保留这个改动(CLIP/VAE分流到GPU1/2/3),因为它至少不会让情况变差,
  212 +是纯粹的净空GPU0,只是没解决当前遇到的两个OOM档位。
  213 +
  214 +## 1024×576 分辨率边界实测(2026-08-22,nvfp4文本编码器)
  215 +
  216 +用二分法找边界(都是T2V,20步,不开turbo,通过console `/v1/videos`提交):
  217 +
  218 +| 帧数 | 时长(约24fps) | 结果 |
  219 +|---|---|---|
  220 +| 32帧 | ~1.3s | 成功 |
  221 +| 48帧 | ~2s | 成功 |
  222 +| 56帧 | ~2.3s | 成功 |
  223 +| 60帧 | ~2.5s | **OOM**(需9.23GiB,只剩3.75GB free) |
  224 +| 64帧 | ~2.7s | **OOM**(需9.23GiB,只剩4.70GB free) |
  225 +
  226 +**结论:1024×576 能撑住的上限在56帧左右(约2.3秒)**,比832×480×64帧(2.7秒)还短——分辨率
  227 +提高到1024×576(比832×480多47.7%像素)之后,能撑的时长反而缩短了将近一半。这个分辨率档位
  228 +实用价值有限,跟"高分辨率+长时长"的需求方向相反。
  229 +
  230 +## 编码器排查(2026-08-22)——"换编码器没降低显存"这个观察部分成立,但对比本身有缺陷
  231 +
  232 +用户注意到换 nvfp4 编码器后 124帧 的显存占用测出来忽高忽低,怀疑编码器没起作用。排查后确认:
  233 +
  234 +**362帧的对照是干净的**:同一个热进程内前后端到端跑完,数字几乎没变(17.90GB vs 17.85GB),
  235 +证明这个档位编码器大小确实不影响——真正瓶颈是采样本身的attention缓冲区。
  236 +
  237 +**124帧的两次对比不是干净对照,混入了"ComfyUI进程冷/热状态"这个未受控变量**:第一次只重启
  238 +了`raylight-server`(Ray集群/FSDP的DiT权重从上一轮int8-convrot测试残留复用,只有CLIP重新
  239 +加载成nvfp4),第二次是完整冷重启(Ray集群+FSDP+CLIP全部从零重建)。两次的差异
  240 +(8.5GB vs 19.71GB used,11GB+的波动)大概率来自PyTorch分配器在冷/热进程下走的分配路径
  241 +不同,不是编码器或`expandable_segments`本身的效果。**"只差200MB"这个此前的结论建立在有
  242 +缺陷的对比上,不可信**——严格来说124帧到底受不受编码器大小影响,目前没有干净的对照数据,
  243 +需要在同一个热进程里做真正的A/B(只切CLIP文件,不重启ComfyUI)才能回答,但鉴于两个编码器
  244 +在这个档位下都是OOM失败,不影响"这个档位用不了"这个实际结论,暂未做这组补充实验。
  245 +
  246 +**顺手修的bug**:`start.sh`每次重启用`>`覆盖`comfyui-server.log`,导致上面两次对比关键的
  247 +ComfyUI侧日志(CLIP加载耗时、显存分配细节)在下次重启后就没了,排查时已经拿不到。改成
  248 +`mv`保留上一轮日志(`comfyui-server.log.prev`),够看"上一次崩溃前发生了什么",不做完整
  249 +logrotate。
  250 +
  251 +## 被推翻的假设
  252 +
  253 +1. **"文生视频用的应该是跟ref2va一样的空引用T2V"**——错。这是DiffSynth-Studio/现有NF4系统
  254 + 的实现方式(gateway.py按有没有first/last frame决定fl2va还是ref2va,没给关键帧就走
  255 + ref2va空引用)。但Raylight/ComfyUI原生的T2V就是`MiniMaxH3ImageToVideo`不给
  256 + first_frame/last_frame,用的是fl2va那颗DiT,跟现有系统的T2V实现路径不是同一个。两条
  257 + 系统对"T2V该用哪个模型"给出了不同答案,都不算错,只是不一样,接入Raylight时选了跟随
  258 + Raylight自己的节点设计(因为没有独立T2V节点,是ComfyUI侧决定的)。
  259 +
  260 +2. **"FSDP_CPU_OFFLOAD能解决832×480×124帧的OOM"**——部分错。它确实能腾出更多显存(116MB
  261 + free → 5.6GB free),因为它把模型权重挪到CPU内存。但124帧那次OOM的根因不是权重装不下,
  262 + 是**attention计算本身要一次性申请12.8GiB的激活值缓冲区**(序列长度决定,不是常驻权重
  263 + 决定)。CPU offload只解决权重显存问题,解决不了激活值问题,所以还是不够,而且拖慢速度
  264 + (4个RayWorker进程各占用系统内存11-12GB用来放CPU offload的权重)。真正要降的是分辨率×
  265 + 帧数这个序列长度,不是开offload开关。
  266 +
  267 +3. **"RayInitializer/RayUNETLoader输入值不变,ComfyUI会跳过重建,第二次应该快很多"**——
  268 + 部分对部分错。查代码时(见 `docs/todo/raylight-comfyui-multi-gpu-eval.md`)确认了这个
  269 + 缓存机制理论上存在,实测也确认**NCCL通信测试和FSDP分片这两步真的被跳过了**(日志里没有
  270 + 再出现"Running NCCL communication test"/"Applying FSDP")。但**文本编码器
  271 + (CLIPLoader→MiniMaxH3TEModel_)每次都被重新加载**,即使输入值逐字节不变,日志显示每次
  272 + "got prompt"后立刻"Requested to load MiniMaxH3TEModel_"。原因没查清楚——可能是
  273 + `comfy-aimdo`的"动态显存"机制本身就设计成每次执行都重新分页加载,不走标准的节点级缓存;
  274 + 也可能是这个特定的量化CLIPLoader实现有自己的缓存失效逻辑。总之实测下来两次64帧测试只
  275 + 差了约17%耗时(822s vs 686s),不是能指望的"秒级复用"。
  276 +
  277 +## 环境搭建踩的坑(跟部署本身无关,但重复搭环境时用得上)
  278 +
  279 +- `uv venv --python 3.11` 选中的系统 `python3.11` 实际是 **3.11.0rc1(预发布版)**,缺
  280 + `sys.get_int_max_str_digits`,torch内部依赖这个属性,ComfyUI直接启动崩溃——改用
  281 + `uv python install 3.12` 让uv自己管理一份正式版解决。以后系统自带的`python3.1x`不能默认
  282 + 信任是正式版,先 `python3.11 --version` 查一眼。
  283 +- 整个目录从 `raylight-eval` 改名成 `raylight` 之后,`.venv/bin/activate` 和
  284 + `.venv/bin/comfy`(console script的shebang)里都硬编码了旧的绝对路径,改名后全部失效
  285 + (`source activate`后PATH指向不存在的旧路径,`comfy`命令直接报"未找到命令")。以后要
  286 + 重命名装了venv的目录,别指望`source activate`还能用,统一改用`.venv/bin/python`绝对/
  287 + 相对路径调用,或者`.venv/bin/python -m <module>`,不要依赖console script的shebang。
  288 +- Raylight官方FL2VA example workflow(`Minimax_H3_I2V_Raylight.json`)本身有个bug:
  289 + `RayBasicGuider`节点的`conditioning`输入没接线(`MiniMaxH3ImageToVideo`的`positive`
  290 + 输出`links`是空数组),直接跑会在校验阶段报`required_input_missing`。Ref2VA的example
  291 + 没有这个问题。
  292 +- ComfyUI的`/history`接口里,视频文件(来自`SaveVideo`节点)放在
  293 + `outputs.<node_id>.images[]`里,不是"videos"key——历史遗留命名,容易看漏。
  294 +- `RayInitializer`的`XFuser_attention`默认值`TORCH_FLASH`依赖FlashAttention(没装,属于
  295 + 可选依赖),会在校验阶段直接报错"'TORCH' not in 15 known options"(连"TORCH"这个我们
  296 + 想用的名字都不对,正确值是`TORCH_MATH`)。
  297 +
  298 +## 2026-08-23(凌晨)— 找到"denoise跑完却卡死"的真根因,修复后做了一次完整的分辨率/帧数上限测试
  299 +
  300 +**真根因**:`_run_job_async`里`stop_tail.set(); tail_task.cancel(); ws_task.cancel()`之后
  301 +`await`被cancel的task,用`contextlib.suppress(Exception)`接——但`asyncio.CancelledError`
  302 +在py3.8+是`BaseException`的子类,不是`Exception`的子类,根本压不住。它会直接从
  303 +`_run_job_async`的`finally`飞出去,绕过`worker()`的`except Exception`,导致`status`永远
  304 +停在`running`但`finished_at`已经被`worker()`自己的`finally`写上了——外部表现正是"ComfyUI
  305 +日志里有`Prompt executed`,任务却卡死",这跟之前怀疑的"websocket断线/丢消息"、"600秒误杀"
  306 +其实是同一个bug的不同片段,不是三个独立问题。改成`contextlib.suppress(asyncio.CancelledError)`
  307 +就好了。顺带把完成判定也从"只信websocket的`execution_success`消息"改成轮询
  308 +`/history/{prompt_id}`(websocket现在只用来上报进度,断了/丢消息不影响完成判定,双重加固)。
  309 +用`734d28c52670`(832×480×200帧)验证过,`status`正确转`done`、文件正确挂上、console能
  310 +正常播放下载。
  311 +
  312 +**顺带修了一个我自己引入的bug**:修`_load_jobs()`把状态改进去也落盘那次,`SELECT`游标没关
  313 +就开新连接写,触发过一次`sqlite3.OperationalError: database is locked`,一度让生产真的
  314 +起不来(这是另一个并发的排列组合测试进程发现并修好的,见其报告——把`SELECT`结果`list()`
  315 +取完关掉连接,再在循环里写)。
  316 +
  317 +**修复后跑了一轮完整的分辨率/帧数上限测试**(用`fork` + 本地`sweep.py`脚本自动化:每个分辨率
  318 +从已知能跑的底线开始,×1.5递增直到失败,再二分收窄,每个`done`任务都额外SSH核实过文件真实
  319 +存在且大小合理,不是只信API返回的`status`字段):
  320 +
  321 +| 分辨率 | 已知能跑 | 新探到的上限 | 说明 |
  322 +|---|---|---|---|
  323 +| 1024×576 | 743帧 done(3716s) | 787帧 真实CUDA OOM | GPU0 ~20.26GB/21.48GB,差几百MB |
  324 +| 832×480 | 1125帧 done(4106s) | 1195帧 真实CUDA OOM | 撞到API硬上限1200帧附近,凑巧同时也是真实显存上限 |
  325 +| 1344×768 | 412帧 done(3315s) | 431帧 真实CUDA OOM | 这个分辨率最贵,412帧已经要55分钟 |
  326 +| 448×256 | **1200帧 done(646s)** | **画质限制在1200帧/50秒,不是显存限制** | 见下面单独说明 |
  327 +
  328 +**448×256——技术上限比1200帧高很多,但推荐上限就是1200帧,因为再往上画质就不能看了**:
  329 +`server.py`的`GenReq.frames`上限从1200调到3000之后继续测:
  330 +
  331 +| 帧数 | 结果 |
  332 +|---|---|
  333 +| 1800帧 | done,1152.2s |
  334 +| 2700帧 | done,2193.5s |
  335 +| 3000帧(调整后的新上限) | done,2634.2s,显存依然远没跑满,没有OOM迹象 |
  336 +
  337 +也就是说这个分辨率的**真实VRAM上限比3000帧还要高,这次没有探到**——但用户看过1200帧(50秒/
  338 +24fps)往后的实际视频输出,判定**画面质量从50秒开始就已经不能看了**。所以**448×256推荐的
  339 +实际上限是1200帧/50秒,这是主观画质判断卡的,不是硬件算力卡的**——这是有意留着的结论,
  340 +不是没测完的TODO,以后不需要再花时间去找它的VRAM天花板在哪。832×480那次"1195帧真实OOM"
  341 +也恰好卡在1200上限附近(当时上限还是1200),没有太多空间验证是不是真的没有更高的空间了
  342 +(差距只有5帧),如果以后要精确知道832×480的真实VRAM上限,可以用现在调到3000的新上限
  343 +重测一次这个分辨率。
  344 +
  345 +**CUDA OOM报错的真实文本**(举例,1024×576×787帧那次):
  346 +```
  347 +torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 792.00 MiB. GPU 0 has a total
  348 +capacity of 21.48 GiB of which 262.25 MiB is free. ... this process has 20.26 GiB memory in use.
  349 +```
  350 +所有真实OOM都发生在GPU0(跟之前的结论一致:GPU0同时扛文本编码器+DiT分片+这块的attention
  351 +buffer,是四张卡里最先顶到天花板的一张),而且都在提交后30秒内就报错——说明失败发生在采样
  352 +刚开始分配显存的时候,不是跑到一半才炸,比较干脆,不存在"跑了很久才失败"的浪费。
... ...
raylight/scripts/server.py 0 → 100644
  1 +#!/usr/bin/env python3
  2 +"""生产网关:把 h3-console 现有的 gateway.py/api.py 契约(/health, /v1/videos...)搬到
  3 +ComfyUI + Raylight 之上。跟旧架构最大的不同:旧架构是4卡4个独立worker、gateway负责按partition
  4 +选卡;新架构是4卡一个 FSDP 集群,任何时刻只能跑一个请求,所以这里把 gateway 的"选后端"和 api.py
  5 +的"单卡worker"合并成一个服务:一条队列,一个后台 worker,串行往 ComfyUI 提交/等待/取结果。
  6 +
  7 +RayInitializer/RayUNETLoader/CLIPLoader/VAELoader 这几个"重"节点的输入值每次提交必须逐字节
  8 +相同——这是 ComfyUI 节点级缓存跳过重新建集群/重新加载模型的唯一机制(Raylight 自己在这条量化+
  9 +FSDP 的配置下没有安全网,见 docs/todo/raylight-comfyui-multi-gpu-eval.md)。所以下面的
  10 +RAY_INIT_CFG 是全局常量,任何请求都不应该改它。
  11 +"""
  12 +import asyncio
  13 +import contextlib
  14 +import json
  15 +import os
  16 +import queue
  17 +import re
  18 +import sqlite3
  19 +import threading
  20 +import time
  21 +import urllib.parse
  22 +import urllib.request
  23 +import uuid
  24 +from pathlib import Path
  25 +from typing import Literal, Optional
  26 +
  27 +import httpx
  28 +import websockets
  29 +from fastapi import FastAPI, HTTPException
  30 +from fastapi.responses import FileResponse
  31 +from pydantic import BaseModel, Field, model_validator
  32 +
  33 +COMFY_HOST = os.environ.get("COMFY_HOST", "127.0.0.1")
  34 +COMFY_PORT = os.environ.get("COMFY_PORT", "8188")
  35 +COMFY_BASE = f"http://{COMFY_HOST}:{COMFY_PORT}"
  36 +COMFY_WS = f"ws://{COMFY_HOST}:{COMFY_PORT}/ws"
  37 +# console 的 _try_submit/_row_view 依赖响应里的 backend 字段来判断"任务分配到哪个后端了",
  38 +# 没有这个字段就会一直认为任务"还没分配",播放/下载会被 409 拒绝——踩过这个坑,2026-08-22。
  39 +SERVER_PORT = os.environ.get("PORT", "8100")
  40 +BACKEND_URL = f"http://127.0.0.1:{SERVER_PORT}"
  41 +
  42 +ROOT = Path("/data/models/raylight/ComfyUI")
  43 +INPUT_DIR = ROOT / "input"
  44 +OUTPUT_DIR = ROOT / "output" / "video"
  45 +DB_PATH = Path("/data/models/raylight/jobs.sqlite3")
  46 +COMFYUI_LOG_PATH = Path("/data/models/raylight/comfyui-server.log")
  47 +# ponytail: Raylight 的 FSDP 采样在 Ray 子进程里跑,从不调 comfy.utils.ProgressBar 上报进度,
  48 +# 所以下面 _run_job_async 里监听的 progress_state websocket 永远拿不到真实 step——只能退而
  49 +# 求其次,轮询子进程 tqdm 直接打到 stdout、被日志捕获的这行文字。上游改了这行的措辞/格式,
  50 +# 这个正则就会失效(退化为进度条不动,不影响任务本身跑成功)。只信任 total 等于本次请求
  51 +# steps 的那次匹配,因为同一句"Model Initializing"文案在 FSDP 初始化阶段也会打印、但那里的
  52 +# 总数是 GPU 数(4),跟真正的采样步数(这里的 steps 字段)容易混淆。
  53 +_PROGRESS_RE = re.compile(r"Model Initializing.*?(\d+)\.\d+/(\d+)\.\d+ \[")
  54 +
  55 +# 每次提交必须逐字节相同才能让 ComfyUI 跳过重建集群——不要给任何一个请求字段改这几个值。
  56 +RAY_INIT_CFG = {
  57 + "ray_cluster_address": "local",
  58 + "ray_cluster_namespace": "default",
  59 + "GPU": 4,
  60 + "ulysses_degree": 4,
  61 + "ring_degree": 1,
  62 + "cfg_degree": 1,
  63 + "dp_degree": 1,
  64 + "sync_ulysses": False,
  65 + "clear_vram_after_sampling": False,
  66 + "FSDP": True,
  67 + "FSDP_CPU_OFFLOAD": False,
  68 + # 2026-08-22:TORCH_MATH 物化完整attention矩阵,是之前 832x480x124帧/448x256x362帧/
  69 + # 1024x576x60帧 反复 OOM 的根因,不是权重装不下。yunchang 其实原生支持 SAGE_FP16,只是环境里
  70 + # 从没装过给Turing(sm_75)编译的sageattention——装上以后三个档位全部通过,GPU0峰值~13.3GB,
  71 + # 还快很多(832x480x124帧从OOM变成100秒)。sageattention来自社区分支
  72 + # github.com/1506086927/SageAttention2_Optimized_Test(经 github.com/Eanya-Tonic/2080ti-minimax-h3
  73 + # 的补丁接入),详见 notes.md。
  74 + "XFuser_attention": "SAGE_FP16",
  75 + "RAYLIGHT_ULYSSES_KV_INT8": "off", # 通信层INT8量化,跟这次的显存修复无关,保持关闭
  76 + "skip_comm_test": False,
  77 + "use_mmap": True,
  78 +}
  79 +# 2026-08-23:4个分辨率×帧数排列组合实测出的真实CUDA OOM边界(832×480/1024×576/1344×768)
  80 +# 或画质边界(448×256,见notes.md——技术上测到3000帧都没OOM,是用户看过实际输出判定50秒往后
  81 +# 画质不能看,不是显存拦的)。跟 h3-console 前端 RESOLUTIONS 里的 maxFrames 保持一致——
  82 +# 两边算法对不上就会出现"前端放行、后端拒绝"或反过来,数字改动两边一起改。
  83 +RESOLUTION_CEILINGS = {
  84 + (832, 480): 1125,
  85 + (448, 256): 1200,
  86 + (1024, 576): 743,
  87 + (1344, 768): 412,
  88 +}
  89 +CLIP_NAME = "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
  90 +VAE_VIDEO = "minimax_h3_video_vae_fp16.safetensors"
  91 +VAE_AUDIO = "minimax_h3_audio_vae_fp32.safetensors"
  92 +DIT_FL2VA = "minimax_h3_fl2va_pruned_int8_convrot.safetensors"
  93 +DIT_REF2VA = "minimax_h3_ref2va_pruned_int8_convrot.safetensors"
  94 +
  95 +
  96 +class GenReq(BaseModel):
  97 + prompt: str
  98 + width: int = Field(832, ge=64, le=2048)
  99 + height: int = Field(480, ge=64, le=2048)
  100 + frames: int = Field(124, ge=5, le=3000) # 2026-08-23: 448x256在1200帧(旧上限)时GPU远没跑满
  101 + # (轻松成功,646秒),真实显存上限没探到——旧的1200是这个字段自己的硬编码,不是显存拦的。
  102 + # 抬到3000留探测空间,其他分辨率的真实上限已经在1200帧以内测到了,不受这次调整影响。见 notes.md
  103 + steps: int = Field(20, ge=1, le=50)
  104 + turbo: bool = False # Raylight 图里还没接turbo LoRA,目前是no-op,见 notes.md
  105 + seed: int = Field(0, ge=0, le=2**31 - 1)
  106 + cfg_scale: Optional[float] = None # 目前是 no-op:MiniMax H3 在 Raylight 里不支持 CFG
  107 + negative_prompt: Optional[str] = None # 同上,暂不生效,见 notes.md
  108 + first_frame_url: Optional[str] = None
  109 + last_frame_url: Optional[str] = None
  110 + reference_image_urls: list[str] = []
  111 +
  112 + @model_validator(mode="after")
  113 + def _check_known_resolution_ceiling(self):
  114 + # 只挡已经实测过的4个分辨率——没测过的组合不拦,跟以前一样"能提交但结果未验证",
  115 + # 不假装知道我们没测过的显存边界。
  116 + ceiling = RESOLUTION_CEILINGS.get((self.width, self.height))
  117 + if ceiling is not None and self.frames > ceiling:
  118 + raise ValueError(
  119 + f"{self.width}x{self.height} 实测上限是 {ceiling} 帧,{self.frames} 帧会真实CUDA OOM"
  120 + f"(或448x256画质会退化,见notes.md),不接受这个请求"
  121 + )
  122 + return self
  123 +
  124 +
  125 +class Job(BaseModel):
  126 + id: str
  127 + status: Literal["queued", "running", "done", "failed"]
  128 + req: GenReq
  129 + created_at: float
  130 + started_at: Optional[float] = None
  131 + finished_at: Optional[float] = None
  132 + step: int = 0
  133 + total_steps: int = 0
  134 + file: Optional[str] = None
  135 + size_bytes: Optional[int] = None
  136 + error: Optional[str] = None
  137 + partition: str = "ref2va"
  138 + comfy_prompt_id: Optional[str] = None
  139 + backend: str = BACKEND_URL
  140 +
  141 +
  142 +JOBS: dict[str, Job] = {}
  143 +Q: "queue.Queue[str]" = queue.Queue()
  144 +LOCK = threading.Lock()
  145 +STATE = {"model": "loading", "loaded_at": None}
  146 +
  147 +
  148 +def db():
  149 + conn = sqlite3.connect(DB_PATH)
  150 + conn.row_factory = sqlite3.Row
  151 + return conn
  152 +
  153 +
  154 +def init_db():
  155 + conn = db()
  156 + conn.execute(
  157 + "CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, updated_at REAL NOT NULL, data TEXT NOT NULL)"
  158 + )
  159 + conn.commit()
  160 + conn.close()
  161 +
  162 +
  163 +def _persist(job: Job):
  164 + with LOCK:
  165 + conn = db()
  166 + conn.execute(
  167 + "INSERT INTO jobs(id, updated_at, data) VALUES (?,?,?) "
  168 + "ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, data=excluded.data",
  169 + (job.id, time.time(), job.model_dump_json()),
  170 + )
  171 + conn.commit()
  172 + conn.close()
  173 +
  174 +
  175 +def _load_jobs():
  176 + conn = db()
  177 + rows = list(conn.execute("SELECT data FROM jobs"))
  178 + conn.close() # 2026-08-23: 之前在这个连接的SELECT游标还开着的时候就调 _persist() 开新连接写,
  179 + # SQLite 默认rollback-journal模式下第二个连接的写commit会被第一个连接的读事务锁住,报
  180 + # 'database is locked',导致服务启动直接崩溃——先把结果取完关掉连接,再在下面循环里写。
  181 + for row in rows:
  182 + try:
  183 + j = Job.model_validate_json(row["data"])
  184 + except Exception:
  185 + continue
  186 + # 进程重启=ComfyUI侧的执行状态和排队状态都丢了,跟老系统的策略一致:不可恢复的直接判失败
  187 + if j.status in ("queued", "running"):
  188 + j.status = "failed"
  189 + j.error = "进程重启导致这条任务的执行状态丢失,无法恢复"
  190 + j.finished_at = time.time()
  191 + _persist(j) # 2026-08-23: 之前只改内存不落盘,sqlite里的status永远停在running/queued,
  192 + # 下次重启会被这段逻辑重新"发现"一遍、把人工写的详细错因覆盖回这句通用文案——必须写回去
  193 + JOBS[j.id] = j
  194 + conn.close()
  195 +
  196 +
  197 +def _partition_of(req: GenReq) -> str:
  198 + if req.reference_image_urls:
  199 + return "ref2va"
  200 + return "fl2va"
  201 +
  202 +
  203 +def _fetch_to_input(url: str) -> str:
  204 + """下载一张图到 ComfyUI 的 input/ 目录,LoadImage 节点按文件名读取。"""
  205 + ext = os.path.splitext(urllib.parse.urlparse(url).path)[1] or ".png"
  206 + if ext.lower() not in (".png", ".jpg", ".jpeg", ".webp"):
  207 + ext = ".png"
  208 + fname = f"{uuid.uuid4().hex}{ext}"
  209 + dest = INPUT_DIR / fname
  210 + with urllib.request.urlopen(url, timeout=60) as resp, open(dest, "wb") as f:
  211 + f.write(resp.read())
  212 + return fname
  213 +
  214 +
  215 +def _static_nodes() -> dict:
  216 + """三套 workflow 共用的静态节点:CLIP/VAE loader + RayInitializer。节点 id 用字符串前缀
  217 + "s_" 避免跟每次请求动态生成的节点 id 冲突。
  218 +
  219 + CLIP/VAE 不走 FSDP 分片,默认会全部堆在 GPU0(跟 Ray rank0 的 DiT 分片挤同一张卡,是
  220 + GPU0 在所有 OOM 测试里最先报错的原因之一,见 notes.md)。这个 ComfyUI fork 自带
  221 + SelectCLIPDevice/SelectVAEDevice 节点(comfy_extras/nodes_multigpu.py),用它们把这三样
  222 + 分别钉到 GPU1/2/3,把 GPU0 净空出来只留 DiT 分片——2026-08-22 加,效果见 notes.md。"""
  223 + return {
  224 + "s_clip": {"class_type": "CLIPLoader",
  225 + "inputs": {"clip_name": CLIP_NAME, "type": "minimax", "device": "default"}},
  226 + "s_clip_gpu": {"class_type": "SelectCLIPDevice", "inputs": {"clip": ["s_clip", 0], "device": "gpu:1"}},
  227 + "s_vae_v": {"class_type": "VAELoader", "inputs": {"vae_name": VAE_VIDEO}},
  228 + "s_vae_v_gpu": {"class_type": "SelectVAEDevice", "inputs": {"vae": ["s_vae_v", 0], "device": "gpu:2"}},
  229 + "s_vae_a": {"class_type": "VAELoader", "inputs": {"vae_name": VAE_AUDIO}},
  230 + "s_vae_a_gpu": {"class_type": "SelectVAEDevice", "inputs": {"vae": ["s_vae_a", 0], "device": "gpu:3"}},
  231 + "s_rayinit": {"class_type": "RayInitializer", "inputs": dict(RAY_INIT_CFG)},
  232 + }
  233 +
  234 +
  235 +def build_fl2va_graph(req: GenReq) -> dict:
  236 + """文生视频 / 首尾帧生视频 —— MiniMaxH3ImageToVideo,不给 first/last frame 就是纯 T2V。"""
  237 + g = _static_nodes()
  238 + g["s_unet"] = {"class_type": "RayUNETLoader",
  239 + "inputs": {"unet_name": DIT_FL2VA, "weight_dtype": "default", "lora": None,
  240 + "ray_actors_init": ["s_rayinit", 0]}}
  241 +
  242 + conditioning_inputs = {
  243 + "prompt": req.prompt, "clip": ["s_clip_gpu", 0], "vae": ["s_vae_v_gpu", 0],
  244 + "width": req.width, "height": req.height, "length": req.frames,
  245 + }
  246 + if req.first_frame_url:
  247 + fname = _fetch_to_input(req.first_frame_url)
  248 + g["n_first"] = {"class_type": "LoadImage", "inputs": {"image": fname}}
  249 + conditioning_inputs["first_frame"] = ["n_first", 0]
  250 + if req.last_frame_url:
  251 + fname = _fetch_to_input(req.last_frame_url)
  252 + g["n_last"] = {"class_type": "LoadImage", "inputs": {"image": fname}}
  253 + conditioning_inputs["last_frame"] = ["n_last", 0]
  254 + g["n_cond"] = {"class_type": "MiniMaxH3ImageToVideo", "inputs": conditioning_inputs}
  255 +
  256 + _append_sampling_tail(g, req, positive_node="n_cond")
  257 + return g
  258 +
  259 +
  260 +def build_ref2va_graph(req: GenReq) -> dict:
  261 + """多参考图生视频 —— MiniMaxH3ReferenceToVideo,最多9张参考图(协议规格上限)。"""
  262 + g = _static_nodes()
  263 + g["s_unet"] = {"class_type": "RayUNETLoader",
  264 + "inputs": {"unet_name": DIT_REF2VA, "weight_dtype": "default", "lora": None,
  265 + "ray_actors_init": ["s_rayinit", 0]}}
  266 +
  267 + conditioning_inputs = {
  268 + "prompt": req.prompt, "clip": ["s_clip_gpu", 0], "vae": ["s_vae_v_gpu", 0],
  269 + "audio_vae": ["s_vae_a_gpu", 0], "width": req.width, "height": req.height,
  270 + "length": req.frames, "ref_image_size": "match",
  271 + }
  272 + for i, url in enumerate(req.reference_image_urls[:9]):
  273 + fname = _fetch_to_input(url)
  274 + nid = f"n_ref{i}"
  275 + g[nid] = {"class_type": "LoadImage", "inputs": {"image": fname}}
  276 + conditioning_inputs[f"ref_images.ref_image_{i}"] = [nid, 0]
  277 + g["n_cond"] = {"class_type": "MiniMaxH3ReferenceToVideo", "inputs": conditioning_inputs}
  278 +
  279 + _append_sampling_tail(g, req, positive_node="n_cond")
  280 + return g
  281 +
  282 +
  283 +def _append_sampling_tail(g: dict, req: GenReq, positive_node: str):
  284 + """采样+解码+存视频这一段两套 workflow 完全一样,抽出来一份。
  285 + cfg_scale/negative_prompt 目前没有入口——MiniMax H3 在 Raylight 这条 pipeline 里不支持 CFG
  286 + parallel(cfg_degree 只能是1),RayBasicGuider 只吃一路 positive conditioning,见 notes.md。"""
  287 + g["n_guider"] = {"class_type": "RayBasicGuider",
  288 + "inputs": {"ray_actors": ["s_unet", 0], "conditioning": [positive_node, 0]}}
  289 + g["n_sched"] = {"class_type": "RayBasicScheduler",
  290 + "inputs": {"scheduler": "simple", "steps": req.steps, "denoise": 1.0,
  291 + "ray_actors": ["s_unet", 0]}}
  292 + g["n_sampler_sel"] = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "res_multistep"}}
  293 + g["n_sample"] = {"class_type": "XFuserSamplerCustomAdvanced",
  294 + "inputs": {"add_noise": True, "noise_seed": req.seed,
  295 + "guider": ["n_guider", 0], "sampler": ["n_sampler_sel", 0],
  296 + "sigmas": ["n_sched", 0], "latent_image": [positive_node, 1]}}
  297 + g["n_vaedec"] = {"class_type": "VAEDecode", "inputs": {"samples": ["n_sample", 0], "vae": ["s_vae_v_gpu", 0]}}
  298 + g["n_vaedec_a"] = {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["n_sample", 0], "vae": ["s_vae_a_gpu", 0]}}
  299 + g["n_video"] = {"class_type": "CreateVideo",
  300 + "inputs": {"fps": 24, "bit_depth": 8, "images": ["n_vaedec", 0], "audio": ["n_vaedec_a", 0]}}
  301 + g["n_save"] = {"class_type": "SaveVideo",
  302 + "inputs": {"filename_prefix": "video/MiniMax_H3", "format": "auto",
  303 + "video": ["n_video", 0], "format.codec": "auto"}}
  304 +
  305 +
  306 +def build_graph(req: GenReq) -> tuple[dict, str]:
  307 + partition = _partition_of(req)
  308 + if partition == "ref2va":
  309 + return build_ref2va_graph(req), partition
  310 + return build_fl2va_graph(req), partition
  311 +
  312 +
  313 +# ---------------- ComfyUI 交互(单队列 worker,严格串行——4卡是一个集群,同时只能跑一个请求) ----------------
  314 +
  315 +async def _tail_log_progress(j: Job, expected_total: int, stop: asyncio.Event):
  316 + """见 _PROGRESS_RE 定义处的 ponytail 注释:progress_state websocket 拿不到真实 step 时的退路。"""
  317 + while not stop.is_set():
  318 + await asyncio.sleep(2)
  319 + try:
  320 + with open(COMFYUI_LOG_PATH, "rb") as f:
  321 + f.seek(0, 2)
  322 + f.seek(max(0, f.tell() - 8192))
  323 + tail = f.read().decode("utf-8", "ignore")
  324 + except OSError:
  325 + continue
  326 + for m in reversed(list(_PROGRESS_RE.finditer(tail))):
  327 + cur, total = int(m.group(1)), int(m.group(2))
  328 + if total == expected_total and (cur, total) != (j.step, j.total_steps):
  329 + j.step, j.total_steps = cur, total
  330 + _persist(j)
  331 + break
  332 +
  333 +
  334 +async def _track_ws_progress(j: Job, client_id: str, prompt_id: str):
  335 + """尽力而为的进度上报——完成判定不靠这个(见 _run_job_async 里的注释),这个连接断了/
  336 + 超时/漏消息都无所谓,静默退出就行,不能让它的失败影响任务本身的成败判定。"""
  337 + try:
  338 + ws_url = f"{COMFY_WS}?clientId={client_id}"
  339 + async with websockets.connect(ws_url, max_size=None) as ws:
  340 + while True:
  341 + raw = await ws.recv()
  342 + if not isinstance(raw, str):
  343 + continue
  344 + msg = json.loads(raw)
  345 + data = msg.get("data", {})
  346 + if msg.get("type") == "progress_state" and data.get("prompt_id") == prompt_id:
  347 + for node_info in data.get("nodes", {}).values():
  348 + if node_info.get("state") == "running":
  349 + j.step = int(node_info.get("value", 0))
  350 + j.total_steps = int(node_info.get("max", 0))
  351 + _persist(j)
  352 + except Exception:
  353 + pass
  354 +
  355 +
  356 +async def _poll_history(prompt_id: str, interval: float = 5.0) -> dict:
  357 + async with httpx.AsyncClient(timeout=30) as http:
  358 + while True:
  359 + resp = await http.get(f"{COMFY_BASE}/history/{prompt_id}")
  360 + resp.raise_for_status()
  361 + data = resp.json()
  362 + if prompt_id in data:
  363 + return data[prompt_id]
  364 + await asyncio.sleep(interval)
  365 +
  366 +
  367 +async def _run_job_async(j: Job):
  368 + graph, _ = build_graph(j.req)
  369 + client_id = uuid.uuid4().hex
  370 + async with httpx.AsyncClient(timeout=60) as http:
  371 + resp = await http.post(f"{COMFY_BASE}/prompt", json={"prompt": graph, "client_id": client_id})
  372 + resp.raise_for_status()
  373 + body = resp.json()
  374 + if body.get("node_errors"):
  375 + raise RuntimeError(f"workflow 校验失败: {json.dumps(body['node_errors'], ensure_ascii=False)}")
  376 + prompt_id = body["prompt_id"]
  377 + j.comfy_prompt_id = prompt_id
  378 + _persist(j)
  379 +
  380 + stop_tail = asyncio.Event()
  381 + tail_task = asyncio.create_task(_tail_log_progress(j, j.req.steps, stop_tail))
  382 + ws_task = asyncio.create_task(_track_ws_progress(j, client_id, prompt_id))
  383 + try:
  384 + # 2026-08-23: 之前完成判定只靠 websocket 的 execution_success 消息——这条连接在大分辨率/
  385 + # 长帧数任务上复现过至少2次"消息收不到"(ComfyUI日志明明有"Prompt executed",这边永远
  386 + # 卡在running),把单队列堵死。改成轮询 /history 作为完成判定的唯一权威来源,
  387 + # 跟websocket解耦:websocket只用来拿实时进度,它断线/丢消息不影响完成判定。
  388 + hist = await _poll_history(prompt_id)
  389 + finally:
  390 + stop_tail.set()
  391 + tail_task.cancel()
  392 + ws_task.cancel()
  393 + # 2026-08-23: 真根因找到了——asyncio.CancelledError 在py3.8+是BaseException的子类,
  394 + # 不是Exception的子类,contextlib.suppress(Exception)根本压不住它。之前这里一旦
  395 + # cancel()完再await,CancelledError会从这个finally里直接飞出去,绕过worker()的
  396 + # except Exception,导致status永远停在running但finished_at已经被worker()的finally
  397 + # 写上了——外面看起来就是"denoise明明跑完了、任务却卡死",其实是这个而不是websocket
  398 + # 断线(早前怀疑的方向)。这就是这两天反复出现的那个"卡死"bug的真身。
  399 + with contextlib.suppress(asyncio.CancelledError):
  400 + await tail_task
  401 + with contextlib.suppress(asyncio.CancelledError):
  402 + await ws_task
  403 +
  404 + status = hist.get("status") or {}
  405 + if status.get("status_str") == "error":
  406 + # 2026-08-23: 之前截前2000字符——Python异常堆栈的关键结论行(比如真正的
  407 + # "CUDA out of memory")在traceback最后,截头会把它切没,console上只能看到一堆
  408 + # 无意义的调用栈开头。改成截尾,真正有用的报错原因才留得下来。
  409 + raise RuntimeError(f"ComfyUI execution_error: {json.dumps(status.get('messages', []), ensure_ascii=False)[-2000:]}")
  410 +
  411 + # 视频文件在 outputs.<save节点id>.images[] 里,不是 "videos" key——ComfyUI 内部历史遗留命名
  412 + save_out = hist["outputs"]["n_save"]["images"][0]
  413 + fpath = OUTPUT_DIR / save_out["filename"]
  414 + if not fpath.exists():
  415 + raise RuntimeError(f"ComfyUI 报告完成但文件不存在: {fpath}")
  416 + j.file = str(fpath)
  417 + j.size_bytes = fpath.stat().st_size
  418 +
  419 +
  420 +def worker():
  421 + STATE["model"] = "ready" # ComfyUI 自己管加载,这里没有独立的"模型加载"阶段
  422 + STATE["loaded_at"] = time.time()
  423 + loop = asyncio.new_event_loop()
  424 + while True:
  425 + jid = Q.get()
  426 + j = JOBS.get(jid)
  427 + if j is None:
  428 + continue
  429 + j.status = "running"
  430 + j.started_at = time.time()
  431 + _persist(j)
  432 + try:
  433 + loop.run_until_complete(_run_job_async(j))
  434 + j.status = "done"
  435 + except Exception as e:
  436 + j.status = "failed"
  437 + j.error = f"{type(e).__name__}: {e}"
  438 + print(f"[job {jid}] failed: {j.error}")
  439 + finally:
  440 + j.finished_at = time.time()
  441 + _persist(j)
  442 + Q.task_done()
  443 +
  444 +
  445 +# ---------------- HTTP API —— 跟 gateway.py 现有契约保持一致,console 不用改一行 UI 代码 ----------------
  446 +
  447 +app = FastAPI()
  448 +
  449 +
  450 +@app.on_event("startup")
  451 +def _start():
  452 + INPUT_DIR.mkdir(parents=True, exist_ok=True)
  453 + init_db()
  454 + _load_jobs()
  455 + threading.Thread(target=worker, daemon=True).start()
  456 +
  457 +
  458 +@app.get("/health")
  459 +def health():
  460 + running = sum(1 for j in JOBS.values() if j.status == "running")
  461 + ready = 1 if STATE["model"] == "ready" else 0
  462 + backend = {
  463 + "status": "ok" if ready else "loading",
  464 + "model": STATE["model"],
  465 + "gpu": "0,1,2,3",
  466 + "partition": "raylight-fsdp", # 不再是路由依据,纯展示——4卡是一个集群,没有单卡分区概念了
  467 + "offload": "fsdp",
  468 + "queued": Q.qsize(),
  469 + "running": running,
  470 + "backend": BACKEND_URL,
  471 + }
  472 + return {
  473 + "status": "ok" if ready else "unavailable",
  474 + "ready": ready, "total": 1,
  475 + "queued": Q.qsize(), "running": running,
  476 + "backends": [backend],
  477 + }
  478 +
  479 +
  480 +@app.post("/v1/videos")
  481 +def create_video(req: GenReq):
  482 + if req.turbo and req.steps > 8:
  483 + raise HTTPException(400, "turbo 模式建议 steps<=8(当前实现turbo LoRA还没接,steps按原样生效)")
  484 + jid = uuid.uuid4().hex[:12]
  485 + partition = _partition_of(req)
  486 + with LOCK:
  487 + JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), partition=partition)
  488 + _persist(JOBS[jid])
  489 + Q.put(jid)
  490 + return {"id": jid, "status": "queued", "queue_position": Q.qsize(),
  491 + "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL}
  492 +
  493 +
  494 +@app.get("/v1/videos/{jid}")
  495 +def get_video(jid: str):
  496 + j = JOBS.get(jid)
  497 + if j is None:
  498 + raise HTTPException(404, "无此任务")
  499 + out = j.model_dump()
  500 + if j.status == "running" and j.started_at:
  501 + out["elapsed_seconds"] = time.time() - j.started_at
  502 + if j.status == "done":
  503 + out["download"] = f"/v1/videos/{jid}/file"
  504 + return out
  505 +
  506 +
  507 +@app.get("/v1/videos/{jid}/file")
  508 +def get_video_file(jid: str):
  509 + j = JOBS.get(jid)
  510 + if j is None:
  511 + raise HTTPException(404, "无此任务")
  512 + if j.status != "done" or not j.file:
  513 + raise HTTPException(409, "任务还没完成")
  514 + return FileResponse(j.file, media_type="video/mp4", filename=f"{jid}.mp4")
  515 +
  516 +
  517 +@app.get("/v1/videos")
  518 +def list_videos(limit: int = 50):
  519 + jobs = sorted(JOBS.values(), key=lambda j: j.created_at, reverse=True)[:limit]
  520 + return [{"id": j.id, "status": j.status, "created_at": j.created_at,
  521 + "size_bytes": j.size_bytes, "error": j.error} for j in jobs]
... ...
raylight/scripts/start.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 起 Raylight 生产服务:常驻 ComfyUI(4卡FSDP集群)+ raylight-server.py(对外契约跟旧 gateway.py
  3 +# 一致,见 notes.md)。已经在跑的会先停掉。
  4 +#
  5 +# 停端口要按端口找 PID,不要用 pkill -f——同理 minimax-h3/start-all.sh 里的教训。
  6 +set -uo pipefail
  7 +cd /data/models/raylight
  8 +
  9 +COMFY_PORT=8188
  10 +SERVER_PORT="${PORT:-8100}"
  11 +
  12 +for PORT in $COMFY_PORT $SERVER_PORT; do
  13 + PID=$(ss -lptn "sport = :$PORT" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u)
  14 + [ -n "$PID" ] && { echo "停 $PORT (PID $PID)"; kill $PID; }
  15 +done
  16 +sleep 3
  17 +
  18 +cd ComfyUI
  19 +export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
  20 +# ponytail: 保留最近10轮日志(.1最新~.10最旧)——2026-08-23密集测试期间发现只留1份不够用,
  21 +# 连续崩溃重启会把上上轮的崩溃现场覆盖掉,排查不了;10轮之前的还是不留,不做完整logrotate
  22 +for i in 9 8 7 6 5 4 3 2 1; do
  23 + [ -f "/data/models/raylight/comfyui-server.log.$i" ] && \
  24 + mv -f "/data/models/raylight/comfyui-server.log.$i" "/data/models/raylight/comfyui-server.log.$((i+1))"
  25 +done
  26 +mv -f /data/models/raylight/comfyui-server.log /data/models/raylight/comfyui-server.log.1 2>/dev/null
  27 +setsid nohup .venv/bin/python main.py --listen 0.0.0.0 --port "$COMFY_PORT" \
  28 + > /data/models/raylight/comfyui-server.log 2>&1 < /dev/null &
  29 +disown
  30 +cd ..
  31 +
  32 +echo "等 ComfyUI 起来"
  33 +for i in $(seq 1 30); do
  34 + sleep 3
  35 + CODE=$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$COMFY_PORT/system_stats")
  36 + [ "$CODE" = "200" ] && { echo " ComfyUI ready"; break; }
  37 +done
  38 +
  39 +setsid nohup ComfyUI/.venv/bin/python -m uvicorn server:app --host 0.0.0.0 --port "$SERVER_PORT" \
  40 + > /data/models/raylight/server.log 2>&1 < /dev/null &
  41 +disown
  42 +
  43 +echo "等 raylight-server 起来"
  44 +for i in $(seq 1 20); do
  45 + sleep 2
  46 + CODE=$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$SERVER_PORT/health")
  47 + [ "$CODE" = "200" ] && { echo " raylight-server ready"; break; }
  48 +done
  49 +curl -s "http://127.0.0.1:$SERVER_PORT/health"; echo
... ...
raylight/scripts/workflows/fl2va_api.json 0 → 100644
  1 +{"119": {"inputs": {"upscale_method": "nearest-exact", "megapixels": 1, "resolution_steps": 32}, "class_type": "ImageScaleToTotalPixels", "_meta": {"title": "Scale Image to Total Pixels"}}, "120": {"inputs": {"image": ["119", 0]}, "class_type": "GetImageSize", "_meta": {"title": "Get Image Size"}}, "123": {"inputs": {"samples": ["144", 0], "vae": ["122", 0]}, "class_type": "VAEDecodeAudio", "_meta": {"title": "VAE Decode Audio"}}, "124": {"inputs": {"samples": ["144", 0], "vae": ["121", 0]}, "class_type": "VAEDecode", "_meta": {"title": "VAE Decode"}}, "132": {"inputs": {"fps": 24, "bit_depth": 8, "images": ["124", 0], "audio": ["123", 0]}, "class_type": "CreateVideo", "_meta": {"title": "Create Video"}}, "144": {"inputs": {"add_noise": true, "noise_seed": 547879687678090, "guider": ["145", 0], "sampler": ["125", 0], "sigmas": ["143", 0], "latent_image": ["133", 1]}, "class_type": "XFuserSamplerCustomAdvanced", "_meta": {"title": "XFuser SamplerCustom Advanced"}}, "125": {"inputs": {"sampler_name": "res_multistep"}, "class_type": "KSamplerSelect", "_meta": {"title": "KSamplerSelect"}}, "145": {"inputs": {"ray_actors": ["142", 0], "conditioning": ["133", 0]}, "class_type": "RayBasicGuider", "_meta": {"title": "Basic Guider (Ray)"}}, "143": {"inputs": {"scheduler": "simple", "steps": 20, "denoise": 1, "ray_actors": ["142", 0]}, "class_type": "RayBasicScheduler", "_meta": {"title": "BasicScheduler (Ray)"}}, "114": {"inputs": {"image": "transparent_rgb_gaming_mouse.png"}, "class_type": "LoadImage", "_meta": {"title": "Load Image"}}, "135": {"inputs": {"value": 2}, "class_type": "PrimitiveFloat", "_meta": {"title": "Float (duration)"}}, "134": {"inputs": {"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17", "values.a": ["135", 0]}, "class_type": "ComfyMathExpression", "_meta": {"title": "Math Expression"}}, "115": {"inputs": {"aspect_ratio": "1:1 (Square)", "megapixels": 0.4, "multiple": 32}, "class_type": "ResolutionSelector", "_meta": {"title": "Resolution Selector"}}, "122": {"inputs": {"vae_name": "minimax_h3_audio_vae_fp32.safetensors"}, "class_type": "VAELoader", "_meta": {"title": "Load VAE"}}, "121": {"inputs": {"vae_name": "minimax_h3_video_vae_fp16.safetensors"}, "class_type": "VAELoader", "_meta": {"title": "Load VAE"}}, "142": {"inputs": {"unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors", "weight_dtype": "default", "lora": null, "ray_actors_init": ["141", 0]}, "class_type": "RayUNETLoader", "_meta": {"title": "Load Diffusion Model (Ray)"}}, "130": {"inputs": {"clip_name": "qwen3vl_32b_minimax_h3_int8_convrot.safetensors", "type": "minimax", "device": "default"}, "class_type": "CLIPLoader", "_meta": {"title": "Load CLIP"}}, "92": {"inputs": {"filename_prefix": "video/MiniMax_H3", "format": "auto", "video": ["132", 0], "format.codec": "auto"}, "class_type": "SaveVideo", "_meta": {"title": "Save Video"}}, "141": {"inputs": {"ray_cluster_address": "local", "ray_cluster_namespace": "default", "GPU": 4, "ulysses_degree": 4, "ring_degree": 1, "cfg_degree": 1, "dp_degree": 1, "sync_ulysses": false, "clear_vram_after_sampling": false, "FSDP": true, "FSDP_CPU_OFFLOAD": false, "XFuser_attention": "TORCH_MATH", "skip_comm_test": false, "use_mmap": true}, "class_type": "RayInitializer", "_meta": {"title": "Ray Init Actor"}}, "133": {"inputs": {"prompt": "<picture 1> A completely ordinary man in a green field jacket, gray T-shirt, jeans, and sneakers stands alone on a quiet rooftop plaza at night. He looks confused and slightly nervous, breathing naturally as the wind moves his jacket. Medium-wide handheld shot, realistic body movement, cinematic anime rendering.\n\nHe notices a plain spear lying beside him, quickly grabs it with both hands, steps forward, rotates his torso, and throws it with all his strength toward the right side of the frame. The camera rapidly arcs around him and pushes closer during the throw. At the exact moment the spear leaves his hand, dozens of enormous circular golden portals ignite behind him one after another, expanding across the entire sky in layered depth. Each portal contains a different spear, sword, axe, or polearm aimed in the same direction.\n\nThe thrown spear becomes wrapped in brilliant gold-white energy and streaks forward with a powerful shockwave. The weapons inside the portals immediately launch after it in a massive synchronized barrage, filling the air with glowing trails, sparks, and swirling magical particles. The ordinary man remains visibly shocked by what he has just summoned, his arm still extended from the throw. Dramatic low-angle camera, strong wind, clothing and hair reacting to the blast, intense golden illumination against the dark blue night, smooth continuous motion, clear transformation from mundane reality into overwhelming mythic spectacle.", "clip": ["130", 0], "vae": ["121", 0], "width": ["115", 0], "height": ["115", 1], "length": ["134", 1], "first_frame": ["114", 0]}, "class_type": "MiniMaxH3ImageToVideo", "_meta": {"title": "MiniMax H3 Image to Video"}}}
0 2 \ No newline at end of file
... ...
raylight/scripts/workflows/ref2va_api.json 0 → 100644
  1 +{"121": {"inputs": {"samples": ["151", 0], "vae": ["120", 0]}, "class_type": "VAEDecodeAudio", "_meta": {"title": "VAE Decode Audio"}}, "122": {"inputs": {"samples": ["151", 0], "vae": ["119", 0]}, "class_type": "VAEDecode", "_meta": {"title": "VAE Decode"}}, "131": {"inputs": {"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17", "values.a": ["132", 0]}, "class_type": "ComfyMathExpression", "_meta": {"title": "Math Expression"}}, "132": {"inputs": {"value": 5}, "class_type": "PrimitiveFloat", "_meta": {"title": "Float (Duration)"}}, "115": {"inputs": {"aspect_ratio": "16:9 (Widescreen)", "megapixels": 0.4, "multiple": 32}, "class_type": "ResolutionSelector", "_meta": {"title": "Resolution Selector (Size)"}}, "136": {"inputs": {"ref_image_size": "match", "clip": ["128", 0], "vae": ["119", 0], "audio_vae": ["120", 0], "prompt": ["138", 0], "width": ["115", 0], "height": ["115", 1], "length": ["131", 1], "ref_images.ref_image_0": ["137", 0], "ref_images.ref_image_1": ["139", 0]}, "class_type": "MiniMaxH3ReferenceToVideo", "_meta": {"title": "MiniMax H3 Reference to Video"}}, "139": {"inputs": {"image": "pasted/image (1).png"}, "class_type": "LoadImage", "_meta": {"title": "Load Image"}}, "137": {"inputs": {"image": "pasted/image.png"}, "class_type": "LoadImage", "_meta": {"title": "Load Image"}}, "138": {"inputs": {"value": "Cinematic science-fiction industrial sequence, 16:9 widescreen, 10 seconds, premium photorealistic VFX, dramatic but clean white-and-warm-gold lighting.\n\n<Picture 1> defines the NVIDIA RTX 2000 Ada GPU hardware design used for both robots: matte-black GPU armor, rear blower fan, metal ventilation grille, PCIe gold contacts, and precise mechanical details.\n\n<Picture 2> defines the Raylight logo.\n\n<Picture 3> defines the MiniMax logo.\n\n<Picture 4> defines the dark neon technological environment, but transform it into a brighter futuristic weaving chamber with a luminous ivory-white background, subtle green energy lines, and warm golden reflections.\n\n<Picture 5> defines the appearance, body proportions, and symmetrical placement of the two transformer-like robots.\n\n[Shot 1] Two transformer-like robots built from the hardware features of <Picture 1> stand opposite each other beside a futuristic mechanical loom. Their full bodies are already transformed at the beginning of the shot. The camera begins in a high overhead wide shot and rapidly descends into a dynamic three-quarter top view.\n\nBoth robots immediately operate the loom using their articulated mechanical hands. Their fingers precisely grab, pull, cross, and tighten hundreds of glowing golden threads. The hand motions must clearly resemble real fabric weaving rather than welding, firing energy, or simply touching the cloth. Small gears rotate inside their wrists, finger joints rapidly articulate, spindles turn, and synchronized mechanical arms pass threads over and under one another.\n\nAs the robots continuously weave, a long horizontal sheet of luxurious metallic-gold fabric grows outward between them. The fabric is physically constructed strand by strand, with visible interlacing threads, realistic tension, ripples, folds, and shimmering woven texture. Do not generate a vertical banner.\n\nFrom 00:04 onward, the camera tracks smoothly above the expanding horizontal fabric while both robots continue weaving at the left and right sides of the frame. The weaving action remains visible throughout.\n\nFrom 00:06 onward, the Raylight emblem from <Picture 2> and the MiniMax emblem from <Picture 3> gradually emerge as two separate woven patterns inside the gold fabric. The logos are not projected, printed, pasted, floating, or glowing above the surface; their shapes are physically formed by contrasting threads integrated into the textile weave.\n\nAt 00:09, the camera reaches a symmetrical overhead hero composition showing both robots, their moving mechanical hands, the complete long horizontal gold fabric, and both clearly recognizable woven logos. End while the loom and robot hands are still moving.\n\nSound: precise metallic finger clicks, rotating gears, fast loom clacks, fabric tension sounds, and a low futuristic mechanical hum. No dialogue.\n\nAvoid: robot transformation sequence, fighting, weapons, laser beams, cloth appearing instantly, vertical fabric, sewing with needles, logos floating above the cloth, logos appearing as screens or projections, unreadable duplicate logos, extra robots, distorted hands, soft toy-like robots, dark underexposed image, camera cuts that hide the weaving action."}, "class_type": "PrimitiveStringMultiline", "_meta": {"title": "Input Text (Prompt)"}}, "151": {"inputs": {"add_noise": true, "noise_seed": 547879687678090, "guider": ["148", 0], "sampler": ["150", 0], "sigmas": ["149", 0], "latent_image": ["136", 1]}, "class_type": "XFuserSamplerCustomAdvanced", "_meta": {"title": "XFuser SamplerCustom Advanced"}}, "148": {"inputs": {"ray_actors": ["147", 0], "conditioning": ["136", 0]}, "class_type": "RayBasicGuider", "_meta": {"title": "Basic Guider (Ray)"}}, "150": {"inputs": {"sampler_name": "res_multistep"}, "class_type": "KSamplerSelect", "_meta": {"title": "KSamplerSelect"}}, "130": {"inputs": {"fps": 24, "bit_depth": 8, "images": ["122", 0], "audio": ["121", 0]}, "class_type": "CreateVideo", "_meta": {"title": "Create Video"}}, "149": {"inputs": {"scheduler": "simple", "steps": 20, "denoise": 1, "ray_actors": ["147", 0]}, "class_type": "RayBasicScheduler", "_meta": {"title": "BasicScheduler (Ray)"}}, "92": {"inputs": {"filename_prefix": "video/MiniMax_H3", "format": "auto", "video": ["130", 0], "format.codec": "auto"}, "class_type": "SaveVideo", "_meta": {"title": "Save Video"}}, "147": {"inputs": {"unet_name": "minimax_h3_ref2va_pruned_int8_convrot.safetensors", "weight_dtype": "default", "lora": null, "ray_actors_init": ["146", 0]}, "class_type": "RayUNETLoader", "_meta": {"title": "Load Diffusion Model (Ray)"}}, "128": {"inputs": {"clip_name": "qwen3vl_32b_minimax_h3_int8_convrot.safetensors", "type": "minimax", "device": "default"}, "class_type": "CLIPLoader", "_meta": {"title": "Load CLIP"}}, "119": {"inputs": {"vae_name": "minimax_h3_video_vae_fp16.safetensors"}, "class_type": "VAELoader", "_meta": {"title": "Load VAE"}}, "120": {"inputs": {"vae_name": "minimax_h3_audio_vae_fp32.safetensors"}, "class_type": "VAELoader", "_meta": {"title": "Load VAE"}}, "146": {"inputs": {"ray_cluster_address": "local", "ray_cluster_namespace": "default", "GPU": 4, "ulysses_degree": 4, "ring_degree": 1, "cfg_degree": 1, "dp_degree": 1, "sync_ulysses": false, "clear_vram_after_sampling": false, "FSDP": true, "FSDP_CPU_OFFLOAD": false, "XFuser_attention": "TORCH_MATH", "skip_comm_test": false, "use_mmap": true}, "class_type": "RayInitializer", "_meta": {"title": "Ray Init Actor"}}}
0 2 \ No newline at end of file
... ...