packages feed

free-foil-0.4.0: bench/restriction/Main.hs

{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE DeriveTraversable   #-}
{-# LANGUAGE GADTs               #-}
{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE PatternSynonyms     #-}
{-# LANGUAGE RankNTypes          #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Restriction benchmark: cutting a chain of binders down to the ones a term
-- actually uses, the two ways it can be done.
--
-- This is the operation a parametrised module performs when it discharges a
-- declaration over the parameters it uses, and it is where the library's
-- account of /restriction/ is put under pressure. The question is the same
-- either way — which of these binders can the term do without? — and there are
-- two answers:
--
-- * ask once. 'supportOf' computes the term's free names in one traversal, and
--   'Foil.withThinnedNameBinderList' cuts the chain down to that set in one
--   step, so the term is walked once whatever the number of binders;
--
-- * ask per binder. 'unsinkAST' answers for one binder at a time, and each call
--   recomputes the term's support, so the term is walked once per binder.
--
-- The second is the shorter thing to write, and it is what a language
-- implementation reaches for first. The benchmark is here to say what it costs:
-- the two should separate linearly in the number of binders, and the point at
-- which they do is worth knowing, since a module with three parameters is the
-- common case and one with fourteen is not unheard of.
--
-- Both directions report the same number, and the benchmark checks that they
-- agree before timing them, so a change that breaks one is not silently
-- measured against the other.
module Main (main) where

import           Data.Bifoldable         (Bifoldable (..))
import           Data.List               (foldl')
import           Data.List.NonEmpty      (NonEmpty (..), nonEmpty)
import           Test.Tasty.Bench

import qualified Control.Monad.Foil      as Foil
import           Control.Monad.Free.Foil (AST (..), ScopedAST (..), pattern Var,
                                          supportOf, unsinkAST)

-- * A signature to build terms over

-- | Application and λ, which is all a term needs to have free variables and
-- binders in it.
data LamSig scope term
  = App term term
  | Lam scope
  deriving (Functor, Foldable, Traversable)

instance Bifoldable LamSig where
  bifoldMap f g = \case
    App l r -> g l <> g r
    Lam body -> f body

-- | Terms whose binders are single names, which is what a parameter block is.
type Term = AST Foil.NameBinder LamSig

-- * The workload

-- | A chain of @n@ binders over the empty scope.
--
-- The continuation is handed the innermost scope, the chain, and the scope
-- before each binder, innermost first.
withChain
  :: forall r. Int
  -> (forall l. Foil.Distinct l
        => Foil.NameBinderList Foil.VoidS l -> r)
  -> r
withChain total cont = go total Foil.emptyScope Foil.NameBinderListEmpty
  where
    go :: forall i. Foil.Distinct i
       => Int -> Foil.Scope i -> Foil.NameBinderList Foil.VoidS i -> r
    go 0 _scope chain = cont chain
    go k scope chain =
      Foil.withFresh scope $ \binder ->
        go (k - 1) (Foil.extendScope binder scope) (Foil.snocNameBinderList chain binder)

-- | A term over the given names, of a size the caller controls.
--
-- The names are used in a left-nested application spine, repeated until the
-- term has @uses@ leaves. Which names appear decides how far the chain can be
-- thinned; how many leaves there are decides what a traversal of the term
-- costs, and the two are what the benchmark varies.
spine :: Int -> NonEmpty (Foil.Name l) -> Term l
spine uses (first :| rest) = foldl' apply (Var first) (map Var more)
  where
    apply f x = Node (App f x)
    more = take (max 0 (uses - 1)) (cycle (first : rest))

-- * The two directions

-- | Ask once: one 'supportOf', then one thinning.
thinOnce :: Foil.Distinct l => Foil.NameBinderList Foil.VoidS l -> Term l -> Int
thinOnce chain term =
  Foil.withThinnedNameBinderList (supportOf term) chain $ \thinned ->
    length (Foil.namesOfPattern thinned)

-- | Ask per binder: peel the chain from the inside out, asking 'unsinkAST' at
-- each binder whether the term can do without it, and abstracting over it when
-- it cannot.
--
-- This is the shape the alternative really has, and the reason it costs what it
-- costs: the term is /rebuilt/ as the peeling goes, so each 'unsinkAST' faces a
-- different (and larger) term and has to compute its support afresh. Asking the
-- same question about one fixed term would let the compiler share that
-- computation, and then the two directions would be indistinguishable — which
-- is what a first version of this benchmark measured, and why it is written out
-- like this instead.
askPerBinder
  :: forall n l. Foil.Distinct n
  => Foil.Scope n -> Foil.NameBinderList n l -> Term l -> Int
askPerBinder scope binders term = fst (peel scope binders term)
  where
    peel :: forall m i. Foil.Distinct m
         => Foil.Scope m -> Foil.NameBinderList m i -> Term i -> (Int, Term m)
    peel _scope' Foil.NameBinderListEmpty inner = (0, inner)
    peel scope' (Foil.NameBinderListCons binder rest) inner =
      case (Foil.assertDistinct binder, Foil.assertExt binder) of
        (Foil.Distinct, Foil.Ext) ->
          let (kept, body) = peel (Foil.extendScope binder scope') rest inner
           in case unsinkAST scope' body of
                Just dropped -> (kept, dropped)
                Nothing      -> (kept + 1, Node (Lam (ScopedAST binder body)))

-- | Both directions, at one size, with the answers checked against each other.
--
-- @binders@ is how many the chain has, @keep@ how many of them the term names,
-- and @uses@ how many leaves the term has.
sizedBench :: Int -> Int -> Int -> Benchmark
sizedBench binders keep uses =
  withChain binders $ \chain ->
    case nonEmpty (take keep (Foil.namesOfPattern chain)) of
      Nothing -> error "a benchmark size must use at least one binder"
      Just names ->
        let term = spine uses names
            once = thinOnce chain term
            perBinder = askPerBinder Foil.emptyScope chain term
         in if once /= perBinder
              then error ("the two directions disagree: " <> show (once, perBinder))
              else bgroup (show binders <> " binders, " <> show keep <> " used, "
                            <> show uses <> " leaves")
                     [ bench "thin once"      (whnf (thinOnce chain) term)
                     , bench "ask per binder"
                         (whnf (askPerBinder Foil.emptyScope chain) term)
                     ]

main :: IO ()
main = defaultMain
  -- The term is held at one size while the chain grows, so what separates the
  -- two directions is the number of binders and nothing else.
  [ bgroup "a fixed term, a growing parameter block"
      [ sizedBench binders 2 64 | binders <- [1, 3, 7, 14, 32] ]
  -- And the other way round: a fixed block, a growing term.
  , bgroup "a fixed parameter block, a growing term"
      [ sizedBench 8 2 uses | uses <- [16, 64, 256, 1024] ]
  ]