From 1d8be226f4c9ac21942eb09296b8399b3a67fc3e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 17:45:07 +0200 Subject: [PATCH 1/3] docs(perf): settle the macOS export's 4 s cold start, and kill the asar lever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4 s between the CLI's `started` event and its first composed frame is reproducible on demand now, and it is not what the record guessed. Making the entire 261 MB `app.asar` resident — the best case a smaller archive could ever approach — changes the cost by 10 ms out of 2120. Memory pressure is real but saturates around +200 ms. What actually costs seconds is the first execution of a newly installed binary: rewriting the same bytes to the same path with the same mtime brings the whole cost back, so it is charged against the file's identity rather than its size, its path, or anything the app caches. Recorded as a rejected route with the interleaved measurements, and the Known gaps entry narrowed to what is still genuinely unknown: which per-inode kernel cache is being populated, whether a quarantined download is worse, and why the original report put the same magnitude on the other side of `started`. Closes #593 --- .../engineering/rendering-performance.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index ea5b63cf1..aaa9856cd 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -570,6 +570,32 @@ Unit tests never look at a pixel. The `native*` arms write real files: export th ## Rejected routes +### Shrinking the macOS `app.asar` to cure the export's cold start + +**What it was.** A headless `openscreen export` was measured repeatedly spending 4.2 s between the CLI's `started` event and its first composed frame, then not doing it any more on the same binary. The standing hypothesis was memory pressure on an 8 GiB machine faulting ~1.8 MB of module chunks out of a 274 MB `app.asar`, and the proposed lever was a smaller archive. **What the measurement said.** The cost is real and now reproducible on demand — but the archive is not it, and residency is not the lever. Shipped 1.10.0 bundle, M1 Mac mini, 4 s fixture, conditions interleaved inside one session; the two unpressured blocks closed at 442 ms and 441 ms, so the comparisons sit on a stable floor. + +| condition | spawn→`started` | `started`→first frame | +|---|---:|---:| +| validated binary, machine free (baseline) | 432 ms | 452 ms | +| + 1.5 GB pinned and continuously touched | 490 ms | 625 / 555 ms | +| + 3 GB pinned | 474 ms | 652 / 632 ms | +| page cache flushed (8 GB read), same binary | 575 ms | 493 ms | +| **first run of a newly written copy** | **2120 ms** | **780 ms** | +| same, whole bundle read into cache first | 2130 ms | 771 ms | +| **newly written copy + 3 GB pinned** | **3988 ms** | **1115 ms** | + +Five things fall out, each with its own control: + +- **Reading every byte of the bundle first changes nothing** — 2130 ms against 2120 ms. That is the best case a smaller archive could ever approach, so a smaller archive cannot help. A cold read of the entire 261 MB archive costs 110 ms; the machine does 2.4 GB/s and the file is not the problem. +- **Cold pages are worth ~35 ms** of the `started`→first-frame interval (493 vs 457). The flush is not imaginary: page faults requiring I/O go 656 → 2730, and 12 708 in the most effective trial. +- **Memory pressure is real but saturates at about +200 ms.** Doubling the pin from 1.5 GB to 3 GB moves the cost from +150 ms to +213 ms, not from +150 ms to +300 ms. +- **It is not Gatekeeper and not signature verification.** Pre-running `spctl -a -t exec` (372 ms) and `codesign --verify --deep` (209 ms) on a fresh copy leaves the first launch exactly where it was: 2137 ms against 2127 ms without. +- **It is bound to the file's identity.** Rewriting the same bytes to the same path with the same mtime — a new inode and nothing else — brings the whole cost back: 2380 ms against 441 ms. So it is neither a path-keyed nor a `userData`-keyed cache the app could pre-warm; it is charged by the kernel against the binary itself. + +The expensive launch is therefore **the first execution of a newly installed binary**, compounding with memory pressure to the ~4 s that was reported (7578 ms total against 3447 ms). It is paid once per install or update, which is also why it disappeared "on the same binary, hours later" — and why it never shows up in a benchmark, which launches the same binary dozens of times. + +**One-line reason not to re-propose:** making the entire archive resident buys nothing, so making it smaller cannot; what remains is charged against the binary's identity, not its size. + ### Capping the macOS decoder's thread count **What it was.** After the export moved to the software H.264 decoder it runs with `thread_count = 0`, which in libavcodec means *automatic* — the decoder picks, from the CPU count and its own threading model, and the number it actually chose was never read back here. The export's CPU-seconds went 8.4 → 29.8. Since the walk is bound by the encoder and the decoder has seconds of slack, capping its threads looked like free CPU. **What the measurement said.** It is not free and it does not return CPU. Public bundle, S4, three cycles with a floor inside each, closing drift 0.9979, output identical across variants: @@ -685,7 +711,7 @@ the bench runs on the reference machine. ## Known gaps -- **macOS export startup can cost 4 s, and nobody has reproduced it on demand.** Measured repeatedly at 4208–4502 ms between the CLI's `started` event and the first composed frame — 18 % of a 60 s export, 71 % of a 5 s one — then gone, on the same shipped binary, hours later (481 ms). It is not the compositor (init is 2.4 ms, runtime MSL compilation included), not the `