packages feed

futhark-0.17.1: src/Futhark/Bench.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Facilities for handling Futhark benchmark results.  A Futhark
-- benchmark program is just like a Futhark test program.
module Futhark.Bench
  ( RunResult (..),
    DataResult (..),
    BenchResult (..),
    encodeBenchResults,
    decodeBenchResults,
    binaryName,
    benchmarkDataset,
    RunOptions (..),
    prepareBenchmarkProgram,
    CompileOptions (..),
  )
where

import Control.Applicative
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Monad.Except
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Char8 as SBS
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.HashMap.Strict as HM
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import Futhark.Test
import System.Exit
import System.FilePath
import System.IO
import System.IO.Error
import System.IO.Temp (withSystemTempFile)
import System.Process.ByteString (readProcessWithExitCode)
import System.Timeout (timeout)

-- | The runtime of a single succesful run.
newtype RunResult = RunResult {runMicroseconds :: Int}
  deriving (Eq, Show)

-- | The results for a single named dataset is either an error
-- message, or runtime measurements along the stderr that was
-- produced.
data DataResult = DataResult String (Either T.Text ([RunResult], T.Text))
  deriving (Eq, Show)

-- | The results for all datasets for some benchmark program.
data BenchResult = BenchResult FilePath [DataResult]
  deriving (Eq, Show)

newtype DataResults = DataResults {unDataResults :: [DataResult]}

newtype BenchResults = BenchResults {unBenchResults :: [BenchResult]}

instance JSON.ToJSON RunResult where
  toJSON = JSON.toJSON . runMicroseconds

instance JSON.FromJSON RunResult where
  parseJSON = fmap RunResult . JSON.parseJSON

instance JSON.ToJSON DataResults where
  toJSON (DataResults rs) =
    JSON.object $ map dataResultJSON rs
  toEncoding (DataResults rs) =
    JSON.pairs $ mconcat $ map (uncurry (JSON..=) . dataResultJSON) rs

instance JSON.FromJSON DataResults where
  parseJSON = JSON.withObject "datasets" $ \o ->
    DataResults <$> mapM datasetResult (HM.toList o)
    where
      datasetResult (k, v) =
        DataResult (T.unpack k)
          <$> ((Right <$> success v) <|> (Left <$> JSON.parseJSON v))
      success = JSON.withObject "result" $ \o ->
        (,) <$> o JSON..: "runtimes" <*> o JSON..: "stderr"

dataResultJSON :: DataResult -> (T.Text, JSON.Value)
dataResultJSON (DataResult desc (Left err)) =
  (T.pack desc, JSON.toJSON err)
dataResultJSON (DataResult desc (Right (runtimes, progerr))) =
  ( T.pack desc,
    JSON.object
      [ ("runtimes", JSON.toJSON $ map runMicroseconds runtimes),
        ("stderr", JSON.toJSON progerr)
      ]
  )

benchResultJSON :: BenchResult -> (T.Text, JSON.Value)
benchResultJSON (BenchResult prog r) =
  ( T.pack prog,
    JSON.Object $ HM.singleton "datasets" (JSON.toJSON $ DataResults r)
  )

instance JSON.ToJSON BenchResults where
  toJSON (BenchResults rs) =
    JSON.Object $ HM.fromList $ map benchResultJSON rs

instance JSON.FromJSON BenchResults where
  parseJSON = JSON.withObject "benchmarks" $ \o ->
    BenchResults <$> mapM onBenchmark (HM.toList o)
    where
      onBenchmark (k, v) =
        BenchResult (T.unpack k)
          <$> JSON.withObject "benchmark" onBenchmark' v
      onBenchmark' o =
        fmap unDataResults . JSON.parseJSON =<< o JSON..: "datasets"

-- | Transform benchmark results to a JSON bytestring.
encodeBenchResults :: [BenchResult] -> LBS.ByteString
encodeBenchResults = JSON.encode . BenchResults

-- | Decode benchmark results from a JSON bytestring.
decodeBenchResults :: LBS.ByteString -> Either String [BenchResult]
decodeBenchResults = fmap unBenchResults . JSON.eitherDecode'

--- Running benchmarks

readRuntime :: T.Text -> Maybe Int
readRuntime s = case reads $ T.unpack s of
  [(runtime, _)] -> Just runtime
  _ -> Nothing

didNotFail :: FilePath -> ExitCode -> T.Text -> ExceptT T.Text IO ()
didNotFail _ ExitSuccess _ =
  return ()
didNotFail program (ExitFailure code) stderr_s =
  throwError $
    T.pack $
      program ++ " failed with error code " ++ show code
        ++ " and output:\n"
        ++ T.unpack stderr_s

compareResult ::
  (MonadError T.Text m, MonadIO m) =>
  FilePath ->
  (SBS.ByteString, [Value]) ->
  (SBS.ByteString, [Value]) ->
  m ()
compareResult program (expected_bs, expected_vs) (actual_bs, actual_vs) =
  case compareValues1 actual_vs expected_vs of
    Just mismatch -> do
      let actualf = program `replaceExtension` "actual"
          expectedf = program `replaceExtension` "expected"
      liftIO $ SBS.writeFile actualf actual_bs
      liftIO $ SBS.writeFile expectedf expected_bs
      throwError $
        T.pack actualf <> " and " <> T.pack expectedf
          <> " do not match:\n"
          <> T.pack (show mismatch)
    Nothing ->
      return ()

runResult ::
  (MonadError T.Text m, MonadIO m) =>
  FilePath ->
  ExitCode ->
  SBS.ByteString ->
  SBS.ByteString ->
  m (SBS.ByteString, [Value])
runResult program ExitSuccess stdout_s _ =
  case valuesFromByteString "stdout" $ LBS.fromStrict stdout_s of
    Left e -> do
      let actualf = program `replaceExtension` "actual"
      liftIO $ SBS.writeFile actualf stdout_s
      throwError $ T.pack $ show e <> "\n(See " <> actualf <> ")"
    Right vs -> return (stdout_s, vs)
runResult program (ExitFailure code) _ stderr_s =
  throwError $
    T.pack $
      binaryName program ++ " failed with error code " ++ show code
        ++ " and output:\n"
        ++ T.unpack (T.decodeUtf8 stderr_s)

-- | How to run a benchmark.
data RunOptions = RunOptions
  { runRunner :: String,
    runRuns :: Int,
    runExtraOptions :: [String],
    runTimeout :: Int,
    runVerbose :: Int,
    -- | Invoked for every runtime measured during the run.  Can be
    -- used to provide a progress bar.
    runResultAction :: Maybe (Int -> IO ())
  }

-- | Like @tail -f@, but running an arbitrary IO action per line.
follow :: (String -> IO ()) -> FilePath -> IO ()
follow f fname = go 0
  where
    go i = do
      i' <- withFile fname ReadMode $ \h -> do
        hSeek h AbsoluteSeek i
        goH h i
      go i'

    goH h i = do
      res <- tryIOError $ hGetLine h
      case res of
        Left e
          | isEOFError e -> do
            threadDelay followDelayMicroseconds
            pure i
          | otherwise -> ioError e
        Right l -> do
          f l
          goH h =<< hTell h

    triesPerSecond = 10
    followDelayMicroseconds = 1000000 `div` triesPerSecond

-- | Run the benchmark program on the indicated dataset.
benchmarkDataset ::
  RunOptions ->
  FilePath ->
  T.Text ->
  Values ->
  Maybe Success ->
  FilePath ->
  IO (Either T.Text ([RunResult], T.Text))
benchmarkDataset opts program entry input_spec expected_spec ref_out =
  -- We store the runtime in a temporary file.
  withSystemTempFile "futhark-bench" $ \tmpfile h -> do
    hClose h -- We will be writing and reading this ourselves.
    input <- getValuesBS dir input_spec
    let getValuesAndBS (SuccessValues vs) = do
          vs' <- getValues dir vs
          bs <- getValuesBS dir vs
          return (LBS.toStrict bs, vs')
        getValuesAndBS SuccessGenerateValues =
          getValuesAndBS $ SuccessValues $ InFile ref_out
    maybe_expected <- maybe (return Nothing) (fmap Just . getValuesAndBS) expected_spec
    let options =
          runExtraOptions opts
            ++ [ "-e",
                 T.unpack entry,
                 "-t",
                 tmpfile,
                 "-r",
                 show $ runRuns opts,
                 "-b"
               ]

    -- Explicitly prefixing the current directory is necessary for
    -- readProcessWithExitCode to find the binary when binOutputf has
    -- no program component.
    let (to_run, to_run_args)
          | null $ runRunner opts = ("." </> binaryName program, options)
          | otherwise = (runRunner opts, binaryName program : options)

    when (runVerbose opts > 1) $
      putStrLn $
        unwords
          [ "Running executable",
            show to_run,
            "with arguments",
            show to_run_args
          ]

    let onResult l
          | Just f <- runResultAction opts,
            [(x, "")] <- reads l =
            f x
          | otherwise =
            pure ()
    watcher <- forkIO $ follow onResult tmpfile

    run_res <-
      timeout (runTimeout opts * 1000000) $
        readProcessWithExitCode to_run to_run_args $
          LBS.toStrict input

    killThread watcher

    runExceptT $ case run_res of
      Just (progCode, output, progerr) -> do
        case maybe_expected of
          Nothing ->
            didNotFail program progCode $ T.decodeUtf8 progerr
          Just expected ->
            compareResult program expected
              =<< runResult program progCode output progerr
        runtime_result <- liftIO $ T.readFile tmpfile
        runtimes <- case mapM readRuntime $ T.lines runtime_result of
          Just runtimes -> return $ map RunResult runtimes
          Nothing -> throwError $ "Runtime file has invalid contents:\n" <> runtime_result

        return (runtimes, T.decodeUtf8 progerr)
      Nothing ->
        throwError $ T.pack $ "Execution exceeded " ++ show (runTimeout opts) ++ " seconds."
  where
    dir = takeDirectory program

-- | How to compile a benchmark.
data CompileOptions = CompileOptions
  { compFuthark :: String,
    compBackend :: String,
    compOptions :: [String]
  }

progNotFound :: String -> String
progNotFound s = s ++ ": command not found"

-- | Compile and produce reference datasets.
prepareBenchmarkProgram ::
  MonadIO m =>
  Maybe Int ->
  CompileOptions ->
  FilePath ->
  [InputOutputs] ->
  m (Either (String, Maybe SBS.ByteString) ())
prepareBenchmarkProgram concurrency opts program cases = do
  let futhark = compFuthark opts

  ref_res <- runExceptT $ ensureReferenceOutput concurrency futhark "c" program cases
  case ref_res of
    Left err ->
      return $
        Left
          ( "Reference output generation for " ++ program ++ " failed:\n"
              ++ unlines (map T.unpack err),
            Nothing
          )
    Right () -> do
      (futcode, _, futerr) <-
        liftIO $
          readProcessWithExitCode
            futhark
            ( [compBackend opts, program, "-o", binaryName program]
                <> compOptions opts
            )
            ""

      case futcode of
        ExitSuccess -> return $ Right ()
        ExitFailure 127 -> return $ Left (progNotFound futhark, Nothing)
        ExitFailure _ -> return $ Left ("Compilation of " ++ program ++ " failed:\n", Just futerr)