From 32242a678856a4e58b57f2c4b372efde36678312 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:11:38 +0200 Subject: [PATCH] perf: 40% LESS CPU usage: cut per-instance CPU cost of shared socket.io Redis pub/sub channel (#27282) * perf: cut per-instance CPU cost of shared socket.io Redis pub/sub channel Profiling a multi-instance deployment (py-spy --gil) showed ~44% of worker CPU in the socket.io pub/sub listener. Two causes, two fixes: - Add hiredis so redis-py parses the RESP protocol in C instead of pure Python (redis/_parsers/resp3.py alone accounted for ~28% of GIL samples; redis-py auto-selects the hiredis parser when importable). - Subclass AsyncRedisManager to drop emits whose target room has no local participants before upstream _handle_emit re-encodes the full packet. Every instance receives every emit published on the shared channel, so with N instances all but the hosting one were paying full packet re-serialization per message just to deliver it to nobody. Broadcasts (room=None) are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q9CQ9qnp3sZGYQQwztsCJT * fix: restrict pub/sub emit early-out to string rooms Adversarial review against python-socketio 5.16.2 found one divergence from upstream: for a degenerate empty-sequence room (emit to room=[]) on an instance whose namespace has no local clients, the filter's get_participants probe raises IndexError from room[0] where upstream returns silently at the namespace guard and still publishes to Redis. Open WebUI only ever emits to scalar string rooms or room=None, so the case is unreachable today; guard on isinstance(room, str) anyway so any non-string room shape passes through to upstream behavior unchanged. Every open-webui emit uses a string room, so the fast path still covers all real traffic. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q9CQ9qnp3sZGYQQwztsCJT * Update requirements.txt * Update pyproject.toml * Update requirements-min.txt --------- Co-authored-by: Claude --- backend/open_webui/socket/main.py | 25 ++++++++++++++++++++++++- backend/requirements-min.txt | 1 + backend/requirements.txt | 3 ++- pyproject.toml | 3 ++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index cb01d62e00..25792b0e78 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -57,6 +57,29 @@ REDIS = None # Configure CORS for Socket.IO SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN +class LocalFilteredRedisManager(socketio.AsyncRedisManager): + """AsyncRedisManager that drops pub/sub emits with no local recipients. + + Every instance subscribed to the shared Socket.IO channel receives every + emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes + the full packet before discovering the target room has no participants on + this instance, so each instance burns CPU serializing payloads addressed + to sessions it does not host — a cost that grows with instance count. + Bail out before that work when the room is empty here. Only string + rooms take the fast path; broadcasts (``room=None``) and any other + room shape (e.g. lists, which upstream indexes before validating) + always pass through unchanged. + """ + + async def _handle_emit(self, message): + room = message.get('room') + if isinstance(room, str): + namespace = message.get('namespace') or '/' + if next(self.get_participants(namespace, room), None) is None: + return + await super()._handle_emit(message) + + if WEBSOCKET_MANAGER == 'redis': sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or '' ws_redis_url = ( @@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis': if sentinel_hosts else WEBSOCKET_REDIS_URL ) - redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS) + redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS) sio = socketio.AsyncServer( cors_allowed_origins=SOCKETIO_CORS_ORIGINS, async_mode='asgi', diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index d5646443e6..7ccc3f7261 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -33,6 +33,7 @@ alembic==1.18.4 pycrdt==0.13.1 redis +hiredis APScheduler==3.11.2 RestrictedPython==8.2 diff --git a/backend/requirements.txt b/backend/requirements.txt index e33bf131ea..4a3169762f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -30,7 +30,8 @@ psycopg[binary]==3.3.4 alembic==1.18.4 pycrdt==0.13.1 -redis==8.0.0 +redis==8.0.1 +hiredis==3.4.0 APScheduler==3.11.2 RestrictedPython==8.2 diff --git a/pyproject.toml b/pyproject.toml index 74659791df..1944904e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,8 @@ dependencies = [ "alembic==1.18.4", "pycrdt==0.13.1", - "redis==8.0.0", + "redis==8.0.1", + "hiredis==3.4.0", # "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey "pytz==2026.2",