mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 22:01:34 +02:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
16 KiB
C++
377 lines
16 KiB
C++
// Closed-loop simulation of the guest vblank lock with the runtime's own
|
|
// RefreshFit and VblankLock (video/refresh_fit.cpp, video/vblank_lock.cpp).
|
|
// From the repo root:
|
|
// clang++ -std=c++20 -O2 -Iruntime tests/vblank_lock_sim.cpp runtime/video/refresh_fit.cpp \
|
|
// runtime/video/vblank_lock.cpp -o build/vblank_lock_sim
|
|
// build/vblank_lock_sim
|
|
//
|
|
// Model (times in ns):
|
|
// display refreshes every P (true rate), phase random; a present made
|
|
// before R_k - latch shows at R_k (FIFO, in order); its
|
|
// completion is seen at R_k + notify (0.2-0.6 ms, sometimes late).
|
|
// guest each vblank (VblankLock::Next, or free-running 16.667 ms)
|
|
// releases one game frame, which reaches the presenter x later
|
|
// (x drawn per scene). The presenter keeps only the newest
|
|
// pending frame (older ones are dropped), picks it up ~0.5 ms
|
|
// later, and presents at once when a swapchain image is free
|
|
// (3 images: one on screen, at most 2 queued), else waits for
|
|
// the next flip holding it.
|
|
// variable (the second table) no grid: a present shows as soon as it is
|
|
// latched, no sooner than 1/144 s after the last refresh, and
|
|
// the display refreshes on its own after 1/48 s without one.
|
|
// Counted from the 60th second (the lock has converged by then): dropped
|
|
// frames, repeats (a refresh with no new frame while frames flow), slips,
|
|
// the median vblank -> on screen latency, and the guest vblank's rate.
|
|
// suspended (the last tables) the game is suspended (iOS: Control Center,
|
|
// the app switcher): no vblank fires and its frames stand still,
|
|
// the display shows what was queued, and the next vblank comes
|
|
// exactly the time suspended later, then VblankLock::Resumed
|
|
// (gpu/command_processor.cpp's Vsync thread).
|
|
#include <video/refresh_fit.h>
|
|
#include <video/vblank_lock.h>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <deque>
|
|
#include <iterator>
|
|
#include <random>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
using namespace video;
|
|
|
|
struct Scene
|
|
{
|
|
const char* name;
|
|
double meanMs, sdMs; // vblank -> presenter, normal
|
|
double spikeP, spikeMs; // occasional slow frames
|
|
};
|
|
|
|
struct Result
|
|
{
|
|
double lockAtS = -1;
|
|
uint64_t drops = 0, repeats = 0, frames = 0, slips = 0;
|
|
double latMedMs = 0;
|
|
double hz = 0;
|
|
double followsAtS = -1; // the lock found the display following the vblank
|
|
double vblankHz = 0; // counted window
|
|
// After the last suspension: vblanks until the phase was reached again,
|
|
// drops plus repeats from the first frame shown to 5 s after the resume,
|
|
// and whether the lock held.
|
|
int64_t rephaseVblanks = -1;
|
|
uint64_t afterResume = 0;
|
|
bool lockedAtEnd = false;
|
|
};
|
|
|
|
static Result Run(double displayHz, bool lockOn, const Scene& scene, double seconds, uint32_t seed, double countFromS,
|
|
bool variable = false, const std::vector<std::pair<double, double>>& suspensions = {}, bool callResumed = true)
|
|
{
|
|
std::mt19937_64 rng(seed);
|
|
std::normal_distribution<double> nd(0, 1);
|
|
std::uniform_real_distribution<double> ud(0, 1);
|
|
const double P = 1e9 / displayHz;
|
|
const int64_t t0 = 1'000'000'000'000;
|
|
const double phase = ud(rng) * P;
|
|
const double latch = 2.0e6; // compositor latches this long before the refresh
|
|
|
|
RefreshFit fit;
|
|
VblankLock lock(16'667'000, lockOn);
|
|
|
|
struct Frame
|
|
{
|
|
int64_t vblank, arrive;
|
|
};
|
|
std::deque<Frame> inflight; // released, not yet at the presenter (sorted by arrive)
|
|
bool pendingFull = false;
|
|
Frame pending{};
|
|
bool held = false; // main thread holds a frame, waiting for an image
|
|
Frame heldFrame{};
|
|
struct Queued
|
|
{
|
|
Frame f;
|
|
int64_t presented;
|
|
};
|
|
std::deque<Queued> queue; // presented, not yet on screen
|
|
Result res;
|
|
std::vector<double> lat;
|
|
|
|
int64_t vblank = t0;
|
|
int64_t k = 1;
|
|
int64_t refresh = t0 + int64_t(phase);
|
|
int64_t lastRefresh = refresh;
|
|
const int64_t end = t0 + int64_t(seconds * 1e9);
|
|
int64_t firstCounted = 0, lastCounted = 0;
|
|
uint64_t vblanks = 0;
|
|
const int64_t countFrom = t0 + int64_t(countFromS * 1e9);
|
|
bool flowing = false;
|
|
// Suspensions as (start, end), in order; `pause` is the one under way or next.
|
|
std::vector<std::pair<int64_t, int64_t>> pauses;
|
|
for (auto [at, length] : suspensions)
|
|
pauses.push_back({ t0 + int64_t(at * 1e9), t0 + int64_t((at + length) * 1e9) });
|
|
size_t pause = 0;
|
|
bool suspended = false;
|
|
int64_t resumedAt = -1, sinceResume = 0, shownAfterResume = -1; // the last resume
|
|
auto afterResume = [&](int64_t t) { return shownAfterResume >= 0 && t <= resumedAt + 5'000'000'000; };
|
|
auto present = [&](const Frame& f, int64_t at) {
|
|
queue.push_back({ f, at });
|
|
};
|
|
auto presenterStep = [&](int64_t now) {
|
|
// Main thread: present what it holds when an image is free, then
|
|
// take the pending frame.
|
|
while (true)
|
|
{
|
|
if (held)
|
|
{
|
|
if (queue.size() >= 2)
|
|
return;
|
|
present(heldFrame, now);
|
|
held = false;
|
|
}
|
|
if (!pendingFull)
|
|
return;
|
|
heldFrame = pending;
|
|
pendingFull = false;
|
|
held = true;
|
|
}
|
|
};
|
|
while (refresh < end)
|
|
{
|
|
if (variable && k > 1)
|
|
{
|
|
const int64_t minGap = int64_t(1e9 / 144), maxGap = int64_t(1e9 / 48);
|
|
refresh = lastRefresh + maxGap;
|
|
if (!queue.empty())
|
|
refresh = std::clamp(queue.front().presented + int64_t(latch), lastRefresh + minGap, lastRefresh + maxGap);
|
|
}
|
|
// Next event: vblank, a frame's arrival, or a flip.
|
|
int64_t nextArrive = inflight.empty() ? INT64_MAX : inflight.front().arrive;
|
|
const int64_t nextEvent = std::min({ vblank, nextArrive, refresh });
|
|
if (pause < pauses.size() && !suspended && nextEvent >= pauses[pause].first)
|
|
{
|
|
// Everything the game does stands still for the suspension; the
|
|
// display flips on through what was already queued.
|
|
suspended = true;
|
|
vblank += pauses[pause].second - pauses[pause].first;
|
|
for (Frame& f : inflight)
|
|
f.arrive += pauses[pause].second - pauses[pause].first;
|
|
continue;
|
|
}
|
|
if (suspended && nextEvent >= pauses[pause].second)
|
|
{
|
|
suspended = false;
|
|
resumedAt = pauses[pause++].second;
|
|
sinceResume = 0;
|
|
shownAfterResume = -1;
|
|
res.rephaseVblanks = -1;
|
|
res.afterResume = 0;
|
|
if (callResumed)
|
|
lock.Resumed();
|
|
presenterStep(resumedAt);
|
|
continue;
|
|
}
|
|
if (vblank <= refresh && vblank <= nextArrive)
|
|
{
|
|
double x = scene.meanMs + scene.sdMs * nd(rng);
|
|
if (ud(rng) < scene.spikeP)
|
|
x = scene.spikeMs + 1.0 * nd(rng);
|
|
x = std::max(1.0, x);
|
|
Frame f{ vblank, vblank + int64_t(x * 1e6 + 500'000) }; // +0.5 ms pickup
|
|
auto it = inflight.end();
|
|
while (it != inflight.begin() && std::prev(it)->arrive > f.arrive)
|
|
--it;
|
|
inflight.insert(it, f);
|
|
if (vblank >= countFrom)
|
|
{
|
|
firstCounted = vblanks++ ? firstCounted : vblank;
|
|
lastCounted = vblank;
|
|
}
|
|
vblank = lock.Next(vblank);
|
|
if (resumedAt >= 0 && res.rephaseVblanks < 0)
|
|
{
|
|
sinceResume++;
|
|
if (std::abs(lock.GetStatus().errorNs) <= double(VblankLock::kMaxStepNs))
|
|
res.rephaseVblanks = sinceResume;
|
|
}
|
|
continue;
|
|
}
|
|
if (nextArrive <= refresh)
|
|
{
|
|
Frame f = inflight.front();
|
|
inflight.pop_front();
|
|
if (pendingFull && f.vblank > pending.vblank && pending.vblank >= countFrom)
|
|
res.drops++;
|
|
if (pendingFull && f.vblank > pending.vblank && afterResume(f.arrive))
|
|
res.afterResume++;
|
|
if (!pendingFull || f.vblank > pending.vblank)
|
|
pending = f;
|
|
pendingFull = true;
|
|
presenterStep(f.arrive);
|
|
continue;
|
|
}
|
|
// Flip at `refresh`: the oldest present made before the latch shows.
|
|
if (!queue.empty() && queue.front().presented <= refresh - int64_t(latch))
|
|
{
|
|
Queued q = queue.front();
|
|
queue.pop_front();
|
|
double notify = 200'000 + 400'000 * ud(rng);
|
|
if (ud(rng) < 0.01)
|
|
notify += 2e6 * ud(rng);
|
|
int64_t onScreen = refresh + int64_t(notify);
|
|
fit.Add(onScreen);
|
|
lock.SetDisplay(fit.Estimate());
|
|
// The presenter doesn't time a frame released before the resume
|
|
// (video/presenter.cpp's OnScreen).
|
|
if (q.f.vblank >= resumedAt)
|
|
lock.Observe(q.f.vblank, q.presented, onScreen);
|
|
if (resumedAt >= 0 && shownAfterResume < 0)
|
|
shownAfterResume = refresh;
|
|
if (q.f.vblank >= countFrom)
|
|
{
|
|
res.frames++;
|
|
lat.push_back((refresh - q.f.vblank) / 1e6);
|
|
}
|
|
flowing = true;
|
|
if (!suspended)
|
|
presenterStep(refresh);
|
|
}
|
|
else
|
|
{
|
|
if (flowing && refresh >= countFrom)
|
|
res.repeats++;
|
|
if (afterResume(refresh))
|
|
res.afterResume++;
|
|
}
|
|
if (res.lockAtS < 0 && lock.Locked())
|
|
res.lockAtS = (refresh - t0) / 1e9;
|
|
if (res.followsAtS < 0 && lock.GetStatus().follows)
|
|
res.followsAtS = (refresh - t0) / 1e9;
|
|
k++;
|
|
lastRefresh = refresh;
|
|
if (!variable)
|
|
refresh = t0 + int64_t(phase + double(k) * P);
|
|
}
|
|
res.vblankHz = vblanks > 1 ? double(vblanks - 1) / (double(lastCounted - firstCounted) / 1e9) : 0.0;
|
|
auto st = lock.GetStatus();
|
|
res.slips = st.slips;
|
|
res.lockedAtEnd = st.locked;
|
|
res.hz = st.display.periodNs > 0 ? 1e9 / st.display.periodNs : 0;
|
|
if (!lat.empty())
|
|
{
|
|
std::nth_element(lat.begin(), lat.begin() + lat.size() / 2, lat.end());
|
|
res.latMedMs = lat[lat.size() / 2];
|
|
}
|
|
return res;
|
|
}
|
|
|
|
int main()
|
|
{
|
|
const Scene scenes[] = {
|
|
{ "light (vblank->presenter 7+-1.5 ms)", 7, 1.5, 0.0, 0 },
|
|
{ "driving (9+-2 ms, 1% at 20 ms)", 9, 2, 0.01, 20 },
|
|
{ "fly-in (14+-1.5 ms, 2% at 20 ms)", 14, 1.5, 0.02, 20 },
|
|
{ "heavy (16+-1.5 ms: often a refresh late)", 16, 1.5, 0.0, 0 },
|
|
};
|
|
const double rates[] = { 59.972616, 59.0, 61.0 };
|
|
const double seconds = 300, countFrom = 60;
|
|
printf("%.0f s per run, counted from %.0f s (after the lock converges); 3 seeds each\n", seconds, countFrom);
|
|
printf("%-44s %-10s %5s %8s %8s %8s %6s %8s\n", "scene", "display", "lock", "lock at", "drops", "repeats", "slips", "lat ms");
|
|
bool ok = true;
|
|
for (const Scene& s : scenes)
|
|
for (double hz : rates)
|
|
for (bool on : { false, true })
|
|
{
|
|
Result sum;
|
|
double lockAt = 0, latMed = 0;
|
|
for (uint32_t seed = 1; seed <= 3; seed++)
|
|
{
|
|
Result r = Run(hz, on, s, seconds, seed * 7919 + uint32_t(hz * 10), countFrom);
|
|
sum.drops += r.drops;
|
|
sum.repeats += r.repeats;
|
|
sum.slips += r.slips;
|
|
lockAt += r.lockAtS;
|
|
latMed += r.latMedMs;
|
|
ok &= !on || (r.lockAtS > 0 && r.followsAtS < 0); // a real grid is never let go
|
|
}
|
|
printf("%-44s %-10.4f %5s %8.1f %8llu %8llu %6llu %8.1f\n", s.name, hz, on ? "on" : "off", on ? lockAt / 3 : 0.0,
|
|
(unsigned long long)sum.drops, (unsigned long long)sum.repeats, (unsigned long long)sum.slips, latMed / 3);
|
|
}
|
|
|
|
// A display that follows the presents: steady frames fit the guest's own
|
|
// rate well enough to lock, and the lock must let go rather than chase
|
|
// it. Jittery ones never fit. The guest vblank should end at 60 Hz.
|
|
const Scene steady[] = {
|
|
{ "steady (vblank->presenter 3+-0.05 ms)", 3, 0.05, 0.0, 0 },
|
|
{ "light (vblank->presenter 7+-1.5 ms)", 7, 1.5, 0.0, 0 },
|
|
};
|
|
printf("\nvariable refresh (48-144 Hz), lock on; %.0f s per run, counted from %.0f s\n", seconds, countFrom);
|
|
printf("%-44s %5s %8s %10s %10s %8s %8s\n", "scene", "seed", "lock at", "let go at", "vblank Hz", "drops", "repeats");
|
|
for (const Scene& s : steady)
|
|
for (uint32_t seed = 1; seed <= 3; seed++)
|
|
{
|
|
Result r = Run(60.0, true, s, seconds, seed * 104729, countFrom, true);
|
|
printf("%-44s %5u %8.1f %10.1f %10.4f %8llu %8llu\n", s.name, seed, r.lockAtS, r.followsAtS, r.vblankHz,
|
|
(unsigned long long)r.drops, (unsigned long long)r.repeats);
|
|
ok &= std::abs(r.vblankHz - 1e9 / 16'667'000) < 0.01;
|
|
}
|
|
printf("%s\n", ok ? "fixed rates stay locked; variable refresh ends free-running at 60 Hz: ok" : "FAILED");
|
|
|
|
// The vblank comes back up to half a refresh off the grid: the lock walks
|
|
// it back a step a vblank, within ~170 vblanks, without deciding that the
|
|
// display follows the vblank, and shows at most one frame twice or not at
|
|
// all on the way.
|
|
const double suspensions[] = { 0.1, 0.5, 6.0013, 12.5, 30.0 };
|
|
printf("\nsuspended at 90 s, lock on; 3 seeds each, max of the three\n");
|
|
printf("%-44s %-10s %9s %8s %14s %6s\n", "scene", "display", "suspended", "rephase", "drops+repeats", "lock");
|
|
bool resumes = true;
|
|
for (const Scene& s : scenes)
|
|
for (double hz : rates)
|
|
for (double d : suspensions)
|
|
{
|
|
int64_t rephase = 0;
|
|
uint64_t after = 0;
|
|
bool held = true;
|
|
// Half a refresh of steps, and a few more: each error is
|
|
// measured before its step, and the fitted grid moves a little.
|
|
const int64_t limit = int64_t(std::ceil(1e9 / hz / 2 / double(VblankLock::kMaxStepNs))) + 10;
|
|
for (uint32_t seed = 1; seed <= 3; seed++)
|
|
{
|
|
Result r = Run(hz, true, s, 90 + d + 10, seed * 6151 + uint32_t(hz * 10), countFrom, false, { { 90, d } });
|
|
rephase = r.rephaseVblanks < 0 ? INT64_MAX : std::max(rephase, r.rephaseVblanks);
|
|
after = std::max(after, r.afterResume);
|
|
held &= r.lockedAtEnd && r.followsAtS < 0;
|
|
}
|
|
printf("%-44s %-10.4f %9.4f %8lld %14llu %6s\n", s.name, hz, d, rephase == INT64_MAX ? -1LL : (long long)rephase,
|
|
(unsigned long long)after, held ? "held" : "LOST");
|
|
resumes &= rephase <= limit && after <= 1 && held;
|
|
}
|
|
|
|
// Suspended again while the lock is still walking back (Control Center
|
|
// pulled down a few times in a row). Resumed() starts each walk's chase
|
|
// count afresh; without it the walks add up to "the display follows the
|
|
// vblank", and the lock is gone for the rest of the run.
|
|
printf("\nsuspended 5 times, each 2-2.7 s after the last resume, light scene; 3 seeds each\n");
|
|
printf("%-10s %9s %5s %10s %16s\n", "display", "suspended", "gap", "lock held", "without Resumed");
|
|
for (double hz : rates)
|
|
for (double d : { 0.5, 12.5 })
|
|
for (double gap : { 2.0, 2.7 })
|
|
{
|
|
std::vector<std::pair<double, double>> pauses;
|
|
for (int i = 0; i < 5; i++)
|
|
pauses.push_back({ 90 + i * (d + gap), d });
|
|
int held = 0, heldWithout = 0;
|
|
for (uint32_t seed = 1; seed <= 3; seed++)
|
|
for (bool call : { true, false })
|
|
{
|
|
Result r = Run(hz, true, scenes[0], 90 + 5 * (d + gap) + 10, seed * 6151 + uint32_t(hz * 10), countFrom, false,
|
|
pauses, call);
|
|
(call ? held : heldWithout) += r.lockedAtEnd && r.followsAtS < 0;
|
|
}
|
|
printf("%-10.4f %9.4f %5.1f %5d of 3 %11d of 3\n", hz, d, gap, held, heldWithout);
|
|
resumes &= held == 3;
|
|
}
|
|
printf("%s\n", resumes ? "after a suspension the lock re-phases within ~170 vblanks, holds, and drops or repeats at most once: ok"
|
|
: "FAILED");
|
|
return ok && resumes ? 0 : 1;
|
|
}
|