Vulkan queue family ownership transfer explained

Two different queues — a compute queue and a graphics queue — can’t freely share a resource. A buffer written by queue family A read by queue family B isn’t just a matter of “the semaphore says it’s done”: the driver may have cached the memory as seen by family A, and family B literally can’t see the contents without an explicit ownership transfer.

The concept

ownership.txt
exclusive (default):
  graphics queue writes buf → buf is OWNED by the graphics family
  compute queue reads buf   → UNDEFINED unless ownership was released
                              by graphics and acquired by compute

concurrent (sharingMode = CONCURRENT):
  all listed families share the resource simultaneously — no transfer
  needed, but the driver may pick a slower/coherent memory path

The transfer ceremony

A transfer is two barriers, one on each queue:

transfer.cpp
// Graphics command buffer — RELEASE the buffer to compute family:
vk::BufferMemoryBarrier release{};
release.setSrcQueueFamilyIndex(graphicsFamily)
       .setDstQueueFamilyIndex(computeFamily)
       .setSrcAccessMask(vk::AccessFlagBits::eShaderWrite)
       .setDstAccessMask({})          // release doesn't transfer reads
       .setBuffer(buf)
       .setOffset(0).setSize(VK_WHOLE_SIZE);
gfxCmd.pipelineBarrier(vk::PipelineStageFlagBits::eComputeShader,
                       vk::PipelineStageFlagBits::eBottomOfPipe,
                       {}, {}, release, {});

// Compute command buffer — ACQUIRE the buffer:
vk::BufferMemoryBarrier acquire{};
acquire.setSrcQueueFamilyIndex(graphicsFamily) // SAME queue pair
       .setDstQueueFamilyIndex(computeFamily)
       .setSrcAccessMask({})          // acquire doesn't re-declare writes
       .setDstAccessMask(vk::AccessFlagBits::eShaderRead)
       .setBuffer(buf)
       .setOffset(0).setSize(VK_WHOLE_SIZE);
cmpCmd.pipelineBarrier(vk::PipelineStageFlagBits::eTopOfPipe,
                       vk::PipelineStageFlagBits::eComputeShader,
                       {}, {}, acquire, {});

Rule of thumb: release on the old family, acquire on the new family, both with the same src/dst queue pair. Only the first barrier is a release, the second is an acquire — the semaphore between them just makes sure the release happened before the acquire on the GPU.

order.txt
gfxQueue  ──[release]──►  semaphore signal  ──►  cmpQueue ──[acquire]──► use

Why CONCURRENT is tempting and when it’s fine

concurrent.cpp
vk::BufferCreateInfo bci{};
bci.setSharingMode(vk::SharingMode::eConcurrent)
   .setQueueFamilyIndices({graphicsFamily, computeFamily});

CONCURRENT skips the transfer entirely. It costs nothing extra on desktop GPUs where all queues share a cache hierarchy. On tile-based mobile GPUs it can force a slower, coherent memory pool — still fine for most cases. Start with CONCURRENT; add transfers only when profiling shows the concurrent path is the bottleneck.

Images need the layout transfer too

For a VkImage, ownership transfer isn’t just the queue pair — it must carry a layout transition (even eGeneral → eGeneral counts). The release barrier transitions out of the old queue’s view, the acquire transitions in — and both list the same old/new layout pair.

image-transfer.txt
graphics: barrier img, old=TransferSrc, new=ShaderRead,
          srcQF=gfx, dstQF=compute     // release
compute:  barrier img, old=TransferSrc, new=ShaderRead,
          srcQF=gfx, dstQF=compute     // acquire

The two oldLayout/newLayout fields match on both sides — the “transfer” is encoded in the queue-family indices, not the layouts.

The p63_asynccompute connection

The async-compute example uses exactly this pattern — compute writes a histogram on queue family 1, graphics displays it on family 0. Since both families are from the same physical device and share memory coherency on discrete GPUs, CONCURRENT is a no-op cost — but the semaphore still orders the contents, not the ownership. The transfer becomes load-bearing only if you pick eExclusive (the default).

Example: VulkanExamples/p63_asynccompute

Practical rules

  • eExclusive (default) + manual transfer = most correct, most code.
  • eConcurrent = simpler, usually free on desktop, may be slower on mobile tilers.
  • Never forget the transfer for eExclusive — the debug layer won’t always catch it, and you’ll see silent garbage on the consumer side.
  • Timeline semaphores pair naturally: signal value N on queue A, wait on queue B — but the ownership transfer is separate and still required for exclusive resources.

Check out similar posts by category: Vulkan C++