perf: prioritize audio preroll after paused network previews

This commit is contained in:
2026-10-17 02:00:04 +02:00
parent e1284936f9
commit 0aa0ed781f
5 changed files with 118 additions and 47 deletions
+53 -42
View File
@@ -133,7 +133,9 @@ static bool uncached(PlayerState state, double target) {
int main(int argc, char **argv) { int main(int argc, char **argv) {
@autoreleasepool { @autoreleasepool {
require(argc == 3, "usage: playback-benchmark <loopback-base-url> <split|hls>"); require(argc == 3 || (argc == 4 && std::string(argv[3]) == "--seek-work"),
"usage: playback-benchmark <loopback-base-url> <split|hls> [--seek-work]");
const bool seekWork = argc == 4;
const std::string base = argv[1], mode = argv[2]; const std::string base = argv[1], mode = argv[2];
require(base.rfind("http://127.0.0.1:", 0) == 0 && base.size() > 17 && base.size() <= 22 && require(base.rfind("http://127.0.0.1:", 0) == 0 && base.size() > 17 && base.size() <= 22 &&
base.find_first_not_of("0123456789", 17) == std::string::npos && base.find_first_not_of("0123456789", 17) == std::string::npos &&
@@ -185,48 +187,57 @@ int main(int argc, char **argv) {
@"probe_media_seconds": @(state.position), @"render_width": @1280, @"render_height": @720}); @"probe_media_seconds": @(state.position), @"render_width": @1280, @"render_height": @720});
constexpr double hitTarget = 20.5, missTarget = 100.5; constexpr double hitTarget = 20.5, missTarget = 100.5;
// Waiting for retained packet coverage is preparation, not seek latency. auto before = state;
until(player.get(), [](auto s) { return cached(s, hitTarget); }, "cached target coverage"); if (seekWork) {
begin(@"cached_seek"); // Preparation is outside the measured phase. Seek directly from the
auto before = player_get_state(player.get()); // opening paused position, without the cached seek or paced playback.
require(cached(before, hitTarget), "cached seek target and GOP must remain inside retained coverage"); until(player.get(), [](auto s) {
const auto hitStart = stamp(); return pausedReady(s) && s.buffered_end >= 59;
state = seek(player.get(), hitTarget, "cached paused decoded seek"); }, "initial read-ahead coverage");
[view draw]; } else {
const auto hitEnd = stamp(); // Waiting for retained packet coverage is preparation, not seek latency.
finish(@"cached_seek", hitStart, hitEnd, state, until(player.get(), [](auto s) { return cached(s, hitTarget); }, "cached target coverage");
@{@"target_seconds": @(hitTarget), @"cache_before_start": @(before.buffered_start), begin(@"cached_seek");
@"cache_before_end": @(before.buffered_end), @"cache_precondition": @"retained_packet_hit"}); before = player_get_state(player.get());
require(cached(before, hitTarget), "cached seek target and GOP must remain inside retained coverage");
const auto hitStart = stamp();
state = seek(player.get(), hitTarget, "cached paused decoded seek");
[view draw];
const auto hitEnd = stamp();
finish(@"cached_seek", hitStart, hitEnd, state,
@{@"target_seconds": @(hitTarget), @"cache_before_start": @(before.buffered_start),
@"cache_before_end": @(before.buffered_end), @"cache_precondition": @"retained_packet_hit"});
// Sustained is real paced playback from the cached seek, including // Sustained is real paced playback from the cached seek, including
// moving-frontier read-ahead, start and final pause acknowledgement. // moving-frontier read-ahead, start and final pause acknowledgement.
// Position is the actual native audio-master clock. Only watchdogs impose // Position is the actual native audio-master clock. Only watchdogs impose
// elapsed-time limits; the completion condition is twelve media seconds. // elapsed-time limits; the completion condition is twelve media seconds.
begin(@"sustained"); begin(@"sustained");
before = player_get_state(player.get()); before = player_get_state(player.get());
require(pausedReady(before) && std::abs(before.position - hitTarget) < .05 && require(pausedReady(before) && std::abs(before.position - hitTarget) < .05 &&
before.duration > before.position + 12, "sustained starts at acknowledged cached target"); before.duration > before.position + 12, "sustained starts at acknowledged cached target");
const auto sustainedStart = stamp(); const auto sustainedStart = stamp();
auto previousPoll = sustainedStart.wall; auto previousPoll = sustainedStart.wall;
bool wasBuffering = true; bool wasBuffering = true;
double bufferingMilliseconds = 0; double bufferingMilliseconds = 0;
require(player_play(player.get()), "start paced sustained playback"); require(player_play(player.get()), "start paced sustained playback");
state = until(player.get(), [&](auto s) { state = until(player.get(), [&](auto s) {
const auto now = Clock::now(); const auto now = Clock::now();
if (wasBuffering) if (wasBuffering)
bufferingMilliseconds += std::chrono::duration<double, std::milli>(now - previousPoll).count(); bufferingMilliseconds += std::chrono::duration<double, std::milli>(now - previousPoll).count();
previousPoll = now; previousPoll = now;
wasBuffering = s.buffering; wasBuffering = s.buffering;
return s.status == PLAYER_PLAYING && !s.seek_pending && !s.buffering && return s.status == PLAYER_PLAYING && !s.seek_pending && !s.buffering &&
s.position >= before.position + 12; s.position >= before.position + 12;
}, "twelve media seconds of native playback", view); }, "twelve media seconds of native playback", view);
require(player_pause(player.get()), "pause sustained playback"); require(player_pause(player.get()), "pause sustained playback");
state = until(player.get(), pausedReady, "sustained pause acknowledgement", view); state = until(player.get(), pausedReady, "sustained pause acknowledgement", view);
const auto sustainedEnd = stamp(); const auto sustainedEnd = stamp();
finish(@"sustained", sustainedStart, sustainedEnd, state, finish(@"sustained", sustainedStart, sustainedEnd, state,
@{@"media_seconds": @(state.position - before.position), @"paced": @YES, @{@"media_seconds": @(state.position - before.position), @"paced": @YES,
@"start_position_seconds": @(before.position), @"buffering_ms": @(bufferingMilliseconds), @"start_position_seconds": @(before.position), @"buffering_ms": @(bufferingMilliseconds),
@"buffering_measurement": @"sampled_at_approximately_1ms"}); @"buffering_measurement": @"sampled_at_approximately_1ms"});
}
begin(@"uncached_seek"); begin(@"uncached_seek");
before = player_get_state(player.get()); before = player_get_state(player.get());
+47 -4
View File
@@ -59,9 +59,11 @@ class BenchmarkServer(StreamingServer):
"connections": len(self.connections)} "connections": len(self.connections)}
def sample(binary: Path, cast: bool, source: str, server: BenchmarkServer): def sample(binary: Path, cast: bool, source: str, server: BenchmarkServer, *, seek_work: bool = False):
command = [str(binary.resolve())] + (["--playback-benchmark"] if cast else []) command = [str(binary.resolve())] + (["--playback-benchmark"] if cast else [])
command += [server.base_url, source] command += [server.base_url, source]
if seek_work:
command.append("--seek-work")
rows = [] rows = []
# A process deadline is infrastructure protection, never a measured playback clock. # A process deadline is infrastructure protection, never a measured playback clock.
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -102,8 +104,10 @@ def sample(binary: Path, cast: bool, source: str, server: BenchmarkServer):
if process.poll() is None: if process.poll() is None:
process.kill() process.kill()
process.wait() process.wait()
if sorted(row["phase"] for row in rows) != ["cached_seek", "cold_start", "sustained", "uncached_seek"]: expected_phases = (["cold_start", "uncached_seek"] if seek_work else
raise RuntimeError("benchmark must report all four phases exactly once") ["cached_seek", "cold_start", "sustained", "uncached_seek"])
if sorted(row["phase"] for row in rows) != expected_phases:
raise RuntimeError(f"benchmark must report each expected phase exactly once: {expected_phases}")
return rows return rows
@@ -114,13 +118,52 @@ def main():
parser.add_argument("fixtures", type=Path) parser.add_argument("fixtures", type=Path)
parser.add_argument("--runs", type=int, default=3) parser.add_argument("--runs", type=int, default=3)
parser.add_argument("--request-delay-ms", type=float, nargs="+", default=[0, 25]) parser.add_argument("--request-delay-ms", type=float, nargs="+", default=[0, 25])
parser.add_argument("--compare-native", type=Path, metavar="BASELINE_BINARY")
parser.add_argument("--seek-work", action="store_true")
args = parser.parse_args() args = parser.parse_args()
if args.runs < 1 or any(value < 0 for value in args.request_delay_ms): if args.runs < 1 or any(value < 0 for value in args.request_delay_ms):
parser.error("runs must be positive and delays nonnegative") parser.error("runs must be positive and delays nonnegative")
if args.seek_work and not args.compare_native:
parser.error("--seek-work requires --compare-native")
fixtures(args.fixtures) fixtures(args.fixtures)
print(json.dumps({"environment": platform.platform(), "machine": platform.machine(), print(json.dumps({"environment": platform.platform(), "machine": platform.machine(),
"runs": args.runs, "request_delay_ms": args.request_delay_ms}), flush=True) "runs": args.runs, "request_delay_ms": args.request_delay_ms,
**({"baseline": str(args.compare_native), "candidate": str(args.native),
"seek_work": args.seek_work} if args.compare_native else {})}), flush=True)
results = [] results = []
if args.compare_native:
for delay in args.request_delay_ms:
for run in range(1, args.runs + 1):
variants = [("baseline", args.compare_native), ("candidate", args.native)]
if run % 2 == 0:
variants.reverse()
for source in ("split", "hls"):
for variant, binary in variants:
with BenchmarkServer(args.fixtures, delay) as server:
for row in sample(binary, False, source, server, seek_work=args.seek_work):
row.update(engine="native", variant=variant, source=source,
request_delay_ms=delay, run=run)
results.append(row)
print(json.dumps(row, sort_keys=True), flush=True)
print("\nMedians [min, max]; CPU is process CPU; peak RSS is process-lifetime high water; "
"upstream counts include concurrent read-ahead.")
print("| Delay ms | Variant | Source | Phase | Wall ms | CPU ms | Peak MiB | Requests | Upstream bytes |")
print("| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |")
for delay in args.request_delay_ms:
for variant in ("baseline", "candidate"):
for source in ("split", "hls"):
phases = dict.fromkeys(row["phase"] for row in results)
for phase in phases:
group = [row for row in results if
(row["request_delay_ms"], row["variant"], row["source"], row["phase"]) ==
(delay, variant, source, phase)]
def summary(key):
values = [row[key] for row in group]
return f"{statistics.median(values):.2f} [{min(values):.2f}, {max(values):.2f}]"
print(f"| {delay:g} | {variant} | {source} | {phase} | "
f"{summary('wall_ms')} | {summary('cpu_ms')} | {summary('peak_rss_mib')} | "
f"{summary('upstream_requests')} | {summary('upstream_bytes')} |")
return
for delay in args.request_delay_ms: for delay in args.request_delay_ms:
for run in range(1, args.runs + 1): for run in range(1, args.runs + 1):
for source in ("split", "hls"): for source in ("split", "hls"):
+1
View File
@@ -138,5 +138,6 @@ make check
The full suite requires a graphical macOS session and a working audio device; it launches isolated app instances and plays short test audio. Targeted `check-*` targets are listed in the [Makefile](Makefile). Visual appearance, physical media-key routing, and receiver behavior still need manual checks. The full suite requires a graphical macOS session and a working audio device; it launches isolated app instances and plays short test audio. Targeted `check-*` targets are listed in the [Makefile](Makefile). Visual appearance, physical media-key routing, and receiver behavior still need manual checks.
`make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency. `make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency.
The [benchmark runner](Checks/playback-benchmark.py) accepts `--compare-native <baseline-binary>` for alternating native-only comparisons; add `--seek-work` to isolate cache-miss seeks when both binaries support that mode. Preserve the baseline executable before rebuilding.
Playback terminology: [CONTEXT.md](CONTEXT.md). Playback terminology: [CONTEXT.md](CONTEXT.md).
+5 -1
View File
@@ -1117,8 +1117,12 @@ struct Player {
bool decodeOne(bool stepping = false) { bool decodeOne(bool stepping = false) {
for (const auto &input : inputs) for (const auto &input : inputs)
if (input) check(input->error(), "Reading network media"); if (input) check(input->error(), "Reading network media");
// Once a paused network preview is shown, finish audio preroll first.
// Video remains a fallback so a combined reader can relieve byte pressure.
const int firstSlot = network && status == PLAYER_PAUSED && previewShown && !stepping ?
0 : nextDecodeSlot;
for (int offset = 0; offset < 2; ++offset) { for (int offset = 0; offset < 2; ++offset) {
const int slot = (nextDecodeSlot + offset) % 2; const int slot = (firstSlot + offset) % 2;
auto &track = tracks[slot]; auto &track = tracks[slot];
if (!track.codec || track.drained || (stepping && slot == 0)) continue; if (!track.codec || track.drained || (stepping && slot == 0)) continue;
if (slot == 0 && pcmOffset < pcmFrames) continue; if (slot == 0 && pcmOffset < pcmFrames) continue;
+12
View File
@@ -31,6 +31,18 @@
- Memory/request baseline: median pre-validation lifetime peak RSS across profiles was 230–251 MiB native / 238–253 MiB Cast, including renderer/loopback-client overhead. Zero-delay cold windows transferred roughly 51–55 MiB, with 6/12 native and 7/12 Cast upstream requests for split/HLS; counts and bytes include concurrent read-ahead, so even a cache hit can show network activity. Keep existing cache/segment limits and origin/header checks. - Memory/request baseline: median pre-validation lifetime peak RSS across profiles was 230–251 MiB native / 238–253 MiB Cast, including renderer/loopback-client overhead. Zero-delay cold windows transferred roughly 51–55 MiB, with 6/12 native and 7/12 Cast upstream requests for split/HLS; counts and bytes include concurrent read-ahead, so even a cache hit can show network activity. Keep existing cache/segment limits and origin/header checks.
- All 24 source sessions passed (96 phase rows): accurate native seek acknowledgements, cache preconditions, real audio-clock advancement, repeated Cast byte equality, and independently decodable Cast endpoint segments with expected frame counts/timestamps/AAC. Large timing spread (25ms-delay Cast split cold 703–1973ms) prevents causal latency/CPU comparisons; no persistence/segment-size benefit is claimed. Fresh process/source is not cold OS caches; process CPU is not total hardware energy. Resolver/CLI launch, live CDN/TLS, bandwidth limits, other codecs/remux, visual appearance and physical receivers are not measured. - All 24 source sessions passed (96 phase rows): accurate native seek acknowledgements, cache preconditions, real audio-clock advancement, repeated Cast byte equality, and independently decodable Cast endpoint segments with expected frame counts/timestamps/AAC. Large timing spread (25ms-delay Cast split cold 703–1973ms) prevents causal latency/CPU comparisons; no persistence/segment-size benefit is claimed. Fresh process/source is not cold OS caches; process CPU is not total hardware energy. Resolver/CLI launch, live CDN/TLS, bandwidth limits, other codecs/remux, visual appearance and physical receivers are not measured.
- do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs - do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs
- [x] prioritize audio preroll after a paused network preview instead of decoding unnecessary future video
- Temporary stage profiling isolated the cost: the HLS 100.5s miss returned packets from 96.021s in 2–3ms, but video decoding continued after preview while audio caught up. Trying audio first after preview reduced decoded video frames from 200 to 136, preserving all 135 preroll frames and the same target preview. Video remains a fallback when audio cannot progress; local shared demux, playing and frame-stepping scheduling are unchanged. Removed all profiling and experimental wakeup code.
- Six interleaved focused pairs per delay profile supported the change. Final unprofiled validation used two alternating-order pairs per source/delay, including cold start, cache-hit seek, 12 media seconds of paced playback and the cache-miss seek. M4 and the same synthetic H.264/AAC fixture as above; medians below describe this fixture, not live CDN performance.
| Added delay ms | HLS miss before ms | HLS miss after ms | Process CPU before ms | Process CPU after ms |
| ---: | ---: | ---: | ---: | ---: |
| 0 | 215.97 | 161.06 | 53.94 | 42.51 |
| 25 | 241.03 | 183.32 | 53.45 | 44.00 |
- HLS miss latency fell 25%/24%, with process CPU down 21%/18%. Both profiles retained four miss-window upstream requests and 21,205,460 bytes per sample. Split-source seeks showed no consistent improvement; no sustained-playback, RSS or energy benefit is claimed. Cache limits, HTTP persistence, origin/header checks and Cast behavior are unchanged.
- Reproduce full-path comparisons after preserving a parent-revision executable as `.build/playback-benchmark-baseline`: `python3 Checks/playback-benchmark.py .build/playback-benchmark .build/cast-media-check .build/fixtures/playback-benchmark --compare-native .build/playback-benchmark-baseline --runs 2`. `--seek-work` is a faster direct cache-miss mode when both binaries support it.
- Verified the final production build with all 16 comparison sessions (64 phase rows), `make check-network` (including encrypted/fMP4 HLS, exact frame steps, resume/EOF, stalls/cancellation and credential/origin isolation) and `make check-seek-queue`. Remaining HLS preroll and Cast first-segment latency are separate opportunities, not included in this change.
- [ ] add playlist support - [ ] add playlist support
- maybe by allowing multiple file selection? 🤔 - maybe by allowing multiple file selection? 🤔
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - [ ] in the "choose what to play" screen, we should change the youtube button to general streaming