vulkan-utils-framegraph (empty) → 0.1.0.0
raw patch · 11 files changed
+2952/−0 lines, 11 filesdep +basedep +containersdep +fragr
Dependencies added: base, containers, fragr, resourcet, tasty, tasty-hunit, text, transformers, vector, vulkan, vulkan-utils, vulkan-utils-framegraph
Files
- LICENSE +30/−0
- README.md +47/−0
- package.yaml +58/−0
- src/Vulkan/Utils/FrameGraph/Aliasing.hs +151/−0
- src/Vulkan/Utils/FrameGraph/Buffer.hs +412/−0
- src/Vulkan/Utils/FrameGraph/Driver.hs +460/−0
- src/Vulkan/Utils/FrameGraph/Image.hs +680/−0
- src/Vulkan/Utils/FrameGraph/Recorder.hs +354/−0
- src/Vulkan/Utils/FrameGraph/Swapchain.hs +71/−0
- test/Spec.hs +586/−0
- vulkan-utils-framegraph.cabal +103/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright IC Rainbow (c) 2026++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of IC Rainbow nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,47 @@+# vulkan-utils-framegraph++Vulkan adapter for the [`fragr`](https://gitlab.com/dpwiz/fragr) frame graph:+resource types whose `preRead` / `preWrite` hooks place `cmdPipelineBarrier`+image transitions automatically, so passes declare *what* they access and the+graph records the barriers.++`Vulkan.Utils.FrameGraph.Image` provides `ManagedImage` — an image plus a+tracked `ImageState` (layout, stage, access). Declare an access with a `Usage`+(encoded into `Fragr.Flags` via `usageFlags`); the hook diffs the tracked+state against the usage's target and emits the transition, then updates the+tracked state.++```haskell+import Fragr qualified as FG+import Vulkan.Utils.FrameGraph.Image (ImageDesc (..), Usage (..), newManagedImage, usageFlags)++offscreen <- newManagedImage image Vk.IMAGE_ASPECT_COLOR_BIT+h <- FG.importResource g "offscreen" (ImageDesc "offscreen") offscreen+h' <- -- in a pass setup: FG.writeWith h (usageFlags ColorAttachment)+ -- a later pass: FG.readWith h' (usageFlags SampledFragment)+```++`Ctx ManagedImage` is the `CommandBuffer` the barriers record into; run the+graph with `FG.execute g cmdBuffer ()`.++## Scope++`ManagedImage` participates as an *imported* resource: the graph tracks its+layout and places barriers but does not own its allocation. Graph-owned+transient images with deferred (frames-in-flight-safe) reclamation are future+work — that needs `FG.executeQueued` + a `RecycleQueue`, since single-queue+`execute` would free a transient during recording, before the GPU has run.++The layout-diff model inserts a barrier whenever the target `ImageState`+differs from the tracked one, and also for every write access with the state+unchanged (same-state WAW); only read-after-read skips the barrier.++When consecutive accesses land on different queues the barrier's source scope+is replaced by the destination stage with no access mask: execution ordering+and memory availability must come from the driver's inter-queue semaphore, and+the barrier chains to its wait. Two caveats follow: the submit wait's+`dstStageMask` must cover the consuming usage's stage, and cross-queue-family+access is only supported for CONCURRENT-shared images — no ownership+release/acquire pair is emitted (`PassSync` acquires/releases are dropped by+`recordingBackend`), so an EXCLUSIVE image's contents are undefined on the new+family.
+ package.yaml view
@@ -0,0 +1,58 @@+name: vulkan-utils-framegraph+version: "0.1.0.0"+synopsis: Vulkan barrier-placement and resource adapter for the fragr frame graph+category: Graphics+maintainer: IC Rainbow <aenor.realm@gmail.com>+license: BSD-3-Clause+github: haskell-game/vulkan+license-file: LICENSE+extra-source-files:+- README.md+- package.yaml++library:+ source-dirs: src+ dependencies:+ - base >= 4.16 && <5+ - containers+ - fragr+ - resourcet+ - text+ - transformers+ - vector+ - vulkan >= 3.27 && < 3.28+ - vulkan-utils++ghc-options:+- -Wall++default-extensions:+- BlockArguments+- DerivingStrategies+- DuplicateRecordFields+- ImportQualifiedPost+- LambdaCase+- NamedFieldPuns+- NoFieldSelectors+- OverloadedLists+- OverloadedRecordDot+- OverloadedStrings+- RecordWildCards+- ScopedTypeVariables+- StrictData+- TypeApplications+- TypeFamilies++tests:+ vulkan-utils-framegraph-test:+ main: Spec.hs+ source-dirs: test+ dependencies:+ - base+ - containers+ - vector+ - vulkan+ - vulkan-utils-framegraph+ - fragr+ - tasty+ - tasty-hunit
+ src/Vulkan/Utils/FrameGraph/Aliasing.hs view
@@ -0,0 +1,151 @@+{-| Plan-time memory aliasing for graph-owned transients.++Two entries whose lifetimes cannot overlap may share one allocation, decided+from the compiled schedule before anything is recorded — no run-time+reclamation, because a backend's @destroyResource@ fires while /recording/,+long before the GPU is done with the memory.++The raw material is 'FG.EntryInfo.live': each entry's first and last+executing pass, as positions in execution order. Disjoint ranges are+necessary but __not sufficient__, and the gap is the whole point of this+module: positions order the executing passes /globally/, while passes on+different queues run concurrently. Two ranges can be disjoint in position+and still overlap in time.++So aliasing rides on the schedule's happens-before relation, not on the+positions: entry @a@ may hand its memory to @b@ only if @a@'s last pass+happens-before @b@'s first — same queue (submission order, plus the takeover+barrier), or a timeline wait that transitively orders the two. 'happensBefore'+derives exactly that from the 'FG.PassSync' waits the compiler already emits.++The caller supplies the compatibility classes (size, alignment, memory type):+'planAliases' only answers "may these share", and returns each group in+takeover order so the backend can seed the newcomer's barrier from its+predecessor's final state.+-}+module Vulkan.Utils.FrameGraph.Aliasing+ ( Candidate (..)+ , planAliases+ , happensBefore+ , Schedule+ , scheduleOf+ ) where++import Data.List (sortOn)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Vector qualified as V+import Data.Word (Word64)++import Fragr qualified as FG++{- | An entry in the running for aliasing: its id and its live range, as+inclusive positions into the executing passes ('FG.EntryInfo.live').++Only entries the graph /owns/ belong here — an import's memory is the+caller's whatever its range says ('FG.EntryInfo.imported' tells them apart).+-}+data Candidate = Candidate+ { entryId :: Int+ , live :: (Int, Int)+ }+ deriving (Eq, Show)++{- | The happens-before relation over one run's executing passes, indexed by+position (the same positions 'FG.EntryInfo.live' reports).++Per pass: the timeline value it signals on its own queue, and a vector clock+of what it has necessarily observed on every queue.+-}+newtype Schedule = Schedule (V.Vector (FG.QueueId, Word64, Map FG.QueueId Word64))++{- | Derive the relation from the compiled schedule.++Takes the executing passes /in execution order/ — the driver's+@mapMaybe (.sync) snapshot.passes@, which is the very list+'FG.EntryInfo.live' indexes.++Each pass observes its queue predecessor's clock and, for every 'FG.Wait',+the clock of the pass whose signal it waits on; then it stamps its own+signal. Waits carry a watermark value rather than an exact signal, so the+lookup takes the latest signal at or below it.++A wait can only name a value an earlier pass signalled (a consumer cannot+hold a handle its producer has not yet made), so the lookup always lands.+Were it ever to miss, the clock stays empty and the pass looks ordered after+nothing — fewer aliases, never an unsound one.+-}+scheduleOf :: [FG.PassSync] -> Schedule+scheduleOf syncs = Schedule (V.fromList (reverse clocks))+ where+ -- Fold in execution order, carrying: each queue's latest clock, and the+ -- clock at each (queue, signalled value) so a wait can look one up.+ (_, _, clocks) = foldl step (Map.empty, Map.empty, []) syncs++ step+ :: ( Map FG.QueueId (Map FG.QueueId Word64)+ , Map (FG.QueueId, Word64) (Map FG.QueueId Word64)+ , [(FG.QueueId, Word64, Map FG.QueueId Word64)]+ )+ -> FG.PassSync+ -> ( Map FG.QueueId (Map FG.QueueId Word64)+ , Map (FG.QueueId, Word64) (Map FG.QueueId Word64)+ , [(FG.QueueId, Word64, Map FG.QueueId Word64)]+ )+ step (latest, atValue, acc) s =+ let+ inherited = Map.findWithDefault Map.empty s.queue latest+ waited =+ [ Map.findWithDefault Map.empty (q, v') atValue+ | w <- s.waits+ , let q = w.queue+ , -- the newest signal on q at or below the watermark+ Just (v', _) <- [Map.lookupLE (q, w.value) atValue >>= keyOn q]+ ]+ merged = Map.unionsWith max (inherited : waited)+ clock = Map.insertWith max s.queue s.signal merged+ in+ ( Map.insert s.queue clock latest+ , Map.insert (s.queue, s.signal) clock atValue+ , (s.queue, s.signal, clock) : acc+ )++ -- lookupLE crosses queue boundaries in the (queue, value) key order;+ -- keep it only when it landed on the queue we asked about.+ keyOn q ((q', v), c) = if q == q' then Just (v, c) else Nothing++{- | Does the pass at the first position necessarily complete before the pass+at the second begins?++True when the second's clock has observed the first's signal — same queue+(submission order), or a wait chain that transitively reaches it.+-}+happensBefore :: Schedule -> Int -> Int -> Bool+happensBefore (Schedule passes) i j+ | i == j = False+ | otherwise = case (passes V.!? i, passes V.!? j) of+ (Just (queue, signal, _), Just (_, _, after)) ->+ Map.findWithDefault 0 queue after >= signal+ _ -> False++{- | Pack candidates into groups that may share one allocation.++The caller has already split them into a compatibility class (same memory+type, and a block big enough for the largest). Within a class, two entries+may share only if the schedule orders one's last pass before the other's+first — 'happensBefore', not merely disjoint positions.++Each group comes back in takeover order, so the backend can seed a+newcomer's aliasing barrier from its predecessor's final state (its contents+are undefined, but the memory dependency on the previous user is real).+-}+planAliases :: Schedule -> [Candidate] -> [[Candidate]]+planAliases sched = foldl place [] . sortOn (fst . (.live))+ where+ -- Greedy: first group whose every member is ordered against this one.+ place groups c = case break (all (compatible c)) groups of+ (before, g : after) -> before <> ((g <> [c]) : after)+ (before, []) -> before <> [[c]]++ compatible c m = ordered m c || ordered c m+ ordered x y = happensBefore sched (snd x.live) (fst y.live)
+ src/Vulkan/Utils/FrameGraph/Buffer.hs view
@@ -0,0 +1,412 @@+{-| A 'FG.Resource' for Vulkan buffers that places memory barriers automatically.++The buffer sibling of "Vulkan.Utils.FrameGraph.Image": a 'ManagedBuffer'+carries the buffer plus its tracked 'BufferState' (stage, access — buffers+have no layout), a pass declares an access with a 'Usage', and the hooks diff+the tracked state against the usage's target and queue a+@VkBufferMemoryBarrier@ into the 'Recorder''s per-pass batch. Barriers cover+the whole buffer; queue hops chain to the driver's semaphore exactly as the+image adapter's do (and cross-family access likewise needs CONCURRENT+sharing).++Import-only: the graph tracks the state and places barriers but does not own+the allocation.+-}+module Vulkan.Utils.FrameGraph.Buffer+ ( ManagedBuffer (..)+ , newManagedBuffer+ , BufferDesc (..)+ , importManagedBuffer+ , importScratchBuffer+ , importOwnedBuffer+ , describedAs+ , sharedAcrossQueues+ , BufferState (..)+ , freshState+ , Usage (..)+ , usageState+ , transitionBufferTo+ , transitionBuffersTo+ , queueTransition+ , transferOwnership+ ) where++import Control.Monad (foldM, unless, when)+import Control.Monad.IO.Class (MonadIO (..))+import Data.Bits ((.&.), (.|.))+import Data.Foldable (traverse_)+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Maybe (fromMaybe, isNothing)+import Data.Text (Text)+import Data.Vector qualified as V++import Fragr qualified as FG+import Vulkan.CStruct.Extends (SomeStruct (..))+import Vulkan.Core10 qualified as Vk+import Vulkan.Utils.FrameGraph.Recorder (Accessor (..), Recorder, TransferSide (..), chainedNode, flushBarriers, markChained, queueBufferBarrier, recorderFamily, recorderHost, recorderQueue, recorderSameFamily)+import Vulkan.Zero (zero)++-- | A buffer whose stage/access the frame graph tracks and barriers.+data ManagedBuffer = ManagedBuffer+ { buffer :: Vk.Buffer+ , stateRef :: IORef BufferState+ , queueRef :: IORef (Maybe FG.QueueId)+ -- ^ The device queue that last accessed it; 'Nothing' until one has.+ , releasedRef :: IORef (Maybe BufferState)+ {- ^ The state a pending ownership release saw, so the acquiring half can+ build the barrier that matches it exactly.+ -}+ , shared :: Bool+ {- ^ The allocation is @SHARING_MODE_CONCURRENT@ across the families the+ graph uses it on ('sharedAcrossQueues'). An unmarked resource accessed+ across queues is fatal: no ownership transfer is emitted, so its contents+ would be undefined on the new family.+ -}+ , info :: Text+ -- ^ Human-readable summary shown by visualization output; attach with 'describedAs'.+ }++-- | Wrap a buffer, starting from 'freshState'.+{-# INLINE newManagedBuffer #-}+newManagedBuffer :: (MonadIO m) => Vk.Buffer -> m ManagedBuffer+newManagedBuffer buffer = liftIO do+ stateRef <- newIORef freshState+ queueRef <- newIORef Nothing+ releasedRef <- newIORef Nothing+ pure ManagedBuffer{releasedRef, buffer, stateRef, queueRef, shared = False, info = ""}++-- | Attach a summary shown next to the resource's name in visualization output.+describedAs :: Text -> ManagedBuffer -> ManagedBuffer+describedAs t mb = mb{info = t}++{- | Mark the allocation as @SHARING_MODE_CONCURRENT@ across the families it+is used on.++Required before any cross-family access the graph cannot transfer+ownership for: an @EXCLUSIVE@ resource's contents are undefined on the new+family, so crossing without this is fatal, not silent. Apply before+importing — imports read it through 'FG.isShared', exempting the buffer+from the schedule's single-owner validation.+-}+sharedAcrossQueues :: ManagedBuffer -> ManagedBuffer+sharedAcrossQueues mb = mb{shared = True}++instance FG.Resource ManagedBuffer where+ type Desc ManagedBuffer = BufferDesc+ type Alloc ManagedBuffer = ()+ type Ctx ManagedBuffer = Recorder+ type Flags ManagedBuffer = Usage++ createResource _ _ =+ error "ManagedBuffer is import-only: allocate the buffer and use importResource"++ destroyResource _ _ _ = pure ()++ preRead h _ usage rec mb = queueTransition rec (FG.handleId h) mb usage+ preWrite h _ usage rec mb = queueTransition rec (FG.handleId h) mb usage++ -- The two halves of a cross-queue hand-off, as in the image adapter.+ preRelease h _ usage peer rec mb = transferOwnership Release rec (FG.handleId h) peer mb usage+ preAcquire h _ usage peer rec mb = transferOwnership Acquire rec (FG.handleId h) peer mb usage++ isShared mb = mb.shared++ describeDesc d = d.info++-- | The synchronization state a buffer's last access left it in.+data BufferState = BufferState+ { stage :: Vk.PipelineStageFlags+ , access :: Vk.AccessFlags+ }+ deriving stock (Eq, Show)++{- | Never accessed on the device since the last host-synchronized point (a+fence-waited setup submit): top of pipe, no access to make available.+-}+freshState :: BufferState+freshState = BufferState Vk.PIPELINE_STAGE_TOP_OF_PIPE_BIT zero++-- | How a pass uses a buffer, i.e. the 'BufferState' it must be in for that access.+data Usage+ = -- | @vkCmdDraw*Indirect@ / @vkCmdDispatchIndirect@ command source.+ IndirectRead+ | TransferSrc+ | TransferDst+ | -- | Storage read/write in the given shader stage (compute, vertex, …).+ StorageRead Vk.PipelineStageFlags+ | StorageWrite Vk.PipelineStageFlags+ | -- | Read-modify-write storage access (atomics).+ StorageReadWrite Vk.PipelineStageFlags+ | -- | Read by the host, after the schedule's timeline wait reaches it.+ HostRead+ | -- | Written by the host (a mapped upload) before device consumers.+ HostWrite+ deriving stock (Eq, Ord, Show)++-- | The target state each 'Usage' requires.+usageState :: Usage -> BufferState+usageState = \case+ IndirectRead -> BufferState Vk.PIPELINE_STAGE_DRAW_INDIRECT_BIT Vk.ACCESS_INDIRECT_COMMAND_READ_BIT+ TransferSrc -> BufferState Vk.PIPELINE_STAGE_TRANSFER_BIT Vk.ACCESS_TRANSFER_READ_BIT+ TransferDst -> BufferState Vk.PIPELINE_STAGE_TRANSFER_BIT Vk.ACCESS_TRANSFER_WRITE_BIT+ StorageRead stage -> BufferState stage Vk.ACCESS_SHADER_READ_BIT+ StorageWrite stage -> BufferState stage Vk.ACCESS_SHADER_WRITE_BIT+ StorageReadWrite stage -> BufferState stage (Vk.ACCESS_SHADER_READ_BIT .|. Vk.ACCESS_SHADER_WRITE_BIT)+ HostRead -> BufferState Vk.PIPELINE_STAGE_HOST_BIT Vk.ACCESS_HOST_READ_BIT+ HostWrite -> BufferState Vk.PIPELINE_STAGE_HOST_BIT Vk.ACCESS_HOST_WRITE_BIT++{- | Whether the 'Usage' writes the buffer (and so needs a barrier even when the+state is unchanged — only read-after-read can skip it).+-}+usageWrites :: Usage -> Bool+usageWrites = \case+ TransferDst -> True+ StorageWrite _ -> True+ StorageReadWrite _ -> True+ HostWrite -> True+ IndirectRead -> False+ TransferSrc -> False+ StorageRead _ -> False+ HostRead -> False++{- | Record the barrier bringing the buffer into the 'Usage''s state and update+the tracked state. Standalone counterpart to the hook path, for barriers+recorded outside a pass; treats the access as same-queue.+-}+transitionBufferTo :: (MonadIO m) => Vk.CommandBuffer -> ManagedBuffer -> Usage -> m ()+{-# INLINE transitionBufferTo #-}+transitionBufferTo cb mb usage = transitionBuffersTo cb [(mb, usage)]++{- | 'transitionBufferTo' over a batch: one @vkCmdPipelineBarrier@, OR-ed stage masks.++The buffers must be distinct: barriers in one command are unordered, so two+entries for the same buffer would race.+-}+transitionBuffersTo :: (MonadIO m) => Vk.CommandBuffer -> [(ManagedBuffer, Usage)] -> m ()+transitionBuffersTo cb accesses = do+ (srcs, dsts, barriers) <- foldM collect (zero, zero, []) accesses+ unless (null barriers) $+ Vk.cmdPipelineBarrier cb srcs dsts zero [] (V.fromList barriers) []+ where+ collect acc@(srcs, dsts, barriers) (mb, usage) = do+ lastQueue <- liftIO (readIORef mb.queueRef)+ nextTransition (maybe HostAccess DeviceQueue lastQueue) (\_ _ -> True) False mb usage >>= \case+ Nothing -> pure acc+ Just (src, dst, barrier) -> pure (srcs .|. src, dsts .|. dst, barrier : barriers)++{- | The hook path: 'transitionBufferTo' rules, but queued and queue-aware.++Queue hops chain to the driver's semaphore, like the image adapter's: the+prior synchronization's scope must cover the usage's stage. Same-family+queues share freely; crossing to another /family/ needs CONCURRENT sharing+or an ownership acquire this pass performed.+-}+queueTransition :: (MonadIO m) => Recorder -> Int -> ManagedBuffer -> Usage -> m ()+queueTransition rec node mb usage = do+ queue <- recorderQueue rec+ chained0 <- chainedNode rec node+ hosted <- recorderHost rec+ sameFamily <- recorderSameFamily rec+ nextTransition (if hosted then HostAccess else DeviceQueue queue) sameFamily chained0 mb usage >>= traverse_ \(srcStage, dstStage, barrier) ->+ queueBufferBarrier rec srcStage dstStage barrier++{- | The producer- and consumer-side halves of a cross-queue hand-off.++The image adapter's rules, minus the layout: on a @CONCURRENT@ buffer the+semaphore alone orders the two sides and both halves are no-ops; on an+@EXCLUSIVE@ one they are a real queue-family ownership transfer, the same+barrier recorded in each queue's buffer with both family indices named.+The acquire advances the tracked state and marks the node chained.++A hand-off to the host is neither: the release carries the full dependency+(a semaphore signal makes device writes available to the device domain+only, so the host half needs a real @HOST@ destination scope), and the+acquire is bookkeeping.+-}+transferOwnership :: (MonadIO m) => TransferSide -> Recorder -> Int -> FG.QueueId -> ManagedBuffer -> Usage -> m ()+transferOwnership side rec node peer mb usage = do+ hosted <- recorderHost rec+ queue <- recorderQueue rec+ ourFamily <- recorderFamily rec queue+ peerFamily <- recorderFamily rec peer+ cur <- liftIO (readIORef mb.stateRef)+ released <- liftIO (readIORef mb.releasedRef)+ let+ next = usageState usage+ (srcFamily, dstFamily) = case side of+ Release -> (ourFamily, peerFamily)+ Acquire -> (peerFamily, ourFamily)+ -- The host owns nothing (it is not a family), and a CONCURRENT buffer is+ -- owned by no one.+ owned =+ not mb.shared+ && not hosted+ && srcFamily /= dstFamily+ && srcFamily /= Vk.QUEUE_FAMILY_IGNORED+ && dstFamily /= Vk.QUEUE_FAMILY_IGNORED+ -- The consumer is the host: only the release's barrier can make the+ -- device's writes visible to it (the schedule's timeline wait cannot).+ toHost = next.stage .&. Vk.PIPELINE_STAGE_HOST_BIT /= zero+ from = case side of+ Release -> cur+ Acquire -> fromMaybe cur released+ barrier =+ SomeStruct+ zero+ { Vk.srcAccessMask = case side of+ Release -> from.access+ Acquire -> zero+ , Vk.dstAccessMask = case side of+ Release -> if toHost then next.access else zero+ Acquire -> next.access+ , Vk.srcQueueFamilyIndex = if owned then srcFamily else Vk.QUEUE_FAMILY_IGNORED+ , Vk.dstQueueFamilyIndex = if owned then dstFamily else Vk.QUEUE_FAMILY_IGNORED+ , Vk.buffer = mb.buffer+ , Vk.offset = 0+ , Vk.size = Vk.WHOLE_SIZE+ }+ (srcStage, dstStage) = case side of+ Release -> (from.stage, if toHost then next.stage else Vk.PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT)+ Acquire -> (Vk.PIPELINE_STAGE_TOP_OF_PIPE_BIT, next.stage)+ case side of+ Release -> do+ when (owned || toHost) $ queueBufferBarrier rec srcStage dstStage barrier+ liftIO (writeIORef mb.releasedRef (Just cur))+ Acquire -> do+ -- An owned acquire without its armed release half would record an+ -- unmatched barrier from a guessed state: a schedule bug, not a+ -- recoverable condition.+ when (owned && isNothing released) $+ error+ ( "Vulkan.Utils.FrameGraph: ownership acquire of "+ <> show mb.info+ <> " without a pending release; the schedule must pair the halves"+ )+ when owned $ queueBufferBarrier rec srcStage dstStage barrier+ liftIO do+ -- The host is not a device queue: recording it as the last one would+ -- make the next device access look cross-queue (cf. 'nextTransition'),+ -- and the driver defers host passes — a late write here would rewind+ -- the device-side tracking.+ unless hosted do+ writeIORef mb.stateRef next+ writeIORef mb.queueRef (Just queue)+ -- Only the owned half consumed the hand-off; a melted acquire (host,+ -- shared, same family) must leave the slot for a pending owned one.+ when owned $ writeIORef mb.releasedRef Nothing+ markChained rec node++{- | Diff the tracked state against the 'Usage''s target and advance it.++A read whose state differs from the previous one still emits a barrier (the+chain through it is what orders a later write after both reads); only a read+of an already-matching state skips it.+-}+nextTransition+ :: (MonadIO m)+ => Accessor+ -> (FG.QueueId -> FG.QueueId -> Bool)+ -- ^ whether two queues belong to one family (share ownership)+ -> Bool+ -- ^ an ownership acquire already synchronized it ('chainedNode')+ -> ManagedBuffer+ -> Usage+ -> m (Maybe (Vk.PipelineStageFlags, Vk.PipelineStageFlags, SomeStruct Vk.BufferMemoryBarrier))+nextTransition accessor sameFamily marked mb usage = liftIO do+ cur <- readIORef mb.stateRef+ lastQueue <- readIORef mb.queueRef+ let+ next = usageState usage+ -- A first access owns nothing yet, and the host is not a queue family (its+ -- accesses order through the schedule's timeline and the producer's+ -- release barrier), so neither crosses ownership.+ crossQueue = case (accessor, lastQueue) of+ (DeviceQueue q, Just prev) -> q /= prev+ _ -> False+ crossFamily =+ crossQueue && case (accessor, lastQueue) of+ (DeviceQueue q, Just prev) -> not (sameFamily q prev)+ _ -> False+ -- A cross-queue hop rides the driver's semaphore even within one family.+ chained = crossQueue || marked+ srcStage = if chained then next.stage else cur.stage+ srcAccess = if chained then zero else cur.access+ -- Semaphore/event-ordered same-state accesses need no barrier of their+ -- own; unchained writes need one even with the state unchanged.+ needed = cur /= next || (usageWrites usage && not chained)+ -- An unshared (EXCLUSIVE) resource reaching another family without an+ -- ownership transfer ('transferOwnership') has undefined contents there. A+ -- write that does not read them is still fine — it acquires by discarding+ -- them — but a read would see garbage, so it is fatal.+ when (crossFamily && not mb.shared && not (usageWrites usage)) $+ error+ ( "Vulkan.Utils.FrameGraph: cross-family read of an unshared resource ("+ <> show mb.info+ <> ") the graph never handed over: it must be produced on the reading family, "+ <> "marked 'sharedAcrossQueues' (CONCURRENT), or written by a pass the graph "+ <> "can transfer ownership from"+ )+ case accessor of+ DeviceQueue q -> writeIORef mb.queueRef (Just q)+ HostAccess -> pure ()+ if needed+ then do+ writeIORef mb.stateRef next+ pure $+ Just+ ( srcStage+ , next.stage+ , SomeStruct+ zero+ { Vk.srcAccessMask = srcAccess+ , Vk.dstAccessMask = next.access+ , Vk.srcQueueFamilyIndex = Vk.QUEUE_FAMILY_IGNORED+ , Vk.dstQueueFamilyIndex = Vk.QUEUE_FAMILY_IGNORED+ , Vk.buffer = mb.buffer+ , Vk.offset = 0+ , Vk.size = Vk.WHOLE_SIZE+ }+ )+ else+ pure Nothing++{- | Descriptor for a 'ManagedBuffer'; carries the buffer's 'describedAs'+summary for visualization output (the resource name travels separately).+-}+newtype BufferDesc = BufferDesc {info :: Text}++{- | Import a 'ManagedBuffer' under @name@, as an observed resource.++Claims the graph's 'FG.addPreExec slot for 'flushBarriers' like the image+imports do — the adapters share that slot; wrap the flush rather than+replacing it.+-}+importManagedBuffer :: (MonadIO m) => FG.FrameGraph Recorder () -> Text -> ManagedBuffer -> m (FG.Handle ManagedBuffer)+importManagedBuffer graph name mb = do+ FG.addPreExec graph flushBarriers+ disarmHandOff mb+ FG.importResource graph name (BufferDesc mb.info) mb++-- | 'Vulkan.Utils.FrameGraph.Image.disarmHandOff' for the buffer lane.+disarmHandOff :: (MonadIO m) => ManagedBuffer -> m ()+disarmHandOff mb = liftIO (writeIORef mb.releasedRef Nothing)++-- | 'importManagedBuffer' via 'FG.importScratch', keeping writers subject to demand culling.+importScratchBuffer :: (MonadIO m) => FG.FrameGraph Recorder () -> Text -> ManagedBuffer -> m (FG.Handle ManagedBuffer)+importScratchBuffer graph name mb = do+ FG.addPreExec graph flushBarriers+ disarmHandOff mb+ FG.importScratch graph name (BufferDesc mb.info) mb++{- | The buffer counterpart of 'Vulkan.Utils.FrameGraph.Image.importOwnedImage':+'FG.importOwned' under the wrapper's tracked last queue, so a cross-family+first touch gets its release / acquire pair across the frame boundary.+-}+importOwnedBuffer :: (MonadIO m) => FG.FrameGraph Recorder () -> Text -> ManagedBuffer -> m (FG.Handle ManagedBuffer)+importOwnedBuffer graph name mb =+ liftIO (readIORef mb.queueRef) >>= \case+ Nothing -> importManagedBuffer graph name mb+ Just owner -> do+ FG.addPreExec graph flushBarriers+ disarmHandOff mb+ FG.importOwned graph name (BufferDesc mb.info) mb owner
+ src/Vulkan/Utils/FrameGraph/Driver.hs view
@@ -0,0 +1,460 @@+{-# LANGUAGE DataKinds #-}++{-| Package-level multi-queue submit driver.++'submitGraphQueued' turns a compiled graph into one submit per executing+device queue: a one-time primary begun from each queue's pool, cross-queue+ordering realised with per-run timeline semaphores straight off the+schedule — each wait's value from 'FG.Wait' and its stage decoded from the+accesses it protects ('waitStage'), the same stages the resource adapters+chain their cross-queue barriers to.++The host is just another queue: passes assigned to the designated host+'FG.QueueId' execute on the CPU after the submits, each waiting its+schedule waits on the real timelines and signalling its own — so readbacks+and mapped uploads take part in the same dependency graph, with the+device-side transitions landing producer-side ('FG.preRelease') and the+host-side hooks tracking state without recording ('setRecorderHost').++Frame-level synchronization is the caller's and arrives as 'SubmitExtras':+swapchain acquire/present semaphores, frames-in-flight timelines, and any+cross-frame hazard on a resource the graphs share (a previous frame's+still-in-flight read is not a pass the compiler can see). Everything inside+one graph is derived; everything between graphs is an extra.++Synchronization is timeline semaphores and synchronization2, and nothing+else: submits go through @vkQueueSubmit2@, so a wait carries its value and+its stage in one 'SemaphoreSubmitInfo'. The schedule's split-barrier events+are deliberately not realised as @VkEvent@s — they only ever bought overlap,+and each access already places a self-sufficient barrier. Binary semaphores+survive only where WSI mandates them (acquire/present), as 'SubmitExtras'.++Cross-queue hand-offs of an @EXCLUSIVE@ resource are realised as queue-family+ownership transfers: the adapters' release and acquire hooks emit the matching+barrier pair, naming the families from this driver's 'QueueSlot's. A+@CONCURRENT@ resource ('sharedAcrossQueues') owns nothing and rides the+semaphore alone.++Each queue's pass stream is cut into segments at wait boundaries+('planSegments'), one submit per segment, so mid-stream cross-queue+dependencies — device ping-pong, device→host→device round trips — schedule+instead of deadlocking.+-}+module Vulkan.Utils.FrameGraph.Driver+ ( submitGraphQueued+ , SubmitConfig (..)+ , QueueSlot (..)+ , submitConfig+ , frameSubmitConfig+ , Submitted (..)+ , waitSubmitted+ , waitStage+ , accessScopes+ ) where++import Control.Monad (unless, void)+import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.Trans.Resource (MonadResource)+import Data.Bits ((.&.), (.|.))+import Data.Coerce (coerce)+import Data.Foldable (foldl', for_, toList, traverse_)+import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef)+import Data.List (partition)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Sequence qualified as Seq+import Data.Set qualified as Set+import Data.Traversable (for)+import Data.Vector qualified as V+import Data.Word (Word32, Word64)+import Type.Reflection (eqTypeRep, typeRep, type (:~~:) (HRefl))++import Fragr qualified as FG+import Vulkan.CStruct.Extends (SomeStruct (..))+import Vulkan.Core10 qualified as Vk+import Vulkan.Core12.Promoted_From_VK_KHR_timeline_semaphore (signalSemaphore, waitSemaphoresSafe)+import Vulkan.Core12.Promoted_From_VK_KHR_timeline_semaphore qualified as SemaphoreSignalInfo (SemaphoreSignalInfo (..))+import Vulkan.Core12.Promoted_From_VK_KHR_timeline_semaphore qualified as SemaphoreWaitInfo (SemaphoreWaitInfo (..))+import Vulkan.Core13.Enums.AccessFlags2 (AccessFlagBits2 (..), AccessFlags2)+import Vulkan.Core13.Enums.PipelineStageFlags2 (PipelineStageFlagBits2 (..), PipelineStageFlags2)+import Vulkan.Core13.Promoted_From_VK_KHR_synchronization2 (SubmitInfo2 (..), queueSubmit2)+import Vulkan.Core13.Promoted_From_VK_KHR_synchronization2 qualified as CommandBufferSubmitInfo (CommandBufferSubmitInfo (..))+import Vulkan.Core13.Promoted_From_VK_KHR_synchronization2 qualified as SemaphoreSubmitInfo (SemaphoreSubmitInfo (..))+import Vulkan.Utils.Frame (Frame (..), SubmitExtras (..), allocatePrimary, allocateTimelineSemaphore, frameSubmitExtras, noExtras)+import Vulkan.Utils.FrameGraph.Buffer qualified as Buffer+import Vulkan.Utils.FrameGraph.Image qualified as Image+import Vulkan.Utils.FrameGraph.Recorder (Recorder, clearChained, flushBarriers, newRecorder, setRecorder, setRecorderFamilies, setRecorderHost)+import Vulkan.Zero (zero)++{- | One queue's completion handle.++Its per-run timeline reaches @value@ once every pass submitted there has+executed. Feed into frames-in-flight bookkeeping, or block on it with+'waitSubmitted'.+-}+data Submitted = Submitted+ { queue :: FG.QueueId+ , semaphore :: Vk.Semaphore+ , value :: Word64+ }++{- | The device side of one 'FG.QueueId': where its passes submit, which+family owns their resources, and the pool their buffers come from.+-}+data QueueSlot = QueueSlot+ { queue :: Vk.Queue+ , family :: Word32+ -- ^ names the sides of an ownership transfer across it+ , pool :: Vk.CommandPool+ }++{- | How 'submitGraphQueued' maps a graph onto the device.++'submitConfig' fills everything but the queue table with inert defaults.++The queue table maps the graph's 'FG.QueueId's to real queues and the pools+to take this run's one-time command buffers from (reset the pool to reclaim+them, e.g. per frame in flight). @extras@ adds the frame-level waits and+signals — waits on the queue's first segment, signals on its last (ignored+for the host queue, which has no submit to splice into).+-}+data SubmitConfig = SubmitConfig+ { device :: Vk.Device+ , queues :: FG.QueueId -> QueueSlot+ , hostQueue :: Maybe FG.QueueId+ -- ^ the host queue: its passes execute on the CPU after the submits+ , extras :: FG.QueueId -> SubmitExtras+ , register :: [Submitted] -> IO ()+ {- ^ Called with every queue's completion once the graph is recorded and+ before anything is submitted:+ wire it to the frame's GPU-work list ('Vulkan.Utils.Frame.fGPUWork') and+ reclamation waits the whole graph with no hand-rolled sync — and no race+ when a submit fails midway, since a registered-but-never-signalled value+ degrades to the recycler's wait timeout instead of reclaiming under+ in-flight work.+ -}+ , deferHost :: Maybe (IO () -> IO ())+ {- ^ Hand the ordered host-pass tail to this runner (e.g. the frame's+ deferred work, executed on the recycle thread) instead of executing it+ before returning; the caller's thread then never blocks on the GPU.++ A deferred host pass must not gate a presented image: at+ @vkQueuePresentKHR@ time every signal the present wait depends on must+ already be submitted (VUID 03268), and the deferred signal is not.+ Meter/readback sinks are fine; a host pass feeding the swapchain chain+ must run inline.+ -}+ }++-- | A 'SubmitConfig' with no host queue, no extras, and no completion sink.+submitConfig :: Vk.Device -> (FG.QueueId -> QueueSlot) -> SubmitConfig+submitConfig device queues =+ SubmitConfig+ { device+ , queues+ , hostQueue = Nothing+ , extras = const noExtras+ , register = const (pure ())+ , deferHost = Nothing+ }++{- | A 'SubmitConfig' wired to the frame.++The canonical frame extras ('frameSubmitExtras') ride 'FG.defaultQueue',+completions register into 'fGPUWork' before anything submits, and the host+tail lands in 'fDeferredWork' — the frame's recycle thread runs it, so the+render thread never blocks on the GPU (mind 'deferHost''s presentation+caveat). Override 'extras' by wrapping the field (it is per-queue) rather+than replacing it.+-}+frameSubmitConfig :: Vk.Device -> Frame rr -> Word32 -> (FG.QueueId -> QueueSlot) -> SubmitConfig+frameSubmitConfig dev f imageIndex queues =+ (submitConfig dev queues)+ { extras = \q -> if q == FG.defaultQueue then frameSubmitExtras f imageIndex else noExtras+ , register = \submitted ->+ for_ submitted \s ->+ atomicModifyIORef' (fGPUWork f) (\jobs -> ((s.semaphore, s.value) : jobs, ()))+ , deferHost = Just (\hostTail -> modifyIORef' (fDeferredWork f) (hostTail :))+ }++{- | Record a compiled graph and submit it, one submit per segment, then+execute the host queue's passes.++The per-run timelines live in the current 'MonadResource' scope, so keep it+open until the returned 'Submitted' values are waited on.++A queue's passes are cut into segments at wait boundaries ('planSegments'),+so mid-stream cross-queue dependencies — device ping-pong,+device→host→device round trips — schedule instead of deadlocking; each+segment's waits hoist to its own front.++Passes on the designated host queue record nothing: after the device+submits go out, each runs on the calling thread (or the 'deferHost' runner)+— its schedule waits realised as a host timeline wait, its body as plain IO+(peek a readback mapping, write an upload one), its signal as+'signalSemaphore', which already-submitted device work may be waiting on.+The graph must have at least one device pass.+-}+submitGraphQueued+ :: (MonadResource m)+ => FG.FrameGraph Recorder ()+ -> SubmitConfig+ -> m [Submitted]+submitGraphQueued graph config = do+ let+ dev = config.device+ queueTable = config.queues+ hostQueue = config.hostQueue+ extras = config.extras+ snap <- FG.snapshot graph+ let+ syncs = mapMaybe (.sync) snap.passes+ (hostSyncs, deviceSyncs) = partition (\s -> Just s.queue == hostQueue) syncs+ (segments, routes) = planSegments deviceSyncs+ case syncs of+ [] -> pure []+ _ -> do+ segmentSlots <- for segments \seg -> do+ let slot = queueTable seg.queue+ cb <- allocatePrimary dev slot.pool+ pure (seg, slot.queue, cb)+ let deviceQids = ordNub [seg.queue | seg <- segments]+ timelines <-+ Map.fromList <$> for (deviceQids <> ordNub [s.queue | s <- hostSyncs]) \qid -> do+ (_, timeline) <- allocateTimelineSemaphore dev 0+ pure (qid, timeline)+ buffers <- case NE.nonEmpty [cb | (_, _, cb) <- segmentSlots] of+ Nothing -> error "submitGraphQueued: a host-only graph has nothing to submit"+ Just ne -> pure ne+ let+ timelineOf qid =+ Map.findWithDefault (error "submitGraphQueued: wait on a queue with no executing pass") qid timelines+ -- Indexed: 'cbFor' runs per pass, and the segment list is a list.+ slotsV = V.fromList segmentSlots+ cbFor pid = case Map.lookup pid routes of+ Just ix -> let (_, _, cb) = slotsV V.! ix in cb+ Nothing -> error "submitGraphQueued: pass outside the planned schedule"++ let+ deviceDone =+ [ Submitted{queue = qid, semaphore = timelineOf qid, value = v}+ | qid <- deviceQids+ , let v = maximum [seg.signal | seg <- segments, seg.queue == qid]+ ]+ hostDone =+ [ Submitted{queue = qid, semaphore = timelineOf qid, value = v}+ | qid <- ordNub [s.queue | s <- hostSyncs]+ , let v = maximum [s.signal | s <- hostSyncs, s.queue == qid]+ ]+ done = deviceDone <> hostDone++ recorder <- newRecorder (NE.head buffers)+ -- The families an ownership transfer names its two sides from; the host+ -- queue is not a family and owns nothing.+ setRecorderFamilies recorder \q ->+ if Just q == hostQueue then Vk.QUEUE_FAMILY_IGNORED else+ -- GHC 9.2 can't parse ".family" (special identifier)+ let QueueSlot{family} = queueTable q in family+ deferredRef <- liftIO (newIORef [])+ FG.addPreExec graph flushBarriers+ -- The release hooks queue producer-side barriers after the pass body.+ FG.addPostExec graph flushBarriers+ let backend =+ FG.QueueBackend+ { FG.beforePass = \psync ->+ if Just psync.queue == hostQueue+ then setRecorderHost recorder psync.queue+ else do+ setRecorder recorder psync.queue (cbFor psync.passId)+ -- Only an ownership acquire chains a node ('markChained');+ -- clear it, or a stale mark suppresses a later pass's barrier.+ clearChained recorder+ , FG.afterPass = \_psync -> pure ()+ , FG.invoke = \psync body ->+ if Just psync.queue == hostQueue+ then modifyIORef' deferredRef ((psync, body) :)+ else body+ , FG.completed = pure []+ }+ FG.executeQueued graph backend Nothing recorder ()+ traverse_ Vk.endCommandBuffer buffers++ -- Recorded, nothing submitted yet: registering here means a failure while+ -- recording never leaves the recycler waiting on timelines the unwinding+ -- scope destroys, while a submit failing midway still only costs it the+ -- wait timeout.+ liftIO (config.register done)++ let+ firstSeg = Map.fromListWith (\_ old -> old) [(seg.queue, ix) | (ix, (seg, _, _)) <- zip [0 :: Int ..] segmentSlots]+ lastSeg = Map.fromList [(seg.queue, ix) | (ix, (seg, _, _)) <- zip [0 ..] segmentSlots]+ liftIO $ for_ (zip [0 ..] segmentSlots) \(ix, (seg, vkQueue, cb)) -> do+ let+ ex = extras seg.queue+ derived = [(timelineOf p, stages, value) | (p, (value, stages)) <- Map.toAscList seg.fronts]+ submitWaits = derived <> (if Map.lookup seg.queue firstSeg == Just ix then ex.waits else [])+ submitSignals =+ (timelineOf seg.queue, seg.signal)+ : (if Map.lookup seg.queue lastSeg == Just ix then ex.signals else [])+ -- A binary semaphore (the WSI pair) ignores the value; a timeline+ -- ignores nothing, and carries its stage in the same struct.+ waitInfo (sem, st, v) = zero{SemaphoreSubmitInfo.semaphore = sem, SemaphoreSubmitInfo.stageMask = st, SemaphoreSubmitInfo.value = v}+ signalInfo (sem, v) = zero{SemaphoreSubmitInfo.semaphore = sem, SemaphoreSubmitInfo.stageMask = PIPELINE_STAGE_2_ALL_COMMANDS_BIT, SemaphoreSubmitInfo.value = v}+ submit =+ zero+ { waitSemaphoreInfos = V.fromList (map waitInfo submitWaits)+ , commandBufferInfos = [SomeStruct zero{CommandBufferSubmitInfo.commandBuffer = Vk.commandBufferHandle cb}]+ , signalSemaphoreInfos = V.fromList (map signalInfo submitSignals)+ }+ :: SubmitInfo2 '[]+ queueSubmit2 vkQueue [SomeStruct submit] Vk.NULL_HANDLE++ -- The host passes, in schedule order: wait, run, signal — inline, or+ -- handed whole to the 'deferHost' runner.+ liftIO do+ deferred <- reverse <$> readIORef deferredRef+ let hostTail = for_ deferred \(psync, body) -> do+ unless (null psync.waits) $+ void $+ waitSemaphoresSafe+ dev+ zero+ { SemaphoreWaitInfo.semaphores = V.fromList [timelineOf w.queue | w <- psync.waits]+ , SemaphoreWaitInfo.values = V.fromList [w.value | w <- psync.waits]+ }+ maxBound+ body+ signalSemaphore dev zero{SemaphoreSignalInfo.semaphore = timelineOf psync.queue, SemaphoreSignalInfo.value = psync.signal}+ case config.deferHost of+ Nothing -> hostTail+ Just runner -> unless (null deferred) (runner hostTail)+ pure done++{- | One planned submit: a contiguous run of one queue's passes whose+cross-queue waits all hoist to its front.+-}+data SegmentPlan = SegmentPlan+ { queue :: FG.QueueId+ , fronts :: Map FG.QueueId (Word64, PipelineStageFlags2)+ {- ^ per producer queue: the timeline value to wait for, at the covered+ accesses' stages+ -}+ , signal :: Word64+ -- ^ the value the segment's submit signals (its passes' max)+ }++{- | Cut each queue's pass stream into segments at wait boundaries.++A pass joins its queue's open segment when every wait it carries is already+implied by the segment's front (same producer, value not above the front's;+its stages widen the mask). Anything else — a higher value, a producer the+segment has not waited on — closes the segment and opens a new one fronted+by the pass's own waits. Front waits therefore only reference passes+registered before the segment's first pass, which makes the segment graph+acyclic by construction: no cycle check, no rejected schedules.++Waiting a mid-segment value completes when that segment's submit does — a+timeline wait is @>=@, so coarsening the signal points is sound.+-}+planSegments :: [FG.PassSync] -> ([SegmentPlan], Map Int Int)+planSegments syncs = (toList segs, routes)+ where+ (_, segs, routes) = foldl' step (Map.empty, Seq.empty, Map.empty) syncs+ step+ :: (Map FG.QueueId Int, Seq.Seq SegmentPlan, Map Int Int)+ -> FG.PassSync+ -> (Map FG.QueueId Int, Seq.Seq SegmentPlan, Map Int Int)+ step (open, acc, rts) s =+ let+ -- An acquire barrier is only ordered after its release if the wait's dst+ -- scope covers it, and its src half comes from the pre-release state —+ -- a stage the consuming accesses need not name. Widen, or the two halves+ -- of the transition race (WAW on the layout transition).+ acquiring = if null s.acquires then zero else PIPELINE_STAGE_2_ALL_COMMANDS_BIT+ needs = Map.fromListWith mergeWait [(w.queue, (w.value, waitStage w .|. acquiring)) | w <- s.waits]+ covered seg =+ Map.foldrWithKey+ (\p (v, _) ok -> ok && maybe False ((v <=) . fst) (Map.lookup p seg.fronts))+ True+ needs+ in+ case Map.lookup s.queue open of+ Just ix+ | seg <- Seq.index acc ix+ , covered seg ->+ ( open+ , Seq.adjust+ (\sg -> SegmentPlan{queue = sg.queue, fronts = Map.unionWith mergeWait sg.fronts needs, signal = max sg.signal s.signal})+ ix+ acc+ , Map.insert s.passId ix rts+ )+ _ ->+ let ix = Seq.length acc+ in ( Map.insert s.queue ix open+ , acc Seq.|> SegmentPlan{queue = s.queue, fronts = needs, signal = s.signal}+ , Map.insert s.passId ix rts+ )+ mergeWait (v1, st1) (v2, st2) = (max v1 v2, st1 .|. st2)++-- | Order-preserving dedup for the short queue lists.+ordNub :: (Ord a) => [a] -> [a]+ordNub = go Set.empty+ where+ go _ [] = []+ go seen (x : xs)+ | x `Set.member` seen = go seen xs+ | otherwise = x : go (Set.insert x seen) xs++-- | Block until every 'Submitted' timeline reaches its value.+waitSubmitted :: (MonadIO m) => Vk.Device -> Word64 -> [Submitted] -> m Vk.Result+waitSubmitted _ _ [] = pure Vk.SUCCESS+waitSubmitted dev timeout submitted =+ waitSemaphoresSafe+ dev+ zero+ { SemaphoreWaitInfo.semaphores = V.fromList [s.semaphore | s <- submitted]+ , SemaphoreWaitInfo.values = V.fromList [s.value | s <- submitted]+ }+ timeout++{- | The stage a schedule wait is consumed at ('SemaphoreSubmitInfo.stageMask').++The OR of its protected accesses' stages. Matches the adapters' cross-queue+barrier chaining ('Image.queueTransition' hands over at exactly the+consuming usage's stage), so the semaphore and the barrier meet. An access+declared without flags carries no scope: over-synchronize.+-}+waitStage :: FG.Wait -> PipelineStageFlags2+waitStage w+ | null w.covers = PIPELINE_STAGE_2_ALL_COMMANDS_BIT+ | otherwise = foldl' (\acc a -> acc .|. fst (accessScopes a)) zero w.covers++{- | An access's synchronization2 scope (stage + access mask).++Dispatched on the access's resource type; an adapter this module does not+know about carries no decodable scope and over-synchronizes.+-}+accessScopes :: FG.Access -> (PipelineStageFlags2, AccessFlags2)+accessScopes (FG.Access (_ :: FG.Handle r) flags) = case flags of+ Nothing -> fullScope+ Just f+ | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Image.ManagedImage) ->+ fromState (Image.usageState f).stage (Image.usageState f).access+ | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Buffer.ManagedBuffer) ->+ fromState (Buffer.usageState f).stage (Buffer.usageState f).access+ | otherwise -> fullScope+ where+ fullScope = (PIPELINE_STAGE_2_ALL_COMMANDS_BIT, ACCESS_2_MEMORY_READ_BIT .|. ACCESS_2_MEMORY_WRITE_BIT)+ fromState st ac+ | st == zero || st .&. Vk.PIPELINE_STAGE_HOST_BIT /= zero = fullScope+ | otherwise = (stage2 st, access2 ac)++-- The synchronization1 bits are valid synchronization2 bits verbatim.+stage2 :: Vk.PipelineStageFlags -> PipelineStageFlags2+stage2 s = coerce (fromIntegral (coerce s :: Word32) :: Word64)++access2 :: Vk.AccessFlags -> AccessFlags2+access2 a = coerce (fromIntegral (coerce a :: Word32) :: Word64)
+ src/Vulkan/Utils/FrameGraph/Image.hs view
@@ -0,0 +1,680 @@+{-| A 'FG.Resource' for Vulkan images that places layout-transition barriers+automatically.++A 'ManagedImage' carries the image plus its tracked 'ImageState' (layout,+stage, access). A pass declares an access with a 'Usage' (the instance's+'FG.Flags' type); the 'FG.preRead' / 'FG.preWrite' hooks diff the+tracked state against the usage's target and queue the barrier into the+'Recorder''s per-pass batch, one @vkCmdPipelineBarrier@ per pass — the+'transitionImageTo' rules, plus semaphore chaining when the access hops+queues.++Import-only: the graph tracks the layout and places barriers but does not own+the allocation (see the package README).+-}+module Vulkan.Utils.FrameGraph.Image+ ( ManagedImage (..)+ , newManagedImage+ , newManagedImageMip+ , newManagedImageLayer+ , newManagedImageSlice+ , SliceRegistry+ , newSliceRegistry+ , forgetImage+ , claimOwnership+ , ImageDesc (..)+ , importManagedImage+ , importScratchImage+ , importOwnedImage+ , describedAs+ , sharedAcrossQueues+ , imageInfo+ , describedImage+ , describedMip+ , describedSlice+ , ImageState (..)+ , undefinedState+ , Usage (..)+ , usageState+ , transitionImageTo+ , transitionImagesTo+ , queueTransition+ , transferOwnership+ , sliceLayers+ , copyManagedImageToHost+ ) where++import Control.Monad (filterM, foldM, unless, when)+import Control.Monad.IO.Class (MonadIO (..))+import Data.Bits ((.&.), (.|.))+import Data.Foldable (traverse_)+import Data.IORef (IORef, atomicModifyIORef', mkWeakIORef, newIORef, readIORef, writeIORef)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, isJust, isNothing)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Vector qualified as V+import Data.Word (Word32)+import GHC.Stack (HasCallStack)+import System.Mem (performGC)+import System.Mem.Weak (Weak, deRefWeak)++import Fragr qualified as FG+import Vulkan.CStruct.Extends (SomeStruct (..))+import Vulkan.Core10 qualified as Vk+import Vulkan.Utils.FrameGraph.Recorder (Accessor (..), Recorder, TransferSide (..), chainedNode, flushBarriers, markChained, overlappingRanges, queueBarrier, recorderFamily, recorderHost, recorderQueue, recorderSameFamily)+import Vulkan.Zero (zero)++{- | An image, or an arbitrary @(mip × array-layer)@ slice of it, whose+layout/stage/access the frame graph tracks and transitions.++The @range@ is the barrier's subresource range, so any slicing granularity is one+'ManagedImage' per slice over the same 'Vk.Image', each tracked independently — the+intra-image barriers fall out of that. A whole-image wrapper ('newManagedImage')+covers all mips+layers as one unit (e.g. a multiview render); per-mip+('newManagedImageMip', a bloom pyramid) or per-layer ('newManagedImageLayer', a+cubemap face / array element) wrappers give finer control. Slices tracked+separately must not overlap — wrapping checks that against the image's live+wrappers in the renderer's 'SliceRegistry' and fails fast, since two trackers+over one subresource diverge silently (wrong old layouts, missed barriers).+-}+data ManagedImage = ManagedImage+ { image :: Vk.Image+ , range :: Vk.ImageSubresourceRange+ , stateRef :: IORef ImageState+ , queueRef :: IORef (Maybe FG.QueueId)+ -- ^ The device queue that last accessed it; 'Nothing' until one has.+ , releasedRef :: IORef (Maybe ImageState)+ {- ^ The state a pending ownership release saw, so the acquiring half can+ build the barrier that matches it exactly.+ -}+ , shared :: Bool+ {- ^ The allocation is @SHARING_MODE_CONCURRENT@ across the families the+ graph uses it on ('sharedAcrossQueues'). An unmarked resource accessed+ across queues is fatal: no ownership transfer is emitted, so its contents+ would be undefined on the new family.+ -}+ , info :: Text+ {- ^ Human-readable summary (format/extent, see 'imageInfo') shown by+ visualization output; attach with 'describedAs'.+ -}+ }++-- | Wrap a whole image (all mips + layers, monolithic), starting from 'undefinedState'.+newManagedImage :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Image -> Vk.ImageAspectFlags -> m ManagedImage+{-# INLINE newManagedImage #-}+newManagedImage reg image aspect = newManaged reg image (Vk.ImageSubresourceRange aspect 0 Vk.REMAINING_MIP_LEVELS 0 Vk.REMAINING_ARRAY_LAYERS)++-- | Wrap a single mip level (all its layers), tracked independently of the others.+newManagedImageMip :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Image -> Vk.ImageAspectFlags -> Word32 -> m ManagedImage+{-# INLINE newManagedImageMip #-}+newManagedImageMip reg image aspect mip = newManaged reg image (Vk.ImageSubresourceRange aspect mip 1 0 1)++-- | Wrap a single array layer / cubemap face (mip 0), tracked independently.+newManagedImageLayer :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Image -> Vk.ImageAspectFlags -> Word32 -> m ManagedImage+{-# INLINE newManagedImageLayer #-}+newManagedImageLayer reg image aspect layer = newManaged reg image (Vk.ImageSubresourceRange aspect 0 1 layer 1)++-- | Wrap an arbitrary @(mip × layer)@ slice (e.g. one light's 6 cube faces in an array).+newManagedImageSlice :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Image -> Vk.ImageAspectFlags -> Word32 -> Word32 -> Word32 -> Word32 -> m ManagedImage+{-# INLINE newManagedImageSlice #-}+newManagedImageSlice reg image aspect baseMip levelCount baseLayer layerCount =+ newManaged reg image (Vk.ImageSubresourceRange aspect baseMip levelCount baseLayer layerCount)++newManaged :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Image -> Vk.ImageSubresourceRange -> m ManagedImage+{-# INLINE newManaged #-}+newManaged reg image range = liftIO do+ stateRef <- newIORef undefinedState+ registerSlice reg image range stateRef+ queueRef <- newIORef Nothing+ releasedRef <- newIORef Nothing+ pure ManagedImage{releasedRef, image, range, stateRef, queueRef, shared = False, info = ""}++{- | A registry of live wrappers per image, enforcing the non-overlap+contract at wrap time ('registerSlice').++One per renderer, created where the images' owning scope begins (the+@ResourceT@ the render loop runs in): replacing the renderer replaces the+registry, so a dead scope's wrappers cannot poison the next one's over the+persisting Vulkan context.+-}+newtype SliceRegistry = SliceRegistry (IORef (Map Vk.Image [SliceEntry]))++-- | One live wrapper's range in a 'SliceRegistry' bucket.+data SliceEntry = SliceEntry+ { range :: Vk.ImageSubresourceRange+ , weak :: Weak (IORef ImageState)+ }++newSliceRegistry :: (MonadIO m) => m SliceRegistry+newSliceRegistry = liftIO (SliceRegistry <$> newIORef Map.empty)++{- | Check the range against the image's live wrappers and record it, fatally+on overlap.++Entries are weak, keyed on each wrapper's 'stateRef': a dropped wrapper's+tracker can never diverge again, so its range frees on collection. That also+keeps a recycled 'Vk.Image' handle (destroy, then create getting the same+value) from clashing with the destroyed image's wrappers.+-}+registerSlice :: (HasCallStack) => SliceRegistry -> Vk.Image -> Vk.ImageSubresourceRange -> IORef ImageState -> IO ()+registerSlice (SliceRegistry registry) image range stateRef = do+ live0 <- pruneLive+ -- A clash may be a dropped wrapper the GC has not reached yet: collect+ -- before accusing.+ live <- if any clash live0 then performGC *> pruneLive else pure live0+ case filter clash live of+ [] -> pure ()+ clashes ->+ error+ ( "Vulkan.Utils.FrameGraph: wrapping "+ <> show range+ <> " of "+ <> show image+ <> " overlaps a live ManagedImage over "+ <> show (map (.range) clashes)+ <> "; slices tracked separately must not overlap"+ )+ -- No finalizer: dead entries are pruned on the image's next wrap (or by+ -- 'forgetImage'), keeping this insert the map's only writer — a finalizer+ -- racing it could resurrect the entry it just removed.+ weak <- mkWeakIORef stateRef (pure ())+ atomicModifyIORef' registry \m -> (Map.insert image (SliceEntry{range, weak} : live) m, ())+ where+ clash e = overlappingRanges range e.range+ pruneLive = do+ m <- readIORef registry+ filterM (fmap isJust . deRefWeak . (.weak)) (Map.findWithDefault [] image m)++{- | Drop every wrapper registered over the image, reachable or not.++The deterministic half of deregistration: weak entries only free once+nothing holds the wrapper, but a destroyed image's wrappers may stay+reachable through scopes that outlive it (another in-flight frame's slot).+Register this next to the image's destruction so a recycled handle cannot+clash with them.+-}+forgetImage :: (MonadIO m) => SliceRegistry -> Vk.Image -> m ()+forgetImage (SliceRegistry registry) image =+ liftIO (atomicModifyIORef' registry \m -> (Map.delete image m, ()))++{- | Attach a summary (e.g. 'imageInfo') shown next to the resource's name+in visualization output.+-}+describedAs :: Text -> ManagedImage -> ManagedImage+describedAs t ManagedImage{..} = ManagedImage{info = t, ..}++{- | Mark the allocation as @SHARING_MODE_CONCURRENT@ across the families it+is used on.++Required before any cross-family access the graph cannot transfer+ownership for: an @EXCLUSIVE@ image's contents are undefined on the new+family, so crossing without this is fatal, not silent. Apply before+importing — imports read it through 'FG.isShared', exempting the image+from the schedule's single-owner validation.+-}+sharedAcrossQueues :: ManagedImage -> ManagedImage+sharedAcrossQueues mi = mi{shared = True}++-- | The conventional 'describedAs' summary: the format (sans prefix) and extent.+imageInfo :: Vk.Format -> Vk.Extent2D -> Text+imageInfo format (Vk.Extent2D w h) =+ Text.pack (drop (Text.length "FORMAT_") (show format) <> " " <> show w <> "x" <> show h)++{- | 'newManagedImage' with the 'imageInfo' description attached, stating the+allocation's format/extent once.+-}+describedImage :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Format -> Vk.Extent2D -> Vk.Image -> Vk.ImageAspectFlags -> m ManagedImage+describedImage reg format ext image aspect = describedAs (imageInfo format ext) <$> newManagedImage reg image aspect++-- | 'newManagedImageMip' with the mip's 'imageInfo' description attached.+describedMip :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Format -> Vk.Extent2D -> Vk.Image -> Vk.ImageAspectFlags -> Word32 -> m ManagedImage+describedMip reg format ext image aspect mip = describedAs (imageInfo format ext) <$> newManagedImageMip reg image aspect mip++-- | A mip-0 layer range via 'newManagedImageSlice', with the 'imageInfo' description attached.+describedSlice :: (HasCallStack, MonadIO m) => SliceRegistry -> Vk.Format -> Vk.Extent2D -> Vk.Image -> Vk.ImageAspectFlags -> Word32 -> Word32 -> m ManagedImage+describedSlice reg format ext image aspect baseLayer layerCount = describedAs (imageInfo format ext) <$> newManagedImageSlice reg image aspect 0 1 baseLayer layerCount++instance FG.Resource ManagedImage where+ type Desc ManagedImage = ImageDesc+ type Alloc ManagedImage = ()+ type Ctx ManagedImage = Recorder+ type Flags ManagedImage = Usage++ createResource _ _ =+ error "ManagedImage is import-only: allocate the image and use importResource"++ destroyResource _ _ _ = pure ()++ preRead h _ usage rec mi = queueTransition rec (FG.handleId h) mi usage+ preWrite h _ usage rec mi = queueTransition rec (FG.handleId h) mi usage++ -- The two halves of a cross-queue hand-off, fired on the producing and the+ -- consuming side of each data edge.+ preRelease h _ usage peer rec mi = transferOwnership Release rec (FG.handleId h) peer mi usage+ preAcquire h _ usage peer rec mi = transferOwnership Acquire rec (FG.handleId h) peer mi usage++ isShared mi = mi.shared++ describeDesc d = d.info++-- | The synchronization state an image is currently left in.+data ImageState = ImageState+ { layout :: Vk.ImageLayout+ , stage :: Vk.PipelineStageFlags+ , access :: Vk.AccessFlags+ }+ deriving stock (Eq, Show)++-- | Freshly created / never-transitioned: undefined layout, top of pipe.+undefinedState :: ImageState+undefinedState =+ ImageState+ { layout = Vk.IMAGE_LAYOUT_UNDEFINED+ , stage = Vk.PIPELINE_STAGE_TOP_OF_PIPE_BIT+ , access = zero+ }++{- | How a pass uses an image, i.e. the 'ImageState' it must be in for that+access. The per-access payload of 'FG.readWith' / 'FG.writeWith'.+-}+data Usage+ = ColorAttachment+ | DepthAttachment+ | TransferSrc+ | TransferDst+ | Present+ | -- | Storage read/write in the given shader stage (compute, fragment, …).+ StorageRead Vk.PipelineStageFlags+ | StorageWrite Vk.PipelineStageFlags+ | -- | Sampled in the given shader stage (fragment, compute, …).+ Sampled Vk.PipelineStageFlags+ | -- | Read by the host after a fence (@GENERAL@, the layout mapped linear images live in).+ HostRead+ deriving stock (Eq, Ord, Show)++{- | The target state each 'Usage' requires. Stage/access mirror the+@Vulkan.Utils.Barrier@ @transition*@ helpers.+-}+usageState :: Usage -> ImageState+usageState = \case+ -- READ covers @LOAD_OP_LOAD@ and blending, as DepthAttachment's covers the depth test.+ ColorAttachment ->+ ImageState+ Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL+ Vk.PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT+ (Vk.ACCESS_COLOR_ATTACHMENT_READ_BIT .|. Vk.ACCESS_COLOR_ATTACHMENT_WRITE_BIT)+ DepthAttachment ->+ ImageState+ Vk.IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL+ (Vk.PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT .|. Vk.PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT)+ (Vk.ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT .|. Vk.ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT)+ TransferSrc ->+ ImageState+ Vk.IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL+ Vk.PIPELINE_STAGE_TRANSFER_BIT+ Vk.ACCESS_TRANSFER_READ_BIT+ TransferDst ->+ ImageState+ Vk.IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL+ Vk.PIPELINE_STAGE_TRANSFER_BIT+ Vk.ACCESS_TRANSFER_WRITE_BIT+ Present ->+ ImageState+ Vk.IMAGE_LAYOUT_PRESENT_SRC_KHR+ Vk.PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT+ zero+ StorageRead stage ->+ ImageState Vk.IMAGE_LAYOUT_GENERAL stage Vk.ACCESS_SHADER_READ_BIT+ StorageWrite stage ->+ ImageState Vk.IMAGE_LAYOUT_GENERAL stage Vk.ACCESS_SHADER_WRITE_BIT+ Sampled stage ->+ ImageState Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL stage Vk.ACCESS_SHADER_READ_BIT+ HostRead ->+ ImageState Vk.IMAGE_LAYOUT_GENERAL Vk.PIPELINE_STAGE_HOST_BIT Vk.ACCESS_HOST_READ_BIT++{- | Whether the 'Usage' writes the image (and so needs a barrier even when the+state is unchanged — only read-after-read can skip it).+-}+usageWrites :: Usage -> Bool+usageWrites = \case+ ColorAttachment -> True+ DepthAttachment -> True+ TransferDst -> True+ StorageWrite _ -> True+ TransferSrc -> False+ Present -> False+ StorageRead _ -> False+ Sampled _ -> False+ HostRead -> False++{- | Record the barrier bringing the image into the 'Usage''s state and update+the tracked state. Standalone counterpart to the hook path, for barriers+recorded outside a pass; treats the access as same-queue.++A write 'Usage' records the barrier even when the state is unchanged — a+same-state write still needs the execution+memory dependency against the+previous access. Only a read of an already-matching state skips it.+-}+transitionImageTo :: (MonadIO m) => Vk.CommandBuffer -> ManagedImage -> Usage -> m ()+{-# INLINE transitionImageTo #-}+transitionImageTo cb mi usage = transitionImagesTo cb [(mi, usage)]++{- | 'transitionImageTo' over a batch: one @vkCmdPipelineBarrier@, OR-ed stage masks.++The images must be tracked separately (distinct non-overlapping slices):+barriers in one command are unordered, so two entries for the same slice+would race.+-}+transitionImagesTo :: (MonadIO m) => Vk.CommandBuffer -> [(ManagedImage, Usage)] -> m ()+transitionImagesTo cb accesses = do+ (srcs, dsts, barriers) <- foldM collect (zero, zero, []) accesses+ unless (null barriers) $+ Vk.cmdPipelineBarrier cb srcs dsts zero [] [] (V.fromList barriers)+ where+ collect acc@(srcs, dsts, barriers) (mi, usage) = do+ lastQueue <- liftIO (readIORef mi.queueRef)+ nextTransition (maybe HostAccess DeviceQueue lastQueue) (\_ _ -> True) False mi usage >>= \case+ Nothing -> pure acc+ Just (src, dst, barrier) -> pure (srcs .|. src, dsts .|. dst, barrier : barriers)++{- | Copy an image into a host-readable one via the trackers.++The source moves to @TRANSFER_SRC@ from whatever state it is actually in, the+destination through @TRANSFER_DST@ to 'HostRead' — no assumed layouts, no+hand-rolled host barrier. Copies the first mip and layer of each wrapper's+slice (the aspects must match).+-}+copyManagedImageToHost :: (MonadIO m) => Vk.CommandBuffer -> Vk.Extent2D -> ManagedImage -> ManagedImage -> m ()+copyManagedImageToHost cb (Vk.Extent2D w h) src cpu = do+ transitionImagesTo cb [(src, TransferSrc), (cpu, TransferDst)]+ Vk.cmdCopyImage+ cb+ src.image+ (usageState TransferSrc).layout+ cpu.image+ (usageState TransferDst).layout+ [Vk.ImageCopy (sliceLayers src) (Vk.Offset3D 0 0 0) (sliceLayers cpu) (Vk.Offset3D 0 0 0) (Vk.Extent3D w h 1)]+ transitionImageTo cb cpu HostRead++-- | The slice's first mip and layer, as a transfer command's subresource.+sliceLayers :: ManagedImage -> Vk.ImageSubresourceLayers+sliceLayers mi = Vk.ImageSubresourceLayers mi.range.aspectMask mi.range.baseMipLevel mi.range.baseArrayLayer 1++{- | The hook path: 'transitionImageTo' rules, but queued and queue-aware.++The barrier goes into the 'Recorder''s per-pass batch (flushed before the+exec callback), and when the access rides a prior synchronization — a+cross-queue hop (the driver's semaphore) or a split-barrier event the pass+waited on ('chainedNode') — it chains to it instead: source scope becomes+the destination stage with no access mask, since the semaphore/event+already provides execution ordering and memory availability. The driver's+wait @dstStageMask@ / event scope must cover the usage's stage (both then+chain). Same-family queues share freely; crossing to another /family/+needs CONCURRENT sharing or an ownership acquire this pass performed —+otherwise an EXCLUSIVE image's contents are undefined there.+-}+queueTransition :: (MonadIO m) => Recorder -> Int -> ManagedImage -> Usage -> m ()+queueTransition rec node mi usage = do+ queue <- recorderQueue rec+ chained0 <- chainedNode rec node+ hosted <- recorderHost rec+ sameFamily <- recorderSameFamily rec+ nextTransition (if hosted then HostAccess else DeviceQueue queue) sameFamily chained0 mi usage >>= traverse_ \(srcStage, dstStage, barrier) ->+ queueBarrier rec srcStage dstStage barrier++{- | The producer- and consumer-side halves of a cross-queue hand-off.++On a @CONCURRENT@ image ('sharedAcrossQueues') the release half carries the+layout transition producer-side — its source scope stays on a queue that+supports it — and the acquire half is a no-op: the driver's semaphore+already orders the two, and no family owns the image.++On an @EXCLUSIVE@ image the pair is a real queue-family ownership transfer:+the same barrier is recorded twice, once in each queue's buffer, with both+family indices named. The halves must match exactly, so both are computed+from the state the release saw — the acquire is what advances the tracked+state, and it marks the node chained so the consumer's own declared access+does not place a second barrier on top of it.++Same-family queues own nothing to transfer: the release still moves the+layout, the acquire still just advances the tracking.++A hand-off to the host is neither: the release carries the full dependency+(a semaphore signal makes device writes available to the device domain+only, so the host half needs a real @HOST@ destination scope), and the+acquire is bookkeeping.+-}+transferOwnership :: (MonadIO m) => TransferSide -> Recorder -> Int -> FG.QueueId -> ManagedImage -> Usage -> m ()+transferOwnership side rec node peer mi usage = do+ hosted <- recorderHost rec+ queue <- recorderQueue rec+ ourFamily <- recorderFamily rec queue+ peerFamily <- recorderFamily rec peer+ cur <- liftIO (readIORef mi.stateRef)+ released <- liftIO (readIORef mi.releasedRef)+ let+ next = usageState usage+ -- The producer is the release's queue and the acquire's peer.+ (srcFamily, dstFamily) = case side of+ Release -> (ourFamily, peerFamily)+ Acquire -> (peerFamily, ourFamily)+ -- The host owns nothing (it is not a family), and a CONCURRENT image is+ -- owned by no one: those hand-offs ride the semaphore alone.+ owned =+ not mi.shared+ && not hosted+ && srcFamily /= dstFamily+ && srcFamily /= Vk.QUEUE_FAMILY_IGNORED+ && dstFamily /= Vk.QUEUE_FAMILY_IGNORED+ -- The consumer is the host: only the release's barrier can make the+ -- device's writes visible to it (the schedule's timeline wait cannot).+ toHost = next.stage .&. Vk.PIPELINE_STAGE_HOST_BIT /= zero+ -- Both halves describe the same barrier, so the acquire builds its own+ -- from the state the release saw.+ from = case side of+ Release -> cur+ Acquire -> fromMaybe cur released+ barrier =+ SomeStruct+ zero+ { Vk.srcAccessMask = case side of+ Release -> from.access+ Acquire -> zero+ , Vk.dstAccessMask = case side of+ Release -> if toHost then next.access else zero+ Acquire -> next.access+ , Vk.oldLayout = from.layout+ , Vk.newLayout = next.layout+ , Vk.srcQueueFamilyIndex = if owned then srcFamily else Vk.QUEUE_FAMILY_IGNORED+ , Vk.dstQueueFamilyIndex = if owned then dstFamily else Vk.QUEUE_FAMILY_IGNORED+ , Vk.image = mi.image+ , Vk.subresourceRange = mi.range+ }+ -- A release's destination scope and an acquire's source scope are ignored+ -- by the spec; the halves must otherwise be identical.+ (srcStage, dstStage) = case side of+ Release -> (from.stage, if toHost then next.stage else Vk.PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT)+ Acquire -> (Vk.PIPELINE_STAGE_TOP_OF_PIPE_BIT, next.stage)+ case side of+ Release -> do+ -- The release performs the layout transition (its barrier executes on+ -- the producer's queue), so the tracked state advances with it.+ when (owned || toHost || cur /= next) do+ queueBarrier rec srcStage dstStage barrier+ liftIO (writeIORef mi.stateRef next)+ liftIO (writeIORef mi.releasedRef (Just cur))+ Acquire -> do+ -- An owned acquire without its armed release half would record an+ -- unmatched barrier from a guessed state: a schedule bug, not a+ -- recoverable condition.+ when (owned && isNothing released) $+ error+ ( "Vulkan.Utils.FrameGraph: ownership acquire of "+ <> show mi.info+ <> " without a pending release; the schedule must pair the halves"+ )+ when owned $ queueBarrier rec srcStage dstStage barrier+ liftIO do+ -- The host is not a device queue: recording it as the last one would+ -- make the next device access look cross-queue (cf. 'nextTransition'),+ -- and its release already advanced the state (the @toHost@ arm) — the+ -- driver defers host passes, so a late write here would rewind the+ -- device-side tracking.+ unless hosted do+ writeIORef mi.stateRef next+ writeIORef mi.queueRef (Just queue)+ -- Only the owned half consumed the hand-off; a melted acquire (host,+ -- shared, same family) must leave the slot for a pending owned one.+ when owned $ writeIORef mi.releasedRef Nothing+ markChained rec node++{- | Diff the tracked state against the 'Usage''s target and advance it.++Hands back the @(srcStage, dstStage, barrier)@ still to be recorded — the+caller commits to recording it (immediately or batched) before the access+runs.+-}+nextTransition+ :: (MonadIO m)+ => Accessor+ -> (FG.QueueId -> FG.QueueId -> Bool)+ -- ^ whether two queues belong to one family (share ownership)+ -> Bool+ -- ^ an ownership acquire already synchronized it ('chainedNode')+ -> ManagedImage+ -> Usage+ -> m (Maybe (Vk.PipelineStageFlags, Vk.PipelineStageFlags, SomeStruct Vk.ImageMemoryBarrier))+nextTransition accessor sameFamily marked mi usage = liftIO do+ cur <- readIORef mi.stateRef+ lastQueue <- readIORef mi.queueRef+ let+ next = usageState usage+ -- A first access owns nothing yet, and the host is not a queue family (its+ -- accesses order through the schedule's timeline and the producer's+ -- release barrier), so neither crosses ownership.+ crossQueue = case (accessor, lastQueue) of+ (DeviceQueue q, Just prev) -> q /= prev+ _ -> False+ crossFamily =+ crossQueue && case (accessor, lastQueue) of+ (DeviceQueue q, Just prev) -> not (sameFamily q prev)+ _ -> False+ -- A cross-queue hop rides the driver's semaphore even within one family.+ chained = crossQueue || marked+ -- Crossing to a new family without a transfer: the contents are undefined+ -- there, so the access acquires by discarding them (it writes — the guard+ -- below rejects a read).+ discards = crossFamily && not mi.shared+ srcStage = if chained then next.stage else cur.stage+ srcAccess = if chained then zero else cur.access+ -- Semaphore/event-ordered same-state accesses need no barrier of their+ -- own; unchained writes need one even with the state unchanged.+ needed = cur /= next || (usageWrites usage && not chained)+ -- An unshared (EXCLUSIVE) resource reaching another family without an+ -- ownership transfer ('transferOwnership') has undefined contents there. A+ -- write that does not read them is still fine — it acquires by discarding+ -- (see 'discards') — but a read would see garbage, so it is fatal.+ when (crossFamily && not mi.shared && not (usageWrites usage)) $+ error+ ( "Vulkan.Utils.FrameGraph: cross-family read of an unshared resource ("+ <> show mi.info+ <> ") the graph never handed over: it must be produced on the reading family, "+ <> "marked 'sharedAcrossQueues' (CONCURRENT), or written by a pass the graph "+ <> "can transfer ownership from"+ )+ case accessor of+ DeviceQueue q -> writeIORef mi.queueRef (Just q)+ HostAccess -> pure ()+ if needed+ then do+ writeIORef mi.stateRef next+ pure $+ Just+ ( srcStage+ , next.stage+ , SomeStruct+ zero+ { Vk.srcAccessMask = srcAccess+ , Vk.dstAccessMask = next.access+ , Vk.oldLayout = if discards then Vk.IMAGE_LAYOUT_UNDEFINED else cur.layout+ , Vk.newLayout = next.layout+ , -- IGNORED (not 0): a plain transition, not an ownership+ -- transfer ('transferOwnership' emits those).+ Vk.srcQueueFamilyIndex = Vk.QUEUE_FAMILY_IGNORED+ , Vk.dstQueueFamilyIndex = Vk.QUEUE_FAMILY_IGNORED+ , Vk.image = mi.image+ , Vk.subresourceRange = mi.range+ }+ )+ else+ pure Nothing++{- | Descriptor for a 'ManagedImage'; carries the image's 'describedAs'+summary for visualization output (the resource name travels separately).+-}+newtype ImageDesc = ImageDesc {info :: Text}++{- | Import a 'ManagedImage' under @name@, as an observed resource.++Also claims the graph's 'FG.addPreExec slot for 'flushBarriers', so the+hook-queued barriers are recorded under any driver — the adapter owns that+slot; wrap the flush rather than replacing it.++Writers of the image become side effects ('FG.importResource'): right for+presentables and anything read outside the graph (readbacks, a next-frame+sampler). For targets only this graph's passes consume, use+'importScratchImage' so demand culling applies.+-}+importManagedImage :: (MonadIO m) => FG.FrameGraph Recorder () -> Text -> ManagedImage -> m (FG.Handle ManagedImage)+importManagedImage graph name mi = do+ FG.addPreExec graph flushBarriers+ disarmHandOff mi+ FG.importResource graph name (ImageDesc mi.info) mi++{- | Drop a hand-off a previous graph's melted acquire left armed.++Run at import, so the unpaired-acquire check sees only this graph's+releases — a stale slot would let it pass and record a barrier from a+frames-old state.+-}+disarmHandOff :: (MonadIO m) => ManagedImage -> m ()+disarmHandOff mi = liftIO (writeIORef mi.releasedRef Nothing)++{- | 'importManagedImage' via 'FG.importScratch', keeping writers subject to demand culling.++The image (and its layout tracking) persists between graphs, but its contents+are only ever consumed through this graph. Passes that feed a between-graphs+consumer must say 'FG.setSideEffect' themselves.+-}+importScratchImage :: (MonadIO m) => FG.FrameGraph Recorder () -> Text -> ManagedImage -> m (FG.Handle ManagedImage)+importScratchImage graph name mi = do+ FG.addPreExec graph flushBarriers+ disarmHandOff mi+ FG.importScratch graph name (ImageDesc mi.info) mi++{- | 'importManagedImage' declaring the queue that owns the image across the+frame boundary ('FG.importOwned'), read off the wrapper's own tracking: a+first touch on another family this frame derives a real release / acquire+pair — the release recorded on the owning queue — instead of the fatal+cross-family read. An image no device queue has touched yet imports+plainly.+-}+importOwnedImage :: (MonadIO m) => FG.FrameGraph Recorder () -> Text -> ManagedImage -> m (FG.Handle ManagedImage)+importOwnedImage graph name mi =+ liftIO (readIORef mi.queueRef) >>= \case+ Nothing -> importManagedImage graph name mi+ Just owner -> do+ FG.addPreExec graph flushBarriers+ disarmHandOff mi+ FG.importOwned graph name (ImageDesc mi.info) mi owner++{- | Declare the queue that owns the image, established outside any graph.++For producers the adapters cannot see — a fenced one-shot bake, an upload+queue — so 'importOwnedImage' has an owner to derive the first hand-off+from. In-graph accesses track this themselves.+-}+claimOwnership :: (MonadIO m) => FG.QueueId -> ManagedImage -> m ()+claimOwnership queue mi = liftIO (writeIORef mi.queueRef (Just queue))
+ src/Vulkan/Utils/FrameGraph/Recorder.hs view
@@ -0,0 +1,354 @@+{-| Command-buffer routing and barrier batching for driving 'FG.executeQueued'.++A 'Recorder' is a mutable slot holding the command buffer the current pass — and+the barrier hooks it fires — record into, plus the batch of barriers those hooks+have queued for the pass ('queueBarrier', emitted as one command by+'flushBarriers' from the graph's 'FG.addPreExec point). 'recordingBackend' is+the topology-agnostic 'FG.QueueBackend' that points the recorder at each pass's+queue buffer; 'recordGraph' wraps the whole record step (fresh recorder, flush+installed, run, close every buffer), leaving each driver to supply only its own+submit policy.++Nothing here is resource-specific beyond the barrier payload types (one lane+per kind): it is the execution seam any 'FG.Resource' adapter records through.+-}+module Vulkan.Utils.FrameGraph.Recorder+ ( Recorder+ , newRecorder+ , setRecorder+ , setRecorderHost+ , clearChained+ , chainedNode+ , markChained+ , recorderHost+ , setRecorderFamilies+ , recorderFamily+ , recorderSameFamily+ , Accessor (..)+ , TransferSide (..)+ , recorderCommandBuffer+ , recorderQueue+ , Barriers (..)+ , queueBarrier+ , queueBufferBarrier+ , overlappingRanges+ , flushBarriers+ , takeBarriers+ , recordingCommandBuffer+ , recordingBackend+ , recordGraph+ , recordGraphSyncs+ ) where++import Control.Monad (unless, void, when)+import Control.Monad.IO.Class (MonadIO (..))+import Data.Bits ((.&.), (.|.))+import Data.Foldable (traverse_)+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)+import Data.IntSet (IntSet)+import Data.IntSet qualified as IntSet+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NE+import Data.Vector qualified as V+import Data.Word (Word32)++import Fragr qualified as FG+import Vulkan.CStruct.Extends (SomeStruct (..))+import Vulkan.Core10 qualified as Vk+import Vulkan.Zero (zero)++{- | The command buffer the barrier hooks (and a pass's exec callback) record+into, tagged with its queue so resource adapters can tell when consecutive+accesses cross queues, plus the pass's pending barrier batch. For single-queue+'FG.execute' it holds one buffer for the whole frame; the multi-queue+'FG.executeQueued' driver swaps it per pass so each queue's work lands in that+queue's buffer.+-}+data Recorder = Recorder+ { slot :: IORef (FG.QueueId, Vk.CommandBuffer)+ , pending :: IORef Barriers+ , host :: IORef Bool+ -- ^ Host mode: the current pass has no command buffer ('setRecorderHost').+ , chained :: IORef IntSet+ {- ^ Nodes whose dependency this pass already synchronized: a split-barrier+ event it waited on, or an ownership acquire it performed.+ -}+ , familyOf :: IORef (Maybe (FG.QueueId -> Word32))+ {- ^ The queue family behind each 'FG.QueueId' ('setRecorderFamilies');+ 'Nothing' until a driver provides one, when distinct queues are+ conservatively treated as distinct families.+ -}+ }++-- | The barriers queued for the current pass, OR-ing the stage scopes.+data Barriers = Barriers+ { srcStage :: !Vk.PipelineStageFlags+ , dstStage :: !Vk.PipelineStageFlags+ , images :: [SomeStruct Vk.ImageMemoryBarrier]+ , buffers :: [SomeStruct Vk.BufferMemoryBarrier]+ }++noBarriers :: Barriers+noBarriers = Barriers zero zero [] []++-- | A recorder pointed at an initial buffer on queue 0; swap it with 'setRecorder'.+newRecorder :: (MonadIO m) => Vk.CommandBuffer -> m Recorder+newRecorder cb =+ liftIO $+ Recorder+ <$> newIORef (FG.QueueId 0, cb)+ <*> newIORef noBarriers+ <*> newIORef False+ <*> newIORef IntSet.empty+ <*> newIORef Nothing++-- | Point the recorder at the queue's command buffer the next passes record into.+setRecorder :: (MonadIO m) => Recorder -> FG.QueueId -> Vk.CommandBuffer -> m ()+setRecorder rec queue cb = liftIO do+ writeIORef rec.slot (queue, cb)+ writeIORef rec.host False++{- | Point the recorder at a host-executed pass.++Host work records no commands: the hooks still fire — advancing each+resource's tracked state so later device accesses diff correctly — but the+barriers they queue are dropped. The device-side half of a host access is+the producer's ('FG.preRelease' transitions the resource into the host+state in the producing queue's buffer), and its ordering is the schedule's+timeline wait, realised by the driver executing the pass.+-}+setRecorderHost :: (MonadIO m) => Recorder -> FG.QueueId -> m ()+setRecorderHost rec queue = liftIO do+ modifyIORef' rec.slot (\(_, cb) -> (queue, cb))+ writeIORef rec.host True++{- | Drop the chained marks, at the start of each pass.++They are per-pass: a mark left over from the previous one would suppress a+barrier this pass genuinely needs.+-}+clearChained :: (MonadIO m) => Recorder -> m ()+clearChained rec = liftIO (writeIORef rec.chained mempty)++-- | Whether the node's dependency this pass already synchronized.+chainedNode :: (MonadIO m) => Recorder -> Int -> m Bool+chainedNode rec node = liftIO (IntSet.member node <$> readIORef rec.chained)++{- | Mark a node as already synchronized for the current pass.++The ownership-acquire hook does this: the acquire barrier it emitted carries+the full dependency, so the pass's own declared access must not re-place one.+-}+markChained :: (MonadIO m) => Recorder -> Int -> m ()+markChained rec node = liftIO (modifyIORef' rec.chained (IntSet.insert node))++-- | The queue-family table an ownership transfer names its two sides from.+setRecorderFamilies :: (MonadIO m) => Recorder -> (FG.QueueId -> Word32) -> m ()+setRecorderFamilies rec families = liftIO (writeIORef rec.familyOf (Just families))++{- | The family behind a 'FG.QueueId'.++@QUEUE_FAMILY_IGNORED@ when no table was set: ownership transfers then melt+to plain transitions, matching a driver that never crosses families.+-}+recorderFamily :: (MonadIO m) => Recorder -> FG.QueueId -> m Word32+recorderFamily rec queue = liftIO (maybe Vk.QUEUE_FAMILY_IGNORED ($ queue) <$> readIORef rec.familyOf)++{- | Whether two queues belong to one family, for per-access comparisons.++Without a table ('setRecorderFamilies') this is queue identity: distinct+queues must be assumed to be distinct families, or an EXCLUSIVE resource+crossing them would silently lose the fatal unshared-read diagnostic.+-}+recorderSameFamily :: (MonadIO m) => Recorder -> m (FG.QueueId -> FG.QueueId -> Bool)+recorderSameFamily rec = liftIO (maybe (==) (\f a b -> f a == f b) <$> readIORef rec.familyOf)++{- | Who is performing an access: a device queue, or the host.++The host is not a queue family — its accesses order through the schedule's+timeline and the producer's release barrier, so they never transfer+ownership.+-}+data Accessor+ = DeviceQueue FG.QueueId+ | HostAccess+ deriving stock (Eq, Show)++-- | Which half of a cross-queue hand-off a barrier is.+data TransferSide = Release | Acquire+ deriving stock (Eq, Show)++-- | Whether the current pass runs on the host ('setRecorderHost').+recorderHost :: (MonadIO m) => Recorder -> m Bool+recorderHost rec = liftIO (readIORef rec.host)++-- | The command buffer currently selected.+recorderCommandBuffer :: (MonadIO m) => Recorder -> m Vk.CommandBuffer+{-# INLINE recorderCommandBuffer #-}+recorderCommandBuffer rec = liftIO (snd <$> readIORef rec.slot)++-- | The queue the current pass records on.+recorderQueue :: (MonadIO m) => Recorder -> m FG.QueueId+{-# INLINE recorderQueue #-}+recorderQueue rec = liftIO (fst <$> readIORef rec.slot)++{- | Queue a barrier into the current pass's batch instead of recording it.++A barrier overlapping a subresource already in the batch flushes it first:+barriers in one command are unordered, and an overlapping pair (a pass+reading then writing one image) is a dependent chain of layout transitions+that needs the command split to stay ordered.++The batch is emitted by 'flushBarriers', which the graph must fire between+the hooks and the exec callback (installed via 'FG.addPreExec by the image+adapter's import and by 'recordGraph') — a driver whose resources queue+through another path must install it itself, or the queued barriers are+never recorded.+-}+queueBarrier+ :: (MonadIO m)+ => Recorder+ -> Vk.PipelineStageFlags+ -> Vk.PipelineStageFlags+ -> SomeStruct Vk.ImageMemoryBarrier+ -> m ()+queueBarrier rec src dst barrier = do+ hosted <- liftIO (readIORef rec.host)+ unless hosted do+ Barriers{images} <- liftIO (readIORef rec.pending)+ when (any (overlapping barrier) images) (flushBarriers rec)+ liftIO $ modifyIORef' rec.pending \b ->+ b{srcStage = b.srcStage .|. src, dstStage = b.dstStage .|. dst, images = barrier : b.images}++-- | 'queueBarrier' for the buffer lane; overlap is per whole buffer.+queueBufferBarrier+ :: (MonadIO m)+ => Recorder+ -> Vk.PipelineStageFlags+ -> Vk.PipelineStageFlags+ -> SomeStruct Vk.BufferMemoryBarrier+ -> m ()+queueBufferBarrier rec src dst barrier@(SomeStruct new) = do+ hosted <- liftIO (readIORef rec.host)+ unless hosted do+ Barriers{buffers} <- liftIO (readIORef rec.pending)+ when (any (\(SomeStruct b) -> b.buffer == new.buffer) buffers) (flushBarriers rec)+ liftIO $ modifyIORef' rec.pending \b ->+ b{srcStage = b.srcStage .|. src, dstStage = b.dstStage .|. dst, buffers = barrier : b.buffers}++-- | Whether two image barriers touch overlapping subresources.+overlapping :: SomeStruct Vk.ImageMemoryBarrier -> SomeStruct Vk.ImageMemoryBarrier -> Bool+overlapping (SomeStruct a) (SomeStruct b) =+ a.image == b.image && overlappingRanges a.subresourceRange b.subresourceRange++-- | Whether two subresource ranges of one image intersect.+overlappingRanges :: Vk.ImageSubresourceRange -> Vk.ImageSubresourceRange -> Bool+overlappingRanges ra rb =+ ra.aspectMask .&. rb.aspectMask /= zero+ && spans ra.baseMipLevel ra.levelCount rb.baseMipLevel rb.levelCount+ && spans ra.baseArrayLayer ra.layerCount rb.baseArrayLayer rb.layerCount+ where+ -- The REMAINING_* sentinels are maxBound: treat as extending to the end.+ spans baseA countA baseB countB = baseA < end baseB countB && baseB < end baseA countA+ end base count = if count == maxBound then maxBound else base + count++{- | Drain the queued batch without recording it.++What 'flushBarriers' would have emitted, handed to the caller instead: the+seam for verifying the hooks' barriers without a device (see the test+suite's ownership-transfer group).+-}+takeBarriers :: (MonadIO m) => Recorder -> m Barriers+takeBarriers rec = liftIO (atomicModifyIORef' rec.pending \b -> (noBarriers, b))++{- | Record the pending batch as one @vkCmdPipelineBarrier@ into the current+buffer and clear it; a no-op when nothing is queued.++The stage masks are the OR of every queued barrier's — a slightly wider (never+weaker) dependency than per-barrier commands, the price of batching.+-}+flushBarriers :: (MonadIO m) => Recorder -> m ()+flushBarriers rec = liftIO do+ -- Peek before draining: this runs once per import per pass, nearly always+ -- on an empty batch, so the empty case must stay a plain read.+ peeked <- readIORef rec.pending+ case (peeked.images, peeked.buffers) of+ ([], []) -> pure ()+ _ -> do+ Barriers{srcStage, dstStage, images, buffers} <- takeBarriers rec+ (_queue, cb) <- readIORef rec.slot+ Vk.cmdPipelineBarrier cb srcStage dstStage zero [] (V.fromList buffers) (V.fromList images)++-- | The command buffer the executing pass records into ('recorderCommandBuffer' of the 'FG.Exec' context).+recordingCommandBuffer :: FG.Exec Recorder alloc Vk.CommandBuffer+{-# INLINE recordingCommandBuffer #-}+recordingCommandBuffer = recorderCommandBuffer =<< FG.askCtx++{- | A 'FG.QueueBackend' that routes each pass's recording to its queue's command+buffer (via @cbFor@) and does nothing else.++The topology-agnostic core an 'FG.executeQueued' driver is built on: it only+points the 'Recorder' at the right buffer before each pass. The rest of a+schedule — timeline waits/signals, split-barrier events, queue-family ownership+— is the caller's to realise from 'FG.PassSync' / 'FG.snapshot' around it. On a+single-queue schedule @cbFor = const theOnlyBuffer@ and it degenerates to+recording everything into one buffer.+-}+recordingBackend :: Recorder -> (FG.QueueId -> Vk.CommandBuffer) -> FG.QueueBackend+recordingBackend recorder cbFor =+ FG.QueueBackend+ { FG.beforePass = \psync -> do+ -- Chained marks are per-pass; a stale one would suppress a barrier.+ clearChained recorder+ setRecorder recorder psync.queue (cbFor psync.queue)+ , FG.afterPass = \_ -> pure ()+ , FG.invoke = \_ body -> body+ , FG.completed = pure []+ }++{- | Record a compiled graph into the given per-queue command buffers: point a+fresh recorder at the first buffer, install the 'flushBarriers' flush, drive+'FG.executeQueued' (routing each pass to @cbFor@), then end every buffer. The+caller supplies only the submit that follows.++Runs without a 'FG.RecycleQueue': import-only adapters never retire a+resource, so there is nothing to reclaim (allocate transients in the frame's own+resource scope instead). The first buffer is the primary the recorder starts on;+all buffers are ended.+-}+recordGraph+ :: (MonadIO m)+ => (FG.QueueId -> Vk.CommandBuffer)+ -> NonEmpty Vk.CommandBuffer+ -> FG.FrameGraph Recorder ()+ -> m ()+recordGraph cbFor buffers graph = void (recordGraphSyncs cbFor buffers graph)++{- | 'recordGraph', handing back each executed pass's 'FG.PassSync'.++In execution order, for a caller deriving its submits from the schedule+(see "Vulkan.Utils.FrameGraph.Driver" for the packaged one).+-}+recordGraphSyncs+ :: (MonadIO m)+ => (FG.QueueId -> Vk.CommandBuffer)+ -> NonEmpty Vk.CommandBuffer+ -> FG.FrameGraph Recorder ()+ -> m [FG.PassSync]+recordGraphSyncs cbFor buffers graph = do+ recorder <- newRecorder (NE.head buffers)+ syncs <- liftIO (newIORef [])+ FG.addPreExec graph flushBarriers+ -- The release hooks queue producer-side barriers after the pass body.+ FG.addPostExec graph flushBarriers+ let+ routing = recordingBackend recorder cbFor+ collecting =+ routing+ { FG.beforePass = \psync -> do+ modifyIORef' syncs (psync :)+ routing.beforePass psync+ }+ FG.executeQueued graph collecting Nothing recorder ()+ traverse_ Vk.endCommandBuffer buffers+ liftIO (reverse <$> readIORef syncs)
+ src/Vulkan/Utils/FrameGraph/Swapchain.hs view
@@ -0,0 +1,71 @@+{-| Swapchain images as frame-graph imports.++Every windowed frame graph ends the same way: some pass writes the acquired+swapchain image and it must reach @PRESENT_SRC@ before the present.+'newSwapchainImages' builds the persistent per-image 'ManagedImage' table,+'importSwapchain' imports the acquired image from it each frame, and+'presentSwapchain' appends the terminal pass. Skipping the terminal pass (or+reading instead of writing) presents the image stuck in the last writer's+layout, with nothing in the types to catch it.+-}+module Vulkan.Utils.FrameGraph.Swapchain+ ( newSwapchainImages+ , forgetSwapchainImages+ , importSwapchain+ , presentSwapchain+ ) where++import Control.Monad.IO.Class (MonadIO)+import Data.Foldable (traverse_)+import Data.Vector (Vector)+import Data.Vector qualified as V+import Data.Word (Word32)++import Fragr qualified as FG+import Vulkan.Core10 qualified as Vk+import Vulkan.Extensions.VK_KHR_surface qualified+import Vulkan.Utils.FrameGraph.Image (ManagedImage, SliceRegistry, Usage (Present), describedImage, forgetImage, importManagedImage)+import Vulkan.Utils.FrameGraph.Recorder (Recorder)+import Vulkan.Utils.Swapchain (Swapchain (..))++{- | Wrap each swapchain image in a layout-tracked 'ManagedImage'.++The table persists for the swapchain's lifetime — the tracked layouts carry+across frames — so build it once per (re)created swapchain, next to the other+per-swapchain bindings. The images belong to the swapchain; the wrappers need+no release.+-}+newSwapchainImages :: (MonadIO m) => SliceRegistry -> Swapchain -> m (Vector ManagedImage)+newSwapchainImages reg sc =+ traverse+ (\image -> describedImage reg sc.sFormat.format sc.sExtent image Vk.IMAGE_ASPECT_COLOR_BIT)+ sc.sImages++{- | Drop this swapchain's image wrappers from the registry.++A retiring swapchain's handles may be recycled into the next one's images;+call this next to 'newSwapchainImages' when recreating, so the old wrappers+are gone before the new table re-wraps whatever handles come back.+-}+forgetSwapchainImages :: (MonadIO m) => SliceRegistry -> Swapchain -> m ()+forgetSwapchainImages reg sc = traverse_ (forgetImage reg) sc.sImages++{- | Import the acquired image (as @swapchain@) into this frame's graph.++Returns the handle for the graph's writes alongside the wrapper, whose+@.image@ the writing pass records into.+-}+importSwapchain :: (MonadIO m) => FG.FrameGraph Recorder () -> Vector ManagedImage -> Word32 -> m (FG.Handle ManagedImage, ManagedImage)+importSwapchain graph swapImages imageIndex = do+ let mi = swapImages V.! fromIntegral imageIndex+ h <- importManagedImage graph "swapchain" mi+ pure (h, mi)++{- | Terminal present pass over the written swapchain handle.++'FG.finalize' registers a side-effecting pass on the writer's queue, so the+chain survives demand culling and the write hook brings the image to+@PRESENT_SRC@ — a no-op barrier when it is already there (an idle re-present).+-}+presentSwapchain :: (MonadIO m) => FG.FrameGraph Recorder () -> FG.Handle ManagedImage -> m ()+presentSwapchain graph h = FG.finalize graph h Present
+ test/Spec.hs view
@@ -0,0 +1,586 @@+module Main (main) where++import Control.Exception (ErrorCall, try)+import Control.Monad (void)+import Data.Bits ((.|.))+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.IntMap.Strict qualified as IntMap+import Data.IntSet qualified as IntSet+import Data.List (sort, subsequences)+import Data.Word (Word32, Word64)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++import Fragr qualified as FG+import Vulkan.CStruct.Extends (SomeStruct (..))+import Vulkan.Core10 qualified as Vk+import Vulkan.Utils.FrameGraph.Aliasing (Candidate (..), happensBefore, planAliases, scheduleOf)+import Vulkan.Utils.FrameGraph.Buffer (ManagedBuffer (..), newManagedBuffer)+import Vulkan.Utils.FrameGraph.Buffer qualified as Buffer+import Vulkan.Utils.FrameGraph.Image (ManagedImage (..), newManagedImage, newManagedImageLayer, newManagedImageMip, newSliceRegistry)+import Vulkan.Utils.FrameGraph.Image qualified as Image+import Vulkan.Utils.FrameGraph.Recorder (Barriers (..), Recorder, TransferSide (..), chainedNode, newRecorder, recorderQueue, recordingBackend, setRecorder, setRecorderFamilies, setRecorderHost, takeBarriers)+import Vulkan.Zero (zero)++main :: IO ()+main = defaultMain (testGroup "vulkan-utils-framegraph" [ordering, aliasing, exhaustive, slices, transfers, boundary])++----------------------------------------------------------------+-- Slice wrap registry+----------------------------------------------------------------++{- | Expect the wrap to be rejected; touch @alive@ after, so the clashing+wrapper stays reachable through the check's garbage collection.+-}+rejectedOver :: ManagedImage -> IO ManagedImage -> IO ()+rejectedOver alive wrap =+ try @ErrorCall wrap >>= \case+ Left _ -> void (readIORef alive.stateRef)+ Right _ -> assertFailure "overlapping wrap accepted"++img :: Vk.Image+img = Vk.Image 1++slices :: TestTree+slices =+ testGroup+ "slice wrap registry"+ [ testCase "disjoint mips of one image wrap fine" do+ reg <- newSliceRegistry+ _ <- newManagedImageMip reg img Vk.IMAGE_ASPECT_COLOR_BIT 0+ void (newManagedImageMip reg img Vk.IMAGE_ASPECT_COLOR_BIT 1)+ , testCase "a second wrapper over a live mip is fatal" do+ reg <- newSliceRegistry+ a <- newManagedImageMip reg img Vk.IMAGE_ASPECT_COLOR_BIT 0+ rejectedOver a (newManagedImageMip reg img Vk.IMAGE_ASPECT_COLOR_BIT 0)+ , testCase "a whole-image wrapper clashes with a live mip" do+ reg <- newSliceRegistry+ a <- newManagedImageMip reg img Vk.IMAGE_ASPECT_COLOR_BIT 3+ rejectedOver a (newManagedImage reg img Vk.IMAGE_ASPECT_COLOR_BIT)+ , testCase "disjoint aspects of one subresource wrap fine" do+ reg <- newSliceRegistry+ _ <- newManagedImage reg img Vk.IMAGE_ASPECT_DEPTH_BIT+ void (newManagedImage reg img Vk.IMAGE_ASPECT_STENCIL_BIT)+ , testCase "mip vs layer wrappers clash where they intersect" do+ reg <- newSliceRegistry+ -- Both cover (mip 0, layer 0).+ a <- newManagedImageMip reg img Vk.IMAGE_ASPECT_COLOR_BIT 0+ rejectedOver a (newManagedImageLayer reg img Vk.IMAGE_ASPECT_COLOR_BIT 0)+ , testCase "dropping the old wrapper legalizes the re-wrap" do+ reg <- newSliceRegistry+ void (newManagedImage reg img Vk.IMAGE_ASPECT_COLOR_BIT)+ -- The registry collects before accusing, so the dropped wrapper's+ -- entry dies here instead of poisoning the image forever.+ void (newManagedImage reg img Vk.IMAGE_ASPECT_COLOR_BIT)+ , -- The renderer-scope semantics: a new scope's registry owes nothing to+ -- the old one's wrappers, even while they are still reachable.+ testCase "a fresh registry accepts a handle another one holds live" do+ old <- newSliceRegistry+ a <- newManagedImage old img Vk.IMAGE_ASPECT_COLOR_BIT+ reg <- newSliceRegistry+ _ <- newManagedImage reg img Vk.IMAGE_ASPECT_COLOR_BIT+ void (readIORef a.stateRef)+ ]++----------------------------------------------------------------+-- Ownership transfer: the QFOT pair semantics, no device+----------------------------------------------------------------++-- | Queues 0 and 2 share family 0; queue 1 is family 1; queue 3 is the host.+family :: FG.QueueId -> Word32+family (FG.QueueId q) = case q of+ 0 -> 0+ 1 -> 1+ 2 -> 0+ _ -> Vk.QUEUE_FAMILY_IGNORED++{- | A recorder over a null command buffer: the hooks' barriers are only ever+drained with 'takeBarriers', never flushed into it — so every hook call is+followed by a drain, keeping the batch's overlap flush unreachable.+-}+fakeRecorder :: IO Recorder+fakeRecorder = do+ rec <- newRecorder zero+ setRecorderFamilies rec family+ pure rec++onQueue :: Recorder -> Int -> IO ()+onQueue rec q = setRecorder rec (FG.QueueId q) zero++-- | Freshly wrapped (through @wrap@), brought to 'Image.ColorAttachment' on queue 0.+producedImageWith :: (ManagedImage -> ManagedImage) -> Recorder -> IO ManagedImage+producedImageWith wrap rec = do+ reg <- newSliceRegistry+ mi <- wrap <$> newManagedImage reg img Vk.IMAGE_ASPECT_COLOR_BIT+ onQueue rec 0+ Image.queueTransition rec 0 mi Image.ColorAttachment+ _ <- takeBarriers rec+ pure mi++producedImage :: Recorder -> IO ManagedImage+producedImage = producedImageWith id++-- | Freshly wrapped and storage-written on queue 0.+producedBuffer :: Recorder -> IO ManagedBuffer+producedBuffer rec = do+ mb <- newManagedBuffer buf+ onQueue rec 0+ Buffer.queueTransition rec 0 mb storageWrite+ _ <- takeBarriers rec+ pure mb++buf :: Vk.Buffer+buf = Vk.Buffer 2++-- | The batch's single image barrier: (families, layouts, access masks).+imageHalf :: Barriers -> IO ((Word32, Word32), (Vk.ImageLayout, Vk.ImageLayout), (Vk.AccessFlags, Vk.AccessFlags))+imageHalf b = case b.images of+ [SomeStruct ib] ->+ pure+ ( (ib.srcQueueFamilyIndex, ib.dstQueueFamilyIndex)+ , (ib.oldLayout, ib.newLayout)+ , (ib.srcAccessMask, ib.dstAccessMask)+ )+ bs -> assertFailure ("expected one image barrier, got " <> show (length bs))++-- | The batch's single buffer barrier: (families, access masks).+bufferHalf :: Barriers -> IO ((Word32, Word32), (Vk.AccessFlags, Vk.AccessFlags))+bufferHalf b = case b.buffers of+ [SomeStruct bb] ->+ pure+ ( (bb.srcQueueFamilyIndex, bb.dstQueueFamilyIndex)+ , (bb.srcAccessMask, bb.dstAccessMask)+ )+ bs -> assertFailure ("expected one buffer barrier, got " <> show (length bs))++sampled :: Image.Usage+sampled = Image.Sampled Vk.PIPELINE_STAGE_FRAGMENT_SHADER_BIT++storage :: Image.Usage+storage = Image.StorageRead Vk.PIPELINE_STAGE_COMPUTE_SHADER_BIT++storageWrite :: Buffer.Usage+storageWrite = Buffer.StorageWrite Vk.PIPELINE_STAGE_COMPUTE_SHADER_BIT++storageRead :: Buffer.Usage+storageRead = Buffer.StorageRead Vk.PIPELINE_STAGE_COMPUTE_SHADER_BIT++transfers :: TestTree+transfers =+ testGroup+ "ownership transfer"+ [ testCase "an owned hand-off records matching release/acquire halves" do+ rec <- fakeRecorder+ mi <- producedImage rec+ Image.transferOwnership Release rec 7 (FG.QueueId 1) mi sampled+ (relFams, relLayouts, relAccess) <- imageHalf =<< takeBarriers rec+ relFams @?= (0, 1)+ relLayouts @?= (Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)+ relAccess @?= (Vk.ACCESS_COLOR_ATTACHMENT_READ_BIT .|. Vk.ACCESS_COLOR_ATTACHMENT_WRITE_BIT, zero)+ -- The release performs the transition; the slot keeps the state it saw.+ readIORef mi.releasedRef >>= (@?= Just (Image.usageState Image.ColorAttachment))+ readIORef mi.stateRef >>= (@?= Image.usageState sampled)+ onQueue rec 1+ Image.transferOwnership Acquire rec 7 (FG.QueueId 0) mi sampled+ (acqFams, acqLayouts, acqAccess) <- imageHalf =<< takeBarriers rec+ -- The spec wants the halves identical up to the ignored scopes.+ acqFams @?= relFams+ acqLayouts @?= relLayouts+ acqAccess @?= (zero, Vk.ACCESS_SHADER_READ_BIT)+ readIORef mi.releasedRef >>= (@?= Nothing)+ readIORef mi.queueRef >>= (@?= Just (FG.QueueId 1))+ chainedNode rec 7 >>= assertBool "the acquire marks the node chained"+ , testCase "a melted acquire leaves the hand-off for a pending owned one" do+ rec <- fakeRecorder+ mi <- producedImage rec+ -- One version, two transfers: a host readback plus an owned hand-off+ -- to family 1 — the shape a host + device fan-out compiles to.+ Image.transferOwnership Release rec 7 (FG.QueueId 3) mi Image.HostRead+ _ <- takeBarriers rec+ Image.transferOwnership Release rec 8 (FG.QueueId 1) mi storage+ (relFams, relLayouts, _) <- imageHalf =<< takeBarriers rec+ relFams @?= (0, 1)+ -- The host's melted acquire must not spend the slot the owned one needs.+ setRecorderHost rec (FG.QueueId 3)+ Image.transferOwnership Acquire rec 7 (FG.QueueId 0) mi Image.HostRead+ readIORef mi.releasedRef >>= (@?= Just (Image.usageState Image.HostRead))+ onQueue rec 1+ Image.transferOwnership Acquire rec 8 (FG.QueueId 0) mi storage+ (acqFams, acqLayouts, _) <- imageHalf =<< takeBarriers rec+ acqFams @?= relFams+ acqLayouts @?= relLayouts+ readIORef mi.releasedRef >>= (@?= Nothing)+ , testCase "a late host acquire does not rewind the device tracking" do+ rec <- fakeRecorder+ mi <- producedImage rec+ -- The same fan-out, but hooks in the Driver's real order: it defers+ -- host passes, so the owned acquire lands before the host's melted one.+ Image.transferOwnership Release rec 7 (FG.QueueId 3) mi Image.HostRead+ _ <- takeBarriers rec+ Image.transferOwnership Release rec 8 (FG.QueueId 1) mi storage+ _ <- takeBarriers rec+ onQueue rec 1+ Image.transferOwnership Acquire rec 8 (FG.QueueId 0) mi storage+ _ <- takeBarriers rec+ setRecorderHost rec (FG.QueueId 3)+ Image.transferOwnership Acquire rec 7 (FG.QueueId 0) mi Image.HostRead+ readIORef mi.stateRef >>= (@?= Image.usageState storage)+ readIORef mi.queueRef >>= (@?= Just (FG.QueueId 1))+ , testCase "an owned acquire without its release half is fatal" do+ rec <- fakeRecorder+ mi <- producedImage rec+ Image.transferOwnership Release rec 7 (FG.QueueId 1) mi sampled+ _ <- takeBarriers rec+ onQueue rec 1+ Image.transferOwnership Acquire rec 7 (FG.QueueId 0) mi sampled+ _ <- takeBarriers rec+ -- The slot is single-use: a duplicate acquire cannot replay the+ -- consumed hand-off, and recording a guessed half would be worse.+ try @ErrorCall (Image.transferOwnership Acquire rec 8 (FG.QueueId 0) mi sampled) >>= \case+ Left _ -> pure ()+ Right () -> assertFailure "an unpaired owned acquire was accepted"+ , testCase "a CONCURRENT hand-off is a producer-side transition, no families named" do+ rec <- fakeRecorder+ mi <- producedImageWith Image.sharedAcrossQueues rec+ Image.transferOwnership Release rec 7 (FG.QueueId 1) mi sampled+ (fams, layouts, _) <- imageHalf =<< takeBarriers rec+ fams @?= (Vk.QUEUE_FAMILY_IGNORED, Vk.QUEUE_FAMILY_IGNORED)+ layouts @?= (Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)+ onQueue rec 1+ Image.transferOwnership Acquire rec 7 (FG.QueueId 0) mi sampled+ acq <- takeBarriers rec+ assertBool "the acquire half melts" (null acq.images)+ , testCase "a same-family hop chains to the semaphore, keeping contents" do+ rec <- fakeRecorder+ mi <- producedImage rec+ onQueue rec 2+ Image.queueTransition rec 1 mi sampled+ (fams, layouts, access) <- imageHalf =<< takeBarriers rec+ fams @?= (Vk.QUEUE_FAMILY_IGNORED, Vk.QUEUE_FAMILY_IGNORED)+ layouts @?= (Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)+ -- The semaphore already made the writes available.+ fst access @?= zero+ , testCase "a cross-family write acquires by discarding" do+ rec <- fakeRecorder+ mi <- producedImage rec+ onQueue rec 1+ Image.queueTransition rec 1 mi Image.TransferDst+ (fams, layouts, _) <- imageHalf =<< takeBarriers rec+ fams @?= (Vk.QUEUE_FAMILY_IGNORED, Vk.QUEUE_FAMILY_IGNORED)+ layouts @?= (Vk.IMAGE_LAYOUT_UNDEFINED, Vk.IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL)+ , testCase "a cross-family read of an unshared image is fatal" do+ rec <- fakeRecorder+ mi <- producedImage rec+ onQueue rec 1+ try @ErrorCall (Image.queueTransition rec 1 mi sampled) >>= \case+ Left _ -> pure ()+ Right () -> assertFailure "an unhanded cross-family read was accepted"+ , testCase "the buffer pair mirrors the image rules, minus layout" do+ rec <- fakeRecorder+ mb <- producedBuffer rec+ Buffer.transferOwnership Release rec 7 (FG.QueueId 1) mb storageRead+ (relFams, relAccess) <- bufferHalf =<< takeBarriers rec+ relFams @?= (0, 1)+ relAccess @?= (Vk.ACCESS_SHADER_WRITE_BIT, zero)+ readIORef mb.releasedRef >>= (@?= Just (Buffer.usageState storageWrite))+ onQueue rec 1+ Buffer.transferOwnership Acquire rec 7 (FG.QueueId 0) mb storageRead+ (acqFams, acqAccess) <- bufferHalf =<< takeBarriers rec+ acqFams @?= relFams+ acqAccess @?= (zero, Vk.ACCESS_SHADER_READ_BIT)+ readIORef mb.releasedRef >>= (@?= Nothing)+ readIORef mb.queueRef >>= (@?= Just (FG.QueueId 1))+ , testCase "a melted buffer release records nothing but arms the slot" do+ rec <- fakeRecorder+ mb <- producedBuffer rec+ -- No layout to move: a same-family release is pure bookkeeping.+ Buffer.transferOwnership Release rec 7 (FG.QueueId 2) mb storageRead+ melted <- takeBarriers rec+ assertBool "a same-family release records no barrier" (null melted.buffers)+ onQueue rec 2+ Buffer.transferOwnership Acquire rec 7 (FG.QueueId 0) mb storageRead+ readIORef mb.releasedRef >>= (@?= Just (Buffer.usageState storageWrite))+ ]++----------------------------------------------------------------+-- Frame-boundary ownership: importOwned* driven end to end, no device+----------------------------------------------------------------++{- | A graph collecting every non-empty barrier batch as (queue, batch).+The drains are installed /before/ the import claims the flush slot, so+'Vulkan.Utils.FrameGraph.Recorder.flushBarriers' always finds an empty+batch and the zero command buffer is never recorded into.+-}+drainingGraph :: IO (FG.FrameGraph Recorder (), IORef [(FG.QueueId, Barriers)])+drainingGraph = do+ g <- FG.newFrameGraph @Recorder @()+ batches <- newIORef []+ let drain r = do+ q <- recorderQueue r+ b <- takeBarriers r+ case (b.images, b.buffers) of+ ([], []) -> pure ()+ _ -> modifyIORef' batches ((q, b) :)+ FG.addPreExec g drain+ FG.addPostExec g drain+ pure (g, batches)++runBoundary :: Recorder -> FG.FrameGraph Recorder () -> [(FG.QueueId, FG.FamilyId)] -> IO ()+runBoundary rec g partition = do+ FG.compileWith partition g+ FG.executeQueued g (recordingBackend rec (const zero)) Nothing rec ()++-- | The collected batches: an acquire on top of the release it consumed.+boundaryPair :: IORef [(FG.QueueId, Barriers)] -> IO ((FG.QueueId, Barriers), (FG.QueueId, Barriers))+boundaryPair batches =+ readIORef batches >>= \case+ [acq, rel] -> pure (rel, acq)+ bs -> assertFailure ("expected the release and acquire halves, got " <> show (length bs))++boundary :: TestTree+boundary =+ testGroup+ "frame-boundary ownership"+ [ testCase "an owned image import pairs a foreign first read across frames" do+ rec <- fakeRecorder+ mi <- producedImage rec+ (g, batches) <- drainingGraph+ h <- Image.importOwnedImage g "ext" mi+ _ <- FG.addPass g "Sample" (FG.setQueue (FG.QueueId 1) *> FG.readWith h sampled *> FG.setSideEffect) (\_ -> pure ())+ runBoundary rec g [(FG.QueueId 0, FG.FamilyId 0), (FG.QueueId 1, FG.FamilyId 1)]+ ((qr, rel), (qa, acq)) <- boundaryPair batches+ qr @?= FG.QueueId 0+ qa @?= FG.QueueId 1+ (relFams, relLayouts, _) <- imageHalf rel+ (acqFams, acqLayouts, _) <- imageHalf acq+ relFams @?= (0, 1)+ acqFams @?= relFams+ relLayouts @?= (Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)+ acqLayouts @?= relLayouts+ readIORef mi.releasedRef >>= (@?= Nothing)+ readIORef mi.queueRef >>= (@?= Just (FG.QueueId 1))+ , testCase "the pair melts within the owner's family" do+ rec <- fakeRecorder+ mi <- producedImage rec+ (g, batches) <- drainingGraph+ h <- Image.importOwnedImage g "ext" mi+ _ <- FG.addPass g "Sample" (FG.setQueue (FG.QueueId 2) *> FG.readWith h sampled *> FG.setSideEffect) (\_ -> pure ())+ runBoundary rec g [(FG.QueueId 0, FG.FamilyId 0), (FG.QueueId 2, FG.FamilyId 0)]+ readIORef batches >>= \case+ [(q, rel)] -> do+ q @?= FG.QueueId 0+ (fams, layouts, _) <- imageHalf rel+ -- A same-family hand-off is a producer-side transition, no QFOT.+ fams @?= (Vk.QUEUE_FAMILY_IGNORED, Vk.QUEUE_FAMILY_IGNORED)+ layouts @?= (Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, Vk.IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)+ bs -> assertFailure ("expected one melted release, got " <> show (length bs))+ , testCase "an untouched wrapper imports plainly" do+ rec <- fakeRecorder+ reg <- newSliceRegistry+ mi <- newManagedImage reg img Vk.IMAGE_ASPECT_COLOR_BIT+ (g, batches) <- drainingGraph+ h <- Image.importOwnedImage g "ext" mi+ _ <- FG.addPass g "Draw" (FG.setQueue (FG.QueueId 1) *> FG.writeWith_ h Image.ColorAttachment) (\_ -> pure ())+ runBoundary rec g [(FG.QueueId 0, FG.FamilyId 0), (FG.QueueId 1, FG.FamilyId 1)]+ readIORef batches >>= \case+ [(q, b)] -> do+ q @?= FG.QueueId 1+ (fams, layouts, _) <- imageHalf b+ fams @?= (Vk.QUEUE_FAMILY_IGNORED, Vk.QUEUE_FAMILY_IGNORED)+ layouts @?= (Vk.IMAGE_LAYOUT_UNDEFINED, Vk.IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL)+ bs -> assertFailure ("expected one plain transition, got " <> show (length bs))+ , testCase "an owned buffer import pairs the same way, minus layout" do+ rec <- fakeRecorder+ mb <- producedBuffer rec+ (g, batches) <- drainingGraph+ h <- Buffer.importOwnedBuffer g "ext" mb+ _ <- FG.addPass g "ReadBack" (FG.setQueue (FG.QueueId 1) *> FG.readWith h storageRead *> FG.setSideEffect) (\_ -> pure ())+ runBoundary rec g [(FG.QueueId 0, FG.FamilyId 0), (FG.QueueId 1, FG.FamilyId 1)]+ ((qr, rel), (qa, acq)) <- boundaryPair batches+ qr @?= FG.QueueId 0+ qa @?= FG.QueueId 1+ (relFams, relAccess) <- bufferHalf rel+ (acqFams, acqAccess) <- bufferHalf acq+ relFams @?= (0, 1)+ acqFams @?= relFams+ relAccess @?= (Vk.ACCESS_SHADER_WRITE_BIT, zero)+ acqAccess @?= (zero, Vk.ACCESS_SHADER_READ_BIT)+ readIORef mb.queueRef >>= (@?= Just (FG.QueueId 1))+ ]++----------------------------------------------------------------+-- Exhaustive: every small schedule, against an independent oracle+----------------------------------------------------------------++{- | Every schedule of @n@ passes over @q@ queues, with every combination of+waits — a pass may wait each foreign queue's current watermark, or not.++That is the whole space the compiler can hand us at this size, so the checks+below are not samples: they are closed over it.+-}+schedules :: Int -> Int -> [[FG.PassSync]]+schedules n q = go 0 (replicate q 0) []+ where+ go i counters acc+ | i == n = [reverse acc]+ | otherwise = do+ queue <- [0 .. q - 1]+ -- Wait on any subset of the other queues' latest signals.+ waitMask <- subsequences [j | j <- [0 .. q - 1], j /= queue]+ let+ signal = (counters !! queue) + 1+ counters' = [if j == queue then signal else c | (j, c) <- zip [0 ..] counters]+ waits = [(j, counters !! j) | j <- waitMask, counters !! j > 0]+ go (i + 1) counters' (pass i queue signal waits : acc)++{- | The relation, derived a second way: build the edges explicitly and take+their transitive closure. Deliberately unlike the vector clock it checks —+an oracle that shared the implementation would prove nothing.+-}+oracle :: [FG.PassSync] -> Int -> Int -> Bool+oracle syncs = \i j -> IntSet.member i (IntMap.findWithDefault IntSet.empty j reach)+ where+ indexed = zip [0 ..] syncs+ -- Direct predecessors: the previous pass on the same queue, and every+ -- pass whose signal a wait names at or below its watermark.+ preds s =+ [ i+ | (i, p) <- indexed+ , p.queue == s.queue && p.signal < s.signal+ || or [p.queue == w.queue && p.signal <= w.value | w <- s.waits]+ ]+ -- Transitive closure, accumulated in position order: every predecessor is+ -- at an earlier position, so its own reach set is already in the map.+ reach = foldl step IntMap.empty indexed+ step m (j, s) =+ let ps = preds s+ in IntMap.insert j (IntSet.unions (IntSet.fromList ps : [IntMap.findWithDefault IntSet.empty i m | i <- ps])) m++exhaustive :: TestTree+exhaustive =+ testGroup+ "exhaustive (5 passes, 3 queues, all wait combinations)"+ [ testCase "happensBefore agrees with the closure oracle everywhere" do+ let bad =+ [ (syncs, i, j)+ | syncs <- schedules 5 3+ , let s = scheduleOf syncs+ , let ref = oracle syncs+ , i <- [0 .. 4]+ , j <- [0 .. 4]+ , happensBefore s i j /= ref i j+ ]+ assertBool (show (take 1 bad)) (null bad)+ , -- The invariant the whole feature rests on: anything the planner puts in+ -- one block must be pairwise ordered, so the tenancies cannot overlap.+ testCase "every planned group is pairwise ordered" do+ let bad =+ [ (syncs, x, y)+ | syncs <- schedules 5 3+ , let s = scheduleOf syncs+ , -- One entry per pass, plus a long-lived one spanning the run.+ cs <- [[Candidate i (i, i) | i <- [0 .. 4]], [Candidate 9 (0, 4), Candidate 0 (1, 1), Candidate 1 (2, 3)]]+ , g <- planAliases s cs+ , x <- g+ , y <- g+ , x /= y+ , not (happensBefore s (snd x.live) (fst y.live))+ , not (happensBefore s (snd y.live) (fst x.live))+ ]+ assertBool (show (take 1 bad)) (null bad)+ ]++-- A pass on a queue, signalling a value, waiting on foreign queues.+pass :: Int -> Int -> Word64 -> [(Int, Word64)] -> FG.PassSync+pass pid queue signal waits =+ FG.PassSync+ { FG.passId = pid+ , FG.name = "p"+ , FG.queue = FG.QueueId queue+ , FG.waits = [FG.Wait{FG.queue = FG.QueueId q, FG.value = v, FG.covers = []} | (q, v) <- waits]+ , FG.signal = signal+ , FG.waitEvents = []+ , FG.signalEvents = []+ , FG.acquires = []+ , FG.releases = []+ }++groupIds :: [[Candidate]] -> [[Int]]+groupIds = sort . map (map (.entryId))++ordering :: TestTree+ordering =+ testGroup+ "happensBefore"+ [ testCase "submission order on one queue" do+ let s = scheduleOf [pass 0 0 1 [], pass 1 0 2 []]+ assertBool "0 before 1" (happensBefore s 0 1)+ assertBool "1 not before 0" (not (happensBefore s 1 0))+ , testCase "a pass is not before itself" do+ let s = scheduleOf [pass 0 0 1 []]+ assertBool "irreflexive" (not (happensBefore s 0 0))+ , -- The case positions alone get wrong: two queues, no wait between them.+ -- Position 0 precedes position 1, but they run concurrently.+ testCase "concurrent queues are unordered despite positions" do+ let s = scheduleOf [pass 0 0 1 [], pass 1 1 1 []]+ assertBool "0 not before 1" (not (happensBefore s 0 1))+ assertBool "1 not before 0" (not (happensBefore s 1 0))+ , testCase "a wait orders across queues" do+ -- q1's pass waits for q0's value 1.+ let s = scheduleOf [pass 0 0 1 [], pass 1 1 1 [(0, 1)]]+ assertBool "0 before 1" (happensBefore s 0 1)+ assertBool "1 not before 0" (not (happensBefore s 1 0))+ , testCase "ordering is transitive through a third queue" do+ -- q0:p0 -> q1:p1 (waits q0) -> q2:p2 (waits q1). p0 must precede p2.+ let s = scheduleOf [pass 0 0 1 [], pass 1 1 1 [(0, 1)], pass 2 2 1 [(1, 1)]]+ assertBool "0 before 2 transitively" (happensBefore s 0 2)+ assertBool "2 not before 0" (not (happensBefore s 2 0))+ , testCase "a wait on a later value still orders the earlier pass" do+ -- Watermarked waits name a value, not a pass: waiting q0's 2 must+ -- also observe q0's pass that signalled 1.+ let s = scheduleOf [pass 0 0 1 [], pass 1 0 2 [], pass 2 1 1 [(0, 2)]]+ assertBool "0 before 2" (happensBefore s 0 2)+ assertBool "1 before 2" (happensBefore s 1 2)+ , testCase "a wait does not order passes after the signal" do+ -- q1 waits q0's value 1; q0's *later* pass (value 2) is not ordered.+ let s = scheduleOf [pass 0 0 1 [], pass 1 0 2 [], pass 2 1 1 [(0, 1)]]+ assertBool "1 not before 2" (not (happensBefore s 1 2))+ ]++aliasing :: TestTree+aliasing =+ testGroup+ "planAliases"+ [ testCase "sequential lifetimes on one queue share a block" do+ let+ s = scheduleOf [pass 0 0 1 [], pass 1 0 2 [], pass 2 0 3 []]+ cs = [Candidate 10 (0, 0), Candidate 11 (1, 2)]+ groupIds (planAliases s cs) @?= [[10, 11]]+ , testCase "overlapping lifetimes do not" do+ let+ s = scheduleOf [pass 0 0 1 [], pass 1 0 2 []]+ cs = [Candidate 10 (0, 1), Candidate 11 (1, 1)]+ groupIds (planAliases s cs) @?= [[10], [11]]+ , -- The silent-corruption case: disjoint positions, concurrent queues.+ testCase "disjoint positions on concurrent queues do NOT share" do+ let+ s = scheduleOf [pass 0 0 1 [], pass 1 1 1 []]+ cs = [Candidate 10 (0, 0), Candidate 11 (1, 1)]+ groupIds (planAliases s cs) @?= [[10], [11]]+ , testCase "a wait makes the same two safe to share" do+ let+ s = scheduleOf [pass 0 0 1 [], pass 1 1 1 [(0, 1)]]+ cs = [Candidate 10 (0, 0), Candidate 11 (1, 1)]+ groupIds (planAliases s cs) @?= [[10, 11]]+ , testCase "a group admits a third only if ordered against all of it" do+ -- p0,p1,p2 on q0 (ordered); p3 on q1, unordered with everything.+ let+ s = scheduleOf [pass 0 0 1 [], pass 1 0 2 [], pass 2 0 3 [], pass 3 1 1 []]+ cs = [Candidate 10 (0, 0), Candidate 11 (1, 1), Candidate 12 (2, 2), Candidate 13 (3, 3)]+ groupIds (planAliases s cs) @?= [[10, 11, 12], [13]]+ , testCase "groups come back in takeover order" do+ let+ s = scheduleOf [pass 0 0 1 [], pass 1 0 2 [], pass 2 0 3 []]+ cs = [Candidate 12 (2, 2), Candidate 10 (0, 0), Candidate 11 (1, 1)]+ map (map (.entryId)) (planAliases s cs) @?= [[10, 11, 12]]+ ]
+ vulkan-utils-framegraph.cabal view
@@ -0,0 +1,103 @@+cabal-version: 2.2++-- This file has been generated from package.yaml by hpack version 0.39.6.+--+-- see: https://github.com/sol/hpack++name: vulkan-utils-framegraph+version: 0.1.0.0+synopsis: Vulkan barrier-placement and resource adapter for the fragr frame graph+category: Graphics+homepage: https://github.com/haskell-game/vulkan#readme+bug-reports: https://github.com/haskell-game/vulkan/issues+maintainer: IC Rainbow <aenor.realm@gmail.com>+license: BSD-3-Clause+license-file: LICENSE+build-type: Simple+extra-source-files:+ README.md+ package.yaml++source-repository head+ type: git+ location: https://github.com/haskell-game/vulkan++library+ exposed-modules:+ Vulkan.Utils.FrameGraph.Aliasing+ Vulkan.Utils.FrameGraph.Buffer+ Vulkan.Utils.FrameGraph.Driver+ Vulkan.Utils.FrameGraph.Image+ Vulkan.Utils.FrameGraph.Recorder+ Vulkan.Utils.FrameGraph.Swapchain+ other-modules:+ Paths_vulkan_utils_framegraph+ autogen-modules:+ Paths_vulkan_utils_framegraph+ hs-source-dirs:+ src+ default-extensions:+ BlockArguments+ DerivingStrategies+ DuplicateRecordFields+ ImportQualifiedPost+ LambdaCase+ NamedFieldPuns+ NoFieldSelectors+ OverloadedLists+ OverloadedRecordDot+ OverloadedStrings+ RecordWildCards+ ScopedTypeVariables+ StrictData+ TypeApplications+ TypeFamilies+ ghc-options: -Wall+ build-depends:+ base >=4.16 && <5+ , containers+ , fragr+ , resourcet+ , text+ , transformers+ , vector+ , vulkan ==3.27.*+ , vulkan-utils+ default-language: Haskell2010++test-suite vulkan-utils-framegraph-test+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ other-modules:+ Paths_vulkan_utils_framegraph+ autogen-modules:+ Paths_vulkan_utils_framegraph+ hs-source-dirs:+ test+ default-extensions:+ BlockArguments+ DerivingStrategies+ DuplicateRecordFields+ ImportQualifiedPost+ LambdaCase+ NamedFieldPuns+ NoFieldSelectors+ OverloadedLists+ OverloadedRecordDot+ OverloadedStrings+ RecordWildCards+ ScopedTypeVariables+ StrictData+ TypeApplications+ TypeFamilies+ ghc-options: -Wall+ build-depends:+ base+ , containers+ , fragr+ , tasty+ , tasty-hunit+ , vector+ , vulkan+ , vulkan-utils-framegraph+ default-language: Haskell2010