Historical evidence, superseded guidance. This is the earlier article, preserved with its original workload, measurements and revision links. Its defaults, branch/deployment labels and recommendations are not current. Read the current recipe.
AVPlumber · Mixer optimization cookbook
Event-loop sharding
Implementation links are pinned to develop at c20464a5 (2026-10-09). The mixer has changed since; this page was checked against develop at 8fa15c92 on 2026-10-13. Measurements retain their original workload and scope. Watch the current 192-input L4 demo.
Current scope. Current file-source pacers are realtime only; browser smooth_timestamps remains. The captured 188-source show plus four keys maps to 38 realtime + 10 browser pacers per shard: 48 nodes instead of 86. Four shards remain; removing 152 FPS nodes does not remove 152 threads. The default loop retains output/AUX and wipe pacing.
Split the sources' pacing timers across four event-loop threads, so one thread no longer wakes for every source and rescans every source on each wake-up.
Status: ImplementedThe spin that was most of a loop's cost
On the 192-source show at 1080p30 each pacing loop read 26 to 30 % of a core (2026-10-13, 16 vCPU L4 host). Most of that was not the timer work that sharding divides. The mixer's realtime nodes count in 1/120000 s, so that fractional frame periods are exact, and the engine's clock reads whole milliseconds. A release time that fell inside the millisecond the clock already showed was not yet due in the finer unit, so the node asked the loop's millisecond timer for a time already past, was called back at once and asked again, until the clock turned over. At 25 and 50 fps a frame period is a whole number of milliseconds, release times fall on the clock's steps, and the spin did not occur.
realtime now treats a release time inside the clock's current millisecond as due. It also has a time_base parameter that names the unit it counts and stamps in; the mixer's _pace() passes time_base: "1/120000" where it used to borrow tick_period, which still exists (a node takes one or the other). Each loop fell to about 8 % of a core and the whole mixer from 791 to 715 %. The four shards stay, for the reasons below: they bound what one loop carries and keep one slow callback from delaying every source. They are no longer where a pacing loop's CPU goes.
Why one loop costs N²
An event loop sleeps in poll() until its next timer or a watched file descriptor fires. Two things grow with the number of sources N on one loop:
Wake-ups per second grow with N. Each source's pacing nodes set their own timers.
Work per wake-up grows with N. Each wake-up rebuilds the pollfd array for every watched fd, and each new timer walks the sorted timer list to find its slot.
So one loop's cost grows with N². Four loops each carry N/4 sources: (N/4)² = N²/16 per loop, which is 16× less on the busiest core. Summed over the 4 loops it is 4 · N²/16 = N²/4, which is 4× less CPU in total.
Measured on the T4 test host with 100 sources on one loop: 43 % of one core and 924 wake-ups per second. That run used the earlier chain, realtime and force_fps per source with millisecond timestamps, so the spin described above was not part of it.
At a glance
- WhatSource i's pacing nodes (
realtime;smooth_timestampsfor browser sources) join event looppacing_{i % 4}through the existingevent_loopnode parameter. Output, aux and wipe pacing stay ondefault. - WhyOne loop for 100 sources used 43 % of a core at 924 wake-ups/s, its modeled scaling term grows as N², and one slow timer delays every source behind it.
- Latency0 frames added to program and to command-to-screen latency: no new queue, buffer or node. What changes is timer lateness: one busy loop delayed
realtime's timestamps and held decoders; four loops cut that delay. The improvement is not measured yet. - Side effectsSources on different loops now run their pacing callbacks in parallel, not one after another. Output, aux and wipe pacing get the
defaultloop to themselves, so source timers no longer queue in front of them. - Cost0 nodes, 0 queues, 0 VRAM, 0 framework lines. 4 new event-loop threads (
pacing_0…pacing_3): source pacing moves from 1 thread to 4. Code: one constant, a two-line helper and anevent_loopargument passed through the input builders. - RiskLow. Sources were never phase-locked to each other, so moving them to separate threads changes no ordering a source relies on.
- Switch it offSet
PACING_LOOPS = 1. All sources then sharepacing_0. That is not the old layout exactly: output, aux and wipe pacing still keepdefaultto themselves.
Since 14 October
On avplumber branches, not yet on develop, and running in the demo show: the loop no longer wakes itself when a handler registers its next wait from inside it; it keeps its waits in epoll with one-shot registrations instead of rebuilding a poll() list on every pass (16 against 28 µs per handler in a load test of 200 sources); a queue's eventfd is written only when the last write was read (the loops' writes fell from 33,274 to 15,051 a second); and each source's fan-out can run on the source's pacing loop, which removed 192 threads but moved their 20 points of CPU onto the loops rather than saving them. The four shards stay. Input, decode and wake-ups has the measurements.
Where in the code
src/EventLoop.hpp: each wake-up rebuilds thepollfdarray for every watched fd.src/EventLoop.hpp:schedule()walks the sorted timer list to insert each timer.src/graph_mgmt.cpp: the existingevent_loopnode parameter picks a named loop.pyplumber/mixer/application.py:PACING_LOOPS = 4and_pacing_loop().pyplumber/mixer/inputs.py:_pace()passesevent_looptorealtime; normalized helper callers retainforce_fps.center_phase(Native input cadence).src/nodes/realtime.cpp:time_base, andclock_step_, the step of the clock inside which a release time is due.pyplumber/mixer/inputs.py:_pace()passestime_base: "1/120000". Both links are pinned todevelopat 8fa15c92.pyplumber/mixer/dmabuf_inputs.py: browser sources pass it tosmooth_timestamps.
Markdown reference: README · Non-blocking nodes documents the event_loop field. The mixer API documentation covers native pacing and sharding.