Skip to content

f_autoconvert: convert between hw formats via system memory - #18300

Open
flowreen wants to merge 4 commits into
mpv-player:masterfrom
flowreen:autoconvert-hw-to-hw
Open

f_autoconvert: convert between hw formats via system memory#18300
flowreen wants to merge 4 commits into
mpv-player:masterfrom
flowreen:autoconvert-hw-to-hw

Conversation

@flowreen

Copy link
Copy Markdown

In short: nvdec produces CUDA frames, d3d11vpp only accepts D3D11 frames, and mpv tries to upload one into the other directly. libavutil cannot transfer between two hardware frame contexts, so it fails on the first frame and the filter is silently disabled, leaving --vf=d3d11vpp usable only with --hwdec=no. This routes the frame through system memory instead, using the download stage build_image_converter() already has but never built for this case.

Background

This came out of trying to make d3d11vpp usable with --gpu-api=vulkan, which someone ran into here: https://www.reddit.com/r/mpv/comments/1v6msec/do_i_need_to_change_config_to_enable_rtxhdr_in_mpv/

That is one user report rather than a groundswell, but the underlying bug here is not specific to that filter or to Windows: any hw-only target fed by a different hw decoder hits it. The d3d11vpp half is sent separately; this one is the generic part and stands on its own.

Problem

When the target only accepts hardware formats and the decoder produces a different hardware format, build_image_converter() hands the frame straight to mp_hwupload. libavutil cannot transfer between two hardware frame contexts, so it fails on the first frame:

[hwupload] upload cuda -> d3d11[nv12]
[hwupload] failed to upload frame
[vf] Disabling filter d3d11vpp.00 because it has failed.

Reproducer on Windows with an NVIDIA GPU:

mpv --gpu-api=vulkan --hwdec=auto --vf=d3d11vpp=nvidia-true-hdr FILE

--hwdec=auto selects nvdec, and those CUDA frames cannot reach the D3D11 video processor. Playback continues with the filter silently disabled.

Fix

The function already declares three stages, and mp_chain_filters() wires all of them:

// 0: hw->sw download
// 1: swscale
// 2: sw->hw upload

but the dst_all_hw branch hardcoded hw_to_sw = false, so stage 0 was never built. Request the download when the source is a hardware format, and stop suppressing the scaler in that case, since the trip through system memory is what makes a sw format conversion possible.

This branch is only reached when the source's hw format is not among the accepted targets, so the direct transfer it attempted always failed at runtime. The change cannot regress a path that previously worked.

It also removes the check that rejected a target whose upload format differed from the source sub format. That check assigned filters[2] and shrank num_fmts before continue, so the loop always ended with upload_created == false while the rejected uploader stayed chained. It logged "Failed to create HW uploader" and then used it anyway.

Testing

Windows 11, RTX 5090, driver 610.74, --vo=gpu-next.

Swept every decoder this machine can produce with --gpu-api=vulkan --vf=d3d11vpp=nvidia-true-hdr: no, auto (nvdec), auto-copy, nvdec, nvdec-copy, dxva2, dxva2-copy, d3d11va, d3d11va-copy, vulkan. All produce correct output. Before this change the CUDA and Vulkan sources failed. That is two distinct cross-device pairs: CUDA to D3D11, and Vulkan to D3D11.

No new copies on paths that were already zero-copy: d3d11 + d3d11va with and without the filter, vulkan + nvdec without the filter, and d3d11 + software decode all still report zero HW-downloading / HW-uploading.

Verified at pixel level rather than only from logs: the same source frame captured through each path gives SSIM 1.000000 and PSNR 95 dB against the d3d11 reference, and the two Vulkan captures are byte-identical to each other. A no-filter control sits at PSNR 28 dB, so the comparison is not vacuous.

Not tested: the vaapi and videotoolbox paths through this branch, as I have no hardware for either. Vulkan to D3D11 working suggests the mechanism is generic rather than CUDA-specific, but I cannot claim more than that.

I do not know this codebase's conventions well, so if you would prefer a different approach, a narrower fix, or this split differently across commits, say so and I will rework it.

AI disclosure

Disclosure: this patch was written with AI assistance (Claude Code), which diagnosed the bug, wrote the change, and ran the testing described above on my machine. I have reviewed it and understand what it changes and why, I take responsibility for it, and review responses will be written by me. filters/f_autoconvert.c carries no licence header and so falls under the repository default of LGPLv2.1+; I submit the change under that licence.

@philipl

philipl commented Jul 26, 2026

Copy link
Copy Markdown
Member

You should be able to sequence it using the format filter to first implicitly convert by specifying format=nv12 or whatever the right one is, and then format=d3d11va (or whatever we call it).

You could also ask Claude to implement the cuda d3d interop path. CUDA supports it but no one has been motivated to do it so far.

@kasper93

kasper93 commented Jul 26, 2026

Copy link
Copy Markdown
Member

I'm not a big fan of round-tripping transfer through system memory silently. Depending of image resolution and framerate this can be a lot of bandwidth that can silently kill the performance on lower end gpus.

You should be about to export frame from CUDA, Vulkan, D3D12 as win32 handle and import that directly to d3d11, it will still do copy, but hopefully the GPU->GPU one.

Also you can already use nvdec with d3d11vpp, like so --hwdec=nvdec-copy --vf=format=d3d11 --vf=d3d11vpp, which basically does the round-trip through cpu memry.

@flowreen

flowreen commented Jul 26, 2026

Copy link
Copy Markdown
Author

Good idea, and you were both right that the round trip should not be the default. It turns out it can be done like this instead: the first version routed through system memory whenever the source was a hw format, which also meant a pair that could be mapped would never get the chance to. This version only falls back to system memory when the pair has no mapping available.

It now decides per candidate target:

bool download_first = !imgfmt_is_sw &&
    !mp_hwupload_can_map(fmts[i], img->imgfmt);

with mp_hwupload_can_map() reading the existing hwmap_pairs table, so system memory is the fallback when no mapping exists rather than the behaviour.

Before, with --hwdec=vulkan --vf=d3d11vpp, silent and unconditional:

[autoconvert] HW-uploading to d3d11
[autoconvert] HW-downloading from vulkan
[hwupload] upload nv12 -> d3d11[nv12]

After:

[autoconvert] Transferring vulkan to d3d11 through system memory. These are
separate devices and cannot share frames, so every frame is copied twice.
Use a decoder that outputs d3d11 to avoid this.
[autoconvert] HW-uploading to d3d11
[autoconvert] HW-downloading from vulkan

For reference, on master with --hwdec=auto the same command gives:

[hwupload] upload cuda -> d3d11[nv12]
[hwupload] failed to upload frame
Disabling filter d3d11vpp.00 because it has failed.

Based on Claude investigation, it found that hwmap_pairs currently holds only the VAAPI and DRMPRIME pairs, so on Windows the new gate never fires and the visible difference is the warning. On Linux it means a vaapi to vulkan source keeps its zero copy map rather than being routed through system memory. I have no vaapi hardware, so that is from the code rather than from a run.

It also confirmed the paths that were already zero copy are untouched and stay silent: d3d11 + d3d11va still goes d3d11[nv12] to d3d11[x2bgr10] with no downloads.

Testing it ran: 2x and 3x d3d11vpp chains, five runtime vf changes on both backends, and a decoder sweep over no, auto, auto-copy, nvdec, nvdec-copy, dxva2, dxva2-copy, d3d11va, d3d11va-copy and vulkan, no failures.

@kasper93 on --hwdec=nvdec-copy --vf=d3d11vpp: it found that this works, and format=d3d11 is not needed since nvdec-copy already outputs system memory frames. But it is the same round trip, applied in the decoder to every frame unconditionally, rather than only when the chain has no other option.

@philipl on the cuda/d3d interop suggestion: it did not get there. What it did get is the prerequisite, device derivation for D3D11VA, which lavu does not implement at all:

flowreen/FFmpeg@dd56c90

That is device level only. It shares no frames and removes no copies, it just puts both devices on the same adapter. I plan to send it to ffmpeg-devel, but would rather hear any objections here first.

It found the interop itself blocked in two places: lavu's vulkan_map_to and vulkan_map_from are both behind CONFIG_LIBDRM, so on Windows they support nothing at all, and AVVkFrame has no array layer field, so a D3D11 texture array cannot be represented, only ArraySize == 1.

(As I understand it: D3D11 hands out a frame as one slice of a stack of pictures, saying "here is the stack, take slice 3". The Vulkan side has nowhere to write down "slice 3", it can only point at a whole picture. So the only D3D11 texture it can accept is one holding exactly a single frame.

A fix I have not attempted: lavu's dynamic d3d11va pools already allocate one texture per frame through d3d11va_alloc_single(), so a map could support ArraySize == 1 and return ENOSYS for arrays. That would cover filter output pools, while decoder pools, which are allocated as arrays, keep falling back to the existing path. Covering arrays properly looks like it would mean adding an array layer field to AVVkFrame, which is a public struct change and a much bigger ask.)

It would also not remove the need for this branch regardless: D3D11 interop is one directional. Vulkan can import a D3D11 texture, D3D11 cannot import Vulkan memory. So vulkan to d3d11 has no mapping in that direction at all, and something has to carry it.

@philipl

philipl commented Jul 26, 2026

Copy link
Copy Markdown
Member

You'll have to spend some time thinking about what design actually makes sense here. I don't know if Claude if capable of doing a good job on its own; my experience suggests it probably would not, and the comments reflect that.

The cuda <-> vulkan interop is an example of one where there is a one-way import restriction. Cuda cannot export, it can only import - so the model is that vulkan always exports an image and then cuda imports it, and if the data needs to flow from cuda to vulkan, cuda does a GPU-side memcpy. And, in fact, even when data is flowing from vulkan to cuda, we do a copy anyway for consistency. GPU-side memcpys are cheap. So if d3d11 requires a one-way relationship, you'd probably want to handle it in the same way. You should look at/point Claude at the cuda interop API docs. Between that and the existing ffmpeg code, there should be enough general guidance. The majority (all?) of the work required here is on the ffmpeg side.

As for whether any of the resulting code would be accepted, I don't know. ffmpeg doesn't have an official policy, but in practice this means "If the code meets the project standards and you can communicate about it showing an actual understanding of what you're doing, then who cares what tools you use". I would not consider what we have right now as meeting that bar, but I wouldn't be the one reviewing or approving any resulting ffmpeg PR.

The fundamental d3d11 (or 12) interop with Vulkan and Cuda is interesting, and on some level long sought after, but it's a messy exercise that clearly hasn't motivated anyone sufficiently over the years.

@kasper93

Copy link
Copy Markdown
Member

You'll have to spend some time thinking about what design actually makes sense here. I don't know if Claude if capable of doing a good job on its own; my experience suggests it probably would not, and the comments reflect that.

I agree, it may not be able to contextualize everything that's going on, let alone see the sane solution, except surface level glue/workarounds. Our frame copy/mapping/conversion code is already quite complex in what it does, and it's easy to get lost in it, with all the corner cases.

The cuda <-> vulkan interop is an example of one where there is a one-way import restriction. Cuda cannot export, it can only import - so the model is that vulkan always exports an image and then cuda imports it, and if the data needs to flow from cuda to vulkan, cuda does a GPU-side memcpy.

This is more complex interop, and basically needs not only "convert" code work, but also pre-existing knowledge in frame allocator. Though, it's not really nothing special. Most of interop need to have frames/memory allocated in certain way that is compatible with "exporting" or "sharing".

The fundamental d3d11 (or 12) interop with Vulkan and Cuda is interesting, and on some level long sought after, but it's a messy exercise that clearly hasn't motivated anyone sufficiently over the years.

I always prefer to have same device pipeline, without sharing. I rectal trying to evaluate d3d12 hwdec use directly to our d3d11 renderer, but it was slow. I don't recall now if this was only GPU-GPU copy, or something more, but was disappointed in the result, where native d3d11 was faster in this case and better.

You should look at/point Claude at the cuda interop API docs. Between that and the existing ffmpeg code, there should be enough general guidance. The majority (all?) of the work required here is on the ffmpeg side.

Yeah, most would be on ffmpeg side, maybe with an API to request sharing between certain apis up front. Though, I have not though about what is available now. I don't want to be this guy, but since we have multiple APIs, which all could be interoperable, this could be done in generic way, with some abstraction, maybe exiting ffmpeg API is enough. But the point being, that we should think about extensibility, not saying that every interop needs to be implemented, but most of the logic is in fact common between those APIs, they often use same underlying primitive to share memory. Anyway, I know nothing, I have not did into just now.

flowreen added 4 commits July 26, 2026 16:26
Shareable textures cost nothing and allow libavutil to transfer frames
directly to another device, for example d3d11 to vulkan.
libavutil can transfer frames between two hardware devices directly on
the GPU only for some device pairs and surface formats, and there is no
way to query support other than trying it. Attempt the direct transfer
and fall back to downloading and reuploading the frame, remembering the
result. Previously a failing direct transfer just failed the filter.

Also add mp_hwupload_probe_hw_to_hw(), which tests with an actual frame
whether an upload target can be reached without a copy through system
memory, either by mapping or by a direct transfer.
The RA format description is only needed by vo_gpu. A libplacebo based
VO ignores it and maps planes by their Vulkan format, which works for
bit packed formats like X2BGR10 that ra_get_imgfmt_desc() cannot
express. Keep failing for other RAs.
When a hw source has to be converted to a different hw format, build
the chain around the uploader instead of rejecting the format. The
frame goes through system memory only when a sw format conversion is
needed, and when sw targets are also available the hw target is
preferred only if a test transfer shows the frame can actually stay on
the GPU.

Previously this case either failed outright, because a direct transfer
between two devices was attempted and libavutil could not do any, or
the frame was downloaded even when the two devices could share it.
@flowreen
flowreen force-pushed the autoconvert-hw-to-hw branch from c0e65d2 to e0bf0bc Compare July 26, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants