fragr-0.1.0.0: test/Spec/MultiQueue.hs
-- | Cross-queue scheduling: waits, events, hand-offs and reclamation.
module Spec.MultiQueue (tests) where
import Fragr
import Control.Exception (try)
import Control.Monad (void)
import Data.Foldable (find, for_)
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Maybe (fromJust, isJust)
import Data.Text (Text)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import Fragr qualified as FG
import Utils
tests :: TestTree
tests =
testGroup
"multi-queue"
[ crossQueueHandoff
, watermarkDedup
, droppedWaitCoversMigrate
, nonAdjacentEventPair
, adjacentNeedsNoEvent
, writeAfterForeignRead
, crossQueueRenameFlags
, recycleAwaitsTimelines
, recycleAwaitsRefcount
, culledContributeNothing
, perQueueCommandLogs
, finalizeFollowsProducerQueue
, finalizeFollowsBareCreator
, executingQueuesAreLive
, queuedWithoutRecycleImport
, queuedWithoutRecycleTransient
, familyFanOut
, siblingGuardWithinFamily
, sharedSkipsSiblingGuard
, twoFamiliesRejected
, markSharedExemptsFanOut
, sharedImportByContract
, queueOutsidePartition
, sameFamilyStillPairs
, mixedFamilyFanOutRejected
, sameQueueSharesAcquire
, renameCarriesNoTransferBack
, foreignImportDerivesNothing
, ownedImportBoundaryPair
, ownedImportAtHome
, ownedImportForeignRename
, ownedImportUnused
]
crossQueueHandoff :: TestTree
crossQueueHandoff = testCase "graphics produces, compute (other queue) consumes: one cross-queue wait, release/acquire" do
g <- FG.newFrameGraph @Device @Device
gbuf <-
FG.addPass
g
"Graphics"
( do
h <- FG.create @Image "gbuf" (img "gbuf")
FG.write h
)
\_data -> pure ()
_ <-
FG.addPass
g
"Compute"
( do
FG.setQueue (QueueId 1)
FG.read gbuf
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
let
graphics = syncOf s "Graphics"
compute = syncOf s "Compute"
compute.waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}]
compute.acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
graphics.releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
graphics.signal @?= 1
dev <- runQueued g
l0 <- queueLog dev (QueueId 0)
l1 <- queueLog dev (QueueId 1)
assertBool "release recorded on q0" (("release " <> tshow gbuf <> " to q1") `elem` l0)
assertBool "acquire recorded on q1" (("acquire " <> tshow gbuf <> " from q0") `elem` l1)
assertBool "wait recorded on q1" ("wait q0>=1" `elem` l1)
watermarkDedup :: TestTree
watermarkDedup = testCase "watermark dedup: the later consumer inherits the earlier wait" do
g <- FG.newFrameGraph @Device @Device
r1 <- FG.addPass g "A1" (FG.create @Image "r1" (img "r1") >>= FG.write) \_data -> pure ()
r2 <- FG.addPass g "A2" (FG.create @Image "r2" (img "r2") >>= FG.write) \_data -> pure ()
_ <-
FG.addPass
g
"C1"
( do
FG.setQueue (QueueId 1)
FG.read r1
FG.read r2
FG.setSideEffect
)
\_data -> pure ()
_ <-
FG.addPass
g
"C2"
( do
FG.setQueue (QueueId 1)
FG.read r1
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
-- C1 waits only on the later value (2), covering both edges' reads.
(syncOf s "C1").waits @?= [Wait{queue = QueueId 0, value = 2, covers = [Access{handle = r1, flags = Nothing}, Access{handle = r2, flags = Nothing}]}]
-- C2's need (q0 >= 1) is already implied by C1's earlier wait.
(syncOf s "C2").waits @?= []
droppedWaitCoversMigrate :: TestTree
droppedWaitCoversMigrate = testCase "a dropped wait's covers migrate to the kept wait" do
g <- FG.newFrameGraph @Device @Device
r1 <- FG.addPass g "A1" (FG.create @Image "r1" (img "r1") >>= FG.write) \_data -> pure ()
r2 <- FG.addPass g "A2" (FG.create @Image "r2" (img "r2") >>= FG.write) \_data -> pure ()
-- C1's wait (value 2) sets the watermark; C2's (value 1) is dropped,
-- but its differently-flagged access must still scope the kept wait.
_ <-
FG.addPass
g
"C1"
( do
FG.setQueue (QueueId 1)
FG.readWith r2 ColorAttachment
FG.setSideEffect
)
\_data -> pure ()
_ <-
FG.addPass
g
"C2"
( do
FG.setQueue (QueueId 1)
FG.readWith r1 ShaderRead
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
(syncOf s "C1").waits
@?= [ Wait
{ queue = QueueId 0
, value = 2
, covers = [Access{handle = r1, flags = Just ShaderRead}, Access{handle = r2, flags = Just ColorAttachment}]
}
]
(syncOf s "C2").waits @?= []
nonAdjacentEventPair :: TestTree
nonAdjacentEventPair = testCase "same-queue non-adjacent dependency emits an event pair" do
g <- FG.newFrameGraph @Device @Device
r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
_ <-
FG.addPass
g
"Middle"
( do
h <- FG.create @Image "m" (img "m")
FG.write_ h
FG.setSideEffect
)
\_data -> pure ()
_ <-
FG.addPass
g
"Consumer"
( do
FG.read r
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
(syncOf s "Producer").signalEvents @?= [SyncEvent{event = EventId 0, covers = [Access{handle = r, flags = Nothing}]}]
(syncOf s "Consumer").waitEvents @?= [SyncEvent{event = EventId 0, covers = [Access{handle = r, flags = Nothing}]}]
(syncOf s "Middle").signalEvents @?= []
(syncOf s "Middle").waitEvents @?= []
adjacentNeedsNoEvent :: TestTree
adjacentNeedsNoEvent = testCase "adjacent same-queue dependency needs no event" do
g <- FG.newFrameGraph @Device @Device
r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
_ <-
FG.addPass
g
"Consumer"
( do
FG.read r
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
(syncOf s "Producer").signalEvents @?= []
(syncOf s "Consumer").waitEvents @?= []
writeAfterForeignRead :: TestTree
writeAfterForeignRead = testCase "a renaming writer waits for foreign-queue readers (write-after-read)" do
g <- FG.newFrameGraph @Device @Device
r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
_ <-
FG.addPass
g
"Reader"
( do
FG.setQueue (QueueId 1)
FG.read r
FG.setSideEffect
)
\_data -> pure ()
r' <-
FG.addPass
g
"Overwrite"
( do
r' <- FG.write r
FG.setSideEffect
pure r'
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
-- Reader holds position 1 on queue 1; Overwrite must not run before
-- it, even though no data flows from Reader to Overwrite. The wait
-- covers the renaming write the anti-edge protects.
(syncOf s "Overwrite").waits @?= [Wait{queue = QueueId 1, value = 1, covers = [Access{handle = r', flags = Nothing}]}]
crossQueueRenameFlags :: TestTree
crossQueueRenameFlags = testCase "a cross-queue rename's transfer carries the renaming write's access" do
g <- FG.newFrameGraph @Device @Device
r <-
FG.addPass
g
"Producer"
(FG.create @Image "r" (img "r") >>= (`FG.writeWith` ColorAttachment))
\_data -> runHere "Producer"
r' <-
FG.addPass
g
"Rename"
( do
FG.setQueue (QueueId 1)
r' <- FG.writeWith r General
FG.setSideEffect
pure r'
)
\_data -> runHere "Rename"
FG.compile g
s <- FG.snapshot g
-- The rename consumes r through its implicit flagless read; the
-- transfer carries the renaming write's access instead — so the
-- release / acquire hooks fire, with the target state.
(syncOf s "Producer").releases @?= [Transfer{handle = r', peer = QueueId 1, flags = Just General}]
(syncOf s "Rename").acquires @?= [Transfer{handle = r', peer = QueueId 0, flags = Just General}]
dev <- runQueued g
l0 <- queueLog dev (QueueId 0)
l1 <- queueLog dev (QueueId 1)
assertBool "release hook fires with the write's flags" ("release-hook r ->general" `elem` l0)
assertBool "acquire hook fires with the write's flags" ("acquire-hook r ->general" `elem` l1)
recycleAwaitsTimelines :: TestTree
recycleAwaitsTimelines = testCase "recycle queue reclaims only once every timeline is past the requirements" do
rq <- FG.newRecycleQueue
destroyed <- newIORef ([] :: [Int])
item <- FG.mkRetireItem 7 [(QueueId 0, 2), (QueueId 1, 3)] (modifyIORef' destroyed (7 :))
FG.retireItem rq item
c1 <- FG.collect rq [(QueueId 0, 2), (QueueId 1, 2)] -- q1 short
c1 @?= []
readIORef destroyed >>= (@?= [])
c2 <- FG.collect rq [(QueueId 0, 5), (QueueId 1, 3)] -- both met
c2 @?= [7]
readIORef destroyed >>= (@?= [7])
recycleAwaitsRefcount :: TestTree
recycleAwaitsRefcount = testCase "an in-use refcount holds a resource past satisfied timelines" do
rq <- FG.newRecycleQueue
destroyed <- newIORef (0 :: Int)
item <- FG.mkRetireItem 1 [(QueueId 0, 1)] (modifyIORef' destroyed (+ 1))
FG.acquireItem item
FG.retireItem rq item
c1 <- FG.collect rq [(QueueId 0, 1)] -- timelines met, but a ref is held
c1 @?= []
readIORef destroyed >>= (@?= 0)
FG.releaseItem item
c2 <- FG.collect rq [(QueueId 0, 1)]
c2 @?= [1]
readIORef destroyed >>= (@?= 1)
culledContributeNothing :: TestTree
culledContributeNothing = testCase "culled passes contribute nothing to the schedule" do
g <- FG.newFrameGraph @Device @Device
_ <-
FG.addPass
g
"Live"
( do
h <- FG.create @Image "r" (img "r")
FG.write_ h
FG.setSideEffect
)
\_data -> pure ()
FG.addPass_ g "Dead" (FG.create @Image "s" (img "s") >>= FG.write_) (pure ())
FG.compile g
s <- FG.snapshot g
[live] <- pure (filter (\p -> p.name == "Live") s.passes)
[dead] <- pure (filter (\p -> p.name == "Dead") s.passes)
assertBool "the live pass has a schedule" (isJust live.sync)
dead.sync @?= Nothing
-- Only the live resource (entry 0) shows up in the retire requirements.
map (.entryId) s.retires @?= [0]
perQueueCommandLogs :: TestTree
perQueueCommandLogs = testCase "a multi-queue frame records the expected per-queue command logs" do
g <- FG.newFrameGraph @Device @Device
FG.addPreExec g \dev -> logHere dev "flush"
FG.addPostExec g \dev -> logHere dev "post-flush"
gbuf <-
FG.addPass
g
"Graphics"
( do
h <- FG.create @Image "gbuf" (img "gbuf")
FG.writeWith h ColorAttachment
)
\_data -> runHere "Graphics"
lit <-
FG.addPass
g
"Compute"
( do
FG.setQueue (QueueId 1)
FG.readWith gbuf ShaderRead
h <- FG.create @Image "lit" (img "lit")
FG.writeWith h General
)
\_data -> runHere "Compute"
_ <-
FG.addPass
g
"Present"
( do
FG.readWith lit ShaderRead
FG.setSideEffect
)
\_data -> runHere "Present"
FG.compile g
FG.executingQueues g >>= (@?= [QueueId 0, QueueId 1])
s <- FG.snapshot g
-- The schedule carries the consuming access's flags on waits and
-- transfers, so drivers can derive wait scopes and target states.
(syncOf s "Compute").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Just ShaderRead}]}]
(syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Just ShaderRead}]
(syncOf s "Present").acquires @?= [Transfer{handle = lit, peer = QueueId 1, flags = Just ShaderRead}]
dev <- runQueued g
l0 <- queueLog dev (QueueId 0)
l1 <- queueLog dev (QueueId 1)
l0
@?= [ "create gbuf"
, "barrier gbuf undefined->color-attachment"
, "flush"
, "run Graphics"
, "release-hook gbuf ->shader-read"
, "post-flush"
, "release " <> tshow gbuf <> " to q1"
, "signal 1"
, "wait q1>=1"
, "acquire " <> tshow lit <> " from q1"
, "acquire-hook lit ->shader-read"
, "barrier lit general->shader-read"
, "flush"
, "run Present"
, "post-flush"
, "signal 2"
, "destroy lit"
]
l1
@?= [ "wait q0>=1"
, "acquire " <> tshow gbuf <> " from q0"
, "create lit"
, "acquire-hook gbuf ->shader-read"
, "barrier gbuf color-attachment->shader-read"
, "barrier lit undefined->general"
, "flush"
, "run Compute"
, "release-hook lit ->shader-read"
, "post-flush"
, "release " <> tshow lit <> " to q0"
, "signal 1"
, "destroy gbuf"
]
finalizeFollowsProducerQueue :: TestTree
finalizeFollowsProducerQueue = testCase "finalize places its pass on the producer's queue" do
g <- FG.newFrameGraph @Device @Device
r <-
FG.addPass
g
"Compute"
( do
FG.setQueue (QueueId 1)
FG.create @Image "r" (img "r") >>= FG.write
)
\_data -> pure ()
FG.finalize g r General
FG.compile g
FG.executingQueues g >>= (@?= [QueueId 1])
s <- FG.snapshot g
let fin = syncOf s "finalize r"
fin.queue @?= QueueId 1
-- Same queue, adjacent: the helper manufactures no cross-queue
-- wait, transfer or event.
fin.waits @?= []
fin.acquires @?= []
finalizeFollowsBareCreator :: TestTree
finalizeFollowsBareCreator = testCase "finalize follows a creator that never writes" do
g <- FG.newFrameGraph @Device @Device
r <-
FG.addPass
g
"Author"
do
FG.setQueue (QueueId 1)
FG.create @Image "r" (img "r")
\_data -> pure ()
FG.finalize g r General
FG.compile g
FG.executingQueues g >>= (@?= [QueueId 1])
s <- FG.snapshot g
(syncOf s "finalize r").queue @?= QueueId 1
executingQueuesAreLive :: TestTree
executingQueuesAreLive = testCase "executingQueues lists only queues with surviving passes" do
g <- FG.newFrameGraph @Device @Device
assertFatal $ FG.executingQueues g
_ <-
FG.addPass
g
"Live"
( do
FG.setQueue (QueueId 1)
h <- FG.create @Image "r" (img "r")
FG.write_ h
FG.setSideEffect
)
\_data -> pure ()
_ <-
FG.addPass
g
"Dead"
( do
FG.setQueue (QueueId 2)
FG.create @Image "s" (img "s") >>= FG.write
)
\_data -> pure ()
FG.compile g
FG.executingQueues g >>= (@?= [QueueId 1])
queuedWithoutRecycleImport :: TestTree
queuedWithoutRecycleImport = testCase "executeQueued without a recycle queue drives an import-only graph" do
g <- FG.newFrameGraph @Device @Device
swapImg <- Image <$> newIORef "undefined" <*> pure False
h <- FG.importResource g "swap" (img "swap") swapImg
_ <-
FG.addPass
g
"Blit"
(FG.writeWith h General)
\_data -> runHere "Blit"
FG.compile g
dev <- newDevice
FG.executeQueued g (mkBackend dev) Nothing dev dev
assertHandoffsDrained dev
l0 <- queueLog dev (QueueId 0)
l0 @?= ["barrier swap undefined->general", "run Blit", "signal 1"]
queuedWithoutRecycleTransient :: TestTree
queuedWithoutRecycleTransient = testCase "executeQueued without a recycle queue refuses a transient to reclaim" do
g <- FG.newFrameGraph @Device @Device
_ <-
FG.addPass
g
"P"
( do
h <- FG.create @Image "t" (img "t")
FG.write_ h
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
dev <- newDevice
assertFatal $ FG.executeQueued g (mkBackend dev) Nothing dev dev
familyFanOut :: TestTree
familyFanOut = testCase "family fan-out: one transfer, acquired by the family's first consumer" do
(g, gbuf) <- fanOutGraph
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
(syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
(syncOf s "C2").acquires @?= []
-- The sibling waits on the producer /and/ on the primary's acquiring
-- pass — the acquire barrier lives on the primary's queue.
(syncOf s "C2").waits
@?= [ Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}
, Wait{queue = QueueId 1, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}
]
dev <- runQueued g
l1 <- queueLog dev (QueueId 1)
l2 <- queueLog dev (QueueId 2)
assertBool "the primary acquires" (("acquire " <> tshow gbuf <> " from q0") `elem` l1)
assertBool "the sibling does not" (("acquire " <> tshow gbuf <> " from q0") `notElem` l2)
assertBool "the sibling waits on the acquirer" ("wait q1>=1" `elem` l2)
siblingGuardWithinFamily :: TestTree
siblingGuardWithinFamily = testCase "the sibling guard melts within the producer's own family" do
-- Consumers of the producer's family need no acquire barrier: the
-- hand-off transitions producer-side, so the data-edge wait on the
-- producer already orders the sibling.
g <- FG.newFrameGraph @Device @Device
gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
for_ [(1 :: Int, "C1"), (2, "C2")] \(q, n) ->
FG.addPass
g
n
( do
FG.setQueue (QueueId q)
FG.read gbuf
FG.setSideEffect
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 0), (QueueId 2, FamilyId 0)] g
s <- FG.snapshot g
(syncOf s "C2").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}]
void (runQueued g)
sharedSkipsSiblingGuard :: TestTree
sharedSkipsSiblingGuard = testCase "a shared resource skips the sibling guard" do
-- CONCURRENT: the release transitions producer-side and the acquire
-- melts, so the sibling's wait on the producer covers everything.
(g, gbuf) <- fanOutGraph
FG.markShared g gbuf
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "C2").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}]
void (runQueued g)
twoFamiliesRejected :: TestTree
twoFamiliesRejected = testCase "released to two families is rejected" do
(g, _) <- fanOutGraph
expectTwoFamilies [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 2)] [("C1", FamilyId 1), ("C2", FamilyId 2)] g
markSharedExemptsFanOut :: TestTree
markSharedExemptsFanOut = testCase "markShared exempts a two-family fan-out from single ownership" do
(g, gbuf) <- fanOutGraph
FG.markShared g gbuf
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 2)] g
s <- FG.snapshot g
(syncOf s "Graphics").releases
@?= [ Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}
, Transfer{handle = gbuf, peer = QueueId 2, flags = Nothing}
]
(syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
(syncOf s "C2").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
sharedImportByContract :: TestTree
sharedImportByContract = testCase "a shared import is exempt through the contract (isShared)" do
g <- FG.newFrameGraph @Device @Device
obj <- Image <$> newIORef "undefined" <*> pure True
h0 <- FG.importScratch g "gbuf" (img "gbuf") obj
gbuf <- FG.addPass g "Graphics" (FG.write h0) \_data -> pure ()
for_ [(1 :: Int, "C1"), (2, "C2")] \(q, n) ->
FG.addPass
g
n
( do
FG.setQueue (QueueId q)
FG.read gbuf
FG.setSideEffect
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 2)] g
s <- FG.snapshot g
(syncOf s "Graphics").releases
@?= [ Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}
, Transfer{handle = gbuf, peer = QueueId 2, flags = Nothing}
]
queueOutsidePartition :: TestTree
queueOutsidePartition = testCase "a queue outside the partition keeps its per-queue transfer" do
(g, gbuf) <- fanOutGraph
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "Graphics").releases
@?= [ Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}
, Transfer{handle = gbuf, peer = QueueId 2, flags = Nothing}
]
(syncOf s "C2").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
sameFamilyStillPairs :: TestTree
sameFamilyStillPairs = testCase "a same-family consumer still gets the transfer pair (backends melt it)" do
g <- FG.newFrameGraph @Device @Device
gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
_ <-
FG.addPass
g
"C1"
( do
FG.setQueue (QueueId 1)
FG.read gbuf
FG.setSideEffect
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 0)] g
s <- FG.snapshot g
(syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
(syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
mixedFamilyFanOutRejected :: TestTree
mixedFamilyFanOutRejected = testCase "a fan-out mixing the producer's own family with a foreign one is rejected" do
-- C1 shares the producer's family, C2 does not. Ownership must move
-- to C2's family while C1 still reads on the source family — an order
-- the release (recorded producer-side) cannot express, so this is a
-- two-family violation like any other; markShared is the way out.
(g, _) <- fanOutGraph
expectTwoFamilies [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 0), (QueueId 2, FamilyId 1)] [("C1", FamilyId 0), ("C2", FamilyId 1)] g
sameQueueSharesAcquire :: TestTree
sameQueueSharesAcquire = testCase "same-queue consumers share one acquire" do
-- The partition-less path groups per (version, queue) like the
-- family path groups per family: one release must be consumed by
-- exactly one acquire, on the first-registered consumer.
g <- FG.newFrameGraph @Device @Device
gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
for_ [("C1" :: Text), "C2"] \n ->
FG.addPass
g
n
( do
FG.setQueue (QueueId 1)
FG.read gbuf
FG.setSideEffect
)
\_data -> pure ()
FG.compile g
s <- FG.snapshot g
(syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
(syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
(syncOf s "C2").acquires @?= []
void (runQueued g)
renameCarriesNoTransferBack :: TestTree
renameCarriesNoTransferBack = testCase "a rename after a foreign read carries no transfer back" do
-- The reader's hand-off pair is the read edge's; the write-after-read
-- anti-edge carries no payload and the rename's data edge is
-- same-queue, so ownership is never handed back — safe only because
-- the overwrite is a write (backends acquire by discarding; a partial
-- cross-family rewrite is Q3's territory).
g <- FG.newFrameGraph @Device @Device
r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
_ <-
FG.addPass
g
"Reader"
( do
FG.setQueue (QueueId 1)
FG.read r
FG.setSideEffect
)
\_data -> pure ()
r' <-
FG.addPass
g
"Overwrite"
( do
r' <- FG.write r
FG.setSideEffect
pure r'
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "Producer").releases @?= [Transfer{handle = r, peer = QueueId 1, flags = Nothing}]
(syncOf s "Reader").acquires @?= [Transfer{handle = r, peer = QueueId 0, flags = Nothing}]
(syncOf s "Overwrite").acquires @?= []
(syncOf s "Overwrite").releases @?= []
(syncOf s "Overwrite").waits @?= [Wait{queue = QueueId 1, value = 1, covers = [Access{handle = r', flags = Nothing}]}]
void (runQueued g)
foreignImportDerivesNothing :: TestTree
foreignImportDerivesNothing = testCase "an import first touched by a foreign queue derives no transfer" do
-- No producer edge this frame, so nothing arms a hand-off: a plain
-- EXCLUSIVE import cannot migrate families across frames (the
-- backend guards the read fatally) — declare the owner with
-- importOwned instead.
g <- FG.newFrameGraph @Device @Device
obj <- Image <$> newIORef "undefined" <*> pure False
h <- FG.importResource g "ext" (img "ext") obj
_ <-
FG.addPass
g
"Foreign"
( do
FG.setQueue (QueueId 1)
FG.read h
FG.setSideEffect
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "Foreign").waits @?= []
(syncOf s "Foreign").acquires @?= []
ownedImportBoundaryPair :: TestTree
ownedImportBoundaryPair = testCase "an owned import hands its first foreign touch a boundary pair" do
-- The synthetic producer stands in for last frame's work on the
-- owning queue: the release records there, the consumer acquires
-- under its own flags and waits on the synthetic pass's signal.
(g, h) <- ownedImport (QueueId 0)
_ <-
FG.addPass
g
"Foreign"
( do
FG.setQueue (QueueId 1)
FG.readWith h ShaderRead
FG.setSideEffect
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "import ext").releases @?= [Transfer{handle = h, peer = QueueId 1, flags = Just ShaderRead}]
(syncOf s "Foreign").acquires @?= [Transfer{handle = h, peer = QueueId 0, flags = Just ShaderRead}]
(syncOf s "Foreign").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = h, flags = Just ShaderRead}]}]
dev <- runQueued g
l0 <- queueLog dev (QueueId 0)
assertBool "the release records on the owner" (("release " <> tshow h <> " to q1") `elem` l0)
assertBool "the release hook fires there" ("release-hook ext ->shader-read" `elem` l0)
ownedImportAtHome :: TestTree
ownedImportAtHome = testCase "an owned import first touched at home derives no transfer" do
(g, h) <- ownedImport (QueueId 0)
_ <- FG.addPass g "Consumer" (FG.readWith h ShaderRead >> FG.setSideEffect) \_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
s <- FG.snapshot g
(syncOf s "import ext").releases @?= []
(syncOf s "Consumer").waits @?= []
(syncOf s "Consumer").acquires @?= []
void (runQueued g)
ownedImportForeignRename :: TestTree
ownedImportForeignRename = testCase "a foreign rename of an owned import carries the write's flags" do
-- The rename consumes the synthetic version through its implicit
-- flagless read; the pair rides the renaming write's access. The
-- writer also proves the observed flip: no setSideEffect, yet it
-- survives culling.
(g, h) <- ownedImport (QueueId 0)
h' <-
FG.addPass
g
"Overwrite"
( do
FG.setQueue (QueueId 1)
FG.writeWith h ColorAttachment
)
\_data -> pure ()
FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
s <- FG.snapshot g
-- The pair names the renaming write's access — the new version's
-- handle, like any rename-consumed transfer.
(syncOf s "import ext").releases @?= [Transfer{handle = h', peer = QueueId 1, flags = Just ColorAttachment}]
(syncOf s "Overwrite").acquires @?= [Transfer{handle = h', peer = QueueId 0, flags = Just ColorAttachment}]
void (runQueued g)
ownedImportUnused :: TestTree
ownedImportUnused = testCase "an unused owned import culls its synthetic pass" do
(g, _) <- ownedImport (QueueId 0)
FG.compile g
s <- FG.snapshot g
(fromJust (find (\p -> p.name == "import ext") s.passes)).sync @?= Nothing
dev <- runQueued g
queueLog dev (QueueId 0) >>= (@?= [])
-- | An image persisted from "last frame" (general layout), owned by the queue.
ownedImport :: QueueId -> IO (FrameGraph Device Device, Handle Image)
ownedImport owner = do
g <- FG.newFrameGraph @Device @Device
obj <- Image <$> newIORef "general" <*> pure False
h <- FG.importOwned g "ext" (img "ext") obj owner
pure (g, h)
-- | One producer on queue 0, consumers on queues 1 and 2.
fanOutGraph :: IO (FrameGraph Device Device, Handle Image)
fanOutGraph = do
g <- FG.newFrameGraph @Device @Device
gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
for_ [(1 :: Int, "C1"), (2, "C2")] \(q, n) ->
FG.addPass
g
n
( do
FG.setQueue (QueueId q)
FG.read gbuf
FG.setSideEffect
)
\_data -> pure ()
pure (g, gbuf)
-- | Compiling under the partition must reject @gbuf@ with these consumers.
expectTwoFamilies :: [(QueueId, FamilyId)] -> [(Text, FamilyId)] -> FrameGraph Device Device -> IO ()
expectTwoFamilies partition consumers g =
try @FragrError (FG.compileWith partition g) >>= \case
Left (ReleasedToTwoFamilies res cs) -> do
res @?= "gbuf"
cs @?= consumers
Left e -> assertFailure ("unexpected error: " <> show e)
Right () -> assertFailure "expected ReleasedToTwoFamilies"