From 97cc2dc03afa1ae4519c2e710ae2a9928df99c91 Mon Sep 17 00:00:00 2001 From: Bodigrim Date: Tue, 19 Jan 2021 00:23:42 +0000 Subject: [PATCH] Initial commit --- .github/workflows/haskell-ci.yml | 180 +++++++++++++ LICENSE | 21 ++ README.md | 139 ++++++++++ Test/Tasty/Bench.hs | 447 +++++++++++++++++++++++++++++++ changelog.md | 3 + tasty-bench.cabal | 42 +++ 6 files changed, 832 insertions(+) create mode 100644 .github/workflows/haskell-ci.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Test/Tasty/Bench.hs create mode 100644 changelog.md create mode 100644 tasty-bench.cabal diff --git a/.github/workflows/haskell-ci.yml b/.github/workflows/haskell-ci.yml new file mode 100644 index 0000000..9110c1b --- /dev/null +++ b/.github/workflows/haskell-ci.yml @@ -0,0 +1,180 @@ +# This GitHub workflow config has been generated by a script via +# +# haskell-ci 'github' 'tasty-bench.cabal' +# +# To regenerate the script (for example after adjusting tested-with) run +# +# haskell-ci regenerate +# +# For more information, see https://github.com/haskell-CI/haskell-ci +# +# version: 0.11.20210111 +# +# REGENDATA ("0.11.20210111",["github","tasty-bench.cabal"]) +# +name: Haskell-CI +on: + - push + - pull_request +jobs: + linux: + name: Haskell-CI Linux - GHC ${{ matrix.ghc }} + runs-on: ubuntu-18.04 + container: + image: buildpack-deps:bionic + continue-on-error: ${{ matrix.allow-failure }} + strategy: + matrix: + include: + - ghc: 8.10.3 + allow-failure: false + - ghc: 8.8.4 + allow-failure: false + - ghc: 8.6.5 + allow-failure: false + - ghc: 8.4.4 + allow-failure: false + - ghc: 8.2.2 + allow-failure: false + - ghc: 8.0.2 + allow-failure: false + - ghc: 7.10.3 + allow-failure: false + - ghc: 7.8.4 + allow-failure: false + - ghc: 7.6.3 + allow-failure: false + - ghc: 7.4.2 + allow-failure: false + - ghc: 7.2.2 + allow-failure: false + - ghc: 7.0.4 + allow-failure: false + fail-fast: false + steps: + - name: apt + run: | + apt-get update + apt-get install -y --no-install-recommends gnupg ca-certificates dirmngr curl git software-properties-common + apt-add-repository -y 'ppa:hvr/ghc' + apt-get update + apt-get install -y ghc-$GHC_VERSION cabal-install-3.2 + env: + GHC_VERSION: ${{ matrix.ghc }} + - name: Set PATH and environment variables + run: | + echo "$HOME/.cabal/bin" >> $GITHUB_PATH + echo "LANG=C.UTF-8" >> $GITHUB_ENV + echo "CABAL_DIR=$HOME/.cabal" >> $GITHUB_ENV + echo "CABAL_CONFIG=$HOME/.cabal/config" >> $GITHUB_ENV + HC=/opt/ghc/$GHC_VERSION/bin/ghc + echo "HC=$HC" >> $GITHUB_ENV + echo "HCPKG=/opt/ghc/$GHC_VERSION/bin/ghc-pkg" >> $GITHUB_ENV + echo "HADDOCK=/opt/ghc/$GHC_VERSION/bin/haddock" >> $GITHUB_ENV + echo "CABAL=/opt/cabal/3.2/bin/cabal -vnormal+nowrap" >> $GITHUB_ENV + HCNUMVER=$(${HC} --numeric-version|perl -ne '/^(\d+)\.(\d+)\.(\d+)(\.(\d+))?$/; print(10000 * $1 + 100 * $2 + ($3 == 0 ? $5 != 1 : $3))') + echo "HCNUMVER=$HCNUMVER" >> $GITHUB_ENV + echo "ARG_TESTS=--enable-tests" >> $GITHUB_ENV + echo "ARG_BENCH=--enable-benchmarks" >> $GITHUB_ENV + echo "ARG_COMPILER=--ghc --with-compiler=/opt/ghc/$GHC_VERSION/bin/ghc" >> $GITHUB_ENV + echo "GHCJSARITH=0" >> $GITHUB_ENV + env: + GHC_VERSION: ${{ matrix.ghc }} + - name: env + run: | + env + - name: write cabal config + run: | + mkdir -p $CABAL_DIR + cat >> $CABAL_CONFIG < cabal-plan.xz + echo 'de73600b1836d3f55e32d80385acc055fd97f60eaa0ab68a755302685f5d81bc cabal-plan.xz' | sha256sum -c - + xz -d < cabal-plan.xz > $HOME/.cabal/bin/cabal-plan + rm -f cabal-plan.xz + chmod a+x $HOME/.cabal/bin/cabal-plan + cabal-plan --version + - name: checkout + uses: actions/checkout@v2 + with: + path: source + - name: sdist + run: | + mkdir -p sdist + cd source || false + $CABAL sdist all --output-dir $GITHUB_WORKSPACE/sdist + - name: unpack + run: | + mkdir -p unpacked + find sdist -maxdepth 1 -type f -name '*.tar.gz' -exec tar -C $GITHUB_WORKSPACE/unpacked -xzvf {} \; + - name: generate cabal.project + run: | + PKGDIR_tasty_bench="$(find "$GITHUB_WORKSPACE/unpacked" -maxdepth 1 -type d -regex '.*/tasty-bench-[0-9.]*')" + echo "PKGDIR_tasty_bench=${PKGDIR_tasty_bench}" >> $GITHUB_ENV + touch cabal.project + touch cabal.project.local + echo "packages: ${PKGDIR_tasty_bench}" >> cabal.project + if [ $((HCNUMVER >= 80200)) -ne 0 ] ; then echo "package tasty-bench" >> cabal.project ; fi + if [ $((HCNUMVER >= 80200)) -ne 0 ] ; then echo " ghc-options: -Werror=missing-methods" >> cabal.project ; fi + cat >> cabal.project <> cabal.project.local + cat cabal.project + cat cabal.project.local + - name: dump install plan + run: | + $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dry-run all + cabal-plan + - name: cache + uses: actions/cache@v2 + with: + key: ${{ runner.os }}-${{ matrix.ghc }}-${{ github.sha }} + path: ~/.cabal/store + restore-keys: ${{ runner.os }}-${{ matrix.ghc }}- + - name: install dependencies + run: | + $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks --dependencies-only -j2 all + $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dependencies-only -j2 all + - name: build w/o tests + run: | + $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all + - name: build + run: | + $CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH all --write-ghc-environment-files=always + - name: cabal check + run: | + cd ${PKGDIR_tasty_bench} || false + ${CABAL} -vnormal check + - name: haddock + run: | + $CABAL v2-haddock $ARG_COMPILER --with-haddock $HADDOCK $ARG_TESTS $ARG_BENCH all + - name: unconstrained build + run: | + rm -f cabal.project.local + $CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..49c99af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Andrew Lelechenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63f28df --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# tasty-bench + +Featherlight benchmark framework (only one file!) for performance measurement with API mimicking [`criterion`](http://hackage.haskell.org/package/criterion) and [`gauge`](http://hackage.haskell.org/package/gauge). + +## How lightweight is it? + +There is only one source file `Test.Tasty.Bench`, less than 450 lines, and no external dependencies except [`tasty`](http://hackage.haskell.org/package/tasty). So if you already depend on `tasty` for a test suite, there +is nothing else to install. + +Compare this to `criterion` (10+ modules, 50+ dependencies) and `gauge` (40+ modules, depends on `basement` and `vector`). + +## How is it possible? + +Our benchmarks are literally regular `tasty` tests, so we can leverage all existing +machinery for command-line options, resource management, structuring, +listing and filtering benchmarks, running and reporting results. It also means +that `tasty-bench` can be used in conjunction with other `tasty` ingredients. + +Unlike `criterion` and `gauge` we use a very simple statistical model described below. +This is arguably a questionable choice, but it works pretty well in practice. +A rare developer is sufficiently well-versed in probability theory +to make sense and use of all numbers generated by `criterion`. + +## How to switch? + +[Cabal mixins](https://cabal.readthedocs.io/en/3.4/cabal-package.html#pkg-field-mixins) +allow to taste `tasty-bench` instead of `criterion` or `gauge` +without changing a single line of code: + +```cabal +cabal-version: 2.0 + +benchmark foo + ... + build-depends: + tasty-bench + mixins: + tasty-bench (Test.Tasty.Bench as Criterion) +``` + +This works vice versa as well: if you use `tasty-bench`, but at some point +need a more comprehensive statistical analysis, +it is easy to switch temporarily back to `criterion`. + +## How to write a benchmark? + +```haskell +import Test.Tasty.Bench + +fibo :: Int -> Integer +fibo n = if n < 2 then toInteger n else fibo (n - 1) + fibo (n - 2) + +main :: IO () +main = defaultMain + [ bgroup "fibonacci numbers" + [ bench "fifth" $ nf fibo 5 + , bench "tenth" $ nf fibo 10 + , bench "twentieth" $ nf fibo 20 + ] + ] +``` + +Since `tasty-bench` provides an API compatible with `criterion`, +one can refer to [its documentation](http://www.serpentine.com/criterion/tutorial.html#how-to-write-a-benchmark-suite) for more examples. + +## How to read results? + +Running the example above results in the following output: + +``` +All + fibonacci numbers + fifth: OK (2.13s) + 63 ns ± 3.4 ns + tenth: OK (1.71s) + 809 ns ± 73 ns + twentieth: OK (3.39s) + 104 μs ± 4.9 μs + +All 3 tests passed (7.25s) +``` + +The output says that, for instance, the first benchmark +was repeatedly executed for 2.13 seconds (wall time), +its mean time was 63 nanoseconds and with 95% probability +execution time should not diverge from the mean +further than ±3.4 nanoseconds (double standard deviation). + +## Statistical model + +Here is a procedure, used by `tasty-bench` to measure execution time: + +1. Set _n_ ← 1. +2. Measure execution time _tₙ_ of _n_ iterations + and execution time _t₂ₙ_ of _2n_ iterations. +3. Find _t_ which minimizes deviation of (_nt_, _2nt_) from (_tₙ_, _t₂ₙ_). +4. If deviation is small enough, return _t_ as a mean execution time. +5. Otherwise set _n_ ← _2n_ and jump back to Step 2. + +This is roughly similar to the linear regression approach which `criterion` takes, +but we fit only two last points. This allows us to simplify away all heavy-weight +statistical analysis. More importantly, earlier measurements, +which are presumably shorter and noisier, do not affect overall result. +This is in contrast to `criterion`, which fits all measurements and +is biased to use more data points corresponding to shorter runs +(it employs _n_ ← _1.05n_ progression). + +## Command-line options + +Use `--help` to list command-line options. + +* `-p`, `--pattern` + + This is a standard `tasty` option, which allows filtering benchmarks + by a pattern or `awk` expression. Please refer to + [`tasty` documentation](https://github.com/feuerbach/tasty#patterns) + for details. + +* `--plain` + + Produce machine-readable output: + `(mean in picoseconds, standard deviation in picoseconds)`. + This is handy for consumption by other `tasty` ingredients. + +* `-t`, `--timeout` + + This is a standard `tasty` option, setting timeout for individual benchmarks + in seconds. Use it when benchmarks tend to take too long: `tasty-bench` will make + an effort to report results (even if of subpar quality) before timeout. Setting + timeout too tight (insufficient for at least three iterations of benchmark) + will result in a benchmark failure. Do not use `--timeout` without a reason: + it forks an additional thread and thus affects reliability of measurements. + +* `--stdev` + + Target relative standard deviation of measurements in percents (5% by default). + Large values correspond to fast and loose benchmarks, and small ones to long and precise. + If it takes far too long, consider setting `--timeout`, + which will interrupt benchmarks, potentially before reaching the target deviation. diff --git a/Test/Tasty/Bench.hs b/Test/Tasty/Bench.hs new file mode 100644 index 0000000..5664b0c --- /dev/null +++ b/Test/Tasty/Bench.hs @@ -0,0 +1,447 @@ +{- | +Module: Test.Tasty.Bench +Copyright: (c) 2021 Andrew Lelechenko +Licence: MIT + +Featherlight benchmark framework (only one file!) for performance measurement with API mimicking [@criterion@](http://hackage.haskell.org/package/criterion) and [@gauge@](http://hackage.haskell.org/package/gauge). + +=== How lightweight is it? + +There is only one source file "Test.Tasty.Bench", less than 450 lines, and no external dependencies except [@tasty@](http://hackage.haskell.org/package/tasty). So if you already depend on @tasty@ for a test suite, there +is nothing else to install. + +Compare this to @criterion@ (10+ modules, 50+ dependencies) and @gauge@ (40+ modules, depends on @basement@ and @vector@). + +=== How is it possible? + +Our benchmarks are literally regular @tasty@ tests, so we can leverage all existing +machinery for command-line options, resource management, structuring, +listing and filtering benchmarks, running and reporting results. It also means +that @tasty-bench@ can be used in conjunction with other @tasty@ ingredients. + +Unlike @criterion@ and @gauge@ we use a very simple statistical model described below. +This is arguably a questionable choice, but it works pretty well in practice. +A rare developer is sufficiently well-versed in probability theory +to make sense and use of all numbers generated by @criterion@. + +=== How to switch? + +[Cabal mixins](https://cabal.readthedocs.io/en/3.4/cabal-package.html#pkg-field-mixins) +allow to taste @tasty-bench@ instead of @criterion@ or @gauge@ +without changing a single line of code: + +@ +cabal-version: 2.0 + +benchmark foo + ... + build-depends: + tasty-bench + mixins: + tasty-bench (Test.Tasty.Bench as Criterion) +@ + +This works vice versa as well: if you use @tasty-bench@, but at some point +need a more comprehensive statistical analysis, +it is easy to switch temporarily back to @criterion@. + +=== How to write a benchmark? + +@ +import Test.Tasty.Bench + +fibo :: Int -> Integer +fibo n = if n < 2 then toInteger n else fibo (n - 1) + fibo (n - 2) + +main :: IO () +main = defaultMain + [ bgroup "fibonacci numbers" + [ bench "fifth" $ nf fibo 5 + , bench "tenth" $ nf fibo 10 + , bench "twentieth" $ nf fibo 20 + ] + ] +@ + +Since @tasty-bench@ provides an API compatible with @criterion@, +one can refer to [its documentation](http://www.serpentine.com/criterion/tutorial.html#how-to-write-a-benchmark-suite) for more examples. + +=== How to read results? + +Running the example above results in the following output: + +@ +All + fibonacci numbers + fifth: OK (2.13s) + 63 ns ± 3.4 ns + tenth: OK (1.71s) + 809 ns ± 73 ns + twentieth: OK (3.39s) + 104 μs ± 4.9 μs + +All 3 tests passed (7.25s) +@ + +The output says that, for instance, the first benchmark +was repeatedly executed for 2.13 seconds (wall time), +its mean time was 63 nanoseconds and with 95% probability +execution time should not diverge from the mean +further than ±3.4 nanoseconds (double standard deviation). + +=== Statistical model + +Here is a procedure, used by @tasty-bench@ to measure execution time: + +1. Set \( n \leftarrow 1 \). +2. Measure execution time \( t_n \) of \( n \) iterations + and execution time \( t_{2n} \) of \( 2n \) iterations. +3. Find \( t \) which minimizes deviation of \( (nt, 2nt) \) from \( (t_n, t_{2n}) \). +4. If deviation is small enough, return \( t \) as a mean execution time. +5. Otherwise set \( n \leftarrow 2n \) and jump back to Step 2. + +This is roughly similar to the linear regression approach which @criterion@ takes, +but we fit only two last points. This allows us to simplify away all heavy-weight +statistical analysis. More importantly, earlier measurements, +which are presumably shorter and noisier, do not affect overall result. +This is in contrast to @criterion@, which fits all measurements and +is biased to use more data points corresponding to shorter runs +(it employs \( n \leftarrow 1.05n \) progression). + +=== Command-line options + +Use @--help@ to list command-line options. + +[@-p@, @--pattern@]: + This is a standard @tasty@ option, which allows filtering benchmarks + by a pattern or @awk@ expression. Please refer + to [@tasty@ documentation](https://github.com/feuerbach/tasty#patterns) + for details. + +[@--plain@]: + Produce machine-readable output: + @(mean in picoseconds, standard deviation in picoseconds)@. + This is handy for consumption by other @tasty@ ingredients. + +[@-t@, @--timeout@]: + This is a standard @tasty@ option, setting timeout for individual benchmarks + in seconds. Use it when benchmarks tend to take too long: @tasty-bench@ will make + an effort to report results (even if of subpar quality) before timeout. Setting + timeout too tight (insufficient for at least three iterations of benchmark) + will result in a benchmark failure. Do not use @--timeout@ without a reason: + it forks an additional thread and thus affects reliability of measurements. + +[@--stdev@]: + Target relative standard deviation of measurements in percents (5% by default). + Large values correspond to fast and loose benchmarks, and small ones to long and precise. + If it takes far too long, consider setting @--timeout@, + which will interrupt benchmarks, potentially before reaching the target deviation. + +-} + +module Test.Tasty.Bench + ( + -- * Running 'Benchmark' + defaultMain + , Benchmark + , bench + , bgroup + -- * Creating 'Benchmarkable' + , Benchmarkable + , nf + , whnf + , nfIO + , whnfIO + , nfAppIO + , whnfAppIO + ) where + +import Control.Applicative +import Control.DeepSeq +import Control.Exception +import Data.Data (Typeable) +import Data.Int +import Data.Monoid +import Data.Proxy +import System.CPUTime +import System.Mem +import Test.Tasty hiding (defaultMain) +import qualified Test.Tasty +import Test.Tasty.Options +import Test.Tasty.Providers +import Text.Printf +import Test.Tasty.Runners + +newtype PlainFormat = PlainFormat { unPlainFormat :: Bool } + deriving (Eq, Ord, Show, Typeable) + +instance IsOption PlainFormat where + defaultValue = PlainFormat False + parseValue = fmap PlainFormat . safeReadBool + optionName = pure "plain" + optionHelp = pure "Produce machine-readable output: (mean in picoseconds, standard deviation in picoseconds). This is handy for consumption by other tasty ingredients." + optionCLParser = mkFlagCLParser mempty (PlainFormat True) + +newtype RelStDev = RelStDev { unRelStDev :: Double } + deriving (Eq, Ord, Show, Typeable) + +instance IsOption RelStDev where + defaultValue = RelStDev 5 + parseValue = fmap RelStDev . safeRead + optionName = pure "stdev" + optionHelp = pure "Target relative standard deviation of measurements in percents (5 by default). Large values correspond to fast and loose benchmarks, and small ones to long and precise. If it takes far too long, consider setting --timeout, which will interrupt benchmarks, potentially before reaching the target deviation." + +-- | Something that can be benchmarked. +-- +-- Drop-in replacement for 'Criterion.Benchmarkable' and 'Gauge.Benchmarkable'. +-- +newtype Benchmarkable = Benchmarkable { _unBenchmarkable :: Int64 -> IO () } + deriving (Typeable) + +showPicos :: Integer -> String +showPicos i + | a == 0 = "0" + | a < 995 = printf "%3.0f ps" t + | a < 995e1 = printf "%3.1f ns" (t / 1e3) + | a < 995e3 = printf "%3.0f ns" (t / 1e3) + | a < 995e4 = printf "%3.1f μs" (t / 1e6) + | a < 995e6 = printf "%3.0f μs" (t / 1e6) + | a < 995e7 = printf "%3.1f ms" (t / 1e9) + | a < 995e9 = printf "%3.0f ms" (t / 1e9) + | otherwise = printf "%.1f s" (t / 1e12) + where + t, a :: Double + t = fromInteger i + a = abs t + +data Measurement = Measurement + { measMean :: !Integer -- ^ time in picoseconds + , measSigma :: !Double -- ^ stdev in picoseconds + } deriving (Eq, Ord) + +instance Show Measurement where + show (Measurement mean sigma) = + -- Two sigmas correspond to 95% probability, + showPicos mean ++ " ± " ++ showPicos (truncate (2 * sigma)) + +predict + :: Integer -- ^ time for one run + -> Integer -- ^ time for two runs + -> Measurement +predict t1 t2 = Measurement a (sqrt (fromInteger d)) + where + sqr x = x * x + d = sqr (t1 - a) + sqr (t2 - 2 * a) + a = (t1 + 2 * t2) `quot` 5 + +predictPerturbed :: Integer -> Integer -> Measurement +predictPerturbed t1 t2 = Measurement + { measMean = measMean (predict t1 t2) + , measSigma = max + (measSigma (predict (t1 - prec) (t2 + prec))) + (measSigma (predict (t1 + prec) (t2 - prec))) + } + where + prec = max cpuTimePrecision 1000000000 -- 1 ms + +measureTime :: Int64 -> Benchmarkable -> IO Integer +measureTime n (Benchmarkable act) = do + performGC + startTime <- getCPUTime + act n + endTime <- getCPUTime + pure $ endTime - startTime + +measureTimeUntil :: Maybe Integer -> Double -> Benchmarkable -> IO Measurement +measureTimeUntil timeout targetRelStDev b = do + t1 <- measureTime 1 b + go 1 t1 0 + where + go :: Int64 -> Integer -> Integer -> IO Measurement + go n t1 sumOfTs = do + t2 <- measureTime (2 * n) b + + let Measurement meanN sigmaN = predictPerturbed t1 t2 + isTimeoutSoon = case timeout of + Nothing -> False + -- multiplying by 1.2 helps to avoid accidental timeouts + Just t -> (sumOfTs + t1 + t2 + (2 * t2)) * 12 >= t * 10 + mean = meanN `quot` toInteger n + sigma = sigmaN / fromIntegral n + isStDevInTargetRange = sigma / fromInteger mean < targetRelStDev + + if mean > 0 && (isStDevInTargetRange || isTimeoutSoon) + then pure $ Measurement mean sigma + else go (2 * n) t2 (sumOfTs + t1) + +instance IsTest Benchmarkable where + testOptions = pure [Option (Proxy :: Proxy RelStDev), Option (Proxy :: Proxy PlainFormat)] + run opts b = const $ case getNumThreads (lookupOption opts) of + 1 -> do + let targetRelStDev = unRelStDev (lookupOption opts) / 100 + timeout = case lookupOption opts of + NoTimeout -> Nothing + Timeout micros _ -> Just $ micros * 1000000 + + meas <- measureTimeUntil timeout targetRelStDev b + let msg = if unPlainFormat (lookupOption opts) + then show (measMean meas, measSigma meas) + else show meas + pure $ testPassed msg + _ -> pure $ testFailed "Benchmarks should be run in a single-threaded mode (--jobs 1)" + +-- | Attach a name to 'Benchmarkable'. +-- +-- This is actually a synonym of 'Test.Tasty.Providers.singleTest' +-- to provide an interface compatible with 'Criterion.bench' and 'Gauge.bench'. +-- +bench :: String -> Benchmarkable -> Benchmark +bench = singleTest + +-- | Attach a name to a group of 'Benchmark'. +-- +-- This is actually a synonym of 'Test.Tasty.testGroup' +-- to provide an interface compatible with 'Criterion.bgroup' +-- and 'Gauge.bgroup'. +-- +bgroup :: String -> [Benchmark] -> Benchmark +bgroup = testGroup + +-- | Benchmarks are actually just a regular 'Test.Tasty.TestTree' in disguise. +-- +-- This is a drop-in replacement for 'Criterion.Benchmark' and 'Gauge.Benchmark'. +-- +type Benchmark = TestTree + +-- | Run benchmarks and report results. +-- +-- Wrapper around 'Test.Tasty.defaultMain' +-- to provide an interface compatible with 'Criterion.defaultMain' +-- and 'Gauge.defaultMain'. +-- +defaultMain :: [Benchmark] -> IO () +defaultMain = Test.Tasty.defaultMain . testGroup "All" + +funcToBench :: (b -> c) -> (a -> b) -> a -> Benchmarkable +funcToBench frc = (Benchmarkable .) . go + where + go f x n + | n <= 0 = pure () + | otherwise = do + _ <- evaluate (frc (f x)) + go f x (n - 1) +{-# INLINE funcToBench #-} + +-- | 'nf' @f@ @x@ measures time to compute +-- a normal form (by means of 'rnf') of @f@ @x@. +-- +-- Note that forcing a normal form requires an additional +-- traverse of the structure. In certain scenarios (imagine benchmarking 'tail'), +-- especially when 'NFData' instance is badly written, +-- this traversal may take non-negligible time and affect results. +-- +-- Drop-in replacement for 'Criterion.nf' and 'Gauge.nf'. +-- +nf :: NFData b => (a -> b) -> a -> Benchmarkable +nf = funcToBench rnf +{-# INLINE nf #-} + +-- | 'whnf' @f@ @x@ measures time to compute +-- a weak head normal form of @f@ @x@. +-- +-- Computing only a weak head normal form is +-- rarely what intuitively is meant by "evaluation". +-- Unless you understand precisely, what is measured, +-- it is recommended to use 'nf' instead. +-- +-- Drop-in replacement for 'Criterion.whnf' and 'Gauge.whnf'. +-- +whnf :: (a -> b) -> a -> Benchmarkable +whnf = funcToBench id +{-# INLINE whnf #-} + +ioToBench :: (b -> c) -> IO b -> Benchmarkable +ioToBench frc act = Benchmarkable go + where + go n + | n <= 0 = pure () + | otherwise = do + val <- act + _ <- evaluate (frc val) + go (n - 1) +{-# INLINE ioToBench #-} + +-- | 'nfIO' @x@ measures time to evaluate side-effects of @x@ +-- and compute its normal form (by means of 'rnf'). +-- +-- Pure subexpression of an effectful computation @x@ +-- may be evaluated only once and get cached; use 'nfAppIO' +-- to avoid this. +-- +-- Note that forcing a normal form requires an additional +-- traverse of the structure. In certain scenarios, +-- especially when 'NFData' instance is badly written, +-- this traversal may take non-negligible time and affect results. +-- +-- Drop-in replacement for 'Criterion.nfIO' and 'Gauge.nfIO'. +-- +nfIO :: NFData a => IO a -> Benchmarkable +nfIO = ioToBench rnf +{-# INLINE nfIO #-} + +-- | 'whnfIO' @x@ measures time to evaluate side-effects of @x@ +-- and compute its weak head normal form. +-- +-- Pure subexpression of an effectful computation @x@ +-- may be evaluated only once and get cached; use 'whnfAppIO' +-- to avoid this. +-- +-- Computing only a weak head normal form is +-- rarely what intuitively is meant by "evaluation". +-- Unless you understand precisely, what is measured, +-- it is recommended to use 'nfIO' instead. +-- +-- Drop-in replacement for 'Criterion.whnfIO' and 'Gauge.whnfIO'. +-- +whnfIO :: NFData a => IO a -> Benchmarkable +whnfIO = ioToBench id +{-# INLINE whnfIO #-} + +ioFuncToBench :: (b -> c) -> (a -> IO b) -> a -> Benchmarkable +ioFuncToBench frc = (Benchmarkable .) . go + where + go f x n + | n <= 0 = pure () + | otherwise = do + val <- f x + _ <- evaluate (frc val) + go f x (n - 1) +{-# INLINE ioFuncToBench #-} + +-- | 'nfAppIO' @f@ @x@ measures time to evaluate side-effects of @f@ @x@ +-- and compute its normal form (by means of 'rnf'). +-- +-- Note that forcing a normal form requires an additional +-- traverse of the structure. In certain scenarios, +-- especially when 'NFData' instance is badly written, +-- this traversal may take non-negligible time and affect results. +-- +-- Drop-in replacement for 'Criterion.nfAppIO' and 'Gauge.nfAppIO'. +-- +nfAppIO :: NFData b => (a -> IO b) -> a -> Benchmarkable +nfAppIO = ioFuncToBench rnf +{-# INLINE nfAppIO #-} + +-- | 'whnfAppIO' @f@ @x@ measures time to evaluate side-effects of @f@ @x@ +-- and compute its weak head normal form. +-- +-- Computing only a weak head normal form is +-- rarely what intuitively is meant by "evaluation". +-- Unless you understand precisely, what is measured, +-- it is recommended to use 'nfAppIO' instead. +-- +-- Drop-in replacement for 'Criterion.whnfAppIO' and 'Gauge.whnfAppIO'. +-- +whnfAppIO :: (a -> IO b) -> a -> Benchmarkable +whnfAppIO = ioFuncToBench id +{-# INLINE whnfAppIO #-} diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..4c0c978 --- /dev/null +++ b/changelog.md @@ -0,0 +1,3 @@ +# 0.1.0.0 + +* Initial release. diff --git a/tasty-bench.cabal b/tasty-bench.cabal new file mode 100644 index 0000000..9c80fab --- /dev/null +++ b/tasty-bench.cabal @@ -0,0 +1,42 @@ +name: tasty-bench +version: 0.1 +cabal-version: >=1.10 +build-type: Simple +license: MIT +license-file: LICENSE +copyright: 2021 Andrew Lelechenko +maintainer: Andrew Lelechenko +homepage: https://github.com/Bodigrim/tasty-bench +bug-reports: https://github.com/Bodigrim/tasty-bench/issues +category: Development, Performance, Testing, Benchmarking +synopsis: Featherlight benchmark framework +description: + Featherlight framework (only one file!) + for performance measurement with API mimicking + @criterion@ and @gauge@. Our benchmarks are just + regular @tasty@ tests. + +extra-source-files: + changelog.md + README.md + +tested-with: GHC==8.10.3, GHC==8.8.4, GHC==8.6.5, GHC==8.4.4, GHC==8.2.2, GHC==8.0.2, GHC==7.10.3, GHC==7.8.4, GHC==7.6.3, GHC==7.4.2, GHC==7.2.2, GHC==7.0.4 + +source-repository head + type: git + location: https://github.com/Bodigrim/tasty-bench + +library + exposed-modules: Test.Tasty.Bench + hs-source-dirs: . + default-language: Haskell2010 + default-extensions: DeriveDataTypeable + ghc-options: -Wall -fno-warn-unused-imports + + build-depends: + base >= 4.3 && < 5, + deepseq >= 1.1, + tasty >= 1.0.1 + if impl(ghc < 7.8) + build-depends: + tagged >= 0.2