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