packages feed

vulkan-utils-spirv-0.1.0.0: src/Vulkan/Utils/SpirV/Layout.hs

{-# LANGUAGE NoFieldSelectors #-}

{-| A self-contained, reversible description of a block's memory layout, plus a
value-level unifier over those layouts.

This is the IR the reflection-driven type machinery is built on. Two levels:

  * 'Layout' \/ 'Member' \/ 'FieldType' — the /structured/ form. It keeps every
    member's name, byte offset, and full semantic shape (scalar kind, vector \/
    matrix dimensions, array sizes, nested structs), so it carries enough
    information to drive code generation /and/ to be run backwards (e.g. towards
    authoring SPIR-V), not merely the flattened offsets.

  * 'OffsetMap' — the /normalized/ form: the flattened offset table of occupied
    scalar 'Slot's (each a byte offset + scalar type), plus an optional
    runtime-array tail. This is exactly the offset map the GPU sees, so it is the
    canonical form for /layout equivalence/: @mat4@, @vec4[4]@ and @float[16]@
    reduce to the same std430 offset map (and correctly diverge under std140,
    where the array strides round to 16). This is structural/layout equivalence —
    identical normalized bytes — not memory aliasing in the SPIR-V @Aliased@ \/ C
    pointer sense.

'unify' is the compatibility relation: not equality and not a prefix match, but
unification. Two layouts unify when their offset maps can be reconciled (a runtime
tail absorbs a concrete repeat, pinning its length); the result is the
most-defined offset map, which can be folded against further layouts ('foldUnify')
to accumulate a combined picture across pipelines. A failure is reported as a
'Mismatch' with a legible 'renderMismatch'.
-}
module Vulkan.Utils.SpirV.Layout
  ( -- * Layout mode
    layoutForDescriptor

    -- * Structured layout
  , Layout (..)
  , Member (..)
  , FieldType (..)
  , ArraySize (..)
  , layoutOf
  , fromFields
  , leafFieldType

    -- * Normalized offset map
  , Slot (..)
  , OffsetMap (..)
  , RuntimeTail (..)
  , normalize
  , offsetMapOf

    -- * Unification
  , Mismatch (..)
  , renderMismatch
  , unify
  , foldUnify
  , mergeKeyed
  ) where

import Data.List (foldl', sortOn)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Vector qualified as V
import Graphics.Gl.Block (roundUp)

import Data.SpirV.Reflect.Enums.DescriptorType qualified as R
import Data.SpirV.Reflect.TypeDescription (TypeDescription)
import Data.SpirV.Reflect.TypeDescription qualified

import Vulkan.Utils.SpirV.Types (LayoutMode (..), MemberShape (..), NumericType (..), ScalarType (..), arrayBaseAlign, arrayDims, classifyType, isBdaPointer, leafAlignment, scalarWidth, structBaseAlign)

{- | The gl-block layout a descriptor's block follows: uniform buffers use
std140, storage buffers std430. Other descriptor types carry no block layout
('Nothing').
-}
layoutForDescriptor :: R.DescriptorType -> Maybe LayoutMode
layoutForDescriptor = \case
  R.DESCRIPTOR_TYPE_UNIFORM_BUFFER -> Just Std140Layout
  R.DESCRIPTOR_TYPE_STORAGE_BUFFER -> Just Std430Layout
  _ -> Nothing

{- | A struct's layout under a given 'LayoutMode': its members at their resolved
byte offsets, total size and base alignment. 'size' is 'Nothing' when the struct
ends in a runtime-sized array (its size is not known statically).
-}
data Layout = Layout
  { name :: Maybe Text
  , mode :: LayoutMode
  , members :: [Member]
  , size :: Maybe Int
  , align :: Int
  }
  deriving (Eq, Show)

-- | A struct member: its name, byte offset within the struct, and structured type.
data Member = Member
  { name :: Text
  , offset :: Int
  , type' :: FieldType
  }
  deriving (Eq, Show)

{- | The structured type of a member. Multi-dimensional arrays nest
(@'ArrayOf' a ('ArrayOf' b t)@, outermost dimension first).
-}
data FieldType
  = Scalar ScalarType
  | -- | component count (2..4)
    Vector ScalarType Int
  | -- | columns, rows
    Matrix ScalarType Int Int
  | ArrayOf ArraySize FieldType
  | Struct Layout
  deriving (Eq, Show)

-- | An array dimension: a fixed length, or a runtime (unsized) trailing array.
data ArraySize = Sized Int | Runtime
  deriving (Eq, Show)

-- Layout arithmetic (std140/std430). --------------------------------------------

{- | @(base alignment, size)@ of a field type; size is 'Nothing' for a runtime
array (open).
-}
fieldExtent :: LayoutMode -> FieldType -> (Int, Maybe Int)
fieldExtent mode = \case
  Scalar s -> (leafAlignment mode s ShScalar, Just (scalarWidth s))
  Vector s n -> (leafAlignment mode s (ShVector n), Just (n * scalarWidth s))
  Matrix s c r ->
    let colStride = leafAlignment mode s (ShMatrix c r)
    in (colStride, Just (c * colStride))
  ArrayOf Runtime e ->
    let (ea, _) = fieldExtent mode e
    in (arrayBaseAlign mode ea, Nothing)
  ArrayOf (Sized n) e ->
    let
      (ea, es) = fieldExtent mode e
      align = arrayBaseAlign mode ea
      stride = roundUp (fromMaybe 0 es) align
    in
      (align, Just (n * stride))
  Struct l -> (l.align, l.size)

{- | The stride between elements of an array of @e@ under the layout. Defined only
when @e@ has a static size (i.e. is not itself a runtime array).
-}
arrayStride :: LayoutMode -> FieldType -> Int
arrayStride mode e =
  let (ea, es) = fieldExtent mode e
  in roundUp (fromMaybe 0 es) (arrayBaseAlign mode ea)

-- Building from reflection. -----------------------------------------------------

-- | Compute the layout of a reflected @OpTypeStruct@ under the given mode.
layoutOf :: LayoutMode -> TypeDescription -> Either String Layout
layoutOf mode td = do
  members <- traverse (memberOf mode) (V.toList td.members)
  pure (fromFields mode (structName td) members)

{- | Build a layout from named fields directly, computing each member's offset
and the struct's size and alignment (no reflection). Besides constructing
expected layouts, this is the direction an authoring front end would use.
-}
fromFields :: LayoutMode -> Maybe Text -> [(Text, FieldType)] -> Layout
fromFields mode name members =
  Layout
    { name
    , mode
    , members = [m | (m, _, _) <- placed]
    , size = if openEnded then Nothing else Just (roundUp end baseAlign)
    , align = baseAlign
    }
  where
    placed = place mode members
    baseAlign = structBaseAlign mode [a | (_, a, _) <- placed]
    openEnded = any (\(_, _, sz) -> sz == Nothing) placed
    end = case reverse placed of
      [] -> 0
      ((Member _ off _, _, msz) : _) -> maybe off (off +) msz

{- | Place members in declaration order, returning each at its offset together
with its alignment and (maybe-open) size.
-}
place :: LayoutMode -> [(Text, FieldType)] -> [(Member, Int, Maybe Int)]
place mode = go 0
  where
    go _ [] = []
    go cursor ((nm, ft) : rest) =
      let
        (align, msz) = fieldExtent mode ft
        off = roundUp cursor align
        cursor' = maybe off (off +) msz
      in
        (Member nm off ft, align, msz) : go cursor' rest

-- | The structured type of a single reflected member.
memberOf :: LayoutMode -> TypeDescription -> Either String (Text, FieldType)
memberOf mode mem = do
  nm <- maybe (Left "member without a name") Right mem.struct_member_name
  base <- baseFieldType mode mem
  let
    dims = arrayDims mem
    wrap d ft = ArrayOf (if d == 0 then Runtime else Sized (fromIntegral d)) ft
  pure (nm, foldr wrap base dims)

{- | The element type of a member, ignoring array dimensions: a leaf
(scalar\/vector\/matrix) or a nested struct.
-}
baseFieldType :: LayoutMode -> TypeDescription -> Either String FieldType
baseFieldType mode mem
  -- A buffer_reference pointer is an 8-byte device address, not the pointee
  -- inline; the REF check precedes 'classifyType' because the pointer also
  -- carries the pointee's leaf flags (e.g. @REF INT@).
  | isBdaPointer mem = Right (Scalar STAddress)
  | otherwise =
      case classifyType mem of
        Just numeric -> Right (leafFieldType numeric)
        Nothing -> Struct <$> layoutOf mode (fromMaybe mem mem.struct_type_description)

leafFieldType :: NumericType -> FieldType
leafFieldType NumericType{scalar, shape} = case shape of
  ShScalar -> Scalar scalar
  ShVector n -> Vector scalar n
  ShMatrix c r -> Matrix scalar c r

-- Normalization to the scalar offset map. ---------------------------------------------

-- | One occupied scalar component at a byte offset.
data Slot = Slot
  { offset :: Int
  , scalar :: ScalarType
  }
  deriving (Eq, Ord, Show)

{- | A runtime-sized array tail: a repeating element offset map starting at 'base'
with the given stride. The element 'Slot's are relative to the element start.
-}
data RuntimeTail = RuntimeTail
  { base :: Int
  , stride :: Int
  , element :: [Slot]
  }
  deriving (Eq, Show)

{- | A normalized layout: the concrete occupied scalar slots (sorted by offset),
the total size (if statically known), and an optional runtime-array tail.
-}
data OffsetMap = OffsetMap
  { slots :: [Slot]
  , size :: Maybe Int
  , tail :: Maybe RuntimeTail
  }
  deriving (Eq, Show)

-- | Flatten a structured 'Layout' to its offset map.
normalize :: Layout -> OffsetMap
normalize l =
  OffsetMap
    { slots = sortOn (.offset) (concatMap fst pieces)
    , size = l.size
    , tail = listToMaybe (mapMaybe snd pieces)
    }
  where
    mode = l.mode
    pieces = map (flattenMember mode) l.members

-- | Build the offset map straight from reflection.
offsetMapOf :: LayoutMode -> TypeDescription -> Either String OffsetMap
offsetMapOf mode = fmap normalize . layoutOf mode

flattenMember :: LayoutMode -> Member -> ([Slot], Maybe RuntimeTail)
flattenMember mode (Member _ off ft) = case ft of
  ArrayOf Runtime e ->
    ([], Just (RuntimeTail off (arrayStride mode e) (flattenAt mode 0 e)))
  _ -> (flattenAt mode off ft, Nothing)

-- | The scalar slots of a (statically-sized) field type at a base offset.
flattenAt :: LayoutMode -> Int -> FieldType -> [Slot]
flattenAt mode off = \case
  Scalar s -> [Slot off s]
  Vector s n -> let w = scalarWidth s in [Slot (off + i * w) s | i <- [0 .. n - 1]]
  Matrix s c r ->
    let
      w = scalarWidth s
      colStride = leafAlignment mode s (ShMatrix c r)
    in
      [Slot (off + col * colStride + row * w) s | col <- [0 .. c - 1], row <- [0 .. r - 1]]
  ArrayOf (Sized n) e ->
    let stride = arrayStride mode e
    in concat [flattenAt mode (off + i * stride) e | i <- [0 .. n - 1]]
  ArrayOf Runtime _ -> [] -- a runtime array can only be a struct's last member
  Struct l -> concatMap (\(Member _ mo mft) -> flattenAt mode (off + mo) mft) l.members

-- Unification. ------------------------------------------------------------------

-- | Why two offset maps fail to unify.
data Mismatch
  = -- | differing scalar at a byte offset
    SlotMismatch Int ScalarType ScalarType
  | -- | differing number of occupied slots
    CountMismatch Int Int
  | -- | differing total size
    SizeMismatch Int Int
  | -- | incompatible runtime-array tails
    TailMismatch String
  deriving (Eq, Show)

renderMismatch :: Mismatch -> String
renderMismatch = \case
  SlotMismatch off l r ->
    "scalar mismatch at byte offset "
      <> show off
      <> ": "
      <> showScalar l
      <> " vs "
      <> showScalar r
  CountMismatch l r ->
    "different number of components: " <> show l <> " vs " <> show r
  SizeMismatch l r ->
    "different total size: " <> show l <> " vs " <> show r <> " bytes"
  TailMismatch msg ->
    "incompatible runtime arrays: " <> msg
  where
    showScalar STFloat = "float"
    showScalar STDouble = "double"
    showScalar STInt = "int"
    showScalar STUInt = "uint"
    showScalar STInt64 = "int64"
    showScalar STUInt64 = "uint64"
    showScalar STBool = "bool"
    showScalar STAddress = "address"

{- | Unify two offset maps into their most-defined common offset map, or report the first
'Mismatch'. A runtime tail on one side absorbs a matching concrete repeat on
the other (pinning the array length); two closed offset maps must coincide exactly.
-}
unify :: OffsetMap -> OffsetMap -> Either Mismatch OffsetMap
unify a b = case (a.tail, b.tail) of
  (Nothing, Nothing) -> do
    matchSlots a.slots b.slots
    matchSize a.size b.size
    pure a
  (Just _, Nothing) -> absorb a b
  (Nothing, Just _) -> absorb b a
  (Just ta, Just tb) -> do
    matchSlots a.slots b.slots
    if ta == tb
      then pure a
      else Left (TailMismatch "tails differ in stride, element shape or base offset")

{- | Reconcile an open offset map (with a runtime tail) against a closed one, requiring
the closed offset map to be the open offset map's prefix followed by whole repeats of its
tail element. Returns the closed (pinned) offset map.
-}
absorb :: OffsetMap -> OffsetMap -> Either Mismatch OffsetMap
absorb open closed = do
  let
    pre = open.slots
    n = length pre
  matchSlots pre (take n closed.slots)
  let
    tl = fromMaybe (error "absorb: open offset map has no tail") open.tail
    rest = drop n closed.slots
  matchRepeats tl rest
  pure closed

-- | The remaining slots must be zero or more whole repeats of the tail element.
matchRepeats :: RuntimeTail -> [Slot] -> Either Mismatch ()
matchRepeats tl = go 0
  where
    el = tl.element
    m = length el
    go _ [] = Right ()
    go j rest
      | length chunk < m =
          Left (TailMismatch "trailing bytes are not a whole array element")
      | otherwise = do
          matchSlots (shift (tl.base + j * tl.stride) el) chunk
          go (j + 1) more
      where
        (chunk, more) = splitAt m rest
    shift base = map (\(Slot o s) -> Slot (o + base) s)

matchSlots :: [Slot] -> [Slot] -> Either Mismatch ()
matchSlots xs ys
  | length xs /= length ys = Left (CountMismatch (length xs) (length ys))
  | otherwise =
      case [ (x.offset, x.scalar, y.scalar)
           | (x, y) <- zip xs ys
           , x.scalar /= y.scalar || x.offset /= y.offset
           ] of
        ((off, l, r) : _) -> Left (SlotMismatch off l r)
        [] -> Right ()

matchSize :: Maybe Int -> Maybe Int -> Either Mismatch ()
matchSize (Just l) (Just r) | l /= r = Left (SizeMismatch l r)
matchSize _ _ = Right ()

{- | Fold a layout against further layouts, accumulating the combined offset map (the
running most-general unifier). Use to add pipelines into a shared layout.
-}
foldUnify :: OffsetMap -> [OffsetMap] -> Either Mismatch OffsetMap
foldUnify = foldl' (\acc g -> acc >>= (`unify` g)) . Right

{- | Merge layouts tagged by a key (e.g. a @(set, binding)@ or vertex @location@):
entries sharing a key must unify, disjoint keys union in. Reports the offending
key with its 'Mismatch'.
-}
mergeKeyed :: (Ord k) => [(k, OffsetMap)] -> Either (k, Mismatch) (Map k OffsetMap)
mergeKeyed = foldl' step (Right Map.empty)
  where
    step acc (k, g) = do
      m <- acc
      case Map.lookup k m of
        Nothing -> Right (Map.insert k g m)
        Just g' -> case unify g' g of
          Right u -> Right (Map.insert k u m)
          Left e -> Left (k, e)

-- | The struct's @type_name@, treating an empty name as absent.
structName :: TypeDescription -> Maybe Text
structName td = td.type_name >>= \t -> if Text.null t then Nothing else Just t