#!/usr/bin/env nix-shell #!nix-shell -i python3 -p python3 -p hyperfine -p "if stdenv.isLinux then linuxPackages.perf else null" import argparse import subprocess import os import json import tempfile import platform import shlex import textwrap flake_args = ["--extra-experimental-features", "nix-command flakes"] cases = { "search": lambda build: [ f"{build}/bin/nix", *flake_args, "search", "--no-eval-cache", "github:nixos/nixpkgs/e1fa12d4f6c6fe19ccb59cac54b5b3f25e160870", "hello", ], "rebuild": lambda build: [ f"{build}/bin/nix", *flake_args, "eval", "--raw", "--impure", "--expr", textwrap.dedent(""" (import { configuration = ./bench/nixpkgs/nixos/modules/installer/cd-dvd/installation-cd-graphical-calamares-plasma6.nix; }).config.system.build.toplevel """).replace("\n", " "), ], "rebuild_lh": lambda build: [ "GC_INITIAL_HEAP_SIZE=10g", *cases['rebuild'](build), ], "parse": lambda build: [ f"{build}/bin/nix", *flake_args, "eval", "-f", "bench/nixpkgs/pkgs/development/haskell-modules/hackage-packages.nix", ], } arg_parser = argparse.ArgumentParser() # FIXME(jade, gilice): it is a reasonable use case to want to run a benchmark run # on just one build. However, since we are using hyperfine in comparison # mode, we would have to combine the JSON ourselves to support that, which # would probably be better done by writing a benchmarking script in # not-bash. arg_parser.add_argument( 'builds', nargs='+', help="At least two build directories to compare, containing bin/nix", ) arg_parser.add_argument( '--cases', type=str, help="A comma-separated list of cases you want to run. Defaults to running all", ) arg_parser.add_argument( '--mode', choices=[ "walltime" ] + [ "icount" ] if platform.system() == 'Linux' else [], # perf doesn't run on Darwin default="walltime", ) arg_parser.add_argument( '--daemon', action='store_true', help='Run a temporary daemon for the benchmark instead of using a local store directly', ) args = arg_parser.parse_args() if len(args.builds) < 2: raise ValueError("need at least two build directories to compare") benchmarks: list[str] = [] if args.cases is None: benchmarks = list(cases.keys()) else: for case in args.cases.split(","): if case not in cases: raise ValueError(f"no such case: {case}") benchmarks.append(case) def make_full_command(build, case): cmd = " ".join(map(shlex.quote, cases[case](build))) if args.daemon: return " ".join([ f"{build}/bin/nix --extra-experimental-features nix-command daemon &", "trap 'kill %1' EXIT;", f"NIX_REMOTE=daemon {cmd}", ]) else: return cmd def bench_walltime(env): for case in benchmarks: for build in args.builds: subprocess.run([ "taskset", "-c", "2,3", "chrt", "-f","50", *[ "hyperfine", "--warmup", "2", "--runs", "10", "--export-json", f"bench/bench-{case}-{build}.json", "--export-markdown", f"bench/bench-{case}-{build}.md", "--", make_full_command(build, case), ], ], env=env, check=True) print("Benchmarks summary\n---\n") for case in benchmarks: results = [] for build in args.builds: with open(f"bench/bench-{case}-{build}.json") as fd: results.append(json.load(fd)["results"][0]) for result in results: print(result["command"]) print("-" * min(80,len(result["command"]))) def attr_rounded(attr): return f"{result[attr]:.3f}" print(" mean: ", attr_rounded("mean"), "±", attr_rounded("stddev")) print(" user:", attr_rounded("user"), "| system", attr_rounded("system")) print(" median: ", attr_rounded("median")) print(" range: ", attr_rounded("min") + "s.." + attr_rounded("max")+"s") print(" relative:", f"{result["mean"]/results[0]["mean"]:.3f}") print("\n") def bench_icount(env): perf_results_for: dict[str, list[tuple[str, float]]] = {} for case in benchmarks: for build in args.builds: # the perf stat -j output (incorrectly) localizes numbers, which will trip up the json parser. env["LC_ALL"]="C" case_command = make_full_command(build, case) commandline = [ "perf", "stat", "-o", f"bench/perf-{case}.json", "-j", "sh", "-c", case_command, ] print("running", case_command) subprocess.run(commandline, env=env, check=True, stdout=subprocess.DEVNULL) # warmup run subprocess.run(commandline, env=env, check=True, stdout=subprocess.DEVNULL) perf_fd = open(f"bench/perf-{case}.json") perf_data = [json.loads(x) for x in perf_fd.readlines()] perf_fd.close() instr = next(x for x in perf_data if x["event"] in ["instructions", "instructions:u"]) # an implementation of a find_first iterator if case not in perf_results_for: perf_results_for[case] = [] perf_results_for[case].append((case_command, float(instr["counter-value"]))) print("Benchmarks summary\n---\n") for (case, entries) in perf_results_for.items(): for entry in entries: cmd,instr = entry print(cmd) print("-" * min(80,len(cmd))) print(" instructions: ", int(instr)) print(" relative instructions:", int(instr)/perf_results_for[case][0][1]) print("\n") with tempfile.TemporaryDirectory() as tmp_dir: subprocess.run([ "nix", "build", "--extra-experimental-features", "nix-command flakes", "--impure", "--expr",'(builtins.getFlake "git+file:.").inputs.nixpkgs.outPath', "-o","bench/nixpkgs" ], check=True) subenv = os.environ.copy() subenv["NIX_CONF_DIR"] = "/var/empty" subenv["NIX_REMOTE"] = tmp_dir subenv["NIX_PATH"] = ":".join([ "nixpkgs=bench/nixpkgs", ]) subenv["NIX_DAEMON_SOCKET_PATH"] = f"{tmp_dir}/daemon" if args.mode == "walltime": bench_walltime(subenv) else: bench_icount(subenv)