import argparse import bisect import collections import shutil import struct import subprocess import sys from pathlib import Path DESCRIPTION = """Symbolize the output of the Windows samplers (APS5_SAMPLE_WORKER, APS5_SAMPLE_COMMITTER and APS5_SAMPLE_THREADS) against the modules that ran. Each sampled frame is written as module+0xRVA; this reads the COFF symbol table of each module found in --libs (the MinGW-built .prx and .dll files keep one), or its export table when it has none, and names every frame module!function. The '# module name size timestamp' header lines the samplers write are compared with the SizeOfImage and TimeDateStamp of the file that is found; a module whose file does not match is left unsymbolized, since its addresses would name the wrong functions. The sampler rewrites its files every 20 s with totals since start-up. --since takes an earlier copy of the same file and subtracts it, which keeps only the samples taken between the two copies (a steady-state window). A module with no such record is also left unsymbolized, since nothing shows which build it was; --unverified symbolizes it anyway against the file found. Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or an address file ('module offset self inclusive' lines, under 'thread samples ' headings for APS5_SAMPLE_THREADS; --thread keeps one thread). Output: the hottest functions by self and by inclusive samples, and with --out a symbolized collapsed-stack file for speedscope.app or flamegraph.pl. For an address file, a function's inclusive count is the sum of its addresses' counts, which overcounts the samples in which it is on the stack more than once.""" IMAGE_SCN_CNT_CODE = 0x20 IMAGE_SYM_CLASS_EXTERNAL = 2 IMAGE_SYM_CLASS_STATIC = 3 class PeModule: def __init__(self, path): data = Path(path).read_bytes() if data[:2] != b"MZ": raise ValueError(f"{path}: not a PE file") pe = struct.unpack_from("> 4) != 2 and value == 0: continue if name[:4] == b"\0\0\0\0": at = strings + struct.unpack_from(" samples '") samples.stacks[frames] += int(count) return samples def subtract(later, earlier): result = collections.Counter(later) result.subtract(earlier) return collections.Counter({key: count for key, count in result.items() if count > 0}) def window(later, earlier): result = Samples() result.headers = dict(later.headers) for name in ("stacks", "self_counts", "inclusive", "total"): setattr(result, name, subtract(getattr(later, name), getattr(earlier, name))) return result def summarize(symbolizer, samples, top): self_counts = collections.Counter() inclusive = collections.Counter() total = 0 if samples.stacks: for frames, count in samples.stacks.items(): names = [symbolizer.frame(frame) for frame in frames.split(";")] total += count self_counts[names[-1]] += count for name in set(names): inclusive[name] += count else: for (module, offset), count in samples.self_counts.items(): self_counts[symbolizer.frame(f"{module}+{offset}")] += count for (module, offset), count in samples.inclusive.items(): inclusive[symbolizer.frame(f"{module}+{offset}")] += count total = samples.total["threads"] or samples.total["samples"] or sum(samples.self_counts.values()) lines = [f"{total} samples"] for title, counter in (("self", self_counts), ("inclusive", inclusive)): if not counter: continue lines.append(f"hottest by {title}:") for name, count in counter.most_common(top): lines.append(f" {count:8d} {100.0 * count / total if total else 0.0:5.1f}% {name}") return lines def main(argv=None): parser = argparse.ArgumentParser(description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("samples", help="collapsed-stack file or address file written by a Windows sampler") parser.add_argument("--libs", action="append", required=True, help="directory holding the modules that ran (repeatable)") parser.add_argument("--since", help="an earlier copy of the same file, subtracted to keep the samples taken after it") parser.add_argument("--out", help="write the symbolized collapsed stacks to this file") parser.add_argument("--top", type=int, default=30, help="functions listed per table (default 30)") parser.add_argument("--demangle", action="store_true", help="demangle C++ names with c++filt when it is on PATH") parser.add_argument("--thread", help="keep only this thread of an APS5_SAMPLE_THREADS file") parser.add_argument("--unverified", action="store_true", help="also symbolize modules the samples have no '# module' record for") args = parser.parse_args(argv) samples = load(args.samples, args.thread) if args.since: samples = window(samples, load(args.since, args.thread)) symbolizer = Symbolizer(args.libs, samples.headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr), unverified=args.unverified) for line in summarize(symbolizer, samples, args.top): print(line) if args.out: if not samples.stacks: parser.error("--out needs a collapsed-stack file") with open(args.out, "w", encoding="utf-8") as output: for frames, count in samples.stacks.items(): output.write(";".join(symbolizer.frame(frame) for frame in frames.split(";")) + f" {count}\n") return 0 if __name__ == "__main__": sys.exit(main())