|
"""Benchmark the per-notification cost of transcript event notification. |
|
|
|
Drives a single sample's growing conversation through the realistic |
|
four-notification-per-turn shape (pending, call registration, completion, |
|
timestamp stamping) with a real ``SampleBufferDatabase`` subscribed the way |
|
``_eval/task/run.py`` subscribes one, and reports primitive counters so the |
|
derived figures can be re-checked: |
|
|
|
wall_s median wall-clock seconds to drive the sample (with |
|
wall_min/wall_max over --repeat runs, so a reader can tell a |
|
real difference from noise) |
|
msg_hashes calls to ``_pool._msg_hash`` (message pooling work) |
|
call_hashes calls to ``_pool._call_hash`` (call pooling work) |
|
msgs_offered messages presented to the pool across all notifications |
|
bytes_shipped total string-parameter bytes handed to sqlite3 across every |
|
write path (events, attachments, message_pool, call_pool). |
|
``INSERT OR IGNORE`` discards duplicates only after the |
|
content has crossed, so this is bytes *shipped*, not stored. |
|
bytes_unique distinct >100-char values among those, i.e. the content that |
|
had to cross at least once |
|
|
|
Counters are deterministic for a given shape; only wall time varies. |
|
|
|
Runs unmodified against this branch and against origin/main; select the |
|
source tree with PYTHONPATH so both runs share one interpreter and one set |
|
of dependency versions: |
|
|
|
PYTHONPATH=<tree>/src python scripts/bench_transcript_notify.py --repeat 3 |
|
|
|
Usage: |
|
python scripts/bench_transcript_notify.py [--json] [--repeat N] |
|
[--turns N --msg-bytes N] |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import json |
|
import sqlite3 |
|
import sys |
|
import tempfile |
|
import time |
|
from pathlib import Path |
|
from typing import Any |
|
|
|
import inspect_ai.event._pool as pool |
|
from inspect_ai.event._model import ModelEvent |
|
from inspect_ai.log._log import EvalSampleSummary |
|
from inspect_ai.log._recorders.buffer import SampleBufferDatabase |
|
from inspect_ai.log._recorders.types import SampleEvent |
|
from inspect_ai.log._transcript import Transcript |
|
from inspect_ai.model._chat_message import ChatMessageUser |
|
from inspect_ai.model._generate_config import GenerateConfig |
|
from inspect_ai.model._model_call import ModelCall |
|
from inspect_ai.model._model_output import ModelOutput |
|
|
|
|
|
class Counters: |
|
def __init__(self) -> None: |
|
self.msg_hashes = 0 |
|
self.call_hashes = 0 |
|
self.msgs_offered = 0 |
|
self.bytes_shipped = 0 |
|
self.unique: set[str] = set() |
|
|
|
@property |
|
def bytes_unique(self) -> int: |
|
return sum(len(h) for h in self.unique) |
|
|
|
|
|
def _instrument(counters: Counters) -> list[tuple[Any, str, Any]]: |
|
"""Wrap the hash and attachment-insert paths; returns undo records.""" |
|
undo: list[tuple[Any, str, Any]] = [] |
|
|
|
real_msg_hash = pool._msg_hash |
|
real_call_hash = pool._call_hash |
|
|
|
def msg_hash(m: Any) -> Any: |
|
counters.msg_hashes += 1 |
|
return real_msg_hash(m) |
|
|
|
def call_hash(m: Any) -> Any: |
|
counters.call_hashes += 1 |
|
return real_call_hash(m) |
|
|
|
pool._msg_hash = msg_hash # type: ignore[assignment] |
|
pool._call_hash = call_hash # type: ignore[assignment] |
|
undo.append((pool, "_msg_hash", real_msg_hash)) |
|
undo.append((pool, "_call_hash", real_call_hash)) |
|
|
|
# Count at the SQLite boundary so every write path is covered (attachments, |
|
# message_pool, call_pool, events) rather than whichever one a given |
|
# workload happens to use. INSERT OR IGNORE discards duplicates only after |
|
# the content has crossed, so parameters handed to sqlite3 are the bytes |
|
# actually shipped. |
|
real_connect = sqlite3.connect |
|
|
|
class CountingConnection(sqlite3.Connection): |
|
def execute(self, sql: str, parameters: Any = (), /) -> Any: # type: ignore[override] |
|
_tally(counters, parameters) |
|
return super().execute(sql, parameters) |
|
|
|
def executemany(self, sql: str, parameters: Any, /) -> Any: # type: ignore[override] |
|
rows = list(parameters) |
|
for row in rows: |
|
_tally(counters, row) |
|
return super().executemany(sql, rows) |
|
|
|
def connect(*args: Any, **kwargs: Any) -> Any: |
|
kwargs.setdefault("factory", CountingConnection) |
|
return real_connect(*args, **kwargs) |
|
|
|
sqlite3.connect = connect # type: ignore[assignment] |
|
undo.append((sqlite3, "connect", real_connect)) |
|
|
|
return undo |
|
|
|
|
|
def _tally(counters: Counters, parameters: Any) -> None: |
|
if isinstance(parameters, dict): |
|
values: Any = parameters.values() |
|
elif isinstance(parameters, (list, tuple)): |
|
values = parameters |
|
else: |
|
return |
|
for value in values: |
|
if isinstance(value, str): |
|
counters.bytes_shipped += len(value) |
|
if len(value) > 100: # pooled/attachment-scale content only |
|
counters.unique.add(value) |
|
|
|
|
|
def _restore(undo: list[tuple[Any, str, Any]]) -> None: |
|
for target, name, original in undo: |
|
setattr(target, name, original) |
|
|
|
|
|
def run_shape(turns: int, msg_bytes: int) -> dict[str, float | int]: |
|
"""Drive one sample of `turns` turns whose messages are ~`msg_bytes` each.""" |
|
counters = Counters() |
|
# Distinct per turn so nothing dedups by accident, and large enough to be |
|
# pooled as attachment content rather than left inline. |
|
filler = "x" * max(msg_bytes - 32, 1) |
|
undo = _instrument(counters) |
|
try: |
|
with tempfile.TemporaryDirectory() as d: |
|
db = SampleBufferDatabase(location="bench", db_dir=Path(d)) |
|
db.start_sample(EvalSampleSummary(id="s1", epoch=1, input="in", target="t")) |
|
transcript = Transcript(bounded=True, resident_tail=100, log_model_api=True) |
|
transcript._subscribe( |
|
lambda e: db.log_events([SampleEvent(id="s1", epoch=1, event=e)]) |
|
) |
|
|
|
history: list[ChatMessageUser] = [] |
|
wire: list[dict[str, Any]] = [] |
|
start = time.perf_counter() |
|
for turn in range(turns): |
|
body = f"turn {turn} {filler}" |
|
history.append(ChatMessageUser(content=body)) |
|
wire.append({"role": "user", "content": body}) |
|
counters.msgs_offered += len(history) |
|
|
|
event = ModelEvent( |
|
model="bench", |
|
input=list(history), |
|
tools=[], |
|
tool_choice="auto", |
|
config=GenerateConfig(), |
|
output=ModelOutput(), |
|
pending=True, |
|
) |
|
transcript._event(event) # 1: pending |
|
event.call = ModelCall( |
|
request={"model": "bench", "messages": [dict(m) for m in wire]} |
|
) |
|
transcript._event_updated(event) # 2: call registration |
|
event.output = ModelOutput.from_content("bench", "response") |
|
event.call = ModelCall( |
|
request={"model": "bench", "messages": [dict(m) for m in wire]}, |
|
response={"id": f"r{turn}"}, |
|
) |
|
event.pending = None |
|
transcript._event_updated(event) # 3: completion |
|
transcript._event_updated(event) # 4: timestamp stamping |
|
wall = time.perf_counter() - start |
|
db.cleanup() |
|
finally: |
|
_restore(undo) |
|
|
|
offered = counters.msgs_offered or 1 |
|
return { |
|
"turns": turns, |
|
"msg_bytes": msg_bytes, |
|
"wall_s": round(wall, 3), |
|
"msg_hashes": counters.msg_hashes, |
|
"call_hashes": counters.call_hashes, |
|
"msgs_offered": counters.msgs_offered, |
|
"hit_rate": round(1.0 - counters.msg_hashes / offered, 4), |
|
"bytes_shipped": counters.bytes_shipped, |
|
"bytes_unique": counters.bytes_unique, |
|
} |
|
|
|
|
|
SHAPES = [(400, 1536), (200, 1536), (200, 32768)] |
|
|
|
|
|
def run_repeated(turns: int, msg_bytes: int, repeat: int) -> dict[str, float | int]: |
|
"""Run one shape `repeat` times; report median wall and its spread. |
|
|
|
Counters are deterministic for a given shape, so only the timing needs |
|
repeating; the median is reported with min/max so a reader can see how |
|
much of a difference is noise. |
|
""" |
|
runs = [run_shape(turns, msg_bytes) for _ in range(repeat)] |
|
walls = sorted(float(r["wall_s"]) for r in runs) |
|
result = dict(runs[-1]) |
|
result["wall_s"] = walls[len(walls) // 2] |
|
result["wall_min"] = walls[0] |
|
result["wall_max"] = walls[-1] |
|
result["repeat"] = repeat |
|
return result |
|
|
|
|
|
def main() -> int: |
|
parser = argparse.ArgumentParser(description=__doc__) |
|
parser.add_argument("--turns", type=int) |
|
parser.add_argument("--msg-bytes", type=int) |
|
parser.add_argument("--repeat", type=int, default=3) |
|
parser.add_argument("--json", action="store_true") |
|
args = parser.parse_args() |
|
|
|
shapes = [(args.turns, args.msg_bytes)] if args.turns and args.msg_bytes else SHAPES |
|
rows = [run_repeated(t, b, args.repeat) for t, b in shapes] |
|
|
|
if args.json: |
|
print(json.dumps({"source": str(Path(pool.__file__).resolve()), "rows": rows})) |
|
else: |
|
print(f"source: {Path(pool.__file__).resolve()}") |
|
for r in rows: |
|
print( |
|
f"turns={r['turns']:4d} msg={r['msg_bytes']:6d}B " |
|
f"wall={r['wall_s']:7.3f}s" |
|
f"[{r.get('wall_min', 0):.3f}-{r.get('wall_max', 0):.3f}] " |
|
f"msg_hashes={r['msg_hashes']:7d} " |
|
f"call_hashes={r['call_hashes']:8d} offered={r['msgs_offered']:8d} " |
|
f"shipped={r['bytes_shipped'] / 1e6:9.1f}MB " |
|
f"unique={r['bytes_unique'] / 1e6:7.1f}MB" |
|
) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main()) |