fragr-0.1.0.0: src/Fragr/Snapshot/Dot.hs
{-|
Graphviz DOT exporters for compiled frame graphs.
@
FG.compile graph
dotSource <- Dot.dump graph
@
Two views: 'dump' renders the dataflow — passes and resource versions —
and 'dumpSync' the compiled schedule as queue lanes ('renderSync').
Imports sharing a dotted name prefix (e.g. mip levels @img.mip0@,
@img.mip1@) render as one record node with a cell per member, keeping the
family together in an array instead of letting the layout scatter it. The
grouping covers the version-1 identities; versions minted by writes render
in the dataflow as usual.
Any other visualization can be written the same way, against the
read-only 'Snapshot' from 'FG.snapshot'.
-}
module Fragr.Snapshot.Dot
( dump
, dumpWith
, render
, Options (..)
, defaultOptions
-- * The schedule view
, dumpSync
, renderSync
) where
import Control.Monad (guard)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.List (nub)
import Data.Map.Strict qualified as Map
import Data.Maybe (maybeToList)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word64)
import Fragr (Access (..), EntryInfo (..), EventId (..), FrameGraph, NodeInfo (..), PassInfo (..), PassSync (..), QueueId (..), RetireInfo (..), Snapshot (..), SyncEvent (..), Transfer (..), Wait (..), accessId, handleId, snapshot, someHandleId, transferId)
dump :: FrameGraph ctx alloc -> IO Text
dump = dumpWith defaultOptions
dumpWith :: Options -> FrameGraph ctx alloc -> IO Text
dumpWith opts g = render opts <$> snapshot g
dumpSync :: FrameGraph ctx alloc -> IO Text
dumpSync g = renderSync <$> snapshot g
data Options = Options
{ clusterImports :: Bool
{- ^ Fence the initial import versions in a dotted @Imported@ frame,
instead of letting them float near their consumers.
-}
, stratify :: Bool
{- ^ Pin each dependency level ('Fragr.Snapshot.PassInfo') to one rank, so
rows are levels and the row count is the critical path. Culled passes stay
unpinned.
-}
, antiEdges :: Bool
{- ^ Overlay the write-after-read edges ('Fragr.Snapshot.PassInfo') as
dashed reader -> renamer edges. Drawn without layout constraint, so they
annotate the dataflow rather than reshape it.
-}
}
deriving stock (Eq, Show)
defaultOptions :: Options
defaultOptions =
Options
{ clusterImports = False
, stratify = False
, antiEdges = True
}
render :: Options -> Snapshot -> Text
render opts s =
Text.unlines $
concat
[ ["digraph FrameGraph {"]
, map indent header
, map (indent . passVertex) s.passes
, map (indent . nodeVertex) (filter standalone s.nodes)
, map (indent . familyVertex) importFamilies
, concatMap (map indent . writeEdges) s.passes
, concatMap (map indent . readEdges) s.nodes
, map indent antiEdgeLines
, concatMap (map indent . createCluster) s.passes
, map indent importedCluster
, map indent levelRanks
, ["}"]
]
where
header =
[ "graph [rankdir=TB, splines=spline" <> (if opts.stratify then ", newrank=true" else "") <> "]"
, "node [shape=record, style=\"rounded,filled\", fontname=\"helvetica\", fontsize=10]"
]
entryById :: IntMap EntryInfo
entryById = IntMap.fromList do
e <- s.entries
pure (e.entryId, e)
nodeById :: IntMap NodeInfo
nodeById = IntMap.fromList do
n <- s.nodes
pure (n.nodeId, n)
passKey :: PassInfo -> Text
passKey p = passIdKey p.passId
passIdKey :: Int -> Text
passIdKey i = "P" <> tshow i
nodeKey :: NodeInfo -> Text
nodeKey n = "R" <> tshow n.resourceId <> "_" <> tshow n.version
handleKey :: Int -> Text
handleKey i = nodeRef (nodeById IntMap.! i)
-- Where edges attach: a family member's port, or the node's own vertex.
nodeRef :: NodeInfo -> Text
nodeRef n = IntMap.findWithDefault (nodeKey n) n.nodeId familyPorts
standalone :: NodeInfo -> Bool
standalone n = not (IntMap.member n.nodeId familyPorts)
-- Imported v1 identities, the Imported cluster's population.
importedV1 :: [NodeInfo]
importedV1 = do
n <- s.nodes
guard (n.version == 1)
guard (entryById IntMap.! n.resourceId).imported
pure n
-- Imports sharing a name prefix before the last dot, two or more
-- strong; singleton prefixes and undotted names stay standalone.
-- Transients never group: they live inside per-pass create clusters,
-- and one record node cannot span two clusters.
importFamilies :: [Family]
importFamilies = do
(prefix, members@(m : _ : _)) <- Map.toAscList byPrefix
pure Family{key = "G" <> tshow m.node.nodeId, prefix, members}
where
byPrefix = Map.fromListWith (flip (<>)) do
n <- importedV1
(prefix, suffix) <- maybeToList (familyOf n.name)
pure (prefix, [Member{node = n, suffix}])
familyOf :: Text -> Maybe (Text, Text)
familyOf name = do
let (pre, suffix) = Text.breakOnEnd "." name
guard (not (Text.null pre))
pure (Text.dropEnd 1 pre, suffix)
familyPorts :: IntMap Text
familyPorts = IntMap.fromList do
f <- importFamilies
m <- f.members
pure (m.node.nodeId, f.key <> ":" <> portName m)
familyVertex f =
f.key
<> " [label=\"{"
<> esc f.prefix
<> "|{"
<> Text.intercalate "|" (map cell f.members)
<> "}}\", fillcolor=lightsteelblue]"
where
cell m = "<" <> portName m <> "> " <> esc m.suffix
passVertex p =
passKey p
<> " [label=\"{"
<> esc p.name
<> (if p.sideEffect then " *" else "")
<> "|refs: "
<> tshow p.refCount
<> ", id: "
<> tshow p.passId
<> "}\", fillcolor="
<> (if p.canExecute then "orange" else "lightgray")
<> "]"
nodeVertex n =
nodeKey n
<> " [label=\"{"
<> esc n.name
<> (if n.version > 1 then " v" <> tshow n.version else "")
<> (if Text.null desc then "" else "|" <> esc desc)
<> "|id: "
<> tshow n.resourceId
<> ", refs: "
<> tshow n.refCount
<> "}\", fillcolor="
<> fill
<> "]"
where
entry = entryById IntMap.! n.resourceId
desc = entry.description
fill
| not entry.imported = "skyblue"
| n.version == 1 = "lightsteelblue"
| otherwise = "steelblue"
writeEdges p = fan (passKey p) (map (handleKey . accessId) p.writes) "orangered"
-- Node id -> keys of the passes reading it, in registration order, one
-- per pass.
readersOfNode :: IntMap [Text]
readersOfNode = IntMap.fromListWith (flip (<>)) do
p <- s.passes
i <- nub (map accessId p.reads)
pure (i, [passKey p])
readEdges n = fan (nodeRef n) (IntMap.findWithDefault [] n.nodeId readersOfNode) "yellowgreen"
antiEdgeLines
| not opts.antiEdges = []
| otherwise = do
r <- s.passes
w <- r.antiAfter
pure (passKey r <> " -> " <> passIdKey w <> " [style=dashed, color=slategray, constraint=false]")
{- One rank per row of the layering, imports first: a level's passes sit
on rank @2*level+1@ and the nodes they produce on the row below, so
every edge points strictly downward and dot never has to route a flat
one. Pinning the nodes as well as the passes is what keeps that true —
left free, an import lands on a pass's rank and its edge is dropped.
Culled passes and their nodes stay unpinned; @newrank@ lets a rankset
cross the create clusters instead of emptying them.
-}
levelRanks
| not opts.stratify = []
| otherwise = do
(_rank, keys) <- IntMap.toAscList byRank
pure ("{ rank=same; " <> Text.unwords keys <> " }")
where
byRank = IntMap.fromListWith (flip (<>)) (imports <> passes <> produced)
imports = map (\k -> (0, [k])) importedKeys
passes = do
p <- s.passes
l <- maybeToList p.level
pure (2 * l + 1, [passKey p])
produced = do
n <- filter standalone s.nodes
l <- maybeToList (IntMap.lookup n.nodeId levelOfNode)
pure (2 * l + 2, [nodeKey n])
-- A node sits on the row below the pass that produces it: the one
-- that writes it, or, for a transient nobody writes, the one that
-- created it. Imports and culled passes' nodes stay unpinned.
levelOfNode :: IntMap Int
levelOfNode = IntMap.fromList do
p <- s.passes
l <- maybeToList p.level
i <- map accessId p.writes <> map someHandleId p.creates
pure (i, l)
fan from targets color
| null targets = []
| otherwise =
[from <> " -> { " <> Text.unwords targets <> " } [color=" <> color <> "]"]
createCluster p
| null p.creates = []
| otherwise =
[ "subgraph cluster_" <> passKey p <> " {"
, indent "style=rounded"
, indent (Text.unwords (passKey p : map (handleKey . someHandleId) p.creates))
, "}"
]
importedCluster
| not opts.clusterImports = []
| null importedKeys = []
| otherwise =
[ "subgraph cluster_imported {"
, indent "style=dotted"
, indent "label=\"Imported\""
, indent (Text.unwords importedKeys)
, "}"
]
-- The vertices carrying the initial import versions: a family's record
-- node stands for its members, which have no vertex of their own.
importedKeys :: [Text]
importedKeys = standaloneKeys <> map (.key) importFamilies
where
standaloneKeys = do
n <- importedV1
guard (standalone n)
pure (nodeKey n)
{- |
Render the compiled schedule as queue lanes: one cluster per queue with
its executing passes in submission order, overlaid with the cross-queue
timeline waits, split-barrier event pairs, ownership transfers and retire
points. Where 'render' answers what data flows, this answers what the
'Fragr.Execute.QueueBackend' will be asked to do.
-}
renderSync :: Snapshot -> Text
renderSync s =
Text.unlines $
concat
[ ["digraph FrameGraphSync {"]
, map indent header
, concatMap (map indent) lanes
, map indent waitEdges
, map indent eventEdges
, map indent transferEdges
, map indent retireLines
, ["}"]
]
where
header =
[ "graph [rankdir=LR, splines=spline]"
, "node [shape=record, style=\"rounded,filled\", fontname=\"helvetica\", fontsize=10]"
]
scheduled :: [(PassInfo, PassSync)]
scheduled = do
p <- s.passes
ps <- maybeToList p.sync
pure (p, ps)
nodeById :: IntMap NodeInfo
nodeById = IntMap.fromList do
n <- s.nodes
pure (n.nodeId, n)
passKey :: PassInfo -> Text
passKey p = "P" <> tshow p.passId
-- One cluster per queue, its passes chained in submission order.
lanes = do
(q, lane) <- Map.toAscList byQueue
pure $
concat
[
[ "subgraph cluster_q" <> tshow q <> " {"
, indent "style=rounded"
, indent ("label=\"queue " <> tshow q <> "\"")
]
, map (indent . laneVertex) lane
, map indent (chain (map (passKey . fst) lane))
, ["}"]
]
byQueue = Map.fromListWith (flip (<>)) do
pps@(_pass, ps) <- scheduled
let QueueId q = ps.queue
pure (q, [pps])
laneVertex (p, ps) =
passKey p <> " [label=\"{" <> esc p.name <> "|signal " <> tshow ps.signal <> "}\", fillcolor=orange]"
chain keys = do
(a, b) <- zip keys (drop 1 keys)
pure (a <> " -> " <> b <> " [color=gray, weight=100]")
-- (queue, signaled value) -> the signaling pass.
signaler :: Map.Map (Int, Word64) Text
signaler = Map.fromList do
(p, ps) <- scheduled
let QueueId q = ps.queue
pure ((q, ps.signal), passKey p)
waitEdges = do
(p, ps) <- scheduled
w <- ps.waits
let QueueId wq = w.queue
src <- maybeToList (Map.lookup (wq, w.value) signaler)
let covers = Text.intercalate ", " (map accessText w.covers)
pure $
src
<> " -> "
<> passKey p
<> " [label=\">="
<> tshow w.value
<> (if Text.null covers then "" else "\\n" <> escPlain covers)
<> "\", color=royalblue]"
eventEnd pick = IntMap.fromList do
(p, ps) <- scheduled
se <- pick ps
let EventId e = se.event
pure (e, passKey p)
eventEdges = do
(e, src) <- IntMap.toList (eventEnd (.signalEvents))
dst <- maybeToList (IntMap.lookup e (eventEnd (.waitEvents)))
pure (src <> " -> " <> dst <> " [label=\"e" <> tshow e <> "\", style=dashed, color=slategray]")
-- A release edge finds its acquirers by node and source queue; a
-- fan-out read acquires on every consumer.
acquirers :: Map.Map (Int, Int) [(Int, Text)]
acquirers = Map.fromListWith (flip (<>)) do
(p, ps) <- scheduled
t@(Transfer _h (QueueId src) _flags) <- ps.acquires
let QueueId consumer = ps.queue
pure ((transferId t, src), [(consumer, passKey p)])
transferEdges = do
(p, ps) <- scheduled
t@(Transfer h (QueueId peer) flags) <- ps.releases
let QueueId srcQ = ps.queue
(consumer, dst) <- Map.findWithDefault [] (transferId t, srcQ) acquirers
guard (consumer == peer)
pure $
passKey p
<> " -> "
<> dst
<> " [label=\""
<> escPlain (accessText (Access h flags))
<> "\", color=purple]"
-- Retire points: a note on the last executing pass touching the
-- entry, carrying the per-queue timeline requirements.
entryOfNode = IntMap.fromList do
n <- s.nodes
pure (n.nodeId, n.resourceId)
entryName = IntMap.fromList do
n <- s.nodes
pure (n.resourceId, n.name)
-- 'IntMap.fromList' keeps the last occurrence, and 'scheduled' walks
-- in execution order: the last executing toucher per entry.
lastToucher :: IntMap Text
lastToucher = IntMap.fromList do
(p, _ps) <- scheduled
i <- map someHandleId p.creates <> map accessId (p.reads <> p.writes)
e <- maybeToList (IntMap.lookup i entryOfNode)
pure (e, passKey p)
retireLines = concat do
r <- s.retires
src <- maybeToList (IntMap.lookup r.entryId lastToucher)
let
key = "T" <> tshow r.entryId
reqs = Text.intercalate ", " do
(QueueId q, v) <- r.requirements
pure ("q" <> tshow q <> ">=" <> tshow v)
rname = IntMap.findWithDefault (tshow r.entryId) r.entryId entryName
pure
[ key <> " [shape=note, label=\"retire " <> escPlain rname <> "\\n" <> escPlain reqs <> "\", fillcolor=khaki]"
, src <> " -> " <> key <> " [style=dotted, color=gray50, arrowhead=none]"
]
accessText :: Access -> Text
accessText (Access h flags) = name <> maybe "" (\f -> " @" <> tshow f) flags
where
name = maybe (tshow (handleId h)) nodeText (IntMap.lookup (handleId h) nodeById)
nodeText n = n.name <> (if n.version > 1 then " v" <> tshow n.version else "")
-- | One rendered import family: its vertex key, shared prefix and members.
data Family = Family
{ key :: Text
, prefix :: Text
, members :: [Member]
}
-- | One family member: the imported v1 node and its name's suffix.
data Member = Member
{ node :: NodeInfo
, suffix :: Text
}
-- | The record port a member's edges attach to.
portName :: Member -> Text
portName m = "n" <> tshow m.node.nodeId
indent :: Text -> Text
indent = (" " <>)
esc :: Text -> Text
esc = Text.concatMap \case
'\\' -> "\\\\"
'"' -> "\\\""
'{' -> "\\{"
'}' -> "\\}"
'|' -> "\\|"
'<' -> "\\<"
'>' -> "\\>"
c -> Text.singleton c
-- | Escape for plain (non-record) labels; record cells use 'esc'.
escPlain :: Text -> Text
escPlain = Text.concatMap \case
'\\' -> "\\\\"
'"' -> "\\\""
c -> Text.singleton c
tshow :: (Show a) => a -> Text
tshow = Text.pack . show