OLD | NEW |
---|---|
1 # Copyright 2013 The Chromium Authors. All rights reserved. | 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 | 2 # Use of this source code is governed by a BSD-style license that can be |
3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
4 | 4 |
5 import collections | 5 import collections |
6 import os | 6 import os |
7 | 7 |
8 from metrics import Metric | 8 from metrics import Metric |
9 | 9 |
10 | |
10 class SpeedIndexMetric(Metric): | 11 class SpeedIndexMetric(Metric): |
11 """The speed index metric is one way of measuring page load speed. | 12 """The speed index metric is one way of measuring page load speed. |
12 | 13 |
13 It is meant to approximate user perception of page load speed, and it | 14 It is meant to approximate user perception of page load speed, and it |
14 is based on the amount of time that it takes to paint to the visual | 15 is based on the amount of time that it takes to paint to the visual |
15 portion of the screen. It includes paint events that occur after the | 16 portion of the screen. It includes paint events that occur after the |
16 onload event, and it doesn't include time loading things off-screen. | 17 onload event, and it doesn't include time loading things off-screen. |
17 | 18 |
18 This speed index metric is based on the devtools speed index at | 19 This speed index metric is based on WebPageTest.org (WPT). |
19 WebPageTest.org (WPT). For more info see: http://goo.gl/e7AH5l | 20 For more info see: http://goo.gl/e7AH5l |
20 """ | 21 """ |
21 def __init__(self): | 22 def __init__(self): |
22 super(SpeedIndexMetric, self).__init__() | 23 super(SpeedIndexMetric, self).__init__() |
24 self._impl = None | |
23 self._script_is_loaded = False | 25 self._script_is_loaded = False |
24 self._is_finished = False | 26 self._is_finished = False |
25 with open(os.path.join(os.path.dirname(__file__), 'speedindex.js')) as f: | 27 with open(os.path.join(os.path.dirname(__file__), 'speedindex.js')) as f: |
26 self._js = f.read() | 28 self._js = f.read() |
27 | 29 |
28 def Start(self, _, tab): | 30 def Start(self, _, tab): |
29 """Start recording events. | 31 """Start recording events. |
30 | 32 |
31 This method should be called in the WillNavigateToPage method of | 33 This method should be called in the WillNavigateToPage method of |
32 a PageMeasurement, so that all the events can be captured. If it's called | 34 a PageMeasurement, so that all the events can be captured. If it's called |
33 in DidNavigateToPage, that will be too late. | 35 in DidNavigateToPage, that will be too late. |
34 """ | 36 """ |
35 tab.StartTimelineRecording() | 37 self._impl = (VideoSpeedIndexImpl(tab) if tab.video_capture_supported else |
38 PaintRectSpeedIndexImpl(tab)) | |
slamm
2013/12/02 22:51:15
Given that the two speed indexes have such differe
tonyg
2013/12/02 23:18:36
I see your point. But I think the Measurements the
slamm
2013/12/03 01:17:32
Thanks for the context.
| |
39 self._impl.Start() | |
36 self._script_is_loaded = False | 40 self._script_is_loaded = False |
37 self._is_finished = False | 41 self._is_finished = False |
38 | 42 |
39 def Stop(self, _, tab): | 43 def Stop(self, _, tab): |
40 """Stop timeline recording.""" | 44 """Stop timeline recording.""" |
41 assert self.IsFinished(tab) | 45 assert self._impl, 'Must call Start() before Stop()' |
42 tab.StopTimelineRecording() | 46 assert self.IsFinished(tab), 'Must wait for IsFinished() before Stop()' |
47 self._impl.Stop() | |
43 | 48 |
44 # Optional argument chart_name is not in base class Metric. | 49 # Optional argument chart_name is not in base class Metric. |
45 # pylint: disable=W0221 | 50 # pylint: disable=W0221 |
46 def AddResults(self, tab, results, chart_name=None): | 51 def AddResults(self, tab, results, chart_name=None): |
47 """Calculate the speed index and add it to the results.""" | 52 """Calculate the speed index and add it to the results.""" |
48 events = tab.timeline_model.GetAllEvents() | 53 index = self._impl.CalculateSpeedIndex() |
49 index = _SpeedIndex(events, _GetViewportSize(tab)) | |
50 results.Add('speed_index', 'ms', index, chart_name=chart_name) | 54 results.Add('speed_index', 'ms', index, chart_name=chart_name) |
51 | 55 |
52 def IsFinished(self, tab): | 56 def IsFinished(self, tab): |
53 """Decide whether the timeline recording should be stopped. | 57 """Decide whether the timeline recording should be stopped. |
54 | 58 |
55 When the timeline recording is stopped determines which paint events | 59 When the timeline recording is stopped determines which paint events |
56 are used in the speed index metric calculation. In general, the recording | 60 are used in the speed index metric calculation. In general, the recording |
57 should continue if there has just been some data received, because | 61 should continue if there has just been some data received, because |
58 this suggests that painting may continue. | 62 this suggests that painting may continue. |
59 | 63 |
(...skipping 19 matching lines...) Expand all Loading... | |
79 if not self._script_is_loaded: | 83 if not self._script_is_loaded: |
80 tab.ExecuteJavaScript(self._js) | 84 tab.ExecuteJavaScript(self._js) |
81 self._script_is_loaded = True | 85 self._script_is_loaded = True |
82 | 86 |
83 time_since_last_response_ms = tab.EvaluateJavaScript( | 87 time_since_last_response_ms = tab.EvaluateJavaScript( |
84 "window.timeSinceLastResponseAfterLoadMs()") | 88 "window.timeSinceLastResponseAfterLoadMs()") |
85 self._is_finished = time_since_last_response_ms > 2000 | 89 self._is_finished = time_since_last_response_ms > 2000 |
86 return self._is_finished | 90 return self._is_finished |
87 | 91 |
88 | 92 |
89 def _GetViewportSize(tab): | 93 class SpeedIndexImpl(object): |
90 """Returns dimensions of the viewport.""" | 94 |
91 return tab.EvaluateJavaScript('[ window.innerWidth, window.innerHeight ]') | 95 def __init__(self, tab): |
92 | 96 self.tab = tab |
93 | 97 |
94 def _SpeedIndex(events, viewport): | 98 def Start(self): |
95 """Calculate the speed index of a page load from a list of events. | 99 raise NotImplementedError() |
96 | 100 |
97 The speed index number conceptually represents the number of milliseconds | 101 def Stop(self): |
98 that the page was "visually incomplete". If the page were 0% complete for | 102 raise NotImplementedError() |
99 1000 ms, then the score would be 1000; if it were 0% complete for 100 ms | 103 |
100 then 90% complete (ie 10% incomplete) for 900 ms, then the score would be | 104 def GetTimeCompletenessList(self): |
101 1.0*100 + 0.1*900 = 190. | 105 """Returns a list of time to visual completeness tuples. |
102 | 106 |
103 Args: | 107 In the WPT PHP implementation, this is also called 'visual progress'. |
104 events: A list of telemetry.core.timeline.slice.Slice objects | 108 """ |
105 viewport: A tuple (width, height) of the window. | 109 raise NotImplementedError() |
106 | 110 |
107 Returns: | 111 def CalculateSpeedIndex(self): |
108 A single number, milliseconds of visual incompleteness. | 112 """Calculate the speed index. |
109 """ | 113 |
110 paint_events = _IncludedPaintEvents(events) | 114 The speed index number conceptually represents the number of milliseconds |
111 time_area_dict = _TimeAreaDict(paint_events, viewport) | 115 that the page was "visually incomplete". If the page were 0% complete for |
112 time_completeness_dict = _TimeCompletenessDict(time_area_dict) | 116 1000 ms, then the score would be 1000; if it were 0% complete for 100 ms |
113 # The first time interval starts from the start of the first event. | 117 then 90% complete (ie 10% incomplete) for 900 ms, then the score would be |
114 prev_time = events[0].start | 118 1.0*100 + 0.1*900 = 190. |
115 prev_completeness = 0.0 | 119 |
116 speed_index = 0.0 | 120 Returns: |
117 for time, completeness in sorted(time_completeness_dict.items()): | 121 A single number, milliseconds of visual incompleteness. |
118 # Add the incemental value for the interval just before this event. | 122 """ |
119 elapsed_time = time - prev_time | 123 time_completeness_list = self.GetTimeCompletenessList() |
120 incompleteness = (1.0 - prev_completeness) | 124 prev_completeness = 0.0 |
121 speed_index += elapsed_time * incompleteness | 125 speed_index = 0.0 |
122 | 126 prev_time = time_completeness_list[0][0] |
123 # Update variables for next iteration. | 127 for time, completeness in time_completeness_list: |
124 prev_completeness = completeness | 128 # Add the incemental value for the interval just before this event. |
125 prev_time = time | 129 elapsed_time = time - prev_time |
126 | 130 incompleteness = (1.0 - prev_completeness) |
127 return speed_index | 131 speed_index += elapsed_time * incompleteness |
128 | 132 |
129 | 133 # Update variables for next iteration. |
130 def _TimeCompletenessDict(time_area_dict): | 134 prev_completeness = completeness |
131 """Make a dictionary of time to visual completeness. | 135 prev_time = time |
132 | 136 return speed_index |
133 In the WPT PHP implementation, this is also called 'visual progress'. | 137 |
134 """ | 138 |
135 total_area = sum(time_area_dict.values()) | 139 class VideoSpeedIndexImpl(SpeedIndexImpl): |
136 assert total_area > 0.0, 'Total paint event area must be greater than 0.' | 140 |
137 completeness = 0.0 | 141 def __init__(self, tab): |
slamm
2013/12/02 22:51:15
Document "tab"?
tonyg
2013/12/02 23:18:36
Done (in the base class).
| |
138 time_completeness_dict = {} | 142 super(VideoSpeedIndexImpl, self).__init__(tab) |
139 for time, area in sorted(time_area_dict.items()): | 143 assert self.tab.video_capture_supported |
140 completeness += float(area) / total_area | 144 self._time_completeness_list = None |
141 # Visual progress is rounded to the nearest percentage point as in WPT. | 145 |
142 time_completeness_dict[time] = round(completeness, 2) | 146 def Start(self): |
143 return time_completeness_dict | 147 # TODO(tonyg): Bitrate is arbitrary here. Experiment with screen capture |
144 | 148 # overhead vs. speed index accuracy and set the bitrate appropriately. |
145 | 149 self.tab.StartVideoCapture(min_bitrate_mbps=4) |
146 def _IncludedPaintEvents(events): | 150 |
147 """Get all events that are counted in the calculation of the speed index. | 151 def Stop(self): |
148 | 152 self._time_completeness_list = [] |
149 There's one category of paint event that's filtered out: paint events | 153 for time_ms, bitmap in self.tab.StopVideoCapture(): |
150 that occur before the first 'ResourceReceiveResponse' and 'Layout' events. | 154 # TODO(tonyg/szym): Implement this. |
151 | 155 raise NotImplementedError('SpeedIndex video calculation not implemented.') |
152 Previously in the WPT speed index, paint events that contain children paint | 156 |
153 events were also filtered out. | 157 def GetTimeCompletenessList(self): |
154 """ | 158 assert self._time_completeness_list, 'Must call Stop() first.' |
155 def FirstLayoutTime(events): | 159 return self._time_completeness_list |
156 """Get the start time of the first layout after a resource received.""" | 160 |
157 has_received_response = False | 161 |
158 for event in events: | 162 class PaintRectSpeedIndexImpl(SpeedIndexImpl): |
159 if event.name == 'ResourceReceiveResponse': | 163 |
160 has_received_response = True | 164 def __init__(self, tab): |
161 elif has_received_response and event.name == 'Layout': | 165 super(PaintRectSpeedIndexImpl, self).__init__(tab) |
162 return event.start | 166 |
163 assert False, 'There were no layout events after resource receive events.' | 167 def Start(self): |
164 | 168 self.tab.StartTimelineRecording() |
165 first_layout_time = FirstLayoutTime(events) | 169 |
166 paint_events = [e for e in events | 170 def Stop(self): |
167 if e.start >= first_layout_time and e.name == 'Paint'] | 171 self.tab.StopTimelineRecording() |
168 return paint_events | 172 |
169 | 173 def GetTimeCompletenessList(self): |
170 | 174 events = self.tab.timeline_model.GetAllEvents() |
171 def _TimeAreaDict(paint_events, viewport): | 175 viewport = self._GetViewportSize() |
172 """Make a dict from time to adjusted area value for events at that time. | 176 paint_events = self._IncludedPaintEvents(events) |
173 | 177 time_area_dict = self._TimeAreaDict(paint_events, viewport) |
174 The adjusted area value of each paint event is determined by how many paint | 178 total_area = sum(time_area_dict.values()) |
175 events cover the same rectangle, and whether it's a full-window paint event. | 179 assert total_area > 0.0, 'Total paint event area must be greater than 0.' |
176 "Adjusted area" can also be thought of as "points" of visual completeness -- | 180 completeness = 0.0 |
177 each rectangle has a certain number of points and these points are | 181 time_completeness_list = [] |
178 distributed amongst the paint events that paint that rectangle. | 182 |
179 | 183 # TODO(tonyg): This sets the start time to the start of the first paint |
180 Args: | 184 # event. That can't be correct. The start time should be navigationStart. |
181 paint_events: A list of paint events | 185 # Since the previous screen is not cleared at navigationStart, we should |
182 viewport: A tuple (width, height) of the window. | 186 # probably assume the completeness is 0 until the first paint and add the |
183 | 187 # time of navigationStart as the start. We need to confirm what WPT does. |
184 Returns: | 188 time_completeness_list.append( |
185 A dictionary of times of each paint event (in milliseconds) to the | 189 (self.tab.timeline_model.GetAllEvents()[0].start, completeness)) |
186 adjusted area that the paint event is worth. | 190 |
187 """ | 191 for time, area in sorted(time_area_dict.items()): |
188 width, height = viewport | 192 completeness += float(area) / total_area |
189 fullscreen_area = width * height | 193 # Visual progress is rounded to the nearest percentage point as in WPT. |
190 | 194 time_completeness_list.append((time, round(completeness, 2))) |
191 def ClippedArea(rectangle): | 195 return time_completeness_list |
192 """Returns rectangle area clipped to viewport size.""" | 196 |
193 _, x0, y0, x1, y1 = rectangle | 197 def _GetViewportSize(self): |
194 x0 = max(0, x0) | 198 """Returns dimensions of the viewport.""" |
195 y0 = max(0, y0) | 199 return self.tab.EvaluateJavaScript( |
196 x1 = min(width, x1) | 200 '[ window.innerWidth, window.innerHeight ]') |
197 y1 = min(height, y1) | 201 |
198 return max(0, x1 - x0) * max(0, y1 - y0) | 202 def _IncludedPaintEvents(self, events): |
199 | 203 """Get all events that are counted in the calculation of the speed index. |
200 grouped = _GroupEventByRectangle(paint_events) | 204 |
201 event_area_dict = collections.defaultdict(int) | 205 There's one category of paint event that's filtered out: paint events |
202 | 206 that occur before the first 'ResourceReceiveResponse' and 'Layout' events. |
203 for rectangle, events in grouped.items(): | 207 |
204 # The area points for each rectangle are divided up among the paint | 208 Previously in the WPT speed index, paint events that contain children paint |
205 # events in that rectangle. | 209 events were also filtered out. |
206 area = ClippedArea(rectangle) | 210 """ |
207 update_count = len(events) | 211 def FirstLayoutTime(events): |
208 adjusted_area = float(area) / update_count | 212 """Get the start time of the first layout after a resource received.""" |
209 | 213 has_received_response = False |
210 # Paint events for the largest-area rectangle are counted as 50%. | 214 for event in events: |
211 if area == fullscreen_area: | 215 if event.name == 'ResourceReceiveResponse': |
212 adjusted_area /= 2 | 216 has_received_response = True |
213 | 217 elif has_received_response and event.name == 'Layout': |
214 for event in events: | 218 return event.start |
215 # The end time for an event is used for that event's time. | 219 assert False, 'There were no layout events after resource receive events.' |
216 event_time = event.end | 220 |
217 event_area_dict[event_time] += adjusted_area | 221 first_layout_time = FirstLayoutTime(events) |
218 | 222 paint_events = [e for e in events |
219 return event_area_dict | 223 if e.start >= first_layout_time and e.name == 'Paint'] |
220 | 224 return paint_events |
221 | 225 |
222 def _GetRectangle(paint_event): | 226 def _TimeAreaDict(self, paint_events, viewport): |
223 """Get the specific rectangle on the screen for a paint event. | 227 """Make a dict from time to adjusted area value for events at that time. |
224 | 228 |
225 Each paint event belongs to a frame (as in html <frame> or <iframe>). | 229 The adjusted area value of each paint event is determined by how many paint |
226 This, together with location and dimensions, comprises a rectangle. | 230 events cover the same rectangle, and whether it's a full-window paint event. |
227 In the WPT source, this 'rectangle' is also called a 'region'. | 231 "Adjusted area" can also be thought of as "points" of visual completeness -- |
228 """ | 232 each rectangle has a certain number of points and these points are |
229 def GetBox(quad): | 233 distributed amongst the paint events that paint that rectangle. |
230 """Gets top-left and bottom-right coordinates from paint event. | 234 |
231 | 235 Args: |
232 In the timeline data from devtools, paint rectangle dimensions are | 236 paint_events: A list of paint events |
233 represented x-y coordinates of four corners, clockwise from the top-left. | 237 viewport: A tuple (width, height) of the window. |
234 See: function WebInspector.TimelinePresentationModel.quadFromRectData | 238 |
235 in file src/out/Debug/obj/gen/devtools/TimelinePanel.js. | 239 Returns: |
236 """ | 240 A dictionary of times of each paint event (in milliseconds) to the |
237 x0, y0, _, _, x1, y1, _, _ = quad | 241 adjusted area that the paint event is worth. |
238 return (x0, y0, x1, y1) | 242 """ |
239 | 243 width, height = viewport |
240 assert paint_event.name == 'Paint' | 244 fullscreen_area = width * height |
241 frame = paint_event.args['frameId'] | 245 |
242 return (frame,) + GetBox(paint_event.args['data']['clip']) | 246 def ClippedArea(rectangle): |
243 | 247 """Returns rectangle area clipped to viewport size.""" |
244 | 248 _, x0, y0, x1, y1 = rectangle |
245 def _GroupEventByRectangle(paint_events): | 249 x0 = max(0, x0) |
246 """Group all paint events according to the rectangle that they update.""" | 250 y0 = max(0, y0) |
247 result = collections.defaultdict(list) | 251 x1 = min(width, x1) |
248 for event in paint_events: | 252 y1 = min(height, y1) |
slamm
2013/12/02 22:51:15
Better or worse?
clipped_width = max(0, min(width
tonyg
2013/12/02 23:18:36
I like your implementation. Changed here.
| |
249 assert event.name == 'Paint' | 253 return max(0, x1 - x0) * max(0, y1 - y0) |
250 result[_GetRectangle(event)].append(event) | 254 |
251 return result | 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']) | |
slamm
2013/12/02 22:51:15
Perhaps one of the following:
(frame, GetBox(...
tonyg
2013/12/02 23:18:36
GetBox returns a tuple, so I don't think those imp
slamm
2013/12/03 01:17:32
Ah, of course. It's right there.
| |
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 | |
OLD | NEW |