Skip to content

Latest commit

 

History

History
492 lines (395 loc) · 37.5 KB

performance.md

File metadata and controls

492 lines (395 loc) · 37.5 KB

性能:实测量级、已走通的手法、已证伪的方向

本文只记测过的数字和验证过的结论,用来避免把同一条路重新走一遍。 测量环境:Mac17,4(Apple Silicon)· macOS 26.5 (25F71) · 2026-09。 换机器或换系统版本后数字会变,但相互之间的量级关系是结构性的。

一、调用成本的量级

用 --duo-ax-bench 从签名后的 app bundle 运行得到(它需要辅助功能权限, 未签名的测试二进制拿不到,AX 调用会直接返回而不发 IPC):

操作 耗时 相对
SkyLight 查询(走 WindowServer,不经 App) 0.013ms 1×
AX 单个属性读(位置 / 尺寸 / 标题 / 角色),目标 App 空闲时 0.0–0.1ms ~8×
同上,目标 App 正忙时(例如刚被要求隐藏自己) 约 19ms ~1500×
CGWindowList 全量枚举(所有 App 所有窗口) 3.3ms ~250×
AX 取单个 App 的窗口列表(kAXWindowsAttribute) 18–25ms ~1500×

两条反直觉的结论,都直接决定了优化方向:

  1. 同一个调用的成本取决于对方忙不忙。 上表第二行和第三行是同一个 axPosition:目标 App 空闲时 0.1ms,正忙时 19ms,差 190 倍。批量折叠时 我们刚要求一批 App 隐藏自己,它们全都处在最忙的状态——在那之后对它们做 任何 AX 调用都要按「忙」的价格算。据此定下的规矩:能在批量操作开始前 做的读取,全部提前到预热阶段并发做完(那时 App 还空闲)。

  2. AX 本身不慢,慢的是「要一份列表」。 单属性读 0.1ms,可以随便调; 而 kAXWindowsAttribute 一次就 20ms,比把全系统窗口列一遍还贵 6 倍。 kAXChildrenAttribute(工具栏探测、红绿灯几何用它)是同一个量级。 优化的单位是「减少列表枚举的次数」,不是「减少 AX 调用的次数」。

  3. 时间花在等,不在算。 上面所有耗时都是 IPC 往返和目标 App 的 runloop 响应时间,与本机 CPU 快慢基本无关——所以 M1 上不会明显更慢,M5 上也不会 明显更快。用「换更快的机器」来解释或解决这里的性能问题是错的。

其它实测常数:

项目 耗时
Metal 管线创建(makeRenderPipelineState ×3 + 载入 metallib) 首次 69ms,之后 0.4ms(Metal 内部有管线缓存)
快速预览截图 CGWindowListCreateImage 约 35ms/窗口
桌面效果会话创建 + 流启动 + 显示 + 销毁 合计 150–220ms,其中无任何主线程阻塞 > 50ms
目标 App 执行 app-hide / minimize 19–474ms/窗口

最后一项是折叠路径的地板。它是别的进程在响应我们的请求,方差极大: Ghostty 之类几毫秒就回,Safari 在排版、转码类 App 在忙时能拖到几百毫秒。 同一份代码折叠 8 个窗口可能是 184ms,折叠 11 个可能是 2131ms,差别几乎全在这里。 报「提速多少倍」时必须说清窗口构成,否则数字没有意义。

二、已证伪:用 SkyLight 绕开目标 App

结论:在 SIP 开启的系统上不可行,不要再尝试。

思路是合理的——SLSSetWindowAlpha 把窗口 alpha 归零、或 SLSMoveWindowWithGroup 把窗口挪到屏外,请求直接到 WindowServer(0.013ms),完全不经过目标 App 的 runloop,可以绕开上面那个 19–474ms 的地板。

实测结果:调用返回成功,但回读发现值没变。

private SLS alpha did not apply id=7806 pid=744 current=1.00
private SLS offscreen did not park id=227 pid=715 reason=fallback

15 次尝试,15 次如此。跨进程的 SkyLight 窗口改动在 SIP 开启时被静默丢弃 (yabai 需要部分关闭 SIP 正是为此)。这不是 API 用法问题,是权限边界。

要让它生效只能让用户关掉 SIP,而正常用户不会为了折叠快一点去关 SIP,所以 这条路被永久排除。曾经加过的「快速隐藏」实验开关已删除(见 197b1f7): 留一个必须关 SIP 才生效的开关,只会让人打开、看不到变化、然后以为是自己 配置错了。

privateSLSAlphaHide 仍留在 fallbackHide 的最后一环(万一环境允许), 但带一次性探测:首次确认无效后本会话不再重试,不必每个窗口都白付一次 写入 + 一次回读。

三、已走通的手法

按收益排序。共同前提是:先测再改——这一轮里有两次「凭直觉的优化」反而 让代码变慢(见第四节)。

1. 用 CGWindowList 筛掉注定为空的 AX 枚举。 专注模式原本遍历全部运行中的 App 并对每个调用 appWindows(pid:)。实测 84 个 候选进程里只有 22 个拥有窗口——另外 62 个只可能返回空数组,却各要付一次 同步 AX 往返(最坏卡满 2s 消息超时)。判据是可证明安全的:没有任何 CoreGraphics 窗口的进程,不可能有 AX 窗口。发现阶段因此从约 8 秒降到 200ms 以内。启动时的救援扫描原本有同样的循环,且连 activationPolicy 都不筛。

2. 并发做只读的 AX 工作。 AX API 可在任意线程调用。各 App / 各窗口之间没有依赖的只读工作都可以 DispatchQueue.concurrentPerform,总耗时从「求和」变成「最慢的那一个」:

  • 窗口枚举(concurrentAppWindows)
  • 外框画像解析(ChromeProfileCache.prewarm)——980ms → 0ms
  • 快速预览截图(concurrentQuickPreviews)——串行 70ms → 并发 41ms(2 窗口)

写入侧不能这么做:建 overlay 是 AppKit、改 shaded 字典是共享状态,都必须 留在主线程串行。并发只用在「读」上,是这一节全部收益的边界。

预热缓存时注意:ChromeProfileCache.entries 没有锁,所以是后台并发算、 回主线程一次性写,保持「只在主线程读写」这个前提不变。

3. 事务级备忘,而不是 TTL 缓存。 一次折叠里同一个 App 的窗口列表会被要三四遍(刷新元素、找焦点继承者、 数窗口数决定隐藏策略),而事务内它不可能变。appWindows 因此支持一个 显式开启的备忘作用域:shade / unshade 进入时开启,退出即丢弃。 每窗口枚举次数从 3.6 次降到 1.3 次。

刻意不用 TTL 缓存:那种缓存会在「App 在 unhide 后重建了 AX 元素」这种 场景下悄悄返回陈旧元素,而这恰恰是恢复阶梯存在的理由。

4. 判断元素是否可用,要用存活探测,不要用几何匹配。 见第四节。

5. 窗口动画要成组,不要串行。 NSWindow.setFrame(_:display:animate:) 会阻塞调用方直到自己那段动画播完。 放在循环里逐个调用,总耗时就是各自动画时长之和——9 条卷帘条归位实测 3201ms, 而且用户看到的是它们一条接一条地挪。改成一个 NSAnimationContext 组、用 animator() 代理,所有窗口同时动,总耗时收敛到一段动画,降到 200ms 以内。

注意:动画期间 window.frame 是中间值。紧接着要读最终位置的调用方 (展开真窗口时要按卷帘条位置定位)必须走不带动画的分支。

6. 主线程上的长任务要分帧。 折叠 N 个窗口是线性成本,躲不掉;但可以不一次性占住主线程。按时间预算 推进(每轮 runloop 折到 120ms 用完就让出,下一轮继续),总时长基本不变, 但菜单栏和输入不再被整段冻结——十几个窗口时这是「卡住了」和「正在进行」 的区别。首帧阻塞从 3434ms 降到 291–495ms。

会话建立等收尾动作仍然等全部折完后一次性完成,语义与同步版一致;级联 期间要拒绝新的同类请求(focusCascadeActive),否则两次操作会交叉修改 同一份状态。

7. 不是每件事都必须在关键路径上做完。 折叠后注册的「窗口被外部唤回」观察者(makeRevealObserver)是 AXObserverCreate + 3 次 AXObserverAddNotification,四次同步 IPC,而且发给的 正是上一行刚被要求隐藏自己、此刻最忙的那个 App——实测 75ms/窗口,18 个窗口 1363ms,占整次折叠的 32%,比「等 App 隐藏自己」的地板还大。

而它监听的三件事(⌘Tab 取消隐藏、点 Dock 恢复、窗口被关闭)没有一件能发生在 刚折叠完的那一瞬间。推迟到下一轮 runloop 注册之后,安装阶段从 2484ms(18 窗口) 降到 736ms(20 窗口),而且注册本身也降到 28ms/窗口——因为对方不再忙。

推迟这类注册时要重新校验:窗口是否仍处于折叠态、元素是否仍是同一个、是否已经 注册过。任一不满足就跳过,否则会留下没人移除的 runloop source。

8. 别为了打日志付 IPC。 applyRestoredGeometry 每次调用 4 次 AX IPC:2 次写 + 2 次回读,而回读 的唯一用途是拼日志里的 actual=(...),不参与任何判断。恢复阶梯每个窗口 跑 80/250/550/1100ms 四档,恢复 15 个窗口就是约 120 次纯为日志的往返。 现在只有首档、末档、以及写入报错时才回读。

9. 空闲时不要跑 CADisplayLink。 FoldRenderer 的 MTKView 是 isPaused = true,只有显式 render() 才绘制; 而设置窗口原本无条件挂一个 60fps 的 display link,窗口开着就一直每帧 render(),哪怕实时预览关着、画面一帧没变。改成只在实时预览真的在推帧时 才跑时钟,静态示意图在参数变化 / 切回页面 / 视图尺寸变化时各画一次 (MTKView 暂停后不会自己重画,尺寸变化要补一帧)。设置窗口空闲 CPU 从 2.0% 降到 0.6%。

同理,效果会话在等待首帧期间也在以 60fps 空转 render(),所以首帧预算 不能给太长:实测成功呈现的中位延迟 93ms,而原本的预算是 2s。批量折叠时 多个会话互抢还会形成「越慢越超时、越超时越慢」的正反馈。预算收到 0.5s, 并且批量操作期间直接不起这个效果(十几个卷帘动画同时播本来也看不出来)。

10. 预取不要打扰刚被点击的应用。 菜单里“置顶/取消置顶当前窗口”的标题靠一次后台预取:每次全局点击(去抖 0.25s) 和每次切换前台应用,都向前台应用要一次焦点窗口。可这个应用此刻正在处理那次点击, /tmp/windowshade.log 里这类请求有 150 多次超过 50ms,最长约 2s(2026-09 实测); 而绝大多数点击都落在同一扇窗口里,结果根本不变。它还和置顶预览的交互接管共用 同一条串行 AX 队列,会把接管往后推。

现在自动触发的预取先查 WindowServer:前台应用最上层的普通窗口如果仍是上一次完整 解析出的那扇窗,就沿用结果,不发 AX 请求。显式置顶、打开菜单时的刷新照旧走完整 解析,所以动作本身不可能作用到旧窗口上。快速路径的实际效果还没有真机数字: 快速路径本身不打日志(它在阈值以内),下次看日志时比较 slow: pin-target 的条数。

11. 收起动画复用已经预取好的窗口枚举。 双击标题栏的第一下就会预取 SCShareableContent(ShareableContentCache,TTL 1.5s), 卷帘条截图也用它;但收起动画 WindowFoldEffects 一直自己再枚举一次(不含缓存)。 日志里唯一一次带动画的收起,从准备到盖板出现 328ms,其中会话创建与流启动按 第一节是 150–220ms,余下约 100–180ms 就是这次枚举;这一带日志里单次枚举 316–830ms。 收起路径现在用缓存(缓存里没有源窗口时它自己强制刷新)。展开路径不能这样做: 背景截图要排除收起时才创建的卷帘条,1.5s 内的旧快照里可能还没有它。 改动后的真机数字还没有,看日志里 duo-window: prepare fold 到 presented cover 的间隔。

12. 收起截图先用 CGWindowListCreateImage,ScreenCaptureKit 兜底。 卷帘条(“跟原来一样”外观)原来用 SCScreenshotManager 截整窗:单独测 82ms, 放在收起途中(焦点刚停靠、窗口在重绘成非活跃态)变成 229ms,超过 450ms 还会退回 代理标题栏。现在先在后台队列用 CGWindowListCreateImage(Capture/FastCapture.swift, 按符号动态取,单独测 48–57ms),拿不到或整张全透明才走 ScreenCaptureKit。收起动画的 背景同理改用 CGWindowListCreateImageFromArray:同样去掉源窗口、动画面板和卷帘条, 其余在屏窗口按原顺序合成,34ms;尺寸对不上才走 ScreenCaptureKit。

同一进程连续收起 5 次、取后 4 次的中位数(tests/run-glance-probe.sh --single --fold-timing, 设 WINDOWSHADE_DISABLE_FAST_CAPTURE=1 得到对照组),Mac17,4 · macOS 27.0 · 2026-09-24:

第一眼看到变化 卷帘条出现
不带动画,只用 ScreenCaptureKit 342ms 499ms
不带动画,快速截图 147ms 328ms
带动画,只用 ScreenCaptureKit 591ms 1015ms
带动画,快速截图 478ms 904ms

带动画时原来剩下的大头是动画自己的实时流启动。现在收起动画也像展开一样先用快速截图起步 (renderer.setImage),实时流在后台就绪后由显示时钟换成实时帧,不再等它。同一进程连续 5 轮,“准备收起”到“盖板出现”:只用 ScreenCaptureKit 冷启动 659ms、其余四轮 199/562/265/277ms; 快速截图起步冷启动 409ms、其余四轮 211/209/157/189ms(中位数约 271 → 199ms,偶发的 500ms 以上长尾消失)。

13. 音效不要在主线程上播。 音频设备闲下来之后,NSSound.play() 会在调用线程上同步把设备拉起来:2026-10-01 在 Mac17,4 / macOS 27.0 上实测第一次 496ms(同一进程随后立即再播是 0ms)。收起 / 展开的音效原来就在主线播 (AppDelegate.playShadeSound),日志里的主线程卡顿采样有三次停在 CoreAudio AudioDeviceStart ← AQMEIO_HAL ← AudioToolbox——每次折叠都可能冻半秒,而折叠动画自己 才 300ms 左右。现在 prototype/App/ShadeSoundPlayer.swift 把播放固定到一条串行队列上、缓存实例, ShadeController.shade 与 FoldExit.unshadeReturningElement 在动作开始前用一次静音播放预热设备; 两处 NSSound.beep() 同样走这条队列。tests/run-shade-sound-tests.sh 钉住「调用方立即返回、 真正播放在后台队列、连续两次都不阻塞」。冷启动那一下仍要 ~500ms,但它花在后台队列上,并且会打 一行 perf: sound … play …ms on the sound queue (audio device cold start)。

14. 静止时别再按 12Hz 去问铰链角度。 盖角是一次同步 HID feature 读取:2026-10-01 实测 0.914ms(100 次平均,最大 1.71ms)。 空闲时按 12Hz 问,就是常驻约 1.1% 单核——消融实验里「效果开着 1.16–1.32% / 关掉 0.31%」的差额 基本就是它。加速度计是驱动推上来的(62 份/秒,回调几乎不花钱),所以拿它当「机器在动」的信号: Core/MotionActivity.swift 的 MotionActivityDetector(相邻读数差 ≥0.02g 算动,之后保持 0.6 秒)判断动静, LidPollInterval 定采样率——合盖途中 60Hz、机器在动 12Hz(原来的空闲速率)、确实静止 4Hz。 AppleSPUAccelerometer 只在状态变化时发一次 onMotionChange;没有运动数据时(加速度计不可用、 倾斜效果关着、传感器停了)保持 12Hz,降频不靠猜。日志里 lid: poll 4.0Hz (still) 就是省下来的那部分, tests/run-motion-activity-tests.sh 钉住判定与三档间隔。手感上:手一碰盖子,加速度计在 ~16ms 内 就把采样率拉回 12Hz,所以「开始合盖」的发现速度和以前一样。另有一条自愈兜底: 铰链自己看到角度变化 ≥0.3°(HingeMoveGate)也当作「在动」并保持 3 秒——覆盖「慢慢合盖、 底座几乎不动、加速度计看不出来」的情况,最多晚一个 4Hz 周期(250ms)就把采样率拉回来。

15. 锁屏时不要起传感器(尤其是“启动时就已经锁屏”)。 副作用是一条真的会踩到的路径:DuoController.suspend() 的规则本来就是「锁屏只留锁屏效果要的传感器」, 但那是靠锁屏那一次转换触发的;应用如果在已经锁屏的状态下启动,suspended 一直是 false, 于是 settingsChanged() 照常把铰链传感器起起来,一直 4Hz 问下去。2026-10-01 实测这种状态下 常驻 0.445%(31 个 1 秒样本,效果开着、锁屏但机器醒着),几乎全是铰链。 现在 settingsChanged() 直接看当前锁屏状态(不是只看 suspended): 锁屏且锁屏效果没开就不起传感器,解锁时 com.apple.screenIsUnlocked → resume() → settingsChanged() 照常把它们起回来。改完同一条件下实测 0.055%(峰值 0.20%),日志里也不再有 lid: poll—— 省下约 0.39% 单核,而且省的是「人不在跟前」的那段时间。

16. 盖角设备其实会推送——订阅它,把轮询降成看门狗。 tools/lid-report-probe 回答了一个一直没验的问题:盖角设备(usage page 0x20 / usage 0x8A) 会主动推 input report。实测(2026-10-01,机器空闲、没人碰盖子):5 秒 50 条、每条 3 字节、 报告号 1(整度),解出 106.00°。(2026-10-01 复核更正:原文说「与 feature 读的 106.04° 一致」, 但这台机器的 feature 读走的是精细格式(报告号 7,0.01°),和推送不是同一格式;探针复跑时 60 次 feature 读一次也没按整度格式解出来。推送本身可靠,但合盖途中不能和 feature 读混用,见第 21 条。) 而且应用那种「设备已打开再读 feature」的组合下推送照样来(两条都验过)。 于是 LidAngleSource 改成:订阅推送作为主路径(约 10Hz,比原来静止的 4Hz 更跟手、回调几乎不花钱), 定时器退化成 1Hz 看门狗(那一拍不读 HID,只检查推送有没有断);推送断了(>3 秒)才退回 4Hz / 12Hz 的 feature 轮询,合盖途中仍然是 60Hz 轮询保证动画跟手。 间隔判定收进 Core/MotionActivity.swift 的 LidPollInterval.seconds(engaged:moving:pushFresh:), 由 tests/run-motion-activity-tests.sh 覆盖。静止时那一笔从 4Hz×0.914ms≈0.37% 降到 1Hz×0.914ms≈0.09%(合盖那几秒照旧 60Hz,短促、不影响常驻)。 真机日志里会写成 lid: poll 1.0Hz (push);这一行要解锁后才会出现(锁屏不起传感器,见上一条)。

改这一带时顺手抓到的一个真崩溃。 为了验证上面那条,新加的 tests/run-lid-source-tests.sh 直接驱动应用里那个 LidAngleSource(不是另写一份),结果一跑就 abort: IOHIDManagerClose 之前没有 IOHIDManagerCancel(加速计那边是对的,铰链这边漏了), IOKit 在 release 时过释放——"Invalid dispatch state" ← IOHIDManagerExtRelease ← IOHIDManagerClose。 订阅推送那条路一上来就要 Activate,正好把这个漏掉的配对暴露出来。补上 Cancel 后测试全绿。 这条测试同时钉住三件事:连得上传感器、3 秒收到 ~31 份读数(推送约 10Hz,1Hz 看门狗不读 HID)、 日志里出现 lid: poll 1.0Hz (push) 而不是退回 4Hz。没有盖角传感器的机器会自动 SKIP。

四、两次「凭直觉的优化」反而变慢

都是同一个错误:拿几何匹配当身份校验。

windowID(of:) 内部优先用位置 + 尺寸 + 标题去和 CGWindowList 比对 (publicWindowID),_AXUIElementGetWindow 只是它的兜底。于是:

  • 在 refreshedWindowElement 里写 windowID(of: fallback) == id 做快速路径, 看起来省掉一次枚举,实际上级联折叠时窗口陆续被隐藏、CG 列表一直在变, 几何匹配会失配——每个窗口先付一次失败匹配、再付一次完整枚举。 元素刷新从 39ms 涨到 612ms。
  • 改成 _AXUIElementGetWindow(fallback, &id) == id 也不行:传进来的 id 本身来自 windowID(of:)(几何匹配的结果),两者对某些 App 并不一致, 比对系统性失配。元素刷新仍是 236ms。

正确做法是不做身份比对。调用方如果刚刚枚举出这个元素(专注模式在并发 发现阶段几毫秒前才拿到),它知道元素是新鲜的,只需确认还活着——一次 axPosition 属性读,0.1ms。元素真的死了,写入会失败,上层本来就会重新解析 后重写。元素刷新降到 1ms。

恢复路径上的 resolvedWindowElement 犯了同一个错(拿 state.sourceWindowID 比对),已一并改成存活探测。

五、诊断工具

--duo-ax-bench(只读)打印本文第一节那张表。必须用签名后的 WindowShade.app/Contents/MacOS/WindowShade 运行,未签名二进制没有辅助功能 权限,AX 调用会直接返回而不发 IPC,量不到真实延迟。

卡顿归因。 MainThreadStallSentinel 用主 RunLoop observer 测量两次活动之间 的间隔,超过 0.5s 报一次。注意两点:

  • 它量的是两次 runloop 活动之间,不是两条日志之间。主线程卡在一个长调用 里时,app 照样能打日志——所以「日志连续」不等于「没卡」。
  • 它在阻塞结束后才拿到控制权,光报时长无法定位。因此 MainThreadActivity 记录主线程活动,报告时给出卡顿窗口内累计占用最久的那个标记及其次数与 占比。早期版本报「最后结束的那个」,结果把恢复 15 个窗口的 8 秒记到 menu: 重建 头上——几秒的连续忙碌通常由几十次短调用组成,末尾那次往往 只是恰好排在最后。占 0% / 未标记 本身也是信息:说明阻塞落在所有标记 之外(多半在异步回调里)。

foldPhase 分段报告。 shade() 内部各段累计,一次专注结束时一并报出 (单段只有几十毫秒,逐次打日志会淹掉日志)。定位未覆盖的时间用二分比 逐个加探针快:把整个安装闭包罩上,它与已知子项的差额直接指出剩余时间在 安装之内还是之外。

卡顿放在哪一段:采样器一次抓多张。 MainThreadSampler 在卡顿超过 250ms 时 暂停主线程抄栈,一次卡顿最多抓 4 张、每张至少隔 200ms,日志写成 main-thread stall sample 2/4 ≈740ms: …。以前一次只抓一张:一秒多的卡顿里, 音频设备启动那 300ms 被看见,同一段卡顿的后半截完全没记录(2026-10-01 就卡在这里)。 跟踪循环(菜单 / 拖拽)单独记 main-thread tracking,不冒充冻结。 bash tests/run-stall-sampler-tests.sh 在主线程真的阻塞 1.1 秒,验证会留下 ≥2 张带序号的采样。

门限日志的读法。 logIfSlow(label, threshold:) 只在超过阈值时记录, 所以某一行没出现就是结论——说明那一段已经快到阈值以内了。

启动台的无头基准。 bash tests/run-launchpad-perf.sh 跑真实视图的扫描 / 初始排列 / 每个 App 的图标与标签 / 首页、资料库、分类、文件夹、负一屏的布局,只计时不截图、不写用户排列, 锁屏也能跑(GUI 探针在锁屏下会以 posErr=-25205 失败)。2026-10-01 Mac17,4 / macOS 27.0: 目录扫描 248ms(196 个 App,启动时后台做)、196 个图标+标签 113ms、首页 setContent 7.2ms、 资料库布局 0.8ms(13 个分类)、打开分类 2.7ms、打开文件夹 1.4ms、负一屏 0.8ms—— 布局这一层不是瓶颈,慢的是取数(扫描 / 图标)与系统调用(见 10、13 两条)。

一条命令做性能复检:bash tests/run-perf-check.sh(要解锁的图形会话)。 它先看锁屏状态 (锁着就直接退出,免得把 posErr=-25205 当成产品问题),停掉 /Applications 那份实例后跑收起计时, 再量 60 秒空闲 CPU 并和基线并排打印,最后从日志里挑出 lid: poll / perf: fold install / main-thread stall sample / main-thread tracking 这几类行。收尾一定会把 /Applications 的应用拉回来。

两个可以直接从日志里读的基线(2026-10-01)。 启动:=== session start 到 status item visible=true,九次会话 27–189ms(多数 ~100ms)。锁屏空闲:同一构建锁屏 30 秒 top -p 平均 0.04%(峰值 0.1%,RSS 27MB)——锁屏时效果会话确实停了,不是白耗电。 实时活动的音乐来源只在用户显式开启(Notch.activities.musicEnabled)后才每 2 秒起一个 osascript;这台机器没开,日志里也没有这类进程,所以它不是本机现在的瓶颈。

量过、可以不再怀疑的(2026-10-01,Mac17,4 / macOS 27.0)。

  • Dock 悬停观察者重建:日志里 19 分钟出现 9 次 dock-element-destroyed 重建,看着可疑; 实测建订阅那几个调用(AXObserverCreate 0.0ms、AXObserverAddNotification 0.8/0.0ms)加 AXUIElementCopyAttributeValue(kAXChildren) 32.8ms(这一步本来就在 workQueue 上)—— 重建的主线程代价约 1–2ms,不是瓶颈。
  • 收起音效的强冷启动确实存在(一次 496ms),但设备闲下来后通常能热 ≥60 秒才凉; 探针 --single --fold-timing 里的 shade() 用默认选项,本来就播反馈音, 只是设备热着时不打 perf: sound 那一行——看不到那行不等于没走到播放路径。
  • 盖角降频的实测对照(2026-10-01,锁屏但机器醒着,传感器在跑):应用 31 个样本的平均 CPU 0.368%(峰值 0.60%),正好等于 4Hz × 0.914ms/s;换成 12Hz 就是约 1.1%——和 「效果开着 1.16–1.32% / 关掉 0.31%」的消融差额对得上。0.73% 单核的节省是量出来的,不是估的。

盖角降频 A/B(2026-10-01,同一构建、同一台机器、锁屏但机器醒着,各 31 个 1 秒样本):

配置 平均 CPU 差值
盒盖 / 倾斜效果关 0.087%(峰值 0.20%) —
盒盖 / 倾斜效果开(改动后,铰链 4Hz) 0.445%(峰值 0.70%) 0.36% ≈ 4Hz × 0.914ms ✓
同一台机器上改动前的对照(铰链 12Hz) 1.16–1.32% 开 / 0.31% 关 ~0.9% ≈ 12Hz × 0.914ms ✓

也就是说:效果开着时的常驻 CPU 从约 1.2% 降到约 0.45%。日志同步显示 lid: poll 4.0Hz (still)。

2026-10-01 复核更正(Claude 独立复测):上表把两种条件混在了一起。「1.16–1.32% 开 / 0.31% 关」 是当天早上解锁、有人用时量的;0.445% / 0.087% 以及后面的 0.055%、0.080% 都是锁屏时量的。 所以「约 1.2% → 约 0.08%」不是同条件对比。同一份最新构建在解锁空闲下复测(top 30 个 1 秒样本): 平均 0.97%(峰值 3.4%)。20 秒 sample 里忙的样本分散:加速度计队列最多(约 33,服务的是用户开着的 「倾斜」效果,改动前就在跑)、主线程约 20、铰链队列约 13。也就是说,锁屏/待机那条线的改进是真的, 解锁时只比早上略低;剩下那 ~1% 没有单一大头。

六、改这一带代码时的注意事项

tests/duo-integration-check.py 有两条源码顺序断言,守卫崩溃一致性:

recordShadeRecoveryIntent(  必须出现在  let hide = hideWindow(  之前
let hideVerifiedNow = hideTookEffect(  必须出现在  didVerifyFold(  之前

给这两处加计时时不要用闭包包装——实际执行顺序不变,但守卫匹配的文本会 消失,测试会红。用手工计时(记起始时间、直接累加)保留那段文本原样。

17. AirPods 那段不要每 2 秒枚举一次设备。 实时活动的对账每 2 秒跑一次,其中 kAudioHardwarePropertyDevices 一次枚举实测 1.74ms (100 次平均,最大 43ms;同一次里的进程列表查询是 0.00ms)。现在设备列表与默认输出挂了 CoreAudio 的属性监听,只有真的变了(或 30 秒兜底)才重新枚举。这条要如实说:锁屏空闲 CPU 量到的是 0.100% → 0.123%,在噪声里——那 0.1% 主要是各种定时器唤醒(诊断采样器每 50ms 醒一次等), 不是这次枚举。 保留这条改动的理由是它减少同步 CoreAudio 调用(解锁态每 2 秒一次),不是量到了 CPU 收益。

18. 诊断采样器不用每 50ms 醒一次。 锁屏空闲那 ~0.1% 在把铰链、AirPods 这些真活儿都拿掉之后仍然在,来源是定时器唤醒: 卡顿采样器的看门狗原本 usleep(50_000)(20 次/秒)。卡顿阈值是 250ms,所以改成 200ms (5 次/秒)照样抓得到——tests/run-stall-sampler-tests.sh 重跑仍是一次 1.1 秒阻塞抓到 4 张, 第一张在 ≈458ms(250–450ms 区间内),报的仍是真实卡了多久。实测锁屏空闲 0.100–0.123% → 0.080%(41 个样本)。

19. 折叠时那几个 AX 读不要留在主线程上。 折叠的截图分析本来就在 pixelAnalysisQueue 上,但紧接着要用的两组 AX 读——红绿灯三个按钮的命中区 (trafficLightRects)和「这扇窗能不能全屏 / 缩放」(realWindowManagementCapability)——留在主线程上问 目标 App;对方忙的时候一次约 19ms(第一节),七八次就是上百毫秒,而这正卡着安装与菜单重建。 现在它们和像素分析放在同一条后台任务里算(读的是同一扇窗、同一时刻,值不变), 主线程仍然要等结果,但等待期间 runloop 是活的——冻住的是这段等待,不是整个 App。 验收点在解锁后一次真实折叠:perf: fold install … phases: 里那两段(都是主线程上的活)应该看不到了, 而 main-thread stall sample n/4 里的长度应该明显变短。整树 --check 与全量 runner 已通过。

20. 「Swish 在不在跑」不要每次手势都问一遍 LaunchServices。 TrackpadGestureController.conflictingApp() 在每次手势开始、每次甩动时都会被问;它逐个读运行中 App 的 localizedName,那是去 LaunchServices 的同步请求。实测(2026-10-01,180 个运行中的 App):一次 中位 22.7ms(20 次,最大 24.2ms);解锁空闲时对主线程采样,正好抓到一次手势开始在这里等了约 140ms。 结果只在 App 启动 / 退出时会变,所以改成缓存:didLaunchApplication / didTerminateApplication 置脏, 30 秒兜底、缓存的 App 已退出也重算。判断逻辑本身一字没改。

21. 合盖途中不要把整度推送混进精细读数。 订阅推送(第 16 条)之后,合盖途中 60Hz 的 feature 读(精细,0.01°)和约 10Hz 的推送(整度) 会交替送进动画,差出最多 1°(约 2% 进度)来回跳。tests/run-lid-source-tests.sh 在真机上量到: 不修时合盖 1 秒里混进 10 份整度读数,修后 0 份。现在合盖途中只认和 feature 读同格式的推送; 不在合盖时推送照常驱动(那时角度在 95° 以上,进度恒为 0,整度足够)。 同一处还补了两件:找到设备之后才挂推送回调并激活(没找到设备的机器走回原来只 Open/Close 的路, 不会去 Cancel 一个没激活过的管理器);stop() 强引用到 close() 跑完、deinit 也先 Cancel 再 Close, 避免推送回调拿着不持有的指针和释放赛跑。

22. 精细格式的机器上,整度推送只当「角度变了」的信号。 第 21 条只管了合盖途中。真机解锁实测(2026-10-01 13:52)又暴露了另一半:推送是取整的整度(2026-10-01 录序列时对照:feature 103.67° 时推送 104), 盖子停在「触发角 + 8°」(默认 103°)那条线附近时,推送说 102(判「开始合盖」→ 起效果)、feature 读说 103.1(判「盖子开着」→ 停效果),桌面效果 3 秒里每 0.3–0.7 秒起停一次,屏幕上就是一层反复出现的模糊倾斜。 现在精细格式的机器上,推送角度变了才立刻做一次精细读并交出去,交给下游的永远是同一种刻度; 盖子不动时只剩 1Hz 看门狗那一读(约 0.09% 单核)。同格式的机器(推送本身就是整度、feature 读也是整度)照旧直接用推送。 修后同一操作再测:开合几次,效果每次只起停一次,Aaron 目测「不抖了,也没闪」。 另:同一台机器上 LidReport.detect 有时判精细、有时判整度(第一次精细读失败就退回整度),这是老行为,两种情况现在都一致。

23. 定时器间隔没变就别重排。 LidAngleSource.applyIntervalIfChanged() 原来每次都 schedule(deadline: .now()),而盖子在动时 (HingeMoveGate 判「在动」的 3 秒内)每份读数都会调到它——定时器一次次被改成「立刻触发」, 合盖途中本该 60Hz 的轮询变成能读多快读多快:tests/run-lid-source-tests.sh 真机量到 1 秒 760 份, 每份还都排一次主线程。现在间隔真的变了才重排,同一测试是 70 份(60Hz 轮询 + 约 10Hz 推送),并加了上限断言。

观察(未改):内建屏熄灭那一下的主线程等待。 合盖到内建屏熄灭时,三次都有一次 0.3–0.6 秒的主线程卡顿, 每次卡在不同的 WindowServer 调用上:MTKView.currentDrawable(还在往正在熄灭的屏画最后一帧,347/308ms)、 orderOut 里的 _updateWMWindowPresentationMode(444ms)、屏幕参数变化回调里的 isOnActiveSpace(482ms)。 这是 WindowServer 重排显示器期间任何同步调用都会等,不是单点 bug;那一刻内建屏已黑,外接屏上 Aaron 没注意到卡,暂不处理。

七、2026-10-03:常驻唤醒与实时活动来源

测量(Aaron 日常用的 build 16,他正在用电脑,top -l 31 -s 1 -stats cpu,idlew,power):CPU 平均 2.16%, 空闲唤醒约 4549 次/秒,物理占用 140 MB。5 秒 sample:主线程几乎都在等事件;com.windowshade.activities.sources 队列占 86 毫秒(约一个核的 1.7%),全在 NotchActivitySources.poll() 里读 CoreAudio。 没人碰电脑时(空闲 36 秒起量 30 秒)一样:CPU 3.31%、空闲唤醒约 4551 次/秒,所以唤醒不是输入带来的。10 秒 sample 里这个队列忙了 1605 毫秒(约一个核的 16%),几乎全在列音频进程、逐个读 bundle ID(HALC_Object_GetPropertyData_DCFString, 每次一个到 coreaudiod 的 mach 消息)。单独量:这台 Mac 上有 33 个音频进程,列一遍约 27 毫秒(机器负载高时)。

24. 语音备忘录没开着,就不列系统里的音频进程。 认“正在录音”要列出 kAudioHardwarePropertyProcessObjectList 再逐个读 bundle ID(每个都是一次到 coreaudiod 的 IPC),原来每 2 秒做一次。现在先看语音备忘录在不在运行,不在就整段跳过; 它在运行时照旧,行为不变。

25. 音乐来源改成事件驱动。 打开“音乐”后,原来每 2 秒启动一次 osascript 子进程问播放器,并用每 25 毫秒睡一下的循环等它。 现在只在播放器发分布式通知(com.apple.Music.playerInfo、com.spotify.client.PlaybackStateChanged)、播放器开关、点了控制、 或 30 秒兜底时才问一次;其余时候进度条按上次读到的位置加经过的时间推算。等子进程改用结束回调加信号量。

下一步(量过再改):输入事件的扇出。 现在一次指针移动或滚动会分别叫醒多处监听:Dock 悬停的回退检测(全局 mouseMoved, Dock 悬停打开时常驻)、标题栏手势(全局 scrollWheel、左键拖动)、捏合(手势事件 tap 线程)、卷轴边上那一条(有卷轴时)、 Dock 留在一块屏(打开时)。方向是合成一个只听不拦的输入线程,在那里先按位置和状态过滤,只有真正相关的事件才回到主线程。 先量“没人碰”和“正常使用”两种情况下的唤醒次数,再决定改哪几处;改的时候守住已发布的手势手感(帕累托)。

七之二、空闲唤醒归因:测量模板(F09)

测量脚本:tools/perf/idle-wakeups.sh <pid> [label] [--out FILE] [--build NUM] [--powermetrics]。每次运行等 36 秒后采 30 秒,追加一行 JSON(label、时间、build、cpu、idlew)。--powermetrics 只打印需要 Aaron 手动 sudo 执行的 powermetrics 与 spindump 命令,脚本本身不提权。

AppleSPUAccelerometer 的调用方(S05 的结论): 截至 build 对应的这个提交,prototype/ 里没有任何代码实例化 AppleSPUAccelerometer,只有类型自身与 EffectEnvironment.swift 里的一处注释提到它。它当前没有调用方,因此空闲唤醒里不会来自这个传感器。去留由 F09 之后的决定定,未决前不删类型。

场景 × build 记录表:

场景 build CPU(%) IDLEW(次/秒) wakeups/s 备注
锁屏 30 秒
解锁空闲 60 秒
两扇窗口并排
隐私页开
隐私页关
置顶 3 扇窗口
开着 Codex 会话

逐项关功能的消融表(每次只关一项,其余保持默认,同一台机器、同一场景):

关掉的功能 build CPU(%) IDLEW(次/秒) wakeups/s 相对基线的变化
无(基线)
活动来源(音频、音乐轮询)
Dock 悬停回退检测
标题栏手势监听
捏合手势 tap
卷轴边缘条
Dock 留在一块屏