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 9from telemetry.value import scalar 10 11 12class SpeedIndexMetric(Metric): 13 """The speed index metric is one way of measuring page load speed. 14 15 It is meant to approximate user perception of page load speed, and it 16 is based on the amount of time that it takes to paint to the visual 17 portion of the screen. It includes paint events that occur after the 18 onload event, and it doesn't include time loading things off-screen. 19 20 This speed index metric is based on WebPageTest.org (WPT). 21 For more info see: http://goo.gl/e7AH5l 22 """ 23 def __init__(self): 24 super(SpeedIndexMetric, self).__init__() 25 self._impl = None 26 27 @classmethod 28 def CustomizeBrowserOptions(cls, options): 29 options.AppendExtraBrowserArgs('--disable-infobars') 30 31 def Start(self, _, tab): 32 """Start recording events. 33 34 This method should be called in the WillNavigateToPage method of 35 a PageTest, so that all the events can be captured. If it's called 36 in DidNavigateToPage, that will be too late. 37 """ 38 self._impl = (VideoSpeedIndexImpl() if tab.video_capture_supported else 39 PaintRectSpeedIndexImpl()) 40 self._impl.Start(tab) 41 42 def Stop(self, _, tab): 43 """Stop timeline recording.""" 44 assert self._impl, 'Must call Start() before Stop()' 45 assert self.IsFinished(tab), 'Must wait for IsFinished() before Stop()' 46 self._impl.Stop(tab) 47 48 # Optional argument chart_name is not in base class Metric. 49 # pylint: disable=W0221 50 def AddResults(self, tab, results, chart_name=None): 51 """Calculate the speed index and add it to the results.""" 52 index = self._impl.CalculateSpeedIndex(tab) 53 # Release the tab so that it can be disconnected. 54 self._impl = None 55 results.AddValue(scalar.ScalarValue( 56 results.current_page, '%s.speed_index' % chart_name, 'ms', index)) 57 58 def IsFinished(self, tab): 59 """Decide whether the timeline recording should be stopped. 60 61 When the timeline recording is stopped determines which paint events 62 are used in the speed index metric calculation. In general, the recording 63 should continue if there has just been some data received, because 64 this suggests that painting may continue. 65 66 A page may repeatedly request resources in an infinite loop; a timeout 67 should be placed in any measurement that uses this metric, e.g.: 68 def IsDone(): 69 return self._speedindex.IsFinished(tab) 70 util.WaitFor(IsDone, 60) 71 72 Returns: 73 True if 2 seconds have passed since last resource received, false 74 otherwise. 75 """ 76 return tab.HasReachedQuiescence() 77 78 79class SpeedIndexImpl(object): 80 81 def Start(self, tab): 82 raise NotImplementedError() 83 84 def Stop(self, tab): 85 raise NotImplementedError() 86 87 def GetTimeCompletenessList(self, tab): 88 """Returns a list of time to visual completeness tuples. 89 90 In the WPT PHP implementation, this is also called 'visual progress'. 91 """ 92 raise NotImplementedError() 93 94 def CalculateSpeedIndex(self, tab): 95 """Calculate the speed index. 96 97 The speed index number conceptually represents the number of milliseconds 98 that the page was "visually incomplete". If the page were 0% complete for 99 1000 ms, then the score would be 1000; if it were 0% complete for 100 ms 100 then 90% complete (ie 10% incomplete) for 900 ms, then the score would be 101 1.0*100 + 0.1*900 = 190. 102 103 Returns: 104 A single number, milliseconds of visual incompleteness. 105 """ 106 time_completeness_list = self.GetTimeCompletenessList(tab) 107 prev_completeness = 0.0 108 speed_index = 0.0 109 prev_time = time_completeness_list[0][0] 110 for time, completeness in time_completeness_list: 111 # Add the incemental value for the interval just before this event. 112 elapsed_time = time - prev_time 113 incompleteness = (1.0 - prev_completeness) 114 speed_index += elapsed_time * incompleteness 115 116 # Update variables for next iteration. 117 prev_completeness = completeness 118 prev_time = time 119 return int(speed_index) 120 121 122class VideoSpeedIndexImpl(SpeedIndexImpl): 123 124 def __init__(self): 125 super(VideoSpeedIndexImpl, self).__init__() 126 self._time_completeness_list = None 127 128 def Start(self, tab): 129 assert tab.video_capture_supported 130 # Blank out the current page so it doesn't count towards the new page's 131 # completeness. 132 tab.Highlight(bitmap.WHITE) 133 # TODO(tonyg): Bitrate is arbitrary here. Experiment with screen capture 134 # overhead vs. speed index accuracy and set the bitrate appropriately. 135 tab.StartVideoCapture(min_bitrate_mbps=4) 136 137 def Stop(self, tab): 138 # Ignore white because Chrome may blank out the page during load and we want 139 # that to count as 0% complete. Relying on this fact, we also blank out the 140 # previous page to white. The tolerance of 8 experimentally does well with 141 # video capture at 4mbps. We should keep this as low as possible with 142 # supported video compression settings. 143 video_capture = tab.StopVideoCapture() 144 histograms = [(time, bmp.ColorHistogram(ignore_color=bitmap.WHITE, 145 tolerance=8)) 146 for time, bmp in video_capture.GetVideoFrameIter()] 147 148 start_histogram = histograms[0][1] 149 final_histogram = histograms[-1][1] 150 total_distance = start_histogram.Distance(final_histogram) 151 152 def FrameProgress(histogram): 153 if total_distance == 0: 154 if histogram.Distance(final_histogram) == 0: 155 return 1.0 156 else: 157 return 0.0 158 return 1 - histogram.Distance(final_histogram) / total_distance 159 160 self._time_completeness_list = [(time, FrameProgress(hist)) 161 for time, hist in histograms] 162 163 def GetTimeCompletenessList(self, tab): 164 assert self._time_completeness_list, 'Must call Stop() first.' 165 return self._time_completeness_list 166 167 168class PaintRectSpeedIndexImpl(SpeedIndexImpl): 169 170 def __init__(self): 171 super(PaintRectSpeedIndexImpl, self).__init__() 172 173 def Start(self, tab): 174 tab.StartTimelineRecording() 175 176 def Stop(self, tab): 177 tab.StopTimelineRecording() 178 179 def GetTimeCompletenessList(self, tab): 180 events = tab.timeline_model.GetAllEvents() 181 viewport = self._GetViewportSize(tab) 182 paint_events = self._IncludedPaintEvents(events) 183 time_area_dict = self._TimeAreaDict(paint_events, viewport) 184 total_area = sum(time_area_dict.values()) 185 assert total_area > 0.0, 'Total paint event area must be greater than 0.' 186 completeness = 0.0 187 time_completeness_list = [] 188 189 # TODO(tonyg): This sets the start time to the start of the first paint 190 # event. That can't be correct. The start time should be navigationStart. 191 # Since the previous screen is not cleared at navigationStart, we should 192 # probably assume the completeness is 0 until the first paint and add the 193 # time of navigationStart as the start. We need to confirm what WPT does. 194 time_completeness_list.append( 195 (tab.timeline_model.GetAllEvents()[0].start, completeness)) 196 197 for time, area in sorted(time_area_dict.items()): 198 completeness += float(area) / total_area 199 # Visual progress is rounded to the nearest percentage point as in WPT. 200 time_completeness_list.append((time, round(completeness, 2))) 201 return time_completeness_list 202 203 def _GetViewportSize(self, tab): 204 """Returns dimensions of the viewport.""" 205 return tab.EvaluateJavaScript('[ 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