packages feed

tilia-0.1.0.0: bench/Tilia/Bench/Measure.hs

{-# OPTIONS_GHC -fno-full-laziness #-}

-- | Measuring what a piece of work costs.
module Tilia.Bench.Measure
  ( Counter,
    openCounter,
    Measurement (..),
    measure,
    measureIO,
  )
where

import Control.Concurrent (yield)
import Control.Exception (evaluate)
import Control.Monad (replicateM)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word64)
import Foreign.C.Types (CInt (..))
import GHC.Stats (RTSStats (..), getRTSStats)
import System.Mem (performMajorGC)

-- | A counter of the instructions the thread that opened it retires in
-- user space.
newtype Counter = Counter CInt

foreign import ccall unsafe "tilia_bench_counter_open"
  counterOpen :: IO CInt

foreign import ccall unsafe "tilia_bench_counter_read"
  counterRead :: CInt -> IO Word64

-- | Open a counter, where the system lets a process count its own
-- instructions.
openCounter :: IO (Maybe Counter)
openCounter = do
  fd <- counterOpen
  pure (if fd < 0 then Nothing else Just (Counter fd))

-- | What a piece of work cost.
data Measurement = Measurement
  { -- | The bytes it allocated.
    measuredAllocated :: !Word64,
    -- | The instructions it retired, where they were counted.
    measuredInstructions :: !(Maybe Word64),
    -- | The CPU time it took, in nanoseconds, the least of the runs.
    measuredTime :: !Int64,
    -- | How far apart the runs' allocations were, as a fraction.
    measuredSpread :: !Double
  }

-- | Run a piece of work once to warm up and then the given number of times,
-- each time on a fresh copy of its input so that nothing is shared between
-- runs, and give what the run that warmed up computed.
measure ::
  -- | The counter of instructions, if there is one.
  Maybe Counter ->
  -- | How many runs to measure.
  Int ->
  -- | The work.
  (Text -> a) ->
  -- | Force everything the work computed.
  (a -> Int) ->
  -- | Its input.
  Text ->
  IO (a, Measurement)
measure counter runs work forced input =
  measureIO counter runs set forced
  where
    set = do
      fresh <- evaluate (T.copy input)
      pure (evaluate (work fresh))

-- | Run a piece of work once to warm up and then the given number of times,
-- each time as a setup that is not measured gives it, and give what the run
-- that warmed up computed.
measureIO ::
  -- | The counter of instructions, if there is one.
  Maybe Counter ->
  -- | How many runs to measure.
  Int ->
  -- | Set a run up, giving its work.
  IO (IO a) ->
  -- | Force everything the work computed.
  (a -> Int) ->
  IO (a, Measurement)
measureIO counter runs set forced = do
  (warm, _) <- once
  samples <- fmap snd <$> replicateM (max 1 runs) once
  let allocations = [a | (a, _, _) <- samples]
      (allocated, instructions, _) = last samples
  pure
    ( warm,
      Measurement
        { measuredAllocated = allocated,
          measuredInstructions = instructions,
          measuredTime = minimum [t | (_, _, t) <- samples],
          measuredSpread =
            fromIntegral (maximum allocations - minimum allocations)
              / fromIntegral (max 1 (minimum allocations))
        }
    )
  where
    count = traverse (\(Counter fd) -> counterRead fd) counter
    once = do
      work <- set
      performMajorGC
      yield
      performMajorGC
      before <- getRTSStats
      started <- count
      result <- work
      _ <- evaluate (forced result)
      ended <- count
      performMajorGC
      after <- getRTSStats
      pure
        ( result,
          ( allocated_bytes after - allocated_bytes before,
            (-) <$> ended <*> started,
            cpu_ns after - cpu_ns before
          )
        )