45c50c19c5899694002c1f03404d41d534d4cde1
[WebKit-https.git] / Tools / Scripts / webkitpy / performance_tests / perftest.py
1 # Copyright (C) 2012 Google Inc. All rights reserved.
2 # Copyright (C) 2012 Zoltan Horvath, Adobe Systems Incorporated. All rights reserved.
3 #
4 # Redistribution and use in source and binary forms, with or without
5 # modification, are permitted provided that the following conditions are
6 # met:
7 #
8 #     * Redistributions of source code must retain the above copyright
9 # notice, this list of conditions and the following disclaimer.
10 #     * Redistributions in binary form must reproduce the above
11 # copyright notice, this list of conditions and the following disclaimer
12 # in the documentation and/or other materials provided with the
13 # distribution.
14 #     * Neither the name of Google Inc. nor the names of its
15 # contributors may be used to endorse or promote products derived from
16 # this software without specific prior written permission.
17 #
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30
31 import errno
32 import logging
33 import math
34 import re
35 import os
36 import signal
37 import socket
38 import subprocess
39 import sys
40 import time
41
42 # Import for auto-install
43 if sys.platform not in ('cygwin', 'win32'):
44     # FIXME: webpagereplay doesn't work on win32. See https://bugs.webkit.org/show_bug.cgi?id=88279.
45     import webkitpy.thirdparty.autoinstalled.webpagereplay.replay
46
47 from webkitpy.layout_tests.controllers.test_result_writer import TestResultWriter
48 from webkitpy.layout_tests.port.driver import DriverInput
49 from webkitpy.layout_tests.port.driver import DriverOutput
50
51
52 _log = logging.getLogger(__name__)
53
54
55 class PerfTestMetric(object):
56     def __init__(self, metric, unit=None, iterations=None):
57         # FIXME: Fix runner.js to report correct metric names
58         self._iterations = iterations or []
59         self._unit = unit or self.metric_to_unit(metric)
60         self._metric = self.time_unit_to_metric(self._unit) if metric == 'Time' else metric
61
62     def name(self):
63         return self._metric
64
65     def has_values(self):
66         return bool(self._iterations)
67
68     def append_group(self, group_values):
69         assert isinstance(group_values, list)
70         self._iterations.append(group_values)
71
72     def grouped_iteration_values(self):
73         return self._iterations
74
75     def flattened_iteration_values(self):
76         return [value for group_values in self._iterations for value in group_values]
77
78     def unit(self):
79         return self._unit
80
81     @staticmethod
82     def metric_to_unit(metric):
83         assert metric in ('Time', 'Malloc', 'JSHeap')
84         return 'ms' if metric == 'Time' else 'bytes'
85
86     @staticmethod
87     def time_unit_to_metric(unit):
88         return {'fps': 'FrameRate', 'runs/s': 'Runs', 'ms': 'Time'}[unit]
89
90
91 class PerfTest(object):
92     def __init__(self, port, test_name, test_path, process_run_count=4):
93         self._port = port
94         self._test_name = test_name
95         self._test_path = test_path
96         self._description = None
97         self._metrics = {}
98         self._ordered_metrics_name = []
99         self._process_run_count = process_run_count
100
101     def test_name(self):
102         return self._test_name
103
104     def test_name_without_file_extension(self):
105         return re.sub(r'\.\w+$', '', self.test_name())
106
107     def test_path(self):
108         return self._test_path
109
110     def description(self):
111         return self._description
112
113     def prepare(self, time_out_ms):
114         return True
115
116     def _create_driver(self):
117         return self._port.create_driver(worker_number=0, no_timeout=True)
118
119     def run(self, time_out_ms):
120         for _ in xrange(self._process_run_count):
121             driver = self._create_driver()
122             try:
123                 if not self._run_with_driver(driver, time_out_ms):
124                     return None
125             finally:
126                 driver.stop()
127
128         should_log = not self._port.get_option('profile')
129         if should_log and self._description:
130             _log.info('DESCRIPTION: %s' % self._description)
131
132         results = {}
133         for metric_name in self._ordered_metrics_name:
134             metric = self._metrics[metric_name]
135             results[metric.name()] = metric.grouped_iteration_values()
136             if should_log:
137                 legacy_chromium_bot_compatible_name = self.test_name_without_file_extension().replace('/', ': ')
138                 self.log_statistics(legacy_chromium_bot_compatible_name + ': ' + metric.name(),
139                     metric.flattened_iteration_values(), metric.unit())
140
141         return results
142
143     @staticmethod
144     def log_statistics(test_name, values, unit):
145         sorted_values = sorted(values)
146
147         # Compute the mean and variance using Knuth's online algorithm (has good numerical stability).
148         square_sum = 0
149         mean = 0
150         for i, time in enumerate(sorted_values):
151             delta = time - mean
152             sweep = i + 1.0
153             mean += delta / sweep
154             square_sum += delta * (time - mean)
155
156         middle = int(len(sorted_values) / 2)
157         mean = sum(sorted_values) / len(values)
158         median = sorted_values[middle] if len(sorted_values) % 2 else (sorted_values[middle - 1] + sorted_values[middle]) / 2
159         stdev = math.sqrt(square_sum / (len(sorted_values) - 1)) if len(sorted_values) > 1 else 0
160
161         _log.info('RESULT %s= %s %s' % (test_name, mean, unit))
162         _log.info('median= %s %s, stdev= %s %s, min= %s %s, max= %s %s' %
163             (median, unit, stdev, unit, sorted_values[0], unit, sorted_values[-1], unit))
164
165     _description_regex = re.compile(r'^Description: (?P<description>.*)$', re.IGNORECASE)
166     _metrics_regex = re.compile(r'^(?P<metric>Time|Malloc|JS Heap):')
167     _statistics_keys = ['avg', 'median', 'stdev', 'min', 'max', 'unit', 'values']
168     _score_regex = re.compile(r'^(?P<key>' + r'|'.join(_statistics_keys) + r')\s+(?P<value>([0-9\.]+(,\s+)?)+)\s*(?P<unit>.*)')
169
170     def _run_with_driver(self, driver, time_out_ms):
171         output = self.run_single(driver, self.test_path(), time_out_ms)
172         self._filter_output(output)
173         if self.run_failed(output):
174             return False
175
176         current_metric = None
177         for line in re.split('\n', output.text):
178             description_match = self._description_regex.match(line)
179             metric_match = self._metrics_regex.match(line)
180             score = self._score_regex.match(line)
181
182             if description_match:
183                 self._description = description_match.group('description')
184             elif metric_match:
185                 current_metric = metric_match.group('metric').replace(' ', '')
186             elif score:
187                 if score.group('key') != 'values':
188                     continue
189
190                 metric = self._ensure_metrics(current_metric, score.group('unit'))
191                 metric.append_group(map(lambda value: float(value), score.group('value').split(', ')))
192             else:
193                 _log.error('ERROR: ' + line)
194                 return False
195
196         return True
197
198     def _ensure_metrics(self, metric_name, unit=None):
199         if metric_name not in self._metrics:
200             self._metrics[metric_name] = PerfTestMetric(metric_name, unit)
201             self._ordered_metrics_name.append(metric_name)
202         return self._metrics[metric_name]
203
204     def run_single(self, driver, test_path, time_out_ms, should_run_pixel_test=False):
205         return driver.run_test(DriverInput(test_path, time_out_ms, image_hash=None, should_run_pixel_test=should_run_pixel_test), stop_when_done=False)
206
207     def run_failed(self, output):
208         if output.text == None or output.error:
209             pass
210         elif output.timeout:
211             _log.error('timeout: %s' % self.test_name())
212         elif output.crash:
213             _log.error('crash: %s' % self.test_name())
214         else:
215             return False
216
217         if output.error:
218             _log.error('error: %s\n%s' % (self.test_name(), output.error))
219
220         return True
221
222     @staticmethod
223     def _should_ignore_line(regexps, line):
224         if not line:
225             return True
226         for regexp in regexps:
227             if regexp.search(line):
228                 return True
229         return False
230
231     _lines_to_ignore_in_stderr = [
232         re.compile(r'^Unknown option:'),
233         re.compile(r'^\[WARNING:proxy_service.cc'),
234         re.compile(r'^\[INFO:'),
235     ]
236
237     _lines_to_ignore_in_parser_result = [
238         re.compile(r'^Running \d+ times$'),
239         re.compile(r'^Ignoring warm-up '),
240         re.compile(r'^Info:'),
241         re.compile(r'^\d+(.\d+)?(\s*(runs\/s|ms|fps))?$'),
242         # Following are for handle existing test like Dromaeo
243         re.compile(re.escape("""main frame - has 1 onunload handler(s)""")),
244         re.compile(re.escape("""frame "<!--framePath //<!--frame0-->-->" - has 1 onunload handler(s)""")),
245         re.compile(re.escape("""frame "<!--framePath //<!--frame0-->/<!--frame0-->-->" - has 1 onunload handler(s)""")),
246         # Following is for html5.html
247         re.compile(re.escape("""Blocked access to external URL http://www.whatwg.org/specs/web-apps/current-work/""")),
248         re.compile(r"CONSOLE MESSAGE: (line \d+: )?Blocked script execution in '[A-Za-z0-9\-\.:]+' because the document's frame is sandboxed and the 'allow-scripts' permission is not set."),
249         # Dromaeo reports values for subtests. Ignore them for now.
250         re.compile(r'(?P<name>.+): \[(?P<values>(\d+(.\d+)?,\s+)*\d+(.\d+)?)\]'),
251     ]
252
253     def _filter_output(self, output):
254         if output.error:
255             output.error = '\n'.join([line for line in re.split('\n', output.error) if not self._should_ignore_line(self._lines_to_ignore_in_stderr, line)])
256         if output.text:
257             output.text = '\n'.join([line for line in re.split('\n', output.text) if not self._should_ignore_line(self._lines_to_ignore_in_parser_result, line)])
258
259
260 class SingleProcessPerfTest(PerfTest):
261     def __init__(self, port, test_name, test_path):
262         super(SingleProcessPerfTest, self).__init__(port, test_name, test_path, process_run_count=1)
263
264
265 class ChromiumStylePerfTest(PerfTest):
266     _chromium_style_result_regex = re.compile(r'^RESULT\s+(?P<name>[^=]+)\s*=\s+(?P<value>\d+(\.\d+)?)\s*(?P<unit>\w+)$')
267
268     def __init__(self, port, test_name, test_path):
269         super(ChromiumStylePerfTest, self).__init__(port, test_name, test_path)
270
271     def run(self, time_out_ms):
272         driver = self._create_driver()
273         try:
274             output = self.run_single(driver, self.test_path(), time_out_ms)
275         finally:
276             driver.stop()
277
278         self._filter_output(output)
279         if self.run_failed(output):
280             return None
281
282         return self.parse_and_log_output(output)
283
284     def parse_and_log_output(self, output):
285         test_failed = False
286         results = {}
287         for line in re.split('\n', output.text):
288             resultLine = ChromiumStylePerfTest._chromium_style_result_regex.match(line)
289             if resultLine:
290                 # FIXME: Store the unit
291                 results[resultLine.group('name').replace(' ', '')] = float(resultLine.group('value'))
292                 _log.info(line)
293             elif not len(line) == 0:
294                 test_failed = True
295                 _log.error(line)
296         return results if results and not test_failed else None
297
298
299 class ReplayServer(object):
300     def __init__(self, archive, record):
301         self._process = None
302
303         # FIXME: Should error if local proxy isn't set to forward requests to localhost:8080 and localhost:8443
304
305         replay_path = webkitpy.thirdparty.autoinstalled.webpagereplay.replay.__file__
306         args = ['python', replay_path, '--no-dns_forwarding', '--port', '8080', '--ssl_port', '8443', '--use_closest_match', '--log_level', 'warning']
307         if record:
308             args.append('--record')
309         args.append(archive)
310
311         self._process = subprocess.Popen(args)
312
313     def wait_until_ready(self):
314         for i in range(0, 3):
315             try:
316                 connection = socket.create_connection(('localhost', '8080'), timeout=1)
317                 connection.close()
318                 return True
319             except socket.error:
320                 time.sleep(1)
321                 continue
322         return False
323
324     def stop(self):
325         if self._process:
326             self._process.send_signal(signal.SIGINT)
327             self._process.wait()
328         self._process = None
329
330     def __del__(self):
331         self.stop()
332
333
334 class ReplayPerfTest(PerfTest):
335     _FORCE_GC_FILE = 'resources/force-gc.html'
336
337     def __init__(self, port, test_name, test_path):
338         super(ReplayPerfTest, self).__init__(port, test_name, test_path)
339         self.force_gc_test = self._port.host.filesystem.join(self._port.perf_tests_dir(), self._FORCE_GC_FILE)
340
341     def _start_replay_server(self, archive, record):
342         try:
343             return ReplayServer(archive, record)
344         except OSError as error:
345             if error.errno == errno.ENOENT:
346                 _log.error("Replay tests require web-page-replay.")
347             else:
348                 raise error
349
350     def prepare(self, time_out_ms):
351         filesystem = self._port.host.filesystem
352         path_without_ext = filesystem.splitext(self.test_path())[0]
353
354         self._archive_path = filesystem.join(path_without_ext + '.wpr')
355         self._expected_image_path = filesystem.join(path_without_ext + '-expected.png')
356         self._url = filesystem.read_text_file(self.test_path()).split('\n')[0]
357
358         if filesystem.isfile(self._archive_path) and filesystem.isfile(self._expected_image_path):
359             _log.info("Replay ready for %s" % self._archive_path)
360             return True
361
362         _log.info("Preparing replay for %s" % self.test_name())
363
364         driver = self._port.create_driver(worker_number=0, no_timeout=True)
365         try:
366             output = self.run_single(driver, self._archive_path, time_out_ms, record=True)
367         finally:
368             driver.stop()
369
370         if not output or not filesystem.isfile(self._archive_path):
371             _log.error("Failed to prepare a replay for %s" % self.test_name())
372             return False
373
374         _log.info("Prepared replay for %s" % self.test_name())
375
376         return True
377
378     def _run_with_driver(self, driver, time_out_ms):
379         times = []
380         malloc = []
381         js_heap = []
382
383         for i in range(0, 6):
384             output = self.run_single(driver, self.test_path(), time_out_ms)
385             if not output or self.run_failed(output):
386                 return False
387             if i == 0:
388                 continue
389
390             times.append(output.test_time * 1000)
391
392             if not output.measurements:
393                 continue
394
395             for metric, result in output.measurements.items():
396                 assert metric == 'Malloc' or metric == 'JSHeap'
397                 if metric == 'Malloc':
398                     malloc.append(result)
399                 else:
400                     js_heap.append(result)
401
402         if times:
403             self._ensure_metrics('Time').append_group(times)
404         if malloc:
405             self._ensure_metrics('Malloc').append_group(malloc)
406         if js_heap:
407             self._ensure_metrics('JSHeap').append_group(js_heap)
408
409         return True
410
411     def run_single(self, driver, url, time_out_ms, record=False):
412         server = self._start_replay_server(self._archive_path, record)
413         if not server:
414             _log.error("Web page replay didn't start.")
415             return None
416
417         try:
418             _log.debug("Waiting for Web page replay to start.")
419             if not server.wait_until_ready():
420                 _log.error("Web page replay didn't start.")
421                 return None
422
423             _log.debug("Web page replay started. Loading the page.")
424             # Force GC to prevent pageload noise. See https://bugs.webkit.org/show_bug.cgi?id=98203
425             super(ReplayPerfTest, self).run_single(driver, self.force_gc_test, time_out_ms, False)
426             output = super(ReplayPerfTest, self).run_single(driver, self._url, time_out_ms, should_run_pixel_test=True)
427             if self.run_failed(output):
428                 return None
429
430             if not output.image:
431                 _log.error("Loading the page did not generate image results")
432                 _log.error(output.text)
433                 return None
434
435             filesystem = self._port.host.filesystem
436             dirname = filesystem.dirname(self._archive_path)
437             filename = filesystem.split(self._archive_path)[1]
438             writer = TestResultWriter(filesystem, self._port, dirname, filename)
439             if record:
440                 writer.write_image_files(actual_image=None, expected_image=output.image)
441             else:
442                 writer.write_image_files(actual_image=output.image, expected_image=None)
443
444             return output
445         finally:
446             server.stop()
447
448
449 class PerfTestFactory(object):
450
451     _pattern_map = [
452         (re.compile(r'^Dromaeo/'), SingleProcessPerfTest),
453         (re.compile(r'^inspector/'), ChromiumStylePerfTest),
454         (re.compile(r'(.+)\.replay$'), ReplayPerfTest),
455     ]
456
457     @classmethod
458     def create_perf_test(cls, port, test_name, path):
459         for (pattern, test_class) in cls._pattern_map:
460             if pattern.match(test_name):
461                 return test_class(port, test_name, path)
462         return PerfTest(port, test_name, path)