Skip to content

feat: adopted device encoding with fixes - #45

Merged
hgaiser merged 14 commits into
hgaiser:mainfrom
DatCaptainHorse:feat/adopted-encode
Oct 19, 2026
Merged

hgaiser merged 14 commits into
hgaiser:mainfrom
DatCaptainHorse:feat/adopted-encode

Conversation

@DatCaptainHorse

Copy link
Copy Markdown
Contributor

Part 2/3 of the #41 split, again with fixes and improvements compared to original PR.

  • pixelforge can be given pre-existing Vulkan device to encode with, like with decode before, allowing seamless zero-copy (and non-DMA-BUF) resource usage.
    • new example encode_adopted shows usage example.
    • Vulkan 1.1 is minimum required version for applications where device is adopted from.
  • new with_xxx_queue methods to allow applications to choose where sbmits go, can help with performance when rendering queue is separate from encoding/decoding given ones (so they won't race each-other or slow each-other down).
  • New ColorConverter::convert_async and Encoder::encode_after methods for more efficient GPU-driven work rather waiting on CPU. Useful in cases where adopted device comes from application that can't be controlled (i.e. hooking into a game as a Vulkan layer).

Resolves #35

Tested on RX 9060 XT, Arc A310 and additionally cloud-rented RTX 6000 Ada with both 580 and 615 drivers.

DatCaptainHorse and others added 14 commits October 19, 2026 15:29
A context adopted from a caller's device took queue 0 of every family it
needed. On most hardware queue 0 of the transfer and compute families is
the caller's own graphics queue, and a VkQueue may not be submitted to
from two threads at once. A renderer that kept drawing while pixelforge
decoded on another thread was racing it on its own queue, with no way to
avoid it short of a lock around every submission on both sides.

The builder now takes a DeviceQueue (family and index) per role, so the
caller can create spare queues and hand those over. Each choice is checked
against what the role needs and against how many queues the family offers;
whether the caller actually created that many is not something Vulkan
reports, so that part stays a documented requirement.

DeviceRequirements now also says which family serves which role, so the
caller knows where a spare queue is worth creating, and whether the device
supports VK_KHR_internally_synchronized_queues. That extension is the other
way out, on hardware with no queue to spare: a queue created with its flag
may be shared, the driver doing the locking. Pixelforge needs no part in
it, so it is reported rather than required.

decode_adopted now keeps queue 0 to itself wherever a family has a second
queue, which also puts the non-zero-index path under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pixelforge called vkQueueSubmit2, vkCmdPipelineBarrier2 and vkCmdCopyImage2
through their core entry points, which exist only when the instance asked
for Vulkan 1.3. A device adopted from an application that asked for 1.1 or
1.2 has them null, and ash turns the first call into a panic. Nothing in the
adopt API said 1.3 was required, and an application sharing its device has
no reason to raise its API version for a library.

The sync2 commands now go through VK_KHR_synchronization2's own table, which
is valid on any device with that extension enabled; pixelforge required the
extension already. The one image copy uses vkCmdCopyImage, since it needs
nothing the newer entry point adds. The decode requirements now list
VK_KHR_timeline_semaphore as well, for the same reason: on a 1.1 device the
timeline semaphores the decoder relies on do not exist without it.

The new adopted-device test builds a Vulkan 1.1 instance and device the way
such an application would, hands pixelforge spare queues, and checks every
decoded frame against a context pixelforge created itself on the same GPU.
It needs no ffmpeg. Without this change it panics on the first barrier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decoding could already run on a device the caller created; encoding could
not, so an application that renders on its own device and wants to encode
the result had to give pixelforge a device of its own and move every frame
across. That means exporting each image as external memory, importing it on
the other device, and waiting on the CPU between the two, since two devices
share no timeline a semaphore could use.

encode_device_requirements() and build_from_existing_encode() are the encode
counterparts of the decode pair. The requirements cover the encoder and the
colour converter: the video encode extensions for the codecs asked for (or
every codec the device encodes, if none were), push descriptors for the
converter, and the 2-plane 4:4:4 formats where the device has them.

Features are now reported too, in DeviceRequirements::features, as booleans
rather than a pNext chain. A caller merging them into its own device may
already chain VkPhysicalDeviceVulkan13Features, and Vulkan forbids chaining
that alongside the per-feature structs it contains, so pixelforge names the
bits and leaves the structs to the caller.

The queue-family scan the decode path used is shared with encode rather than
copied, and the adopted-device test gains an encode case: a Vulkan 1.1
device, spare queues, RGB frames through the converter into the encoder, and
the stream checked against one encoded by a context pixelforge created
itself. encode_adopted is the example counterpart of decode_adopted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ColorConverter::convert submitted its dispatch and then waited on a fence,
and Encoder::encode had no way to wait for anything the caller submitted.
That was harmless when every frame crossed from another device anyway,
since that crossing needed a CPU wait of its own. On a shared device it is
the last CPU round trip left in the frame: the caller's copy, the
conversion and the encode can all be ordered by semaphores instead.

TimelinePoint names a timeline semaphore value. convert_async() waits for
the points it is given, submits, and returns the point its own timeline
reaches when the conversion is done; encode_after() makes the frame's first
submission, the upload copy if there is one and otherwise the encode, wait
for the points it is given. So a frame goes caller -> convert -> encode
with each step waiting on the previous on the GPU. convert() and encode()
are now those with nothing to wait for, and convert() still blocks until
done, so existing callers see no difference.

External waits hold back ALL_COMMANDS rather than a narrower stage:
pixelforge's submissions open with layout transitions the caller cannot
see, and a wait that let those run early would race the caller's writes.

The converter used to wait for its fence right after submitting; it now
waits at the start of the next conversion instead, before the command
buffer, output buffer or source view are reused or destroyed.

The adopted-device encode test now takes this path: each conversion waits
on a point the host signals only after submitting it, and each encode on
the conversion. On RADV the result is bit-identical to the synchronous
path on a context pixelforge created itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adopted context takes every feature DeviceRequirements::features sets as
enabled, since Vulkan cannot be asked afterwards which were. encode_adopted
built its device by hand and left out videoEncodeAV1, so on a device that
encodes AV1 the context claimed a feature the device never turned on: the
kind of mismatch where the driver parses the structs and ignores them,
which surfaces as wrong output rather than an error. The example only
encodes H.264, which is why nothing failed.

It now sets every feature the requirements ask for, and says why that
matters. The adopted-device test already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both adopted-device tests now call common::init_logging(), so with
validation on their messages reach the test output instead of nowhere.

The encode test also describes its source image as GENERAL, the layout
the shared test source starts in and the converter leaves it in. It still
said SHADER_READ_ONLY_OPTIMAL, from before the colour conversion tests
moved to GENERAL, which the validation layer reports for every conversion
now that the converter uses push descriptors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The crate docs gain a section on encoding on the caller's own device: what
to create it with, why pixelforge should get queues of its own, and how a
frame is ordered from the caller's rendering through conversion to encode.
The features list mentions shared devices, and the examples list gains
encode_adopted. README.md is regenerated from them.

CHANGELOG covers the additions and the lower Vulkan version an adopted
device may use. AGENTS.md says what the adopted-device test checks and
when to run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
one_shot, which uploads the synthetic source image and reads back what
the converter wrote, ran on the transfer queue. Both images are the
converter's, used on its compute queue, and created for one queue family.

On RADV and ANV the transfer and compute queues the tests get share a
family, so this went unnoticed. NVIDIA has a dedicated transfer family:
there the upload's final barrier asked for shader read access on a queue
with no shader stages, which the validation layer reports for every
source image, and the image then moved to the compute family with no
ownership handover. The tests passed anyway, but on no guarantee.

one_shot now records and submits on the compute queue, the family every
image it touches belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ColorConverter destroyed its timeline semaphore when dropped, after
waiting for its own last conversion. But the points convert_async hands
out are waited on by other submissions, an encode_after first of all, and
those can still be pending when the conversion they wait for is done.
Dropping the converter between submitting such an encode and the encode
running destroyed a semaphore a queue was still waiting on, which Vulkan
leaves undefined.

The converter now hands its semaphore to the context, which destroys it
when it goes itself. Every encoder holds the context and waits for its
queues when dropped, so by then nothing of pixelforge's waits on it. A
caller's own submission waiting on such a point has the same rule, which
TimelinePoint now states.

The adopted-device encode test now drops its converter while the last
encode is held back on a point signalled only afterwards. Without this
change that hangs the GPU on ANV; with it the test passes and validation
is quiet.

The in_flight comment also stops mentioning a cached source view, which
the converter no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two ways the encode requirements could disagree with the device the
caller then builds.

Codecs were checked on the encode family the scan picked, while a caller
passing with_encode_queue may choose another. The encode queue is now
settled first, the caller's choice included, and codecs and timestamp
support are checked on its family.

ycbcr2plane444Formats and videoEncodeAV1 were reported as features to
enable whenever their extensions were present. A driver may offer an
extension with its feature off, and a caller enabling it as told gets
VK_ERROR_FEATURE_NOT_PRESENT. Both bits are now queried, AV1 counts as
supported only with its feature on, and the 4:4:4 formats only with
theirs.

Checking for an extension is now one helper, DeviceExtensions, which
enumerates once rather than per question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DeviceRequirements, DeviceFeatures and QueueRoles are only ever built by
pixelforge, and each gains fields as pixelforge learns to use more of a
device. Without #[non_exhaustive] every such field is a breaking change
for a caller that builds or exhaustively destructures one.

BREAKING CHANGE: DeviceRequirements, DeviceFeatures and QueueRoles can no
longer be built with a struct literal or matched without `..` outside
pixelforge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
encode_after hands its waits to the frame's first submission. For the
encoder's own input image that is the encode, and nothing waits on the
CPU. For any other image it is the copy into the input image, which the
encoder waits for on the CPU before recording, so the call blocks until
the caller's work is done. The docs promised no CPU wait without saying
which case that held for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decode_adopted handed pixelforge spare transfer and compute queues but
left decode on queue 0 of its family, while encode_adopted hands over a
spare encode queue. An application that also uses the decode family and
copied the example would share that queue with pixelforge across
threads. It now hands over a spare decode queue as well, which also puts
with_decode_queue under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@hgaiser hgaiser left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks good to me. I removed the adopted examples since I don't consider it necessary to demonstrate the core usage of this crate.

Other than that, looks good 👍

@hgaiser
hgaiser merged commit b0dfec5 into hgaiser:main Oct 19, 2026
7 checks passed
@DatCaptainHorse
DatCaptainHorse deleted the feat/adopted-encode branch October 20, 2026 22:17
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.

ash integration and non owned device

2 participants