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