speedindex.py revision a3f6a49ab37290eeeb8db0f41ec0f1cb74a68be7
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
6import os
7
8from metrics import Metric
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    self._script_is_loaded = False
26    self._is_finished = False
27    with open(os.path.join(os.path.dirname(__file__), 'speedindex.js')) as f:
28      self._js = f.read()
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(tab) if tab.video_capture_supported else
38                  PaintRectSpeedIndexImpl(tab))
39    self._impl.Start()
40    self._script_is_loaded = False
41    self._is_finished = False
42
43  def Stop(self, _, tab):
44    """Stop timeline recording."""
45    assert self._impl, 'Must call Start() before Stop()'
46    assert self.IsFinished(tab), 'Must wait for IsFinished() before Stop()'
47    self._impl.Stop()
48
49  # Optional argument chart_name is not in base class Metric.
50  # pylint: disable=W0221
51  def AddResults(self, tab, results, chart_name=None):
52    """Calculate the speed index and add it to the results."""
53    index = self._impl.CalculateSpeedIndex()
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    if self._is_finished:
75      return True
76
77    # The script that provides the function window.timeSinceLastResponseMs()
78    # needs to be loaded for this function, but it must be loaded AFTER
79    # the Start method is called, because if the Start method is called in
80    # the PageMeasurement's WillNavigateToPage function, then it will
81    # not be available here. The script should only be re-loaded once per page
82    # so that variables in the script get reset only for a new page.
83    if not self._script_is_loaded:
84      tab.ExecuteJavaScript(self._js)
85      self._script_is_loaded = True
86
87    time_since_last_response_ms = tab.EvaluateJavaScript(
88        "window.timeSinceLastResponseAfterLoadMs()")
89    self._is_finished = time_since_last_response_ms > 2000
90    return self._is_finished
91
92
93class SpeedIndexImpl(object):
94
95  def __init__(self, tab):
96    """Constructor.
97
98    Args:
99      tab: The telemetry.core.Tab object for which to calculate SpeedIndex.
100    """
101    self.tab = tab
102
103  def Start(self):
104    raise NotImplementedError()
105
106  def Stop(self):
107    raise NotImplementedError()
108
109  def GetTimeCompletenessList(self):
110    """Returns a list of time to visual completeness tuples.
111
112    In the WPT PHP implementation, this is also called 'visual progress'.
113    """
114    raise NotImplementedError()
115
116  def CalculateSpeedIndex(self):
117    """Calculate the speed index.
118
119    The speed index number conceptually represents the number of milliseconds
120    that the page was "visually incomplete". If the page were 0% complete for
121    1000 ms, then the score would be 1000; if it were 0% complete for 100 ms
122    then 90% complete (ie 10% incomplete) for 900 ms, then the score would be
123    1.0*100 + 0.1*900 = 190.
124
125    Returns:
126      A single number, milliseconds of visual incompleteness.
127    """
128    time_completeness_list = self.GetTimeCompletenessList()
129    prev_completeness = 0.0
130    speed_index = 0.0
131    prev_time = time_completeness_list[0][0]
132    for time, completeness in time_completeness_list:
133      # Add the incemental value for the interval just before this event.
134      elapsed_time = time - prev_time
135      incompleteness = (1.0 - prev_completeness)
136      speed_index += elapsed_time * incompleteness
137
138      # Update variables for next iteration.
139      prev_completeness = completeness
140      prev_time = time
141    return speed_index
142
143
144class VideoSpeedIndexImpl(SpeedIndexImpl):
145
146  def __init__(self, tab):
147    super(VideoSpeedIndexImpl, self).__init__(tab)
148    assert self.tab.video_capture_supported
149    self._time_completeness_list = None
150
151  def Start(self):
152    # TODO(tonyg): Bitrate is arbitrary here. Experiment with screen capture
153    # overhead vs. speed index accuracy and set the bitrate appropriately.
154    self.tab.StartVideoCapture(min_bitrate_mbps=4)
155
156  def Stop(self):
157    self._time_completeness_list = []
158    self.tab.StopVideoCapture()
159    # TODO(tonyg/szym): Implement this.
160    raise NotImplementedError('SpeedIndex video calculation not implemented.')
161
162  def GetTimeCompletenessList(self):
163    assert self._time_completeness_list, 'Must call Stop() first.'
164    return self._time_completeness_list
165
166
167class PaintRectSpeedIndexImpl(SpeedIndexImpl):
168
169  def __init__(self, tab):
170    super(PaintRectSpeedIndexImpl, self).__init__(tab)
171
172  def Start(self):
173    self.tab.StartTimelineRecording()
174
175  def Stop(self):
176    self.tab.StopTimelineRecording()
177
178  def GetTimeCompletenessList(self):
179    events = self.tab.timeline_model.GetAllEvents()
180    viewport = self._GetViewportSize()
181    paint_events = self._IncludedPaintEvents(events)
182    time_area_dict = self._TimeAreaDict(paint_events, viewport)
183    total_area = sum(time_area_dict.values())
184    assert total_area > 0.0, 'Total paint event area must be greater than 0.'
185    completeness = 0.0
186    time_completeness_list = []
187
188    # TODO(tonyg): This sets the start time to the start of the first paint
189    # event. That can't be correct. The start time should be navigationStart.
190    # Since the previous screen is not cleared at navigationStart, we should
191    # probably assume the completeness is 0 until the first paint and add the
192    # time of navigationStart as the start. We need to confirm what WPT does.
193    time_completeness_list.append(
194        (self.tab.timeline_model.GetAllEvents()[0].start, completeness))
195
196    for time, area in sorted(time_area_dict.items()):
197      completeness += float(area) / total_area
198      # Visual progress is rounded to the nearest percentage point as in WPT.
199      time_completeness_list.append((time, round(completeness, 2)))
200    return time_completeness_list
201
202  def _GetViewportSize(self):
203    """Returns dimensions of the viewport."""
204    return self.tab.EvaluateJavaScript(
205        '[ window.innerWidth, window.innerHeight ]')
206
207  def _IncludedPaintEvents(self, events):
208    """Get all events that are counted in the calculation of the speed index.
209
210    There's one category of paint event that's filtered out: paint events
211    that occur before the first 'ResourceReceiveResponse' and 'Layout' events.
212
213    Previously in the WPT speed index, paint events that contain children paint
214    events were also filtered out.
215    """
216    def FirstLayoutTime(events):
217      """Get the start time of the first layout after a resource received."""
218      has_received_response = False
219      for event in events:
220        if event.name == 'ResourceReceiveResponse':
221          has_received_response = True
222        elif has_received_response and event.name == 'Layout':
223          return event.start
224      assert False, 'There were no layout events after resource receive events.'
225
226    first_layout_time = FirstLayoutTime(events)
227    paint_events = [e for e in events
228                    if e.start >= first_layout_time and e.name == 'Paint']
229    return paint_events
230
231  def _TimeAreaDict(self, paint_events, viewport):
232    """Make a dict from time to adjusted area value for events at that time.
233
234    The adjusted area value of each paint event is determined by how many paint
235    events cover the same rectangle, and whether it's a full-window paint event.
236    "Adjusted area" can also be thought of as "points" of visual completeness --
237    each rectangle has a certain number of points and these points are
238    distributed amongst the paint events that paint that rectangle.
239
240    Args:
241      paint_events: A list of paint events
242      viewport: A tuple (width, height) of the window.
243
244    Returns:
245      A dictionary of times of each paint event (in milliseconds) to the
246      adjusted area that the paint event is worth.
247    """
248    width, height = viewport
249    fullscreen_area = width * height
250
251    def ClippedArea(rectangle):
252      """Returns rectangle area clipped to viewport size."""
253      _, x0, y0, x1, y1 = rectangle
254      clipped_width = max(0, min(width, x1) - max(0, x0))
255      clipped_height = max(0, min(height, y1) - max(0, y0))
256      return clipped_width * clipped_height
257
258    grouped = self._GroupEventByRectangle(paint_events)
259    event_area_dict = collections.defaultdict(int)
260
261    for rectangle, events in grouped.items():
262      # The area points for each rectangle are divided up among the paint
263      # events in that rectangle.
264      area = ClippedArea(rectangle)
265      update_count = len(events)
266      adjusted_area = float(area) / update_count
267
268      # Paint events for the largest-area rectangle are counted as 50%.
269      if area == fullscreen_area:
270        adjusted_area /= 2
271
272      for event in events:
273        # The end time for an event is used for that event's time.
274        event_time = event.end
275        event_area_dict[event_time] += adjusted_area
276
277    return event_area_dict
278
279  def _GetRectangle(self, paint_event):
280    """Get the specific rectangle on the screen for a paint event.
281
282    Each paint event belongs to a frame (as in html <frame> or <iframe>).
283    This, together with location and dimensions, comprises a rectangle.
284    In the WPT source, this 'rectangle' is also called a 'region'.
285    """
286    def GetBox(quad):
287      """Gets top-left and bottom-right coordinates from paint event.
288
289      In the timeline data from devtools, paint rectangle dimensions are
290      represented x-y coordinates of four corners, clockwise from the top-left.
291      See: function WebInspector.TimelinePresentationModel.quadFromRectData
292      in file src/out/Debug/obj/gen/devtools/TimelinePanel.js.
293      """
294      x0, y0, _, _, x1, y1, _, _ = quad
295      return (x0, y0, x1, y1)
296
297    assert paint_event.name == 'Paint'
298    frame = paint_event.args['frameId']
299    return (frame,) + GetBox(paint_event.args['data']['clip'])
300
301  def _GroupEventByRectangle(self, paint_events):
302    """Group all paint events according to the rectangle that they update."""
303    result = collections.defaultdict(list)
304    for event in paint_events:
305      assert event.name == 'Paint'
306      result[self._GetRectangle(event)].append(event)
307    return result
308