Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion technical-documentation/engineering/rendering-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

| 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:
Expand Down Expand Up @@ -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 `<video>` metadata probes (13 ms and 6 ms), not the CLI prologue (24 ms total), and not the renderer entry point (measured at −0.1 %). It correlates with memory pressure on an 8 GiB machine — `387M unused / 2613M compressor` while it reproduced, `564M unused / 1837M compressor` after — which would fit faulting ~1.8 MB of module chunks out of a 274 MB `app.asar` while the compressor thrashes: seconds of wall clock, no CPU in either process, cost independent of the media. Untested. Recreating the pressure deliberately and watching it return is what would settle it, and then whether asar size is the lever.
- **The macOS export's 4 s cold start is priced, but the platform mechanism behind it is unnamed.** The cost reproduces on demand and its levers are settled ([Rejected routes](#shrinking-the-macos-appasar-to-cure-the-exports-cold-start)): it is the first execution of a newly installed binary, amplified by memory pressure. Which per-inode cache that first execution populates — page-granular code-signature validation, a dyld launch closure, or both — was not established, because `DYLD_PRINT_STATISTICS` is stripped from a binary signed with the hardened runtime. Three things stay untested. Whether the cost scales with **bundle size** at all: residency was refuted, size was not, and content cannot be removed without invalidating the signature that is part of what is being measured. Whether a **real download** is worse: a quarantined bundle takes a heavier Gatekeeper path than the `ditto` copies used here, so a user's first launch after downloading may cost more than any number above. And the **original report's split**, which put 4.2 s between `started` and the first frame with the renderer's `domInteractive` at 3887 ms, where this reproduction puts the bulk before `started` — same magnitude, different place, so it may be a second phenomenon wearing the same total.
- **10-bit and HEVC decode on macOS are unmeasured.** The export's decode predicate is `codec_id == H264 && format == YUV420P`, so both keep VideoToolbox untested. HEVC is the case most likely to invert the result, since its software decoder is materially more expensive. 10-bit needs work beyond the predicate first: `mac_frames::CpuFrames` converts to 8-bit NV12, so routing 10-bit through the software path would silently truncate — the predicate is currently what prevents that.
- **The macOS preview's decode backend has never been measured.** `DecodeIntent` splits preview from export precisely so the preview could keep the old arbitration; the export won on throughput, but the preview scrubs, where seek latency after `avcodec_flush_buffers` may matter more, and it shares the machine with the editor UI. Changing it without measuring it would be the same mistake the export change corrects.
- **The energy cost of software decode on macOS is unmeasured, and the CPU figure is not a proxy for it.** The export burns 3.5× the **CPU-seconds** it used to (8.4 → 29.8 s), and that is the only thing measured. It does not follow that energy moved by the same factor: on an M-series the P and E cores draw very differently, clock is not fixed, and a shorter run at higher occupancy can spend less total energy than a longer one — racing to idle. Nor is the jump waste: VideoToolbox does the same decoding in a fixed-function block that CPU accounting never sees, so the work did not grow, it moved somewhere visible and got 12× faster on the way. Capping the decoder's threads does **not** recover it (see Rejected routes); what it would buy is lower peak core occupancy — how unusable the machine feels during an export — which is a different question and also unmeasured. `powermetrics` would answer the energy half and needs sudo.
Expand Down
Loading