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

Raw frames read straight into the memory the GPU reads

Measured on 2026-10-13 on the 16 vCPU L4 host that runs the demo. Source links are pinned to commit 8fa15c92 on develop. Watch the current 192-input L4 demo.

An uncompressed 1080p NV12 frame is 3.1 MB, and a raw source hands one to the GPU for every frame it shows. The mixer's raw file sources copied each of those frames twice on the CPU first: out of the page cache into a packet, and out of the packet into a page-locked staging buffer. A source node that reads the file straight into the page-locked buffer leaves one copy and one thread per source. On the 192-source show, 47 of them raw, the upload threads disappeared and the mixer went from 679 to 510 % of a core.

Status: Live; the demo recipe selects it
Where the bytes of one raw frame go on the way to the GPU, in the three upload modes With hwupload the frame is copied by the CPU from the page cache to a packet buffer and then transferred from that pageable buffer by the driver. With pinned it is copied by the CPU twice, from the page cache to a packet buffer and from there to a page-locked staging buffer, and the GPU reads the staging buffer. With direct it is copied by the CPU once, from the page cache straight into the page-locked buffer the GPU reads. Illustration · one 3.1 MB NV12 frame, from the file to the GPU hwupload page cachethe fileCPU copy packet bufferpageable, new each framedriver, under its shared lock GPU frame pinned page cachethe fileCPU copy packet bufferpageable, new each frameCPU copy stagingpage-lockedGPU reads GPU frame direct page cachethe fileCPU copy (the read itself) the node's blockpage-locked, reusedGPU reads GPU frame a CPU thread moves every byte the GPU fetches the bytes itself; no CPU thread touches them
Illustration, not a measurement. Red arrows are copies a CPU thread performs byte by byte. The blue arrow is the transfer to the GPU, which the GPU performs itself from page-locked memory.

Three ways to hand a frame to the GPU

canvas.raw_upload selects how NV12 and P010 file sources reach CUDA. Each step below fixed what the one before it left.

  1. hwupload: FFmpeg's stock upload from ordinary memory. The frame sits in ordinary pageable memory, so the driver has to make it transferable on every frame, and traces showed upload, import and compositor threads waiting on one another inside the driver while it did. In a 68-input show at 60 fps with 17 raw sources this path missed 69 % of Program deadlines. It remains the library default; it needs no patched FFmpeg.
  2. pinned: a patched FFmpeg filter with a page-locked staging buffer and its own CUDA stream. The same show, measured with the pinned uploader that preceded the filter, missed no deadline. This step removed waiting, not work: threads stopped queueing on the driver's lock, but every byte of every frame was still moved by a CPU thread, twice. On the 192-source show the 47 upload threads spent 132 % of a core doing nothing but the second copy.
  3. direct: one node, raw_video_file_cuda, that owns the page-locked buffer and reads the file into it. The read is the only copy. There is no packet, no decoder that wraps the packet, no staging step, and no FFmpeg patch involved.

What a frame cost with pinned

The chain was input_rec → demux → dec_video(rawvideo) → filter_video(hwupload_cuda=pinned=1): four threads for each raw source. Per 3.1 MB frame:

Two copies of 3.1 MB for each of 1,410 raw frames a second is about 9 GB a second through the CPU, and about three times that in memory traffic, since a copy reads its source and writes a destination that is not in cache. That traffic is shared with every other thread on the machine.

What the node does

  1. At start it allocates one page-locked block the size of a frame through the CUDA driver, a private CUDA stream, and a pool of GPU frames.
  2. For each frame it calls pread() into that block at the frame's offset in the file. The kernel's copy out of the page cache lands directly where the GPU will read.
  3. It queues one transfer per plane from the block to a GPU frame on its own stream, after an event that orders the transfer behind any reader still using that pooled frame, and waits for the stream to finish. This is the sequence the pinned filter uses; only the staging copy before it is gone.
  4. It stamps the timestamp and the colour tags and puts the frame on its output queue, where it blocks until the pacing node takes the previous one. The block is free again as soon as the transfer has finished.

Looping is arithmetic on a frame counter: the offset wraps, the timestamp counts on. Nothing is kept between passes. Every frame shown was read from the file and transferred for that showing, so a raw file still stands in for a live uncompressed input.

Why the upload reads 0 %

The thread meter no longer lists an upload class because no thread has that job. The upload thread did three things, and each went somewhere:

So the upload is not free, it is small: about 6 points of a core where there were 132. What a raw source still costs is the read: about 113 % of a core of kernel time for 47 sources, the one copy that remains.

Before and after

The running 192-source show at 1080p30 (105 HEVC through NVDEC, 40 browser, 47 raw NV12; 24 encoded outputs), one 20-second reading of every mixer thread on the build before and one on the build with the node, about three minutes after it started.

Measured CPU by thread class with the pinned filter and with the direct node, user and kernel time Upload threads: 125 percent of a core user and 6 kernel with pinned, gone with direct. Decode and demux threads: 97 user and 16 kernel with pinned, 86 user and 12 kernel with direct. Reading threads: 14 user and 125 kernel with pinned, 20 user and 120 kernel with direct. The two Program compositors: 61 user and 7 kernel with pinned, 53 user and 6 kernel with direct. Measured · % of one core, 192 sources at 1080p30, 47 of them raw NV12 user time kernel time Upload threads (47, then none)pinned132 %directno such threadDecode and demux threads (305, then 211)pinned113 %direct98 %Reading threads (47 raw + 105 compressed)pinned139 %direct141 %The two Program compositorspinned68 %direct59 %
Measured. The reading threads look unchanged because the 47 raw readers keep the name input_N and are counted with the 105 compressed sources' inputs; inside the class, kernel time is the read and user time is where the upload's remainder went.
% of one coreThreadspinnedThreadsdirect
upload_N47132.10–
input_N, raw and compressed152138.8152140.6
  of which kernel125.2120.4
decode_N15399.410687.6
demux_N15213.81059.5
Program compositor A134.3129.9
Program compositor B133.9129.6
AUX compositors2036.62033.6
Pacing loops537.2533.4
All mixer threads1,3276791,186510
  user / kernel433 / 244285 / 227
Time runnable without a core322123

Isolated: the upload paths alone

Eight raw NV12 1080p sources at 30 fps on a second, otherwise idle L4 host of the same shape, each paced, scaled on the GPU and discarded, 30 seconds per path (tests/cuda/benchmark_raw_upload.py). The figures are the whole process, so they include the pacing, the scaler and the sink of each source, which are the same on every row.

Upload path% of one coreUserKernelThreadsCPU ms per frame
hwupload422319541.75
pinned412219541.69
direct22417300.90
direct, write-combined block21318300.86

What it gives up

For a live uncompressed input

The upload half is its own class, CudaPinnedUploader: the page-locked block, the stream, the ordering event, the transfers and the frame pool. A source that can write its samples where it is told uses it the same way: a capture SDK given the block as its buffer, or a packet reassembler that assembles rows into it. For such a source the remaining copy does not exist either; the file read is what stands in for it here.

Since this measurement

A fourth mode, "resident", keeps a looped file's pictures in one page-locked block the size of the file: each picture is read the first time it is shown, and later passes only transfer it to the GPU. On the same show on 13 October the 152 reading threads fell from 139.7 to 27.3 % of a core and the mixer from 471 to 335 %, at the price of 8.8 GB of host memory that cannot be swapped out. It is on an avplumber branch, not yet on develop; the demo show runs it, while Setup still builds shows with direct. Every picture is still transferred for every showing. Input, decode and wake-ups has the measurement and why it still stands in for a live input.

At a glance

Related pages

Where in the code

Source links are pinned to the measured commit.