246 lines
12 KiB
Plaintext
246 lines
12 KiB
Plaintext
#include "../Sources/Core/player_macos.h"
|
|
#import <AppKit/AppKit.h>
|
|
#import <MetalKit/MetalKit.h>
|
|
extern "C" {
|
|
#include <libavutil/log.h>
|
|
}
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <functional>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <sys/resource.h>
|
|
|
|
using Clock = std::chrono::steady_clock;
|
|
|
|
static void require(bool condition, const char *message) {
|
|
if (!condition) {
|
|
std::fprintf(stderr, "FAIL native benchmark: %s\n", message);
|
|
std::exit(1);
|
|
}
|
|
}
|
|
|
|
static void emit(NSDictionary *row) {
|
|
NSError *error = nil;
|
|
NSData *json = [NSJSONSerialization dataWithJSONObject:row options:0 error:&error];
|
|
require(json != nil, "encode JSON result");
|
|
require(std::fwrite(json.bytes, 1, json.length, stdout) == json.length &&
|
|
std::fputc('\n', stdout) != EOF && std::fflush(stdout) == 0, "write JSON result");
|
|
// The runner snapshots upstream counters before acknowledging each boundary.
|
|
// Preparation is excluded; read-ahead concurrent with a measured phase is included.
|
|
std::string acknowledgement;
|
|
require(bool(std::getline(std::cin, acknowledgement)) && acknowledgement.empty(),
|
|
"runner must acknowledge each JSON boundary with an empty line");
|
|
}
|
|
|
|
struct Stamp {
|
|
Clock::time_point wall;
|
|
rusage usage;
|
|
};
|
|
|
|
static Stamp stamp() {
|
|
Stamp result{Clock::now(), {}};
|
|
require(getrusage(RUSAGE_SELF, &result.usage) == 0, "read process resource usage");
|
|
return result;
|
|
}
|
|
|
|
static double cpuMilliseconds(const rusage &usage) {
|
|
return (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) * 1000.0 +
|
|
(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1000.0;
|
|
}
|
|
|
|
static void begin(NSString *phase) {
|
|
emit(@{@"event": @"begin", @"phase": phase});
|
|
}
|
|
|
|
static void finish(NSString *phase, const Stamp &start, const Stamp &end,
|
|
PlayerState state, NSDictionary *details) {
|
|
NSMutableDictionary *row = [@{
|
|
@"phase": phase,
|
|
@"wall_ms": @(std::chrono::duration<double, std::milli>(end.wall - start.wall).count()),
|
|
@"cpu_ms": @(cpuMilliseconds(end.usage) - cpuMilliseconds(start.usage)),
|
|
// Darwin reports ru_maxrss in bytes. This is process-lifetime high-water RSS,
|
|
// not a per-phase delta; CPU includes every native decoder/output thread.
|
|
@"peak_rss_mib": @(end.usage.ru_maxrss / (1024.0 * 1024.0)),
|
|
@"media_seconds": @0,
|
|
@"output_bytes": @0,
|
|
@"position_seconds": @(state.position),
|
|
@"buffered_start": @(state.buffered_start),
|
|
@"buffered_end": @(state.buffered_end)
|
|
} mutableCopy];
|
|
[row addEntriesFromDictionary:details];
|
|
emit(row);
|
|
}
|
|
|
|
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
|
|
const char *phase, MTKView *view = nil) {
|
|
const auto deadline = Clock::now() + std::chrono::seconds(60);
|
|
auto nextDraw = Clock::now();
|
|
while (Clock::now() < deadline) {
|
|
const auto state = player_get_state(player);
|
|
if (state.status == PLAYER_ERROR) {
|
|
std::fprintf(stderr, "FAIL native benchmark %s: %s\n", phase, state.error);
|
|
std::exit(1);
|
|
}
|
|
// Exercise the real Metal renderer at the fixture's 30 fps, not at the
|
|
// 1 kHz state-poll rate. Manual draws also work for this offscreen window.
|
|
if (view && Clock::now() >= nextDraw) {
|
|
[view draw];
|
|
nextDraw = Clock::now() + std::chrono::duration_cast<Clock::duration>(
|
|
std::chrono::duration<double>(1.0 / 30));
|
|
}
|
|
if (ready(state)) return state;
|
|
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .001, false);
|
|
}
|
|
const auto state = player_get_state(player);
|
|
std::fprintf(stderr,
|
|
"FAIL native benchmark %s: deadline status=%d position=%.6f duration=%.6f "
|
|
"pending=%d buffering=%d cache=%.6f..%.6f\n", phase, state.status, state.position,
|
|
state.duration, state.seek_pending, state.buffering, state.buffered_start, state.buffered_end);
|
|
std::exit(1);
|
|
}
|
|
|
|
static bool pausedReady(PlayerState state) {
|
|
return state.status == PLAYER_PAUSED && !state.seek_pending && !state.buffering &&
|
|
state.has_audio && state.has_video && state.can_seek;
|
|
}
|
|
|
|
static PlayerState seek(Player *player, double target, const char *phase) {
|
|
const auto revision = player_get_state(player).revision;
|
|
require(player_seek(player, target), "paused seek accepted");
|
|
return until(player, [=](auto state) {
|
|
// !buffering is important: seek_pending alone acknowledges reader/command
|
|
// work, not decoded preview readiness. The paused tick clears buffering
|
|
// only after presenting a decoded frame and supplying initial audio.
|
|
return pausedReady(state) && state.revision != revision &&
|
|
std::abs(state.position - target) < .05;
|
|
}, phase);
|
|
}
|
|
|
|
static bool cached(PlayerState state, double target) {
|
|
// Leave room for the preceding two-second GOP and following audio/video.
|
|
return pausedReady(state) && state.buffered_end > state.buffered_start &&
|
|
state.buffered_start <= target - 2 && state.buffered_end >= target + 1;
|
|
}
|
|
|
|
static bool uncached(PlayerState state, double target) {
|
|
return pausedReady(state) && state.buffered_end > state.buffered_start &&
|
|
target > state.buffered_end + 2;
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
@autoreleasepool {
|
|
require(argc == 3, "usage: playback-benchmark <loopback-base-url> <split|hls>");
|
|
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 &&
|
|
std::strtoul(base.c_str() + 17, nullptr, 10) > 0 &&
|
|
std::strtoul(base.c_str() + 17, nullptr, 10) <= 65535,
|
|
"fixture base URL must be an http://127.0.0.1:<port> origin");
|
|
require(mode == "split" || mode == "hls", "source must be split or hls");
|
|
const bool split = mode == "split";
|
|
const std::string video = base + (split ? "/media/bench-video.mp4" : "/media/bench.m3u8");
|
|
const std::string audio = split ? base + "/media/bench-audio.m4a" : "";
|
|
PlayerNetworkSource source{video.c_str(), split ? audio.c_str() : nullptr,
|
|
split ? "X-Terebi-Fixture: video\r\n" : "X-Terebi-Fixture: combined\r\n",
|
|
split ? "X-Terebi-Fixture: audio\r\n" : nullptr,
|
|
120, false, split ? nullptr : "miruro:YmVuY2g:1", 0};
|
|
av_log_set_level(AV_LOG_ERROR);
|
|
[NSApplication sharedApplication];
|
|
|
|
// Cold start follows queued autoplay through the first real audio-clock
|
|
// advancement and pause. Buffering gates audio and decoded video readiness.
|
|
// It includes player/audio/Metal setup, but excludes process launch and
|
|
// fixture creation; it is NOT just the initial PLAYER_PAUSED publication.
|
|
begin(@"cold_start");
|
|
const auto coldStart = stamp();
|
|
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
|
require(bool(player), "create player");
|
|
require(player_set_volume(player.get(), 0), "mute real native audio output");
|
|
void *native = player_macos_make_video_view(player.get());
|
|
require(native != nullptr, "initialize native Metal view");
|
|
MTKView *view = (__bridge_transfer MTKView *)native;
|
|
view.paused = YES;
|
|
view.autoResizeDrawable = NO;
|
|
view.drawableSize = CGSizeMake(1280, 720);
|
|
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-4000, -4000, 1280, 720)
|
|
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
|
window.contentView = view;
|
|
[window orderBack:nil];
|
|
require(player_open_network(player.get(), &source), "accept network source");
|
|
require(player_play(player.get()), "queue autoplay during network open");
|
|
until(player.get(), [](auto state) {
|
|
return state.status == PLAYER_PLAYING && !state.buffering && state.position >= .05;
|
|
}, "initial real playback readiness", view);
|
|
require(view.currentDrawable != nil, "initial Metal drawable");
|
|
[view draw];
|
|
require(player_pause(player.get()), "pause initial playback probe");
|
|
auto state = until(player.get(), pausedReady, "initial playback probe paused", view);
|
|
const auto coldEnd = stamp();
|
|
finish(@"cold_start", coldStart, coldEnd, state,
|
|
@{@"readiness": @"queued_autoplay_then_audio_clock_probe_and_pause",
|
|
@"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"});
|
|
|
|
// 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());
|
|
require(uncached(before, missTarget), "uncached seek must lie beyond retained packet coverage");
|
|
const auto missStart = stamp();
|
|
state = seek(player.get(), missTarget, "uncached paused decoded seek");
|
|
[view draw];
|
|
const auto missEnd = stamp();
|
|
finish(@"uncached_seek", missStart, missEnd, state,
|
|
@{@"target_seconds": @(missTarget), @"cache_before_start": @(before.buffered_start),
|
|
@"cache_before_end": @(before.buffered_end), @"cache_precondition": @"outside_retained_packets"});
|
|
[window orderOut:nil];
|
|
window.contentView = nil;
|
|
player.reset();
|
|
}
|
|
}
|