Historical recipe — not current setup guidance. Measurements and implementation links below belong to their original workload and revision. Deployment labels and defaults may be obsolete. Use the reviewed performance recipes; browse historical evidence. The export was removed at 159d0182: the browser image runs stock Electron, and the mixer supports only Chromium's ordinary capture, sampled in place.
AVPlumber · Mixer optimization cookbook
Chromium paints into the buffer the mixer reads
Measured on 2026-10-14 on the 16 vCPU NVIDIA L4 host that runs the demo. Not on develop: the Chromium and Electron patches are not yet published, and the mixer side, which waits on each frame's fence, is on the avplumber branch mixer/browser-acquire-fence at 93ab9998. The demo show ran with the patched browser from the trial below until the export was removed. Watch the current 192-input L4 demo.
A browser source is an offscreen Electron window whose frames reach the mixer as DMA-BUFs. Stock Chromium draws the window's picture into a private texture, copies it into a separate capture buffer, and then polls until the copy has finished before it hands the buffer over. With the patches, Chromium's compositor paints the picture straight into a buffer it can export, and lends that buffer to the mixer together with a fence that signals when the drawing is done. The mixer waits on the fence before it reads. No copy, no polling, and the buffer is painted again only after the mixer gives it back.
Status: removed; ran in the demo show as a trial with a patched browser- 159 → 148 %of one vCPU, the browser container with 40 windows, in a ten-minute trial on the 192-source show
- 31 → 19 %the GPU-process main thread
- 170–200 → 36–87browser-source frame repeats per 30 s, all of the remaining ones from one source
- +0.5 GBGPU memory; GPU utilisation unchanged
- < 1.5 msevery frame's fence wait in the mixer; no timeouts
- 0missed deadlines and output drops in the ten minutes of the trial
What changed
- The compositor paints into exportable buffers. An offscreen window's display gets a new output device that allocates its buffers the way the capture pool does for native handles (GBM, renderable and scanout, tiled), and paints the window's root pass into one of them. Buffers come back from the mixer in any order, so the device does not do partial redraws: every frame it lends is complete, whatever the buffer held before.
- Lent, not copied. The capturer still asks for each frame, but with a request that carries no destination. The GPU thread answers it by lending the buffer it has just drawn. That buffer is not drawn into again until the mixer's release comes back. If every buffer is out, the frame is dropped: Chromium never blocks and never paints into a buffer the mixer still holds.
- A fence with every frame. After the draw is flushed, Chromium creates an EGL native fence and exports it as a sync_file descriptor. It travels with the frame: Electron puts it on the
paintevent, the browser service sends it as the frame's second descriptor on the socket and flags it in the frame header, and the mixer's receive thread waits for it to become readable before the frame goes on. The wait sits in thepoll()the thread already makes, so nothing spins. A fence that has not signalled within 100 ms (fence_timeout_ms) drops the frame and acknowledges it. - Why a fence is needed at all. Probes on an L4 with this driver showed that NVIDIA attaches no implicit fence to the DMA-BUF, so without one the mixer could read a buffer the GPU is still drawing; a fence after a draw signalled 1.5 to 2.2 ms later. CUDA 13.4 on this platform can neither import a DMA-BUF directly nor wait on a sync_file, so the mixer keeps its existing import (an EGL image registered with CUDA once per physical buffer) and waits on the CPU.
- The capturer targets the compositor's root. The first build delivered nothing and logged nothing: the capturer targeted the page's own surface, so every request landed on a pass that was not the root and was dropped. In this mode Electron captures the compositor's root frame sink, and the page is drawn once, straight into the exported buffer.
- The root stays transparent. The second build lost alpha: an opaque root was drawn under the page. In this mode Electron holds the root, its gutters and the compositor background transparent, so premultiplied alpha arrives as the page drew it.
- A pool that never frees. Buffers are allocated once and never freed while the display lives; a released buffer goes to the back of the free list, so every buffer is reused in turn. Only a size or format change replaces them. Why this matters is the first show trial, below.
The first show trial stalled the whole GPU
Forty patched windows went into the running 192-source show. Within three minutes the whole mixer stalled: the compositors' wait for the GPU went from 2 to 20 ms, decoded and raw sources repeated frames as well as browser ones, and in about five minutes there were 8,132 missed deadlines and 65,852 output drops before it was rolled back by hand.
- The buffers churned. The new device reused its free buffers last in, first out, so the buffers at the bottom of the stack sat idle longer than the mixer's importer keeps an unused import, and were imported again: about 0.3 a second per window. The native-handle capture pool behind it kept only two idle frames and destroyed the rest, so bursty returns destroyed and re-created buffers.
- Imports and releases are process-wide. Every new import and every release goes through NVIDIA driver paths that all GPU work shares. That is consistent with a stall that crossed process boundaries; no driver lock trace was taken. It is the same kind of stall that returning browser frames one per new frame removed for the stock path.
- The fix. Buffers are never freed while the display lives, reuse is first in, first out, and the device logs its allocation counters every 5 s. In the second trial each display allocated 10 buffers and freed none.
The second trial
The running show: 192 sources at 1080p30 (105 HEVC files through NVDEC, 40 browser windows, 47 raw NV12 files) and 24 encoded outputs, with the fence-aware mixer in both columns. Ten minutes on the stock browser and ten on the patched one with the export on.
| 192 sources at 1080p30 | Stock Electron | Zero-copy export |
|---|---|---|
| Browser container, % of one vCPU | 159 | 148 |
| GPU-process main thread, % of one vCPU | 31 | 19 |
| Browser-source frame repeats per 30 s | about 170–200 | 36–87, all from one source |
| GPU memory | – | +0.5 GB |
| GPU utilisation | – | unchanged |
| Buffers per display: allocated / freed | – | 10 / 0 |
| Missed deadlines, output drops | 0 | 0 |
- The mixer's own CPU did not move: 236–238 % of a core with the stock browser, 238–243 % with the export. The fence wait did not show in the mixer total beyond the spread of the readings.
- A reading about fifteen minutes later, on the next mixer build, put the browser container at 158 %. The 159 → 148 is the ten-minute comparison, not yet a steady figure.
- The patched browser has stayed in the demo show since this trial, as a container of its own beside the regular stack.
What it was checked against
- Colour. Pure red and blue windows come out as the exact BT.709 YCbCr values through import and composition.
- Alpha. Premultiplied swatches over P210 black give Y = 64, 284, 504, 720 and 940.
- Lifetime. All four cases pass, including a consumer killed mid-stream: Chromium gets every buffer back or frees it with the window.
- Fences. Every frame carried one; every wait was under 1.5 ms, with no timeout.
What did not help
- Native-handle capture alone. The patched build can also run the stock copy into a native-handle buffer, without the export. Even after the pool fix it cost the show 13 missed deadlines at 12 windows. That was not explained; the export replaces that mode, and it stays off.
- Turning off Chromium's PartitionAlloc hardening. Tried for the browser's CPU; the difference was within run-to-run noise.
- Trials beside the show at full size. A second browser with 36 windows next to the running show overloads the L4 even with stock Electron, and was stopped after 83 missed deadlines. Isolated tests beside a show are valid only at about 12 windows.
The patches
A series for Chromium 152.0.7977.130 and Electron 44.5.1, built into the browser image. The two native-handle capture patches, the first of each list below, are in the avplumber repository under docker-compose/dmabuf/chromium; the other nine are not yet published. Every change sits behind two Chromium features that are off by default, so one binary runs stock or patched.
- Chromium: (1) capture native-handle SharedImages as textures; (2) skip the black clear when a blit covers the whole destination; (3) carry an acquire fence with texture copy results; (4) zero-copy export of the offscreen root pass, the feature
AvpZeroCopyOffscreenExport; (5) rate-limited warnings on every branch that drops a zero-copy frame; (6) log the exported root pass and the device's geometry; (7) never free capture buffers while the display lives, first-in first-out reuse, allocation counters. - Electron: (1) opt offscreen shared textures into native handles; (2) expose the capturer's acquire fence on the
paintevent; (3) capture the compositor root frame sink in zero-copy mode; (4) hold the compositor root transparent in zero-copy mode.
At a glance
- WhatChromium's offscreen compositor paints each window's root pass into exportable DMA-BUF buffers and lends them to the mixer with a sync_file fence; the mixer waits on the fence before it reads and returns the buffer when done.
- WhyStock Chromium drew every frame twice (into a texture, then a copy with two clears into the capture buffer) and polled for the copy every 2 ms. The browser was 1.6 cores of the 192-source show.
- Latency0 frames added. The frame is handed over when its fence is created instead of after a polled copy; the mixer's fence waits were under 1.5 ms. Cut latency was not re-measured.
- Side effects+0.5 GB of GPU memory: each window keeps its buffers for its whole life. Buffers arrive as RGBA rather than BGRA; the mixer imports both.
- CostA patched Chromium and Electron, eleven commits, rebuilt for every Electron update. In the mixer: one more descriptor per frame and one
poll()wait in a thread that already polls. - RiskThe first trial stalled the whole GPU through buffer churn; the fix is in, and the second trial ran clean for ten minutes. A patched browser must not reach a host before the fence-aware mixer: an older mixer rejects every fenced frame and leaks one descriptor per frame. Measured on one host and one show.
- Switch it off
DMA_BROWSER_ZERO_COPY_EXPORT=1in the browser service turns it on (it impliesDMA_BROWSER_NATIVE_HANDLE_CAPTURE=1); leave it unset, the default, and restart the browser service for the stock path. Stock Electron ignores both features.
Related pages
- Browser frames go back one per new frame: the same driver-path stall, for the stock capture pool.
- Browser frame hand-off: the socket and the acknowledgements the fence now travels with.
- Browser CPU is per painted frame: where a window's CPU goes in the three Chromium processes.
- Input, decode and wake-ups: the mixer's own CPU on the same show.
Where in the code
src/nodes/hwaccel/ipc_dmabuf_source.cpp: the wait on a frame's acquire fence before it is forwarded,fence_timeout_ms, and thefence_*counters in the node's statistics.deps/dma-browser/addons/fdpass/texinfo.h: the frame header and the flag that says a fence descriptor follows.deps/dma-browser/src/main/HwAccelConfigurator.ts: the two switches and the features they ask for, never assumed.deps/dma-browser/README.md: the patched build, the acquire fence and the order to update in.
Source links are pinned to the branch commit that runs in the show. The Chromium and Electron patches are described above by their commit titles.