perf(live): cut the per-poll socket, refetch and transcription CPU storms - #103
Open
tanvishdesai wants to merge 2 commits into
Open
perf(live): cut the per-poll socket, refetch and transcription CPU storms#103tanvishdesai wants to merge 2 commits into
tanvishdesai wants to merge 2 commits into
Conversation
The transcription service ran with cpu_threads unset and beam_size=5. On a 2-vCPU droplet CTranslate2's default spins up more OpenMP threads than there are cores, and it does so while the Node API, MongoDB and Redis are competing for the same two — including during an answer burst, since recording resumes as soon as the teacher launches a poll. Wall time was never the constraint. A 10s window only has to be transcribed in well under 10s; what matters is CPU-seconds taken away from the event loop. Measured on a 10s slice of lecture speech (base/int8), process pinned to two logical cores to match the droplet, mean of 5 runs: threads beam wall CPU-s default 5 2.710s 5.241 <- previous defaults 2 5 1.887s 3.744 1 1 1.951s 1.938 <- new defaults 2.70x less CPU and 28% less wall time. The default is worse than an explicit threads=2 on both axes for identical work, which is oversubscription overhead that only shows up on a small box: an earlier 16-core run made the pin look like a 28% latency cost rather than a 28% gain. Transcript output is byte-identical across all eight thread/beam combinations tested, so this is not an accuracy trade. beam_size=1 is the smaller half of the win (~1.2x); the thread pin is the rest. Both are env-overridable so a bigger box or a dedicated transcription host can raise them without a code change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three hot paths in a live session did work proportional to the whole room for
results only one client reads, or re-fetched data the client already had. All
three were measured against a real server with real socket clients, before and
after.
1. Joining a room was O(N^2). Every join was announced to the entire room, so
the k-th of N students triggered k deliveries. Measured with 200 real
socket.io clients: 34,619 room:joined frames, each student receiving 200.
The teacher is the only consumer of the participant count, and the joiner
needs its own ack. Both now go where they are read, via a per-room staff
channel: 400 frames, one per student. Also fixes two latent bugs in the
room-wide version -- the leaver never received their own room:left (they had
already left the channel), while every other student did and cleared their
socket-store room state on a stranger's departure.
2. counts:updated was broadcast to every student and read by exactly one badge
on the teacher's page. It also re-grouped every response in the room on each
1.5s tick, re-deriving counts for polls that closed long ago -- a scan that
grows for the whole session. It now counts only the poll whose tally moved,
as an indexed count rather than an aggregation, and goes to the staff
channel: 50 student frames -> 0, same values on the teacher side. The
payload is partial so the client merges instead of replacing; the throttle
key is per (room, question) so two polls receiving answers during the grace
window cannot suppress each other.
Scoping by the answered question rather than the room's current one is what
makes grace-window stragglers land on the right poll.
3. Every student re-fetched their whole question list when their own answering
timer expired. Timers are started by a broadcast, so the whole room fired at
once, unspread. For a student who already answered the request returns
exactly what they have: the poll is still the room's live one at expiry, so
the answer stays withheld until the next launch supersedes it. Answerers now
skip it; students who missed the poll still fetch, jittered, since that is
their only path to seeing the question.
At 700 students, one poll transition:
requests payload connect refusals p95
before 700 2089 KB 1163 2513ms
after 105 310 KB 0 12ms
The refusals are the important number: 700 simultaneous connections overflow
Node's default TCP accept backlog, so requests are rejected at the socket
layer before reaching the app and have to be retried. Both halves matter --
skipping alone is 7.4x, the jitter is a further 25x.
Also removed a second full room aggregation in broadcastLeaderboard that
shipped a `counts` field no client reads, and cut the student read endpoint to
a lean projection plus the cached membership check the POST path already uses.
Behaviour verified end to end against a running server: teacher still receives
correct counts, students receive none, and replaying the partial broadcasts
through the client's merge reproduces the old full-room aggregation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three hot paths in a live session were doing work proportional to the whole room to produce a result one client reads, and the transcription service was oversubscribing a 2-core box. Everything here is a do less work change — no new dependencies, no schema changes, no rewrite.
Every number below was measured against a real server with real clients, before and after, not estimated.
1. Joining a room was O(N²)
Every join was announced to the entire room, so the k-th of N students triggered k deliveries. With 200 real socket.io clients:
The teacher is the only consumer of the participant count; the joiner needs its own ack. Both now go where they're read, via a per-room
:staffchannel.This also fixes two latent bugs: the leaver never received their own
room:left(they'd already left the channel), while every other student did — and cleared their socket-store room state on a stranger's departure.2.
counts:updatedwas broadcast to 700 and read by 1It also re-grouped every response in the room on each 1.5s tick, re-deriving counts for polls closed long ago — a scan that grows for the whole session.
Now it counts only the poll whose tally moved, as an indexed count rather than an aggregation, and goes to the staff channel. Measured: 50 student frames → 0, identical values on the teacher side.
Payload is partial, so the client merges rather than replaces. Throttle key is per
(room, question)so two polls receiving answers during the grace window can't suppress each other. Scoping by the answered question rather than the room's current one is what makes grace-window stragglers land on the right poll.3. The poll-transition refetch storm
Every student re-fetched their whole question list when their answering timer expired. Timers are started by a broadcast, so the whole room fired at once, unspread.
For a student who already answered, that request returns exactly what they have — the poll is still the room's live one at expiry, so the answer stays withheld until the next launch supersedes it. Answerers now skip it. Students who missed the poll still fetch, jittered, since that's their only path to seeing the question.
700 students, one poll transition:
The refusals are the important number. 700 simultaneous connections overflow Node's default TCP accept backlog, so requests are rejected at the socket layer before reaching the app and have to be retried. That's a concrete mechanism for the "lags or doesn't work properly around 700 students" symptom, and it's invisible to any application-level metric.
Both halves matter: skipping alone is 7.4×, the jitter is a further 25×.
4. Transcription CPU
cpu_threadswas unset andbeam_size=5. On 2 vCPUs, CTranslate2's default spins up more OpenMP threads than there are cores — while Node, MongoDB and Redis compete for the same two, including during an answer burst (recording resumes as soon as a poll launches).Wall time was never the constraint; a 10s window only has to finish in well under 10s. CPU-seconds taken from the event loop is what matters. Pinned to two logical cores to match the droplet, 10s of lecture speech, mean of 5 runs:
2.70× less CPU and 28% less wall time. The default is worse than an explicit
threads=2on both axes for identical work — oversubscription overhead that only appears on a small box. An earlier 16-core run made the pin look like a 28% latency cost rather than a gain, which is why this was re-measured under droplet conditions.Transcript output is byte-identical across all eight thread/beam combinations tested, so this is not an accuracy trade.
beam_size=1is the smaller half (~1.2×); the thread pin is the rest. Both are env-overridable (TRANSCRIPTION_CPU_THREADS,TRANSCRIPTION_BEAM_SIZE).Tests
Two new suites, 11 tests, alongside the existing ones (all green):
liveCounts.test.js— replaying the partial broadcasts through the client's merge always reproduces the old full-room aggregation, across multiple polls, cross-room ids, and the grace-window straggler case. Plus anexplain()assertion that the scoped filter staysIXSCAN.liveResponsesReveal.test.js— asserts against the real route the premise that skipping the answerer's refetch rests on: the skipped request returns a byte-identical body, the answer stays withheld while the poll is live, the reveal fires on supersede, non-answerers still need the fetch, and membership is still enforced after the switch to the cached check.Not verified
REDIS_URL).${roomCode}:staffis an ordinary socket.io room and the Redis adapter treats it identically toroomCode— the same mechanism already in production — but it wasn't exercised. Worth one check on staging with both pm2 instances up: the teacher's answer badge and participant counter should still update.Deploy note
No migration and no required config. The two
TRANSCRIPTION_*vars take effect on the next restart ofspandan-transcription.servicewith no action needed; documented as commented overrides in the unit file.🤖 Generated with Claude Code