run_perf.py 40.8 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12
# Copyright 2014 the V8 project authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""
Performance runner for d8.

Call e.g. with tools/run-perf.py --arch ia32 some_suite.json

The suite json format is expected to be:
{
  "path": <relative path chunks to perf resources and main file>,
13
  "owners": [<list of email addresses of benchmark owners (required)>],
14 15 16 17
  "name": <optional suite name, file name is default>,
  "archs": [<architecture name for which this suite is run>, ...],
  "binary": <name of binary to run, default "d8">,
  "flags": [<flag to d8>, ...],
18
  "test_flags": [<flag to the test file>, ...],
19 20
  "run_count": <how often will this suite run (optional)>,
  "run_count_XXX": <how often will this suite run for arch XXX (optional)>,
21 22 23 24
  "timeout": <how long test is allowed to run>,
  "timeout_XXX": <how long test is allowed run run for arch XXX>,
  "retry_count": <how many times to retry failures (in addition to first try)",
  "retry_count_XXX": <how many times to retry failures for arch XXX>
25
  "resources": [<js file to be moved to android device>, ...]
26 27 28 29
  "main": <main js perf runner file>,
  "results_regexp": <optional regexp>,
  "results_processor": <optional python results processor script>,
  "units": <the unit specification for the performance dashboard>,
30
  "process_size": <flag - collect maximum memory used by the process>,
31 32 33 34 35 36
  "tests": [
    {
      "name": <name of the trace>,
      "results_regexp": <optional more specific regexp>,
      "results_processor": <optional python results processor script>,
      "units": <the unit specification for the performance dashboard>,
37
      "process_size": <flag - collect maximum memory used by the process>,
38 39 40 41 42 43 44 45 46 47 48 49 50
    }, ...
  ]
}

The tests field can also nest other suites in arbitrary depth. A suite
with a "main" file is a leaf suite that can contain one more level of
tests.

A suite's results_regexp is expected to have one string place holder
"%s" for the trace name. A trace's results_regexp overwrites suite
defaults.

A suite's results_processor may point to an optional python script. If
51 52 53
specified, it is called after running the tests (with a path relative to the
suite level's path). It is expected to read the measurement's output text
on stdin and print the processed output to stdout.
54

55
The results_regexp will be applied to the processed output.
56 57 58 59 60 61

A suite without "tests" is considered a performance test itself.

Full example (suite with one runner):
{
  "path": ["."],
62
  "owners": ["username@chromium.org"],
63
  "flags": ["--expose-gc"],
64
  "test_flags": ["5"],
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
  "archs": ["ia32", "x64"],
  "run_count": 5,
  "run_count_ia32": 3,
  "main": "run.js",
  "results_regexp": "^%s: (.+)$",
  "units": "score",
  "tests": [
    {"name": "Richards"},
    {"name": "DeltaBlue"},
    {"name": "NavierStokes",
     "results_regexp": "^NavierStokes: (.+)$"}
  ]
}

Full example (suite with several runners):
{
  "path": ["."],
82
  "owners": ["username@chromium.org", "otherowner@google.com"],
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
  "flags": ["--expose-gc"],
  "archs": ["ia32", "x64"],
  "run_count": 5,
  "units": "score",
  "tests": [
    {"name": "Richards",
     "path": ["richards"],
     "main": "run.js",
     "run_count": 3,
     "results_regexp": "^Richards: (.+)$"},
    {"name": "NavierStokes",
     "path": ["navier_stokes"],
     "main": "run.js",
     "results_regexp": "^NavierStokes: (.+)$"}
  ]
}

Path pieces are concatenated. D8 is always run with the suite's path as cwd.
101 102

The test flags are passed to the js test file after '--'.
103 104
"""

105 106 107 108
# for py2/py3 compatibility
from __future__ import print_function
from functools import reduce

109
from collections import OrderedDict
110
import copy
111
import json
112
import logging
113
import math
114
import argparse
115 116
import os
import re
117
import subprocess
118
import sys
119
import time
120
import traceback
121

122
from testrunner.local import android
123
from testrunner.local import command
124
from testrunner.local import utils
125
from testrunner.objects.output import Output, NULL_OUTPUT
126

127 128 129 130 131 132 133 134 135 136
from math import sqrt
# NOTE: added import here to prevent breakages during the py2/3 migration,
# once we enable python3 only, we can move the import up
try:
  from numpy import mean
  from numpy import std as stdev
except ImportError:
  from statistics import mean, stdev


137
# for py2/py3 compatibility
138 139 140 141 142
try:
  basestring       # Python 2
except NameError:  # Python 3
  basestring = str

143 144 145 146 147
SUPPORTED_ARCHS = ['arm',
                   'ia32',
                   'mips',
                   'mipsel',
                   'x64',
148 149
                   'arm64',
                   'riscv64']
150 151 152 153

GENERIC_RESULTS_RE = re.compile(r'^RESULT ([^:]+): ([^=]+)= ([^ ]+) ([^ ]*)$')
RESULT_STDDEV_RE = re.compile(r'^\{([^\}]+)\}$')
RESULT_LIST_RE = re.compile(r'^\[([^\]]+)\]$')
154
TOOLS_BASE = os.path.abspath(os.path.dirname(__file__))
155
INFRA_FAILURE_RETCODE = 87
156
MIN_RUNS_FOR_CONFIDENCE = 10
157

158 159 160 161 162 163

def GeometricMean(values):
  """Returns the geometric mean of a list of values.

  The mean is calculated using log to avoid overflow.
  """
164
  values = list(map(float, values))
165
  return math.exp(sum(map(math.log, values)) / len(values))
166 167


168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
class ResultTracker(object):
  """Class that tracks trace/runnable results and produces script output.

  The output is structured like this:
  {
    "traces": [
      {
        "graphs": ["path", "to", "trace", "config"],
        "units": <string describing units, e.g. "ms" or "KB">,
        "results": [<list of values measured over several runs>],
        "stddev": <stddev of the value if measure by script or ''>
      },
      ...
    ],
    "runnables": [
      {
        "graphs": ["path", "to", "runnable", "config"],
        "durations": [<list of durations of each runnable run in seconds>],
        "timeout": <timeout configured for runnable in seconds>,
      },
      ...
    ],
    "errors": [<list of strings describing errors>],
  }
  """
  def __init__(self):
    self.traces = {}
    self.errors = []
    self.runnables = {}

198
  def AddTraceResult(self, trace, result, stddev):
199 200 201 202
    if trace.name not in self.traces:
      self.traces[trace.name] = {
        'graphs': trace.graphs,
        'units': trace.units,
203
        'results': [result],
204 205 206 207 208 209
        'stddev': stddev or '',
      }
    else:
      existing_entry = self.traces[trace.name]
      assert trace.graphs == existing_entry['graphs']
      assert trace.units == existing_entry['units']
210 211 212
      if stddev:
        existing_entry['stddev'] = stddev
      existing_entry['results'].append(result)
213

214 215
  def TraceHasStdDev(self, trace):
    return trace.name in self.traces and self.traces[trace.name]['stddev'] != ''
216

217 218 219 220 221
  def AddError(self, error):
    self.errors.append(error)

  def AddRunnableDuration(self, runnable, duration):
    """Records a duration of a specific run of the runnable."""
222 223 224
    if runnable.name not in self.runnables:
      self.runnables[runnable.name] = {
        'graphs': runnable.graphs,
225
        'durations': [duration],
226 227 228 229 230 231
        'timeout': runnable.timeout,
      }
    else:
      existing_entry = self.runnables[runnable.name]
      assert runnable.timeout == existing_entry['timeout']
      assert runnable.graphs == existing_entry['graphs']
232
      existing_entry['durations'].append(duration)
233 234

  def ToDict(self):
235
    return {
236
        'traces': list(self.traces.values()),
237
        'errors': self.errors,
238
        'runnables': list(self.runnables.values()),
239
    }
240 241

  def WriteToFile(self, file_name):
242
    with open(file_name, 'w') as f:
243 244
      f.write(json.dumps(self.ToDict()))

245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
  def HasEnoughRuns(self, graph_config, confidence_level):
    """Checks if the mean of the results for a given trace config is within
    0.1% of the true value with the specified confidence level.

    This assumes Gaussian distribution of the noise and based on
    https://en.wikipedia.org/wiki/68%E2%80%9395%E2%80%9399.7_rule.

    Args:
      graph_config: An instance of GraphConfig.
      confidence_level: Number of standard deviations from the mean that all
          values must lie within. Typical values are 1, 2 and 3 and correspond
          to 68%, 95% and 99.7% probability that the measured value is within
          0.1% of the true value.

    Returns:
      True if specified confidence level have been achieved.
    """
    if not isinstance(graph_config, TraceConfig):
      return all(self.HasEnoughRuns(child, confidence_level)
                 for child in graph_config.children)

    trace = self.traces.get(graph_config.name, {})
    results = trace.get('results', [])
    logging.debug('HasEnoughRuns for %s', graph_config.name)

    if len(results) < MIN_RUNS_FOR_CONFIDENCE:
      logging.debug('  Ran %d times, need at least %d',
                    len(results), MIN_RUNS_FOR_CONFIDENCE)
      return False

    logging.debug('  Results: %d entries', len(results))
276 277 278 279 280
    avg = mean(results)
    avg_stderr = stdev(results) / sqrt(len(results))
    logging.debug('  Mean: %.2f, mean_stderr: %.2f', avg, avg_stderr)
    logging.info('>>> Confidence level is %.2f', avg / (1000.0 * avg_stderr))
    return confidence_level * avg_stderr < avg / 1000.0
281

282
  def __str__(self):  # pragma: no cover
283
    return json.dumps(self.ToDict(), indent=2, separators=(',', ': '))
284 285


286
def RunResultsProcessor(results_processor, output, count):
287
  # Dummy pass through for null-runs.
288
  if output.stdout is None:
289
    return output
290 291 292 293 294 295 296 297 298

  # We assume the results processor is relative to the suite.
  assert os.path.exists(results_processor)
  p = subprocess.Popen(
      [sys.executable, results_processor],
      stdin=subprocess.PIPE,
      stdout=subprocess.PIPE,
      stderr=subprocess.PIPE,
  )
299
  new_output = copy.copy(output)
300 301
  new_output.stdout = p.communicate(
      input=output.stdout.encode('utf-8'))[0].decode('utf-8')
302 303
  logging.info('>>> Processed stdout (#%d):\n%s', count, output.stdout)
  return new_output
304 305


306 307 308 309 310 311 312 313
class Node(object):
  """Represents a node in the suite tree structure."""
  def __init__(self, *args):
    self._children = []

  def AppendChild(self, child):
    self._children.append(child)

314 315 316 317
  @property
  def children(self):
    return self._children

318 319 320

class DefaultSentinel(Node):
  """Fake parent node with all default values."""
321
  def __init__(self, binary = 'd8'):
322
    super(DefaultSentinel, self).__init__()
323
    self.binary = binary
324
    self.run_count = 10
325
    self.timeout = 60
326
    self.retry_count = 4
327 328 329
    self.path = []
    self.graphs = []
    self.flags = []
330
    self.test_flags = []
331
    self.process_size = False
332
    self.resources = []
333
    self.results_processor = None
334 335
    self.results_regexp = None
    self.stddev_regexp = None
336
    self.units = 'score'
337
    self.total = False
338
    self.owners = []
339 340


341
class GraphConfig(Node):
342 343 344 345 346
  """Represents a suite definition.

  Can either be a leaf or an inner node that provides default values.
  """
  def __init__(self, suite, parent, arch):
347
    super(GraphConfig, self).__init__()
348 349
    self._suite = suite

350 351 352 353 354 355
    assert isinstance(suite.get('path', []), list)
    assert isinstance(suite.get('owners', []), list)
    assert isinstance(suite['name'], basestring)
    assert isinstance(suite.get('flags', []), list)
    assert isinstance(suite.get('test_flags', []), list)
    assert isinstance(suite.get('resources', []), list)
356 357

    # Accumulated values.
358 359 360 361 362
    self.path = parent.path[:] + suite.get('path', [])
    self.graphs = parent.graphs[:] + [suite['name']]
    self.flags = parent.flags[:] + suite.get('flags', [])
    self.test_flags = parent.test_flags[:] + suite.get('test_flags', [])
    self.owners = parent.owners[:] + suite.get('owners', [])
363 364

    # Values independent of parent node.
365
    self.resources = suite.get('resources', [])
366 367

    # Descrete values (with parent defaults).
368 369 370
    self.binary = suite.get('binary', parent.binary)
    self.run_count = suite.get('run_count', parent.run_count)
    self.run_count = suite.get('run_count_%s' % arch, self.run_count)
371 372
    self.retry_count = suite.get('retry_count', parent.retry_count)
    self.retry_count = suite.get('retry_count_%s' % arch, self.retry_count)
373 374 375 376
    self.timeout = suite.get('timeout', parent.timeout)
    self.timeout = suite.get('timeout_%s' % arch, self.timeout)
    self.units = suite.get('units', parent.units)
    self.total = suite.get('total', parent.total)
377
    self.results_processor = suite.get(
378 379
        'results_processor', parent.results_processor)
    self.process_size = suite.get('process_size', parent.process_size)
380 381 382 383 384 385 386

    # A regular expression for results. If the parent graph provides a
    # regexp and the current suite has none, a string place holder for the
    # suite name is expected.
    # TODO(machenbach): Currently that makes only sense for the leaf level.
    # Multiple place holders for multiple levels are not supported.
    if parent.results_regexp:
387
      regexp_default = parent.results_regexp % re.escape(suite['name'])
388 389
    else:
      regexp_default = None
390
    self.results_regexp = suite.get('results_regexp', regexp_default)
391 392 393

    # A similar regular expression for the standard deviation (optional).
    if parent.stddev_regexp:
394
      stddev_default = parent.stddev_regexp % re.escape(suite['name'])
395 396
    else:
      stddev_default = None
397
    self.stddev_regexp = suite.get('stddev_regexp', stddev_default)
398

399 400 401 402
  @property
  def name(self):
    return '/'.join(self.graphs)

403

404 405
class TraceConfig(GraphConfig):
  """Represents a leaf in the suite tree structure."""
406
  def __init__(self, suite, parent, arch):
407
    super(TraceConfig, self).__init__(suite, parent, arch)
408
    assert self.results_regexp
409
    assert self.owners
410

411 412 413 414 415 416 417 418 419 420 421 422 423 424
  def ConsumeOutput(self, output, result_tracker):
    """Extracts trace results from the output.

    Args:
      output: Output object from the test run.
      result_tracker: Result tracker to be updated.

    Returns:
      The raw extracted result value or None if an error occurred.
    """
    result = None
    stddev = None

    try:
425 426
      result = float(
        re.search(self.results_regexp, output.stdout, re.M).group(1))
427 428 429 430 431 432 433 434
    except ValueError:
      result_tracker.AddError(
          'Regexp "%s" returned a non-numeric for test %s.' %
          (self.results_regexp, self.name))
    except:
      result_tracker.AddError(
          'Regexp "%s" did not match for test %s.' %
          (self.results_regexp, self.name))
435

436 437 438 439 440 441 442 443 444 445 446 447 448 449 450
    try:
      if self.stddev_regexp:
        if result_tracker.TraceHasStdDev(self):
          result_tracker.AddError(
              'Test %s should only run once since a stddev is provided by the '
              'test.' % self.name)
        stddev = re.search(self.stddev_regexp, output.stdout, re.M).group(1)
    except:
      result_tracker.AddError(
          'Regexp "%s" did not match for test %s.' %
          (self.stddev_regexp, self.name))

    if result:
      result_tracker.AddTraceResult(self, result, stddev)
    return result
451 452


453
class RunnableConfig(GraphConfig):
454 455
  """Represents a runnable suite definition (i.e. has a main file).
  """
456 457
  def __init__(self, suite, parent, arch):
    super(RunnableConfig, self).__init__(suite, parent, arch)
458
    self.arch = arch
459

460 461
  @property
  def main(self):
462
    return self._suite.get('main', '')
463 464 465 466 467 468 469 470

  def ChangeCWD(self, suite_path):
    """Changes the cwd to to path defined in the current graph.

    The tests are supposed to be relative to the suite configuration.
    """
    suite_dir = os.path.abspath(os.path.dirname(suite_path))
    bench_dir = os.path.normpath(os.path.join(*self.path))
471 472 473
    cwd = os.path.join(suite_dir, bench_dir)
    logging.debug('Changing CWD to: %s' % cwd)
    os.chdir(cwd)
474

475
  def GetCommandFlags(self, extra_flags=None):
476
    suffix = ['--'] + self.test_flags if self.test_flags else []
477
    return self.flags + (extra_flags or []) + [self.main] + suffix
478

479
  def GetCommand(self, cmd_prefix, shell_dir, extra_flags=None):
480
    # TODO(machenbach): This requires +.exe if run on windows.
481
    extra_flags = extra_flags or []
482
    if self.binary != 'd8' and '--prof' in extra_flags:
483
      logging.info('Profiler supported only on a benchmark run with d8')
484 485

    if self.process_size:
486
      cmd_prefix = ['/usr/bin/time', '--format=MaxMemory: %MKB'] + cmd_prefix
487 488 489 490 491 492 493 494
    if self.binary.endswith('.py'):
      # Copy cmd_prefix instead of update (+=).
      cmd_prefix = cmd_prefix + [sys.executable]

    return command.Command(
        cmd_prefix=cmd_prefix,
        shell=os.path.join(shell_dir, self.binary),
        args=self.GetCommandFlags(extra_flags=extra_flags),
495 496
        timeout=self.timeout or 60,
        handle_sigterm=True)
497

498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527
  def ProcessOutput(self, output, result_tracker, count):
    """Processes test run output and updates result tracker.

    Args:
      output: Output object from the test run.
      result_tracker: ResultTracker object to be updated.
      count: Index of the test run (used for better logging).
    """
    if self.results_processor:
      output = RunResultsProcessor(self.results_processor, output, count)

    results_for_total = []
    for trace in self.children:
      result = trace.ConsumeOutput(output, result_tracker)
      if result:
        results_for_total.append(result)

    if self.total:
      # Produce total metric only when all traces have produced results.
      if len(self.children) != len(results_for_total):
        result_tracker.AddError(
            'Not all traces have produced results. Can not compute total for '
            '%s.' % self.name)
        return

      # Calculate total as a the geometric mean for results from all traces.
      total_trace = TraceConfig(
          {'name': 'Total', 'units': self.children[0].units}, self, self.arch)
      result_tracker.AddTraceResult(
          total_trace, GeometricMean(results_for_total), '')
528

529 530

class RunnableTraceConfig(TraceConfig, RunnableConfig):
531 532
  """Represents a runnable suite definition that is a leaf."""
  def __init__(self, suite, parent, arch):
533
    super(RunnableTraceConfig, self).__init__(suite, parent, arch)
534

535 536 537
  def ProcessOutput(self, output, result_tracker, count):
    result_tracker.AddRunnableDuration(self, output.duration)
    self.ConsumeOutput(output, result_tracker)
538 539


540 541 542
def MakeGraphConfig(suite, arch, parent):
  """Factory method for making graph configuration objects."""
  if isinstance(parent, RunnableConfig):
543
    # Below a runnable can only be traces.
544
    return TraceConfig(suite, parent, arch)
545
  elif suite.get('main') is not None:
546
    # A main file makes this graph runnable. Empty strings are accepted.
547
    if suite.get('tests'):
548
      # This graph has subgraphs (traces).
549
      return RunnableConfig(suite, parent, arch)
550 551
    else:
      # This graph has no subgraphs, it's a leaf.
552
      return RunnableTraceConfig(suite, parent, arch)
553
  elif suite.get('tests'):
554
    # This is neither a leaf nor a runnable.
555
    return GraphConfig(suite, parent, arch)
556
  else:  # pragma: no cover
557
    raise Exception('Invalid suite configuration.')
558 559


560
def BuildGraphConfigs(suite, arch, parent):
561 562 563 564 565
  """Builds a tree structure of graph objects that corresponds to the suite
  configuration.
  """

  # TODO(machenbach): Implement notion of cpu type?
566
  if arch not in suite.get('archs', SUPPORTED_ARCHS):
567 568
    return None

569
  graph = MakeGraphConfig(suite, arch, parent)
570
  for subsuite in suite.get('tests', []):
571
    BuildGraphConfigs(subsuite, arch, graph)
572 573 574 575
  parent.AppendChild(graph)
  return graph


576
def FlattenRunnables(node, node_cb):
577 578 579
  """Generator that traverses the tree structure and iterates over all
  runnables.
  """
580
  node_cb(node)
581
  if isinstance(node, RunnableConfig):
582 583 584
    yield node
  elif isinstance(node, Node):
    for child in node._children:
585
      for result in FlattenRunnables(child, node_cb):
586 587
        yield result
  else:  # pragma: no cover
588
    raise Exception('Invalid suite configuration.')
589 590


591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610
def find_build_directory(base_path, arch):
  """Returns the location of d8 or node in the build output directory.

  This supports a seamless transition between legacy build location
  (out/Release) and new build location (out/build).
  """
  def is_build(path):
    # We support d8 or node as executables. We don't support testing on
    # Windows.
    return (os.path.isfile(os.path.join(path, 'd8')) or
            os.path.isfile(os.path.join(path, 'node')))
  possible_paths = [
    # Location developer wrapper scripts is using.
    '%s.release' % arch,
    # Current build location on bots.
    'build',
    # Legacy build location on bots.
    'Release',
  ]
  possible_paths = [os.path.join(base_path, p) for p in possible_paths]
611
  actual_paths = list(filter(is_build, possible_paths))
612
  assert actual_paths, 'No build directory found.'
613 614 615
  assert len(
      actual_paths
  ) == 1, 'Found ambiguous build directories use --binary-override-path.'
616 617 618
  return actual_paths[0]


619
class Platform(object):
620 621 622 623 624
  def __init__(self, args):
    self.shell_dir = args.shell_dir
    self.shell_dir_secondary = args.shell_dir_secondary
    self.extra_flags = args.extra_flags.split()
    self.args = args
625

626
  @staticmethod
627 628
  def ReadBuildConfig(args):
    config_path = os.path.join(args.shell_dir, 'v8_build_config.json')
629 630 631 632 633
    if not os.path.isfile(config_path):
      return {}
    with open(config_path) as f:
      return json.load(f)

634
  @staticmethod
635 636 637
  def GetPlatform(args):
    if Platform.ReadBuildConfig(args).get('is_android', False):
      return AndroidPlatform(args)
638
    else:
639
      return DesktopPlatform(args)
640

641
  def _Run(self, runnable, count, secondary=False):
642 643
    raise NotImplementedError()  # pragma: no cover

644
  def _LoggedRun(self, runnable, count, secondary=False):
645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660
    suffix = ' - secondary' if secondary else ''
    title = '>>> %%s (#%d)%s:' % ((count + 1), suffix)
    try:
      output = self._Run(runnable, count, secondary)
    except OSError:
      logging.exception(title % 'OSError')
      raise
    if output.stdout:
      logging.info(title % 'Stdout' + '\n%s', output.stdout)
    if output.stderr:  # pragma: no cover
      # Print stderr for debugging.
      logging.info(title % 'Stderr' + '\n%s', output.stderr)
      logging.warning('>>> Test timed out after %ss.', runnable.timeout)
    if output.exit_code != 0:
      logging.warning('>>> Test crashed with exit code %d.', output.exit_code)
    return output
661

662
  def Run(self, runnable, count, secondary):
663 664 665 666 667
    """Execute the benchmark's main file.

    Args:
      runnable: A Runnable benchmark instance.
      count: The number of this (repeated) run.
668 669 670 671 672
      secondary: True if secondary run should be executed.

    Returns:
      A tuple with the two benchmark outputs. The latter will be NULL_OUTPUT if
      secondary is False.
673
    """
674
    output = self._LoggedRun(runnable, count, secondary=False)
675
    if secondary:
676
      return output, self._LoggedRun(runnable, count, secondary=True)
677
    else:
678
      return output, NULL_OUTPUT
679

680 681

class DesktopPlatform(Platform):
682 683
  def __init__(self, args):
    super(DesktopPlatform, self).__init__(args)
684 685
    self.command_prefix = []

686
    # Setup command class to OS specific version.
687
    command.setup(utils.GuessOS(), args.device)
688

689
    if args.prioritize or args.affinitize != None:
690
      self.command_prefix = ['schedtool']
691
      if args.prioritize:
692
        self.command_prefix += ['-n', '-20']
693
      if args.affinitize != None:
694 695 696 697
        # schedtool expects a bit pattern when setting affinity, where each
        # bit set to '1' corresponds to a core where the process may run on.
        # First bit corresponds to CPU 0. Since the 'affinitize' parameter is
        # a core number, we need to map to said bit pattern.
698
        cpu = int(args.affinitize)
699
        core = 1 << cpu
700 701
        self.command_prefix += ['-a', ('0x%x' % core)]
      self.command_prefix += ['-e']
702

703 704 705 706 707
  def PreExecution(self):
    pass

  def PostExecution(self):
    pass
708

709
  def PreTests(self, node, path):
710
    if isinstance(node, RunnableConfig):
711
      node.ChangeCWD(path)
712

713 714
  def _Run(self, runnable, count, secondary=False):
    shell_dir = self.shell_dir_secondary if secondary else self.shell_dir
715
    cmd = runnable.GetCommand(self.command_prefix, shell_dir, self.extra_flags)
716
    logging.debug('Running command: %s' % cmd)
717
    output = cmd.execute()
718

719
    if output.IsSuccess() and '--prof' in self.extra_flags:
720
      os_prefix = {'linux': 'linux', 'macos': 'mac'}.get(utils.GuessOS())
721
      if os_prefix:
722 723
        tick_tools = os.path.join(TOOLS_BASE, '%s-tick-processor' % os_prefix)
        subprocess.check_call(tick_tools + ' --only-summary', shell=True)
724
      else:  # pragma: no cover
725
        logging.warning(
726
            'Profiler option currently supported on Linux and Mac OS.')
727

728
    # /usr/bin/time outputs to stderr
729
    if runnable.process_size:
730 731
      output.stdout += output.stderr
    return output
732 733


734 735
class AndroidPlatform(Platform):  # pragma: no cover

736 737 738
  def __init__(self, args):
    super(AndroidPlatform, self).__init__(args)
    self.driver = android.android_driver(args.device)
739 740

  def PreExecution(self):
741
    self.driver.set_high_perf_mode()
742

743
  def PostExecution(self):
744 745
    self.driver.set_default_perf_mode()
    self.driver.tear_down()
746

747
  def PreTests(self, node, path):
748 749
    if isinstance(node, RunnableConfig):
      node.ChangeCWD(path)
750 751 752 753 754
    suite_dir = os.path.abspath(os.path.dirname(path))
    if node.path:
      bench_rel = os.path.normpath(os.path.join(*node.path))
      bench_abs = os.path.join(suite_dir, bench_rel)
    else:
755
      bench_rel = '.'
756 757
      bench_abs = suite_dir

758
    self.driver.push_executable(self.shell_dir, 'bin', node.binary)
759
    if self.shell_dir_secondary:
760
      self.driver.push_executable(
761
          self.shell_dir_secondary, 'bin_secondary', node.binary)
762

763
    if isinstance(node, RunnableConfig):
764 765 766
      self.driver.push_file(bench_abs, node.main, bench_rel)
    for resource in node.resources:
      self.driver.push_file(bench_abs, resource, bench_rel)
767

768
  def _Run(self, runnable, count, secondary=False):
769
    target_dir = 'bin_secondary' if secondary else 'bin'
770
    self.driver.drop_ram_caches()
771 772 773 774 775

    # Relative path to benchmark directory.
    if runnable.path:
      bench_rel = os.path.normpath(os.path.join(*runnable.path))
    else:
776
      bench_rel = '.'
777

778
    logcat_file = None
779
    if self.args.dump_logcats_to:
780 781
      runnable_name = '-'.join(runnable.graphs)
      logcat_file = os.path.join(
782
          self.args.dump_logcats_to, 'logcat-%s-#%d%s.log' % (
783 784 785
            runnable_name, count + 1, '-secondary' if secondary else ''))
      logging.debug('Dumping logcat into %s', logcat_file)

786 787
    output = Output()
    start = time.time()
788
    try:
789
      output.stdout = self.driver.run(
790 791 792 793
          target_dir=target_dir,
          binary=runnable.binary,
          args=runnable.GetCommandFlags(self.extra_flags),
          rel_path=bench_rel,
794
          timeout=runnable.timeout,
795
          logcat_file=logcat_file,
796
      )
797
    except android.CommandFailedException as e:
798 799
      output.stdout = e.output
      output.exit_code = e.status
800
    except android.TimeoutException as e:
801 802
      output.stdout = e.output
      output.timed_out = True
803
    if runnable.process_size:
804 805 806
      output.stdout += 'MaxMemory: Unsupported'
    output.duration = time.time() - start
    return output
807

808

809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833
class CustomMachineConfiguration:
  def __init__(self, disable_aslr = False, governor = None):
    self.aslr_backup = None
    self.governor_backup = None
    self.disable_aslr = disable_aslr
    self.governor = governor

  def __enter__(self):
    if self.disable_aslr:
      self.aslr_backup = CustomMachineConfiguration.GetASLR()
      CustomMachineConfiguration.SetASLR(0)
    if self.governor != None:
      self.governor_backup = CustomMachineConfiguration.GetCPUGovernor()
      CustomMachineConfiguration.SetCPUGovernor(self.governor)
    return self

  def __exit__(self, type, value, traceback):
    if self.aslr_backup != None:
      CustomMachineConfiguration.SetASLR(self.aslr_backup)
    if self.governor_backup != None:
      CustomMachineConfiguration.SetCPUGovernor(self.governor_backup)

  @staticmethod
  def GetASLR():
    try:
834
      with open('/proc/sys/kernel/randomize_va_space', 'r') as f:
835
        return int(f.readline().strip())
836
    except Exception:
837
      logging.exception('Failed to get current ASLR settings.')
838
      raise
839 840 841 842

  @staticmethod
  def SetASLR(value):
    try:
843
      with open('/proc/sys/kernel/randomize_va_space', 'w') as f:
844
        f.write(str(value))
845 846
    except Exception:
      logging.exception(
847
          'Failed to update ASLR to %s. Are we running under sudo?', value)
848
      raise
849 850 851

    new_value = CustomMachineConfiguration.GetASLR()
    if value != new_value:
852
      raise Exception('Present value is %s' % new_value)
853 854 855 856

  @staticmethod
  def GetCPUCoresRange():
    try:
857
      with open('/sys/devices/system/cpu/present', 'r') as f:
858
        indexes = f.readline()
859
        r = list(map(int, indexes.split('-')))
860
        if len(r) == 1:
861 862
          return list(range(r[0], r[0] + 1))
        return list(range(r[0], r[1] + 1))
863
    except Exception:
864
      logging.exception('Failed to retrieve number of CPUs.')
865
      raise
866 867 868

  @staticmethod
  def GetCPUPathForId(cpu_index):
869
    ret = '/sys/devices/system/cpu/cpu'
870
    ret += str(cpu_index)
871
    ret += '/cpufreq/scaling_governor'
872 873 874 875 876 877 878 879 880
    return ret

  @staticmethod
  def GetCPUGovernor():
    try:
      cpu_indices = CustomMachineConfiguration.GetCPUCoresRange()
      ret = None
      for cpu_index in cpu_indices:
        cpu_device = CustomMachineConfiguration.GetCPUPathForId(cpu_index)
881
        with open(cpu_device, 'r') as f:
882 883 884 885 886
          # We assume the governors of all CPUs are set to the same value
          val = f.readline().strip()
          if ret == None:
            ret = val
          elif ret != val:
887
            raise Exception('CPU cores have differing governor settings')
888
      return ret
889
    except Exception:
890 891
      logging.exception('Failed to get the current CPU governor. Is the CPU '
                        'governor disabled? Check BIOS.')
892
      raise
893 894 895 896 897 898 899

  @staticmethod
  def SetCPUGovernor(value):
    try:
      cpu_indices = CustomMachineConfiguration.GetCPUCoresRange()
      for cpu_index in cpu_indices:
        cpu_device = CustomMachineConfiguration.GetCPUPathForId(cpu_index)
900
        with open(cpu_device, 'w') as f:
901 902
          f.write(value)

903
    except Exception:
904 905
      logging.exception('Failed to change CPU governor to %s. Are we '
                        'running under sudo?', value)
906
      raise
907 908 909

    cur_value = CustomMachineConfiguration.GetCPUGovernor()
    if cur_value != value:
910
      raise Exception('Could not set CPU governor. Present value is %s'
911
                      % cur_value )
912

913 914 915 916 917 918

class MaxTotalDurationReachedError(Exception):
  """Exception used to stop running tests when max total duration is reached."""
  pass


919 920 921 922 923 924 925
def Main(argv):
  parser = argparse.ArgumentParser()
  parser.add_argument('--arch',
                      help='The architecture to run tests for. Pass "auto" '
                      'to auto-detect.', default='x64',
                      choices=SUPPORTED_ARCHS + ['auto'])
  parser.add_argument('--buildbot',
926
                      help='Deprecated',
927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973
                      default=False, action='store_true')
  parser.add_argument('-d', '--device',
                      help='The device ID to run Android tests on. If not '
                      'given it will be autodetected.')
  parser.add_argument('--extra-flags',
                      help='Additional flags to pass to the test executable',
                      default='')
  parser.add_argument('--json-test-results',
                      help='Path to a file for storing json results.')
  parser.add_argument('--json-test-results-secondary',
                      help='Path to a file for storing json results from run '
                      'without patch or for reference build run.')
  parser.add_argument('--outdir', help='Base directory with compile output',
                      default='out')
  parser.add_argument('--outdir-secondary',
                      help='Base directory with compile output without patch '
                      'or for reference build')
  parser.add_argument('--binary-override-path',
                      help='JavaScript engine binary. By default, d8 under '
                      'architecture-specific build dir. '
                      'Not supported in conjunction with outdir-secondary.')
  parser.add_argument('--prioritize',
                      help='Raise the priority to nice -20 for the '
                      'benchmarking process.Requires Linux, schedtool, and '
                      'sudo privileges.', default=False, action='store_true')
  parser.add_argument('--affinitize',
                      help='Run benchmarking process on the specified core. '
                      'For example: --affinitize=0 will run the benchmark '
                      'process on core 0. --affinitize=3 will run the '
                      'benchmark process on core 3. Requires Linux, schedtool, '
                      'and sudo privileges.', default=None)
  parser.add_argument('--noaslr',
                      help='Disable ASLR for the duration of the benchmarked '
                      'process. Requires Linux and sudo privileges.',
                      default=False, action='store_true')
  parser.add_argument('--cpu-governor',
                      help='Set cpu governor to specified policy for the '
                      'duration of the benchmarked process. Typical options: '
                      '"powersave" for more stable results, or "performance" '
                      'for shorter completion time of suite, with potentially '
                      'more noise in results.')
  parser.add_argument('--filter',
                      help='Only run the benchmarks beginning with this '
                      'string. For example: '
                      '--filter=JSTests/TypedArrays/ will run only TypedArray '
                      'benchmarks from the JSTests suite.',
                      default='')
974
  parser.add_argument('--confidence-level', type=float,
975 976 977 978
                      help='Repeatedly runs each benchmark until specified '
                      'confidence level is reached. The value is interpreted '
                      'as the number of standard deviations from the mean that '
                      'all values must lie within. Typical values are 1, 2 and '
979 980 981 982 983
                      '3 and correspond to 68%%, 95%% and 99.7%% probability '
                      'that the measured value is within 0.1%% of the true '
                      'value. Larger values result in more retries and thus '
                      'longer runtime, but also provide more reliable results. '
                      'Also see --max-total-duration flag.')
984 985 986 987
  parser.add_argument('--max-total-duration', type=int, default=7140,  # 1h 59m
                      help='Max total duration in seconds allowed for retries '
                      'across all tests. This is especially useful in '
                      'combination with the --confidence-level flag.')
988 989 990
  parser.add_argument('--dump-logcats-to',
                      help='Writes logcat output from each test into specified '
                      'directory. Only supported for android targets.')
991 992 993 994 995
  parser.add_argument('--run-count', type=int, default=0,
                      help='Override the run count specified by the test '
                      'suite. The default 0 uses the suite\'s config.')
  parser.add_argument('-v', '--verbose', default=False, action='store_true',
                      help='Be verbose and print debug output.')
996
  parser.add_argument('suite', nargs='+', help='Path to the suite config file.')
997

998 999 1000
  try:
    args = parser.parse_args(argv)
  except SystemExit:
1001
    return INFRA_FAILURE_RETCODE
1002

1003
  logging.basicConfig(
1004 1005
      level=logging.DEBUG if args.verbose else logging.INFO,
      format='%(asctime)s %(levelname)-8s  %(message)s')
1006

1007 1008 1009 1010 1011 1012
  if args.arch == 'auto':  # pragma: no cover
    args.arch = utils.DefaultArch()
    if args.arch not in SUPPORTED_ARCHS:
      logging.error(
          'Auto-detected architecture "%s" is not supported.', args.arch)
      return INFRA_FAILURE_RETCODE
1013

1014 1015
  if (args.json_test_results_secondary and
      not args.outdir_secondary):  # pragma: no cover
1016 1017
    logging.error('For writing secondary json test results, a secondary outdir '
                  'patch must be specified.')
1018
    return INFRA_FAILURE_RETCODE
1019

1020
  workspace = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
1021

1022
  if args.binary_override_path == None:
1023 1024
    args.shell_dir = find_build_directory(
        os.path.join(workspace, args.outdir), args.arch)
1025
    default_binary_name = 'd8'
1026
  else:
1027
    if not os.path.isfile(args.binary_override_path):
1028
      logging.error('binary-override-path must be a file name')
1029
      return INFRA_FAILURE_RETCODE
1030
    if args.outdir_secondary:
1031
      logging.error('specify either binary-override-path or outdir-secondary')
1032
      return INFRA_FAILURE_RETCODE
1033 1034 1035
    args.shell_dir = os.path.abspath(
        os.path.dirname(args.binary_override_path))
    default_binary_name = os.path.basename(args.binary_override_path)
1036

1037
  if args.outdir_secondary:
1038 1039
    args.shell_dir_secondary = find_build_directory(
        os.path.join(workspace, args.outdir_secondary), args.arch)
1040
  else:
1041
    args.shell_dir_secondary = None
1042

1043 1044
  if args.json_test_results:
    args.json_test_results = os.path.abspath(args.json_test_results)
1045

1046 1047 1048
  if args.json_test_results_secondary:
    args.json_test_results_secondary = os.path.abspath(
        args.json_test_results_secondary)
1049 1050 1051

  # Ensure all arguments have absolute path before we start changing current
  # directory.
1052
  args.suite = list(map(os.path.abspath, args.suite))
1053

1054 1055
  prev_aslr = None
  prev_cpu_gov = None
1056
  platform = Platform.GetPlatform(args)
1057

1058 1059
  result_tracker = ResultTracker()
  result_tracker_secondary = ResultTracker()
1060
  have_failed_tests = False
1061 1062 1063
  with CustomMachineConfiguration(governor = args.cpu_governor,
                                  disable_aslr = args.noaslr) as conf:
    for path in args.suite:
1064
      if not os.path.exists(path):  # pragma: no cover
1065
        result_tracker.AddError('Configuration file %s does not exist.' % path)
1066
        continue
1067

1068 1069
      with open(path) as f:
        suite = json.loads(f.read())
1070

1071
      # If no name is given, default to the file name without .json.
1072
      suite.setdefault('name', os.path.splitext(os.path.basename(path))[0])
1073

1074 1075
      # Setup things common to one test suite.
      platform.PreExecution()
1076

1077 1078
      # Build the graph/trace tree structure.
      default_parent = DefaultSentinel(default_binary_name)
1079
      root = BuildGraphConfigs(suite, args.arch, default_parent)
1080

1081 1082 1083
      # Callback to be called on each node on traversal.
      def NodeCB(node):
        platform.PreTests(node, path)
1084

1085
      # Traverse graph/trace tree and iterate over all runnables.
1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101
      start = time.time()
      try:
        for runnable in FlattenRunnables(root, NodeCB):
          runnable_name = '/'.join(runnable.graphs)
          if (not runnable_name.startswith(args.filter) and
              runnable_name + '/' != args.filter):
            continue
          logging.info('>>> Running suite: %s', runnable_name)

          def RunGenerator(runnable):
            if args.confidence_level:
              counter = 0
              while not result_tracker.HasEnoughRuns(
                  runnable, args.confidence_level):
                yield counter
                counter += 1
1102
            else:
1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129
              for i in range(0, max(1, args.run_count or runnable.run_count)):
                yield i

          for i in RunGenerator(runnable):
            attempts_left = runnable.retry_count + 1
            while attempts_left:
              total_duration = time.time() - start
              if total_duration > args.max_total_duration:
                logging.info(
                    '>>> Stopping now since running for too long (%ds > %ds)',
                    total_duration, args.max_total_duration)
                raise MaxTotalDurationReachedError()

              output, output_secondary = platform.Run(
                  runnable, i, secondary=args.shell_dir_secondary)
              result_tracker.AddRunnableDuration(runnable, output.duration)
              result_tracker_secondary.AddRunnableDuration(
                  runnable, output_secondary.duration)

              if output.IsSuccess() and output_secondary.IsSuccess():
                runnable.ProcessOutput(output, result_tracker, i)
                if output_secondary is not NULL_OUTPUT:
                  runnable.ProcessOutput(
                      output_secondary, result_tracker_secondary, i)
                break

              attempts_left -= 1
1130 1131 1132 1133 1134
              if not attempts_left:
                logging.info('>>> Suite %s failed after %d retries',
                             runnable_name, runnable.retry_count + 1)
                have_failed_tests = True
              else:
1135 1136 1137
                logging.info('>>> Retrying suite: %s', runnable_name)
      except MaxTotalDurationReachedError:
        have_failed_tests = True
1138

1139 1140
      platform.PostExecution()

1141
    if args.json_test_results:
1142
      result_tracker.WriteToFile(args.json_test_results)
1143
    else:  # pragma: no cover
1144
      print('Primary results:', result_tracker)
1145

1146 1147 1148 1149 1150
  if args.shell_dir_secondary:
    if args.json_test_results_secondary:
      result_tracker_secondary.WriteToFile(args.json_test_results_secondary)
    else:  # pragma: no cover
      print('Secondary results:', result_tracker_secondary)
1151

1152
  if (result_tracker.errors or result_tracker_secondary.errors or
1153
      have_failed_tests):
1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166
    return 1

  return 0


def MainWrapper():
  try:
    return Main(sys.argv[1:])
  except:
    # Log uncaptured exceptions and report infra failure to the caller.
    traceback.print_exc()
    return INFRA_FAILURE_RETCODE

1167

1168
if __name__ == '__main__':  # pragma: no cover
1169
  sys.exit(MainWrapper())