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) {
@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];
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 &&
@@ -185,48 +187,57 @@ int main(int argc, char **argv) {
@"probe_media_seconds": @(state.position), @"render_width": @1280, @"render_height": @720});
constexpr double hitTarget = 20.5, missTarget = 100.5;
// Waiting for retained packet coverage is preparation, not seek latency.
until(player.get(), [](auto s) { return cached(s, hitTarget); }, "cached target coverage");
begin(@"cached_seek");
auto 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"});
auto before = state;
if (seekWork) {
// Preparation is outside the measured phase. Seek directly from the
// opening paused position, without the cached seek or paced playback.
until(player.get(), [](auto s) {
return pausedReady(s) && s.buffered_end >= 59;
}, "initial read-ahead coverage");
} else {
// Waiting for retained packet coverage is preparation, not seek latency.
until(player.get(), [](auto s) { return cached(s, hitTarget); }, "cached target coverage");
begin(@"cached_seek");
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
// moving-frontier read-ahead, start and final pause acknowledgement.
// Position is the actual native audio-master clock. Only watchdogs impose
// elapsed-time limits; the completion condition is twelve media seconds.
begin(@"sustained");
before = player_get_state(player.get());
require(pausedReady(before) && std::abs(before.position - hitTarget) < .05 &&
before.duration > before.position + 12, "sustained starts at acknowledged cached target");
const auto sustainedStart = stamp();
auto previousPoll = sustainedStart.wall;
bool wasBuffering = true;
double bufferingMilliseconds = 0;
require(player_play(player.get()), "start paced sustained playback");
state = until(player.get(), [&](auto s) {
const auto now = Clock::now();
if (wasBuffering)
bufferingMilliseconds += std::chrono::duration<double, std::milli>(now - previousPoll).count();
previousPoll = now;
wasBuffering = s.buffering;
return s.status == PLAYER_PLAYING && !s.seek_pending && !s.buffering &&
s.position >= before.position + 12;
}, "twelve media seconds of native playback", view);
require(player_pause(player.get()), "pause sustained playback");
state = until(player.get(), pausedReady, "sustained pause acknowledgement", view);
const auto sustainedEnd = stamp();
finish(@"sustained", sustainedStart, sustainedEnd, state,
@{@"media_seconds": @(state.position - before.position), @"paced": @YES,
@"start_position_seconds": @(before.position), @"buffering_ms": @(bufferingMilliseconds),
@"buffering_measurement": @"sampled_at_approximately_1ms"});
// Sustained is real paced playback from the cached seek, including
// moving-frontier read-ahead, start and final pause acknowledgement.
// Position is the actual native audio-master clock. Only watchdogs impose
// elapsed-time limits; the completion condition is twelve media seconds.
begin(@"sustained");
before = player_get_state(player.get());
require(pausedReady(before) && std::abs(before.position - hitTarget) < .05 &&
before.duration > before.position + 12, "sustained starts at acknowledged cached target");
const auto sustainedStart = stamp();
auto previousPoll = sustainedStart.wall;
bool wasBuffering = true;
double bufferingMilliseconds = 0;
require(player_play(player.get()), "start paced sustained playback");
state = until(player.get(), [&](auto s) {
const auto now = Clock::now();
if (wasBuffering)
bufferingMilliseconds += std::chrono::duration<double, std::milli>(now - previousPoll).count();
previousPoll = now;
wasBuffering = s.buffering;
return s.status == PLAYER_PLAYING && !s.seek_pending && !s.buffering &&
s.position >= before.position + 12;
}, "twelve media seconds of native playback", view);
require(player_pause(player.get()), "pause sustained playback");
state = until(player.get(), pausedReady, "sustained pause acknowledgement", view);
const auto sustainedEnd = stamp();
finish(@"sustained", sustainedStart, sustainedEnd, state,
@{@"media_seconds": @(state.position - before.position), @"paced": @YES,
@"start_position_seconds": @(before.position), @"buffering_ms": @(bufferingMilliseconds),
@"buffering_measurement": @"sampled_at_approximately_1ms"});
}
begin(@"uncached_seek");
before = player_get_state(player.get());
+47 -4
View File
@@ -59,9 +59,11 @@ class BenchmarkServer(StreamingServer):
"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 += [server.base_url, source]
if seek_work:
command.append("--seek-work")
rows = []
# A process deadline is infrastructure protection, never a measured playback clock.
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:
process.kill()
process.wait()
if sorted(row["phase"] for row in rows) != ["cached_seek", "cold_start", "sustained", "uncached_seek"]:
raise RuntimeError("benchmark must report all four phases exactly once")
expected_phases = (["cold_start", "uncached_seek"] if seek_work else
["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
@@ -114,13 +118,52 @@ def main():
parser.add_argument("fixtures", type=Path)
parser.add_argument("--runs", type=int, default=3)
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()
if args.runs < 1 or any(value < 0 for value in args.request_delay_ms):
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)
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 = []
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 run in range(1, args.runs + 1):
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.
`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).
+5 -1
View File
@@ -1117,8 +1117,12 @@ struct Player {
bool decodeOne(bool stepping = false) {
for (const auto &input : inputs)
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) {
const int slot = (nextDecodeSlot + offset) % 2;
const int slot = (firstSlot + offset) % 2;
auto &track = tracks[slot];
if (!track.codec || track.drained || (stepping && slot == 0)) 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.
- 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
- [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
- maybe by allowing multiple file selection? 🤔
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming