packages feed

wireform-parquet-0.1.0.0: bench/SelectionThroughput.hs

-- Shared PyArrow fixtures and independent expected values are generated by
-- scripts/parquet_selection_fixtures.py; see the benchmark manifest.
module Main (main) where

import Arrow.Column qualified as AC
import Arrow.Column.Buffer qualified as Buffer
import Arrow.Types qualified as AT
import Control.DeepSeq (NFData, force)
import Control.Exception (evaluate)
import Control.Monad (unless)
import Criterion.Main (bench, bgroup, defaultMain, nf)
import Data.ByteString qualified as BS
import Data.Int (Int32, Int64)
import Data.Maybe (isJust)
import Data.Text qualified as Text
import Data.Vector qualified as V
import Data.Vector.Primitive qualified as VP
import Data.Vector.Storable qualified as VS
import GHC.Generics (Generic)
import Parquet.Arrow qualified as Arrow
import Parquet.HighLevel qualified as HighLevel
import Parquet.PageIndex qualified as PageIndex
import Parquet.Predicate qualified as Predicate
import Parquet.Read qualified as Read
import Parquet.Types qualified as P
import System.Environment (getEnv, lookupEnv)
import System.FilePath ((</>))
import Text.Read (readMaybe)


data Fixture = Fixture
  { fixtureName :: !String
  , fixtureBytes :: !BS.ByteString
  , fixturePredicate :: !Predicate.PColPredicate
  , fixtureFile :: !Read.ParquetFile
  , fixtureField :: !AT.Field
  , fixtureCodec :: !P.Compression
  , fixtureSelection :: !Read.PageSelection
  }


data RawValues = RawInt64 !(VP.Vector Int64) | RawBytes !(V.Vector BS.ByteString)
  deriving stock (Eq, Generic)
  deriving anyclass (NFData)


data RawColumn = RawColumn
  { rawRepetition :: !(VP.Vector Int32)
  , rawDefinition :: !(VP.Vector Int32)
  , rawValues :: !RawValues
  }
  deriving stock (Eq, Generic)
  deriving anyclass (NFData)


data BufferColumn = BufferColumn
  { bufferRepetition :: !(VP.Vector Int32)
  , bufferDefinition :: !(VP.Vector Int32)
  , bufferOffsets :: !(VS.Vector Int32)
  , bufferValues :: !BS.ByteString
  }
  deriving stock (Eq, Generic)
  deriving anyclass (NFData)


-- Time the native buffer readers used by Arrow, with metadata/index planning
-- prepared. The legacy vector group remains an independent control.
selectedBuffers :: Fixture -> BS.ByteString -> BufferColumn
selectedBuffers fixture bytes = either error id $ case AT.fieldType field of
  AT.AInt 64 True
    | nullable -> leveled scalar (Read.readScalarBuffersSelectedLevels P.PTInt64 codec 0 1 bytes selection)
    | otherwise -> dense scalar (Read.readScalarBuffersSelectedValues P.PTInt64 codec bytes selection)
  AT.AUtf8
    | nullable -> leveled binary (Read.readBinaryBuffersSelectedLevels codec 0 1 bytes selection)
    | otherwise -> dense binary (Read.readBinaryBuffersSelectedValues codec bytes selection)
  _ -> Left "unsupported buffer selection benchmark type"
  where
    field = fixtureField fixture
    nullable = AT.fieldNullable field
    codec = fixtureCodec fixture
    selection = fixtureSelection fixture
    scalar reps defs values = BufferColumn reps defs VS.empty (Read.scalarData values)
    binary reps defs values = BufferColumn reps defs (Read.binaryOffsets values) (Read.binaryData values)
    dense wrap decoded = wrap VP.empty VP.empty <$> decoded
    leveled wrap decoded = do
      levels <- decoded
      Right (wrap (Read.clRepetition levels) (Read.clDefinition levels) (Read.clValues levels))


expectedBuffers :: RawColumn -> BufferColumn
expectedBuffers raw = case rawValues raw of
  RawInt64 values -> BufferColumn reps defs VS.empty (Buffer.storableToBytes (VP.convert values :: VS.Vector Int64))
  RawBytes values -> case AC.fromByteStrings values of
    AC.ColBinary Nothing offsets bytes -> BufferColumn reps defs offsets bytes
    _ -> error "expected dense binary reference buffers"
  where
    reps = rawRepetition raw
    defs = rawDefinition raw


-- Profile the legacy public vector readers, independently of the contiguous
-- binary-buffer path used by Arrow. Only metadata and selection planning are
-- prepared: headers, decompression, levels, dictionaries and values are
-- decoded on every iteration, without constructing an Arrow column.
selectedRaw :: Fixture -> BS.ByteString -> RawColumn
selectedRaw fixture bytes = either error id $
  case AT.fieldType field of
    AT.AInt 64 True
      | nullable -> leveled RawInt64 (Read.readInt64SelectedLevels codec 0 1 bytes selection)
      | otherwise -> dense RawInt64 (Read.readInt64SelectedValues codec bytes selection)
    AT.AUtf8
      | nullable -> leveled RawBytes (Read.readByteArraySelectedLevels codec 0 1 bytes selection)
      | otherwise -> dense RawBytes (Read.readByteArraySelectedValues codec bytes selection)
    _ -> Left "unsupported raw selection benchmark type"
  where
    field = fixtureField fixture
    nullable = AT.fieldNullable field
    codec = fixtureCodec fixture
    selection = fixtureSelection fixture
    dense wrap decoded = RawColumn VP.empty VP.empty . wrap <$> decoded
    leveled wrap decoded = do
      levels <- decoded
      Right (RawColumn (Read.clRepetition levels) (Read.clDefinition levels) (wrap (Read.clValues levels)))


expectedRaw :: AT.Field -> AC.ColumnArray -> Either String RawColumn
expectedRaw field column = case AT.fieldType field of
  AT.AInt 64 True -> do
    array <- maybe (Left "expected INT64 array") Right (AC.asPrim AC.PInt64 column)
    Right (fromRows (RawInt64 . VP.convert) (V.generate count (AC.unsafePrimAt array)))
  AT.AUtf8 -> do
    array <- maybe (Left "expected byte array") Right (AC.asBinary column)
    Right (fromRows RawBytes (V.generate count (AC.unsafeBytesAt array)))
  _ -> Left "unsupported expected raw benchmark type"
  where
    count = AC.columnLength column
    fromRows wrap rows =
      RawColumn
        (if AT.fieldNullable field then VP.replicate count 0 else VP.empty)
        (if AT.fieldNullable field then VP.generate count (\i -> if isJust (rows V.! i) then 1 else 0) else VP.empty)
        (wrap (V.mapMaybe id rows))


data SelectionPlan = SelectionPlan
  { planCodec :: !P.Compression
  , planSelection :: !Read.PageSelection
  }


prepareSelection :: Predicate.PColPredicate -> Read.ParquetFile -> Either String SelectionPlan
prepareSelection predicate file = do
  bounds <- PageIndex.readColumnIndex file 0 0 >>= maybe (Left "missing column index") Right
  offsets <- PageIndex.readOffsetIndex file 0 0 >>= maybe (Left "missing offset index") Right
  let footer = Read.pfFooter file
  group <- maybe (Left "missing row group") Right (P.fmRowGroups footer V.!? 0)
  metadata <- maybe (Left "missing column metadata") Right (P.rgColumns group V.!? 0 >>= P.ccMetadata)
  context <- maybe (Left "missing statistics context") Right (Predicate.statisticsContext footer 0)
  let keep = (== Predicate.PMaybeKeep) <$> Predicate.evalPagesByColumnIndex context bounds predicate
  Right (SelectionPlan (P.cmCodec metadata) (Read.PageSelection (P.oiPageLocations offsets) keep (P.cmDictionaryPageOffset metadata) (Just (P.rgNumRows group))))


selected :: Predicate.PColPredicate -> BS.ByteString -> AC.ColumnArray
selected predicate bytes = either error id $ do
  file <- HighLevel.decodeParquet HighLevel.defaultReadOptions bytes
  schema <- Arrow.parquetFileArrowSchemaChecked file
  field <- maybe (Left "selection fixture has no column") Right (AT.arrowFields schema V.!? 0)
  snd <$> Arrow.readParquetColumnWithPagePruning file 0 0 field predicate


-- File/schema planning is forced and value-checked during fixture loading.
-- This still times index parsing, page selection, decoding and Arrow assembly.
selectedPlanned :: Predicate.PColPredicate -> AT.Field -> Read.ParquetFile -> AC.ColumnArray
selectedPlanned predicate field file =
  either error snd $
    Arrow.readParquetColumnWithPagePruning file 0 0 field predicate


-- Force both index readers and every pruning decision without decoding pages.
-- Summing retained page row ranges gives a check against the independent output.
selectedIndexRows :: Predicate.PColPredicate -> Read.ParquetFile -> Int64
selectedIndexRows predicate file = either error id $ do
  bounds <- PageIndex.readColumnIndex file 0 0 >>= maybe (Left "missing column index") Right
  offsets <- PageIndex.readOffsetIndex file 0 0 >>= maybe (Left "missing offset index") Right
  let metadata = Read.pfFooter file
      locations = P.oiPageLocations offsets
  group <- maybe (Left "missing row group") Right (P.fmRowGroups metadata V.!? 0)
  context <- maybe (Left "missing statistics context") Right (Predicate.statisticsContext metadata 0)
  let keep = (== Predicate.PMaybeKeep) <$> Predicate.evalPagesByColumnIndex context bounds predicate
      rows total i retained
        | not retained = total
        | otherwise =
            let start = P.plFirstRowIndex (locations V.! i)
                end = maybe (P.rgNumRows group) P.plFirstRowIndex (locations V.!? (i + 1))
            in total + end - start
  if V.length locations /= V.length keep
    then Left "page index lengths disagree"
    else Right (V.ifoldl' rows 0 keep)


loadFixtures :: IO [Fixture]
loadFixtures = do
  root <- getEnv "WIREFORM_PARQUET_SELECTION_FIXTURES"
  plans <- lines <$> readFile (root </> "selection-plan.txt")
  traverse (load root . words) plans
  where
    load root [name, typ, literal] = do
      value <- case typ of
        "text" -> pure (Predicate.PVText (Text.pack literal))
        "int" -> maybe (fail "invalid integer selection pivot") (pure . Predicate.PVInt64) (readMaybe literal)
        _ -> fail "invalid selection fixture type"
      bytes <- BS.readFile (root </> (name ++ ".parquet"))
      expectedBytes <- BS.readFile (root </> (name ++ "-expected.parquet"))
      expected <- evaluate $ force $ either error id $ do
        file <- HighLevel.decodeParquet HighLevel.defaultReadOptions expectedBytes
        schema <- Arrow.parquetFileArrowSchemaChecked file
        field <- maybe (Left "expected fixture has no column") Right (AT.arrowFields schema V.!? 0)
        Arrow.readParquetColumn file 0 0 field
      let predicate = Predicate.PGtEq value
      actual <- evaluate (force (selected predicate bytes))
      unless (actual == expected) (fail ("selection value mismatch: " ++ name))
      file <- either fail pure (HighLevel.decodeParquet HighLevel.defaultReadOptions bytes)
      schema <- either fail pure (Arrow.parquetFileArrowSchemaChecked file)
      field <- maybe (fail "selection fixture has no column") pure (AT.arrowFields schema V.!? 0)
      planned <- evaluate (force (selectedPlanned predicate field file))
      unless (planned == expected) (fail ("planned selection value mismatch: " ++ name))
      indexedRows <- evaluate (selectedIndexRows predicate file)
      unless (indexedRows == fromIntegral (AC.columnLength expected)) (fail ("index row count mismatch: " ++ name))
      plan <- either fail pure (prepareSelection predicate file)
      let fixture = Fixture name bytes predicate file field (planCodec plan) (planSelection plan)
      raw <- evaluate (force (selectedRaw fixture bytes))
      expectedValues <- either fail (evaluate . force) (expectedRaw field expected)
      unless (raw == expectedValues) (fail ("raw selection value/level mismatch: " ++ name))
      buffers <- evaluate (force (selectedBuffers fixture bytes))
      referenceBuffers <- evaluate (force (expectedBuffers expectedValues))
      unless (buffers == referenceBuffers) (fail ("native buffer selection value/level mismatch: " ++ name))
      pure fixture
    load _ _ = fail "invalid selection plan"


main :: IO ()
main = do
  fixtures <- loadFixtures
  profile <- (== Just "1") <$> lookupEnv "WIREFORM_PARQUET_PROFILE_PHASES"
  let planned =
        if profile
          then
            [ bgroup "selected pages planned" (plannedBenchmark <$> fixtures)
            , bgroup "selected pages index" (indexBenchmark <$> fixtures)
            , bgroup "selected pages raw" (rawBenchmark <$> fixtures)
            , bgroup "selected pages buffers" (bufferBenchmark <$> fixtures)
            ]
          else []
  defaultMain ([bgroup "selected pages" (benchmark <$> fixtures)] ++ planned)
  where
    benchmark fixture = bench (fixtureName fixture) (nf (selected (fixturePredicate fixture)) (fixtureBytes fixture))
    bufferBenchmark fixture = bench (fixtureName fixture) (nf (selectedBuffers fixture) (fixtureBytes fixture))
    plannedBenchmark fixture =
      bench
        (fixtureName fixture)
        (nf (selectedPlanned (fixturePredicate fixture) (fixtureField fixture)) (fixtureFile fixture))
    indexBenchmark fixture =
      bench
        (fixtureName fixture)
        (nf (selectedIndexRows (fixturePredicate fixture)) (fixtureFile fixture))
    rawBenchmark fixture =
      bench (fixtureName fixture) (nf (selectedRaw fixture) (fixtureBytes fixture))