blob: 1e30e635588d2cac802bf5f2bb2896f6fd083467 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: avoid_dynamic_calls
import 'dart:convert';
import 'package:gcloud/storage.dart';
import 'package:googleapis/storage/v1.dart' show DetailedApiRequestError;
import 'package:googleapis_auth/auth_io.dart';
import 'common.dart';
import 'constants.dart';
import 'gcs_lock.dart';
/// A [MetricPoint] modeled after the format that Skia Perf expects.
///
/// Skia Perf Format is a JSON file that looks like:
/// ```json
/// {
/// "gitHash": "fe4a4029a080bc955e9588d05a6cd9eb490845d4",
/// "key": {
/// "arch": "x86",
/// "gpu": "GTX660",
/// "model": "ShuttleA",
/// "os": "Ubuntu12"
/// },
/// "results": {
/// "ChunkAlloc_PushPop_640_480": {
/// "nonrendering": {
/// "min_ms": 0.01485466666666667,
/// "options": {
/// "source_type": "bench"
/// }
/// }
/// },
/// "DeferredSurfaceCopy_discardable_640_480": {
/// "565": {
/// "min_ms": 2.215988,
/// "options": {
/// "source_type": "bench"
/// }
/// }
/// }
/// }
/// }
/// }
/// ```
class SkiaPerfPoint extends MetricPoint {
SkiaPerfPoint._(this.githubRepo, this.gitHash, this.testName, this.subResult,
double? value, this._options, this.jsonUrl)
: assert(_options[kGithubRepoKey] == null),
assert(_options[kGitRevisionKey] == null),
assert(_options[kNameKey] == null),
super(
value,
<String, String?>{}
..addAll(_options)
..addAll(<String, String?>{
kGithubRepoKey: githubRepo,
kGitRevisionKey: gitHash,
kNameKey: testName,
kSubResultKey: subResult,
}),
) {
assert(tags[kGithubRepoKey] != null);
assert(tags[kGitRevisionKey] != null);
assert(tags[kNameKey] != null);
}
/// Construct [SkiaPerfPoint] from a well-formed [MetricPoint].
///
/// The [MetricPoint] must have [kGithubRepoKey], [kGitRevisionKey],
/// [kNameKey] in its tags for this to be successful.
///
/// If the [MetricPoint] has a tag 'date', that tag will be removed so Skia
/// perf can plot multiple metrics with different date as a single trace.
/// Skia perf will use the git revision's date instead of this date tag in
/// the time axis.
factory SkiaPerfPoint.fromPoint(MetricPoint p) {
final String? githubRepo = p.tags[kGithubRepoKey];
final String? gitHash = p.tags[kGitRevisionKey];
final String? name = p.tags[kNameKey];
if (githubRepo == null || gitHash == null || name == null) {
throw StateError(
'$kGithubRepoKey, $kGitRevisionKey, $kNameKey must be set in'
' the tags of $p.');
}
final String subResult = p.tags[kSubResultKey] ?? kSkiaPerfValueKey;
final Map<String, String> options = <String, String>{}..addEntries(
p.tags.entries.where(
(MapEntry<String, dynamic> entry) =>
entry.key != kGithubRepoKey &&
entry.key != kGitRevisionKey &&
entry.key != kNameKey &&
entry.key != kSubResultKey &&
// https://github.com/google/benchmark automatically generates a
// 'date' field. If it's included in options, the Skia perf won't
// be able to connect different points in a single trace because
// the date is always different.
entry.key != 'date',
),
);
return SkiaPerfPoint._(
githubRepo, gitHash, name, subResult, p.value, options, null);
}
/// In the format of '<owner>/<name>' such as 'flutter/flutter' or
/// 'flutter/engine'.
final String githubRepo;
/// SHA such as 'ad20d368ffa09559754e4b2b5c12951341ca3b2d'
final String? gitHash;
/// For Flutter devicelab, this is the task name (e.g.,
/// 'flutter_gallery__transition_perf'); for Google benchmark, this is the
/// benchmark name (e.g., 'BM_ShellShutdown').
///
/// In Skia perf web dashboard, this value can be queried and filtered by
/// "test".
final String testName;
/// The name of "subResult" comes from the special treatment of "sub_result"
/// in SkiaPerf. If not provided, its value will be set to kSkiaPerfValueKey.
///
/// When Google benchmarks are converted to SkiaPerfPoint, this subResult
/// could be "cpu_time" or "real_time".
///
/// When devicelab benchmarks are converted to SkiaPerfPoint, this subResult
/// is often the metric name such as "average_frame_build_time_millis" whereas
/// the [testName] is the benchmark or task name such as
/// "flutter_gallery__transition_perf".
final String subResult;
/// The url to the Skia perf json file in the Google Cloud Storage bucket.
///
/// This can be null if the point has been stored in the bucket yet.
final String? jsonUrl;
Map<String, dynamic> _toSubResultJson() {
return <String, dynamic>{
subResult: value,
kSkiaPerfOptionsKey: _options,
};
}
/// Convert a list of SkiaPoints with the same git repo and git revision into
/// a single json file in the Skia perf format.
///
/// The list must be non-empty.
static Map<String, dynamic> toSkiaPerfJson(List<SkiaPerfPoint> points) {
assert(points.isNotEmpty);
assert(() {
for (final SkiaPerfPoint p in points) {
if (p.githubRepo != points[0].githubRepo ||
p.gitHash != points[0].gitHash) {
return false;
}
}
return true;
}(), 'All points must have same githubRepo and gitHash');
final Map<String, dynamic> results = <String, dynamic>{};
for (final SkiaPerfPoint p in points) {
final Map<String, dynamic> subResultJson = p._toSubResultJson();
if (results[p.testName] == null) {
results[p.testName] = <String, dynamic>{
kSkiaPerfDefaultConfig: subResultJson,
};
} else {
// Flutter currently doesn't support having the same name but different
// options/configurations. If this actually happens in the future, we
// probably can use different values of config (currently there's only
// one kSkiaPerfDefaultConfig) to resolve the conflict.
assert(results[p.testName][kSkiaPerfDefaultConfig][kSkiaPerfOptionsKey]
.toString() ==
subResultJson[kSkiaPerfOptionsKey].toString());
assert(
results[p.testName][kSkiaPerfDefaultConfig][p.subResult] == null);
results[p.testName][kSkiaPerfDefaultConfig][p.subResult] = p.value;
}
}
return <String, dynamic>{
kSkiaPerfGitHashKey: points[0].gitHash,
kSkiaPerfResultsKey: results,
};
}
// Equivalent to tags without git repo, git hash, and name because those two
// are already stored somewhere else.
final Map<String, String> _options;
}
/// Handle writing and updates of Skia perf GCS buckets.
class SkiaPerfGcsAdaptor {
/// Construct the adaptor given the associated GCS bucket where the data is
/// read from and written to.
SkiaPerfGcsAdaptor(this._gcsBucket) : assert(_gcsBucket != null);
/// Used by Skia to differentiate json file format versions.
static const int version = 1;
/// Write a list of SkiaPerfPoint into a GCS file with name `objectName` in
/// the proper json format that's understandable by Skia perf services.
///
/// The `objectName` must be a properly formatted string returned by
/// [computeObjectName].
///
/// The read may retry multiple times if transient network errors with code
/// 504 happens.
Future<void> writePoints(
String objectName, List<SkiaPerfPoint> points) async {
final String jsonString = jsonEncode(SkiaPerfPoint.toSkiaPerfJson(points));
final List<int> content = utf8.encode(jsonString);
// Retry multiple times as GCS may return 504 timeout.
for (int retry = 0; retry < 5; retry += 1) {
try {
await _gcsBucket.writeBytes(objectName, content);
return;
} catch (e) {
if (e is DetailedApiRequestError && e.status == 504) {
continue;
}
rethrow;
}
}
// Retry one last time and let the exception go through.
await _gcsBucket.writeBytes(objectName, content);
}
/// Read a list of `SkiaPerfPoint` that have been previously written to the
/// GCS file with name `objectName`.
///
/// The Github repo and revision of those points will be inferred from the
/// `objectName`.
///
/// Return an empty list if the object does not exist in the GCS bucket.
///
/// The read may retry multiple times if transient network errors with code
/// 504 happens.
Future<List<SkiaPerfPoint>> readPoints(String objectName) async {
// Retry multiple times as GCS may return 504 timeout.
for (int retry = 0; retry < 5; retry += 1) {
try {
return await _readPointsWithoutRetry(objectName);
} catch (e) {
if (e is DetailedApiRequestError && e.status == 504) {
continue;
}
rethrow;
}
}
// Retry one last time and let the exception go through.
return _readPointsWithoutRetry(objectName);
}
Future<List<SkiaPerfPoint>> _readPointsWithoutRetry(String objectName) async {
ObjectInfo? info;
try {
info = await _gcsBucket.info(objectName);
} catch (e) {
if (e.toString().contains('No such object')) {
return <SkiaPerfPoint>[];
} else {
rethrow;
}
}
final Stream<List<int>> stream = _gcsBucket.read(objectName);
final Stream<int> byteStream = stream.expand((List<int> x) => x);
final Map<String, dynamic> decodedJson =
jsonDecode(utf8.decode(await byteStream.toList()))
as Map<String, dynamic>;
final List<SkiaPerfPoint> points = <SkiaPerfPoint>[];
final String firstGcsNameComponent = objectName.split('/')[0];
_populateGcsNameToGithubRepoMapIfNeeded();
final String githubRepo = _gcsNameToGithubRepo[firstGcsNameComponent]!;
assert(githubRepo != null);
final String? gitHash = decodedJson[kSkiaPerfGitHashKey] as String?;
final Map<String, dynamic> results =
decodedJson[kSkiaPerfResultsKey] as Map<String, dynamic>;
for (final String name in results.keys) {
final Map<String, dynamic> subResultMap =
results[name][kSkiaPerfDefaultConfig] as Map<String, dynamic>;
for (final String subResult
in subResultMap.keys.where((String s) => s != kSkiaPerfOptionsKey)) {
points.add(SkiaPerfPoint._(
githubRepo,
gitHash,
name,
subResult,
subResultMap[subResult] as double?,
(subResultMap[kSkiaPerfOptionsKey] as Map<String, dynamic>)
.cast<String, String>(),
info.downloadLink.toString(),
));
}
}
return points;
}
/// Compute the GCS file name that's used to store metrics for a given commit
/// (git revision).
///
/// Skia perf needs all directory names to be well formatted. The final name
/// of the json file can be arbitrary, and multiple json files can be put
/// in that leaf directory. We are using multiple json files divided by test
/// names to scale up the system to avoid too many writes competing for
/// the same json file.
static Future<String> computeObjectName(String githubRepo, String? revision,
DateTime commitTime, String taskName) async {
assert(_githubRepoToGcsName[githubRepo] != null);
final String? topComponent = _githubRepoToGcsName[githubRepo];
// [commitTime] is not guranteed to be UTC. Ensure it is so all results
// pushed to GCS are the same timezone.
final DateTime commitUtcTime = commitTime.toUtc();
final String month = commitUtcTime.month.toString().padLeft(2, '0');
final String day = commitUtcTime.day.toString().padLeft(2, '0');
final String hour = commitUtcTime.hour.toString().padLeft(2, '0');
final String dateComponents = '${commitUtcTime.year}/$month/$day/$hour';
return '$topComponent/$dateComponents/$revision/${taskName}_values.json';
}
static final Map<String, String> _githubRepoToGcsName = <String, String>{
kFlutterFrameworkRepo: 'flutter-flutter',
kFlutterEngineRepo: 'flutter-engine',
};
static final Map<String?, String> _gcsNameToGithubRepo = <String?, String>{};
static void _populateGcsNameToGithubRepoMapIfNeeded() {
if (_gcsNameToGithubRepo.isEmpty) {
for (final String repo in _githubRepoToGcsName.keys) {
final String? gcsName = _githubRepoToGcsName[repo];
assert(_gcsNameToGithubRepo[gcsName] == null);
_gcsNameToGithubRepo[gcsName] = repo;
}
}
}
final Bucket _gcsBucket;
}
/// A [MetricDestination] that conforms to Skia Perf's protocols.
class SkiaPerfDestination extends MetricDestination {
/// Creates a new [SkiaPerfDestination].
SkiaPerfDestination(this._gcs, this._lock);
/// Create from a full credentials json (of a service account).
static Future<SkiaPerfDestination> makeFromGcpCredentials(
Map<String, dynamic> credentialsJson,
{bool isTesting = false}) async {
final AutoRefreshingAuthClient client = await clientViaServiceAccount(
ServiceAccountCredentials.fromJson(credentialsJson), Storage.SCOPES);
return make(
client,
credentialsJson[kProjectId] as String,
isTesting: isTesting,
);
}
/// Create from an access token and its project id.
static Future<SkiaPerfDestination> makeFromAccessToken(
String token, String projectId,
{bool isTesting = false}) async {
final AuthClient client = authClientFromAccessToken(token, Storage.SCOPES);
return make(client, projectId, isTesting: isTesting);
}
/// Create from an [AuthClient] and a GCP project id.
///
/// [AuthClient] can be obtained from functions like `clientViaUserConsent`.
static Future<SkiaPerfDestination> make(AuthClient client, String projectId,
{bool isTesting = false}) async {
final Storage storage = Storage(client, projectId);
final String bucketName = isTesting ? kTestBucketName : kBucketName;
if (!await storage.bucketExists(bucketName)) {
throw StateError('Bucket $bucketName does not exist.');
}
final SkiaPerfGcsAdaptor adaptor =
SkiaPerfGcsAdaptor(storage.bucket(bucketName));
final GcsLock lock = GcsLock(client, bucketName);
return SkiaPerfDestination(adaptor, lock);
}
@override
Future<void> update(
List<MetricPoint> points, DateTime commitTime, String taskName) async {
// 1st, create a map based on git repo, git revision, and point id. Git repo
// and git revision are the top level components of the Skia perf GCS object
// name.
final Map<String, Map<String?, Map<String, SkiaPerfPoint>>> pointMap =
<String, Map<String, Map<String, SkiaPerfPoint>>>{};
for (final SkiaPerfPoint p
in points.map((MetricPoint x) => SkiaPerfPoint.fromPoint(x))) {
if (p != null) {
pointMap[p.githubRepo] ??= <String, Map<String, SkiaPerfPoint>>{};
pointMap[p.githubRepo]![p.gitHash] ??= <String, SkiaPerfPoint>{};
pointMap[p.githubRepo]![p.gitHash]![p.id] = p;
}
}
// All created locks must be released before returning
final List<Future<void>> lockFutures = <Future<void>>[];
// 2nd, read existing points from the gcs object and update with new ones.
for (final String repo in pointMap.keys) {
for (final String? revision in pointMap[repo]!.keys) {
final String objectName = await SkiaPerfGcsAdaptor.computeObjectName(
repo, revision, commitTime, taskName);
final Map<String, SkiaPerfPoint>? newPoints = pointMap[repo]![revision];
// Too many bots writing the metrics of a git revision into a single json
// file will cause high contention on the lock. We use multiple
// json files according to task names. Skia perf read all json files in
// the directory so one can use arbitrary names for those sharded json
// file names.
lockFutures.add(
_lock!.protectedRun('$objectName.lock', () async {
final List<SkiaPerfPoint> oldPoints =
await _gcs.readPoints(objectName);
for (final SkiaPerfPoint p in oldPoints) {
if (newPoints![p.id] == null) {
newPoints[p.id] = p;
}
}
await _gcs.writePoints(objectName, newPoints!.values.toList());
}),
);
}
}
await Future.wait(lockFutures);
}
final SkiaPerfGcsAdaptor _gcs;
late final GcsLock? _lock;
}