tilia-0.1.0.0: bench/Main.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Measure what formatting a fixed sample of modules costs, and compare
-- what it allocates and the instructions it retires with the record kept
-- in the repository.
module Main (main) where
import Control.Applicative ((<|>))
import Control.Exception (SomeException, try)
import Control.Monad (join, mfilter, unless)
import Data.Containers.ListUtils (nubOrd)
import Data.Foldable (for_)
import Data.Int (Int64)
import Data.List (sortOn)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, isJust, listToMaybe)
import Data.Ord (Down (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Traversable (for)
import Data.Version (showVersion)
import Data.Word (Word64)
import Options.Applicative
( Parser,
ParserInfo,
execParser,
fullDesc,
help,
helper,
info,
long,
maybeReader,
metavar,
option,
optional,
progDesc,
showDefault,
strOption,
value,
)
import System.Environment (lookupEnv)
import System.Exit (die, exitFailure)
import System.IO (BufferMode (..), hSetBuffering, stdout)
import System.Info (fullCompilerVersion)
import Text.Printf (printf)
import Text.Read (readMaybe)
import Tilia.Bench.Cases
import Tilia.Bench.Fixity
import Tilia.Bench.Measure
import Tilia.Bench.Record
import Tilia.Corpus (hackagePackages, obtain)
-- | How the benchmarks were asked to run.
data Options = Options
{ -- | How many runs to measure after the one that warms up.
optRuns :: Int,
-- | Run only the benchmarks whose module names hold this.
optMatch :: Maybe Text,
-- | How far one benchmark's allocations or instructions may move from
-- the record before it is out of date, as a fraction.
optTolerance :: Double,
-- | How far the allocations or instructions of all the benchmarks of a
-- stage may move, as a fraction.
optTotalTolerance :: Double,
-- | Where to save what was measured.
optSave :: Maybe FilePath,
-- | What an earlier run saved, to compare this one with.
optBaseline :: Maybe FilePath
}
-- | A benchmark, by stage and module.
type Key = (Text, Text)
main :: IO ()
main = do
hSetBuffering stdout LineBuffering
options <- execParser optionsInfo
accepting <- (== Just "1") <$> lookupEnv "TILIA_BENCH_ACCEPT"
examples <- obtain hackagePackages >>= either (die . T.unpack) pure
sampled <- corpusSubjects examples >>= either (die . T.unpack) pure
let matching name = maybe True (`T.isInfixOf` name) (optMatch options)
subjects =
[s | s <- sampled <> syntheticSubjects, matching (subjectName s)]
partial = isJust (optMatch options)
counter <- openCounter
processor <- (<* counter) <$> processorName
written <- readRecord recordPath
baseline <- traverse readBaseline (optBaseline options)
let measuring stage s work input = do
(result, m) <-
measure counter (optRuns options) work (maybe 0 T.length) input
let k = (stageName stage, subjectName s)
shown = comparable processor (recordInterfaces written) written
T.putStrLn (lineFor (Just shown) baseline k m)
pure (result, (k, m))
formatted <- fmap concat . for subjects $ \s -> do
(out, formatting) <-
measuring Format s (subjectFormat s) (subjectSource s)
checking <- case (subjectCheck s, out) of
(Just check, Just printed) ->
pure . snd <$> measuring Check s check printed
_ -> pure []
pure (formatting : checking)
ended <- withFixityBenchmarks $ \(resolving, interfaces) -> do
let record = comparable processor interfaces written
decoding = recordInterfaces written == interfaces
checked (stage, _) = decoding || stage /= stageName Decode
others <- for [b | b <- resolving, matching (benchmarkName b)] $ \b -> do
(_, m) <- measureIO counter (optRuns options) (benchmarkRun b) id
let k = (stageName (benchmarkStage b), benchmarkName b)
shown = if checked k then Just record else Nothing
T.putStrLn (lineFor shown baseline k m)
pure (k, m)
let measured = formatted <> others
for_ (optSave options) (`writeBaseline` measured)
totalled record measured
for_ baseline (summarize measured)
if accepting
then do
let entryOf k m =
steadied (Map.lookup k (recordEntries record)) $
Entry
(measuredAllocated m)
(measuredInstructions m <* processor)
writeRecord
recordPath
Record
{ recordCompiler = compiler,
recordProcessor = fromMaybe "none" processor,
recordInterfaces = interfaces,
recordEntries =
Map.union
(Map.fromList [(k, entryOf k m) | (k, m) <- measured])
(if partial then recordEntries record else Map.empty)
}
putStrLn ("Wrote " <> recordPath <> ".")
else do
for_ (unchecked processor record) $ \reason ->
T.putStrLn ("\nInstructions are not checked: " <> reason <> ".")
unless decoding $
putStrLn
"\nDecoding is not checked: the record decoded other interfaces."
let problems =
verdict
options
record
partial
[x | x@(k, _) <- measured, checked k]
unless (null problems) $ do
putStrLn ""
for_ problems T.putStrLn
putStrLn ""
putStrLn "If that is the intended change, regenerate the record:"
putStrLn " TILIA_BENCH_ACCEPT=1 cabal bench"
exitFailure
either (die . T.unpack) pure ended
-- | Where the record is kept, relative to the package.
recordPath :: FilePath
recordPath = "bench/bench.record"
-- | The compiler running the benchmarks.
compiler :: Text
compiler = T.pack ("ghc " <> showVersion fullCompilerVersion)
-- | The processor running the benchmarks, as Linux names it.
processorName :: IO (Maybe Text)
processorName =
try (T.readFile "/proc/cpuinfo") >>= \case
Left (_ :: SomeException) -> pure Nothing
Right cpus ->
pure $
listToMaybe
[ T.strip (T.drop 1 said)
| (key, said) <- T.breakOn ":" <$> T.lines cpus,
T.strip key == "model name"
]
-- | Forget what a record says that does not hold here: the instructions it
-- counted on another processor than this one, and what decoding other
-- interfaces than these cost.
comparable :: Maybe Text -> Text -> Record -> Record
comparable processor interfaces record =
record{recordEntries = Map.mapMaybeWithKey kept (recordEntries record)}
where
kept (stage, _) e
| stage == stageName Decode,
recordInterfaces record /= interfaces =
Nothing
| processor == Just (recordProcessor record) = Just e
| otherwise = Just e{entryInstructions = Nothing}
-- | Why the instructions are not checked against the record, where they
-- are not.
unchecked :: Maybe Text -> Record -> Maybe Text
unchecked processor record
| Nothing <- processor = Just "they cannot be counted here"
| recordProcessor record == "none" = Just "the record has none"
| processor /= Just (recordProcessor record) =
Just ("the record counted them on " <> recordProcessor record)
| otherwise = Nothing
-- | What the benchmarks can be asked to do.
optionsInfo :: ParserInfo Options
optionsInfo =
info (helper <*> optionsParser) . mconcat $
[ fullDesc,
progDesc "Measure what Tilia costs and check it against the record"
]
-- | The options of the benchmarks.
optionsParser :: Parser Options
optionsParser =
Options
<$> (option positive . mconcat)
[ long "runs",
metavar "N",
value 1,
showDefault,
help "How many runs to measure after the one that warms up"
]
<*> (optional . strOption . mconcat)
[ long "match",
metavar "TEXT",
help "Run only the benchmarks whose names hold TEXT"
]
<*> (option percent . mconcat)
[ long "tolerance",
metavar "PERCENT",
value 0.005,
help "How far one benchmark may move from the record (default: 0.5)"
]
<*> (option percent . mconcat)
[ long "total-tolerance",
metavar "PERCENT",
value 0.0005,
help "How far the benchmarks of a stage may move (default: 0.05)"
]
<*> (optional . strOption . mconcat)
[ long "save",
metavar "FILE",
help "Save the time and instructions of every benchmark to FILE"
]
<*> (optional . strOption . mconcat)
[ long "baseline",
metavar "FILE",
help "Compare with what an earlier run saved to FILE"
]
where
positive = maybeReader (mfilter (> 0) . readMaybe)
percent = maybeReader (fmap (/ 100) . mfilter (>= 0) . readMaybe)
-- | One benchmark's line of the report: its time, against the baseline
-- where there is one, and what it allocated and the instructions it
-- retired, against the record where it is checked against one.
lineFor :: Maybe Record -> Maybe Baseline -> Key -> Measurement -> Text
lineFor record baseline k@(stage, name) m =
T.pack $
printf
"%-7s %8.3f s %-9s %10.1f MB %-11s %s %-9s %s%s"
stage
(seconds (measuredTime m))
(maybe "" ((`against` measuredTime m) . fst) (Map.lookup k =<< baseline))
(megabytes (measuredAllocated m))
(maybe "(unchecked)" (maybe "(new)" (changeOf allocations)) entry)
(instructionsColumn (measuredInstructions m))
(maybe "" (changeOf instructions) (join entry))
name
differing
where
entry = Map.lookup k . recordEntries <$> record
changeOf cost e = maybe "" (uncurry against) (summed cost [(e, m)])
differing :: String
differing
| measuredSpread m > 0.001 =
printf " (runs differ by %.2f%%)" (100 * measuredSpread m)
| otherwise = ""
-- | A cost the record keeps for every benchmark.
data Cost = Cost
{ -- | The verb that says how it moved.
costVerb :: Text,
-- | The noun that follows how far it moved.
costNoun :: Text,
-- | What the record says it is.
costRecorded :: Entry -> Maybe Word64,
-- | What was measured.
costMeasured :: Measurement -> Maybe Word64
}
-- | The bytes allocated.
allocations :: Cost
allocations =
Cost "allocates" "" (Just . entryAllocated) (Just . measuredAllocated)
-- | The instructions retired, where they were counted.
instructions :: Cost
instructions =
Cost "retires" " instructions" entryInstructions measuredInstructions
-- | What the record says and what was measured of a cost, summed over the
-- benchmarks that have it on both sides.
summed :: Cost -> [(Entry, Measurement)] -> Maybe (Word64, Word64)
summed cost pairs =
case [ (old, new)
| (e, m) <- pairs,
Just old <- [costRecorded cost e],
Just new <- [costMeasured cost m]
] of
[] -> Nothing
both -> Just (sum (fmap fst both), sum (fmap snd both))
-- | What is wrong with the record, given what was measured: one line for
-- every benchmark it is out of date for, and for every stage whose
-- benchmarks together cost more or less than it says.
verdict :: Options -> Record -> Bool -> [(Key, Measurement)] -> [Text]
verdict options record partial measured
| recordCompiler record /= compiler =
[ "The record was made with "
<> (if T.null made then "no compiler" else made)
<> " and these benchmarks were built with "
<> compiler
<> ", which allocates differently."
]
| otherwise =
concatMap judged measured
<> [ "In the record but not measured: " <> stage <> " " <> name
| not partial,
k@(stage, name) <- Map.keys (recordEntries record),
k `notElem` fmap fst measured
]
<> [ moving cost ("all of " <> stage) old new
| stage <- stages measured,
cost <- [allocations, instructions],
Just (old, new) <- [summed cost (known record stage measured)],
moved (optTotalTolerance options) old new
]
where
made = recordCompiler record
judged (k@(stage, name), m) = case Map.lookup k (recordEntries record) of
Nothing -> ["Not in the record: " <> stage <> " " <> name]
Just e ->
[ moving cost (stage <> " " <> name) old new
| cost <- [allocations, instructions],
Just (old, new) <- [summed cost [(e, m)]],
moved (optTolerance options) old new
]
moving :: Cost -> Text -> Word64 -> Word64 -> Text
moving cost subject old new =
T.pack $
printf
"%s %s %.2f%% %s%s than the record says"
subject
(costVerb cost)
(abs (change (fromIntegral old) (fromIntegral new)))
(if new > old then "more" else "less" :: String)
(costNoun cost)
-- | Has a cost moved further than a fraction of what it was?
moved :: Double -> Word64 -> Word64 -> Bool
moved tolerance old new =
abs (fromIntegral new - fromIntegral old)
> tolerance * (fromIntegral old :: Double)
-- | What to record for a benchmark: each cost as the record has it where it
-- moved no further than two runs of one build differ, so that writing the
-- record again changes only what moved.
steadied :: Maybe Entry -> Entry -> Entry
steadied recorded new = case recorded of
Nothing -> new
Just old ->
Entry
{ entryAllocated = held (entryAllocated old) (entryAllocated new),
entryInstructions =
(held <$> entryInstructions old <*> entryInstructions new)
<|> entryInstructions new
}
where
held o n = if moved jitter o n then n else o
-- | How far a cost moves between two runs of one build, as a fraction.
jitter :: Double
jitter = 0.0001
-- | Say what the benchmarks of each stage took, allocated and retired
-- together, and how that compares with the record.
totalled :: Record -> [(Key, Measurement)] -> IO ()
totalled record measured = do
putStrLn ""
for_ (stages measured) $ \stage -> do
let ms = [m | ((s, _), m) <- measured, s == stage]
sumOf f = sum (fmap f ms)
changeOf cost =
maybe "" (uncurry against) (summed cost (known record stage measured))
printf
"%-7s %8.3f s %9s %10.1f MB %-11s %s %-9s %d benchmarks\n"
stage
(seconds (sumOf measuredTime))
("" :: String)
(megabytes (sumOf measuredAllocated))
(changeOf allocations)
(instructionsColumn (sum <$> traverse measuredInstructions ms))
(changeOf instructions)
(length ms)
-- | The stages measured, in the order they were.
stages :: [(Key, Measurement)] -> [Text]
stages = nubOrd . fmap (fst . fst)
-- | What the record and the measurements say about each benchmark of a
-- stage that both have.
known :: Record -> Text -> [(Key, Measurement)] -> [(Entry, Measurement)]
known record stage measured =
[ (e, m)
| (k@(s, _), m) <- measured,
s == stage,
Just e <- [Map.lookup k (recordEntries record)]
]
-- | What an earlier run measured: the time of every benchmark and the
-- instructions it retired, where they were counted.
type Baseline = Map Key (Int64, Maybe Word64)
-- | Save what was measured.
writeBaseline :: FilePath -> [(Key, Measurement)] -> IO ()
writeBaseline path measured =
T.writeFile path . T.unlines $
[ T.unwords
[ stage,
T.pack (show (measuredTime m)),
maybe "-" (T.pack . show) (measuredInstructions m),
name
]
| ((stage, name), m) <- measured
]
-- | Read what 'writeBaseline' saved.
readBaseline :: FilePath -> IO Baseline
readBaseline path = do
text <- T.readFile path
pure $
Map.fromList
[ ((stage, T.unwords rest), (t, readMaybe (T.unpack retired)))
| stage : time : retired : rest <- fmap T.words (T.lines text),
Just t <- [readMaybe (T.unpack time)]
]
-- | Say how the benchmarks measured on both sides moved against the
-- baseline, all together and where they moved most: by the instructions
-- they retired where both sides counted them, by time otherwise.
summarize :: [(Key, Measurement)] -> Baseline -> IO ()
summarize measured baseline =
unless (null both) $ do
putStrLn ""
printf "Against the baseline, over %d benchmarks:\n" (length both)
printf
" time %.3f s -> %.3f s %s\n"
(seconds oldTime)
(seconds newTime)
(oldTime `against` newTime)
for_ counted $ \retired ->
let old = sum (fmap fst retired)
new = sum (fmap snd retired)
in printf
" instructions %.3f G -> %.3f G %s\n"
(billions old)
(billions new)
(old `against` new)
for_ (take 5 (sortOn (Down . abs . snd) movers)) $ \((stage, name), c) ->
printf " (%+.2f%%) %-7s %s\n" c stage name
where
both =
[ (k, ((t, i), (measuredTime m, measuredInstructions m)))
| (k, m) <- measured,
Just (t, i) <- [Map.lookup k baseline]
]
oldTime = sum [t | (_, ((t, _), _)) <- both]
newTime = sum [t | (_, (_, (t, _))) <- both]
counted =
traverse (\(_, ((_, old), (_, new))) -> (,) <$> old <*> new) both
movers = case counted of
Just retired ->
zip (fmap fst both) [change' old new | (old, new) <- retired]
Nothing ->
[(k, change' old new) | (k, ((old, _), (new, _))) <- both]
change' :: (Integral a) => a -> a -> Double
change' old new = change (fromIntegral old) (fromIntegral new)
-- | How much a quantity moved from the first value to the second, as the
-- report writes it.
against :: (Integral a) => a -> a -> String
against old new =
printf "(%+.2f%%)" (change (fromIntegral old) (fromIntegral new))
-- | How much a quantity moved, in percent.
change :: Double -> Double -> Double
change old new
| old == 0 = 0
| otherwise = 100 * (new - old) / old
-- | Instructions, in billions, or as much space where they were not
-- counted.
instructionsColumn :: Maybe Word64 -> String
instructionsColumn =
maybe (replicate 15 ' ') (printf "%7.3f G instr" . billions)
-- | Nanoseconds, in seconds.
seconds :: Int64 -> Double
seconds ns = fromIntegral ns / 1e9
-- | Bytes, in megabytes.
megabytes :: Word64 -> Double
megabytes b = fromIntegral b / 1e6
-- | A count, in billions.
billions :: Word64 -> Double
billions n = fromIntegral n / 1e9