mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 09:07:22 +00:00
`persist_bench.py --opentracing` runs each level with tracing off, `python` (on, nothing sampled, tracing in Rust patched off), `unsampled` (on, nothing sampled) or `sampled` (everything sampled), with spans going to a UDP port nothing reads. `python` against `unsampled` is what tracing in Rust costs when a request is not traced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4zxygfJC93SsugxskhoJG
532 lines
18 KiB
Python
Executable File
532 lines
18 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
|
|
|
|
`--opentracing unsampled` enables opentracing with a sampler that samples
|
|
nothing, to measure what tracing costs when it is on but a request is not
|
|
traced; `sampled` samples everything. `python` is `unsampled` with tracing
|
|
in Rust patched off, so that `python` against `unsampled` is what Rust's
|
|
tracing costs. Spans go to a UDP port nothing reads.
|
|
|
|
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
|
|
# Where spans go when `--opentracing` is on. Nothing listens.
|
|
OPENTRACING_PORT = 16831
|
|
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,
|
|
opentracing: str = "off",
|
|
) -> None:
|
|
self.level = level
|
|
self.opentracing = opentracing
|
|
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"),
|
|
}
|
|
if self.opentracing != "off":
|
|
config["opentracing"] = {
|
|
"enabled": True,
|
|
"jaeger_config": {
|
|
"sampler": {
|
|
"type": "const",
|
|
"param": 1 if self.opentracing == "sampled" else 0,
|
|
},
|
|
"local_agent": {
|
|
"reporting_host": "127.0.0.1",
|
|
"reporting_port": OPENTRACING_PORT,
|
|
},
|
|
},
|
|
}
|
|
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,
|
|
)
|
|
if self.opentracing == "python":
|
|
# Tracing in Python only: `init_tracer` looks the function up
|
|
# when it runs.
|
|
command = [
|
|
sys.executable,
|
|
"-c",
|
|
"import runpy, synapse.logging.opentracing as ot; "
|
|
"ot._enable_rust_tracing = lambda *args: None; "
|
|
"runpy.run_module('synapse.app.homeserver', run_name='__main__')",
|
|
"-c",
|
|
config_path,
|
|
]
|
|
else:
|
|
command = [
|
|
sys.executable,
|
|
"-m",
|
|
"synapse.app.homeserver",
|
|
"-c",
|
|
config_path,
|
|
]
|
|
self.process = subprocess.Popen(
|
|
command,
|
|
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, opentracing: str = "off"
|
|
) -> list[Result]:
|
|
workdir = tempfile.mkdtemp(prefix=f"persist-bench-{level}-")
|
|
server = Server(level, workdir, opentracing=opentracing)
|
|
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.")
|
|
parser.add_argument(
|
|
"--opentracing",
|
|
default="off",
|
|
help="Comma-separated: off, python (unsampled, Rust tracing patched off), "
|
|
"unsampled (enabled, nothing sampled) or sampled.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
results: list[Result] = []
|
|
for _ in range(args.repeat):
|
|
for opentracing in args.opentracing.split(","):
|
|
for level in args.levels.split(","):
|
|
level_results = bench_level(
|
|
level, args.users, args.scale, args.concurrency, opentracing
|
|
)
|
|
for result in level_results:
|
|
if opentracing != "off":
|
|
result.level = f"{level}/{opentracing}"
|
|
results.extend(level_results)
|
|
|
|
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()
|