simvx.graphics.gpu.queue

One wrapper per unique queue handle

Graphics and present are usually the same underlying queue: a single universal family satisfies both, and vkGetDeviceQueue hands back the same handle twice. Two wrappers around one handle would hold two different locks and serialise nothing, so :func:wrap_device_queues deduplicates on the handle address and hands the same wrapper back for both roles. That is why the wrapping happens in one place rather than at each use site.

Lock ordering

func:

device_wait_idle has to hold every one of a device’s queue locks at once. It takes them in registration order, which is the order

func:

wrap_device_queues created them in: graphics, then present, then compute, then transfer, skipping any that alias an earlier one. That order is fixed for the life of the device (the wrapper set is built once at device creation and never added to), and every acquirer of more than one queue lock goes through this function, so there is exactly one order in which multiple queue locks are ever taken and no cycle can form. If a second multi-queue acquirer is ever written, it must reuse :func:device_wait_idle’s order rather than pick its own, or two threads can take the same pair in opposite orders and hang.

The locks are re-entrant so that a caller already holding one queue’s lock can still reach a device-wide wait without deadlocking against itself. No code does that today; the cost of allowing it is nil and the failure it prevents is a silent hang rather than an exception.

What may run inside a queue lock

Each locked span holds exactly one binding call and nothing else: no logging, no hooks, no retry, no reach back into the renderer. That is what stops a queue lock ever being the outer half of an ordering cycle, and it is deliberate rather than incidental, so keep it that way when adding to this module. An exception raised by a binding unwinds through the with, releasing the lock before any handler runs, which is why the device-loss and out-of-date paths can call :func:device_wait_idle without deadlocking against themselves.

One caller does run Python inside the span, and it is not ours. With validation enabled, the layer invokes the debug-utils messenger synchronously on the submitting thread during vkQueueSubmit / vkQueueWaitIdle / vkQueuePresentKHR, so the engine’s callback, and through it the logging module’s handler locks, execute while a queue lock is held. That adds a queue-lock to logging-lock edge on every validated run.

It is safe only while nothing on the simvx.graphics.gpu.instance logger re-enters the engine or blocks on a lock the engine holds. Every handler in this tree satisfies that (the test recorder appends to a list; the rest write to streams), but an application is free to attach its own, and a handler that waited on engine state would close the cycle without touching a line of engine code. Nothing would flag it, which is why it is written down here.

The same reasoning covers garbage collection, which can fire weakref.finalize callbacks on this thread while the lock is held. No finalizer in the tree acquires a queue lock, the frame-state lock or the upload lock today, so no cycle can form through one. That holds by inspection rather than by construction: a new finalizer in this package that reaches a device-wide wait would reopen it, and is worth checking for at review.

Externally-synchronised access to the device’s VkQueue handles.

Vulkan makes host access to a VkQueue the application’s problem: two threads must never be inside vkQueueSubmit, vkQueueWaitIdle or vkQueuePresentKHR for the same queue at the same time, even with disjoint command buffers, and vkDeviceWaitIdle requires host access to every queue on the device to be externally synchronised. The bindings are cffi in ABI mode and cffi releases the GIL around every C call, so two Python threads genuinely can overlap inside those functions: the interpreter does not stand in for the synchronisation the spec asks for.

This module is where that synchronisation lives. A :class:SubmitQueue owns a queue handle together with the lock that guards it, and it is the only thing the rest of the package is handed: :func:create_logical_device returns wrappers,

class:

~simvx.graphics.gpu.context.GPUContext carries wrappers, and the raw handle is reachable only as :attr:SubmitQueue.handle for the few structures that must be passed one. The point of the shape is that a caller cannot submit without taking the lock, because there is no call that does. A lock that callers must remember to take is a lock that will be forgotten, and this one was forgotten for the whole tick-time upload path before it existed.

tests/test_queue_synchronisation_seams.py keeps it that way: it fails on any raw vkQueueSubmit / vkQueueWaitIdle / vkQueuePresentKHR / vkDeviceWaitIdle written anywhere in the package outside this module.

Module Contents

Classes

SubmitQueue

A VkQueue handle and the lock that externally synchronises it.

Functions

wrap_device_queues

Wrap a device’s queue handles, sharing one wrapper per unique handle.

device_wait_idle

vkDeviceWaitIdle, holding every one of the device’s queue locks.

forget_device

Drop a destroyed device’s wrappers from the registry. Idempotent.

load_present_fn

Resolve vkQueuePresentKHR for instance.

Data

API

simvx.graphics.gpu.queue.__all__

[‘SubmitQueue’, ‘wrap_device_queues’, ‘device_wait_idle’, ‘forget_device’, ‘load_present_fn’]

class simvx.graphics.gpu.queue.SubmitQueue(handle: Any, family_index: int, role: str = 'graphics')

A VkQueue handle and the lock that externally synchronises it.

Args: handle: The raw VkQueue. family_index: The queue family the queue was taken from. role: What the queue is used for, for logging and debugging. A queue serving two roles (the usual graphics/present case) is named for the first, since it is one queue however many names it answers to.

Initialization

__slots__

(‘handle’, ‘family_index’, ‘role’, ‘_lock’)

submit(submits: list[Any], fence: Any = None) None

vkQueueSubmit this batch, holding the queue’s lock across the call.

fence may be None for a submit nothing waits on. Driver errors (VkErrorDeviceLost in particular) propagate to the caller unchanged: the lock is released on the way out, so a device-loss handler is free to take it again.

wait_idle() None

vkQueueWaitIdle on this queue, holding its lock across the call.

This blocks other threads from submitting to the same queue for the duration of the drain, which is stronger than the spec demands but is what makes a submit-then-drain pair mean what its callers assume.

present(present_fn: Any, present_info: Any) Any

Call vkQueuePresentKHR under the lock.

The function pointer is passed in rather than held here because it comes from vkGetInstanceProcAddr and belongs to the instance, not to this queue; :func:load_present_fn is the one place it is resolved. Out-of-date and surface-lost results surface as exceptions from the binding and propagate unchanged.

__repr__() str
simvx.graphics.gpu.queue.wrap_device_queues(device: Any, *, graphics: tuple[Any, int], present: tuple[Any, int] | None = None, compute: tuple[Any, int] | None = None, transfer: tuple[Any, int] | None = None) tuple[simvx.graphics.gpu.queue.SubmitQueue, simvx.graphics.gpu.queue.SubmitQueue | None, simvx.graphics.gpu.queue.SubmitQueue | None, simvx.graphics.gpu.queue.SubmitQueue | None]

Wrap a device’s queue handles, sharing one wrapper per unique handle.

Each role is given as (handle, family_index), or None when the device has no queue in that role. Returns (graphics, present, compute, transfer) wrappers in the same shape. When two roles name the same handle (graphics and present on a universal family, which is the common case) the same wrapper object is returned for both, so they share the one lock that actually guards the one queue.

The wrappers are registered against device in creation order so

Func:

device_wait_idle can find them. Call :func:forget_device when the device is destroyed.

simvx.graphics.gpu.queue.device_wait_idle(device: Any) None

vkDeviceWaitIdle, holding every one of the device’s queue locks.

A device wait externally synchronises all of the device’s queues, so it is itself a queue access on each of them and cannot run concurrently with a submit. The locks are taken in registration order: see the module docstring for why that order is the only one any multi-queue acquirer may use.

A device with no registered wrappers (a raw handle built by a test fixture, or one already torn down) waits without locking, because there is no other thread that could be holding a lock it does not know about.

simvx.graphics.gpu.queue.forget_device(device: Any) None

Drop a destroyed device’s wrappers from the registry. Idempotent.

simvx.graphics.gpu.queue.load_present_fn(instance: Any) Any

Resolve vkQueuePresentKHR for instance.

Present is a swapchain-extension entry point, so it is reached through vkGetInstanceProcAddr rather than as an attribute of the bindings, and a name spelled in a string is invisible to a scan looking for attribute access. Resolving it here keeps the string in the same module as the lock that must be held around the call, which is what lets tests/test_queue_synchronisation_seams.py ban the name outright.

Pass the result to :meth:SubmitQueue.present; never call it directly.