perf: benchmark native and Cast startup, seeks, and sustained playback

This commit is contained in:
2026-10-17 01:44:57 +02:00
parent fcad96de62
commit e1284936f9
6 changed files with 572 additions and 2 deletions
+148
View File
@@ -31,6 +31,7 @@ extern "C" {
#include <fcntl.h>
#include <poll.h>
#include <spawn.h>
#include <sys/resource.h>
#include <sys/wait.h>
extern char **environ;
@@ -830,7 +831,154 @@ static void decodeWork(const std::filesystem::path &directory, const std::string
decodeUsage(beforeDecode);
std::cout << "\ncast-media: codec decode, independent IDRs, AAC continuity, sequential and random seeks passed\n";
}
// External fixtures keep upstream serving CPU/RSS out of the relay process.
// Cache seek means retained generated TS, not a packet-cache seek. All output
// decoding happens after the final snapshot, so validation cannot inflate any
// phase's CPU or process high-water RSS. Only endpoint responses are retained.
static void playbackBenchmark(std::string origin, const std::string &kind) {
if (origin.ends_with('/')) origin.pop_back();
const std::string prefix = "http://127.0.0.1:";
assert(origin.starts_with(prefix));
assert(origin.size() > prefix.size() && origin.find_first_not_of("0123456789", prefix.size()) == std::string::npos);
const int port = std::stoi(origin.substr(prefix.size())); assert(port > 0 && port <= 65535);
assert(kind == "split" || kind == "hls");
const bool split = kind == "split";
const std::string video = origin + (split ? "/media/bench-video.mp4" : "/media/bench.m3u8");
const std::string audio = origin + "/media/bench-audio.m4a";
const CastMediaSource source{video.c_str(), split ? "X-Terebi-Fixture: video\r\n" : "X-Terebi-Fixture: combined\r\n",
split ? audio.c_str() : nullptr, split ? "X-Terebi-Fixture: audio\r\n" : nullptr, false, !split, false, 120};
auto acknowledge = [] {
std::string line; assert(bool(std::getline(std::cin, line)));
};
auto snapshot = [] {
rusage usage{}; assert(getrusage(RUSAGE_SELF, &usage) == 0);
return std::pair{std::chrono::steady_clock::now(), usage};
};
auto begin = [&](const char *phase) {
std::cout << "{\"event\":\"begin\",\"phase\":\"" << phase << "\"}" << std::endl;
acknowledge();
return snapshot();
};
auto cpuMS = [](const rusage &usage) {
return 1000.0 * (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec)
+ (usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1000.0;
};
auto wallMS = [](auto from, auto to) {
return std::chrono::duration<double, std::milli>(to - from).count();
};
auto finish = [&](const char *phase, const auto &before, const auto &after, double seconds, size_t bytes, const std::string &extra = "") {
#ifdef __APPLE__
const double rssMiB = double(after.second.ru_maxrss) / (1024 * 1024);
#else
const double rssMiB = double(after.second.ru_maxrss) / 1024;
#endif
std::cout << "{\"phase\":\"" << phase << "\",\"wall_ms\":" << wallMS(before.first, after.first)
<< ",\"cpu_ms\":" << cpuMS(after.second) - cpuMS(before.second)
<< ",\"peak_rss_mib\":" << rssMiB << ",\"media_seconds\":" << seconds
<< ",\"output_bytes\":" << bytes << extra << "}" << std::endl;
acknowledge();
};
std::map<int, HTTP> outputs;
std::string url; double duration = 0;
const auto cold = begin("cold_start");
auto *media = start(source, url, duration);
const auto prepared = std::chrono::steady_clock::now();
const auto manifest = request(url);
const auto base = url.substr(0, url.rfind('/') + 1);
const auto firstBegan = std::chrono::steady_clock::now();
outputs.emplace(0, request(base + "segment/0.ts"));
const auto coldEnd = snapshot();
finish("cold_start", cold, coldEnd, 0, outputs.at(0).bytes.size(),
",\"prepare_ms\":" + std::to_string(wallMS(cold.first, prepared))
+ ",\"manifest_ms\":" + std::to_string(wallMS(prepared, firstBegan))
+ ",\"first_segment_ms\":" + std::to_string(wallMS(firstBegan, coldEnd.first)));
assert(duration >= 120 && duration < 121 && manifest.status == 200);
const std::string playlist(manifest.bytes.begin(), manifest.bytes.end());
assert(playlist.find("#EXT-X-ENDLIST") != std::string::npos);
std::vector<double> boundaries{0};
for (size_t at = 0; (at = playlist.find("#EXTINF:", at)) != std::string::npos; ++at)
boundaries.push_back(boundaries.back() + std::stod(playlist.substr(at + 8)));
assert(boundaries.size() >= 18);
for (int id = 0; id <= 17; ++id) assert(std::abs(boundaries[id] - id * 6) < .00002);
// No intervening generation: segment zero is in the eight-entry TS cache.
{
const auto cached = begin("cached_seek");
const auto repeated = request(base + "segment/0.ts");
const auto cachedEnd = snapshot();
finish("cached_seek", cached, cachedEnd, 0, repeated.bytes.size(), ",\"segment_id\":0");
assert(repeated.status == 200 && repeated.bytes == outputs.at(0).bytes);
}
outputs.clear();
// Sequential generation stays on the warm pipeline. The initialization and
// first six seconds above are deliberately excluded; there is no pacing.
size_t producedBytes = 0;
const auto sustained = begin("sustained");
for (int id = 1; id <= 10; ++id) {
auto response = request(base + "segment/" + std::to_string(id) + ".ts");
producedBytes += response.bytes.size();
assert(response.status == 200);
if (id == 10) outputs.emplace(id, std::move(response));
}
const auto sustainedEnd = snapshot();
finish("sustained", sustained, sustainedEnd, boundaries[11] - boundaries[1], producedBytes,
",\"unpaced\":true,\"first_segment_excluded\":true,\"first_segment_id\":1,\"segment_count\":10");
// Generating through 66s evicts segment zero from the eight-entry TS cache
// and its packets from the thirty-second rewind history: a miss in both.
const auto uncached = begin("uncached_seek");
outputs.emplace(0, request(base + "segment/0.ts"));
const auto uncachedEnd = snapshot();
finish("uncached_seek", uncached, uncachedEnd, 0, outputs.at(0).bytes.size(), ",\"segment_id\":0");
cast_media_destroy(media);
char temp[] = "/tmp/terebi-cast-benchmark.XXXXXX"; assert(mkdtemp(temp));
const auto output = (std::filesystem::path(temp) / "segment.ts").string();
for (const auto &[id, response] : outputs) {
assert(response.status == 200);
writeFile(output, response.bytes);
// samples requires an IDR entry, strict independent decode, both A/V,
// monotonic DTS and one decoded frame for every compressed sample.
const auto rows = samples(output, 1);
int videos = 0, audios = 0;
int64_t firstAudio = 0, firstVideo = 0;
for (const auto &[key, value] : rows) {
assert(key.second >= id * 6 * 90000 && key.second < (id + 1) * 6 * 90000);
if (key.first) {
if (!videos) {
firstVideo = key.second;
// The parent's HLS TS starts AAC 21.334ms before video;
// split MP4 starts both at zero. Allow TS rescale rounding.
const int64_t expected = id * 6 * 90000 + (split ? 0 : 1920);
assert(std::abs(firstVideo - expected) <= 2);
}
assert(key.second == firstVideo + videos * 3000);
++videos;
} else {
if (!audios) {
firstAudio = key.second;
assert(firstAudio < id * 6 * 90000 + 1922);
}
assert(key.second == firstAudio + audios * 1920);
++audios;
}
}
assert(videos == 180 && audios >= 281 && audios <= 282);
}
std::filesystem::remove_all(temp);
std::cerr << "cast playback benchmark: independently decodable IDRs, 30fps timestamps/frame counts and AAC verified\n";
}
int main(int argc, char **argv) {
if (argc > 1 && std::string(argv[1]) == "--playback-benchmark") {
if (argc != 4 || (std::string(argv[3]) != "split" && std::string(argv[3]) != "hls")) {
std::cerr << "usage: " << argv[0] << " --playback-benchmark <base_url> <split|hls>\n";
return 2;
}
playbackBenchmark(argv[2], argv[3]);
return 0;
}
bool liveCheck = false, seekCheck = false, hlsCheck = false, remuxCheck = false;
for (int argument = 1; argument < argc; ++argument) {
if (std::string(argv[argument]) == "--live") liveCheck = true;