packages feed

hanalyze-0.1.0.0: bench/haskell/BenchUtil.hs

{-# LANGUAGE OverloadedStrings #-}
-- | Shared helpers used by the Haskell side of the benchmarks.
-- Writes a uniform per-row CSV layout consumed by the Python aggregator.
module BenchUtil
  ( BenchRow (..)
  , writeRows
  , timeit
  , timeitIO
  , timeitTasty
  , timeitTastyIO
  , readCsvXY
  , readCsvXYG
  ) where

import           Data.IORef                 (newIORef, readIORef, writeIORef)

import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.ByteString            as BS
import qualified Data.Vector                as V
import qualified Numeric.LinearAlgebra      as LA
import           Data.Csv                   (decode, HasHeader (..))
import           Data.Time.Clock            (getCurrentTime, diffUTCTime)
import           Text.Printf                (printf)

import qualified Test.Tasty.Bench           as TB
import           Test.Tasty                 (Timeout (NoTimeout))
import           System.IO                  (withFile, IOMode (..), hPutStrLn,
                                             hSetBuffering, BufferMode (..))
import           Control.Exception          (evaluate)

-- | One benchmark observation. The aggregator joins (system, suite, name)
-- across @bench/results/haskell/*.csv@ and @bench/results/python/*.csv@.
data BenchRow = BenchRow
  { brSystem  :: String   -- ^ "haskell" or "python".
  , brSuite   :: String   -- ^ "regression", "kernel", "optim", "mo", "bo", ...
  , brName    :: String   -- ^ Stable benchmark name (e.g. "LM_n1000_p5").
  , brTimeMs  :: Double   -- ^ Median wall time per call in milliseconds.
  , brAccMain :: Double   -- ^ Primary accuracy metric (R², HV, |x*-x_true| etc.).
  , brAccAux  :: Double   -- ^ Secondary metric (RMSE, IGD, runs to optimum, ...).
  , brExtra   :: String   -- ^ Free-form note (e.g. "kernel=Gaussian h=0.5").
  } deriving Show

-- | Write a list of rows to a CSV file (with header).
writeRows :: FilePath -> [BenchRow] -> IO ()
writeRows path rows = withFile path WriteMode $ \h -> do
  hSetBuffering h LineBuffering
  hPutStrLn h "system,suite,name,time_ms,acc_main,acc_aux,extra"
  mapM_ (\r -> hPutStrLn h
          (printf "%s,%s,%s,%.6g,%.6g,%.6g,%s"
            (brSystem r) (brSuite r) (brName r)
            (brTimeMs r) (brAccMain r) (brAccAux r)
            (escapeCsv (brExtra r)))) rows

escapeCsv :: String -> String
escapeCsv s
  | any (`elem` (",\"\n" :: String)) s =
      '"' : concatMap (\c -> if c == '"' then "\"\"" else [c]) s ++ "\""
  | otherwise = s

-- | Run a fresh recomputation @n@ times, return the median wall-time in
-- milliseconds plus the value from the last invocation. The caller passes
-- a per-iteration builder @runIt :: Int -> IO a@ (the index defeats GHC's
-- common-subexpression elimination so the work is actually re-run each
-- time) and a probe @force :: a -> Double@ that pulls a scalar out of the
-- result, forcing the underlying Matrix / Vector computation via
-- 'evaluate'.
timeitIO :: Int -> (a -> Double) -> (Int -> IO a) -> IO (Double, a)
timeitIO n force runIt = do
  -- IORef は per-iteration の runtime 依存を作る (GHC が CSE しないように)。
  ref <- newIORef (0 :: Int)
  ts <- mapM (\i -> do
                writeIORef ref i
                _  <- readIORef  ref
                t0 <- getCurrentTime
                x  <- runIt i
                _  <- evaluate (force x)
                t1 <- getCurrentTime
                return (1000.0 * realToFrac (diffUTCTime t1 t0))) [1 .. n]
  x <- runIt 0
  _ <- evaluate (force x)
  let sorted = quickSort ts
      med    = sorted !! (length sorted `div` 2)
  return (med, x)

-- | Convenience wrapper: the action does not actually depend on the
-- iteration index. Provided for backwards compatibility — please use
-- 'timeitIO' for new code.
timeit :: Int -> (a -> Double) -> IO a -> IO (Double, a)
timeit n force action = timeitIO n force (\_ -> action)

-- | tasty-bench based timer (Phase 13).
--
-- Adaptive iteration count converges to a stable mean. Returns
-- (mean wall-time in ms, last result). The relative standard
-- deviation cap is 5% (much tighter than the default 10%).
--
-- Use this for new code; 'timeit' / 'timeitIO' kept for backwards
-- compatibility while the migration is in progress.
timeitTastyIO :: (a -> Double) -> (Int -> IO a) -> IO (Double, a)
timeitTastyIO force runIt = do
  -- Build a Benchmarkable that depends on a counter so GHC cannot
  -- common-subexpression-eliminate across iterations.
  ref <- newIORef (0 :: Int)
  let bm = TB.nfIO $ do
             i <- readIORef ref
             writeIORef ref (i + 1)
             x <- runIt i
             _ <- evaluate (force x)
             pure ()
  -- 0.05 = 5% relative stdev target. NoTimeout = run as long as
  -- needed for convergence (typical < 1 s for ms-range benchmarks).
  secs <- TB.measureCpuTime NoTimeout 0.05 bm
  -- Probe value to return alongside the timing.
  x <- runIt 0
  _ <- evaluate (force x)
  return (1000.0 * secs, x)

-- | Like 'timeitTastyIO' but the action does not depend on the
-- iteration index.
timeitTasty :: (a -> Double) -> IO a -> IO (Double, a)
timeitTasty force action = timeitTastyIO force (\_ -> action)

quickSort :: Ord a => [a] -> [a]
quickSort []     = []
quickSort (p:xs) = quickSort [y | y <- xs, y < p]
                ++ [p]
                ++ quickSort [y | y <- xs, y >= p]
  where
    quickSort []     = []
    quickSort (p:xs) = quickSort [y | y <- xs, y < p]
                    ++ [p]
                    ++ quickSort [y | y <- xs, y >= p]

-- ---------------------------------------------------------------------------
-- CSV input (small, header-fronted, all-numeric)
-- ---------------------------------------------------------------------------

-- | Read a CSV with header @x0,x1,...,x{p-1},y@ into @(X, y)@.
readCsvXY :: FilePath -> IO (LA.Matrix Double, LA.Vector Double)
readCsvXY path = do
  bytes <- BL.fromStrict <$> BS.readFile path
  case decode HasHeader bytes :: Either String (V.Vector (V.Vector Double)) of
    Left err -> error ("readCsvXY: " ++ path ++ ": " ++ err)
    Right rs ->
      let n = V.length rs
          p = V.length (rs V.! 0) - 1
          xs = LA.fromLists
                 [ [ rs V.! i V.! j | j <- [0 .. p - 1] ]
                 | i <- [0 .. n - 1] ]
          ys = LA.fromList [ rs V.! i V.! p | i <- [0 .. n - 1] ]
      in return (xs, ys)

-- | Read a CSV with header @x0,...,x{p-1},group,y@ into @(X, group_idx, y)@.
readCsvXYG :: FilePath -> IO (LA.Matrix Double, V.Vector Int, LA.Vector Double)
readCsvXYG path = do
  bytes <- BL.fromStrict <$> BS.readFile path
  case decode HasHeader bytes :: Either String (V.Vector (V.Vector Double)) of
    Left err -> error ("readCsvXYG: " ++ path ++ ": " ++ err)
    Right rs ->
      let n = V.length rs
          p = V.length (rs V.! 0) - 2
          xs = LA.fromLists
                 [ [ rs V.! i V.! j | j <- [0 .. p - 1] ]
                 | i <- [0 .. n - 1] ]
          gs = V.fromList [ round (rs V.! i V.! p)        :: Int | i <- [0 .. n - 1] ]
          ys = LA.fromList [ rs V.! i V.! (p + 1)                  | i <- [0 .. n - 1] ]
      in return (xs, gs, ys)