Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions benchmarker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,29 @@ roughly four times a second:
- Outcome breakdown: verified, unverified (no `Content-Length`), byte
mismatches, HTTP errors, and failures.

- When the run started and ended, the wall-clock time between them, and the
**build mode** (debug, profile or release) it was measured in. Debug numbers
are not comparable with profile or release ones.

Every response's received byte count is checked against its `Content-Length`; a
mismatch is reported as a problem rather than a success.

The copy button at the top right of the statistics panel puts the whole run on
the clipboard — source URL, target URL, cache type, request range, client and
concurrency alongside the numbers — as either an aligned plain-text summary or
JSON for feeding into other tooling.
### Result history

Every run that reaches a terminal phase — finished, cancelled or failed — is
recorded and stays available from the **Result** dropdown at the top of the
statistics panel, newest first, so runs can be compared without re-running them.
The panel follows the run in flight unless an older one is picked. History lives
in memory only: it is gone when the app exits.

The copy button at the top right of the statistics panel puts the run on show on
the clipboard — source URL, target URL, cache type, request range, client,
concurrency, timestamps and build mode alongside the numbers — as either an
aligned plain-text summary or JSON. Its third option exports **every** recorded
run as a JSON list for feeding into other tooling.

The clear button beside it deletes the run on show, or clears the whole history.
A run still in flight has not been recorded yet, so it cannot be deleted.

For cache-server runs, the page also shows live download progress
(`x / y bytes`, percentage) taken from `HttpCacheStream.cacheStateStream`, and
Expand All @@ -107,6 +123,7 @@ lib/
benchmark_config.dart inputs, run types, request distribution
benchmark_controller.dart run orchestration and aggregation
benchmark_report.dart clipboard reports (text and JSON)
benchmark_result.dart one recorded run: timings, mode, stats
benchmark_stats.dart timing/percentile accumulation
benchmark_worker.dart worker isolate entry point
http_client_builder.dart selectable http client implementations
Expand Down
143 changes: 135 additions & 8 deletions benchmarker/lib/src/benchmark/benchmark_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,35 @@ import '../util/formatting.dart';
import 'benchmark_config.dart';
import 'benchmark_log.dart';
import 'benchmark_report.dart';
import 'benchmark_result.dart';
import 'benchmark_stats.dart';
import 'worker_pool.dart';
import 'worker_protocol.dart';

enum BenchmarkPhase {
idle,
preparing,
running,
cancelling,
finished,
cancelled,
failed;
idle('Idle'),
preparing('Preparing…'),
running('Running'),
cancelling('Cancelling…'),
finished('Finished'),
cancelled('Cancelled'),
failed('Failed');

const BenchmarkPhase(this.label);

/// Human-readable name of the phase.
final String label;

bool get isBusy =>
this == BenchmarkPhase.preparing ||
this == BenchmarkPhase.running ||
this == BenchmarkPhase.cancelling;

/// Whether a run in this phase is over and can be recorded.
bool get isTerminal =>
this == BenchmarkPhase.finished ||
this == BenchmarkPhase.cancelled ||
this == BenchmarkPhase.failed;
}

/// Drives a benchmark run: prepares the cache, dispatches work to the isolate
Expand All @@ -40,6 +52,9 @@ class BenchmarkController extends ChangeNotifier {
final Map<String, int> _problemCounts = {};
final Stopwatch _runClock = Stopwatch();

/// Completed runs of this session, oldest first.
final List<BenchmarkResult> _results = [];

WorkerPool? _pool;
StreamSubscription<WorkerEvent>? _poolEvents;
HttpCacheStream? _cacheStream;
Expand All @@ -50,6 +65,7 @@ class BenchmarkController extends ChangeNotifier {

final Set<int> _outstandingWorkers = {};
int _jobId = 0;
int _runId = 0;
bool _cancelRequested = false;
bool _dirty = false;
bool _warnedUnverified = false;
Expand All @@ -61,6 +77,11 @@ class BenchmarkController extends ChangeNotifier {
BenchmarkStats? _stats;
CacheState? _cacheState;
Uri? _targetUrl;
DateTime? _startedAt;

/// Id of the recorded result the UI is pinned to, or null while it follows
/// the run in flight.
int? _selectedResultId;

BenchmarkPhase get phase => _phase;

Expand All @@ -85,6 +106,76 @@ class BenchmarkController extends ChangeNotifier {
/// Number of live worker isolates, or 0 when no pool is spawned.
int get poolSize => _pool?.size ?? 0;

/// Status line of the current phase, e.g. `Running · 4 workers`.
String get statusLabel => _phase == BenchmarkPhase.running
? '${_phase.label} · $poolSize workers'
: _phase.label;

/// Every completed run of this session, oldest first.
List<BenchmarkResult> get results => List.unmodifiable(_results);

/// The run in flight, or null when no run is in progress.
BenchmarkResult? get liveResult {
final stats = _stats;
final startedAt = _startedAt;
if (!_phase.isBusy || stats == null || startedAt == null) return null;
return BenchmarkResult(
id: _runId,
stats: stats,
status: statusLabel,
startedAt: startedAt,
config: _config,
targetUrl: _targetUrl,
);
}

/// The result the UI shows: the pinned one from the history, or the run in
/// flight when nothing is pinned.
BenchmarkResult? get selectedResult {
final id = _selectedResultId;
if (id != null) {
for (final result in _results) {
if (result.id == id) return result;
}
}
return liveResult;
}

/// Id of the result the UI shows, or null when there is nothing to show.
int? get selectedResultId => selectedResult?.id;

/// Pins the history entry with [id]. Selecting the run in flight, or an id
/// that is no longer in the history, follows the live run again.
void selectResult(int? id) {
_selectedResultId =
id == null || (liveResult != null && id == liveResult!.id) ? null : id;
notifyListeners();
}

/// Removes a single run from the history.
///
/// A run in flight is never recorded yet, so it cannot be deleted.
void deleteResult(int id) {
final removed = _results.indexWhere((result) => result.id == id);
if (removed < 0) return;
_results.removeAt(removed);
if (_selectedResultId == id) {
// Fall back to the neighbouring run, or to the live run when the history
// is empty.
final next = removed < _results.length ? removed : _results.length - 1;
_selectedResultId = next < 0 ? null : _results[next].id;
}
notifyListeners();
}

/// Drops every recorded run. The run in flight, if any, keeps going.
void clearResults() {
if (_results.isEmpty) return;
_results.clear();
_selectedResultId = null;
notifyListeners();
}

/// Starts a run. Does nothing when a run is already in progress.
Future<void> start(BenchmarkConfig config) async {
if (_phase.isBusy || _disposed) return;
Expand All @@ -98,11 +189,20 @@ class BenchmarkController extends ChangeNotifier {
_stats = BenchmarkStats.empty(config.totalRequests);
_cacheState = null;
_targetUrl = null;
_startedAt = DateTime.now();
// The stats panel follows the new run rather than whatever the user was
// reading in the history.
_selectedResultId = null;
_runId++;
_runClock
..reset()
..stop();

_log('── ${config.type.label} run: ${config.sourceUrl}');
_log('── Run #$_runId · ${config.type.label}: ${config.sourceUrl}');
_log(
'Started ${formatTimestamp(_startedAt!)} · '
'${BuildMode.current.label} build',
);
_log(
'${config.totalRequests} requests · ${config.concurrency} workers · '
'${config.clientOption.label}',
Expand Down Expand Up @@ -296,10 +396,37 @@ class BenchmarkController extends ChangeNotifier {
_stopTicker();
_refreshStats();
_summarize(phase);
_record(phase);
unawaited(_releaseCacheStream());
_setPhase(phase);
}

/// Files the finished run in the history and pins the stats panel to it,
/// unless the user has pinned an older run in the meantime.
void _record(BenchmarkPhase phase) {
final stats = _stats;
final startedAt = _startedAt;
if (!phase.isTerminal || stats == null || startedAt == null) return;

final endedAt = DateTime.now();
final result = BenchmarkResult(
id: _runId,
stats: stats,
status: phase.label,
startedAt: startedAt,
endedAt: endedAt,
config: _config,
targetUrl: _targetUrl,
);
_results.add(result);
_selectedResultId ??= result.id;

_log(
'Ended ${formatTimestamp(endedAt)} · '
'${formatDuration(result.wallDuration!)} wall clock',
);
}

void _summarize(BenchmarkPhase phase) {
final stats = _stats;
if (stats == null) return;
Expand Down
59 changes: 39 additions & 20 deletions benchmarker/lib/src/benchmark/benchmark_report.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import 'dart:convert';

import '../util/formatting.dart';
import 'benchmark_config.dart';
import 'benchmark_result.dart';
import 'benchmark_stats.dart';

const JsonEncoder _encoder = JsonEncoder.withIndent(' ');

/// One line describing what each request in [config] asks for.
String describeRangePlan(BenchmarkConfig config) {
final plan = config.rangePlan;
Expand All @@ -17,12 +20,18 @@ String describeRangePlan(BenchmarkConfig config) {
}

/// Renders a run's configuration and results as indented JSON.
String buildJsonReport({
required BenchmarkStats stats,
BenchmarkConfig? config,
Uri? targetUrl,
String? status,
}) {
String buildJsonReport(BenchmarkResult result) =>
_encoder.convert(buildReportMap(result));

/// Renders every run as a JSON list, oldest first.
String buildJsonReportList(Iterable<BenchmarkResult> results) =>
_encoder.convert([for (final result in results) buildReportMap(result)]);

/// Builds the JSON structure describing a single run.
Map<String, Object?> buildReportMap(BenchmarkResult result) {
final stats = result.stats;
final config = result.config;

Map<String, Object?> timing(TimingStats? timing) {
if (timing == null) return {};
return {
Expand All @@ -37,12 +46,17 @@ String buildJsonReport({
}

final plan = config?.rangePlan;
final report = <String, Object?>{
return <String, Object?>{
'run_id': result.id,
'source_url': config?.sourceUrl.toString(),
'target_url': targetUrl?.toString(),
'target_url': result.targetUrl?.toString(),
'cache_type': config?.type.name,
'cache_type_label': config?.type.label,
'status': status,
'status': result.status,
'build_mode': result.mode.name,
'started_at': result.startedAt.toIso8601String(),
'ended_at': result.endedAt?.toIso8601String(),
'wall_duration_us': result.wallDuration?.inMicroseconds,
'concurrency': config?.concurrency,
'http_client': config?.clientOption.label,
'range': {
Expand Down Expand Up @@ -82,21 +96,16 @@ String buildJsonReport({
'completion': timing(stats.completionTime),
},
};

return const JsonEncoder.withIndent(' ').convert(report);
}

/// Renders a run's configuration and results as an aligned plain-text summary.
String buildTextReport({
required BenchmarkStats stats,
BenchmarkConfig? config,
Uri? targetUrl,
String? status,
}) {
String buildTextReport(BenchmarkResult result) {
final stats = result.stats;
final config = result.config;
final buffer = StringBuffer()
..writeln(
'http_cache_stream benchmark'
'${config == null ? '' : ' — ${config.type.label}'}',
'${config == null ? '' : ' — ${config.type.label}'} (run #${result.id})',
);

void field(String label, String? value) {
Expand All @@ -105,14 +114,24 @@ String buildTextReport({
}

field('Source', config?.sourceUrl.toString());
field('Target', targetUrl?.toString());
field('Target', result.targetUrl?.toString());
field('Client', config?.clientOption.label);
field(
'Concurrency',
config == null ? null : '${config.concurrency} worker isolates',
);
field('Range', config == null ? null : describeRangePlan(config));
field('Status', status);
field('Status', result.status);
field('Mode', '${result.mode.label} build');
field('Started', formatTimestamp(result.startedAt));
field(
'Ended',
result.endedAt == null ? null : formatTimestamp(result.endedAt!),
);
field(
'Duration',
result.wallDuration == null ? null : formatDuration(result.wallDuration!),
);

buffer
..writeln()
Expand Down
Loading
Loading