packages feed

aihc-cpp-2.0.0.0: bench/Bench/Corpus.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Deterministic corpus generation for the aihc-cpp benchmarks.
--
-- The corpus is generated rather than committed so that it can be resized
-- without churning the repository, and it is fully deterministic so that two
-- runs on two machines benchmark byte-identical inputs.
--
-- The cases are chosen to isolate the different costs a preprocessor pays, so
-- that a regression can be attributed to one of them rather than showing up as
-- a single number that moved.
--
-- This corpus is artificial, and it is worth being explicit about where it
-- departs from reality. Measured over 1,631 CPP-using modules from 211 Hackage
-- packages: the median module is 6.2KB with 3.5% directive lines; conditionals
-- dominate the directive mix (@#if@ and @#endif@ together outnumber @#define@
-- by more than ten to one); @__GLASGOW_HASKELL__@, @MIN_VERSION_base@ and
-- @mingw32_HOST_OS@ account for most macro references, nearly all of them
-- inside @#if@ conditions rather than expanded into the output; and a module
-- that includes anything usually includes one or two headers.
--
-- So 'passthroughCase' and 'conditionalsCase' are close to real code, while
-- 'macrosCase' (a function-like macro expanded on every line) and
-- 'includesCase' (24 included files) are far heavier than anything real.
-- They are useful for isolating a cost, and misleading if read as a workload.
-- Point the benchmark at real source instead — see the AIHC_CPP_BENCH_CORPUS
-- setting in @bench\/Micro.hs@ — before drawing conclusions about throughput.
module Bench.Corpus
  ( CorpusCase (..),
    corpusCases,
    generateCorpus,
    defaultCorpusRoot,
  )
where

import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))

-- | Where the generated corpus goes by default.
--
-- Deliberately outside @src@, @test@, @app@ and @bench@, the directories the
-- formatter and linter walk. A generated corpus is megabytes of machine-written
-- Haskell full of redundant brackets, and hlint follows @#include@ directives,
-- so letting a linter reach it produces six-figure hint counts and exhausts
-- memory. Putting it under @dist-newstyle@ makes that structurally impossible
-- rather than depending on an exclusion pattern staying correct, and means
-- @cabal clean@ disposes of it.
defaultCorpusRoot :: FilePath
defaultCorpusRoot = "dist-newstyle" </> "bench-corpus"

-- | One benchmark input: a top-level file plus any files it includes.
data CorpusCase = CorpusCase
  { -- | Short identifier, also the basename of the generated file.
    caseName :: !String,
    -- | Human-readable note about what this case stresses.
    caseDescription :: !String,
    -- | Path of the entry file, relative to the corpus root.
    caseEntry :: !FilePath,
    -- | All files to write, relative to the corpus root.
    caseFiles :: [(FilePath, String)]
  }

-- | Rough target size, in lines, for each generated case.
--
-- Large enough that a single run takes milliseconds rather than microseconds,
-- so the measurement is not dominated by timer resolution, and small enough
-- that a full sweep of three preprocessors stays interactive.
caseLines :: Int
caseLines = 4000

-- | The benchmark corpus.
--
-- The mix is deliberately weighted towards 'passthrough', because that is what
-- real CPP-using Haskell looks like: a few directives at the top and thousands
-- of lines the preprocessor merely has to copy. The remaining cases isolate
-- individual costs so a regression can be attributed.
corpusCases :: [CorpusCase]
corpusCases =
  [ passthroughCase,
    conditionalsCase,
    macrosCase,
    literalsCase,
    includesCase
  ]

-- | Write the corpus under the given root directory.
generateCorpus :: FilePath -> IO ()
generateCorpus root = do
  createDirectoryIfMissing True root
  createDirectoryIfMissing True (root </> "includes")
  mapM_ writeCase corpusCases
  where
    writeCase c = mapM_ writeOne (caseFiles c)
    writeOne (path, contents) = writeFile (root </> path) contents

-- ---------------------------------------------------------------------------
-- Deterministic pseudo-randomness
-- ---------------------------------------------------------------------------

-- | A tiny linear congruential generator (the Numerical Recipes constants).
--
-- Avoids a dependency on @random@ and, more importantly, pins the corpus to an
-- exact byte sequence that does not drift between library versions.
lcg :: Int -> Int
lcg s = (s * 1664525 + 1013904223) `mod` 2147483648

-- | An infinite deterministic stream of values drawn from a list.
pick :: Int -> [a] -> [a]
pick seed xs = go seed
  where
    n = length xs
    go s = let s' = lcg s in (xs !! (s' `mod` n)) : go s'

-- ---------------------------------------------------------------------------
-- Cases
-- ---------------------------------------------------------------------------

-- | Ordinary Haskell with a realistic sprinkling of directives.
--
-- Roughly 5% directive lines, with the rest simply copied through. This is the
-- case that best predicts the cost a preprocessor adds to a real compile, and
-- the one to weight most heavily when reading the results.
passthroughCase :: CorpusCase
passthroughCase =
  CorpusCase
    { caseName = "passthrough",
      caseDescription = "realistic module: ~5% directives, the rest copied through",
      caseEntry = "passthrough.hs",
      caseFiles = [("passthrough.hs", body)]
    }
  where
    body = unlines (header <> concat (take (caseLines `div` 20) chunks))
    header =
      [ "{-# LANGUAGE CPP #-}",
        "module Passthrough where",
        "#define VERSION_base 1",
        "#define HAS_FEATURE(x) (x)"
      ]
    chunks = zipWith chunk [0 :: Int ..] (pick 1 [0 .. 9 :: Int])
    chunk i r =
      [ "",
        "-- | Documentation for value " <> show i <> ".",
        "value" <> show i <> " :: Int -> Int",
        "value" <> show i <> " x = x + " <> show (r * i `mod` 97),
        "",
        "helper" <> show i <> " :: [Int] -> Int",
        "helper" <> show i <> " xs = sum (map value" <> show i <> " xs)",
        ""
      ]
        <> ( if i `mod` 5 == 0
               then
                 [ "#ifdef VERSION_base",
                   "guarded" <> show i <> " :: Int",
                   "guarded" <> show i <> " = " <> show i,
                   "#else",
                   "guarded" <> show i <> " :: Int",
                   "guarded" <> show i <> " = 0",
                   "#endif"
                 ]
               else
                 [ "plain" <> show i <> " :: Int",
                   "plain" <> show i <> " = " <> show i
                 ]
           )
        <> replicate 8 ("-- filler comment line for value " <> show i)

-- | Dense, deeply nested conditionals with arithmetic and @defined@.
conditionalsCase :: CorpusCase
conditionalsCase =
  CorpusCase
    { caseName = "conditionals",
      caseDescription = "nested #if/#elif/#else with arithmetic and defined()",
      caseEntry = "conditionals.hs",
      caseFiles = [("conditionals.hs", body)]
    }
  where
    body = unlines (header <> concatMap block [0 .. caseLines `div` 16])
    header =
      [ "{-# LANGUAGE CPP #-}",
        "module Conditionals where",
        "#define MAJOR 9",
        "#define MINOR 12",
        "#define GLASGOW_HASKELL 912",
        "#define WORD_SIZE_IN_BITS 64"
      ]
    block i =
      [ "#if MAJOR > 8 && MINOR >= 4",
        "#  if defined(GLASGOW_HASKELL) && GLASGOW_HASKELL >= 900",
        "#    if WORD_SIZE_IN_BITS == 64",
        "cond" <> show i <> " :: Int",
        "cond" <> show i <> " = " <> show i,
        "#    else",
        "cond" <> show i <> " :: Int",
        "cond" <> show i <> " = 0",
        "#    endif",
        "#  elif defined(MISSING)",
        "cond" <> show i <> " = -1",
        "#  else",
        "cond" <> show i <> " = -2",
        "#  endif",
        "#else",
        "cond" <> show i <> " = -3",
        "#endif"
      ]

-- | Heavy object- and function-like macro expansion.
macrosCase :: CorpusCase
macrosCase =
  CorpusCase
    { caseName = "macros",
      caseDescription = "object- and function-like macro expansion on every line",
      caseEntry = "macros.hs",
      caseFiles = [("macros.hs", body)]
    }
  where
    body = unlines (header <> concatMap block [0 .. caseLines `div` 6])
    header =
      [ "{-# LANGUAGE CPP #-}",
        "module Macros where",
        "#define MIN_VERSION_base(a,b,c) 1",
        "#define WRAP(x) (fromIntegral (x))",
        "#define PAIR(a,b) ((a), (b))",
        "#define NAME base",
        "#define WIDE(a,b,c,d) ((a) + (b) + (c) + (d))"
      ]
    block i =
      [ "macro" <> show i <> " :: Int",
        "macro" <> show i <> " = WRAP(" <> show i <> ")",
        "pair" <> show i <> " = PAIR(" <> show i <> ", " <> show (i + 1) <> ")",
        "wide" <> show i <> " = WIDE(" <> show i <> ", 2, 3, 4)",
        "#if MIN_VERSION_base(4,16,0)",
        "gated" <> show i <> " = WRAP(" <> show i <> ")",
        "#endif"
      ]

-- | String, character and comment heavy input.
--
-- Haskell-aware preprocessors track Haskell block comments and string literals
-- so they can avoid expanding macros inside them. That scanning is real work,
-- and the three implementations do differing amounts of it, so it gets its own
-- case rather than quietly taxing the average of the others.
literalsCase :: CorpusCase
literalsCase =
  CorpusCase
    { caseName = "literals",
      caseDescription = "string/char literals and Haskell comments (macro-suppression scanning)",
      caseEntry = "literals.hs",
      caseFiles = [("literals.hs", body)]
    }
  where
    body = unlines (header <> concatMap block [0 .. caseLines `div` 8])
    header =
      [ "{-# LANGUAGE CPP #-}",
        "module Literals where",
        "#define NAME notExpandedInStrings"
      ]
    block i =
      [ "text" <> show i <> " :: String",
        "text" <> show i <> " = \"NAME must not be expanded here \" ++ show " <> show i,
        "chars" <> show i <> " = ['N', 'A', 'M', 'E']",
        "{- NAME inside a Haskell block comment",
        "   spanning several lines, still NAME -}",
        "prime" <> show i <> "' = " <> show i <> " -- NAME in a line comment",
        "escaped" <> show i <> " = \"a \\\"NAME\\\" quoted\"",
        ""
      ]

-- | An include chain, exercising the continuation-based include protocol.
includesCase :: CorpusCase
includesCase =
  CorpusCase
    { caseName = "includes",
      caseDescription = "chain of #include files resolved through the continuation API",
      caseEntry = "includes.hs",
      caseFiles = ("includes.hs", entry) : map leaf [0 .. leafCount - 1]
    }
  where
    leafCount = 24 :: Int
    entry =
      unlines
        ( [ "{-# LANGUAGE CPP #-}",
            "module Includes where"
          ]
            <> ["#include \"includes/part" <> show n <> ".inc\"" | n <- [0 .. leafCount - 1]]
        )
    leaf n =
      ( "includes" </> ("part" <> show n <> ".inc"),
        unlines
          ( [ "#ifndef PART" <> show n,
              "#define PART" <> show n <> " 1",
              "#define PART" <> show n <> "_VALUE(x) ((x) + " <> show n <> ")"
            ]
              <> concat
                [ [ "part" <> show n <> "_" <> show k <> " :: Int",
                    "part" <> show n <> "_" <> show k <> " = PART" <> show n <> "_VALUE(" <> show k <> ")"
                  ]
                | k <- [0 .. (caseLines `div` leafCount) - 1 :: Int]
                ]
              <> ["#endif"]
          )
      )