packages feed

ghcide-0.3.0: bench/hist/Main.hs

{-  Bench history

    A Shake script to analyze the performance of ghcide over the git history of the project

    Driven by a config file `bench/hist.yaml` containing the list of Git references to analyze.

    Builds each one of them and executes a set of experiments using the ghcide-bench suite.

    The results of the benchmarks and the analysis are recorded in the file
    system with the following structure:

    bench-hist
    ├── <git-reference>                       - one folder per version
    │   ├── <experiment>.benchmark-gcStats    - RTS -s output
    │   ├── <experiment>.csv                  - stats for the experiment
    │   ├── <experiment>.svg                  - Graph of bytes over elapsed time
    │   ├── <experiment>.diff.svg             - idem, including the previous version
    │   ├── <experiment>.log                  - ghcide-bench output
    │   ├── ghc.path                          - path to ghc used to build the binary
    │   ├── ghcide                            - binary for this version
    │   └── results.csv                       - results of all the experiments for the version
    ├── results.csv        - aggregated results of all the experiments and versions
    ├── <experiment>.svg   - graph of bytes over elapsed time, for all the included versions

   For diff graphs, the "previous version" is the preceding entry in the list of versions
   in the config file. A possible improvement is to obtain this info via `git rev-list`.

   The script relies on stack for building and running all the binaries.

   To execute the script:

   > stack bench

   To build a specific analysis, enumerate the desired file artifacts

   > stack bench --ba "bench-hist/HEAD/results.csv bench-hist/HEAD/edit.diff.svg"

 -}
{-# LANGUAGE DeriveAnyClass    #-}
{-# LANGUAGE DerivingStrategies#-}
{-# LANGUAGE TypeFamilies      #-}

import Control.Applicative (Alternative (empty))
import Control.Monad (when, forM, forM_, replicateM)
import Data.Foldable (find)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Yaml ((.!=), (.:?), FromJSON (..), ToJSON (..), Value (..), decodeFileThrow)
import Development.Shake
import Development.Shake.Classes (Binary, Hashable, NFData)
import GHC.Exts (IsList (..))
import GHC.Generics (Generic)
import qualified Graphics.Rendering.Chart.Backend.Diagrams as E
import Graphics.Rendering.Chart.Easy ((.=))
import qualified Graphics.Rendering.Chart.Easy as E
import Numeric.Natural (Natural)
import System.Directory
import System.FilePath
import qualified Text.ParserCombinators.ReadP as P
import Text.Read (Read (..), get, readMaybe, readP_to_Prec)

config :: FilePath
config = "bench/hist.yaml"

-- | Read the config without dependency
readConfigIO :: FilePath -> IO Config
readConfigIO = decodeFileThrow

newtype GetSamples = GetSamples () deriving newtype (Binary, Eq, Hashable, NFData, Show)

newtype GetExperiments = GetExperiments () deriving newtype (Binary, Eq, Hashable, NFData, Show)

newtype GetVersions = GetVersions () deriving newtype (Binary, Eq, Hashable, NFData, Show)

newtype GetParent = GetParent Text deriving newtype (Binary, Eq, Hashable, NFData, Show)

newtype GetCommitId = GetCommitId String deriving newtype (Binary, Eq, Hashable, NFData, Show)

type instance RuleResult GetSamples = Natural

type instance RuleResult GetExperiments = [Unescaped String]

type instance RuleResult GetVersions = [GitCommit]

type instance RuleResult GetParent = Text

type instance RuleResult GetCommitId = String

main :: IO ()
main = shakeArgs shakeOptions {shakeChange = ChangeModtimeAndDigest} $ do
  want ["all"]

  readConfig <- newCache $ \fp -> need [fp] >> liftIO (readConfigIO fp)

  _ <- addOracle $ \GetSamples {} -> samples <$> readConfig config
  _ <- addOracle $ \GetExperiments {} -> experiments <$> readConfig config
  _ <- addOracle $ \GetVersions {} -> versions <$> readConfig config
  _ <- addOracle $ \(GetParent name) -> findPrev name . versions <$> readConfig config

  let readVersions = askOracle $ GetVersions ()
      readExperiments = askOracle $ GetExperiments ()
      readSamples = askOracle $ GetSamples ()
      getParent = askOracle . GetParent

  build <- liftIO $ outputFolder <$> readConfigIO config
  ghcideBenchPath <- ghcideBench <$> liftIO (readConfigIO config)

  phony "all" $ do
    Config {..} <- readConfig config

    forM_ versions $ \ver ->
      need [build </> T.unpack (humanName ver) </> "results.csv"]

    need $
      [build </> "results.csv"]
        ++ [ build </> escaped (escapeExperiment e) <.> "svg"
             | e <- experiments
           ]
        ++ [ build </> T.unpack (humanName ver) </> escaped (escapeExperiment e) <.> mode <.> "svg"
             | e <- experiments,
               ver <- versions,
               mode <- ["", "diff"]
           ]

  build -/- "*/commitid" %> \out -> do
      alwaysRerun

      let [_,ver,_] = splitDirectories out
      mbEntry <- find ((== T.pack ver) . humanName) <$> readVersions
      let gitThing :: String
          gitThing = maybe ver (T.unpack . gitName) mbEntry
      Stdout commitid <- command [] "git" ["rev-list", "-n", "1", gitThing]
      writeFileChanged out $ init commitid

  priority 10 $ [build -/- "HEAD/ghcide"
                , build -/- "HEAD/ghc.path"
                ]
    &%> \[out, ghcpath] -> do
      liftIO $ createDirectoryIfMissing True $ dropFileName out
      need =<< getDirectoryFiles "." ["src//*.hs", "exe//*.hs", "ghcide.cabal"]
      cmd_
          ( "stack --local-bin-path=" <> takeDirectory out
              <> " --stack-yaml=stack88.yaml build ghcide:ghcide --copy-bins --ghc-options -rtsopts"
          )
      Stdout ghcLoc <- cmd (s "stack --stack-yaml=stack88.yaml exec which ghc")
      writeFile' ghcpath ghcLoc

  [ build -/- "*/ghcide",
    build -/- "*/ghc.path"
    ]
    &%> \[out, ghcpath] -> do
      let [b, ver, _] = splitDirectories out
      liftIO $ createDirectoryIfMissing True $ dropFileName out
      commitid <- readFile' $ b </> ver </> "commitid"
      cmd_ $ "git worktree add bench-temp " ++ commitid
      flip actionFinally (cmd_ (s "git worktree remove bench-temp --force")) $ do
        Stdout ghcLoc <- cmd [Cwd "bench-temp"] (s "stack --stack-yaml=stack88.yaml exec which ghc")
        cmd_
          [Cwd "bench-temp"]
          ( "stack --local-bin-path=../"
              <> takeDirectory out
              <> " --stack-yaml=stack88.yaml build ghcide:ghcide --copy-bins --ghc-options -rtsopts"
          )
        writeFile' ghcpath ghcLoc

  priority 8000 $
    build -/- "*/results.csv" %> \out -> do
      experiments <- readExperiments

      let allResultFiles = [takeDirectory out </> escaped (escapeExperiment e) <.> "csv" | e <- experiments]
      allResults <- traverse readFileLines allResultFiles

      let header = head $ head allResults
          results = map tail allResults
      writeFileChanged out $ unlines $ header : concat results

  ghcideBenchResource <- newResource "ghcide-bench" 1

  priority 0 $
    [ build -/- "*/*.csv",
      build -/- "*/*.benchmark-gcStats",
      build -/- "*/*.log"
    ]
      &%> \[outcsv, _outGc, outLog] -> do
        let [_, _, exp] = splitDirectories outcsv
        samples <- readSamples
        liftIO $ createDirectoryIfMissing True $ dropFileName outcsv
        let ghcide = dropFileName outcsv </> "ghcide"
            ghcpath = dropFileName outcsv </> "ghc.path"
        need [ghcide, ghcpath]
        ghcPath <- readFile' ghcpath
        withResource ghcideBenchResource 1 $ do
          command_
              [ EchoStdout False,
                FileStdout outLog,
                RemEnv "NIX_GHC_LIBDIR",
                RemEnv "GHC_PACKAGE_PATH",
                AddPath [takeDirectory ghcPath, "."] []
              ]
              ghcideBenchPath
              [ "--timeout=3000",
                "-v",
                "--samples=" <> show samples,
                "--csv=" <> outcsv,
                "--example-package-version=3.0.0.0",
                "--ghcide-options= +RTS -I0.5 -RTS",
                "--ghcide=" <> ghcide,
                "--select",
                unescaped (unescapeExperiment (Escaped $ dropExtension exp))
              ]
          cmd_ Shell $ "mv *.benchmark-gcStats " <> dropFileName outcsv

  build -/- "results.csv" %> \out -> do
    versions <- readVersions
    let allResultFiles =
          [build </> T.unpack (humanName v) </> "results.csv" | v <- versions]

    need [build </> T.unpack (humanName v) </> "ghcide" | v <- versions]

    allResults <- traverse readFileLines allResultFiles

    let header = head $ head allResults
        results = map tail allResults
        header' = "version, " <> header
        results' = zipWith (\v -> map (\l -> T.unpack (humanName v) <> ", " <> l)) versions results

    writeFileChanged out $ unlines $ header' : concat results'

  priority 2 $
    build -/- "*/*.diff.svg" %> \out -> do
      let [b, ver, exp_] = splitDirectories out
          exp = Escaped $ dropExtension $ dropExtension exp_
      prev <- getParent $ T.pack ver

      runLog <- loadRunLog b exp ver
      runLogPrev <- loadRunLog b exp $ T.unpack prev

      let diagram = Diagram Live [runLog, runLogPrev] title
          title = show (unescapeExperiment exp) <> " - live bytes over time compared"
      plotDiagram True diagram out

  priority 1 $
    build -/- "*/*.svg" %> \out -> do
      let [b, ver, exp] = splitDirectories out
      runLog <- loadRunLog b (Escaped $ dropExtension exp) ver
      let diagram = Diagram Live [runLog] title
          title = ver <> " live bytes over time"
      plotDiagram True diagram out

  build -/- "*.svg" %> \out -> do
    let exp = Escaped $ dropExtension $ takeFileName out
    versions <- readVersions

    runLogs <- forM (filter include versions) $ \v -> do
      loadRunLog build exp $ T.unpack $ humanName v

    let diagram = Diagram Live runLogs title
        title = show (unescapeExperiment exp) <> " - live bytes over time"
    plotDiagram False diagram out

----------------------------------------------------------------------------------------------------

data Config = Config
  { experiments :: [Unescaped String],
    samples :: Natural,
    versions :: [GitCommit],
    -- | Path to the ghcide-bench binary for the experiments
    ghcideBench :: FilePath,
    -- | Output folder ('foo' works, 'foo/bar' does not)
    outputFolder :: String
  }
  deriving (Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

data GitCommit = GitCommit
  { -- | A git hash, tag or branch name (e.g. v0.1.0)
    gitName :: Text,
    -- | A human understandable name (e.g. fix-collisions-leak)
    name :: Maybe Text,
    -- | The human understandable name of the parent, if specified explicitly
    parent :: Maybe Text,
    -- | Whether to include this version in the top chart
    include :: Bool
  }
  deriving (Binary, Eq, Hashable, Generic, NFData, Show)

instance FromJSON GitCommit where
  parseJSON (String s) = pure $ GitCommit s Nothing Nothing True
  parseJSON (Object (toList -> [(name, String gitName)])) =
    pure $ GitCommit gitName (Just name) Nothing True
  parseJSON (Object (toList -> [(name, Object props)])) =
    GitCommit
      <$> props .:? "git"  .!= name
      <*> pure (Just name)
      <*> props .:? "parent"
      <*> props .:? "include" .!= True
  parseJSON _ = empty

instance ToJSON GitCommit where
  toJSON GitCommit {..} =
    case name of
      Nothing -> String gitName
      Just n -> Object $ fromList [(n, String gitName)]

humanName :: GitCommit -> Text
humanName GitCommit {..} = fromMaybe gitName name

findPrev :: Text -> [GitCommit] -> Text
findPrev name (x : y : xx)
  | humanName y == name = humanName x
  | otherwise = findPrev name (y : xx)
findPrev name _ = name

----------------------------------------------------------------------------------------------------

-- | A line in the output of -S
data Frame = Frame
  { allocated, copied, live :: !Int,
    user, elapsed, totUser, totElapsed :: !Double,
    generation :: !Int
  }
  deriving (Show)

instance Read Frame where
  readPrec = do
    spaces
    allocated <- readPrec @Int <* spaces
    copied <- readPrec @Int <* spaces
    live <- readPrec @Int <* spaces
    user <- readPrec @Double <* spaces
    elapsed <- readPrec @Double <* spaces
    totUser <- readPrec @Double <* spaces
    totElapsed <- readPrec @Double <* spaces
    _ <- readPrec @Int <* spaces
    _ <- readPrec @Int <* spaces
    "(Gen:  " <- replicateM 7 get
    generation <- readPrec @Int
    ')' <- get
    return Frame {..}
    where
      spaces = readP_to_Prec $ const P.skipSpaces

data TraceMetric = Allocated | Copied | Live | User | Elapsed
  deriving (Generic, Enum, Bounded, Read)

instance Show TraceMetric where
  show Allocated = "Allocated bytes"
  show Copied = "Copied bytes"
  show Live = "Live bytes"
  show User = "User time"
  show Elapsed = "Elapsed time"

frameMetric :: TraceMetric -> Frame -> Double
frameMetric Allocated = fromIntegral . allocated
frameMetric Copied = fromIntegral . copied
frameMetric Live = fromIntegral . live
frameMetric Elapsed = elapsed
frameMetric User = user

data Diagram = Diagram
  { traceMetric :: TraceMetric,
    runLogs :: [RunLog],
    title :: String
  }
  deriving (Generic)

-- | A file path containing the output of -S for a given run
data RunLog = RunLog
  { runVersion :: !String,
    _runExperiment :: !String,
    runFrames :: ![Frame],
    runSuccess :: !Bool
  }

loadRunLog :: FilePath -> Escaped FilePath -> FilePath -> Action RunLog
loadRunLog buildF exp ver = do
  let log_fp = buildF </> ver </> escaped exp <.> "benchmark-gcStats"
      csv_fp = replaceExtension log_fp "csv"
  log <- readFileLines log_fp
  csv <- readFileLines csv_fp
  let frames =
        [ f
          | l <- log,
            Just f <- [readMaybe l],
            -- filter out gen 0 events as there are too many
            generation f == 1
        ]
      success = case map (T.split (== ',') . T.pack) csv of
          [_header, _name:s:_] | Just s <- readMaybe (T.unpack s) -> s
          _ -> error $ "Cannot parse: " <> csv_fp
  return $ RunLog ver (dropExtension $ escaped exp) frames success

plotDiagram :: Bool -> Diagram -> FilePath -> Action ()
plotDiagram includeFailed t@Diagram {traceMetric, runLogs} out = do
  let extract = frameMetric traceMetric
  liftIO $ E.toFile E.def out $ do
    E.layout_title .= title t
    E.setColors myColors
    forM_ runLogs $ \rl ->
      when (includeFailed || runSuccess rl) $ E.plot $ do
        lplot <- E.line
            (runVersion rl ++ if runSuccess rl then "" else " (FAILED)")
            [ [ (totElapsed f, extract f)
                | f <- runFrames rl
                ]
            ]
        return (lplot E.& E.plot_lines_style . E.line_width E.*~ 2)

s :: String -> String
s = id

(-/-) :: FilePattern -> FilePattern -> FilePattern
a -/- b = a <> "/" <> b

newtype Escaped a = Escaped {escaped :: a}

newtype Unescaped a = Unescaped {unescaped :: a}
  deriving newtype (Show, FromJSON, ToJSON, Eq, NFData, Binary, Hashable)

escapeExperiment :: Unescaped String -> Escaped String
escapeExperiment = Escaped . map f . unescaped
  where
    f ' ' = '_'
    f other = other

unescapeExperiment :: Escaped String -> Unescaped String
unescapeExperiment = Unescaped . map f . escaped
  where
    f '_' = ' '
    f other = other

myColors :: [E.AlphaColour Double]
myColors = map E.opaque
  [ E.blue
  , E.green
  , E.red
  , E.orange
  , E.yellow
  , E.violet
  , E.black
  , E.gold
  , E.brown
  , E.hotpink
  , E.aliceblue
  , E.aqua
  , E.beige
  , E.bisque
  , E.blueviolet
  , E.burlywood
  , E.cadetblue
  , E.chartreuse
  , E.coral
  , E.crimson
  , E.darkblue
  , E.darkgray
  , E.darkgreen
  , E.darkkhaki
  , E.darkmagenta
  , E.deeppink
  , E.dodgerblue
  , E.firebrick
  , E.forestgreen
  , E.fuchsia
  , E.greenyellow
  , E.lightsalmon
  , E.seagreen
  , E.olive
  , E.sandybrown
  , E.sienna
  , E.peru
  ]