perf: skip the per-stream and per-media walks in poll_event#1009
perf: skip the per-stream and per-media walks in poll_event#1009theovil1 wants to merge 8 commits into
Conversation
|
Thanks for putting this together. The benchmark and the SFU numbers are very useful. There is quite a bit of overlap with #1008. That PR removes the timeout scans for regular feedback, queued sends and packetization using the new scheduler. I think that makes The remaining Can you rework this on top of #1008 (or soon merged to main), with readiness owned by That should give us the important fast path without the atomic or the timer-related parts of this PR. A single coarse readiness bit might be enough. If the benchmark says otherwise we can split it by event kind afterwards. Since this changes the shape of the PR quite a bit, are you happy to rework it in that direction after #1008? We can also push commits to your branch if you'd rather we help with the rewrite. |
|
Thanks for the detailed read, and for taking the time. Agreed on all three points. Dropping Happy to rework it in that direction. I will rebase on I will also run it on our SFU bench and report back on your open question, whether the coarse bit is enough or whether splitting by event kind buys anything measurable. That is the part I can answer with real numbers rather than an opinion. No need to push commits to the branch, I will do the rewrite, but thanks for the offer. |
4ca6d95 to
761190b
Compare
|
Reworked as you asked, on top of #1008. I retargeted the base to What changed from the first version. Two things worth flagging, since that is where the correctness lives. The mark is cleared at the end of the poll chain rather than at the end of 846 tests pass, clippy is clean. Numbers on our SFU: 100 participants in rooms of 10 on one thread, every camera on, one speaker at a time rotating with a 600 ms handover overlap, silent participants on Opus DTX. Same binary, only the str0m dependency changes. Paired runs, alternating order, 60 second windows. CPU goes from 0.720 to 0.602 cores. Mean saving 16.1 percent, median 17.4, 6 of 6 pairs positive, 95 percent confidence interval 10 to 22. The profile agrees from the other side, and this is what convinced me the coarse bit is enough:
I expected the coarse bit to fail, actually. We do not run in rtp_mode, so every incoming RTP packet depayloads and marks readiness, and I assumed the bit would end up permanently set. It does not, because a mark only survives one exhaustive poll: at roughly 100 packets per second per client against thousands of |
poll_event() answers "does any stream have a keyframe request, a fresh sender report, a pause transition?" by walking every stream, on every single poll_output(). In a 1:1 call that is invisible. In a conference each Rtc holds roughly one m-line per remote participant, and an SFU calls poll_output() thousands of times a second per connection, so the walks are paid O(N) times per connection and O(N^2) per room. The overwhelming majority of them find nothing. Add a coarse Readiness flag owned by Session. The paths that actually queue an event mark it: incoming PLI, FIR and REMB on StreamTx, a fresh sender report on StreamRx, a pause or unpause transition, and the SDP paths that queue MediaAdded and MediaChanged. The walks only run while the flag is marked, and it is cleared once an exhaustive poll has come back empty. Correctness does not depend on the mark being tight: a spurious mark costs one extra walk, never a missed event.
poll_event_fallible() walks every media asking "did the depayloader complete a sample?", also on every poll_output(). Gate it on the same readiness flag, marked when RTP is actually depayloaded. The flag is now cleared at the end of the poll chain rather than at the end of poll_event(), since lib.rs calls poll_event() first and poll_event_fallible() right after. Two things to be careful about there: in rtp_mode nothing walks the medias, so the mark is spent in poll_event(); and until SRTP is ready poll_event() bails out before its walks, while MediaAdded and MediaChanged are queued during negotiation, so the flag must survive that.
Reports the cost of a poll that finds nothing to do, which in an SFU is
the majority of calls, as a function of the number of m-lines on the
connection. That is the shape that matters for a server: a 1:1 call has
two m-lines and the cost is invisible, an SFU client in an N-person room
has roughly N.
Ignored by default since it is a measurement rather than an assertion.
Run with:
cargo test --release --test poll-scaling -- --ignored --nocapture
Review feedback. Bind `ready` once per function instead of passing `&mut self.event_ready` at every call, and reuse the marked check across the two guards in poll_event.
761190b to
54640d0
Compare
|
All six applied, thanks.
I also fixed the CI, which was my fault twice over. The readiness guard used a let chain, which is only stable from 1.88, so it broke the 1.85 MSRV job, and I had not run rustfmt. Both are fixed, and I folded the MSRV fix into the commit that introduced it rather than adding a fix on top, so every commit in the branch now passes 846 tests pass, clippy clean. |
A direction change arriving from the remote side goes through update_media, which sets need_changed_event without ever reaching Session::set_direction. The readiness flag was never marked, so poll_event skipped its walk and the MediaChanged event was lost. Caught by stream_media_changed_event. Mark once in apply_offer and apply_answer instead of at each of the paths that can queue an event there. Negotiation is rare, and a spurious mark costs one extra walk, never a missed event.
|
@theovil1 This should not been closed! 🤦 Can you reopen against main? |
The problem
On every call to
poll_output(),poll_eventwalks every stream and every media to ask whether anything is queued: fourvalues_mut().find_map(..)walks (keyframe, sender feedback, REMB, paused), a loop overmediasforMediaAddedandMediaChanged, and the sample walk inpoll_event_fallible.In a 1:1 call, with two m-lines, this is invisible. In a conference it is not: each
Rtcholds roughly one m-line per remote participant, so the walks are O(N).We run str0m server side in an SFU. In a 30 participant room, one connection makes about 53,000
poll_output()calls per second, and 86% of them return only aTimeout. Each of those is a full walk that finds nothing. The cost is paid O(N) times per iteration, O(N^2) per room, and it does not depend on how much media is actually flowing.This is the same thing @lherman-cs described in #922 ("str0m is burning quite a few CPU cycles just polling for notifications ... ideally we'd have an O(1) way to handle stream notifications"), and the idea below is his.
The change
A single coarse readiness flag owned by
Session:No shared mutable state, no atomic. The paths that queue an event take
&mut Readinessand mark it where they already queue the thing: incoming PLI, FIR and REMB onStreamTx, a fresh sender report onStreamRx, pause and unpause transitions, the depayloader completing a sample, and the SDP paths that queueMediaAddedandMediaChanged.The walks only run while the flag is marked, and it is cleared once an exhaustive poll has come back empty.
Correctness does not depend on the mark being tight. A spurious mark costs one extra walk, never a missed event. That is what makes it safe to keep the producer side conservative.
Two subtleties, since that is where the correctness lives:
poll_event().lib.rscallspoll_event()and thenpoll_event_fallible(), so the sample walk still has to run. Inrtp_modenothing walks the medias, so there the mark is spent inpoll_event().poll_event()bails out early until SRTP is ready, whileMediaAddedandMediaChangedare queued during negotiation, so clearing then would lose them.This targets
timeout-scheduler(#1008), which already removes the timeout side of the problem. No public API change.Results
Our SFU, 100 participants in rooms of 10 on one thread, every camera on, one speaker at a time rotating with a 600 ms handover overlap, silent participants on Opus DTX. Same binary, only the str0m dependency changes. Paired runs, alternating order, 60 second windows.
CPU goes from 0.720 to 0.602 cores. Mean saving 16.1 percent, median 17.4, 6 of 6 pairs positive, 95 percent confidence interval 10 to 22.
The profile agrees from the other side:
Session::poll_event_fallibleRtc::do_poll_outputStreams::poll_remb_requestScheduler::arm846 tests pass, clippy is clean.
Why one coarse bit is enough
I expected it to fail. We do not run in
rtp_mode, so every incoming RTP packet depayloads and marks readiness, and I assumed the bit would end up permanently set and the guards useless.It does not, because a mark only survives one exhaustive poll. At roughly 100 packets per second per client against thousands of
poll_outputcalls, the vast majority of calls still skip the walks. Splitting readiness by event kind buys nothing measurable here, so this keeps the single bit.Note: this PR was written in French and translated to English with the help of an AI tool. Apologies for any awkward phrasing.