diff --git a/changelog.d/createroom-perf.misc b/changelog.d/createroom-perf.misc new file mode 100644 index 0000000000..d4e9a0a49d --- /dev/null +++ b/changelog.d/createroom-perf.misc @@ -0,0 +1 @@ +Speed up room creation. diff --git a/scripts-dev/createroom_bench.py b/scripts-dev/createroom_bench.py new file mode 100755 index 0000000000..5b40429f74 --- /dev/null +++ b/scripts-dev/createroom_bench.py @@ -0,0 +1,139 @@ +#!/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: +# . +# + +"""Time `/createRoom` against a real Synapse on Postgres. + +Starts the server the way `persist_bench.py` does, once per +`rust_event_persistence` level. For each scenario it creates five warm-up +rooms, then `--rooms` rooms one after another, and reports the mean and median +request time and the CPU of the reactor thread and of the process per room. + + poetry run python scripts-dev/createroom_bench.py --levels off,full + +`--psycopg2` runs the server on psycopg2 instead of the native driver. +""" + +import argparse +import json +import os +import statistics +import sys +import tempfile +import time +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import persist_bench as pb # noqa: E402 + +SCENARIOS: dict[str, dict[str, Any]] = { + "plain": {}, + "private_invite": {"preset": "private_chat", "invite": ["@invitee:bench"]}, + "public_named": {"preset": "public_chat", "name": "Bench room", "topic": "A topic"}, + "initial_state": { + "initial_state": [ + { + "type": "m.room.encryption", + "state_key": "", + "content": {"algorithm": "m.megolm.v1.aes-sha2"}, + }, + { + "type": "m.room.guest_access", + "state_key": "", + "content": {"guest_access": "can_join"}, + }, + { + "type": "m.space.child", + "state_key": "!a:bench", + "content": {"via": ["bench"]}, + }, + { + "type": "m.space.child", + "state_key": "!b:bench", + "content": {"via": ["bench"]}, + }, + ] + }, +} + +CREATE_ROOM = "/_matrix/client/v3/createRoom" + + +def bench_level(level: str, rooms: int, rust_driver: bool) -> list[dict[str, Any]]: + results = [] + workdir = tempfile.mkdtemp(prefix=f"createroom-bench-{level}-") + server = pb.Server(level, workdir, rust_driver=rust_driver) + try: + server.start() + token = pb.register("creator") + pb.register("invitee") + for name, body in SCENARIOS.items(): + for _ in range(5): + pb.ok("POST", CREATE_ROOM, body, token) + before = server.sample() + times = [] + for _ in range(rooms): + t0 = time.perf_counter() + pb.ok("POST", CREATE_ROOM, body, token) + times.append(time.perf_counter() - t0) + after = server.sample() + row = { + "level": level, + "scenario": name, + "mean_ms": 1000 * statistics.mean(times), + "p50_ms": 1000 * statistics.median(times), + "reactor_cpu_ms": 1000 + * (after.reactor_cpu - before.reactor_cpu) + / rooms, + "process_cpu_ms": 1000 + * (after.process_cpu - before.process_cpu) + / rooms, + } + results.append(row) + print(row, flush=True) + finally: + server.stop() + return results + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--levels", default="off,full") + parser.add_argument("--rooms", type=int, default=40) + parser.add_argument("--psycopg2", action="store_true") + parser.add_argument("--json", help="also write the results to this file") + args = parser.parse_args() + + results = [] + for level in args.levels.split(","): + results += bench_level(level, args.rooms, rust_driver=not args.psycopg2) + + print( + f"\n{'scenario':<16} {'level':<12} {'mean ms':>8} {'p50 ms':>8}" + f" {'reactor ms':>11} {'process ms':>11}" + ) + for r in results: + print( + f"{r['scenario']:<16} {r['level']:<12} {r['mean_ms']:>8.1f}" + f" {r['p50_ms']:>8.1f} {r['reactor_cpu_ms']:>11.1f}" + f" {r['process_cpu_ms']:>11.1f}" + ) + if args.json: + with open(args.json, "w") as f: + json.dump(results, f, indent=2) + + +if __name__ == "__main__": + main() diff --git a/scripts-dev/persist_bench.py b/scripts-dev/persist_bench.py index 1f86049225..c6a1eddc09 100755 --- a/scripts-dev/persist_bench.py +++ b/scripts-dev/persist_bench.py @@ -120,9 +120,10 @@ def _metrics() -> dict[str, float]: class Server: """A Synapse process on its own database.""" - def __init__(self, level: str, workdir: str) -> None: + def __init__(self, level: str, workdir: str, rust_driver: bool = True) -> None: self.level = level self.workdir = workdir + self.rust_driver = rust_driver self.database = f"persist_bench_{level}_{secrets.token_hex(3)}" self.process: subprocess.Popen | None = None @@ -153,7 +154,7 @@ class Server: ], "database": { "name": "psycopg2", - "use_rust_driver": True, + "use_rust_driver": self.rust_driver, "args": { "database": self.database, "host": os.environ.get("PGHOST", "localhost"), @@ -171,6 +172,7 @@ class Server: }, "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}, diff --git a/tests/rest/client/bench_createroom.py b/tests/rest/client/bench_createroom.py new file mode 100644 index 0000000000..4317adeb6b --- /dev/null +++ b/tests/rest/client/bench_createroom.py @@ -0,0 +1,186 @@ +# +# 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: +# . +# + +"""`/createRoom` timed per room in the trial harness. + +Not a test: trial only collects `test_*.py`, and the case skips unless +`SYNAPSE_BENCH_CREATEROOM` is set: + + SYNAPSE_BENCH_CREATEROOM=1 run-trial.sh tests.rest.client.bench_createroom + +Each scenario creates three warm-up rooms, then `SYNAPSE_BENCH_ROOMS` rooms +(default 30), and reports wall and CPU time per room. Other settings: + +- `SYNAPSE_BENCH_SCENARIOS`: a comma-separated subset of the scenarios. +- `SYNAPSE_BENCH_PROFILE`: a directory to write one cProfile dump per scenario. +- `SYNAPSE_BENCH_COUNT`: count database interactions and SQL statements per + room, and print the most frequent of each. +""" + +import cProfile +import os +import time +from collections import Counter +from typing import Any +from unittest import SkipTest + +from twisted.internet.testing import MemoryReactor + +from synapse.rest import admin +from synapse.rest.client import login, room +from synapse.server import HomeServer +from synapse.storage import database +from synapse.util.clock import Clock + +from tests.unittest import HomeserverTestCase + +SCENARIOS: dict[str, dict[str, Any]] = { + "plain": {}, + "private_invite": {"preset": "private_chat", "invite": ["@other:test"]}, + "public_named": { + "preset": "public_chat", + "name": "Bench room", + "topic": "A topic", + # Filled in per room, as aliases must be unique. + "room_alias_name": None, + }, + "initial_state": { + "initial_state": [ + { + "type": "m.room.encryption", + "state_key": "", + "content": {"algorithm": "m.megolm.v1.aes-sha2"}, + }, + { + "type": "m.room.guest_access", + "state_key": "", + "content": {"guest_access": "can_join"}, + }, + { + "type": "m.space.child", + "state_key": "!a:test", + "content": {"via": ["test"]}, + }, + { + "type": "m.space.child", + "state_key": "!b:test", + "content": {"via": ["test"]}, + }, + ] + }, +} + + +class CreateRoomBench(HomeserverTestCase): + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + ] + + def setUp(self) -> None: + if not os.environ.get("SYNAPSE_BENCH_CREATEROOM"): + raise SkipTest("set SYNAPSE_BENCH_CREATEROOM to run the benchmark") + super().setUp() + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.user = self.register_user("creator", "pass") + self.tok = self.login("creator", "pass") + self.register_user("other", "pass") + self.counting = False + self.txn_counts: Counter[str] = Counter() + self.sql_counts: Counter[str] = Counter() + + def _install_counters(self) -> None: + """Count database interactions by description and SQL statements by text.""" + bench = self + orig_run = database.DatabasePool.runInteraction + orig_exec = database.LoggingTransaction._do_execute + + def run(self: Any, desc: str, *args: Any, **kwargs: Any) -> Any: + if bench.counting: + bench.txn_counts[desc] += 1 + return orig_run(self, desc, *args, **kwargs) + + def do_execute( + self: Any, func: Any, sql: str, *args: Any, **kwargs: Any + ) -> Any: + if bench.counting: + bench.sql_counts[" ".join(sql.split())[:90]] += 1 + return orig_exec(self, func, sql, *args, **kwargs) + + database.DatabasePool.runInteraction = run # type: ignore[method-assign] + database.LoggingTransaction._do_execute = do_execute # type: ignore[method-assign] + self.addCleanup(setattr, database.DatabasePool, "runInteraction", orig_run) + self.addCleanup(setattr, database.LoggingTransaction, "_do_execute", orig_exec) + + def _create(self, body: dict[str, Any], i: int) -> None: + body = dict(body) + if "room_alias_name" in body: + body["room_alias_name"] = f"bench-{i}" + channel = self.make_request("POST", "/createRoom", body, access_token=self.tok) + self.assertEqual(channel.code, 200, channel.result) + + def _print_counts(self, name: str, n: int) -> None: + total = sum(self.txn_counts.values()) / n + print(f"\n== {name}: DB interactions per room (total {total:.1f}) ==") + for desc, c in self.txn_counts.most_common(60): + print(f"{c / n:7.1f} {desc}") + total = sum(self.sql_counts.values()) / n + print(f"== {name}: SQL statements per room (total {total:.1f}) ==") + for sql, c in self.sql_counts.most_common(60): + print(f"{c / n:7.1f} {sql}") + self.txn_counts.clear() + self.sql_counts.clear() + + def test_bench(self) -> None: + n = int(os.environ.get("SYNAPSE_BENCH_ROOMS", "30")) + profile_dir = os.environ.get("SYNAPSE_BENCH_PROFILE") + only = os.environ.get("SYNAPSE_BENCH_SCENARIOS") + count = bool(os.environ.get("SYNAPSE_BENCH_COUNT")) + if count: + self._install_counters() + + rows = [] + for name, body in SCENARIOS.items(): + if only and name not in only.split(","): + continue + for i in range(3): + self._create(body, -i - 1) + + prof = cProfile.Profile() if profile_dir else None + t0 = time.perf_counter() + c0 = time.process_time() + if prof: + prof.enable() + self.counting = True + for i in range(n): + self._create(body, i) + self.counting = False + if prof: + prof.disable() + wall = time.perf_counter() - t0 + cpu = time.process_time() - c0 + + if count: + self._print_counts(name, n) + rows.append((name, wall / n * 1000, cpu / n * 1000)) + if prof and profile_dir: + os.makedirs(profile_dir, exist_ok=True) + prof.dump_stats(os.path.join(profile_dir, f"{name}.prof")) + + print() + print(f"{'scenario':16} {'wall ms/room':>12} {'cpu ms/room':>12}") + for name, wall, cpu in rows: + print(f"{name:16} {wall:12.1f} {cpu:12.1f}") diff --git a/tests/server.py b/tests/server.py index 47e1b45705..724f58e1e8 100644 --- a/tests/server.py +++ b/tests/server.py @@ -1780,16 +1780,19 @@ def _apply_background_updates_to_template_db() -> None: # Neither database may have connections while it is used as a template or # dropped, so close the pool first and connect to the maintenance database. + # Admin work over psycopg2 regardless of the driver under test, as + # `setup_test_homeserver` does. db_pool._db_pool.close() - db_engine = db_pool.engine - db_conn = db_engine.module.connect( + import psycopg2 + + db_conn = psycopg2.connect( dbname=POSTGRES_DBNAME_FOR_INITIAL_CREATE, user=POSTGRES_USER, host=POSTGRES_HOST, port=POSTGRES_PORT, password=POSTGRES_PASSWORD, ) - db_engine.attempt_to_set_autocommit(db_conn, True) + db_conn.autocommit = True cur = db_conn.cursor() cur.execute("DROP DATABASE %s;" % (POSTGRES_BASE_DB,)) cur.execute(