Skip to content

6.5.4 - The segment pump steps down when nothing waits on it

Choose a tag to compare

@superuser404notfound superuser404notfound released this 03 Aug 06:02

Reported by @edde746 with a paired energy measurement on an iPhone Air (#286): HLSSegmentProducer's pump queue is created at .userInitiated and stays there for the whole session, and demoting that one queue to .utility cut the process's own draw from 10.2 mW to 4.1 mW at identical throughput and identical position advance. Nothing was made cheaper, process CPU rose 7.8 percent, so the work moved to more power-efficient cores rather than shrinking. The request was for the class to be host-configurable, explicitly not for a changed default.

Changed

  • The HLS segment pump runs at the efficiency QoS whenever nothing is waiting on it. A single class for the whole session is wrong in both directions. The pump is minutes of content ahead and parked on backpressure for nearly all of a steady-state window, where .userInitiated buys nothing; and it is on AVPlayer's critical path every time a segment that has not been cut yet is requested, where .utility costs. HLSLocalServer answers segment requests from a .userInitiated work queue, and on a cache miss VideoSegmentProvider.serveSegment parks that thread inside cache.fetch until this pump produces the segment. NSCondition does not donate priority across a wait dependency, so a permanently demoted pump is a priority inversion dispatch has no way to see, and a cache miss is not an edge case: it is cold start, seek landing and rebuffer.

    The pump therefore owns its thread now, because a dispatch queue's QoS is fixed at creation while the pump's urgency changes inside one long-running block, and it retunes its own class as it runs. It stays responsive until the consumer has started rendering, and while the consumer sits within 16 s of content of what this pump has produced. Beyond that it runs at .utility. Live is excluded: its production is source-paced, and the LL-HLS blocking reload holds an AVPlayer request open on the very next segment, so it is latency-critical throughout.

Measurement

M1 (4P + 4E), box saturated with eight concurrent software HEVC decodes, aetherctl play on an HEVC Main10 1080p23.976 2.9 Mbps MKV, arms alternating within each repeat, medians of 5:

.userInitiated .utility adaptive
forward window filled 0.159 s 1.212 s 0.150 s
time to first frame 0.140 s 0.240 s 0.140 s

The two distributions do not overlap on the producer number: pinned to .utility the window took 0.97 s to 1.54 s to fill across five runs, against 0.10 s to 0.36 s for the other two arms. On an idle box all three arms are indistinguishable (0.067 s and 0.100 s), which is consistent with the report and is exactly why one device with thermal headroom cannot settle the question.

A rapid-seek burst under the same load discriminates nothing: 18 of 18 runs settled at a 0.26 s maximum wedge in every arm.

What the Mac cannot reproduce is the energy side. Per-process draw stayed within noise across the arms here (3.19, 3.28, 3.31 mW), so the 60 percent stands as the reporter's measurement, not ours. What it can show is the residency that saving rests on. Over a 120 s steady-state window, ri_cpu_time_qos_utility was 65 ms before this change, 419 ms pinned to .utility, and 416 ms adaptive: the pump spends essentially all the same CPU time in the efficiency class, without paying for it at startup.

Notes

Not a LoadOptions knob, because a knob relocates the question rather than answering it. The host does not know which regime a session is in either, so whichever value it set would be wrong in the other one. The engine does know: it can see the consumer's fetch target, its own production head, and whether the first frame has rendered.

Two guards in that decision were put there by measurement, not by design. Demoting as soon as the consumer has fetched anything is too early, because AVPlayer keeps filling its startup buffer after the first segment; doing so cost 80 ms of time to first frame under load with the forward buffer already nine segments deep. And the lead has to be measured against what the current pump has stored, not cache.highestStoredIndex: that high-water is monotonic across producer epochs, so a pump restarted for a seek read the previous epoch's head, computed a comfortable lead over content it had not produced, and demoted itself 5 ms into the seek landing.

The class is read back after every change. A thread opted out of the QoS system keeps its old class silently, and the whole mechanism would then be a no-op that still looked configured.

Compatibility

No API changes. VOD only; live production is untouched.