Files
synapse/scripts-dev/persist_bench.py
T
Erik JohnstonandClaude Opus 5.5 8ae4e7d6b1 Add a Postgres proxy that counts round trips, and use it in the bench
`scripts-dev/pg_roundtrip_proxy.py` sits between Synapse and Postgres. It
counts round trips, a client message after a server message on the same
connection, can add latency to each, and records the protocol messages
and SQL of each round trip. SIGUSR2 resets it and SIGUSR1 writes the
counts and log to a file.

`createroom_bench.py --proxy-delay-ms N` runs the server through it and
reports the round trips per request beside the timings. With a delay,
the extra wall time over a run without one shows how many of those round
trips the request waits for in turn. A new `e2ee_invite` scenario is the
room the E2EE monitor creates on jki.re, and `--scenarios` picks a
subset. `persist_bench.Server` honours `PGPORT`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVLsNh6akt4ifEizY4D1ta
2026-10-18 19:33:45 +00:00

473 lines
16 KiB
Python
Executable File

#!/usr/bin/env python
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""Benchmark event persistence at each `rust_event_persistence` level.
For each level, start a real Synapse process on a fresh Postgres database
with the native Rust driver, run the same workloads through the client API,
and report per workload: wall time, the CPU of the reactor thread and of the
process, the change in resident memory, and the `persist_events`
transactions from the metrics endpoint.
poetry run python scripts-dev/persist_bench.py --levels off,full
Needs a Postgres server the `PG*` environment variables reach, with
permission to create databases.
"""
import argparse
import http.client
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable
LEVELS = ("off", "prepare", "transaction", "full")
CLIENT_PORT = 18008
METRICS_PORT = 19092
SHARED_SECRET = "bench"
@dataclass
class Sample:
"""What the process had used at a point in time."""
wall: float
reactor_cpu: float
process_cpu: float
rss_mb: float
metrics: dict[str, float]
@dataclass
class Result:
level: str
workload: str
events: int
wall: float
reactor_cpu: float
process_cpu: float
rss_delta_mb: float
persist_txns: float
persist_txn_secs: float
extra: dict[str, float] = field(default_factory=dict)
def _cpu_of(path: str) -> float:
"""utime + stime of a `/proc/.../stat` file, in seconds."""
with open(path) as f:
fields = f.read().rsplit(")", 1)[1].split()
ticks = os.sysconf("SC_CLK_TCK")
return (int(fields[11]) + int(fields[12])) / ticks
def _rss_mb(pid: int) -> float:
with open(f"/proc/{pid}/status") as f:
for line in f:
if line.startswith("VmRSS:"):
return int(line.split()[1]) / 1024
return 0.0
_METRIC = re.compile(r"^(?P<name>[a-z_]+)(?:\{(?P<labels>[^}]*)\})? (?P<value>\S+)$")
def _metrics() -> dict[str, float]:
"""The metrics this benchmark reads, keyed `name{desc}`."""
conn = http.client.HTTPConnection("127.0.0.1", METRICS_PORT, timeout=30)
conn.request("GET", "/_synapse/metrics")
body = conn.getresponse().read().decode()
wanted = {}
for line in body.splitlines():
match = _METRIC.match(line)
if not match:
continue
name = match["name"]
labels = match["labels"] or ""
if name.startswith("synapse_storage_transaction_time_"):
desc = re.search(r'desc="([^"]*)"', labels)
if desc:
wanted[f"{name}{{{desc[1]}}}"] = float(match["value"])
elif name in (
"synapse_storage_events_persisted_events_total",
"synapse_storage_events_rust_prepare_total",
):
wanted[f"{name}{{{labels}}}"] = float(match["value"])
return wanted
class Server:
"""A Synapse process on its own database."""
def __init__(
self,
level: str,
workdir: str,
rust_driver: bool = True,
experimental: dict[str, Any] | None = None,
) -> None:
self.level = level
self.workdir = workdir
self.rust_driver = rust_driver
# Experimental features to set besides `rust_event_persistence`.
self.experimental = experimental or {}
self.database = f"persist_bench_{level}_{secrets.token_hex(3)}"
self.process: subprocess.Popen | None = None
def start(self) -> None:
subprocess.run(["createdb", self.database], check=True)
config = {
"server_name": "bench",
"pid_file": os.path.join(self.workdir, "homeserver.pid"),
"report_stats": False,
"signing_key_path": os.path.join(self.workdir, "signing.key"),
"media_store_path": os.path.join(self.workdir, "media"),
"registration_shared_secret": SHARED_SECRET,
"enable_metrics": True,
"suppress_key_server_warning": True,
"trusted_key_servers": [],
"listeners": [
{
"port": CLIENT_PORT,
"bind_addresses": ["127.0.0.1"],
"type": "http",
"resources": [{"names": ["client"]}],
},
{
"port": METRICS_PORT,
"bind_addresses": ["127.0.0.1"],
"type": "metrics",
},
],
"database": {
"name": "psycopg2",
"use_rust_driver": self.rust_driver,
"args": {
"database": self.database,
"host": os.environ.get("PGHOST", "localhost"),
"port": int(os.environ.get("PGPORT", "5432")),
"user": os.environ.get("PGUSER", "postgres"),
"password": os.environ.get("PGPASSWORD", ""),
"cp_min": 5,
"cp_max": 10,
},
},
# Out of the way of the workloads.
"rc_message": {"per_second": 100000, "burst_count": 100000},
"rc_joins": {
"local": {"per_second": 100000, "burst_count": 100000},
"remote": {"per_second": 100000, "burst_count": 100000},
},
"rc_joins_per_room": {"per_second": 100000, "burst_count": 100000},
"rc_registration": {"per_second": 100000, "burst_count": 100000},
"rc_room_creation": {"per_second": 100000, "burst_count": 100000},
"rc_login": {
"address": {"per_second": 100000, "burst_count": 100000},
"account": {"per_second": 100000, "burst_count": 100000},
},
"rc_invites": {
"per_room": {"per_second": 100000, "burst_count": 100000},
"per_user": {"per_second": 100000, "burst_count": 100000},
},
"experimental_features": {
"rust_event_persistence": self.level,
**self.experimental,
},
"log_config": os.path.join(self.workdir, "log.yaml"),
}
with open(os.path.join(self.workdir, "log.yaml"), "w") as f:
f.write(
"version: 1\n"
"formatters: {f: {format: '%(asctime)s %(name)s %(levelname)s %(message)s'}}\n"
f"handlers: {{file: {{class: logging.FileHandler, formatter: f, filename: {self.workdir}/homeserver.log}}}}\n"
"root: {level: WARNING, handlers: [file]}\n"
"disable_existing_loggers: false\n"
)
config_path = os.path.join(self.workdir, "homeserver.yaml")
with open(config_path, "w") as f:
json.dump(config, f)
env = {k: v for k, v in os.environ.items() if not k.lower().endswith("_proxy")}
subprocess.run(
[
sys.executable,
"-m",
"synapse.app.homeserver",
"-c",
config_path,
"--generate-keys",
],
check=True,
env=env,
)
self.process = subprocess.Popen(
[sys.executable, "-m", "synapse.app.homeserver", "-c", config_path],
env=env,
stdout=open(os.path.join(self.workdir, "stdout.log"), "w"),
stderr=subprocess.STDOUT,
)
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
if self.process.poll() is not None:
raise RuntimeError(f"Synapse exited: see {self.workdir}")
try:
status, _ = request("GET", "/_matrix/client/versions")
if status == 200:
return
except OSError:
pass
time.sleep(0.5)
raise RuntimeError("Synapse did not start")
def stop(self) -> None:
if self.process is not None:
self.process.terminate()
self.process.wait(timeout=60)
subprocess.run(["dropdb", "--if-exists", self.database], check=False)
def sample(self) -> Sample:
assert self.process is not None
pid = self.process.pid
return Sample(
wall=time.monotonic(),
# The reactor runs on the main thread, whose TID is the PID.
reactor_cpu=_cpu_of(f"/proc/{pid}/task/{pid}/stat"),
process_cpu=_cpu_of(f"/proc/{pid}/stat"),
rss_mb=_rss_mb(pid),
metrics=_metrics(),
)
def request(
method: str, path: str, body: Any = None, token: str | None = None
) -> tuple[int, Any]:
conn = http.client.HTTPConnection("127.0.0.1", CLIENT_PORT, timeout=300)
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
conn.request(
method,
path,
body=json.dumps(body) if body is not None else None,
headers=headers,
)
response = conn.getresponse()
data = response.read()
return response.status, json.loads(data) if data else None
def ok(method: str, path: str, body: Any = None, token: str | None = None) -> Any:
status, data = request(method, path, body, token)
if status != 200:
raise RuntimeError(f"{method} {path}: {status} {data}")
return data
def register(localpart: str) -> str:
import hashlib
import hmac
nonce = ok("GET", "/_synapse/admin/v1/register")["nonce"]
mac = hmac.new(SHARED_SECRET.encode(), digestmod=hashlib.sha1)
mac.update(f"{nonce}\x00{localpart}\x00pass\x00notadmin".encode())
return ok(
"POST",
"/_synapse/admin/v1/register",
{
"nonce": nonce,
"username": localpart,
"password": "pass",
"mac": mac.hexdigest(),
},
)["access_token"]
def run_parallel(jobs: list[Callable[[], None]], concurrency: int) -> None:
with ThreadPoolExecutor(concurrency) as pool:
for future in [pool.submit(job) for job in jobs]:
future.result()
def send(room_id: str, token: str, body: str) -> str:
txn = secrets.token_hex(8)
return ok(
"PUT",
f"/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn}",
{"msgtype": "m.text", "body": body},
token,
)["event_id"]
def create_room(token: str) -> str:
return ok(
"POST", "/_matrix/client/v3/createRoom", {"preset": "public_chat"}, token
)["room_id"]
# Workloads: each sets up what it needs, then returns the jobs it times and
# the number of events they persist.
def messages(tokens: list[str], scale: int) -> tuple[list[Callable[[], None]], int]:
"""Each user sends `scale` messages into its own room, all at once."""
rooms = [(create_room(token), token) for token in tokens]
def sends(room_id: str, token: str) -> None:
for i in range(scale):
send(room_id, token, f"m{i}")
return [partial(sends, room_id, token) for room_id, token in rooms], len(
rooms
) * scale
def membership(tokens: list[str], scale: int) -> tuple[list[Callable[[], None]], int]:
"""Every user joins one room and leaves it, `scale // 10` times."""
room_id = create_room(tokens[0])
rounds = max(1, scale // 10)
def churn(token: str) -> None:
for _ in range(rounds):
ok("POST", f"/_matrix/client/v3/rooms/{room_id}/join", {}, token)
ok("POST", f"/_matrix/client/v3/rooms/{room_id}/leave", {}, token)
return [partial(churn, token) for token in tokens[1:]], (
len(tokens) - 1
) * rounds * 2
def big_state(tokens: list[str], scale: int) -> tuple[list[Callable[[], None]], int]:
"""A room with `scale * 20` state events, which every user then joins."""
room_id = create_room(tokens[0])
for i in range(scale * 20):
ok(
"PUT",
f"/_matrix/client/v3/rooms/{room_id}/state/org.example.bench/{i}",
{"i": i},
tokens[0],
)
def join(token: str) -> None:
ok("POST", f"/_matrix/client/v3/rooms/{room_id}/join", {}, token)
return [partial(join, token) for token in tokens[1:]], len(tokens) - 1
def redactions(tokens: list[str], scale: int) -> tuple[list[Callable[[], None]], int]:
"""Redact `scale * 4` messages of one room, all at once."""
room_id = create_room(tokens[0])
event_ids = [send(room_id, tokens[0], f"r{i}") for i in range(scale * 4)]
def redact(event_id: str) -> None:
ok(
"PUT",
f"/_matrix/client/v3/rooms/{room_id}/redact/{event_id}/{secrets.token_hex(8)}",
{},
tokens[0],
)
return [partial(redact, event_id) for event_id in event_ids], len(event_ids)
WORKLOADS = {
"messages": messages,
"membership": membership,
"big_state_joins": big_state,
"redactions": redactions,
}
def bench_level(level: str, users: int, scale: int, concurrency: int) -> list[Result]:
workdir = tempfile.mkdtemp(prefix=f"persist-bench-{level}-")
server = Server(level, workdir)
results = []
try:
server.start()
tokens = [register(f"u{i}") for i in range(users)]
for name, workload in WORKLOADS.items():
jobs, events = workload(tokens, scale)
before = server.sample()
run_parallel(jobs, concurrency)
after = server.sample()
def delta(key: str) -> float:
return after.metrics.get(key, 0.0) - before.metrics.get(key, 0.0)
results.append(
Result(
level=level,
workload=name,
events=events,
wall=after.wall - before.wall,
reactor_cpu=after.reactor_cpu - before.reactor_cpu,
process_cpu=after.process_cpu - before.process_cpu,
rss_delta_mb=after.rss_mb - before.rss_mb,
persist_txns=delta(
"synapse_storage_transaction_time_count_total{persist_events}"
),
persist_txn_secs=delta(
"synapse_storage_transaction_time_sum_total{persist_events}"
),
)
)
print(results[-1], flush=True)
finally:
server.stop()
shutil.rmtree(workdir, ignore_errors=True)
return results
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--levels", default=",".join(LEVELS))
parser.add_argument("--users", type=int, default=20)
parser.add_argument("--scale", type=int, default=50)
parser.add_argument("--concurrency", type=int, default=20)
parser.add_argument("--repeat", type=int, default=1)
parser.add_argument("--json", help="Write the results here, as JSON.")
args = parser.parse_args()
results: list[Result] = []
for _ in range(args.repeat):
for level in args.levels.split(","):
results.extend(bench_level(level, args.users, args.scale, args.concurrency))
print(
f"\n{'workload':<16} {'level':<12} {'events':>6} {'wall s':>8} "
f"{'reactor s':>9} {'proc s':>8} {'ms/event':>9} {'txn ms':>8}"
)
for r in results:
txn_ms = 1000 * r.persist_txn_secs / r.persist_txns if r.persist_txns else 0.0
print(
f"{r.workload:<16} {r.level:<12} {r.events:>6} {r.wall:>8.2f} "
f"{r.reactor_cpu:>9.2f} {r.process_cpu:>8.2f} "
f"{1000 * r.wall / max(r.events, 1):>9.2f} {txn_ms:>8.2f}"
)
if args.json:
with open(args.json, "w") as f:
json.dump([r.__dict__ for r in results], f, indent=2)
if __name__ == "__main__":
main()