Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/env python
      2 # Copyright 2014 the V8 project authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """
      7 Performance runner for d8.
      8 
      9 Call e.g. with tools/run-perf.py --arch ia32 some_suite.json
     10 
     11 The suite json format is expected to be:
     12 {
     13   "path": <relative path chunks to perf resources and main file>,
     14   "name": <optional suite name, file name is default>,
     15   "archs": [<architecture name for which this suite is run>, ...],
     16   "binary": <name of binary to run, default "d8">,
     17   "flags": [<flag to d8>, ...],
     18   "run_count": <how often will this suite run (optional)>,
     19   "run_count_XXX": <how often will this suite run for arch XXX (optional)>,
     20   "resources": [<js file to be loaded before main>, ...]
     21   "main": <main js perf runner file>,
     22   "results_regexp": <optional regexp>,
     23   "results_processor": <optional python results processor script>,
     24   "units": <the unit specification for the performance dashboard>,
     25   "tests": [
     26     {
     27       "name": <name of the trace>,
     28       "results_regexp": <optional more specific regexp>,
     29       "results_processor": <optional python results processor script>,
     30       "units": <the unit specification for the performance dashboard>,
     31     }, ...
     32   ]
     33 }
     34 
     35 The tests field can also nest other suites in arbitrary depth. A suite
     36 with a "main" file is a leaf suite that can contain one more level of
     37 tests.
     38 
     39 A suite's results_regexp is expected to have one string place holder
     40 "%s" for the trace name. A trace's results_regexp overwrites suite
     41 defaults.
     42 
     43 A suite's results_processor may point to an optional python script. If
     44 specified, it is called after running the tests like this (with a path
     45 relatve to the suite level's path):
     46 <results_processor file> <same flags as for d8> <suite level name> <output>
     47 
     48 The <output> is a temporary file containing d8 output. The results_regexp will
     49 be applied to the output of this script.
     50 
     51 A suite without "tests" is considered a performance test itself.
     52 
     53 Full example (suite with one runner):
     54 {
     55   "path": ["."],
     56   "flags": ["--expose-gc"],
     57   "archs": ["ia32", "x64"],
     58   "run_count": 5,
     59   "run_count_ia32": 3,
     60   "main": "run.js",
     61   "results_regexp": "^%s: (.+)$",
     62   "units": "score",
     63   "tests": [
     64     {"name": "Richards"},
     65     {"name": "DeltaBlue"},
     66     {"name": "NavierStokes",
     67      "results_regexp": "^NavierStokes: (.+)$"}
     68   ]
     69 }
     70 
     71 Full example (suite with several runners):
     72 {
     73   "path": ["."],
     74   "flags": ["--expose-gc"],
     75   "archs": ["ia32", "x64"],
     76   "run_count": 5,
     77   "units": "score",
     78   "tests": [
     79     {"name": "Richards",
     80      "path": ["richards"],
     81      "main": "run.js",
     82      "run_count": 3,
     83      "results_regexp": "^Richards: (.+)$"},
     84     {"name": "NavierStokes",
     85      "path": ["navier_stokes"],
     86      "main": "run.js",
     87      "results_regexp": "^NavierStokes: (.+)$"}
     88   ]
     89 }
     90 
     91 Path pieces are concatenated. D8 is always run with the suite's path as cwd.
     92 """
     93 
     94 import json
     95 import math
     96 import optparse
     97 import os
     98 import re
     99 import sys
    100 
    101 from testrunner.local import commands
    102 from testrunner.local import utils
    103 
    104 ARCH_GUESS = utils.DefaultArch()
    105 SUPPORTED_ARCHS = ["android_arm",
    106                    "android_arm64",
    107                    "android_ia32",
    108                    "arm",
    109                    "ia32",
    110                    "mips",
    111                    "mipsel",
    112                    "nacl_ia32",
    113                    "nacl_x64",
    114                    "x64",
    115                    "arm64"]
    116 
    117 GENERIC_RESULTS_RE = re.compile(
    118     r"^Trace\(([^\)]+)\), Result\(([^\)]+)\), StdDev\(([^\)]+)\)$")
    119 
    120 
    121 def GeometricMean(values):
    122   """Returns the geometric mean of a list of values.
    123 
    124   The mean is calculated using log to avoid overflow.
    125   """
    126   values = map(float, values)
    127   return str(math.exp(sum(map(math.log, values)) / len(values)))
    128 
    129 
    130 class Results(object):
    131   """Place holder for result traces."""
    132   def __init__(self, traces=None, errors=None):
    133     self.traces = traces or []
    134     self.errors = errors or []
    135 
    136   def ToDict(self):
    137     return {"traces": self.traces, "errors": self.errors}
    138 
    139   def WriteToFile(self, file_name):
    140     with open(file_name, "w") as f:
    141       f.write(json.dumps(self.ToDict()))
    142 
    143   def __add__(self, other):
    144     self.traces += other.traces
    145     self.errors += other.errors
    146     return self
    147 
    148   def __str__(self):  # pragma: no cover
    149     return str(self.ToDict())
    150 
    151 
    152 class Node(object):
    153   """Represents a node in the suite tree structure."""
    154   def __init__(self, *args):
    155     self._children = []
    156 
    157   def AppendChild(self, child):
    158     self._children.append(child)
    159 
    160 
    161 class DefaultSentinel(Node):
    162   """Fake parent node with all default values."""
    163   def __init__(self):
    164     super(DefaultSentinel, self).__init__()
    165     self.binary = "d8"
    166     self.run_count = 10
    167     self.timeout = 60
    168     self.path = []
    169     self.graphs = []
    170     self.flags = []
    171     self.resources = []
    172     self.results_regexp = None
    173     self.stddev_regexp = None
    174     self.units = "score"
    175     self.total = False
    176 
    177 
    178 class Graph(Node):
    179   """Represents a suite definition.
    180 
    181   Can either be a leaf or an inner node that provides default values.
    182   """
    183   def __init__(self, suite, parent, arch):
    184     super(Graph, self).__init__()
    185     self._suite = suite
    186 
    187     assert isinstance(suite.get("path", []), list)
    188     assert isinstance(suite["name"], basestring)
    189     assert isinstance(suite.get("flags", []), list)
    190     assert isinstance(suite.get("resources", []), list)
    191 
    192     # Accumulated values.
    193     self.path = parent.path[:] + suite.get("path", [])
    194     self.graphs = parent.graphs[:] + [suite["name"]]
    195     self.flags = parent.flags[:] + suite.get("flags", [])
    196     self.resources = parent.resources[:] + suite.get("resources", [])
    197 
    198     # Descrete values (with parent defaults).
    199     self.binary = suite.get("binary", parent.binary)
    200     self.run_count = suite.get("run_count", parent.run_count)
    201     self.run_count = suite.get("run_count_%s" % arch, self.run_count)
    202     self.timeout = suite.get("timeout", parent.timeout)
    203     self.units = suite.get("units", parent.units)
    204     self.total = suite.get("total", parent.total)
    205 
    206     # A regular expression for results. If the parent graph provides a
    207     # regexp and the current suite has none, a string place holder for the
    208     # suite name is expected.
    209     # TODO(machenbach): Currently that makes only sense for the leaf level.
    210     # Multiple place holders for multiple levels are not supported.
    211     if parent.results_regexp:
    212       regexp_default = parent.results_regexp % re.escape(suite["name"])
    213     else:
    214       regexp_default = None
    215     self.results_regexp = suite.get("results_regexp", regexp_default)
    216 
    217     # A similar regular expression for the standard deviation (optional).
    218     if parent.stddev_regexp:
    219       stddev_default = parent.stddev_regexp % re.escape(suite["name"])
    220     else:
    221       stddev_default = None
    222     self.stddev_regexp = suite.get("stddev_regexp", stddev_default)
    223 
    224 
    225 class Trace(Graph):
    226   """Represents a leaf in the suite tree structure.
    227 
    228   Handles collection of measurements.
    229   """
    230   def __init__(self, suite, parent, arch):
    231     super(Trace, self).__init__(suite, parent, arch)
    232     assert self.results_regexp
    233     self.results = []
    234     self.errors = []
    235     self.stddev = ""
    236 
    237   def ConsumeOutput(self, stdout):
    238     try:
    239       self.results.append(
    240           re.search(self.results_regexp, stdout, re.M).group(1))
    241     except:
    242       self.errors.append("Regexp \"%s\" didn't match for test %s."
    243                          % (self.results_regexp, self.graphs[-1]))
    244 
    245     try:
    246       if self.stddev_regexp and self.stddev:
    247         self.errors.append("Test %s should only run once since a stddev "
    248                            "is provided by the test." % self.graphs[-1])
    249       if self.stddev_regexp:
    250         self.stddev = re.search(self.stddev_regexp, stdout, re.M).group(1)
    251     except:
    252       self.errors.append("Regexp \"%s\" didn't match for test %s."
    253                          % (self.stddev_regexp, self.graphs[-1]))
    254 
    255   def GetResults(self):
    256     return Results([{
    257       "graphs": self.graphs,
    258       "units": self.units,
    259       "results": self.results,
    260       "stddev": self.stddev,
    261     }], self.errors)
    262 
    263 
    264 class Runnable(Graph):
    265   """Represents a runnable suite definition (i.e. has a main file).
    266   """
    267   @property
    268   def main(self):
    269     return self._suite.get("main", "")
    270 
    271   def ChangeCWD(self, suite_path):
    272     """Changes the cwd to to path defined in the current graph.
    273 
    274     The tests are supposed to be relative to the suite configuration.
    275     """
    276     suite_dir = os.path.abspath(os.path.dirname(suite_path))
    277     bench_dir = os.path.normpath(os.path.join(*self.path))
    278     os.chdir(os.path.join(suite_dir, bench_dir))
    279 
    280   def GetCommand(self, shell_dir):
    281     # TODO(machenbach): This requires +.exe if run on windows.
    282     return (
    283       [os.path.join(shell_dir, self.binary)] +
    284       self.flags +
    285       self.resources +
    286       [self.main]
    287     )
    288 
    289   def Run(self, runner):
    290     """Iterates over several runs and handles the output for all traces."""
    291     for stdout in runner():
    292       for trace in self._children:
    293         trace.ConsumeOutput(stdout)
    294     res = reduce(lambda r, t: r + t.GetResults(), self._children, Results())
    295 
    296     if not res.traces or not self.total:
    297       return res
    298 
    299     # Assume all traces have the same structure.
    300     if len(set(map(lambda t: len(t["results"]), res.traces))) != 1:
    301       res.errors.append("Not all traces have the same number of results.")
    302       return res
    303 
    304     # Calculate the geometric means for all traces. Above we made sure that
    305     # there is at least one trace and that the number of results is the same
    306     # for each trace.
    307     n_results = len(res.traces[0]["results"])
    308     total_results = [GeometricMean(t["results"][i] for t in res.traces)
    309                      for i in range(0, n_results)]
    310     res.traces.append({
    311       "graphs": self.graphs + ["Total"],
    312       "units": res.traces[0]["units"],
    313       "results": total_results,
    314       "stddev": "",
    315     })
    316     return res
    317 
    318 class RunnableTrace(Trace, Runnable):
    319   """Represents a runnable suite definition that is a leaf."""
    320   def __init__(self, suite, parent, arch):
    321     super(RunnableTrace, self).__init__(suite, parent, arch)
    322 
    323   def Run(self, runner):
    324     """Iterates over several runs and handles the output."""
    325     for stdout in runner():
    326       self.ConsumeOutput(stdout)
    327     return self.GetResults()
    328 
    329 
    330 class RunnableGeneric(Runnable):
    331   """Represents a runnable suite definition with generic traces."""
    332   def __init__(self, suite, parent, arch):
    333     super(RunnableGeneric, self).__init__(suite, parent, arch)
    334 
    335   def Run(self, runner):
    336     """Iterates over several runs and handles the output."""
    337     traces = {}
    338     for stdout in runner():
    339       for line in stdout.strip().splitlines():
    340         match = GENERIC_RESULTS_RE.match(line)
    341         if match:
    342           trace = match.group(1)
    343           result = match.group(2)
    344           stddev = match.group(3)
    345           trace_result = traces.setdefault(trace, Results([{
    346             "graphs": self.graphs + [trace],
    347             "units": self.units,
    348             "results": [],
    349             "stddev": "",
    350           }], []))
    351           trace_result.traces[0]["results"].append(result)
    352           trace_result.traces[0]["stddev"] = stddev
    353 
    354     return reduce(lambda r, t: r + t, traces.itervalues(), Results())
    355 
    356 
    357 def MakeGraph(suite, arch, parent):
    358   """Factory method for making graph objects."""
    359   if isinstance(parent, Runnable):
    360     # Below a runnable can only be traces.
    361     return Trace(suite, parent, arch)
    362   elif suite.get("main"):
    363     # A main file makes this graph runnable.
    364     if suite.get("tests"):
    365       # This graph has subgraphs (traces).
    366       return Runnable(suite, parent, arch)
    367     else:
    368       # This graph has no subgraphs, it's a leaf.
    369       return RunnableTrace(suite, parent, arch)
    370   elif suite.get("generic"):
    371     # This is a generic suite definition. It is either a runnable executable
    372     # or has a main js file.
    373     return RunnableGeneric(suite, parent, arch)
    374   elif suite.get("tests"):
    375     # This is neither a leaf nor a runnable.
    376     return Graph(suite, parent, arch)
    377   else:  # pragma: no cover
    378     raise Exception("Invalid suite configuration.")
    379 
    380 
    381 def BuildGraphs(suite, arch, parent=None):
    382   """Builds a tree structure of graph objects that corresponds to the suite
    383   configuration.
    384   """
    385   parent = parent or DefaultSentinel()
    386 
    387   # TODO(machenbach): Implement notion of cpu type?
    388   if arch not in suite.get("archs", ["ia32", "x64"]):
    389     return None
    390 
    391   graph = MakeGraph(suite, arch, parent)
    392   for subsuite in suite.get("tests", []):
    393     BuildGraphs(subsuite, arch, graph)
    394   parent.AppendChild(graph)
    395   return graph
    396 
    397 
    398 def FlattenRunnables(node):
    399   """Generator that traverses the tree structure and iterates over all
    400   runnables.
    401   """
    402   if isinstance(node, Runnable):
    403     yield node
    404   elif isinstance(node, Node):
    405     for child in node._children:
    406       for result in FlattenRunnables(child):
    407         yield result
    408   else:  # pragma: no cover
    409     raise Exception("Invalid suite configuration.")
    410 
    411 
    412 # TODO: Implement results_processor.
    413 def Main(args):
    414   parser = optparse.OptionParser()
    415   parser.add_option("--arch",
    416                     help=("The architecture to run tests for, "
    417                           "'auto' or 'native' for auto-detect"),
    418                     default="x64")
    419   parser.add_option("--buildbot",
    420                     help="Adapt to path structure used on buildbots",
    421                     default=False, action="store_true")
    422   parser.add_option("--json-test-results",
    423                     help="Path to a file for storing json results.")
    424   parser.add_option("--outdir", help="Base directory with compile output",
    425                     default="out")
    426   (options, args) = parser.parse_args(args)
    427 
    428   if len(args) == 0:  # pragma: no cover
    429     parser.print_help()
    430     return 1
    431 
    432   if options.arch in ["auto", "native"]:  # pragma: no cover
    433     options.arch = ARCH_GUESS
    434 
    435   if not options.arch in SUPPORTED_ARCHS:  # pragma: no cover
    436     print "Unknown architecture %s" % options.arch
    437     return 1
    438 
    439   workspace = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
    440 
    441   if options.buildbot:
    442     shell_dir = os.path.join(workspace, options.outdir, "Release")
    443   else:
    444     shell_dir = os.path.join(workspace, options.outdir,
    445                              "%s.release" % options.arch)
    446 
    447   results = Results()
    448   for path in args:
    449     path = os.path.abspath(path)
    450 
    451     if not os.path.exists(path):  # pragma: no cover
    452       results.errors.append("Configuration file %s does not exist." % path)
    453       continue
    454 
    455     with open(path) as f:
    456       suite = json.loads(f.read())
    457 
    458     # If no name is given, default to the file name without .json.
    459     suite.setdefault("name", os.path.splitext(os.path.basename(path))[0])
    460 
    461     for runnable in FlattenRunnables(BuildGraphs(suite, options.arch)):
    462       print ">>> Running suite: %s" % "/".join(runnable.graphs)
    463       runnable.ChangeCWD(path)
    464 
    465       def Runner():
    466         """Output generator that reruns several times."""
    467         for i in xrange(0, max(1, runnable.run_count)):
    468           # TODO(machenbach): Allow timeout per arch like with run_count per
    469           # arch.
    470           output = commands.Execute(runnable.GetCommand(shell_dir),
    471                                     timeout=runnable.timeout)
    472           print ">>> Stdout (#%d):" % (i + 1)
    473           print output.stdout
    474           if output.stderr:  # pragma: no cover
    475             # Print stderr for debugging.
    476             print ">>> Stderr (#%d):" % (i + 1)
    477             print output.stderr
    478           if output.timed_out:
    479             print ">>> Test timed out after %ss." % runnable.timeout
    480           yield output.stdout
    481 
    482       # Let runnable iterate over all runs and handle output.
    483       results += runnable.Run(Runner)
    484 
    485   if options.json_test_results:
    486     results.WriteToFile(options.json_test_results)
    487   else:  # pragma: no cover
    488     print results
    489 
    490   return min(1, len(results.errors))
    491 
    492 if __name__ == "__main__":  # pragma: no cover
    493   sys.exit(Main(sys.argv[1:]))
    494