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: Implemented

The 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.

One shared loop versus four sharded loops With one loop, a slow timer callback delays every source queued behind it. With four loops, the stall delays only the sources on its own loop. Before · 1 loop, all sources default s0 s1 s2 s3 slow s4 s5 s6 s7 … s99 Every source after the stall runs late. After · 4 loops, source i on pacing_{i % 4} pacing_0 pacing_1 pacing_2 pacing_3 s0 s4 s8 … s96 s1 s5 s9 … s97 s2 s6 s10 … s98 s3 slow s7 s11 … s99 A stall delays only its quarter; loops use up to 4 cores.
Boxes are timer callbacks in the order one thread runs them. Green: on time. Amber: a slow callback. Red: delayed by it.

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.

Modelled relative cost of the N² term Relative cost, where 100 sources on one loop equals 1.00. N=50: one loop 0.25, busiest of four 0.02, four-loop total 0.06. N=100: 1.00, 0.06, 0.25. N=110: 1.21, 0.08, 0.30. N=150: 2.25, 0.14, 0.56. Model · N² term · 100 sources, 1 loop = 1.00 1 loop 4 loops, busiest loop 4 loops, total N = 50 0.25 0.02 0.06 N = 100 1.00 · measured 43 % of a core 0.06 0.25 N = 110 1.21 0.08 0.30 N = 150 2.25 0.14 0.56
Model, not a measurement. Cost grows with (sources on a loop)². The busiest loop holds N/4 sources, rounded up. Only the 100-source, one-loop point is measured; the model ignores work that does not scale as N².

At a glance

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

Markdown reference: README · Non-blocking nodes documents the event_loop field. The mixer API documentation covers native pacing and sharding.