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

The compositor's wait costs the frames that arrived

Measured on 2026-10-13 on the 16 vCPU L4 host that runs the demo. Both commits are on develop since 2026-10-13: cc716b6f, the wait with epoll, and 13ea5848, the sleep on the input events. Source links are pinned to develop at 8fa15c92, which has both. Watch the current 192-input L4 demo.

The busiest thread of the 192-source show is the Program compositor. It sleeps until any source has a new frame, and it did that with poll() over one descriptor per source: 193 descriptors handed to the kernel, hooked, unhooked and scanned on each of about 2,200 wakes a second, to learn that two or three had fired. With epoll the list of sources is handed to the kernel once, when the compositor is built. After that each sleep is one call that carries no list, and the answer is only the sources that have a new frame. The thread fell from 52 to 35 % of a core; nothing about when it wakes or what it draws changed.

Status: Live
What one wake of the compositor touches, with poll and with epoll The compositor waits on 193 event descriptors, one per source and one to stop. In this wake three of them fired. With poll the kernel visits all 193 on the way in and again on the way out, and the thread looks through 193 results. With epoll the kernel hands back the three that fired and touches nothing else. Illustration · one wake of a compositor with 192 sources, three frames arrived Before · poll() 193 descriptors handed over, hooked, unhooked and scanned on every wake · 3 had a frame After · epoll_wait() registered once when the node is built · a wake returns the 3 that fired fired visited by the kernel and the thread not touched
Illustration, not a measurement. Each cell is one event descriptor: one per source edge and one for stopping the node. poll() takes the whole list on every call, so its cost follows the number of sources. epoll keeps the list in the kernel and reports only what fired.

Why this thread matters

More vCPUs help a show whose threads together need more than the machine has. They do not help one thread that needs more than a core. On the 192-source show at 1080p30 the Program compositor read 52 % of a core, and its cost is not linear:

Carried to 1080p50 with 192 sources, the fit gave 84 % of a core for this one thread, and a full core at about 214 sources. Both Program buses run the loop, and so does every AUX bus.

What one wake did

  1. One poll() over 193 descriptors. The kernel walks the list, hooks the thread onto each descriptor's wait queue, sleeps, and unhooks from each on the way out.
  2. A pass over the results to read the descriptors that fired, and a pass over all 192 source edges to find one with data.
  3. A second pass over all 192 edges to take the new frames into the playout buffer.
  4. A third pass when the tick cannot be drawn yet, before sleeping again.

The Program compositor keeps every scene source subscribed, on air or not, so that a cut starts on the next frame. That is why its list is the whole show. An AUX compositor waits on the same 193 descriptors but subscribes only the 4 to 12 sources it shows, so it wakes about twenty times less often.

Event and wait, in plain terms

It is the difference between reading the whole guest list to the doorman every time you ask whether anyone has arrived, and giving him the list once and asking who is new.

The change

MultiEventWait, the engine's wait for any of a set of events, now registers its events with epoll when it is built and calls epoll_wait() to sleep. Everything else is as it was:

The class is shared: the Program and AUX compositors, the muxers and the clip cache all wait through it.

Before and after

The running 192-source show at 1080p30 (105 NVDEC, 40 browser, 47 raw NV12; 24 encoded outputs), one 20-second reading of every mixer thread before and one after the build was swapped in.

Measured CPU of the compositor threads, user and kernel time, before and after Program compositor A: 34.4 percent user and 17.4 kernel before, 31.8 user and 3.0 kernel after. Program compositor B: 31.3 and 14.8 before, 30.1 and 3.0 after. The twenty AUX compositors together: 46.5 and 34.4 before, 38.8 and 6.7 after. Measured · % of one core, 192 sources at 1080p30 user time kernel time Program compositor Abefore51.8 %with epoll34.7 %Program compositor Bbefore46.0 %with epoll33.1 %20 AUX compositors togetherbefore80.8 %with epoll45.4 %
Measured. The kernel part is what the change targets; the user part is the passes over the edges, the frames and the draw, and it barely moves.
% of one coreBeforeWith epollKernel part
Program compositor A51.834.717.4 → 3.0
Program compositor B46.033.114.8 → 3.0
AUX compositors, all 2080.845.434.4 → 6.7
Program preview and multiviewer compositors16.810.57.6 → 1.1
Pacing loops, all 5 (not touched by the change)105.5112.628.6 → 30.9
All 1,472 mixer threads852800326 → 279

Isolated on one tree

The table above compares the trial image the demo host was running with a build of develop plus this commit, so it is not the commit alone. A second L4 host of the same shape ran the same 192-source show from three images built from one tree: develop, develop with the epoll commit, and with the next commit as well, which drops two of the compositor's three passes (see below). Two 20-second readings each; the two agreed within half a point, and the table has their mean.

% of one coredevelop+ epoll+ direct wait
Program compositor A43.132.130.5
  of which kernel13.52.62.7
Program compositor B41.931.229.9
AUX compositors, all 2073.644.232.1
  of which kernel34.46.95.9
Pacing loops, all 5 (control)110.7114.6111.6
All 1,472 mixer threads807769747
Time runnable without a core, all threads449456326

The epoll commit alone takes 11 points off each Program compositor and 29 off the AUX compositors, all of it kernel time. The absolute figures differ from the demo host's, which was on another build and has viewers attached; the kernel time goes the same way on both.

The second step, and what it showed

After taking in every input the compositor called findSourceWithData() to wait. That looked at all 192 edges before sleeping and again after waking, for an index the compositor never used. The next commit makes it sleep on its events directly. A frame that arrives after the pass leaves its event signalled and ends the wait at once; an input that still holds frames beyond the pass's bound of eight skips the wait.

So what is left of the Program compositor, about 28 points of user time, is not the passes. It works out at about 48 µs for each of the 5,760 source frames a second, and what that time is spent on was not measured here. A later profile found 26 % of the thread in looking at all 192 input queues on every wake; see below. Until then the projection for 1080p50 with 192 sources is the measured figure scaled by the frame rate: about 51 % of a core, down from 84.

Two things that were open have since been addressed:

Since this measurement

How it was measured

From /proc only, with nothing installed on the host and nothing CPU-bound beside the show:

The user and kernel split is what made the first step obvious: a third of the thread's time was in the kernel, and the only thing it asks the kernel for at that rate is the wait.

At a glance

Related pages

Where in the code

Source links are pinned to develop at 8fa15c92, which has both commits.