Skip to content

$subtitleCues publishes the cumulative array once per decoded packet, so every consumer pays O(n) per packet #271

Description

@edde746

Rewritten after fixing this on the host side and re-measuring on device. My original report
attributed an ~18-second main-actor stall to the engine's uncapped drain tick. That attribution was
wrong and I retract it.
The same media on the same code path no longer freezes after a change
entirely in my own host code — memoizing a per-cue text conversion my cue consumer was re-running over
the whole cumulative snapshot on every publication. The engine tick still decodes the same ~3,132
packets; it was not what wedged the actor. What is below is what remains measurable from the host side.

What should the engine do?

$subtitleCues is published once per decoded subtitle packet, and each publication carries the
entire cumulative cue array. subtitleDrainTick calls applySubtitleEvent inside its packet loop
(AetherEngine+Subtitles.swift:297-305), and applyEventMutations performs a full get/set of the
@Published property per event. Any consumer that walks the array therefore does O(n) work per packet,
which is O(n²) per drain window — and there are three consumers today (the host's libass presenter, the
legacy overlay, and the engine's own software PiP compositor sink).

Ask: bind the channel's cue array once per tick, apply every event of the window to it, and publish
once at the end. applyEventMutations already takes inout [SubtitleCue], so this is hoisting the
binding out of the loop.

Measured, on device

Typeset ASS track (stream 5 of an MKV with font attachments), engine 6.4.0 (b43f17df), native AVPlayer
path, iPhone. One second of steady-state playback, host telemetry:

overlay: driver=active ticks=62 … | pub=104 scan=608608 add=0 build=0 ingestMs=43.4/4.4
main:    beats=4 windowMs=1078.8 maxLateMs=46.9 clockMs=135400 inFlight=idle
  • 104 publications per second, each carrying ~5,852 retained cues → 608,608 cue visits per second
    in one consumer alone.
  • The host spends 43 ms/s just walking them, with main-actor lateness of 35–66 ms.
  • add=0 — none of those visits found new work. The walk is pure overhead.

A host cannot avoid the walk: a cumulative snapshot does not say which elements are new. On this file,
publishing once per tick instead of once per packet would take 104 publications/s to roughly 2/s.

For scale on window size, ffprobe over the window one reset tick covered (playhead 114.9 s, backscan
15, lead 60, so 99.9–174.9 s): 3132 subtitle packets, 240 sharing PTS 107.680, 303 on the densest
single PTS.

Two further items, labelled by how I know them

Code-derived, not observed as the freeze cause. subtitleDrainTick is a synchronous @MainActor
function whose window is bounded in seconds of content (backscan 15 + lead 60) and never in packets or
wall time, and its loop is unbounded (:297-305). The OCR drainer treats the same shape as needing a
bound — entries.prefix(Self.subtitleOCRMaxPacketsPerTick) (AetherEngine+SubtitleOCR.swift:82). With
cheap consumers this no longer stalls anything I can measure, but one dense window still runs to
completion with no suspension point, so the ceiling is set by file density rather than by the engine.

One constraint if that cap is ported as-is: the cursor is a bare PTS advanced by lastDecodedPts.nextUp
(SubtitleOverlayDrainer.swift:48), so truncating a batch inside a same-PTS run would skip the
remainder rather than resume it. Harmless in the OCR worker (one composition per PTS on bitmap tracks);
data loss here, where a dense ASS track deliberately keeps every distinct payload on a shared timestamp.
A batch boundary has to fall on a PTS boundary, or the cursor needs an intra-PTS index.

Latent, and no longer reachable on this sample. drainPlan compares the live playhead against
cursor.lastPlayhead, which is captured at tick start (:251, 310-313), against
subtitleDrainJumpThresholdSeconds of 2.5. Any tick lasting longer than that makes the next tick see a
discontinuity and reset onto a fresh, disjoint 75-second window instead of decoding forward from the
cursor. That is a positive feedback loop for any slow tick, whatever the cause. Re-reading sourceTime
at tick end, or comparing the jump against elapsed wall time, would keep self-inflicted drift from
reading as a seek.

Area

Performance

Host app / integration

Custom

Would you be willing to open a PR?

Maybe, with guidance

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions