147 lines
8.0 KiB
C++
147 lines
8.0 KiB
C++
#pragma once
|
|
|
|
#include "app/controller.h"
|
|
#include "app/pdf_canvas.h"
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include <QQuickWindow>
|
|
#include <QTimer>
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
|
|
// Test-only instrumentation. It observes the production event loop, presentation
|
|
// notifications and worker counters without changing scheduling or deadlines.
|
|
class InteractionMeasurement : public QObject {
|
|
public:
|
|
static qint64 nowNs() {
|
|
return std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
InteractionMeasurement(docview::Controller *reader, docview::PdfCanvas *canvas, QQuickWindow *window)
|
|
: reader_(reader), canvas_(canvas), start_(nowNs()) {
|
|
connect(window, &QQuickWindow::frameSwapped, this, [this] {
|
|
const auto presented = nowNs();
|
|
QMetaObject::invokeMethod(this, [this, presented] {
|
|
++frameSerial;
|
|
lastFrameNs = presented;
|
|
if (phase_.isEmpty() || presented < phaseStart_) return;
|
|
frames.append(QJsonObject{{"phase", phase_}, {"atMs", ms(presented)},
|
|
{"intervalMs", previousFrame_ ? (presented - previousFrame_) / 1e6 : 0.}});
|
|
previousFrame_ = presented;
|
|
sample();
|
|
}, Qt::QueuedConnection);
|
|
}, Qt::DirectConnection);
|
|
connect(reader, &docview::Controller::disposeWeb, this, [this] { sample(); });
|
|
connect(canvas, &docview::PdfCanvas::tileRequested, this, [this] { sample(); });
|
|
timer_.setInterval(10);
|
|
timer_.setTimerType(Qt::PreciseTimer);
|
|
connect(&timer_, &QTimer::timeout, this, [this] {
|
|
const auto now = nowNs();
|
|
if (!phase_.isEmpty()) {
|
|
if (previousTick_) heartbeats.append(QJsonObject{{"phase", phase_}, {"atMs", ms(now)},
|
|
{"gapMs", (now - previousTick_) / 1e6}});
|
|
previousTick_ = now;
|
|
sample();
|
|
}
|
|
});
|
|
timer_.start();
|
|
}
|
|
double ms(qint64 time) const { return (time - start_) / 1e6; }
|
|
void begin(const QString &phase) {
|
|
phase_ = phase;
|
|
previousFrame_ = previousTick_ = 0;
|
|
phaseStart_ = nowNs();
|
|
phaseInput_ = phaseStart_;
|
|
sample();
|
|
}
|
|
void input(QJsonObject event) {
|
|
phaseInput_ = nowNs();
|
|
event["atMs"] = ms(phaseInput_);
|
|
event["phase"] = phase_;
|
|
inputs.append(event);
|
|
sample();
|
|
}
|
|
void inputOutcome(const QJsonObject &outcome) {
|
|
auto row = inputs.last().toObject();
|
|
for (auto it = outcome.begin(); it != outcome.end(); ++it) row[it.key()] = it.value();
|
|
inputs[inputs.size() - 1] = row;
|
|
}
|
|
void finish(const QJsonObject &verified) {
|
|
QJsonObject row = verified;
|
|
row["phase"] = phase_;
|
|
row["startMs"] = ms(phaseStart_);
|
|
row["lastInputMs"] = ms(phaseInput_);
|
|
row["finalPresentedMs"] = ms(lastFrameNs);
|
|
row["lastInputToFinalFrameMs"] = (lastFrameNs - phaseInput_) / 1e6;
|
|
row["phaseDurationMs"] = (lastFrameNs - phaseStart_) / 1e6;
|
|
phases.append(row);
|
|
sample();
|
|
phase_.clear();
|
|
}
|
|
void sample() {
|
|
qint64 queued = 0, active = 0, queuedRenders = 0, activeRenders = 0;
|
|
for (auto *object : reader_->children()) {
|
|
auto *session = dynamic_cast<docview::Session *>(object);
|
|
if (!session || !session->worker) continue;
|
|
auto *worker = session->worker;
|
|
const auto discarded = qint64(worker->discardedQueuedRequestCount());
|
|
discarded_ += qMax<qint64>(0, discarded - observedDiscards_.value(session->token));
|
|
observedDiscards_[session->token] = discarded;
|
|
queued += worker->queuedRequestCount(); active += worker->activeRequestCount();
|
|
queuedRenders += worker->queuedRequestCount("render"); activeRenders += worker->activeRequestCount("render");
|
|
}
|
|
const auto pending = qint64(canvas_->pendingRenderCount());
|
|
pendingPeak_ = qMax(pendingPeak_, pending);
|
|
queuePeak_ = qMax(queuePeak_, queued);
|
|
queuedRenderPeak_ = qMax(queuedRenderPeak_, queuedRenders);
|
|
activeRenderPeak_ = qMax(activeRenderPeak_, activeRenders);
|
|
cachePeak_ = qMax(cachePeak_, canvas_->cacheCostBytes());
|
|
if (!phase_.isEmpty() && samples.size() < 100000)
|
|
samples.append(QJsonObject{{"phase", phase_}, {"atMs", ms(nowNs())},
|
|
{"queuedRequests", queued}, {"activeRequests", active}, {"queuedRenders", queuedRenders},
|
|
{"activeRenders", activeRenders}, {"pendingCanvasRenders", pending},
|
|
{"discardedQueuedRequestsTotal", discarded_}, {"staleRenderResponses", qint64(canvas_->staleRenderResponseCount())},
|
|
{"discardedPrefetchResponses", qint64(canvas_->discardedPrefetchResponseCount())},
|
|
{"cacheBytes", canvas_->cacheCostBytes()}});
|
|
}
|
|
static QJsonObject statistics(QList<double> values) {
|
|
if (values.isEmpty()) return {{"count", 0}};
|
|
std::sort(values.begin(), values.end());
|
|
auto percentile = [&](double p) { return values[qBound<qsizetype>(0, qsizetype(std::ceil(p * values.size())) - 1, values.size() - 1)]; };
|
|
return {{"count", values.size()}, {"minMs", values.first()}, {"p50Ms", percentile(.5)},
|
|
{"p95Ms", percentile(.95)}, {"maxMs", values.last()}};
|
|
}
|
|
QJsonObject result(bool success) {
|
|
sample();
|
|
QList<double> intervals, gaps;
|
|
for (const auto &row : frames) if (row.toObject().value("intervalMs").toDouble() > 0) intervals.append(row.toObject().value("intervalMs").toDouble());
|
|
for (const auto &row : heartbeats) gaps.append(row.toObject().value("gapMs").toDouble());
|
|
const auto frameStats = statistics(intervals), heartbeatStats = statistics(gaps);
|
|
return {{"schemaVersion", 1}, {"success", success}, {"designRequirement", "08-performance-tests §3.3(5), T20"},
|
|
{"scope", "Synthetic bounded interaction workload; development-host reference, not reference-machine acceptance"},
|
|
{"presentationBoundary", "second frameSwapped after target and viewport readiness; final PDF also pixel-checked. frameSwapped is presentation-queue notification, not physical scanout; Web frames are not correlated with Chromium frame IDs"},
|
|
{"uiGapDefinition", "actual intervals of a 10 ms precise GUI timer; a proxy for event-loop starvation, not OS scheduling attribution"},
|
|
{"discardDefinition", "actual WorkerProcess lifetime unsent-queue discards, including stop sampled before Session deletion; excludes active cancellation"},
|
|
{"inputs", inputs}, {"phases", phases}, {"frames", frames}, {"uiHeartbeats", heartbeats}, {"workSamples", samples},
|
|
{"frameIntervals", frameStats}, {"uiHeartbeatGaps", heartbeatStats},
|
|
{"maxUiDelayBeyondNominalMs", qMax(0., heartbeatStats.value("maxMs").toDouble() - 10.)},
|
|
{"discardedQueuedRequests", discarded_}, {"pendingRenderPeak", pendingPeak_}, {"queuedRequestPeak", queuePeak_},
|
|
{"queuedRenderPeak", queuedRenderPeak_}, {"activeRenderPeak", activeRenderPeak_}, {"cachePeakBytes", cachePeak_},
|
|
{"provisionalBudgetsUnchanged", QJsonObject{{"visualResponseP95Ms", 50}, {"cachedPdfP95Ms", 100},
|
|
{"uncachedPdfP95Ms", 500}, {"continuousFrameP95MsAt60Hz", 33}}},
|
|
{"budgetAssessment", "not evaluated as a product guarantee: operation burst/final-frame latencies differ from per-input visual-response metric"}};
|
|
}
|
|
quint64 frameSerial = 0;
|
|
qint64 lastFrameNs = 0;
|
|
QJsonArray inputs, phases, frames, heartbeats, samples;
|
|
private:
|
|
docview::Controller *reader_;
|
|
docview::PdfCanvas *canvas_;
|
|
QTimer timer_;
|
|
QString phase_;
|
|
QHash<QString, qint64> observedDiscards_;
|
|
qint64 start_, phaseStart_ = 0, phaseInput_ = 0, previousFrame_ = 0, previousTick_ = 0;
|
|
qint64 discarded_ = 0, pendingPeak_ = 0, queuePeak_ = 0, queuedRenderPeak_ = 0, activeRenderPeak_ = 0, cachePeak_ = 0;
|
|
};
|