packages feed

proarrow-0.4.0.0: test/Examples/Parallel.hs

{-# LANGUAGE QualifiedDo #-}

-- | A small build system: one SMC program supplies execution, diagrams and parallel scheduling.
module Examples.Parallel (test, demo, runSequential, runParallel, Run (..), Event (..), sequentialPicture, parallelPicture, timeline) where

import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (modifyMVar_, newMVar, readMVar)
import Control.Monad ((>=>))
import Data.Kind (Type)
import Data.List (isInfixOf, sort, sortOn)
import GHC.Clock (getMonotonicTimeNSec)
import System.Directory (createDirectory, createDirectoryIfMissing)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO (readFile')
import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode)
import Test.Falsify (testFailed)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.Falsify (testProperty)
import Text.Printf (printf)
import Prelude hiding (id, (**), (.))
import Prelude qualified as P

import Proarrow.Category.Instance.Kleisli (KLEISLI (..), Kleisli (..))
import Proarrow.Category.Instance.OpenHypergraph qualified as HG
import Proarrow.Category.Monoidal (Monoidal, MonoidalProfunctor, type (**))
import Proarrow.Category.Monoidal.Hypergraph (Sized (..))
import Proarrow.Category.Monoidal.Strictified (Strictified (..))
import Proarrow.Core (CategoryOf (..), Profunctor, Promonad (..))
import Proarrow.Functor (Prelude (..))
import Proarrow.Object (pattern Objs)
import Proarrow.Profunctor.Instance.Star (Star, pattern Star)
import Proarrow.Testing (expect)
import Proarrow.Tools.Diagrams.Svg (SVG (..), Svg (..), W (Wire), node, render)
import Proarrow.Tools.Diagrams.Svg qualified as Svg
import Proarrow.Tools.SMC (toSMC1, (-<))
import Proarrow.Tools.SMC qualified as SMC

-- In GHCi (requires ghc, haddock and tar on PATH):
--
-- cabal repl test
-- import Examples.Parallel
-- demo "/tmp/smc-build"
--
-- Jobs use isolated build directories and communicate through wires. Concurrent tensor with
-- sequential composition is not a lawful SMC for arbitrary IO with hidden shared-state effects.

-- * One pipeline, interpreted as functions, diagrams and concurrent jobs

buildGraph
  :: forall {k} (workspace :: k) sources object app executable documentation package
   . (Monoidal k)
  => (workspace ~> sources)
  -> (sources ~> object)
  -> (sources ~> object)
  -> (object ** object ~> app)
  -> (app ~> executable)
  -> (sources ~> documentation)
  -> (executable ** documentation ~> package)
  -> (HG.Wires '[workspace] :: HG.SIMPLIFY k) ~> HG.Wires '[package]
buildGraph prepare@Objs model@Objs view@Objs main@Objs link@Objs document@Objs package@Objs =
  toSMC1 \workspace -> SMC.do
    sources <- HG.prim1 prepare -< workspace
    modelObject <- HG.prim1 model -< sources
    viewObject <- HG.prim1 view -< sources
    app <- HG.prim2 main -< (modelObject, viewObject)
    executable <- HG.prim1 link -< app
    docs <- HG.prim1 document -< sources
    HG.prim2 package -< (executable, docs)

newtype Workspace = Workspace FilePath
newtype Sources = Sources FilePath
newtype Object = Object FilePath

-- An application contains Main.o together with its dependencies.
data App = App FilePath [FilePath]
newtype Executable = Executable FilePath
newtype Documentation = Documentation FilePath
newtype Package = Package FilePath

sourceFiles :: [(String, String)]
sourceFiles =
  [
    ( "Model"
    , unlines
        [ "-- | Sample data for the chart application."
        , "module Model where"
        , "-- | The values to plot."
        , "values :: [Int]"
        , "values = [4, 7, 2, 9]"
        ]
    )
  ,
    ( "View"
    , unlines
        [ "-- | Rendering the chart."
        , "module View where"
        , "-- | Draw one bar per value."
        , "chart :: [Int] -> String"
        , "chart ns = unlines [replicate n '#' | n <- ns]"
        ]
    )
  ,
    ( "Main"
    , unlines
        [ "-- | The chart application."
        , "module Main where"
        , "import Model"
        , "import View"
        , "main :: IO ()"
        , "main = putStr (chart values)"
        ]
    )
  ]

expected :: String
expected = "####\n#######\n##\n#########\n"

command :: FilePath -> [String] -> IO String
command program args = do
  (status, out, err) <- readProcessWithExitCode program args ""
  case status of
    ExitSuccess -> pure out
    ExitFailure code -> fail (unwords (program : args) ++ " failed (" ++ show code ++ ")\n" ++ out ++ err)

prepareSources :: Workspace -> IO Sources
prepareSources (Workspace root) = do
  createDirectory (root </> "src")
  mapM_ (\(name, text) -> writeFile (root </> "src" </> name ++ ".hs") text) sourceFiles
  pure (Sources root)

compileModule :: String -> Sources -> IO Object
compileModule name (Sources root) = do
  let output = root </> name
  createDirectory output
  _ <- command "ghc" ["-v0", "-O0", "-c", root </> "src" </> name ++ ".hs", "-odir", output, "-hidir", output]
  pure (Object (output </> name ++ ".o"))

compileMain :: (Object, Object) -> IO App
compileMain (Object model, Object view) = do
  let root = takeDirectory (takeDirectory model)
      output = root </> "Main"
  createDirectory output
  _ <-
    command
      "ghc"
      [ "-v0"
      , "-O0"
      , "-c"
      , root </> "src" </> "Main.hs"
      , "-i" ++ takeDirectory model
      , "-i" ++ takeDirectory view
      , "-odir"
      , output
      , "-ohi"
      , output </> "Main.hi"
      ]
  pure (App root [model, view, output </> "Main.o"])

linkApp :: App -> IO Executable
linkApp (App root objects) = do
  let path = root </> "app"
  _ <- command "ghc" (["-v0", "-o", path] ++ objects)
  pure (Executable path)

generateDocs :: Sources -> IO Documentation
generateDocs (Sources root) = do
  let output = root </> "docs"
  _ <-
    command
      "haddock"
      ( ["--html", "--hyperlinked-source", "--title=Chart demo", "--odir=" ++ output]
          ++ [root </> "src" </> name ++ ".hs" | (name, _) <- sourceFiles]
      )
  pure (Documentation output)

packageApp :: (Executable, Documentation) -> IO Package
packageApp (Executable executable, Documentation docs) = do
  let root = takeDirectory executable
      archive = root </> "app.tar.gz"
  _ <-
    command
      "tar"
      ["-czf", archive, "-C", root, "app", "-C", takeDirectory docs, takeFileName docs]
  pure (Package archive)

-- * Execution

-- Star supplies the profunctor and concurrent tensor through Concurrently's applicative.
-- Job adds sequential composition locally for isolated jobs: independent effects must commute,
-- and scheduling differences are ignored when comparing results.
type Job :: Type -> Type -> Type
newtype Job a b = Job (Star (Prelude Async.Concurrently) a b)
  deriving newtype (Profunctor, MonoidalProfunctor)

fromIO :: (a -> IO b) -> Job a b
fromIO f = Job (Star (Prelude P.. Async.Concurrently P.. f))

runJob :: Job a b -> a -> IO b
runJob (Job (Star f)) = Async.runConcurrently P.. unPrelude P.. f

instance Promonad Job where
  id = fromIO pure
  g . f = fromIO (runJob f >=> runJob g)

instance Sized (KLEISLI Job) where
  sizeOf = 2

runWith :: HG.Wiring (KLEISLI Job) -> IO Run
runWith wiring = withBuildRun \workspace record -> do
  let job :: String -> (a -> IO b) -> Kleisli (KL a :: KLEISLI Job) (KL b)
      job name action = Kleisli (loggedJob record name action)
      graph =
        buildGraph
          (job "prepare sources" prepareSources)
          (job "compile Model" (compileModule "Model"))
          (job "compile View" (compileModule "View"))
          (job "compile Main" compileMain)
          (job "link" linkApp)
          (job "generate docs" generateDocs)
          (job "package" packageApp)
  Str (Kleisli execute) <- either fail pure (HG.simplify wiring graph)
  runJob execute workspace

runSequential :: IO Run
runSequential = runWith HG.copyDiscard

runParallel :: IO Run
runParallel = runWith (HG.layered HG.copyDiscard)

-- * Demo support

data Event = Event {seconds :: Double, jobName :: String, phase :: String} deriving stock (Eq, Show)
data Run = Run {result :: String, elapsed :: Double, events :: [Event]} deriving stock (Eq, Show)

loggedJob :: (String -> String -> IO ()) -> String -> (a -> IO b) -> Job a b
loggedJob record name action = fromIO \a -> do
  record name "start"
  b <- action a
  record name "finish"
  pure b

-- Measure only the build, then verify its package and remove the temporary workspace.
withBuildRun :: (Workspace -> (String -> String -> IO ()) -> IO Package) -> IO Run
withBuildRun build = withSystemTempDirectory "proarrow-build" \root -> do
  logbook <- newMVar []
  started <- getMonotonicTimeNSec
  let sinceStart = do
        now <- getMonotonicTimeNSec
        pure (fromIntegral (now - started) / 1e9)
      record name status = do
        t <- sinceStart
        modifyMVar_ logbook (pure P.. (Event t name status :))
  package <- build (Workspace root) record
  duration <- sinceStart
  trace <- sortOn seconds <$> readMVar logbook
  value <- verifyPackage package
  pure (Run value duration trace)

verifyPackage :: Package -> IO String
verifyPackage (Package archive) = do
  let unpacked = takeDirectory archive </> "unpacked"
  createDirectory unpacked
  _ <- command "tar" ["-xzf", archive, "-C", unpacked]
  value <- command (unpacked </> "app") []
  contents <- readFile' (unpacked </> "docs" </> "index.html")
  if value == expected && "Chart demo" `isInfixOf` contents
    then pure value
    else fail "packaged application or documentation is incorrect"

-- | Build and check both schedules, saving diagrams and measured timelines in the given directory.
-- The installed GHC and host load affect timings; concurrency need not make a tiny build faster.
demo :: FilePath -> IO ()
demo directory = do
  createDirectoryIfMissing True directory
  sequential <- runSequential
  parallel <- runParallel
  display "Sequential" sequential
  display "Layered" parallel
  either fail (writeFile (directory </> "sequential.svg")) sequentialPicture
  either fail (writeFile (directory </> "parallel.svg")) parallelPicture
  writeFile (directory </> "timeline.svg") (timeline sequential parallel)
  putStrLn ("Diagrams and measured timeline: " ++ directory)
  putStrLn ("Packaged executable output:\n" ++ result parallel)
  where
    display title run = do
      putStrLn title
      mapM_ (\(Event t name status) -> printf "  %5.3fs  %-14s %s\n" t name status) (events run)
      printf "  total: %.3fs\n\n" (elapsed run)

-- | Both measured schedules on the same time scale. Job names come from this example's fixed labels.
timeline :: Run -> Run -> String
timeline sequential parallel =
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    ++ "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"900\" height=\"570\" viewBox=\"0 0 900 570\">"
    ++ "<rect width=\"900\" height=\"570\" fill=\"white\"/><g font-family=\"sans-serif\" font-size=\"14\">"
    ++ panel 28 "Sequential build" sequential
    ++ panel 300 "Automatic parallel build" parallel
    ++ "</g></svg>"
  where
    scale = 640 / max 0.001 (max (elapsed sequential) (elapsed parallel))
    names = ["prepare sources", "compile Model", "compile View", "compile Main", "link", "generate docs", "package"]
    panel :: Int -> String -> Run -> String
    panel y title run =
      printf "<text x=\"20\" y=\"%d\" font-weight=\"bold\">%s: %.3fs</text>" y title (elapsed run)
        ++ concat [bar (y + 18 + i * 28) name run | (i, name) <- zip [0 ..] names]
        ++ printf "<path d=\"M180 %d H820\" stroke=\"#aaa\"/>" (y + 228)
        ++ concat
          [ printf
              "<text x=\"%.2f\" y=\"%d\" text-anchor=\"middle\" fill=\"#555\">%.2fs</text>"
              (180 + t * scale)
              (y + 248)
              t
          | i <- [0 .. 4 :: Int]
          , let t = fromIntegral i * max (elapsed sequential) (elapsed parallel) / 4
          ]
    bar :: Int -> String -> Run -> String
    bar y name run =
      printf "<text x=\"20\" y=\"%d\">%s</text>" (y + 15) name
        ++ concat
          [ printf
              "<rect x=\"%.2f\" y=\"%d\" width=\"%.2f\" height=\"21\" rx=\"3\" fill=\"#4078b5\"/>"
              (180 + start * scale)
              y
              (max 2 ((end - start) * scale))
          | Event start job "start" <- events run
          , job == name
          , Event end job' "finish" <- events run
          , job' == name
          ]

-- * Pictures and pure checks

picture :: HG.Wiring SVG -> Either String (Svg (S '[Wire "dir"]) (S '[Wire "pkg"]))
picture wiring = do
  Str diagram <-
    HG.simplify wiring $
      buildGraph
        (node @'[Wire "dir"] @'[Wire "src"] "prepare sources")
        (node @'[Wire "src"] @'[Wire "obj"] "compile Model")
        (node @'[Wire "src"] @'[Wire "obj"] "compile View")
        (node @'[Wire "obj", Wire "obj"] @'[Wire "app"] "compile Main")
        (node @'[Wire "app"] @'[Wire "exe"] "link")
        (node @'[Wire "src"] @'[Wire "doc"] "generate docs")
        (node @'[Wire "exe", Wire "doc"] @'[Wire "pkg"] "package")
  pure diagram

sequentialPicture, parallelPicture :: Either String String
sequentialPicture = render <$> picture HG.copyDiscard
parallelPicture = render <$> picture (HG.layered HG.copyDiscard)

-- Gate groups in a composition, with tensor operands sharing a stage.
stages :: Svg.Diagram -> [[String]]
stages = \case
  Svg.Node _ title _ _ -> [[title]]
  Svg.Seq a b -> stages a ++ stages b
  Svg.Beside a b -> together (stages a) (stages b)
  Svg.Trace _ d -> stages d
  _ -> []
  where
    together [] bs = bs
    together as [] = as
    together (a : as) (b : bs) = (a ++ b) : together as bs

test :: TestTree
test =
  testGroup
    "Parallel build"
    [ testProperty "sequential job dependencies" $
        case picture HG.copyDiscard of
          Left err -> testFailed err
          Right (Svg _ diagram) ->
            expect
              "stages"
              [["prepare sources"], ["compile Model"], ["compile View"], ["compile Main"], ["link"], ["generate docs"], ["package"]]
              (map sort (stages diagram))
    , testProperty "layered jobs respect dependencies" $
        case picture (HG.layered HG.copyDiscard) of
          Left err -> testFailed err
          Right (Svg _ diagram) ->
            expect
              "stages"
              [["prepare sources"], ["compile Model", "compile View", "generate docs"], ["compile Main"], ["link"], ["package"]]
              (map sort (stages diagram))
    ]