You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
macOS: export startup can cost 4 s before the first frame, and it tracks memory pressure rather than the code #593
On an 8 GB Mac mini M1, a headless openscreen export spent 4.2 s between emitting started and composing its first frame — 18 % of a 60 s export's measured interval, and 71 % of a 5 s one. Then it stopped doing so, on the same binary, with nothing changed.
What was measured
Decomposed from the CLI's own JSON events plus timestamps inside the renderer:
while it reproduced
now
started → first progress
4208–4502 ms
481 ms
renderer domInteractive
3887 ms
15–355 ms
the whole of runExport
24 ms
24 ms
Same shipped 1.10.0 application, untouched, hours apart. Everything after the module graph was always fast: loading the project is 4 ms, the two <video> metadata probes are 13 ms and 6 ms, and Compositor::new is 2.4 ms including runtime MSL compilation of shaders.metal.
What it is not
Three hypotheses were measured and all three were wrong:
Runtime Metal shader compilation — 2.4 ms for the whole compositor init.
<video> probes scaling with media size — a 1.7 MB / 5 s source gives the same 4197 ms as a 50 MB / 60 s one.
CPU work — neither process burns any. Both sit in kevent64 / __workq_kernreturn; 14 CPU-seconds for a 23 s export.
It is also not the CLI window loading the editor's entry point. That was tried (#590) and measured at −0.1 % against a proper baseline.
What it most likely is
Memory pressure. While it reproduced the machine was at 387M unused / 2613M compressor with ~6.5 M swapouts; it is now 564M unused / 1837M compressor, 57 % free, and the cost is gone. Faulting ~1.8 MB of module chunks out of a 274 MB app.asar while the compressor is thrashing looks precisely like what was observed: several seconds of wall clock, no CPU, independent of the work being asked for.
That is a hypothesis. Nobody has recreated the pressure deliberately and watched the cost come back.
Why it is worth chasing anyway
8 GB is the entry-level Apple Silicon machine, and the export is exactly the moment the app is competing with a browser and an editor for RAM. If this is the cause, real users hit it and it is invisible in any profile — it does not show up as CPU.
It lands inside the measured interval. The CLI emits started as soon as the window is created, so any startup latency counts against the export in benchmarks and in the user's perception alike.
The app.asar is 274 MB, of which the renderer needs about 1.8 MB. Whether that size costs anything under pressure is testable.
How to settle it
Reproduce the pressure deliberately — pin memory until the compressor is near where it was, then measure started → first progress on the shipped app — and if it returns, try the same with a smaller asar (or with asar: false) to see whether archive size is the lever. Until someone does that, the cause is unproven and no fix should be attempted on the strength of it.
Settled, by doing what this issue asked for: the pressure was recreated deliberately, the cost came back, and archive size turned out not to be the lever. #600 records it.
It reproduces. A newly installed binary launched on a machine with 3 GB pinned and continuously touched costs 7578 ms total against a 3447 ms baseline — the ~4 s reported here, on demand. The two unpressured control blocks in the same session closed at 442 ms and 441 ms, so that gap is not drift.
The proposed fix is dead. Reading every byte of the bundle into cache first — the ceiling a smaller app.asar could ever approach — moves the cost from 2120 ms to 2130 ms. Cold pages are worth ~35 ms of the interval; a cold read of the entire 261 MB archive is 110 ms. Memory pressure is real but saturates at about +200 ms, and doubling the pin barely moves it.
What it actually is. The first execution of a newly installed binary. 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 vs 441 ms), which rules out any cache the app could pre-warm. Pre-running spctl and codesign --verify --deep removes none of it, so it is not Gatekeeper either. Paid once per install or update; invisible to any benchmark, which launches the same binary dozens of times.
What is still open, and now recorded in Known gaps rather than here: which per-inode kernel cache is being populated (DYLD_PRINT_STATISTICS is stripped under the hardened runtime), whether bundle size matters at all (residency was refuted, size was not — content cannot be removed without invalidating the signature), whether a quarantined download is worse than the ditto copies measured, and why this reproduction puts the bulk of the cost beforestarted where the original report put it after. That last one may be a second phenomenon with the same total.
Activity
EtienneLescot commentedon Sep 4, 2026
Settled, by doing what this issue asked for: the pressure was recreated deliberately, the cost came back, and archive size turned out not to be the lever. #600 records it.
It reproduces. A newly installed binary launched on a machine with 3 GB pinned and continuously touched costs 7578 ms total against a 3447 ms baseline — the ~4 s reported here, on demand. The two unpressured control blocks in the same session closed at 442 ms and 441 ms, so that gap is not drift.
The proposed fix is dead. Reading every byte of the bundle into cache first — the ceiling a smaller
app.asarcould ever approach — moves the cost from 2120 ms to 2130 ms. Cold pages are worth ~35 ms of the interval; a cold read of the entire 261 MB archive is 110 ms. Memory pressure is real but saturates at about +200 ms, and doubling the pin barely moves it.What it actually is. The first execution of a newly installed binary. 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 vs 441 ms), which rules out any cache the app could pre-warm. Pre-running
spctlandcodesign --verify --deepremoves none of it, so it is not Gatekeeper either. Paid once per install or update; invisible to any benchmark, which launches the same binary dozens of times.What is still open, and now recorded in Known gaps rather than here: which per-inode kernel cache is being populated (
DYLD_PRINT_STATISTICSis stripped under the hardened runtime), whether bundle size matters at all (residency was refuted, size was not — content cannot be removed without invalidating the signature), whether a quarantined download is worse than thedittocopies measured, and why this reproduction puts the bulk of the cost beforestartedwhere the original report put it after. That last one may be a second phenomenon with the same total.docs(perf): settle the macOS export's 4 s cold start, and kill the as…