Skip to content

perf(runtime): derive composition duration only when the composition changes - #3830

Closed
miguel-heygen wants to merge 1 commit into
mainfrom
perf/duration-cache
Closed

perf(runtime): derive composition duration only when the composition changes#3830
miguel-heygen wants to merge 1 commit into
mainfrom
perf/duration-cache

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

The problem

A paused editor with nothing happening burns CPU continuously, and a large share of it is the runtime recomputing a number that has not changed.

transportTick calls getSafeTimelineDurationSeconds on every animation frame, unconditionally. Deriving that value scans every video[data-start], audio[data-start] in the document and, per element, resolves an absolute start by walking its composition ancestry, then does the same for nested composition windows. The result is a function of the composition, not of the playhead — so on a paused, untouched composition it produces the same answer ~60 times a second, forever.

Measured on a 91-media-element composition, paused and untouched: the media scan ran 1.05 times per animation frame. That is what a user feels as a warm laptop with an editor open and nothing happening.

The change

Derive the two DOM-derived duration floors once and reuse them until an input could have changed. Nothing about the returned value changes; only how often it is computed.

The honest part of this is the invalidation set, so here it is in full. Each input, and the signal that catches it:

Input that can change the answer Signal
Timing attributes edited (live editing, variables re-applied, the runtime's own autostamping) MutationObserver, attributeFilter over the timing attributes the resolvers actually read
Timed elements added or removed, including nested compositions that mount asynchronously after init The same observer, childList + subtree
Media metadata arriving, or el.load() resetting duration to NaN — neither mutates the DOM Capture-phase loadedmetadata / durationchange / emptied listeners on document
A timeline registered, or an existing one lengthened, in window.__timelines — a plain object nothing can observe A registry signature compared on read

The bias is deliberate and stated in the code: a redundant derivation is a missed optimisation, a missed one is a wrong duration.

Observer records are delivered in a microtask, so the pending queue is drained on read as well (takeRecords()). Without that, a caller that edits a timing attribute and reads the duration back in the same synchronous block — the ordinary live-editing case — would be served the pre-edit value. Taking the records suppresses the callback for them, which is exactly equivalent, since the callback only marks the cache stale.

The render path does not read the cache at all. Capture depends on the exact duration and a frame rendered against a wrong one cannot be recovered, so once renderSeek has been called the derivation runs in full on every frame, exactly as before. The producer drives frames through window.__player.renderSeek(...), so the flag is set before any captured frame.

resolveAdapterDurationFloorSeconds() is deliberately left uncached: adapters infer duration from live animation objects (CSS/WAAPI/Lottie) that can change with no DOM mutation or media event to observe, and it is a short loop over registered adapters rather than a document scan.

Results

Two studio previews, arms differing only by the runtime bundle (the harness request-intercepts the runtime and fails the run if interception never fires), interleaved.

The headline number is a count, not a duration. The machine was heavily loaded while measuring (1-minute load average 40–98), which makes every millisecond figure unreliable — but a count of work done is invariant to contention, and it is a stronger claim anyway.

Idle, paused and untouched:

Composition Media scans per frame, before after
91 media elements 1.05 0.05
25-block carousel 1.05 0.05

Repeated across two passes each; identical to two decimal places. On the carousel the authored-composition scan likewise went from 1.00 per frame to 0.00. The residual 0.05 per frame is a separate per-20-tick caller in timeline.ts that this change does not touch.

Across 100 single-frame seeks, confirming the cache does not thrash while scrubbing:

Composition Media scans per seek, before after
91 media elements 2.28 0.11
25-block carousel 1.45 0.10

Does the invalidation cost as much as the recompute? No, and it is worth showing rather than asserting. Timed in-page, same realm, interleaved so contention hits both equally:

Composition Derivation Cache-hit read path Ratio
91 media elements, 22 registered timelines 0.0933 ms 0.0010 ms 93x
25-block carousel, 27 registered timelines 0.0243 ms 0.0007 ms 35x

The read path is the registry signature plus a takeRecords() drain. The derivation figure understates the real cost, since the in-page probe does not reconstruct a timing resolver the way the real derivation does, so treat the ratio as a lower bound.

Wall-clock CPU was measured too and is not reported as fact: at load 40–98 the before/after renderer CPU figures moved in both directions and are pure noise.

Tests

packages/core: bun run test:hyperframe-runtime-ci passes end to end (typecheck, preview guards, build, contract, behaviour, seek, duration, parity, security, coverage, linter). The two arms that pin the invariants at stake here:

$ bun run test:hyperframe-runtime-duration-guards
{"event":"hyperframe_runtime_duration_guards_verified", ...}

$ bun run test:hyperframe-runtime-parity
{"event":"hyperframe_runtime_parity_verified","bytes":400875,"sha256":"d588ab2c..."}

Root bun run lint and bun run format:check are clean. 1091 runtime tests pass.

11 new tests, and the two things they are built to prove:

Invariance, not a threshold. A threshold ("fewer than N derivations") passes on a small fixture and still degrades linearly in production. The test asserts that quadrupling the frame count does not change the derivation count at all. Reverting the cache in place turns that assertion into expected 25 to be 100 — 25 frames produced 25 derivations, 100 produced 100, exactly one per frame.

Every invalidation signal is load-bearing. Each was disabled in turn to confirm a specific test notices:

Signal removed Tests that fail
childList + subtree 5
attributeFilter 3
Registry signature 1
takeRecords() drain 5
Media event listeners 2

The takeRecords() row is the one worth pausing on: it looks like defensive polish, and removing it breaks nearly every DOM-driven invalidation, because they all read back inside the same task.

The counter is non-invasive — no production test hook. It counts calls to [data-composition-id][data-start], which the runtime queries in exactly one place, reachable only from a real derivation.

Noted, not fixed

timeline.ts carries near-copies of two functions this change touches. resolveMediaElementDurationSeconds is a true clone. resolveMediaWindowEndSeconds is not a safe merge: it resolves a clip's start from the raw data-start attribute, while the runtime's version routes through the media-start-basis resolution. Those two can disagree. That needs a decision about which is correct rather than a silent consolidation, so it is called out here rather than folded into a performance change.

…changes

The runtime re-derived the composition's total duration on every animation
frame. transportTick called getSafeTimelineDurationSeconds unconditionally,
and deriving it scans every media element in the document and walks each
one's composition ancestry to resolve an absolute start and duration.

That value is a function of the composition, not of the playhead, so a
paused editor with nothing happening recomputed the same answer ~60 times a
second. On a 91-media-element composition the media scan ran 1.05 times per
frame while the editor sat idle and untouched.

Derive it once and reuse it until an input could have changed. The inputs,
and the signal that catches each:

  - timing attributes edited (live editing, variables re-applied, the
    runtime's own autostamping) -> MutationObserver with an attribute filter
  - timed elements added or removed, including nested compositions that
    mount asynchronously after init -> the same observer, childList+subtree
  - media metadata arriving, or el.load() resetting duration to NaN, neither
    of which mutates the DOM -> capture-phase media event listeners
  - a timeline registered or lengthened in window.__timelines, a plain
    object nothing can observe -> a registry signature compared on read

Observer records are delivered in a microtask, so the queue is drained on
read as well; otherwise an edit read back in the same synchronous block, the
ordinary live-editing case, would be served the pre-edit value.

The render path does not read the cache at all. Capture depends on the exact
duration and a frame rendered against a wrong one cannot be recovered, so it
pays the full derivation on every frame exactly as before.

Measured on the 91-media-element composition, idle and untouched: media
scans per frame 1.05 -> 0.05. Across 100 single-frame seeks: 2.28 -> 0.11
per seek, so seeking does not invalidate the cache either. The residual is
a separate per-20-tick caller in timeline.ts, untouched here.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Folded into #3824 so the two runtime scrub-cost changes review as one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant