[Docs] (docs/Disaggregated, MG_Remote, MG_Remote/Client, MG_Remote/Protocol, MG_Remote/Server, MG_Remote/Transport, MG_Remote/Wire): shorten long comments and move their design arguments into docs

This commit is contained in:
2026-10-15 21:53:39 -04:00
parent 37ecba200b
commit 23ee8bdb49
63 changed files with 930 additions and 3343 deletions
@@ -64,3 +64,13 @@
| `MGPDrawInfo` / `MGPDrawRange` | `DrawVbo`;multi-draw 范围与用户索引在变长尾,indirect 的 `DrawCount` 由 client 解析 |
每条 `kVarTail` 的 `set_*` 带 `ContentHash`,hash 未变就不发(§5.4)。
### 4.2 split 下的诚实规则
同一地址空间里 host 指针能解引用、以 host 地址为 `Offset` 的 blobref 能解析、persistent map 返回的地址能直接用;`inproc` 若借这些捷径变绿,`spawn` 无法复现。所以 split 下编解码一律按跨进程规则执行,`inproc` 也不例外(`MG_Remote/Wire/PipeWireCodec.h`):
1. encoder 把 `MGHostSpan::Ptr` 恒写 `nullptr`,字节放进 `SEG_STAGE`,由 `Seg` / `Offset` 指名。
2. `MGPBlobRef` 要么完整声明(真实 `Seg`、段内 `Offset`、非零 `Size`),要么三个字段全零。`Seg` 或 `Offset` 非零而 `Size == 0` 正是 monolith emitter 的形状(`Offset` 是 host 地址),split 下按损坏拒绝,不能读成"无 blob",否则未改造 emitter 的字节会被静默丢弃。
3. decoder 对 `Ptr != nullptr`、内容记录的 `Size == 0`、`Size != 0` 而 `Seg == kSegNone`、`Offset + Size` 越过段尾一律报 `ProtocolCorruption`(`CheckBlobIsHonest`、`RequireDeclaredBlob`、`CheckHostSpanIsHonest`)。记录是否带内容由 payload 的其他字段决定,所以"内容记录须有 blob"由 decoder 的逐 op 分支调用 `RequireDeclaredBlob` 检查。
4. T2 档下 `MGPipeApplyMapPersistent` 在 split 下恒返回 `nullptr`(§12),client 不拿 server 的地址。
5. `MOBILEGL_IPC_AUDIT=1` 时 server 把已退休记录的 `SEG_STAGE` 字节填成 `0xDD`,apply 之后仍持有指针的实现下一帧会读到 `0xDD`。
@@ -31,6 +31,16 @@
- **唯一允许同步 ack 的入口是 `glBufferStorage`**:`ResourceRespecify` 的 `kNeedsAck` 由逐记录谓词收窄到"不可变 **buffer**"(D-A2),纹理 OOM 在 monolith 里本来就推迟到 sync 时刻。respecify 带逐 level 作用域与"只换元数据"的形式,不误丢已接受的待上传。
- 其余错误一律晚到,走有序的 `OnGlError`。
#### 8.3.1 server 日志的转发队列
`MG_Remote/Transport/LogForward.h` 的 `LogForwardChannel`:`Offer` 只在通道互斥锁下拷贝一行入队,唯一的发送线程按入队顺序写帧,写 socket 时不持锁,所以 FATAL 以下的行花在转发上的只是一次加锁加一次拷贝(`LogForwardCounters::maxOfferNs`),与对端是否在读无关。server 自己的日志文件在转发之前写(`Log.cpp`),总有全部行。
- DEBUG / INFO / WARN 有损:使有损字节超过 `lossyQueueBytes` 的行丢弃并计数;缺口闭合时(下一条放得下的 ≤WARN 行、`LogFlush` ack 或 `Close`)通道自己插一条 WARN `LogForward{Dropped}`,写明条数、字节与起始时间。
- ERROR 无损、限速:令牌桶(`errorBurstLines` 起步、`errorLinesPerSecond` 补充),超速的行按序排队延迟、不丢;排队的错误字节超过 `errorQueueBytes`(有损上限的 8 倍)时不再入队,由排在下一个能入队条目之前的一条 ERROR `LogForward{Coalesced}` 代替。
- FATAL = ERROR + 等待:最多等 `fatalFlushMs`,直到发送线程写出它及之前的一切;一次等待超时后,在发送线程写出任何东西之前,后续 FATAL 不再等(`MGLOG_F` 也标记可存活的情形,可能连发)。`Log.cpp` 在日志锁外交出 FATAL 行。
- `LogFlush` ack(`OfferControlFrame`)排在之前提交的每一行之后,`MGPipeSyncPeerLog` 的「ack 到达时之前的行都已在文件里」因此成立;它排队期间发送线程不限速。
- `Close()` 不再收行,给队列 `closeDrainMs` 不限速发完;发送线程仍卡在写里时调用属主提供的 `unblock`(`ServerMain`:对控制 socket `shutdown(2)`),以便 join 而不是泄漏线程,剩余条目计为 abandoned。
- 只有 TCP server 形态转发(`ServerMain.cpp`,`MOBILEGL_IPC_LOG_FORWARD`);unix spawn server 与 inproc apply 线程自己写 `<base>.server.log`。
### 8.4 纹理重铸:字节全在 server 手里,没有拉取(P9 W2 撤销原「唯一的新停顿类」D-B6)
原设计假设 server 不保留纹素、重铸要把 level 从 client 拉回来。P5c 之后 server 有 `StagedTextureStore`,GPU 写过的层本来就在 GPU 图像里——**两个后端的重铸都不需要 client 的任何字节**:
+18
View File
@@ -20,10 +20,28 @@
一页 4 KiB,每个争用组一条 cache line,按写者分组(P6 lk):producer 线 `cmdHead` / `submittedSeq`;consumer 线两个 tail;四个 consumer 写的水位收成 `Progress`(`appliedSeq` / `retiredSeq` / `completedFrameSerial` / `presentAckSerial`);最后是 epoch、generation、park 标志与 `eventRingFull` / `eventDropped`。游标是单调字节计数、2 的幂掩码、永不重置;pad 记录处理 wrap;不可能的头 → `Fatal{ProtocolCorruption}`。
#### 11.2.1 五个水位
| 水位 | 由谁推进 | 含义 |
|---|---|---|
| `submittedSeq` | producer,发布之后 | 诊断量(client 领先 server 多少),没有人等它;放在 producer 的 cache line 上,`LinkProgress` 因此单写者 |
| `appliedSeq` | consumer,每 apply 一条记录 +1 | verb barrier 与每个 reply 等待读它,所以不得批量:批量会让等待方卡在已完成的工作上,或在未完成的工作上醒来 |
| `retiredSeq` | consumer,记录引用的 `SEG_STAGE` 字节用完后 | staging 分配器只在它之后回收;不超过 `appliedSeq` |
| `completedFrameSerial` | server,present 完成时 | 回收与老化等它;比 `appliedSeq` 落后一个 GPU 深度,不得混用 |
| `presentAckSerial` | server,归还 present credit 时 | present 节流等它;唯一限制延迟而非字节的背压 |
规则(`SessionRings.h` 的 `Watermark` 是唯一实现):所有等待用 `>=`,两侧都可能一次前进多步,`==` 会错过唤醒。水位只许晚发布、不许早发布:晚只是多等,早会让等待方静默使用尚未发生的工作,环上没有校验能发现;除 `appliedSeq` 外都可惰性发布,回退是 `Fatal{ProtocolCorruption}`。wrap 填充(`kRecPad`)不推进 seq:seq 就是 reply slot 的 id,只有一侧计数填充时两侧序号每次 wrap 偏一、静默读到别的调用的答案,所以两侧都在计数前跳过它(`RingConsumer::Pop`)。
### 11.3 双向 doorbell(`Doorbell.h`)
两个方向都是"先自旋、再置 parked、再阻塞",发布方仅当对端 parked 时敲;丢失唤醒由两个 `seq_cst` fence 关闭。`CondVarDoorbell`(同进程,带 `Kill()`)与 `SocketDoorbell`(跨进程,对端关闭即死亡检测)。stream 数据面上门铃是本地条件变量,由读线程敲。
#### 11.3.1 丢失唤醒为什么靠两道 fence
等待方:置 park 标志 → `std::atomic_thread_fence(seq_cst)` → 重测条件(`Doorbell::Wait`);通知方:发布水位 → 同样的 fence → 读 park 标志(`NotifyIfParked`)。两道 fence 都在 seq_cst 全序里、必有先后,按 [atomics.order] 至少一侧看得见另一侧的写,所以标志自身的读写可以是 relaxed。
只把标志的 store / load 改成 seq_cst 不够:Dekker 论证要求四个访问都在全序里,而水位发布是 release store(`RingProducer::Publish`)、条件重测是 acquire load。x86 上 release store 是普通 MOV,还停在 store buffer 里时,对标志的普通 MOV 读就可能读到 0,通知方于是不敲门铃,等待方在旧水位上永久 park;ARMv8 上 STLR→LDAR 是 RCsc,只是碰巧没事。所以两道 fence 都不可删,且 `NotifyIfParked` 必须在水位发布之后调用:fence 只约束它之前的访问。
### 11.4 控制面(`protocol.fbs`、`Framing.h`、`ITransport.h`)
- 一份 FlatBuffers schema:热路径是 `struct` 直接进 ring(与 POD 逐条 `static_assert` 对齐);罕见 / 变长 / 需演进的走控制连接的 `CtrlMsg`(`Hello`、`Welcome`、`Refuse`、`CapsSnapshot`、`SurfaceOp/SurfaceReply`、`SessionFault`、`LogLine` …),union tag 只追加,控制修订号钉在 `sha256(protocol.fbs)` 上。
@@ -14,6 +14,18 @@
T2 下 client 侧推送三件套:不做 map/unmap 命令对(payload 带 `hasLiveHostWrites`);在每个 validate 点按块(`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`,默认 64)推送可达的已映射 buffer,脏页追踪 + 哈希抑制只发变化的块(P5d);门 `PersistentCoherentMapScenario`。`mpr` 数的是每一次 `MapPersistent` 发射(铸成或拒绝都算),所以在 monolith 下也可断言。
### 12.1 采纳档的握手期结算
档在握手期、第一条 `map_persistent` 之前,由知道数据面的一侧结算(`MG_Remote/Transport/AdoptTier.h`)。T0 用不了从不是 `Fatal`,而是 T2 加一行:`MOBILEGL_IPC_ADOPT_TIER=0` 时是具名行,未设时只打 MGLOG_D,照样计数。
| 数据面 | 一侧 | knob 0 / 未设 | knob 1 |
|---|---|---|---|
| stream | 两端 | 只有 T2(不共享内存);knob 0 打 `Refuse{AdoptTierOnStream, "T0"}` | `Refuse{AdoptTierOnStream, "T1"}`,T2 |
| 共享段 | client | 在 `Hello` 的 `LinkTerms.adoptTier` 请求 T0(0;未设为 `kAdoptAskT0Default`);是否真跑 T0 在第一条 `map_persistent` 时读 `kCapAdoptT0`(`ClientSession::AdoptT0`) | `Refuse{AdoptTierClosed, "T1"}`,请求 T2 |
| 共享段 | server | 不看自己的 knob:按 client 的请求,在第一次原生 bind 时批准(`ServerSession::SettleAdoptT0AtBind`,之后的 `CapsSnapshot` 带 `kCapAdoptT0`)或具名说明原因 | 同左 |
T2 路径上的使用时检查 `Client::AdoptTierIsEmulate` 读回结算结果:已结算的会话照常走 T2;knob 0 / 1 下没有任何握手结算过的 `map_persistent`(无会话的调用方)具名中止为 `Fatal{UnimplementedAdoptTier}`,不静默当成 T2。
## 13. 回读、roundtrip 清单与验证
### 13.1 稳态零 roundtrip 与不可避免的阻塞点
@@ -10,6 +10,14 @@
- 线程:client v1 不加线程,编码在 GL 线程直接写 ring;server 有 io 线程与**终身持有原生 context** 的 `mgl-srv-apply`。全库无有效亲和性控制(Redmi 内核忽略 app 线程的 `sched_setaffinity`)。
- 拆机顺序:publish + server 排空 → 停 apply 线程 → 关传输 → client 排空编译池 → `MobileGL::Destroy()`(落地形状见 §17)。
### 14.1 apply 线程的亲和与自旋(`auto`)
`MOBILEGL_IPC_SERVER_AFFINITY=auto`(缺省)按会话对端(`Hello` 的 `DialMode`)与数据面选择 apply 线程的亲和与 park 前自旋(`MG_Remote/Server/ApplyThreadPolicy.h`,纯函数):同进程、fork 出的、拨入但走 stream 的对端 → 全部大核与 `MOBILEGL_IPC_SPIN_US`;拨入且走共享段的对端 → 一个核(`ReservedApplyCore`:多个 prime 核时取最低的;只有一个 prime 核时取另一个大核;否则不钉,绝不给唯一的 prime 核)与 `kConnectedPeerSpinUs`(除非显式设了 `MOBILEGL_IPC_SPIN_US`)。显式掩码或 `off` 对任何对端优先。
- 拨入的共享段 client 直接写 apply 线程自旋的那条环,两边又落在同两颗 prime 核上:Redmi 上共享段 23–26 fps、TCP 45–54,手工分开后 118。stream 数据面由 io 线程喂 apply 线程,apply 不在 client 的写上自旋(apply CPU 7.9 ms/帧,共享段 26.8),所以维持全部大核。
- 取最低而非最高的 prime 核:调度器把前台应用的热 GL 线程放在最高的 prime 核(44 次采样 43 次在 cpu7);apply 线程钉在那里 6.5 fps,钉在最低的 120 fps。
- 短自旋保留:立即 park(0)在所有量过的格子里都更慢(helper 路径 120 → 74 fps,shell 27 → 17)。
- 经 `ExternalClientHelper` 启动的 client 也被挡在保留核之外:supervisor 启动时公布该核(`ReservedApplyCoreForConfig`),broker 交给 helper;操作员给了掩码或 `off` 时不保留任何核。
## 15. 进程、EGL 与平台(P6 / P6.5 / P12)
### 15.1 启动与握手
@@ -19,6 +27,17 @@
- `Hello`(abi、后端类型、指纹、配置、令牌、`LinkTerms` 请求)→ `Welcome`(server 陈述的尺寸、段 / aux 名、`dataNonce`、真实 pid)或 `Refuse{code, detail, 对端值}`;握手路径零 abort。非 loopback 监听必须有令牌(≥16 字节、常量时间比较、数据面按 `dataNonce` 绑定到已认证的控制连接、fork 前认证;Ph,OQ-21 裁定不做 TLS)。
- TCP keepalive / 心跳失败**由传输层报告为挂断**,进 device-lost 闩;"闩绝不取自 apply 超时"不因此松动。冷启动期 server 发 `SurfaceProgress`,client 预算这段沉默(控制修订 2)。
#### 15.1.1 TCP supervisor 的预认证
TCP `--serve` 的 supervisor(`ServerMain.cpp` 的 `TcpSupervisor`,判定部分在 `PreAuthGate.h`)先认证、再把会话交出去(fork 子进程,或进程内线程);未认证的对端最多占一个有界的 pending 槽:
- 新连接在首帧完整之前是 pending。supervisor 在一个 poll 循环里同时从所有 pending 连接非阻塞地读,任何未认证对端都拖不住别人;只读到首帧末尾,之后的字节属于会话。
- 每个 pending 连接有 `MOBILEGL_IPC_PREAUTH_MS`(默认 2000)读完首帧,同时最多 `MOBILEGL_IPC_PREAUTH_MAX`(默认 8)个。队列按地址共享:满时新连接挤掉占槽最多的地址最老的那个;它自己的地址已占同样多时,被拒 `Busy` 的是它。
- Hello 在 supervisor 里认证、令牌先于指纹:未认证的对端只收到 `Refuse{Authentication}`,得不到本构建的 wire / build 指纹。只为已认证的 Hello 交出会话,并把这一帧交给会话。
- 失败(令牌错、首帧畸形、超时、静默占槽超过 `kPreAuthProbeGraceMs` 后关闭或被挤掉)记在对端地址上;满 `MOBILEGL_IPC_AUTH_BACKOFF_AFTER`(默认 5,0 = 关)次后该地址在 accept 时、读任何字节之前即被拒,窗口自 `MOBILEGL_IPC_AUTH_BACKOFF_MS`(默认 1000)起逐次翻倍、上限一分钟;该地址认证成功一次即清零。宽限期内无字节就关闭的端口探测、指向无活会话的 DataBind、退避本身发出的拒绝都不算失败。
- DataBind 经 hand-off 连同 nonce 转给活会话(会话比较 nonce);没有活会话时由 supervisor 具名拒绝。已认证的第二个 Hello 收到 `Busy`。
- 判定部分不含 I/O(`MG_Remote/Server/PreAuthGate.h`),由 `PreAuthGateTest` 覆盖;早于 `kAuthBackoffForgetMs` 的失败不再计入退避。
### 15.2 Android
- 交付链(spike A 已证):APK 唯一可 exec 的位置是 `lib/<abi>/`,server 以 `add_executable` + `lib*.so` 命名构建;从 `untrusted_app` 进程 `fork`+`execve` 同域、零 avc denial;`posix_spawn` 在 minSdk 26 不可用。一份共享库两个角色(stub `dlopen` + `dlsym("mobilegl_server_main")`)。
@@ -57,3 +76,12 @@ server 分配并以 dma-buf 描述符导出的颜色缓冲,让一个 client
| `SetDamageRegion` | `eglSetDamageRegionKHR`:本帧将画的区域 |
| `Flush` | 往图像里渲染的会话(X server 的 glamor)的 `glFlush` / `glFinish`:自上个边界用过的图像按 flush 的 fence 发布为已写、已读(隐式同步),发布后回答 |
| `NativeFence` | `EGL_ANDROID_native_fence_sync`:会话已提交工作完成时 signal 的 sync_file(写图像的会话同时做 `Flush` 的发布),经 aux socket 先于 reply 送达,`Format = kMGPNativeFenceFdFollows`;`Format = 0` 表示 server 给不出描述符,reply 发出时工作已完成 |
### 15.5 EGL 归属:apply 线程终身持有原生 context
split 下应用线程从不调用驱动的 `eglMakeCurrent`:`BackendObject_Remote` 的 EGL 虚函数调用 `ServerLoop.h` 的 `Server*` 转发函数,每个都经 `RunSurfaceControlFrame` 成为阻塞控制请求,由 `mgl-srv-apply` 对 server 私有的后端对象执行。
- 为什么是这条线程:DirectGLES 的 `IsBackendContextCurrentOnThisThread()` 与 `Managers.cpp` 的 `CanTouchGLNow()` 守卫答「否」时静默降级——fence 恒为已 signal、query 返回空句柄、`Present` 不建帧 fence(buffer pool 的回收水位不再前进)、persistent map 拒绝采纳。apply 线程终身持有 context,这些守卫在 server 上都答「是」。
- 原生 `eglMakeCurrent` 只在 apply 线程当前绑定的 tuple 真正改变时运行,去重分两层:apply 线程上的 `ServerLoop::ApplyMakeCurrent` 用 `ClassifyEglMakeCurrent` 与上次绑定的 tuple 比较,相同即 no-op、不重发 caps,不同则转发并重发 caps 快照,client 的 release-current 只记录(`ClientReleaseCount`)不转发;`BackendObject_DirectGLES::MakeEGLCurrent` 在有传输时,若目标 draw surface 已是本线程原生当前、且虚 tuple 正是那次绑定所为(或那次绑定是 surface 自己的创建),跳过原生调用。同一 surface 上换虚 context 仍原生重绑,因为 `MakeCurrent` 的失效针对的是变了的前端 context。只记一个 tuple:线程上同时只有一个 current context,A → B → A 是三次原生绑定。
- 在原生绑定可能已改变、或 tuple 指向的 context 可能已不存在的每个事件之后遗忘 tuple(`ServerLoop::ForgetCurrentTuple`):`ReleaseEGLResources`、释放它指向的 surface、surface(重)建(创建时原生绑定新 surface)、后端销毁。EGL 句柄值会复用;记住死 tuple 的代价是静默地没有 current context,遗忘的代价只是一次多余的转发。
- `ReleaseEGLResources` 与 `~BackendObject_DirectGLES` 必须阻塞:前者本身是控制请求,后者在 apply 线程退出前于该线程执行,`ServerLoop::Stop()` 等它结束;否则 `MobileGL::Destroy()` 会在 server 仍持有 context 时继续往下拆。
- 对照 `ServerLoopEglTest.MakeCurrentBindsNativelyOncePerContextAndNeverForwardsAClientRelease` 在 EGL 函数表处计数:surface 创建 + 两次相同 make-current + 一次 client release + release 后再绑定 = 一次原生 `eglMakeCurrent`、零次原生 release。