Files
docview/tests/interaction_measurement.h
T
2026-09-21 13:41:40 +09:00

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;
};