speedindex.py revision a1401311d1ab56c4ed0a474bd38c108f75cb0cd9
1# Copyright 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import collections
6
7from metrics import Metric
8from telemetry.core import bitmap
9
10
11class SpeedIndexMetric(Metric):
12  """The speed index metric is one way of measuring page load speed.
13
14  It is meant to approximate user perception of page load speed, and it
15  is based on the amount of time that it takes to paint to the visual
16  portion of the screen. It includes paint events that occur after the
17  onload event, and it doesn't include time loading things off-screen.
18
19  This speed index metric is based on WebPageTest.org (WPT).
20  For more info see: http://goo.gl/e7AH5l
21  """
22  def __init__(self):
23    super(SpeedIndexMetric, self).__init__()
24    self._impl = None
25
26  @classmethod
27  def CustomizeBrowserOptions(cls, options):
28    options.AppendExtraBrowserArgs('--disable-infobars')
29
30  def Start(self, _, tab):
31    """Start recording events.
32
33    This method should be called in the WillNavigateToPage method of
34    a PageMeasurement, so that all the events can be captured. If it's called
35    in DidNavigateToPage, that will be too late.
36    """
37    self._impl = (VideoSpeedIndexImpl() if tab.video_capture_supported else
38                  PaintRectSpeedIndexImpl())
39    self._impl.Start(tab)
40
41  def Stop(self, _, tab):
42    """Stop timeline recording."""
43    assert self._impl, 'Must call Start() before Stop()'
44    assert self.IsFinished(tab), 'Must wait for IsFinished() before Stop()'
45    self._impl.Stop(tab)
46
47  # Optional argument chart_name is not in base class Metric.
48  # pylint: disable=W0221
49  def AddResults(self, tab, results, chart_name=None):
50    """Calculate the speed index and add it to the results."""
51    index = self._impl.CalculateSpeedIndex(tab)
52    # Release the tab so that it can be disconnected.
53    self._impl = None
54    results.Add('speed_index', 'ms', index, chart_name=chart_name)
55
56  def IsFinished(self, tab):
57    """Decide whether the timeline recording should be stopped.
58
59    When the timeline recording is stopped determines which paint events
60    are used in the speed index metric calculation. In general, the recording
61    should continue if there has just been some data received, because
62    this suggests that painting may continue.
63
64    A page may repeatedly request resources in an infinite loop; a timeout
65    should be placed in any measurement that uses this metric, e.g.:
66      def IsDone():
67        return self._speedindex.IsFinished(tab)
68      util.WaitFor(IsDone, 60)
69
70    Returns:
71      True if 2 seconds have passed since last resource received, false
72      otherwise.
73    """
74    return tab.HasReachedQuiescence()
75
76
77class SpeedIndexImpl(object):
78
79  def Start(self, tab):
80    raise NotImplementedError()
81
82  def Stop(self, tab):
83    raise NotImplementedError()
84
85  def GetTimeCompletenessList(self, tab):
86    """Returns a list of time to visual completeness tuples.
87
88    In the WPT PHP implementation, this is also called 'visual progress'.
89    """
90    raise NotImplementedError()
91
92  def CalculateSpeedIndex(self, tab):
93    """Calculate the speed index.
94
95    The speed index number conceptually represents the number of milliseconds
96    that the page was "visually incomplete". If the page were 0% complete for
97    1000 ms, then the score would be 1000; if it were 0% complete for 100 ms
98    then 90% complete (ie 10% incomplete) for 900 ms, then the score would be
99    1.0*100 + 0.1*900 = 190.
100
101    Returns:
102      A single number, milliseconds of visual incompleteness.
103    """
104    time_completeness_list = self.GetTimeCompletenessList(tab)
105    prev_completeness = 0.0
106    speed_index = 0.0
107    prev_time = time_completeness_list[0][0]
108    for time, completeness in time_completeness_list:
109      # Add the incemental value for the interval just before this event.
110      elapsed_time = time - prev_time
111      incompleteness = (1.0 - prev_completeness)
112      speed_index += elapsed_time * incompleteness
113
114      # Update variables for next iteration.
115      prev_completeness = completeness
116      prev_time = time
117    return int(speed_index)
118
119
120class VideoSpeedIndexImpl(SpeedIndexImpl):
121
122  def __init__(self):
123    super(VideoSpeedIndexImpl, self).__init__()
124    self._time_completeness_list = None
125
126  def Start(self, tab):
127    assert tab.video_capture_supported
128    # Blank out the current page so it doesn't count towards the new page's
129    # completeness.
130    tab.Highlight(bitmap.WHITE)
131    # TODO(tonyg): Bitrate is arbitrary here. Experiment with screen capture
132    # overhead vs. speed index accuracy and set the bitrate appropriately.
133    tab.StartVideoCapture(min_bitrate_mbps=4)
134
135  def Stop(self, tab):
136    # Ignore white because Chrome may blank out the page during load and we want
137    # that to count as 0% complete. Relying on this fact, we also blank out the
138    # previous page to white. The tolerance of 8 experimentally does well with
139    # video capture at 4mbps. We should keep this as low as possible with
140    # supported video compression settings.
141    histograms = [(time, bmp.ColorHistogram(ignore_color=bitmap.WHITE,
142                                            tolerance=8))
143                  for time, bmp in tab.StopVideoCapture()]
144
145    start_histogram = histograms[0][1]
146    final_histogram = histograms[-1][1]
147    total_distance = start_histogram.Distance(final_histogram)
148
149    def FrameProgress(histogram):
150      if total_distance == 0:
151        if histogram.Distance(final_histogram) == 0:
152          return 1.0
153        else:
154          return 0.0
155      return 1 - histogram.Distance(final_histogram) / total_distance
156
157    self._time_completeness_list = [(time, FrameProgress(hist))
158                                    for time, hist in histograms]
159
160  def GetTimeCompletenessList(self, tab):
161    assert self._time_completeness_list, 'Must call Stop() first.'
162    return self._time_completeness_list
163
164
165class PaintRectSpeedIndexImpl(SpeedIndexImpl):
166
167  def __init__(self):
168    super(PaintRectSpeedIndexImpl, self).__init__()
169
170  def Start(self, tab):
171    tab.StartTimelineRecording()
172
173  def Stop(self, tab):
174    tab.StopTimelineRecording()
175
176  def GetTimeCompletenessList(self, tab):
177    events = tab.timeline_model.GetAllEvents()
178    viewport = self._GetViewportSize(tab)
179    paint_events = self._IncludedPaintEvents(events)
180    time_area_dict = self._TimeAreaDict(paint_events, viewport)
181    total_area = sum(time_area_dict.values())
182    assert total_area > 0.0, 'Total paint event area must be greater than 0.'
183    completeness = 0.0
184    time_completeness_list = []
185
186    # TODO(tonyg): This sets the start time to the start of the first paint
187    # event. That can't be correct. The start time should be navigationStart.
188    # Since the previous screen is not cleared at navigationStart, we should
189    # probably assume the completeness is 0 until the first paint and add the
190    # time of navigationStart as the start. We need to confirm what WPT does.
191    time_completeness_list.append(
192        (tab.timeline_model.GetAllEvents()[0].start, completeness))
193
194    for time, area in sorted(time_area_dict.items()):
195      completeness += float(area) / total_area
196      # Visual progress is rounded to the nearest percentage point as in WPT.
197      time_completeness_list.append((time, round(completeness, 2)))
198    return time_completeness_list
199
200  def _GetViewportSize(self, tab):
201    """Returns dimensions of the viewport."""
202    return tab.EvaluateJavaScript('[ window.innerWidth, window.innerHeight ]')
203
204  def _IncludedPaintEvents(self, events):
205    """Get all events that are counted in the calculation of the speed index.
206
207    There's one category of paint event that's filtered out: paint events
208    that occur before the first 'ResourceReceiveResponse' and 'Layout' events.
209
210    Previously in the WPT speed index, paint events that contain children paint
211    events were also filtered out.
212    """
213    def FirstLayoutTime(events):
214      """Get the start time of the first layout after a resource received."""
215      has_received_response = False
216      for event in events:
217        if event.name == 'ResourceReceiveResponse':
218          has_received_response = True
219        elif has_received_response and event.name == 'Layout':
220          return event.start
221      assert False, 'There were no layout events after resource receive events.'
222
223    first_layout_time = FirstLayoutTime(events)
224    paint_events = [e for e in events
225                    if e.start >= first_layout_time and e.name == 'Paint']
226    return paint_events
227
228  def _TimeAreaDict(self, paint_events, viewport):
229    """Make a dict from time to adjusted area value for events at that time.
230
231    The adjusted area value of each paint event is determined by how many paint
232    events cover the same rectangle, and whether it's a full-window paint event.
233    "Adjusted area" can also be thought of as "points" of visual completeness --
234    each rectangle has a certain number of points and these points are
235    distributed amongst the paint events that paint that rectangle.
236
237    Args:
238      paint_events: A list of paint events
239      viewport: A tuple (width, height) of the window.
240
241    Returns:
242      A dictionary of times of each paint event (in milliseconds) to the
243      adjusted area that the paint event is worth.
244    """
245    width, height = viewport
246    fullscreen_area = width * height
247
248    def ClippedArea(rectangle):
249      """Returns rectangle area clipped to viewport size."""
250      _, x0, y0, x1, y1 = rectangle
251      clipped_width = max(0, min(width, x1) - max(0, x0))
252      clipped_height = max(0, min(height, y1) - max(0, y0))
253      return clipped_width * clipped_height
254
255    grouped = self._GroupEventByRectangle(paint_events)
256    event_area_dict = collections.defaultdict(int)
257
258    for rectangle, events in grouped.items():
259      # The area points for each rectangle are divided up among the paint
260      # events in that rectangle.
261      area = ClippedArea(rectangle)
262      update_count = len(events)
263      adjusted_area = float(area) / update_count
264
265      # Paint events for the largest-area rectangle are counted as 50%.
266      if area == fullscreen_area:
267        adjusted_area /= 2
268
269      for event in events:
270        # The end time for an event is used for that event's time.
271        event_time = event.end
272        event_area_dict[event_time] += adjusted_area
273
274    return event_area_dict
275
276  def _GetRectangle(self, paint_event):
277    """Get the specific rectangle on the screen for a paint event.
278
279    Each paint event belongs to a frame (as in html <frame> or <iframe>).
280    This, together with location and dimensions, comprises a rectangle.
281    In the WPT source, this 'rectangle' is also called a 'region'.
282    """
283    def GetBox(quad):
284      """Gets top-left and bottom-right coordinates from paint event.
285
286      In the timeline data from devtools, paint rectangle dimensions are
287      represented x-y coordinates of four corners, clockwise from the top-left.
288      See: function WebInspector.TimelinePresentationModel.quadFromRectData
289      in file src/out/Debug/obj/gen/devtools/TimelinePanel.js.
290      """
291      x0, y0, _, _, x1, y1, _, _ = quad
292      return (x0, y0, x1, y1)
293
294    assert paint_event.name == 'Paint'
295    frame = paint_event.args['frameId']
296    return (frame,) + GetBox(paint_event.args['data']['clip'])
297
298  def _GroupEventByRectangle(self, paint_events):
299    """Group all paint events according to the rectangle that they update."""
300    result = collections.defaultdict(list)
301    for event in paint_events:
302      assert event.name == 'Paint'
303      result[self._GetRectangle(event)].append(event)
304    return result
305