Field note / Virtualization
Getting Firefox to decode video on my old NVIDIA GPU in a microVM
Chronicle of my trials getting video decoding on my NVIDIA GPU working inside a Cloud Hypervisor microVM. Vulkan to the rescue, kind of.
I got stock upstream Firefox to decode H.264 in hardware from inside a Cloud Hypervisor microVM on my desktop. It renders through the host’s NVIDIA T1000 and decodes on that card’s NVDEC engine, the fixed-function video decoder sitting on the NVIDIA die next to the shader cores. None of this worked out of the box. Nothing in the stack carried video at all, so I had to patch Mesa in the guest and virglrenderer on the host, and extend the protocol between them. What I did not patch is Firefox. Every change that makes it work lives underneath the browser.
That constraint drove everything. I already had a working decode path and it required a forked Firefox, which means rebasing on a browser whose release cadence is driven by security fixes. Every rebase is a chance to silently break one. So I set the target at hardware decode with a completely unmodified browser.
The guest never talks to the NVIDIA driver. It talks to virtio-gpu, a
paravirtualised GPU device, and the commands it writes there come out in a host
process that replays them against the real driver. That host process is
virglrenderer. The dialect is
Venus, which serializes
Vulkan calls almost one for one rather than translating them, so a guest Vulkan
call becomes the same host Vulkan call. Venus ships as a Mesa ICD, an
Installable Client Driver, which is the shared library the Vulkan loader
presents to an application as a GPU driver.
The virtual machine monitor, and why it is not crosvm
Cloud Hypervisor has no virtio-gpu device.
Spectrum OS maintains a
patch set that adds one, but their patches target Cloud Hypervisor v50 and I
wanted v52, which is the first release to ship --generic-vhost-user and which
includes the fix for CVE-2026-45782, a virtio-block use-after-free that is a
guest to host escape. So I
vendored the Spectrum patches and rebased them onto v52,
along with the rust-vmm/vhost backports
the shared memory regions need. The two that matter are
virtio-devices: add a GPU device
and
vhost-user-media device.
The obvious question is why not run crosvm as the VMM,
since it already has all of this. The answer is that the wider project this lab
belongs to has requirements crosvm does not serve. The one that decided it was a
software Trusted Platform Module. Cloud Hypervisor takes
--tpm socket=<path>
and speaks to an ordinary swtpm on the
other end. crosvm’s TPM backend is vtpm_proxy, compiled behind a vtpm
feature, and it forwards commands over D-Bus to org.chromium.Vtpm, a ChromeOS
service. There is no way to point it at an arbitrary swtpm socket. TPM state
persistence matters because wiping it looks like device tampering to remote
identity providers and forces re-enrolment.
crosvm still runs, as a sidecar holding the GPU. Cloud Hypervisor runs the VM,
crosvm runs device gpu, and the two speak vhost-user over a socket. vhost-user
is the convention for moving a virtio device’s data path out of the VMM into a
separate process, which maps guest memory directly and is handed the queues over
a Unix socket. crosvm reaches the host driver through rutabaga_gfx, its
graphics abstraction layer, and then virglrenderer.
The launcher wires it up:
crosvm device gpu \
--socket "$RUN_DIR/gpu.sock" \
--wayland-sock "$WAYLAND_SOCK" \
--gpu-device-node /dev/dri/renderD128 \
--params '{"context-types":"virgl2:venus","implicit-render-server":true,"external-blob":true}' &
cloud-hypervisor \
--gpu "socket=$RUN_DIR/gpu.sock" \
...
--gpu-device-node is not upstream. It is a
small patch I carry
that lets the GPU sidecar be handed a DRM render node, which virglrenderer’s
video code needs later. DRM is the Direct Rendering Manager, the kernel graphics
subsystem, and a render node is the /dev/dri/renderD* device granting
offscreen GPU access without the display-control privileges of the primary node.
That much gets page rendering onto the GPU. Video still decoded in software.
What was missing
Firefox’s Vulkan Video decoder hard-gates in
SelectVulkanDecoderPhysicalDevice() on two extensions being present,
VK_KHR_video_queue and VK_KHR_video_decode_queue. Venus advertised neither,
so Firefox fell through to VA-API, the Video Acceleration API, and then to
software. Below the browser, nothing existed at any layer:
| Layer | State before this work |
|---|---|
| venus-protocol | zero video extensions in VK_XML_EXTENSION_LIST, no wire commands |
| virglrenderer | no vkr_video.c; vkr_extension_table strips video before the guest sees it |
| Mesa Venus | no video passthrough, and actively strips video format-feature bits from NV12 |
| upstream merge requests | none, in any of the three |
The path I had to build, end to end:
stock Firefox (guest)
-> libavcodec.so.62 (ffmpeg 8, --enable-vulkan)
-> AV_HWDEVICE_TYPE_VULKAN -> VK_KHR_video_decode_h264
-> Mesa Venus ICD (guest) <- add extension exposure
-> virtio-gpu / crosvm <- unchanged, forwards bytes
-> virglrenderer (host) <- add vkr_video.c
-> NVIDIA Vulkan ICD -> T1000 NVDEC4
crosvm and rutabaga need no changes. They forward bytes without inspecting them, which is what you want in the middle of a protocol you are extending.
Why Vulkan Video and not VA-API
VA-API is what Firefox looks for first on Linux, and nvidia-vaapi-driver
provides it on NVIDIA by translating to NVDEC. virglrenderer already has VA-API
video support and is even built with it. It cannot work, for a reason in the
wire format rather than in anyone’s code.
Reaching it at all takes two overrides. Nothing passes
VIRGL_RENDERER_USE_VIDEO, bit 11 of the flags word virgl_renderer_init()
takes: rutabaga_gfx generates the constant into its bindings and references it
nowhere, its VirglRendererFlags builder stops at bit 10, and the inner u32
is private, so crosvm cannot set the bit even deliberately. The failure is
silent, because virgl_video_init() is what assigns va_dpy and
virgl_video_fill_caps() returns -1 on a NULL one, so the capability set
reaches the guest with num_video_caps = 0 and no error anywhere. Past that,
virgl_video_init() rejects any VA driver whose vendor string lacks
Mesa Gallium, which I overrode with a
patch
on the reading that it looked conservative.
That reading was wrong. With both overrides the guest advertises three H.264 profiles and decodes nothing:
VIRGL-VIDEO-EVIDENCE decode_bitstream=2048 failed=0 last_err=0
ERROR end picture failed, err = 0x17 # VA_STATUS_ERROR_DECODING_ERROR
nvEndPicture cuvidDecodePicture failed: 1 # CUDA_ERROR_INVALID_VALUE
Every vaCreateBuffer and vaRenderPicture succeeds and vaEndPicture, the
call that commits the decode, is rejected for every frame. virglrenderer’s
h264_fill_slice_param() sets two fields and leaves the rest commented out,
which looks like laziness and is not. The data is not on the wire. The
protocol’s virgl_h264_picture_desc carries exactly one slice-related field:
uint32_t slice_count;
A slice is an independently decodable chunk of one H.264 picture, and a decoder
needs its size, offset, type, and first macroblock to find it in the bitstream.
The virgl video protocol was designed against Mesa’s VA drivers, which hand the
whole bitstream to hardware that parses slice headers itself. NVDEC does not
re-parse. nvidia-vaapi-driver builds CUVIDPICPARAMS from the VA slice
parameters, gets zeros where offsets and sizes belong, and cuvidDecodePicture
correctly rejects it. Upstream’s “only supports mesa va drivers now” is an
accurate statement about what the protocol can express. Lifting it means
extending the virgl video wire format to carry per-slice parameters across guest
Mesa and virglrenderer for every codec, which is a protocol change rather than a
patch.
Vulkan Video has no such gap, and Firefox compiles it in unconditionally:
# toolkit/moz.configure
set_config("MOZ_ENABLE_VULKAN_VIDEO", True, when=toolkit_gtk)
The V4L2 decoder I had been using before, V4L2 being the Video4Linux2 kernel
media interface, is gated to target.cpu in ("arm", "aarch64", "riscv64") on
the same file. One of those needs a browser fork and one does not.
The three forks
I give each fork a base/<rev> tag at its seed commit and do the work on a
vulkan-video branch, so it stays rebaseable and upstreamable. Every link below
is pinned to a commit rather than a branch. I left long comments in the code
explaining why each change is shaped the way it is, and those comments are the
real reference.
| Upstream | Fork, pinned |
|---|---|
virgl/venus-protocol |
vicondoa/venus-protocol-vulkan-video @ f81cb96 |
virgl/virglrenderer |
vicondoa/virglrenderer-venus-vulkan-video @ add87c0 |
mesa/mesa |
vicondoa/mesa-venus-vulkan-video @ 848ed88 |
The wire protocol. Venus command IDs are explicitly assigned in
VK_EXT_command_serialization.xml rather than derived from position, and the
wire format version must not change, because Venus requires exact guest and
renderer equality. At the base revision 345 command types were assigned; the
thirteen video commands append at 346 through 358 and no existing value moves.
The commits are
protocol list,
StdVideo definitions and flag packing,
codec struct serialization,
and
capping guest-controlled array counts.
StdVideo is Vulkan’s name for the structs carrying codec-defined syntax elements
rather than Vulkan ones, so a StdVideoH264SequenceParameterSet is the H.264
spec’s sequence parameter set with its field names intact. Those are full of
packed bitfields whose layout the C compiler chooses, which is why they cannot
be memcpy’d across a wire that two toolchains might compile. The last commit
matters more than it looks: every array count in a video struct arrives from an
untrusted guest, and capping them is the difference between a protocol extension
and a way out of the VM.
The guest driver. I gave Mesa’s Venus ICD nine video entrypoints in
src/virtio/vulkan/vn_video.c,
covering session creation, session parameters, and memory binding, plus four
command-buffer entrypoints in vn_command_buffer.c:
vn_CmdBeginVideoCodingKHR(), vn_CmdEndVideoCodingKHR(),
vn_CmdControlVideoCodingKHR(), and vn_CmdDecodeVideoKHR(). Exposure is
conditional on the renderer, so the extension bits are set only alongside a
renderer_extensions.KHR_video_queue check, and
VkQueueFamilyVideoPropertiesKHR is chained into the queue family query so a
guest sees per-family codec operations rather than a video queue that decodes
nothing. Decode only, H.264 only, no encode.
The host renderer. I gave virglrenderer
src/venus/vkr_video.c,
about 540 lines, plus vkr_video_validate.h, vkr_video_reject.h, and
vkr_video_scrub.h. Those validate the decode scope, the DPB slots, the
capabilities, and the command sequence on the way in, and scrub reply payloads
on the way out. The DPB is the Decoded Picture Buffer, the set of
already-decoded frames the codec may reference when decoding the next one, and a
guest that can name slots outside it can make the host read memory it should
not.
Scrubbing the video format query reply flags
and
zeroing output pNext payloads on the format-query reject
both exist to avoid leaking host state back to the guest on a failed path.
With those three in place vkCmdDecodeVideoKHR fired, the sessions came up, and
I saw activity on the host NVDEC engine.
The green frame
Firefox then played about half a second of video and showed a flat dark green rectangle. Decode was fine throughout. Presentation was broken, and the two are different stacks: decode is Vulkan through Venus, presentation is OpenGL through virgl, where the decoded frame is exported as a DMA-BUF and re-imported as GL textures for the compositor. A DMA-BUF is a kernel object that lets one driver hand a buffer to another as a file descriptor without a copy.
A decoded NV12 frame is one multi-planar VkImage backed by one
VkDeviceMemory, because vkCmdDecodeVideoKHR takes a single
dstPictureResource. NV12 stores brightness and colour in separate planes:
offset 0 luma 1280x720, one byte per texel, stride 1280
offset 983040 chroma 640x360, two bytes per texel, stride 1280
983040 is 1280x768, so luma is padded to a 768-row alignment and chroma begins
beyond the end of luma’s own 921600-byte extent. That shape is not negotiable.
Two separate images breaks decode, and disjoint planes via
VK_IMAGE_CREATE_DISJOINT_BIT would work but appear nowhere in ffmpeg’s
hwcontext_vulkan.c, and changing the client is the thing I am refusing to do.
Firefox imports the two planes separately, one EGL image each, EGL being the interface a driver uses to import buffers. That is the portable approach that works on every native driver. Its log reports both imports succeeding with every field correct. The two file descriptors refer to regions of one buffer, so in the guest they resolve to the same GEM handle, the kernel’s process-local integer naming one buffer object. Four defects sat between that log and a correct picture.
1. The plane index did not survive the import.
virgl_drm_winsys_resource_create_handle()
caches imported buffer objects by GEM handle, so both planes hit the same cache
entry and nothing recorded which plane each import was. Every plane presented as
plane 0 of a fresh buffer. That index is load bearing, because virgl_encode.c
writes res->metadata.plane into the field virglrenderer reads to select a
per-plane image. Fixed by recording import offsets per buffer object in
first-seen order, where the index of an offset is the plane index.
2. The second import could not be described at all. The same function
assigns the caller’s *blob_mem only on the path that queries RESOURCE_INFO,
so on a cache hit the caller’s value stayed 0 from its CALLOC. blob_mem
records which memory scheme a virtio-gpu blob resource uses, and the caller uses
exactly that value to decide whether to describe the resource to the host. Every
import sharing a buffer object with an earlier one silently declined to describe
itself. Measured directly: chroma arrived with blob_mem=0 where luma had
blob_mem=2.
3. A wider description never left the guest. With those fixed, the driver could describe the buffer as the planar whole, and the winsys dropped it, because it only forwards a description while the resource is still untyped. That is right for a retype and wrong for a plane. A description covering more planes than any before it names part of the same buffer that nothing has described yet. Fixed by tracking the widest plane count already described and letting a wider one through.
4. The host built no image for the extra plane, then built it wrong.
virglrenderer already had per-plane images and already selected them by index in
vrend_create_sampler_view(), and nothing connected the two halves. Upstream
only builds those images from a gbm_bo, and crosvm initialises virglrenderer
with surfaceless EGL and no GBM (Generic Buffer Management) device, so
egl->gbm is NULL and virgl_egl_aux_plane_image_from_gbm_bo() cannot serve
here at all. The replacement
imports the plane straight from the DMA-BUF
with the per-plane stride and offset the guest sent.
That last one had two problems stacked behind it. The fourcc, the four-character
code naming a pixel format, has to be one the driver accepts, and a two-channel
8-bit plane has two DRM spellings differing only in which byte is named first.
Querying this host’s EGL returned RG88=1 and GR88=0, so importing chroma as
GR88, the semantically correct spelling, failed outright. Firefox carries the
same substitution for the same reason. The fix is to
try both spellings.
Then the plane view has to be resolved first: the guest encodes the plane index
in the field that otherwise reads as first_layer, so a plane view arrives
looking like a request for layer N of a single-layer texture, and the
texture-view branch validated it as a layer range and rejected it, poisoning the
context.
Testing for an auxiliary plane image before the texture-view branch
fixes it, and is safe because an auxiliary image exists at an index only for a
resource whose planes were imported separately.
The commits are describe further planes of a shared buffer on import, let a wider plane description reach the host, build a per-plane image for further planes, import a chroma plane with a fourcc the host accepts, and resolve a plane view before the texture-view branch. Each defect alone produces a wrong picture, which is why every partial fix relocated the symptom instead of removing it.
The presentation path depends on the VA-API dead end
Firefox has three routes from a decoded frame to the screen. Direct export hands the Vulkan image out as a DMA-BUF. GPU copy blits the imported surface plane by plane into textures Firefox owns. Zero copy hands the imported surface to the compositor and lets it sample from the decoder’s own memory.
The defect 4 fix lands in vrend_create_sampler_view(), and only the zero-copy
route goes through a sampler view. The copy route blits from the resource’s own
texture, never reaches the per-plane image, and stays broken regardless. So the
working configuration has to select zero copy.
Firefox configures HW_DECODED_VIDEO_ZERO_COPY only after
gfxPlatformGtk::InitPlatformHardwareVideoConfig() gets past an early return
requiring the generic HARDWARE_VIDEO_DECODING feature, and that feature is
decided by a VA-API probe. With zero copy unconfigured,
VideoFramePool::ShouldCopySurface() returns true unconditionally and every
frame takes the broken copy path. The VA-API work that failed at decoding is
what opens the presentation path: the guest advertising three H.264 profiles is
what the probe reads. InitHWDecoderIfAllowed() then tries
InitVulkanDecoder() before InitVAAPIDecoder(), so Vulkan Video still decodes
every frame.
I want to be honest about how good that is. Before this, the lab set
gfx.blacklist.hardwarevideodecoding to 1 to skip the probe outright, which
was a lie told to the browser. That pref is gone and Firefox reaches its own
conclusion from what the driver reports. But the guest’s VA-API decode is
hollow, so the position moved from asserting a capability the guest does not
have to relying on one the guest advertises but has not been shown to possess.
The residual risk is narrow and real: if Firefox ever ordered VA-API ahead of
Vulkan, it would be selecting on an advertisement I have measured against. It
does not do that today.
Four other things that had to be true
VN_DEBUG=no_nvidia_drm_spoof is required. Venus zeroes out
VkPhysicalDeviceDrmPropertiesEXT on NVIDIA hosts as a window system
integration workaround, and Firefox reads that same property for an unrelated
decision. Removing the flag returns 1820 CmdSubmit3d refusals.
ffmpeg 8 has to be explicitly on LD_LIBRARY_PATH. ffmpeg 7’s
vulkan_map_to_drm() waits on a number of semaphores equal to the plane count
while the f->sem[] array it reads them from is sized by image count, so an
NV12 frame’s sem[1] is VK_NULL_HANDLE and the wait faults. Firefox already
prefers libavcodec.so.62 when it can find it, and the nixpkgs wrapper
hardcodes ffmpeg_7, so .62 was never on the path.
Venus’s vn_GetMemoryFdKHR() needs a null guard. Exporting memory allocated
without an export handle type dereferenced NULL in the guest ICD, which a debug
assert catches and a release build turns into a dead process.
Two format-table entries were missing. DRM_FORMAT_GR88 maps to
PIPE_FORMAT_RG88_UNORM, absent from virgl’s table and so resolving to
VIRGL_FORMAT_NONE, and virglrenderer’s GBM conversion table had no entry for a
two-channel 8-bit plane. Both are genuine gaps and both are kept, though neither
fixed anything on its own.
What did not work
V4L2 was my previous approach and it still works, through a patched crosvm
virtio-media device forwarded by a patched Cloud Hypervisor to a VA-API
backend. It has two problems. Decode is a completely separate path from
rendering, so decoded frames are not first-class GPU images on the same device
as everything else. More importantly Firefox does not use it without a fork,
because MOZ_ENABLE_V4L2 is gated to arm, aarch64, and riscv64. My
firefox-v4l2-nvidia fork
carries four patches, three of which exist only to defeat gates upstream put
there deliberately. That fork can now be deleted, which was the point.
Direct export does not work, for a reason outside my code. Firefox only ever
requests the modifier-tiled export shape, and that exact query, NV12 with
VIDEO_DECODE_DST and a DMA_BUF handle type, is refused by the host NVIDIA
driver. NV12 offers exactly one modifier there, LINEAR, and it comes without
VIDEO_DECODE_OUTPUT.
Teaching the blit to find the later plane is the first idea anybody has, since
the copy path is only broken because it cannot reach the per-plane image. It
does not work, and the reason is the interesting part: with zero copy off, the
guest never imports the chroma plane as a separate resource at all. That run
produced 6482 plane=0 imports and zero plane=1 imports, so plane-index
inference correctly returns -1 and the blit fails for the original reason.
Fixing the copy path means changing what gets imported rather than what the blit
looks up. That is the one loose end I would most like to close, because it would
remove the dependency on zero copy and therefore on the VA-API probe.
Where it landed
On a clean boot, with unmodified Firefox:
plane image failures : 0
layer-validation err : 0
BLIT failures : 0
CmdSubmit3d refusals : 0
Illegal cmd buffer : 0
renderer decode : decode_cmds=2048 sessions=1
frames : 810 total, 6 dropped
host NVDEC engine : nonzero in 35 of 35 samples
picture : 12,619 distinct colours, mean RGB 152,158,158
YouTube works, including its adaptive bitrate ladder: playback switched from
854x480 to 1280x720 mid-stream and the decode path rebuilt the decoder for the
new resolution without a new failure. That result comes with a permanent pin
attached. WebM is disabled by policy in the guest, so YouTube serves H.264 in
MP4, which is the only codec Venus carries today. The driver here does ship
VK_KHR_video_decode_vp9, so VP9 is deferred rather than blocked, but Turing,
the generation this T1000 belongs to, has no AV1 decode engine at all.
Firefox’s fallback to software is completely silent, so “the video played”
proves nothing. decode_cmds is the number that carries the claim, because a
negative control with the Vulkan decoder disabled establishes that it reads 0.
Reading the NVDEC percentage needs the same care: about 3% is what 720p30 costs
in real time on a T1000, and the contrast is what carries it, since a
software-decoding Firefox reads a flat 0 on every sample while the same clip
decoded as fast as possible reads 98% against 99% host-native.
I am not publishing a frame-drop figure, because I do not have an honest one.
The numbers I have were taken with seven other VMs and several rustc jobs
saturating the host. Whether 720p is clean on a quiet machine is unmeasured.
This is a prototype
Everything above runs in an isolated lab, not in anything I depend on. It is a
single NixOS flake that builds the three forks, the patched crosvm and Cloud
Hypervisor, and a guest image with stock Firefox in it, and it launches one VM
on demand. The lab is
labs/venus-vulkan-video
in my d2b repository, and
SOLUTION.md
there is the full account, including the findings documents for the paths that
failed.
None of it is upstream, none of it has been reviewed by anyone who maintains these projects, and the security-relevant parts, meaning the validation and scrubbing around a wire format a guest controls, have had exactly one set of eyes on them. The forks are structured to be rebaseable and upstreamable because that is where this should go, and it has not gone there yet.