blob: fee30ff8734cd5a2b2dd2d804cb4df43c0828422 [file] [log] [blame]
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import logging
from telemetry.util import perf_tests_helper
from telemetry.util import statistics
from telemetry.value import improvement_direction
from telemetry.value import list_of_scalar_values
from telemetry.value import scalar
from telemetry.web_perf.metrics import rendering_stats
from telemetry.web_perf.metrics import timeline_based_metric
NOT_ENOUGH_FRAMES_MESSAGE = (
'Not enough frames for smoothness metrics (at least two are required).\n'
'Issues that have caused this in the past:\n'
'- Browser bugs that prevents the page from redrawing\n'
'- Bugs in the synthetic gesture code\n'
'- Page and benchmark out of sync (e.g. clicked element was renamed)\n'
'- Pages that render extremely slow\n'
'- Pages that can\'t be scrolled')
class SmoothnessMetric(timeline_based_metric.TimelineBasedMetric):
"""Computes metrics that measure smoothness of animations over given ranges.
Animations are typically considered smooth if the frame rates are close to
60 frames per second (fps) and uniformly distributed over the sequence. To
determine if a timeline range contains a smooth animation, we update the
results object with several representative metrics:
frame_times: A list of raw frame times
mean_frame_time: The arithmetic mean of frame times
percentage_smooth: Percentage of frames that were hitting 60 FPS.
frame_time_discrepancy: The absolute discrepancy of frame timestamps
mean_pixels_approximated: The mean percentage of pixels approximated
queueing_durations: The queueing delay between compositor & main threads
Note that if any of the interaction records provided to AddResults have less
than 2 frames, we will return telemetry values with None values for each of
the smoothness metrics. Similarly, older browsers without support for
tracking the BeginMainFrame events will report a ListOfScalarValues with a
None value for the queueing duration metric.
"""
def __init__(self):
super(SmoothnessMetric, self).__init__()
def AddResults(self, model, renderer_thread, interaction_records, results):
self.VerifyNonOverlappedRecords(interaction_records)
renderer_process = renderer_thread.parent
stats = rendering_stats.RenderingStats(
renderer_process, model.browser_process, model.surface_flinger_process,
[r.GetBounds() for r in interaction_records])
has_surface_flinger_stats = model.surface_flinger_process is not None
self._PopulateResultsFromStats(results, stats, has_surface_flinger_stats)
def _PopulateResultsFromStats(self, results, stats,
has_surface_flinger_stats):
page = results.current_page
values = [
self._ComputeQueueingDuration(page, stats),
self._ComputeFrameTimeDiscrepancy(page, stats),
self._ComputeMeanPixelsApproximated(page, stats),
self._ComputeMeanPixelsCheckerboarded(page, stats)
]
values += self._ComputeLatencyMetric(page, stats, 'input_event_latency',
stats.input_event_latency)
values += self._ComputeLatencyMetric(page, stats,
'main_thread_scroll_latency',
stats.main_thread_scroll_latency)
values.append(self._ComputeFirstGestureScrollUpdateLatencies(page, stats))
values += self._ComputeFrameTimeMetric(page, stats)
if has_surface_flinger_stats:
values += self._ComputeSurfaceFlingerMetric(page, stats)
for v in values:
results.AddValue(v)
def _HasEnoughFrames(self, list_of_frame_timestamp_lists):
"""Whether we have collected at least two frames in every timestamp list."""
return all(len(s) >= 2 for s in list_of_frame_timestamp_lists)
@staticmethod
def _GetNormalizedDeltas(data, refresh_period, min_normalized_delta=None):
deltas = [t2 - t1 for t1, t2 in zip(data, data[1:])]
if min_normalized_delta != None:
deltas = [d for d in deltas
if d / refresh_period >= min_normalized_delta]
return (deltas, [delta / refresh_period for delta in deltas])
@staticmethod
def _JoinTimestampRanges(frame_timestamps):
"""Joins ranges of timestamps, adjusting timestamps to remove deltas
between the start of a range and the end of the prior range.
"""
timestamps = []
for timestamp_range in frame_timestamps:
if len(timestamps) == 0:
timestamps.extend(timestamp_range)
else:
for i in range(1, len(timestamp_range)):
timestamps.append(timestamps[-1] +
timestamp_range[i] - timestamp_range[i-1])
return timestamps
def _ComputeSurfaceFlingerMetric(self, page, stats):
jank_count = None
avg_surface_fps = None
max_frame_delay = None
frame_lengths = None
none_value_reason = None
if self._HasEnoughFrames(stats.frame_timestamps):
timestamps = self._JoinTimestampRanges(stats.frame_timestamps)
frame_count = len(timestamps)
milliseconds = timestamps[-1] - timestamps[0]
min_normalized_frame_length = 0.5
frame_lengths, normalized_frame_lengths = \
self._GetNormalizedDeltas(timestamps, stats.refresh_period,
min_normalized_frame_length)
if len(frame_lengths) < frame_count - 1:
logging.warning('Skipping frame lengths that are too short.')
frame_count = len(frame_lengths) + 1
if len(frame_lengths) == 0:
raise Exception('No valid frames lengths found.')
_, normalized_changes = \
self._GetNormalizedDeltas(frame_lengths, stats.refresh_period)
jankiness = [max(0, round(change)) for change in normalized_changes]
pause_threshold = 20
jank_count = sum(1 for change in jankiness
if change > 0 and change < pause_threshold)
avg_surface_fps = int(round((frame_count - 1) * 1000.0 / milliseconds))
max_frame_delay = round(max(normalized_frame_lengths))
frame_lengths = normalized_frame_lengths
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return (
scalar.ScalarValue(
page, 'avg_surface_fps', 'fps', avg_surface_fps,
description='Average frames per second as measured by the '
'platform\'s SurfaceFlinger.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.UP),
scalar.ScalarValue(
page, 'jank_count', 'janks', jank_count,
description='Number of changes in frame rate as measured by the '
'platform\'s SurfaceFlinger.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN),
scalar.ScalarValue(
page, 'max_frame_delay', 'vsyncs', max_frame_delay,
description='Largest frame time as measured by the platform\'s '
'SurfaceFlinger.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN),
list_of_scalar_values.ListOfScalarValues(
page, 'frame_lengths', 'vsyncs', frame_lengths,
description='Frame time in vsyncs as measured by the platform\'s '
'SurfaceFlinger.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)
)
def _ComputeLatencyMetric(self, page, stats, name, list_of_latency_lists):
"""Returns Values for the mean and discrepancy for given latency stats."""
mean_latency = None
latency_discrepancy = None
none_value_reason = None
if self._HasEnoughFrames(stats.frame_timestamps):
latency_list = perf_tests_helper.FlattenList(list_of_latency_lists)
if len(latency_list) == 0:
return ()
mean_latency = round(statistics.ArithmeticMean(latency_list), 3)
latency_discrepancy = (
round(statistics.DurationsDiscrepancy(latency_list), 4))
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return (
scalar.ScalarValue(
page, 'mean_%s' % name, 'ms', mean_latency,
description='Arithmetic mean of the raw %s values' % name,
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN),
scalar.ScalarValue(
page, '%s_discrepancy' % name, 'ms', latency_discrepancy,
description='Discrepancy of the raw %s values' % name,
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)
)
def _ComputeFirstGestureScrollUpdateLatencies(self, page, stats):
"""Returns a ListOfScalarValuesValues of gesture scroll update latencies.
Returns a Value for the first gesture scroll update latency for each
interaction record in |stats|.
"""
none_value_reason = None
first_gesture_scroll_update_latencies = [round(latencies[0], 4)
for latencies in stats.gesture_scroll_update_latency
if len(latencies)]
if (not self._HasEnoughFrames(stats.frame_timestamps) or
not first_gesture_scroll_update_latencies):
first_gesture_scroll_update_latencies = None
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return list_of_scalar_values.ListOfScalarValues(
page, 'first_gesture_scroll_update_latency', 'ms',
first_gesture_scroll_update_latencies,
description='First gesture scroll update latency measures the time it '
'takes to process the very first gesture scroll update '
'input event. The first scroll gesture can often get '
'delayed by work related to page loading.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)
def _ComputeQueueingDuration(self, page, stats):
"""Returns a Value for the frame queueing durations."""
queueing_durations = None
none_value_reason = None
if 'frame_queueing_durations' in stats.errors:
none_value_reason = stats.errors['frame_queueing_durations']
elif self._HasEnoughFrames(stats.frame_timestamps):
queueing_durations = perf_tests_helper.FlattenList(
stats.frame_queueing_durations)
if len(queueing_durations) == 0:
queueing_durations = None
none_value_reason = 'No frame queueing durations recorded.'
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return list_of_scalar_values.ListOfScalarValues(
page, 'queueing_durations', 'ms', queueing_durations,
description='The frame queueing duration quantifies how out of sync '
'the compositor and renderer threads are. It is the amount '
'of wall time that elapses between a '
'ScheduledActionSendBeginMainFrame event in the compositor '
'thread and the corresponding BeginMainFrame event in the '
'main thread.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)
def _ComputeFrameTimeMetric(self, page, stats):
"""Returns Values for the frame time metrics.
This includes the raw and mean frame times, as well as the percentage of
frames that were hitting 60 fps.
"""
frame_times = None
mean_frame_time = None
percentage_smooth = None
none_value_reason = None
if self._HasEnoughFrames(stats.frame_timestamps):
frame_times = perf_tests_helper.FlattenList(stats.frame_times)
mean_frame_time = round(statistics.ArithmeticMean(frame_times), 3)
# We use 17ms as a somewhat looser threshold, instead of 1000.0/60.0.
smooth_threshold = 17.0
smooth_count = sum(1 for t in frame_times if t < smooth_threshold)
percentage_smooth = float(smooth_count) / len(frame_times) * 100.0
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return (
list_of_scalar_values.ListOfScalarValues(
page, 'frame_times', 'ms', frame_times,
description='List of raw frame times, helpful to understand the '
'other metrics.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN),
scalar.ScalarValue(
page, 'mean_frame_time', 'ms', mean_frame_time,
description='Arithmetic mean of frame times.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN),
scalar.ScalarValue(
page, 'percentage_smooth', 'score', percentage_smooth,
description='Percentage of frames that were hitting 60 fps.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.UP)
)
def _ComputeFrameTimeDiscrepancy(self, page, stats):
"""Returns a Value for the absolute discrepancy of frame time stamps."""
frame_discrepancy = None
none_value_reason = None
if self._HasEnoughFrames(stats.frame_timestamps):
frame_discrepancy = round(statistics.TimestampsDiscrepancy(
stats.frame_timestamps), 4)
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return scalar.ScalarValue(
page, 'frame_time_discrepancy', 'ms', frame_discrepancy,
description='Absolute discrepancy of frame time stamps, where '
'discrepancy is a measure of irregularity. It quantifies '
'the worst jank. For a single pause, discrepancy '
'corresponds to the length of this pause in milliseconds. '
'Consecutive pauses increase the discrepancy. This metric '
'is important because even if the mean and 95th '
'percentile are good, one long pause in the middle of an '
'interaction is still bad.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)
def _ComputeMeanPixelsApproximated(self, page, stats):
"""Add the mean percentage of pixels approximated.
This looks at tiles which are missing or of low or non-ideal resolution.
"""
mean_pixels_approximated = None
none_value_reason = None
if self._HasEnoughFrames(stats.frame_timestamps):
mean_pixels_approximated = round(statistics.ArithmeticMean(
perf_tests_helper.FlattenList(
stats.approximated_pixel_percentages)), 3)
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return scalar.ScalarValue(
page, 'mean_pixels_approximated', 'percent', mean_pixels_approximated,
description='Percentage of pixels that were approximated '
'(checkerboarding, low-resolution tiles, etc.).',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)
def _ComputeMeanPixelsCheckerboarded(self, page, stats):
"""Add the mean percentage of pixels checkerboarded.
This looks at tiles which are only missing.
It does not take into consideration tiles which are of low or
non-ideal resolution.
"""
mean_pixels_checkerboarded = None
none_value_reason = None
if self._HasEnoughFrames(stats.frame_timestamps):
if rendering_stats.CHECKERBOARDED_PIXEL_ERROR in stats.errors:
none_value_reason = stats.errors[
rendering_stats.CHECKERBOARDED_PIXEL_ERROR]
else:
mean_pixels_checkerboarded = round(statistics.ArithmeticMean(
perf_tests_helper.FlattenList(
stats.checkerboarded_pixel_percentages)), 3)
else:
none_value_reason = NOT_ENOUGH_FRAMES_MESSAGE
return scalar.ScalarValue(
page, 'mean_pixels_checkerboarded', 'percent',
mean_pixels_checkerboarded,
description='Percentage of pixels that were checkerboarded.',
none_value_reason=none_value_reason,
improvement_direction=improvement_direction.DOWN)