fragr
A frame graph (a.k.a. render graph) engine in Haskell, after the GDC 2017
talk "FrameGraph: Extensible Rendering Architecture in Frostbite"
(Yuriy O'Donnell). See SPEC.md for the full language-neutral
specification this package implements.
The library is renderer-agnostic: it knows nothing about GPUs, textures,
or graphics APIs. It is a generic engine for declaring a DAG of passes
over virtual resources — it culls unused work, computes resource
lifetimes, and executes the surviving passes in order, creating and
destroying resources just-in-time.
Quick start
- Give your resource type a
FG.Resource instance.
- Each frame: build the graph,
FG.compile, FG.execute, discard.
- See it run:
stack exec fragr-exe execute.
import Fragr qualified as FG
-- 1. The resource contract:
instance FG.Resource Buffer where
type Desc Buffer = BufferDesc -- allocation descriptor
type Alloc Buffer = StagingPool -- opaque, forwarded from execute
type Ctx Buffer = CommandBuffer -- opaque, forwarded from execute
createResource desc pool = ... -- pop a slot of desc's size, or allocate
destroyResource desc pool buf = ... -- push the slot back
-- optional: a Flags type, the hooks it feeds (preRead / preWrite, and
-- preAcquire / preRelease under executeQueued), describeDesc
-- Pass data: a record of the handles the setup declared.
data Upload = Upload
{ staging :: Handle Buffer
, synced :: Handle Buffer
}
main = do
pool <- newStagingPool -- persists across frames
-- 2. Each frame: rebuild, compile, execute.
g <- FG.newFrameGraph
mesh <- FG.importResource g "mesh" meshDesc gpuMesh
up <- FG.addPass g "Upload"
do
staging <- FG.create @Buffer "staging" stagingDesc
staging' <- FG.write staging
synced <- FG.write mesh -- writing an import forces a side effect
pure Upload{staging = staging', synced}
\up -> do
buf <- FG.get @Buffer up.staging
cb <- FG.askCtx
... -- memcpy the chunk into buf, record the copy into the mesh
FG.addPass_ g "Draw"
do
FG.read up.synced
FG.setSideEffect
do
cb <- FG.askCtx
... -- record draws against the synced mesh
FG.compile g
FG.execute g cmdBuffer pool
The frame model
A frame is a single-use object: build, compile, execute, discard —
the frame loop rebuilds it every frame, Frostbite-style. compile is
cheap enough for the hot path.
Per-frame values (camera, exposure, the swapchain image) travel through
the ctx argument of execute or are captured when the passes are
re-registered.
The alloc argument is the piece that outlives frames: transients are
virtual, and createResource / destroyResource are where the
allocation strategy lives. Back them with a pool and a rebuilt frame's
transients recycle their memory instead of touching the driver. A
transient's slot frees the moment its last user ends — in the example
above, the staging slot returns to the pool before the next pass runs.
Setup and execution
Pass setup runs in the Build monad: declarations implicitly target
the pass under construction. Setup is a declaration — anything a pass
needs from the outside world is produced before addPass and captured.
The execution callback runs in Exec, a MonadIO — recording
commands is what it is for. It carries:
- the pass's resource accessor:
FG.get, FG.getDesc (only for handles
the pass declared);
- the frame context:
FG.askCtx.
Handles are versioned: writing a resource you did not create in the
same pass renames it and returns a fresh handle. Always keep the
returned handle (h' <- FG.write h). For terminal writes whose minted
handle is dead by design, write_ / writeWith_ discard it visibly,
and addPass_ registers a sink pass (present, readback) with no pass
data — a sink then composes with zero _ <- binds.
Passes whose outputs nobody consumes are culled. Three ways out:
FG.setSideEffect marks the pass itself observable;
- writing an
importResource marks it automatically — the contents are
observable from outside the graph. Import a target only this graph's
passes care about with importScratch instead, and its writers stay
cullable like a transient's;
FG.finalize g h flags declares a resource's terminal state (e.g.
presentable) as exactly such a pass, placed on the queue of the pass
that produced the handle.
Flags and hooks
readWith / writeWith carry per-resource Flags to the preRead /
preWrite hooks. Each Resource instance picks its own type (an image
layout ADT, a stage/access mask pair, ...) via the Flags associated
type, and the handle's resource type ties the declaration to it —
passing another resource's flags is a type error. Plain read / write
declare the access with no flags and fire no hooks.
FG.addPreExec installs a per-pass flush point between the hooks and
the pass callback, so hooks can accumulate work into ctx (say, image
barriers) and emit it as one batched command. FG.addPostExec is its
counterpart after the callback and the preRelease hooks, batching
release barriers the same way. Flush points compose, so an adapter
library and the application can hook the same graph.
Graph rendering
Both written against the read-only FG.snapshot view — as any custom
writer would be:
Fragr.Snapshot.Dot — Graphviz DOT export of the compiled graph
(Dot.dump g, or Dot.dumpWith for the Dot.Options). One vertex
per resource version, overlaid with the write-after-read edges that
order passes no data flows between. Opting into stratify pins one
row per dependency level, so the widest row is the concurrency the
graph permits. Dot.dumpSync renders the compiled schedule instead:
one lane per queue in submission order, overlaid with the timeline
waits, event pairs, ownership transfers and retire points.
Fragr.Snapshot.JSON — the interactive viewer's JSON document
(Json.dump g), which instead collapses each resource's whole rename
chain into one record. The serializer is hand-rolled, keeping the
library free of an aeson dependency.
Multi-queue support
On top of the single-queue execute, the library can schedule the
surviving passes across several submission queues (e.g. a Vulkan
graphics queue and an async-compute queue) — still renderer-agnostically:
it only knows about QueueIds, per-queue timeline values and
EventIds, never about a real semaphore or barrier.
To use it:
- Assign passes to queues in their setup blocks:
FG.setQueue (QueueId 1) (default: defaultQueue, queue 0).
FG.compile g — or compileWith with a queue-family partition.
- Size per-queue state (command buffers) from
FG.executingQueues g.
FG.executeQueued g backend (Just recycleQueue) ctx alloc.
The schedule
compile derives a PassSync schedule per surviving pass:
- cross-queue waits — the timeline values a pass must wait for, one
per foreign producer queue, deduplicated by a per-queue watermark (a
value an earlier same-queue pass already waited for is dropped); each
kept
Wait lists the accesses (handles + flags) it protects, so a
driver can derive its wait scope (e.g. waitDstStageMask) instead of
over-synchronizing;
- timeline signal — the
i-th executing pass on a queue signals
value i on completion;
- split-barrier events — a same-queue dependency with a pass in
between gets a
signalEvents / waitEvents pair, each SyncEvent
carrying its own pass's accesses for the barrier scopes; adjacent ones
rely on a plain barrier (the preRead / preWrite hook path);
- ownership transfer —
releases / acquires list the outputs
handed between queues as Transfers carrying the consuming access's
flags (for a cross-queue rename, the renaming write's), so a backend
can record release/acquire barriers into the right target state; the
resource contract's preRelease hook fires on the producer (after its
callback) and preAcquire on the consumer (before its callback), with
the addPreExec / addPostExec flush points bracketing the callback
to batch what each side accumulates.
No pass reordering happens: registration order stays execution order, so
every cross-queue edge points backward and in-order submission plus
waits cannot deadlock.
Queue families
Ownership's real unit is the queue family: give compileWith a
QueueId -> FamilyId partition and a fan-out to several queues of one
family carries a single transfer — the family's first-registered
consumer acquires for its siblings. Consumption on two distinct families
is rejected (ReleasedToTwoFamilies) unless the resource has no single
owner (e.g. Vulkan CONCURRENT sharing): imports read that off the
object (Resource.isShared), created transients say markShared.
Queues outside the partition (e.g. a host queue) keep per-queue
transfers, which is also plain compile's behavior for everything.
An import last touched by one family and first used by another this
frame has no producer edge to derive a transfer from. importOwned g "mesh" meshDesc gpuMesh (QueueId 1) names the owning queue and
registers a synthetic pass on it, standing in for last frame's work —
the release gains a queue to record on and the schedule a producer edge,
with families, sibling waits and single-owner validation applying
unchanged. Its writers stay cullable, like importScratch's.
Driving the schedule
executeQueued g backend (Just recycleQueue) ctx alloc walks the
schedule through backend :: QueueBackend, a record of callbacks the
library invokes around each pass (wait/acquire before, release/signal
after) — it never names a GPU primitive itself. An import-only graph
(fragr does scheduling and hooks, all resources owned outside) may pass
Nothing instead.
Deferred, Vulkan-style reclamation goes through a RecycleQueue:
instead of destroying a transient inline, executeQueued retires it
with the per-queue timeline values that must be reached first; collect
(given the currently-reached timelines) frees everything whose
requirements are met and whose in-use refcount is zero.
The full schedule and retire requirements are also exposed through
FG.snapshot (PassInfo.sync and Snapshot.retires) for inspection.
See the multi-queue test group for a worked simulated-Vulkan backend.
The demo
Run stack exec fragr-exe execute. It executes a small streaming frame
twice against a staging-buffer pool and prints every allocation
decision. What to watch for:
- Each upload pass borrows a staging transient, fills it, and copies
its chunk into the imported GPU mesh buffer.
- The staging slot retires the moment its upload ends, so chunk B
reuses chunk A's slot within the frame.
- The rebuilt second frame allocates nothing — one buffer serves every
upload.
Under executeQueued the same retirement goes through the
RecycleQueue, so a slot returns to the pool only once the GPU has
actually finished the copy.