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

Browser frames go back one per new frame

Implementation links checked 2026-10-12 against develop at b708e59f. Measured on 2026-10-12 on the 16 vCPU L4 host that runs the demo. Watch the current 192-input L4 demo.

A browser window's frames reach the mixer as DMA-BUFs, and the mixer tells the browser when it has finished with each one. It used to say so the moment a frame was let go, several at a time. Chromium's buffer pool then destroyed and allocated buffers all the time, every new buffer was a new CUDA import and a released one, and each release held a lock inside the CUDA library that every other GPU call waits for. Now one frame goes back for each frame that arrives. The pool settles on a fixed set of buffers, and the 192-source show that ran at 1080p25 runs at 1080p30.

Status: Live, on by default
One browser window's buffer pool over twelve frames, before and after Before: the mixer sends frames back in bunches of four, five and three. Each bunch leaves more than two buffers idle in Chromium's pool, which destroys the surplus, six buffers in all, and has to allocate four new buffers for later frames. After: one frame goes back for each frame that arrives, two buffers are idle after every return, nothing is destroyed and nothing is allocated. Illustration · one browser window's buffers over twelve frames Before · a frame goes back when the mixer lets it go handed outsent backidle in the poolafter the returns new 4 new new 5 new 3 2 kept destroyed After · one goes back for each frame that arrives handed outsent backidle in the poolafter the returns 1 1 1 1 1 1 1 1 1 1 1 1 2 kept
Illustration, not a measurement. Each column is one frame. Blue: the browser hands out a buffer it had idle. Red square: it had none idle and allocated a new one. Green pill: frames the mixer sent back at that moment. Bars: buffers idle in the pool after those returns; Chromium keeps two and destroys the red part. In the first panel the same twelve frames cost four allocations and six destroyed buffers. In the second the bar is always two.

The symptom

The show: 192 sources at 1080p, SDR, 256 scenes, 24 encoded outputs. At 30 fps that is 105 NVDEC decodes, 40 browser windows and 47 raw NV12 uploads. It ran clean at 25 fps and missed deadlines at 30, with room left everywhere a gauge looks: CUDA at 31 to 68 %, no thread above 45 % of a core, VRAM at 19.5 of 23 GB.

The show did not degrade smoothly. It swung between a clean state and a collapsed one on its own, and in the collapsed state each Program bus missed about 140 deadlines a minute. One short clean run therefore proved nothing; the measurements below hold in either state.

The cause

  1. The buffers are Chromium's, and it keeps two idle. A window's frames come out of Chromium's capture pool. The pool keeps at most two idle buffers and destroys the rest. The browser service's ringSize is not a ring of buffers: it only limits how many frames may be out of the browser at once.
  2. The mixer sent frames back in bunches. A frame went back when its last user let it go: the playout buffer, a compositor, a monitor. That released one to six frames of a window together. Chromium found more than two idle, destroyed the surplus, and allocated new buffers for the frames after.
  3. Every new buffer is a new import. A new buffer is a new DMA-BUF. The window's drm_prime_to_cuda node registers it with CUDA (cuGraphicsEGLRegisterImage) and later drops the import of a destroyed one (cuGraphicsUnregisterResource) on its release thread. At 30 fps the 40 windows together imported about ten new buffers a second.
  4. The release holds a lock the whole context needs. cuGraphicsUnregisterResource takes a reader-writer lock inside the user-space CUDA library for writing and can sleep while it holds it. Every other call into the context queues behind it: the uploads, the compositors' draws, the encoders' inputs. At 30 fps this tipped into a state where that one thread held the lock most of the time.

Raw frames read straight into the memory the GPU reads has an earlier show whose traces showed upload, import and compositor threads waiting on one another inside the driver; uploads wait on this lock too, so it is very likely the same lock, though that show was not measured again. Here the holder was read from the lock itself; see How the holder was found.

The fix

The mixer still lets a frame go as soon as nothing uses it. What changed is when the browser hears about it. A finished frame waits in a return queue, and each frame that arrives sends the oldest waiting one back. The pool sees one buffer come back for each one it hands out, however unevenly the mixer finishes with frames, so it never has more than two idle and never runs out.

A browser window's frame queue of nine Nine frames of one window may be out of the browser at once. Six are for frames the mixer is using, in flight. Three are the return queue: frames the mixer has finished with, waiting to go back. Each arriving frame sends the oldest waiting one back. browser_frame_queue = 9 · frames of one window out of the browser 6 in flight · in use by the mixer 3 in the return queue · finished Each frame that arrives sends the oldest one in the return queue back to the browser.
One window's frame queue. Nine is the most a window can have out: Chromium's pool of eleven less the two it keeps idle. The mixer's own pipeline needs up to six; the other three are the return queue.

Before and after at 1080p30

The same show on the same host, ten minutes each, start-up excluded.

Ten minutes, 192 sources at 1080p30BeforeWith the return queue
Program deadlines missed, bus A / B28 / 210 / 0
AUX deadlines missed, 22 buses970
AUX frames dropped2180
Draws longer than the 33 ms slot4620
Longest draw198 msunder 25 ms
New browser buffers importedabout 10 a secondnone
Browser paints dropped–0 of 264,996

The show then ran 42 minutes with cuts being made: no deadline missed on Program A, on Program B or on any of the 22 AUX buses (1.65 million AUX frames), and no frame dropped. A draw takes about 3 ms of its 33 ms. The before column was not counted for dropped paints.

With that, the nvidia_l4_cuarray profile admits 192 sources at 30 fps as it does at 25; the 30 fps limit was 170. See Source limits by mode and frame rate.

At 1080p60

50 and 60 fps used to keep all nine frames for the mixer and had no return queue. The 60 fps show: 99 sources (40 NVDEC, 40 browser, 19 raw NV12) and 16 encoded outputs, eight minutes each.

Eight minutes, 99 sources at 1080p60Nine in flightSix and three
Importer log lines for new buffers19865
Program draws longer than the 16.7 ms slot, A / B16 / 70 / 0
Deadlines missed, AUX frames dropped00
Browser paints dropped0 of 414,9770 of 414,885

At 60 fps the return queue removes the slow draws and about two thirds of the new buffers, not all of them as at 30 fps: the time the mixer holds a frame varies by more frames than three waiting returns even out. Neither run missed a deadline, so here it buys margin. The importer logs a window's first two new buffers and then every 64th, so the line counts compare the two runs and are not buffer counts.

What it was not

Each of these was a candidate and was measured away on the same show.

How the holder was found

Everything below reads /proc or runs in a throwaway container; nothing was installed on the host.

  1. Waiting, not working. Per-thread schedstat gives the time each thread ran and the time it was runnable without a core. Per-thread state and system call (/proc/<tid>/stat and syscall) showed the Program compositors asleep in a futex 65 % of the time at 30 fps and 0 % at 25.
  2. Which lock. The futex address is the second argument in /proc/<tid>/syscall. Threads waited on two addresses four bytes apart, which is one glibc pthread_rwlock_t: its writer-phase and writer futex words. The owner word of the same structure names the thread that holds it for writing. That is a thread id in the container's PID namespace; NSpid in /proc/<tid>/status maps it to the host's.
  3. Who holds it. The holder was the same thread in every sample. One gdb attach from a container sharing the mixer's PID namespace gave its stack: cuGraphicsUnregisterResource under the importer's release. An attach stops the mixer for about 17 seconds, so never on a show that matters.
  4. Where a tick's time goes. mixer.status now carries playout.timing per bus: histograms of the whole draw, of the part that queues it into CUDA, of the wait for the GPU and of the hand-off downstream, in quarters of the frame period. Every slow draw was slow in the part that queues the work; none was slow waiting for the GPU.

Do not measure with anything CPU-bound beside the show. One single-core test container on the same host cost the 30 fps show 87 deadlines in a minute.

Two changes that came with it

At a glance

Related pages

Where in the code

Markdown references: NODES.md · ipc_dmabuf_source documents return_queue; the browser service's README says what ringSize limits; capacity.md has the limits.

Source links are pinned to the reviewed revision.