"""Print a summary of specialization stats for all files in the default stats folders. """ # NOTE: Bytecode introspection modules (opcode, dis, etc.) should only # happen when loading a single dataset. When comparing datasets, it # could get it wrong, leading to subtle errors. import argparse import collections import json import os.path from datetime import date import itertools import sys import re if os.name == "nt": DEFAULT_DIR = "c:\\temp\\py_stats\\" else: DEFAULT_DIR = "/tmp/py_stats/" TOTAL = "specialization.hit", "specialization.miss", "execution_count" def format_ratio(num, den): """ Format a ratio as a percentage. When the denominator is 0, returns the empty string. """ if den == 0: return "" else: return f"{num/den:.01%}" def percentage_to_float(s): """ Converts a percentage string to a float. The empty string is returned as 0.0 """ if s == "": return 0.0 else: assert s[-1] == "%" return float(s[:-1]) def join_rows(a_rows, b_rows): """ Joins two tables together, side-by-side, where the first column in each is a common key. """ if len(a_rows) == 0 and len(b_rows) == 0: return [] if len(a_rows): a_ncols = list(set(len(x) for x in a_rows)) if len(a_ncols) != 1: raise ValueError("Table a is ragged") if len(b_rows): b_ncols = list(set(len(x) for x in b_rows)) if len(b_ncols) != 1: raise ValueError("Table b is ragged") if len(a_rows) and len(b_rows) and a_ncols[0] != b_ncols[0]: raise ValueError("Tables have different widths") if len(a_rows): ncols = a_ncols[0] else: ncols = b_ncols[0] default = [""] * (ncols - 1) a_data = {x[0]: x[1:] for x in a_rows} b_data = {x[0]: x[1:] for x in b_rows} if len(a_data) != len(a_rows) or len(b_data) != len(b_rows): raise ValueError("Duplicate keys") # To preserve ordering, use A's keys as is and then add any in B that aren't # in A keys = list(a_data.keys()) + [k for k in b_data.keys() if k not in a_data] return [(k, *a_data.get(k, default), *b_data.get(k, default)) for k in keys] def calculate_specialization_stats(family_stats, total): rows = [] for key in sorted(family_stats): if key.startswith("specialization.failure_kinds"): continue if key in ("specialization.hit", "specialization.miss"): label = key[len("specialization.") :] elif key == "execution_count": continue elif key in ( "specialization.success", "specialization.failure", "specializable", ): continue elif key.startswith("pair"): continue else: label = key rows.append( ( f"{label:>12}", f"{family_stats[key]:>12}", format_ratio(family_stats[key], total), ) ) return rows def calculate_specialization_success_failure(family_stats): total_attempts = 0 for key in ("specialization.success", "specialization.failure"): total_attempts += family_stats.get(key, 0) rows = [] if total_attempts: for key in ("specialization.success", "specialization.failure"): label = key[len("specialization.") :] label = label[0].upper() + label[1:] val = family_stats.get(key, 0) rows.append((label, val, format_ratio(val, total_attempts))) return rows def calculate_specialization_failure_kinds(name, family_stats, defines): total_failures = family_stats.get("specialization.failure", 0) failure_kinds = [0] * 40 for key in family_stats: if not key.startswith("specialization.failure_kind"): continue _, index = key[:-1].split("[") index = int(index) failure_kinds[index] = family_stats[key] failures = [(value, index) for (index, value) in enumerate(failure_kinds)] failures.sort(reverse=True) rows = [] for value, index in failures: if not value: continue rows.append( ( kind_to_text(index, defines, name), value, format_ratio(value, total_failures), ) ) return rows def print_specialization_stats(name, family_stats, defines): if "specializable" not in family_stats: return total = sum(family_stats.get(kind, 0) for kind in TOTAL) if total == 0: return with Section(name, 3, f"specialization stats for {name} family"): rows = calculate_specialization_stats(family_stats, total) emit_table(("Kind", "Count", "Ratio"), rows) rows = calculate_specialization_success_failure(family_stats) if rows: print_title("Specialization attempts", 4) emit_table(("", "Count:", "Ratio:"), rows) rows = calculate_specialization_failure_kinds(name, family_stats, defines) emit_table(("Failure kind", "Count:", "Ratio:"), rows) def print_comparative_specialization_stats( name, base_family_stats, head_family_stats, defines ): if "specializable" not in base_family_stats: return base_total = sum(base_family_stats.get(kind, 0) for kind in TOTAL) head_total = sum(head_family_stats.get(kind, 0) for kind in TOTAL) if base_total + head_total == 0: return with Section(name, 3, f"specialization stats for {name} family"): base_rows = calculate_specialization_stats(base_family_stats, base_total) head_rows = calculate_specialization_stats(head_family_stats, head_total) emit_table( ("Kind", "Base Count", "Base Ratio", "Head Count", "Head Ratio"), join_rows(base_rows, head_rows), ) base_rows = calculate_specialization_success_failure(base_family_stats) head_rows = calculate_specialization_success_failure(head_family_stats) rows = join_rows(base_rows, head_rows) if rows: print_title("Specialization attempts", 4) emit_table( ("", "Base Count:", "Base Ratio:", "Head Count:", "Head Ratio:"), rows ) base_rows = calculate_specialization_failure_kinds( name, base_family_stats, defines ) head_rows = calculate_specialization_failure_kinds( name, head_family_stats, defines ) emit_table( ( "Failure kind", "Base Count:", "Base Ratio:", "Head Count:", "Head Ratio:", ), join_rows(base_rows, head_rows), ) def gather_stats(input): # Note the output of this function must be JSON-serializable if os.path.isfile(input): with open(input, "r") as fd: stats = json.load(fd) stats["_stats_defines"] = { int(k): v for k, v in stats["_stats_defines"].items() } stats["_defines"] = {int(k): v for k, v in stats["_defines"].items()} return stats elif os.path.isdir(input): stats = collections.Counter() for filename in os.listdir(input): with open(os.path.join(input, filename)) as fd: for line in fd: try: key, value = line.split(":") except ValueError: print( f"Unparsable line: '{line.strip()}' in {filename}", file=sys.stderr, ) continue key = key.strip() value = int(value) stats[key] += value stats["__nfiles__"] += 1 import opcode stats["_specialized_instructions"] = [ op for op in opcode._specialized_opmap.keys() if "__" not in op ] stats["_stats_defines"] = get_stats_defines() stats["_defines"] = get_defines() return stats else: raise ValueError(f"{input:r} is not a file or directory path") def extract_opcode_stats(stats, prefix): opcode_stats = collections.defaultdict(dict) for key, value in stats.items(): if not key.startswith(prefix): continue name, _, rest = key[len(prefix) + 1 :].partition("]") opcode_stats[name][rest.strip(".")] = value return opcode_stats def parse_kinds(spec_src, prefix="SPEC_FAIL"): defines = collections.defaultdict(list) start = "#define " + prefix + "_" for line in spec_src: line = line.strip() if not line.startswith(start): continue line = line[len(start) :] name, val = line.split() defines[int(val.strip())].append(name.strip()) return defines def pretty(defname): return defname.replace("_", " ").lower() def kind_to_text(kind, defines, opname): if kind <= 8: return pretty(defines[kind][0]) if opname == "LOAD_SUPER_ATTR": opname = "SUPER" elif opname.endswith("ATTR"): opname = "ATTR" elif opname in ("FOR_ITER", "SEND"): opname = "ITER" elif opname.endswith("SUBSCR"): opname = "SUBSCR" for name in defines[kind]: if name.startswith(opname): return pretty(name[len(opname) + 1 :]) return "kind " + str(kind) def categorized_counts(opcode_stats, specialized_instructions): basic = 0 specialized = 0 not_specialized = 0 for name, opcode_stat in opcode_stats.items(): if "execution_count" not in opcode_stat: continue count = opcode_stat["execution_count"] if "specializable" in opcode_stat: not_specialized += count elif name in specialized_instructions: miss = opcode_stat.get("specialization.miss", 0) not_specialized += miss specialized += count - miss else: basic += count return basic, not_specialized, specialized def print_title(name, level=2): print("#" * level, name) print() class Section: def __init__(self, title, level=2, summary=None): self.title = title self.level = level if summary is None: self.summary = title.lower() else: self.summary = summary def __enter__(self): print_title(self.title, self.level) print("
") print("", self.summary, "") print() return self def __exit__(*args): print() print("
") print() def to_str(x): if isinstance(x, int): return format(x, ",d") else: return str(x) def emit_table(header, rows): width = len(header) header_line = "|" under_line = "|" for item in header: under = "---" if item.endswith(":"): item = item[:-1] under += ":" header_line += item + " | " under_line += under + "|" print(header_line) print(under_line) for row in rows: if width is not None and len(row) != width: raise ValueError("Wrong number of elements in row '" + str(row) + "'") print("|", " | ".join(to_str(i) for i in row), "|") print() def emit_histogram(title, stats, key, total): rows = [] for k, v in stats.items(): if k.startswith(key): entry = int(re.match(r".+\[([0-9]+)\]", k).groups()[0]) rows.append((f"<= {entry}", int(v), format_ratio(int(v), total))) # Don't include larger buckets with 0 entries for j in range(len(rows) - 1, -1, -1): if rows[j][1] != 0: break rows = rows[: j + 1] print(f"**{title}**\n") emit_table(("Range", "Count:", "Ratio:"), rows) def calculate_execution_counts(opcode_stats, total): counts = [] for name, opcode_stat in opcode_stats.items(): if "execution_count" in opcode_stat: count = opcode_stat["execution_count"] miss = 0 if "specializable" not in opcode_stat: miss = opcode_stat.get("specialization.miss") counts.append((count, name, miss)) counts.sort(reverse=True) cumulative = 0 rows = [] for count, name, miss in counts: cumulative += count if miss: miss = format_ratio(miss, count) else: miss = "" rows.append( ( name, count, format_ratio(count, total), format_ratio(cumulative, total), miss, ) ) return rows def emit_execution_counts(opcode_stats, total): with Section("Execution counts", summary="execution counts for all instructions"): rows = calculate_execution_counts(opcode_stats, total) emit_table(("Name", "Count:", "Self:", "Cumulative:", "Miss ratio:"), rows) def _emit_comparative_execution_counts(base_rows, head_rows): base_data = {x[0]: x[1:] for x in base_rows} head_data = {x[0]: x[1:] for x in head_rows} opcodes = base_data.keys() | head_data.keys() rows = [] default = [0, "0.0%", "0.0%", 0] for opcode in opcodes: base_entry = base_data.get(opcode, default) head_entry = head_data.get(opcode, default) if base_entry[0] == 0: change = 1 else: change = (head_entry[0] - base_entry[0]) / base_entry[0] rows.append((opcode, base_entry[0], head_entry[0], f"{change:0.1%}")) rows.sort(key=lambda x: abs(percentage_to_float(x[-1])), reverse=True) emit_table(("Name", "Base Count:", "Head Count:", "Change:"), rows) def emit_comparative_execution_counts( base_opcode_stats, base_total, head_opcode_stats, head_total, level=2 ): with Section( "Execution counts", summary="execution counts for all instructions", level=level ): base_rows = calculate_execution_counts(base_opcode_stats, base_total) head_rows = calculate_execution_counts(head_opcode_stats, head_total) _emit_comparative_execution_counts(base_rows, head_rows) def get_defines(): spec_path = os.path.join(os.path.dirname(__file__), "../../Python/specialize.c") with open(spec_path) as spec_src: defines = parse_kinds(spec_src) return defines def emit_specialization_stats(opcode_stats, defines): with Section("Specialization stats", summary="specialization stats by family"): for name, opcode_stat in opcode_stats.items(): print_specialization_stats(name, opcode_stat, defines) def emit_comparative_specialization_stats( base_opcode_stats, head_opcode_stats, defines ): with Section("Specialization stats", summary="specialization stats by family"): opcodes = set(base_opcode_stats.keys()) & set(head_opcode_stats.keys()) for opcode in opcodes: print_comparative_specialization_stats( opcode, base_opcode_stats[opcode], head_opcode_stats[opcode], defines ) def calculate_specialization_effectiveness( opcode_stats, total, specialized_instructions ): basic, not_specialized, specialized = categorized_counts( opcode_stats, specialized_instructions ) return [ ("Basic", basic, format_ratio(basic, total)), ("Not specialized", not_specialized, format_ratio(not_specialized, total)), ("Specialized", specialized, format_ratio(specialized, total)), ] def emit_specialization_overview(opcode_stats, total, specialized_instructions): with Section("Specialization effectiveness"): rows = calculate_specialization_effectiveness( opcode_stats, total, specialized_instructions ) emit_table(("Instructions", "Count:", "Ratio:"), rows) for title, field in ( ("Deferred", "specialization.deferred"), ("Misses", "specialization.miss"), ): total = 0 counts = [] for name, opcode_stat in opcode_stats.items(): # Avoid double counting misses if title == "Misses" and "specializable" in opcode_stat: continue value = opcode_stat.get(field, 0) counts.append((value, name)) total += value counts.sort(reverse=True) if total: with Section(f"{title} by instruction", 3): rows = [ (name, count, format_ratio(count, total)) for (count, name) in counts[:10] ] emit_table(("Name", "Count:", "Ratio:"), rows) def emit_comparative_specialization_overview( base_opcode_stats, base_total, head_opcode_stats, head_total, specialized_instructions, ): with Section("Specialization effectiveness"): base_rows = calculate_specialization_effectiveness( base_opcode_stats, base_total, specialized_instructions ) head_rows = calculate_specialization_effectiveness( head_opcode_stats, head_total, specialized_instructions ) emit_table( ( "Instructions", "Base Count:", "Base Ratio:", "Head Count:", "Head Ratio:", ), join_rows(base_rows, head_rows), ) def get_stats_defines(): stats_path = os.path.join( os.path.dirname(__file__), "../../Include/cpython/pystats.h" ) with open(stats_path) as stats_src: defines = parse_kinds(stats_src, prefix="EVAL_CALL") return defines def calculate_call_stats(stats, defines): total = 0 for key, value in stats.items(): if "Calls to" in key: total += value rows = [] for key, value in stats.items(): if "Calls to" in key: rows.append((key, value, format_ratio(value, total))) elif key.startswith("Calls "): name, index = key[:-1].split("[") index = int(index) label = name + " (" + pretty(defines[index][0]) + ")" rows.append((label, value, format_ratio(value, total))) for key, value in stats.items(): if key.startswith("Frame"): rows.append((key, value, format_ratio(value, total))) return rows def emit_call_stats(stats, defines): with Section("Call stats", summary="Inlined calls and frame stats"): rows = calculate_call_stats(stats, defines) emit_table(("", "Count:", "Ratio:"), rows) def emit_comparative_call_stats(base_stats, head_stats, defines): with Section("Call stats", summary="Inlined calls and frame stats"): base_rows = calculate_call_stats(base_stats, defines) head_rows = calculate_call_stats(head_stats, defines) rows = join_rows(base_rows, head_rows) rows.sort(key=lambda x: -percentage_to_float(x[-1])) emit_table( ("", "Base Count:", "Base Ratio:", "Head Count:", "Head Ratio:"), rows ) def calculate_object_stats(stats): total_materializations = stats.get("Object new values") total_allocations = stats.get("Object allocations") + stats.get( "Object allocations from freelist" ) total_increfs = stats.get("Object interpreter increfs") + stats.get( "Object increfs" ) total_decrefs = stats.get("Object interpreter decrefs") + stats.get( "Object decrefs" ) rows = [] for key, value in stats.items(): if key.startswith("Object"): if "materialize" in key: ratio = format_ratio(value, total_materializations) elif "allocations" in key: ratio = format_ratio(value, total_allocations) elif "increfs" in key: ratio = format_ratio(value, total_increfs) elif "decrefs" in key: ratio = format_ratio(value, total_decrefs) else: ratio = "" label = key[6:].strip() label = label[0].upper() + label[1:] rows.append((label, value, ratio)) return rows def calculate_gc_stats(stats): gc_stats = [] for key, value in stats.items(): if not key.startswith("GC"): continue n, _, rest = key[3:].partition("]") name = rest.strip() gen_n = int(n) while len(gc_stats) <= gen_n: gc_stats.append({}) gc_stats[gen_n][name] = value return [ (i, gen["collections"], gen["objects collected"], gen["object visits"]) for (i, gen) in enumerate(gc_stats) ] def emit_object_stats(stats): with Section("Object stats", summary="allocations, frees and dict materializatons"): rows = calculate_object_stats(stats) emit_table(("", "Count:", "Ratio:"), rows) def emit_comparative_object_stats(base_stats, head_stats): with Section("Object stats", summary="allocations, frees and dict materializatons"): base_rows = calculate_object_stats(base_stats) head_rows = calculate_object_stats(head_stats) emit_table( ("", "Base Count:", "Base Ratio:", "Head Count:", "Head Ratio:"), join_rows(base_rows, head_rows), ) def emit_gc_stats(stats): with Section("GC stats", summary="GC collections and effectiveness"): rows = calculate_gc_stats(stats) emit_table( ("Generation:", "Collections:", "Objects collected:", "Object visits:"), rows, ) def emit_comparative_gc_stats(base_stats, head_stats): with Section("GC stats", summary="GC collections and effectiveness"): base_rows = calculate_gc_stats(base_stats) head_rows = calculate_gc_stats(head_stats) emit_table( ( "Generation:", "Base collections:", "Head collections:", "Base objects collected:", "Head objects collected:", "Base object visits:", "Head object visits:", ), join_rows(base_rows, head_rows), ) def get_total(opcode_stats): total = 0 for opcode_stat in opcode_stats.values(): if "execution_count" in opcode_stat: total += opcode_stat["execution_count"] return total def emit_pair_counts(opcode_stats, total): pair_counts = [] for name_i, opcode_stat in opcode_stats.items(): for key, value in opcode_stat.items(): if key.startswith("pair_count"): name_j, _, _ = key[11:].partition("]") if value: pair_counts.append((value, (name_i, name_j))) with Section("Pair counts", summary="Pair counts for top 100 pairs"): pair_counts.sort(reverse=True) cumulative = 0 rows = [] for count, pair in itertools.islice(pair_counts, 100): name_i, name_j = pair cumulative += count rows.append( ( f"{name_i} {name_j}", count, format_ratio(count, total), format_ratio(cumulative, total), ) ) emit_table(("Pair", "Count:", "Self:", "Cumulative:"), rows) with Section( "Predecessor/Successor Pairs", summary="Top 5 predecessors and successors of each opcode", ): predecessors = collections.defaultdict(collections.Counter) successors = collections.defaultdict(collections.Counter) total_predecessors = collections.Counter() total_successors = collections.Counter() for count, (first, second) in pair_counts: if count: predecessors[second][first] = count successors[first][second] = count total_predecessors[second] += count total_successors[first] += count for name in opcode_stats.keys(): total1 = total_predecessors[name] total2 = total_successors[name] if total1 == 0 and total2 == 0: continue pred_rows = succ_rows = () if total1: pred_rows = [ (pred, count, f"{count/total1:.1%}") for (pred, count) in predecessors[name].most_common(5) ] if total2: succ_rows = [ (succ, count, f"{count/total2:.1%}") for (succ, count) in successors[name].most_common(5) ] with Section(name, 3, f"Successors and predecessors for {name}"): emit_table(("Predecessors", "Count:", "Percentage:"), pred_rows) emit_table(("Successors", "Count:", "Percentage:"), succ_rows) def calculate_optimization_stats(stats): attempts = stats["Optimization attempts"] created = stats["Optimization traces created"] executed = stats["Optimization traces executed"] uops = stats["Optimization uops executed"] trace_stack_overflow = stats["Optimization trace stack overflow"] trace_stack_underflow = stats["Optimization trace stack underflow"] trace_too_long = stats["Optimization trace too long"] trace_too_short = stats["Optimiztion trace too short"] inner_loop = stats["Optimization inner loop"] recursive_call = stats["Optimization recursive call"] return [ ("Optimization attempts", attempts, ""), ("Traces created", created, format_ratio(created, attempts)), ("Traces executed", executed, ""), ("Uops executed", uops, int(uops / (executed or 1))), ("Trace stack overflow", trace_stack_overflow, ""), ("Trace stack underflow", trace_stack_underflow, ""), ("Trace too long", trace_too_long, ""), ("Trace too short", trace_too_short, ""), ("Inner loop found", inner_loop, ""), ("Recursive call", recursive_call, ""), ] def calculate_uop_execution_counts(opcode_stats): total = 0 counts = [] for name, opcode_stat in opcode_stats.items(): if "execution_count" in opcode_stat: count = opcode_stat["execution_count"] counts.append((count, name)) total += count counts.sort(reverse=True) cumulative = 0 rows = [] for count, name in counts: cumulative += count rows.append( (name, count, format_ratio(count, total), format_ratio(cumulative, total)) ) return rows def emit_optimization_stats(stats): if "Optimization attempts" not in stats: return uop_stats = extract_opcode_stats(stats, "uops") with Section( "Optimization (Tier 2) stats", summary="statistics about the Tier 2 optimizer" ): with Section("Overall stats", level=3): rows = calculate_optimization_stats(stats) emit_table(("", "Count:", "Ratio:"), rows) emit_histogram( "Trace length histogram", stats, "Trace length", stats["Optimization traces created"], ) emit_histogram( "Optimized trace length histogram", stats, "Optimized trace length", stats["Optimization traces created"], ) emit_histogram( "Trace run length histogram", stats, "Trace run length", stats["Optimization traces executed"], ) with Section("Uop stats", level=3): rows = calculate_uop_execution_counts(uop_stats) emit_table(("Uop", "Count:", "Self:", "Cumulative:"), rows) with Section("Unsupported opcodes", level=3): unsupported_opcodes = extract_opcode_stats(stats, "unsupported_opcode") data = [] for opcode, entry in unsupported_opcodes.items(): data.append((entry["count"], opcode)) data.sort(reverse=True) rows = [(x[1], x[0]) for x in data] emit_table(("Opcode", "Count"), rows) def emit_comparative_optimization_stats(base_stats, head_stats): print("## Comparative optimization stats not implemented\n\n") def output_single_stats(stats): opcode_stats = extract_opcode_stats(stats, "opcode") total = get_total(opcode_stats) emit_execution_counts(opcode_stats, total) emit_pair_counts(opcode_stats, total) emit_specialization_stats(opcode_stats, stats["_defines"]) emit_specialization_overview( opcode_stats, total, stats["_specialized_instructions"] ) emit_call_stats(stats, stats["_stats_defines"]) emit_object_stats(stats) emit_gc_stats(stats) emit_optimization_stats(stats) with Section("Meta stats", summary="Meta statistics"): emit_table(("", "Count:"), [("Number of data files", stats["__nfiles__"])]) def output_comparative_stats(base_stats, head_stats): base_opcode_stats = extract_opcode_stats(base_stats, "opcode") base_total = get_total(base_opcode_stats) head_opcode_stats = extract_opcode_stats(head_stats, "opcode") head_total = get_total(head_opcode_stats) emit_comparative_execution_counts( base_opcode_stats, base_total, head_opcode_stats, head_total ) emit_comparative_specialization_stats( base_opcode_stats, head_opcode_stats, head_stats["_defines"] ) emit_comparative_specialization_overview( base_opcode_stats, base_total, head_opcode_stats, head_total, head_stats["_specialized_instructions"], ) emit_comparative_call_stats(base_stats, head_stats, head_stats["_stats_defines"]) emit_comparative_object_stats(base_stats, head_stats) emit_comparative_gc_stats(base_stats, head_stats) emit_comparative_optimization_stats(base_stats, head_stats) def output_stats(inputs, json_output=None): if len(inputs) == 1: stats = gather_stats(inputs[0]) if json_output is not None: json.dump(stats, json_output) output_single_stats(stats) elif len(inputs) == 2: if json_output is not None: raise ValueError("Can not output to JSON when there are multiple inputs") base_stats = gather_stats(inputs[0]) head_stats = gather_stats(inputs[1]) output_comparative_stats(base_stats, head_stats) print("---") print("Stats gathered on:", date.today()) def main(): parser = argparse.ArgumentParser(description="Summarize pystats results") parser.add_argument( "inputs", nargs="*", type=str, default=[DEFAULT_DIR], help=f""" Input source(s). For each entry, if a .json file, the output provided by --json-output from a previous run; if a directory, a directory containing raw pystats .txt files. If one source is provided, its stats are printed. If two sources are provided, comparative stats are printed. Default is {DEFAULT_DIR}. """, ) parser.add_argument( "--json-output", nargs="?", type=argparse.FileType("w"), help="Output complete raw results to the given JSON file.", ) args = parser.parse_args() if len(args.inputs) > 2: raise ValueError("0-2 arguments may be provided.") output_stats(args.inputs, json_output=args.json_output) if __name__ == "__main__": main()