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.
7 from metrics
import Metric
8 from telemetry
.core
import bitmap
9 from telemetry
.value
import scalar
12 class SpeedIndexMetric(Metric
):
13 """The speed index metric is one way of measuring page load speed.
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.
20 This speed index metric is based on WebPageTest.org (WPT).
21 For more info see: http://goo.gl/e7AH5l
24 super(SpeedIndexMetric
, self
).__init
__()
28 def CustomizeBrowserOptions(cls
, options
):
29 options
.AppendExtraBrowserArgs('--disable-infobars')
31 def Start(self
, _
, tab
):
32 """Start recording events.
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.
38 self
._impl
= (VideoSpeedIndexImpl() if tab
.video_capture_supported
else
39 PaintRectSpeedIndexImpl())
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()'
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.
55 results
.AddValue(scalar
.ScalarValue(
56 results
.current_page
, '%s_speed_index' % chart_name
, 'ms', index
,
57 description
='Speed Index. This focuses on time when visible parts of '
58 'page are displayed and shows the time when the '
59 'first look is "almost" composed. If the contents of the '
60 'testing page are composed by only static resources, load '
61 'time can measure more accurately and speed index will be '
62 'smaller than the load time. On the other hand, If the '
63 'contents are composed by many XHR requests with small '
64 'main resource and javascript, speed index will be able to '
65 'get the features of performance more accurately than load '
66 'time because the load time will measure the time when '
67 'static resources are loaded. If you want to get more '
68 'detail, please refer to http://goo.gl/Rw3d5d. Currently '
69 'there are two implementations: for Android and for '
70 'Desktop. The Android version uses video capture; the '
71 'Desktop one uses paint events and has extra overhead to '
72 'catch paint events.'))
74 def IsFinished(self
, tab
):
75 """Decide whether the timeline recording should be stopped.
77 When the timeline recording is stopped determines which paint events
78 are used in the speed index metric calculation. In general, the recording
79 should continue if there has just been some data received, because
80 this suggests that painting may continue.
82 A page may repeatedly request resources in an infinite loop; a timeout
83 should be placed in any measurement that uses this metric, e.g.:
85 return self._speedindex.IsFinished(tab)
86 util.WaitFor(IsDone, 60)
89 True if 2 seconds have passed since last resource received, false
92 return tab
.HasReachedQuiescence()
95 class SpeedIndexImpl(object):
98 raise NotImplementedError()
101 raise NotImplementedError()
103 def GetTimeCompletenessList(self
, tab
):
104 """Returns a list of time to visual completeness tuples.
106 In the WPT PHP implementation, this is also called 'visual progress'.
108 raise NotImplementedError()
110 def CalculateSpeedIndex(self
, tab
):
111 """Calculate the speed index.
113 The speed index number conceptually represents the number of milliseconds
114 that the page was "visually incomplete". If the page were 0% complete for
115 1000 ms, then the score would be 1000; if it were 0% complete for 100 ms
116 then 90% complete (ie 10% incomplete) for 900 ms, then the score would be
117 1.0*100 + 0.1*900 = 190.
120 A single number, milliseconds of visual incompleteness.
122 time_completeness_list
= self
.GetTimeCompletenessList(tab
)
123 prev_completeness
= 0.0
125 prev_time
= time_completeness_list
[0][0]
126 for time
, completeness
in time_completeness_list
:
127 # Add the incemental value for the interval just before this event.
128 elapsed_time
= time
- prev_time
129 incompleteness
= (1.0 - prev_completeness
)
130 speed_index
+= elapsed_time
* incompleteness
132 # Update variables for next iteration.
133 prev_completeness
= completeness
135 return int(speed_index
)
138 class VideoSpeedIndexImpl(SpeedIndexImpl
):
141 super(VideoSpeedIndexImpl
, self
).__init
__()
142 self
._time
_completeness
_list
= None
144 def Start(self
, tab
):
145 assert tab
.video_capture_supported
146 # Blank out the current page so it doesn't count towards the new page's
148 tab
.Highlight(bitmap
.WHITE
)
149 # TODO(tonyg): Bitrate is arbitrary here. Experiment with screen capture
150 # overhead vs. speed index accuracy and set the bitrate appropriately.
151 tab
.StartVideoCapture(min_bitrate_mbps
=4)
154 # Ignore white because Chrome may blank out the page during load and we want
155 # that to count as 0% complete. Relying on this fact, we also blank out the
156 # previous page to white. The tolerance of 8 experimentally does well with
157 # video capture at 4mbps. We should keep this as low as possible with
158 # supported video compression settings.
159 video_capture
= tab
.StopVideoCapture()
160 histograms
= [(time
, bmp
.ColorHistogram(ignore_color
=bitmap
.WHITE
,
162 for time
, bmp
in video_capture
.GetVideoFrameIter()]
164 start_histogram
= histograms
[0][1]
165 final_histogram
= histograms
[-1][1]
166 total_distance
= start_histogram
.Distance(final_histogram
)
168 def FrameProgress(histogram
):
169 if total_distance
== 0:
170 if histogram
.Distance(final_histogram
) == 0:
174 return 1 - histogram
.Distance(final_histogram
) / total_distance
176 self
._time
_completeness
_list
= [(time
, FrameProgress(hist
))
177 for time
, hist
in histograms
]
179 def GetTimeCompletenessList(self
, tab
):
180 assert self
._time
_completeness
_list
, 'Must call Stop() first.'
181 return self
._time
_completeness
_list
184 class PaintRectSpeedIndexImpl(SpeedIndexImpl
):
187 super(PaintRectSpeedIndexImpl
, self
).__init
__()
189 def Start(self
, tab
):
190 tab
.StartTimelineRecording()
193 tab
.StopTimelineRecording()
195 def GetTimeCompletenessList(self
, tab
):
196 events
= tab
.timeline_model
.GetAllEvents()
197 viewport
= self
._GetViewportSize
(tab
)
198 paint_events
= self
._IncludedPaintEvents
(events
)
199 time_area_dict
= self
._TimeAreaDict
(paint_events
, viewport
)
200 total_area
= sum(time_area_dict
.values())
201 assert total_area
> 0.0, 'Total paint event area must be greater than 0.'
203 time_completeness_list
= []
205 # TODO(tonyg): This sets the start time to the start of the first paint
206 # event. That can't be correct. The start time should be navigationStart.
207 # Since the previous screen is not cleared at navigationStart, we should
208 # probably assume the completeness is 0 until the first paint and add the
209 # time of navigationStart as the start. We need to confirm what WPT does.
210 time_completeness_list
.append(
211 (tab
.timeline_model
.GetAllEvents()[0].start
, completeness
))
213 for time
, area
in sorted(time_area_dict
.items()):
214 completeness
+= float(area
) / total_area
215 # Visual progress is rounded to the nearest percentage point as in WPT.
216 time_completeness_list
.append((time
, round(completeness
, 2)))
217 return time_completeness_list
219 def _GetViewportSize(self
, tab
):
220 """Returns dimensions of the viewport."""
221 return tab
.EvaluateJavaScript('[ window.innerWidth, window.innerHeight ]')
223 def _IncludedPaintEvents(self
, events
):
224 """Get all events that are counted in the calculation of the speed index.
226 There's one category of paint event that's filtered out: paint events
227 that occur before the first 'ResourceReceiveResponse' and 'Layout' events.
229 Previously in the WPT speed index, paint events that contain children paint
230 events were also filtered out.
232 def FirstLayoutTime(events
):
233 """Get the start time of the first layout after a resource received."""
234 has_received_response
= False
236 if event
.name
== 'ResourceReceiveResponse':
237 has_received_response
= True
238 elif has_received_response
and event
.name
== 'Layout':
240 assert False, 'There were no layout events after resource receive events.'
242 first_layout_time
= FirstLayoutTime(events
)
243 paint_events
= [e
for e
in events
244 if e
.start
>= first_layout_time
and e
.name
== 'Paint']
247 def _TimeAreaDict(self
, paint_events
, viewport
):
248 """Make a dict from time to adjusted area value for events at that time.
250 The adjusted area value of each paint event is determined by how many paint
251 events cover the same rectangle, and whether it's a full-window paint event.
252 "Adjusted area" can also be thought of as "points" of visual completeness --
253 each rectangle has a certain number of points and these points are
254 distributed amongst the paint events that paint that rectangle.
257 paint_events: A list of paint events
258 viewport: A tuple (width, height) of the window.
261 A dictionary of times of each paint event (in milliseconds) to the
262 adjusted area that the paint event is worth.
264 width
, height
= viewport
265 fullscreen_area
= width
* height
267 def ClippedArea(rectangle
):
268 """Returns rectangle area clipped to viewport size."""
269 _
, x0
, y0
, x1
, y1
= rectangle
270 clipped_width
= max(0, min(width
, x1
) - max(0, x0
))
271 clipped_height
= max(0, min(height
, y1
) - max(0, y0
))
272 return clipped_width
* clipped_height
274 grouped
= self
._GroupEventByRectangle
(paint_events
)
275 event_area_dict
= collections
.defaultdict(int)
277 for rectangle
, events
in grouped
.items():
278 # The area points for each rectangle are divided up among the paint
279 # events in that rectangle.
280 area
= ClippedArea(rectangle
)
281 update_count
= len(events
)
282 adjusted_area
= float(area
) / update_count
284 # Paint events for the largest-area rectangle are counted as 50%.
285 if area
== fullscreen_area
:
289 # The end time for an event is used for that event's time.
290 event_time
= event
.end
291 event_area_dict
[event_time
] += adjusted_area
293 return event_area_dict
295 def _GetRectangle(self
, paint_event
):
296 """Get the specific rectangle on the screen for a paint event.
298 Each paint event belongs to a frame (as in html <frame> or <iframe>).
299 This, together with location and dimensions, comprises a rectangle.
300 In the WPT source, this 'rectangle' is also called a 'region'.
303 """Gets top-left and bottom-right coordinates from paint event.
305 In the timeline data from devtools, paint rectangle dimensions are
306 represented x-y coordinates of four corners, clockwise from the top-left.
307 See: function WebInspector.TimelinePresentationModel.quadFromRectData
308 in file src/out/Debug/obj/gen/devtools/TimelinePanel.js.
310 x0
, y0
, _
, _
, x1
, y1
, _
, _
= quad
311 return (x0
, y0
, x1
, y1
)
313 assert paint_event
.name
== 'Paint'
314 frame
= paint_event
.args
['frameId']
315 return (frame
,) + GetBox(paint_event
.args
['data']['clip'])
317 def _GroupEventByRectangle(self
, paint_events
):
318 """Group all paint events according to the rectangle that they update."""
319 result
= collections
.defaultdict(list)
320 for event
in paint_events
:
321 assert event
.name
== 'Paint'
322 result
[self
._GetRectangle
(event
)].append(event
)