GSoC'26 VideoLAN, Final Report and Wrap-up

GSoC 2026 with VideoLAN Final Report: zero-copy GPU video in VLC

By Ahmed Sobhy14 min read217 views

Teaching VLC to stop copying every frame

Playing a video comes down to two main jobs. First, the decoder unpacks the highly compressed video file into raw picture frames. Then, the renderer takes those raw frames, scales them to fit your window, adjusts the colors, and actually draws them on your screen.

Your graphics card can decode video on its own. There's a dedicated block on the chip for it, separate from the part that draws things, and VLC uses it wherever it can: NVDEC on NVIDIA, VA-API on Intel and AMD, MediaCodec on Android. When the decoder finishes, the frame is already sitting in graphics memory, on the same chip as the hardware that's about to draw it.

VLC's libplacebo output, which draws through Vulkan, copied it off the card anyway. Down into system memory, back up onto the card, and only then did the renderer draw it. Nothing touched the pixels in between. The round trip happened because the renderer at the other end couldn't read the frame where it already was, and it happened for every frame of every video you played that way. The OpenGL output VLC still ships by default has never had this problem, and that's a large part of why libplacebo isn't the default yet.

Eliminating that round trip, getting the decoder and the renderer to share the same memory directly, a process known as interop (interoperability), was my Google Summer of Code project with VideoLAN, mentored by Thomas Guillem. It works now, and the code is linked at the end.

The round trip through system memory, before and after before: a hardware decoded frame decoded frame (GPU) system memory GPU texture screen download upload after decoded frame (GPU) GPU texture screen the frame stays where the decoder left it
The whole project in one picture

Who I am and why this project

I'm Ahmed Sobhy, a computer engineering student at Cairo University. I like low-level systems code in C, which is a polite way of saying I enjoy reading specifications and staring at memory layouts.

Like a lot of people, VLC has been on my screen for almost as long as I've had a computer. It's that familiar orange cone I used as a kid whenever I needed to play some random video file that nothing else could open. So when I was looking through GSoC organizations this year, VideoLAN was at the top of my list.

The funny thing is, the project I ended up spending my summer on isn't actually the one I applied for. I originally wrote a proposal to add HDR tonemapping to an experimental Vulkan output. I spent weeks before the deadline practically living in that branch, sending small patches just to learn my way around the codebase.

But right after I submitted, the team made an architectural shift: Vulkan rendering was going to go through libplacebo. Tonemapping was part of the reason for the move. It's one of the things libplacebo is best known for doing properly, so routing Vulkan through it meant VLC got tonemapping for free, and my proposal along with it. What the VLC module around libplacebo didn't handle was interop, and that's the lower-level, missing piece the project refocused on instead.

I was actually thrilled. Interop was the exact part of the codebase I'd been reading up on, and it sat perfectly at the intersection of my weird love for hardware APIs and memory management. If you want the full backstory on that pivot and how I prepped for the summer, I wrote all about it in the first post of this series.

What was missing

VLC doesn't draw the picture itself. It hands the finished frame to a renderer, and the renderer scales it, fixes the colors, and gets it on screen.

The renderer VLC has shipped for years is built on OpenGL, and it solved this problem a long time ago. It has a set of small modules, one per decoder, that take the frame where it lies and show it to OpenGL without moving it anywhere. The newer renderer is built on libplacebo, the GPU rendering library that came out of mpv, and it draws through Vulkan. It had none of those modules. It knew about exactly one kind of picture, pixels in system memory, and everything else took the long way round. That's what I spent the summer writing.

What I built

The project had four parts, and they didn't all end the same way:

  • NVDEC interop (the NVIDIA decoder): built
  • VA-API interop (the Intel and AMD one): built
  • Software pictures: built
  • MediaCodec on Android interop: not built

So, three modules, one for each place a frame can come from, and the layer they plug into. The layer is a small contract: a module says whether it can handle the picture it's offered, agrees on a format, and then hands over a frame whenever one arrives. Where that frame came from is the module's problem, and the renderer never has to ask.

Architecture diagram showing how the interop layer connects the NVDEC, VA-API, and software modules to the libplacebo renderer through a single contract
How the three modules plug into one contract
modulethe frame arrives ashow it reaches the renderer
NVDEC memory owned by NVIDIA's decoder handed over without leaving the card
VA-API a handle owned by the graphics driver pointed at where it lies, no copy
software pixels in system memory uploaded, the way it always was

The software module is the odd one out, since software pictures already worked. It's there for another GSoC project on VLC's video output this summer, Abdelrahman Gamal's, which adds filters that run on the card, deinterlacing among them, to the same display code. A filter shouldn't have to know whether its frame came off the card or out of the CPU, so his code only has to learn one way in. Probably my favorite thing that happened all summer.

Why do we need separate hardware modules at all? Because GPU manufacturers never agreed on a single, universal way to handle hardware decoding. They built their own APIs, and they exist for different reasons:

  • NVDEC is NVIDIA's proprietary API. Because NVIDIA controls both the hardware decoder and the graphics driver, getting them to talk is relatively smooth. NVDEC hands you CUDA memory, and CUDA and Vulkan have a private arrangement for passing memory between them: no file descriptors, no negotiating, nobody else involved.
  • VA-API is an open standard, primarily used by Intel and AMD on Linux. It exists to be compatible across different hardware, but that wide compatibility makes it much messier to work with. VA-API hands you an opaque surface, and to get anything out of it you have to ask the driver to export a "dma-buf", a file descriptor the kernel gives to whoever needs to see that memory. Then you and Vulkan have to agree on how the bytes are actually arranged in there.

Then there's timing. The decoder and the renderer are two pieces of hardware writing and reading the same picture, and nothing was making them take turns. They now share a counter that only goes up: the copy waits for a number, does its work, raises the number, and the renderer waits for that number before it samples anything. It's the part I was least sure I could pull off, and it's the part I'm happiest with.

What it actually saves

I wanted to see the number rather than assume it. So I built two VLC binaries from the same commit with the same compiler flags, one with the interop work and one without, and put the same measuring patch in both. What I measured is the time the video output thread spends per frame turning a decoded picture into something the renderer can sample. On the old path that's the download and the upload. On the new one it's handing over a handle.

The clips are 30 seconds of synthetic video encoded three ways: H.264 at 1080p60, H.264 at 4K60, and HEVC 10-bit at 4K60, decoded on an RTX 3060 Ti under Fedora 43

Per-frame cost of getting a decoded picture to the renderer, NVDEC without interop with interop 640 140 1801 141 3657 139 0 1000 2000 3000 4000 1080p60 2160p60 2160p60, 10-bit microseconds per frame
Time spent per frame getting a decoded picture in front of the renderer. NVDEC on an RTX 3060 Ti, median of five 25 second runs

The old path pays to move the picture, so it grows with the resolution. The new one sits at about 140 microseconds for all three clips, because there is nothing to move.

The PCIe counters agree. On the 4K 10-bit clip nvidia-smi shows about 1750 MB/s coming off the card and 1600 MB/s going back onto it. With interop those are 3 and 16.

None of that showed up as dropped frames. The old path played all three clips at 60 fps and never missed one, because 3.6 milliseconds still fits inside a 16.7 millisecond budget. What you get back on this machine is CPU time, 5.2 milliseconds per frame down to 1.6, and a bus with nothing on it. Nobody watching would have seen a difference. That starts to matter when the budget gets tighter or the frames get bigger: at 4K 10-bit the copy is already using a fifth of the budget, and it scales with pixel count. The interop path uses under one percent and stays there.

What was hard

Not the parts I expected. Getting the decoders and Vulkan to hand memory over was mostly careful reading.

Get stuck on a web framework and a hundred Stack Overflow answers are waiting for you. Get stuck importing a decoder's surface into Vulkan and you have the specification, two or three other implementations to read, and whoever wrote the library. My documentation for most of the summer was VLC's own OpenGL interop, my mentor's experimental branch, and the chapters of the Vulkan spec about external memory. That's slower, and I ended up liking it more than I expected: you read primary sources because there's nothing else to read.

Then there's the fact that the screen lies to you. Video can play perfectly from code that breaks the rules, because a driver is allowed to accept things the specification forbids, and NVIDIA's is relaxed about it. The only ground truth is Vulkan's validation layers, a mode that checks every call you make against the spec and complains in paragraphs.

And the layers only tell you about the machine you're sitting at. Vendors disagree about how many buffers come back from an export, about which formats can be shared at all, and about how strictly any of this gets enforced. Code that's fine on my NVIDIA card can be illegal on the AMD one two slots down, and the reverse happens too.

The code, and where it stands

None of the interop work is merged yet. Everything went up as a merge request on my own fork first, and Thomas reviewed it there. Only once he was happy with the shape of it did it go onto VLC's own repository, where the interop merge request now waits for review from the wider team.

  • Upstream, open: MR !9856, the interop layer itself plus the NVDEC and software modules
  • Upstream, open: MR !10042, the VA-API module
  • Upstream, merged: MR !9328, the missing Wayland Vulkan platform in VLC's meson build. Without it the Vulkan renderer doesn't start on Wayland, so everything above depends on it
  • My fork: MR !2, where it was written and where Thomas's first review happened

To run it, build the branch below (or VLC's master, once the merge request lands) and ask for the libplacebo renderer:

# NVDEC
vlc --vout=libplacebo --pl-gpu=placebo_vk --dec-dev=nvdec video.mkv

# VA-API (also _x11 and _drm)
vlc --vout=libplacebo --pl-gpu=placebo_vk --dec-dev=decdev_vaapi_wl video.mkv

# no hardware decoder at all
vlc --vout=libplacebo --pl-gpu=placebo_vk --dec-dev=none video.mkv

--dec-dev picks which hardware decoder device VLC uses, and both the decoder and the interop module follow from that choice. Leave it off and VLC chooses for you.

With -vv the log tells you which module it picked, or that it gave up and fell back to uploading through system memory. --vk-debug turns on the validation layers.

What's left

  • Review: both merge requests still have to get through it.
  • MediaCodec interop: needs a libplacebo change before any VLC code makes sense, and I don't have a Vulkan-capable Android device to test on. It was in the accepted proposal, and cutting it was a decision I took with Thomas rather than on my own.
  • The external renderer, where another application hands VLC its own Vulkan device and gets video drawn into it: also cut for scope, again in agreement with Thomas. It needs a libvlc API that doesn't exist for the other outputs either, so it's a project of its own rather than a loose end.

What the summer taught me

I wrote this up week by week while it was happening, wrong turns included, in a series of posts with a lot more detail than this one. That series ended up being the most useful thing I did for myself, not for anyone reading it. Writing a week down forces you to find out whether you actually understood it, and more than once I sat down to explain something and found out halfway through the paragraph that I couldn't. Some weeks the honest post was that a thing had failed and here's why, and those were the ones worth writing.

VLC is old and enormous, and finding my way around it was a skill of its own: which of the video output files actually matter, how modules find each other at runtime, what the git history says about why something is the shape it is. It also runs on a few billion devices, and that changes how you write code.

Mostly, though, I understand graphics hardware far better than I did before. Before this, I could have told you a GPU draws triangles quickly. Now I know what its memory is actually made of, why an image isn't laid out the way you'd assume, what a driver is allowed to do behind your back, and how two engines on the same chip agree about who's allowed to touch a buffer.

Thanks

Thank you to my mentor, Thomas Guillem. I was lucky to get him on this one. He gave me a lot of his time over the summer, and he was responsive to my questions. Most of what I now understand about how this kind of code should be written, I understand because he explained the reasoning instead of just telling me what to change. The project is in far better shape for it, and so am I.

And to VideoLAN, for being VideoLAN. Keeping something this widely used free, open source and genuinely pleasant to use isn't the easy path, and they've held to it for over twenty years. VLC has been my default player for as long as I've had a computer of my own, so going from someone who only used it to someone who works on it is easily the thing I'm happiest about this year.

It isn't finished, and I don't want the end of GSoC to be the end of it. Both merge requests still have to get through review. The two pieces that didn't fit this summer are the ones I most want to come back to: they were cut for scope, not because they stopped being interesting.

If you've been here since the first post, thank you for staying. And if this is the first one you've found, the whole summer is written up in the series, wrong turns and all. Go read it. Either way, I'm glad I got to leave something in VLC that millions of people will be able to use.

Did you enjoy this article?

Share this article

Comments(3)

Leave a comment

O
OmarAug 23, 2026

I remember at the start of the project when you were still looking at how Vulkan works. Happy to see it done and really enjoyed the blog series. Proud of you, man ❤️

H
HabibaAug 22, 2026

Such an inspiring journey! Super nice that you documented it too. Good luck 👏🏻🎉

L
LoayAug 22, 2026

What a journey! Thank you so much for sharing it with us! Your posts were always really interesting and genuinely got me excited about so many topics.👏❤️ The project itself looks really interesting and meaningful too. You should be really proud of what you’ve accomplished.❤️ Keep going and I hope the best for you🙏

© 2026 Ahmed Sobhy. All rights reserved.