Skip to content

Commit 891ce18

Browse files
committed
benchmark code
1 parent 71a115d commit 891ce18

6 files changed

Lines changed: 398 additions & 0 deletions

File tree

bench/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
out/

bench/ab.exs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Measures two generated parser modules against each other in one VM.
2+
#
3+
# BENCH_EBIN=<dir> mix run bench/ab.exs
4+
#
5+
# Expects BenchOld and BenchNew, as produced by bench/gen_module.exs. With only
6+
# BenchNew on the path it reports that side's absolute numbers instead.
7+
# bench/compare.sh drives the whole thing; run this directly only if you have
8+
# already built the modules yourself.
9+
#
10+
# The beam directory arrives by env var rather than `elixir -pa`, because Mix
11+
# prunes the code path down to the project's own load paths.
12+
#
13+
# Env: BENCH_EBIN, BENCH_ROUNDS (default 21), BENCH_BUDGET_US per round (20000).
14+
Code.require_file("support.exs", __DIR__)
15+
16+
if ebin = System.get_env("BENCH_EBIN"), do: Code.prepend_path(ebin)
17+
18+
alias Bench.{Measure, Table}
19+
20+
rounds = System.get_env("BENCH_ROUNDS", "21") |> String.to_integer()
21+
budget = System.get_env("BENCH_BUDGET_US", "20000") |> String.to_integer()
22+
23+
old? = Code.ensure_loaded?(BenchOld)
24+
if not Code.ensure_loaded?(BenchNew), do: raise("BenchNew is not on the code path")
25+
26+
call = fn module, parser, input -> fn -> apply(module, parser, [input]) end end
27+
28+
rows =
29+
for {label, parser, input} <- Bench.Cases.inputs() do
30+
new = call.(BenchNew, parser, input)
31+
32+
# One iteration count for both sides, so the reduction counts stay directly
33+
# comparable
34+
iters = Measure.calibrate(new, budget)
35+
36+
if old? do
37+
old = call.(BenchOld, parser, input)
38+
39+
if apply(BenchOld, parser, [input]) != apply(BenchNew, parser, [input]) do
40+
raise "#{label}: the two revisions disagree on the result"
41+
end
42+
43+
t_old = Measure.time(old, iters, rounds)
44+
t_new = Measure.time(new, iters, rounds)
45+
# The control: the same code timed twice. Its distance from 1.000x is how
46+
# much of the speedup column is noise.
47+
t_control = Measure.time(old, iters, rounds)
48+
49+
r_old = Measure.reductions(old)
50+
r_new = Measure.reductions(new)
51+
52+
[
53+
label,
54+
Table.ns(t_old),
55+
Table.ns(t_new),
56+
Table.ratio(t_old / t_new),
57+
Table.ratio(t_old / t_control),
58+
Table.int(r_old),
59+
Table.signed(r_new - r_old)
60+
]
61+
else
62+
[label, Table.ns(Measure.time(new, iters, rounds)), Table.int(Measure.reductions(new))]
63+
end
64+
end
65+
66+
IO.puts("")
67+
68+
if old? do
69+
Table.print(["case", "old(ns)", "new(ns)", "speedup", "control", "red.", "red.diff"], rows)
70+
71+
IO.puts("""
72+
73+
speedup old/new wall clock, min-of-#{rounds}. Only trust it if it clears `control`.
74+
control the old side timed twice. This run's noise floor.
75+
red.diff reductions for one call, new minus old. Exact: 0 means both revisions
76+
perform exactly the same work, whatever the clock says.
77+
""")
78+
else
79+
Table.print(["case", "ns/call", "red./call"], rows)
80+
IO.puts("\nBenchOld not on the path, so this is the working tree alone.\n")
81+
end

bench/compare.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
# Compares the working tree against a git ref.
3+
#
4+
# bench/compare.sh # vs HEAD
5+
# bench/compare.sh master # vs any ref
6+
#
7+
# Both revisions are measured in a single VM, from parser source dumped by
8+
# bench/gen_module.exs, so neither side pays for a recompile and neither warms
9+
# the machine up for the other. The dumps are left in bench/out/ to be diffed.
10+
#
11+
# The ref is checked out into a throwaway worktree, and this directory's scripts
12+
# are copied in, so the ref may predate them and both sides are driven by the
13+
# same case definitions.
14+
set -euo pipefail
15+
16+
REF="${1:-HEAD}"
17+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
18+
OUT="$ROOT/bench/out"
19+
TMP="$(mktemp -d)"
20+
WT="$TMP/worktree"
21+
22+
cleanup() {
23+
[ -d "$WT" ] && git -C "$ROOT" worktree remove --force "$WT" >/dev/null 2>&1 || true
24+
git -C "$ROOT" worktree prune >/dev/null 2>&1 || true
25+
rm -rf "$TMP"
26+
}
27+
trap cleanup EXIT
28+
29+
cd "$ROOT"
30+
mkdir -p "$OUT"
31+
32+
echo "==> working tree"
33+
mix run bench/gen_module.exs New "$OUT/new.ex"
34+
35+
echo "==> $REF ($(git rev-parse --short "$REF"))"
36+
git worktree add --quiet --detach "$WT" "$REF"
37+
mkdir -p "$WT/bench"
38+
cp bench/*.exs "$WT/bench/"
39+
(cd "$WT" && mix run bench/gen_module.exs Old "$OUT/old.ex")
40+
41+
echo
42+
echo "==> generated code"
43+
if diff -q <(sed 's/BenchOld/Bench/' "$OUT/old.ex") \
44+
<(sed 's/BenchNew/Bench/' "$OUT/new.ex") >/dev/null; then
45+
echo " identical -- any difference in the table below is noise"
46+
else
47+
echo " differs: diff bench/out/old.ex bench/out/new.ex"
48+
echo " ($(diff <(sed 's/BenchOld/Bench/' "$OUT/old.ex") \
49+
<(sed 's/BenchNew/Bench/' "$OUT/new.ex") | grep -c '^[<>]') changed lines;"
50+
echo " renumbered functions and reordered clauses are expected and harmless)"
51+
fi
52+
53+
mkdir -p "$TMP/ebin"
54+
elixirc --ignore-module-conflict -o "$TMP/ebin" "$OUT/old.ex" "$OUT/new.ex"
55+
BENCH_EBIN="$TMP/ebin" mix run bench/ab.exs

bench/gen_module.exs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Dumps the parsers in Bench.Cases as plain, formatted source.
2+
#
3+
# mix run bench/gen_module.exs <ModuleSuffix> <output.ex>
4+
#
5+
# Two purposes:
6+
#
7+
# * The code two revisions generate can be compiled into a single VM and
8+
# measured side by side, without either paying for a recompile.
9+
# * The dumps are diffable. An empty diff settles a performance question
10+
# outright: identical code cannot run at different speeds. A non-empty one
11+
# still needs reading, since function numbering and clause order shift
12+
# around harmlessly.
13+
Code.require_file("support.exs", __DIR__)
14+
15+
[suffix, out] = System.argv()
16+
17+
body =
18+
for {name, combinator} <- Bench.Cases.parsers() do
19+
{defs, inline} = NimbleParsec.Compiler.compile(name, Enum.reverse(combinator), inline: true)
20+
21+
clauses =
22+
for {fun, args, guards, body} <- defs do
23+
head = Macro.to_string({fun, [], args})
24+
head = if guards == true, do: head, else: "#{head} when #{Macro.to_string(guards)}"
25+
"defp #{head} do\n#{Macro.to_string(body)}\nend"
26+
end
27+
28+
# Calls the generated entry point directly, minus the `defparsec` wrapper's
29+
# argument handling
30+
"""
31+
@compile {:inline, #{inspect(inline)}}
32+
def #{name}(binary), do: #{name}__0(binary, [], [], %{}, {1, 0}, 0)
33+
34+
#{Enum.join(clauses, "\n\n")}
35+
"""
36+
end
37+
38+
source = "defmodule Bench#{suffix} do\n#{Enum.join(body, "\n")}\nend\n"
39+
File.write!(out, [Code.format_string!(source), "\n"])
40+
IO.puts("wrote #{out} (Bench#{suffix})")

bench/string_bench.exs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Absolute numbers for the working tree, through the public API.
2+
#
3+
# mix run bench/string_bench.exs
4+
#
5+
# This goes through the parsers `defparsec` defines, wrapper and all, so the
6+
# numbers are what a caller actually pays. For comparing two revisions use
7+
# bench/compare.sh, which measures both in one VM and can prove when the
8+
# generated code is identical.
9+
#
10+
# Env: BENCH_ROUNDS (default 21), BENCH_BUDGET_US per round (default 20000).
11+
Code.require_file("support.exs", __DIR__)
12+
13+
alias Bench.{Measure, Table}
14+
15+
defmodule Bench.Parsers do
16+
import NimbleParsec
17+
18+
# `defparsec` evaluates its combinator in the module body, so the shared
19+
# definitions can be fed to it straight from Bench.Cases
20+
for {name, combinator} <- Bench.Cases.parsers() do
21+
defparsec(name, combinator)
22+
end
23+
end
24+
25+
rounds = System.get_env("BENCH_ROUNDS", "21") |> String.to_integer()
26+
budget = System.get_env("BENCH_BUDGET_US", "20000") |> String.to_integer()
27+
28+
rows =
29+
for {label, parser, input} <- Bench.Cases.inputs() do
30+
fun = fn -> apply(Bench.Parsers, parser, [input]) end
31+
iters = Measure.calibrate(fun, budget)
32+
33+
[
34+
label,
35+
Table.ns(Measure.time(fun, iters, rounds)),
36+
Table.ns(Measure.reductions(fun, 2_000))
37+
]
38+
end
39+
40+
IO.puts("")
41+
Table.print(~w(case ns/call red./call), rows)
42+
43+
IO.puts("""
44+
45+
ns/call min-of-#{rounds} rounds, each in a fresh process.
46+
red./call reductions, including a constant loop overhead. Deterministic.
47+
""")

0 commit comments

Comments
 (0)