tasty-bench-fit-0.1: src/Test/Tasty/Bench/Fit.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE NumDecimals #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Guess complexity of the function.
module Test.Tasty.Bench.Fit (
-- * Fit benchmarks
fit,
fits,
mkFitConfig,
FitConfig (..),
-- * Complexity
Complexity (..),
Measurement (..),
guessComplexity,
evalComplexity,
-- * Predicates
isConstant,
isLogarithmic,
isLinear,
isLinearithmic,
isQuadratic,
isCubic,
) where
import Control.DeepSeq (NFData)
import Data.List (maximumBy)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Map (Map)
import qualified Data.Map as M
import Data.Ord (comparing)
import System.IO.Unsafe (unsafeInterleaveIO)
import Test.Tasty (Timeout, mkTimeout)
import Test.Tasty.Bench (Benchmarkable, RelStDev (..), measureCpuTimeAndStDev, nf)
import Test.Tasty.Bench.Fit.Complexity (
Complexity (..),
Measurement (..),
evalComplexity,
guessComplexity,
isConstant,
isCubic,
isLinear,
isLinearithmic,
isLogarithmic,
isQuadratic,
)
#ifdef DEBUG
import Debug.Trace
#endif
-- | Configuration for 'fit'.
data FitConfig = FitConfig
{ fitBench :: Word -> Benchmarkable
-- ^ Which function to measure? Typically 'nf' @f@.
, fitLow :: Word
-- ^ The smallest size of the input.
-- It should be as small as possible, but big enough for the main asymptotic
-- term to dwarf constant overhead and other terms.
, fitHigh :: Word
-- ^ The largest size of the input.
-- As large as practically possible, at least 100x larger than
-- the smallest size.
, fitTimeout :: Timeout
-- ^ Timeout of individual measurements.
, fitRelStDev :: RelStDev
-- ^ Target relative standard deviation of individual measurements.
, fitOracle :: Map Word Measurement -> Complexity
-- ^ An oracle to determine complexity from measurements.
-- Typically 'guessComplexity'.
}
-- | Generate a default 'fit' configuration.
mkFitConfig
:: (NFData a)
=> (Word -> a)
-- ^ Raw function to measure, without 'nf'.
-> (Word, Word)
-- ^ The smallest and the largest sizes of the input.
-> FitConfig
mkFitConfig f (low, high) =
FitConfig
{ fitBench = nf f
, fitLow = low
, fitHigh = high
, fitTimeout = mkTimeout 1e8
, fitRelStDev = RelStDev 0.02
, fitOracle = guessComplexity
}
-- | Determine time complexity of the function:
--
-- >>> fit $ mkFitConfig (\x -> sum [1..x]) (10, 10000)
-- 1.2153e-8 * x
-- >>> fit $ mkFitConfig (\x -> Data.List.nub [1..x]) (10, 10000)
-- 2.8369e-9 * x ^ 2
-- >>> fit $ mkFitConfig (\x -> Data.List.sort $ take (fromIntegral x) $ iterate (\n -> n * 6364136223846793005 + 1) (1 :: Int)) (10, 100000)
-- 5.2990e-8 * x * log x
--
-- One can usually get reliable results for functions, which do not
-- allocate much: like in-place vector sort or fused list operations like
-- 'sum' @[1..x]@.
--
-- Unfortunately, fitting functions, which allocate a lot,
-- is likely to be disappointing: GC kicks in irregularly depending on nursery
-- and heap sizes and often skews observations beyond any recognition.
-- Consider running such measurements with @-O0@ or in @ghci@ prompt. This is how
-- the usage example above was generated. Without optimizations your program
-- allocates much more and triggers GC regularly, somewhat evening out its effect.
fit :: FitConfig -> IO Complexity
fit cnf = converge <$> fits cnf
converge :: NonEmpty Complexity -> Complexity
converge xs = case zs of
[] -> NE.last xs
(_, _, z) : _ -> z
where
ys = NE.toList xs
zs =
dropWhile (\(x, y, z) -> p x z || p y z) $
zip3 ys (tail ys) (drop 2 ys)
p
Complexity {cmplVarPower = varPow, cmplLogPower = logPow, cmplMultiplier = mult}
Complexity {cmplVarPower = varPow', cmplLogPower = logPow', cmplMultiplier = mult'} =
abs (varPow - varPow') > 0.001
|| logPow /= logPow'
|| abs ((mult - mult') / mult) > 0.01
-- | Same as 'fit', but interactively emits a list of complexities,
-- gradually converging to the final result.
--
-- If 'fit' takes too long, you might wish to implement your own criterion
-- of convergence atop of 'fits' directly.
fits :: FitConfig -> IO (NonEmpty Complexity)
fits FitConfig {..} = unsafeInterleaveIO $ do
lowTime <- measure fitLow
highTime <- measure fitHigh
let mp = M.fromList [(fitLow, lowTime), (fitHigh, highTime)]
cmpl = fitOracle mp
cmpl `seq` (cmpl :|) <$> go mp
where
measure :: Word -> IO Measurement
measure =
fmap (uncurry Measurement)
. measureCpuTimeAndStDev fitTimeout fitRelStDev
. fitBench
processGap
:: forall t
. (Ord t)
=> [(Word, t)]
-> Map Word Measurement
-> IO (Map Word Measurement)
processGap gaps mp
| M.null gaps' = pure mp
| otherwise = (\m -> M.insert maxGap m mp) <$> measure maxGap
where
gaps' = M.fromList gaps `M.difference` mp
maxGap = fst $ maximumBy (comparing snd) $ M.toList gaps'
go :: Map Word Measurement -> IO [Complexity]
go mp = unsafeInterleaveIO $ do
let xys = M.toAscList $ fmap measTime mp
paired = zip xys (drop 1 xys)
arithGaps :: [(Word, Double)]
arithGaps =
map
(\((x, tx), (y, ty)) -> (round ((d x + d y) / 2), ty - tx))
paired
geomGaps :: [(Word, Double)]
geomGaps =
map
(\((x, tx), (y, ty)) -> (round (sqrt (d x * d y)), ty / tx))
paired
mp' <- processGap arithGaps mp
mp'' <- processGap geomGaps mp'
traceShowM' (M.keys mp'')
let cmpl = fitOracle mp''
traceShowM' cmpl
(cmpl :) <$> (if mp == mp'' then pure [] else go mp'')
d :: Word -> Double
d = fromIntegral
traceShowM' :: (Applicative m, Show a) => a -> m ()
#ifdef DEBUG
traceShowM' = traceShowM
#else
traceShowM' = const (pure ())
#endif