Files
oak-editor/docs/zh/render-process-isolation-plan.md
T

281 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 渲染独立进程化 — 实现计划
> **状态**:实施中(阶段 0–5 已完成,阶段 6 可选优化未做)
> **分支**`feat/render-process-isolation`
> **范围**:把视频帧渲染拆到独立进程,主进程通过共享内存 + stdio 调度多个渲染 worker,全程无锁。
---
## 1. 背景(为什么做)
OakOlive 分叉,Qt6/C++17 视频编辑器)当前是**单进程**架构:所有渲染在主进程的后台
`QThread` 里完成(`app/render/rendermanager.cpp``video_thread_` / `audio_thread_` /
`waveform_threads_` 等),通过 `RenderManager::RenderFrame()``RenderThread` 队列 →
`RenderProcessor::Process()` 的 ticket 异步管线工作。
把渲染留在主进程有三个问题:
1. **崩溃传染** —— OFX 第三方插件(0.3 里程碑的核心目标“任意 OFX 插件加载不崩溃”)一旦崩溃,会带走整个编辑器,丢失未保存的工作。
2. **难以横向扩展** —— GPU 上下文、解码器缓存都绑在一个进程里,无法利用多核/多 GPU 并行。
3. **预渲染受限** —— 预渲染窗口(见 `TODO.md` 的 LRU 预渲染计划)受单进程资源约束。
**目标**:把**视频帧渲染**(节点图遍历 + GPU 合成 + OFX 插件 + 颜色变换,即 `RenderProcessor`
的视频路径)拆到**独立的渲染进程**。主进程作为调度器,通过**共享内存 + stdio** 与**多个**渲染
worker 通信。硬性要求**无锁**:跨进程数据交换走预分配的共享内存 slot 池 + SPSC 环形索引队列,
控制平面走 stdio 上的换行分隔消息。
### 1.1 已确认的范围决策
| 维度 | 决策 |
|---|---|
| **拆分范围** | 仅**视频帧渲染**。音频/波形/dry-run 暂留主进程。→ worker 链接 OpenGL / OCIO / OpenImageIO / OFX**不**链接 UIWidgets)。 |
| **GPU 上下文** | 每个 worker **自建 offscreen `QOpenGLContext`**,渲染后 `DownloadFromTexture` 到共享内存里的 CPU 帧;主进程只负责显示上传。 |
| **素材输入** | **主进程解码**(复用现有 `DecoderCache`),把解码后的原始帧经共享内存喂给 worker。→ worker **不**链接 FFmpeg。 |
| **图同步** | **全量序列化**整个节点图(复用 `ProjectSerializer`),架构预留增量通道。 |
| **帧回传** | **固定 slot 池 + 无锁环形队列**(按最大分辨率预分配)。 |
| **控制协议** | **纯文本 NDJSON**(每行一条 JSON),便于 `cat`/`tee` 调试、手工注入测试。大块图数据走临时文件传路径。 |
| **落地策略** | **分阶段**,每步可编译可验证,旧的进程内渲染保留为默认,用开关切换。 |
---
## 2. 现有架构锚点(复用,不重写)
| 关注点 | 文件 / 符号 |
|---|---|
| 渲染调度/线程池 | `app/render/rendermanager.{h,cpp}``RenderManager``RenderThread` |
| 视频渲染核心 | `app/render/renderprocessor.{h,cpp}``RenderProcessor::Process()``GenerateTexture/GenerateFrame` |
| 渲染抽象 | `app/render/renderer.h``app/render/opengl/openglrenderer.{h,cpp}``Init()``PostInit()``DownloadFromTexture` |
| 异步票据 | `app/render/renderticket.{h,cpp}``RenderTicket``RenderTicketWatcher``Finish(QVariant)` |
| 图复制/增量更新(IPC 协议蓝本) | `app/render/projectcopier.{h,cpp}``QueuedJob` 枚举、`ProcessUpdateQueue()` |
| 全量序列化 | `app/node/project/serializer/serializer*.{h,cpp}``ProjectSerializer::Save/Load``LoadType::kProject` |
| 自动缓存协调 | `app/render/previewautocacher.{h,cpp}` — 票据的实际消费者 |
| 帧内存(单段连续 buffer) | `app/codec/frame.{h,cpp}` + `app/render/framemanager.h``data_`/`linesize_`/`allocated_size()` |
| 帧消费/显示 | `app/widget/viewer/viewer.cpp``SetDisplayImage()``ticket->Get()` |
| 进程入口 | `app/main.cpp``QSurfaceFormat` 设置(OpenGL 3.2 core)、`AA_ShareOpenGLContexts` |
| 构建 | 根 `CMakeLists.txt``app/CMakeLists.txt``add_executable(olive-editor ...)` + `libolive-editor` OBJECT 库 |
**关键观察**
- `RenderProcessor::Process()` 已是无状态静态入口,参数全在 `ticket->property(...)` 里。这是进程边界的天然切割点。
- `Frame` 的数据是**单段连续 malloc**`FrameManager::Allocate`),`linesize` 为步长 → 可直接 memcpy 进/出共享内存 slot。
- `OpenGLRenderer::Init()`(无参版)已能自建 `QOffscreenSurface` + `QOpenGLContext``PostInit()` 使其 current —— worker 直接复用。
- 项目原先**完全没有** QSharedMemory / QLocalSocket / mmap / shm_open / 环形缓冲 → 全部 IPC 原语需新建。
- worker 做 GPU 渲染但不解码 → `RenderProcessor::ProcessVideoFootage()`(当前直接调 `DecoderCache`)在 worker 侧必须改为**从主进程推入的输入帧取数据**,这是关键重构点(阶段 4)。
---
## 3. 目标架构
```
┌─────────────────── 主进程 (olive-editor) ───────────────────┐
│ Viewer / PreviewAutoCacher │
│ │ GetSingleFrame() │
│ ▼ │
│ RenderManager (调度器) │
│ ├─ DecoderCache ← 解码原始素材帧 │
│ ├─ RenderWorkerPool ← 新增 │
│ │ ├─ WorkerProcess #0 (QProcess + stdio + SHM) │
│ │ ├─ WorkerProcess #1 │
│ │ └─ ... │
│ └─ ProjectSerializer ← 全量图快照 │
└─────────────────────────────────────────────────────────────┘
stdio (控制平面: NDJSON, 每行一条 JSON 消息)
SHM (数据平面: 输入素材帧 slot 池 + 输出帧 slot 池, 无锁环形索引)
┌──────────────── 渲染进程 (olive-render-worker) ×N ───────────┐
│ workermain: 读 stdin NDJSON 控制循环 │
│ ├─ 反序列化节点图 (ProjectSerializer::Load) │
│ ├─ offscreen QOpenGLContext + OpenGLRenderer │
│ ├─ RenderProcessor (视频路径; ProcessVideoFootage 改为 │
│ │ 从输入 SHM slot 取帧, 不再直接解码) │
│ └─ DownloadFromTexture → 写输出 SHM slot → 发 frame_ready │
└─────────────────────────────────────────────────────────────┘
```
### 3.1 无锁 IPC 设计
**控制平面(stdio**worker 的 stdin/stdout**纯文本 NDJSON**——每条消息一行
compact `QJsonObject``\n` 结尾。仅承载低频控制流量(握手、提交任务、取消、关闭)。
纯文本便于 `cat`/`tee` 抓管道调试、手工注入测试;单读单写天然无锁。诊断信息走 stderr,
绝不污染 stdout 控制通道。**大块图数据走临时文件**:`load_graph` 不在行内塞字节,主进程把
序列化图写临时文件,消息只带路径 `{"type":"load_graph","path":"/tmp/xxx.ove"}`
**数据平面(共享内存)**:每个 worker 一段共享内存,封装在 `SharedMemoryRegion`
POSIX `shm_open`+`mmap` / Windows `CreateFileMapping`+`MapViewOfFile`)。布局由
`FrameSlotPool` 管理:
- **两个 SPSC 环形队列**`SpscRingBuffer`)的原子游标(`std::atomic<uint32_t>` head/tail
`memory_order_acquire/release`):`free_ring`(空闲 slot 索引)和 `ready_ring`(已填充 slot
索引)。每个环单生产者单消费者 → 无需互斥锁。
- **定长 slot 数组**:按最大分辨率(如 8K RGBA half)预分配的等长槽,外加每槽
`FrameSlotMeta`width/height/format/linesize/timestamp 等 POD)。
- **所有权靠索引转移**:填充方 `Acquire()`(从 free 环弹出)→ 写 meta+像素 → `Publish()`
(压入 ready 环);消费方 `Consume()`(从 ready 环弹出)→ 读 → `Release()`(压回 free 环)。
环满即天然背压,无需额外锁。
一个 pool 建模单向帧流。输出方向(worker→主)放渲染结果;输入方向(主→worker)放解码素材。
---
## 4. 分阶段实现计划
> 每个阶段都能独立编译、独立验证。前期阶段不改变现有行为(进程内渲染仍是默认),
> 用开关切到多进程路径,最后再切默认。
### ✅ 阶段 0:IPC 基础设施(已完成)
新增 `app/render/ipc/` 模块:
- `spscringbuffer.h` —— header-only`std::atomic` 游标的单生产者单消费者环形索引队列,POD,可直接放共享内存。
- `sharedmemoryregion.{h,cpp}` —— 跨平台共享内存段封装(POSIX `shm_open`+`mmap` / Windows `CreateFileMapping`+`MapViewOfFile`)。直接用原生 API 而非 `QSharedMemory`(后者带隐式信号量与引用计数,不适合大帧)。
- `frameslotpool.{h,cpp}` —— 在共享内存段上布局两个环 + 定长 slot 池;提供 `Acquire/Publish/Consume/Release``FrameSlotMeta`
- `ipcmessage.{h,cpp}` —— NDJSON 控制消息编解码(`WriteMessage`/`ReadMessage` + 各类型的 `ToJson/FromJson`)。
**控制消息类型**NDJSON`type` 字段区分):`handshake``load_graph`(图临时文件路径)、
`render_frame`node-uuid、time、vparams)、`frame_ready`(输出 slot 索引、ticket-id)、
`cancel`ticket-id)、`shutdown``error`。预留 `graph_update` 增量类型(阶段 6 实现)。
**测试**`tests/gtest/render_ipc_test.cpp`Google Test):
- 环形队列:基础语义 + 回绕 + **并发 200 万值** FIFO 无丢失无重复。
- slot 池:单线程握手 + 耗尽/回填 + **并发 20 万帧**数据完整性。
- NDJSON:类型往返 + 逐字节半包 + 畸形行跳过 + 错误类型拒绝。
> 注意:`SpscRingBuffer` 内部数组访问器命名为 `slot_array()` 而非 `slots()`,以规避 Qt 的 `slots` 宏。
### ✅ 阶段 1:worker 可执行目标(已完成)
- `app/CMakeLists.txt` 新增 `add_executable(olive-render-worker ...)`,复用 `libolive-editor` OBJECT 库 + `olive-version-obj`,与 `olive-gtest` 同款链接方式。
- 新增 `app/render/worker/workermain.cpp`:用 **`QGuiApplication`**(非 `QApplication`,无 Widgets;也非纯 `QCoreApplication`,因为需要平台 GL 集成)。
- 安装与主进程一致的 `QSurfaceFormat`OpenGL 3.2 core24 位深度),设置 `AA_UseDesktopOpenGL` / `AA_ShareOpenGLContexts`
- 当前行为:`OpenGLRenderer::Init()` + `PostInit()` 建 offscreen GL 上下文 → 校验 `context()->isValid()` → 在 stdout 打一行 NDJSON 握手(含实际 GL 版本)→ 干净退出。
- **链接说明**:当前用全量 `OLIVE_LIBRARIES`(含 Widgets/FFmpeg),裁剪 UI-only 依赖留到后续阶段。
**验证结果**worker 在默认平台与 `-platform offscreen` 下均成功输出
`{"gl_major":3,"gl_minor":2,...,"type":"handshake"}`stdout 仅一行合法 JSON,退出码 0。
### 阶段 2:worker 主循环 + 单帧渲染回路(基础回路已接入)
-`workermain.cpp`:读 stdin NDJSON 控制消息循环,支持 `handshake` / `load_graph` /
`render_frame` / `cancel` / `shutdown`,启动握手仍保持 stdout 单行 NDJSON。
-`load_graph``ProjectSerializer::Load(LoadType::kProject)` 反序列化出 `Project` + 节点图;
`ProjectSerializer::LoadData` 现在暴露旧 ptr token → 新 `Node*` 映射,worker 用它解析
`render_frame.node`。旧版 serializer 已有的 node UUID 映射也保留兼容。
-`render_frame` → 构造本地 `RenderTicket`(参数从消息填 property,复刻
`RenderManager::RenderFrame` 的关键 `setProperty`)→
`RenderProcessor::Process(ticket, renderer, decoder_cache=nullptr, shader_cache)`
- ✅ 先**不**接输入素材:渲染结果为 `FramePtr` 后写入输出 `FrameSlotPool` slot
`FrameSlotMeta`,发布 slot 并回 `frame_ready`
- ✅ 临时测试驱动启动 1 个 worker,加载最小 SolidGenerator 项目,主进程从输出 slot
读回 64x64 F32 RGBA 帧并校验元数据与像素非零。待固化为自动化测试。
**验证结果**
- `cmake --build build --target olive-render-worker olive-gtest -j2` 通过。
- `QT_QPA_PLATFORM=offscreen build/tests/gtest/olive-gtest --gtest_filter='SpscRingBuffer*:*FrameSlotPool*:*IpcMessage*:*ProjectSerializer*' --gtest_brief=1`
通过,11 个测试全部通过。
- 非沙箱环境直接运行 worker 通过,输出合法启动握手并退出码 0;工具沙箱内直接运行会以
134 退出,gdb/非沙箱复测确认不是 worker 代码路径崩溃。
- 有效共享内存 attach 测试通过:测试驱动创建 POSIX shm + `FrameSlotPool`worker attach 后
shutdown,退出码 0。
- 单帧渲染闭环测试通过:临时驱动加载 SolidGenerator,发送 `render_frame`,收到
`frame_ready`;输出 slot 元数据为 `id=1001, 64x64, fmt=3, channels=4, bytes=65536`
前 4KB 像素存在非零数据。
### 阶段 3:主进程 WorkerPool + 调度器接线(单 worker MVP 已接入)
- ✅ 新增 `app/render/renderworkerpool.{h,cpp}`
- 当前 MVP 用后台 `QThread` 持有任务队列,每个任务启动 1 个 `olive-render-worker`
建立输出 SHM 段 + stdio 管道。
- `SubmitFrame(RenderTicketPtr, RenderVideoParams)`:写全量图快照临时文件 →
发送 `handshake` / `load_graph` / `render_frame` → worker 回 `frame_ready` 后从输出 slot
拷出 `FramePtr``ticket->Finish(...)`。对上层 `RenderTicketWatcher`/`Viewer` 保持透明。
- 当前仅支持普通视频 `ReturnType::kFrame`;素材输入仍按阶段 4 处理,失败或不支持时回退旧路径。
-`RenderManager` 增加 `kMultiProcess` backend 分支(与 `kOpenGL` 并存),开关开启且
WorkerPool 接受任务时 `RenderFrame()``RenderWorkerPool`
-`Config` 增加 `RenderProcessIsolationEnabled`,默认 `false`,默认仍走进程内 `kOpenGL`
- 待补:常驻 N worker、忙闲/负载派发、崩溃重启与重派、Viewer 开关实测。
**验证结果**
- `cmake --build build --target olive-render-worker olive-editor -j22` 通过。
- `QT_QPA_PLATFORM=offscreen build/tests/gtest/olive-gtest --gtest_filter='SpscRingBuffer*:*FrameSlotPool*:*IpcMessage*:*ProjectSerializer*' --gtest_brief=1`
通过,11 个测试全部通过。
- 非沙箱环境 `printf '{"type":"shutdown"}\n' | build/app/olive-render-worker` 通过,输出合法
handshake。
### 阶段 4:素材输入解耦(关键重构)
-`Decoder` 增加 CPU 帧接口 `RetrieveVideoFrame()`FFmpeg 路径输出 packed RGBA CPU frameOIIO 路径返回 still frame CPU buffer。
-`RenderWorkerPool` 派发前 dry-run 遍历当前帧素材输入,使用主进程 `DecoderCache` 预解码,成功后写入 main→worker 输入 `FrameSlotPool`
-`render_frame` 支持有序 `input_slots` 列表;worker 按顺序 consume/release`RenderProcessor::ProcessVideoFootage()` 从 slot 上传纹理并继续原有色彩管理。
- ✅ 没有输入 slot 且 worker 无 `DecoderCache` 时,素材节点安全跳过,不再空指针崩溃。
- ✅ worker 和 `RenderProcessor` 都会校验 IPC 输入 slot 范围,畸形 `input_slots` 不会越界访问共享内存。
- ✅ 真实素材 CPU 预解码已由 `CodecDecoder.RetrieveVideoFrameFromDemoMp4` 覆盖;IPC slot 顺序由
`IpcMessage.TypedRoundTrip`/`FrameSlotPool` 回归覆盖;CPU 预解码失败时 `RenderWorkerPool::SubmitFrame()`
拒绝接管,`RenderManager::RenderFrame()` 自动回退进程内路径。
### 阶段 5:多 worker、取消、健壮性
-`RenderManager::RemoveTicket()` 已转发到 `RenderWorkerPool`,多进程渲染 ticket 可被统一取消。
-`RenderWorkerPool::RemoveTicket()` 支持移除尚未开始的排队任务,并同步清理对应图快照临时文件。
- ✅ 正在执行的 worker 任务会标记 `RenderTicket` 取消,并通过保存的 worker PID 终止对应进程,避免跨线程直接操作 `QProcess*`;由 pool 执行线程收尾 `Finish()`
-`RenderWorkerPool` 现在使用共享队列 + 多执行循环,worker 数量按 `QThread::idealThreadCount() - 2`,并发消费 `PreviewAutoCacher`/Viewer 提交的帧任务。
- ✅ worker 启动、握手、`load_graph``render_frame` 或等待 `frame_ready` 失败时,未取消 ticket 会重建 SHM/input slots 并重启新 worker 重派一次。
- ✅ worker 响应超时/提前退出的日志包含 `QProcess` 状态、退出状态、退出码与进程错误,便于区分崩溃、正常退出和启动/管道错误。
- ✅ OFX/插件基础路径由 `PluginSmoke``PluginSupport``PluginOfxMisc``PluginRenderPipeline`
回归覆盖;worker 启动/握手/加载图/渲染等待失败均按异常 worker 退出路径重试一次,覆盖崩溃隔离的调度语义。
- 背压:slot 池/环满时调度器暂缓派发(环满即天然背压)。
### 阶段 6:图增量同步(可选优化)
-`ProjectCopier``QueuedJob`kNodeAdded/kEdgeAdded/kValueChanged…)编码成 `graph_update` 消息,worker 侧等价 `ProcessUpdateQueue`,省去每次全量序列化。
- 阶段 0 已预留消息类型,此处填实现。
---
## 5. 文件清单
**新增**
| 文件 | 阶段 | 状态 |
|---|---|---|
| `app/render/ipc/spscringbuffer.h` | 0 | ✅ |
| `app/render/ipc/sharedmemoryregion.{h,cpp}` | 0 | ✅ |
| `app/render/ipc/frameslotpool.{h,cpp}` | 0 | ✅ |
| `app/render/ipc/ipcmessage.{h,cpp}` | 0 | ✅ |
| `app/render/ipc/CMakeLists.txt` | 0 | ✅ |
| `tests/gtest/render_ipc_test.cpp` | 0 | ✅ |
| `app/render/worker/workermain.cpp` | 1/2 | ✅ 基础主循环 |
| `app/render/renderworkerpool.{h,cpp}` | 3 | ✅ 单 worker MVP |
**修改**
| 文件 | 阶段 | 状态 |
|---|---|---|
| `app/render/CMakeLists.txt`(加 `add_subdirectory(ipc)` | 0 | ✅ |
| `tests/gtest/CMakeLists.txt`(注册 ipc 测试) | 0 | ✅ |
| `app/CMakeLists.txt`(新增 `olive-render-worker` target | 1 | ✅ |
| `app/node/project/serializer/serializer*.{h,cpp}`(暴露加载映射供 worker 查节点) | 2 | ✅ |
| `app/render/rendermanager.{h,cpp}``kMultiProcess` 分支 + WorkerPool 接线) | 3 | ✅ 单 worker MVP |
| `app/codec/decoder.{h,cpp}` + `app/codec/{ffmpeg,oiio}`CPU frame 解码接口) | 4 | ✅ 首版 |
| `app/render/renderworkerpool.{h,cpp}`(主进程预解码并填 input slot | 4 | ✅ 首版 |
| `app/render/renderprocessor.cpp``ProcessVideoFootage` 改取输入 slot | 4 | ✅ 首版 |
| `app/config/config.cpp`(多进程开关默认值) | 3 | ✅ 默认关闭 |
---
## 6. 验证方式(端到端)
1. **IPC 单元测试**:多线程压测 SPSC 环形队列 + slot 池,确认无锁正确性(无丢失/重复/数据竞争,可配 TSan)。— 阶段 0 已覆盖。
2. **像素一致性回归**:同一项目同一帧,`kOpenGL`(进程内)vs `kMultiProcess` 逐像素对比应一致(先纯生成节点,再含真实素材)。
3. **运行实测**:开关打开后启动编辑器,播放/拖拽时间线,Viewer 正常无卡死;`ps` 能看到 `olive-render-worker` 子进程,主进程退出时子进程随之退出。
4. **崩溃隔离**:人为让 worker 段错误(或加载会崩的 OFX 插件),确认主进程存活、WorkerPool 自动重启并恢复渲染。
5. **性能**:多 worker 预渲染窗口吞吐 vs 单进程基线对比。
---
## 7. 开放问题(实现时定)
- SHM slot 尺寸/数量的默认值(按硬件分档,参考 `TODO.md` 同款问题)。
- worker 数默认值(CPU/GPU 数推导)。
- OFX 插件在多 worker 下的句柄/许可证并发是否有限制。
- worker 链接集裁剪时机:何时安全移除 Widgets/FFmpeg 依赖(依赖阶段 4 素材解耦完成)。