vulkan-utils-framegraph-0.1.0.0: src/Vulkan/Utils/FrameGraph/Aliasing.hs
{-| 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)