okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettest/manual: compare builds interleaved in the load bench, with an option for large cellsb570aa7

main
7.2 KiB218 linesraw
1"""Compare the daemon's CPU and memory across builds on one scripted load.
2
3The load is `--sessions` sessions run at once, each a model turn that makes
4`--calls` python tool calls (every cell asks the kernel for the commands
5catalog, a host-route call) and then streams a long final answer. The numbers
6come from the daemon's own VM over inspection, so build the daemon you want to
7measure and point at it:
8
9    ALBEDO_TEST_DAEMON=$(test/snapshot-daemon.sh /tmp/bench/old) \
10        python3 test/manual/json_roundtrip_bench.py
11
12Or compare builds in one run, interleaved ABBA so a busy machine slows both
13alike (give each build a label):
14
15    python3 test/manual/json_roundtrip_bench.py \\
16        --daemon old=/tmp/bench/old/albedo-daemon --daemon new=/tmp/bench/new/albedo-daemon
17
18reductions and gc_words barely move with contention; cpu_ms and wall_s do, so
19read those only on a quiet machine.
20cpu_ms is the VM's CPU time, reductions its work, gc_words the heap the
21collector reclaimed (garbage made), peak_mb the daemon's highest resident set
22sampled every 50 ms.
23"""
24
25import argparse
26import os
27import re
28import statistics
29import subprocess
30import sys
31import threading
32import time
33from pathlib import Path
34
35sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "e2e"))
36
37import harness  # noqa: E402
38from harness import Albedo, Provider, python, text  # noqa: E402
39from inspect_support import Inspection  # noqa: E402
40
41CALL = "len(await commands.catalog())"
42ANSWER = "a streamed word " * 200
43
44
45def cell(kilobytes):
46    """The cell's source: the host call, after `kilobytes` KiB of padding that
47    the kernel's command carries (the daemon once decoded all of it per cell)."""
48    pad = f'PAD = "{"x" * 1024 * kilobytes}"\n' if kilobytes else ""
49    return pad + CALL
50
51
52def script(calls, code):
53    def reply(request):
54        used = sum(
55            1 for message in request["messages"] if message.get("role") == "tool"
56        )
57        return python(code) if used < calls else text(ANSWER)
58
59    return reply
60
61
62def numbers(output):
63    return [int(value) for value in re.findall(r"\d+", output)]
64
65
66class Meter:
67    def __init__(self, inspection, pid):
68        self.inspection = inspection
69        self.pid = pid
70
71    def read(self):
72        return numbers(
73            self.inspection.evaluate(
74                "Read = fun() -> {Cpu, _} = erlang:statistics(runtime), "
75                "{Red, _} = erlang:statistics(reductions), "
76                "{_, Words, _} = erlang:statistics(garbage_collection), "
77                "[Cpu, Red, Words] end, "
78                'io:format("~w", [Call(erlang, apply, [Read, []])])'
79            )
80        )
81
82    def rss_mb(self):
83        out = subprocess.run(
84            ["ps", "-o", "rss=", "-p", str(self.pid)], capture_output=True, text=True
85        ).stdout
86        return int(out) / 1024 if out.strip() else 0.0
87
88
89def load(app, sessions, meter):
90    """Run every session at once: the wall time and the highest RSS seen."""
91
92    def run(session):
93        app.prompt(session, "go").close()
94        app.idle(session, timeout=180)
95
96    peak = [meter.rss_mb()]
97    done = threading.Event()
98
99    def watch():
100        while not done.wait(0.05):
101            peak[0] = max(peak[0], meter.rss_mb())
102
103    threads = [threading.Thread(target=run, args=(s,)) for s in sessions]
104    watcher = threading.Thread(target=watch)
105    started = time.monotonic()
106    watcher.start()
107    for thread in threads:
108        thread.start()
109    for thread in threads:
110        thread.join()
111    wall = time.monotonic() - started
112    done.set()
113    watcher.join()
114    return wall, peak[0]
115
116
117def measure(launcher, args):
118    """Boot one daemon from `launcher` and run the load: one row per run."""
119    harness._executable = launcher
120    daemon = harness.Daemon("bench")
121    daemon.env["ALBEDO_INSPECT"] = "1"
122    provider = Provider(script(args.calls, cell(args.code_kb)), chunk_size=20)
123    rows = []
124    with harness.on_daemon(daemon):
125        app = Albedo(provider)
126        app.__enter__()
127        try:
128            meter = Meter(Inspection(app), daemon._pid)
129            for index in range(args.repeat + 1):
130                sessions = [app.session() for _ in range(args.sessions)]
131                before = meter.read()
132                wall, peak = load(app, sessions, meter)
133                after = meter.read()
134                if index:  # the first run warms the daemon
135                    rows.append(
136                        {
137                            "wall_s": wall,
138                            "cpu_ms": after[0] - before[0],
139                            "reductions": after[1] - before[1],
140                            "gc_words": after[2] - before[2],
141                            "peak_mb": peak,
142                        }
143                    )
144        finally:
145            app.__exit__(None, None, None)
146            provider.close()
147            daemon.discard()
148    return rows
149
150
151def main():
152    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
153    parser.add_argument("--sessions", type=int, default=8)
154    parser.add_argument("--calls", type=int, default=25)
155    parser.add_argument("--repeat", type=int, default=4, help="runs per boot")
156    parser.add_argument("--code-kb", type=int, default=0, help="padding in each cell")
157    parser.add_argument(
158        "--daemon",
159        action="append",
160        metavar="LABEL=LAUNCHER",
161        help="a build to measure; give two or more to compare them",
162    )
163    parser.add_argument(
164        "--rounds", type=int, default=2, help="boots of each build, ABBA order"
165    )
166    args = parser.parse_args()
167    builds = [tuple(item.split("=", 1)) for item in args.daemon or []] or [
168        ("daemon", os.environ["ALBEDO_TEST_DAEMON"])
169    ]
170    labels = [label for label, _ in builds]
171    order = []
172    for round_ in range(args.rounds):
173        order += labels if round_ % 2 == 0 else labels[::-1]
174    rows = {label: [] for label in labels}
175    launchers = dict(builds)
176    for label in order:
177        taken = measure(launchers[label], args)
178        rows[label] += taken
179        print(
180            f"{label:>10} boot: " + format_row(statistics_of(taken, statistics.median))
181        )
182    print()
183    for label, taken in rows.items():
184        print(
185            f"{label:>10} median: "
186            + format_row(statistics_of(taken, statistics.median))
187        )
188        print(f"{label:>10} min..max: " + spread(taken))
189    if len(builds) == 2:
190        (first, one), (second, two) = rows.items()
191        print()
192        for key in ("reductions", "gc_words", "cpu_ms", "peak_mb", "wall_s"):
193            a = statistics.median(r[key] for r in one)
194            b = statistics.median(r[key] for r in two)
195            print(f"{key:>10}: {second} vs {first} {100 * (b - a) / a:+.1f}%")
196
197
198def statistics_of(rows, fold):
199    return {key: fold(row[key] for row in rows) for key in rows[0]}
200
201
202def spread(rows):
203    return "  ".join(
204        f"{key} {min(r[key] for r in rows):.4g}..{max(r[key] for r in rows):.4g}"
205        for key in ("reductions", "gc_words", "cpu_ms", "peak_mb")
206    )
207
208
209def format_row(row):
210    return (
211        f"wall {row['wall_s']:.2f}s  cpu {row['cpu_ms']:.0f}ms  "
212        f"reductions {row['reductions'] / 1e6:.1f}M  gc_words {row['gc_words'] / 1e6:.1f}M  "
213        f"peak {row['peak_mb']:.1f}MB"
214    )
215
216
217if __name__ == "__main__":
218    main()