Part 1: the fix I planned last week
Last week ended with a plan to stop feeding libplacebo one combined disjoint image, and give it one plain image per plane instead. This week I wrote it, and it did what the plan said it would.
Each plane now gets its own ordinary Vulkan image. Luma is an R8 image at full size, chroma is an R8G8 image at half size (R16 and R16G16 for 10 and 16 bit). None of them is disjoint, so the dedicated allocation that NVIDIA needs for the CUDA import is legal again, and the CUDA copy itself did not change at all. Only the number of images it fills went from one to two. libplacebo wraps each plane image on its own and does the YUV to RGB in its shader. This is the same shape the OpenGL interop has used all along, one texture and one mapping per plane, so I was really just bringing the Vulkan path in line with the path that already worked.
Part 2: wrapping the image once, not every frame
With the picture correct, there was something both wasteful and wrong that needed to be addressed. Every single frame the code was destroying the old wrapper around the plane image and creating a fresh one. libplacebo's own docs say wrapping the same underlying image more than once is undefined behavior, so this was standing on ground the library tells you not to stand on.
The images do not change for the life of the output. Only their contents do. So the wrapper should be made once and kept. I moved to a small cache: wrap each plane image a single time, hold onto that wrapper, and reuse it on every later frame. The copy still runs per frame, the wrap does not. This is the same idea the OpenGL interop uses, allocate the texture once and only upload into it afterwards.
Part 3: the real problem this week, two clocks touching one image
The frame looked right, but two different pieces of hardware work touch the same plane image without either one knowing about the other. CUDA copies the newly decoded frame into it. Vulkan, driven by libplacebo, samples it to draw. These run on separate timelines, and nothing was making them take turns.
Two things can go wrong from that. The render can read the image while the copy is still only half done, so it draws a frame that is part new and part old. And the copy for the next frame can start writing while the GPU is still reading the current one on screen. Without any ordering, NVIDIA happened to get away with it in my quick tests, but "happens to work" is precisely the kind of thing that breaks on a different driver or a faster machine. The correctness of the whole path depends on the two sides agreeing on who touches the image when.
The OpenGL interop I have been using as the reference for all of this never had to solve it by hand. CUDA's OpenGL interop hides the ordering inside its map and unmap calls, so the handoff between CUDA and the GPU happens for you. Vulkan hands you the raw tools instead and expects you to build the handshake yourself, which is the rest of this blog.
Part 4: the tool for it, a shared counter both sides can wait on
The mechanism that fixes this is a semaphore, but a specific kind. A plain binary semaphore is one on or off flag with a strict one signal, one wait rule, and it does not fit here because one event (the copy finishing) needs to release several waiters (one per plane), and because I want two different APIs raising the same fence. The right tool is a timeline semaphore.
A timeline semaphore is just a 64 bit counter that only ever goes up. Raising it means "I finished up to here." Waiting on a value means "block until the counter reaches this." Any number of waiters can wait on the same value, and the wait can be queued before the matching raise even happens, it simply blocks until it arrives. The piece that makes this work across the API boundary is that the same Vulkan semaphore can be exported and imported into CUDA, so CUDA and Vulkan raise and wait on one shared counter. That is the whole trick: one number both clocks can see.
There is one more idea underneath it, libplacebo's ownership model. When you hand it an external image, it considers the image "held" by you until you formally release it, and to touch the image again you have to "hold" it back. Those hold and release calls are exactly where you attach the semaphore, so the ownership handoff and the fence are the same event.
Part 5: the handshake
Put together, every frame runs the same six step handshake on one shared counter per image. Let N be the value the counter reaches once the previous render is done reading.
The two waits are the two guarantees. When CUDA waits for N before copying, it is promising not to overwrite a frame the GPU has not finished showing. That is the read fence. When the render waits for N+1 before sampling, it is promising not to draw a frame the copy has not finished writing. That is the acquire fence. It is one counter running both directions, and the image is never touched by both sides at the same time.
Here is the MR with all my work (if you are reading this later, some of this week's commits may be force-pushed and reordered, so the exact history might look different): here
Did you enjoy this article?
Comments(0)
Leave a comment
No comments yet. Be the first to share your thoughts!
