diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index ea5b63cf1..3c7cd36f7 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -570,6 +570,34 @@ 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** | + +**Read the columns, not the total.** The magnitude matches the report — 5103 ms from spawn to the first frame against an 884 ms baseline — but it lands on the other side of `started`: 3988 ms of it before the event, 1115 ms after. The original report put its 4.2 s entirely *after* `started`, with the renderer's `domInteractive` at 3887 ms. Nothing here reproduces that split, which is why [Known gaps](#known-gaps) keeps it open as possibly a second phenomenon. + +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 ceiling for any lever working through residency, so pre-warming the archive cannot pay. It says nothing about bundle *size*, which is a different variable and untested — see the one-line reason below. 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 ~36 ms** of the `started`→first-frame interval. That is 493 ms against the **paired warm arm of the same experiment** (457 ms), not against the table's baseline row — pairing each flushed run with the unflushed run that followed it is the comparison that holds the machine constant. Against the table row it reads 41 ms; the difference between the two is the noise this pairing exists to remove. 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, and over the range tested it grows far slower than the pin.** Each figure is the mean of two paired pressure/free blocks: 1.5 GB costs +183 and +114 ms (mean **+148**), 3 GB costs +213 and +195 ms (mean **+204**). Doubling the pin buys 38 % more cost, not 100 % — but 1.5–3 GB is the whole tested range, and nothing here says where it goes above that. +- **Neither user-space check warms whatever costs the time.** 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. That is the whole claim: those two tools do not populate the state being paid for. It does not clear Gatekeeper as a mechanism — and it cannot, since every copy measured here was made with `ditto` and carries no quarantine attribute, so the heavier assessment a real download triggers was never exercised. +- **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 platform against the binary itself — by which layer is exactly what stays open, since ruling out the two user-space checks does not rule out the kernel's own per-page validation, nor a dyld launch closure. + +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:** pre-warming the archive is refuted outright — full residency buys 10 ms out of 2120 — so no lever that works by improving residency can pay. Whether a *smaller* bundle would shorten the identity-bound cost is a different question and an open one: it was not tested here, because removing content invalidates the signature that is part of what is being measured. Re-propose that one only with a size-controlled experiment attached. + ### 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 +713,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 `