|  | #!/usr/bin/python3 | 
|  | # Copyright 2019 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. | 
|  |  | 
|  | # Runs chromium/src/run_benchmark for a given story and extracts the generated | 
|  | # runtime call stats. | 
|  |  | 
|  | import argparse | 
|  | import csv | 
|  | import json | 
|  | import glob | 
|  | import os | 
|  | import pathlib | 
|  | import re | 
|  | import tabulate | 
|  | import shutil | 
|  | import statistics | 
|  | import subprocess | 
|  | import sys | 
|  | import tempfile | 
|  |  | 
|  | from callstats_groups import RUNTIME_CALL_STATS_GROUPS | 
|  |  | 
|  |  | 
|  | JSON_FILE_EXTENSION=".pb_converted.json" | 
|  |  | 
|  | def parse_args(): | 
|  | parser = argparse.ArgumentParser( | 
|  | description="Run story and collect runtime call stats.") | 
|  | parser.add_argument("story", metavar="story", nargs=1, help="story to run") | 
|  | parser.add_argument( | 
|  | "--group", | 
|  | dest="group", | 
|  | action="store_true", | 
|  | help="group common stats together into buckets") | 
|  | parser.add_argument( | 
|  | "-r", | 
|  | "--repeats", | 
|  | dest="repeats", | 
|  | metavar="N", | 
|  | action="store", | 
|  | type=int, | 
|  | default=1, | 
|  | help="number of times to run the story") | 
|  | parser.add_argument( | 
|  | "-v", | 
|  | "--verbose", | 
|  | dest="verbose", | 
|  | action="store_true", | 
|  | help="output benchmark runs to stdout") | 
|  | parser.add_argument( | 
|  | "--device", | 
|  | dest="device", | 
|  | action="store", | 
|  | help="device to run the test on. Passed directly to run_benchmark") | 
|  | parser.add_argument( | 
|  | "-d", | 
|  | "--dir", | 
|  | dest="dir", | 
|  | action="store", | 
|  | help=("directory to look for already generated output in. This must " | 
|  | "already exists and it won't re-run the benchmark")) | 
|  | parser.add_argument( | 
|  | "-f", | 
|  | "--format", | 
|  | dest="format", | 
|  | action="store", | 
|  | choices=["csv", "table"], | 
|  | help="output as CSV") | 
|  | parser.add_argument( | 
|  | "-o", | 
|  | "--output", | 
|  | metavar="FILE", | 
|  | dest="out_file", | 
|  | action="store", | 
|  | help="write table to FILE rather stdout") | 
|  | parser.add_argument( | 
|  | "--browser", | 
|  | dest="browser", | 
|  | metavar="BROWSER_TYPE", | 
|  | action="store", | 
|  | default="release", | 
|  | help=("Passed directly to --browser option of run_benchmark. Ignored if " | 
|  | "-executable is used")) | 
|  | parser.add_argument( | 
|  | "-e", | 
|  | "--executable", | 
|  | dest="executable", | 
|  | metavar="EXECUTABLE", | 
|  | action="store", | 
|  | help=("path to executable to run. If not given it will pass '--browser " | 
|  | "release' to run_benchmark")) | 
|  | parser.add_argument( | 
|  | "--chromium-dir", | 
|  | dest="chromium_dir", | 
|  | metavar="DIR", | 
|  | action="store", | 
|  | default=".", | 
|  | help=("path to chromium directory. If not given, the script must be run " | 
|  | "inside the chromium/src directory")) | 
|  | parser.add_argument( | 
|  | "--js-flags", dest="js_flags", action="store", help="flags to pass to v8") | 
|  | parser.add_argument( | 
|  | "--extra-browser-args", | 
|  | dest="browser_args", | 
|  | action="store", | 
|  | help="flags to pass to chrome") | 
|  | parser.add_argument( | 
|  | "--benchmark", | 
|  | dest="benchmark", | 
|  | action="store", | 
|  | default="v8.browsing_desktop", | 
|  | help="benchmark to run") | 
|  | parser.add_argument( | 
|  | "--stdev", | 
|  | dest="stdev", | 
|  | action="store_true", | 
|  | help="adds columns for the standard deviation") | 
|  | parser.add_argument( | 
|  | "--filter", | 
|  | dest="filter", | 
|  | action="append", | 
|  | help="useable with --group to only show buckets specified by filter") | 
|  | parser.add_argument( | 
|  | "--retain", | 
|  | dest="retain", | 
|  | action="store", | 
|  | default="json", | 
|  | choices=["none", "json", "all"], | 
|  | help=("controls artifacts to be retained after run. With none, all files " | 
|  | "are deleted; only the json.gz file is retained for each run; and " | 
|  | "all keep all files")) | 
|  |  | 
|  | return parser.parse_args() | 
|  |  | 
|  |  | 
|  | def process_trace(trace_file): | 
|  | text_string = pathlib.Path(trace_file).read_text() | 
|  | result = json.loads(text_string) | 
|  |  | 
|  | output = {} | 
|  | result = result["traceEvents"] | 
|  | for o in result: | 
|  | o = o["args"] | 
|  | if "runtime-call-stats" in o: | 
|  | r = o["runtime-call-stats"] | 
|  | for name in r: | 
|  | count = r[name][0] | 
|  | duration = r[name][1] | 
|  | if name in output: | 
|  | output[name]["count"] += count | 
|  | output[name]["duration"] += duration | 
|  | else: | 
|  | output[name] = {"count": count, "duration": duration} | 
|  |  | 
|  | return output | 
|  |  | 
|  |  | 
|  | def run_benchmark(story, | 
|  | repeats=1, | 
|  | output_dir=".", | 
|  | verbose=False, | 
|  | js_flags=None, | 
|  | browser_args=None, | 
|  | chromium_dir=".", | 
|  | executable=None, | 
|  | benchmark="v8.browsing_desktop", | 
|  | device=None, | 
|  | browser="release"): | 
|  |  | 
|  | orig_chromium_dir = chromium_dir | 
|  | xvfb = os.path.join(chromium_dir, "testing", "xvfb.py") | 
|  | if not os.path.isfile(xvfb): | 
|  | chromium_dir = os.path(chromium_dir, "src") | 
|  | xvfb = os.path.join(chromium_dir, "testing", "xvfb.py") | 
|  | if not os.path.isfile(xvfb): | 
|  | print(("chromium_dir does not point to a valid chromium checkout: " + | 
|  | orig_chromium_dir)) | 
|  | sys.exit(1) | 
|  |  | 
|  | command = [ | 
|  | xvfb, | 
|  | os.path.join(chromium_dir, "tools", "perf", "run_benchmark"), | 
|  | "run", | 
|  | "--story", | 
|  | story, | 
|  | "--pageset-repeat", | 
|  | str(repeats), | 
|  | "--output-dir", | 
|  | output_dir, | 
|  | "--intermediate-dir", | 
|  | os.path.join(output_dir, "artifacts"), | 
|  | benchmark, | 
|  | ] | 
|  |  | 
|  | if executable: | 
|  | command += ["--browser-executable", executable] | 
|  | else: | 
|  | command += ["--browser", browser] | 
|  |  | 
|  | if device: | 
|  | command += ["--device", device] | 
|  | if browser_args: | 
|  | command += ["--extra-browser-args", browser_args] | 
|  | if js_flags: | 
|  | command += ["--js-flags", js_flags] | 
|  |  | 
|  | if not benchmark.startswith("v8."): | 
|  | # Most benchmarks by default don't collect runtime call stats so enable them | 
|  | # manually. | 
|  | categories = [ | 
|  | "v8", | 
|  | "disabled-by-default-v8.runtime_stats", | 
|  | ] | 
|  |  | 
|  | command += ["--extra-chrome-categories", ",".join(categories)] | 
|  |  | 
|  | print("Output directory: %s" % output_dir) | 
|  | stdout = "" | 
|  | print(f"Running: {' '.join(command)}\n") | 
|  | proc = subprocess.Popen( | 
|  | command, | 
|  | stdout=subprocess.PIPE, | 
|  | stderr=subprocess.PIPE, | 
|  | universal_newlines=True) | 
|  | proc.stderr.close() | 
|  | status_matcher = re.compile(r"\[ +(\w+) +\]") | 
|  | for line in iter(proc.stdout.readline, ""): | 
|  | stdout += line | 
|  | match = status_matcher.match(line) | 
|  | if verbose or match: | 
|  | print(line, end="") | 
|  |  | 
|  | proc.stdout.close() | 
|  |  | 
|  | if proc.wait() != 0: | 
|  | print("\nrun_benchmark failed:") | 
|  | # If verbose then everything has already been printed. | 
|  | if not verbose: | 
|  | print(stdout) | 
|  | sys.exit(1) | 
|  |  | 
|  | print("\nrun_benchmark completed") | 
|  |  | 
|  |  | 
|  | def write_output(f, table, headers, run_count, format="table"): | 
|  | if format == "csv": | 
|  | # strip new lines from CSV output | 
|  | headers = [h.replace("\n", " ") for h in headers] | 
|  | writer = csv.writer(f) | 
|  | writer.writerow(headers) | 
|  | writer.writerows(table) | 
|  | else: | 
|  | # First column is name, and then they alternate between counts and durations | 
|  | summary_count = len(headers) - 2 * run_count - 1 | 
|  | floatfmt = ("",) + (".0f", ".2f") * run_count + (".2f",) * summary_count | 
|  | f.write(tabulate.tabulate(table, headers=headers, floatfmt=floatfmt)) | 
|  | f.write("\n") | 
|  |  | 
|  |  | 
|  | class Row: | 
|  |  | 
|  | def __init__(self, name, run_count): | 
|  | self.name = name | 
|  | self.durations = [0] * run_count | 
|  | self.counts = [0] * run_count | 
|  | self.mean_duration = None | 
|  | self.mean_count = None | 
|  | self.stdev_duration = None | 
|  | self.stdev_count = None | 
|  |  | 
|  | def __repr__(self): | 
|  | data_str = ", ".join( | 
|  | str((c, d)) for (c, d) in zip(self.counts, self.durations)) | 
|  | return (f"{self.name}: {data_str}, mean_count: {self.mean_count}, " + | 
|  | f"mean_duration: {self.mean_duration}") | 
|  |  | 
|  | def add_data(self, counts, durations): | 
|  | self.counts = counts | 
|  | self.durations = durations | 
|  |  | 
|  | def add_data_point(self, run, count, duration): | 
|  | self.counts[run] = count | 
|  | self.durations[run] = duration | 
|  |  | 
|  | def prepare(self, stdev=False): | 
|  | if len(self.durations) > 1: | 
|  | self.mean_duration = statistics.mean(self.durations) | 
|  | self.mean_count = statistics.mean(self.counts) | 
|  | if stdev: | 
|  | self.stdev_duration = statistics.stdev(self.durations) | 
|  | self.stdev_count = statistics.stdev(self.counts) | 
|  |  | 
|  | def as_list(self): | 
|  | l = [self.name] | 
|  | for (c, d) in zip(self.counts, self.durations): | 
|  | l += [c, d] | 
|  | if self.mean_duration is not None: | 
|  | l += [self.mean_count] | 
|  | if self.stdev_count is not None: | 
|  | l += [self.stdev_count] | 
|  | l += [self.mean_duration] | 
|  | if self.stdev_duration is not None: | 
|  | l += [self.stdev_duration] | 
|  | return l | 
|  |  | 
|  | def key(self): | 
|  | if self.mean_duration is not None: | 
|  | return self.mean_duration | 
|  | else: | 
|  | return self.durations[0] | 
|  |  | 
|  |  | 
|  | class Bucket: | 
|  |  | 
|  | def __init__(self, name, run_count): | 
|  | self.name = name | 
|  | self.run_count = run_count | 
|  | self.data = {} | 
|  | self.table = None | 
|  | self.total_row = None | 
|  |  | 
|  | def __repr__(self): | 
|  | s = "Bucket: " + self.name + " {\n" | 
|  | if self.table: | 
|  | s += "\n  ".join(str(row) for row in self.table) + "\n" | 
|  | elif self.data: | 
|  | s += "\n  ".join(str(row) for row in self.data.values()) + "\n" | 
|  | if self.total_row: | 
|  | s += "  " + str(self.total_row) + "\n" | 
|  | return s + "}" | 
|  |  | 
|  | def add_data_point(self, name, run, count, duration): | 
|  | if name not in self.data: | 
|  | self.data[name] = Row(name, self.run_count) | 
|  |  | 
|  | self.data[name].add_data_point(run, count, duration) | 
|  |  | 
|  | def prepare(self, stdev=False): | 
|  | if self.data: | 
|  | for row in self.data.values(): | 
|  | row.prepare(stdev) | 
|  |  | 
|  | self.table = sorted(self.data.values(), key=Row.key) | 
|  | self.total_row = Row("Total", self.run_count) | 
|  | self.total_row.add_data([ | 
|  | sum(r.counts[i] | 
|  | for r in self.data.values()) | 
|  | for i in range(0, self.run_count) | 
|  | ], [ | 
|  | sum(r.durations[i] | 
|  | for r in self.data.values()) | 
|  | for i in range(0, self.run_count) | 
|  | ]) | 
|  | self.total_row.prepare(stdev) | 
|  |  | 
|  | def as_list(self, add_bucket_titles=True, filter=None): | 
|  | t = [] | 
|  | if filter is None or self.name in filter: | 
|  | if add_bucket_titles: | 
|  | t += [["\n"], [self.name]] | 
|  | t += [r.as_list() for r in self.table] | 
|  | t += [self.total_row.as_list()] | 
|  | return t | 
|  |  | 
|  |  | 
|  | def collect_buckets(story, group=True, repeats=1, output_dir="."): | 
|  | if group: | 
|  | groups = RUNTIME_CALL_STATS_GROUPS | 
|  | else: | 
|  | groups = [] | 
|  |  | 
|  | buckets = {} | 
|  |  | 
|  | for i in range(0, repeats): | 
|  | story_dir = f"{story.replace(':', '_')}_{i + 1}" | 
|  | trace_dir = os.path.join(output_dir, "artifacts", story_dir, "trace", | 
|  | "traceEvents") | 
|  |  | 
|  | # run_benchmark now dumps two files: a .pb.gz file and a .pb_converted.json | 
|  | # file. We only need the latter. | 
|  | trace_file_glob = os.path.join(trace_dir, "*" + JSON_FILE_EXTENSION) | 
|  | trace_files = glob.glob(trace_file_glob) | 
|  | if not trace_files: | 
|  | print("Could not find *%s file in %s" % (JSON_FILE_EXTENSION, trace_dir)) | 
|  | sys.exit(1) | 
|  | if len(trace_files) > 1: | 
|  | print("Expecting one file but got: %s" % trace_files) | 
|  | sys.exit(1) | 
|  |  | 
|  | trace_file = trace_files[0] | 
|  |  | 
|  | output = process_trace(trace_file) | 
|  | for name in output: | 
|  | bucket_name = "Other" | 
|  | for group in groups: | 
|  | if group[1].match(name): | 
|  | bucket_name = group[0] | 
|  | break | 
|  |  | 
|  | value = output[name] | 
|  | if bucket_name not in buckets: | 
|  | bucket = Bucket(bucket_name, repeats) | 
|  | buckets[bucket_name] = bucket | 
|  | else: | 
|  | bucket = buckets[bucket_name] | 
|  |  | 
|  | bucket.add_data_point(name, i, value["count"], value["duration"] / 1000.0) | 
|  | return buckets | 
|  |  | 
|  |  | 
|  | def create_table(buckets, record_bucket_names=True, filter=None): | 
|  | table = [] | 
|  | for bucket in buckets.values(): | 
|  | table += bucket.as_list( | 
|  | add_bucket_titles=record_bucket_names, filter=filter) | 
|  | return table | 
|  |  | 
|  |  | 
|  | def main(): | 
|  | args = parse_args() | 
|  | story = args.story[0] | 
|  |  | 
|  | retain = args.retain | 
|  | if args.dir is not None: | 
|  | output_dir = args.dir | 
|  | if not os.path.isdir(output_dir): | 
|  | print("Specified output directory does not exist: " % output_dir) | 
|  | sys.exit(1) | 
|  | else: | 
|  | output_dir = tempfile.mkdtemp(prefix="runtime_call_stats_") | 
|  | run_benchmark( | 
|  | story, | 
|  | repeats=args.repeats, | 
|  | output_dir=output_dir, | 
|  | verbose=args.verbose, | 
|  | js_flags=args.js_flags, | 
|  | browser_args=args.browser_args, | 
|  | chromium_dir=args.chromium_dir, | 
|  | benchmark=args.benchmark, | 
|  | executable=args.executable, | 
|  | browser=args.browser, | 
|  | device=args.device) | 
|  |  | 
|  | try: | 
|  | buckets = collect_buckets( | 
|  | story, group=args.group, repeats=args.repeats, output_dir=output_dir) | 
|  |  | 
|  | for b in buckets.values(): | 
|  | b.prepare(args.stdev) | 
|  |  | 
|  | table = create_table( | 
|  | buckets, record_bucket_names=args.group, filter=args.filter) | 
|  |  | 
|  | headers = [""] + ["Count", "Duration\n(ms)"] * args.repeats | 
|  | if args.repeats > 1: | 
|  | if args.stdev: | 
|  | headers += [ | 
|  | "Count\nMean", "Count\nStdev", "Duration\nMean (ms)", | 
|  | "Duration\nStdev (ms)" | 
|  | ] | 
|  | else: | 
|  | headers += ["Count\nMean", "Duration\nMean (ms)"] | 
|  |  | 
|  | if args.out_file: | 
|  | with open(args.out_file, "w", newline="") as f: | 
|  | write_output(f, table, headers, args.repeats, args.format) | 
|  | else: | 
|  | write_output(sys.stdout, table, headers, args.repeats, args.format) | 
|  | finally: | 
|  | if retain == "none": | 
|  | shutil.rmtree(output_dir) | 
|  | elif retain == "json": | 
|  | # Delete all files bottom up except ones ending in JSON_FILE_EXTENSION and | 
|  | # attempt to delete subdirectories (ignoring errors). | 
|  | for dir_name, subdir_list, file_list in os.walk( | 
|  | output_dir, topdown=False): | 
|  | for file_name in file_list: | 
|  | if not file_name.endswith(JSON_FILE_EXTENSION): | 
|  | os.remove(os.path.join(dir_name, file_name)) | 
|  | for subdir in subdir_list: | 
|  | try: | 
|  | os.rmdir(os.path.join(dir_name, subdir)) | 
|  | except OSError: | 
|  | pass | 
|  |  | 
|  |  | 
|  | if __name__ == "__main__": | 
|  | sys.exit(main()) |